@arjunkhera/atlas 0.3.12 → 0.3.14
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 +14 -3
- package/door/kit-releases.json +10 -1
- package/door/lib/proof.mjs +51 -8
- package/door/lib/tests.mjs +2 -0
- package/package.json +10 -2
- package/skills/tests/SKILL.md +21 -0
- package/tests/contract.mjs +48 -9
- package/tests/drivers/browser.mjs +364 -0
- package/tests/drivers/index.mjs +11 -4
- package/tests/environment.mjs +18 -3
- package/tests/evidence.mjs +30 -7
- package/tests/index.mjs +2 -1
- package/tests/link-check.mjs +22 -3
- package/tests/scenario.mjs +19 -0
- package/tests/verdict.mjs +300 -0
package/door/cli.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { writeDesignPage, readTracker } from './lib/design-build.mjs';
|
|
|
28
28
|
import { loadPrivateTerms } from './lib/privacy.mjs';
|
|
29
29
|
import { checkCommand as testsCheck, writeCommand as testsWrite } from './lib/tests.mjs';
|
|
30
30
|
import { proofCommand as testsProof } from './lib/proof.mjs';
|
|
31
|
+
import { verdictCommand as testsVerdict, namedCommand as testsNamed } from '../tests/verdict.mjs';
|
|
31
32
|
|
|
32
33
|
export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
33
34
|
const ATLAS_VERSION = packagePackageVersion(PACKAGE_ROOT);
|
|
@@ -63,6 +64,14 @@ const HELP = `atlas — the door into a repo's Atlas files
|
|
|
63
64
|
atlas tests proof --root <area> print the proof table of one run, one row for each assertion
|
|
64
65
|
--run <id> the run; default the newest run in the evidence folder
|
|
65
66
|
--own <file> JSON of the verifier's own checks: { id: { result, proof } }
|
|
67
|
+
--base <ref> show whether a flaky assertion is named by the pull request
|
|
68
|
+
atlas tests verdict --root <area> decide each scenario of a run from both attempts: pass, fail or flaky; exit 1 on a fail
|
|
69
|
+
--run <id> the run; default the newest run in the evidence folder
|
|
70
|
+
--base <ref> the base of the pull request; flaky is a fail for an assertion it names. With no base every flaky is a fail
|
|
71
|
+
--covers also name the assertions of a scenario whose covers path the change touches
|
|
72
|
+
atlas tests named --root <area> list the assertions that a pull request names
|
|
73
|
+
--base <ref> the base: an assertion is named when its words changed or it is new
|
|
74
|
+
--covers also name the assertions of a scenario whose covers path the change touches
|
|
66
75
|
|
|
67
76
|
atlas install install Atlas for every folder on this Mac
|
|
68
77
|
atlas upgrade [--version <v>] check, show and install a newer release
|
|
@@ -89,7 +98,7 @@ export const FLAGS = Object.freeze({
|
|
|
89
98
|
check: { root: 'optional', repo: 'value', 'fail-on': 'value' },
|
|
90
99
|
ste: { share: 'switch', terms: 'value' },
|
|
91
100
|
design: { tracker: 'value', out: 'value', draft: 'switch' },
|
|
92
|
-
tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value' },
|
|
101
|
+
tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', covers: 'switch' },
|
|
93
102
|
install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch' },
|
|
94
103
|
upgrade: { version: 'optional', yes: 'switch' },
|
|
95
104
|
doctor: {},
|
|
@@ -265,8 +274,10 @@ function testsCommand(chosen) {
|
|
|
265
274
|
return testsCheck({ halves: chosen.halves, evidence: chosen.evidence, tests: chosen.tests, guardsFrom: chosen['guards-from'], run: chosen.run }, root, line, PACKAGE_ROOT);
|
|
266
275
|
}
|
|
267
276
|
if (what === 'write') return testsWrite({ dryRun: Boolean(chosen['dry-run']) }, root, PACKAGE_ROOT, line);
|
|
268
|
-
if (what === 'proof') return testsProof({ run: chosen.run, own: chosen.own, evidence: chosen.evidence }, root, line);
|
|
269
|
-
|
|
277
|
+
if (what === 'proof') return testsProof({ run: chosen.run, own: chosen.own, evidence: chosen.evidence, base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
|
|
278
|
+
if (what === 'verdict') return testsVerdict({ run: chosen.run, base: chosen.base, evidence: chosen.evidence, covers: Boolean(chosen.covers) }, root, line);
|
|
279
|
+
if (what === 'named') return testsNamed({ base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
|
|
280
|
+
throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict or named.`);
|
|
270
281
|
}
|
|
271
282
|
|
|
272
283
|
function checkCommand(chosen) {
|
package/door/kit-releases.json
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"note": "Every kit of the folder tests/ that a release of Atlas shipped, as the kit hash of its manifest (the sha256 of the sorted lines of path and file hash). The kit of this package is always accepted and need not be listed. When a release changes anything in tests/, add the kit hash of the release before it here. atlas tests check reads this list; git history is never read.",
|
|
3
|
-
"releases": [
|
|
3
|
+
"releases": [
|
|
4
|
+
{
|
|
5
|
+
"kit_sha256": "ae0f32b8c6694fbffb8bdacd8bd919d27c1c1da6281a46779e521dfea2341565",
|
|
6
|
+
"first_package": "0.3.8"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"kit_sha256": "b2ddd9412d42f92c090432c0782aec498120395b2f8c7271006349c94b26c772",
|
|
10
|
+
"first_package": "0.3.13"
|
|
11
|
+
}
|
|
12
|
+
]
|
|
4
13
|
}
|
package/door/lib/proof.mjs
CHANGED
|
@@ -8,11 +8,15 @@
|
|
|
8
8
|
// { "<scenario>/e1": { "result": "pass", "proof": "revision 1" } }; a full id
|
|
9
9
|
// (with its #fingerprint) works as a key too. A way that is blocked or failed
|
|
10
10
|
// shows its reason in the proof column. Evidence of a fault run is left out.
|
|
11
|
+
// A scenario that kept files (screenshots of the browser driver) gets a list under the table:
|
|
12
|
+
// each file with its run folder, and the count of console errors and failed requests of its page.
|
|
13
|
+
// The pictures stay in the CI run (design section 8.3); the table holds their names only.
|
|
11
14
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
12
15
|
import { join, resolve } from 'node:path';
|
|
13
16
|
import { newestRunPerScenario } from '../../tests/link-check.mjs';
|
|
17
|
+
import { settleRecords, listNamed, keyOf } from '../../tests/verdict.mjs';
|
|
14
18
|
|
|
15
|
-
const MAX_CELL =
|
|
19
|
+
const MAX_CELL = 200;
|
|
16
20
|
|
|
17
21
|
const clean = (text) => String(text ?? '').replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim();
|
|
18
22
|
const clip = (text, n = MAX_CELL) => { const t = clean(text); return t.length > n ? `${t.slice(0, n - 3)}...` : t; };
|
|
@@ -23,6 +27,7 @@ function summary(value) {
|
|
|
23
27
|
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
const attemptNumber = (name) => { const m = /\.(\d+)\.json$/.exec(name); return m ? Number(m[1]) : 1; };
|
|
26
31
|
const usage = (message) => Object.assign(new Error(message), { usage: true });
|
|
27
32
|
|
|
28
33
|
// Reads the --own file. Throws an Error with a plain message on a bad file.
|
|
@@ -36,11 +41,14 @@ export function readOwn(file) {
|
|
|
36
41
|
return data;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
|
|
44
|
+
// `named` is a Set of "scenario/assertion" keys (from listNamed), or null when the proof gets no base.
|
|
45
|
+
export function buildProof({ evidenceDir, runId = null, own = {}, named = null }) {
|
|
40
46
|
const perScenario = runId ? null : newestRunPerScenario(evidenceDir);
|
|
41
47
|
if (!runId && !perScenario.size) throw new Error(`there is no run in ${evidenceDir}`);
|
|
42
48
|
const names = runId ? [runId] : [...new Set(perScenario.values())].sort();
|
|
43
49
|
const newest = new Map();
|
|
50
|
+
const attempts = new Map();
|
|
51
|
+
const attemptProblems = [];
|
|
44
52
|
const used = new Set();
|
|
45
53
|
let faultRuns = 0;
|
|
46
54
|
for (const id of names) {
|
|
@@ -54,20 +62,41 @@ export function buildProof({ evidenceDir, runId = null, own = {} }) {
|
|
|
54
62
|
if (data.fault_run) { faultRuns += 1; continue; }
|
|
55
63
|
if (perScenario && perScenario.get(data.scenario) !== id) continue;
|
|
56
64
|
used.add(id);
|
|
57
|
-
const
|
|
58
|
-
if (!
|
|
65
|
+
const key = `${id}\u0000${data.scenario}`;
|
|
66
|
+
if (!attempts.has(key)) attempts.set(key, []);
|
|
67
|
+
attempts.get(key).push({ name, data });
|
|
59
68
|
}
|
|
60
69
|
}
|
|
70
|
+
// Several attempts of one scenario in one run (<scenario>.json, <scenario>.2.json) are joined:
|
|
71
|
+
// an assertion that failed, then passed, reads FLAKY. Of two runs of a scenario, the newest counts.
|
|
72
|
+
for (const list of attempts.values()) {
|
|
73
|
+
list.sort((a, b) => (attemptNumber(a.name) - attemptNumber(b.name)));
|
|
74
|
+
const { record: settled, problems } = settleRecords(list.map((x) => x.data));
|
|
75
|
+
for (const p of problems) attemptProblems.push(`${settled.scenario}: ${p}`);
|
|
76
|
+
const before = newest.get(settled.scenario);
|
|
77
|
+
if (!before || String(settled.started) >= String(before.started)) newest.set(settled.scenario, settled);
|
|
78
|
+
}
|
|
61
79
|
const id = runId ?? [...used].sort().join(', ');
|
|
62
80
|
if (!newest.size) throw new Error(`the run "${id}" has no evidence file that counts${faultRuns ? ' (only fault runs)' : ''}`);
|
|
63
81
|
const scenarios = [...newest.values()].sort((a, b) => (a.scenario < b.scenario ? -1 : 1));
|
|
64
82
|
const ways = [...new Set(scenarios.flatMap((s) => Object.keys(s.ways ?? {})))];
|
|
65
83
|
const rows = [];
|
|
66
|
-
const blocked = [];
|
|
84
|
+
const blocked = [...attemptProblems];
|
|
85
|
+
const screenshots = [];
|
|
86
|
+
const pageEvents = [];
|
|
67
87
|
for (const s of scenarios) {
|
|
68
88
|
for (const [way, w] of Object.entries(s.ways ?? {})) {
|
|
69
89
|
if (w.verdict === 'blocked' || w.verdict === 'fail') blocked.push(`${s.scenario} through ${way} is ${w.verdict}${w.reason ? `: ${clip(w.reason, 200)}` : ''}`);
|
|
70
90
|
}
|
|
91
|
+
for (const file of s.files ?? []) {
|
|
92
|
+
if (/\.(png|jpe?g|webp)$/i.test(file)) screenshots.push({ scenario: s.scenario, file, run: s.run_id });
|
|
93
|
+
}
|
|
94
|
+
const events = (s.calls ?? []).map((c) => c.request?.event).filter(Boolean);
|
|
95
|
+
// A scenario that kept a picture used a page, so a count of 0 is news too.
|
|
96
|
+
if (events.length || (s.files ?? []).some((f) => /\.(png|jpe?g|webp)$/i.test(f))) {
|
|
97
|
+
const count = (kind) => events.filter((e) => e === kind).length;
|
|
98
|
+
pageEvents.push({ scenario: s.scenario, consoleErrors: count('console-error') + count('page-error'), failedRequests: count('request-failed'), httpErrors: count('http-error'), stopped: count('request-stopped') });
|
|
99
|
+
}
|
|
71
100
|
const ids = [...new Set((s.assertions ?? []).map((a) => a.id))];
|
|
72
101
|
for (const full of ids) {
|
|
73
102
|
const short = full.replace(/#[0-9a-f]+$/, '');
|
|
@@ -78,7 +107,10 @@ export function buildProof({ evidenceDir, runId = null, own = {} }) {
|
|
|
78
107
|
}));
|
|
79
108
|
const problems = entries.filter((e) => e.result !== 'pass' && e.result !== 'not here').map((e) => {
|
|
80
109
|
const why = e.proof?.error ?? e.proof?.why ?? s.ways?.[e.way]?.reason ?? '';
|
|
81
|
-
|
|
110
|
+
const note = e.result !== 'flaky' ? ''
|
|
111
|
+
: named === null ? ' (FLAKY counts as a fail unless the pull request does not name it)'
|
|
112
|
+
: named.has(keyOf(full)) ? ' (FLAKY, named, so a fail)' : ' (FLAKY, shown, not a fail)';
|
|
113
|
+
return `${e.way} ${e.result}${why ? `: ${why}` : ''}${note}`;
|
|
82
114
|
});
|
|
83
115
|
const passed = entries.find((e) => e.result === 'pass' && e.proof?.value !== undefined);
|
|
84
116
|
const mine = own[full] ?? own[short] ?? null;
|
|
@@ -92,7 +124,7 @@ export function buildProof({ evidenceDir, runId = null, own = {} }) {
|
|
|
92
124
|
});
|
|
93
125
|
}
|
|
94
126
|
}
|
|
95
|
-
return { runId: id, environment: scenarios[0].environment, ways, rows, blocked, faultRuns, scenarios: scenarios.map((s) => s.scenario) };
|
|
127
|
+
return { runId: id, environment: scenarios[0].environment, ways, rows, blocked, faultRuns, screenshots, pageEvents, scenarios: scenarios.map((s) => s.scenario) };
|
|
96
128
|
}
|
|
97
129
|
|
|
98
130
|
export function renderProof(proof) {
|
|
@@ -107,6 +139,16 @@ export function renderProof(proof) {
|
|
|
107
139
|
out.push('Blocked or failed ways in:');
|
|
108
140
|
for (const b of proof.blocked) out.push(`- ${clean(b)}`);
|
|
109
141
|
}
|
|
142
|
+
if (proof.screenshots.length) {
|
|
143
|
+
out.push('');
|
|
144
|
+
out.push('Screenshots (kept in test/evidence of the run; the CI run holds them for 14 days):');
|
|
145
|
+
for (const shot of proof.screenshots) out.push(`- ${clean(shot.scenario)}: test/evidence/${clean(shot.run)}/${clean(shot.file)}`);
|
|
146
|
+
}
|
|
147
|
+
if (proof.pageEvents.length) {
|
|
148
|
+
out.push('');
|
|
149
|
+
out.push('Page events:');
|
|
150
|
+
for (const e of proof.pageEvents) out.push(`- ${clean(e.scenario)}: ${e.consoleErrors} console error(s), ${e.failedRequests} failed request(s), ${e.httpErrors} HTTP error answer(s), ${e.stopped} request(s) stopped by the guards`);
|
|
151
|
+
}
|
|
110
152
|
if (proof.faultRuns) {
|
|
111
153
|
out.push('');
|
|
112
154
|
out.push(`${proof.faultRuns} evidence file(s) of fault runs were left out.`);
|
|
@@ -117,8 +159,9 @@ export function renderProof(proof) {
|
|
|
117
159
|
export function proofCommand(chosen, root, line) {
|
|
118
160
|
try {
|
|
119
161
|
const own = chosen.own ? readOwn(resolve(chosen.own)) : {};
|
|
162
|
+
const named = chosen.base ? new Set(listNamed({ root, base: chosen.base, covers: Boolean(chosen.covers) }).named.map((n) => n.key)) : null;
|
|
120
163
|
const evidenceDir = resolve(chosen.evidence ?? join(root, 'test', 'evidence'));
|
|
121
|
-
for (const text of renderProof(buildProof({ evidenceDir, runId: chosen.run ?? null, own }))) line(text);
|
|
164
|
+
for (const text of renderProof(buildProof({ evidenceDir, runId: chosen.run ?? null, own, named }))) line(text);
|
|
122
165
|
return 0;
|
|
123
166
|
} catch (error) {
|
|
124
167
|
line(`proof: ${error.message}`);
|
package/door/lib/tests.mjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// atlas tests check --root <area> run the link check on an area
|
|
4
4
|
// atlas tests write --root <area> copy the shipped test kit into <area>/test/atlas/
|
|
5
5
|
// atlas tests proof --root <area> print the proof table of one run (proof.mjs)
|
|
6
|
+
// atlas tests verdict --root <area> --run <id> [--base <ref>] decide each scenario: pass, fail or flaky (tests/verdict.mjs)
|
|
7
|
+
// atlas tests named --root <area> --base <ref> list the assertions that a pull request names (tests/verdict.mjs)
|
|
6
8
|
//
|
|
7
9
|
// The kit is the folder `tests/` of this package: the contract library, the
|
|
8
10
|
// drivers, the stand-in helper and the link check. `write` copies each file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arjunkhera/atlas",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14",
|
|
4
4
|
"description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -25,5 +25,13 @@
|
|
|
25
25
|
"agents/",
|
|
26
26
|
".claude-plugin/",
|
|
27
27
|
".mcp.json"
|
|
28
|
-
]
|
|
28
|
+
],
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"playwright": ">=1.40.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependenciesMeta": {
|
|
33
|
+
"playwright": {
|
|
34
|
+
"optional": true
|
|
35
|
+
}
|
|
36
|
+
}
|
|
29
37
|
}
|
package/skills/tests/SKILL.md
CHANGED
|
@@ -41,6 +41,27 @@ every file that command writes.
|
|
|
41
41
|
2. `atlas tests check --root <repo>` runs the link check. It prints one line
|
|
42
42
|
for each finding, as `file:line code words`. Run it before you ask for a merge.
|
|
43
43
|
3. `atlas tests proof --root <repo>` prints the proof table of one run.
|
|
44
|
+
4. `atlas tests verdict --root <repo> --run <id> --base <ref>` decides each
|
|
45
|
+
scenario of a run. Exit 1 means a scenario failed.
|
|
46
|
+
5. `atlas tests named --root <repo> --base <ref>` lists the assertions that a
|
|
47
|
+
pull request names.
|
|
48
|
+
|
|
49
|
+
When a scenario fails in CI, CI runs the whole scenario once more on the
|
|
50
|
+
same commit. The second run writes `<scenario>.2.json` in the same run folder.
|
|
51
|
+
Never retry inside a test. Then CI runs `atlas tests verdict`:
|
|
52
|
+
|
|
53
|
+
1. Both runs fail: the result is `fail`. More than two runs, or two runs on
|
|
54
|
+
different commits, are also a `fail`.
|
|
55
|
+
2. The first run fails and the second passes: each assertion that failed
|
|
56
|
+
reads `flaky`. The proof table shows "failed, then passed, on commit
|
|
57
|
+
<sha>".
|
|
58
|
+
3. A pull request names an assertion when its words changed or it is new.
|
|
59
|
+
`flaky` is a `fail` for a named assertion. For any other assertion it is
|
|
60
|
+
shown, and it is not a fail. The flag `--covers` also names the assertions
|
|
61
|
+
of a scenario whose `covers` path the change touches. It is off until the
|
|
62
|
+
owner decides.
|
|
63
|
+
4. The rule to open an item for each `flaky` result waits on the owner's
|
|
64
|
+
answer. Do not open one yet.
|
|
44
65
|
|
|
45
66
|
The link check has three halves. Run the first two before the tests and the
|
|
46
67
|
third after them:
|
package/tests/contract.mjs
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
// a stand-in made in this process, instead of a start command
|
|
35
35
|
// adapter.warmUp async (env) => {} make the product call its stand-ins once
|
|
36
36
|
// adapter.drivers { wayOrDriver: async (ctx) => driver } a driver the kit does not ship
|
|
37
|
+
// adapter.playwright the Playwright module, for a test of the browser driver that has none installed
|
|
37
38
|
// adapter.functions the module (or { way: module }) for the function driver
|
|
38
39
|
// adapter.loadModule async (file) => module loads the `module` of a function way in; the
|
|
39
40
|
// kit gives the path under the product root, so a fault run loads the copy
|
|
@@ -145,10 +146,10 @@ export function scenario(path, body, options = {}) {
|
|
|
145
146
|
if (!runsHere) {
|
|
146
147
|
it(`${sc.id} [not here: runs-in excludes ${environment}]`, () => {
|
|
147
148
|
const ev = evidence();
|
|
148
|
-
for (const
|
|
149
|
-
for (const
|
|
150
|
-
ev.setWay(way, { verdict: 'not here', started: new Date().toISOString(), ended: new Date().toISOString() });
|
|
149
|
+
for (const a of sc.assertions) {
|
|
150
|
+
for (const way of a.ways) ev.setResult(a.fullId, way, 'not here', { why: `runs-in does not list ${environment}` });
|
|
151
151
|
}
|
|
152
|
+
for (const way of sc.ways) ev.setWay(way, { verdict: 'not here', started: new Date().toISOString(), ended: new Date().toISOString() });
|
|
152
153
|
});
|
|
153
154
|
return;
|
|
154
155
|
}
|
|
@@ -167,6 +168,8 @@ export function scenario(path, body, options = {}) {
|
|
|
167
168
|
error = thrown;
|
|
168
169
|
}
|
|
169
170
|
const outcome = settle({ sc, way, ev, error });
|
|
171
|
+
// A failed run keeps a picture of each open page (a driver that has one), before the run closes.
|
|
172
|
+
if (run && outcome.verdict === 'fail') await keepOnFail(state, sc.id);
|
|
170
173
|
// The run is closed: a late call (after a time limit, say) changes nothing.
|
|
171
174
|
run?.close();
|
|
172
175
|
await cleanUp(state, run, ev);
|
|
@@ -182,6 +185,12 @@ export function scenario(path, body, options = {}) {
|
|
|
182
185
|
});
|
|
183
186
|
}
|
|
184
187
|
|
|
188
|
+
async function keepOnFail(state, label) {
|
|
189
|
+
for (const pending of state.drivers.values()) {
|
|
190
|
+
try { await (await pending).keepOnFail?.(`fail-${label}`); } catch { /* a missing picture is not the news */ }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
185
194
|
function withLimit(promise, ms, text) {
|
|
186
195
|
let timer;
|
|
187
196
|
const limit = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`the scenario ran out of time: its limit is ${text}`)), ms); timer.unref?.(); });
|
|
@@ -195,7 +204,9 @@ async function cleanUp(state, run, ev) {
|
|
|
195
204
|
|
|
196
205
|
// Turns the end of a body into results, a verdict and a list of problems.
|
|
197
206
|
function settle({ sc, way, ev, error }) {
|
|
198
|
-
|
|
207
|
+
// The entries of this way in, and the entries of a way in that only a step names (the page).
|
|
208
|
+
// A run through `mcp` checks the page assertions too, so it settles them.
|
|
209
|
+
const mine = ev.record.assertions.filter((a) => a.way === way || !sc.through.includes(a.way));
|
|
199
210
|
const problems = [];
|
|
200
211
|
let verdict = 'pass';
|
|
201
212
|
let reason = null;
|
|
@@ -220,6 +231,21 @@ function settle({ sc, way, ev, error }) {
|
|
|
220
231
|
if (verdict === 'blocked') problems.unshift(`BLOCKED: ${reason}`);
|
|
221
232
|
else if (reason && !(error instanceof GateFailed)) problems.push(`the run stopped: ${reason}`);
|
|
222
233
|
ev.setWay(way, { verdict, reason, ended: new Date().toISOString() });
|
|
234
|
+
// A way in that only a step names has no run of its own. Its verdict is the verdict of its assertions.
|
|
235
|
+
for (const extra of sc.ways.filter((w) => !sc.through.includes(w))) {
|
|
236
|
+
const own = ev.record.assertions.filter((a) => a.way === extra).map((a) => a.result);
|
|
237
|
+
// A gate that failed, or a block, leaves the later assertions `not checked`. That is no `fail` of the page.
|
|
238
|
+
const stopped = own.every((r) => r === 'pass' || r === 'not here' || r === 'not checked' || r === 'blocked') && own.some((r) => r === 'not checked' || r === 'blocked');
|
|
239
|
+
let extraVerdict;
|
|
240
|
+
let extraReason = null;
|
|
241
|
+
if (own.includes('fail')) { extraVerdict = 'fail'; extraReason = `an assertion through ${extra} failed`; }
|
|
242
|
+
else if (stopped) {
|
|
243
|
+
extraVerdict = 'blocked';
|
|
244
|
+
extraReason = error instanceof GateFailed ? 'the gate stopped the run' : verdict === 'blocked' ? reason : 'the run stopped before every assertion through this way in was checked';
|
|
245
|
+
} else if (own.every((r) => r === 'pass' || r === 'not here')) extraVerdict = own.every((r) => r === 'not here') ? 'not here' : 'pass';
|
|
246
|
+
else { extraVerdict = 'fail'; extraReason = `an assertion through ${extra} is not a pass`; }
|
|
247
|
+
ev.setWay(extra, { verdict: extraVerdict, reason: extraReason, started: ev.record.ways[way].started, ended: new Date().toISOString() });
|
|
248
|
+
}
|
|
223
249
|
return { verdict, problems };
|
|
224
250
|
}
|
|
225
251
|
|
|
@@ -240,7 +266,12 @@ function makeRun({ ctx, state, way, env, ev, adapter }) {
|
|
|
240
266
|
if (!state.drivers.has(wayName)) {
|
|
241
267
|
const spec = tests['ways-in'][wayName];
|
|
242
268
|
if (!spec) throw new Error(`there is no way in "${wayName}" in tests.yaml`);
|
|
243
|
-
|
|
269
|
+
// A driver is shared by the runs of the scenario (the page way in has no run of its own), so it reaches
|
|
270
|
+
// the record and the attach of the run that is live now, and not those of the run that made it.
|
|
271
|
+
// A driver is shared by the runs of a scenario (the page way in has no run of its own), so it reaches the
|
|
272
|
+
// record and the attach of the run that is live now (state.live), not those of the run that made it.
|
|
273
|
+
// attach: where a driver keeps a file (a screenshot) as evidence, in the folder of this scenario.
|
|
274
|
+
state.drivers.set(wayName, makeDriver({ way: wayName, spec, env, root, hosts: env.hosts, redactor: state.redactor, record: (call) => state.live?.record(call), adapter, runId: RUN_ID, attach: (name) => state.live.attach(name) }));
|
|
244
275
|
}
|
|
245
276
|
return state.drivers.get(wayName);
|
|
246
277
|
};
|
|
@@ -257,8 +288,9 @@ function makeRun({ ctx, state, way, env, ev, adapter }) {
|
|
|
257
288
|
const settleOne = (full, fn, opts = {}, mode) => {
|
|
258
289
|
open(`run.${mode}`);
|
|
259
290
|
const { a, fp } = find(full);
|
|
260
|
-
|
|
261
|
-
|
|
291
|
+
// An assertion that reads a result of a step with its own way in (the page) belongs to that way.
|
|
292
|
+
const target = opts.way ?? (a.ways.includes(way) ? way : a.ways[0]);
|
|
293
|
+
if (!a.ways.includes(target)) throw new Error(`"${target}" is not a way in of ${a.id}: its ways are ${a.ways.join(', ')}`);
|
|
262
294
|
const entry = ev.entry(a.fullId, target);
|
|
263
295
|
const calls = callsSince();
|
|
264
296
|
let failure = null;
|
|
@@ -274,8 +306,14 @@ function makeRun({ ctx, state, way, env, ev, adapter }) {
|
|
|
274
306
|
if (isThenable(value)) { failure = 'a check must not be async: wait first, then check'; value = undefined; }
|
|
275
307
|
} catch (e) { failure = e?.message ?? String(e); }
|
|
276
308
|
}
|
|
277
|
-
|
|
278
|
-
|
|
309
|
+
// `settled_by` names the run (the way in of `through:`) that settled the assertion. A page assertion of a
|
|
310
|
+
// scenario with two `through` ways is settled twice. A fail stands over a later pass. The result of the other
|
|
311
|
+
// run is listed under `also`, so no result is overwritten without a trace.
|
|
312
|
+
const earlier = entry.proof?.settled_by && entry.proof.settled_by !== way ? entry : null;
|
|
313
|
+
const also = earlier ? [...(earlier.proof.also ?? []), { run: earlier.proof.settled_by, result: earlier.result }] : (entry.proof?.also ?? undefined);
|
|
314
|
+
if (failure) ev.setResult(a.fullId, target, 'fail', { error: failure, calls, settled_by: way, also });
|
|
315
|
+
else if (entry.result !== 'fail') ev.setResult(a.fullId, target, 'pass', { value: value === undefined ? undefined : clip(value, state.redactor), calls, settled_by: way, also });
|
|
316
|
+
else if (earlier || entry.proof) { entry.proof = { ...entry.proof, also: [...(entry.proof.also ?? []), { run: way, result: 'pass' }] }; ev.write(); }
|
|
279
317
|
if (failure && mode === 'gate') throw new GateFailed(`[${a.id}] ${failure}`);
|
|
280
318
|
return a;
|
|
281
319
|
};
|
|
@@ -393,6 +431,7 @@ function makeRun({ ctx, state, way, env, ev, adapter }) {
|
|
|
393
431
|
// Called by the library after the body and the results are settled.
|
|
394
432
|
close: () => { closed = true; },
|
|
395
433
|
};
|
|
434
|
+
state.live = { record, attach: (name) => run.attach(name) };
|
|
396
435
|
run.actions = typeof adapter.actions === 'function' ? adapter.actions(run) : (adapter.actions ?? {});
|
|
397
436
|
return run;
|
|
398
437
|
}
|