staysfixed 0.1.1 → 0.2.1

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,10 @@ 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
+ // store.js reads `pngSize` back out of this file. Two modules about the same pictures
17
+ // leaning on each other is fine here — both sides are plain functions, so neither is
18
+ // half-built when the other asks for it.
19
+ import { thumbnailOf } from './store.js';
16
20
 
17
21
  /**
18
22
  * Every instruction a declarative step is allowed to give, in the order they run
@@ -47,10 +51,20 @@ const KNOWN_KEYS = new Set([...ACTION_ORDER, 'text', 'note']);
47
51
  * freeze: import('../types.js').FreezeConfig,
48
52
  * masks: import('../types.js').Mask[],
49
53
  * }} settings
50
- * @param {{fixturesDir: string, record?: boolean, timeoutMs?: number}} ctx
51
- * @returns {Promise<import('../types.js').CaptureReport & {masks: import('../types.js').MaskRect[]}>}
52
- * The standard report plus the mask rectangles that were painted, so the
53
- * comparison can paint the exact same rectangles onto the approved picture.
54
+ * @param {{fixturesDir: string, record?: boolean, timeoutMs?: number, thumbnail?: boolean}} ctx
55
+ * `thumbnail` is asked for only while somebody is watching the run happen; it costs a
56
+ * decode of the picture that was just taken (skipped when the masks already decoded it)
57
+ * plus about forty milliseconds of shrinking, so it is off unless it is wanted.
58
+ * @returns {Promise<import('../types.js').CaptureReport & {
59
+ * masks: import('../types.js').MaskRect[],
60
+ * timings: {steps: number, prepare: number, settle: number},
61
+ * spent: {steps: number, prepare: number, settle: number},
62
+ * thumbnail?: string,
63
+ * }>}
64
+ * The standard report, plus the mask rectangles that were painted so the comparison can
65
+ * paint the exact same rectangles onto the approved picture, plus where the time went.
66
+ * Only this function knows how its own milliseconds were spent, so it says, rather than
67
+ * leaving the run to guess by wrapping things it cannot see inside.
54
68
  */
55
69
  export async function captureScreen(page, screen, settings, ctx) {
56
70
  const deviceScaleFactor = settings.viewport.deviceScaleFactor ?? 2;
@@ -67,6 +81,14 @@ export async function captureScreen(page, screen, settings, ctx) {
67
81
  deviceScaleFactor,
68
82
  });
69
83
 
