diff --git a/packages/server/scripts/acceptance-p17.mjs b/packages/server/scripts/acceptance-p17.mjs new file mode 100644 index 0000000..72a94e6 --- /dev/null +++ b/packages/server/scripts/acceptance-p17.mjs @@ -0,0 +1,200 @@ +#!/usr/bin/env node +// Acceptation P17 : « le terminal reste tout noir alors que la session tourne ». +// +// Cause racine reproduite ici : le replay d'attache est une frame BINAIRE, et le client n'apprend le +// numéro de canal qu'avec le message `attached`. Émis AVANT, le replay tombait sur un canal inconnu et +// était jeté en silence : rien à l'écran jusqu'au prochain octet spontané du PTY, c'est-à-dire jamais +// pour un TUI au repos. Ce script vérifie l'ordre réel des trames sur un VRAI WebSocket, et couvre au +// passage les attaches sans écran et l'épilogue de sortie. +// +// Aucun quota Claude consommé : commande `bash`. +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } 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 = 7549; +const ORIGIN = `http://127.0.0.1:${PORT}`; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const results = []; +const check = (name, ok, detail = '') => { + results.push({ name, ok, detail }); + console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`); +}; + +const tmp = mkdtempSync(join(tmpdir(), 'arb-p17-')); +const srv = spawn('node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--no-discover'], { + env: { ...process.env, ARBORETUM_LOG: 'warn' }, + stdio: ['ignore', 'pipe', 'pipe'], +}); +let srvOut = ''; +srv.stdout.on('data', (d) => (srvOut += d)); +srv.stderr.on('data', (d) => (srvOut += d)); + +/** + * Client WS qui conserve la CHRONOLOGIE des trames (`frames`), texte et binaire mêlés : c'est le seul + * moyen de tester un ordre. Les frames binaires sont décodées en {type, channel, payload}. + */ +function wsClient(cookie) { + const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } }); + ws.binaryType = 'arraybuffer'; + const frames = []; + const msgs = []; + ws.on('message', (data, isBinary) => { + if (!isBinary) { + const msg = JSON.parse(String(data)); + msgs.push(msg); + frames.push({ kind: 'text', msg }); + return; + } + const buf = Buffer.from(data); + frames.push({ kind: 'binary', type: buf.readUInt8(0), channel: buf.readUInt32LE(1), payload: buf.subarray(5) }); + }); + const waitMsg = async (pred, timeout = 8000) => { + const t0 = Date.now(); + while (Date.now() - t0 < timeout) { + const m = msgs.find(pred); + if (m) return m; + await sleep(50); + } + return null; + }; + /** + * Sortie telle que le VRAI client la peindrait : il n'enregistre un canal qu'en recevant `attached` + * et jette toute frame binaire arrivée avant. On imite ce comportement, sinon ce script verrait un + * écran que le navigateur, lui, n'affiche pas. + */ + const outputOf = (channel) => { + const known = frames.findIndex((f) => f.kind === 'text' && f.msg.type === 'attached' && f.msg.channel === channel); + if (known < 0) return ''; + return frames + .slice(known) + .filter((f) => f.kind === 'binary' && f.channel === channel) + .map((f) => f.payload.toString('latin1')) + .join(''); + }; + return { ws, frames, msgs, waitMsg, outputOf, send: (m) => ws.send(JSON.stringify(m)) }; +} + +try { + await sleep(1500); + const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0]; + check('boot + token bootstrap', !!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.get('set-cookie')?.split(';')[0] ?? ''; + check('login → cookie', login.status === 200 && cookie.startsWith('arb_session=')); + + const api = (path, init = {}) => + fetch(`${ORIGIN}${path}`, { ...init, headers: { 'Content-Type': 'application/json', Origin: ORIGIN, Cookie: cookie, ...(init.headers ?? {}) } }); + + const created = await api('/api/v1/sessions', { method: 'POST', body: JSON.stringify({ cwd: tmp, command: 'bash' }) }); + const sid = (await created.json()).session.id; + check('spawn bash', created.status === 201 && !!sid); + + // --- 1. Première attache : de la sortie existe déjà dans le ring --- + const c1 = wsClient(cookie); + await new Promise((res, rej) => (c1.ws.on('open', res), c1.ws.on('error', rej))); + c1.send({ type: 'hello', protocol: 1 }); + await c1.waitMsg((m) => m.type === 'hello_ok'); + + c1.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 120, rows: 32 }); + const att1 = await c1.waitMsg((m) => m.type === 'attached'); + check('attach interactif + controlling', att1?.controlling === true); + await sleep(300); + c1.send({ type: 'stdin', channel: att1.channel, data: 'echo MARQUEUR-ECRAN-1\r' }); + await sleep(800); + check('stdin → output', c1.outputOf(att1.channel).includes('MARQUEUR-ECRAN-1')); + + // --- 2. Ré-attache (nouvelle connexion, PTY silencieux) : l'écran DOIT revenir --- + // C'est le scénario vécu : l'app est rechargée, Claude est à son prompt et n'émet plus rien. + const c2 = wsClient(cookie); + await new Promise((res, rej) => (c2.ws.on('open', res), c2.ws.on('error', rej))); + c2.send({ type: 'hello', protocol: 1 }); + await c2.waitMsg((m) => m.type === 'hello_ok'); + c2.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 120, rows: 32 }); + const att2 = await c2.waitMsg((m) => m.type === 'attached'); + await sleep(400); + + const idxAttached = c2.frames.findIndex((f) => f.kind === 'text' && f.msg.type === 'attached'); + const idxResync = c2.frames.findIndex((f) => f.kind === 'binary' && f.type === 0x02); + check('ORDRE : `attached` précède le replay binaire', idxAttached >= 0 && idxResync > idxAttached, `attached@${idxAttached}, resync@${idxResync}`); + check('le replay porte le canal annoncé', c2.frames[idxResync]?.channel === att2.channel); + const replay = c2.outputOf(att2.channel); + check('l’écran se reconstitue à la ré-attache (fin de l’écran noir)', replay.includes('MARQUEUR-ECRAN-1'), `${replay.length} octets rejoués`); + + // --- 3. Un observateur peint aussi : il doit recevoir son replay --- + const c3 = wsClient(cookie); + await new Promise((res, rej) => (c3.ws.on('open', res), c3.ws.on('error', rej))); + c3.send({ type: 'hello', protocol: 1 }); + await c3.waitMsg((m) => m.type === 'hello_ok'); + c3.send({ type: 'attach', sessionId: sid, mode: 'observer', cols: 100, rows: 30 }); + const att3 = await c3.waitMsg((m) => m.type === 'attached'); + await sleep(400); + check('un observateur reçoit son replay (non-controlling)', att3?.controlling === false && c3.outputOf(att3.channel).includes('MARQUEUR-ECRAN-1')); + + // --- 4. Attache SANS écran : ne vole pas le contrôle, ne reçoit rien --- + // Régression : le DialogPrompt attachait en interactif avec des dimensions bidon, prenait le + // `controlling` et figeait la géométrie du TUI pour le terminal ouvert ensuite. + const c4 = wsClient(cookie); + await new Promise((res, rej) => (c4.ws.on('open', res), c4.ws.on('error', rej))); + c4.send({ type: 'hello', protocol: 1 }); + await c4.waitMsg((m) => m.type === 'hello_ok'); + c4.send({ type: 'attach', sessionId: sid, mode: 'interactive', cols: 80, rows: 24, screen: false }); + const att4 = await c4.waitMsg((m) => m.type === 'attached'); + await sleep(300); + check('attache sans écran : jamais controlling', att4?.controlling === false); + check('attache sans écran : aucune frame binaire', !c4.frames.some((f) => f.kind === 'binary')); + + c2.send({ type: 'stdin', channel: att2.channel, data: 'echo APRES-AVEUGLE\r' }); + await sleep(800); + check('attache sans écran : ne reçoit pas la sortie du PTY', !c4.frames.some((f) => f.kind === 'binary')); + check('le terminal à écran garde le contrôle et fonctionne', c2.outputOf(att2.channel).includes('APRES-AVEUGLE')); + // elle garde en revanche le droit d'écrire (c'est sa seule raison d'être) + c4.send({ type: 'stdin', channel: att4.channel, data: 'echo ECRIT-PAR-AVEUGLE\r' }); + await sleep(800); + check('attache sans écran : peut écrire', c2.outputOf(att2.channel).includes('ECRIT-PAR-AVEUGLE')); + + // --- 5. Épilogue de sortie : la raison de la mort est visible DANS le terminal --- + const dying = await api('/api/v1/sessions', { method: 'POST', body: JSON.stringify({ cwd: tmp, command: 'bash' }) }); + const dsid = (await dying.json()).session.id; + const c5 = wsClient(cookie); + await new Promise((res, rej) => (c5.ws.on('open', res), c5.ws.on('error', rej))); + c5.send({ type: 'hello', protocol: 1 }); + await c5.waitMsg((m) => m.type === 'hello_ok'); + c5.send({ type: 'attach', sessionId: dsid, mode: 'interactive', cols: 80, rows: 24 }); + const att5 = await c5.waitMsg((m) => m.type === 'attached'); + await sleep(300); + c5.send({ type: 'stdin', channel: att5.channel, data: 'exit 3\r' }); + const detached5 = await c5.waitMsg((m) => m.type === 'detached' && m.channel === att5.channel, 8000); + const epilogue = c5.outputOf(att5.channel); + check('épilogue : le code de sortie est écrit dans le terminal', epilogue.includes('[arboretum]') && epilogue.includes('exited with code 3'), epilogue.slice(-60).replace(/[\r\n]+/g, ' ')); + check('épilogue reçu AVANT le detached', !!detached5); + + // --- 6. Le PTY n'a pas été redimensionné par les attaches sans écran --- + const listed = await (await api('/api/v1/sessions')).json(); + check('session toujours vivante après tout ça', listed.sessions.some((s) => s.id === sid && s.live)); + + for (const c of [c1, c2, c3, c4, c5]) c.ws.close(); +} catch (err) { + check('exception', false, String(err)); +} finally { + srv.kill('SIGTERM'); + await sleep(1500); + check('arrêt propre du daemon (SIGTERM)', srv.exitCode === 0 || srv.exitCode === null); + rmSync(tmp, { recursive: true, force: true }); + const failed = results.filter((r) => !r.ok); + if (failed.length > 0) console.log(`\n--- sortie du daemon ---\n${srvOut.slice(-2000)}`); + console.log(failed.length === 0 ? '\nACCEPTANCE P17: ALL GREEN' : `\nACCEPTANCE P17: ${failed.length} FAILURE(S)`); + process.exit(failed.length === 0 ? 0 : 1); +} diff --git a/packages/server/scripts/verify-terminals.mjs b/packages/server/scripts/verify-terminals.mjs new file mode 100644 index 0000000..8490cbc --- /dev/null +++ b/packages/server/scripts/verify-terminals.mjs @@ -0,0 +1,411 @@ +#!/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); +} diff --git a/packages/server/src/core/claude-launcher.ts b/packages/server/src/core/claude-launcher.ts index 28236f5..d0f23ca 100644 --- a/packages/server/src/core/claude-launcher.ts +++ b/packages/server/src/core/claude-launcher.ts @@ -86,7 +86,14 @@ export function resolveClaudeBin(configuredPath?: string | null): string { } return configuredPath; } - if (cachedClaudeBin) return cachedClaudeBin; + // Cache REVALIDÉ : le daemon vit des jours. Un changement de version nvm/asdf, une réinstallation + // du CLI ou un simple `npm i -g` remplace le chemin, et le cache pointait alors sur un fichier + // disparu : node-pty spawnait dans le vide, le PTY mourait sans un octet, et l'utilisateur n'avait + // qu'un terminal vide sans explication. + if (cachedClaudeBin) { + if (isExecutable(cachedClaudeBin)) return cachedClaudeBin; + cachedClaudeBin = null; + } const found = findClaudeOnPath(); if (!found) { throw new Error( diff --git a/packages/server/src/core/pty-manager.ts b/packages/server/src/core/pty-manager.ts index 3b38461..daf8cdb 100644 --- a/packages/server/src/core/pty-manager.ts +++ b/packages/server/src/core/pty-manager.ts @@ -23,6 +23,8 @@ const NOTIFY_DEBOUNCE_MS = 1500; /** Capture du claudeSessionId après spawn : poll du registre par pid (waitReady validé S1). */ const CLAUDE_ID_POLL_MS = 400; const CLAUDE_ID_TIMEOUT_MS = 60_000; +/** Replay d'une attache sans écran : rien à peindre (alloué une fois, jamais muté). */ +const EMPTY_REPLAY = Buffer.alloc(0); /** * Ligne `sessions` telle que lue pour construire un SessionSummary historique (session terminée). @@ -79,6 +81,14 @@ function parseAddedDirs(raw: string | null): string[] { export interface ClientBinding { channel: number; mode: 'interactive' | 'observer'; + /** + * false = attache SANS écran (le client ne peint rien : il n'est là que pour écrire, cf. le + * DialogPrompt qui répond à un dialogue depuis la liste « À traiter »). Un tel binding ne prend + * jamais le `controlling` et ne redimensionne donc jamais le PTY : sinon ses dimensions + * arbitraires figeaient la géométrie du TUI pour le vrai terminal ouvert ensuite. Il ne reçoit + * pas non plus la sortie (inutile) et ne pèse pas dans le flow control. + */ + screen: boolean; controlling: boolean; sentBytes: number; ackedBytes: number; @@ -403,22 +413,32 @@ export class PtyManager extends EventEmitter { // ---- attach / detach / io ---- - attach(sessionId: string, binding: ClientBinding, cols: number, rows: number): { ok: true; controlling: boolean } | { ok: false; code: 'NOT_FOUND' | 'SESSION_EXITED' } { + /** + * Attache un client. Le payload de replay (reset terminal + queue du ring, l'écran TUI courant se + * reconstitue) est RENVOYÉ, pas envoyé : la gateway doit l'émettre APRÈS le message `attached`, + * sinon le client reçoit une frame binaire sur un canal qu'il ne connaît pas encore et la jette, + * ce qui laissait un terminal vide jusqu'au prochain octet spontané du PTY (jamais, pour un TUI + * au repos). Invariant à ne pas casser. + */ + attach( + sessionId: string, + binding: ClientBinding, + cols: number, + rows: number, + ): { ok: true; controlling: boolean; replay: Buffer } | { ok: false; code: 'NOT_FOUND' | 'SESSION_EXITED' } { const s = this.live.get(sessionId); if (!s) return { ok: false, code: 'NOT_FOUND' }; if (s.exited) return { ok: false, code: 'SESSION_EXITED' }; const hasController = [...s.clients].some((c) => c.controlling); - binding.controlling = binding.mode === 'interactive' && !hasController; + binding.controlling = binding.mode === 'interactive' && binding.screen && !hasController; s.clients.add(binding); if (binding.controlling) { s.proc.resize(cols, rows); s.tracker?.resize(cols, rows); } - // Replay : reset terminal + queue du ring (l'écran TUI courant se reconstitue) - binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES)); binding.sentBytes = 0; binding.ackedBytes = 0; - return { ok: true, controlling: binding.controlling }; + return { ok: true, controlling: binding.controlling, replay: binding.screen ? s.ring.tail(REPLAY_TAIL_BYTES) : EMPTY_REPLAY }; } detach(sessionId: string, binding: ClientBinding): void { @@ -426,7 +446,9 @@ export class PtyManager extends EventEmitter { if (!s) return; s.clients.delete(binding); if (binding.controlling) { - const next = [...s.clients].find((c) => c.mode === 'interactive'); + // Le relais ne peut aller qu'à un client qui PEINT : un binding sans écran redimensionnerait + // le PTY à des dimensions arbitraires (cf. ClientBinding.screen). + const next = [...s.clients].find((c) => c.mode === 'interactive' && c.screen); if (next) { next.controlling = true; next.onControlChanged(true); @@ -535,7 +557,7 @@ export class PtyManager extends EventEmitter { s.ring.write(chunk); s.tracker?.feed(chunk); for (const c of s.clients) { - if (c.lagging) continue; + if (c.lagging || !c.screen) continue; c.sendOutput(chunk); c.sentBytes += chunk.length; if (c.sentBytes - c.ackedBytes > FLOW.LAGGING_BYTES) c.lagging = true; @@ -545,11 +567,13 @@ export class PtyManager extends EventEmitter { /** * pause() seulement quand TOUS les clients interactifs non-lagging dépassent HIGH ; - * resume() quand le min repasse sous LOW. Les observers ne freinent jamais le PTY. + * resume() quand le min repasse sous LOW. Les observers ne freinent jamais le PTY, et les + * attaches sans écran ne reçoivent rien : les compter maintiendrait un `outstanding` nul et + * empêcherait toute pause, donc on les écarte. */ private updateFlowControl(s: ManagedSession): void { if (s.exited) return; - const interactive = [...s.clients].filter((c) => c.mode === 'interactive' && !c.lagging); + const interactive = [...s.clients].filter((c) => c.mode === 'interactive' && c.screen && !c.lagging); if (interactive.length === 0) { if (s.paused) { s.proc.resume(); @@ -576,6 +600,11 @@ export class PtyManager extends EventEmitter { if (s.notifyTimer) clearTimeout(s.notifyTimer); const endedAt = new Date().toISOString(); this.db.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?').run(endedAt, exitCode, s.id); + // Épilogue visible DANS le terminal : sans lui, un process mort à l'instant du spawn (binaire + // introuvable, auth expirée, commande de lancement qui sort aussitôt) ne laissait qu'un écran + // vide et un bandeau « Session terminée », sans jamais dire pourquoi. Diffusé AVANT le + // `onDetached` : après, le client a déjà oublié le canal et jetterait la frame. + this.handleOutput(s, Buffer.from(`\r\n[arboretum] ${s.command} ${signal ? `terminated by signal ${signal}` : `exited with code ${exitCode ?? 0}`}\r\n`)); for (const c of s.clients) c.onDetached('session_exit'); s.clients.clear(); this.live.delete(s.id); diff --git a/packages/server/src/ws/gateway.ts b/packages/server/src/ws/gateway.ts index 24cdebd..85fcdb9 100644 --- a/packages/server/src/ws/gateway.ts +++ b/packages/server/src/ws/gateway.ts @@ -182,6 +182,7 @@ export function registerWsGateway( const binding: ClientBinding = { channel, mode: msg.mode, + screen: msg.screen ?? true, controlling: false, sentBytes: 0, ackedBytes: 0, @@ -200,7 +201,13 @@ export function registerWsGateway( return; } channels.set(channel, { sessionId: msg.sessionId, binding }); + // ORDRE CRITIQUE : `attached` d'abord, le replay ENSUITE. Le client n'apprend le numéro de + // canal qu'avec `attached` ; une frame binaire émise avant tombe sur un canal inconnu et + // est jetée en silence, ce qui laissait le terminal vide (un TUI au repos ne réémet rien). send({ type: 'attached', channel, sessionId: msg.sessionId, mode: msg.mode, controlling: res.controlling }); + // Toujours envoyé quand le client peint, même vide : le resync porte AUSSI l'ordre de reset, + // sans quoi une ré-attache après reconnexion empilerait le nouveau flux sur un écran périmé. + if (binding.screen) binding.sendResync(res.replay); return; } case 'detach': { diff --git a/packages/server/test/claude-bin-cache.test.ts b/packages/server/test/claude-bin-cache.test.ts new file mode 100644 index 0000000..4e3f2df --- /dev/null +++ b/packages/server/test/claude-bin-cache.test.ts @@ -0,0 +1,66 @@ +// Le chemin du CLI claude est mémorisé pour la vie du process (le `which` coûte un fork par spawn). +// Régression : un daemon qui tourne des jours voyait ce chemin devenir invalide (bascule de version +// nvm/asdf, réinstallation du CLI) et continuait de spawner un fichier disparu. Le PTY mourait sans +// produire un seul octet, ce qui donnait un terminal vide et muet. Le cache est donc revalidé. +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +let dir: string; +/** Réponse courante du faux `which` : on la fait varier comme le ferait un changement de version. */ +let onPath: string; + +function makeBin(name: string): string { + const path = join(dir, name); + writeFileSync(path, '#!/bin/sh\nexit 0\n'); + chmodSync(path, 0o755); + return path; +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'arb-claude-bin-')); + vi.resetModules(); + vi.doMock('node:child_process', () => ({ execFileSync: () => `${onPath}\n` })); +}); + +afterEach(() => { + vi.doUnmock('node:child_process'); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('resolveClaudeBin · cache revalidé', () => { + it('le chemin caché disparu est re-résolu au lieu d’être servi tel quel', async () => { + const first = makeBin('claude-v1'); + onPath = first; + const { resolveClaudeBin } = await import('../src/core/claude-launcher.js'); + + expect(resolveClaudeBin()).toBe(first); + + // le CLI est réinstallé ailleurs : l'ancien chemin n'existe plus + rmSync(first); + const second = makeBin('claude-v2'); + onPath = second; + + expect(resolveClaudeBin()).toBe(second); + }); + + it('tant que le chemin caché existe, aucun `which` supplémentaire n’est fait', async () => { + const bin = makeBin('claude-stable'); + onPath = bin; + const calls: number[] = []; + vi.doMock('node:child_process', () => ({ + execFileSync: () => { + calls.push(1); + return `${onPath}\n`; + }, + })); + vi.resetModules(); + const { resolveClaudeBin } = await import('../src/core/claude-launcher.js'); + + expect(resolveClaudeBin()).toBe(bin); + expect(resolveClaudeBin()).toBe(bin); + expect(resolveClaudeBin()).toBe(bin); + expect(calls).toHaveLength(1); + }); +}); diff --git a/packages/server/test/pty-manager.test.ts b/packages/server/test/pty-manager.test.ts index cb892f6..f17f1f0 100644 --- a/packages/server/test/pty-manager.test.ts +++ b/packages/server/test/pty-manager.test.ts @@ -77,10 +77,11 @@ type BindingSpies = ClientBinding & { const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); let channelSeq = 1; -function makeBinding(mode: 'interactive' | 'observer'): BindingSpies { +function makeBinding(mode: 'interactive' | 'observer', screen = true): BindingSpies { return { channel: channelSeq++, mode, + screen, controlling: false, sentBytes: 0, ackedBytes: 0, @@ -252,10 +253,12 @@ describe('PtyManager (pty mocké)', () => { const b = makeBinding('interactive'); const res = manager.attach(summary.id, b, 80, 24); - expect(res).toEqual({ ok: true, controlling: true }); + expect(res).toMatchObject({ ok: true, controlling: true }); - expect(b.sendResync).toHaveBeenCalledTimes(1); - const payload = b.sendResync.mock.calls[0]![0]!; + // Le replay est RENVOYÉ (la gateway l'émet après `attached`), jamais envoyé par attach : + // une frame binaire qui précède `attached` tombe sur un canal inconnu du client et est jetée. + expect(b.sendResync).not.toHaveBeenCalled(); + const payload = (res as { replay: Buffer }).replay; const full = Buffer.from(chunks.join(''), 'ascii'); expect(payload.length).toBe(REPLAY_TAIL_BYTES); expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true); @@ -264,12 +267,31 @@ describe('PtyManager (pty mocké)', () => { expect(b.ackedBytes).toBe(0); }); - it('ring vide → resync avec payload vide', () => { + it('ring vide → replay vide', () => { const { summary } = spawnBash(); const b = makeBinding('interactive'); - manager.attach(summary.id, b, 80, 24); - expect(b.sendResync).toHaveBeenCalledTimes(1); - expect(b.sendResync.mock.calls[0]![0]!.length).toBe(0); + const res = manager.attach(summary.id, b, 80, 24); + expect((res as { replay: Buffer }).replay.length).toBe(0); + expect(b.sendResync).not.toHaveBeenCalled(); + }); + + it('attache sans écran : ni contrôle, ni resize, ni sortie, ni replay', () => { + const { summary, pty } = spawnBash(); + pty.emitData('hello'); + const blind = makeBinding('interactive', false); + const res = manager.attach(summary.id, blind, 120, 32); + expect(res).toMatchObject({ ok: true, controlling: false }); + expect((res as { replay: Buffer }).replay.length).toBe(0); + expect(pty.resize).not.toHaveBeenCalled(); + + // le vrai terminal ouvert ensuite prend bien le contrôle et impose SA géométrie + const real = makeBinding('interactive'); + expect(manager.attach(summary.id, real, 200, 50)).toMatchObject({ ok: true, controlling: true }); + expect(pty.resize).toHaveBeenCalledWith(200, 50); + + pty.emitData('world'); + expect(blind.sendOutput).not.toHaveBeenCalled(); + expect(real.sendOutput).toHaveBeenCalledTimes(1); }); it('contrôle au premier interactif seulement, les observers ne comptent pas', () => { @@ -278,13 +300,13 @@ describe('PtyManager (pty mocké)', () => { const a = makeBinding('interactive'); const b = makeBinding('interactive'); - expect(manager.attach(summary.id, obs, 80, 24)).toEqual({ ok: true, controlling: false }); + expect(manager.attach(summary.id, obs, 80, 24)).toMatchObject({ ok: true, controlling: false }); expect(pty.resize).not.toHaveBeenCalled(); // un observer ne redimensionne pas - expect(manager.attach(summary.id, a, 100, 30)).toEqual({ ok: true, controlling: true }); + expect(manager.attach(summary.id, a, 100, 30)).toMatchObject({ ok: true, controlling: true }); expect(pty.resize).toHaveBeenCalledWith(100, 30); // le contrôleur impose sa taille - expect(manager.attach(summary.id, b, 200, 50)).toEqual({ ok: true, controlling: false }); + expect(manager.attach(summary.id, b, 200, 50)).toMatchObject({ ok: true, controlling: false }); expect(pty.resize).toHaveBeenCalledTimes(1); // pas de resize pour le non-contrôleur expect(manager.get(summary.id)?.clients).toBe(3); }); @@ -531,8 +553,9 @@ describe('PtyManager (pty mocké)', () => { manager.ack(summary.id, a, 3 * mib); // rattrapage : outstanding 0 < LOW expect(a.lagging).toBe(false); - expect(a.sendResync).toHaveBeenCalledTimes(2); // attach + rattrapage - const payload = a.sendResync.mock.calls[1]![0]!; + // seul le rattrapage passe par sendResync : le replay d'attache est renvoyé à la gateway + expect(a.sendResync).toHaveBeenCalledTimes(1); + const payload = a.sendResync.mock.calls[0]![0]!; const full = Buffer.from(chunks.join(''), 'ascii'); expect(payload.equals(full.subarray(full.length - REPLAY_TAIL_BYTES))).toBe(true); expect(a.sentBytes).toBe(0); @@ -592,7 +615,7 @@ describe('PtyManager (pty mocké)', () => { manager.attach(summary.id, a, 80, 24); manager.detach(summary.id, a); const b = makeBinding('interactive'); - expect(manager.attach(summary.id, b, 80, 24)).toEqual({ ok: true, controlling: true }); + expect(manager.attach(summary.id, b, 80, 24)).toMatchObject({ ok: true, controlling: true }); }); }); diff --git a/packages/server/test/ws-attach-order.test.ts b/packages/server/test/ws-attach-order.test.ts new file mode 100644 index 0000000..0d56eef --- /dev/null +++ b/packages/server/test/ws-attach-order.test.ts @@ -0,0 +1,185 @@ +// Ordre des trames à l'attache : régression de « le terminal reste tout noir alors que la session +// tourne ». Le replay d'attache est une frame BINAIRE ; le client n'apprend le numéro de canal +// qu'avec le message `attached`, et jette toute frame binaire portant un canal inconnu. Émettre le +// replay avant `attached` revenait donc à ne rien afficher jusqu'au prochain octet spontané du PTY, +// c'est-à-dire jamais pour un TUI au repos (Claude à son prompt). +// +// On instrumente la gateway avec un faux socket et de faux bus d'événements : c'est le seul moyen +// d'observer l'ORDRE réel des `socket.send` sans monter un vrai serveur WebSocket (couvert par +// scripts/acceptance-p17.mjs). +import { tmpdir } from 'node:os'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BINARY_FRAME, decodeBinaryFrame, PROTOCOL_VERSION } from '@arboretum/shared'; +import { registerWsGateway } from '../src/ws/gateway.js'; +import { PtyManager } from '../src/core/pty-manager.js'; +import { openDb, type Db } from '../src/db/index.js'; + +vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' })); + +const ptyMock = vi.hoisted(() => ({ instances: [] as unknown[] })); + +vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => { + let nextPid = 200_000; + class FakePtyImpl { + pid = nextPid++; + write = vi.fn(); + resize = vi.fn(); + pause = vi.fn(); + resume = vi.fn(); + kill = vi.fn(); + private dataCbs: Array<(d: string) => void> = []; + constructor( + readonly file: string, + readonly args: string[], + readonly opts: unknown, + ) {} + onData(cb: (d: string) => void): { dispose: () => void } { + this.dataCbs.push(cb); + return { dispose: () => {} }; + } + onExit(): { dispose: () => void } { + return { dispose: () => {} }; + } + emitData(d: string): void { + for (const cb of this.dataCbs) cb(d); + } + } + return { + default: { + spawn: (file: string, args: string[], opts: unknown): FakePtyImpl => { + const p = new FakePtyImpl(file, args, opts); + ptyMock.instances.push(p); + return p; + }, + }, + }; +}); + +/** Bus d'événements inerte : la gateway s'abonne à 7 services dont un seul nous intéresse. */ +const inertBus = (): { on: () => void; off: () => void } => ({ on: () => {}, off: () => {} }); + +interface FakeSocket { + readyState: number; + OPEN: number; + sent: Array; + send(data: string | Uint8Array): void; + on(event: string, cb: (...args: unknown[]) => void): void; + ping(): void; + terminate(): void; + close(): void; + emit(event: string, ...args: unknown[]): void; +} + +function makeSocket(): FakeSocket { + const handlers = new Map void>>(); + return { + readyState: 1, + OPEN: 1, + sent: [], + send(data) { + this.sent.push(data); + }, + on(event, cb) { + const list = handlers.get(event) ?? []; + list.push(cb); + handlers.set(event, list); + }, + ping() {}, + terminate() {}, + close() {}, + emit(event, ...args) { + for (const cb of handlers.get(event) ?? []) cb(...args); + }, + }; +} + +describe('gateway · ordre des trames à l’attache', () => { + let db: Db; + let manager: PtyManager; + let socket: FakeSocket; + + beforeEach(() => { + ptyMock.instances.length = 0; + db = openDb(':memory:'); + manager = new PtyManager(db); + socket = makeSocket(); + + let handler: ((s: unknown, req: unknown) => void) | null = null; + const app = { + get: (_path: string, _opts: unknown, h: (s: unknown, req: unknown) => void) => { + handler = h; + }, + }; + registerWsGateway( + app as never, + manager, + inertBus() as never, + inertBus() as never, + inertBus() as never, + inertBus() as never, + inertBus() as never, + inertBus() as never, + '0.0.0-test', + ); + handler!(socket, {}); + socket.emit('message', Buffer.from(JSON.stringify({ type: 'hello', protocol: PROTOCOL_VERSION })), false); + socket.sent.length = 0; // on ignore le hello_ok + }); + + const send = (msg: unknown): void => socket.emit('message', Buffer.from(JSON.stringify(msg)), false); + const texts = (): Array> => + socket.sent.filter((f): f is string => typeof f === 'string').map((f) => JSON.parse(f) as Record); + + it('`attached` d’abord, replay binaire ENSUITE, sur le même canal', () => { + const summary = manager.spawn({ cwd: tmpdir(), command: 'bash' }); + (ptyMock.instances.at(-1) as { emitData(d: string): void }).emitData('prompt$ '); + + send({ type: 'attach', sessionId: summary.id, mode: 'interactive', cols: 80, rows: 24 }); + + expect(socket.sent).toHaveLength(2); + const attached = JSON.parse(socket.sent[0] as string) as { type: string; channel: number }; + expect(attached.type).toBe('attached'); + + const frame = decodeBinaryFrame(socket.sent[1] as Uint8Array); + expect(frame.type).toBe(BINARY_FRAME.RESYNC); + expect(frame.channel).toBe(attached.channel); + expect(Buffer.from(frame.payload).toString()).toBe('prompt$ '); + }); + + it('ring vide : le resync part quand même (il porte l’ordre de reset)', () => { + const summary = manager.spawn({ cwd: tmpdir(), command: 'bash' }); + send({ type: 'attach', sessionId: summary.id, mode: 'interactive', cols: 80, rows: 24 }); + + expect(socket.sent).toHaveLength(2); + const frame = decodeBinaryFrame(socket.sent[1] as Uint8Array); + expect(frame.type).toBe(BINARY_FRAME.RESYNC); + expect(frame.payload.byteLength).toBe(0); + }); + + it('attache sans écran : `attached` seul, aucune frame binaire', () => { + const summary = manager.spawn({ cwd: tmpdir(), command: 'bash' }); + (ptyMock.instances.at(-1) as { emitData(d: string): void }).emitData('bruit'); + + send({ type: 'attach', sessionId: summary.id, mode: 'interactive', cols: 120, rows: 32, screen: false }); + + expect(texts().map((m) => m.type)).toEqual(['attached']); + expect(socket.sent.every((f) => typeof f === 'string')).toBe(true); + expect(texts()[0]).toMatchObject({ controlling: false }); + }); + + it('un observer reçoit aussi son replay (il peint, lui)', () => { + const summary = manager.spawn({ cwd: tmpdir(), command: 'bash' }); + (ptyMock.instances.at(-1) as { emitData(d: string): void }).emitData('ecran'); + + send({ type: 'attach', sessionId: summary.id, mode: 'observer', cols: 80, rows: 24 }); + + const frame = decodeBinaryFrame(socket.sent[1] as Uint8Array); + expect(frame.type).toBe(BINARY_FRAME.RESYNC); + expect(Buffer.from(frame.payload).toString()).toBe('ecran'); + }); + + it('session introuvable : erreur seule, pas de canal ni de frame binaire', () => { + send({ type: 'attach', sessionId: 'inconnue', mode: 'interactive', cols: 80, rows: 24 }); + expect(texts()).toEqual([{ type: 'error', code: 'NOT_FOUND', message: 'Cannot attach: NOT_FOUND' }]); + }); +}); diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index e0747dd..45955cc 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -266,7 +266,11 @@ export interface CloneOperation { // ---- Messages client → serveur ---- export type ClientMessage = | { type: 'hello'; protocol: number } - | { type: 'attach'; sessionId: string; mode: 'interactive' | 'observer'; cols: number; rows: number } + // `screen` (défaut true) : false = le client n'affiche RIEN, il attache seulement pour écrire + // (ex. répondre à un dialogue depuis la liste « À traiter »). Le serveur ne lui envoie alors ni + // sortie ni replay, ne lui confie jamais le contrôle et ignore ses dimensions : sans ce drapeau, + // une telle attache volait le `controlling` et figeait la géométrie du TUI pour le vrai terminal. + | { type: 'attach'; sessionId: string; mode: 'interactive' | 'observer'; cols: number; rows: number; screen?: boolean } | { type: 'detach'; channel: number } | { type: 'stdin'; channel: number; data: string } // P4-A : répondre à un dialogue Claude sans clavier. Le serveur traduit l'intention @@ -340,7 +344,7 @@ export function parseClientMessage(raw: string): ClientMessage | null { return isCount(m.protocol) ? { type: 'hello', protocol: m.protocol } : null; case 'attach': return typeof m.sessionId === 'string' && (m.mode === 'interactive' || m.mode === 'observer') && isDim(m.cols) && isDim(m.rows) - ? { type: 'attach', sessionId: m.sessionId, mode: m.mode, cols: m.cols, rows: m.rows } + ? { type: 'attach', sessionId: m.sessionId, mode: m.mode, cols: m.cols, rows: m.rows, screen: typeof m.screen === 'boolean' ? m.screen : true } : null; case 'detach': return isU32(m.channel) ? { type: 'detach', channel: m.channel } : null; diff --git a/packages/shared/test/protocol.test.ts b/packages/shared/test/protocol.test.ts index 8041d0d..9fa1167 100644 --- a/packages/shared/test/protocol.test.ts +++ b/packages/shared/test/protocol.test.ts @@ -77,13 +77,24 @@ describe('parseClientMessage · cas valides', () => { it('attach avec dimensions aux bornes', () => { const make = (cols: number, rows: number): string => JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols, rows }); - expect(parseClientMessage(make(2, 2))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 2, rows: 2 }); - expect(parseClientMessage(make(1000, 1000))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 1000, rows: 1000 }); + expect(parseClientMessage(make(2, 2))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 2, rows: 2, screen: true }); + expect(parseClientMessage(make(1000, 1000))).toEqual({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 1000, rows: 1000, screen: true }); }); it('attach en mode observer', () => { expect(parseClientMessage(JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'observer', cols: 80, rows: 24 }))) - .toEqual({ type: 'attach', sessionId: 's1', mode: 'observer', cols: 80, rows: 24 }); + .toEqual({ type: 'attach', sessionId: 's1', mode: 'observer', cols: 80, rows: 24, screen: true }); + }); + + // `screen` est ADDITIF : absent, il vaut true (un vieux client garde le comportement d'origine). + // À false, l'attache n'affiche rien : ni contrôle du PTY, ni sortie, ni replay. + it('attach avec screen : défaut true, false respecté, valeur non booléenne ignorée', () => { + const attach = (screen?: unknown): unknown => + parseClientMessage(JSON.stringify({ type: 'attach', sessionId: 's1', mode: 'interactive', cols: 80, rows: 24, screen })); + expect(attach(false)).toMatchObject({ screen: false }); + expect(attach(true)).toMatchObject({ screen: true }); + expect(attach()).toMatchObject({ screen: true }); + expect(attach('nope')).toMatchObject({ screen: true }); }); it('detach avec channel aux bornes u32', () => { diff --git a/packages/vscode/src/api/ws-client.ts b/packages/vscode/src/api/ws-client.ts index b2384b4..4760b12 100644 --- a/packages/vscode/src/api/ws-client.ts +++ b/packages/vscode/src/api/ws-client.ts @@ -37,6 +37,8 @@ export interface AttachOptions { cols: number; rows: number; sink: AttachmentSink; + /** false = attache d'écriture seule, sans affichage (cf. le champ `screen` du protocole). */ + screen?: boolean; } /** Transport minimal : abstrait pour les tests ; l'impl par défaut enveloppe `ws`. */ @@ -74,6 +76,7 @@ export class Attachment { readonly sink: AttachmentSink, cols: number, rows: number, + readonly screen = true, ) { this.cols = cols; this.rows = rows; @@ -165,7 +168,7 @@ export class ArbWsClient { /** résout sur le prochain 'attached' corrélé ; rejette si la session est introuvable/terminée */ attach(opts: AttachOptions): Promise { - const att = new Attachment(this, opts.sessionId, opts.mode, opts.sink, opts.cols, opts.rows); + const att = new Attachment(this, opts.sessionId, opts.mode, opts.sink, opts.cols, opts.rows, opts.screen ?? true); const promise = new Promise((resolve, reject) => { att.pending = { resolve, reject }; }); @@ -279,7 +282,7 @@ export class ArbWsClient { private sendAttach(att: Attachment): void { this.awaitingAttached.push(att); - this.sendControl({ type: 'attach', sessionId: att.sessionId, mode: att.mode, cols: att.cols, rows: att.rows }); + this.sendControl({ type: 'attach', sessionId: att.sessionId, mode: att.mode, cols: att.cols, rows: att.rows, screen: att.screen }); } private handleText(text: string): void { diff --git a/packages/vscode/src/commands.ts b/packages/vscode/src/commands.ts index f3d2053..e6f2aba 100644 --- a/packages/vscode/src/commands.ts +++ b/packages/vscode/src/commands.ts @@ -59,7 +59,9 @@ export async function answerSession( return; } const noop: AttachmentSink = { data: () => {}, reset: () => {}, detached: () => {}, controlChanged: () => {} }; - const att = await deps.ws.attach({ sessionId: session.id, mode: 'interactive', cols: 80, rows: 24, sink: noop }); + // `screen: false` : canal de réponse seul, sans affichage. Sinon il volait le `controlling` de la + // session et imposait 80x24 au PTY, ce qui déformait le terminal réellement ouvert ailleurs. + const att = await deps.ws.attach({ sessionId: session.id, mode: 'interactive', cols: 80, rows: 24, sink: noop, screen: false }); att.answer(action, optionN); setTimeout(() => att.detach(), 600); // laisse le serveur traiter avant de fermer le canal éphémère } diff --git a/packages/web/src/App.vue b/packages/web/src/App.vue index 859486e..221b6af 100644 --- a/packages/web/src/App.vue +++ b/packages/web/src/App.vue @@ -13,6 +13,7 @@ import { computed } from 'vue'; import { wsClient } from './lib/ws-client'; import { useRealtimeBootstrap } from './composables/useRealtimeBootstrap'; import { useWatchedWorktrees } from './composables/useWatchedWorktrees'; +import { useTerminalContext } from './composables/useTerminalContext'; import WsBanner from './components/layout/WsBanner.vue'; import ToastContainer from './components/ToastContainer.vue'; import CommandPalette from './components/CommandPalette.vue'; @@ -24,4 +25,6 @@ const wsReconnecting = computed(() => wsClient.status.value === 'reconnecting'); useRealtimeBootstrap(); // Idem pour les abonnements ciblés `watch` : ils doivent survivre au démontage des panneaux. useWatchedWorktrees(); +// Le terminal focalisé impose le worktree actif (propriétaire unique de cette écriture). +useTerminalContext(); diff --git a/packages/web/src/components/CommandPalette.vue b/packages/web/src/components/CommandPalette.vue index 2d3fd42..a2adae4 100644 --- a/packages/web/src/components/CommandPalette.vue +++ b/packages/web/src/components/CommandPalette.vue @@ -53,7 +53,7 @@ diff --git a/packages/web/src/components/ide/AttentionList.vue b/packages/web/src/components/ide/AttentionList.vue index a91a236..d926b04 100644 --- a/packages/web/src/components/ide/AttentionList.vue +++ b/packages/web/src/components/ide/AttentionList.vue @@ -10,7 +10,7 @@ type="button" class="truncate text-left font-mono text-[11px] text-warn/80 transition-colors hover:text-warn" :title="s.cwd" - @click="ide.openTerminal(s.id)" + @click="(e: MouseEvent) => ide.openTerminal(s.id, { split: e.altKey })" > {{ sessionLabel(s, worktrees) }} diff --git a/packages/web/src/components/ide/ChangesView.vue b/packages/web/src/components/ide/ChangesView.vue index cf4b32d..1e449bb 100644 --- a/packages/web/src/components/ide/ChangesView.vue +++ b/packages/web/src/components/ide/ChangesView.vue @@ -4,7 +4,19 @@ {{ t('changes.title') }} {{ t('changes.summary', { n: pendingCount }, pendingCount) }} - + + +