#!/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); // --- 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});`; const seedGit = `${seedExplorer}localStorage.setItem('arb.ide.activity', '"git"');localStorage.setItem('arb.history.open', 'true');`; 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 }, { name: 'git-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedGit }, { 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); await client.send( 'Page.addScriptToEvaluateOnNewDocument', { source: `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`); 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); }