Le cwd d'une session de groupe devient le PARENT COMMUN des répertoires couverts (racine neutre, chemins relatifs cross-repo naturels) au lieu du premier repo arbitraire ; chaque repo reste relié en --add-dir. Garde-fou : si le parent commun est la racine du FS, on retombe sur le premier répertoire. Nouveau module pur resolveGroupCwd (core/group-session.ts). Côté web, un lancement réussi redirige vers le terminal plein écran de la session, avec un toast (succès ou couverture partielle).
189 lines
9.3 KiB
JavaScript
189 lines
9.3 KiB
JavaScript
#!/usr/bin/env node
|
|
// Acceptation P5 (sans navigateur, sans quota Claude) : groupes de travail.
|
|
// Vrai daemon + vrai repo git tmp. Couvre : CRUD groupe via REST, broadcast WS group_update/
|
|
// group_removed sur le topic 'groups', ajout/retrait de repo, et purge CASCADE de la membership
|
|
// quand le repo est supprimé (PRAGMA foreign_keys = ON).
|
|
import { spawn, execFileSync } 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 = 7545;
|
|
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-p5-'));
|
|
function initRepo(path) {
|
|
execFileSync('mkdir', ['-p', path]);
|
|
const git = (...args) => execFileSync('git', args, { cwd: path, stdio: 'pipe' });
|
|
git('init', '-b', 'main');
|
|
git('config', 'user.email', 'test@arboretum.dev');
|
|
git('config', 'user.name', 'Test');
|
|
execFileSync('bash', ['-lc', 'echo "# demo" > README.md'], { cwd: path });
|
|
git('add', '-A');
|
|
git('commit', '-m', 'init');
|
|
}
|
|
const repo = join(tmp, 'demo-repo');
|
|
const repo2 = join(tmp, 'demo-repo-2');
|
|
initRepo(repo);
|
|
initRepo(repo2);
|
|
|
|
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));
|
|
|
|
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 = 8000) => {
|
|
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) } : {}),
|
|
});
|
|
|
|
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', 'worktrees', 'groups'] });
|
|
|
|
// Enregistrement d'un repo (cible de la membership).
|
|
const addRepo = await j('/api/v1/repos', 'POST', cookie, { path: repo });
|
|
const repoSummary = (await addRepo.json()).repo;
|
|
check('POST /repos → 201', addRepo.status === 201 && repoSummary?.valid === true);
|
|
|
|
// Création d'un groupe vide via REST → broadcast WS group_update.
|
|
const created = await j('/api/v1/groups', 'POST', cookie, { label: 'Sprint 42', color: '#4f46e5' });
|
|
const group = (await created.json()).group;
|
|
check('POST /groups → 201', created.status === 201 && group?.label === 'Sprint 42' && group?.repoIds.length === 0);
|
|
const pushedCreate = await c.waitMsg((m) => m.type === 'group_update' && m.group?.id === group.id);
|
|
check('broadcast WS group_update (création)', !!pushedCreate);
|
|
|
|
const list = await (await j('/api/v1/groups', 'GET', cookie)).json();
|
|
check('GET /groups → groupe présent', list.groups?.some((g) => g.id === group.id));
|
|
|
|
// Ajout du repo au groupe → group_update avec repoIds peuplé.
|
|
const added = await j(`/api/v1/groups/${group.id}/repos`, 'POST', cookie, { repoId: repoSummary.id });
|
|
const addedBody = await added.json();
|
|
check('POST /groups/:id/repos → repoIds', added.status === 200 && addedBody.group?.repoIds.includes(repoSummary.id));
|
|
const pushedAdd = await c.waitMsg((m) => m.type === 'group_update' && m.group?.repoIds?.includes(repoSummary.id));
|
|
check('broadcast WS group_update (ajout repo)', !!pushedAdd);
|
|
|
|
// ---- P6 : session de groupe multi-repo (UNE session couvrant tous les repos via --add-dir) ----
|
|
// Enregistre un 2e repo, l'ajoute au groupe, puis lance UNE session de groupe (bash, sans quota).
|
|
const addRepo2 = await j('/api/v1/repos', 'POST', cookie, { path: repo2 });
|
|
const repo2Summary = (await addRepo2.json()).repo;
|
|
check('POST /repos (2e repo) → 201', addRepo2.status === 201 && repo2Summary?.valid === true);
|
|
await j(`/api/v1/groups/${group.id}/repos`, 'POST', cookie, { repoId: repo2Summary.id });
|
|
|
|
// Mode « checkouts principaux » (pas de branch) : couvre le worktree principal de chaque repo.
|
|
const gsRes = await j(`/api/v1/groups/${group.id}/session`, 'POST', cookie, { command: 'bash' });
|
|
const gsBody = await gsRes.json();
|
|
const gsession = gsBody.session;
|
|
check('POST /groups/:id/session → 201', gsRes.status === 201 && !!gsession);
|
|
check('session de groupe : 2 répertoires couverts', Array.isArray(gsBody.dirs) && gsBody.dirs.length === 2);
|
|
// cwd = parent commun des repos (P6), chaque repo relié en --add-dir → 2 addedDirs.
|
|
check('session de groupe : cwd = parent commun des repos', gsession?.cwd === tmp);
|
|
check('session de groupe : addedDirs = les 2 repos', (gsession?.addedDirs?.length ?? 0) === 2);
|
|
check('session de groupe : groupId posé', gsession?.groupId === group.id);
|
|
check('session de groupe : cwd parent + 2 repos addedDirs = 3 chemins distincts', new Set([gsession?.cwd, ...(gsession?.addedDirs ?? [])]).size === 3);
|
|
|
|
// La session apparaît dans la liste globale avec son groupId.
|
|
const sessList = await (await j('/api/v1/sessions', 'GET', cookie)).json();
|
|
const listed = sessList.sessions?.find((s) => s.id === gsession.id);
|
|
check('GET /sessions : session de groupe présente avec groupId', listed?.groupId === group.id);
|
|
|
|
// broadcast WS session_update reçu pour la session de groupe.
|
|
const pushedSession = await c.waitMsg((m) => m.type === 'session_update' && m.session?.id === gsession.id);
|
|
check('broadcast WS session_update (session de groupe)', !!pushedSession);
|
|
|
|
// groupe sans worktree résolu (branche inexistante) → 400.
|
|
const gsBad = await j(`/api/v1/groups/${group.id}/session`, 'POST', cookie, { command: 'bash', branch: 'no/such/branch' });
|
|
check('POST /groups/:id/session branche absente → 400', gsBad.status === 400);
|
|
|
|
// Arrêt de la session de groupe + nettoyage du 2e repo (CASCADE retire repo2 de la membership).
|
|
const killSession = await j(`/api/v1/sessions/${gsession.id}`, 'DELETE', cookie);
|
|
check('DELETE session de groupe → 200', killSession.status === 200);
|
|
await j(`/api/v1/repos/${repo2Summary.id}`, 'DELETE', cookie);
|
|
|
|
// Renommage via PATCH.
|
|
const patched = await j(`/api/v1/groups/${group.id}`, 'PATCH', cookie, { label: 'Renamed' });
|
|
check('PATCH /groups/:id → 200 (renommé)', patched.status === 200 && (await patched.json()).group?.label === 'Renamed');
|
|
|
|
// repoId inexistant → 404.
|
|
const bad = await j(`/api/v1/groups/${group.id}/repos`, 'POST', cookie, { repoId: 'does-not-exist' });
|
|
check('POST repo inexistant → 404', bad.status === 404);
|
|
|
|
// Suppression du repo → CASCADE purge la membership.
|
|
const delRepo = await j(`/api/v1/repos/${repoSummary.id}`, 'DELETE', cookie);
|
|
check('DELETE /repos/:id → 200', delRepo.status === 200);
|
|
await sleep(200);
|
|
const afterCascade = await (await j(`/api/v1/groups/${group.id}`, 'GET', cookie)).json();
|
|
check('CASCADE : membership purgée à la suppression du repo', afterCascade.group?.repoIds.length === 0);
|
|
|
|
// Suppression du groupe → broadcast WS group_removed.
|
|
const delGroup = await j(`/api/v1/groups/${group.id}`, 'DELETE', cookie);
|
|
check('DELETE /groups/:id → 200', delGroup.status === 200);
|
|
const removed = await c.waitMsg((m) => m.type === 'group_removed' && m.groupId === group.id);
|
|
check('broadcast WS group_removed', !!removed);
|
|
const delAgain = await j(`/api/v1/groups/${group.id}`, 'DELETE', cookie);
|
|
check('DELETE groupe inconnu → 404', delAgain.status === 404);
|
|
|
|
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 P5: ALL GREEN' : `\nACCEPTANCE P5: ${failed.length} FAILURE(S)`);
|
|
process.exit(failed.length === 0 ? 0 : 1);
|
|
}
|