Files
johanleroy ce224fd470 fix(web): les fichiers s'ouvrent enfin, et le travail git par projet au centre
Deux défauts d'usage de la zone centrale de l'IDE.

1. Ouvrir un fichier affichait une page vide. Le conteneur de l'unique instance
   Monaco vivait sous le `v-if="activeTab"` : monté sans onglet actif (premier
   usage, ou après avoir tout fermé), `onMounted` sortait en silence sur
   `!host.value`, `ready` restait faux pour toujours, et les deux watchers
   d'affichage étaient gardés par lui. Le conteneur est désormais toujours monté
   (v-show, jamais v-if : sous un v-if, fermer le dernier onglet détruisait le
   DOM et l'éditeur gardait une référence sur un noeud détaché) et l'éditeur est
   créé paresseusement depuis `showTab`, devenu l'unique entonnoir d'affichage.
   `useMonaco` ne mémoïse plus un échec, l'attente et l'erreur sont visibles, et
   la relecture disque ne se tait plus. Sur mobile, ouvrir un fichier amène enfin
   la zone centrale au premier plan et la valeur morte `mobilePanel = 'sidebar'`
   disparaît (migrée).

   Trouvé par l'E2E : seul l'`editor.worker` était fourni, donc ouvrir un `.ts`
   lançait le service TypeScript contre un worker qui n'en connaissait pas les
   méthodes, et jetait un rejet non géré à chaque ouverture. Les workers de
   langage sont fournis (chunks à la demande), diagnostics sémantiques coupés
   (sans tsconfig ni node_modules, ils inventeraient des erreurs).

2. Le statut git était à l'étroit et mono-projet. La zone centrale porte un mode
   « Fichiers / Changements » (bascule à icônes dans la barre d'onglets, donc
   accessible même sans onglet ouvert). La vue Changements montre un bloc par
   worktree pour TOUS les projets ouverts : fichiers indexés et non indexés, diff
   déplié sur place, commit, push, fetch, pull et historique par bloc. Le panneau
   Git de la barre latérale devient l'index (compteurs déjà diffusés par WS, donc
   zéro requête) et le badge de la barre d'activité, codé à 0, compte enfin les
   worktrees à traiter.

   Au passage : `canPush` était faux dans les deux sens (actif sans rien à
   pousser, bloqué en étant seulement en retard), l'amend d'un message seul est
   accepté comme le serveur le permettait déjà, le rebase est proposé dès qu'on
   est en retard, une confirmation d'abandon ne survit plus au rechargement de la
   liste, et `commit`/`fetch`/`push` émettent enfin `worktree_changes` (le
   compteur « n non poussés » restait périmé juste après un push).

Vérifié : 528 tests, acceptations P7/P9/P14/P15, `verify-ui` (4 captures
nouvelles) et un `verify-editor` qui prouve par clics réels l'ouverture d'un
fichier sans onglet persisté, la fermeture/réouverture, la bascule mobile et le
chunk éditeur absent. Ce dernier échoue bien si l'on remet le conteneur sous un
v-if.
2026-08-04 23:18:40 +02:00

272 lines
14 KiB
JavaScript

