#!/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); }