staysfixed 0.2.3 → 0.3.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "staysfixed",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "Prove that what already worked still works after an agent changed the code. Picture checks, guards for fixed bugs, a pre-release walkthrough, and known-good markers — as a CLI and as an MCP server.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -246,6 +246,36 @@ export const CHECK_LABELS = Object.freeze({
246
246
  failed: 'the picture could not be taken',
247
247
  });
248
248
 
249
+ /**
250
+ * The name of one line of the list.
251
+ *
252
+ * A step is announced twice — once the moment it starts, once when it settles — and
253
+ * the second announcement has to land on the SAME line rather than adding another.
254
+ * That only works if both sides agree on what to call it, so the names live here and
255
+ * neither side is free to invent its own.
256
+ *
257
+ * @typedef {'frozen'|'steps'|'settle'|'loaded'|'network'|'masks'|'size'|'pixels'|'console'|'retried'|'platform'|'failed'} CheckKey
258
+ */
259
+
260
+ /**
261
+ * Every line name, as values, so nothing has to spell one out in a string.
262
+ * @type {Readonly<Record<CheckKey, CheckKey>>}
263
+ */
264
+ export const CHECK_KEYS = Object.freeze({
265
+ frozen: 'frozen',
266
+ steps: 'steps',
267
+ settle: 'settle',
268
+ loaded: 'loaded',
269
+ network: 'network',
270
+ masks: 'masks',
271
+ size: 'size',
272
+ pixels: 'pixels',
273
+ console: 'console',
274
+ retried: 'retried',
275
+ platform: 'platform',
276
+ failed: 'failed',
277
+ });
278
+
249
279
  /**
250
280
  * What was asked of the freeze layer for one screen. Everything here is a
251
281
  * setting, not a measurement — it is how the list can say a check was switched
@@ -316,43 +346,169 @@ export function buildChecks(input) {
316
346
  /** @type {import('../types.js').CheckStep[]} */
317
347
  const out = [];
318
348
  /**
319
- * @param {string} label
320
- * @param {string|undefined} detail
321
- * @param {import('../types.js').CheckStep['state']} state
349
+ * Every line is written down under its name. The live half of this — a step
350
+ * announced the moment it starts, before anything is known about how it went —
351
+ * settles onto the line with the same name, so the two have to be handed the
352
+ * same names from the same place.
353
+ *
354
+ * @param {CheckKey} key
355
+ * @returns {Say}
322
356
  */
323
- const say = (label, detail, state) => {
324
- out.push(detail ? { label, detail, state } : { label, state });
357
+ const at = (key) => (label, detail, state) => {
358
+ out.push(detail ? { label, detail, state, key } : { label, state, key });
325
359
  };
326
360
 
327
361
  const screen = input.screen ?? /** @type {import('../types.js').ScreenConfig} */ ({ name: '' });
328
362
  const errors = input.consoleErrors ?? [];
329
363
 
330
- frozenStep(say, input.frozen);
331
- stepsStep(say, screen, input.failure);
364
+ frozenStep(at(CHECK_KEYS.frozen), input.frozen);
365
+ stepsStep(at(CHECK_KEYS.steps), screen, input.failure);
332
366
 
333
367
  if (input.failure) {
334
368
  // Nothing after this happened, so nothing after this is claimed. The one
335
369
  // thing still worth saying is whether the page was shouting on its way down.
336
- say(CHECK_LABELS.failed, input.failure, 'bad');
337
- consoleStep(say, errors);
370
+ at(CHECK_KEYS.failed)(CHECK_LABELS.failed, input.failure, 'bad');
371
+ consoleStep(at(CHECK_KEYS.console), errors);
338
372
  return out;
339
373
  }
340
374
 
341
- settleStep(say, input.settle, input.frozen);
342
- loadedStep(say, input.loaded, input.frozen);
343
- networkStep(say, input.freeze, input.frozen);
344
- masksStep(say, input.masks, input.masksAsked);
345
- sizeStep(say, input);
346
- pixelsStep(say, input);
347
- consoleStep(say, errors);
348
- retryStep(say, input);
349
- platformStep(say, input.platform);
375
+ settleStep(at(CHECK_KEYS.settle), input.settle, input.frozen);
376
+ loadedStep(at(CHECK_KEYS.loaded), input.loaded, input.frozen);
377
+ networkStep(at(CHECK_KEYS.network), input.freeze, input.frozen);
378
+ masksStep(at(CHECK_KEYS.masks), input.masks, input.masksAsked);
379
+ sizeStep(at(CHECK_KEYS.size), input);
380
+ pixelsStep(at(CHECK_KEYS.pixels), input);
381
+ consoleStep(at(CHECK_KEYS.console), errors);
382
+ retryStep(at(CHECK_KEYS.retried), input);
383
+ platformStep(at(CHECK_KEYS.platform), input.platform);
350
384
 
351
385
  return out;
352
386
  }
