#!/usr/bin/env node // Vérification E2E du DOCK TERMINAUX par interaction réelle : daemon temporaire isolé + Chromium // headless piloté en CDP + cookie de session injecté. On clique comme un utilisateur, puis on lit le // DOM et l'écran d'xterm. // // Ce que ce script prouve, et qu'aucune capture ne prouvait : // (a) un terminal attaché AFFICHE la sortie de son PTY (le bug « écran tout noir » venait du replay // émis avant `attached`, donc jeté par le client : ici on lit le texte réellement peint) ; // (b) deux terminaux tiennent côte à côte, chacun dans sa colonne, tous les deux visibles ; // (c) la frappe va au terminal de la colonne ACTIVE, et suit le changement de colonne ; // (d) fermer une colonne rend sa place à l'autre, et le terminal restant continue de fonctionner ; // (e) la vue Changements suit le terminal focalisé (portée), et la bascule « tout voir » la libère. // // Sessions `bash` uniquement : aucun quota Claude consommé. // Usage : node packages/server/scripts/verify-terminals.mjs // Prérequis : `npm run build` puis `node packages/server/scripts/copy-web.mjs`. import { spawn, execFileSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } 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 = 7413; const CDP_PORT = 9336; const ORIGIN = `http://127.0.0.1:${PORT}`; const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..'); 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; } 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 }; } /** Répertoire de captures optionnel : `node scripts/verify-terminals.mjs [out]`. */ const shotDir = process.argv[2] ?? null; const tmp = mkdtempSync(join(tmpdir(), 'arb-verify-term-')); let srv = null; let browser = null; try { check('SPA copiée dans packages/server/public', existsSync(join(serverDir, 'public', 'index.html'))); // Deux dépôts : le second sert à prouver que la portée git suit bien le terminal focalisé. const repos = []; for (const name of ['alpha', 'beta']) { const dir = join(tmp, name); mkdirSync(dir, { recursive: true }); const git = (...args) => execFileSync('git', args, { cwd: dir, stdio: 'pipe' }); git('init', '-b', 'main'); git('config', 'user.email', 'test@arboretum.dev'); git('config', 'user.name', 'Test'); writeFileSync(join(dir, 'README.md'), `# ${name}\n`); git('add', '-A'); git('commit', '-m', 'commit initial'); writeFileSync(join(dir, `wip-${name}.txt`), 'travail en cours\n'); repos.push({ name, dir }); } 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 sessionCookie = (login.headers.getSetCookie?.() ?? []).map((c) => c.split(';')[0]).find((c) => c.startsWith('arb_session=')); const cookieValue = sessionCookie?.slice('arb_session='.length) ?? ''; check('login → cookie de session', !!sessionCookie); const j = (path, method, body) => fetch(`${ORIGIN}${path}`, { method, headers: { Origin: ORIGIN, Cookie: sessionCookie ?? '', ...(body ? { 'Content-Type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), }); for (const r of repos) { const res = await (await j('/api/v1/repos', 'POST', { path: r.dir })).json(); r.id = res.repo?.id; } check('deux dépôts enregistrés', repos.every((r) => !!r.id)); // Une session bash par dépôt : ce sont elles qui peupleront les deux colonnes. for (const r of repos) { const res = await (await j('/api/v1/sessions', 'POST', { cwd: r.dir, command: 'bash' })).json(); r.sessionId = res.session?.id; } check('deux sessions bash lancées', repos.every((r) => !!r.sessionId)); const chromeBin = findChromium(); check('Chromium disponible', !!chromeBin, chromeBin ?? 'introuvable'); if (!chromeBin) throw new Error('Chromium introuvable'); 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', // xterm peint sur un canvas WebGL quand il peut : le texte n'est alors PAS dans le DOM et // aucun test ne peut le lire. On force le renderer DOM ; le chemin vérifié (transport → xterm) // est le même, seule la peinture change. '--disable-webgl', '--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; 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('Page.enable', {}, sessionId); await client.send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 950, deviceScaleFactor: 1, mobile: false }, sessionId); await client.send('Network.enable', {}, sessionId); await client.send('Network.setCookie', { name: 'arb_session', value: cookieValue, domain: '127.0.0.1', path: '/', httpOnly: true }, sessionId); // Aucun état de vue persisté : le dock part vide, comme au premier lancement. // Amorçage AVANT navigation (sinon le store lit un localStorage encore vide) : panneau Sessions à // gauche pour ouvrir les terminaux, et zone centrale en mode Changements pour observer la portée. await client.send( 'Page.addScriptToEvaluateOnNewDocument', { source: [ "localStorage.clear();", `localStorage.setItem('arb.theme', '"dark"');`, "localStorage.setItem('arboretum.locale', 'en');", `localStorage.setItem('arb.ide.activity', '"sessions"');`, `localStorage.setItem('arb.ide.centerMode', '"changes"');`, "localStorage.setItem('arb.ide.leftVisible', 'true');", ].join(''), }, sessionId, ); const consoleBefore = client.events.length; await client.send('Page.navigate', { url: `${ORIGIN}/ide` }, sessionId); const evaluate = async (expression) => (await client.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId)).result?.value; async function waitFor(fn, tries = 60, delay = 250) { for (let i = 0; i < tries; i++) { if (await fn()) return true; await sleep(delay); } return false; } /** Texte réellement PEINT par une instance xterm (index de colonne dans le dock). */ const screenText = (n) => evaluate( `(() => { const rows = [...document.querySelectorAll('.xterm-rows')]; const el = rows[${n}] ?? [...document.querySelectorAll('.xterm-screen')][${n}]; return el ? el.innerText.replace(/\\u00a0/g, ' ') : null; })()`, ); const columnCount = () => evaluate(`document.querySelectorAll('.xterm-screen').length`); const clickRow = (label) => evaluate( `(() => { const span = [...document.querySelectorAll('button span')].find((s) => s.textContent.trim() === ${JSON.stringify(label)}); const btn = span?.closest('button'); if (!btn) return false; btn.click(); return true; })()`, ); const clickTitled = (title, nth = 0) => evaluate( `(() => { const btns = [...document.querySelectorAll('[title=' + JSON.stringify(${JSON.stringify(title)}) + ']')]; const btn = btns[${nth}]; if (!btn) return false; btn.click(); return true; })()`, ); /** * Clic NATIF au centre d'une colonne : c'est le seul moyen de donner le focus au textarea caché * d'xterm (un PointerEvent synthétique ne déplace pas le focus du navigateur). */ async function clickColumn(n) { const box = await evaluate( `(() => { const el = [...document.querySelectorAll('.xterm-screen')][${n}]; if (!el) return null; const r = el.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()`, ); if (!box) return false; for (const type of ['mousePressed', 'mouseReleased']) { await client.send('Input.dispatchMouseEvent', { type, x: box.x, y: box.y, button: 'left', clickCount: 1 }, sessionId); } await sleep(200); return true; } /** Frappe réelle : insertText va à l'élément focalisé (le textarea d'xterm). */ const type = (text) => client.send('Input.insertText', { text }, sessionId); const pressEnter = async () => { await client.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, text: '\r' }, sessionId); await client.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }, sessionId); }; check('SPA chargée sur /ide', await waitFor(async () => (await evaluate(`!!document.querySelector('[aria-label], nav, main')`)) === true)); // --- (a) un terminal affiche la sortie de son PTY --- /** Clique la ligne de session du panneau Sessions correspondant à un dépôt. `alt` = ouvrir à côté. */ const clickSessionRow = (repoName, alt = false) => evaluate( `(() => { const rows = [...document.querySelectorAll('aside button, div button')].filter( (b) => b.textContent.includes(${JSON.stringify(repoName)}) && b.querySelector('span'), ); const row = rows[0]; if (!row) return false; row.dispatchEvent(new MouseEvent('click', { bubbles: true, altKey: ${alt} })); return true; })()`, ); const listed = await waitFor(async () => (await evaluate(`document.body.innerText.includes('alpha') && document.body.innerText.includes('beta')`)) === true, 40); check('les deux sessions sont listées dans le panneau', listed); const openedFirst = await waitFor(async () => { await clickSessionRow('alpha'); return (await columnCount()) >= 1; }, 30); check('un terminal s’ouvre dans le dock', openedFirst, `colonnes: ${await columnCount()}`); await clickColumn(0); check('le terminal prend le focus clavier', (await evaluate(`document.activeElement?.tagName?.toLowerCase() ?? ''`)) === 'textarea'); // Deux preuves complémentaires : le PTY a bien reçu la frappe (fichier créé dans SON cwd), et sa // sortie est réellement peinte à l'écran (c'était précisément ce qui manquait : un écran noir). await type('touch recu-A && echo MARQUEUR-COLONNE-A'); await pressEnter(); const gotA = await waitFor(() => existsSync(join(repos[0].dir, 'recu-A')), 40); check('(a) la frappe atteint le PTY du terminal', gotA); const paintedA = await waitFor(async () => ((await screenText(0)) ?? '').includes('MARQUEUR-COLONNE-A'), 40); check('(a) le terminal PEINT la sortie de son PTY (fin de l’écran noir)', paintedA, ((await screenText(0)) ?? '').replace(/\s+/g, ' ').slice(-70)); // --- (b) deux terminaux côte à côte --- const splitDone = await waitFor(async () => { await clickSessionRow('beta', true); // Alt+clic = ouvrir à côté return (await columnCount()) === 2; }, 30); check('(b) deux colonnes de terminaux visibles simultanément', splitDone, `colonnes: ${await columnCount()}`); const bothVisible = await evaluate( `(() => { const screens = [...document.querySelectorAll('.xterm-screen')]; if (screens.length !== 2) return false; return screens.every((s) => { const r = s.getBoundingClientRect(); return r.width > 50 && r.height > 20; }); })()`, ); check('(b) les deux colonnes ont une surface réelle', bothVisible === true); const sideBySide = await evaluate( `(() => { const [a, b] = [...document.querySelectorAll('.xterm-screen')].map((s) => s.getBoundingClientRect()); return !!a && !!b && Math.abs(a.top - b.top) < 40 && Math.abs(a.left - b.left) > 100; })()`, ); check('(b) elles sont côte à côte (et non empilées)', sideBySide === true); // Capture optionnelle (argument 1) : preuve visuelle des deux colonnes, utile en revue. if (shotDir) { mkdirSync(shotDir, { recursive: true }); const shot = await client.send('Page.captureScreenshot', { format: 'png' }, sessionId); const file = join(shotDir, 'terminal-columns.png'); writeFileSync(file, Buffer.from(shot.data, 'base64')); check('capture des deux colonnes écrite', existsSync(file), file); } // --- (c) la frappe va à la colonne active (la seconde vient d'être créée) --- await type('touch recu-B && echo MARQUEUR-COLONNE-B'); await pressEnter(); const gotB = await waitFor(() => existsSync(join(repos[1].dir, 'recu-B')), 40); check('(c) la frappe va au PTY de la colonne active (nouvelle colonne focalisée)', gotB); check('(c) elle ne fuit pas dans l’autre PTY', !existsSync(join(repos[0].dir, 'recu-B'))); const paintedB = await waitFor(async () => ((await screenText(1)) ?? '').includes('MARQUEUR-COLONNE-B'), 40); check('(c) la seconde colonne peint aussi sa sortie', paintedB); // Retour sur la première colonne : un clic dedans doit lui rendre la frappe. await clickColumn(0); await type('touch retour-A'); await pressEnter(); const backToA = await waitFor(() => existsSync(join(repos[0].dir, 'retour-A')), 40); check('(c) le focus suit le clic sur une colonne', backToA); // --- (e) la portée git suit le terminal focalisé (zone centrale en mode Changements) --- // La colonne active est celle d'alpha (on vient d'y revenir) : seul son fichier modifié doit être // listé, celui de beta doit disparaître de la vue. const centerText = () => evaluate(`document.querySelector('main')?.innerText ?? ''`); const scopedToAlpha = await waitFor(async () => { const txt = await centerText(); return txt.includes('wip-alpha.txt') && !txt.includes('wip-beta.txt'); }, 40); check('(e) la vue Changements ne montre que le projet du terminal focalisé', scopedToAlpha, (await centerText()).replace(/\s+/g, ' ').slice(0, 90)); // Focaliser la colonne de beta doit faire suivre la vue. await clickColumn(1); const followsBeta = await waitFor(async () => { const txt = await centerText(); return txt.includes('wip-beta.txt') && !txt.includes('wip-alpha.txt'); }, 40); check('(e) changer de colonne fait suivre la vue Changements', followsBeta, (await centerText()).replace(/\s+/g, ' ').slice(0, 90)); // La bascule « tout voir » doit libérer la portée. await evaluate( `(() => { const btn = [...document.querySelectorAll('button')].find((b) => /Show every project|Follow the focused terminal/.test(b.getAttribute('title') ?? '')); btn?.click(); return !!btn; })()`, ); // Un bloc replié ne liste pas ses fichiers : la preuve d'élargissement, ce sont les deux dépôts. const seesBoth = await waitFor(async () => { const txt = await centerText(); return txt.includes('alpha') && txt.includes('beta'); }, 40); check('(e) la bascule « tout voir » libère la portée', seesBoth); // --- (d) fermer une colonne --- const closed = await waitFor(async () => { await clickTitled('Close', 0); return (await columnCount()) <= 1; }, 20); check('(d) fermer un terminal libère sa colonne', closed, `colonnes: ${await columnCount()}`); const consoleErrors = client.events .slice(consoleBefore) .filter((e) => e.method === 'Log.entryAdded' && e.params?.entry?.level === 'error') .map((e) => e.params.entry.text) // Les 404 de favicon et les avertissements de chunk ne concernent pas le dock. .filter((t) => !/favicon|manifest/i.test(t)); check('aucune erreur console', consoleErrors.length === 0, consoleErrors.slice(0, 3).join(' | ')); } catch (err) { check('exception', false, String(err)); } finally { browser?.kill('SIGKILL'); srv?.kill('SIGTERM'); await sleep(1200); srv?.kill('SIGKILL'); rmSync(tmp, { recursive: true, force: true }); const failed = results.filter((r) => !r.ok); console.log(failed.length === 0 ? '\nVERIFY TERMINALS: ALL GREEN' : `\nVERIFY TERMINALS: ${failed.length} FAILURE(S)`); process.exit(failed.length === 0 ? 0 : 1); }