Acompanha em segundo plano os testes dos módulos cadastrados no workspace, com contadores observados, estado e logs por módulo.


Acompanhe suas suítes de teste no Claude Code enquanto elas rodam em segundo plano. Plugin test-progress, um Claude Code Mod independente da Anthropic. Versão 0.10.1, licença MIT.

▶ inicia, ■ cancela, ↻ Apenas com erro reexecuta só o que falhou e clicar no nome abre o log com as cores do runner.O topo do painel conta módulos com sucesso / com erro / total. O percentual é testes resolvidos / total conhecido; ~ indica total parcial, que ainda pode crescer. 100% não comprova sucesso: confira estado, falhas e código de saída.
Com acesso SSH ao GitHub configurado, rode no Claude Code um comando por vez (colados juntos, as linhas seguintes viram argumentos do primeiro):
/plugin marketplace add git@github.com:fabiopbarbieri/claude-test-progress.git
/plugin install test-progress@test-progress-marketplace
/reload-plugins
Depois, /test-progress help, /test-progress paths e /test-progress list. Instalação por terminal, clone SSH e --plugin-dir: guia de uso.
O jeito mais simples é pedir ao Claude: a skill test-progress:configure cadastra as suítes do projeto e explica erros do painel. Depois, quando você pedir para rodar os testes, a skill test-progress:run-tests os inicia pelo coletor, para o run aparecer no painel.
O cadastro fica em .claude/test-progress.json, no diretório em que você abriu a sessão. Exemplo para um app Maven com mvnw na raiz:
{
"schemaVersion": 1,
"modules": {
"api": {
"label": "API",
"command": ["./mvnw", "test"],
"cwd": ".",
"adapter": "maven",
"runtime": "inherit"
}
}
}
command é argv (um item por argumento, sem shell). Para contar testes em outras suítes, use os adapters pelo caminho absoluto mostrado em /test-progress paths. Exemplos prontos, templates pessoais e todos os campos: configurar seu projeto.
| Comando | Ação |
|---|---|
/test-progress | Abre ou fecha o painel, sem iniciar testes |
/test-progress list | Lista módulos e diagnósticos |
/test-progress start <id> / start all | Inicia um módulo / todos os habilitados |
/test-progress logs <id> | Mostra o log do módulo |
/test-progress cancel <id> / cancel all | Cancela a execução |
/test-progress help / paths | Ajuda / caminhos da instalação |
Acrescente --text para a saída em texto. No painel, ⇅ alterna a ordem dos módulos (cadastro, nome, recentes, atenção) e ? mostra a legenda.
Quando todas as execuções terminam e alguma falhou, o painel abre sozinho no log do primeiro módulo com falha, inclusive para execuções iniciadas pelo Claude via CLI. Ele não toma o teclado e, por ser aberto pelo mod, só aparece com o terminal largo o bastante (144 colunas); fechado, não reabre para as mesmas execuções.
No mesmo momento sai uma notificação nativa ("Falharam: API (1 ✗)") pelo canal de notificação configurado no Claude Code, para avisar mesmo longe do terminal ou enquanto o painel espera largura.
Encerre os jobs antes de trocar a instalação: fechar o painel, recarregar ou desinstalar não para processos em segundo plano.
claude plugin marketplace update test-progress-marketplace
claude plugin update test-progress@test-progress-marketplace --scope user
Para remover:
claude plugin uninstall test-progress@test-progress-marketplace --scope user
Depois de atualizar, confira /test-progress paths e ajuste os caminhos dos adapters. Procedimento completo.
Claude Code 2.1.295+ e Node 14+ para o coletor. Linux e WSL são o caminho principal; Windows nativo tem guia próprio. O plugin não instala runners, browsers nem runtimes. Versões testadas por suíte: compatibilidade.
Uso · Diagnóstico · Compatibilidade · Windows · Angular 9 · Validação · Extensões · Contribuição · CI e governança · Segurança · Licença MIT
hooks/register.tsx 757 lines1// Claude Code Mods 2.1.295+. No host Node APIs run inside the Mod sandbox.
2import { atom, read } from 'claude-code';
3import type { EngineInterface, Register, Timer } from 'claude-code';
4import type { TestProgressDiagnostic, TestProgressPanel } from '../types';
5import { ACTIVE, labels, validateEnvelope, parseCommand, visibleModuleIds, moduleTitle, countSummary, diagnosticText,
6 sanitizeText, sanitizeTail, plainTail, ansiSpans, statusGlyph, progressBar, outcomeText, summaryCounts, finishedWithFailure, compactPercent, compactCounts, clock,
7 configStatus, SORTS, sortLabels, nextSort, sortModuleIds } from '../runner/module-presentation.mjs';
8
9type $ = EngineInterface;
10type Action = 'list' | 'start' | 'status' | 'logs' | 'cancel' | 'help' | 'paths';
11const PANE = 'claude-test-progress';
12const NARROW_COLUMNS = 60;
13// Columns the band leaves free at the right, where the host draws its collapse control.
14const BAND_RESERVE = 6;
15const IDLE_POLL_TICKS = 10;
16const LOG_ROWS = 12;
17const empty = (): TestProgressPanel => ({ identity: '', generation: 0, sessionOwner: '', modules: {}, jobs: {},
18 stateDiagnostics: {}, workspace: null, busy: false, lastError: '', registrationError: '', selectedLogs: null,
19 logTail: [], logTop: null, chooseLogs: false, showHelp: false, sort: 'order', seenRuns: [], collector: null });
20// The host holds what the pane draws, so a hot reload keeps the selection, the seen runs and the collector Node.
21const panel = atom({ plugin: 'test-progress', key: 'panel' } as const, empty());
22// Working copy: hooks change it, then publish it; drawings read the host's value and redraw when it changes.
23let p = empty(), hydrated = false, published = '';
24// Collector calls run one at a time; commands and buttons wait instead of being dropped.
25let queue: Promise<unknown> = Promise.resolve(), queued = 0, idleTicks = 0, timer: Timer | null = null;
26const errorText = (error: unknown) => String(error instanceof Error ? error.message : error);
27async function hydrate($: $) {
28 if (hydrated) return;
29 const held = await read($, panel);
30 if (!hydrated) {
31 p = { ...empty(), ...held, busy: false };
32 if (!SORTS.includes(p.sort)) p.sort = 'order';
33 hydrated = true; published = JSON.stringify(p);
34 }
35}
36async function publish($: $) {
37 hydrated = true;
38 const next = JSON.stringify(p);
39 if (next === published) return;
40 published = next;
41 await $.state.set({ plugin: 'test-progress', key: 'panel' }, JSON.parse(next) as TestProgressPanel);
42}
43const visible = () => visibleModuleIds(p.modules, p.jobs, p.stateDiagnostics);
44const enabled = () => visible().filter(id => p.modules[id]?.enabled);
45const catalogue = () => JSON.stringify({ modules: p.modules, moduleConfig: p.workspace?.moduleConfig,
46 stateBlocked: p.workspace?.stateBlocked, stateDiagnostics: p.stateDiagnostics });
47const actionProjection = () => JSON.stringify({ catalogue: catalogue(), jobs: Object.keys(p.jobs).sort().map(id => {
48 const job = p.jobs[id]!;
49 return [id, job.runId, job.status, !!job.recoveryRequired, !!job.cancellable];
50}) });
51// Labels alone read best; a label shared by two visible modules gets its ID.
52function displayNames() {
53 const ids = visible(), count: Record<string, number> = {};
54 for (const id of ids) { const label = p.modules[id]?.label ?? id; count[label] = (count[label] ?? 0) + 1; }
55 return Object.fromEntries(ids.map(id => {
56 const label = p.modules[id]?.label ?? id;
57 return [id, (count[label] ?? 0) > 1 && label !== id ? `${label} (${id})` : label];
58 }));
59}
60// The log shows a window of LOG_ROWS lines; with no top of its own it follows the end.
61function logWindow() {
62 const max = Math.max(0, p.logTail.length - LOG_ROWS);
63 return { top: p.logTop === null ? max : Math.min(Math.max(0, p.logTop), max), max };
64}
65const closeLogs = () => { p.selectedLogs = null; p.logTail = []; p.logTop = null; };
66const diagnostics = (id: string): TestProgressDiagnostic[] => [...(p.modules[id]?.diagnostics ?? []), ...(p.stateDiagnostics[id] ?? [])];
67function serialized<T>(task: () => Promise<T>): Promise<T> {
68 queued += 1;
69 const run = queue.then(task).finally(() => { queued -= 1; });
70 queue = run.catch(() => {});
71 return run;
72}
73const liveJobs = () => Object.values(p.jobs).some(job => ACTIVE.has(job.status) || job.recoveryRequired);
74// Idle, a query starts a Node only when something it read changed. Each answer names its sources
75// (the state directory and the configuration files and module folders); every IDLE_CHECK_TICKS
76// seconds the Mod compares them through the host's file system, with a query at least every
77// IDLE_REFRESH_TICKS seconds (ten after a failed one). Without sources, or if the file system
78// refuses, the panel polls every IDLE_POLL_TICKS seconds instead.
79const IDLE_CHECK_TICKS = 2;
80const IDLE_REFRESH_TICKS = 300;
81const absolutePath = (value: unknown): value is string => typeof value === 'string' && !value.includes('\0') &&
82 (value.startsWith('/') || /^[a-z]:\\/i.test(value));
83let sources: { identity: string; state: string; paths: string[] } | null = null;
84let baseline: string | null = null, refreshTicks = 0;
85function rememberSources(value: { state?: unknown; paths?: unknown } | undefined) {
86 const paths = Array.isArray(value?.paths) ? value.paths : null;
87 sources = absolutePath(value?.state) && paths && paths.length <= 256 && paths.every(absolutePath)
88 ? { identity: p.identity, state: value.state, paths } : null;
89}
90async function fingerprint($: $) {
91 const current = sources;
92 if (!current || current.identity !== p.identity) return null;
93 try {
94 const rows = (await $.fs.list(current.state)).map(entry => `${entry.name}|${entry.kind}|${entry.size}|${entry.mtimeMs}`).sort();
95 for (const path of current.paths) {
96 const info = await $.fs.stat(path).catch(() => null);
97 // A folder counts by presence: its own time moves with every file a test writes in it.
98 rows.push(`${path}|${!info ? 'absent' : info.kind === 'dir' ? 'dir' : `${info.kind}|${info.size}|${info.mtimeMs}`}`);
99 }
100 return rows.join('\n');
101 } catch { return null; }
102}
103// Poll every second only while something can change without us: an unseen identity or a live job.
104async function pollDue($: $) {
105 if (!p.workspace || liveJobs()) { idleTicks = 0; return true; }
106 const ticks = ++idleTicks, refresh = --refreshTicks <= 0;
107 if (!refresh && ticks % IDLE_CHECK_TICKS) return false;
108 const print = refresh ? null : await fingerprint($);
109 const due = refresh || (print === null ? ticks >= IDLE_POLL_TICKS : print !== baseline);
110 if (due) idleTicks = 0;
111 return due;
112}
113// While a job is live, one collector started once streams each status change (`watch`),
114// instead of a Node started every second. A watcher that stops answering, or one the host
115// cannot start, falls back to the per-second query.
116const WATCH_STALE_MS = 5000;
117const WATCH_RETRY_MS = 60000;
118type Watcher = { identity: string; logs: string; seenAt: number; stop: () => void };
119let watcher: Watcher | null = null, watchBlockedUntil = 0;
120function stopWatcher() {
121 const current = watcher;
122 watcher = null;
123 current?.stop();
124}
125function applyEnvelope(data: ReturnType<typeof validateEnvelope>) {
126 p.modules = data.modules; p.jobs = data.jobs; p.workspace = data.workspace; p.stateDiagnostics = data.stateDiagnostics;
127 rememberSources(data.sources);
128 const selected = p.selectedLogs && p.jobs[p.selectedLogs.id];
129 if (p.selectedLogs && selected?.runId === p.selectedLogs.runId && Array.isArray(selected.logTail)) p.logTail = sanitizeTail(selected.logTail);
130}
131async function applyWatched($: $, self: Watcher, line: string) {
132 if (watcher !== self) return;
133 await synchronizeIdentity($);
134 if (p.identity !== self.identity) { stopWatcher(); return; }
135 let data;
136 try { data = validateEnvelope(JSON.parse(line)); } catch { return; }
137 applyEnvelope(data);
138 p.lastError = data.ok ? '' : data.error ?? 'O coletor encerrou a observação.';
139 if (await paneShown($)) acknowledge();
140 else await autoOpen($);
141 await publish($);
142}
143// True while a watcher for this identity and log selection runs or was just started.
144function watch($: $, logs: string) {
145 const direct = p.collector?.identity === p.identity ? p.collector : null;
146 if (!direct || Date.now() < watchBlockedUntil) return false;
147 if (watcher?.identity === p.identity && watcher.logs === logs) return true;
148 stopWatcher();
149 const [cwd = '', owner = ''] = p.identity.split('\n');
150 let stream: ReturnType<$['process']['spawn']>;
151 try {
152 // A 1 MB young generation keeps this long-lived Node small (LONG_LIVED_NODE_FLAGS).
153 stream = $.process.spawn({ argv: [direct.path, '--max-semi-space-size=1', `${$.plugin.root}/runner/cli.mjs`, 'watch',
154 '--cwd', cwd, '--owner', owner, '--module', logs], env: { TEST_PROGRESS_NODE_SOURCE: direct.source } });
155 } catch { watchBlockedUntil = Date.now() + WATCH_RETRY_MS; return false; }
156 const self: Watcher = { identity: p.identity, logs, seenAt: Date.now(),
157 stop: () => { void stream.return({ code: null, signal: null }).catch(() => {}); } };
158 watcher = self;
159 void (async () => {
160 let buffer = '', lines = 0;
161 try {
162 for await (const chunk of stream) {
163 if (watcher !== self) break;
164 if (chunk.stream !== 'stdout') continue;
165 buffer += chunk.text;
166 for (let end = buffer.indexOf('\n'); end >= 0; end = buffer.indexOf('\n')) {
167 const line = buffer.slice(0, end);
168 buffer = buffer.slice(end + 1);
169 if (!line.trim()) continue;
170 lines++; self.seenAt = Date.now();
171 await serialized(() => applyWatched($, self, line));
172 }
173 }
174 } catch { /* The per-second query takes over. */ }
175 finally {
176 if (watcher === self) watcher = null;
177 if (!lines) watchBlockedUntil = Date.now() + WATCH_RETRY_MS;
178 }
179 })();
180 return true;
181}
182const validCollector = (value: { path?: unknown; source?: unknown } | undefined): value is { path: string; source: string } =>
183 typeof value?.path === 'string' && !value.path.includes('\0') && (value.path.startsWith('/') || /^[a-z]:\\/i.test(value.path)) &&
184 typeof value.source === 'string' && /^[\w-]{1,32}$/.test(value.source);
185// The bootstrap's Node is kept between sessions for the same folder, PATH and override, so a new
186// session queries it directly instead of starting PowerShell or bash first. A Node that no longer
187// answers is forgotten, and that query goes through the bootstrap.
188const COLLECTORS = 'collectors';
189async function collectorKey($: $, cwd: string) {
190 try {
191 const [path, override] = await Promise.all([$.env.get('PATH').then(value => value ?? $.env.get('Path')), $.env.get('TEST_PROGRESS_NODE')]);
192 return JSON.stringify([cwd, path ?? null, override ?? null]);
193 } catch { return null; }
194}
195async function storedCollectors($: $) {
196 const value = await $.store.get(COLLECTORS).catch(() => undefined);
197 return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, { path?: unknown; source?: unknown }> : {};
198}
199async function rememberedCollector($: $, key: string | null) {
200 if (!key) return null;
201 const value = (await storedCollectors($))[key];
202 if (!validCollector(value)) return null;
203 p.collector = { identity: p.identity, path: value.path, source: value.source };
204 return p.collector;
205}
206async function keepCollector($: $, key: string | null, value: { path: string; source: string }) {
207 if (!key) return;
208 // The newest 32 folders stay; each entry is a path and a source label.
209 const kept = Object.entries(await storedCollectors($)).filter(([entry]) => entry !== key).slice(-31);
210 await $.store.set(COLLECTORS, Object.fromEntries([...kept, [key, { path: value.path, source: value.source }]])).catch(() => {});
211}
212async function forgetCollector($: $, key: string | null) {
213 if (!key) return;
214 const all = await storedCollectors($);
215 if (!(key in all)) return;
216 delete all[key];
217 await $.store.set(COLLECTORS, all).catch(() => {});
218}
219const duration = (ms: number | undefined) => typeof ms === 'number' ? `${Math.floor(ms / 1000)}s` : 'desconhecida';
220function startAllowed(id: string) {
221 const job = p.jobs[id];
222 return !p.busy && p.workspace?.moduleConfig?.status === 'valid' && !p.workspace?.stateBlocked &&
223 !p.stateDiagnostics['*']?.length && p.modules[id]?.enabled === true && p.modules[id]?.directoryPresent === true &&
224 !diagnostics(id).length && !(job && ACTIVE.has(job.status)) && !job?.recoveryRequired;
225}
226const allAllowed = () => enabled().length > 0 && enabled().every(startAllowed);
227const failedIds = () => enabled().filter(id => finishedWithFailure(p.jobs[id]));
228const failedAllowed = () => failedIds().length > 0 && failedIds().every(startAllowed);
229// Opening or using the pane acknowledges the runs it shows, so the band can step aside.
230function acknowledge() {
231 const seen = new Set(p.seenRuns);
232 for (const job of Object.values(p.jobs)) seen.add(job.runId);
233 if (seen.size !== p.seenRuns.length) p.seenRuns = [...seen].slice(-200);
234}
235function shortSummary(id: string) {
236 const job = p.jobs[id];
237 if (!job) return `${moduleTitle(id, p.modules[id])}: sem execução`;
238 const counts = compactCounts(job).map(count => count.text).join(' ');
239 return `${moduleTitle(id, p.modules[id])}: ${statusGlyph(job).glyph} ${compactPercent(job)} · ${labels[job.status] ?? job.status}${counts ? ` · ${counts}` : ''}`;
240}
241function textSummary() {
242 const rows = ['Test Progress', ...(p.sessionOwner ? [`owner=${p.sessionOwner}`] : [])];
243 for (const id of visible()) {
244 const job = p.jobs[id];
245 rows.push(shortSummary(id), ` linguagem=${p.modules[id]?.language ?? 'não informada'}`);
246 if (!p.modules[id]?.enabled) rows.push(' Módulo removido/desativado.');
247 for (const item of diagnostics(id)) rows.push(` Diagnóstico: ${diagnosticText(item)}`);
248 if (!job) continue;
249 rows.push(` ${countSummary(job)}; passed=${job.passed ?? 0}, failed=${job.failed ?? 0}, skipped=${job.skipped ?? 0}`,
250 ` origem=${job.source}; fase=${job.phase}; exitCode=${job.exitCode ?? 'ainda desconhecido'}`,
251 ` duração=${duration(job.elapsedMs)}; último sinal do executor=${job.heartbeatAt ?? 'ainda não observado'}`,
252 ` última saída=${job.lastOutputAt ?? 'não observada'}; último progresso reconhecido=${job.lastProgressAt ?? 'não observado'}`,
253 ` runId=${job.runId}; log=${job.logPath ?? 'não disponível'}`);
254 if (job.collectorRuntime) rows.push(` Node coletor=${job.collectorRuntime.version} (${job.collectorRuntime.source})`);
255 if (job.nodeRuntime) rows.push(` Node da suíte=${job.nodeRuntime.version} (${job.nodeRuntime.source}); .nvmrc=${job.nodeRuntime.nvmrc ?? 'ausente'}`);
256 if (job.error) rows.push(` ${job.error}`);
257 }
258 if (!visible().length) rows.push('Nenhum módulo ativado neste workspace.', 'Configure .claude/test-progress.json; consulte /test-progress help.');
259 rows.push(`Cadastro: ${configStatus[p.workspace?.moduleConfig?.status ?? ''] ?? 'não consultado'} · .claude/test-progress.json`);
260 if (p.workspace?.error) rows.push(`Configuração: ${p.workspace.error}`);
261 for (const item of p.workspace?.moduleConfig?.diagnostics ?? []) rows.push(`Configuração: ${diagnosticText(item)}`);
262 for (const item of p.stateDiagnostics['*'] ?? []) rows.push(`Estado: ${diagnosticText(item)}`);
263 if (p.lastError) rows.push(`Erro: ${p.lastError}`);
264 if (p.registrationError) rows.push(`Registro: ${p.registrationError}`);
265 return rows.join('\n');
266}
267async function synchronizeIdentity($: $) {
268 await hydrate($);
269 const cwd = await $.session.cwd(), owner = await $.session.id();
270 const key = `${cwd}\n${owner}`;
271 if (p.identity !== key) {
272 // A new identity starts clean, but the collector Node it bootstrapped, the registration outcome and the ordering stay.
273 p = { ...empty(), identity: key, generation: p.generation + 1, sessionOwner: owner,
274 registrationError: p.registrationError, collector: p.collector, busy: p.busy, sort: p.sort };
275 }
276 return { cwd, owner, generation: p.generation };
277}
278async function collect($: $, action: Action = 'status', moduleId = 'all'): Promise<unknown> {
279 const context = await synchronizeIdentity($);
280 const windows = /^[a-z]:[\\/]|^\\\\/i.test(context.cwd);
281 // After one bootstrap, call the Node it selected directly: no shell, no PATH/nvm probing.
282 let key: string | null | undefined;
283 const keyOf = async () => key === undefined ? (key = await collectorKey($, context.cwd)) : key;
284 const direct = p.collector?.identity === p.identity ? p.collector : await rememberedCollector($, await keyOf());
285 // What the files looked like before this query; an answer makes it the idle baseline.
286 const print = await fingerprint($);
287 const settle = (ok: boolean) => { baseline = print; refreshTicks = ok ? IDLE_REFRESH_TICKS : IDLE_POLL_TICKS; };
288 let argv: string[];
289 if (direct) argv = [direct.path, `${$.plugin.root}/runner/cli.mjs`, action,
290 '--cwd', context.cwd, '--owner', context.owner, '--module', moduleId];
291 else if (windows) {
292 const override = await $.env.get('TEST_PROGRESS_POWERSHELL'), systemRoot = await $.env.get('SystemRoot');
293 argv = [override || (systemRoot ? `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` : 'powershell.exe'),
294 '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', `${$.plugin.root}/scripts/run-collector.ps1`,
295 '-Action', action, '-Cwd', context.cwd, '-Owner', context.owner, '-Module', moduleId];
296 } else argv = ['bash', `${$.plugin.root}/scripts/run-collector.sh`, action,
297 '--cwd', context.cwd, '--owner', context.owner, '--module', moduleId];
298 const init = { timeoutMs: action === 'start' ? 60000 : windows ? 15000 : 5000,
299 ...(direct ? { env: { TEST_PROGRESS_NODE_SOURCE: direct.source } } : {}) };
300 // A stale cached Node falls back to the bootstrap once; a start is never repeated.
301 const fallback = async () => { p.collector = null; await forgetCollector($, await keyOf()); return !!direct && action !== 'start'; };
302 const changed = () => new Error('A sessão mudou durante a consulta. Atualize para ver os jobs desta sessão.');
303 let response;
304 try { response = await $.process.run(argv, init); }
305 catch (error) {
306 await synchronizeIdentity($);
307 if (context.generation !== p.generation) throw changed();
308 if (direct && await fallback()) return collect($, action, moduleId);
309 settle(false);
310 throw error;
311 }
312 await synchronizeIdentity($);
313 if (context.generation !== p.generation) throw changed();
314 let raw: unknown;
315 try { raw = JSON.parse(response.stdout.trim()); }
316 catch {
317 if (direct && await fallback()) return collect($, action, moduleId);
318 settle(false);
319 throw new Error(sanitizeText(response.stderr).trim().slice(0, 1024) || `Coletor retornou resposta inválida (exit ${response.exitCode}); requer Node 14+ local.`);
320 }
321 let data;
322 try { data = validateEnvelope(raw); } catch (error) { settle(false); throw error; }
323 if (!direct && validCollector(data.collector)) {
324 p.collector = { identity: p.identity, path: data.collector.path, source: data.collector.source };
325 await keepCollector($, await keyOf(), p.collector);
326 }
327 // Error envelopes still carry the valid catalogue and persistent jobs.
328 applyEnvelope(data);
329 settle(data.ok && response.exitCode === 0);
330 if (!data.ok || response.exitCode !== 0) {
331 const actionErrors = Object.entries(data.actionResults ?? {}).filter(([, result]) => !result.ok)
332 .map(([id, result]) => `${id}: ${result.error ?? 'ação recusada'}`).join('\n');
333 throw new Error(data.error ?? (actionErrors || `Coletor encerrou com código ${response.exitCode}.`));
334 }
335 p.lastError = '';
336 return data;
337}
338type Expected = { generation: number; projection: string };
339function perform($: $, action: Action, moduleId = 'all', expected: Expected | null = null) {
340 return serialized(() => performNow($, action, moduleId, expected));
341}
342// The collector starts one ID or all; a rerun of the failed modules starts each in turn after one check.
343function startFailed($: $, expected: Expected) {
344 return serialized(async () => {
345 await hydrate($);
346 p.busy = true; await publish($);
347 try {
348 await synchronizeIdentity($);
349 await collect($, 'status');
350 if (p.generation !== expected.generation || actionProjection() !== expected.projection) throw new Error('O cadastro ou a execução mudou. Revise os módulos antes de agir.');
351 p.busy = false;
352 const ids = failedIds(), allowed = failedAllowed();
353 p.busy = true;
354 if (!allowed) throw new Error('Início indisponível. Confira os diagnósticos e jobs ativos.');
355 for (const id of ids) await collect($, 'start', id);
356 } catch (error) { p.lastError = errorText(error); }
357 finally { p.busy = false; await publish($); }
358 });
359}
360async function performNow($: $, action: Action, moduleId: string, expected: Expected | null) {
361 await hydrate($);
362 p.busy = true; await publish($);
363 try {
364 await synchronizeIdentity($);
365 if (expected && (p.generation !== expected.generation || actionProjection() !== expected.projection)) throw new Error('A seleção mudou. Atualize o painel antes de agir.');
366 if (expected && ['start', 'cancel', 'logs'].includes(action)) {
367 // Refresh before rendered actions; never act on a renamed module or a replaced run silently.
368 await collect($, 'status');
369 if (p.generation !== expected.generation || actionProjection() !== expected.projection) throw new Error('O cadastro ou a execução mudou. Revise o módulo antes de agir.');
370 if (action === 'start') {
371 p.busy = false;
372 const allowed = moduleId === 'all' ? allAllowed() : startAllowed(moduleId);
373 p.busy = true;
374 if (!allowed) throw new Error('Início indisponível. Confira os diagnósticos e jobs ativos.');
375 }
376 }
377 await collect($, action, moduleId);
378 if (action === 'logs') {
379 const job = p.jobs[moduleId];
380 if (moduleId === 'all') { closeLogs(); p.chooseLogs = true; }
381 else if (job) { p.selectedLogs = { id: moduleId, runId: job.runId }; p.logTail = sanitizeTail(job.logTail); p.logTop = null; p.chooseLogs = false; }
382 }
383 } catch (error) { p.lastError = errorText(error); }
384 finally { p.busy = false; await publish($); }
385}
386const HELP = [
387 '/test-progress — abre ou fecha o painel; não inicia testes.',
388 '/test-progress list — lista módulos ativados, IDs, linguagens e diagnósticos.',
389 '/test-progress start <id|all> — inicia explicitamente os módulos ativados.',
390 '/test-progress status [id|all] — consulta o estado.',
391 '/test-progress logs [id|all] — consulta os logs; escolha um ID no painel.',
392 '/test-progress cancel [id|all] — cancela jobs ativos/recuperáveis desta sessão.',
393 '/test-progress help | paths — ajuda e caminhos instalados.',
394 'Acrescente --text para resposta textual, inclusive no modo headless.',
395 'Cadastro: <diretório da sessão>/.claude/test-progress.json.',
396 'Windows: PowerShell 5.1/7; cancelamento da árvore por Job Object.',
397 'Percentual = testes resolvidos / testes conhecidos. Total parcial pode crescer.',
398 'Cobertura de código e estimativa de tempo não são calculadas.',
399].join('\n');
400const LEGEND = ['● rodando ✓ ok ✗ falhou ■ cancelado ! erro ou órfão ○ sem execução',
401 '▶ iniciar ↻ reinicia os com erro ■ cancelar nome abre o log × fecha o log ~ total parcial',
402 '⇅ ordena: cadastro → nome → recentes → atenção (falhas primeiro)',
403 'A roda do mouse rola o log aberto; ↓ volta ao fim.',
404 'S/E/T: módulos com sucesso / com erro / total',
405 '✓ passaram ✗ falharam ⊘ ignorados',
406 '/test-progress help lista os comandos.'];
407function textLogs(moduleId: string) {
408 const ids = moduleId === 'all' ? Object.keys(p.jobs).sort() : [moduleId];
409 return ids.flatMap(id => { const job = p.jobs[id]; return job ? [`LOGS · ${id} · ${job.runId}\n${plainTail(job.logTail).join('\n')}`] : []; }).join('\n\n');
410}
411async function paneShown($: $) {
412 try { return (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown); } catch { return false; }
413}
414// Runs whose failure already opened the pane once; closing it keeps it closed for those runs.
415const autoOpened = new Set<string>();
416// Once nothing is running, unseen failures open the pane on the first failed module's log, without
417// taking the keyboard. Passing runs never open it. Runs only by the tick, inside its serialized task.
418// The host may hold the pane until the terminal is wide enough; the band still reports meanwhile.
419async function autoOpen($: $) {
420 if (Object.values(p.jobs).some(job => ACTIVE.has(job.status))) return;
421 const failed = visible().filter(id => { const job = p.jobs[id]; return !!job && finishedWithFailure(job) &&
422 !p.seenRuns.includes(job.runId) && !autoOpened.has(job.runId); });
423 if (!failed.length) return;
424 for (const id of failed) autoOpened.add(p.jobs[id]!.runId);
425 // A native notification reaches the person away from the terminal, or where the pane waits for width.
426 // Their own channel and Notification hooks decide; a refusal or a missing channel changes nothing here.
427 const names = displayNames();
428 const notice = failed.map(id => { const count = p.jobs[id]!.failed ?? 0; return `${names[id] ?? id} (${count ? `${count} ✗` : 'erro'})`; });
429 void Promise.resolve().then(() => $.ui.notify(`Falharam: ${notice.join(', ')}`, { title: 'Test Progress' })).catch(() => {});
430 try {
431 if ((await $.ui.panes()).some(pane => pane.id === PANE)) return;
432 const first = failed[0]!;
433 await collect($, 'logs', first);
434 const job = p.jobs[first];
435 if (job) { p.selectedLogs = { id: first, runId: job.runId }; p.logTail = sanitizeTail(job.logTail); p.logTop = null; p.chooseLogs = false; }
436 await $.ui.open({ id: PANE, title: 'Test Progress' });
437 } catch { /* No pane in this surface: the band and the command remain. */ }
438}
439export const register: Register = on => {
440 on('session.start', async ($, e, next) => {
441 await hydrate($);
442 timer?.cancel();
443 timer = $.clock.every(1000, () => {
444 // A waiting command already refreshes state; never stack ticks behind it.
445 if (queued) return;
446 void serialized(async () => {
447 try {
448 await synchronizeIdentity($);
449 if (watcher && watcher.identity !== p.identity) stopWatcher();
450 if (await pollDue($)) {
451 // Every query returns the whole catalogue and all jobs; only a live selected run re-reads its log.
452 const selected = p.selectedLogs, job = selected && p.jobs[selected.id];
453 const live = !!selected && !!job && ACTIVE.has(job.status);
454 const streaming = liveJobs() && watch($, live ? selected.id : 'all');
455 if (streaming && watcher && Date.now() - watcher.seenAt > WATCH_STALE_MS) stopWatcher();
456 if (!streaming || !watcher) await collect($, live ? 'logs' : 'status', live ? selected.id : 'all');
457 }
458 } catch (error) { p.lastError = errorText(error); }
459 if (await paneShown($)) acknowledge();
460 else await autoOpen($);
461 await publish($);
462 });
463 });
464 await perform($, 'status');
465 try {
466 // Keep one generic hint; no catalogue updates overwrite another command's registration.
467 const existing = (await $.command.list()).find(command => command.name === 'test-progress');
468 if (existing && (existing.source !== 'plugin' || existing.plugin !== 'test-progress')) {
469 throw new Error('O comando test-progress já existe; origem não pertence a este plugin.');
470 }
471 await $.command.register({ name: 'test-progress', description: 'Painel de execução de testes em segundo plano',
472 argumentHint: '[list|start|status|logs|cancel|help|paths] [id|all] [--text]', immediate: true });
473 p.registrationError = '';
474 } catch (error) { p.registrationError = errorText(error); }
475 await publish($);
476 return next(e);
477 });
478 on('session.end', async ($, e, next) => {
479 // Poll survives clear/resume/branch; workers and timer aren't cancelled here.
480 stopWatcher();
481 await hydrate($);
482 p = { ...empty(), generation: p.generation + 1, registrationError: p.registrationError, collector: p.collector, sort: p.sort };
483 await publish($);
484 return next(e);
485 });
486 on('command.run', { command: 'test-progress' }, async ($, e, next) => {
487 await hydrate($);
488 // A name match alone isn't ownership: register() can have failed, or another
489 // command can have replaced our name since startup. Preserve its full result.
490 if (p.registrationError) return next(e);
491 try {
492 const current = (await $.command.list()).find(command => command.name === 'test-progress');
493 if (!current || current.source !== 'plugin' || current.plugin !== 'test-progress') {
494 p.registrationError = 'O comando test-progress está indisponível ou pertence a outra origem.';
495 await publish($);
496 return next(e);
497 }
498 } catch (error) {
499 p.registrationError = `Não foi possível confirmar a origem do comando: ${errorText(error)}`;
500 await publish($);
501 return next(e);
502 }
503 let command;
504 try { command = parseCommand(e.args); } catch (error) { return { text: errorText(error) }; }
505 if (command.action === 'help') return { text: HELP };
506 if (command.action === 'paths') return { text: [`Plugin: ${$.plugin.root}`,
507 `Rails: ${$.plugin.root}/adapters/rails/run.rb`, `Python: ${$.plugin.root}/adapters/python/run.py`,
508 `Karma: ${$.plugin.root}/adapters/karma/reporter.cjs`, `JUnit: ${$.plugin.root}/adapters/junit/pom.xml`,
509 `Ruby / RSpec: ${$.plugin.root}/adapters/ruby/run.rb`,
510 `Playwright: ${$.plugin.root}/adapters/playwright/reporter.cjs`, `Exemplos: ${$.plugin.root}/config.example.json`].join('\n') };
511 // A bare /test-progress toggles: an open pane closes, a closed one refreshes and opens.
512 if (!String(e.args ?? '').trim()) {
513 try {
514 if ((await $.ui.panes()).some(pane => pane.id === PANE)) { await $.ui.close({ id: PANE }); return {}; }
515 } catch { /* No pane record: open as usual. */ }
516 }
517 await perform($, command.action, command.moduleId);
518 if (command.text || command.action === 'list') return { text: textSummary() + (command.action === 'logs' ? `\n${textLogs(command.moduleId)}` : '') };
519 try {
520 const placement = await $.ui.open({ id: PANE, title: 'Test Progress', focus: true, closeOnEscape: true });
521 if (!placement.isPlaced) return { text: `${textSummary()}\nPainel aguardando espaço: ${placement.reason}` };
522 acknowledge(); await publish($);
523 } catch (error) { return { text: `${textSummary()}\nPainel indisponível: ${errorText(error)}` }; }
524 return {};
525 });
526 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
527 // Reading the host's value subscribes this drawing; each publish redraws it.
528 await read($, panel);
529 await hydrate($);
530 const { Box, Text, Button } = $.ui.resolve(e);
531 const expected = { generation: p.generation, projection: actionProjection() };
532 const guarded = (fn: () => Promise<void> | void) => async () => {
533 await synchronizeIdentity($);
534 if (p.generation !== expected.generation || actionProjection() !== expected.projection) {
535 p.lastError = 'A seleção mudou. Tente de novo.'; await publish($); return;
536 }
537 acknowledge();
538 await fn();
539 await publish($);
540 };
541 const bodyColumns = typeof e.props?.bodyColumns === 'number' ? e.props.bodyColumns : 80;
542 const narrow = bodyColumns < NARROW_COLUMNS;
543 // Button has no disabled prop in the native API. Dim and guard instead of inventing HTML attributes.
544 const button = (key: string, label: string, action: Action, id = 'all', allowed = true) =>
545 <Button key={key} label={label} plain dimColor={p.busy || !allowed}
546 onPress={guarded(async () => { if (allowed) await perform($, action, id, expected); })} />;
547 const local = (key: string, label: string, fn: () => void) => <Button key={key} label={label} plain onPress={guarded(fn)} />;
548 const names = displayNames();
549 const toggleLogs = (id: string) => guarded(async () => {
550 if (p.selectedLogs?.id === id && !p.chooseLogs) { closeLogs(); return; }
551 await perform($, 'logs', id, expected);
552 });
553 // ▶/■ leads the row, in a fixed cell so names stay aligned when a module has neither.
554 const runControl = (id: string) => {
555 const module = p.modules[id], job = p.jobs[id];
556 return <Box key="run" width={1}>{job && (ACTIVE.has(job.status) || job.recoveryRequired && job.cancellable) ? button(`cancel-${id}`, '■', 'cancel', id) :
557 module?.enabled ? button(`start-${id}`, '▶', 'start', id, startAllowed(id)) : null}</Box>;
558 };
559 const progress = (id: string, cells: number) => {
560 const job = p.jobs[id];
561 if (!job) return [<Text key="none" color="inactive">—</Text>];
562 if (job.recoveryRequired) return [<Text key="orphan" color="error">órfão: processo ainda vivo</Text>];
563 const counts = compactCounts(job).map(count => <Text key={count.text} color={count.color}>{`${count.text} `}</Text>);
564 if (!ACTIVE.has(job.status)) {
565 const outcome = outcomeText(job);
566 return [...counts, <Text key="outcome" color={outcome.color}>{outcome.text}</Text>];
567 }
568 const stopping = job.phase === 'cancellation-requested';
569 const bar = progressBar(job, cells);
570 return [
571 <Box key="bar" flexDirection="row" width={cells + 1}>
572 {bar.done ? <Text key="done" color={stopping ? 'inactive' : 'suggestion'}>{bar.done}</Text> : null}
573 <Text key="rest" color="subtle">{bar.rest}</Text>
574 </Box>,
575 <Box key="pct" width={7}><Text dimColor={stopping}>{compactPercent(job)}</Text></Box>,
576 ...(stopping ? [<Text key="stopping" color="inactive">parando…</Text>] : counts),
577 ];
578 };
579 // The runner's own colors win; a line without any keeps the dim tail and the red error guess.
580 const logLine = (line: string, key: string) => {
581 const spans = ansiSpans(line);
582 const text = spans.map(span => span.text).join('');
583 if (!spans.some(span => span.style)) return /\b(ERROR|FAIL(ED|URE)?)\b/.test(text) ?
584 <Text key={key} wrap="truncate-end" color="error">{`│ ${text}`}</Text> :
585 <Text key={key} wrap="truncate-end" dimColor>{`│ ${text}`}</Text>;
586 return <Text key={key} wrap="truncate-end">
587 <Text dimColor>{'│ '}</Text>
588 {spans.map((span, j) => span.style ? <Text key={`s${j}`} {...span.style}>{span.text}</Text> : <Text key={`s${j}`} dimColor>{span.text}</Text>)}
589 </Text>;
590 };
591 const logBlock = (id: string) => {
592 const selected = p.selectedLogs;
593 if (selected?.id !== id) return [];
594 const job = p.jobs[id];
595 const changed = job?.runId !== selected.runId;
596 const { top, max } = logWindow(), total = p.logTail.length;
597 const lines = p.logTail.slice(top, top + LOG_ROWS);
598 const range = total <= LOG_ROWS ? `${total} ${total === 1 ? 'linha' : 'linhas'}` :
599 top === max ? `últimas ${lines.length} de ${total} linhas` : `linhas ${top + 1}–${top + lines.length} de ${total}`;
600 const header = `log · run ${selected.runId.slice(0, 8)} · ${range}`;
601 return [
602 <Box key={`log-${id}`} flexDirection="column" paddingLeft={4}>
603 <Box key="log-header" flexDirection="row" justifyContent="space-between">
604 <Text key="log-title" color="inactive" wrap="truncate-end">{`│ ${header}`}</Text>
605 <Box key="log-actions" flexDirection="row" flexShrink={0} gap={1}>
606 {top < max ? local(`end-logs-${id}`, '↓', () => { p.logTop = null; }) : null}
607 {local(`close-logs-${id}`, '×', closeLogs)}
608 </Box>
609 </Box>
610 {changed && job ? <Box key="changed" flexDirection="row" gap={1}>
611 <Text color="inactive">│ nova execução</Text>
612 {button('select-current-logs', '↻', 'logs', id)}
613 </Box> : null}
614 {lines.length ? lines.map((line, i) => logLine(line, `log-${selected.runId}-${top + i}`)) :
615 <Text key="empty" color="inactive">│ sem saída ainda</Text>}
616 </Box>,
617 ];
618 };
619 // Errors are the line the person most needs whole: they wrap, then point at the next step.
620 const problemBlock = (id: string) => {
621 const job = p.jobs[id];
622 const problems = [...diagnostics(id).map(diagnosticText), ...(job?.error ? [job.error] : [])];
623 if (!problems.length) return [];
624 const hint = job && p.selectedLogs?.id !== id ? '→ clique no nome para abrir o log' + (job.phase === 'no-progress-observed' || /eventos de progresso/.test(job.error ?? '') ?
625 ' · confira o "adapter" do módulo' : '') : '';
626 return [
627 <Box key="problems" flexDirection="column" paddingLeft={4}>
628 {problems.slice(0, 2).map((item, i) => <Text key={`problem-${i}`} color="error" wrap="wrap">{item}</Text>)}
629 {hint ? <Text key="hint" dimColor wrap="wrap">{hint}</Text> : null}
630 </Box>,
631 ];
632 };
633 const row = (id: string) => {
634 const job = p.jobs[id], module = p.modules[id];
635 const { glyph, color } = statusGlyph(job);
636 const time = job ? clock(job.elapsedMs) : '';
637 const live = !!job && ACTIVE.has(job.status);
638 const head = [
639 <Box key="glyph" width={1}><Text color={color} bold>{glyph}</Text></Box>,
640 runControl(id),
641 // With a run, the name opens and closes its log.
642 <Box key="name" flexGrow={1} flexShrink={1} overflow="hidden">{job ?
643 <Button key={`logs-${id}`} label={names[id] ?? id} plain dimColor={!module?.enabled || p.busy && p.selectedLogs?.id !== id} onPress={toggleLogs(id)} /> :
644 <Text wrap="truncate" bold={!!module?.enabled} dimColor={!module?.enabled}>{names[id] ?? id}</Text>}</Box>,
645 ];
646 const tail = <Box key="tail" flexDirection="row" flexShrink={0} gap={1}>
647 {time ? <Text key="time" dimColor>{time}</Text> : null}
648 </Box>;
649 // The name takes the free space, so results and actions stay right-aligned at any width.
650 // Narrow: a finished result fits the name line; only a live bar takes a second one.
651 const middle = <Box key="middle" flexDirection="row" flexShrink={0}>{progress(id, narrow ? 10 : 12)}</Box>;
652 return <Box key={`module-${id}`} flexDirection="column">
653 <Box key="line" flexDirection="row" gap={1}>{head}{narrow && live ? null : middle}{tail}</Box>
654 {narrow && live ? <Box key="progress" flexDirection="row" paddingLeft={4}>{progress(id, 10)}</Box> : null}
655 {problemBlock(id)}
656 {logBlock(id)}
657 </Box>;
658 };
659 const ids = sortModuleIds(visible(), p.modules, p.jobs, p.sort);
660 const configured = ['absent', 'valid'].includes(p.workspace?.moduleConfig?.status ?? 'absent');
661 const counts = summaryCounts(ids, p.jobs);
662 return <Box key="module-list" flexDirection="column">
663 <Box key="toolbar" flexDirection="row" justifyContent="space-between">
664 <Box key="summary" flexDirection="row" gap={2}>
665 <Box key="counts" flexDirection="row">
666 <Text key="passed" color="success">{`${counts.passed}`}</Text>
667 <Text key="sep-1" dimColor>/</Text>
668 <Text key="failed" color="error">{`${counts.failed}`}</Text>
669 <Text key="sep-2" dimColor>/</Text>
670 <Text key="total" dimColor>{`${counts.total}`}</Text>
671 </Box>
672 {enabled().length >= 2 ? button('start-all', '▶ Todos', 'start', 'all', allAllowed()) : null}
673 {/* Button takes no color at rest; the whole label is the target and turns red under the pointer. Hidden while nothing failed. */}
674 {failedIds().length ? <Box key="failed-action"><Button key="start-failed" label="↻ Apenas com erro" plain hover={{ color: 'error' }}
675 dimColor={p.busy || !failedAllowed()} onPress={guarded(async () => { if (failedAllowed()) await startFailed($, expected); })} /></Box> : null}
676 {p.busy ? <Text key="busy" dimColor>…</Text> : null}
677 </Box>
678 <Box key="toolbar-actions" flexDirection="row" gap={1}>
679 {ids.length >= 2 ? local('sort', p.sort === 'order' ? '⇅' : `⇅ ${sortLabels[p.sort]}`, () => { p.sort = nextSort(p.sort); }) : null}
680 {local('help', '?', () => { p.showHelp = !p.showHelp; })}
681 </Box>
682 </Box>
683 {ids.map(row)}
684 {!ids.length && configured && !p.workspace?.error ? <Text key="empty" dimColor>Nenhum módulo em .claude/test-progress.json</Text> : null}
685 {p.chooseLogs ? <Text key="choose-logs" dimColor>Clique no nome de um módulo para ver o log.</Text> : null}
686 {p.selectedLogs && !ids.includes(p.selectedLogs.id) ? logBlock(p.selectedLogs.id) : null}
687 {p.workspace?.error ? <Text key="config-error" color="error" wrap="wrap">{`Configuração: ${p.workspace.error}`}</Text> : null}
688 {(p.workspace?.moduleConfig?.diagnostics ?? []).map((item, i) => <Text key={`config-${i}`} color="error" wrap="wrap">{`Configuração: ${diagnosticText(item)}`}</Text>)}
689 {(p.stateDiagnostics['*'] ?? []).map((item, i) => <Text key={`state-${i}`} color="error" wrap="wrap">{`Estado: ${diagnosticText(item)}`}</Text>)}
690 {p.lastError ? <Text key="last-error" color="error" wrap="wrap">{p.lastError}</Text> : null}
691 {p.registrationError ? <Text key="registration" color="error" wrap="wrap">{`Registro: ${p.registrationError}`}</Text> : null}
692 {p.showHelp ? LEGEND.map((line, i) => <Text key={`legend-${i}`} dimColor wrap="wrap">{line}</Text>) : null}
693 </Box>;
694 });
695 // With a log open, the wheel moves the log's own window; at its edges, and for the keys, the pane scrolls.
696 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
697 await hydrate($);
698 if (!p.selectedLogs || e.origin.kind !== 'person' || !e.pointer) return next(e);
699 const { top, max } = logWindow(), to = Math.min(max, Math.max(0, top + e.by));
700 if (to === top) return next(e);
701 p.logTop = to === max ? null : to;
702 await publish($);
703 return {};
704 });
705 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
706 const existing = await next(e);
707 if (e.props?.hasSurvey) return existing;
708 await read($, panel);
709 await hydrate($);
710 const seen = new Set(p.seenRuns);
711 const live = (id: string) => { const job = p.jobs[id]!; return ACTIVE.has(job.status) || !!job.recoveryRequired; };
712 const failed = (id: string) => { const job = p.jobs[id]!; return ['failed', 'error'].includes(job.status) || (job.failed ?? 0) > 0; };
713 const ids = visible().filter(id => p.jobs[id]);
714 const active = ids.filter(live), unseenFailures = ids.filter(id => !live(id) && failed(id) && !seen.has(p.jobs[id]!.runId));
715 // Nothing running and nothing new to report: the band steps aside.
716 if (!active.length && !unseenFailures.length) return existing;
717 const passed = ids.filter(id => !live(id) && !failed(id) && p.jobs[id]!.status === 'completed');
718 const shown = [...active, ...unseenFailures, ...passed];
719 const { Box, Text } = $.ui.resolve(e);
720 const names = displayNames();
721 const parts = (id: string) => {
722 const job = p.jobs[id]!, { glyph, color } = statusGlyph(job), pct = compactPercent(job);
723 return { glyph, color, name: names[id] ?? id, orphan: !!job.recoveryRequired,
724 progress: job.recoveryRequired || !live(id) ? '' : pct === '—' ? ` ${job.resolved ?? 0}` : ` ${pct}`, dim: pct === '—',
725 failed: !job.recoveryRequired && (job.failed ?? 0) > 0 ? ` ✗${job.failed}` : '' };
726 };
727 const item = (id: string) => {
728 const { glyph, color, name, orphan, progress, dim, failed } = parts(id);
729 return <Box key={`summary-${id}`} flexDirection="row">
730 <Text key="glyph" color={color}>{`${glyph} `}</Text>
731 <Text key="name">{name}</Text>
732 {orphan ? <Text key="state" color="error">{' órfão'}</Text> :
733 progress ? (dim ? <Text key="resolved" dimColor>{progress}</Text> : <Text key="pct">{progress}</Text>) : null}
734 {failed ? <Text key="failed" color="error">{failed}</Text> : null}
735 </Box>;
736 };
737 // As many items as the line holds, leaving room for the host's own corner control and the "+N" tail.
738 const SEP = ' · ', width = (s: string) => [...s].length;
739 const itemWidth = (id: string) => { const x = parts(id); return 2 + width(x.name) + (x.orphan ? 6 : width(x.progress)) + width(x.failed); };
740 const room = (typeof e.props?.bodyColumns === 'number' ? e.props.bodyColumns : 80) - BAND_RESERVE;
741 let fit = 0, used = 0;
742 for (const id of shown) {
743 const end = used + (fit ? SEP.length : 0) + itemWidth(id), rest = shown.length - fit - 1;
744 if (fit && end + (rest ? width(`${SEP}+${rest}`) : 0) > room) break;
745 fit++; used = end;
746 }
747 const children = shown.slice(0, fit).flatMap((id, i) => [...(i ? [<Text key={`sep-${i}`} dimColor>{SEP}</Text>] : []), item(id)]);
748 return <Box flexDirection="column">
749 {existing ?? null}
750 <Box key="band" flexDirection="row" overflow="hidden">
751 {children}
752 {shown.length > fit ? <Text key="more" dimColor>{`${SEP}+${shown.length - fit}`}</Text> : null}
753 </Box>
754 </Box>;
755 });
756};
757runner/module-presentation.mjs 257 lines1// Pure presentation rules shared by native surfaces and text. No host APIs.
2import { validModuleId } from './module-id.mjs';
3import { SCHEMA_VERSION } from './schema.mjs';
4export const ACTIVE = new Set(['preparing', 'running']);
5export const labels = { preparing: 'Preparando', running: 'Em execução', completed: 'Encerrado',
6 failed: 'Encerrado com falha', cancelled: 'Cancelado', error: 'Erro' };
7const record = value => value && typeof value === 'object' && !Array.isArray(value);
8export function validateEnvelope(data) {
9 const config = data?.workspace?.moduleConfig;
10 if (data?.schemaVersion !== SCHEMA_VERSION || typeof data.ok !== 'boolean' || !record(data.modules) ||
11 !record(data.jobs) || !record(data.stateDiagnostics) || !record(config) ||
12 !['absent', 'valid', 'invalid'].includes(config.status) ||
13 ![SCHEMA_VERSION, null].includes(config.schemaVersion) || !Array.isArray(config.enabledIds)) {
14 throw new Error(`Versão de resposta do coletor incompatível; requer schemaVersion ${SCHEMA_VERSION}.`);
15 }
16 for (const [id, module] of Object.entries(data.modules)) {
17 if (!validModuleId(id) || module?.id !== id || typeof module.label !== 'string' ||
18 !(module.language === null || typeof module.language === 'string') ||
19 !Number.isFinite(module.order) || typeof module.enabled !== 'boolean' ||
20 typeof module.directoryPresent !== 'boolean' || typeof module.origin !== 'string' || !Array.isArray(module.diagnostics)) {
21 throw new Error('Catálogo de módulos inválido na resposta do coletor.');
22 }
23 }
24 for (const [id, job] of Object.entries(data.jobs)) {
25 if (!validModuleId(id) || job?.schemaVersion !== SCHEMA_VERSION || job.moduleId !== id ||
26 typeof job.runId !== 'string' || !job.runId || job.source !== 'config' || !Object.prototype.hasOwnProperty.call(labels, job.status) ||
27 typeof job.phase !== 'string' || job.command !== undefined && !Array.isArray(job.command)) {
28 throw new Error('Snapshot de job incompatível na resposta do coletor.');
29 }
30 }
31 for (const [id, items] of Object.entries(data.stateDiagnostics)) {
32 if (id !== '*' && !validModuleId(id) || !Array.isArray(items)) throw new Error('Diagnósticos de estado inválidos na resposta do coletor.');
33 }
34 if (config.enabledIds.some(id => !validModuleId(id))) throw new Error('IDs de módulos inválidos na resposta do coletor.');
35 return data;
36}
37export function parseCommand(raw) {
38 const tokens = String(raw ?? '').trim().split(/\s+/).filter(Boolean);
39 const text = tokens.includes('--text');
40 const args = tokens.filter(token => token !== '--text');
41 const action = args[0] ?? 'status';
42 if (!['list', 'start', 'status', 'logs', 'cancel', 'help', 'paths'].includes(action) || args.length > 2 ||
43 (['list', 'help', 'paths'].includes(action) && args.length > 1) ||
44 (action === 'start' && args.length !== 2)) throw new Error('Use /test-progress help para consultar os comandos.');
45 const moduleId = args[1] ?? 'all';
46 if (moduleId !== 'all' && !validModuleId(moduleId)) throw new Error('ID de módulo inválido. Use list para ver os IDs.');
47 return { action, moduleId, text };
48}
49export function visibleModuleIds(modules, jobs, diagnostics) {
50 const enabled = Object.keys(modules).filter(id => modules[id].enabled)
51 .sort((a, b) => modules[a].order - modules[b].order || a.localeCompare(b));
52 const retained = new Set(Object.keys(jobs).filter(id => ACTIVE.has(jobs[id].status) || jobs[id].recoveryRequired));
53 for (const id of Object.keys(diagnostics)) if (id !== '*' && validModuleId(id) && diagnostics[id]?.length) retained.add(id);
54 return [...enabled, ...[...retained].filter(id => !enabled.includes(id)).sort()];
55}
56// Panel orderings, cycled by one toolbar button. `order` keeps the catalogue as visibleModuleIds returns it.
57export const SORTS = ['order', 'name', 'recent', 'attention'];
58export const sortLabels = { order: 'cadastro', name: 'nome', recent: 'recentes', attention: 'atenção' };
59export const nextSort = sort => SORTS[(SORTS.indexOf(sort) + 1) % SORTS.length];
60const startedAt = job => { const at = Date.parse(job?.startedAt ?? ''); return Number.isFinite(at) ? at : -Infinity; };
61// What needs a look first: failures and orphans, live runs, cancellations, passes, then modules never run.
62function attention(job) {
63 if (!job) return 4;
64 if (job.recoveryRequired || job.status === 'error') return 0;
65 if (ACTIVE.has(job.status)) return 1;
66 if (job.status === 'failed' || (job.failed ?? 0) > 0) return 0;
67 return job.status === 'cancelled' ? 2 : 3;
68}
69export function sortModuleIds(ids, modules, jobs, sort) {
70 const name = id => modules[id]?.label ?? id;
71 const by = {
72 name: (a, b) => name(a).localeCompare(name(b), 'pt-BR', { sensitivity: 'base', numeric: true }),
73 // Most recent run first; modules without a run keep catalogue order at the end.
74 recent: (a, b) => startedAt(jobs[b]) - startedAt(jobs[a]),
75 attention: (a, b) => attention(jobs[a]) - attention(jobs[b]) || startedAt(jobs[b]) - startedAt(jobs[a]),
76 }[sort];
77 if (!by) return ids;
78 const position = new Map(ids.map((id, i) => [id, i]));
79 return [...ids].sort((a, b) => by(a, b) || position.get(a) - position.get(b));
80}
81export const moduleTitle =(id, module) => `${module?.label ?? id} · ${id}`;
82export function percentage(job) {
83 return typeof job.percent === 'number' && Number.isFinite(job.percent) ? `${job.percent.toFixed(job.percent % 1 ? 1 : 0)}%` : '—';
84}
85export function progressText(job) {
86 if (job.total === 0) return '— [ sem testes ]';
87 if (job.total === null || job.total === undefined || typeof job.percent !== 'number' || !Number.isFinite(job.percent)) return '— [ total desconhecido ]';
88 const filled = Math.max(0, Math.min(16, Math.round(job.percent / 100 * 16)));
89 return `${percentage(job)} [${'■'.repeat(filled)}${'·'.repeat(16 - filled)}]`;
90}
91export function countSummary(job) {
92 const denominator = job.total == null ? 'total desconhecido' : `${job.total} no total${job.totalStable ? '' : ' · total parcial'}`;
93 return `${job.resolved ?? 0} resolvidos / ${denominator}`;
94}
95export const diagnosticText = item => typeof item === 'string' ? item : item?.message ?? item?.code ?? 'Diagnóstico indisponível.';
96export function sanitizeText(value) {
97 return String(value ?? '').replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
98 .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/\x1b[@-_]/g, '')
99 .replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '').replace(/\t/g, ' ');
100}
101// Log lines keep their SGR color sequences for the panel; every other escape and control goes.
102const SGR = /\x1b\[[\d;:]*m/g;
103export function sanitizeLogText(value) {
104 const text = String(value ?? '').replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '');
105 let result = '', last = 0;
106 for (const match of text.matchAll(SGR)) {
107 result += sanitizeText(text.slice(last, match.index)) + match[0];
108 last = match.index + match[0].length;
109 }
110 return result + sanitizeText(text.slice(last));
111}
112export const sanitizeTail = tail => Array.isArray(tail) ? tail.slice(-200).map(sanitizeLogText) : [];
113export const plainTail = tail => Array.isArray(tail) ? tail.slice(-200).map(sanitizeText) : [];
114// Named colors follow the person's terminal palette; the 256-color cube and truecolor become hex.
115const NAMES = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'];
116const named = index => (index < 8 ? NAMES[index] : `${NAMES[index - 8]}Bright`);
117const hex = (r, g, b) => `#${[r, g, b].map(part => Math.max(0, Math.min(255, part | 0)).toString(16).padStart(2, '0')).join('')}`;
118function palette(index) {
119 if (!(index >= 0 && index <= 255)) return undefined;
120 if (index < 16) return named(index);
121 if (index >= 232) { const level = 8 + (index - 232) * 10; return hex(level, level, level); }
122 const cube = index - 16, level = part => (part ? 55 + part * 40 : 0);
123 return hex(level(Math.floor(cube / 36)), level(Math.floor(cube / 6) % 6), level(cube % 6));
124}
125// Reads the extended color that starts at params[i] (38/48), returning it and how many params it used.
126function extended(params, i) {
127 if (params[i].length > 1) {
128 const parts = params[i];
129 if (parts[1] === 5) return [palette(parts[2]), 1];
130 if (parts[1] === 2) { const rgb = parts.slice(-3); return [rgb.length === 3 ? hex(...rgb) : undefined, 1]; }
131 return [undefined, 1];
132 }
133 const mode = params[i + 1]?.[0];
134 if (mode === 5) return [palette(params[i + 2]?.[0]), 3];
135 if (mode === 2) return [hex(params[i + 2]?.[0], params[i + 3]?.[0], params[i + 4]?.[0]), 5];
136 return [undefined, 1];
137}
138function applySgr(style, body) {
139 const params = (body === '' ? '0' : body).split(';').map(param => param.split(':').map(part => (part === '' ? 0 : Number(part))));
140 for (let i = 0; i < params.length;) {
141 const code = params[i][0];
142 if (code === 0) { for (const name of Object.keys(style)) delete style[name]; i++; continue; }
143 if (code === 38 || code === 48) {
144 const [color, used] = extended(params, i);
145 if (color) style[code === 38 ? 'color' : 'backgroundColor'] = color;
146 i += used; continue;
147 }
148 if (code === 1) style.bold = true;
149 else if (code === 2) style.dimColor = true;
150 else if (code === 3) style.italic = true;
151 else if (code === 4) style.underline = true;
152 else if (code === 7) style.inverse = true;
153 else if (code === 9) style.strikethrough = true;
154 else if (code === 22) { delete style.bold; delete style.dimColor; }
155 else if (code === 23) delete style.italic;
156 else if (code === 24) delete style.underline;
157 else if (code === 27) delete style.inverse;
158 else if (code === 29) delete style.strikethrough;
159 else if (code >= 30 && code <= 37) style.color = named(code - 30);
160 else if (code === 39) delete style.color;
161 else if (code >= 40 && code <= 47) style.backgroundColor = named(code - 40);
162 else if (code === 49) delete style.backgroundColor;
163 else if (code >= 90 && code <= 97) style.color = named(code - 90 + 8);
164 else if (code >= 100 && code <= 107) style.backgroundColor = named(code - 100 + 8);
165 i++;
166 }
167}
168// Splits a sanitized log line into runs of text and the SGR style each one carries (null when plain).
169export function ansiSpans(line) {
170 const text = String(line ?? '');
171 const spans = [], style = {};
172 let last = 0;
173 const push = value => {
174 if (!value) return;
175 const current = Object.keys(style).length ? { ...style } : null;
176 const previous = spans[spans.length - 1];
177 if (previous && JSON.stringify(previous.style) === JSON.stringify(current)) previous.text += value;
178 else spans.push({ text: value, style: current });
179 };
180 for (const match of text.matchAll(SGR)) {
181 push(text.slice(last, match.index));
182 applySgr(style, match[0].slice(2, -1));
183 last = match.index + match[0].length;
184 }
185 push(text.slice(last));
186 return spans;
187}
188// Compact panel and band vocabulary: one-column glyphs so terminal columns stay aligned.
189export function statusGlyph(job) {
190 if (!job) return { glyph: '○', color: 'inactive' };
191 if (job.recoveryRequired || job.status === 'error') return { glyph: '!', color: 'error' };
192 if (ACTIVE.has(job.status)) return job.phase === 'cancellation-requested' ?
193 { glyph: '◌', color: 'inactive' } : { glyph: '●', color: 'warning' };
194 if (job.status === 'cancelled') return { glyph: '■', color: 'inactive' };
195 if (job.status === 'failed' || (job.failed ?? 0) > 0) return { glyph: '✗', color: 'error' };
196 return { glyph: '✓', color: 'success' };
197}
198const knownPercent = job => job.total != null && job.total > 0 && typeof job.percent === 'number' && Number.isFinite(job.percent);
199// A thin bar while a run is live: done cells in the accent, the rest subtle. Unknown totals draw dashes.
200export function progressBar(job, cells = 12) {
201 if (!knownPercent(job)) return { done: '', rest: '╌'.repeat(cells) };
202 const filled = Math.max(0, Math.min(cells, Math.round(job.percent / 100 * cells)));
203 return { done: '━'.repeat(filled), rest: '━'.repeat(cells - filled) };
204}
205// A finished run reads as its result, not as a full bar.
206export function outcomeText(job) {
207 if (job.status === 'error') return { text: 'erro', color: 'error' };
208 if (job.status === 'cancelled') return { text: 'cancelado', color: 'inactive' };
209 if (job.total === 0) return { text: '-', color: 'inactive' };
210 // Under `exit` without events the exit code is the whole result.
211 if (job.total == null && job.adapter === 'exit' && Number.isInteger(job.exitCode)) return { text: `exit ${job.exitCode}`, color: 'inactive' };
212 if (job.total == null) return { text: 'total desconhecido', color: 'inactive' };
213 return { text: `· ${job.total}`, color: 'inactive' };
214}
215// A finished run that failed: a failed or errored status, or any failed test.
216export function finishedWithFailure(job) {
217 return !!job && !ACTIVE.has(job.status) && !job.recoveryRequired &&
218 (['failed', 'error'].includes(job.status) || (job.failed ?? 0) > 0);
219}
220// Toolbar summary S/E/T: modules that passed, modules that failed, all modules.
221export function summaryCounts(ids, jobs) {
222 const failed = ids.filter(id => finishedWithFailure(jobs[id])).length;
223 const passed = ids.filter(id => jobs[id]?.status === 'completed' && !finishedWithFailure(jobs[id])).length;
224 return { passed, failed, total: ids.length };
225}
226// Protocol lines are the collector's input, not the person's log; runs of blank lines collapse to one.
227// A line holding only color sequences is as blank as an empty one.
228const blank = line => !line.replace(SGR, '').trim();
229export function readableTail(lines, prefix) {
230 const kept = [];
231 for (const line of lines) {
232 if (line.includes(prefix)) continue;
233 if (blank(line) && (!kept.length || blank(kept[kept.length - 1]))) continue;
234 kept.push(line);
235 }
236 while (kept.length && blank(kept[kept.length - 1])) kept.pop();
237 return kept;
238}
239export function compactPercent(job) {
240 if (job.total === 0) return 'sem testes';
241 if (!knownPercent(job)) return '—';
242 // A finished run's total is final, whatever the stream last claimed.
243 return `${ACTIVE.has(job.status) && !job.totalStable ? '~' : ''}${percentage(job)}`;
244}
245// ⊘ renders flush against the next digit in common terminal fonts, so it keeps a space.
246export function compactCounts(job) {
247 return [['passed', '✓', 'success'], ['failed', '✗', 'error'], ['skipped', '⊘ ', 'inactive']]
248 .filter(([key]) => (job?.[key] ?? 0) > 0).map(([key, glyph, color]) => ({ text: `${glyph}${job[key]}`, color }));
249}
250export function clock(ms) {
251 if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return '';
252 const total = Math.floor(ms / 1000), h = Math.floor(total / 3600), m = Math.floor(total % 3600 / 60), s = total % 60;
253 const pad = value => String(value).padStart(2, '0');
254 return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
255}
256export const configStatus = { valid: 'válido', absent: 'ausente', invalid: 'inválido' };
257runner/module-id.mjs 14 lines1const reserved = new Set(['all', 'constructor', 'prototype', 'con', 'prn', 'aux', 'nul']);
2
3export function validModuleId(id) {
4 return typeof id === 'string' && /^[a-z][a-z0-9-]{0,47}$/.test(id) &&
5 !reserved.has(id) && !/^(?:com|lpt)[1-9]$/.test(id);
6}
7
8export function requireModuleId(id) {
9 if (validModuleId(id)) return id;
10 const error = new Error('ID de módulo inválido ou reservado.');
11 error.code = 'INVALID_MODULE_ID';
12 throw error;
13}
14runner/schema.mjs 3 lines1// Version of every persisted record (configuration, state, batches) and of the collector response.
2export const SCHEMA_VERSION = 1;
3types/index.d.ts 94 lines1// Test Progress state contract: what the pane and the band draw, held by the host so a hot reload keeps it.
2
3/** One module of the workspace catalogue, as the collector reports it. */
4export type TestProgressModule = {
5 id: string
6 label: string
7 language: string | null
8 order: number
9 enabled: boolean
10 directoryPresent: boolean
11 origin: string
12 diagnostics: TestProgressDiagnostic[]
13}
14
15/** A diagnostic is either plain text or a coded message. */
16export type TestProgressDiagnostic = string | { code?: string; message?: string; blocking?: boolean }
17
18/** One run's snapshot. Counts are observed, never estimated; `total: null` is unknown, not zero. */
19export type TestProgressJob = {
20 schemaVersion: number
21 moduleId: string
22 runId: string
23 source: 'config'
24 status: 'preparing' | 'running' | 'completed' | 'failed' | 'cancelled' | 'error'
25 phase: string
26 adapter?: string
27 total?: number | null
28 totalStable?: boolean
29 resolved?: number
30 passed?: number
31 failed?: number
32 skipped?: number
33 percent?: number | null
34 exitCode?: number | null
35 elapsedMs?: number
36 startedAt?: string | null
37 endedAt?: string | null
38 error?: string
39 recoveryRequired?: boolean
40 cancellable?: boolean
41 logPath?: string
42 logTail?: string[]
43 heartbeatAt?: string | null
44 lastOutputAt?: string | null
45 lastProgressAt?: string | null
46 collectorRuntime?: { version: string; source: string }
47 nodeRuntime?: { version: string; source: string; nvmrc?: string | null }
48}
49
50export type TestProgressWorkspace = {
51 moduleConfig?: {
52 status: 'absent' | 'valid' | 'invalid'
53 schemaVersion: number | null
54 enabledIds: string[]
55 diagnostics?: TestProgressDiagnostic[]
56 }
57 stateBlocked?: boolean
58 error?: string
59}
60
61/** The Node the collector bootstrapped, reused for later queries of the same identity. */
62export type TestProgressCollector = { identity: string; path: string; source: string }
63
64/** Everything the pane and band draw, plus the session identity it belongs to. */
65export type TestProgressPanel = {
66 identity: string
67 generation: number
68 sessionOwner: string
69 modules: Record<string, TestProgressModule>
70 jobs: Record<string, TestProgressJob>
71 stateDiagnostics: Record<string, TestProgressDiagnostic[]>
72 workspace: TestProgressWorkspace | null
73 busy: boolean
74 lastError: string
75 registrationError: string
76 selectedLogs: { id: string; runId: string } | null
77 logTail: string[]
78 /** First log line shown; null follows the end as the run writes. */
79 logTop: number | null
80 chooseLogs: boolean
81 showHelp: boolean
82 /** How the pane orders modules; kept across identities so a new session reads the same way. */
83 sort: 'order' | 'name' | 'recent' | 'attention'
84 /** Runs the person has seen in the pane; the band keeps only unseen failures. */
85 seenRuns: string[]
86 collector: TestProgressCollector | null
87}
88
89declare module 'claude-code' {
90 interface PluginState {
91 'test-progress': { panel: TestProgressPanel }
92 }
93}
94