#!/usr/bin/env node // Vérification E2E de la ZONE CENTRALE de l'IDE, par interaction réelle (pas des captures) : daemon // temporaire isolé + Chromium headless piloté en CDP + cookie de session injecté. On clique dans // l'arbre comme un utilisateur, puis on lit le DOM de Monaco. // // Ce que ce script prouve, et qu'aucune capture ne prouvait : // (a) ouvrir un fichier alors qu'AUCUN onglet n'est persisté affiche réellement son contenu. C'est // le cas nominal qui restait vide et muet : le conteneur de Monaco vivait sous un `v-if`, donc // l'éditeur n'était jamais créé et aucun watcher ne retentait ; // (b) fermer le dernier onglet puis réouvrir un fichier réaffiche le contenu (le conteneur ne doit // pas être détruit, sinon l'éditeur pointe sur un noeud détaché) ; // (c) sur mobile, toucher un fichier amène la zone centrale au premier plan (l'arbre et l'éditeur // étant mutuellement exclusifs sous 768 px, un contenu visible EST la preuve de la bascule) ; // (d) chunk de l'éditeur introuvable : un message et des actions de récupération, pas une zone vide ; // (e) la bascule Fichiers / Changements liste bien les fichiers modifiés et permet de committer. // // Usage : node packages/server/scripts/verify-editor.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 = 7412; const CDP_PORT = 9335; const ORIGIN = `http://127.0.0.1:${PORT}`; const MARKER = 'ARB_EDITOR_RENDERED_4242'; 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; } /** 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-editor-')); let srv = null; let browser = null; try { 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 fichier sonde EN PREMIÈRE LIGNE (Monaco virtualise le rendu) --- 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'); writeFileSync(join(repo, 'probe.ts'), `export const probe = '${MARKER}';\n`); git('add', '-A'); git('commit', '-m', 'commit initial'); // du travail non commité, pour la vue Changements writeFileSync(join(repo, 'dirty.txt'), 'travail en cours\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 sessionCookie = (login.headers.getSetCookie?.() ?? []).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 repoRes = await (await j('/api/v1/repos', 'POST', { path: repo })).json(); const repoId = repoRes.repo?.id; const repoLabel = repoRes.repo?.label; check('dépôt de démonstration enregistré', !!repoId, repoLabel ?? ''); 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; // localStorage est partagé par les cibles d'un même profil : on l'efface pour chaque scénario. // AUCUN onglet persisté : c'est précisément le cas qui restait vide. `arboretum.locale` n'est pas // un persistedRef (chaîne brute, pas de JSON). const seed = (extra = '') => `localStorage.clear();localStorage.setItem('arb.theme', '"dark"');localStorage.setItem('arboretum.locale', 'en');${extra}`; /** Ouvre une cible isolée, authentifiée, avec un état de vue amorcé. */ async function openTarget({ width = 1440, height = 900, extraSeed = '', blocked = [] } = {}) { 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('Page.enable', {}, sessionId); await client.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 500 }, sessionId); await client.send('Network.setCookie', { name: 'arb_session', value: cookieValue, domain: '127.0.0.1', path: '/', httpOnly: true }, sessionId); if (blocked.length > 0) await client.send('Network.setBlockedURLs', { urls: blocked }, sessionId); await client.send('Page.addScriptToEvaluateOnNewDocument', { source: seed(extraSeed) }, sessionId); const before = client.events.length; await client.send('Page.navigate', { url: `${ORIGIN}/ide` }, sessionId); return { targetId, sessionId, before }; } const evaluate = async (expression, sessionId) => (await client.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId)).result?.value; /** Attend qu'une condition devienne vraie (jamais de sleep fixe sur un rendu asynchrone). */ 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; } // Clic sur une ligne par son libellé exact : les libellés vivent dans un à l'intérieur du //