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.
@@ -13,6 +13,11 @@ import { applyFreeze, prepareForShutter } from '../freeze/index.js';
13
13
  import { settle } from '../freeze/settle.js';
14
14
  import { resolveMasks, paintMasks } from '../freeze/mask.js';
15
15
  import { StaysFixedError, isExpected, messageOf } from '../core/errors.js';
16
+ // The words a step is described by, and the two builders that turn what is known at
17
+ // this instant into one line of the list. Imported rather than written again here so
18
+ // the line a person watches tick is the same line, in the same words, that the
19
+ // finished list hands back a moment later.
20
+ import { CHECK_KEYS, CHECK_LABELS, checkStep, runningStep } from '../core/events.js';
16
21
  // store.js reads `pngSize` back out of this file. Two modules about the same pictures
17
22
  // leaning on each other is fine here — both sides are plain functions, so neither is
18
23
  // half-built when the other asks for it.
@@ -51,20 +56,42 @@ const KNOWN_KEYS = new Set([...ACTION_ORDER, 'text', 'note']);
51
56
  * freeze: import('../types.js').FreezeConfig,
52
57
  * masks: import('../types.js').Mask[],
53
58
  * }} settings
54
- * @param {{fixturesDir: string, record?: boolean, timeoutMs?: number, thumbnail?: boolean}} ctx
59
+ * @param {{
60
+ * fixturesDir: string,
61
+ * record?: boolean,
62
+ * timeoutMs?: number,
63
+ * thumbnail?: boolean,
64
+ * onStep?: (step: import('../types.js').CheckStep) => void,
65
+ * }} ctx
55
66
  * `thumbnail` is asked for only while somebody is watching the run happen; it costs a
56
67
  * decode of the picture that was just taken (skipped when the masks already decoded it)
57
68
  * plus about forty milliseconds of shrinking, so it is off unless it is wanted.
69
+ *
70
+ * `onStep` is the same idea for words instead of pixels: hand one in and this says
71
+ * what it is doing as it does it — each phase named the moment it starts and named
72
+ * again the moment it settles — so a watcher can tick the list off live instead of
73
+ * being shown a finished table. Gated exactly the way `thumbnail` is: the run hands
74
+ * one in only while a panel is open, so the ordinary case is one `typeof` check per
75
+ * phase and nothing allocated at all.
58
76
  * @returns {Promise<import('../types.js').CaptureReport & {
59
77
  * masks: import('../types.js').MaskRect[],
60
78
  * timings: {steps: number, prepare: number, settle: number},
61
79
  * spent: {steps: number, prepare: number, settle: number},
80
+ * frozen: import('../core/events.js').FrozenPlan,
81
+ * loaded?: import('../core/events.js').LoadedReport,
62
82
  * thumbnail?: string,
63
83
  * }>}
64
84
  * The standard report, plus the mask rectangles that were painted so the comparison can
65
85
  * paint the exact same rectangles onto the approved picture, plus where the time went.
66
86
  * Only this function knows how its own milliseconds were spent, so it says, rather than
67
87
  * leaving the run to guess by wrapping things it cannot see inside.
88
+ *
89
+ * `frozen` and `loaded` are here so the run can tell a person what was actually done to
90
+ * this screen. `frozen` is what the freeze layer was ASKED for — the only place that
91
+ * knows a check was switched off in the config, and therefore the only way the list can
92
+ * say so instead of quietly claiming success. `loaded` is what the page itself said was
93
+ * still loading at the moment the shutter fired: a measurement, taken here because it
94
+ * cannot be recovered afterwards, and gone the instant the app moves on.
68
95
  */
