staysfixed 0.2.2 → 0.2.3

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.2",
3
+ "version": "0.2.3",
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",
@@ -16,6 +16,10 @@
16
16
  import { pathToFileURL } from 'node:url';
17
17
  import { detail } from './log.js';
18
18
  import { messageOf } from './errors.js';
19
+ // Only for the stock pixel allowance. The number a person is shown has to be the
20
+ // number the verdict was made against, so it is read from the one place that
21
+ // defines it rather than copied and left to drift.
22
+ import { DEFAULT_TOLERANCE } from './config.js';
19
23
 
20
24
  /** @typedef {import('../types.js').RunEvent} RunEvent */
21
25
  /** @typedef {import('../types.js').RunEvents} RunEvents */
@@ -197,3 +201,517 @@ export function makeTimings() {
197
201
 
198
202
  return { add, mark, get };
199
203
  }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Showing the working
207
+ // ---------------------------------------------------------------------------
208
+
209
+ /**
210
+ * Every phrase a step of a picture check can be described by, in one place.
211
+ *
212
+ * They live here as data rather than scattered through the code below for one
213
+ * reason: this is the wording a non-programmer reads, so the whole set has to be
214
+ * readable — and judgeable — in a single glance. If a line here sounds like a
215
+ * programmer talking, it is wrong, whatever the code around it does.
216
+ *
217
+ * Most steps have two or three wordings, because a step that was switched off in
218
+ * the config must say so rather than quietly claim success.
219
+ */
220
+ export const CHECK_LABELS = Object.freeze({
221
+ frozen: 'the clock was frozen',
222
+ frozenOff: 'the clock was left alone',
223
+ steps: 'reached the screen',
224
+ stepsFailed: 'could not reach the screen',
225
+ settle: 'everything held still',
226
+ settleGaveUp: 'it never fully stopped moving',
227
+ loaded: 'fonts and pictures landed',
228
+ loadedWaiting: 'something was still loading',
229
+ loadedOff: 'fonts were left to load on their own',
230
+ loadedUnknown: 'could not ask what was still loading',
231
+ network: 'the outside world kept out',
232
+ networkLive: 'the app was left online',
233
+ networkUnknown: 'the network was not watched',
234
+ masks: 'live things painted over',
235
+ masksNone: 'nothing needed painting over',
236
+ size: 'same size as approved',
237
+ sizeChanged: 'not the same size as approved',
238
+ sizeNew: 'nothing approved to measure against',
239
+ pixels: 'every pixel compared',
240
+ pixelsNew: 'nothing to compare it with yet',
241
+ pixelsSkipped: 'the pixels could not be compared',
242
+ console: 'no errors from the page',
243
+ consoleBad: 'the page threw errors',
244
+ retried: 'photographed more than once',
245
+ platform: 'approved on a different computer',
246
+ failed: 'the picture could not be taken',
247
+ });
248
+
249
+ /**
250
+ * What was asked of the freeze layer for one screen. Everything here is a
251
+ * setting, not a measurement — it is how the list can say a check was switched
252
+ * off instead of pretending it passed.
253
+ *
254
+ * @typedef {object} FrozenPlan
255
+ * @property {boolean} clock The clock, timezone and locale were pinned.
256
+ * @property {boolean} motion Animations, transitions and carets were killed.
257
+ * @property {boolean} random Random numbers were seeded.
258
+ * @property {boolean} fonts The shutter waited for fonts.
259
+ * @property {'replay'|'block-external'|'live'} network How requests were treated.
260
+ * @property {number} frames Identical frames in a row demanded.
261
+ * @property {number} [maxDriftPixels] Pixels allowed to wobble and still count as identical.
262
+ */
263
+
264
+ /**
265
+ * What the page said was still loading at the moment the picture was taken.
266
+ * Read off the real page, so it is a measurement rather than a hope.
267
+ *
268
+ * @typedef {object} LoadedReport
269
+ * @property {string} [fonts] document.fonts.status: 'loaded', 'loading', or 'none'.
270
+ * @property {number} [images] How many pictures the page has.
271
+ * @property {number} [imagesPending] How many of them had not finished.
272
+ */
273
+
274
+ /**
275
+ * Everything needed to say what was done to one screen.
276
+ *
277
+ * All of it optional except the screen, because a screen that fell over halfway
278
+ * still deserves an honest list of how far it got.
279
+ *
280
+ * @typedef {object} ChecksInput
281
+ * @property {import('../types.js').ScreenConfig} screen The recipe that was followed.
282
+ * @property {import('../types.js').CheckStatus} [status] How the screen ended up.
283
+ * @property {FrozenPlan} [frozen] What the freeze layer was asked to do.
284
+ * @property {import('../types.js').SettleReport} [settle] How the holding-still went.
285
+ * @property {LoadedReport} [loaded] What was still loading at the shutter.
286
+ * @property {import('../types.js').FreezeStats} [freeze] Requests blocked, replayed, allowed.
287
+ * @property {import('../types.js').MaskRect[]} [masks] Rectangles painted over both pictures.
288
+ * @property {number} [masksAsked] How many masks the config set for this screen.
289
+ * @property {string[]} [consoleErrors] Errors the page threw, unprompted.
290
+ * @property {{width:number,height:number}} [size] The new picture.
291
+ * @property {{width:number,height:number}} [approvedSize] The approved one.
292
+ * @property {import('../types.js').CompareReport|null} [compare] The verdict on the pixels.
293
+ * @property {boolean} [hasApproved] Was there an approved picture at all.
294
+ * @property {import('../types.js').ToleranceConfig} [tolerance] How much difference was allowed.
295
+ * @property {number} [attempts] How many times it was photographed.
296
+ * @property {{approvedOn?: string, here?: string}} [platform] Where the two pictures were taken.
297
+ * @property {string} [failure] Why it could not be photographed at all.
298
+ */
299
+
300
+ /**
301
+ * Turn what a capture and a comparison know into the list of things that were
302
+ * actually done to a screen, in the order they happened.
303
+ *
304
+ * A verdict on its own — "matches", "1.9s" — tells nobody what was verified. It
305
+ * reads like a speed test, and a person who cannot see the work cannot decide
306
+ * whether to believe it. This is that work, written out.
307
+ *
308
+ * Pure on purpose: no browser, no disk, no clock. The words that appear in front
309
+ * of a person are then testable without photographing anything, and every path
310
+ * through the tool that wants to explain itself uses the same ones.
311
+ *
312
+ * @param {ChecksInput} input
313
+ * @returns {import('../types.js').CheckStep[]}
314
+ */
315
+ export function buildChecks(input) {
316
+ /** @type {import('../types.js').CheckStep[]} */
317
+ const out = [];
318
+ /**
319
+ * @param {string} label
320
+ * @param {string|undefined} detail
321
+ * @param {import('../types.js').CheckStep['state']} state
322
+ */
323
+ const say = (label, detail, state) => {
324
+ out.push(detail ? { label, detail, state } : { label, state });
325
+ };
326
+
327
+ const screen = input.screen ?? /** @type {import('../types.js').ScreenConfig} */ ({ name: '' });
328
+ const errors = input.consoleErrors ?? [];
329
+
330
+ frozenStep(say, input.frozen);
331
+ stepsStep(say, screen, input.failure);
332
+
333
+ if (input.failure) {
334
+ // Nothing after this happened, so nothing after this is claimed. The one
335
+ // 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);
338
+ return out;
339
+ }
340
+
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);
350
+
351
+ return out;
352
+ }
353
+
354
+ /** @typedef {(label: string, detail: string|undefined, state: import('../types.js').CheckStep['state']) => void} Say */
355
+
356
+ /**
357
+ * @param {Say} say
358
+ * @param {FrozenPlan|undefined} frozen
359
+ */
360
+ function frozenStep(say, frozen) {
361
+ if (!frozen) return;
362
+ /** @type {string[]} */
363
+ const also = [];
364
+ if (frozen.motion) also.push('animations off');
365
+ if (frozen.random) also.push('random numbers pinned');
366
+ if (!frozen.clock) {
367
+ say(
368
+ CHECK_LABELS.frozenOff,
369
+ also.length > 0 ? also.join(', ') : 'the time may be different every run',
370
+ 'skipped',
371
+ );
372
+ return;
373
+ }
374
+ say(CHECK_LABELS.frozen, also.length > 0 ? also.join(', ') : 'the same instant every run', 'ok');
375
+ }
376
+
377
+ /**
378
+ * @param {Say} say
379
+ * @param {import('../types.js').ScreenConfig} screen
380
+ * @param {string|undefined} failure
381
+ */
382
+ function stepsStep(say, screen, failure) {
383
+ const label = failure ? CHECK_LABELS.stepsFailed : CHECK_LABELS.steps;
384
+ const state = failure ? 'bad' : 'ok';
385
+
386
+ if (typeof screen.do === 'function') {
387
+ say(label, 'its own instructions', state);
388
+ return;
389
+ }
390
+ const steps = screen.steps ?? [];
391
+ const waits = steps.filter((s) => s && s.wait !== undefined).length;
392
+ const after = (screen.after ?? []).length;
393
+
394
+ if (steps.length === 0) {
395
+ say(label, 'nothing to do — it was already there', state);
396
+ return;
397
+ }
398
+ /** @type {string[]} */
399
+ const parts = [`${steps.length} ${plural(steps.length, 'step', 'steps')}`];
400
+ parts.push(waits === 0 ? 'none of them a timed wait' : `${waits} of them a timed wait`);
401
+ if (after > 0) parts.push(`${after} more to put the app back`);
402
+ say(label, parts.join(', '), state);
403
+ }
404
+
405
+ /**
406
+ * @param {Say} say
407
+ * @param {import('../types.js').SettleReport|undefined} settle
408
+ * @param {FrozenPlan|undefined} frozen
409
+ */
410
+ function settleStep(say, settle, frozen) {
411
+ if (!settle) return;
412
+ const frames = Math.max(1, frozen?.frames ?? 2);
413
+ const drift = settle.lastDriftPixels ?? 0;
414
+
415
+ if (!settle.settled) {
416
+ const moving =
417
+ drift > 0
418
+ ? `${count(drift)} ${plural(drift, 'pixel', 'pixels')} still moving`
419
+ : 'it never held';
420
+ say(
421
+ CHECK_LABELS.settleGaveUp,
422
+ `gave up after ${count(settle.attempts)} ${plural(settle.attempts, 'photo', 'photos')}, ${moving}`,
423
+ 'warn',
424
+ );
425
+ return;
426
+ }
427
+
428
+ /** @type {string[]} */
429
+ const parts = [`${frames} identical ${plural(frames, 'frame', 'frames')} in a row`];
430
+ parts.push(
431
+ settle.attempts > frames ? `after ${count(settle.attempts)} photos` : 'first try',
432
+ );
433
+ // Only worth saying when a project deliberately allows a little wobble AND
434
+ // some wobble actually happened; otherwise it is noise about nothing.
435
+ if ((frozen?.maxDriftPixels ?? 0) > 0 && drift > 0) {
436
+ parts.push(`${count(drift)} ${plural(drift, 'pixel', 'pixels')} of allowed wobble`);
437
+ }
438
+ say(CHECK_LABELS.settle, parts.join(', '), 'ok');
439
+ }
440
+
441
+ /**
442
+ * @param {Say} say
443
+ * @param {LoadedReport|undefined} loaded
444
+ * @param {FrozenPlan|undefined} frozen
445
+ */
446
+ function loadedStep(say, loaded, frozen) {
447
+ if (frozen && frozen.fonts === false) {
448
+ say(CHECK_LABELS.loadedOff, 'the shutter did not wait for them', 'skipped');
449
+ return;
450
+ }
451
+ if (!loaded) {
452
+ say(CHECK_LABELS.loadedUnknown, 'the page had moved on by the time we asked', 'skipped');
453
+ return;
454
+ }
455
+
456
+ const pending = loaded.imagesPending ?? 0;
457
+ const fontsBusy = loaded.fonts === 'loading';
458
+ if (!fontsBusy && pending === 0) {
459
+ const total = loaded.images ?? 0;
460
+ say(
461
+ CHECK_LABELS.loaded,
462
+ total > 0
463
+ ? `every face loaded, ${count(total)} ${plural(total, 'picture', 'pictures')}, none still loading`
464
+ : 'every face loaded, nothing still loading',
465
+ 'ok',
466
+ );
467
+ return;
468
+ }
469
+
470
+ /** @type {string[]} */
471
+ const busy = [];
472
+ if (fontsBusy) busy.push('a font was still loading');
473
+ if (pending > 0) {
474
+ busy.push(`${count(pending)} ${plural(pending, 'picture', 'pictures')} still loading`);
475
+ }
476
+ say(CHECK_LABELS.loadedWaiting, busy.join(', '), 'warn');
477
+ }
478
+
479
+ /**
480
+ * @param {Say} say
481
+ * @param {import('../types.js').FreezeStats|undefined} stats
482
+ * @param {FrozenPlan|undefined} frozen
483
+ */
484
+ function networkStep(say, stats, frozen) {
485
+ if (frozen && frozen.network === 'live') {
486
+ say(CHECK_LABELS.networkLive, 'requests were left alone', 'skipped');
487
+ return;
488
+ }
489
+ if (!stats) {
490
+ say(CHECK_LABELS.networkUnknown, 'nothing was counted', 'skipped');
491
+ return;
492
+ }
493
+
494
+ const blocked = stats.requestsBlocked ?? 0;
495
+ const replayed = stats.requestsReplayed ?? 0;
496
+ const recorded = stats.requestsRecorded ?? 0;
497
+ const allowed = stats.requestsAllowed ?? 0;
498
+
499
+ /** @type {string[]} */
500
+ const parts = [];
501
+ if (blocked > 0) parts.push(`${count(blocked)} ${plural(blocked, 'request', 'requests')} blocked`);
502
+ if (replayed > 0) parts.push(`${count(replayed)} replayed from saved copies`);
503
+ if (recorded > 0) parts.push(`${count(recorded)} saved for next time`);
504
+ if (parts.length === 0) {
505
+ parts.push(
506
+ allowed > 0
507
+ ? `nothing to block, ${count(allowed)} ${plural(allowed, 'request', 'requests')} stayed inside the app`
508
+ : 'nothing tried to load',
509
+ );
510
+ } else if (allowed > 0) {
511
+ parts.push(`${count(allowed)} allowed through`);
512
+ }
513
+ say(CHECK_LABELS.network, parts.join(', '), 'ok');
514
+ }
515
+
516
+ /**
517
+ * Masks are set in the config and found on the page, and those are two different
518
+ * numbers. A screen with three masks configured and none of them on it painted
519
+ * nothing — saying "no live areas set" there would be a small lie, and the kind
520
+ * that makes somebody stop trusting the rest of the list.
521
+ *
522
+ * @param {Say} say
523
+ * @param {import('../types.js').MaskRect[]|undefined} masks
524
+ * @param {number|undefined} asked
525
+ */
526
+ function masksStep(say, masks, asked) {
527
+ const n = masks ? masks.length : 0;
528
+ if (n === 0) {
529
+ const set = asked ?? 0;
530
+ say(
531
+ CHECK_LABELS.masksNone,
532
+ set > 0
533
+ ? `${count(set)} set, none of them on this screen`
534
+ : 'no live areas set for this screen',
535
+ 'skipped',
536
+ );
537
+ return;
538
+ }
539
+ say(CHECK_LABELS.masks, `${count(n)} ${plural(n, 'area', 'areas')}, on both pictures`, 'ok');
540
+ }
541
+
542
+ /**
543
+ * @param {Say} say
544
+ * @param {ChecksInput} input
545
+ */
546
+ function sizeStep(say, input) {
547
+ const size = input.size;
548
+ const approved = input.approvedSize;
549
+ const has = input.hasApproved !== false && Boolean(approved);
550
+
551
+ if (!size) return;
552
+ if (!has || !approved) {
553
+ say(CHECK_LABELS.sizeNew, dimensions(size), 'skipped');
554
+ return;
555
+ }
556
+ if (approved.width !== size.width || approved.height !== size.height) {
557
+ say(CHECK_LABELS.sizeChanged, `${dimensions(size)} now, ${dimensions(approved)} approved`, 'bad');
558
+ return;
559
+ }
560
+ say(CHECK_LABELS.size, dimensions(size), 'ok');
561
+ }
562
+
563
+ /**
564
+ * The line the whole thing exists for: how many pixels were looked at, how many
565
+ * of them moved, and how many were allowed to. A share on its own ("0.30%
566
+ * changed") means nothing without the allowance beside it.
567
+ *
568
+ * @param {Say} say
569
+ * @param {ChecksInput} input
570
+ */
571
+ function pixelsStep(say, input) {
572
+ const compare = input.compare;
573
+ const size = compare?.size ?? input.size;
574
+ const total = size ? size.width * size.height : 0;
575
+
576
+ if (input.hasApproved === false || !compare) {
577
+ say(
578
+ CHECK_LABELS.pixelsNew,
579
+ total > 0 ? `${count(total)} pixels waiting for a first approval` : undefined,
580
+ 'skipped',
581
+ );
582
+ return;
583
+ }
584
+ if (compare.sizeMismatch) {
585
+ say(CHECK_LABELS.pixelsSkipped, 'two different sizes cannot be laid over each other', 'skipped');
586
+ return;
587
+ }
588
+
589
+ const allowed = allowanceFor(total, input.tolerance);
590
+ const differed = compare.diffPixels ?? 0;
591
+ const pixels = `${count(total)} pixels`;
592
+
593
+ if (differed === 0) {
594
+ say(CHECK_LABELS.pixels, `${pixels}, none different`, 'ok');
595
+ return;
596
+ }
597
+ const moved = `${count(differed)} different (${share(compare.diffRatio ?? 0)})`;
598
+ if (compare.equal) {
599
+ say(CHECK_LABELS.pixels, `${pixels}, ${moved}, within the ${count(allowed)} allowed`, 'ok');
600
+ return;
601
+ }
602
+ say(CHECK_LABELS.pixels, `${pixels}, ${moved}, more than the ${count(allowed)} allowed`, 'bad');
603
+ }
604
+
605
+ /**
606
+ * @param {Say} say
607
+ * @param {string[]} errors
608
+ */
609
+ function consoleStep(say, errors) {
610
+ if (errors.length === 0) {
611
+ say(CHECK_LABELS.console, 'nothing thrown', 'ok');
612
+ return;
613
+ }
614
+ const first = firstLine(errors[0]);
615
+ say(
616
+ CHECK_LABELS.consoleBad,
617
+ `${count(errors.length)} ${plural(errors.length, 'error', 'errors')}${first ? `, first: ${first}` : ''}`,
618
+ 'warn',
619
+ );
620
+ }
621
+
622
+ /**
623
+ * @param {Say} say
624
+ * @param {ChecksInput} input
625
+ */
626
+ function retryStep(say, input) {
627
+ const attempts = input.attempts ?? 1;
628
+ if (attempts <= 1) return;
629
+ const passed = input.status === 'passed';
630
+ say(
631
+ CHECK_LABELS.retried,
632
+ passed
633
+ ? `it looked different at first, then matched on try ${count(attempts)} — it may be unreliable`
634
+ : `${count(attempts)} tries, different every time`,
635
+ 'warn',
636
+ );
637
+ }
638
+
639
+ /**
640
+ * @param {Say} say
641
+ * @param {{approvedOn?: string, here?: string}|undefined} platform
642
+ */
643
+ function platformStep(say, platform) {
644
+ if (!platform || !platform.approvedOn || !platform.here) return;
645
+ if (platform.approvedOn === platform.here) return;
646
+ say(
647
+ CHECK_LABELS.platform,
648
+ `approved on ${platform.approvedOn}, checked on ${platform.here} — text is drawn differently on each`,
649
+ 'warn',
650
+ );
651
+ }
652
+
653
+ /**
654
+ * How many differing pixels this project is willing to forgive.
655
+ * The same arithmetic the comparison itself does, so the number a person is
656
+ * shown is the number the verdict was made against.
657
+ *
658
+ * @param {number} total
659
+ * @param {import('../types.js').ToleranceConfig|undefined} tolerance
660
+ * @returns {number}
661
+ */
662
+ function allowanceFor(total, tolerance) {
663
+ const t = tolerance ?? {};
664
+ if (typeof t.maxPixels === 'number') return t.maxPixels;
665
+ return Math.floor(total * (t.pixels ?? DEFAULT_TOLERANCE.pixels));
666
+ }
667
+
668
+ /**
669
+ * @param {{width:number,height:number}} size
670
+ * @returns {string}
671
+ */
672
+ function dimensions(size) {
673
+ return `${count(size.width)} × ${count(size.height)}`;
674
+ }
675
+
676
+ /**
677
+ * @param {number} n
678
+ * @returns {string}
679
+ */
680
+ function count(n) {
681
+ return Number.isFinite(n) ? Math.round(n).toLocaleString('en-US') : String(n);
682
+ }
683
+
684
+ /**
685
+ * @param {number} n
686
+ * @param {string} one
687
+ * @param {string} many
688
+ * @returns {string}
689
+ */
690
+ function plural(n, one, many) {
691
+ return n === 1 ? one : many;
692
+ }
693
+
694
+ /**
695
+ * A share of the picture, said in a way that is never misleading. A count of
696
+ * pixels can be large and still round to 0.00%, and "0.00% changed" beside a
697
+ * five-figure pixel count reads as a bug in the tool.
698
+ *
699
+ * @param {number} ratio
700
+ * @returns {string}
701
+ */
702
+ function share(ratio) {
703
+ const pct = (Number.isFinite(ratio) ? ratio : 0) * 100;
704
+ if (pct <= 0) return '0%';
705
+ if (pct < 0.01) return 'under 0.01%';
706
+ return `${pct.toFixed(2)}%`;
707
+ }
708
+
709
+ /**
710
+ * @param {string|undefined} text
711
+ * @returns {string}
712
+ */
713
+ function firstLine(text) {
714
+ if (typeof text !== 'string') return '';
715
+ const line = text.split('\n')[0].trim();
716
+ return line.length > 80 ? `${line.slice(0, 79)}…` : line;
717
+ }
@@ -59,12 +59,21 @@ const KNOWN_KEYS = new Set([...ACTION_ORDER, 'text', 'note']);
59
59
  * masks: import('../types.js').MaskRect[],