353
387
 
354
388
  /** @typedef {(label: string, detail: string|undefined, state: import('../types.js').CheckStep['state']) => void} Say */
355
389
 
390
+ /**
391
+ * One line of that same list, on its own, the moment it settles.
392
+ *
393
+ * The list above is built when a screen is finished, which is the only time every
394
+ * number is known — but a person watching wants the line to tick the moment the
395
+ * thing itself happens, not two seconds later in a table. This builds exactly one
396
+ * of those lines, from the same code and therefore in the same words: there is no
397
+ * second set of phrases to drift out of step with the first.
398
+ *
399
+ * Hands back nothing when there is nothing honest to say yet — a size step before
400
+ * anything has been measured, say — so a caller can offer what it knows and let
401
+ * this decide whether it amounts to a line.
402
+ *
403
+ * @param {CheckKey} key
404
+ * @param {ChecksInput} input Only the parts this line needs have to be filled in.
405
+ * @returns {import('../types.js').CheckStep|undefined}
406
+ */
407
+ export function checkStep(key, input) {
408
+ /** @type {import('../types.js').CheckStep[]} */
409
+ const out = [];
410
+ /** @type {Say} */
411
+ const say = (label, detail, state) => {
412
+ out.push(detail ? { label, detail, state, key } : { label, state, key });
413
+ };
414
+ const screen = input.screen ?? /** @type {import('../types.js').ScreenConfig} */ ({ name: '' });
415
+
416
+ switch (key) {
417
+ case 'frozen':
418
+ frozenStep(say, input.frozen);
419
+ break;
420
+ case 'steps':
421
+ stepsStep(say, screen, input.failure);
422
+ break;
423
+ case 'settle':
424
+ settleStep(say, input.settle, input.frozen);
425
+ break;
426
+ case 'loaded':
427
+ loadedStep(say, input.loaded, input.frozen);
428
+ break;
429
+ case 'network':
430
+ networkStep(say, input.freeze, input.frozen);
431
+ break;
432
+ case 'masks':
433
+ masksStep(say, input.masks, input.masksAsked);
434
+ break;
435
+ case 'size':
436
+ sizeStep(say, input);
437
+ break;
438
+ case 'pixels':
439
+ pixelsStep(say, input);
440
+ break;
441
+ case 'console':
442
+ consoleStep(say, input.consoleErrors ?? []);
443
+ break;
444
+ case 'retried':
445
+ retryStep(say, input);
446
+ break;
447
+ case 'platform':
448
+ platformStep(say, input.platform);
449
+ break;
450
+ case 'failed':
451
+ if (input.failure) say(CHECK_LABELS.failed, input.failure, 'bad');
452
+ break;
453
+ default:
454
+ break;
455
+ }
456
+ return out[0];
457
+ }
458
+
459
+ /**
460
+ * The same line again, said while the thing is still happening.
461
+ *
462
+ * A step is announced before it has an outcome, which means the words have to be
463
+ * chosen from what is already known — and what is already known before a step runs
464
+ * is the config. So the clock line says "left alone" up front when a project turned
465
+ * the clock freezing off, rather than claiming a freeze and taking it back.
466
+ *
467
+ * Nothing is ever announced early that only exists afterwards: whether a screen was
468
+ * photographed twice, or approved on another computer, is not a thing anybody can be
469
+ * told is "happening".
470
+ *
471
+ * @param {CheckKey} key
472
+ * @param {ChecksInput} input
473
+ * @returns {import('../types.js').CheckStep|undefined}
474
+ */
475
+ export function runningStep(key, input) {
476
+ const label = runningLabel(key, input);
477
+ return label ? { label, state: 'running', key } : undefined;
478
+ }
479
+
480
+ /**
481
+ * @param {CheckKey} key
482
+ * @param {ChecksInput} input
483
+ * @returns {string|undefined}
484
+ */
485
+ function runningLabel(key, input) {
486
+ const frozen = input.frozen;
487
+ switch (key) {
488
+ case 'frozen':
489
+ return frozen && frozen.clock === false ? CHECK_LABELS.frozenOff : CHECK_LABELS.frozen;
490
+ case 'steps':
491
+ return CHECK_LABELS.steps;
492
+ case 'settle':
493
+ return CHECK_LABELS.settle;
494
+ case 'loaded':
495
+ return frozen && frozen.fonts === false ? CHECK_LABELS.loadedOff : CHECK_LABELS.loaded;
496
+ case 'network':
497
+ return frozen && frozen.network === 'live' ? CHECK_LABELS.networkLive : CHECK_LABELS.network;
498
+ case 'masks':
499
+ return (input.masksAsked ?? 0) > 0 ? CHECK_LABELS.masks : CHECK_LABELS.masksNone;
500
+ case 'size':
501
+ return input.hasApproved === false ? CHECK_LABELS.sizeNew : CHECK_LABELS.size;
502
+ case 'pixels':
503
+ return input.hasApproved === false ? CHECK_LABELS.pixelsNew : CHECK_LABELS.pixels;
504
+ case 'console':
505
+ return CHECK_LABELS.console;
506
+ default:
507
+ // Nothing else is a thing that can be watched happening.
508
+ return undefined;
509
+ }
510
+ }
511
+
356
512
  /**
357
513
  * @param {Say} say
358
514
  * @param {FrozenPlan|undefined} frozen
package/src/guard/api.js CHANGED
@@ -5,6 +5,14 @@
5
5
  * who wrote it has forgotten the bug. That is why assertions are a sentence plus
6
6
  * a check, and never a bare comparison: `expect('the sidebar is hidden', ...)`
7
7
  * fails with "expected: the sidebar is hidden", which anyone can act on.
8
+ *
9
+ * Those sentences are also the answer to the fairest question anyone asks about
10
+ * this tool: "is it only about how things look?" It is not — a guard drives the
11
+ * app and asserts what it still does — but that was invisible, because a guard
12
+ * reported one line however many things it proved. So every claim, and every
13
+ * action between the claims, now says itself out loud the moment it happens:
14
+ * announced as it starts, settled as it finishes. The list a person watches tick
15
+ * off IS the guard's own words, in the guard's own order.
8
16
  */
