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/store.js
CHANGED
|
@@ -9,12 +9,25 @@
|
|
|
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 preview is, in real pixels.
|
|
21
|
+
*
|
|
22
|
+
* The watch panel is 460 CSS pixels across, and every screen a person reviews on is
|
|
23
|
+
* retina, so the panel is really 920 pixels wide. A 320-pixel preview stretched over
|
|
24
|
+
* that is the blur everybody complained about: you cannot read a label in it, and
|
|
25
|
+
* zooming in only makes the blur bigger. 900 is where a preview stops being a
|
|
26
|
+
* stand-in and starts being a picture — and it still weighs about 40KB, which a run
|
|
27
|
+
* can hand to a window a dozen times over without anybody noticing.
|
|
28
|
+
*/
|
|
29
|
+
const PREVIEW_WIDTH = 900;
|
|
30
|
+
|
|
18
31
|
/**
|
|
19
32
|
* A small note written beside a result picture so `approve` knows things the PNG
|
|
20
33
|
* cannot tell it — the screen density it was taken at, and its description.
|
|
@@ -23,6 +36,117 @@ import { pngSize } from './capture.js';
|
|
|
23
36
|
* @property {string} [describe]
|
|
24
37
|
*/
|
|
25
38
|
|
|
39
|
+
/**
|
|
40
|
+
* A picture already decoded into pixels: what `PNG.sync.read` hands back, and what the
|
|
41
|
+
* mask painter works on. Described by its shape rather than by pngjs's class, because
|
|
42
|
+
* pngjs does not hand back one of those.
|
|
43
|
+
* @typedef {{width: number, height: number, data: Uint8Array}} Pixels
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A small copy of a picture, ready to drop straight into a page.
|
|
48
|
+
*
|
|
49
|
+
* This is the INSTANT preview, and only that. The watch panel loads the real PNG off
|
|
50
|
+
* disk the moment it exists, so this only has to hold the frame for the fraction of a
|
|
51
|
+
* second before that file arrives — but it has to hold it honestly, at a size a person
|
|
52
|
+
* can actually read.
|
|
53
|
+
*
|
|
54
|
+
* It is built from the finished picture rather than asked of the app again: what a
|
|
55
|
+
* watcher sees is exactly what was compared, blackout boxes and all, and never a second
|
|
56
|
+
* photograph taken a moment later that shows something slightly different.
|
|
57
|
+
*
|
|
58
|
+
* Points are averaged now, not sampled. Dropping nine pixels out of every ten is what
|
|
59
|
+
* made the old previews look broken rather than merely small — a one-pixel border or a
|
|
60
|
+
* line of text either survived or vanished depending on where it happened to land.
|
|
61
|
+
* Averaging every pixel that falls inside an output pixel costs about forty
|
|
62
|
+
* milliseconds on a retina screenshot, and it is the difference between a picture and a
|
|
63
|
+
* smear.
|
|
64
|
+
*
|
|
65
|
+
* @param {Buffer|Pixels} source
|
|
66
|
+
* The bytes of a PNG, or a picture already decoded. Hand over the decoded one when
|
|
67
|
+
* you have it — a retina screenshot costs about eighty milliseconds to decode, and
|
|
68
|
+
* decoding the same megapixels twice for one screen is the whole cost of this
|
|
69
|
+
* function paid for nothing.
|
|
70
|
+
* @returns {Promise<string|null>} a data: address for an <img>, or null if it cannot be read
|
|
71
|
+
*/
|
|
72
|
+
export async function thumbnailOf(source) {
|
|
73
|
+
try {
|
|
74
|
+
if (Buffer.isBuffer(source) && pngSize(source).width <= PREVIEW_WIDTH) {
|
|
75
|
+
// Already small enough to be its own preview. Saves decoding and re-encoding a
|
|
76
|
+
// picture only to hand back what we were given.
|
|
77
|
+
return `data:image/png;base64,${source.toString('base64')}`;
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Not a PNG we can measure from its header. Fall through and let the decode below
|
|
81
|
+
// be the one that decides whether there is a picture here at all.
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
// Told apart by what it IS, not by `instanceof PNG`: pngjs's sync reader hands back
|
|
86
|
+
// a plain object rather than an instance of its own class, so an instance check
|
|
87
|
+
// here quietly says "no" to every decoded picture ever passed in.
|
|
88
|
+
const full = Buffer.isBuffer(source) ? PNG.sync.read(source) : source;
|
|
89
|
+
if (!(full.width > 0) || !(full.height > 0)) return null;
|
|
90
|
+
const scale = full.width > PREVIEW_WIDTH ? full.width / PREVIEW_WIDTH : 1;
|
|
91
|
+
const width = Math.max(1, Math.round(full.width / scale));
|
|
92
|
+
const height = Math.max(1, Math.round(full.height / scale));
|
|
93
|
+
|
|
94
|
+
const small = new PNG({ width, height });
|
|
95
|
+
const from = full.data;
|
|
96
|
+
const into = small.data;
|
|
97
|
+
|
|
98
|
+
for (let y = 0; y < height; y += 1) {
|
|
99
|
+
const top = Math.floor(y * scale);
|
|
100
|
+
// Always at least one row, even when the picture is barely bigger than the preview.
|
|
101
|
+
const bottom = Math.min(full.height, Math.max(top + 1, Math.floor((y + 1) * scale)));
|
|
102
|
+
for (let x = 0; x < width; x += 1) {
|
|
103
|
+
const left = Math.floor(x * scale);
|
|
104
|
+
const right = Math.min(full.width, Math.max(left + 1, Math.floor((x + 1) * scale)));
|
|
105
|
+
let r = 0;
|
|
106
|
+
let g = 0;
|
|
107
|
+
let b = 0;
|
|
108
|
+
let a = 0;
|
|
109
|
+
let n = 0;
|
|
110
|
+
for (let sy = top; sy < bottom; sy += 1) {
|
|
111
|
+
let i = (sy * full.width + left) * 4;
|
|
112
|
+
for (let sx = left; sx < right; sx += 1) {
|
|
113
|
+
r += from[i];
|
|
114
|
+
g += from[i + 1];
|
|
115
|
+
b += from[i + 2];
|
|
116
|
+
a += from[i + 3];
|
|
117
|
+
n += 1;
|
|
118
|
+
i += 4;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const at = (y * width + x) * 4;
|
|
122
|
+
into[at] = (r / n + 0.5) | 0;
|
|
123
|
+
into[at + 1] = (g / n + 0.5) | 0;
|
|
124
|
+
into[at + 2] = (b / n + 0.5) | 0;
|
|
125
|
+
into[at + 3] = (a / n + 0.5) | 0;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return `data:image/png;base64,${PNG.sync.write(small).toString('base64')}`;
|
|
129
|
+
} catch {
|
|
130
|
+
// A picture nobody can decode is not worth failing a run over — the run itself has
|
|
131
|
+
// already said what it thinks of the screen, and a watcher simply sees no picture.
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The fingerprint of a picture.
|
|
138
|
+
*
|
|
139
|
+
* The same sha256 that is written into an approved picture's note, so anyone holding that
|
|
140
|
+
* note can tell whether a fresh photograph is the same file without reading the old one
|
|
141
|
+
* off disk or decoding either of them.
|
|
142
|
+
*
|
|
143
|
+
* @param {Buffer|Uint8Array|string} buffer
|
|
144
|
+
* @returns {string} hex sha256
|
|
145
|
+
*/
|
|
146
|
+
export function fingerprint(buffer) {
|
|
147
|
+
return sha256(buffer);
|
|
148
|
+
}
|
|
149
|
+
|
|
26
150
|
/**
|
|
27
151
|
* @param {import('../types.js').ProjectPaths} paths
|
|
28
152
|
* @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
|
package/src/run.js
CHANGED
|
@@ -16,13 +16,14 @@ import { ensureDirs, clearResults, resultPicture, safeName } from './core/paths.
|
|
|
16
16
|
import { gitInfo } from './core/git.js';
|
|
17
17
|
import { loadHistory, saveHistory, foldRun, condemned } from './core/history.js';
|
|
18
18
|
import { warn, detail, shortPath } from './core/log.js';
|
|
19
|
+
import { makeTimings, emitEvent } from './core/events.js';
|
|
19
20
|
import { launchApp } from './drive/launch.js';
|
|
20
21
|
import { platformTag } from './drive/find.js';
|
|
21
22
|
import { runPictures } from './picture/run.js';
|
|
22
23
|
import { approveFromResult, listApproved } from './picture/store.js';
|
|
23
24
|
import { loadGuards } from './guard/load.js';
|
|
24
25
|
import { runGuards } from './guard/run.js';
|
|
25
|
-
import { walkApp, writeWalkContactSheet } from './walk/run.js';
|
|
26
|
+
import { walkApp, writeWalkContactSheet, countWalkSteps } from './walk/run.js';
|
|
26
27
|
import { listMarkers } from './marker/mark.js';
|
|
27
28
|
import { writeRunReport } from './report/html.js';
|
|
28
29
|
import { printPictureResult, printGuardResult } from './report/console.js';
|
|
@@ -83,6 +84,10 @@ const LAST_RUN = 'last-run.json';
|
|
|
83
84
|
* onPicture?: (result: import('./types.js').PictureResult) => void,
|
|
84
85
|
* onGuard?: (result: import('./types.js').GuardResult) => void,
|
|
85
86
|
* writeReport?: boolean,
|
|
87
|
+
* events?: import('./types.js').RunEvents,
|
|
88
|
+
* watching?: boolean,
|
|
89
|
+
* onApp?: (app: import('./types.js').LaunchedApp) => Promise<void>,
|
|
90
|
+
* timings?: ReturnType<typeof makeTimings>,
|
|
86
91
|
* }} [opts]
|
|
87
92
|
* @returns {Promise<import('./types.js').RunSummary>}
|
|
88
93
|
*/
|
|
@@ -90,6 +95,12 @@ export async function runCheck(project, opts = {}) {
|
|
|
90
95
|
const { config, paths } = project;
|
|
91
96
|
const startedAt = new Date();
|
|
92
97
|
const started = Date.now();
|
|
98
|
+
const events = opts.events;
|
|
99
|
+
const watching = opts.watching === true;
|
|
100
|
+
// A run always knows where its time went, whether or not anybody asked. It
|
|
101
|
+
// costs two numbers per phase, and the alternative is being unable to answer
|
|
102
|
+
// "why did that take three minutes" without running it all again.
|
|
103
|
+
const timings = opts.timings ?? makeTimings();
|
|
93
104
|
|
|
94
105
|
await ensureDirs(paths);
|
|
95
106
|
// Yesterday's evidence goes in the bin before today's is taken. A stale diff
|
|
@@ -117,6 +128,17 @@ export async function runCheck(project, opts = {}) {
|
|
|
117
128
|
});
|
|
118
129
|
}
|
|
119
130
|
|
|
131
|
+
emitEvent(events, {
|
|
132
|
+
type: 'run:start',
|
|
133
|
+
plan: {
|
|
134
|
+
screens: screens.length,
|
|
135
|
+
guards: guards.length,
|
|
136
|
+
app: describeApp(config.app),
|
|
137
|
+
project: path.basename(paths.root),
|
|
138
|
+
watching,
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
120
142
|
const onPicture = opts.onPicture ?? (opts.quiet ? undefined : (/** @type {import('./types.js').PictureResult} */ r) => printPictureResult(r));
|
|
121
143
|
const onGuard = opts.onGuard ?? (opts.quiet ? undefined : (/** @type {import('./types.js').GuardResult} */ r) => printGuardResult(r));
|
|
122
144
|
|
|
@@ -128,24 +150,41 @@ export async function runCheck(project, opts = {}) {
|
|
|
128
150
|
// Nothing to look at means nothing to open. Starting a browser to check zero
|
|
129
151
|
// screens is thirty seconds of somebody's life for no answer.
|
|
130
152
|
if (screens.length > 0 || guards.length > 0) {
|
|
131
|
-
await withApp(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
153
|
+
await withApp(
|
|
154
|
+
project,
|
|
155
|
+
async (app) => {
|
|
156
|
+
if (screens.length > 0) {
|
|
157
|
+
emitEvent(events, { type: 'phase', message: 'photographing' });
|
|
158
|
+
pictures = await runPictures(project, app, {
|
|
159
|
+
only: screens.map((s) => s.name),
|
|
160
|
+
record: opts.record ?? false,
|
|
161
|
+
retries: config.retries,
|
|
162
|
+
tool: TOOL,
|
|
163
|
+
onResult: onPicture,
|
|
164
|
+
signal: opts.signal,
|
|
165
|
+
events,
|
|
166
|
+
timings,
|
|
167
|
+
// Small pictures cost time to make, so they are only made when there
|
|
168
|
+
// is a window open to show them in.
|
|
169
|
+
thumbnail: Boolean(events && watching),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (guards.length > 0) {
|
|
173
|
+
emitEvent(events, { type: 'phase', message: 'running the guards' });
|
|
174
|
+
const stopGuards = timings.mark('guards');
|
|
175
|
+
try {
|
|
176
|
+
guardResults = await runGuards(project, app, guards, {
|
|
177
|
+
onResult: onGuard,
|
|
178
|
+
signal: opts.signal,
|
|
179
|
+
events,
|
|
180
|
+
});
|
|
181
|
+
} finally {
|
|
182
|
+
stopGuards();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
{ events, timings, onApp: opts.onApp },
|
|
187
|
+
);
|
|
149
188
|
}
|
|
150
189
|
|
|
151
190
|
const git = await gitInfo(paths.root);
|
|
@@ -171,7 +210,7 @@ export async function runCheck(project, opts = {}) {
|
|
|
171
210
|
|
|
172
211
|
const totals = countUp(pictures, guardResults);
|
|
173
212
|
|
|
174
|
-
/** @type {import('./types.js').RunSummary} */
|
|
213
|
+
/** @type {import('./types.js').RunSummary & {timings?: import('./types.js').Timings}} */
|
|
175
214
|
const summary = {
|
|
176
215
|
id: runId(startedAt),
|
|
177
216
|
startedAt: startedAt.toISOString(),
|
|
@@ -187,6 +226,9 @@ export async function runCheck(project, opts = {}) {
|
|
|
187
226
|
tool: TOOL,
|
|
188
227
|
platform: platformTag(),
|
|
189
228
|
condemned: condemnedNames,
|
|
229
|
+
// Read here rather than at the very end: what follows is writing files, and
|
|
230
|
+
// where the run spent its time is a fact about the run, not about the report.
|
|
231
|
+
timings: timings.get(),
|
|
190
232
|
};
|
|
191
233
|
|
|
192
234
|
if (opts.writeReport !== false) {
|
|
@@ -204,6 +246,10 @@ export async function runCheck(project, opts = {}) {
|
|
|
204
246
|
warn(`The run finished, but its result could not be saved for \`staysfixed status\`. ${messageOf(e)}`);
|
|
205
247
|
}
|
|
206
248
|
|
|
249
|
+
// Last, so anything watching that closes on the verdict does not race the
|
|
250
|
+
// report being written.
|
|
251
|
+
emitEvent(events, { type: 'run:done', summary });
|
|
252
|
+
|
|
207
253
|
return summary;
|
|
208
254
|
}
|
|
209
255
|
|
|
@@ -222,7 +268,15 @@ export async function runCheck(project, opts = {}) {
|
|
|
222
268
|
*
|
|
223
269
|
* @param {import('./types.js').Project} project
|
|
224
270
|
* @param {string} screenName
|
|
225
|
-
* @param {{
|
|
271
|
+
* @param {{
|
|
272
|
+
* record?: boolean,
|
|
273
|
+
* retries?: number,
|
|
274
|
+
* signal?: AbortSignal,
|
|
275
|
+
* onResult?: (r: import('./types.js').PictureResult) => void,
|
|
276
|
+
* events?: import('./types.js').RunEvents,
|
|
277
|
+
* watching?: boolean,
|
|
278
|
+
* timings?: ReturnType<typeof makeTimings>,
|
|
279
|
+
* }} [opts]
|
|
226
280
|
* @returns {Promise<{png: Buffer, result: import('./types.js').PictureResult, path: string}>}
|
|
227
281
|
*/
|
|
228
282
|
export async function captureOne(project, screenName, opts = {}) {
|
|
@@ -240,17 +294,47 @@ export async function captureOne(project, screenName, opts = {}) {
|
|
|
240
294
|
|
|
241
295
|
await ensureDirs(paths);
|
|
242
296
|
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
297
|
+
const events = opts.events;
|
|
298
|
+
const watching = opts.watching === true;
|
|
299
|
+
const timings = opts.timings ?? makeTimings();
|
|
300
|
+
|
|
301
|
+
// One screen is still a run as far as anyone watching is concerned, so it
|
|
302
|
+
// describes itself the same way. This is what lets an agent's own capture be
|
|
303
|
+
// watched in the same window as a full check.
|
|
304
|
+
emitEvent(events, {
|
|
305
|
+
type: 'run:start',
|
|
306
|
+
plan: {
|
|
307
|
+
screens: 1,
|
|
308
|
+
guards: 0,
|
|
309
|
+
app: describeApp(config.app),
|
|
310
|
+
project: path.basename(paths.root),
|
|
311
|
+
watching,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const results = await withApp(
|
|
316
|
+
project,
|
|
317
|
+
(app) => {
|
|
318
|
+
emitEvent(events, { type: 'phase', message: 'photographing' });
|
|
319
|
+
return runPictures(project, app, {
|
|
320
|
+
only: [screen.name],
|
|
321
|
+
record: opts.record ?? false,
|
|
322
|
+
retries: opts.retries ?? config.retries,
|
|
323
|
+
tool: TOOL,
|
|
324
|
+
onResult: opts.onResult,
|
|
325
|
+
signal: opts.signal,
|
|
326
|
+
events,
|
|
327
|
+
timings,
|
|
328
|
+
thumbnail: Boolean(events && watching),
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
{ events, timings },
|
|
252
332
|
);
|
|
253
333
|
|
|
334
|
+
// Said as soon as the app is shut. What is left is reading a file off disk,
|
|
335
|
+
// and a watcher should not be left with a spinner turning through it.
|
|
336
|
+
emitEvent(events, { type: 'run:done' });
|
|
337
|
+
|
|
254
338
|
const result = results[0];
|
|
255
339
|
if (!result) {
|
|
256
340
|
throw new StaysFixedError(`"${screen.name}" was not photographed.`, {
|
|
@@ -281,21 +365,58 @@ export async function captureOne(project, screenName, opts = {}) {
|
|
|
281
365
|
* signal?: AbortSignal,
|
|
282
366
|
* onStep?: (update: import('./walk/run.js').WalkProgress) => void,
|
|
283
367
|
* writeReport?: boolean,
|
|
368
|
+
* events?: import('./types.js').RunEvents,
|
|
369
|
+
* watching?: boolean,
|
|
370
|
+
* onApp?: (app: import('./types.js').LaunchedApp) => Promise<void>,
|
|
371
|
+
* timings?: ReturnType<typeof makeTimings>,
|
|
284
372
|
* }} [opts]
|
|
285
373
|
* @returns {Promise<import('./types.js').WalkReport>}
|
|
286
374
|
*/
|
|
287
375
|
export async function runWalk(project, opts = {}) {
|
|
288
376
|
await ensureDirs(project.paths);
|
|
289
377
|
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
378
|
+
const events = opts.events;
|
|
379
|
+
const watching = opts.watching === true;
|
|
380
|
+
// The caller's stopwatch when it brought one — `--profile` reads it back out
|
|
381
|
+
// afterwards, so a walk that quietly kept its own would print all zeros.
|
|
382
|
+
const timings = opts.timings ?? makeTimings();
|
|
383
|
+
|
|
384
|
+
emitEvent(events, {
|
|
385
|
+
type: 'run:start',
|
|
386
|
+
plan: {
|
|
387
|
+
// Counted before anything is opened so the window can draw the whole list
|
|
388
|
+
// straight away. A walk with nothing to walk through says nothing here and
|
|
389
|
+
// fails a moment later, in one place, with a sentence a person can act on.
|
|
390
|
+
screens: countWalk(project.config, opts.only),
|
|
391
|
+
guards: 0,
|
|
392
|
+
app: describeApp(project.config.app),
|
|
393
|
+
project: path.basename(project.paths.root),
|
|
394
|
+
watching,
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const report = await withApp(
|
|
399
|
+
project,
|
|
400
|
+
(app) => {
|
|
401
|
+
emitEvent(events, { type: 'phase', message: 'photographing' });
|
|
402
|
+
return walkApp(project, app, {
|
|
403
|
+
only: opts.only,
|
|
404
|
+
record: opts.record ?? false,
|
|
405
|
+
onStep: opts.onStep,
|
|
406
|
+
signal: opts.signal,
|
|
407
|
+
events,
|
|
408
|
+
thumbnail: Boolean(events && watching),
|
|
409
|
+
timings,
|
|
410
|
+
});
|
|
411
|
+
},
|
|
412
|
+
{ events, timings, onApp: opts.onApp },
|
|
297
413
|
);
|
|
298
414
|
|
|
415
|
+
// A walk has no verdict to hand over — the pictures are the point — so this
|
|
416
|
+
// says only that it is over. Said here, with the app shut and every photo
|
|
417
|
+
// taken; the page that shows them is written after.
|
|
418
|
+
emitEvent(events, { type: 'run:done' });
|
|
419
|
+
|
|
299
420
|
if (opts.writeReport === false) return report;
|
|
300
421
|
|
|
301
422
|
try {
|
|
@@ -441,16 +562,43 @@ export async function approveScreens(project, names, opts = {}) {
|
|
|
441
562
|
* Electron window left running is a leaked process on somebody's machine, and
|
|
442
563
|
* the next run will fight it for the debug port.
|
|
443
564
|
*
|
|
565
|
+
* `onApp` is the one hook in here. It is handed the app the moment it is open
|
|
566
|
+
* and before a single picture is taken, which is what lets the watch panel put
|
|
567
|
+
* itself beside a window that did not exist when the panel opened. It moves
|
|
568
|
+
* windows around a desk; it never touches the page, and the picture comes from
|
|
569
|
+
* the viewport the capture sets, not from the window — so where the window ends
|
|
570
|
+
* up cannot change what was photographed.
|
|
571
|
+
*
|
|
444
572
|
* @template T
|
|
445
573
|
* @param {import('./types.js').Project} project
|
|
446
574
|
* @param {(app: import('./types.js').LaunchedApp) => Promise<T>} work
|
|
575
|
+
* @param {{
|
|
576
|
+
* events?: import('./types.js').RunEvents,
|
|
577
|
+
* timings?: ReturnType<typeof makeTimings>,
|
|
578
|
+
* onApp?: (app: import('./types.js').LaunchedApp) => Promise<void>,
|
|
579
|
+
* }} [ctx]
|
|
447
580
|
* @returns {Promise<T>}
|
|
448
581
|
*/
|
|
449
|
-
async function withApp(project, work) {
|
|
450
|
-
|
|
582
|
+
async function withApp(project, work, ctx = {}) {
|
|
583
|
+
emitEvent(ctx.events, { type: 'phase', message: 'opening the app' });
|
|
584
|
+
const stopLaunch = ctx.timings?.mark('launch');
|
|
585
|
+
// Stopped even when the app never opened: a launch that gave up after fifty
|
|
586
|
+
// seconds is exactly the number somebody wants to see.
|
|
587
|
+
const app = await launchApp(project).finally(() => stopLaunch?.());
|
|
588
|
+
if (ctx.onApp) {
|
|
589
|
+
try {
|
|
590
|
+
await ctx.onApp(app);
|
|
591
|
+
} catch (e) {
|
|
592
|
+
// Whatever wanted a look at the app is a spectator. A spectator that
|
|
593
|
+
// trips over must not take the run down with it, and is not worth a
|
|
594
|
+
// warning in the middle of a clean one.
|
|
595
|
+
detail(`Something watching this run could not be shown the app. ${messageOf(e)}`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
451
598
|
try {
|
|
452
599
|
return await work(app);
|
|
453
600
|
} finally {
|
|
601
|
+
emitEvent(ctx.events, { type: 'phase', message: 'closing' });
|
|
454
602
|
try {
|
|
455
603
|
await app.close();
|
|
456
604
|
} catch (e) {
|
|
@@ -459,6 +607,48 @@ async function withApp(project, work) {
|
|
|
459
607
|
}
|
|
460
608
|
}
|
|
461
609
|
|
|
610
|
+
/**
|
|
611
|
+
* How many screens a walk will visit, or none when there is nothing to walk.
|
|
612
|
+
*
|
|
613
|
+
* @param {import('./types.js').ResolvedConfig} config
|
|
614
|
+
* @param {string|string[]} [only]
|
|
615
|
+
* @returns {number}
|
|
616
|
+
*/
|
|
617
|
+
function countWalk(config, only) {
|
|
618
|
+
try {
|
|
619
|
+
return countWalkSteps(config, only);
|
|
620
|
+
} catch {
|
|
621
|
+
// Nothing to walk through. The walk itself says so properly, in one place.
|
|
622
|
+
return 0;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* The app being opened, in a few words: what kind it is and which one it is.
|
|
628
|
+
*
|
|
629
|
+
* A watcher shows this at the top of a narrow panel, so the whole path or the
|
|
630
|
+
* whole address would be noise — the name of the binary, or the host being
|
|
631
|
+
* opened, is what a person recognises.
|
|
632
|
+
*
|
|
633
|
+
* @param {import('./types.js').AppConfig} app
|
|
634
|
+
* @returns {string}
|
|
635
|
+
*/
|
|
636
|
+
function describeApp(app) {
|
|
637
|
+
if (app.kind === 'electron') {
|
|
638
|
+
const binary = app.binary ?? app.attach ?? '';
|
|
639
|
+
return binary ? `electron — ${path.basename(binary)}` : 'electron';
|
|
640
|
+
}
|
|
641
|
+
const url = app.url ?? app.attach ?? '';
|
|
642
|
+
if (!url) return 'web';
|
|
643
|
+
try {
|
|
644
|
+
// A web address has no useful last part — "/" is not a name — so the host is
|
|
645
|
+
// what gets shown: "web — localhost:5173".
|
|
646
|
+
return `web — ${new URL(url).host || url}`;
|
|
647
|
+
} catch {
|
|
648
|
+
return `web — ${url}`;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
462
652
|
/**
|
|
463
653
|
* Flatten both kinds of result into the one shape the flake register folds.
|
|
464
654
|
* @param {PictureRunResult[]} pictures
|