#!/usr/bin/env node // Vérification E2E du copier / coller dans le terminal web (régression : la sélection d'xterm n'est // pas une sélection DOM, le « Copier » natif ne voyait donc rien). Daemon temporaire isolé + session // `bash` (pas `claude` : pas de quota consommé) + Chromium piloté en CDP : on tape un marqueur, on le // sélectionne à la souris, Ctrl+Shift+C, et on relit le presse-papier réel du navigateur. Puis // l'inverse : on remplit le presse-papier, Ctrl+Shift+V, et on vérifie que le PTY l'a reçu. import { spawn } from 'node:child_process'; import { mkdtempSync, rmSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { WebSocket } from 'ws'; const PORT = 7411; const CDP_PORT = 9334; 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)); let failures = 0; function check(label, ok, detail = '') { console.log(`${ok ? '✅' : '❌'} ${label}${detail ? ` : ${detail}` : ''}`); if (!ok) failures++; } function findChromium() { for (const bin of ['/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome']) { if (existsSync(bin)) return bin; } return null; } /** Client CDP minimal : un socket, corrélation par id. */ function cdp(url) { const ws = new WebSocket(url, { perMessageDeflate: false }); const pending = new Map(); let seq = 0; const ready = new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); }); ws.on('message', (raw) => { const msg = JSON.parse(raw.toString()); const entry = pending.get(msg.id); if (!entry) return; pending.delete(msg.id); msg.error ? entry.reject(new Error(msg.error.message)) : entry.resolve(msg.result); }); return { ready, close: () => ws.close(), send(method, params = {}, sessionId) { const id = ++seq; return new Promise((resolve, reject) => { 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); }); }, }; } let srv, browser, tmp; try { tmp = mkdtempSync(join(tmpdir(), 'arb-clip-')); 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); const login = await fetch(`${ORIGIN}/api/v1/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: ORIGIN }, body: JSON.stringify({ token }), }); const cookie = (login.headers.getSetCookie?.() ?? []).map((c) => c.split(';')[0]).find((c) => c.startsWith('arb_session=')); const cookieValue = cookie?.slice('arb_session='.length) ?? ''; check('login → cookie de session', !!cookie); const sess = await ( await fetch(`${ORIGIN}/api/v1/sessions`, { method: 'POST', headers: { Origin: ORIGIN, Cookie: cookie, 'Content-Type': 'application/json' }, body: JSON.stringify({ cwd: tmp, command: 'bash' }), }) ).json(); const sessionId = sess.session?.id; check('session bash lancée', !!sessionId); const chromeBin = findChromium(); check('Chromium disponible', !!chromeBin, chromeBin ?? 'introuvable'); if (!chromeBin || !sessionId) throw new Error('prérequis manquants'); 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; // Presse-papier lisible/écrivable sans geste utilisateur (sinon readText() rejette en headless). await client.send('Browser.grantPermissions', { origin: ORIGIN, permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'], }); const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' }); const { sessionId: sid } = await client.send('Target.attachToTarget', { targetId, flatten: true }); await client.send('Page.enable', {}, sid); await client.send('Runtime.enable', {}, sid); await client.send('Network.enable', {}, sid); await client.send('Network.setCookie', { name: 'arb_session', value: cookieValue, domain: '127.0.0.1', path: '/', httpOnly: true }, sid); await client.send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }, sid); const evaluate = async (expression, awaitPromise = false) => (await client.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise }, sid)).result?.value; await client.send('Page.navigate', { url: `${ORIGIN}/sessions/${sessionId}` }, sid); // attend que xterm soit monté ET que bash ait rendu son invite let screen = null; for (let i = 0; i < 80 && !screen; i++) { await sleep(250); screen = await evaluate(`(() => { const el = document.querySelector('.xterm-screen'); if (!el) return null; const r = el.getBoundingClientRect(); return r.width > 50 ? JSON.stringify(r) : null; })()`); } check('terminal xterm monté', !!screen); const rect = screen ? JSON.parse(screen) : null; // Le renderer WebGL peint dans un canvas : `.xterm-rows` est vide, on ne peut RIEN vérifier via le // DOM. Les preuves passent donc par le système de fichiers (le cwd de la session est `tmp`) et par // le presse-papier réel du navigateur. const focusTerm = () => client.send('Runtime.evaluate', { expression: `document.querySelector('.xterm-helper-textarea')?.focus()` }, sid); const pressEnter = async () => { await client.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, text: '\r' }, sid); await client.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }, sid); }; const waitForFile = async (name, tries = 40) => { for (let i = 0; i < tries; i++) { if (existsSync(join(tmp, name))) return true; await sleep(200); } return false; }; // --- Frappe dans le PTY (Input.insertText → textarea xterm → stdin) --- await focusTerm(); await client.send('Input.insertText', { text: 'touch typed-ok' }, sid); await pressEnter(); check('le PTY exécute une commande tapée au clavier', await waitForFile('typed-ok')); // Marqueur affiché à l'écran, cible de la copie const MARKER = 'COPIE_MOI_4242'; await client.send('Input.insertText', { text: `echo ${MARKER}` }, sid); await pressEnter(); await sleep(600); // --- Sélection à la souris sur la zone du terminal, puis Ctrl+Shift+C --- if (rect) { const y = rect.y + 8; await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: rect.x + 2, y, button: 'left', clickCount: 1, buttons: 1 }, sid); await client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: rect.x + rect.width - 4, y: y + 40, button: 'left', buttons: 1 }, sid); await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: rect.x + rect.width - 4, y: y + 40, button: 'left', clickCount: 1, buttons: 0 }, sid); } await sleep(300); const selection = await evaluate(`(() => { const s = document.querySelector('.xterm')?.classList; return document.getSelection()?.toString() ?? ''; })()`); // ctrl(2) + shift(8) = 10 const keyOpts = { modifiers: 10, windowsVirtualKeyCode: 67, nativeVirtualKeyCode: 67, key: 'C', code: 'KeyC' }; await client.send('Input.dispatchKeyEvent', { type: 'keyDown', ...keyOpts }, sid); await client.send('Input.dispatchKeyEvent', { type: 'keyUp', ...keyOpts }, sid); await sleep(500); const copied = (await evaluate('navigator.clipboard.readText()', true)) ?? ''; check('Ctrl+Shift+C copie la sélection du terminal', copied.includes(MARKER), JSON.stringify(copied.slice(0, 60))); // --- Collage : presse-papier → Ctrl+Shift+V → la commande collée doit atteindre le PTY --- await evaluate(`navigator.clipboard.writeText('touch paste-ok')`, true); await focusTerm(); const vOpts = { modifiers: 10, windowsVirtualKeyCode: 86, nativeVirtualKeyCode: 86, key: 'V', code: 'KeyV' }; await client.send('Input.dispatchKeyEvent', { type: 'keyDown', ...vOpts }, sid); await client.send('Input.dispatchKeyEvent', { type: 'keyUp', ...vOpts }, sid); await sleep(400); await pressEnter(); check('Ctrl+Shift+V colle le presse-papier dans le terminal', await waitForFile('paste-ok')); // --- Ctrl+C ne doit PAS être détourné : il reste SIGINT --- // `sleep 25` bloque le shell ; si le ^C passe, le shell reprend et exécute la commande suivante. await focusTerm(); await client.send('Input.insertText', { text: 'sleep 25' }, sid); await pressEnter(); await sleep(700); const cOpts = { modifiers: 2, windowsVirtualKeyCode: 67, nativeVirtualKeyCode: 67, key: 'c', code: 'KeyC' }; await client.send('Input.dispatchKeyEvent', { type: 'keyDown', ...cOpts }, sid); await client.send('Input.dispatchKeyEvent', { type: 'keyUp', ...cOpts }, sid); await sleep(500); await client.send('Input.insertText', { text: 'touch interrupt-ok' }, sid); await pressEnter(); check('Ctrl+C reste transmis au PTY (SIGINT, pas une copie)', await waitForFile('interrupt-ok', 30)); client.close(); } catch (err) { check('exécution du scénario', false, err?.message ?? String(err)); } finally { browser?.kill('SIGKILL'); srv?.kill('SIGKILL'); await sleep(300); if (tmp) rmSync(tmp, { recursive: true, force: true }); } console.log(failures === 0 ? '\nVERIFY CLIPBOARD: ALL GREEN' : `\nVERIFY CLIPBOARD: ${failures} ÉCHEC(S)`); process.exit(failures === 0 ? 0 : 1);