release: git-arboretum 3.3.0 (« Démarrer le projet » : lancement multi-terminaux), vscode 0.4.0, desktop 0.1.3
All checks were successful
CI / Build & test (Node 22) (push) Successful in 10m17s
CI / Build & test (Node 24) (push) Successful in 10m13s
CI / No em/en dashes (push) Successful in 4s
Deploy site (production) / build-and-deploy (push) Successful in 24s
Release / Publish to Gitea npm registry (push) Successful in 10m17s
VSCode Release / Package VSIX (push) Successful in 9m39s
Desktop Release / Build Linux (AppImage + deb) (push) Successful in 15m41s
CI / Pack & boot smoke (Node 22) (push) Successful in 10m3s
All checks were successful
CI / Build & test (Node 22) (push) Successful in 10m17s
CI / Build & test (Node 24) (push) Successful in 10m13s
CI / No em/en dashes (push) Successful in 4s
Deploy site (production) / build-and-deploy (push) Successful in 24s
Release / Publish to Gitea npm registry (push) Successful in 10m17s
VSCode Release / Package VSIX (push) Successful in 9m39s
Desktop Release / Build Linux (AppImage + deb) (push) Successful in 15m41s
CI / Pack & boot smoke (Node 22) (push) Successful in 10m3s
« Démarrer le projet » : un repo définit une fois ses commandes de démarrage (serveur de dev, API, base de données), un clic ouvre un terminal PTY par commande dans le dock IDE. Serveur (additif, PROTOCOL_VERSION inchangé) : - LaunchCommand[] persistées sur repos.launch_commands (migration 13) ; champ additif SessionSummary.launchRunId. - POST /repos/:id/launch : résolution du worktree côté serveur, cwd de commande borné (anti-traversal), commandIds outrepasse enabled. - GET /repos/:id/launch/detect : détection package.json / Procfile / docker-compose. - Shell de login interactif ($SHELL -l -i, charge le PATH nvm/asdf) + auto-type de la commande ; le shell survit à la commande (échec visible). Web : LaunchProjectModal + actions (ProjectTreeNode, SessionsPanel, CommandPalette), stores sessions/worktrees, i18n EN/FR. Alignement du reste du projet : - Extension VS Code 0.4.0 : commande Start Project (repo/worktree), Stop Launch, badge « launch » dans l'arbre, méthode REST startLaunch. - Site vitrine : 16e feature card (Rocket) + section showcase « Start the project » (mockup fidèle au modal), i18n EN/FR. - Documentation : README (EN + FR), help-content (EN + FR), CHANGELOGs server + vscode. Vérifié : 430 tests, typecheck, build (web + site + vscode), acceptance-p13 ALL GREEN, VSIX packagé, garde anti-tirets, vérif visuelle du site (thèmes clair et sombre).
This commit is contained in:
202
packages/server/scripts/acceptance-p13.mjs
Normal file
202
packages/server/scripts/acceptance-p13.mjs
Normal file
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env node
|
||||
// Acceptation P13 (sans navigateur, sans quota Claude) : « Démarrer le projet » (lancement
|
||||
// multi-terminaux). Vrai daemon + vrai repo git tmp + vrai client WS. Couvre : exposition/persistance
|
||||
// de launch_commands (+ broadcast repo_update), auto-détection (package.json/Procfile/compose),
|
||||
// lancement d'un terminal par commande activée (même launchRunId, command bash, titre = label),
|
||||
// auto-type réellement exécuté (marqueur dans le ring) dans un shell INTERACTIF (survit à la commande),
|
||||
// bornage anti-traversal du cwd + sous-dossier valide, sélection par commandIds, filtrage des désactivées,
|
||||
// et « tout arrêter ».
|
||||
import { spawn, execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } 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 = 7553;
|
||||
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-p13-'));
|
||||
const repo = join(tmp, 'demo-repo');
|
||||
mkdirSync(repo, { recursive: true });
|
||||
mkdirSync(join(repo, 'sub'), { 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');
|
||||
// Fichiers pour l'auto-détection.
|
||||
writeFileSync(join(repo, 'package.json'), JSON.stringify({ scripts: { dev: 'echo dev', build: 'echo build', test: 'echo test' } }));
|
||||
writeFileSync(join(repo, 'Procfile'), 'web: echo procweb\n');
|
||||
writeFileSync(join(repo, 'docker-compose.yml'), 'services: {}\n');
|
||||
writeFileSync(join(repo, 'README.md'), '# demo\n');
|
||||
git('add', '-A');
|
||||
git('commit', '-m', 'init');
|
||||
|
||||
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));
|
||||
|
||||
// Client WS multiplexé (contrôle JSON + sortie binaire → ring décodé en latin1).
|
||||
function wsClient(cookie) {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } });
|
||||
ws.binaryType = 'arraybuffer';
|
||||
const state = { msgs: [], outputs: new Map() };
|
||||
ws.on('message', (data, isBinary) => {
|
||||
if (!isBinary) {
|
||||
state.msgs.push(JSON.parse(String(data)));
|
||||
return;
|
||||
}
|
||||
const buf = Buffer.from(data);
|
||||
const type = buf.readUInt8(0);
|
||||
const channel = buf.readUInt32LE(1);
|
||||
const payload = buf.subarray(5);
|
||||
if (type === 0x02) state.outputs.set(channel, payload.toString('latin1'));
|
||||
else state.outputs.set(channel, ((state.outputs.get(channel) ?? '') + payload.toString('latin1')).slice(-200000));
|
||||
});
|
||||
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 && cookie.startsWith('arb_session='));
|
||||
|
||||
// Enregistre le repo.
|
||||
const reg = await j('/api/v1/repos', 'POST', cookie, { path: repo });
|
||||
const repoId = (await reg.json()).repo?.id;
|
||||
check('register repo', reg.status === 201 && !!repoId);
|
||||
|
||||
// launchCommands vide par défaut.
|
||||
const list0 = await (await j('/api/v1/repos', 'GET', cookie)).json();
|
||||
const r0 = list0.repos.find((r) => r.id === repoId);
|
||||
check('launchCommands défaut = []', Array.isArray(r0?.launchCommands) && r0.launchCommands.length === 0);
|
||||
|
||||
// Auto-détection.
|
||||
const det = await (await j(`/api/v1/repos/${repoId}/launch/detect`, 'GET', cookie)).json();
|
||||
const runs = (det.suggestions ?? []).map((s) => s.run);
|
||||
check(
|
||||
'detect : package.json + Procfile + docker-compose',
|
||||
runs.includes('npm run dev') && runs.includes('npm run build') && runs.some((r) => r.startsWith('echo procweb')) && runs.includes('docker compose up'),
|
||||
`${runs.length} suggestions`,
|
||||
);
|
||||
check('detect : dev activé, build désactivé (heuristique)', (det.suggestions.find((s) => s.run === 'npm run dev')?.enabled === true) && (det.suggestions.find((s) => s.run === 'npm run build')?.enabled === false));
|
||||
|
||||
// Abonnement worktrees pour vérifier le broadcast repo_update.
|
||||
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: 'sub', topics: ['worktrees', 'sessions'] });
|
||||
await sleep(200);
|
||||
|
||||
const nonce = String(token).slice(-6);
|
||||
const commands = [
|
||||
{ id: 'c-web', label: 'web', run: `echo ARB_LAUNCH_OK_${nonce}; echo ARB_FLAGS_$-; sleep 30`, enabled: true },
|
||||
{ id: 'c-api', label: 'api', run: 'sleep 30', enabled: true },
|
||||
{ id: 'c-build', label: 'build', run: 'echo SHOULD_NOT_RUN', enabled: false },
|
||||
{ id: 'c-sub', label: 'sub', run: 'pwd; sleep 30', cwd: 'sub', enabled: false },
|
||||
{ id: 'c-esc', label: 'esc', run: 'pwd', cwd: '../../etc', enabled: false },
|
||||
];
|
||||
const patch = await j(`/api/v1/repos/${repoId}`, 'PATCH', cookie, { launchCommands: commands });
|
||||
const patched = (await patch.json()).repo;
|
||||
check('PATCH launchCommands persiste', patch.status === 200 && patched.launchCommands.length === 5);
|
||||
const evt = await c1.waitMsg((m) => m.type === 'repo_update' && m.repo?.id === repoId && (m.repo.launchCommands?.length ?? 0) === 5);
|
||||
check('broadcast repo_update porte launchCommands', !!evt);
|
||||
|
||||
// Lancement par défaut (commandes activées : web, api).
|
||||
const launch = await j(`/api/v1/repos/${repoId}/launch`, 'POST', cookie, {});
|
||||
const lr = await launch.json();
|
||||
check('POST /launch → N sessions (activées only)', launch.status === 201 && lr.sessions?.length === 2, `${lr.sessions?.length} sessions`);
|
||||
const runIds = new Set(lr.sessions.map((s) => s.launchRunId));
|
||||
check('même launchRunId sur tous les terminaux', runIds.size === 1 && [...runIds][0], [...runIds][0]);
|
||||
check('command = bash + titre = label', lr.sessions.every((s) => s.command === 'bash') && lr.sessions.map((s) => s.title).sort().join(',') === 'api,web');
|
||||
const launchRunId = [...runIds][0];
|
||||
|
||||
// Attache au terminal « web » → l'auto-type a été exécuté (marqueur) dans un shell INTERACTIF.
|
||||
const webSid = lr.sessions.find((s) => s.title === 'web').id;
|
||||
c1.send({ type: 'attach', sessionId: webSid, mode: 'interactive', cols: 120, rows: 32 });
|
||||
const att = await c1.waitMsg((m) => m.type === 'attached' && m.sessionId === webSid);
|
||||
await sleep(1200);
|
||||
const out = c1.state.outputs.get(att.channel) ?? '';
|
||||
check('auto-type exécuté (marqueur dans le ring)', out.includes(`ARB_LAUNCH_OK_${nonce}`), `${out.length} o`);
|
||||
check('shell interactif (flags $- contiennent i)', /ARB_FLAGS_[a-zA-Z]*i/.test(out));
|
||||
|
||||
// Le shell survit à la commande (sleep encore vivant).
|
||||
const sess1 = await (await j('/api/v1/sessions', 'GET', cookie)).json();
|
||||
const apiSid = lr.sessions.find((s) => s.title === 'api').id;
|
||||
check('shell survivant (session live)', sess1.sessions.find((s) => s.id === apiSid)?.live === true);
|
||||
|
||||
// Sélection par commandIds (web seul).
|
||||
const one = await (await j(`/api/v1/repos/${repoId}/launch`, 'POST', cookie, { commandIds: ['c-web'] })).json();
|
||||
check('commandIds : sous-ensemble', one.sessions?.length === 1 && one.sessions[0].title === 'web');
|
||||
|
||||
// Sous-dossier valide : pwd sous le worktree.
|
||||
const subRes = await (await j(`/api/v1/repos/${repoId}/launch`, 'POST', cookie, { commandIds: ['c-sub'] })).json();
|
||||
const subSid = subRes.sessions?.[0]?.id;
|
||||
c1.send({ type: 'attach', sessionId: subSid, mode: 'interactive', cols: 120, rows: 32 });
|
||||
const attSub = await c1.waitMsg((m) => m.type === 'attached' && m.sessionId === subSid);
|
||||
await sleep(800);
|
||||
check('cwd sous-dossier borné (pwd dans /sub)', (c1.state.outputs.get(attSub.channel) ?? '').includes('/sub'));
|
||||
|
||||
// Traversal rejeté : cwd ../../etc.
|
||||
const esc = await j(`/api/v1/repos/${repoId}/launch`, 'POST', cookie, { commandIds: ['c-esc'] });
|
||||
check('cwd traversal rejeté (4xx)', esc.status >= 400 && esc.status < 500, `status ${esc.status}`);
|
||||
|
||||
// « Tout arrêter » : kill de tous les terminaux du launchRunId initial.
|
||||
const before = (await (await j('/api/v1/sessions', 'GET', cookie)).json()).sessions.filter((s) => s.launchRunId === launchRunId);
|
||||
for (const s of before) await j(`/api/v1/sessions/${s.id}`, 'DELETE', cookie);
|
||||
// Un shell interactif ignore SIGTERM (standard) → mort garantie au SIGKILL après le délai de grâce (~5 s).
|
||||
await sleep(6500);
|
||||
const after = (await (await j('/api/v1/sessions', 'GET', cookie)).json()).sessions.filter((s) => s.launchRunId === launchRunId);
|
||||
check('tout arrêter → terminaux non vivants', before.length === 2 && after.length === 2 && after.every((s) => !s.live));
|
||||
|
||||
c1.ws.close();
|
||||
} catch (err) {
|
||||
check('exception', false, String(err && err.stack ? err.stack : err));
|
||||
} finally {
|
||||
srv.kill('SIGTERM');
|
||||
await sleep(1500);
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
console.log(failed.length === 0 ? '\nACCEPTANCE P13: ALL GREEN' : `\nACCEPTANCE P13: ${failed.length} FAILURE(S)`);
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
}
|
||||
Reference in New Issue
Block a user