#!/usr/bin/env node // Vérification VISUELLE de la SPA authentifiée, sans Playwright : daemon temporaire isolé + Chromium // headless piloté en CDP + cookie de session injecté. Produit des captures PNG (thème sombre et clair, // largeurs desktop et mobile) et échoue si une erreur console / exception Vue survient. // // Usage : node packages/server/scripts/verify-ui.mjs [dossier-de-sortie] // Prérequis : `npm run build` puis `node packages/server/scripts/copy-web.mjs` (le daemon sert la SPA // depuis packages/server/public, que le build NE rafraîchit PAS). import { spawn, execFileSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname, resolve as resolvePath } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const WebSocket = require('ws'); const PORT = 7998; const CDP_PORT = 9333; const ORIGIN = `http://127.0.0.1:${PORT}`; const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..'); const outDir = resolvePath(process.argv[2] ?? join(serverDir, '..', '..', '.ui-shots')); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const results = []; const check = (name, ok, detail = '') => { results.push({ name, ok, detail }); console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`); }; function findChromium() { for (const bin of ['chromium', 'chromium-browser', 'google-chrome', 'google-chrome-stable']) { try { return execFileSync('which', [bin]).toString().trim(); } catch { /* essai suivant */ } } return null; } /** Client CDP minimal : un seul socket, corrélation par id, sessionId pour la cible attachée. */ function cdp(url) { const ws = new WebSocket(url, { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024 }); let nextId = 1; const pending = new Map(); const events = []; ws.on('message', (raw) => { const msg = JSON.parse(String(raw)); if (msg.id && pending.has(msg.id)) { const { resolve, reject } = pending.get(msg.id); pending.delete(msg.id); msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result); return; } if (msg.method) events.push(msg); }); const ready = new Promise((res, rej) => (ws.on('open', res), ws.on('error', rej))); const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => { const id = nextId++; pending.set(id, { resolve, reject }); ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); setTimeout(() => pending.has(id) && (pending.delete(id), reject(new Error(`CDP timeout: ${method}`))), 30_000); }); return { ws, ready, send, events }; } const tmp = mkdtempSync(join(tmpdir(), 'arb-verify-ui-')); mkdirSync(outDir, { recursive: true }); let srv = null; let browser = null; try { // La SPA servie vient de packages/server/public : garde-fou contre la vérification d'un ancien build. const publicIndex = join(serverDir, 'public', 'index.html'); check('SPA copiée dans packages/server/public', existsSync(publicIndex), publicIndex); // --- dépôt de démonstration : un checkout principal, un worktree de feature, du travail en cours --- const repo = join(tmp, 'demo-repo'); mkdirSync(repo, { recursive: true }); const git = (...args) => execFileSync('git', args, { cwd: repo, stdio: 'pipe' }); git('init', '-b', 'main'); git('config', 'user.email', 'test@arboretum.dev'); git('config', 'user.name', 'Test'); writeFileSync(join(repo, 'README.md'), '# demo\n'); mkdirSync(join(repo, 'src'), { recursive: true }); writeFileSync(join(repo, 'src', 'app.ts'), 'export const version = 1\n'); git('add', '-A'); git('commit', '-m', 'commit initial'); writeFileSync(join(repo, 'src', 'app.ts'), 'export const version = 2\n'); srv = spawn( 'node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 't.db'), '--claude-home', join(tmp, 'claude'), '--no-discover'], { env: { ...process.env, XDG_DATA_HOME: join(tmp, 'xdg'), ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] }, ); let srvOut = ''; srv.stdout.on('data', (d) => (srvOut += d)); srv.stderr.on('data', (d) => (srvOut += d)); for (let i = 0; i < 60 && !/arb_[0-9a-f]{16,}/.test(srvOut); i++) await sleep(150); const token = /arb_[0-9a-f]{16,}/.exec(srvOut)?.[0]; check('daemon temporaire démarré + token', !!token); const login = await fetch(`${ORIGIN}/api/v1/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: ORIGIN }, body: JSON.stringify({ token }), }); const setCookie = login.headers.getSetCookie?.() ?? []; const sessionCookie = setCookie.map((c) => c.split(';')[0]).find((c) => c.startsWith('arb_session=')); check('login → cookie de session', !!sessionCookie); const cookieValue = sessionCookie?.slice('arb_session='.length) ?? ''; const j = (path, method, body) => fetch(`${ORIGIN}${path}`, { method, headers: { Origin: ORIGIN, Cookie: sessionCookie ?? '', ...(body ? { 'Content-Type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), }); const repoId = (await (await j('/api/v1/repos', 'POST', { path: repo })).json()).repo?.id; check('dépôt de démonstration enregistré', !!repoId); const wtRes = await (await j(`/api/v1/repos/${repoId}/worktrees`, 'POST', { branch: 'feature/demo', runHooks: false })).json(); check('worktree de feature créé', !!wtRes.worktree?.path); // du travail non commité dans le worktree de feature, pour peupler les compteurs git de l'arbre if (wtRes.worktree?.path) writeFileSync(join(wtRes.worktree.path, 'wip.txt'), 'travail en cours\n'); const groupRes = await (await j('/api/v1/groups', 'POST', { label: 'Démo', color: '#34d399', repoIds: [repoId] })).json(); check('groupe de démonstration créé', !!groupRes.group?.id); const sess = await (await j('/api/v1/sessions', 'POST', { cwd: repo, command: 'bash' })).json(); check('session bash de démonstration', !!sess.session?.id); // Un fichier indexé et un non indexé dans le checkout principal : la vue Changements doit montrer // ses DEUX sections (« Staged » et « Changes »), sinon la capture ne prouve que la moitié. writeFileSync(join(repo, 'notes.md'), 'brouillon\n'); const staged = await j(`/api/v1/repos/${repoId}/worktrees/stage`, 'POST', { path: repo, files: ['src/app.ts'] }); check('un fichier indexé pour la démonstration', staged.ok); // --- Chromium headless en CDP --- const chromeBin = findChromium(); check('Chromium disponible', !!chromeBin, chromeBin ?? 'introuvable'); if (!chromeBin) throw new Error('Chromium introuvable : impossible de vérifier le rendu'); browser = spawn( chromeBin, [ '--headless=new', `--remote-debugging-port=${CDP_PORT}`, `--user-data-dir=${join(tmp, 'chrome')}`, '--no-first-run', '--no-default-browser-check', '--disable-gpu', '--hide-scrollbars', ], { stdio: ['ignore', 'pipe', 'pipe'] }, ); let wsUrl = null; for (let i = 0; i < 80 && !wsUrl; i++) { await sleep(200); try { wsUrl = (await (await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`)).json()).webSocketDebuggerUrl; } catch { /* pas encore prêt */ } } check('Chromium en écoute CDP', !!wsUrl); const client = cdp(wsUrl); await client.ready; // État de vue injecté avant le premier paint : on veut des captures qui MONTRENT le contenu // (arbre déplié, worktree actif), pas un IDE vide. const expanded = JSON.stringify(JSON.stringify([repoId])); const context = JSON.stringify(JSON.stringify({ repoId, wtPath: repo })); const seedExplorer = `localStorage.setItem('arb.ide.expandedRepos', ${expanded});localStorage.setItem('arb.ide.context', ${context});`; // Index Git de la sidebar : compteurs par projet, sans liste de fichiers (celle-ci vit au centre). const seedGit = `${seedExplorer}localStorage.setItem('arb.ide.activity', '"git"');`; // Vue Changements de la zone centrale : blocs dépliés du checkout principal ET du worktree de // feature, plus l'historique du premier (sa clé de repli est désormais PAR worktree). const changesKeys = JSON.stringify( JSON.stringify([`${repoId}\0${repo}`, ...(wtRes.worktree?.path ? [`${repoId}\0${wtRes.worktree.path}`] : [])]), ); const seedChanges = `${seedExplorer}localStorage.setItem('arb.ide.centerMode', '"changes"');` + `localStorage.setItem('arb.ide.changesExpanded', ${changesKeys});` + `localStorage.setItem(${JSON.stringify(`arb.history.open:${repoId}\0${repo}`)}, 'true');`; const seedChangesMobile = `${seedChanges}localStorage.setItem('arb.ide.mobilePanel', '"changes"');`; // Zone d'édition : un onglet persisté, jamais capturé jusqu'ici. const editorTabId = `${repoId}\0${repo}\0src/app.ts`; const seedEditor = `${seedExplorer}localStorage.setItem('arb.ide.tabs', ${JSON.stringify( JSON.stringify([{ id: editorTabId, repoId, wtPath: repo, file: 'src/app.ts', view: 'editor' }]), )});localStorage.setItem('arb.ide.activeTab', ${JSON.stringify(JSON.stringify(editorTabId))});`; // Panneau Groupes avec le groupe ET le worktree dépliés : c'est la vue qui porte l'arborescence de // fichiers des membres du groupe, sinon jamais capturée. const seedGroups = `${seedExplorer}localStorage.setItem('arb.ide.activity', '"groups"');` + `localStorage.setItem('arb.ide.expandedGroups', ${JSON.stringify(JSON.stringify([groupRes.group?.id]))});` + `localStorage.setItem('arb.ide.expandedWts', ${JSON.stringify(JSON.stringify([repo]))});`; // `expect` : une capture non vide ne prouve pas que le bon composant s'est rendu. On exige donc un // fragment de texte propre à la surface visée. const shots = [ { name: 'ide-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedExplorer }, { name: 'ide-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedExplorer }, { name: 'git-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedGit, expect: 'feature/demo' }, { name: 'git-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedGit, expect: 'feature/demo' }, { name: 'changes-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedChanges, expect: 'wip.txt' }, { name: 'changes-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedChanges, expect: 'wip.txt' }, { name: 'changes-dark-mobile', theme: 'dark', width: 390, height: 844, seed: seedChangesMobile, expect: 'wip.txt' }, { name: 'editor-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedEditor, expect: 'src/app.ts' }, { name: 'groups-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedGroups }, { name: 'ide-dark-mobile', theme: 'dark', width: 390, height: 844, seed: seedExplorer }, { name: 'ide-light-mobile', theme: 'light', width: 390, height: 844, seed: seedExplorer }, { name: 'dashboard-dark-mobile', theme: 'dark', width: 390, height: 844, path: '/dashboard' }, ]; for (const shot of shots) { const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' }); const { sessionId } = await client.send('Target.attachToTarget', { targetId, flatten: true }); await client.send('Runtime.enable', {}, sessionId); await client.send('Log.enable', {}, sessionId); await client.send('Network.enable', {}, sessionId); await client.send('Emulation.setDeviceMetricsOverride', { width: shot.width, height: shot.height, deviceScaleFactor: 1, mobile: shot.width < 500 }, sessionId); await client.send('Network.setCookie', { name: 'arb_session', value: cookieValue, domain: '127.0.0.1', path: '/', httpOnly: true }, sessionId); // Thème : la SPA lit `arb.theme` avant le premier paint (script anti-FOUC). await client.send('Page.enable', {}, sessionId); // `localStorage.clear()` d'abord : les cibles d'un même profil Chromium le PARTAGENT, donc sans // ça une capture héritait de l'état de vue de la précédente (une capture de la zone d'édition // montrait la vue Changements laissée par celle d'avant). await client.send( 'Page.addScriptToEvaluateOnNewDocument', { source: `localStorage.clear();localStorage.setItem('arb.theme', ${JSON.stringify(JSON.stringify(shot.theme))});${shot.seed ?? ''}` }, sessionId, ); const before = client.events.length; await client.send('Page.navigate', { url: `${ORIGIN}${shot.path ?? '/ide'}` }, sessionId); await sleep(3500); // laisse le temps au bootstrap REST + WS et au rendu const text = await client.send('Runtime.evaluate', { expression: 'document.body.innerText', returnByValue: true }, sessionId); const rendered = String(text.result?.value ?? ''); check(`${shot.name} : page rendue`, rendered.length > 20, `${rendered.length} caractères`); if (shot.expect) check(`${shot.name} : contenu attendu`, rendered.includes(shot.expect), shot.expect); const errs = client.events .slice(before) .filter((e) => e.sessionId === sessionId) .filter((e) => (e.method === 'Runtime.consoleAPICalled' && e.params?.type === 'error') || e.method === 'Runtime.exceptionThrown') .map((e) => e.params?.exceptionDetails?.text ?? (e.params?.args ?? []).map((a) => a.value ?? a.description).join(' ')) // Les erreurs réseau des favicons/manifest en headless ne concernent pas l'app. .filter((m) => m && !/favicon|manifest\.webmanifest/i.test(m)); check(`${shot.name} : aucune erreur console`, errs.length === 0, errs.slice(0, 3).join(' | ')); const { data } = await client.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }, sessionId); const file = join(outDir, `${shot.name}.png`); writeFileSync(file, Buffer.from(data, 'base64')); check(`${shot.name} : capture écrite`, true, file); await client.send('Target.closeTarget', { targetId }); } client.ws.close(); } catch (err) { check('exception', false, String(err)); } finally { browser?.kill('SIGTERM'); srv?.kill('SIGTERM'); await sleep(1200); rmSync(tmp, { recursive: true, force: true }); const failed = results.filter((r) => !r.ok); console.log(failed.length === 0 ? `\nVERIFY UI: ALL GREEN (captures dans ${outDir})` : `\nVERIFY UI: ${failed.length} FAILURE(S)`); process.exit(failed.length === 0 ? 0 : 1); }