@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
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
- throw new Error(`there is no tests verb "${what ?? ''}". Use check, write or proof.`);
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) {
@@ -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
  }
@@ -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 = 110;
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
- export function buildProof({ evidenceDir, runId = null, own = {} }) {
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 before = newest.get(data.scenario);
58
- if (!before || String(data.started) >= String(before.started)) newest.set(data.scenario, data);
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
- return `${e.way} ${e.result}${why ? `: ${why}` : ''}`;
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}`);
@@ -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.12",
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
  }
@@ -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:
@@ -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 way of sc.through) {
149
- for (const a of sc.assertions) ev.setResult(a.fullId, way, 'not here', { why: `runs-in does not list ${environment}` });
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
- const mine = ev.record.assertions.filter((a) => a.way === way);
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
- state.drivers.set(wayName, makeDriver({ way: wayName, spec, env, root, hosts: env.hosts, redactor: state.redactor, record, adapter, runId: RUN_ID }));
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
- const target = opts.way ?? way;
261
- if (!sc.through.includes(target)) throw new Error(`"${target}" is not a way in of this scenario`);
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
- if (failure) ev.setResult(a.fullId, target, 'fail', { error: failure, calls });
278
- else if (entry.result !== 'fail') ev.setResult(a.fullId, target, 'pass', { value: value === undefined ? undefined : clip(value, state.redactor), calls });
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
  }