60
60
  * timings: {steps: number, prepare: number, settle: number},
61
61
  * spent: {steps: number, prepare: number, settle: number},
62
+ * frozen: import('../core/events.js').FrozenPlan,
63
+ * loaded?: import('../core/events.js').LoadedReport,
62
64
  * thumbnail?: string,
63
65
  * }>}
64
66
  * The standard report, plus the mask rectangles that were painted so the comparison can
65
67
  * paint the exact same rectangles onto the approved picture, plus where the time went.
66
68
  * Only this function knows how its own milliseconds were spent, so it says, rather than
67
69
  * leaving the run to guess by wrapping things it cannot see inside.
70
+ *
71
+ * `frozen` and `loaded` are here so the run can tell a person what was actually done to
72
+ * this screen. `frozen` is what the freeze layer was ASKED for — the only place that
73
+ * knows a check was switched off in the config, and therefore the only way the list can
74
+ * say so instead of quietly claiming success. `loaded` is what the page itself said was
75
+ * still loading at the moment the shutter fired: a measurement, taken here because it
76
+ * cannot be recovered afterwards, and gone the instant the app moves on.
68
77
  */
69
78
  export async function captureScreen(page, screen, settings, ctx) {
70
79
  const deviceScaleFactor = settings.viewport.deviceScaleFactor ?? 2;
@@ -134,6 +143,14 @@ export async function captureScreen(page, screen, settings, ctx) {
134
143
  });
