@spec-wave/cli 0.30.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/protocol/qa-result.v1.json +62 -0
- package/protocol/qa-trail-report.v1.json +113 -0
- package/src/api/github-graphql.mjs +6 -1
- package/src/api/github-rest.mjs +21 -0
- package/src/cli.mjs +80 -5
- package/src/commands/decompose.mjs +29 -3
- package/src/commands/doctor.mjs +102 -3
- package/src/commands/implement.mjs +56 -44
- package/src/commands/merge.mjs +43 -14
- package/src/commands/order.mjs +350 -96
- package/src/commands/qa-lead.mjs +748 -0
- package/src/commands/qa-run.mjs +104 -25
- package/src/config.mjs +15 -0
- package/src/lib/artifact-publish.mjs +5 -2
- package/src/lib/board.mjs +14 -0
- package/src/lib/dependency-map.mjs +300 -0
- package/src/lib/doc-paths.mjs +4 -0
- package/src/lib/git-retry.mjs +82 -0
- package/src/lib/net-cache.mjs +142 -0
- package/src/lib/qa-exec.mjs +23 -2
- package/src/lib/qa-lead-backend.mjs +213 -0
- package/src/lib/qa-lead.mjs +627 -0
- package/src/lib/qa-report.mjs +65 -9
- package/src/lib/skill-compose.mjs +234 -0
- package/src/lib/story-graph.mjs +256 -0
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/merge/SKILL.md +1 -0
- package/src/plugin/skills/order/SKILL.md +21 -5
- package/src/plugin/skills/qa/SKILL.md +3 -1
- package/src/plugin/skills/qa-executor/SKILL.md +76 -0
- package/src/plugin/skills/qa-lead/SKILL.md +89 -0
- package/src/templates/skill/SKILL.md +953 -298
- package/src/templates/skill/core.md +584 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Push com `pull --rebase` + retry — spec-qa-lead §3.2.
|
|
2
|
+
//
|
|
3
|
+
// O `qa` commita o bug.md no checkout e PUBLICA: com o `qa-lead` despachando N
|
|
4
|
+
// containers em paralelo, um commit que fica só no container morre com ele — e
|
|
5
|
+
// dois containers reprovando ao mesmo tempo fazem o segundo push ser rejeitado.
|
|
6
|
+
// O retry cobre exatamente a rejeição de corrida (`non-fast-forward`,
|
|
7
|
+
// `cannot lock ref`); recusa DELIBERADA do remoto (GH006/branch protegida,
|
|
8
|
+
// auth) falha na primeira, porque repetir uma recusa de política só multiplica
|
|
9
|
+
// o custo — a mesma divisão do isTransientProviderError do lado da IA.
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
13
|
+
|
|
14
|
+
/** Tentativas de pull+push antes de desistir (teto da spec: 5). */
|
|
15
|
+
export const PUSH_MAX_ATTEMPTS = 5;
|
|
16
|
+
|
|
17
|
+
// Rejeições que significam "outro push chegou antes" — repetir resolve.
|
|
18
|
+
const TRANSIENT_RE = /non-fast-forward|cannot lock ref|fetch first|failed to push some refs|cannot rebase onto multiple branches|shallow update not allowed/i;
|
|
19
|
+
|
|
20
|
+
// Recusas de política/credencial — repetir NUNCA resolve.
|
|
21
|
+
const PERMANENT_RE = /GH006|protected branch|permission denied|authentication|403|could not read Username|denied to|no upstream branch|not a git repository|could not resolve host|unable to access/i;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A rejeição de push parece uma corrida perdida? (função PURA)
|
|
25
|
+
*
|
|
26
|
+
* Na dúvida (mensagem que não casa com nenhum padrão), transitória: o custo de
|
|
27
|
+
* uma tentativa a mais é segundos; o de desistir cedo é perder um Bug cujo
|
|
28
|
+
* cenário já foi pago.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} stderr saída de erro do git
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
export function isTransientPushRejection(stderr) {
|
|
34
|
+
const text = String(stderr || '');
|
|
35
|
+
if (PERMANENT_RE.test(text)) return false;
|
|
36
|
+
if (TRANSIENT_RE.test(text)) return true;
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function git(cwd, args) {
|
|
41
|
+
// stderr capturado de propósito: a classificação transitória/permanente
|
|
42
|
+
// depende de LER a rejeição — `stdio: 'inherit'` a jogaria no terminal.
|
|
43
|
+
return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `pull --rebase` + `push`, repetindo enquanto a rejeição for de corrida.
|
|
48
|
+
*
|
|
49
|
+
* Backoff linear com jitter (1s, 2s, 3s…): os perdedores da corrida se
|
|
50
|
+
* espalham em vez de colidirem de novo no mesmo instante.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} params
|
|
53
|
+
* @param {string} params.cwd raiz do checkout
|
|
54
|
+
* @param {number} [params.attempts]
|
|
55
|
+
* @param {(ms: number) => Promise<void>} [params.delay] injetável nos testes
|
|
56
|
+
* @returns {Promise<{ ok: boolean, attempts: number, error: string|null }>}
|
|
57
|
+
*/
|
|
58
|
+
export async function pushWithRebase({ cwd, attempts = PUSH_MAX_ATTEMPTS, delay = sleep } = {}) {
|
|
59
|
+
let lastError = null;
|
|
60
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
61
|
+
try {
|
|
62
|
+
try {
|
|
63
|
+
git(cwd, ['pull', '--rebase', '--autostash']);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
// Sem upstream configurado o pull falha, mas o push ainda pode passar
|
|
66
|
+
// (primeiro push da branch) — deixa o push decidir.
|
|
67
|
+
lastError = String(err.stderr || err.message || err);
|
|
68
|
+
}
|
|
69
|
+
git(cwd, ['push']);
|
|
70
|
+
return { ok: true, attempts: attempt, error: null };
|
|
71
|
+
} catch (err) {
|
|
72
|
+
lastError = String(err.stderr || err.message || err);
|
|
73
|
+
if (!isTransientPushRejection(lastError)) {
|
|
74
|
+
return { ok: false, attempts: attempt, error: lastError.trim() };
|
|
75
|
+
}
|
|
76
|
+
if (attempt < attempts) {
|
|
77
|
+
await delay(attempt * 1000 + Math.floor(Math.random() * 500));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { ok: false, attempts, error: (lastError || 'push rejeitado').trim() };
|
|
82
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Cache local de leituras do GitHub (I/O fino — as decisões de frescor são
|
|
2
|
+
// puras, em lib/dependency-map.mjs).
|
|
3
|
+
//
|
|
4
|
+
// Vive em `<root>/.spec-wave/cache/` — o diretório de scratch já gitignored:
|
|
5
|
+
// cache é POR CLONE, nunca commitado (o que é commitável é o
|
|
6
|
+
// dependency-map.json, que não expira). Regras que não se negociam:
|
|
7
|
+
//
|
|
8
|
+
// • best-effort nos dois sentidos: ler cache corrompido devolve null (e
|
|
9
|
+
// apaga), gravar NUNCA lança — cache indisponível vira refetch, não erro;
|
|
10
|
+
// • só SUCESSO entra: um `.catch(() => [])` de rede não pode virar uma lista
|
|
11
|
+
// vazia cacheada por 10 minutos — o dado errado com cara de fresco é pior
|
|
12
|
+
// que a chamada repetida que o cache existe para evitar;
|
|
13
|
+
// • entrada de outro owner/repo (worktree, config trocado) é ignorada;
|
|
14
|
+
// • mutação nunca lê cache e sempre INVALIDA o que tocou.
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
export const CACHE_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
/** Default do TTL: 10 minutos — cobre um encadeamento order → implement → merge. */
|
|
22
|
+
export const DEFAULT_CACHE_TTL_SEC = 600;
|
|
23
|
+
|
|
24
|
+
const CACHE_DIR = ['.spec-wave', 'cache'];
|
|
25
|
+
|
|
26
|
+
/** Caminho absoluto de uma entrada. */
|
|
27
|
+
export function cachePath(root, key) {
|
|
28
|
+
return path.join(root || process.cwd(), ...CACHE_DIR, `${sanitizeKey(key)}.json`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A chave vira nome de arquivo: nada de separadores de caminho.
|
|
32
|
+
function sanitizeKey(key) {
|
|
33
|
+
return String(key).replace(/[^A-Za-z0-9._-]/g, '_');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* TTL efetivo do cache (função PURA).
|
|
38
|
+
*
|
|
39
|
+
* Precedência: env `SPEC_WAVE_CACHE_TTL` (segundos; `0` desliga) →
|
|
40
|
+
* `cache.ttlSec` do .spec-wave.json → 600.
|
|
41
|
+
*
|
|
42
|
+
* @param {object|null} config o .spec-wave.json
|
|
43
|
+
* @param {object} [env]
|
|
44
|
+
* @returns {number} segundos (0 = desligado)
|
|
45
|
+
*/
|
|
46
|
+
export function resolveCacheTtl(config, env = process.env) {
|
|
47
|
+
const fromEnv = env?.SPEC_WAVE_CACHE_TTL;
|
|
48
|
+
if (fromEnv !== undefined && fromEnv !== '') {
|
|
49
|
+
const n = Number(fromEnv);
|
|
50
|
+
if (Number.isFinite(n) && n >= 0) return n;
|
|
51
|
+
}
|
|
52
|
+
const fromConfig = config?.cache?.ttlSec;
|
|
53
|
+
if (Number.isFinite(fromConfig) && fromConfig >= 0) return fromConfig;
|
|
54
|
+
return DEFAULT_CACHE_TTL_SEC;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Lê uma entrada do cache. `null` em qualquer defeito: ausente, JSON inválido,
|
|
59
|
+
* versão desconhecida, owner/repo (ou extra, ex.: projectId) divergentes.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} root raiz do projeto
|
|
62
|
+
* @param {string} key
|
|
63
|
+
* @param {{owner?:string, repo?:string, [k:string]: *}} [expect] campos que a
|
|
64
|
+
* entrada precisa bater para valer neste contexto
|
|
65
|
+
* @returns {{ kind:string, fetchedAt:string, data:* }|null}
|
|
66
|
+
*/
|
|
67
|
+
export function readCacheEntry(root, key, expect = {}) {
|
|
68
|
+
const file = cachePath(root, key);
|
|
69
|
+
let entry;
|
|
70
|
+
try {
|
|
71
|
+
entry = JSON.parse(readFileSync(file, 'utf-8'));
|
|
72
|
+
} catch {
|
|
73
|
+
// Corrompida não volta a atrapalhar: apaga best-effort.
|
|
74
|
+
try { rmSync(file, { force: true }); } catch { /* melhor esforço */ }
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (entry?.v !== CACHE_VERSION || typeof entry.fetchedAt !== 'string') return null;
|
|
78
|
+
for (const [field, expected] of Object.entries(expect)) {
|
|
79
|
+
if (expected != null && entry[field] !== expected) return null;
|
|
80
|
+
}
|
|
81
|
+
return entry;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Grava uma entrada. NUNCA lança — disco cheio/sem permissão vira no-op.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} root
|
|
88
|
+
* @param {string} key
|
|
89
|
+
* @param {string} kind ex.: 'board-items'
|
|
90
|
+
* @param {*} data resultado de uma chamada BEM-SUCEDIDA
|
|
91
|
+
* @param {object} [extra] campos de contexto (owner, repo, projectId…)
|
|
92
|
+
* @returns {boolean} true se gravou
|
|
93
|
+
*/
|
|
94
|
+
export function writeCacheEntry(root, key, kind, data, extra = {}) {
|
|
95
|
+
try {
|
|
96
|
+
const dir = path.join(root || process.cwd(), ...CACHE_DIR);
|
|
97
|
+
mkdirSync(dir, { recursive: true });
|
|
98
|
+
writeFileSync(cachePath(root, key), `${JSON.stringify({
|
|
99
|
+
v: CACHE_VERSION,
|
|
100
|
+
kind,
|
|
101
|
+
key: sanitizeKey(key),
|
|
102
|
+
fetchedAt: new Date().toISOString(),
|
|
103
|
+
...extra,
|
|
104
|
+
data,
|
|
105
|
+
})}\n`);
|
|
106
|
+
return true;
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Remove entradas por chave exata ou prefixo (`'blockedby-*'`). Best-effort.
|
|
114
|
+
*
|
|
115
|
+
* Chamada após qualquer ESCRITA que invalide a leitura cacheada — mover board,
|
|
116
|
+
* mergear, criar issues.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} root
|
|
119
|
+
* @param {...string} keysOrPrefixes
|
|
120
|
+
*/
|
|
121
|
+
export function invalidateCache(root, ...keysOrPrefixes) {
|
|
122
|
+
const dir = path.join(root || process.cwd(), ...CACHE_DIR);
|
|
123
|
+
if (!existsSync(dir)) return;
|
|
124
|
+
let files;
|
|
125
|
+
try {
|
|
126
|
+
files = readdirSync(dir);
|
|
127
|
+
} catch {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
for (const spec of keysOrPrefixes) {
|
|
131
|
+
const raw = String(spec);
|
|
132
|
+
const isPrefix = raw.endsWith('*');
|
|
133
|
+
const base = sanitizeKey(isPrefix ? raw.slice(0, -1) : raw);
|
|
134
|
+
for (const file of files) {
|
|
135
|
+
const name = file.replace(/\.json$/, '');
|
|
136
|
+
const hit = isPrefix ? name.startsWith(base) : name === base;
|
|
137
|
+
if (hit) {
|
|
138
|
+
try { rmSync(path.join(dir, file), { force: true }); } catch { /* melhor esforço */ }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/lib/qa-exec.mjs
CHANGED
|
@@ -200,6 +200,23 @@ export function renderQaCommand(template, vars) {
|
|
|
200
200
|
return String(template).replace(/\{(\w+)\}/g, (m, key) => (key in vars ? vars[key] : m));
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Ambiente entregue ao executor (função PURA) — spec-qa-lead §3.1.
|
|
205
|
+
*
|
|
206
|
+
* A env do PROCESSO tem precedência sobre `qa.env`, chave a chave. O motivo é o
|
|
207
|
+
* paralelismo do `qa-lead`: `qa.env` é versionado com o valor da execução
|
|
208
|
+
* manual (`BASE_URL: http://localhost:3000`), e N containers simultâneos
|
|
209
|
+
* precisam cada um do SEU endereço — o Lead injeta o valor real na sessão, sem
|
|
210
|
+
* placeholder no arquivo (a variação é de runtime, não de configuração).
|
|
211
|
+
*
|
|
212
|
+
* @param {Record<string,string>} [configEnv] bloco `qa.env` do .spec-wave.json
|
|
213
|
+
* @param {Record<string,string>} [processEnv] env do processo (vence)
|
|
214
|
+
* @returns {Record<string,string>}
|
|
215
|
+
*/
|
|
216
|
+
export function qaProcessEnv(configEnv = {}, processEnv = {}) {
|
|
217
|
+
return { ...(configEnv || {}), ...(processEnv || {}) };
|
|
218
|
+
}
|
|
219
|
+
|
|
203
220
|
/**
|
|
204
221
|
* Monta o contexto entregue ao executor (função PURA).
|
|
205
222
|
*
|
|
@@ -238,7 +255,8 @@ export function buildQaContext({
|
|
|
238
255
|
lines.push('- Execute **um cenário por vez**, na ordem em que aparecem abaixo.');
|
|
239
256
|
lines.push('- Registre a **evidência bruta** de cada cenário (comando executado, saída, código de status).');
|
|
240
257
|
lines.push('- **NÃO corrija código.** QA não conserta: cenário reprovado vira Bug. Alterar o código durante a execução **invalida o veredito**.');
|
|
241
|
-
lines.push('- Cenário que **não pôde ser executado** (ambiente quebrado, seed que falhou, dependência fora do ar) é `blocked`, **nunca** `fail
|
|
258
|
+
lines.push('- Cenário que **não pôde ser executado** (ambiente quebrado, seed que falhou, dependência fora do ar) é `blocked`, **nunca** `fail` — `fail` falso cria um Bug falso e custa investigação de dev.');
|
|
259
|
+
lines.push('- **NÃO escreva no GitHub** (comentar, abrir issue, mover card) — o veredito volta pelo arquivo de resultados; quem escreve no GitHub é a CLI.');
|
|
242
260
|
lines.push('');
|
|
243
261
|
lines.push('### Como registrar o veredito');
|
|
244
262
|
lines.push('');
|
|
@@ -252,7 +270,10 @@ export function buildQaContext({
|
|
|
252
270
|
}, null, 2));
|
|
253
271
|
lines.push('```');
|
|
254
272
|
lines.push('');
|
|
255
|
-
lines.push('Um objeto por cenário-alvo, com o número POSICIONAL do cenário. Nenhum pode ser omitido.');
|
|
273
|
+
lines.push('Um objeto por cenário-alvo, com o número POSICIONAL do cenário. Nenhum pode ser omitido — se abortar no meio, os cenários não alcançados entram como `blocked` (resultado parcial honesto vale mais que ausência de resultado). Regras duras (a CLI recusa o arquivo fora delas):');
|
|
274
|
+
lines.push('');
|
|
275
|
+
lines.push('- `fail` exige `evidencia` não vazia (ela vira o bug.md do Bug aberto);');
|
|
276
|
+
lines.push('- `blocked` exige `blockedReason`, um de: `ambiente` · `setup-falhou` · `massa-de-dados` · `dependencia-nao-entregue` · `bloqueado-por-bug` · `credencial` · `outro` (este exige `evidencia` com o motivo).');
|
|
256
277
|
|
|
257
278
|
if (setup) {
|
|
258
279
|
lines.push('');
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Backends de execução do `qa-lead run` (rfc/spec-qa-lead.md §6.4).
|
|
2
|
+
//
|
|
3
|
+
// Interface única, duas implementações, escolhidas por `qa.lead.backend`:
|
|
4
|
+
//
|
|
5
|
+
// • `docker` (default desta entrega): um `docker run` por Feature, com rede
|
|
6
|
+
// isolada, clone próprio do checkout e cleanup garantido — inclusive na
|
|
7
|
+
// morte do Lead (handler de sinal registrado pelo comando): container órfão
|
|
8
|
+
// consome recurso do cliente.
|
|
9
|
+
// • `sandbox`: a interface existe, a implementação é follow-up — a Tarefa Zero
|
|
10
|
+
// confirmou que o spec-wave-sandbox ainda não expõe API de criação de
|
|
11
|
+
// sessão. Escolhê-lo recusa orientando, em vez de fingir que roda.
|
|
12
|
+
//
|
|
13
|
+
// O container NÃO compartilha o checkout do host: ele CLONA `/repo` (montado
|
|
14
|
+
// read-only) para um diretório próprio. Dois `qa` paralelos commitando bug.md
|
|
15
|
+
// no MESMO working tree corromperiam o index um do outro; com clones isolados,
|
|
16
|
+
// a contenção fica onde a §3.2 já a resolve — no push, com retry+rebase.
|
|
17
|
+
|
|
18
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
19
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
20
|
+
|
|
21
|
+
function sh(args, opts = {}) {
|
|
22
|
+
return execFileSync('docker', args, {
|
|
23
|
+
encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], ...opts,
|
|
24
|
+
}).trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Monta o script executado DENTRO do container (função PURA — testável).
|
|
29
|
+
*
|
|
30
|
+
* `qa.setup` roda antes do `qa` (§6.2: "Dentro do container: qa.setup →
|
|
31
|
+
* qa <feature>"); o endereço real do serviço chega por env injetada (§3.1).
|
|
32
|
+
*
|
|
33
|
+
* @param {object} params
|
|
34
|
+
* @param {number} params.issue
|
|
35
|
+
* @param {number[]|null} [params.only]
|
|
36
|
+
* @param {string|null} [params.setup] `qa.setup` do .spec-wave.json
|
|
37
|
+
* @param {string} params.cliVersion versão fixada da CLI
|
|
38
|
+
* @param {string|null} [params.originUrl] remoto real (o clone nasce apontando
|
|
39
|
+
* para /repo, que é read-only — sem isto o push do bug.md morre)
|
|
40
|
+
* @returns {string} script bash
|
|
41
|
+
*/
|
|
42
|
+
export function containerScript({ issue, only = null, setup = null, cliVersion, originUrl = null }) {
|
|
43
|
+
const lines = [
|
|
44
|
+
'set -euo pipefail',
|
|
45
|
+
'git clone /repo /spec-wave-work',
|
|
46
|
+
'cd /spec-wave-work',
|
|
47
|
+
'git config user.name "spec-wave-qa"',
|
|
48
|
+
'git config user.email "spec-wave-qa[bot]@users.noreply.github.com"',
|
|
49
|
+
];
|
|
50
|
+
if (originUrl) lines.push(`git remote set-url origin ${JSON.stringify(originUrl)}`);
|
|
51
|
+
if (setup) lines.push(setup);
|
|
52
|
+
const onlyFlag = only && only.length > 0 ? ` --only ${only.join(',')}` : '';
|
|
53
|
+
lines.push(`npx -y @spec-wave/cli@${cliVersion} qa ${issue}${onlyFlag}`);
|
|
54
|
+
return lines.join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Backend Docker — um container e uma rede por execução. */
|
|
58
|
+
export class DockerBackend {
|
|
59
|
+
/**
|
|
60
|
+
* @param {object} params
|
|
61
|
+
* @param {string} params.image `qa.lead.container.image`
|
|
62
|
+
* @param {string} params.checkoutDir raiz do clone do host (montada em /repo)
|
|
63
|
+
*/
|
|
64
|
+
constructor({ image, checkoutDir }) {
|
|
65
|
+
this.image = image;
|
|
66
|
+
this.checkoutDir = checkoutDir;
|
|
67
|
+
/** @type {Set<string>} containers vivos (para o cleanup de sinal) */
|
|
68
|
+
this.live = new Set();
|
|
69
|
+
/** @type {Set<string>} redes criadas */
|
|
70
|
+
this.networks = new Set();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** O docker responde? (best-effort — o preflight consome) */
|
|
74
|
+
available() {
|
|
75
|
+
try {
|
|
76
|
+
sh(['info', '--format', '{{.ServerVersion}}']);
|
|
77
|
+
return { ok: true, detail: null };
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return { ok: false, detail: String(err.stderr || err.message).split('\n')[0] };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A imagem existe localmente ou é puxável? (preflight) */
|
|
84
|
+
imageAvailable() {
|
|
85
|
+
try {
|
|
86
|
+
sh(['image', 'inspect', this.image]);
|
|
87
|
+
return { ok: true, detail: 'imagem presente localmente' };
|
|
88
|
+
} catch {
|
|
89
|
+
try {
|
|
90
|
+
sh(['pull', this.image], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
91
|
+
return { ok: true, detail: 'imagem puxada do registry' };
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return { ok: false, detail: String(err.stderr || err.message).split('\n')[0] };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Roda um script num container isolado até terminar ou estourar o timeout.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} params
|
|
102
|
+
* @param {string} params.name sufixo estável (ex.: `qa-318`)
|
|
103
|
+
* @param {string} params.script bash de containerScript()
|
|
104
|
+
* @param {Record<string,string>} [params.env]
|
|
105
|
+
* @param {number} params.timeoutMin
|
|
106
|
+
* @param {(line: string) => void} [params.onLog]
|
|
107
|
+
* @returns {Promise<{ ok: boolean, timedOut: boolean, code: number|null, durationSec: number }>}
|
|
108
|
+
*/
|
|
109
|
+
async run({ name, script, env = {}, timeoutMin, onLog = () => {} }) {
|
|
110
|
+
const id = `spec-wave-${name}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
111
|
+
const network = `${id}-net`;
|
|
112
|
+
const started = Date.now();
|
|
113
|
+
|
|
114
|
+
sh(['network', 'create', network]);
|
|
115
|
+
this.networks.add(network);
|
|
116
|
+
|
|
117
|
+
const args = [
|
|
118
|
+
'run', '--rm', '--name', id, '--network', network,
|
|
119
|
+
'-v', `${this.checkoutDir}:/repo:ro`,
|
|
120
|
+
'-w', '/',
|
|
121
|
+
];
|
|
122
|
+
for (const [key, value] of Object.entries(env)) {
|
|
123
|
+
if (value == null || value === '') continue; // credencial vazia não entra
|
|
124
|
+
args.push('-e', `${key}=${value}`);
|
|
125
|
+
}
|
|
126
|
+
args.push(this.image, 'bash', '-lc', script);
|
|
127
|
+
|
|
128
|
+
this.live.add(id);
|
|
129
|
+
let timedOut = false;
|
|
130
|
+
const code = await new Promise((resolve) => {
|
|
131
|
+
const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
132
|
+
const feed = (chunk) => String(chunk).split('\n').filter(Boolean).forEach(onLog);
|
|
133
|
+
child.stdout.on('data', feed);
|
|
134
|
+
child.stderr.on('data', feed);
|
|
135
|
+
const timer = setTimeout(() => {
|
|
136
|
+
timedOut = true;
|
|
137
|
+
try { sh(['rm', '-f', id]); } catch { /* já morreu */ }
|
|
138
|
+
}, timeoutMin * 60 * 1000);
|
|
139
|
+
child.on('close', (exitCode) => {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
resolve(exitCode);
|
|
142
|
+
});
|
|
143
|
+
child.on('error', () => {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
resolve(null);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
this.live.delete(id);
|
|
149
|
+
try { sh(['network', 'rm', network]); this.networks.delete(network); } catch { /* fica para o cleanup */ }
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
ok: !timedOut && code === 0,
|
|
153
|
+
timedOut,
|
|
154
|
+
code,
|
|
155
|
+
durationSec: Math.round((Date.now() - started) / 1000),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Mata containers vivos e remove redes — chamado no fim E no sinal. */
|
|
160
|
+
async cleanup() {
|
|
161
|
+
for (const id of [...this.live]) {
|
|
162
|
+
try { sh(['rm', '-f', id]); } catch { /* já removido */ }
|
|
163
|
+
this.live.delete(id);
|
|
164
|
+
}
|
|
165
|
+
// A rede só desmonta depois que o container solta — uma tentativa tardia.
|
|
166
|
+
await sleep(200);
|
|
167
|
+
for (const net of [...this.networks]) {
|
|
168
|
+
try { sh(['network', 'rm', net]); this.networks.delete(net); } catch { /* melhor esforço */ }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Backend do spec-wave-sandbox — INTERFACE pronta, implementação follow-up.
|
|
175
|
+
*
|
|
176
|
+
* A Tarefa Zero (item 4) verificou que a API de sessão ainda não existe; até
|
|
177
|
+
* ela existir, escolher este backend é um erro orientado, não um stub que
|
|
178
|
+
* finge executar.
|
|
179
|
+
*/
|
|
180
|
+
export class SandboxBackend {
|
|
181
|
+
constructor() {
|
|
182
|
+
this.reason =
|
|
183
|
+
'O backend `sandbox` ainda não está disponível: o spec-wave-sandbox não expõe API de ' +
|
|
184
|
+
'criação de sessão (Tarefa Zero, rfc/spec-qa-lead.md). Use `qa.lead.backend: "docker"`.';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
available() {
|
|
188
|
+
return { ok: false, detail: this.reason };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
imageAvailable() {
|
|
192
|
+
return { ok: false, detail: this.reason };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async run() {
|
|
196
|
+
throw new Error(this.reason);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async cleanup() { /* nada a limpar */ }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Fabrica o backend configurado.
|
|
204
|
+
*
|
|
205
|
+
* @param {object} params
|
|
206
|
+
* @param {'docker'|'sandbox'} params.backend
|
|
207
|
+
* @param {string|null} params.image
|
|
208
|
+
* @param {string} params.checkoutDir
|
|
209
|
+
*/
|
|
210
|
+
export function createExecutionBackend({ backend, image, checkoutDir }) {
|
|
211
|
+
if (backend === 'sandbox') return new SandboxBackend();
|
|
212
|
+
return new DockerBackend({ image, checkoutDir });
|
|
213
|
+
}
|