@spec-wave/cli 0.28.0 → 0.30.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 +1 -1
- package/src/api/github-graphql.mjs +37 -0
- package/src/api/github-rest.mjs +21 -0
- package/src/cli.mjs +73 -6
- package/src/commands/audit.mjs +280 -0
- package/src/commands/doctor.mjs +83 -2
- package/src/commands/generate-qa-plan.mjs +421 -0
- package/src/commands/implement.mjs +12 -0
- package/src/commands/merge.mjs +292 -0
- package/src/commands/move.mjs +26 -11
- package/src/commands/order.mjs +42 -0
- package/src/commands/qa-run.mjs +813 -0
- package/src/commands/run.mjs +9 -4
- package/src/config.mjs +17 -1
- package/src/lib/artifact-pr.mjs +2 -0
- package/src/lib/board.mjs +18 -2
- package/src/lib/critique.mjs +98 -13
- package/src/lib/decomposition-doc.mjs +5 -1
- package/src/lib/doc-paths.mjs +5 -2
- package/src/lib/next-step.mjs +15 -3
- package/src/lib/pr-step.mjs +12 -7
- package/src/lib/qa-exec.mjs +314 -0
- package/src/lib/qa-plan-doc.mjs +340 -0
- package/src/lib/qa-report.mjs +340 -0
- package/src/lib/spec-audit.mjs +372 -0
- package/src/lib/tech-context.mjs +20 -14
- package/src/plugin/.claude-plugin/plugin.json +1 -1
- package/src/plugin/skills/audit/SKILL.md +34 -0
- package/src/plugin/skills/audit/model-prompt.critique.md +34 -0
- package/src/plugin/skills/merge/SKILL.md +34 -0
- package/src/plugin/skills/order/SKILL.md +1 -0
- package/src/plugin/skills/plan/model-prompt.md +1 -0
- package/src/plugin/skills/plan/reference/tech-context.md +6 -0
- package/src/plugin/skills/preparar-feature/SKILL.md +3 -1
- package/src/plugin/skills/preparar-specs/SKILL.md +21 -1
- package/src/plugin/skills/preparar-specs/reference/revisao.md +5 -2
- package/src/plugin/skills/qa/SKILL.md +105 -0
- package/src/plugin/skills/qa/model-prompt.critique.md +44 -0
- package/src/plugin/skills/qa/model-prompt.md +68 -0
- package/src/templates/config/tech_context.yml +13 -0
- package/src/templates/skill/SKILL.md +77 -4
- package/src/templates/workflows/generate-qa-plan.yml +64 -0
- package/src/templates/workflows/qa.yml +9 -1
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// Relatório de execução do QA — módulo PURO, sem I/O.
|
|
2
|
+
//
|
|
3
|
+
// O comentário de veredito é a FONTE DE ESTADO entre execuções: rodar com
|
|
4
|
+
// `--only` lê o último relatório da issue para compor o estado acumulado dos
|
|
5
|
+
// cenários que não rodaram desta vez. O marcador HTML da primeira linha é
|
|
6
|
+
// CONTRATO com a UI do SpecWave — mudar o formato exige bump de versão
|
|
7
|
+
// (`v1` → `v2`).
|
|
8
|
+
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { REQUIRED_BUG_SECTIONS } from '../config.mjs';
|
|
11
|
+
|
|
12
|
+
export const QA_REPORT_VERSION = 1;
|
|
13
|
+
|
|
14
|
+
/** Vereditos aceitos por cenário. */
|
|
15
|
+
export const QA_VERDICTS = ['pass', 'fail', 'blocked'];
|
|
16
|
+
|
|
17
|
+
export const QA_REPORT_MARKER_RE =
|
|
18
|
+
/<!--\s*spec-wave:qa-report\s+v(?<version>\d+)\s+issue=(?<issue>\d+)\s+run=(?<run>\d+)\s+verdict=(?<verdict>pass|fail|blocked)\s+planSha=(?<planSha>[0-9a-f]+)\s+headSha=(?<headSha>[0-9a-f?]+)\s*-->/g;
|
|
19
|
+
|
|
20
|
+
// Origem de um Bug aberto por reprovação de QA — gravado no CORPO da issue do
|
|
21
|
+
// Bug. É a chave da idempotência: re-executar o mesmo cenário reprovado não
|
|
22
|
+
// abre um segundo Bug, comenta no que já existe.
|
|
23
|
+
export const QA_ORIGIN_MARKER_RE =
|
|
24
|
+
/<!--\s*spec-wave:qa-origin\s+issue=(?<issue>\d+)\s+cenario=(?<cenario>\d+)\s*-->/;
|
|
25
|
+
|
|
26
|
+
/** Monta o marcador do relatório (função PURA). */
|
|
27
|
+
export function qaReportMarker({ issue, run, verdict, planSha, headSha }) {
|
|
28
|
+
return `<!-- spec-wave:qa-report v${QA_REPORT_VERSION} issue=${issue} run=${run} ` +
|
|
29
|
+
`verdict=${verdict} planSha=${planSha} headSha=${headSha || '?'} -->`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Monta o marcador de origem de um Bug de QA (função PURA). */
|
|
33
|
+
export function qaOriginMarker({ issue, cenario }) {
|
|
34
|
+
return `<!-- spec-wave:qa-origin issue=${issue} cenario=${cenario} -->`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* O corpo de um Bug aberto veio deste cenário? (função PURA)
|
|
39
|
+
*
|
|
40
|
+
* @param {string} body corpo da issue do Bug
|
|
41
|
+
* @param {{issue:number, cenario:number}} origem Story dona + número do cenário
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
export function matchesQaOrigin(body, { issue, cenario }) {
|
|
45
|
+
const m = QA_ORIGIN_MARKER_RE.exec(String(body || ''));
|
|
46
|
+
if (!m) return false;
|
|
47
|
+
return parseInt(m.groups.issue, 10) === issue && parseInt(m.groups.cenario, 10) === cenario;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** SHA curto (7 hex) do conteúdo do plano — vai no marcador do relatório. */
|
|
51
|
+
export function shortSha(content) {
|
|
52
|
+
return createHash('sha1').update(String(content ?? ''), 'utf-8').digest('hex').slice(0, 7);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Veredito agregado da execução (função PURA).
|
|
57
|
+
*
|
|
58
|
+
* `fail` vence `blocked` de propósito: com um defeito confirmado, o desfecho é
|
|
59
|
+
* vermelho (abre Bug) mesmo que outro cenário tenha ficado bloqueado. `blocked`
|
|
60
|
+
* sem nenhum `fail` é INCONCLUSIVO — ambiente quebrado não é defeito de
|
|
61
|
+
* produto, então nada move e nenhum Bug nasce.
|
|
62
|
+
*
|
|
63
|
+
* @param {Array<{verdict:string}>} results
|
|
64
|
+
* @returns {'pass'|'fail'|'blocked'}
|
|
65
|
+
*/
|
|
66
|
+
export function aggregateVerdict(results = []) {
|
|
67
|
+
if (results.some(r => r.verdict === 'fail')) return 'fail';
|
|
68
|
+
if (results.some(r => r.verdict === 'blocked')) return 'blocked';
|
|
69
|
+
return 'pass';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Valida o payload de resultados escrito pelo executor (função PURA).
|
|
74
|
+
*
|
|
75
|
+
* O executor (o agente acionado por `qa.command`) grava um JSON com o veredito
|
|
76
|
+
* de cada cenário. Fora do contrato → erro nomeando o campo: um resultado
|
|
77
|
+
* ilegível não pode virar aprovação nem reprovação.
|
|
78
|
+
*
|
|
79
|
+
* @param {*} payload objeto lido do arquivo de resultados
|
|
80
|
+
* @param {number[]} expected números dos cenários que DEVIAM ter sido executados
|
|
81
|
+
* @returns {Array<{numero:number, verdict:'pass'|'fail'|'blocked', evidencia:string}>}
|
|
82
|
+
*/
|
|
83
|
+
export function validateQaResults(payload, expected = []) {
|
|
84
|
+
if (payload === null || typeof payload !== 'object' || !Array.isArray(payload.scenarios)) {
|
|
85
|
+
throw new Error('o arquivo de resultados deveria ser {"scenarios": [...]}.');
|
|
86
|
+
}
|
|
87
|
+
const out = new Map();
|
|
88
|
+
payload.scenarios.forEach((item, i) => {
|
|
89
|
+
const at = `scenarios[${i}]`;
|
|
90
|
+
const numero = Number(item?.cenario);
|
|
91
|
+
if (!Number.isInteger(numero) || numero <= 0) {
|
|
92
|
+
throw new Error(`${at}.cenario deveria ser o número posicional do cenário, veio ${JSON.stringify(item?.cenario)}.`);
|
|
93
|
+
}
|
|
94
|
+
const verdict = String(item?.verdict || '').toLowerCase();
|
|
95
|
+
if (!QA_VERDICTS.includes(verdict)) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`${at}.verdict = ${JSON.stringify(item?.verdict)} não é aceito ` +
|
|
98
|
+
`(aceitos, exatamente: ${QA_VERDICTS.join(' | ')}).`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
out.set(numero, {
|
|
102
|
+
numero,
|
|
103
|
+
verdict,
|
|
104
|
+
evidencia: String(item?.evidencia ?? '').trim(),
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
const missing = expected.filter(n => !out.has(n));
|
|
108
|
+
if (missing.length > 0) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`faltou o veredito do(s) cenário(s) ${missing.join(', ')} — cenário que não pôde ` +
|
|
111
|
+
'ser executado deve vir como "blocked", nunca ser omitido.'
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return expected.map(n => out.get(n));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Compõe o estado acumulado quando a execução foi parcial (`--only`) —
|
|
119
|
+
* função PURA.
|
|
120
|
+
*
|
|
121
|
+
* Cenários não executados nesta corrida herdam o veredito do ÚLTIMO relatório;
|
|
122
|
+
* sem relatório anterior, contam como pendentes (nunca como pass): a aprovação
|
|
123
|
+
* exige que todos os alvos tenham passado em ALGUMA corrida registrada.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} params
|
|
126
|
+
* @param {Array<{numero:number, verdict:string, evidencia:string}>} params.executed
|
|
127
|
+
* @param {Map<number, {verdict:string}>} [params.previous] numero → resultado anterior
|
|
128
|
+
* @param {number[]} params.allNumbers todos os cenários do escopo
|
|
129
|
+
* @returns {{ combined: Array, pendingNumbers: number[] }}
|
|
130
|
+
*/
|
|
131
|
+
export function combineWithPrevious({ executed = [], previous = new Map(), allNumbers = [] } = {}) {
|
|
132
|
+
const executedBy = new Map(executed.map(r => [r.numero, r]));
|
|
133
|
+
const combined = [];
|
|
134
|
+
const pendingNumbers = [];
|
|
135
|
+
for (const n of allNumbers) {
|
|
136
|
+
if (executedBy.has(n)) {
|
|
137
|
+
combined.push({ ...executedBy.get(n), carried: false });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const prev = previous.get(n);
|
|
141
|
+
if (prev) {
|
|
142
|
+
combined.push({ numero: n, verdict: prev.verdict, evidencia: prev.evidencia || '(corrida anterior)', carried: true });
|
|
143
|
+
} else {
|
|
144
|
+
pendingNumbers.push(n);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { combined, pendingNumbers };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const RESULTS_JSON_RE = /```json spec-wave:qa-results\s*\n([\s\S]*?)\n```/;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Último relatório de QA de uma issue (função PURA).
|
|
154
|
+
*
|
|
155
|
+
* Lê os comentários em ordem cronológica; o mais recente vence. O estado por
|
|
156
|
+
* cenário sai do bloco JSON cercado — reparsear a tabela markdown seria frágil.
|
|
157
|
+
*
|
|
158
|
+
* @param {Array<{body?:string}>} comments comentários da issue, em ordem
|
|
159
|
+
* @returns {{ run: number, verdict: string|null, results: Map<number,object> }}
|
|
160
|
+
*/
|
|
161
|
+
export function parseLastQaReport(comments = []) {
|
|
162
|
+
let run = 0;
|
|
163
|
+
let verdict = null;
|
|
164
|
+
let results = new Map();
|
|
165
|
+
for (const comment of comments) {
|
|
166
|
+
const body = String(comment?.body || '');
|
|
167
|
+
QA_REPORT_MARKER_RE.lastIndex = 0;
|
|
168
|
+
const m = QA_REPORT_MARKER_RE.exec(body);
|
|
169
|
+
if (!m) continue;
|
|
170
|
+
run = Math.max(run, parseInt(m.groups.run, 10));
|
|
171
|
+
verdict = m.groups.verdict;
|
|
172
|
+
const bloco = body.match(RESULTS_JSON_RE);
|
|
173
|
+
if (!bloco) continue;
|
|
174
|
+
try {
|
|
175
|
+
const payload = JSON.parse(bloco[1]);
|
|
176
|
+
if (Array.isArray(payload?.scenarios)) {
|
|
177
|
+
results = new Map(payload.scenarios
|
|
178
|
+
.filter(s => Number.isInteger(s?.cenario))
|
|
179
|
+
.map(s => [s.cenario, { verdict: s.verdict, evidencia: s.evidencia || '' }]));
|
|
180
|
+
}
|
|
181
|
+
} catch { /* bloco ilegível não vira estado */ }
|
|
182
|
+
}
|
|
183
|
+
return { run, verdict, results };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const VERDICT_ICON = { pass: '✅ pass', fail: '❌ fail', blocked: '⚪ blocked' };
|
|
187
|
+
|
|
188
|
+
// Evidência entra numa célula de tabela markdown: sem quebra de linha, sem pipe
|
|
189
|
+
// e sem fechar o marcador HTML antes da hora.
|
|
190
|
+
function sanitizeCell(text, max = 200) {
|
|
191
|
+
const clean = String(text ?? '')
|
|
192
|
+
.replace(/-->/g, '--‑>')
|
|
193
|
+
.replace(/\|/g, '\\|')
|
|
194
|
+
.replace(/\s+/g, ' ')
|
|
195
|
+
.trim();
|
|
196
|
+
return clean.length > max ? `${clean.slice(0, max)}…` : clean;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Monta o comentário de relatório de QA (função PURA).
|
|
201
|
+
*
|
|
202
|
+
* Primeira linha = marcador (contrato com a UI). O bloco JSON no fim é o que a
|
|
203
|
+
* próxima execução com `--only` lê para compor o estado acumulado.
|
|
204
|
+
*
|
|
205
|
+
* @param {object} params
|
|
206
|
+
* @param {number} params.issue issue onde o relatório será postado
|
|
207
|
+
* @param {string} params.scope ex.: "Story #412" ou "Feature #360"
|
|
208
|
+
* @param {number} params.run número desta execução (1ª, 2ª…)
|
|
209
|
+
* @param {'pass'|'fail'|'blocked'} params.verdict veredito agregado
|
|
210
|
+
* @param {string} params.planSha sha curto do qa-plan.md executado
|
|
211
|
+
* @param {string} [params.headSha] sha curto do HEAD do checkout
|
|
212
|
+
* @param {Array<{numero, verdict, evidencia, carried?}>} params.results
|
|
213
|
+
* @param {Array<{number:number, cenario:number, existing?:boolean}>} [params.bugs]
|
|
214
|
+
* @param {number[]} [params.pendingNumbers] cenários sem veredito em corrida parcial
|
|
215
|
+
* @param {string} [params.trailer] linha final livre (o que acontece a seguir)
|
|
216
|
+
* @returns {string} markdown
|
|
217
|
+
*/
|
|
218
|
+
export function renderQaReport({
|
|
219
|
+
issue, scope, run, verdict, planSha, headSha, results = [], bugs = [],
|
|
220
|
+
pendingNumbers = [], trailer = '',
|
|
221
|
+
} = {}) {
|
|
222
|
+
const parts = [
|
|
223
|
+
qaReportMarker({ issue, run, verdict, planSha, headSha }),
|
|
224
|
+
'## 🧪 Relatório de QA (spec-wave)',
|
|
225
|
+
`**Escopo:** ${scope} · **Cenários:** ${results.length} · **Execução:** ${run}ª`,
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
const linhas = ['| Cenário | Veredito | Evidência |', '|---|---|---|'];
|
|
229
|
+
for (const r of results) {
|
|
230
|
+
const carried = r.carried ? ' _(corrida anterior)_' : '';
|
|
231
|
+
linhas.push(`| ${r.numero} | ${VERDICT_ICON[r.verdict] || r.verdict} | ${sanitizeCell(r.evidencia)}${carried} |`);
|
|
232
|
+
}
|
|
233
|
+
parts.push(linhas.join('\n'));
|
|
234
|
+
|
|
235
|
+
if (pendingNumbers.length > 0) {
|
|
236
|
+
parts.push(
|
|
237
|
+
`⚠️ Cenário(s) ainda **sem veredito** em nenhuma corrida: ${pendingNumbers.join(', ')} — ` +
|
|
238
|
+
'a aprovação só sai quando todos tiverem passado.'
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (bugs.length > 0) {
|
|
242
|
+
parts.push(
|
|
243
|
+
'**Bugs abertos:** ' +
|
|
244
|
+
bugs.map(b => `#${b.number} (cenário ${b.cenario}${b.existing ? ', já existia' : ''})`).join(', ')
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
if (trailer) parts.push(trailer);
|
|
248
|
+
|
|
249
|
+
parts.push([
|
|
250
|
+
'```json spec-wave:qa-results',
|
|
251
|
+
JSON.stringify({
|
|
252
|
+
issue,
|
|
253
|
+
run,
|
|
254
|
+
verdict,
|
|
255
|
+
scenarios: results.map(r => ({
|
|
256
|
+
cenario: r.numero, verdict: r.verdict, evidencia: sanitizeCell(r.evidencia, 400),
|
|
257
|
+
})),
|
|
258
|
+
}, null, 2),
|
|
259
|
+
'```',
|
|
260
|
+
].join('\n'));
|
|
261
|
+
|
|
262
|
+
return parts.join('\n\n');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* bug.md DETERMINÍSTICO de um cenário reprovado (função PURA).
|
|
267
|
+
*
|
|
268
|
+
* Exceção documentada à regra "nunca escreva o bug.md à mão" (spec §2.1):
|
|
269
|
+
* quando o Bug nasce de um cenário de QA reprovado, a reprodução, o
|
|
270
|
+
* esperado/obtido e o teste de regressão JÁ EXISTEM e são determinísticos — são
|
|
271
|
+
* o próprio cenário e a saída real da execução. Regenerar por IA só
|
|
272
|
+
* introduziria alucinação sobre uma execução observada.
|
|
273
|
+
*
|
|
274
|
+
* Emite EXATAMENTE as seis seções de REQUIRED_BUG_SECTIONS (o validate compara
|
|
275
|
+
* byte a byte com `# <seção>`).
|
|
276
|
+
*
|
|
277
|
+
* @param {object} params
|
|
278
|
+
* @param {string} params.title título do Bug (sem o prefixo [BUG])
|
|
279
|
+
* @param {{anchor, numero, story, criterio, precondicoes, passos, esperado}} params.scenario
|
|
280
|
+
* @param {string} params.evidence evidência bruta da reprovação
|
|
281
|
+
* @param {string} params.severity P0–P3
|
|
282
|
+
* @param {string} [params.headSha] commit do checkout onde a reprova aconteceu
|
|
283
|
+
* @param {number|null} [params.featureNumber]
|
|
284
|
+
* @returns {string} markdown
|
|
285
|
+
*/
|
|
286
|
+
export function renderQaBugDoc({
|
|
287
|
+
title, scenario, evidence, severity, headSha = null, featureNumber = null,
|
|
288
|
+
} = {}) {
|
|
289
|
+
const [
|
|
290
|
+
reproducao, esperadoObtido, impacto, causaRaiz, escopo, regressao,
|
|
291
|
+
] = REQUIRED_BUG_SECTIONS;
|
|
292
|
+
const passos = scenario.passos
|
|
293
|
+
? scenario.passos
|
|
294
|
+
: '_(o cenário não declara passos — ver o corpo do cenário no qa-plan.md)_';
|
|
295
|
+
const pre = scenario.precondicoes ? `**Pré-condições:** ${scenario.precondicoes}\n\n` : '';
|
|
296
|
+
return [
|
|
297
|
+
`# [BUG] ${title}`,
|
|
298
|
+
'',
|
|
299
|
+
`> Aberto automaticamente pela reprovação do **${scenario.anchor}** do plano de QA` +
|
|
300
|
+
`${featureNumber ? ` da Feature #${featureNumber}` : ''} (Story #${scenario.story})` +
|
|
301
|
+
`${headSha ? `, no commit \`${headSha}\`` : ''}. Conteúdo determinístico: cenário + saída real da execução.`,
|
|
302
|
+
'',
|
|
303
|
+
`## ${reproducao}`,
|
|
304
|
+
'',
|
|
305
|
+
pre + passos,
|
|
306
|
+
'',
|
|
307
|
+
`## ${esperadoObtido}`,
|
|
308
|
+
'',
|
|
309
|
+
`**Critério:** ${scenario.criterio || '—'}`,
|
|
310
|
+
'',
|
|
311
|
+
`**Esperado:** ${scenario.esperado || '—'}`,
|
|
312
|
+
'',
|
|
313
|
+
`**Obtido:** ${evidence || '(sem evidência registrada)'}`,
|
|
314
|
+
'',
|
|
315
|
+
`## ${impacto}`,
|
|
316
|
+
'',
|
|
317
|
+
`Severidade **${severity}**. O critério de aceite acima está descumprido na Story #${scenario.story} — ` +
|
|
318
|
+
'a Story não avança de 🧪 QA enquanto este Bug estiver aberto.',
|
|
319
|
+
'',
|
|
320
|
+
`## ${causaRaiz}`,
|
|
321
|
+
'',
|
|
322
|
+
'_A investigar durante a correção — este documento registra uma execução observada, ' +
|
|
323
|
+
'não uma hipótese de causa._',
|
|
324
|
+
'',
|
|
325
|
+
`## ${escopo}`,
|
|
326
|
+
'',
|
|
327
|
+
'_A definir na investigação. O fix precisa fazer o cenário abaixo passar sem alterar o critério de aceite._',
|
|
328
|
+
'',
|
|
329
|
+
`## ${regressao}`,
|
|
330
|
+
'',
|
|
331
|
+
`Re-executar o cenário reprovado após o fix:`,
|
|
332
|
+
'',
|
|
333
|
+
'```bash',
|
|
334
|
+
`npx @spec-wave/cli@latest qa ${scenario.story} --only ${scenario.numero}`,
|
|
335
|
+
'```',
|
|
336
|
+
'',
|
|
337
|
+
`O veredito precisa ser **pass** com o mesmo esperado: ${scenario.esperado || '—'}`,
|
|
338
|
+
'',
|
|
339
|
+
].join('\n');
|
|
340
|
+
}
|