69
96
  export async function captureScreen(page, screen, settings, ctx) {
70
97
  const deviceScaleFactor = settings.viewport.deviceScaleFactor ?? 2;
@@ -74,12 +101,88 @@ export async function captureScreen(page, screen, settings, ctx) {
74
101
  await page.setViewport(settings.viewport);
75
102
  page.clearConsole();
76
103
 
104
+ // What the freeze layer is being ASKED for. Worked out before it is asked, because a
105
+ // line announced as it starts can only be worded from what is already known — and a
106
+ // project that switched the clock freezing off has to be told that at the moment it
107
+ // does not happen, not have a freeze claimed and then taken back.
108
+ /** @type {import('../core/events.js').FrozenPlan} */
109
+ const plan = {
110
+ clock: settings.freeze.clock !== false,
111
+ motion: settings.freeze.motion !== false,
112
+ random: settings.freeze.random !== 'off',
113
+ fonts: settings.freeze.fonts !== false,
114
+ network: settings.freeze.network ?? 'block-external',
115
+ frames: settleConfig.frames ?? 2,
116
+ maxDriftPixels: settleConfig.maxDriftPixels ?? 0,
117
+ };
118
+ const masksAsked = (settings.masks ?? []).length;
119
+
120
+ // Gated exactly the way `thumbnail` is: the run hands an `onStep` in only while a
121
+ // panel is open. Nobody watching means this is null, every announcement below is one
122
+ // `if` that does nothing, and not a single object is made.
123
+ const onStep = typeof ctx.onStep === 'function' ? ctx.onStep : null;
124
+ /**
125
+ * The line announced as started and not yet settled — so a failure can close it
126
+ * instead of leaving a row spinning forever on somebody's screen.
127
+ * @type {import('../types.js').CheckStep|null}
128
+ */
129
+ let pending = null;
130
+
131
+ /**
132
+ * Name a phase as it begins.
133
+ * @param {import('../core/events.js').CheckKey} key
134
+ * @param {import('../core/events.js').ChecksInput} input
135
+ * @returns {void}
136
+ */
137
+ const begins = (key, input) => {
138
+ if (!onStep) return;
139
+ const step = runningStep(key, input);
140
+ pending = step ?? null;
141
+ if (step) onStep(step);
142
+ };
143
+
144
+ /**
145
+ * Name the same phase again, settled, under the same key.
146
+ * @param {import('../core/events.js').CheckKey} key
147
+ * @param {import('../core/events.js').ChecksInput} input
148
+ * @param {import('../types.js').CheckStep} [instead] For a line whose number is not
149
+ * knowable yet; the finished list fills that in.
150
+ * @returns {void}
151
+ */
152
+ const settled = (key, input, instead) => {
153
+ if (!onStep) return;
154
+ pending = null;
155
+ const step = instead ?? checkStep(key, input);
156
+ if (step) onStep(step);
157
+ };
158
+
159
+ /**
160
+ * Close whatever was in flight when something went wrong.
161
+ * @param {unknown} error
162
+ * @returns {void}
163
+ */
164
+ const closePending = (error) => {
165
+ if (!onStep || !pending) return;
166
+ const why = messageOf(error);
167
+ // Where a phase has its own wording for going wrong, use it: "could not reach the
168
+ // screen" is what happened, and leaving "reached the screen" up with a cross beside
169
+ // it says the opposite of the truth for as long as anybody is reading it.
170
+ const label = pending.key === CHECK_KEYS.steps ? CHECK_LABELS.stepsFailed : pending.label;
171
+ onStep({ ...pending, label, state: 'bad', detail: why });
172
+ pending = null;
173
+ };
174
+
175
+ begins(CHECK_KEYS.frozen, { screen, frozen: plan });
77
176
  const frozen = await applyFreeze(page, settings.freeze, {
78
177
  fixturesDir: ctx.fixturesDir,
79
178
  screenName: screen.name,
80
179
  record: ctx.record ?? false,
81
180
  deviceScaleFactor,
181
+ }).catch((error) => {
182
+ closePending(error);
183
+ throw error;
82
184
  });
185
+ settled(CHECK_KEYS.frozen, { screen, frozen: plan });
83
186
 
84
187
  // A frozen clock cannot time anything, so the stopwatch is the host's own, and it is
85
188
  // the monotonic one: a machine that adjusts its clock mid-run must not be able to
@@ -90,11 +193,13 @@ export async function captureScreen(page, screen, settings, ctx) {
90
193
  const spent = { steps: 0, prepare: 0, settle: 0 };
91
194
 
92
195
  try {
196
+ begins(CHECK_KEYS.steps, { screen });
93
197
  if (typeof screen.do === 'function') {
94
198
  await screen.do(page);
95
199
  } else {
96
200
  await runSteps(page, screen.steps ?? []);
97
201
  }
202
+ settled(CHECK_KEYS.steps, { screen });
98
203
 
99
204
  // Scrolling back to the top is the deterministic default, but a screen that
100
205
  // deliberately scrolled somewhere must be photographed where it landed.
@@ -104,11 +209,21 @@ export async function captureScreen(page, screen, settings, ctx) {
104
209
  const startedPrepare = clock();
105
210
  spent.steps = since(startedSteps, startedPrepare);
106
211
 
212
+ // Waiting for the page to be ready. WHAT was still loading can only be read off the
213
+ // frame that ends up being kept, which does not exist yet, so this line settles on
214
+ // the one thing that is true here — the shutter waited — and the finished list fills
215
+ // in the count of faces and pictures a moment later, on the same line.
216
+ begins(CHECK_KEYS.loaded, { screen, frozen: plan });
107
217
  await prepareForShutter(page, {
108
218
  fonts: settings.freeze.fonts !== false,
109
219
  timeoutMs,
110
220
  keepScroll: scrolledOnPurpose,
111
221
  });
222
+ settled(
223
+ CHECK_KEYS.loaded,
224
+ { screen, frozen: plan },
225
+ plan.fonts ? { key: CHECK_KEYS.loaded, label: CHECK_LABELS.loaded, state: 'ok' } : undefined,
226
+ );
112
227
 
113
228
  /** @type {import('../types.js').CaptureOptions} */
114
229
  const shotOptions = {};
@@ -124,6 +239,10 @@ export async function captureScreen(page, screen, settings, ctx) {
124
239
  const startedSettle = clock();
125
240
  spent.prepare = since(startedPrepare, startedSettle);
126
241
 
242
+ // Holding still — and the shutter is inside it. The settle loop photographs the
243
+ // screen over and over until two frames in a row are identical and keeps the last of
244
+ // them, so this is ONE line rather than two: there is one thing happening.
245
+ begins(CHECK_KEYS.settle, { screen, frozen: plan });
127
246
  const held = await settle(page, {
128
247
  frames: settleConfig.frames ?? 2,
129
248
  intervalMs: settleConfig.intervalMs ?? 250,
@@ -133,7 +252,17 @@ export async function captureScreen(page, screen, settings, ctx) {
133
252
  probe: () => page.shoot(probeOptions),
134
253
  });
135
254
  spent.settle = since(startedSettle, clock());
255
+ settled(CHECK_KEYS.settle, { screen, settle: held.report, frozen: plan });
256
+
257
+ // Asked the moment the picture exists, and never before: this is a statement about
258
+ // the frame that was kept. Reading it is a single round trip that touches nothing —
259
+ // no styles, no scroll, no focus — so it cannot change what the picture looks like,
260
+ // and at a couple of milliseconds against a screen that takes seconds it is not
261
+ // worth gating behind whether anybody is watching. Nobody can ask the page this
262
+ // question later; by then the app has moved on.
263
+ const loaded = await readLoaded(page);
136
264
 
265
+ begins(CHECK_KEYS.masks, { screen, masksAsked });
137
266
  const rects = await resolveMasks(page, settings.masks ?? [], { deviceScaleFactor });
138
267
  // Masks force us to decode the picture; hold on to those pixels. The preview a
139
268
  // watcher is shown is made from the very same ones — the picture that gets
@@ -142,6 +271,9 @@ export async function captureScreen(page, screen, settings, ctx) {
142
271
  const painted = rects.length > 0 ? paintInto(held.png, rects) : null;
143
272
  const png = painted ? painted.png : held.png;
144
273
  const size = pngSize(png);
274
+ // Said after the painting, not after the finding: the line claims the boxes are on
275
+ // the picture, so it waits until they are.
276
+ settled(CHECK_KEYS.masks, { screen, masks: rects, masksAsked });
145
277
 
146
278
  // The picture is taken; now put the app back if this screen asked us to.
147
279
  //
@@ -156,24 +288,42 @@ export async function captureScreen(page, screen, settings, ctx) {
156
288
  await runSteps(page, screen.after);
157
289
  }
158
290
 
159
- /** @type {import('../types.js').CaptureReport & {masks: import('../types.js').MaskRect[], timings: typeof spent, spent: typeof spent, thumbnail?: string}} */
291
+ // Requests are not a phase the outside world is kept out for the whole capture —
292
+ // so this is one line, said once, at the end. Counted here rather than a few lines
293
+ // earlier so the number a watcher sees is the same number the finished list carries,
294
+ // including anything the putting-back steps asked for.
295
+ const stats = frozen.stats();
296
+ settled(CHECK_KEYS.network, { screen, freeze: stats, frozen: plan });
297
+
298
+ /** @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
299
  const report = {
161
300
  png,
162
301
  width: size.width,
163
302
  height: size.height,
164
303
  settle: held.report,
165
304
  consoleErrors: page.consoleErrors(),
166
- freeze: frozen.stats(),
305
+ freeze: stats,
167
306
  masks: rects,
307
+ // What was asked of the freeze layer, in the same words the config used. Worked
308
+ // out at the top rather than here, because by the time anybody reports on this
309
+ // screen the settings have been merged away — and because the live list needs the
310
+ // same answer before any of this happens, and the two must never disagree.
311
+ frozen: plan,
168
312
  // The same three numbers under both names the rest of the tool asks for them by.
169
313
  timings: spent,
170
314
  spent,
171
315
  };
316
+ if (loaded) report.loaded = loaded;
172
317
  if (ctx.thumbnail === true) {
173
318
  const small = await thumbnailOf(painted ? painted.image : png);
174
319
  if (small) report.thumbnail = small;
175
320
  }
176
321
  return report;
322
+ } catch (error) {
323
+ // A row that stays "running" for the rest of the run is a lie about what happened.
324
+ // The run reports the failure itself; this only closes the line it fell over on.
325
+ closePending(error);
326
+ throw error;
177
327
  } finally {
178
328
  // Releasing must never be the thing that hides a real failure.
179
329
  try {
@@ -184,6 +334,51 @@ export async function captureScreen(page, screen, settings, ctx) {
184
334
  }
185
335
  }
186
336
 
337
+ /**
338
+ * What the page says is still loading, at the moment the picture was taken.
339
+ *
340
+ * The shutter already waited for fonts and images before it fired; this asks the page
341
+ * whether that wait actually finished, so a run can say "nothing still loading" and mean
342
+ * it. Read-only by construction — it looks at `document.fonts.status` and the `complete`
343
+ * flag of every `<img>`, and touches nothing else, which is what makes it safe to run
344
+ * against a page whose picture has already been kept.
345
+ *
346
+ * A page that navigated, closed or crashed answers nothing, and nothing is what gets
347
+ * reported: a missing measurement must never be dressed up as a passing one.
348
+ *
349
+ * @param {import('../types.js').PageHandle} page
350
+ * @returns {Promise<import('../core/events.js').LoadedReport|undefined>}
351
+ */
352
+ async function readLoaded(page) {
353
+ const source = `(() => {
354
+ var out = { fonts: 'none', images: 0, imagesPending: 0 };
355
+ try {
356
+ if (document.fonts && document.fonts.status) out.fonts = String(document.fonts.status);
357
+ } catch (e) {}
358
+ try {
359
+ var imgs = document.images ? Array.prototype.slice.call(document.images) : [];
360
+ out.images = imgs.length;
361
+ for (var i = 0; i < imgs.length; i++) {
362
+ if (!imgs[i].complete) out.imagesPending++;
363
+ }
364
+ } catch (e) {}
365
+ return out;
366
+ })()`;
367
+
368
+ try {
369
+ const seen = await page.evaluate(source);
370
+ if (!seen || typeof seen !== 'object') return undefined;
371
+ return {
372
+ fonts: typeof seen.fonts === 'string' ? seen.fonts : undefined,
373
+ images: Number(seen.images) || 0,
374
+ imagesPending: Number(seen.imagesPending) || 0,
375
+ };
376
+ } catch {
377
+ // The page is gone. Say nothing rather than guess.
378
+ return undefined;
379
+ }
380
+ }
381
+
187
382
  /**
188
383
  * Milliseconds between two readings of the monotonic clock.
189
384
  * @param {bigint} from
@@ -21,12 +21,17 @@ import { resetWindow } from '../drive/launch.js';
21
21
  import { gitInfo } from '../core/git.js';
22
22
  import { messageOf } from '../core/errors.js';
23
23
  import { detail } from '../core/log.js';
24
- import { emitEvent, fileUrl } from '../core/events.js';
24
+ import { emitEvent, fileUrl, buildChecks, checkStep, runningStep, CHECK_KEYS } from '../core/events.js';
25
25
 
26
26
  /**
27
- * A picture result plus the one extra fact the flake register needs: whether it
28
- * only agreed with the approved picture after being photographed again.
29
- * @typedef {import('../types.js').PictureResult & {retriedToPass?: boolean}} PictureRunResult
27
+ * A picture result plus two things the plain result has no room for: whether it
28
+ * only agreed with the approved picture after being photographed again, which is
29
+ * what the flake register needs, and the list of what was actually done to the
30
+ * screen, which is what a person needs before they will believe the verdict.
31
+ * @typedef {import('../types.js').PictureResult & {
32
+ * retriedToPass?: boolean,
33
+ * checks?: import('../types.js').CheckStep[],
34
+ * }} PictureRunResult
30
35
  */
31
36
 
32
37
  /**
@@ -163,6 +168,10 @@ function emitDone(events, result, thumbs) {
163
168
  thumbnail: thumbs.shot,
164
169
  approvedThumb: thumbs.approved,
165
170
  diffThumb: thumbs.diff,
171
+ // The working: every step this screen really went through, in order. Built once,
172
+ // on the result, and passed straight along — so the panel and anything else
173
+ // listening read the same words the result was saved with.
174
+ checks: result.checks,
166
175
  // The real pictures. Only ever set for a file that was written or read a moment
167
176
  // ago, so anything that arrives here can be opened; a screen that was skipped, or
168
177
  // one that could not be photographed at all, sends none of them.
@@ -223,6 +232,88 @@ async function runOneScreen(project, page, screen, ctx) {
223
232
  let compare = null;
224
233
  let attempts = 0;
225
234
 
235
+ /**
236
+ * Say one thing about this screen the moment it is true.
237
+ *
238
+ * The finished list at the end is still the authority — a watcher that missed every
239
+ * one of these ends up with exactly the same rows — but a person who is watching
240
+ * wants to see the working tick past as it happens rather than appear all at once
241
+ * when the screen is already over.
242
+ *
243
+ * Only while somebody is watching, and gated on the very same flag the small pictures
244
+ * are: the run sets `thumbnail` when a panel is open. Nobody watching means this is
245
+ * undefined, nothing is built and nothing is sent.
246
+ *
247
+ * @type {((step: import('../types.js').CheckStep) => void)|undefined}
248
+ */
249
+ const onStep =
250
+ ctx.thumbnail === true && ctx.events
251
+ ? (/** @type {import('../types.js').CheckStep} */ step) => {
252
+ emitEvent(ctx.events, { type: 'screen:step', name: screen.name, step });
253
+ }
254
+ : undefined;
255
+
256
+ /**
257
+ * One line of the working, built from what is known at this instant.
258
+ *
259
+ * @param {import('../core/events.js').CheckKey} key
260
+ * @param {boolean} [starting] True to name it as it begins, false once it has settled.
261
+ * @returns {void}
262
+ */
263
+ function tell(key, starting) {
264
+ if (!onStep) return;
265
+ /** @type {import('../core/events.js').ChecksInput} */
266
+ const known = {
267
+ screen,
268
+ size,
269
+ approvedSize: compare?.approvedSize,
270
+ compare,
271
+ hasApproved: Boolean(approved),
272
+ tolerance: settings.tolerance,
273
+ consoleErrors,
274
+ };
275
+ const step = starting ? runningStep(key, known) : checkStep(key, known);
276
+ if (step) onStep(step);
277
+ }
278
+ /** @type {Awaited<ReturnType<typeof captureScreen>>|undefined} */
279
+ let last;
280
+
281
+ /**
282
+ * Hand back one finished screen, with the list of what was done to it when that
283
+ * list is worth building.
284
+ *
285
+ * Every way out of this function goes through here, so there is exactly one place
286
+ * that knows how to describe a screen — and no path that can quietly forget to.
287
+ *
288
+ * @param {PictureRunResult} result
289
+ * @param {string} [failure] Why the screen could not be photographed at all.
290
+ * @returns {PictureRunResult}
291
+ */
292
+ function done(result, failure) {
293
+ if (worthExplaining(ctx, result)) {
294
+ result.checks = buildChecks({
295
+ screen,
296
+ status: result.status,
297
+ frozen: last?.frozen,
298
+ settle: last?.settle,
299
+ loaded: last?.loaded,
300
+ freeze: last?.freeze,
301
+ masks: last?.masks,
302
+ masksAsked: settings.masks.length,
303
+ consoleErrors,
304
+ size,
305
+ approvedSize: result.approvedSize,
306
+ compare,
307
+ hasApproved: Boolean(approved),
308
+ tolerance: settings.tolerance,
309
+ attempts,
310
+ platform: { approvedOn: approved?.meta?.platform, here: ctx.here },
311
+ failure,
312
+ });
313
+ }
314
+ return finish(ctx, result);
315
+ }
316
+
226
317
  try {
227
318
  // Photograph, and if it disagrees with the approved picture, photograph again
228
319
  // before believing it. One flickering frame is not a regression.
@@ -241,8 +332,13 @@ async function runOneScreen(project, page, screen, ctx) {
241
332
  record: ctx.record ?? false,
242
333
  timeoutMs: settings.freeze.settle?.timeoutMs,
243
334
  thumbnail: ctx.thumbnail === true,
335
+ // The phases inside one photograph — freezing, the recipe, waiting, holding
336
+ // still, painting over the live areas — can only be announced by the thing
337
+ // doing them. Everything after the shutter is announced here instead.
338
+ onStep,
244
339
  });
245
340
  accountForCapture(ctx.timings, shot);
341
+ last = shot;
246
342
  consoleErrors = shot.consoleErrors;
247
343
  size = { width: shot.width, height: shot.height };
248
344
 
@@ -270,6 +366,12 @@ async function runOneScreen(project, page, screen, ctx) {
270
366
 
271
367
  if (!approved) break;
272
368
 
369
+ // These two lines exist only once there is something to compare against, so they
370
+ // are said here rather than by the capture: the size match and the pixel count are
371
+ // both answered by the single call below.
372
+ tell(CHECK_KEYS.size, true);
373
+ tell(CHECK_KEYS.pixels, true);
374
+
273
375
  const stopCompare = ctx.timings?.mark('compare');
274
376
  // compareFast, not comparePng: identical bytes are answered from the PNG header
275
377
  // instead of decoding two retina images pixel by pixel. Nothing changed on most
@@ -277,21 +379,36 @@ async function runOneScreen(project, page, screen, ctx) {
277
379
  // comparing stage from about a quarter of a second a screen to nothing at all.
278
380
  compare = compareFast(approved.png, shot.png, settings.tolerance, shot.masks);
279
381
  stopCompare?.();
382
+ tell(CHECK_KEYS.size);
383
+ tell(CHECK_KEYS.pixels);
280
384
  if (compare.equal) break;
281
385
  if (attempt <= ctx.retries) {
282
386
  detail(`${screen.name} looked different on attempt ${attempt} — taking it again.`);
283
387
  }
284
388
  }
389
+
390
+ if (!approved) {
391
+ // Nothing to measure this against, which is an answer rather than a gap. Said
392
+ // here because the loop above never reached a comparison to say it.
393
+ tell(CHECK_KEYS.size);
394
+ tell(CHECK_KEYS.pixels);
395
+ }
396
+ // Whatever the page shouted while nobody was looking. Known from the moment the
397
+ // picture was taken, and the last line of the working either way.
398
+ tell(CHECK_KEYS.console);
285
399
  } catch (error) {
286
- return finish(ctx, {
287
- name: screen.name,
288
- describe: screen.describe,
289
- status: 'failed',
290
- message: join(`${screen.name} could not be photographed. ${messageOf(error)}`, platformNote),
291
- durationMs: Date.now() - started,
292
- attempts,
293
- consoleErrors: consoleErrors.length > 0 ? consoleErrors : undefined,
294
- });
400
+ return done(
401
+ {
402
+ name: screen.name,
403
+ describe: screen.describe,
404
+ status: 'failed',
405
+ message: join(`${screen.name} could not be photographed. ${messageOf(error)}`, platformNote),
406
+ durationMs: Date.now() - started,
407
+ attempts,
408
+ consoleErrors: consoleErrors.length > 0 ? consoleErrors : undefined,
409
+ },
410
+ messageOf(error),
411
+ );
295
412
  }
296
413
 
297
414
  /** @type {PictureRunResult} */
@@ -319,7 +436,7 @@ async function runOneScreen(project, page, screen, ctx) {
319
436
  });
320
437
  // It exists now, because that call is what wrote it.
321
438
  if (ctx.thumbs) ctx.thumbs.approvedFile = fileUrl(approvedPaths.png);
322
- return finish(ctx, {
439
+ return done({
323
440
  ...base,
324
441
  status: 'new',
325
442
  approvedPath: approvedPaths.png,
@@ -327,7 +444,7 @@ async function runOneScreen(project, page, screen, ctx) {
327
444
  message: join(`${screen.name} had no approved picture — this one was saved as the first.`, platformNote),
328
445
  });
329
446
  }
330
- return finish(ctx, {
447
+ return done({
331
448
  ...base,
332
449
  status: 'new',
333
450
  message: join(
@@ -340,7 +457,7 @@ async function runOneScreen(project, page, screen, ctx) {
340
457
  if (!compare) {
341
458
  // Cannot happen: with an approved picture every attempt compares. Kept so a
342
459
  // future edit that breaks that assumption fails loudly instead of silently passing.
343
- return finish(ctx, {
460
+ return done({
344
461
  ...base,
345
462
  status: 'failed',
346
463
  message: join(`${screen.name} was photographed but never compared.`, platformNote),
@@ -357,7 +474,7 @@ async function runOneScreen(project, page, screen, ctx) {
357
474
 
358
475
  if (compare.equal) {
359
476
  const retriedToPass = attempts > 1;
360
- return finish(ctx, {
477
+ return done({
361
478
  ...common,
362
479
  status: 'passed',
363
480
  retriedToPass,
@@ -399,7 +516,7 @@ async function runOneScreen(project, page, screen, ctx) {
399
516
  ? 'There is no difference picture for a size change — open the new picture and look at it.'
400
517
  : `Open the difference picture, and if the new look is right run \`staysfixed approve ${screen.name}\`.`;
401
518
 
402
- return finish(ctx, {
519
+ return done({
403
520
  ...common,
404
521
  status: 'changed',
405
522
  diffPath,
@@ -446,6 +563,28 @@ function platformWarning(approvedOn, here) {
446
563
  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
564
  }
448
565
 
566
+ /**
567
+ * Is this screen worth explaining?
568
+ *
569
+ * Building the list is cheap — a few dozen short strings — but cheap is not the same
570
+ * as free, and there is no reason to write out the working of a screen nobody will
571
+ * ever read it for. Two cases deserve it: somebody has the live panel open and is
572
+ * watching this happen, or the screen did something other than quietly agree with
573
+ * its approved picture. A plain pass in a plain run carries no message at all, which
574
+ * is exactly how a pass with a warning on it — a retry, a picture approved on another
575
+ * computer — still gets its working shown.
576
+ *
577
+ * @param {ScreenCtx} ctx
578
+ * @param {PictureRunResult} result
579
+ * @returns {boolean}
580
+ */
581
+ function worthExplaining(ctx, result) {
582
+ if (ctx.thumbnail === true) return true;
583
+ if (result.status !== 'passed') return true;
584
+ if (result.retriedToPass === true) return true;
585
+ return Boolean(result.message);
586
+ }
587
+
449
588
  /**
450
589
  * @param {{onResult?: (r: PictureRunResult) => void}} ctx
451
590
  * @param {PictureRunResult} result
package/src/types.js CHANGED
@@ -304,6 +304,9 @@
304
304
  * @property {string} [failedAt] The plain-language expectation that failed.
305
305
  * @property {string} [file]
306
306
  * @property {string} [because]
307
+ * @property {CheckStep[]} [checks] Every claim the guard asserted and every action it took,
308
+ * in order. A guard that failed still shows the claims that
309
+ * held before it — that is most of the value.
307
310
  * @property {number} durationMs
308
311
  * @property {number} [attempts]
309
312
  */
@@ -482,7 +485,7 @@ export {};
482
485
  * run began) so a watcher can draw a timeline without keeping its own clock.
483
486
  *
484
487
  * @typedef {object} RunEvent
485
- * @property {'run:start'|'screen:start'|'screen:shot'|'screen:done'|'guard:start'|'guard:done'|'phase'|'note'|'run:done'} type
488
+ * @property {'run:start'|'screen:start'|'screen:step'|'screen:shot'|'screen:done'|'guard:start'|'guard:step'|'guard:done'|'phase'|'note'|'run:done'} type
486
489
  * @property {number} at Milliseconds since the run started.
487
490
  * @property {string} [name] Screen or guard name.
488
491
  * @property {string} [describe] The plain-language description.
@@ -503,6 +506,12 @@ export {};
503
506
  * thumbnail is unreadable, which is the whole point of looking.
504
507
  * @property {string} [approvedFile] file:// URL of the approved picture.
505
508
  * @property {string} [diffFile] file:// URL of the difference image.
509
+ * @property {CheckStep} [step] One step, reported the moment it happens, so a watcher can
510
+ * tick the list off live instead of waiting for the verdict.
511
+ * @property {CheckStep[]} [checks] What was actually done to this screen, in order.
512
+ * A verdict on its own ("matches") does not tell anyone what
513
+ * was verified, and a row showing only a name and a duration
514
+ * reads as a speed test. This is the working shown.
506
515
  * @property {string} [approvedThumb]
507
516
  * @property {string} [diffThumb]
508
517
  * @property {RunSummary} [summary] Only on 'run:done'.
@@ -544,3 +553,18 @@ export {};
544
553
  * @property {number} other
545
554
  * @property {number} total
546
555
  */
556
+
557
+ /**
558
+ * One thing that was done to a screen, and how it went.
559
+ *
560
+ * These are the real steps of a picture check, in the order they happen — reaching the
561
+ * screen, holding it still, waiting for fonts and pictures, matching the size, comparing
562
+ * every pixel, and listening for errors the page threw while nobody was looking.
563
+ *
564
+ * @typedef {object} CheckStep
565
+ * @property {string} label Plain language: "held still", "every pixel compared".
566
+ * @property {string} [detail] The number behind it: "5,184,000 pixels, none different".
567
+ * @property {'running'|'ok'|'warn'|'bad'|'skipped'} state
568
+ * @property {string} [key] Stable id, so a step reported as 'running' can be found
569
+ * again and settled when it finishes.
570
+ */