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