Files
parenthese/tools/layout-bench/e2e-account.mjs
T
IliesAllaliandClaude Opus 5.5 86ac1eb7ab Scénario compte (e2e-account) : barre de la famille depuis le 25/09
Le test cherchait l'ancienne barre (« Contribuer à l'arbre »). Il suit maintenant la carte d'accueil, la barre « Ajouter un souvenir » et le menu « Plus » pour créer un compte. 19/19 en local.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:22:46 +02:00

235 lines
15 KiB
JavaScript

// Test de bout en bout du flux compte famille, sur un backend local (jamais la prod).
// Usage : node e2e-account.mjs [frontUrl=http://localhost:5173] [apiUrl=http://localhost:4000]
// Scénario :
// 1. API : le propriétaire crée un compte et un arbre partagé (mot de passe contributeur)
// 2. Navigateur A (famille, sans compte) : lien partagé → mot de passe → arbre ouvert en contributeur
// 3. Rechargement : reste contributeur (jeton en poche), sans ressaisie
// 4. Menu « Plus » → "Créer un compte" → formulaire → compte créé → retour sur l'arbre, rattaché (barre de la famille connectée)
// 5. Rechargement : l'arbre s'ouvre directement, sans grille d'accès
// 6. API : l'arbre figure dans "mes arbres" du nouveau compte avec le rôle contributor
// 7. Navigateur B (propriétaire) : connexion → paramètres → "modifications appliquées directement" → API confirme
import { spawn } from 'node:child_process'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
const FRONT = process.argv[2] || 'http://localhost:5173'
const API = process.argv[3] || 'http://localhost:4000'
const BRAVE = process.env.BRAVE_PATH || 'C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe'
const stamp = Date.now()
const OWNER = { email: `owner-${stamp}@e2e.local`, password: 'OwnerPass-123' }
const FAMILY = { firstName: 'Camille', email: `famille-${stamp}@e2e.local`, password: 'FamilyPass-123' }
const CONTRIB_PASSWORD = 'Contribue-2026'
const VISITOR_PASSWORD = 'Visite-2026'
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const results = []
function check(name, ok, detail = '') {
results.push({ name, ok, detail })
console.log(`${ok ? 'OK ' : 'KO '} ${name}${detail ? ` (${detail})` : ''}`)
}
async function api(pathname, { method = 'GET', token, body } = {}) {
const r = await fetch(API + pathname, {
method,
headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: body ? JSON.stringify(body) : undefined,
})
const text = await r.text()
let data; try { data = JSON.parse(text) } catch { data = text }
if (!r.ok) throw new Error(`${r.status} ${method} ${pathname} ${typeof data === 'string' ? data.slice(0, 200) : JSON.stringify(data).slice(0, 200)}`)
return data
}
// ---------- Navigateur headless minimal (CDP, sans dépendance) ----------
class CDP {
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map()
ws.onmessage = (m) => { const msg = JSON.parse(m.data); if (msg.id) { const p = this.pending.get(msg.id); this.pending.delete(msg.id); msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result) } } }
send(method, params = {}) { const id = ++this.id; this.ws.send(JSON.stringify({ id, method, params })); return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject })) }
async eval(expression, awaitPromise = true) {
const r = await this.send('Runtime.evaluate', { expression, awaitPromise, returnByValue: true })
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval error')
return r.result.value
}
}
async function launchBrowser(label) {
const port = 9400 + Math.floor(Math.random() * 300)
const profileDir = mkdtempSync(path.join(tmpdir(), `parenthese-e2e-${label}-`))
const proc = spawn(BRAVE, ['--headless=new', `--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, '--window-size=1400,900', '--no-first-run', '--disable-extensions', '--disable-features=Translate,BraveRewards,BraveAds', 'about:blank'], { stdio: 'ignore' })
let version
for (let i = 0; i < 100 && !version; i++) { try { const r = await fetch(`http://127.0.0.1:${port}/json/version`); if (r.ok) version = await r.json() } catch {} ; if (!version) await sleep(200) }
if (!version) throw new Error('devtools injoignable')
const bws = new WebSocket(version.webSocketDebuggerUrl); await new Promise(r => bws.onopen = r)
const b = new CDP(bws)
const { targetId } = await b.send('Target.createTarget', { url: 'about:blank' })
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
const pws = new WebSocket(list.find(t => t.id === targetId).webSocketDebuggerUrl); await new Promise(r => pws.onopen = r)
const p = new CDP(pws)
await p.send('Page.enable'); await p.send('Runtime.enable')
const page = {
async goto(url) { await p.send('Page.navigate', { url }); await sleep(1500) },
eval: (expr, awaitPromise = true) => p.eval(expr, awaitPromise),
async waitFor(expr, ms = 15000) {
const t0 = Date.now()
while (Date.now() - t0 < ms) { if (await p.eval(`!!(${expr})`, false)) return true; await sleep(250) }
return false
},
// Renseigne un input contrôlé par React (setter natif + événement input)
async fill(selector, value) {
return p.eval(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set
setter.call(el, ${JSON.stringify(value)}); el.dispatchEvent(new Event('input', { bubbles: true })); return true })()`, false)
},
async clickText(selector, text) {
return p.eval(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find(e => (e.textContent || '').trim() === ${JSON.stringify(text)}); if (!el) return false; el.click(); return true })()`, false)
},
async clickLabel(label) {
return p.eval(`(() => { const el = document.querySelector('button[aria-label=' + JSON.stringify(${JSON.stringify(label)}) + ']'); if (!el) return false; el.click(); return true })()`, false)
},
// Bouton visible dont le texte ou l'aria-label vaut exactement `text`
hasButton: (text) => p.eval(`[...document.querySelectorAll('button, [role=button], [role=menuitem]')].some(b => b.offsetParent !== null && ((b.textContent || '').trim() === ${JSON.stringify(text)} || b.getAttribute('aria-label') === ${JSON.stringify(text)}))`, false),
clickButton: (text) => p.eval(`(() => { const el = [...document.querySelectorAll('button, [role=button], [role=menuitem]')].find(b => b.offsetParent !== null && ((b.textContent || '').trim() === ${JSON.stringify(text)} || b.getAttribute('aria-label') === ${JSON.stringify(text)})); if (!el) return false; el.click(); return true })()`, false),
async shot(file) { const { data } = await p.send('Page.captureScreenshot', { format: 'png' }); const { writeFileSync } = await import('node:fs'); writeFileSync(new URL(`./out/${file}`, import.meta.url), Buffer.from(data, 'base64')) },
close() { try { proc.kill() } catch {} ; try { rmSync(profileDir, { recursive: true, force: true }) } catch {} },
}
return page
}
// ---------- 1. Propriétaire + arbre via l'API ----------
const owner = await api('/auth/register', { method: 'POST', body: OWNER })
const createdTree = await api('/trees', { method: 'POST', token: owner.token, body: { name: `Famille E2E ${stamp}`, slug: `famille-e2e-${stamp}`, visitorPassword: VISITOR_PASSWORD, contributorPassword: CONTRIB_PASSWORD } })
const treeObj = createdTree.tree || createdTree
const treeId = treeObj.id, slug = treeObj.slug
check('1. arbre créé par le propriétaire', Boolean(treeId && slug), `slug ${slug}`)
// ---------- 2. Famille sans compte : mot de passe → contributeur ----------
const A = await launchBrowser('famille')
try {
await A.goto(`${FRONT}/arbre/${slug}`)
await A.waitFor(`document.querySelector('input[type=password]')`)
await A.fill('input[type=password]', CONTRIB_PASSWORD)
await A.clickText('button', "Ouvrir l'arbre")
const opened2 = await A.waitFor(`document.querySelector('canvas.galaxy-canvas')`)
if (!opened2) {
await A.shot('e2e-fail-step2.png')
console.log(' gate:', await A.eval(`(document.querySelector('.access-gate-error')?.textContent || '') + ' | ' + document.body.innerText.slice(0, 300).replace(/\\n+/g, ' / ')`, false))
}
check('2. arbre ouvert avec le mot de passe', opened2)
await sleep(1000)
// Barre de la famille depuis le 25/09 : « Ajouter un souvenir », « Ajouter une personne », « Plus »
check('2. carte d accueil de la première visite fermée', await A.clickButton("Regarder l'arbre"))
await sleep(500)
check('2. barre de la famille (sans compte)', await A.hasButton('Ajouter un souvenir') && await A.hasButton('Plus'))
// ---------- 3. Rechargement : reste contributeur ----------
await A.goto(`${FRONT}/arbre/${slug}`)
await A.waitFor(`document.querySelector('canvas.galaxy-canvas')`)
await sleep(1000)
check('3. après rechargement, toujours contributeur sans ressaisie', await A.hasButton('Ajouter un souvenir'))
// ---------- 4. Créer un compte depuis le menu « Plus » ----------
await A.clickButton('Plus')
await sleep(400)
check('4. clic "Créer un compte"', await A.clickButton('Créer un compte'))
check('4. écran compte en mode inscription', await A.waitFor(`document.querySelector('#accountFirstName')`))
const pendingText = await A.eval(`document.querySelector('.account-pending-tree')?.textContent || ''`, false)
check('4. rappel du retour sur l arbre', pendingText.includes('Famille E2E'), pendingText.trim())
await A.fill('#accountFirstName', FAMILY.firstName)
await A.fill('#accountEmail', FAMILY.email)
await A.fill('#accountPassword', FAMILY.password)
await A.shot('e2e-register.png')
await A.clickText('button', 'Créer mon compte')
check('4. retour sur l arbre après création', await A.waitFor(`document.querySelector('canvas.galaxy-canvas')`, 20000))
await sleep(1500)
check('4. famille connectée : barre de la famille et menu du compte', await A.hasButton('Ajouter un souvenir') && await A.hasButton('Menu compte'))
const wizardShown = await A.eval(`/appelle votre famille/i.test(document.body.innerText)`, false)
check('4. pas d assistant "votre arbre" par-dessus', !wizardShown)
await A.shot('e2e-after-register.png')
// ---------- 5. Rechargement connecté : ouverture directe ----------
await A.goto(`${FRONT}/arbre/${slug}`)
const direct = await A.waitFor(`document.querySelector('canvas.galaxy-canvas')`, 20000)
const gateShown = await A.eval(`!!document.querySelector('input[type=password]')`, false)
check('5. rechargement connecté : arbre direct, pas de grille', direct && !gateShown)
await sleep(1000)
check('5. toujours contributeur connecté', await A.hasButton('Ajouter un souvenir') && await A.hasButton('Menu compte'))
} finally {
A.close()
}
// ---------- 6. API : l'arbre est dans le compte famille ----------
const familyLogin = await api('/auth/login', { method: 'POST', body: { email: FAMILY.email, password: FAMILY.password } })
const familyTrees = await api('/trees', { token: familyLogin.token })
const entry = (familyTrees.trees || []).find(t => String(t.id) === String(treeId))
check('6. arbre rattaché au compte famille', Boolean(entry) && entry.role === 'contributor' && entry.accessMode === 'share', entry ? `${entry.role}/${entry.accessMode}` : 'absent')
// ---------- 6b. Contribution avec le compte : en attente de validation (réglage par défaut) ----------
const submitPerson = async (token, firstName) => {
const r = await api(`/trees/${treeId}/contributions/sessions`, {
method: 'POST', token,
body: { title: `Ajout ${firstName}`, changes: [{ entityType: 'person', action: 'create', after: { firstName, lastName: 'E2E' } }] },
})
return { status: r.session?.status ?? r.status, raw: r }
}
const pendingSession = await submitPerson(familyLogin.token, 'Proposition')
const pendingList = await api(`/trees/${treeId}/contributions/sessions?status=pending`, { token: owner.token })
check('6b. contribution famille en attente, visible par le propriétaire', pendingSession.status === 'pending' && (pendingList.sessions || []).length === 1, `${pendingSession.status}, ${(pendingList.sessions || []).length} en attente`)
// ---------- 7. Propriétaire : interrupteur contributions ----------
const B = await launchBrowser('owner')
try {
await B.goto(`${FRONT}/?account=1`)
await B.waitFor(`document.querySelector('#accountEmail')`)
await B.fill('#accountEmail', OWNER.email)
await B.fill('#accountPassword', OWNER.password)
await B.clickText('button', 'Se connecter')
check('7. propriétaire connecté, arbre ouvert', await B.waitFor(`document.querySelector('canvas.galaxy-canvas') || document.querySelector('.tree-creation-wizard, .initial-tree-wizard')`, 20000))
await sleep(1500)
// fermer un éventuel assistant de premier arbre (arbre vide) puis ouvrir les paramètres via le menu avatar
await B.clickText('button', 'Plus tard')
await B.clickText('button', 'Passer')
await sleep(500)
await B.eval(`document.querySelector('.nav-avatar')?.click()`, false)
await sleep(400)
const opened = await B.eval(`(() => { const el = [...document.querySelectorAll('button')].find(b => /param/i.test(b.textContent || '')); if (!el) return false; el.click(); return true })()`, false)
check('7. panneau paramètres ouvert', opened && await B.waitFor(`document.querySelector('input[name=contributionPolicy]')`, 10000))
const before = await api(`/trees/${treeId}/settings`, { token: owner.token })
check('7. réglage initial = validation', before.settings?.contributorPolicy === 'pending', before.settings?.contributorPolicy)
await B.eval(`document.querySelector('input[name=contributionPolicy][value=direct]')?.click()`, false)
await sleep(1500)
await B.shot('e2e-settings-direct.png')
const after = await api(`/trees/${treeId}/settings`, { token: owner.token })
check('7. réglage enregistré = direct', after.settings?.contributorPolicy === 'direct' && after.settings?.memberContributionPolicy === 'direct', `${after.settings?.contributorPolicy}/${after.settings?.memberContributionPolicy}`)
} finally {
B.close()
}
// ---------- 8. En mode direct : la contribution est appliquée immédiatement ----------
const directSession = await submitPerson(familyLogin.token, 'Directe')
const graph = await api(`/trees/${treeId}/graph`, { token: owner.token })
const applied = (graph.graph?.persons || []).some(p => p.firstName === 'Directe')
check('8. contribution appliquée directement et visible dans l arbre', directSession.status === 'approved' && applied, `${directSession.status}, ${(graph.graph?.persons || []).length} personne(s)`)
// ---------- Nettoyage : les deux comptes de test sont supprimés, l'arbre du propriétaire part avec le sien ----------
if (process.env.CLEANUP === '1') {
for (const [label, account, token] of [['famille', FAMILY, familyLogin.token], ['propriétaire', OWNER, owner.token]]) {
try {
await api('/auth/me', { method: 'DELETE', token, body: { password: account.password } })
check(`9. compte ${label} supprimé`, true)
} catch (err) {
check(`9. compte ${label} supprimé`, false, String(err.message).slice(0, 120))
}
}
try {
await api(`/trees/${treeId}/graph`, { token: owner.token })
check('9. arbre de test inaccessible', false, 'le graphe répond encore')
} catch {
check('9. arbre de test inaccessible', true)
}
}
const failed = results.filter(r => !r.ok)
console.log(`\n${results.length - failed.length}/${results.length} contrôles OK`)
process.exit(failed.length ? 1 : 0)