#!/usr/bin/env node
// Vérification VISUELLE de la SPA authentifiée, sans Playwright : daemon temporaire isolé + Chromium
// headless piloté en CDP + cookie de session injecté. Produit des captures PNG (thème sombre et clair,
// largeurs desktop et mobile) et échoue si une erreur console / exception Vue survient.
//
// Usage : node packages/server/scripts/verify-ui.mjs [dossier-de-sortie]
// Prérequis : `npm run build` puis `node packages/server/scripts/copy-web.mjs` (le daemon sert la SPA
// depuis packages/server/public, que le build NE rafraîchit PAS).
import { spawn, execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname, resolve as resolvePath } 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 = 7998;
const CDP_PORT = 9333;
const ORIGIN = `http://127.0.0.1:${PORT}`;
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const outDir = resolvePath(process.argv[2] ?? join(serverDir, '..', '..', '.ui-shots'));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const results = [];
const check = (name, ok, detail = '') => {
results.push({ name, ok, detail });
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`);
};
function findChromium() {
for (const bin of ['chromium', 'chromium-browser', 'google-chrome', 'google-chrome-stable']) {
try {
return execFileSync('which', [bin]).toString().trim();
} catch {
/* essai suivant */
}
}
return null;
}
/** Client CDP minimal : un seul socket, corrélation par id, sessionId pour la cible attachée. */
function cdp(url) {
const ws = new WebSocket(url, { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024 });
let nextId = 1;
const pending = new Map();
const events = [];
ws.on('message', (raw) => {
const msg = JSON.parse(String(raw));
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
return;
}
if (msg.method) events.push(msg);
});
const ready = new Promise((res, rej) => (ws.on('open', res), ws.on('error', rej)));
const send = (method, params = {}, sessionId) =>
new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
setTimeout(() => pending.has(id) && (pending.delete(id), reject(new Error(`CDP timeout: ${method}`))), 30_000);
});
return { ws, ready, send, events };
}
const tmp = mkdtempSync(join(tmpdir(), 'arb-verify-ui-'));
mkdirSync(outDir, { recursive: true });
let srv = null;
let browser = null;
try {
// La SPA servie vient de packages/server/public : garde-fou contre la vérification d'un ancien build.
const publicIndex = join(serverDir, 'public', 'index.html');
check('SPA copiée dans packages/server/public', existsSync(publicIndex), publicIndex);
// --- dépôt de démonstration : un checkout principal, un worktree de feature, du travail en cours ---
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');
mkdirSync(join(repo, 'src'), { recursive: true });
writeFileSync(join(repo, 'src', 'app.ts'), 'export const version = 1\n');
git('add', '-A');
git('commit', '-m', 'commit initial');
writeFileSync(join(repo, 'src', 'app.ts'), 'export const version = 2\n');
srv = spawn(
'node',
[join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 't.db'), '--claude-home', join(tmp, 'claude'), '--no-discover'],
{ env: { ...process.env, XDG_DATA_HOME: join(tmp, 'xdg'), ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] },
);
let srvOut = '';
srv.stdout.on('data', (d) => (srvOut += d));
srv.stderr.on('data', (d) => (srvOut += d));
for (let i = 0; i < 60 && !/arb_[0-9a-f]{16,}/.test(srvOut); i++) await sleep(150);
const token = /arb_[0-9a-f]{16,}/.exec(srvOut)?.[0];
check('daemon temporaire démarré + token', !!token);
const login = await fetch(`${ORIGIN}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: ORIGIN },
body: JSON.stringify({ token }),
});
const setCookie = login.headers.getSetCookie?.() ?? [];
const sessionCookie = setCookie.map((c) => c.split(';')[0]).find((c) => c.startsWith('arb_session='));
check('login → cookie de session', !!sessionCookie);
const cookieValue = sessionCookie?.slice('arb_session='.length) ?? '';
const j = (path, method, body) =>
fetch(`${ORIGIN}${path}`, {
method,
headers: { Origin: ORIGIN, Cookie: sessionCookie ?? '', ...(body ? { 'Content-Type': 'application/json' } : {}) },
...(body ? { body: JSON.stringify(body) } : {}),
});
const repoId = (await (await j('/api/v1/repos', 'POST', { path: repo })).json()).repo?.id;
check('dépôt de démonstration enregistré', !!repoId);
const wtRes = await (await j(`/api/v1/repos/${repoId}/worktrees`, 'POST', { branch: 'feature/demo', runHooks: false })).json();
check('worktree de feature créé', !!wtRes.worktree?.path);
// du travail non commité dans le worktree de feature, pour peupler les compteurs git de l'arbre
if (wtRes.worktree?.path) writeFileSync(join(wtRes.worktree.path, 'wip.txt'), 'travail en cours\n');
const groupRes = await (await j('/api/v1/groups', 'POST', { label: 'Démo', color: '#34d399', repoIds: [repoId] })).json();
check('groupe de démonstration créé', !!groupRes.group?.id);
const sess = await (await j('/api/v1/sessions', 'POST', { cwd: repo, command: 'bash' })).json();
check('session bash de démonstration', !!sess.session?.id);
// Un fichier indexé et un non indexé dans le checkout principal : la vue Changements doit montrer
// ses DEUX sections (« Staged » et « Changes »), sinon la capture ne prouve que la moitié.
writeFileSync(join(repo, 'notes.md'), 'brouillon\n');
const staged = await j(`/api/v1/repos/${repoId}/worktrees/stage`, 'POST', { path: repo, files: ['src/app.ts'] });
check('un fichier indexé pour la démonstration', staged.ok);
// --- Chromium headless en CDP ---
const chromeBin = findChromium();
check('Chromium disponible', !!chromeBin, chromeBin ?? 'introuvable');
if (!chromeBin) throw new Error('Chromium introuvable : impossible de vérifier le rendu');
browser = spawn(
chromeBin,
[
'--headless=new',
`--remote-debugging-port=${CDP_PORT}`,
`--user-data-dir=${join(tmp, 'chrome')}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-gpu',
'--hide-scrollbars',
],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
let wsUrl = null;
for (let i = 0; i < 80 && !wsUrl; i++) {
await sleep(200);
try {
wsUrl = (await (await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`)).json()).webSocketDebuggerUrl;
} catch {
/* pas encore prêt */
}
}
check('Chromium en écoute CDP', !!wsUrl);
const client = cdp(wsUrl);
await client.ready;
// État de vue injecté avant le premier paint : on veut des captures qui MONTRENT le contenu
// (arbre déplié, worktree actif), pas un IDE vide.
const expanded = JSON.stringify(JSON.stringify([repoId]));
const context = JSON.stringify(JSON.stringify({ repoId, wtPath: repo }));
const seedExplorer = `localStorage.setItem('arb.ide.expandedRepos', ${expanded});localStorage.setItem('arb.ide.context', ${context});`;
// Index Git de la sidebar : compteurs par projet, sans liste de fichiers (celle-ci vit au centre).
const seedGit = `${seedExplorer}localStorage.setItem('arb.ide.activity', '"git"');`;
// Vue Changements de la zone centrale : blocs dépliés du checkout principal ET du worktree de
// feature, plus l'historique du premier (sa clé de repli est désormais PAR worktree).
const changesKeys = JSON.stringify(
JSON.stringify([`${repoId}\0${repo}`, ...(wtRes.worktree?.path ? [`${repoId}\0${wtRes.worktree.path}`] : [])]),
);
const seedChanges =
`${seedExplorer}localStorage.setItem('arb.ide.centerMode', '"changes"');` +
`localStorage.setItem('arb.ide.changesExpanded', ${changesKeys});` +
`localStorage.setItem(${JSON.stringify(`arb.history.open:${repoId}\0${repo}`)}, 'true');`;
const seedChangesMobile = `${seedChanges}localStorage.setItem('arb.ide.mobilePanel', '"changes"');`;
// Zone d'édition : un onglet persisté, jamais capturé jusqu'ici.
const editorTabId = `${repoId}\0${repo}\0src/app.ts`;
const seedEditor =
`${seedExplorer}localStorage.setItem('arb.ide.tabs', ${JSON.stringify(
JSON.stringify([{ id: editorTabId, repoId, wtPath: repo, file: 'src/app.ts', view: 'editor' }]),
)});localStorage.setItem('arb.ide.activeTab', ${JSON.stringify(JSON.stringify(editorTabId))});`;
// Panneau Groupes avec le groupe ET le worktree dépliés : c'est la vue qui porte l'arborescence de
// fichiers des membres du groupe, sinon jamais capturée.
const seedGroups =
`${seedExplorer}localStorage.setItem('arb.ide.activity', '"groups"');` +
`localStorage.setItem('arb.ide.expandedGroups', ${JSON.stringify(JSON.stringify([groupRes.group?.id]))});` +
`localStorage.setItem('arb.ide.expandedWts', ${JSON.stringify(JSON.stringify([repo]))});`;
// `expect` : une capture non vide ne prouve pas que le bon composant s'est rendu. On exige donc un
// fragment de texte propre à la surface visée.
const shots = [
{ name: 'ide-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedExplorer },
{ name: 'ide-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedExplorer },
{ name: 'git-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedGit, expect: 'feature/demo' },
{ name: 'git-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedGit, expect: 'feature/demo' },
{ name: 'changes-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedChanges, expect: 'wip.txt' },
{ name: 'changes-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedChanges, expect: 'wip.txt' },
{ name: 'changes-dark-mobile', theme: 'dark', width: 390, height: 844, seed: seedChangesMobile, expect: 'wip.txt' },
{ name: 'editor-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedEditor, expect: 'src/app.ts' },
{ name: 'groups-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedGroups },
{ name: 'ide-dark-mobile', theme: 'dark', width: 390, height: 844, seed: seedExplorer },
{ name: 'ide-light-mobile', theme: 'light', width: 390, height: 844, seed: seedExplorer },
{ name: 'dashboard-dark-mobile', theme: 'dark', width: 390, height: 844, path: '/dashboard' },
];
for (const shot of shots) {
const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' });
const { sessionId } = await client.send('Target.attachToTarget', { targetId, flatten: true });
await client.send('Runtime.enable', {}, sessionId);
await client.send('Log.enable', {}, sessionId);
await client.send('Network.enable', {}, sessionId);
await client.send('Emulation.setDeviceMetricsOverride', { width: shot.width, height: shot.height, deviceScaleFactor: 1, mobile: shot.width < 500 }, sessionId);
await client.send('Network.setCookie', { name: 'arb_session', value: cookieValue, domain: '127.0.0.1', path: '/', httpOnly: true }, sessionId);
// Thème : la SPA lit `arb.theme` avant le premier paint (script anti-FOUC).
await client.send('Page.enable', {}, sessionId);
// `localStorage.clear()` d'abord : les cibles d'un même profil Chromium le PARTAGENT, donc sans
// ça une capture héritait de l'état de vue de la précédente (une capture de la zone d'édition
// montrait la vue Changements laissée par celle d'avant).
await client.send(
'Page.addScriptToEvaluateOnNewDocument',
{ source: `localStorage.clear();localStorage.setItem('arb.theme', ${JSON.stringify(JSON.stringify(shot.theme))});${shot.seed ?? ''}` },
sessionId,
);
const before = client.events.length;
await client.send('Page.navigate', { url: `${ORIGIN}${shot.path ?? '/ide'}` }, sessionId);
await sleep(3500); // laisse le temps au bootstrap REST + WS et au rendu
const text = await client.send('Runtime.evaluate', { expression: 'document.body.innerText', returnByValue: true }, sessionId);
const rendered = String(text.result?.value ?? '');
check(`${shot.name} : page rendue`, rendered.length > 20, `${rendered.length} caractères`);
if (shot.expect) check(`${shot.name} : contenu attendu`, rendered.includes(shot.expect), shot.expect);
const errs = client.events
.slice(before)
.filter((e) => e.sessionId === sessionId)
.filter((e) => (e.method === 'Runtime.consoleAPICalled' && e.params?.type === 'error') || e.method === 'Runtime.exceptionThrown')
.map((e) => e.params?.exceptionDetails?.text ?? (e.params?.args ?? []).map((a) => a.value ?? a.description).join(' '))
// Les erreurs réseau des favicons/manifest en headless ne concernent pas l'app.
.filter((m) => m && !/favicon|manifest\.webmanifest/i.test(m));
check(`${shot.name} : aucune erreur console`, errs.length === 0, errs.slice(0, 3).join(' | '));
const { data } = await client.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }, sessionId);
const file = join(outDir, `${shot.name}.png`);
writeFileSync(file, Buffer.from(data, 'base64'));
check(`${shot.name} : capture écrite`, true, file);
await client.send('Target.closeTarget', { targetId });
}
client.ws.close();
} catch (err) {
check('exception', false, String(err));
} finally {
browser?.kill('SIGTERM');
srv?.kill('SIGTERM');
await sleep(1200);
rmSync(tmp, { recursive: true, force: true });
const failed = results.filter((r) => !r.ok);
console.log(failed.length === 0 ? `\nVERIFY UI: ALL GREEN (captures dans ${outDir})` : `\nVERIFY UI: ${failed.length} FAILURE(S)`);
process.exit(failed.length === 0 ? 0 : 1);
}