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