Files
arboretum/packages/server/scripts/acceptance-p17.mjs
T
johanleroy 17e95754b1 fix(server, desktop): plus de transcript perdu, et la mise à jour s'applique toute seule
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.
2026-08-05 11:35:51 +02:00

228 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
}