135
144
  spent.settle = since(startedSettle, clock());
136
145
 
146
+ // Asked the moment the picture exists, and never before: this is a statement about
147
+ // the frame that was kept. Reading it is a single round trip that touches nothing —
148
+ // no styles, no scroll, no focus — so it cannot change what the picture looks like,
149
+ // and at a couple of milliseconds against a screen that takes seconds it is not
150
+ // worth gating behind whether anybody is watching. Nobody can ask the page this
151
+ // question later; by then the app has moved on.
152
+ const loaded = await readLoaded(page);
153
+
137
154
  const rects = await resolveMasks(page, settings.masks ?? [], { deviceScaleFactor });
138
155
  // Masks force us to decode the picture; hold on to those pixels. The preview a
139
156
  // watcher is shown is made from the very same ones — the picture that gets
@@ -156,7 +173,7 @@ export async function captureScreen(page, screen, settings, ctx) {
156
173
  await runSteps(page, screen.after);
157
174
  }
158
175
 
159
- /** @type {import('../types.js').CaptureReport & {masks: import('../types.js').MaskRect[], timings: typeof spent, spent: typeof spent, thumbnail?: string}} */
176
+ /** @type {import('../types.js').CaptureReport & {masks: import('../types.js').MaskRect[], timings: typeof spent, spent: typeof spent, frozen: import('../core/events.js').FrozenPlan, loaded?: import('../core/events.js').LoadedReport, thumbnail?: string}} */
160
177
  const report = {
161
178
  png,
162
179
  width: size.width,
@@ -165,10 +182,23 @@ export async function captureScreen(page, screen, settings, ctx) {
165
182
  consoleErrors: page.consoleErrors(),
166
183
  freeze: frozen.stats(),
167
184
  masks: rects,
185
+ // What was asked of the freeze layer, in the same words the config used. Written
186
+ // down here rather than worked out later, because by the time anybody reports on
187
+ // this screen the settings have been merged away.
188
+ frozen: {
189
+ clock: settings.freeze.clock !== false,
190
+ motion: settings.freeze.motion !== false,
191
+ random: settings.freeze.random !== 'off',
192
+ fonts: settings.freeze.fonts !== false,
193
+ network: settings.freeze.network ?? 'block-external',
194
+ frames: settleConfig.frames ?? 2,
195
+ maxDriftPixels: settleConfig.maxDriftPixels ?? 0,
196
+ },
168
197
  // The same three numbers under both names the rest of the tool asks for them by.
169
198
  timings: spent,
170
199
  spent,
171
200
  };
201
+ if (loaded) report.loaded = loaded;
172
202
  if (ctx.thumbnail === true) {
173
203
  const small = await thumbnailOf(painted ? painted.image : png);
174
204
  if (small) report.thumbnail = small;
@@ -184,6 +214,51 @@ export async function captureScreen(page, screen, settings, ctx) {
184
214
  }
185
215
  }
186
216
 
217
+ /**
218
+ * What the page says is still loading, at the moment the picture was taken.
219
+ *
220
+ * The shutter already waited for fonts and images before it fired; this asks the page
221
+ * whether that wait actually finished, so a run can say "nothing still loading" and mean
222
+ * it. Read-only by construction — it looks at `document.fonts.status` and the `complete`
223
+ * flag of every `<img>`, and touches nothing else, which is what makes it safe to run
224
+ * against a page whose picture has already been kept.
225
+ *
226
+ * A page that navigated, closed or crashed answers nothing, and nothing is what gets
227
+ * reported: a missing measurement must never be dressed up as a passing one.
228
+ *
229
+ * @param {import('../types.js').PageHandle} page
230
+ * @returns {Promise<import('../core/events.js').LoadedReport|undefined>}
231
+ */
232
+ async function readLoaded(page) {
233
+ const source = `(() => {
234
+ var out = { fonts: 'none', images: 0, imagesPending: 0 };
235
+ try {
236
+ if (document.fonts && document.fonts.status) out.fonts = String(document.fonts.status);
237
+ } catch (e) {}
238
+ try {
239
+ var imgs = document.images ? Array.prototype.slice.call(document.images) : [];
240
+ out.images = imgs.length;
241
+ for (var i = 0; i < imgs.length; i++) {
242
+ if (!imgs[i].complete) out.imagesPending++;
243
+ }
244
+ } catch (e) {}
245
+ return out;
246
+ })()`;
247
+
248
+ try {
249
+ const seen = await page.evaluate(source);
250
+ if (!seen || typeof seen !== 'object') return undefined;
251
+ return {
252
+ fonts: typeof seen.fonts === 'string' ? seen.fonts : undefined,
253
+ images: Number(seen.images) || 0,
254
+ imagesPending: Number(seen.imagesPending) || 0,
255
+ };
256
+ } catch {
257
+ // The page is gone. Say nothing rather than guess.
258
+ return undefined;
259
+ }
260
+ }
261
+
187
262
  /**
188
263
  * Milliseconds between two readings of the monotonic clock.
189
264
  * @param {bigint} from
@@ -21,12 +21,17 @@ import { resetWindow } from '../drive/launch.js';
21
21
  import { gitInfo } from '../core/git.js';
22
22
  import { messageOf } from '../core/errors.js';
23
23
  import { detail } from '../core/log.js';
24
- import { emitEvent, fileUrl } from '../core/events.js';
24
+ import { emitEvent, fileUrl, buildChecks } from '../core/events.js';
25
25
 
26
26
  /**
27
- * A picture result plus the one extra fact the flake register needs: whether it
28
- * only agreed with the approved picture after being photographed again.
29
- * @typedef {import('../types.js').PictureResult & {retriedToPass?: boolean}} PictureRunResult
27
+ * A picture result plus two things the plain result has no room for: whether it
28
+ * only agreed with the approved picture after being photographed again, which is
29
+ * what the flake register needs, and the list of what was actually done to the
30
+ * screen, which is what a person needs before they will believe the verdict.
31
+ * @typedef {import('../types.js').PictureResult & {
32
+ * retriedToPass?: boolean,
33
+ * checks?: import('../types.js').CheckStep[],
34
+ * }} PictureRunResult
30
35
  */
31
36
 
32
37
  /**
@@ -163,6 +168,10 @@ function emitDone(events, result, thumbs) {
163
168
  thumbnail: thumbs.shot,
164
169
  approvedThumb: thumbs.approved,
165
170
  diffThumb: thumbs.diff,
171
+ // The working: every step this screen really went through, in order. Built once,
172
+ // on the result, and passed straight along — so the panel and anything else
173
+ // listening read the same words the result was saved with.
174
+ checks: result.checks,
166
175
  // The real pictures. Only ever set for a file that was written or read a moment
167
176
  // ago, so anything that arrives here can be opened; a screen that was skipped, or
168
177
  // one that could not be photographed at all, sends none of them.
@@ -222,6 +231,44 @@ async function runOneScreen(project, page, screen, ctx) {
222
231
  /** @type {import('../types.js').CompareReport|null} */
223
232
  let compare = null;
224
233
  let attempts = 0;
234
+ /** @type {Awaited<ReturnType<typeof captureScreen>>|undefined} */
235
+ let last;
236
+
237
+ /**
238
+ * Hand back one finished screen, with the list of what was done to it when that
239
+ * list is worth building.
240
+ *
241
+ * Every way out of this function goes through here, so there is exactly one place
242
+ * that knows how to describe a screen — and no path that can quietly forget to.
243
+ *
244
+ * @param {PictureRunResult} result
245
+ * @param {string} [failure] Why the screen could not be photographed at all.
246
+ * @returns {PictureRunResult}
247
+ */
248
+ function done(result, failure) {
249
+ if (worthExplaining(ctx, result)) {
250
+ result.checks = buildChecks({
251
+ screen,
252
+ status: result.status,
253
+ frozen: last?.frozen,
254
+ settle: last?.settle,
255
+ loaded: last?.loaded,
256
+ freeze: last?.freeze,
257
+ masks: last?.masks,
258
+ masksAsked: settings.masks.length,
259
+ consoleErrors,
260
+ size,
261
+ approvedSize: result.approvedSize,
262
+ compare,
263
+ hasApproved: Boolean(approved),
264
+ tolerance: settings.tolerance,
265
+ attempts,
266
+ platform: { approvedOn: approved?.meta?.platform, here: ctx.here },
267
+ failure,
268
+ });
269
+ }
270
+ return finish(ctx, result);
271
+ }
225
272
 
226
273
  try {
227
274
  // Photograph, and if it disagrees with the approved picture, photograph again
@@ -243,6 +290,7 @@ async function runOneScreen(project, page, screen, ctx) {
243
290
  thumbnail: ctx.thumbnail === true,
244
291
  });
245
292
  accountForCapture(ctx.timings, shot);
293
+ last = shot;
246
294
  consoleErrors = shot.consoleErrors;
247
295
  size = { width: shot.width, height: shot.height };
248
296
 
@@ -283,15 +331,18 @@ async function runOneScreen(project, page, screen, ctx) {
283
331
  }
284
332
  }
