Deux défauts vécus sur le poste, tous deux « invisibles » jusqu'à ce qu'on regarde. 1. Une console Claude ouverte depuis l'app affichait « Transcript saving is off, inherited CLAUDE_CODE_CHILD_SESSION marker ». Le daemon avait été lancé depuis une session Claude Code, il héritait donc de ses marqueurs d'exécution et les repassait à CHAQUE session qu'il lance. Le CLI se croyait sous-session et coupait la sauvegarde de son transcript : plus d'historique, plus de --resume, claudeSessionId restant null (et avec lui l'état fin busy/waiting/idle, ce qui explique les sessions sans activité détectée). L'environnement des PTY est desormais assaini de ces marqueurs, pour `claude` comme pour les shells (un `claude` tapé à la main en héritait aussi). La configuration légitime de l'utilisateur (CLAUDE_CONFIG_DIR, ANTHROPIC_*, proxies) passe intacte. Vérifié par acceptance-p17 : le daemon de test est lancé avec un environnement volontairement pollué, et le PTY n'en voit plus rien. 2. Une mise à jour installée à chaud demandait encore une manipulation. La 0.2.4 détectait le remplacement du binaire et proposait un dialogue « Restart now » : le travail restait à la charge de l'utilisateur. L'app redémarre maintenant d'elle-même quand cela ne coûte rien, c'est-à-dire le cas courant, et ne demande que s'il y a quelque chose à perdre : des sessions vivantes à interrompre (le dialogue dit combien) ou un daemon injoignable. Un « Later » reste définitif pour cette version : rien ne redémarre dans le dos de personne. La détection ne dépend plus d'un retour par le tray ou le Dock : un `stat` toutes les 30 s la couvre même fenêtre ouverte, par poll et non par `fs.watch`, qui ne voit souvent rien quand un paquet remplace un binaire ou tout un répertoire.
228 lines
12 KiB
JavaScript
228 lines
12 KiB
JavaScript
#!/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-'));
|
||
// Daemon lancé avec un environnement POLLUÉ, exactement comme lorsqu'il est démarré depuis une
|
||
// session Claude Code (cas vécu : l'app de bureau lancée par un agent). Ces marqueurs ne doivent
|
||
// JAMAIS atteindre les sessions qu'il lance, sinon le CLI se croit sous-session et coupe la
|
||
// sauvegarde de son transcript (plus d'historique, plus de --resume).
|
||
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',
|
||
CLAUDECODE: '1',
|
||
CLAUDE_CODE_CHILD_SESSION: '1',
|
||
CLAUDE_CODE_SESSION_ID: 'parent-session-id',
|
||
CLAUDE_PID: '424242',
|
||
ARB_MARQUEUR_LEGITIME: 'conserve-moi',
|
||
},
|
||
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. L'environnement du PTY est assaini des marqueurs de la session parente ---
|
||
// Le nom du marqueur est CONCATÉNÉ dans la commande ('EN' + 'V:') pour que l'écho local du terminal
|
||
// ne ressemble pas au résultat : sinon on relit sa propre frappe et le test passe toujours.
|
||
c2.send({ type: 'stdin', channel: att2.channel, data: 'echo "EN""V:[$CLAUDECODE][$CLAUDE_CODE_CHILD_SESSION][$CLAUDE_CODE_SESSION_ID][$CLAUDE_PID]"\r' });
|
||
await sleep(900);
|
||
const envLine = /ENV:\[[^\]]*\]\[[^\]]*\]\[[^\]]*\]\[[^\]]*\]/.exec(c2.outputOf(att2.channel).replace(/\r?\n/g, ''))?.[0] ?? '';
|
||
check('les marqueurs de session parente ne sont pas transmis au PTY', envLine === 'ENV:[][][][]', envLine || 'non observé');
|
||
|
||
c2.send({ type: 'stdin', channel: att2.channel, data: 'echo "GARDE:[$ARB_MARQUEUR_LEGITIME]"\r' });
|
||
await sleep(900);
|
||
check(
|
||
'le reste de l’environnement est bien transmis',
|
||
c2.outputOf(att2.channel).includes('GARDE:[conserve-moi]'),
|
||
);
|
||
|
||
// --- 7. 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);
|
||
}
|