9
17
 
10
18
  import { exec } from 'node:child_process';
@@ -28,15 +36,79 @@ const DEFAULT_RUN_TIMEOUT = 60_000;
28
36
  /** Commands can print a lot; 10MB before we cut them off. */
29
37
  const MAX_OUTPUT = 10 * 1024 * 1024;
30
38
 
39
+ /** Longest a selector, path or command is shown before it is cut short. */
40
+ const MAX_LABEL = 80;
41
+
42
+ /**
43
+ * How a step's id says what kind of step it is.
44
+ *
45
+ * An assertion is the point of a guard; opening a page or clicking a button is
46
+ * the setup that gets there. `CheckStep` has no room for that difference — and
47
+ * should not grow one for the sake of a colour — so it rides on the id, which
48
+ * every step needs anyway. Anything watching can draw `claim-` lines loud and
49
+ * `did-` lines quiet; anything that does not care sees a perfectly ordinary list.
50
+ */
51
+ const CLAIM = 'claim';
52
+ const ACTION = 'did';
53
+
54
+ /**
55
+ * What a guard is told to report to, when anyone is collecting.
56
+ * @typedef {object} GuardApiOptions
57
+ * @property {(step: import('../types.js').CheckStep) => void} [onStep]
58
+ * Called as each claim and each action starts, and again as it settles.
59
+ * The two calls carry the same `key`.
60
+ */
61
+
31
62
  /**
32
63
  * Build the object passed to a guard's `run`.
33
64
  *
34
65
  * @param {import('../types.js').PageApi} page
35
66
  * @param {import('../types.js').Project} project
67
+ * @param {GuardApiOptions} [opts]
36
68
  * @returns {import('../types.js').GuardApi}
37
69
  */
