staysfixed 0.2.2 → 0.3.0

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.3.0",
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,673 @@ 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
+ * 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
+
279
+ /**
280
+ * What was asked of the freeze layer for one screen. Everything here is a
281
+ * setting, not a measurement — it is how the list can say a check was switched
282
+ * off instead of pretending it passed.
283
+ *
284
+ * @typedef {object} FrozenPlan
285
+ * @property {boolean} clock The clock, timezone and locale were pinned.
286
+ * @property {boolean} motion Animations, transitions and carets were killed.
287
+ * @property {boolean} random Random numbers were seeded.
288
+ * @property {boolean} fonts The shutter waited for fonts.
289
+ * @property {'replay'|'block-external'|'live'} network How requests were treated.
290
+ * @property {number} frames Identical frames in a row demanded.
291
+ * @property {number} [maxDriftPixels] Pixels allowed to wobble and still count as identical.
292
+ */
293
+
294
+ /**
295
+ * What the page said was still loading at the moment the picture was taken.
296
+ * Read off the real page, so it is a measurement rather than a hope.
297
+ *
298
+ * @typedef {object} LoadedReport
299
+ * @property {string} [fonts] document.fonts.status: 'loaded', 'loading', or 'none'.
300
+ * @property {number} [images] How many pictures the page has.
301
+ * @property {number} [imagesPending] How many of them had not finished.
302
+ */
303
+
304
+ /**
305
+ * Everything needed to say what was done to one screen.
306
+ *
307
+ * All of it optional except the screen, because a screen that fell over halfway
308
+ * still deserves an honest list of how far it got.
309
+ *
310
+ * @typedef {object} ChecksInput
311
+ * @property {import('../types.js').ScreenConfig} screen The recipe that was followed.
312
+ * @property {import('../types.js').CheckStatus} [status] How the screen ended up.
313
+ * @property {FrozenPlan} [frozen] What the freeze layer was asked to do.
314
+ * @property {import('../types.js').SettleReport} [settle] How the holding-still went.
315
+ * @property {LoadedReport} [loaded] What was still loading at the shutter.
316
+ * @property {import('../types.js').FreezeStats} [freeze] Requests blocked, replayed, allowed.
317
+ * @property {import('../types.js').MaskRect[]} [masks] Rectangles painted over both pictures.
318
+ * @property {number} [masksAsked] How many masks the config set for this screen.
319
+ * @property {string[]} [consoleErrors] Errors the page threw, unprompted.
320
+ * @property {{width:number,height:number}} [size] The new picture.
321
+ * @property {{width:number,height:number}} [approvedSize] The approved one.
322
+ * @property {import('../types.js').CompareReport|null} [compare] The verdict on the pixels.
323
+ * @property {boolean} [hasApproved] Was there an approved picture at all.
324
+ * @property {import('../types.js').ToleranceConfig} [tolerance] How much difference was allowed.
325
+ * @property {number} [attempts] How many times it was photographed.
326
+ * @property {{approvedOn?: string, here?: string}} [platform] Where the two pictures were taken.
327
+ * @property {string} [failure] Why it could not be photographed at all.
328
+ */
329
+
330
+ /**
331
+ * Turn what a capture and a comparison know into the list of things that were
332
+ * actually done to a screen, in the order they happened.
333
+ *
334
+ * A verdict on its own — "matches", "1.9s" — tells nobody what was verified. It
335
+ * reads like a speed test, and a person who cannot see the work cannot decide
336
+ * whether to believe it. This is that work, written out.
337
+ *
338
+ * Pure on purpose: no browser, no disk, no clock. The words that appear in front
339
+ * of a person are then testable without photographing anything, and every path
340
+ * through the tool that wants to explain itself uses the same ones.
341
+ *
342
+ * @param {ChecksInput} input
343
+ * @returns {import('../types.js').CheckStep[]}
344
+ */
345
+ export function buildChecks(input) {
346
+ /** @type {import('../types.js').CheckStep[]} */
347
+ const out = [];
348
+ /**
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}
356
+ */
357
+ const at = (key) => (label, detail, state) => {
358
+ out.push(detail ? { label, detail, state, key } : { label, state, key });
359
+ };
360
+
361
+ const screen = input.screen ?? /** @type {import('../types.js').ScreenConfig} */ ({ name: '' });
362
+ const errors = input.consoleErrors ?? [];
363
+
364
+ frozenStep(at(CHECK_KEYS.frozen), input.frozen);
365
+ stepsStep(at(CHECK_KEYS.steps), screen, input.failure);
366
+
367
+ if (input.failure) {
368
+ // Nothing after this happened, so nothing after this is claimed. The one
369
+ // thing still worth saying is whether the page was shouting on its way down.
370
+ at(CHECK_KEYS.failed)(CHECK_LABELS.failed, input.failure, 'bad');
371
+ consoleStep(at(CHECK_KEYS.console), errors);
372
+ return out;
373
+ }
374
+
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);
384
+
385
+ return out;
386
+ }
387
+
388
+ /** @typedef {(label: string, detail: string|undefined, state: import('../types.js').CheckStep['state']) => void} Say */
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
+
512
+ /**
513
+ * @param {Say} say
514
+ * @param {FrozenPlan|undefined} frozen
515
+ */
516
+ function frozenStep(say, frozen) {
517
+ if (!frozen) return;
518
+ /** @type {string[]} */
519
+ const also = [];
520
+ if (frozen.motion) also.push('animations off');
521
+ if (frozen.random) also.push('random numbers pinned');
522
+ if (!frozen.clock) {
523
+ say(
524
+ CHECK_LABELS.frozenOff,
525
+ also.length > 0 ? also.join(', ') : 'the time may be different every run',
526
+ 'skipped',
527
+ );
528
+ return;
529
+ }
530
+ say(CHECK_LABELS.frozen, also.length > 0 ? also.join(', ') : 'the same instant every run', 'ok');
531
+ }
532
+
533
+ /**
534
+ * @param {Say} say
535
+ * @param {import('../types.js').ScreenConfig} screen
536
+ * @param {string|undefined} failure
537
+ */
538
+ function stepsStep(say, screen, failure) {
539
+ const label = failure ? CHECK_LABELS.stepsFailed : CHECK_LABELS.steps;
540
+ const state = failure ? 'bad' : 'ok';
541
+
542
+ if (typeof screen.do === 'function') {
543
+ say(label, 'its own instructions', state);
544
+ return;
545
+ }
546
+ const steps = screen.steps ?? [];
547
+ const waits = steps.filter((s) => s && s.wait !== undefined).length;
548
+ const after = (screen.after ?? []).length;
549
+
550
+ if (steps.length === 0) {
551
+ say(label, 'nothing to do — it was already there', state);
552
+ return;
553
+ }
554
+ /** @type {string[]} */
555
+ const parts = [`${steps.length} ${plural(steps.length, 'step', 'steps')}`];
556
+ parts.push(waits === 0 ? 'none of them a timed wait' : `${waits} of them a timed wait`);
557
+ if (after > 0) parts.push(`${after} more to put the app back`);
558
+ say(label, parts.join(', '), state);
559
+ }
560
+
561
+ /**
562
+ * @param {Say} say
563
+ * @param {import('../types.js').SettleReport|undefined} settle
564
+ * @param {FrozenPlan|undefined} frozen
565
+ */
566
+ function settleStep(say, settle, frozen) {
567
+ if (!settle) return;
568
+ const frames = Math.max(1, frozen?.frames ?? 2);
569
+ const drift = settle.lastDriftPixels ?? 0;
570
+
571
+ if (!settle.settled) {
572
+ const moving =
573
+ drift > 0
574
+ ? `${count(drift)} ${plural(drift, 'pixel', 'pixels')} still moving`
575
+ : 'it never held';
576
+ say(
577
+ CHECK_LABELS.settleGaveUp,
578
+ `gave up after ${count(settle.attempts)} ${plural(settle.attempts, 'photo', 'photos')}, ${moving}`,
579
+ 'warn',
580
+ );
581
+ return;
582
+ }
583
+
584
+ /** @type {string[]} */
585
+ const parts = [`${frames} identical ${plural(frames, 'frame', 'frames')} in a row`];
586
+ parts.push(
587
+ settle.attempts > frames ? `after ${count(settle.attempts)} photos` : 'first try',
588
+ );
589
+ // Only worth saying when a project deliberately allows a little wobble AND
590
+ // some wobble actually happened; otherwise it is noise about nothing.
591
+ if ((frozen?.maxDriftPixels ?? 0) > 0 && drift > 0) {
592
+ parts.push(`${count(drift)} ${plural(drift, 'pixel', 'pixels')} of allowed wobble`);
593
+ }
594
+ say(CHECK_LABELS.settle, parts.join(', '), 'ok');
595
+ }
596
+
597
+ /**
598
+ * @param {Say} say
599
+ * @param {LoadedReport|undefined} loaded
600
+ * @param {FrozenPlan|undefined} frozen
601
+ */
602
+ function loadedStep(say, loaded, frozen) {
603
+ if (frozen && frozen.fonts === false) {
604
+ say(CHECK_LABELS.loadedOff, 'the shutter did not wait for them', 'skipped');
605
+ return;
606
+ }
607
+ if (!loaded) {
608
+ say(CHECK_LABELS.loadedUnknown, 'the page had moved on by the time we asked', 'skipped');
609
+ return;
610
+ }
611
+
612
+ const pending = loaded.imagesPending ?? 0;
613
+ const fontsBusy = loaded.fonts === 'loading';
614
+ if (!fontsBusy && pending === 0) {
615
+ const total = loaded.images ?? 0;
616
+ say(
617
+ CHECK_LABELS.loaded,
618
+ total > 0
619
+ ? `every face loaded, ${count(total)} ${plural(total, 'picture', 'pictures')}, none still loading`
620
+ : 'every face loaded, nothing still loading',
621
+ 'ok',
622
+ );
623
+ return;
624
+ }
625
+
626
+ /** @type {string[]} */
627
+ const busy = [];
628
+ if (fontsBusy) busy.push('a font was still loading');
629
+ if (pending > 0) {
630
+ busy.push(`${count(pending)} ${plural(pending, 'picture', 'pictures')} still loading`);
631
+ }
632
+ say(CHECK_LABELS.loadedWaiting, busy.join(', '), 'warn');
633
+ }
634
+
635
+ /**
636
+ * @param {Say} say
637
+ * @param {import('../types.js').FreezeStats|undefined} stats
638
+ * @param {FrozenPlan|undefined} frozen
639
+ */
640
+ function networkStep(say, stats, frozen) {
641
+ if (frozen && frozen.network === 'live') {
642
+ say(CHECK_LABELS.networkLive, 'requests were left alone', 'skipped');
643
+ return;
644
+ }
645
+ if (!stats) {
646
+ say(CHECK_LABELS.networkUnknown, 'nothing was counted', 'skipped');
647
+ return;
648
+ }
649
+
650
+ const blocked = stats.requestsBlocked ?? 0;
651
+ const replayed = stats.requestsReplayed ?? 0;
652
+ const recorded = stats.requestsRecorded ?? 0;
653
+ const allowed = stats.requestsAllowed ?? 0;
654
+
655
+ /** @type {string[]} */
656
+ const parts = [];
657
+ if (blocked > 0) parts.push(`${count(blocked)} ${plural(blocked, 'request', 'requests')} blocked`);
658
+ if (replayed > 0) parts.push(`${count(replayed)} replayed from saved copies`);
659
+ if (recorded > 0) parts.push(`${count(recorded)} saved for next time`);
660
+ if (parts.length === 0) {
661
+ parts.push(
662
+ allowed > 0
663
+ ? `nothing to block, ${count(allowed)} ${plural(allowed, 'request', 'requests')} stayed inside the app`
664
+ : 'nothing tried to load',
665
+ );
666
+ } else if (allowed > 0) {
667
+ parts.push(`${count(allowed)} allowed through`);
668
+ }
669
+ say(CHECK_LABELS.network, parts.join(', '), 'ok');
670
+ }
671
+
672
+ /**
673
+ * Masks are set in the config and found on the page, and those are two different
674
+ * numbers. A screen with three masks configured and none of them on it painted
675
+ * nothing — saying "no live areas set" there would be a small lie, and the kind
676
+ * that makes somebody stop trusting the rest of the list.
677
+ *
678
+ * @param {Say} say
679
+ * @param {import('../types.js').MaskRect[]|undefined} masks
680
+ * @param {number|undefined} asked
681
+ */
682
+ function masksStep(say, masks, asked) {
683
+ const n = masks ? masks.length : 0;
684
+ if (n === 0) {
685
+ const set = asked ?? 0;
686
+ say(
687
+ CHECK_LABELS.masksNone,
688
+ set > 0
689
+ ? `${count(set)} set, none of them on this screen`
690
+ : 'no live areas set for this screen',
691
+ 'skipped',
692
+ );
693
+ return;
694
+ }
695
+ say(CHECK_LABELS.masks, `${count(n)} ${plural(n, 'area', 'areas')}, on both pictures`, 'ok');
696
+ }
697
+
698
+ /**
699
+ * @param {Say} say
700
+ * @param {ChecksInput} input
701
+ */
702
+ function sizeStep(say, input) {
703
+ const size = input.size;
704
+ const approved = input.approvedSize;
705
+ const has = input.hasApproved !== false && Boolean(approved);
706
+
707
+ if (!size) return;
708
+ if (!has || !approved) {
709
+ say(CHECK_LABELS.sizeNew, dimensions(size), 'skipped');
710
+ return;
711
+ }
712
+ if (approved.width !== size.width || approved.height !== size.height) {
713
+ say(CHECK_LABELS.sizeChanged, `${dimensions(size)} now, ${dimensions(approved)} approved`, 'bad');
714
+ return;
715
+ }
716
+ say(CHECK_LABELS.size, dimensions(size), 'ok');
717
+ }
718
+
719
+ /**
720
+ * The line the whole thing exists for: how many pixels were looked at, how many
721
+ * of them moved, and how many were allowed to. A share on its own ("0.30%
722
+ * changed") means nothing without the allowance beside it.
723
+ *
724
+ * @param {Say} say
725
+ * @param {ChecksInput} input
726
+ */
727
+ function pixelsStep(say, input) {
728
+ const compare = input.compare;
729
+ const size = compare?.size ?? input.size;
730
+ const total = size ? size.width * size.height : 0;
731
+
732
+ if (input.hasApproved === false || !compare) {
733
+ say(
734
+ CHECK_LABELS.pixelsNew,
735
+ total > 0 ? `${count(total)} pixels waiting for a first approval` : undefined,
736
+ 'skipped',
737
+ );
738
+ return;
739
+ }
740
+ if (compare.sizeMismatch) {
741
+ say(CHECK_LABELS.pixelsSkipped, 'two different sizes cannot be laid over each other', 'skipped');
742
+ return;
743
+ }
744
+
745
+ const allowed = allowanceFor(total, input.tolerance);
746
+ const differed = compare.diffPixels ?? 0;
747
+ const pixels = `${count(total)} pixels`;
748
+
749
+ if (differed === 0) {
750
+ say(CHECK_LABELS.pixels, `${pixels}, none different`, 'ok');
751
+ return;
752
+ }
753
+ const moved = `${count(differed)} different (${share(compare.diffRatio ?? 0)})`;
754
+ if (compare.equal) {
755
+ say(CHECK_LABELS.pixels, `${pixels}, ${moved}, within the ${count(allowed)} allowed`, 'ok');
756
+ return;
757
+ }
758
+ say(CHECK_LABELS.pixels, `${pixels}, ${moved}, more than the ${count(allowed)} allowed`, 'bad');
759
+ }
760
+
761
+ /**
762
+ * @param {Say} say
763
+ * @param {string[]} errors
764
+ */
765
+ function consoleStep(say, errors) {
766
+ if (errors.length === 0) {
767
+ say(CHECK_LABELS.console, 'nothing thrown', 'ok');
768
+ return;
769
+ }
770
+ const first = firstLine(errors[0]);
771
+ say(
772
+ CHECK_LABELS.consoleBad,
773
+ `${count(errors.length)} ${plural(errors.length, 'error', 'errors')}${first ? `, first: ${first}` : ''}`,
774
+ 'warn',
775
+ );
776
+ }
777
+
778
+ /**
779
+ * @param {Say} say
780
+ * @param {ChecksInput} input
781
+ */
782
+ function retryStep(say, input) {
783
+ const attempts = input.attempts ?? 1;
784
+ if (attempts <= 1) return;
785
+ const passed = input.status === 'passed';
786
+ say(
787
+ CHECK_LABELS.retried,
788
+ passed
789
+ ? `it looked different at first, then matched on try ${count(attempts)} — it may be unreliable`
790
+ : `${count(attempts)} tries, different every time`,
791
+ 'warn',
792
+ );
793
+ }
794
+
795
+ /**
796
+ * @param {Say} say
797
+ * @param {{approvedOn?: string, here?: string}|undefined} platform
798
+ */
799
+ function platformStep(say, platform) {
800
+ if (!platform || !platform.approvedOn || !platform.here) return;
801
+ if (platform.approvedOn === platform.here) return;
802
+ say(
803
+ CHECK_LABELS.platform,
804
+ `approved on ${platform.approvedOn}, checked on ${platform.here} — text is drawn differently on each`,
805
+ 'warn',
806
+ );
807
+ }
808
+
809
+ /**
810
+ * How many differing pixels this project is willing to forgive.
811
+ * The same arithmetic the comparison itself does, so the number a person is
812
+ * shown is the number the verdict was made against.
813
+ *
814
+ * @param {number} total
815
+ * @param {import('../types.js').ToleranceConfig|undefined} tolerance
816
+ * @returns {number}
817
+ */
818
+ function allowanceFor(total, tolerance) {
819
+ const t = tolerance ?? {};
820
+ if (typeof t.maxPixels === 'number') return t.maxPixels;
821
+ return Math.floor(total * (t.pixels ?? DEFAULT_TOLERANCE.pixels));
822
+ }
823
+
824
+ /**
825
+ * @param {{width:number,height:number}} size
826
+ * @returns {string}
827
+ */
828
+ function dimensions(size) {
829
+ return `${count(size.width)} × ${count(size.height)}`;
830
+ }
831
+
832
+ /**
833
+ * @param {number} n
834
+ * @returns {string}
835
+ */
836
+ function count(n) {
837
+ return Number.isFinite(n) ? Math.round(n).toLocaleString('en-US') : String(n);
838
+ }
839
+
840
+ /**
841
+ * @param {number} n
842
+ * @param {string} one
843
+ * @param {string} many
844
+ * @returns {string}
845
+ */
846
+ function plural(n, one, many) {
847
+ return n === 1 ? one : many;
848
+ }
849
+
850
+ /**
851
+ * A share of the picture, said in a way that is never misleading. A count of
852
+ * pixels can be large and still round to 0.00%, and "0.00% changed" beside a
853
+ * five-figure pixel count reads as a bug in the tool.
854
+ *
855
+ * @param {number} ratio
856
+ * @returns {string}
857
+ */
858
+ function share(ratio) {
859
+ const pct = (Number.isFinite(ratio) ? ratio : 0) * 100;
860
+ if (pct <= 0) return '0%';
861
+ if (pct < 0.01) return 'under 0.01%';
862
+ return `${pct.toFixed(2)}%`;
863
+ }
864
+
865
+ /**
866
+ * @param {string|undefined} text
867
+ * @returns {string}
868
+ */
869
+ function firstLine(text) {
870
+ if (typeof text !== 'string') return '';
871
+ const line = text.split('\n')[0].trim();
872
+ return line.length > 80 ? `${line.slice(0, 79)}…` : line;
873
+ }