285
333
  } catch (error) {
286
- return finish(ctx, {
287
- name: screen.name,
288
- describe: screen.describe,
289
- status: 'failed',
290
- message: join(`${screen.name} could not be photographed. ${messageOf(error)}`, platformNote),
291
- durationMs: Date.now() - started,
292
- attempts,
293
- consoleErrors: consoleErrors.length > 0 ? consoleErrors : undefined,
294
- });
334
+ return done(
335
+ {
336
+ name: screen.name,
337
+ describe: screen.describe,
338
+ status: 'failed',
339
+ message: join(`${screen.name} could not be photographed. ${messageOf(error)}`, platformNote),
340
+ durationMs: Date.now() - started,
341
+ attempts,
342
+ consoleErrors: consoleErrors.length > 0 ? consoleErrors : undefined,
343
+ },
344
+ messageOf(error),
345
+ );
295
346
  }
296
347
 
297
348
  /** @type {PictureRunResult} */
@@ -319,7 +370,7 @@ async function runOneScreen(project, page, screen, ctx) {
319
370
  });
320
371
  // It exists now, because that call is what wrote it.
321
372
  if (ctx.thumbs) ctx.thumbs.approvedFile = fileUrl(approvedPaths.png);
322
- return finish(ctx, {
373
+ return done({
323
374
  ...base,
324
375
  status: 'new',
325
376
  approvedPath: approvedPaths.png,
@@ -327,7 +378,7 @@ async function runOneScreen(project, page, screen, ctx) {
327
378
  message: join(`${screen.name} had no approved picture — this one was saved as the first.`, platformNote),
328
379
  });
329
380
  }
330
- return finish(ctx, {
381
+ return done({
331
382
  ...base,
332
383
  status: 'new',
333
384
  message: join(
@@ -340,7 +391,7 @@ async function runOneScreen(project, page, screen, ctx) {
340
391
  if (!compare) {
341
392
  // Cannot happen: with an approved picture every attempt compares. Kept so a
342
393
  // future edit that breaks that assumption fails loudly instead of silently passing.
343
- return finish(ctx, {
394
+ return done({
344
395
  ...base,
345
396
  status: 'failed',
346
397
  message: join(`${screen.name} was photographed but never compared.`, platformNote),
@@ -357,7 +408,7 @@ async function runOneScreen(project, page, screen, ctx) {
357
408
 
358
409
  if (compare.equal) {
359
410
  const retriedToPass = attempts > 1;
360
- return finish(ctx, {
411
+ return done({
361
412
  ...common,
362
413
  status: 'passed',
363
414
  retriedToPass,
@@ -399,7 +450,7 @@ async function runOneScreen(project, page, screen, ctx) {
399
450
  ? 'There is no difference picture for a size change — open the new picture and look at it.'
400
451
  : `Open the difference picture, and if the new look is right run \`staysfixed approve ${screen.name}\`.`;
401
452
 
402
- return finish(ctx, {
453
+ return done({
403
454
  ...common,
404
455
  status: 'changed',
405
456
  diffPath,
@@ -446,6 +497,28 @@ function platformWarning(approvedOn, here) {
446
497
  return `Careful: this picture was approved on ${approvedOn} and checked on ${here}. Text is drawn differently on each, so a small difference here may mean nothing.`;
447
498
  }
448
499
 
500
+ /**
501
+ * Is this screen worth explaining?
502
+ *
503
+ * Building the list is cheap — a few dozen short strings — but cheap is not the same
504
+ * as free, and there is no reason to write out the working of a screen nobody will
505
+ * ever read it for. Two cases deserve it: somebody has the live panel open and is
506
+ * watching this happen, or the screen did something other than quietly agree with
507
+ * its approved picture. A plain pass in a plain run carries no message at all, which
508
+ * is exactly how a pass with a warning on it — a retry, a picture approved on another
509
+ * computer — still gets its working shown.
510
+ *
511
+ * @param {ScreenCtx} ctx
512
+ * @param {PictureRunResult} result
513
+ * @returns {boolean}
514
+ */
515
+ function worthExplaining(ctx, result) {
516
+ if (ctx.thumbnail === true) return true;
517
+ if (result.status !== 'passed') return true;
518
+ if (result.retriedToPass === true) return true;
519
+ return Boolean(result.message);
520
+ }
521
+
449
522
  /**
450
523
  * @param {{onResult?: (r: PictureRunResult) => void}} ctx
451
524
  * @param {PictureRunResult} result
package/src/types.js CHANGED
@@ -503,6 +503,10 @@ export {};
503
503
  * thumbnail is unreadable, which is the whole point of looking.
504
504
  * @property {string} [approvedFile] file:// URL of the approved picture.
505
505
  * @property {string} [diffFile] file:// URL of the difference image.
506
+ * @property {CheckStep[]} [checks] What was actually done to this screen, in order.
507
+ * A verdict on its own ("matches") does not tell anyone what
508
+ * was verified, and a row showing only a name and a duration
509
+ * reads as a speed test. This is the working shown.
506
510
  * @property {string} [approvedThumb]
507
511
  * @property {string} [diffThumb]
508
512
  * @property {RunSummary} [summary] Only on 'run:done'.
@@ -544,3 +548,16 @@ export {};
544
548
  * @property {number} other
545
549
  * @property {number} total
546
550
  */
551
+
552
+ /**
553
+ * One thing that was done to a screen, and how it went.
554
+ *
555
+ * These are the real steps of a picture check, in the order they happen — reaching the
556
+ * screen, holding it still, waiting for fonts and pictures, matching the size, comparing
557
+ * every pixel, and listening for errors the page threw while nobody was looking.
558
+ *
559
+ * @typedef {object} CheckStep
560
+ * @property {string} label Plain language: "held still", "every pixel compared".
561
+ * @property {string} [detail] The number behind it: "5,184,000 pixels, none different".
562
+ * @property {'ok'|'warn'|'bad'|'skipped'} state
563
+ */
@@ -217,6 +217,10 @@ export function panelHtml(plan = {}) {
217
217
 
218
218
  // --- every check, one quiet line each ----------------------------------
219
219
  '<div class="scroll" id="scroll">',
220
+ // The working for whatever is on the glass. It belongs in the scrolling
221
+ // column, not inside the picture's own box — put there it stole the
222
+ // picture's height and squashed it to a sliver.
223
+ '<section class="work-here" id="work" hidden></section>',
220
224
  '<section class="group" id="groupScreens" hidden>',
221
225
  '<p class="grouplabel">Screens<span class="mono" id="countScreens"></span></p>',
222
226
  '<div class="items" id="listScreens"></div>',
@@ -807,6 +811,56 @@ button { font: inherit; color: inherit; }
807
811
  .detail .claim em { font-style: normal; color: var(--faint); }
808
812
  .detail .story { margin-top: 7px; color: var(--faint); }
809
813
 
814
+ /* The working, shown.
815
+ A verdict and a stopwatch do not tell anybody what was actually done to a
816
+ screen — which is exactly what the first person to look at this panel said.
817
+ So every step of a picture check is written out here in the order it
818
+ happened, numbered, with the number behind it on the right. It reads like a
819
+ receipt: quiet, aligned, and only ever seen by somebody who opened the row.
820
+ The spine down the left is built one segment per step, so the step that
821
+ moved or broke colours its own piece of it — the same idea as the meter at
822
+ the top of the window. */
823
+ .work-here { padding: 2px var(--pad) 10px; }
824
+ .work-here .worklabel { margin: 0 0 6px; }
825
+ .detail .worklabel {
826
+ margin: 11px 0 6px;
827
+ font-size: var(--t-label); font-weight: 600;
828
+ letter-spacing: 0.16em; text-transform: uppercase;
829
+ color: var(--faint);
830
+ }
831
+ .work { list-style: none; margin: 0; padding: 0; }
832
+ .step {
833
+ display: flex; align-items: baseline; gap: 9px;
834
+ padding: 4px 0 4px 11px;
835
+ box-shadow: inset 1px 0 0 var(--line);
836
+ }
837
+ .step.warn { box-shadow: inset 1.5px 0 0 var(--moved); }
838
+ .step.bad { box-shadow: inset 1.5px 0 0 var(--broke); }
839
+ .step.skipped { box-shadow: inset 1px 0 0 var(--resting); }
840
+ /* The numeral keeps its own column whatever happens to the right of it — a
841
+ number that has been left behind on a line of its own is not a sequence any
842
+ more, which is what a long label does to it on a panel dragged narrow. */
843
+ .stepno { flex: 0 0 15px; text-align: right; font-size: var(--t-label); color: var(--faint); }
844
+ /* Label on one line, its number under it.
845
+ They used to share a line and wrap to a second, right-aligned one whenever the
846
+ number was long — which at 460px was most of them, and the column came out
847
+ ragged. Stacked, it reads down the page like a receipt: what was done, then
848
+ what it came to. */
849
+ .stepbody { flex: 1 1 auto; min-width: 0; }
850
+ .stepwhat { color: var(--soft); }
851
+ .stepnum {
852
+ display: block; margin-top: 1px;
853
+ font-size: var(--t-meta); color: var(--faint);
854
+ overflow-wrap: anywhere;
855
+ }
856
+ /* A step that wants a person is the one thing in the list that is allowed to
857
+ be read from across the row: its words go to full strength, its number takes
858
+ the colour of what happened. */
859
+ .step.warn .stepwhat, .step.bad .stepwhat { color: var(--ink); }
860
+ .step.warn .stepno, .step.warn .stepnum { color: var(--moved); }
861
+ .step.bad .stepno, .step.bad .stepnum { color: var(--broke); }
862
+ .step.skipped .stepwhat, .step.skipped .stepno, .step.skipped .stepnum { color: var(--faint); }
863
+
810
864
  .nothing { padding: 26px 16px; color: var(--faint); font-size: var(--t-body); line-height: 1.7; text-align: center; }
811
865
  .nothing .mono { color: var(--soft); }
812
866
 
@@ -1120,7 +1174,7 @@ const SCRIPT = `
1120
1174
  var ui = {
1121
1175
  clock: el('clock'), project: el('project'), app: el('app'), targetsep: el('targetsep'),
1122
1176
  state: el('state'), note: el('note'),
1123
- track: el('track'), fill: el('fill'), counts: el('counts'), what: el('what'),
1177
+ track: el('track'), fill: el('fill'), counts: el('counts'), what: el('what'), work: el('work'),
1124
1178
  stage: el('stage'), shot: el('shot'), layerA: el('layerA'), layerB: el('layerB'), blank: el('blank'),
1125
1179
  caption: el('caption'), shotname: el('shotname'), shotout: el('shotout'), tabs: el('tabs'),
1126
1180
  scroll: el('scroll'), follow: el('follow'), nothing: el('nothing'),
@@ -1454,6 +1508,17 @@ const SCRIPT = `
1454
1508
  out.textContent = entry.outText;
1455
1509
  d.appendChild(out);
1456
1510
  }
1511
+ // The working. Everything that was really done to this screen, in the
1512
+ // order it happened, numbered so it reads as a sequence rather than a bag
1513
+ // of facts. Absent on older runs and on guards, and then simply not drawn.
1514
+ var work = workList(entry.checks);
1515
+ if (work) {
1516
+ var worklabel = document.createElement('p');
1517
+ worklabel.className = 'worklabel';
1518
+ worklabel.textContent = 'What was done';
1519
+ d.appendChild(worklabel);
1520
+ d.appendChild(work);
1521
+ }
1457
1522
  if (entry.failedAt) {
1458
1523
  var claim = document.createElement('div');
1459
1524
  claim.className = 'claim';
@@ -1475,6 +1540,80 @@ const SCRIPT = `
1475
1540
  if (!entry.hasDetail) setOpen(entry, false);
1476
1541
  }
1477
1542
 
1543
+ /**
1544
+ * The working, as a numbered list — or null when there is nothing to show.
1545
+ *
1546
+ * Everything that was really done to a screen, in the order it happened.
1547
+ * Numbered because that is the question people ask: not "did it pass" but
1548
+ * "what did you actually check". Absent on older runs and on guards.
1549
+ *
1550
+ * @param {any} checks
1551
+ * @returns {HTMLOListElement|null}
1552
+ */
1553
+ function workList(checks) {
1554
+ var steps = Array.isArray(checks) ? checks : null;
1555
+ if (!steps || !steps.length) return null;
1556
+ var work = document.createElement('ol');
1557
+ work.className = 'work';
1558
+ var counted = 0;
1559
+ for (var k = 0; k < steps.length; k++) {
1560
+ var step = steps[k];
1561
+ if (!step || typeof step !== 'object') continue;
1562
+ var said = String(step.label == null ? '' : step.label).trim();
1563
+ if (!said) continue;
1564
+ counted++;
1565
+
1566
+ var line = document.createElement('li');
1567
+ var state = step.state;
1568
+ var carries = state === 'warn' || state === 'bad' || state === 'skipped';
1569
+ line.className = 'step ' + (carries ? state : 'ok');
1570
+
1571
+ var no = document.createElement('span');
1572
+ no.className = 'stepno mono';
1573
+ no.textContent = String(counted);
1574
+ var body = document.createElement('span');
1575
+ body.className = 'stepbody';
1576
+ var what = document.createElement('span');
1577
+ what.className = 'stepwhat';
1578
+ what.textContent = said;
1579
+ body.appendChild(what);
1580
+
1581
+ var behind = String(step.detail == null ? '' : step.detail).trim();
1582
+ if (behind) {
1583
+ var num = document.createElement('span');
1584
+ num.className = 'stepnum mono';
1585
+ num.textContent = behind;
1586
+ body.appendChild(num);
1587
+ }
1588
+ line.appendChild(no);
1589
+ line.appendChild(body);
1590
+ work.appendChild(line);
1591
+ }
1592
+ return counted ? work : null;
1593
+ }
1594
+
1595
+ /**
1596
+ * The same list, always visible, under the picture on the glass.
1597
+ *
1598
+ * Behind a click it may as well not exist — the first person to use this asked
1599
+ * for the checks and never found them, because a passing row is closed by
1600
+ * default. Whichever screen is being shown says what was done to it, right
1601
+ * there, without anybody having to go looking.
1602
+ *
1603
+ * @param {any} checks
1604
+ */
1605
+ function showWork(checks) {
1606
+ var list = workList(checks);
1607
+ ui.work.textContent = '';
1608
+ if (!list) { ui.work.hidden = true; return; }
1609
+ var label = document.createElement('p');
1610
+ label.className = 'worklabel';
1611
+ label.textContent = 'What was done';
1612
+ ui.work.appendChild(label);
1613
+ ui.work.appendChild(list);
1614
+ ui.work.hidden = false;
1615
+ }
1616
+
1478
1617
  /** Put a screen's pictures back in the hero, with the words that went with them. */
1479
1618
  function recall(entry) {
1480
1619
  var p = entry.pics;
@@ -1488,6 +1627,7 @@ const SCRIPT = `
1488
1627
  if (!showed) return;
1489
1628
  nameTheShot(entry.name);
1490
1629
  sayOutcome(entry.tone, outcomeShort(p));
1630
+ showWork(entry.checks);
1491
1631
  markShowing(entry);
1492
1632
  }
1493
1633
 
@@ -2348,6 +2488,9 @@ const SCRIPT = `
2348
2488
  // in the list, because the picture is where the person is looking and
2349
2489
  // that line is what tells them there is something to decide.
2350
2490
  sayOutcome(tone, outcomeShort(ev));
2491
+ // And what was actually done to it, right under the picture, while the
2492
+ // person is looking at it.
2493
+ showWork(ev.checks);
2351
2494
  }
2352
2495
  tint(worstTone());
2353
2496
  return;
@@ -2418,6 +2561,9 @@ const SCRIPT = `
2418
2561
  // picture that is already decoded and the swap never flashes.
2419
2562
  preloadAll(entry.pics);
2420
2563
  }
2564
+ // What was actually done to this screen. Kept as it arrived — the run is
2565
+ // the one thing that knows, and the panel never invents a step.
2566
+ if (Array.isArray(ev.checks)) entry.checks = ev.checks;
2421
2567
  entry.outText = outcomeText(kind, ev);
2422
2568
  if (entry.verdict) entry.verdict.textContent = shortOutcome(kind, ev);
2423
2569
  entry.failedAt = (kind === 'guard' && ev.status === 'failed' && ev.failedAt) ? ev.failedAt : '';