staysfixed 0.1.0 → 0.2.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,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,19 @@ 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, so it is off unless it is wanted.
57
+ * @returns {Promise<import('../types.js').CaptureReport & {
58
+ * masks: import('../types.js').MaskRect[],
59
+ * timings: {steps: number, prepare: number, settle: number},
60
+ * spent: {steps: number, prepare: number, settle: number},
61
+ * thumbnail?: string,
62
+ * }>}
63
+ * The standard report, plus the mask rectangles that were painted so the comparison can
64
+ * paint the exact same rectangles onto the approved picture, plus where the time went.
65
+ * Only this function knows how its own milliseconds were spent, so it says, rather than
66
+ * leaving the run to guess by wrapping things it cannot see inside.
54
67
  */
55
68
  export async function captureScreen(page, screen, settings, ctx) {
56
69
  const deviceScaleFactor = settings.viewport.deviceScaleFactor ?? 2;
@@ -67,6 +80,14 @@ export async function captureScreen(page, screen, settings, ctx) {
67
80
  deviceScaleFactor,
68
81
  });
69
82
 
83
+ // A frozen clock cannot time anything, so the stopwatch is the host's own, and it is
84
+ // the monotonic one: a machine that adjusts its clock mid-run must not be able to
85
+ // report that a screen took a negative amount of time.
86
+ const clock = process.hrtime.bigint;
87
+ const startedSteps = clock();
88
+ /** @type {{steps: number, prepare: number, settle: number}} */
89
+ const spent = { steps: 0, prepare: 0, settle: 0 };
90
+
70
91
  try {
71
92
  if (typeof screen.do === 'function') {
72
93
  await screen.do(page);
@@ -79,6 +100,9 @@ export async function captureScreen(page, screen, settings, ctx) {
79
100
  const scrolledOnPurpose =
80
101
  typeof screen.do === 'function' || (screen.steps ?? []).some((s) => s.scrollTo !== undefined);
81
102
 
103
+ const startedPrepare = clock();
104
+ spent.steps = since(startedSteps, startedPrepare);
105
+
82
106
  await prepareForShutter(page, {
83
107
  fonts: settings.freeze.fonts !== false,
84
108
  timeoutMs,
@@ -90,13 +114,24 @@ export async function captureScreen(page, screen, settings, ctx) {
90
114
  if (screen.fullPage) shotOptions.fullPage = true;
91
115
  if (screen.clip) shotOptions.clip = screen.clip;
92
116
 
117
+ // The same frame, asked for cheaply. The settle loop shoots the screen over and over
118
+ // to find out whether anything moved and throws every one of those pictures away, so
119
+ // it gets a small lossy one; the picture that is kept and compared is always the PNG.
120
+ /** @type {import('../types.js').CaptureOptions & {format: 'jpeg', quality: number}} */
121
+ const probeOptions = { ...shotOptions, format: 'jpeg', quality: 50 };
122
+
123
+ const startedSettle = clock();
124
+ spent.prepare = since(startedPrepare, startedSettle);
125
+
93
126
  const held = await settle(page, {
94
127
  frames: settleConfig.frames ?? 2,
95
128
  intervalMs: settleConfig.intervalMs ?? 250,
96
129
  timeoutMs: settleConfig.timeoutMs ?? 10_000,
97
130
  maxDriftPixels: settleConfig.maxDriftPixels ?? 0,
98
131
  capture: () => page.shoot(shotOptions),
132
+ probe: () => page.shoot(probeOptions),
99
133
  });
134
+ spent.settle = since(startedSettle, clock());
100
135
 
101
136
  const rects = await resolveMasks(page, settings.masks ?? [], { deviceScaleFactor });
102
137
  const png = rects.length > 0 ? paintInto(held.png, rects) : held.png;
@@ -115,7 +150,8 @@ export async function captureScreen(page, screen, settings, ctx) {
115
150
  await runSteps(page, screen.after);
116
151
  }
117
152
 
118
- return {
153
+ /** @type {import('../types.js').CaptureReport & {masks: import('../types.js').MaskRect[], timings: typeof spent, spent: typeof spent, thumbnail?: string}} */
154
+ const report = {
119
155
  png,
120
156
  width: size.width,
121
157
  height: size.height,
@@ -123,7 +159,15 @@ export async function captureScreen(page, screen, settings, ctx) {
123
159
  consoleErrors: page.consoleErrors(),
124
160
  freeze: frozen.stats(),
125
161
  masks: rects,
162
+ // The same three numbers under both names the rest of the tool asks for them by.
163
+ timings: spent,
164
+ spent,
126
165
  };
166
+ if (ctx.thumbnail === true) {
167
+ const small = await thumbnailOf(png);
168
+ if (small) report.thumbnail = small;
169
+ }
170
+ return report;
127
171
  } finally {
128
172
  // Releasing must never be the thing that hides a real failure.
129
173
  try {
@@ -134,6 +178,16 @@ export async function captureScreen(page, screen, settings, ctx) {
134
178
  }
135
179
  }
136
180
 
181
+ /**
182
+ * Milliseconds between two readings of the monotonic clock.
183
+ * @param {bigint} from
184
+ * @param {bigint} to
185
+ * @returns {number}
186
+ */
187
+ function since(from, to) {
188
+ return Number(to - from) / 1e6;
189
+ }
190
+
137
191
  /**
138
192
  * Paint the masks into a screenshot and re-encode it.
139
193
  * @param {Buffer} buffer
@@ -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 } 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,19 @@ import { detail } from '../core/log.js';
28
29
  * @typedef {import('../types.js').PictureResult & {retriedToPass?: boolean}} PictureRunResult
29
30
  */
30
31
 
32
+ /**
33
+ * The small pictures a watcher is shown, filled in as a screen is worked on.
34
+ *
35
+ * They are kept apart from the result on purpose. A result is written to disk and
36
+ * read back by `approve` and `status`, and a base64 picture in there would bloat
37
+ * every saved run for the sake of a panel that was only open for a minute.
38
+ *
39
+ * @typedef {object} Thumbs
40
+ * @property {string} [shot] The picture just taken.
41
+ * @property {string} [approved] The approved picture, when this screen changed.
42
+ * @property {string} [diff] What moved, when this screen changed.
43
+ */
44
+
31
45
  /**
32
46
  * @param {import('../types.js').Project} project
33
47
  * @param {import('../types.js').LaunchedApp} app
@@ -39,6 +53,9 @@ import { detail } from '../core/log.js';
39
53
  * retries?: number,
40
54
  * tool?: string,
41
55
  * signal?: AbortSignal,
56
+ * events?: import('../types.js').RunEvents,
57
+ * timings?: ReturnType<typeof import('../core/events.js').makeTimings>,
58
+ * thumbnail?: boolean,
42
59
  * }} [opts]
43
60
  * @returns {Promise<PictureRunResult[]>}
44
61
  */
@@ -53,48 +70,118 @@ export async function runPictures(project, app, opts = {}) {
53
70
  // A desktop app has no url to go back to between screens, so it gets a reload instead.
54
71
  // A web app does not need one: every screen starts with a `goto`.
55
72
  const reset = config.app.kind === 'electron' ? () => resetWindow(app) : undefined;
73
+ const events = opts.events;
74
+
75
+ // Which screens are being photographed is settled before the first shutter, so
76
+ // anyone watching can be told how many there are and where each one sits in the
77
+ // queue rather than watching a list of unknown length crawl past.
78
+ const chosen = config.screens.filter((screen) => !only || only.has(screen.name));
79
+ const total = chosen.length;
56
80
 
57
81
  /** @type {PictureRunResult[]} */
58
82
  const results = [];
59
83
 
60
- for (const screen of config.screens) {
84
+ for (let i = 0; i < chosen.length; i++) {
61
85
  if (opts.signal?.aborted) break;
62
- if (only && !only.has(screen.name)) continue;
86
+ const screen = chosen[i];
87
+
88
+ emitEvent(events, {
89
+ type: 'screen:start',
90
+ name: screen.name,
91
+ describe: screen.describe,
92
+ index: i + 1,
93
+ total,
94
+ });
63
95
 
64
96
  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
- );
97
+ const skipped = finish(opts, {
98
+ name: screen.name,
99
+ describe: screen.describe,
100
+ status: 'skipped',
101
+ message: `${screen.name} is switched off in the config.`,
102
+ durationMs: 0,
103
+ });
104
+ results.push(skipped);
105
+ emitDone(events, skipped, {});
74
106
  continue;
75
107
  }
76
108
 
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
- );
109
+ // Filled in as the screen is worked on, and read once it is finished. The
110
+ // thumbnails exist only while somebody is watching, so they travel beside the
111
+ // result instead of inside it.
112
+ /** @type {Thumbs} */
113
+ const thumbs = {};
114
+
115
+ const result = await runOneScreen(project, page, screen, {
116
+ record: opts.record,
117
+ updateNew: opts.updateNew,
118
+ tool: opts.tool,
119
+ onResult: opts.onResult,
120
+ retries,
121
+ here,
122
+ reset,
123
+ events,
124
+ timings: opts.timings,
125
+ thumbnail: opts.thumbnail === true,
126
+ thumbs,
127
+ });
128
+ results.push(result);
129
+ emitDone(events, result, thumbs);
88
130
  }
89
131
 
90
132
  return results;
91
133
  }
92
134
 
135
+ /**
136
+ * Tell anyone watching how a screen turned out, with the pictures if there are any.
137
+ *
138
+ * @param {import('../types.js').RunEvents|undefined} events
139
+ * @param {PictureRunResult} result
140
+ * @param {Thumbs} thumbs
141
+ * @returns {void}
142
+ */
143
+ function emitDone(events, result, thumbs) {
144
+ emitEvent(events, {
145
+ type: 'screen:done',
146
+ name: result.name,
147
+ describe: result.describe,
148
+ status: result.status,
149
+ durationMs: result.durationMs,
150
+ diffPixels: result.diffPixels,
151
+ diffRatio: result.diffRatio,
152
+ message: result.message,
153
+ thumbnail: thumbs.shot,
154
+ approvedThumb: thumbs.approved,
155
+ diffThumb: thumbs.diff,
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Everything one screen needs, including where to leave what it learns.
161
+ *
162
+ * `thumbs` is written into rather than returned because a screen can finish down
163
+ * half a dozen different paths, and threading a second return value through all
164
+ * of them would bury the thing this function is actually for.
165
+ *
166
+ * @typedef {object} ScreenCtx
167
+ * @property {boolean} [record]
168
+ * @property {boolean} [updateNew]
169
+ * @property {string} [tool]
170
+ * @property {(r: PictureRunResult) => void} [onResult]
171
+ * @property {number} retries
172
+ * @property {string} here
173
+ * @property {() => Promise<void>} [reset]
174
+ * @property {import('../types.js').RunEvents} [events]
175
+ * @property {ReturnType<typeof import('../core/events.js').makeTimings>} [timings]
176
+ * @property {boolean} [thumbnail] Make the small pictures a watcher needs.
177
+ * @property {Thumbs} [thumbs] Where those small pictures are left.
178
+ */
179
+
93
180
  /**
94
181
  * @param {import('../types.js').Project} project
95
182
  * @param {import('../types.js').PageHandle} page
96
183
  * @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
184
+ * @param {ScreenCtx} ctx
98
185
  * @returns {Promise<PictureRunResult>}
99
186
  */
100
187
  async function runOneScreen(project, page, screen, ctx) {
@@ -132,10 +219,20 @@ async function runOneScreen(project, page, screen, ctx) {
132
219
  fixturesDir: paths.fixtures,
133
220
  record: ctx.record ?? false,
134
221
  timeoutMs: settings.freeze.settle?.timeoutMs,
222
+ thumbnail: ctx.thumbnail === true,
135
223
  });
224
+ accountForCapture(ctx.timings, shot);
136
225
  consoleErrors = shot.consoleErrors;
137
226
  size = { width: shot.width, height: shot.height };
138
227
 
228
+ // Said out loud the moment the shutter fires, before anything is compared,
229
+ // so a person watching sees the picture appear while the run is still
230
+ // deciding what it thinks of it.
231
+ if (shot.thumbnail) {
232
+ if (ctx.thumbs) ctx.thumbs.shot = shot.thumbnail;
233
+ emitEvent(ctx.events, { type: 'screen:shot', name: screen.name, thumbnail: shot.thumbnail });
234
+ }
235
+
139
236
  await writeResult(paths, screen.name, shot.png, {
140
237
  deviceScaleFactor: settings.viewport.deviceScaleFactor,
141
238
  describe: screen.describe,
@@ -143,7 +240,13 @@ async function runOneScreen(project, page, screen, ctx) {
143
240
 
144
241
  if (!approved) break;
145
242
 
146
- compare = comparePng(approved.png, shot.png, settings.tolerance, shot.masks);
243
+ const stopCompare = ctx.timings?.mark('compare');
244
+ // compareFast, not comparePng: identical bytes are answered from the PNG header
245
+ // instead of decoding two retina images pixel by pixel. Nothing changed on most
246
+ // screens on most runs, so this is the case that actually happens — it took the
247
+ // comparing stage from about a quarter of a second a screen to nothing at all.
248
+ compare = compareFast(approved.png, shot.png, settings.tolerance, shot.masks);
249
+ stopCompare?.();
147
250
  if (compare.equal) break;
148
251
  if (attempt <= ctx.retries) {
149
252
  detail(`${screen.name} looked different on attempt ${attempt} — taking it again.`);
@@ -239,6 +342,14 @@ async function runOneScreen(project, page, screen, ctx) {
239
342
  let diffPath;
240
343
  if (compare.diffPng) diffPath = await writeDiff(paths, screen.name, compare.diffPng);
241
344
 
345
+ // Only for a screen that actually moved, and only when somebody is watching:
346
+ // this is the one moment a person wants the approved picture and the difference
347
+ // side by side with the new one.
348
+ if (ctx.thumbnail === true && ctx.thumbs) {
349
+ ctx.thumbs.approved = (await thumbnailOf(approved.png)) ?? undefined;
350
+ if (compare.diffPng) ctx.thumbs.diff = (await thumbnailOf(compare.diffPng)) ?? undefined;
351
+ }
352
+
242
353
  const what = describeDifference(compare, screen.name);
243
354
  const next = compare.sizeMismatch
244
355
  ? 'There is no difference picture for a size change — open the new picture and look at it.'
@@ -252,6 +363,32 @@ async function runOneScreen(project, page, screen, ctx) {
252
363
  });
253
364
  }
254
365
 
366
+ /**
367
+ * Put the time one picture took into the buckets it belongs in. The walk uses
368
+ * this too, so a walkthrough and a check file their time the same way.
369
+ *
370
+ * Only capture knows how its own milliseconds went, so it says, and this puts
371
+ * what it said where the profile can find it. If it ever stops saying, the one
372
+ * number still worth claiming is how long the screen was held still before the
373
+ * shutter; the rest goes unclaimed into `other` rather than being guessed at,
374
+ * because a made-up profile is worse than a missing one.
375
+ *
376
+ * @param {ReturnType<typeof import('../core/events.js').makeTimings>|undefined} timings
377
+ * @param {Awaited<ReturnType<typeof captureScreen>>} shot
378
+ * @returns {void}
379
+ */
380
+ export function accountForCapture(timings, shot) {
381
+ if (!timings) return;
382
+ const reported = shot.timings;
383
+ if (reported) {
384
+ timings.add('steps', reported.steps);
385
+ timings.add('prepare', reported.prepare);
386
+ timings.add('settle', reported.settle);
387
+ return;
388
+ }
389
+ if (shot.settle) timings.add('settle', shot.settle.waitedMs);
390
+ }
391
+
255
392
  /**
256
393
  * Font rendering differs between operating systems, so a picture approved on one
257
394
  * and checked on another is the single most common false alarm. Say it; never
@@ -9,12 +9,20 @@
9
9
 
10
10
  import fsp from 'node:fs/promises';
11
11
  import path from 'node:path';
12
+ import { PNG } from 'pngjs';
12
13
  import { approvedPicture, resultPicture, safeName } from '../core/paths.js';
13
14
  import { sha256 } from '../core/hash.js';
14
15
  import { StaysFixedError } from '../core/errors.js';
15
16
  import { platformTag } from '../drive/find.js';
16
17
  import { pngSize } from './capture.js';
17
18
 
19
+ /**
20
+ * How wide a thumbnail is, in real pixels. Wide enough to stay sharp in a panel a few
21
+ * hundred pixels across on a retina screen, small enough that a run can put dozens of
22
+ * them down a wire without anybody noticing.
23
+ */
24
+ const THUMBNAIL_WIDTH = 320;
25
+
18
26
  /**
19
27
  * A small note written beside a result picture so `approve` knows things the PNG
20
28
  * cannot tell it — the screen density it was taken at, and its description.
@@ -23,6 +31,63 @@ import { pngSize } from './capture.js';
23
31
  * @property {string} [describe]
24
32
  */
25
33
 
34
+ /**
35
+ * A small copy of a picture, ready to drop straight into a page.
36
+ *
37
+ * This is for watching a run happen, so it is built from the finished picture rather than
38
+ * asked of the app again: what a watcher sees is exactly what was compared, blackout
39
+ * boxes and all, and never a second photograph taken a moment later that shows something
40
+ * slightly different.
41
+ *
42
+ * Points are sampled rather than averaged. Averaging every pixel of a retina screenshot
43
+ * costs a fifth of a second per screen and buys smoother edges on a picture two inches
44
+ * wide; the point of the panel is to watch a run at full speed.
45
+ *
46
+ * @param {Buffer} png
47
+ * @returns {Promise<string|null>} a data: address for an <img>, or null if it cannot be read
48
+ */
49
+ export async function thumbnailOf(png) {
50
+ try {
51
+ const full = PNG.sync.read(png);
52
+ if (!(full.width > 0) || !(full.height > 0)) return null;
53
+ const step = full.width > THUMBNAIL_WIDTH ? full.width / THUMBNAIL_WIDTH : 1;
54
+ const width = Math.max(1, Math.round(full.width / step));
55
+ const height = Math.max(1, Math.round(full.height / step));
56
+
57
+ const small = new PNG({ width, height });
58
+ for (let y = 0; y < height; y += 1) {
59
+ const sourceRow = Math.min(full.height - 1, Math.floor(y * step)) * full.width;
60
+ for (let x = 0; x < width; x += 1) {
61
+ const from = (sourceRow + Math.min(full.width - 1, Math.floor(x * step))) * 4;
62
+ const to = (y * width + x) * 4;
63
+ small.data[to] = full.data[from];
64
+ small.data[to + 1] = full.data[from + 1];
65
+ small.data[to + 2] = full.data[from + 2];
66
+ small.data[to + 3] = full.data[from + 3];
67
+ }
68
+ }
69
+ return `data:image/png;base64,${PNG.sync.write(small).toString('base64')}`;
70
+ } catch {
71
+ // A picture nobody can decode is not worth failing a run over — the run itself has
72
+ // already said what it thinks of the screen, and a watcher simply sees no picture.
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The fingerprint of a picture.
79
+ *
80
+ * The same sha256 that is written into an approved picture's note, so anyone holding that
81
+ * note can tell whether a fresh photograph is the same file without reading the old one
82
+ * off disk or decoding either of them.
83
+ *
84
+ * @param {Buffer|Uint8Array|string} buffer
85
+ * @returns {string} hex sha256
86
+ */
87
+ export function fingerprint(buffer) {
88
+ return sha256(buffer);
89
+ }
90
+
26
91
  /**
27
92
  * @param {import('../types.js').ProjectPaths} paths
28
93
  * @param {string} name
@@ -268,9 +268,12 @@ export function printGuardResult(r) {
268
268
  * The closing block of `staysfixed check`.
269
269
  * @param {import('../types.js').RunSummary} run
270
270
  * @param {import('../types.js').Project} [project]
271
+ * @param {{profile?: boolean, timings?: import('../types.js').Timings|null}} [opts]
272
+ * Ask for the timing block with --profile; pass `timings` when the caller
273
+ * kept its own record rather than reading it back off the summary.
271
274
  * @returns {void}
272
275
  */
273
- export function printRunSummary(run, project) {
276
+ export function printRunSummary(run, project, opts = {}) {
274
277
  const pictures = run.pictures ?? [];
275
278
  const guards = run.guards ?? [];
276
279
  const verdict = verdictFor(run);
@@ -284,6 +287,13 @@ export function printRunSummary(run, project) {
284
287
  if (guards.length) counted.push(`${countText(guards.length)} ${plural(guards.length, 'guard', 'guards')}`);
285
288
  if (counted.length) say(paint.grey(` ${counted.join(', ')}, ${plainTime(run.durationMs ?? 0)}.`));
286
289
 
290
+ // Nobody asked for numbers unless they asked for numbers. The run reads the
291
+ // same with or without this block.
292
+ if (opts.profile) {
293
+ const measured = opts.timings ?? /** @type {{timings?: import('../types.js').Timings}} */ (run).timings;
294
+ printTimings(measured, pictures.length);
295
+ }
296
+
287
297
  /** @type {string[][]} */
288
298
  const rows = [];
289
299
  for (const p of pictures) {
@@ -324,6 +334,75 @@ export function printRunSummary(run, project) {
324
334
  blank();
325
335
  }
326
336
 
337
+ /**
338
+ * Where the seconds went, in the order of biggest first, because the only reason
339
+ * anyone reads this is to find the one part worth speeding up.
340
+ *
341
+ * The names are what each phase actually does, not what the code calls it: nobody
342
+ * outside this repository knows what "settle" or "prepare" mean.
343
+ */
344
+ const TIMING_LABELS = /** @type {[string, string][]} */ ([
345
+ ['launch', 'opening the app'],
346
+ ['steps', 'running the steps'],
347
+ ['prepare', 'waiting for fonts and images'],
348
+ ['settle', 'taking the pictures until two agree'],
349
+ ['compare', 'comparing against the approved pictures'],
350
+ ['guards', 'running the guards'],
351
+ ['other', 'everything else'],
352
+ ]);
353
+
354
+ /**
355
+ * The `--profile` block. Rounded to something a person would say out loud: this
356
+ * is here to point at the slow part, not to be a benchmark.
357
+ *
358
+ * @param {import('../types.js').Timings|null|undefined} timings
359
+ * @param {number} [screenCount] Screens photographed, for the per-screen average.
360
+ * @returns {void}
361
+ */
362
+ export function printTimings(timings, screenCount = 0) {
363
+ if (!timings) {
364
+ heading('Where the time went');
365
+ say(paint.grey(' This run did not record its timings.'));
366
+ return;
367
+ }
368
+
369
+ // Timings is a fixed shape, but reading it by name keeps the table and the
370
+ // type from drifting apart.
371
+ const t = /** @type {Record<string, number>} */ (/** @type {unknown} */ (timings));
372
+ const total = Number(t.total) || 0;
373
+
374
+ /** @type {string[][]} */
375
+ const rows = [];
376
+ const parts = TIMING_LABELS.map(([key, label]) => ({ label, ms: Number(t[key]) || 0 }))
377
+ .filter((part) => part.ms > 0)
378
+ .sort((a, b) => b.ms - a.ms);
379
+
380
+ for (const part of parts) {
381
+ const share = total > 0 ? `${Math.round((part.ms / total) * 100)}%` : '';
382
+ rows.push([part.label, duration(part.ms), paint.grey(share)]);
383
+ }
384
+
385
+ heading('Where the time went');
386
+ if (rows.length === 0) {
387
+ say(paint.grey(' Nothing took long enough to measure.'));
388
+ return;
389
+ }
390
+ table(rows, { indent: 2 });
391
+
392
+ // The two closing lines are written by hand rather than added as rows, so the
393
+ // totals sit under the parts instead of being sorted in among them.
394
+ const labelWidth = Math.max(...rows.map((row) => row[0].length));
395
+ say(paint.grey(` ${'in total'.padEnd(labelWidth)} ${duration(total)}`));
396
+ if (screenCount > 0) {
397
+ const each = duration(total / screenCount);
398
+ say(
399
+ paint.grey(
400
+ ` ${'each screen'.padEnd(labelWidth)} ${each} on average, across ${countText(screenCount)} ${plural(screenCount, 'screen', 'screens')}`,
401
+ ),
402
+ );
403
+ }
404
+ }
405
+
327
406
  /**
328
407
  * The closing block of `staysfixed walk`.
329
408
  * @param {import('../types.js').WalkReport} report