fix(server, web, vscode): les terminaux ne restent plus noirs, panes côte à côte, git scopé au terminal
Un terminal pouvait rester tout noir alors que sa session tournait. Le PTY était vivant et avait bien écrit sa sortie : la panne était dans le transport. Le replay d'attache est une frame BINAIRE, mais un client n'apprend son numéro de canal qu'avec le message `attached` ; le serveur envoyait le replay AVANT, donc tout client le jetait sur un canal inconnu. Rien n'était peint, et un TUI au repos (Claude à son prompt) ne réémet jamais rien de lui-même. `attach()` renvoie désormais le replay et la gateway l'émet APRÈS `attached` : un seul correctif serveur répare le web, l'app de bureau et l'extension VS Code, qui portaient le même défaut client. Le resize de l'attache masquait le bug en provoquant un SIGWINCH, d'où son apparence intermittente. Seconde moitié du symptôme (« je tape et rien ne se passe ») : le dock montait avant la liste des sessions, en déduisait « non attachable » et s'attachait en observateur, à vie et en silence. Un pane n'attache plus avant de connaître sa session (`sessions.loaded`). Attaches sans écran : le message `attach` accepte un `screen` optionnel (défaut true). Un client qui n'affiche rien et veut seulement répondre à un dialogue ne prend plus le contrôle de la session, ne lui impose plus ses dimensions (ce qui figeait la géométrie du vrai terminal) et ne reçoit plus le flux pour le jeter. Rendre les pannes visibles : la raison d'un exit est écrite dans le terminal (`[arboretum] bash exited with code 3`) avant le détachement ; un repaint est forcé si rien n'arrive 1,2 s après l'attache, puis annoncé avec « Refresh screen » ; les refus de canal remontent à l'écran au lieu d'un console.warn ; le chemin du CLI claude est revalidé (périmé après une bascule nvm/asdf, le PTY mourait sans un octet). Colonnes de terminaux : le dock devient une rangée de colonnes redimensionnables (3 max), chacune avec ses onglets. Algèbre pure dans lib/dock-model.ts, cinq invariants documentés, ratios plutôt que pixels. `dockSessionIds` et `activeDockSessionId` deviennent des computed dérivés : aucun consommateur ni test existant ne change. Alt+clic ouvre à côté depuis les quatre panneaux. Le plafond de hauteur du dock suit le viewport au lieu d'un 640 px figé. Correctif préexistant au passage : PanelSplitter passait ses bornes par valeur, figées au premier rendu, alors que le clavier les relisait. Portée git : la vue Changements suit le worktree du terminal focalisé, ou tous les dépôts de son groupe pour une session de groupe, avec « tout voir » à un clic. L'index Git de la sidebar reste global (c'est la sortie d'une portée étroite) et le badge d'activité aussi (il sert à signaler le travail qu'on ne regarde pas). Seul le TERMINAL impose le contexte : le repli sur l'onglet éditeur, essayé d'abord, rétrécissait la vue multi-projet dès qu'on ouvrait un fichier. Vérifications : acceptance-p17.mjs prouve l'ordre des trames sur un vrai WebSocket (avec l'ancien ordre : 0 octet rejoué, échec), verify-terminals.mjs prouve par interaction réelle que le terminal peint, que deux colonnes coexistent, que la frappe atteint le bon PTY (fichier témoin par cwd) et que la vue suit le terminal.
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
#!/usr/bin/env node
|
||||
// Vérification E2E du DOCK TERMINAUX par interaction réelle : daemon temporaire isolé + Chromium
|
||||
// headless piloté en CDP + cookie de session injecté. On clique comme un utilisateur, puis on lit le
|
||||
// DOM et l'écran d'xterm.
|
||||
//
|
||||
// Ce que ce script prouve, et qu'aucune capture ne prouvait :
|
||||
// (a) un terminal attaché AFFICHE la sortie de son PTY (le bug « écran tout noir » venait du replay
|
||||
// émis avant `attached`, donc jeté par le client : ici on lit le texte réellement peint) ;
|
||||
// (b) deux terminaux tiennent côte à côte, chacun dans sa colonne, tous les deux visibles ;
|
||||
// (c) la frappe va au terminal de la colonne ACTIVE, et suit le changement de colonne ;
|
||||
// (d) fermer une colonne rend sa place à l'autre, et le terminal restant continue de fonctionner ;
|
||||
// (e) la vue Changements suit le terminal focalisé (portée), et la bascule « tout voir » la libère.
|
||||
//
|
||||
// Sessions `bash` uniquement : aucun quota Claude consommé.
|
||||
// Usage : node packages/server/scripts/verify-terminals.mjs
|
||||
// Prérequis : `npm run build` puis `node packages/server/scripts/copy-web.mjs`.
|
||||
import { spawn, execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } 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 = 7413;
|
||||
const CDP_PORT = 9336;
|
||||
const ORIGIN = `http://127.0.0.1:${PORT}`;
|
||||
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
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;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Répertoire de captures optionnel : `node scripts/verify-terminals.mjs [out]`. */
|
||||
const shotDir = process.argv[2] ?? null;
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'arb-verify-term-'));
|
||||
let srv = null;
|
||||
let browser = null;
|
||||
|
||||
try {
|
||||
check('SPA copiée dans packages/server/public', existsSync(join(serverDir, 'public', 'index.html')));
|
||||
|
||||
// Deux dépôts : le second sert à prouver que la portée git suit bien le terminal focalisé.
|
||||
const repos = [];
|
||||
for (const name of ['alpha', 'beta']) {
|
||||
const dir = join(tmp, name);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const git = (...args) => execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
|
||||
git('init', '-b', 'main');
|
||||
git('config', 'user.email', 'test@arboretum.dev');
|
||||
git('config', 'user.name', 'Test');
|
||||
writeFileSync(join(dir, 'README.md'), `# ${name}\n`);
|
||||
git('add', '-A');
|
||||
git('commit', '-m', 'commit initial');
|
||||
writeFileSync(join(dir, `wip-${name}.txt`), 'travail en cours\n');
|
||||
repos.push({ name, dir });
|
||||
}
|
||||
|
||||
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 sessionCookie = (login.headers.getSetCookie?.() ?? []).map((c) => c.split(';')[0]).find((c) => c.startsWith('arb_session='));
|
||||
const cookieValue = sessionCookie?.slice('arb_session='.length) ?? '';
|
||||
check('login → cookie de session', !!sessionCookie);
|
||||
|
||||
const j = (path, method, body) =>
|
||||
fetch(`${ORIGIN}${path}`, {
|
||||
method,
|
||||
headers: { Origin: ORIGIN, Cookie: sessionCookie ?? '', ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
|
||||
for (const r of repos) {
|
||||
const res = await (await j('/api/v1/repos', 'POST', { path: r.dir })).json();
|
||||
r.id = res.repo?.id;
|
||||
}
|
||||
check('deux dépôts enregistrés', repos.every((r) => !!r.id));
|
||||
|
||||
// Une session bash par dépôt : ce sont elles qui peupleront les deux colonnes.
|
||||
for (const r of repos) {
|
||||
const res = await (await j('/api/v1/sessions', 'POST', { cwd: r.dir, command: 'bash' })).json();
|
||||
r.sessionId = res.session?.id;
|
||||
}
|
||||
check('deux sessions bash lancées', repos.every((r) => !!r.sessionId));
|
||||
|
||||
const chromeBin = findChromium();
|
||||
check('Chromium disponible', !!chromeBin, chromeBin ?? 'introuvable');
|
||||
if (!chromeBin) throw new Error('Chromium introuvable');
|
||||
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',
|
||||
// xterm peint sur un canvas WebGL quand il peut : le texte n'est alors PAS dans le DOM et
|
||||
// aucun test ne peut le lire. On force le renderer DOM ; le chemin vérifié (transport → xterm)
|
||||
// est le même, seule la peinture change.
|
||||
'--disable-webgl',
|
||||
'--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;
|
||||
|
||||
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('Page.enable', {}, sessionId);
|
||||
await client.send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 950, deviceScaleFactor: 1, mobile: false }, sessionId);
|
||||
await client.send('Network.enable', {}, sessionId);
|
||||
await client.send('Network.setCookie', { name: 'arb_session', value: cookieValue, domain: '127.0.0.1', path: '/', httpOnly: true }, sessionId);
|
||||
// Aucun état de vue persisté : le dock part vide, comme au premier lancement.
|
||||
// Amorçage AVANT navigation (sinon le store lit un localStorage encore vide) : panneau Sessions à
|
||||
// gauche pour ouvrir les terminaux, et zone centrale en mode Changements pour observer la portée.
|
||||
await client.send(
|
||||
'Page.addScriptToEvaluateOnNewDocument',
|
||||
{
|
||||
source: [
|
||||
"localStorage.clear();",
|
||||
`localStorage.setItem('arb.theme', '"dark"');`,
|
||||
"localStorage.setItem('arboretum.locale', 'en');",
|
||||
`localStorage.setItem('arb.ide.activity', '"sessions"');`,
|
||||
`localStorage.setItem('arb.ide.centerMode', '"changes"');`,
|
||||
"localStorage.setItem('arb.ide.leftVisible', 'true');",
|
||||
].join(''),
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
const consoleBefore = client.events.length;
|
||||
await client.send('Page.navigate', { url: `${ORIGIN}/ide` }, sessionId);
|
||||
|
||||
const evaluate = async (expression) => (await client.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId)).result?.value;
|
||||
async function waitFor(fn, tries = 60, delay = 250) {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
if (await fn()) return true;
|
||||
await sleep(delay);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Texte réellement PEINT par une instance xterm (index de colonne dans le dock). */
|
||||
const screenText = (n) =>
|
||||
evaluate(
|
||||
`(() => {
|
||||
const rows = [...document.querySelectorAll('.xterm-rows')];
|
||||
const el = rows[${n}] ?? [...document.querySelectorAll('.xterm-screen')][${n}];
|
||||
return el ? el.innerText.replace(/\\u00a0/g, ' ') : null;
|
||||
})()`,
|
||||
);
|
||||
const columnCount = () => evaluate(`document.querySelectorAll('.xterm-screen').length`);
|
||||
const clickRow = (label) =>
|
||||
evaluate(
|
||||
`(() => {
|
||||
const span = [...document.querySelectorAll('button span')].find((s) => s.textContent.trim() === ${JSON.stringify(label)});
|
||||
const btn = span?.closest('button');
|
||||
if (!btn) return false;
|
||||
btn.click();
|
||||
return true;
|
||||
})()`,
|
||||
);
|
||||
const clickTitled = (title, nth = 0) =>
|
||||
evaluate(
|
||||
`(() => {
|
||||
const btns = [...document.querySelectorAll('[title=' + JSON.stringify(${JSON.stringify(title)}) + ']')];
|
||||
const btn = btns[${nth}];
|
||||
if (!btn) return false;
|
||||
btn.click();
|
||||
return true;
|
||||
})()`,
|
||||
);
|
||||
/**
|
||||
* Clic NATIF au centre d'une colonne : c'est le seul moyen de donner le focus au textarea caché
|
||||
* d'xterm (un PointerEvent synthétique ne déplace pas le focus du navigateur).
|
||||
*/
|
||||
async function clickColumn(n) {
|
||||
const box = await evaluate(
|
||||
`(() => {
|
||||
const el = [...document.querySelectorAll('.xterm-screen')][${n}];
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) };
|
||||
})()`,
|
||||
);
|
||||
if (!box) return false;
|
||||
for (const type of ['mousePressed', 'mouseReleased']) {
|
||||
await client.send('Input.dispatchMouseEvent', { type, x: box.x, y: box.y, button: 'left', clickCount: 1 }, sessionId);
|
||||
}
|
||||
await sleep(200);
|
||||
return true;
|
||||
}
|
||||
/** Frappe réelle : insertText va à l'élément focalisé (le textarea d'xterm). */
|
||||
const type = (text) => client.send('Input.insertText', { text }, sessionId);
|
||||
const pressEnter = async () => {
|
||||
await client.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, text: '\r' }, sessionId);
|
||||
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 }, sessionId);
|
||||
};
|
||||
|
||||
check('SPA chargée sur /ide', await waitFor(async () => (await evaluate(`!!document.querySelector('[aria-label], nav, main')`)) === true));
|
||||
|
||||
// --- (a) un terminal affiche la sortie de son PTY ---
|
||||
/** Clique la ligne de session du panneau Sessions correspondant à un dépôt. `alt` = ouvrir à côté. */
|
||||
const clickSessionRow = (repoName, alt = false) =>
|
||||
evaluate(
|
||||
`(() => {
|
||||
const rows = [...document.querySelectorAll('aside button, div button')].filter(
|
||||
(b) => b.textContent.includes(${JSON.stringify(repoName)}) && b.querySelector('span'),
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return false;
|
||||
row.dispatchEvent(new MouseEvent('click', { bubbles: true, altKey: ${alt} }));
|
||||
return true;
|
||||
})()`,
|
||||
);
|
||||
|
||||
const listed = await waitFor(async () => (await evaluate(`document.body.innerText.includes('alpha') && document.body.innerText.includes('beta')`)) === true, 40);
|
||||
check('les deux sessions sont listées dans le panneau', listed);
|
||||
|
||||
const openedFirst = await waitFor(async () => {
|
||||
await clickSessionRow('alpha');
|
||||
return (await columnCount()) >= 1;
|
||||
}, 30);
|
||||
check('un terminal s’ouvre dans le dock', openedFirst, `colonnes: ${await columnCount()}`);
|
||||
|
||||
await clickColumn(0);
|
||||
check('le terminal prend le focus clavier', (await evaluate(`document.activeElement?.tagName?.toLowerCase() ?? ''`)) === 'textarea');
|
||||
// Deux preuves complémentaires : le PTY a bien reçu la frappe (fichier créé dans SON cwd), et sa
|
||||
// sortie est réellement peinte à l'écran (c'était précisément ce qui manquait : un écran noir).
|
||||
await type('touch recu-A && echo MARQUEUR-COLONNE-A');
|
||||
await pressEnter();
|
||||
const gotA = await waitFor(() => existsSync(join(repos[0].dir, 'recu-A')), 40);
|
||||
check('(a) la frappe atteint le PTY du terminal', gotA);
|
||||
const paintedA = await waitFor(async () => ((await screenText(0)) ?? '').includes('MARQUEUR-COLONNE-A'), 40);
|
||||
check('(a) le terminal PEINT la sortie de son PTY (fin de l’écran noir)', paintedA, ((await screenText(0)) ?? '').replace(/\s+/g, ' ').slice(-70));
|
||||
|
||||
// --- (b) deux terminaux côte à côte ---
|
||||
const splitDone = await waitFor(async () => {
|
||||
await clickSessionRow('beta', true); // Alt+clic = ouvrir à côté
|
||||
return (await columnCount()) === 2;
|
||||
}, 30);
|
||||
check('(b) deux colonnes de terminaux visibles simultanément', splitDone, `colonnes: ${await columnCount()}`);
|
||||
|
||||
const bothVisible = await evaluate(
|
||||
`(() => {
|
||||
const screens = [...document.querySelectorAll('.xterm-screen')];
|
||||
if (screens.length !== 2) return false;
|
||||
return screens.every((s) => { const r = s.getBoundingClientRect(); return r.width > 50 && r.height > 20; });
|
||||
})()`,
|
||||
);
|
||||
check('(b) les deux colonnes ont une surface réelle', bothVisible === true);
|
||||
|
||||
const sideBySide = await evaluate(
|
||||
`(() => {
|
||||
const [a, b] = [...document.querySelectorAll('.xterm-screen')].map((s) => s.getBoundingClientRect());
|
||||
return !!a && !!b && Math.abs(a.top - b.top) < 40 && Math.abs(a.left - b.left) > 100;
|
||||
})()`,
|
||||
);
|
||||
check('(b) elles sont côte à côte (et non empilées)', sideBySide === true);
|
||||
|
||||
// Capture optionnelle (argument 1) : preuve visuelle des deux colonnes, utile en revue.
|
||||
if (shotDir) {
|
||||
mkdirSync(shotDir, { recursive: true });
|
||||
const shot = await client.send('Page.captureScreenshot', { format: 'png' }, sessionId);
|
||||
const file = join(shotDir, 'terminal-columns.png');
|
||||
writeFileSync(file, Buffer.from(shot.data, 'base64'));
|
||||
check('capture des deux colonnes écrite', existsSync(file), file);
|
||||
}
|
||||
|
||||
// --- (c) la frappe va à la colonne active (la seconde vient d'être créée) ---
|
||||
await type('touch recu-B && echo MARQUEUR-COLONNE-B');
|
||||
await pressEnter();
|
||||
const gotB = await waitFor(() => existsSync(join(repos[1].dir, 'recu-B')), 40);
|
||||
check('(c) la frappe va au PTY de la colonne active (nouvelle colonne focalisée)', gotB);
|
||||
check('(c) elle ne fuit pas dans l’autre PTY', !existsSync(join(repos[0].dir, 'recu-B')));
|
||||
const paintedB = await waitFor(async () => ((await screenText(1)) ?? '').includes('MARQUEUR-COLONNE-B'), 40);
|
||||
check('(c) la seconde colonne peint aussi sa sortie', paintedB);
|
||||
|
||||
// Retour sur la première colonne : un clic dedans doit lui rendre la frappe.
|
||||
await clickColumn(0);
|
||||
await type('touch retour-A');
|
||||
await pressEnter();
|
||||
const backToA = await waitFor(() => existsSync(join(repos[0].dir, 'retour-A')), 40);
|
||||
check('(c) le focus suit le clic sur une colonne', backToA);
|
||||
|
||||
// --- (e) la portée git suit le terminal focalisé (zone centrale en mode Changements) ---
|
||||
// La colonne active est celle d'alpha (on vient d'y revenir) : seul son fichier modifié doit être
|
||||
// listé, celui de beta doit disparaître de la vue.
|
||||
const centerText = () => evaluate(`document.querySelector('main')?.innerText ?? ''`);
|
||||
const scopedToAlpha = await waitFor(async () => {
|
||||
const txt = await centerText();
|
||||
return txt.includes('wip-alpha.txt') && !txt.includes('wip-beta.txt');
|
||||
}, 40);
|
||||
check('(e) la vue Changements ne montre que le projet du terminal focalisé', scopedToAlpha, (await centerText()).replace(/\s+/g, ' ').slice(0, 90));
|
||||
|
||||
// Focaliser la colonne de beta doit faire suivre la vue.
|
||||
await clickColumn(1);
|
||||
const followsBeta = await waitFor(async () => {
|
||||
const txt = await centerText();
|
||||
return txt.includes('wip-beta.txt') && !txt.includes('wip-alpha.txt');
|
||||
}, 40);
|
||||
check('(e) changer de colonne fait suivre la vue Changements', followsBeta, (await centerText()).replace(/\s+/g, ' ').slice(0, 90));
|
||||
|
||||
// La bascule « tout voir » doit libérer la portée.
|
||||
await evaluate(
|
||||
`(() => {
|
||||
const btn = [...document.querySelectorAll('button')].find((b) => /Show every project|Follow the focused terminal/.test(b.getAttribute('title') ?? ''));
|
||||
btn?.click();
|
||||
return !!btn;
|
||||
})()`,
|
||||
);
|
||||
// Un bloc replié ne liste pas ses fichiers : la preuve d'élargissement, ce sont les deux dépôts.
|
||||
const seesBoth = await waitFor(async () => {
|
||||
const txt = await centerText();
|
||||
return txt.includes('alpha') && txt.includes('beta');
|
||||
}, 40);
|
||||
check('(e) la bascule « tout voir » libère la portée', seesBoth);
|
||||
|
||||
// --- (d) fermer une colonne ---
|
||||
const closed = await waitFor(async () => {
|
||||
await clickTitled('Close', 0);
|
||||
return (await columnCount()) <= 1;
|
||||
}, 20);
|
||||
check('(d) fermer un terminal libère sa colonne', closed, `colonnes: ${await columnCount()}`);
|
||||
|
||||
const consoleErrors = client.events
|
||||
.slice(consoleBefore)
|
||||
.filter((e) => e.method === 'Log.entryAdded' && e.params?.entry?.level === 'error')
|
||||
.map((e) => e.params.entry.text)
|
||||
// Les 404 de favicon et les avertissements de chunk ne concernent pas le dock.
|
||||
.filter((t) => !/favicon|manifest/i.test(t));
|
||||
check('aucune erreur console', consoleErrors.length === 0, consoleErrors.slice(0, 3).join(' | '));
|
||||
} catch (err) {
|
||||
check('exception', false, String(err));
|
||||
} finally {
|
||||
browser?.kill('SIGKILL');
|
||||
srv?.kill('SIGTERM');
|
||||
await sleep(1200);
|
||||
srv?.kill('SIGKILL');
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
console.log(failed.length === 0 ? '\nVERIFY TERMINALS: ALL GREEN' : `\nVERIFY TERMINALS: ${failed.length} FAILURE(S)`);
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
}
|
||||
Reference in New Issue
Block a user