38
- export function makeGuardApi(page, project) {
70
+ export function makeGuardApi(page, project, opts = {}) {
39
71
  const root = project.paths.root;
72
+ const onStep = typeof opts.onStep === 'function' ? opts.onStep : null;
73
+ let counted = 0;
74
+
75
+ /**
76
+ * Hand one step out, and never let that matter.
77
+ *
78
+ * Reporting is a convenience laid over a check; a listener that throws must not
79
+ * change whether a bug that was fixed is still fixed.
80
+ *
81
+ * @param {import('../types.js').CheckStep} step
82
+ * @returns {void}
83
+ */
84
+ function tell(step) {
85
+ if (!onStep) return;
86
+ try {
87
+ onStep(step);
88
+ } catch {
89
+ // Watching is never worth a guard.
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Say a thing has started, and hand back the way to say how it went.
95
+ *
96
+ * When nobody is collecting this allocates nothing and returns a function that
97
+ * does nothing, so a guard run with no watcher costs exactly what it did before.
98
+ *
99
+ * @param {string} kind CLAIM or ACTION.
100
+ * @param {string} label Plain language, already final — the same words settle it.
101
+ * @returns {(state: import('../types.js').CheckStep['state'], detail?: string) => void}
102
+ */
103
+ function announce(kind, label) {
104
+ if (!onStep) return () => {};
105
+ counted += 1;
106
+ const key = `${kind}-${counted}`;
107
+ tell({ key, label, state: 'running' });
108
+ return (state, detail) => {
109
+ tell(detail ? { key, label, detail, state } : { key, label, state });
110
+ };
111
+ }
40
112
 
41
113
  return {
42
114
  page,
@@ -46,16 +118,30 @@ export function makeGuardApi(page, project) {
46
118
  * @param {string} to
47
119
  * @returns {Promise<void>}
48
120
  */
49
- open(to) {
50
- return page.goto(to);
121
+ async open(to) {
122
+ const settle = announce(ACTION, `opened ${short(to)}`);
123
+ try {
124
+ await page.goto(to);
125
+ } catch (error) {
126
+ settle('bad', reasonOf(error));
127
+ throw error;
128
+ }
129
+ settle('ok');
51
130
  },
52
131
 
53
132
  /**
54
133
  * @param {string} selector
55
134
  * @returns {Promise<void>}
56
135
  */
57
- click(selector) {
58
- return page.click(selector);
136
+ async click(selector) {
137
+ const settle = announce(ACTION, `clicked ${short(selector)}`);
138
+ try {
139
+ await page.click(selector);
140
+ } catch (error) {
141
+ settle('bad', reasonOf(error));
142
+ throw error;
143
+ }
144
+ settle('ok');
59
145
  },
60
146
 
61
147
  /**
@@ -75,18 +161,32 @@ export function makeGuardApi(page, project) {
75
161
  });
76
162
  }
77
163
 
164
+ // The claim goes out before it is checked, not after. A person watching
165
+ // sees what is being asked while it is being asked — which is the whole
166
+ // difference between a list that ticks and a table that appears.
167
+ const settle = announce(CLAIM, claim.trim());
168
+
78
169
  let result;
79
170
  try {
80
171
  result = await check();
81
172
  } catch (cause) {
82
173
  // A nested expectation already reads well — do not bury it in another layer.
83
- if (cause instanceof ExpectationFailed) throw cause;
84
- throw new Error(`while checking '${claim}': ${cause instanceof Error ? cause.message : String(cause)}`, {
174
+ if (cause instanceof ExpectationFailed) {
175
+ settle('bad', cause.claim === claim.trim() ? undefined : `inside it: ${cause.claim}`);
176
+ throw cause;
177
+ }
178
+ const why = cause instanceof Error ? cause.message : String(cause);
179
+ settle('bad', firstLine(why));
180
+ throw new Error(`while checking '${claim}': ${why}`, {
85
181
  cause,
86
182
  });
87
183
  }
88
184
 
89
- if (isNegative(result)) throw new ExpectationFailed(claim);
185
+ if (isNegative(result)) {
186
+ settle('bad', 'this is not true any more');
187
+ throw new ExpectationFailed(claim);
188
+ }
189
+ settle('ok');
90
190
  },
91
191
 
92
192
  /**
@@ -94,15 +194,17 @@ export function makeGuardApi(page, project) {
94
194
  * must still succeed, a file that must still be generated — live here.
95
195
  *
96
196
  * A non-zero exit is returned, never thrown: whether it means failure is the
97
- * guard's decision, not ours.
197
+ * guard's decision, not ours. The step says so the same way — a command that
198
+ * came back unhappy is worth noticing, and is still not a verdict.
98
199
  *
99
200
  * @param {string} cmd
100
201
  * @param {{cwd?: string, timeoutMs?: number}} [runOpts]
101
202
  * @returns {Promise<{code: number, stdout: string, stderr: string}>}
102
203
  */
103
- run(cmd, runOpts = {}) {
204
+ async run(cmd, runOpts = {}) {
104
205
  const cwd = runOpts.cwd ? path.resolve(root, runOpts.cwd) : root;
105
206
  const timeoutMs = runOpts.timeoutMs ?? DEFAULT_RUN_TIMEOUT;
207
+ const settle = announce(ACTION, `ran ${short(cmd)}`);
106
208
 
107
209
  /** @type {Promise<{code: number, stdout: string, stderr: string}>} */
108
210
  const finished = new Promise((resolve) => {
@@ -129,7 +231,13 @@ export function makeGuardApi(page, project) {
129
231
  },
130
232
  );
131
233
  });
132
- return finished;
234
+
235
+ const outcome = await finished;
236
+ if (outcome.code === 0) settle('ok', 'finished cleanly, code 0');
237
+ else if (outcome.code === 124) {
238
+ settle('warn', `stopped after ${humanTime(timeoutMs)}, code 124`);
239
+ } else settle('warn', `came back with code ${outcome.code}`);
240
+ return outcome;
133
241
  },
134
242
 
135
243
  /**
@@ -149,20 +257,30 @@ export function makeGuardApi(page, project) {
149
257
  });
150
258
  }
151
259
 
260
+ const settle = announce(ACTION, `read ${short(relative)}`);
261
+ /** @type {string} */
262
+ let text;
152
263
  try {
153
- return await fsp.readFile(full, 'utf8');
264
+ text = await fsp.readFile(full, 'utf8');
154
265
  } catch (cause) {
155
266
  const code = /** @type {any} */ (cause)?.code;
156
267
  if (code === 'ENOENT') {
268
+ settle('bad', 'there is no such file');
157
269
  throw new StaysFixedError(`There is no file called "${relative}" in the project.`, { cause });
158
270
  }
159
271
  if (code === 'EISDIR') {
272
+ settle('bad', 'that is a folder, not a file');
160
273
  throw new StaysFixedError(`"${relative}" is a folder, not a file.`, { cause });
161
274
  }
275
+ settle('bad', reasonOf(cause));
162
276
  throw new StaysFixedError(`Could not read "${relative}": ${cause instanceof Error ? cause.message : String(cause)}`, {
163
277
  cause,
164
278
  });
165
279
  }
280
+
281
+ const lines = text === '' ? 0 : text.split('\n').length;
282
+ settle('ok', `${count(lines)} ${lines === 1 ? 'line' : 'lines'}`);
283
+ return text;
166
284
  },
167
285
  };
168
286
  }
@@ -186,6 +304,44 @@ function isNegative(value) {
186
304
  return false;
187
305
  }
188
306
 
307
+ /**
308
+ * A selector, path or command, short enough to read in a list.
309
+ *
310
+ * @param {string} text
311
+ * @returns {string}
312
+ */
313
+ function short(text) {
314
+ const one = String(text ?? '').replace(/\s+/g, ' ').trim();
315
+ if (one === '') return 'nothing';
316
+ return one.length > MAX_LABEL ? `${one.slice(0, MAX_LABEL - 1)}…` : one;
317
+ }
318
+
319
+ /**
320
+ * @param {unknown} error
321
+ * @returns {string}
322
+ */
323
+ function reasonOf(error) {
324
+ return firstLine(error instanceof Error ? error.message : String(error));
325
+ }
326
+
327
+ /**
328
+ * @param {string} text
329
+ * @returns {string}
330
+ */
331
+ function firstLine(text) {
332
+ const line = String(text ?? '').split('\n')[0].trim();
333
+ if (line === '') return 'it did not say why';
334
+ return line.length > 120 ? `${line.slice(0, 119)}…` : line;
335
+ }
336
+
337
+ /**
338
+ * @param {number} n
339
+ * @returns {string}
340
+ */
341
+ function count(n) {
342
+ return Number.isFinite(n) ? Math.round(n).toLocaleString('en-US') : String(n);
343
+ }
344
+
189
345
  /**
190
346
  * @param {number} ms
191
347
  * @returns {string}
package/src/guard/run.js CHANGED
@@ -6,6 +6,14 @@
6
6
  * from the same clean state, a guard that needs a second go is recorded as
7
7
  * wobbly rather than green, and the failure message carries the story of the
8
8
  * original bug so nobody has to go looking for it.
9
+ *
10
+ * A guard also has to be watchable while it happens. It is the part of this tool
11
+ * that checks behaviour rather than looks — it drives the app and asserts what it
12
+ * still does — and reporting it as a single line hid every one of those
13
+ * assertions. So each claim and each action is passed straight out as it starts
14
+ * and again as it settles, and the whole list travels with the result. A guard
15
+ * that fails on its fifth claim still shows the four that held: "these are fine,
16
+ * this one is not" is most of the value of running it at all.
9
17
  */
10
18
 
11
19
  import { makeGuardApi, ExpectationFailed } from './api.js';
@@ -14,8 +22,14 @@ import { emitEvent } from '../core/events.js';
14
22
 
15
23
  const DEFAULT_TIMEOUT = 30_000;
16
24
 
25
+ /** How the runner names its own first step — the clean start every guard gets. */
26
+ const FRESH_KEY = 'fresh';
27
+
17
28
  /**
18
- * @typedef {import('../types.js').GuardResult & {retriedToPass?: boolean}} GuardRunResult
29
+ * @typedef {import('../types.js').GuardResult & {
30
+ * retriedToPass?: boolean,
31
+ * checks?: import('../types.js').CheckStep[],
32
+ * }} GuardRunResult
19
33
  */
20
34
 
21
35
  /**
@@ -25,6 +39,8 @@ const DEFAULT_TIMEOUT = 30_000;
25
39
  * @property {string} [failedAt]
26
40
  */
27
41
 
42
+ /** @typedef {(step: import('../types.js').CheckStep) => void} StepSink */
43
+
28
44
  /**
29
45
  * Run every guard against an app that is already open.
30
46
  *
@@ -91,10 +107,31 @@ export async function runGuards(project, app, guards, opts = {}) {
91
107
  /** @type {AttemptOutcome} */
92
108
  let outcome = { ok: false, message: 'This guard did not run.' };
93
109
  let attempts = 0;
110
+ /** @type {import('../types.js').CheckStep[]} */
111
+ let checks = [];
94
112
 
95
113
  while (attempts < retries + 1) {
96
114
  attempts += 1;
97
- outcome = await attemptGuard(project, app, guard, baseUrl, timeoutMs);
115
+ // A second go starts the list again. What a person needs to see is what the
116
+ // verdict was actually made on, and that is the last attempt — the earlier
117
+ // one is already recorded, more usefully, as "it only passed on try 2".
118
+ const attempt = attempts;
119
+ /** @type {import('../types.js').CheckStep[]} */
120
+ const collected = [];
121
+ checks = collected;
122
+
123
+ /** @type {StepSink} */
124
+ const onStep = (step) => {
125
+ // Keys are unique inside one attempt; a retry re-announces the same
126
+ // claims, and a watcher must not mistake the second run of a claim for
127
+ // the settling of the first.
128
+ const stamped =
129
+ attempt > 1 && step.key ? { ...step, key: `try${attempt}-${step.key}` } : step;
130
+ record(collected, stamped);
131
+ emitEvent(events, { type: 'guard:step', name: guard.name, step: stamped });
132
+ };
133
+
134
+ outcome = await attemptGuard(project, app, guard, baseUrl, timeoutMs, onStep);
98
135
  if (outcome.ok) break;
99
136
  if (opts.signal?.aborted) break;
100
137
  }
@@ -108,6 +145,7 @@ export async function runGuards(project, app, guards, opts = {}) {
108
145
  durationMs: Date.now() - startedAt,
109
146
  attempts,
110
147
  };
148
+ if (checks.length > 0) result.checks = checks;
111
149
 
112
150
  if (outcome.ok) {
113
151
  // Passing only on the second go is not passing. The flake register picks
@@ -127,6 +165,29 @@ export async function runGuards(project, app, guards, opts = {}) {
127
165
  return results;
128
166
  }
129
167
 
168
+ /**
169
+ * Put one step into the list it belongs to.
170
+ *
171
+ * A step is said twice — once as it starts, once as it finishes — and the list
172
+ * should hold one line per thing, not two. The settled version replaces the
173
+ * running one in place, so the order stays the order it happened in.
174
+ *
175
+ * @param {import('../types.js').CheckStep[]} into
176
+ * @param {import('../types.js').CheckStep} step
177
+ * @returns {void}
178
+ */
179
+ function record(into, step) {
180
+ if (step.key) {
181
+ for (let i = 0; i < into.length; i++) {
182
+ if (into[i].key === step.key) {
183
+ into[i] = step;
184
+ return;
185
+ }
186
+ }
187
+ }
188
+ into.push(step);
189
+ }
190
+
130
191
  /**
131
192
  * @param {import('../types.js').RunEvents|undefined} events
132
193
  * @param {GuardRunResult} result
@@ -141,6 +202,10 @@ function emitGuardDone(events, result) {
141
202
  message: result.message,
142
203
  failedAt: result.failedAt,
143
204
  because: result.because,
205
+ // Everything this guard actually asserted, in its own words and its own
206
+ // order — so a listener that arrived late, or one that only keeps the
207
+ // verdicts, still has the working.
208
+ checks: result.checks,
144
209
  });
145
210
  }
146
211
 
@@ -152,12 +217,26 @@ function emitGuardDone(events, result) {
152
217
  * @param {import('../types.js').Guard} guard
153
218
  * @param {string|undefined} baseUrl
154
219
  * @param {number} timeoutMs
220
+ * @param {StepSink} [onStep]
155
221
  * @returns {Promise<AttemptOutcome>}
156
222
  */
157
- async function attemptGuard(project, app, guard, baseUrl, timeoutMs) {
223
+ async function attemptGuard(project, app, guard, baseUrl, timeoutMs, onStep) {
158
224
  /** @type {ReturnType<typeof setTimeout>|undefined} */
159
225
  let timer;
160
226
 
227
+ /**
228
+ * @param {import('../types.js').CheckStep} step
229
+ * @returns {void}
230
+ */
231
+ const tell = (step) => {
232
+ if (!onStep) return;
233
+ try {
234
+ onStep(step);
235
+ } catch {
236
+ // Watching a guard must never be able to fail one.
237
+ }
238
+ };
239
+
161
240
  try {
162
241
  await Promise.race([
163
242
  (async () => {
@@ -171,10 +250,29 @@ async function attemptGuard(project, app, guard, baseUrl, timeoutMs) {
171
250
  // own, which is the single most confusing shape a failure can take. So an
172
251
  // Electron window is reloaded instead. Its main process keeps whatever it
173
252
  // was holding; only the screen goes back to how it opened.
174
- if (baseUrl) await app.page.goto(baseUrl);
175
- else await resetWindow(app);
253
+ const fresh = 'started from a clean screen';
254
+ tell({ key: FRESH_KEY, label: fresh, state: 'running' });
255
+ try {
256
+ if (baseUrl) await app.page.goto(baseUrl);
257
+ else await resetWindow(app);
258
+ } catch (error) {
259
+ tell({
260
+ key: FRESH_KEY,
261
+ label: fresh,
262
+ detail: error instanceof Error ? error.message : String(error),
263
+ state: 'bad',
264
+ });
265
+ throw error;
266
+ }
267
+ tell({
268
+ key: FRESH_KEY,
269
+ label: fresh,
270
+ detail: baseUrl ? 'back to the front door' : 'the window was reloaded',
271
+ state: 'ok',
272
+ });
273
+
176
274
  clearConsole(app);
177
- await guard.run(makeGuardApi(app.page, project));
275
+ await guard.run(makeGuardApi(app.page, project, onStep ? { onStep } : {}));
178
276
  })(),
179
277
  new Promise((_resolve, reject) => {
180
278
  timer = setTimeout(() => {