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