Files
arboretum/packages/server/scripts/acceptance-p10.mjs
Johan LEROY 65ef616867
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / No em/en dashes (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
Deploy site (production) / build-and-deploy (push) Successful in 20s
chore(typo): retire tous les tirets cadratins/demi-cadratins + garde CI
Remplace les 547 tirets cadratins (U+2014) et demi-cadratins (U+2013) des fichiers versionnés par la ponctuation contextuelle adaptée (point médian, deux-points, virgule, parenthèses ; tiret simple pour les plages), sur 122 fichiers (appli, vitrine, doc, tests, workflows, scripts).

Ajoute le job CI « lint-dashes » (git grep -P) qui échoue si un tiret cadratin/demi-cadratin réapparaît, hors logo binaire et captures brutes du terminal (fidélité des fixtures de détection de dialogue).
2026-07-17 16:44:00 +02:00

160 lines
7.7 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 P10 (sans navigateur, sans quota Claude) : archivage automatique des sessions terminées.
// Vrai daemon + faux binaire `claude` (echo+sleep, comme p2) + 2e connexion sqlite (WAL) pour fabriquer
// des sessions managées terminées avec un ended_at ancien. Couvre : sweep archive-now (archived_at posé,
// event WS session_archived), exclusion par défaut + inclusion via ?includeArchived, archive/unarchive
// manuels, rétention=0 (no-op), et, preuve clé « pas de perte », resume d'une session ARCHIVÉE → 201.
import { spawn } from 'node:child_process';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { DatabaseSync } from 'node:sqlite';
const require = createRequire(import.meta.url);
const WebSocket = require('ws');
const PORT = 7551;
const ORIGIN = `http://127.0.0.1:${PORT}`;
const OLD = '2020-01-01T00:00:00.000Z';
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-accept-p10-'));
const claudeHome = join(tmp, 'claude');
const workDir = join(tmp, 'work');
const fakeBin = join(tmp, 'bin');
const dbPath = join(tmp, 'a.db');
mkdirSync(join(claudeHome, 'projects'), { recursive: true });
mkdirSync(join(claudeHome, 'sessions'), { recursive: true });
mkdirSync(workDir, { recursive: true });
mkdirSync(fakeBin, { recursive: true });
writeFileSync(join(fakeBin, 'claude'), '#!/usr/bin/env bash\necho "FAKE-CLAUDE args=[$*] cwd=$(pwd)"\nsleep 30\n');
chmodSync(join(fakeBin, 'claude'), 0o755);
// Insère une session managée « claude » terminée directement en DB (2e connexion WAL).
function insertManagedDeadSession(id, claudeSid, endedAt) {
const db = new DatabaseSync(dbPath);
db.prepare(
'INSERT INTO sessions (id, cwd, command, created_at, ended_at, claude_session_id) VALUES (?, ?, ?, ?, ?, ?)',
).run(id, workDir, 'claude', OLD, endedAt, claudeSid);
db.close();
}
const srv = spawn(
'node',
[join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', dbPath, '--claude-home', claudeHome, '--no-discover'],
{ env: { ...process.env, ARBORETUM_LOG: 'warn', PATH: `${fakeBin}:${process.env.PATH}` }, stdio: ['ignore', 'pipe', 'pipe'] },
);
let srvOut = '';
srv.stdout.on('data', (d) => (srvOut += d));
srv.stderr.on('data', (d) => (srvOut += d));
function wsClient(cookie) {
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } });
const state = { msgs: [] };
ws.on('message', (data, isBinary) => {
if (!isBinary) state.msgs.push(JSON.parse(String(data)));
});
const waitMsg = async (pred, timeout = 6000) => {
const t0 = Date.now();
while (Date.now() - t0 < timeout) {
const m = state.msgs.find(pred);
if (m) return m;
await sleep(50);
}
return null;
};
return { ws, state, waitMsg, send: (m) => ws.send(JSON.stringify(m)) };
}
const j = (path, method, cookie, body) =>
fetch(`${ORIGIN}${path}`, {
method,
headers: { Origin: ORIGIN, Cookie: cookie, ...(body ? { 'Content-Type': 'application/json' } : {}) },
...(body ? { body: JSON.stringify(body) } : {}),
});
const listSessions = async (cookie, q = '') => (await (await j(`/api/v1/sessions${q}`, 'GET', cookie)).json()).sessions ?? [];
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);
const c = wsClient(cookie);
await new Promise((res, rej) => (c.ws.on('open', res), c.ws.on('error', rej)));
c.send({ type: 'hello', protocol: 1 });
await c.waitMsg((m) => m.type === 'hello_ok');
c.send({ type: 'sub', topics: ['sessions'] });
// ---- A. Auto-archivage (sweep via archive-now) ----
insertManagedDeadSession('arch-old', 'sid-old', OLD);
const before = await listSessions(cookie);
check('session terminée ancienne visible par défaut (avant archivage)', before.some((s) => s.id === 'arch-old' && !s.archived));
c.state.msgs.length = 0;
const now = await (await j('/api/v1/sessions/archive-now', 'POST', cookie)).json();
check('POST /sessions/archive-now → ≥ 1 archivée', now.archived >= 1, `archived=${now.archived}`);
const evt = await c.waitMsg((m) => m.type === 'session_archived' && m.sessionId === 'arch-old');
check('event WS session_archived reçu', !!evt);
const def = await listSessions(cookie);
check('GET /sessions exclut larchivée par défaut', !def.some((s) => s.id === 'arch-old'));
const inc = await listSessions(cookie, '?includeArchived=true');
check('GET /sessions?includeArchived=true inclut larchivée (archived=true)', inc.some((s) => s.id === 'arch-old' && s.archived === true));
// ---- B. Preuve « pas de perte » : resume d'une session ARCHIVÉE → 201 ----
const resume = await j('/api/v1/sessions/arch-old/resume', 'POST', cookie);
const resumed = await resume.json();
check('resume dune session archivée → 201 (PTY managé claude)', resume.status === 201 && resumed.session?.command === 'claude');
// ---- C. Archive / unarchive manuels ----
insertManagedDeadSession('manual1', 'sid-man', new Date().toISOString()); // récente → le sweep ne la touche pas
c.state.msgs.length = 0;
const arch = await j('/api/v1/sessions/manual1/archive', 'POST', cookie);
check('POST /sessions/:id/archive → 200', arch.status === 200);
check('archive manuel → event session_archived', !!(await c.waitMsg((m) => m.type === 'session_archived' && m.sessionId === 'manual1')));
check('archive manuel → exclue par défaut', !(await listSessions(cookie)).some((s) => s.id === 'manual1'));
const unarch = await j('/api/v1/sessions/manual1/archive', 'DELETE', cookie);
check('DELETE /sessions/:id/archive → 200', unarch.status === 200);
check('unarchive → ré-affichée par défaut (archived=false)', (await listSessions(cookie)).some((s) => s.id === 'manual1' && !s.archived));
const missing = await j('/api/v1/sessions/does-not-exist/archive', 'POST', cookie);
check('archive dun id inconnu → 404', missing.status === 404);
// ---- D. Rétention = 0 → archivage désactivé (no-op) ----
const patch = await j('/api/v1/settings', 'PATCH', cookie, { retentionDays: 0 });
check('PATCH retentionDays=0 → 200', patch.status === 200);
insertManagedDeadSession('arch-old2', 'sid-old2', OLD);
const now0 = await (await j('/api/v1/sessions/archive-now', 'POST', cookie)).json();
check('rétention=0 → sweep narchive rien', now0.archived === 0);
check('session ancienne reste visible (archivage désactivé)', (await listSessions(cookie)).some((s) => s.id === 'arch-old2' && !s.archived));
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.signalCode === null || srv.exitCode === null);
rmSync(tmp, { recursive: true, force: true });
const failed = results.filter((r) => !r.ok);
console.log(failed.length === 0 ? '\nACCEPTANCE P10: ALL GREEN' : `\nACCEPTANCE P10: ${failed.length} FAILURE(S)`);
process.exit(failed.length === 0 ? 0 : 1);
}