mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-10 15:52:14 +00:00
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>
235 lines
15 KiB
JavaScript
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)
|