#!/usr/bin/env node // Acceptation P15 (sans navigateur, sans quota Claude) : historisation. Vrai daemon + vrai repo git // tmp. Couvre GET /worktrees/log (ordre, champs, limit/skip, marquage non poussé) et la forme // `diff?commit=` (diff unifié complet d'un commit, hash invalide et inconnu rejetés). import { spawn, execFileSync } from 'node:child_process'; import { mkdtempSync, rmSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const PORT = 7555; 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-accept-p15-')); const repo = join(tmp, 'demo-repo'); mkdirSync(repo, { recursive: true }); const git = (...args) => execFileSync('git', args, { cwd: repo, stdio: 'pipe' }); git('init', '-b', 'main'); git('config', 'user.email', 'test@arboretum.dev'); git('config', 'user.name', 'Test'); writeFileSync(join(repo, 'README.md'), '# demo\n'); git('add', '-A'); git('commit', '-m', 'init'); // un sujet contenant un guillemet et un caractère accentué : piège classique de parsing appendFileSync(join(repo, 'README.md'), 'deuxième ligne\n'); git('commit', '-am', 'ajoute la « deuxième » ligne'); writeFileSync(join(repo, 'feature.txt'), 'contenu de la feature\n'); git('add', '-A'); git('commit', '-m', 'ajoute feature.txt'); const srv = spawn( 'node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude'), '--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)); 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) } : {}), }); 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 addRepo = await j('/api/v1/repos', 'POST', cookie, { path: repo }); const repoId = (await addRepo.json()).repo.id; check('POST /repos → 201', addRepo.status === 201 && !!repoId); const enc = encodeURIComponent(repo); // ---- GET /log : ordre, champs, sujet non trivial ---- const log = await (await j(`/api/v1/repos/${repoId}/worktrees/log?path=${enc}`, 'GET', cookie)).json(); const subjects = (log.commits ?? []).map((c) => c.subject); check('GET /log : 3 commits, du plus récent au plus ancien', subjects.length === 3 && subjects[0] === 'ajoute feature.txt' && subjects[2] === 'init'); check('GET /log : sujet accentué et guillemets préservés', subjects[1] === 'ajoute la « deuxième » ligne'); const head = log.commits?.[0]; check('GET /log : champs hash/shortHash/auteur/date remplis', /^[0-9a-f]{40}$/.test(head?.hash ?? '') && (head?.shortHash?.length ?? 0) >= 7 && head?.author === 'Test' && !Number.isNaN(Date.parse(head?.date ?? ''))); check('GET /log : branche locale sans remote → hasUpstream=false', log.hasUpstream === false && log.unpushedCount === 0); // ---- limit / skip ---- const page = await (await j(`/api/v1/repos/${repoId}/worktrees/log?path=${enc}&limit=1&skip=1`, 'GET', cookie)).json(); check('GET /log : limit + skip bornent la fenêtre', page.commits?.length === 1 && page.commits[0].subject === 'ajoute la « deuxième » ligne'); const bad = await j(`/api/v1/repos/${repoId}/worktrees/log?path=${enc}&limit=abc`, 'GET', cookie); check('GET /log : limit non numérique → 400', bad.status === 400); const noPath = await j(`/api/v1/repos/${repoId}/worktrees/log`, 'GET', cookie); check('GET /log : path manquant → 400', noPath.status === 400); // ---- diff d'un commit ---- const cd = await (await j(`/api/v1/repos/${repoId}/worktrees/diff?path=${enc}&commit=${head.hash}`, 'GET', cookie)).json(); check('GET /diff?commit= : diff unifié du commit', typeof cd.diff === 'string' && cd.diff.includes('feature.txt') && cd.diff.includes('+contenu de la feature')); check('GET /diff?commit= : ni binaire ni tronqué', cd.binary === false && cd.tooLarge === false); const shortHash = await (await j(`/api/v1/repos/${repoId}/worktrees/diff?path=${enc}&commit=${head.shortHash}`, 'GET', cookie)).json(); check('GET /diff?commit= : hash court accepté', typeof shortHash.diff === 'string' && shortHash.diff.includes('feature.txt')); const invalid = await j(`/api/v1/repos/${repoId}/worktrees/diff?path=${enc}&commit=${encodeURIComponent('--upload-pack=x')}`, 'GET', cookie); check('GET /diff?commit= : révision non hexadécimale refusée', invalid.status === 400); const unknown = await j(`/api/v1/repos/${repoId}/worktrees/diff?path=${enc}&commit=deadbeef`, 'GET', cookie); check('GET /diff?commit= : commit inconnu → 404', unknown.status === 404); const neither = await j(`/api/v1/repos/${repoId}/worktrees/diff?path=${enc}`, 'GET', cookie); check('GET /diff : ni file ni commit → 400', neither.status === 400); // ---- la forme fichier reste intacte (non-régression P7/P9) ---- appendFileSync(join(repo, 'README.md'), 'travail en cours\n'); const fileDiff = await (await j(`/api/v1/repos/${repoId}/worktrees/diff?path=${enc}&file=README.md`, 'GET', cookie)).json(); check('GET /diff?file= : toujours fonctionnel', typeof fileDiff.diff === 'string' && fileDiff.diff.includes('+travail en cours')); } 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 P15: ALL GREEN' : `\nACCEPTANCE P15: ${failed.length} FAILURE(S)`); process.exit(failed.length === 0 ? 0 : 1); }