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.
- package/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +529 -0
- package/bin/staysfixed.js +18 -0
- package/examples/guards/the-sidebar-still-collapses.js +91 -0
- package/examples/staysfixed.config.electron.js +172 -0
- package/examples/staysfixed.config.web.js +277 -0
- package/package.json +61 -0
- package/src/cli/approve.js +126 -0
- package/src/cli/check.js +73 -0
- package/src/cli/doctor.js +379 -0
- package/src/cli/flake.js +61 -0
- package/src/cli/index.js +519 -0
- package/src/cli/init.js +564 -0
- package/src/cli/mark.js +69 -0
- package/src/cli/status.js +19 -0
- package/src/cli/trace.js +73 -0
- package/src/cli/walk.js +57 -0
- package/src/core/config.js +226 -0
- package/src/core/errors.js +48 -0
- package/src/core/git.js +90 -0
- package/src/core/hash.js +32 -0
- package/src/core/history.js +173 -0
- package/src/core/log.js +144 -0
- package/src/core/paths.js +135 -0
- package/src/drive/browser.js +540 -0
- package/src/drive/cdp.js +382 -0
- package/src/drive/electron.js +326 -0
- package/src/drive/find.js +331 -0
- package/src/drive/launch.js +263 -0
- package/src/drive/page.js +1042 -0
- package/src/freeze/clock.js +213 -0
- package/src/freeze/fonts.js +243 -0
- package/src/freeze/index.js +234 -0
- package/src/freeze/mask.js +187 -0
- package/src/freeze/motion.js +206 -0
- package/src/freeze/network.js +455 -0
- package/src/freeze/random.js +87 -0
- package/src/freeze/settle.js +178 -0
- package/src/guard/api.js +197 -0
- package/src/guard/load.js +324 -0
- package/src/guard/name.js +327 -0
- package/src/guard/run.js +224 -0
- package/src/index.js +61 -0
- package/src/marker/mark.js +260 -0
- package/src/marker/trace.js +293 -0
- package/src/mcp/server.js +377 -0
- package/src/mcp/tools.js +978 -0
- package/src/picture/capture.js +276 -0
- package/src/picture/compare.js +103 -0
- package/src/picture/run.js +284 -0
- package/src/picture/store.js +208 -0
- package/src/report/console.js +540 -0
- package/src/report/html.js +579 -0
- package/src/run.js +614 -0
- package/src/types.js +471 -0
- 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
|
+
}
|