84
+ // A frozen clock cannot time anything, so the stopwatch is the host's own, and it is
85
+ // the monotonic one: a machine that adjusts its clock mid-run must not be able to
86
+ // report that a screen took a negative amount of time.
87
+ const clock = process.hrtime.bigint;
88
+ const startedSteps = clock();
89
+ /** @type {{steps: number, prepare: number, settle: number}} */
90
+ const spent = { steps: 0, prepare: 0, settle: 0 };
91
+
70
92
  try {
71
93
  if (typeof screen.do === 'function') {
72
94
  await screen.do(page);
@@ -79,6 +101,9 @@ export async function captureScreen(page, screen, settings, ctx) {
79
101
  const scrolledOnPurpose =
80
102
  typeof screen.do === 'function' || (screen.steps ?? []).some((s) => s.scrollTo !== undefined);
81
103
 
104
+ const startedPrepare = clock();
105
+ spent.steps = since(startedSteps, startedPrepare);
106
+
82
107
  await prepareForShutter(page, {
83
108
  fonts: settings.freeze.fonts !== false,
84
109
  timeoutMs,
@@ -90,16 +115,32 @@ export async function captureScreen(page, screen, settings, ctx) {
90
115
  if (screen.fullPage) shotOptions.fullPage = true;
91
116
  if (screen.clip) shotOptions.clip = screen.clip;
92
117
 
118
+ // The same frame, asked for cheaply. The settle loop shoots the screen over and over
119
+ // to find out whether anything moved and throws every one of those pictures away, so
120
+ // it gets a small lossy one; the picture that is kept and compared is always the PNG.
121
+ /** @type {import('../types.js').CaptureOptions & {format: 'jpeg', quality: number}} */
122
+ const probeOptions = { ...shotOptions, format: 'jpeg', quality: 50 };
123
+
124
+ const startedSettle = clock();
125
+ spent.prepare = since(startedPrepare, startedSettle);
126
+
93
127
  const held = await settle(page, {
94
128
  frames: settleConfig.frames ?? 2,
95
129
  intervalMs: settleConfig.intervalMs ?? 250,
96
130
  timeoutMs: settleConfig.timeoutMs ?? 10_000,
97
131
  maxDriftPixels: settleConfig.maxDriftPixels ?? 0,
98
132
  capture: () => page.shoot(shotOptions),
133
+ probe: () => page.shoot(probeOptions),
99
134
  });
135
+ spent.settle = since(startedSettle, clock());
100
136
 
101
137
  const rects = await resolveMasks(page, settings.masks ?? [], { deviceScaleFactor });
102
- const png = rects.length > 0 ? paintInto(held.png, rects) : held.png;
138
+ // Masks force us to decode the picture; hold on to those pixels. The preview a
139
+ // watcher is shown is made from the very same ones — the picture that gets
140
+ // compared, blackout boxes and all — so a masked screen decodes a retina
141
+ // screenshot once instead of twice, which is about eighty milliseconds a screen.
142
+ const painted = rects.length > 0 ? paintInto(held.png, rects) : null;
143
+ const png = painted ? painted.png : held.png;
103
144
  const size = pngSize(png);
104
145
 
105
146
  // The picture is taken; now put the app back if this screen asked us to.
@@ -115,7 +156,8 @@ export async function captureScreen(page, screen, settings, ctx) {
115
156
  await runSteps(page, screen.after);
116
157
  }
117
158
 
118
- return {
159
+ /** @type {import('../types.js').CaptureReport & {masks: import('../types.js').MaskRect[], timings: typeof spent, spent: typeof spent, thumbnail?: string}} */
160
+ const report = {
119
161
  png,
120
162
  width: size.width,
121
163
  height: size.height,
@@ -123,7 +165,15 @@ export async function captureScreen(page, screen, settings, ctx) {
123
165
  consoleErrors: page.consoleErrors(),
124
166
  freeze: frozen.stats(),
125
167
  masks: rects,
168
+ // The same three numbers under both names the rest of the tool asks for them by.
169
+ timings: spent,
170
+ spent,
126
171
  };
172
+ if (ctx.thumbnail === true) {
173
+ const small = await thumbnailOf(painted ? painted.image : png);
174
+ if (small) report.thumbnail = small;
175
+ }
176
+ return report;
127
177
  } finally {
128
178
  // Releasing must never be the thing that hides a real failure.
129
179
  try {
@@ -134,16 +184,31 @@ export async function captureScreen(page, screen, settings, ctx) {
134
184
  }
135
185
  }
136
186
 
187
+ /**
188
+ * Milliseconds between two readings of the monotonic clock.
189
+ * @param {bigint} from
190
+ * @param {bigint} to
191
+ * @returns {number}
192
+ */
193
+ function since(from, to) {
194
+ return Number(to - from) / 1e6;
195
+ }
196
+
137
197
  /**
138
198
  * Paint the masks into a screenshot and re-encode it.
199
+ *
200
+ * Hands back the decoded picture as well as the bytes. Whoever wants a preview of this
201
+ * screen would otherwise decode the very same megapixels a second time, and on a retina
202
+ * screenshot that is the most expensive thing either of them does.
203
+ *
139
204
  * @param {Buffer} buffer
140
205
  * @param {import('../types.js').MaskRect[]} rects
141
- * @returns {Buffer}
206
+ * @returns {{png: Buffer, image: import('pngjs').PNG}}
142
207
  */
143
208
  function paintInto(buffer, rects) {
144
209
  const image = PNG.sync.read(buffer);
145
210
  paintMasks(image, rects);
146
- return PNG.sync.write(image);
211
+ return { png: PNG.sync.write(image), image };
147
212
  }
148
213
 
149
214
  /**
@@ -13,6 +13,66 @@ import pixelmatch from 'pixelmatch';
13
13
  import { paintMasks } from '../freeze/mask.js';
14
14
  import { DEFAULT_TOLERANCE } from '../core/config.js';
15
15
  import { StaysFixedError } from '../core/errors.js';
16
+ import { pngSize } from './capture.js';
17
+
18
+ /**
19
+ * Whether two pictures are the very same file.
20
+ * @param {Buffer} a
21
+ * @param {Buffer} b
22
+ * @returns {boolean}
23
+ */
24
+ export function sameBytes(a, b) {
25
+ return Boolean(a) && Boolean(b) && a.length === b.length && a.equals(b);
26
+ }
27
+
28
+ /**
29
+ * The same comparison, without the work when there is nothing to compare.
30
+ *
31
+ * On a healthy project every screen is unchanged, which means the new picture is the very
32
+ * same file as the approved one — the encoder is deterministic, so identical pixels
33
+ * produce identical bytes. Decoding two full retina PNGs to prove that costs a quarter of
34
+ * a second per screen, every run, to reach a conclusion the file lengths already gave
35
+ * away.
36
+ *
37
+ * This is not a looser check, it is the same answer arrived at honestly. Identical bytes
38
+ * are identical pixels, so nothing differs, so nothing can exceed any allowance. Masks do
39
+ * not change that: a mask paints the same rectangle onto both pictures, and painting the
40
+ * same thing onto two identical pictures leaves them identical. The size comes out of the
41
+ * PNG header, which is where `comparePng` would have got it too.
42
+ *
43
+ * @param {Buffer} approvedBuf
44
+ * @param {Buffer} actualBuf
45
+ * @param {import('../types.js').ToleranceConfig} tolerance
46
+ * @param {import('../types.js').MaskRect[]} [maskRects]
47
+ * @returns {import('../types.js').CompareReport}
48
+ */
49
+ export function compareFast(approvedBuf, actualBuf, tolerance, maskRects = []) {
50
+ if (sameBytes(approvedBuf, actualBuf)) {
51
+ try {
52
+ const size = pngSize(actualBuf);
53
+ const allowed =
54
+ tolerance.maxPixels ??
55
+ Math.floor(size.width * size.height * (tolerance.pixels ?? DEFAULT_TOLERANCE.pixels));
56
+ // A negative allowance is a setting that says even a perfect match is a failure.
57
+ // Nonsense, but it is the caller's nonsense, and the long way round is the only one
58
+ // that can answer it the same way it always has.
59
+ if (allowed >= 0) {
60
+ return {
61
+ equal: true,
62
+ diffPixels: 0,
63
+ diffRatio: 0,
64
+ diffPng: null,
65
+ sizeMismatch: false,
66
+ size,
67
+ approvedSize: { width: size.width, height: size.height },
68
+ };
69
+ }
70
+ } catch {
71
+ // Not a readable PNG header. Let the full path throw the sentence it always throws.
72
+ }
73
+ }
74
+ return comparePng(approvedBuf, actualBuf, tolerance, maskRects);
75
+ }
16
76
 
17
77
  /**
18
78
  * @param {Buffer} approvedBuf
@@ -12,8 +12,8 @@
12
12
  */
13
13
 
14
14
  import { captureScreen } from './capture.js';
15
- import { comparePng, describeDifference } from './compare.js';
16
- import { readApproved, writeResult, writeDiff, approveFromResult } from './store.js';
15
+ import { compareFast, describeDifference } from './compare.js';
16
+ import { readApproved, writeResult, writeDiff, approveFromResult, thumbnailOf } from './store.js';
17
17
  import { settingsForScreen } from '../core/config.js';
18
18
  import { approvedPicture, resultPicture } from '../core/paths.js';
19
19
  import { platformTag } from '../drive/find.js';
@@ -21,6 +21,7 @@ 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
25
 
25
26
  /**
26
27
  * A picture result plus the one extra fact the flake register needs: whether it
@@ -28,6 +29,29 @@ import { detail } from '../core/log.js';
28
29
  * @typedef {import('../types.js').PictureResult & {retriedToPass?: boolean}} PictureRunResult
29
30
  */
30
31
 
32
+ /**
33
+ * What a watcher is shown of one screen, filled in as it is worked on.
34
+ *
35
+ * Two kinds of thing, for two different moments. The `file://` addresses are the real
36
+ * full-resolution pictures this run wrote, and they are what a person actually looks
37
+ * at and zooms into; they cost nothing to fill in, so they are always filled in. The
38
+ * base64 previews are the instant stand-in for the fraction of a second before a file
39
+ * loads, they cost real milliseconds to make, and they are only made when a window is
40
+ * open to show them in.
41
+ *
42
+ * All of it is kept apart from the result on purpose. A result is written to disk and
43
+ * read back by `approve` and `status`, and a base64 picture in there would bloat
44
+ * every saved run for the sake of a panel that was only open for a minute.
45
+ *
46
+ * @typedef {object} Thumbs
47
+ * @property {string} [shot] Instant preview of the picture just taken.
48
+ * @property {string} [approved] Instant preview of the approved picture, when this screen changed.
49
+ * @property {string} [diff] Instant preview of what moved, when this screen changed.
50
+ * @property {string} [shotFile] The picture just taken, on disk.
51
+ * @property {string} [approvedFile] The approved picture, on disk, when there is one.
52
+ * @property {string} [diffFile] The difference picture, on disk, when one was written.
53
+ */
54
+
31
55
  /**
32
56
  * @param {import('../types.js').Project} project
33
57
  * @param {import('../types.js').LaunchedApp} app
@@ -39,6 +63,9 @@ import { detail } from '../core/log.js';
39
63
  * retries?: number,
40
64
  * tool?: string,
41
65
  * signal?: AbortSignal,
66
+ * events?: import('../types.js').RunEvents,
67
+ * timings?: ReturnType<typeof import('../core/events.js').makeTimings>,
68
+ * thumbnail?: boolean,
42
69
  * }} [opts]
43
70
  * @returns {Promise<PictureRunResult[]>}
44
71
  */
@@ -53,48 +80,124 @@ export async function runPictures(project, app, opts = {}) {
53
80
  // A desktop app has no url to go back to between screens, so it gets a reload instead.
54
81
  // A web app does not need one: every screen starts with a `goto`.
55
82
  const reset = config.app.kind === 'electron' ? () => resetWindow(app) : undefined;
83
+ const events = opts.events;
84
+
85
+ // Which screens are being photographed is settled before the first shutter, so
86
+ // anyone watching can be told how many there are and where each one sits in the
87
+ // queue rather than watching a list of unknown length crawl past.
88
+ const chosen = config.screens.filter((screen) => !only || only.has(screen.name));
89
+ const total = chosen.length;
56
90
 
57
91
  /** @type {PictureRunResult[]} */
58
92
  const results = [];
59
93
 
60
- for (const screen of config.screens) {
94
+ for (let i = 0; i < chosen.length; i++) {
61
95
  if (opts.signal?.aborted) break;
62
- if (only && !only.has(screen.name)) continue;
96
+ const screen = chosen[i];
97
+
98
+ emitEvent(events, {
99
+ type: 'screen:start',
100
+ name: screen.name,
101
+ describe: screen.describe,
102
+ index: i + 1,
103
+ total,
104
+ });
63
105
 
64
106
  if (screen.skip) {
65
- results.push(
66
- finish(opts, {
67
- name: screen.name,
68
- describe: screen.describe,
69
- status: 'skipped',
70
- message: `${screen.name} is switched off in the config.`,
71
- durationMs: 0,
72
- }),
73
- );
107
+ const skipped = finish(opts, {
108
+ name: screen.name,
109
+ describe: screen.describe,
110
+ status: 'skipped',
111
+ message: `${screen.name} is switched off in the config.`,
112
+ durationMs: 0,
113
+ });
114
+ results.push(skipped);
115
+ emitDone(events, skipped, {});
74
116
  continue;
75
117
  }
76
118
 
77
- results.push(
78
- await runOneScreen(project, page, screen, {
79
- record: opts.record,
80
- updateNew: opts.updateNew,
81
- tool: opts.tool,
82
- onResult: opts.onResult,
83
- retries,
84
- here,
85
- reset,
86
- }),
87
- );
119
+ // Filled in as the screen is worked on, and read once it is finished. The
120
+ // thumbnails exist only while somebody is watching, so they travel beside the
121
+ // result instead of inside it.
122
+ /** @type {Thumbs} */
123
+ const thumbs = {};
124
+
125
+ const result = await runOneScreen(project, page, screen, {
126
+ record: opts.record,
127
+ updateNew: opts.updateNew,
128
+ tool: opts.tool,
129
+ onResult: opts.onResult,
130
+ retries,
131
+ here,
132
+ reset,
133
+ events,
134
+ timings: opts.timings,
135
+ thumbnail: opts.thumbnail === true,
136
+ thumbs,
137
+ });
138
+ results.push(result);
139
+ emitDone(events, result, thumbs);
88
140
  }
89
141
 
90
142
  return results;
91
143
  }
92
144
 
145
+ /**
146
+ * Tell anyone watching how a screen turned out, with the pictures if there are any.
147
+ *
148
+ * @param {import('../types.js').RunEvents|undefined} events
149
+ * @param {PictureRunResult} result
150
+ * @param {Thumbs} thumbs
151
+ * @returns {void}
152
+ */
153
+ function emitDone(events, result, thumbs) {
154
+ emitEvent(events, {
155
+ type: 'screen:done',
156
+ name: result.name,
157
+ describe: result.describe,
158
+ status: result.status,
159
+ durationMs: result.durationMs,
160
+ diffPixels: result.diffPixels,
161
+ diffRatio: result.diffRatio,
162
+ message: result.message,
163
+ thumbnail: thumbs.shot,
164
+ approvedThumb: thumbs.approved,
165
+ diffThumb: thumbs.diff,
166
+ // The real pictures. Only ever set for a file that was written or read a moment
167
+ // ago, so anything that arrives here can be opened; a screen that was skipped, or
168
+ // one that could not be photographed at all, sends none of them.
169
+ shotFile: thumbs.shotFile,
170
+ approvedFile: thumbs.approvedFile,
171
+ diffFile: thumbs.diffFile,
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Everything one screen needs, including where to leave what it learns.
177
+ *
178
+ * `thumbs` is written into rather than returned because a screen can finish down
179
+ * half a dozen different paths, and threading a second return value through all
180
+ * of them would bury the thing this function is actually for.
181
+ *
182
+ * @typedef {object} ScreenCtx
183
+ * @property {boolean} [record]
184
+ * @property {boolean} [updateNew]
185
+ * @property {string} [tool]
186
+ * @property {(r: PictureRunResult) => void} [onResult]
187
+ * @property {number} retries
188
+ * @property {string} here
189
+ * @property {() => Promise<void>} [reset]
190
+ * @property {import('../types.js').RunEvents} [events]
191
+ * @property {ReturnType<typeof import('../core/events.js').makeTimings>} [timings]
192
+ * @property {boolean} [thumbnail] Make the small pictures a watcher needs.
193
+ * @property {Thumbs} [thumbs] Where those small pictures are left.
194
+ */
195
+
93
196
  /**
94
197
  * @param {import('../types.js').Project} project
95
198
  * @param {import('../types.js').PageHandle} page
96
199
  * @param {import('../types.js').ScreenConfig} screen
97
- * @param {{record?: boolean, updateNew?: boolean, tool?: string, onResult?: (r: PictureRunResult) => void, retries: number, here: string, reset?: () => Promise<void>}} ctx
200
+ * @param {ScreenCtx} ctx
98
201
  * @returns {Promise<PictureRunResult>}
99
202
  */
100
203
  async function runOneScreen(project, page, screen, ctx) {
@@ -107,6 +210,11 @@ async function runOneScreen(project, page, screen, ctx) {
107
210
  const approved = await readApproved(paths, screen.name);
108
211
  const platformNote = platformWarning(approved?.meta?.platform, ctx.here);
109
212
 
213
+ // We have just read it, so it is certainly there. Pointed out even for a screen that
214
+ // ends up matching: "show me what this is supposed to look like" is a fair question
215
+ // about a screen that passed, and answering it costs one string.
216
+ if (approved && ctx.thumbs) ctx.thumbs.approvedFile = fileUrl(approvedPaths.png);
217
+
110
218
  /** @type {string[]} */
111
219
  let consoleErrors = [];
112
220
  /** @type {{width: number, height: number}|undefined} */
@@ -132,18 +240,43 @@ async function runOneScreen(project, page, screen, ctx) {
132
240
  fixturesDir: paths.fixtures,
133
241
  record: ctx.record ?? false,
134
242
  timeoutMs: settings.freeze.settle?.timeoutMs,
243
+ thumbnail: ctx.thumbnail === true,
135
244
  });
245
+ accountForCapture(ctx.timings, shot);
136
246
  consoleErrors = shot.consoleErrors;
137
247
  size = { width: shot.width, height: shot.height };
138
248
 
139
- await writeResult(paths, screen.name, shot.png, {
249
+ const shotFile = await writeResult(paths, screen.name, shot.png, {
140
250
  deviceScaleFactor: settings.viewport.deviceScaleFactor,
141
251
  describe: screen.describe,
142
252
  });
143
253
 
254
+ // Said out loud before anything is compared, so a person watching sees the
255
+ // picture appear while the run is still deciding what it thinks of it — but
256
+ // AFTER the PNG is on disk, not the instant the shutter fires. The panel opens
257
+ // the real file to show true pixels, and an <img> pointed at a file that does
258
+ // not exist yet draws a torn page and never retries. The preview travels on the
259
+ // same event and covers the moment the file takes to load.
260
+ if (ctx.thumbs) {
261
+ if (shot.thumbnail) ctx.thumbs.shot = shot.thumbnail;
262
+ ctx.thumbs.shotFile = fileUrl(shotFile);
263
+ }
264
+ emitEvent(ctx.events, {
265
+ type: 'screen:shot',
266
+ name: screen.name,
267
+ thumbnail: shot.thumbnail,
268
+ shotFile: fileUrl(shotFile),
269
+ });
270
+
144
271
  if (!approved) break;
145
272
 
146
- compare = comparePng(approved.png, shot.png, settings.tolerance, shot.masks);
273
+ const stopCompare = ctx.timings?.mark('compare');
274
+ // compareFast, not comparePng: identical bytes are answered from the PNG header
275
+ // instead of decoding two retina images pixel by pixel. Nothing changed on most
276
+ // screens on most runs, so this is the case that actually happens — it took the
277
+ // comparing stage from about a quarter of a second a screen to nothing at all.
278
+ compare = compareFast(approved.png, shot.png, settings.tolerance, shot.masks);
279
+ stopCompare?.();
147
280
  if (compare.equal) break;
148
281
  if (attempt <= ctx.retries) {
149
282
  detail(`${screen.name} looked different on attempt ${attempt} — taking it again.`);
@@ -184,6 +317,8 @@ async function runOneScreen(project, page, screen, ctx) {
184
317
  describe: screen.describe,
185
318
  deviceScaleFactor: settings.viewport.deviceScaleFactor,
186
319
  });
320
+ // It exists now, because that call is what wrote it.
321
+ if (ctx.thumbs) ctx.thumbs.approvedFile = fileUrl(approvedPaths.png);
187
322
  return finish(ctx, {
188
323
  ...base,
189
324
  status: 'new',
@@ -237,7 +372,27 @@ async function runOneScreen(project, page, screen, ctx) {
237
372
 
238
373
  /** @type {string|undefined} */
239
374
  let diffPath;
240
- if (compare.diffPng) diffPath = await writeDiff(paths, screen.name, compare.diffPng);
375
+ if (compare.diffPng) {
376
+ diffPath = await writeDiff(paths, screen.name, compare.diffPng);
377
+ // Set only inside this branch: a screen that matched has no difference picture, and
378
+ // a stale one from a previous run is deleted before every run for exactly that
379
+ // reason. Pointing at one that is not there is how a panel starts lying.
380
+ if (ctx.thumbs) ctx.thumbs.diffFile = fileUrl(diffPath);
381
+ }
382
+
383
+ // Only for a screen that actually moved, and only when somebody is watching:
384
+ // this is the one moment a person wants the approved picture and the difference
385
+ // side by side with the new one.
386
+ //
387
+ // Both are made at the same size as the new picture's preview, because a person
388
+ // comparing three pictures must not be shown one sharp one and two blurred ones.
389
+ // Each costs a decode plus a shrink — about an eighth of a second on a retina
390
+ // screenshot — so this stays behind both gates, and behind `changed`. A run where
391
+ // everything held pays none of it.
392
+ if (ctx.thumbnail === true && ctx.thumbs) {
393
+ ctx.thumbs.approved = (await thumbnailOf(approved.png)) ?? undefined;
394
+ if (compare.diffPng) ctx.thumbs.diff = (await thumbnailOf(compare.diffPng)) ?? undefined;
395
+ }
241
396
 
242
397
  const what = describeDifference(compare, screen.name);
243
398
  const next = compare.sizeMismatch
@@ -252,6 +407,32 @@ async function runOneScreen(project, page, screen, ctx) {
252
407
  });
253
408
  }
254
409
 
410
+ /**
411
+ * Put the time one picture took into the buckets it belongs in. The walk uses
412
+ * this too, so a walkthrough and a check file their time the same way.
413
+ *
414
+ * Only capture knows how its own milliseconds went, so it says, and this puts
415
+ * what it said where the profile can find it. If it ever stops saying, the one
416
+ * number still worth claiming is how long the screen was held still before the
417
+ * shutter; the rest goes unclaimed into `other` rather than being guessed at,
418
+ * because a made-up profile is worse than a missing one.
419
+ *
420
+ * @param {ReturnType<typeof import('../core/events.js').makeTimings>|undefined} timings
421
+ * @param {Awaited<ReturnType<typeof captureScreen>>} shot
422
+ * @returns {void}
423
+ */
424
+ export function accountForCapture(timings, shot) {
425
+ if (!timings) return;
426
+ const reported = shot.timings;
427
+ if (reported) {
428
+ timings.add('steps', reported.steps);
429
+ timings.add('prepare', reported.prepare);
430
+ timings.add('settle', reported.settle);
431
+ return;
432
+ }
433
+ if (shot.settle) timings.add('settle', shot.settle.waitedMs);
434
+ }
435
+
255
436
  /**
256
437
  * Font rendering differs between operating systems, so a picture approved on one
257
438
  * and checked on another is the single most common false alarm. Say it; never