staysfixed 0.1.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +529 -0
  4. package/bin/staysfixed.js +18 -0
  5. package/examples/guards/the-sidebar-still-collapses.js +91 -0
  6. package/examples/staysfixed.config.electron.js +172 -0
  7. package/examples/staysfixed.config.web.js +277 -0
  8. package/package.json +61 -0
  9. package/src/cli/approve.js +126 -0
  10. package/src/cli/check.js +73 -0
  11. package/src/cli/doctor.js +379 -0
  12. package/src/cli/flake.js +61 -0
  13. package/src/cli/index.js +519 -0
  14. package/src/cli/init.js +564 -0
  15. package/src/cli/mark.js +69 -0
  16. package/src/cli/status.js +19 -0
  17. package/src/cli/trace.js +73 -0
  18. package/src/cli/walk.js +57 -0
  19. package/src/core/config.js +226 -0
  20. package/src/core/errors.js +48 -0
  21. package/src/core/git.js +90 -0
  22. package/src/core/hash.js +32 -0
  23. package/src/core/history.js +173 -0
  24. package/src/core/log.js +144 -0
  25. package/src/core/paths.js +135 -0
  26. package/src/drive/browser.js +540 -0
  27. package/src/drive/cdp.js +382 -0
  28. package/src/drive/electron.js +326 -0
  29. package/src/drive/find.js +331 -0
  30. package/src/drive/launch.js +263 -0
  31. package/src/drive/page.js +1042 -0
  32. package/src/freeze/clock.js +213 -0
  33. package/src/freeze/fonts.js +243 -0
  34. package/src/freeze/index.js +234 -0
  35. package/src/freeze/mask.js +187 -0
  36. package/src/freeze/motion.js +206 -0
  37. package/src/freeze/network.js +455 -0
  38. package/src/freeze/random.js +87 -0
  39. package/src/freeze/settle.js +178 -0
  40. package/src/guard/api.js +197 -0
  41. package/src/guard/load.js +324 -0
  42. package/src/guard/name.js +327 -0
  43. package/src/guard/run.js +224 -0
  44. package/src/index.js +61 -0
  45. package/src/marker/mark.js +260 -0
  46. package/src/marker/trace.js +293 -0
  47. package/src/mcp/server.js +377 -0
  48. package/src/mcp/tools.js +978 -0
  49. package/src/picture/capture.js +276 -0
  50. package/src/picture/compare.js +103 -0
  51. package/src/picture/run.js +284 -0
  52. package/src/picture/store.js +208 -0
  53. package/src/report/console.js +540 -0
  54. package/src/report/html.js +579 -0
  55. package/src/run.js +614 -0
  56. package/src/types.js +471 -0
  57. package/src/walk/run.js +541 -0
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Taking one picture of one screen.
3
+ *
4
+ * The order of operations here is the whole reason picture checks can be
5
+ * trusted: freeze the world FIRST (clock, fonts, randomness, network), then
6
+ * drive the app, then hold still until two frames in a row are identical, and
7
+ * only then press the shutter. Do any of those out of order and the tool starts
8
+ * crying wolf, which is worse than having no tool at all.
9
+ */
10
+
11
+ import { PNG } from 'pngjs';
12
+ import { applyFreeze, prepareForShutter } from '../freeze/index.js';
13
+ import { settle } from '../freeze/settle.js';
14
+ import { resolveMasks, paintMasks } from '../freeze/mask.js';
15
+ import { StaysFixedError, isExpected, messageOf } from '../core/errors.js';
16
+
17
+ /**
18
+ * Every instruction a declarative step is allowed to give, in the order they run
19
+ * when a single step carries more than one. `goto` first, `wait` last — that way
20
+ * `{ waitFor: '#list', click: '#list li', wait: 100 }` reads and behaves the same way.
21
+ * @type {readonly (keyof import('../types.js').Step)[]}
22
+ */
23
+ const ACTION_ORDER = [
24
+ 'goto',
25
+ 'waitFor',
26
+ 'scrollTo',
27
+ 'hover',
28
+ 'click',
29
+ 'type',
30
+ 'press',
31
+ 'evaluate',
32
+ 'waitForGone',
33
+ 'wait',
34
+ ];
35
+
36
+ /** Keys allowed on a step that are not actions: `text` feeds `type`, `note` is for humans. */
37
+ const KNOWN_KEYS = new Set([...ACTION_ORDER, 'text', 'note']);
38
+
39
+ /**
40
+ * Photograph one screen.
41
+ *
42
+ * @param {import('../types.js').PageHandle} page
43
+ * @param {import('../types.js').ScreenConfig} screen
44
+ * @param {{
45
+ * viewport: import('../types.js').ViewportConfig,
46
+ * tolerance: import('../types.js').ToleranceConfig,
47
+ * freeze: import('../types.js').FreezeConfig,
48
+ * masks: import('../types.js').Mask[],
49
+ * }} 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
+ */
55
+ export async function captureScreen(page, screen, settings, ctx) {
56
+ const deviceScaleFactor = settings.viewport.deviceScaleFactor ?? 2;
57
+ const settleConfig = settings.freeze.settle ?? {};
58
+ const timeoutMs = ctx.timeoutMs ?? settleConfig.timeoutMs ?? 10_000;
59
+
60
+ await page.setViewport(settings.viewport);
61
+ page.clearConsole();
62
+
63
+ const frozen = await applyFreeze(page, settings.freeze, {
64
+ fixturesDir: ctx.fixturesDir,
65
+ screenName: screen.name,
66
+ record: ctx.record ?? false,
67
+ deviceScaleFactor,
68
+ });
69
+
70
+ try {
71
+ if (typeof screen.do === 'function') {
72
+ await screen.do(page);
73
+ } else {
74
+ await runSteps(page, screen.steps ?? []);
75
+ }
76
+
77
+ // Scrolling back to the top is the deterministic default, but a screen that
78
+ // deliberately scrolled somewhere must be photographed where it landed.
79
+ const scrolledOnPurpose =
80
+ typeof screen.do === 'function' || (screen.steps ?? []).some((s) => s.scrollTo !== undefined);
81
+
82
+ await prepareForShutter(page, {
83
+ fonts: settings.freeze.fonts !== false,
84
+ timeoutMs,
85
+ keepScroll: scrolledOnPurpose,
86
+ });
87
+
88
+ /** @type {import('../types.js').CaptureOptions} */
89
+ const shotOptions = {};
90
+ if (screen.fullPage) shotOptions.fullPage = true;
91
+ if (screen.clip) shotOptions.clip = screen.clip;
92
+
93
+ const held = await settle(page, {
94
+ frames: settleConfig.frames ?? 2,
95
+ intervalMs: settleConfig.intervalMs ?? 250,
96
+ timeoutMs: settleConfig.timeoutMs ?? 10_000,
97
+ maxDriftPixels: settleConfig.maxDriftPixels ?? 0,
98
+ capture: () => page.shoot(shotOptions),
99
+ });
100
+
101
+ const rects = await resolveMasks(page, settings.masks ?? [], { deviceScaleFactor });
102
+ const png = rects.length > 0 ? paintInto(held.png, rects) : held.png;
103
+ const size = pngSize(png);
104
+
105
+ // The picture is taken; now put the app back if this screen asked us to.
106
+ //
107
+ // A reload between screens restores what the PAGE was holding, but not what the app
108
+ // has written to disk. A screen that collapses a sidebar, switches a theme or turns
109
+ // a setting off leaves that behind for everything after it, and the failure then
110
+ // lands on some innocent check further down the list. `after` is where a screen
111
+ // cleans up after itself. It runs even though the shutter has already fired, and a
112
+ // failure here is reported rather than swallowed — a cleanup that quietly stopped
113
+ // working would poison every run after it.
114
+ if (screen.after && screen.after.length > 0) {
115
+ await runSteps(page, screen.after);
116
+ }
117
+
118
+ return {
119
+ png,
120
+ width: size.width,
121
+ height: size.height,
122
+ settle: held.report,
123
+ consoleErrors: page.consoleErrors(),
124
+ freeze: frozen.stats(),
125
+ masks: rects,
126
+ };
127
+ } finally {
128
+ // Releasing must never be the thing that hides a real failure.
129
+ try {
130
+ await frozen.release();
131
+ } catch {
132
+ // ignored on purpose
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Paint the masks into a screenshot and re-encode it.
139
+ * @param {Buffer} buffer
140
+ * @param {import('../types.js').MaskRect[]} rects
141
+ * @returns {Buffer}
142
+ */
143
+ function paintInto(buffer, rects) {
144
+ const image = PNG.sync.read(buffer);
145
+ paintMasks(image, rects);
146
+ return PNG.sync.write(image);
147
+ }
148
+
149
+ /**
150
+ * Run the declarative steps from a JSON config, in order.
151
+ *
152
+ * @param {import('../types.js').PageApi} page
153
+ * @param {import('../types.js').Step[]} steps
154
+ * @returns {Promise<void>}
155
+ */
156
+ export async function runSteps(page, steps) {
157
+ for (let i = 0; i < steps.length; i++) {
158
+ const step = steps[i];
159
+ const n = i + 1;
160
+ if (!step || typeof step !== 'object') {
161
+ throw new StaysFixedError(`Step ${n} is not an instruction.`, {
162
+ hint: 'Each step is an object, for example { click: "#save" }.',
163
+ });
164
+ }
165
+ for (const key of Object.keys(step)) {
166
+ if (!KNOWN_KEYS.has(/** @type {keyof import('../types.js').Step} */ (key))) {
167
+ throw new StaysFixedError(`Step ${n} says \`${key}\` — I do not know that instruction.`, {
168
+ hint: `The instructions I know are: ${ACTION_ORDER.join(', ')}, text, note.`,
169
+ });
170
+ }
171
+ }
172
+ try {
173
+ await runStep(page, step, n);
174
+ } catch (cause) {
175
+ if (isExpected(cause)) throw cause;
176
+ throw new StaysFixedError(`${describeStep(step, n)} did not work.`, {
177
+ hint: messageOf(cause),
178
+ cause,
179
+ });
180
+ }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * @param {import('../types.js').PageApi} page
186
+ * @param {import('../types.js').Step} step
187
+ * @param {number} n
188
+ * @returns {Promise<void>}
189
+ */
190
+ async function runStep(page, step, n) {
191
+ let did = false;
192
+ if (step.goto !== undefined) {
193
+ await page.goto(step.goto);
194
+ did = true;
195
+ }
196
+ if (step.waitFor !== undefined) {
197
+ await page.waitFor(step.waitFor);
198
+ did = true;
199
+ }
200
+ if (step.scrollTo !== undefined) {
201
+ await page.scrollTo(step.scrollTo);
202
+ did = true;
203
+ }
204
+ if (step.hover !== undefined) {
205
+ await page.hover(step.hover);
206
+ did = true;
207
+ }
208
+ if (step.click !== undefined) {
209
+ await page.click(step.click);
210
+ did = true;
211
+ }
212
+ if (step.type !== undefined) {
213
+ if (typeof step.text !== 'string') {
214
+ throw new StaysFixedError(`Step ${n} says to type into \`${step.type}\` but never says what to type.`, {
215
+ hint: 'Add `text` to that step, for example { type: "#search", text: "hello" }.',
216
+ });
217
+ }
218
+ await page.type(step.type, step.text);
219
+ did = true;
220
+ }
221
+ if (step.press !== undefined) {
222
+ await page.press(step.press);
223
+ did = true;
224
+ }
225
+ if (step.evaluate !== undefined) {
226
+ await page.evaluate(step.evaluate);
227
+ did = true;
228
+ }
229
+ if (step.waitForGone !== undefined) {
230
+ await page.waitForGone(step.waitForGone);
231
+ did = true;
232
+ }
233
+ if (step.wait !== undefined) {
234
+ await page.wait(step.wait);
235
+ did = true;
236
+ }
237
+ if (!did && step.note === undefined) {
238
+ throw new StaysFixedError(`Step ${n} does not say what to do.`, {
239
+ hint: `Give it one of: ${ACTION_ORDER.join(', ')}.`,
240
+ });
241
+ }
242
+ }
243
+
244
+ /**
245
+ * A step named the way a human would say it out loud.
246
+ * @param {import('../types.js').Step} step
247
+ * @param {number} n
248
+ * @returns {string}
249
+ */
250
+ function describeStep(step, n) {
251
+ for (const key of ACTION_ORDER) {
252
+ const value = step[key];
253
+ if (value !== undefined) return `Step ${n} (${key} ${JSON.stringify(value)})`;
254
+ }
255
+ return `Step ${n}`;
256
+ }
257
+
258
+ /**
259
+ * Width and height straight out of the PNG header.
260
+ *
261
+ * Settle compares a frame every quarter second; decoding a whole retina
262
+ * screenshot just to learn its size would dominate the run. The IHDR chunk always
263
+ * starts at byte 8, so width and height sit at bytes 16 and 20, big-endian.
264
+ *
265
+ * @param {Buffer} buffer
266
+ * @returns {{width: number, height: number}}
267
+ */
268
+ export function pngSize(buffer) {
269
+ if (!buffer || buffer.length < 24) {
270
+ throw new StaysFixedError('That screenshot came back empty — there is no picture to measure.');
271
+ }
272
+ if (buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) {
273
+ throw new StaysFixedError('That screenshot is not a PNG picture.');
274
+ }
275
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
276
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Comparing a fresh picture with the approved one.
3
+ *
4
+ * Two things here decide whether the tool is believed or ignored:
5
+ * the masks are painted onto BOTH pictures (so hiding a clock hides it on the
6
+ * old picture too, and adding a mask never forces a re-approval), and the
7
+ * verdict is a pixel count against an explicit allowance rather than a vague
8
+ * "looks close enough".
9
+ */
10
+
11
+ import { PNG } from 'pngjs';
12
+ import pixelmatch from 'pixelmatch';
13
+ import { paintMasks } from '../freeze/mask.js';
14
+ import { DEFAULT_TOLERANCE } from '../core/config.js';
15
+ import { StaysFixedError } from '../core/errors.js';
16
+
17
+ /**
18
+ * @param {Buffer} approvedBuf
19
+ * @param {Buffer} actualBuf
20
+ * @param {import('../types.js').ToleranceConfig} tolerance
21
+ * @param {import('../types.js').MaskRect[]} [maskRects]
22
+ * @returns {import('../types.js').CompareReport}
23
+ */
24
+ export function comparePng(approvedBuf, actualBuf, tolerance, maskRects = []) {
25
+ const approved = decode(approvedBuf, 'the approved picture');
26
+ const actual = decode(actualBuf, 'the new picture');
27
+
28
+ const approvedSize = { width: approved.width, height: approved.height };
29
+ const size = { width: actual.width, height: actual.height };
30
+
31
+ if (approvedSize.width !== size.width || approvedSize.height !== size.height) {
32
+ // Different sizes cannot be compared pixel by pixel at all. The caller
33
+ // explains it in words instead of showing a meaningless diff.
34
+ return { equal: false, diffPixels: 0, diffRatio: 0, diffPng: null, sizeMismatch: true, size, approvedSize };
35
+ }
36
+
37
+ if (maskRects.length > 0) {
38
+ paintMasks(approved, maskRects);
39
+ paintMasks(actual, maskRects);
40
+ }
41
+
42
+ const { width, height } = size;
43
+ const diff = new PNG({ width, height });
44
+ const diffPixels = pixelmatch(approved.data, actual.data, diff.data, width, height, {
45
+ threshold: tolerance.threshold ?? DEFAULT_TOLERANCE.threshold,
46
+ // Read this one carefully: includeAA:true INCLUDES anti-aliased pixels in the
47
+ // count, which is stricter. So "ignore anti-aliasing noise" means includeAA:false.
48
+ includeAA: !(tolerance.antialiasing ?? DEFAULT_TOLERANCE.antialiasing),
49
+ alpha: 0.3,
50
+ diffColor: [255, 0, 0],
51
+ diffColorAlt: [255, 140, 0],
52
+ });
53
+
54
+ const total = width * height;
55
+ const allowed = tolerance.maxPixels ?? Math.floor(total * (tolerance.pixels ?? DEFAULT_TOLERANCE.pixels));
56
+ const equal = diffPixels <= allowed;
57
+
58
+ return {
59
+ equal,
60
+ diffPixels,
61
+ diffRatio: total > 0 ? diffPixels / total : 0,
62
+ diffPng: equal ? null : PNG.sync.write(diff),
63
+ sizeMismatch: false,
64
+ size,
65
+ approvedSize,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * @param {Buffer} buffer
71
+ * @param {string} what
72
+ * @returns {PNG}
73
+ */
74
+ function decode(buffer, what) {
75
+ try {
76
+ return PNG.sync.read(buffer);
77
+ } catch (cause) {
78
+ throw new StaysFixedError(`I could not open ${what} — the file is damaged or is not a PNG.`, {
79
+ hint: 'Delete it and take the picture again.',
80
+ cause,
81
+ });
82
+ }
83
+ }
84
+
85
+ /**
86
+ * One sentence a non-technical person can act on.
87
+ * @param {import('../types.js').CompareReport} report
88
+ * @param {string} name
89
+ * @returns {string}
90
+ */
91
+ export function describeDifference(report, name) {
92
+ if (report.sizeMismatch) {
93
+ const was = `${report.approvedSize.width}x${report.approvedSize.height}`;
94
+ const now = `${report.size.width}x${report.size.height}`;
95
+ return `${name} changed size — it was ${was}, now it is ${now}.`;
96
+ }
97
+ if (report.equal) {
98
+ return `${name} looks exactly like the approved picture.`;
99
+ }
100
+ const pixels = report.diffPixels.toLocaleString('en-US');
101
+ const percent = (report.diffRatio * 100).toFixed(2);
102
+ return `${name} looks different — ${pixels} pixels changed (${percent}% of the picture).`;
103
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * The picture net: photograph every screen, compare each against its approved
3
+ * picture, and report in plain language.
4
+ *
5
+ * Two decisions in here are deliberate and load-bearing.
6
+ * First, a screen that looks different is photographed again before the tool
7
+ * believes it — one bad frame must never be reported as a regression, and a
8
+ * screen that only matched on the second try is flagged so the flake register
9
+ * catches it and a human eventually deletes or fixes it.
10
+ * Second, nothing is ever approved automatically. A screen with no approved
11
+ * picture is reported as new and waits for a person to look at it.
12
+ */
13
+
14
+ import { captureScreen } from './capture.js';
15
+ import { comparePng, describeDifference } from './compare.js';
16
+ import { readApproved, writeResult, writeDiff, approveFromResult } from './store.js';
17
+ import { settingsForScreen } from '../core/config.js';
18
+ import { approvedPicture, resultPicture } from '../core/paths.js';
19
+ import { platformTag } from '../drive/find.js';
20
+ import { resetWindow } from '../drive/launch.js';
21
+ import { gitInfo } from '../core/git.js';
22
+ import { messageOf } from '../core/errors.js';
23
+ import { detail } from '../core/log.js';
24
+
25
+ /**
26
+ * A picture result plus the one extra fact the flake register needs: whether it
27
+ * only agreed with the approved picture after being photographed again.
28
+ * @typedef {import('../types.js').PictureResult & {retriedToPass?: boolean}} PictureRunResult
29
+ */
30
+
31
+ /**
32
+ * @param {import('../types.js').Project} project
33
+ * @param {import('../types.js').LaunchedApp} app
34
+ * @param {{
35
+ * only?: string[],
36
+ * record?: boolean,
37
+ * updateNew?: boolean,
38
+ * onResult?: (r: PictureRunResult) => void,
39
+ * retries?: number,
40
+ * tool?: string,
41
+ * signal?: AbortSignal,
42
+ * }} [opts]
43
+ * @returns {Promise<PictureRunResult[]>}
44
+ */
45
+ export async function runPictures(project, app, opts = {}) {
46
+ const { config } = project;
47
+ // The launcher hands back the public page surface; the capture loop needs the
48
+ // plumbing underneath it (init scripts, console buffer) that the same object carries.
49
+ const page = /** @type {import('../types.js').PageHandle} */ (app.page);
50
+ const only = opts.only && opts.only.length > 0 ? new Set(opts.only) : null;
51
+ const retries = Math.max(0, opts.retries ?? config.retries);
52
+ const here = platformTag();
53
+ // A desktop app has no url to go back to between screens, so it gets a reload instead.
54
+ // A web app does not need one: every screen starts with a `goto`.
55
+ const reset = config.app.kind === 'electron' ? () => resetWindow(app) : undefined;
56
+
57
+ /** @type {PictureRunResult[]} */
58
+ const results = [];
59
+
60
+ for (const screen of config.screens) {
61
+ if (opts.signal?.aborted) break;
62
+ if (only && !only.has(screen.name)) continue;
63
+
64
+ 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
+ );
74
+ continue;
75
+ }
76
+
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
+ );
88
+ }
89
+
90
+ return results;
91
+ }
92
+
93
+ /**
94
+ * @param {import('../types.js').Project} project
95
+ * @param {import('../types.js').PageHandle} page
96
+ * @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
98
+ * @returns {Promise<PictureRunResult>}
99
+ */
100
+ async function runOneScreen(project, page, screen, ctx) {
101
+ const { config, paths } = project;
102
+ const started = Date.now();
103
+ const settings = settingsForScreen(config, screen);
104
+ const approvedPaths = approvedPicture(paths, screen.name);
105
+ const resultPaths = resultPicture(paths, screen.name);
106
+
107
+ const approved = await readApproved(paths, screen.name);
108
+ const platformNote = platformWarning(approved?.meta?.platform, ctx.here);
109
+
110
+ /** @type {string[]} */
111
+ let consoleErrors = [];
112
+ /** @type {{width: number, height: number}|undefined} */
113
+ let size;
114
+ /** @type {import('../types.js').CompareReport|null} */
115
+ let compare = null;
116
+ let attempts = 0;
117
+
118
+ try {
119
+ // Photograph, and if it disagrees with the approved picture, photograph again
120
+ // before believing it. One flickering frame is not a regression.
121
+ for (let attempt = 1; attempt <= ctx.retries + 1; attempt++) {
122
+ attempts = attempt;
123
+ // Every screen starts from the same place.
124
+ //
125
+ // A web screen begins with a `goto`, so it is naturally isolated. A desktop app
126
+ // has no front door, and without this the ORDER of the screens quietly decides
127
+ // the result — the screen that collapses a sidebar left it collapsed for every
128
+ // screen and every guard after it. (A walk deliberately does NOT do this: a walk
129
+ // is one journey through the app, in order.)
130
+ if (ctx.reset) await ctx.reset();
131
+ const shot = await captureScreen(page, screen, settings, {
132
+ fixturesDir: paths.fixtures,
133
+ record: ctx.record ?? false,
134
+ timeoutMs: settings.freeze.settle?.timeoutMs,
135
+ });
136
+ consoleErrors = shot.consoleErrors;
137
+ size = { width: shot.width, height: shot.height };
138
+
139
+ await writeResult(paths, screen.name, shot.png, {
140
+ deviceScaleFactor: settings.viewport.deviceScaleFactor,
141
+ describe: screen.describe,
142
+ });
143
+
144
+ if (!approved) break;
145
+
146
+ compare = comparePng(approved.png, shot.png, settings.tolerance, shot.masks);
147
+ if (compare.equal) break;
148
+ if (attempt <= ctx.retries) {
149
+ detail(`${screen.name} looked different on attempt ${attempt} — taking it again.`);
150
+ }
151
+ }
152
+ } catch (error) {
153
+ return finish(ctx, {
154
+ name: screen.name,
155
+ describe: screen.describe,
156
+ status: 'failed',
157
+ message: join(`${screen.name} could not be photographed. ${messageOf(error)}`, platformNote),
158
+ durationMs: Date.now() - started,
159
+ attempts,
160
+ consoleErrors: consoleErrors.length > 0 ? consoleErrors : undefined,
161
+ });
162
+ }
163
+
164
+ /** @type {PictureRunResult} */
165
+ const base = {
166
+ name: screen.name,
167
+ describe: screen.describe,
168
+ status: 'passed',
169
+ durationMs: Date.now() - started,
170
+ attempts,
171
+ actualPath: resultPaths.png,
172
+ size,
173
+ consoleErrors: consoleErrors.length > 0 ? consoleErrors : undefined,
174
+ };
175
+
176
+ if (!approved) {
177
+ if (ctx.updateNew) {
178
+ // Only ever reached from a flag a person typed, and only for a screen that
179
+ // has never been approved. A picture that already exists always needs eyes.
180
+ const git = await gitInfo(paths.root);
181
+ const meta = await approveFromResult(paths, screen.name, {
182
+ git,
183
+ tool: ctx.tool,
184
+ describe: screen.describe,
185
+ deviceScaleFactor: settings.viewport.deviceScaleFactor,
186
+ });
187
+ return finish(ctx, {
188
+ ...base,
189
+ status: 'new',
190
+ approvedPath: approvedPaths.png,
191
+ approvedSize: { width: meta.width, height: meta.height },
192
+ message: join(`${screen.name} had no approved picture — this one was saved as the first.`, platformNote),
193
+ });
194
+ }
195
+ return finish(ctx, {
196
+ ...base,
197
+ status: 'new',
198
+ message: join(
199
+ `${screen.name} has no approved picture yet — look at it and run \`staysfixed approve ${screen.name}\` if it is right.`,
200
+ platformNote,
201
+ ),
202
+ });
203
+ }
204
+
205
+ if (!compare) {
206
+ // Cannot happen: with an approved picture every attempt compares. Kept so a
207
+ // future edit that breaks that assumption fails loudly instead of silently passing.
208
+ return finish(ctx, {
209
+ ...base,
210
+ status: 'failed',
211
+ message: join(`${screen.name} was photographed but never compared.`, platformNote),
212
+ });
213
+ }
214
+
215
+ const common = {
216
+ ...base,
217
+ approvedPath: approvedPaths.png,
218
+ approvedSize: compare.approvedSize,
219
+ diffPixels: compare.diffPixels,
220
+ diffRatio: compare.diffRatio,
221
+ };
222
+
223
+ if (compare.equal) {
224
+ const retriedToPass = attempts > 1;
225
+ return finish(ctx, {
226
+ ...common,
227
+ status: 'passed',
228
+ retriedToPass,
229
+ message: retriedToPass
230
+ ? join(
231
+ `${screen.name} matched, but only when it was photographed again — it may be unreliable.`,
232
+ platformNote,
233
+ )
234
+ : platformNote || undefined,
235
+ });
236
+ }
237
+
238
+ /** @type {string|undefined} */
239
+ let diffPath;
240
+ if (compare.diffPng) diffPath = await writeDiff(paths, screen.name, compare.diffPng);
241
+
242
+ const what = describeDifference(compare, screen.name);
243
+ const next = compare.sizeMismatch
244
+ ? 'There is no difference picture for a size change — open the new picture and look at it.'
245
+ : `Open the difference picture, and if the new look is right run \`staysfixed approve ${screen.name}\`.`;
246
+
247
+ return finish(ctx, {
248
+ ...common,
249
+ status: 'changed',
250
+ diffPath,
251
+ message: join(what, next, platformNote),
252
+ });
253
+ }
254
+
255
+ /**
256
+ * Font rendering differs between operating systems, so a picture approved on one
257
+ * and checked on another is the single most common false alarm. Say it; never
258
+ * fail on it alone.
259
+ * @param {string|undefined} approvedOn
260
+ * @param {string} here
261
+ * @returns {string}
262
+ */
263
+ function platformWarning(approvedOn, here) {
264
+ if (!approvedOn || approvedOn === here) return '';
265
+ 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.`;
266
+ }
267
+
268
+ /**
269
+ * @param {{onResult?: (r: PictureRunResult) => void}} ctx
270
+ * @param {PictureRunResult} result
271
+ * @returns {PictureRunResult}
272
+ */
273
+ function finish(ctx, result) {
274
+ if (ctx.onResult) ctx.onResult(result);
275
+ return result;
276
+ }
277
+
278
+ /**
279
+ * @param {...(string|undefined)} parts
280
+ * @returns {string}
281
+ */
282
+ function join(...parts) {
283
+ return parts.filter(Boolean).join(' ');
284
+ }