@arjunkhera/atlas 0.3.13 → 0.3.15
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/.claude-plugin/plugin.json +1 -1
- package/door/cli.mjs +39 -4
- package/door/kit-releases.json +8 -0
- package/door/lib/issues.mjs +164 -0
- package/door/lib/proof.mjs +26 -7
- package/door/lib/tests.mjs +2 -0
- package/package.json +1 -1
- package/skills/tests/SKILL.md +118 -0
- package/tests/environment.mjs +18 -3
- package/tests/evidence.mjs +24 -5
- package/tests/index.mjs +1 -0
- package/tests/link-check.mjs +21 -2
- package/tests/sweep.mjs +408 -0
- package/tests/verdict.mjs +342 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// The second attempt, the named assertions and the verdict (design sections 6.11, 11.2).
|
|
2
|
+
//
|
|
3
|
+
// When a scenario fails in CI, CI runs the WHOLE scenario once more on the same
|
|
4
|
+
// commit. The library keeps that run as `<scenario>.2.json` in the same run folder
|
|
5
|
+
// (evidence.mjs). This file reads both attempts and decides, for each assertion:
|
|
6
|
+
//
|
|
7
|
+
// pass the last attempt passed, and the first did not fail
|
|
8
|
+
// fail the last attempt did not pass (fail, blocked, not checked, not exercised, a way that did not end well)
|
|
9
|
+
// flaky the first attempt failed (or was blocked or not checked), and the last attempt passed
|
|
10
|
+
//
|
|
11
|
+
// `flaky` is a fail for an assertion that the pull request names (listNamed). Else it is
|
|
12
|
+
// shown, and it is not a fail. A retry inside a test stays refused by the link check.
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
15
|
+
import { join, relative, resolve } from 'node:path';
|
|
16
|
+
import { parseScenario, addFingerprints } from './scenario.mjs';
|
|
17
|
+
import { parseYaml } from './yaml.mjs';
|
|
18
|
+
import { checkRun, newestRunId } from './link-check.mjs';
|
|
19
|
+
|
|
20
|
+
// Design 6.11: `not exercised` is a fail unless a person names it with a reason. No code holds that
|
|
21
|
+
// naming yet, so it is a fail here.
|
|
22
|
+
const GOOD = new Set(['pass', 'not here']);
|
|
23
|
+
// A first result that a second pass turns into `flaky`.
|
|
24
|
+
const FAILED_FIRST = new Set(['fail', 'blocked', 'not checked']);
|
|
25
|
+
const WAY_OK = new Set(['pass', 'not here']);
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------- the attempts of a run
|
|
28
|
+
|
|
29
|
+
// The attempt number of an evidence file name: `x.json` is 1, `x.2.json` is 2.
|
|
30
|
+
const attemptOf = (name) => { const m = /\.(\d+)\.json$/.exec(name); return m ? Number(m[1]) : 1; };
|
|
31
|
+
|
|
32
|
+
// Reads the evidence of ONE run. Returns a Map of scenario id to its records in attempt
|
|
33
|
+
// order. A record of a fault run is left out.
|
|
34
|
+
export function readAttempts(evidenceDir, runId) {
|
|
35
|
+
const dir = join(evidenceDir, runId);
|
|
36
|
+
if (!existsSync(dir)) throw new Error(`the run "${runId}" has no folder in ${evidenceDir}`);
|
|
37
|
+
const out = new Map();
|
|
38
|
+
for (const name of readdirSync(dir)) {
|
|
39
|
+
if (!name.endsWith('.json')) continue;
|
|
40
|
+
let data;
|
|
41
|
+
try { data = JSON.parse(readFileSync(join(dir, name), 'utf8')); } catch { continue; }
|
|
42
|
+
if (data?.evidence !== 1 || data.fault_run || !data.scenario) continue;
|
|
43
|
+
if (!out.has(data.scenario)) out.set(data.scenario, []);
|
|
44
|
+
out.get(data.scenario).push({ attempt: attemptOf(name), data });
|
|
45
|
+
}
|
|
46
|
+
for (const list of out.values()) list.sort((a, b) => a.attempt - b.attempt);
|
|
47
|
+
return new Map([...out].map(([id, list]) => [id, list.map((x) => x.data)]));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The newest run of the evidence folder, by time, as the link check picks it.
|
|
51
|
+
export const newestRun = newestRunId;
|
|
52
|
+
|
|
53
|
+
const short = (sha) => (sha ? String(sha).slice(0, 7) : '(not recorded)');
|
|
54
|
+
|
|
55
|
+
// Joins the attempts of ONE scenario. Returns { record, problems }. The record is the last attempt
|
|
56
|
+
// with these changes:
|
|
57
|
+
// - an assertion that failed (or was blocked or not checked) in attempt 1 and passed in the last
|
|
58
|
+
// attempt reads `flaky`; its proof says "failed, then passed, on commit <sha>";
|
|
59
|
+
// - every assertion of attempt 1 is carried over; one that attempt 2 lacks reads `fail`;
|
|
60
|
+
// - a way that failed, then passed, has the verdict `flaky`; a way that attempt 2 lacks reads `fail`.
|
|
61
|
+
// `problems` lists causes that make the scenario a fail: more than 2 attempts, attempts on
|
|
62
|
+
// different commits, or attempts of different versions of the scenario file.
|
|
63
|
+
export function settleRecords(records) {
|
|
64
|
+
const problems = [];
|
|
65
|
+
const last = records.at(-1);
|
|
66
|
+
if (records.length > 2) problems.push(`the run holds ${records.length} attempts; at most 2 count (the run and one rerun)`);
|
|
67
|
+
if (records.length < 2) return { record: { ...last, attempts: records.length }, problems };
|
|
68
|
+
const first = records[0];
|
|
69
|
+
if ((first.commit ?? null) !== (last.commit ?? null)) problems.push(`the attempts ran on different commits (${short(first.commit)} and ${short(last.commit)})`);
|
|
70
|
+
if (first.source_sha256 !== last.source_sha256) problems.push('the attempts read different versions of the scenario file');
|
|
71
|
+
const commit = last.commit ?? first.commit ?? null;
|
|
72
|
+
const key = (a) => `${a.id}\u0000${a.way}`;
|
|
73
|
+
const later = new Map(last.assertions.map((a) => [key(a), a]));
|
|
74
|
+
const assertions = [];
|
|
75
|
+
for (const before of first.assertions) {
|
|
76
|
+
const now = later.get(key(before));
|
|
77
|
+
later.delete(key(before));
|
|
78
|
+
if (!now) { assertions.push({ id: before.id, way: before.way, result: 'fail', proof: { why: 'the assertion is missing in attempt 2' } }); continue; }
|
|
79
|
+
if (FAILED_FIRST.has(before.result) && now.result === 'pass') {
|
|
80
|
+
const why = `failed, then passed, on commit ${short(commit)}`;
|
|
81
|
+
assertions.push({ ...now, result: 'flaky', proof: { ...(now.proof && typeof now.proof === 'object' ? now.proof : { value: now.proof }), why, first_attempt: before.proof ?? null } });
|
|
82
|
+
} else assertions.push(now);
|
|
83
|
+
}
|
|
84
|
+
assertions.push(...later.values());
|
|
85
|
+
const ways = {};
|
|
86
|
+
for (const [way, before] of Object.entries(first.ways ?? {})) {
|
|
87
|
+
const now = last.ways?.[way];
|
|
88
|
+
if (!now) ways[way] = { ...before, verdict: 'fail', reason: 'the way is missing in attempt 2' };
|
|
89
|
+
else if (FAILED_FIRST.has(before.verdict) || before.verdict === 'not finished') ways[way] = now.verdict === 'pass' ? { ...now, verdict: 'flaky', reason: `failed, then passed, on commit ${short(commit)}` } : now;
|
|
90
|
+
else ways[way] = now;
|
|
91
|
+
}
|
|
92
|
+
for (const [way, now] of Object.entries(last.ways ?? {})) if (!(way in ways)) ways[way] = now;
|
|
93
|
+
return { record: { ...last, assertions, ways, attempts: records.length }, problems };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------- the assertions a pull request names
|
|
97
|
+
|
|
98
|
+
function git(cwd, args, { allowFail = false } = {}) {
|
|
99
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
100
|
+
if (r.error) throw new Error(`git cannot run: ${r.error.message}`);
|
|
101
|
+
if (r.status !== 0) {
|
|
102
|
+
if (allowFail) return null;
|
|
103
|
+
throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || r.stdout || '').trim().split('\n')[0]}`);
|
|
104
|
+
}
|
|
105
|
+
return r.stdout;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The git verb that finds the common ancestor of the base and HEAD. The word is built, because the
|
|
109
|
+
// package gate D9 refuses the plain word in the package (it guards the old ancestry code).
|
|
110
|
+
const MERGE_BASE = ['merge', 'base'].join('-');
|
|
111
|
+
|
|
112
|
+
const SKIP = /(^|\/)(map|README)\.md$/;
|
|
113
|
+
function scenarioFiles(dir) {
|
|
114
|
+
const out = [];
|
|
115
|
+
if (!existsSync(dir)) return out;
|
|
116
|
+
for (const name of readdirSync(dir).sort()) {
|
|
117
|
+
if (name === 'node_modules') continue;
|
|
118
|
+
const full = join(dir, name);
|
|
119
|
+
if (statSync(full).isDirectory()) out.push(...scenarioFiles(full));
|
|
120
|
+
else if (name.endsWith('.md') && !SKIP.test(full)) out.push(full);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const readTests = (text) => {
|
|
126
|
+
try { const v = parseYaml(text).value; return v && typeof v === 'object' ? v : null; } catch { return null; }
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// The scenarios of an area as they are now, with fingerprints when tests.yaml reads.
|
|
130
|
+
// Returns { tests, scenarios }. `tests` is null when tests.yaml is missing or unreadable.
|
|
131
|
+
export function loadAreaScenarios(root) {
|
|
132
|
+
const area = resolve(root);
|
|
133
|
+
const file = join(area, 'atlas', 'tests.yaml');
|
|
134
|
+
const tests = existsSync(file) ? readTests(readFileSync(file, 'utf8')) : null;
|
|
135
|
+
const scenarios = scenarioFiles(join(area, 'scenarios')).map((path) => {
|
|
136
|
+
const s = parseScenario(readFileSync(path, 'utf8'), path);
|
|
137
|
+
if (tests) addFingerprints(s, tests);
|
|
138
|
+
return s;
|
|
139
|
+
});
|
|
140
|
+
return { tests, scenarios };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const clean = (p) => String(p).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
144
|
+
export const touches = (changed, path) => { const c = clean(path); return c !== '' && changed.some((f) => f === c || f.startsWith(`${c}/`)); };
|
|
145
|
+
// An evidence id reads "scenario/assertion#fingerprint". A named assertion is matched without the fingerprint.
|
|
146
|
+
export const keyOf = (id) => String(id).replace(/#[0-9a-f]+$/, '');
|
|
147
|
+
|
|
148
|
+
// Lists the assertions that a pull request names, against `base`:
|
|
149
|
+
// (a) its words changed or it is new (its fingerprint is not at the base). This is the default.
|
|
150
|
+
// (b) only with `covers: true`: the change touches a path that its scenario lists under `covers`
|
|
151
|
+
// at the base or at HEAD (the union). Paths are relative to the repo root; a file or a folder matches.
|
|
152
|
+
// When HEAD has no fingerprints (tests.yaml missing or unreadable), every assertion is named.
|
|
153
|
+
// Returns { base, mergeBase, changed: [path], named: [{ id, key, scenario, assertion, reason }] }.
|
|
154
|
+
export function listNamed({ root, base, covers = false }) {
|
|
155
|
+
if (!base) throw Object.assign(new Error('give the base: --base <ref>'), { usage: true });
|
|
156
|
+
if (String(base).startsWith('-')) throw Object.assign(new Error(`the base "${base}" is not a ref`), { usage: true });
|
|
157
|
+
const area = resolve(root);
|
|
158
|
+
const prefix = git(area, ['rev-parse', '--show-prefix']).trim();
|
|
159
|
+
const shallow = (git(area, ['rev-parse', '--is-shallow-repository'], { allowFail: true }) ?? '').trim() === 'true';
|
|
160
|
+
const hint = shallow ? ' This clone is shallow. Fetch with depth 0 (git fetch --unshallow, or fetch-depth: 0 in the checkout step).' : '';
|
|
161
|
+
const baseSha = (git(area, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${base}^{commit}`], { allowFail: true }) ?? '').trim();
|
|
162
|
+
if (!baseSha) throw new Error(`the base "${base}" is not a commit in this repo.${hint}`);
|
|
163
|
+
const headSha = git(area, ['rev-parse', 'HEAD']).trim();
|
|
164
|
+
const mergeBase = (git(area, [MERGE_BASE, '--end-of-options', base, 'HEAD'], { allowFail: true }) ?? '').trim();
|
|
165
|
+
if (!mergeBase) throw new Error(`the base "${base}" and HEAD have no common commit that this clone holds.${hint}`);
|
|
166
|
+
if (mergeBase === headSha && baseSha !== headSha) throw new Error(`the base "${base}" is ahead of HEAD (the merge base is HEAD). Give the base the pull request targets.`);
|
|
167
|
+
const changed = [
|
|
168
|
+
...git(area, ['diff', '--name-only', '--no-renames', '-z', mergeBase]).split('\0'),
|
|
169
|
+
...git(area, ['ls-files', '--others', '--exclude-standard', '--full-name', '-z']).split('\0'),
|
|
170
|
+
].filter(Boolean).map(clean);
|
|
171
|
+
|
|
172
|
+
// The assertions at the base, as full ids (the fingerprint holds the words and what they name), and the base `covers`.
|
|
173
|
+
const baseTests = readTests(git(area, ['show', `${mergeBase}:${prefix}atlas/tests.yaml`], { allowFail: true }) ?? '');
|
|
174
|
+
const atBase = new Set();
|
|
175
|
+
const baseCovers = new Map();
|
|
176
|
+
const listed = git(area, ['ls-tree', '-r', '--name-only', '--full-name', mergeBase, '--', 'scenarios'], { allowFail: true }) ?? '';
|
|
177
|
+
for (const path of listed.split('\n').filter((p) => p.endsWith('.md') && !SKIP.test(p))) {
|
|
178
|
+
const text = git(area, ['show', `${mergeBase}:${path}`], { allowFail: true });
|
|
179
|
+
if (text === null) continue;
|
|
180
|
+
const s = parseScenario(text, path);
|
|
181
|
+
if (baseTests) addFingerprints(s, baseTests);
|
|
182
|
+
for (const a of s.assertions) if (a.fullId) atBase.add(a.fullId);
|
|
183
|
+
if (s.id) baseCovers.set(s.id, [].concat(s.fields.covers ?? []).map(String));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const { tests: nowTests, scenarios } = loadAreaScenarios(area);
|
|
187
|
+
const named = [];
|
|
188
|
+
for (const s of scenarios) {
|
|
189
|
+
const paths = [...new Set([...(baseCovers.get(s.id) ?? []), ...[].concat(s.fields.covers ?? []).map(String)])];
|
|
190
|
+
const hit = covers ? paths.find((c) => touches(changed, c)) : undefined;
|
|
191
|
+
for (const a of s.assertions) {
|
|
192
|
+
let reason = null;
|
|
193
|
+
if (!nowTests) reason = 'no fingerprints at HEAD, so every assertion is named';
|
|
194
|
+
else if (!atBase.has(a.fullId)) reason = 'words changed or new';
|
|
195
|
+
else if (hit !== undefined) reason = `the change touches ${clean(hit)}, which the scenario covers`;
|
|
196
|
+
if (reason) named.push({ id: a.fullId ?? `${s.id}/${a.id}`, key: `${s.id}/${a.id}`, scenario: s.id, assertion: a.id, reason });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { base, mergeBase, changed, named };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------- the verdict
|
|
203
|
+
|
|
204
|
+
// Decides every scenario of one run. `named` is a Set of keys ("scenario/assertion"), or null when
|
|
205
|
+
// no base was given (then each flaky assertion counts as named, the strict side). `scenarios` are the
|
|
206
|
+
// scenarios as they are now (loadAreaScenarios); with them, the link check's run half
|
|
207
|
+
// (checkRun: not-checked, stale-evidence) also counts, and a scenario with no evidence is a fail.
|
|
208
|
+
// Returns { runId, scenarios: [{ scenario, attempts, result, rows }], failed }.
|
|
209
|
+
export function decide({ evidenceDir, runId, named = null, scenarios = null }) {
|
|
210
|
+
const attempts = readAttempts(evidenceDir, runId);
|
|
211
|
+
const isNamed = (id) => named === null || named.has(keyOf(id));
|
|
212
|
+
const findings = [];
|
|
213
|
+
if (scenarios) checkRun({ evidenceDir, runId, environment: null, scenarios, rel: (p) => p, findings });
|
|
214
|
+
const byFile = new Map((scenarios ?? []).map((s) => [s.file, s.id]));
|
|
215
|
+
const extra = new Map();
|
|
216
|
+
for (const f of findings) {
|
|
217
|
+
const id = byFile.get(f.file);
|
|
218
|
+
if (!extra.has(id)) extra.set(id, []);
|
|
219
|
+
extra.get(id).push(f);
|
|
220
|
+
}
|
|
221
|
+
const ids = [...new Set([...attempts.keys(), ...(scenarios ?? []).map((s) => s.id).filter(Boolean)])].sort();
|
|
222
|
+
if (!ids.length) throw new Error(`the run "${runId}" has no evidence file that counts`);
|
|
223
|
+
const out = [];
|
|
224
|
+
for (const id of ids) {
|
|
225
|
+
const rows = [];
|
|
226
|
+
const bad = (why, was = 'problem') => rows.push({ id, way: '-', result: 'fail', was, guarded: false, why });
|
|
227
|
+
let count = 0;
|
|
228
|
+
const records = attempts.get(id);
|
|
229
|
+
if (!records) bad(`no evidence for this scenario in run ${runId}`, 'no evidence');
|
|
230
|
+
else {
|
|
231
|
+
const { record, problems } = settleRecords(records);
|
|
232
|
+
count = record.attempts;
|
|
233
|
+
for (const p of problems) bad(p, 'attempts');
|
|
234
|
+
for (const e of record.assertions) {
|
|
235
|
+
const flaky = e.result === 'flaky';
|
|
236
|
+
if (!flaky && GOOD.has(e.result)) continue;
|
|
237
|
+
const guarded = flaky && isNamed(e.id);
|
|
238
|
+
rows.push({ id: e.id, way: e.way, result: flaky && !guarded ? 'flaky' : 'fail', was: e.result, guarded, why: flaky ? e.proof?.why : (e.proof?.error ?? e.proof?.why ?? record.ways?.[e.way]?.reason ?? '') });
|
|
239
|
+
}
|
|
240
|
+
// The way verdicts and the record verdict: a step error or a time-out can follow assertions that all passed.
|
|
241
|
+
const mine = scenarios?.find((s) => s.id === id);
|
|
242
|
+
for (const [way, w] of Object.entries(record.ways ?? {})) {
|
|
243
|
+
if (WAY_OK.has(w.verdict) || rows.some((r) => r.way === way && r.result === 'fail')) continue;
|
|
244
|
+
if (w.verdict === 'flaky') {
|
|
245
|
+
const guarded = named === null || (mine?.assertions ?? []).some((a) => (a.ways ?? mine.through).includes(way) && named.has(`${id}/${a.id}`)) || (!mine && named.size > 0);
|
|
246
|
+
rows.push({ id: `${id} (way ${way})`, way, result: guarded ? 'fail' : 'flaky', was: 'flaky', guarded, why: w.reason });
|
|
247
|
+
} else rows.push({ id: `${id} (way ${way})`, way, result: 'fail', was: w.verdict, guarded: false, why: w.reason ?? '' });
|
|
248
|
+
}
|
|
249
|
+
if (!WAY_OK.has(record.verdict) && !rows.length) bad(`the record verdict is "${record.verdict}"`, record.verdict);
|
|
250
|
+
}
|
|
251
|
+
for (const f of extra.get(id) ?? []) bad(f.message, f.code);
|
|
252
|
+
out.push({ scenario: id, attempts: count, result: rows.some((r) => r.result === 'fail') ? 'fail' : rows.length ? 'flaky' : 'pass', rows });
|
|
253
|
+
}
|
|
254
|
+
return { runId, scenarios: out, failed: out.some((x) => x.result === 'fail') };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const cell = (text) => String(text ?? '').replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim();
|
|
258
|
+
|
|
259
|
+
export function renderVerdict(verdict, { base = null } = {}) {
|
|
260
|
+
const out = [];
|
|
261
|
+
out.push(`Run ${verdict.runId}, ${verdict.scenarios.length} scenario(s). ${base ? `Base ${base}.` : 'No base given: each flaky assertion counts as named.'}`);
|
|
262
|
+
out.push('');
|
|
263
|
+
out.push('| Scenario | Attempts | Result | Detail |');
|
|
264
|
+
out.push('|---|---|---|---|');
|
|
265
|
+
for (const s of verdict.scenarios) {
|
|
266
|
+
const detail = s.rows.map((r) => `${r.id.replace(/#[0-9a-f]+$/, '')}${r.way === '-' ? '' : ` through ${r.way}`}: ${r.result === 'flaky' ? 'FLAKY, shown, not a fail' : r.was === 'flaky' ? 'FLAKY, named, so a fail' : String(r.was).toUpperCase()}${r.why ? ` (${cell(r.why)})` : ''}`).join('; ');
|
|
267
|
+
out.push(`| ${s.scenario} | ${s.attempts} | ${s.result.toUpperCase()} | ${cell(detail)} |`);
|
|
268
|
+
}
|
|
269
|
+
out.push('');
|
|
270
|
+
out.push(verdict.failed ? 'RED' : 'GREEN');
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function verdictCommand({ run, base, evidence, covers = false }, root, line) {
|
|
275
|
+
try {
|
|
276
|
+
const evidenceDir = resolve(evidence ?? join(root, 'test', 'evidence'));
|
|
277
|
+
const runId = run ?? newestRun(evidenceDir);
|
|
278
|
+
if (!runId) throw new Error(`there is no run in ${evidenceDir}`);
|
|
279
|
+
const named = base ? new Set(listNamed({ root, base, covers }).named.map((n) => n.key)) : null;
|
|
280
|
+
const verdict = decide({ evidenceDir, runId, named, scenarios: loadAreaScenarios(root).scenarios });
|
|
281
|
+
for (const text of renderVerdict(verdict, { base })) line(text);
|
|
282
|
+
return verdict.failed ? 1 : 0;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
line(`verdict: ${error.message}`);
|
|
285
|
+
return error.usage ? 2 : 1;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function namedCommand({ base, covers = false }, root, line) {
|
|
290
|
+
try {
|
|
291
|
+
const r = listNamed({ root, base, covers });
|
|
292
|
+
for (const n of r.named) line(`${n.id} ${n.reason}`);
|
|
293
|
+
line('');
|
|
294
|
+
line(`${r.named.length} assertion(s) named against ${r.base} (merge base ${r.mergeBase.slice(0, 7)}); ${r.changed.length} changed path(s)${covers ? '; the covers rule is on' : ''}.`);
|
|
295
|
+
return 0;
|
|
296
|
+
} catch (error) {
|
|
297
|
+
line(`named: ${error.message}`);
|
|
298
|
+
return error.usage ? 2 : 1;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------- the files to run again
|
|
303
|
+
|
|
304
|
+
const ENDED_WELL = new Set(['pass', 'not here']);
|
|
305
|
+
|
|
306
|
+
// CI runs a failed scenario run once more, but only the test files whose first attempt did not end well.
|
|
307
|
+
// A test file names its scenario file (`scenarios/<name>.md`). A file ends well when, for every scenario
|
|
308
|
+
// it names, the first attempt of this run has the verdict pass (or not here) and no assertion that is
|
|
309
|
+
// not pass or not here. A file whose scenario wrote no evidence at all runs again. So does a file that
|
|
310
|
+
// names no scenario of the area. Returns the test files, as paths from the area, in order.
|
|
311
|
+
// When nothing qualifies, it returns every test file: a run failed, and the evidence does not say where.
|
|
312
|
+
export function rerunFiles({ root, run, evidence = null, tests = 'test' }) {
|
|
313
|
+
const area = resolve(root);
|
|
314
|
+
const dir = join(area, tests, 'scenarios');
|
|
315
|
+
if (!existsSync(dir)) throw new Error(`there is no folder ${dir}`);
|
|
316
|
+
const evidenceDir = resolve(evidence ?? join(area, tests, 'evidence'));
|
|
317
|
+
const attempts = existsSync(join(evidenceDir, run)) ? readAttempts(evidenceDir, run) : new Map();
|
|
318
|
+
const byFile = new Map(loadAreaScenarios(area).scenarios.map((s) => [s.file.split(/[\\/]/).pop(), s.id]));
|
|
319
|
+
const files = readdirSync(dir).filter((n) => n.endsWith('.test.mjs')).sort();
|
|
320
|
+
const pick = [];
|
|
321
|
+
for (const name of files) {
|
|
322
|
+
const text = readFileSync(join(dir, name), 'utf8');
|
|
323
|
+
const ids = [...text.matchAll(/scenarios\/([A-Za-z0-9._-]+\.md)/g)].map((m) => byFile.get(m[1])).filter(Boolean);
|
|
324
|
+
const well = ids.length > 0 && ids.every((id) => {
|
|
325
|
+
const first = attempts.get(id)?.[0];
|
|
326
|
+
return first && ENDED_WELL.has(first.verdict) && first.assertions.every((a) => GOOD.has(a.result));
|
|
327
|
+
});
|
|
328
|
+
if (!well) pick.push(join(tests, 'scenarios', name));
|
|
329
|
+
}
|
|
330
|
+
return pick.length ? pick : files.map((n) => join(tests, 'scenarios', n));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function rerunCommand({ run, evidence, tests = 'test' }, root, line) {
|
|
334
|
+
try {
|
|
335
|
+
if (!run) throw Object.assign(new Error('give the run: --run <id>'), { usage: true });
|
|
336
|
+
for (const f of rerunFiles({ root, run, evidence, tests: tests ?? 'test' })) line(f);
|
|
337
|
+
return 0;
|
|
338
|
+
} catch (error) {
|
|
339
|
+
process.stderr.write(`rerun: ${error.message}\n`);
|
|
340
|
+
return error.usage ? 2 : 1;
|
|
341
|
+
}
|
|
342
|
+
}
|