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 +1 -1
- package/src/core/events.js +674 -0
- package/src/guard/api.js +168 -12
- package/src/guard/run.js +104 -6
- package/src/picture/capture.js +198 -3
- package/src/picture/run.js +157 -18
- package/src/types.js +25 -1
- package/src/watch/panel.js +836 -168
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "staysfixed",
|
|
3
|
-
"version": "0.
|
|
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",
|
package/src/core/events.js
CHANGED
|
@@ -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
|
+
}
|