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 +1 -1
- package/src/core/events.js +518 -0
- package/src/picture/capture.js +76 -1
- package/src/picture/run.js +91 -18
- package/src/types.js +17 -0
- package/src/watch/panel.js +147 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "staysfixed",
|
|
3
|
-
"version": "0.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",
|
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,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
|
+
}
|
package/src/picture/capture.js
CHANGED
|
@@ -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
|
package/src/picture/run.js
CHANGED
|
@@ -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
|
|
28
|
-
* only agreed with the approved picture after being photographed again
|
|
29
|
-
*
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
*/
|
package/src/watch/panel.js
CHANGED
|
@@ -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 : '';
|