staysfixed 0.2.0 → 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/package.json +1 -1
- package/src/core/events.js +28 -0
- package/src/picture/capture.js +16 -5
- package/src/picture/run.js +60 -16
- package/src/picture/store.js +83 -24
- package/src/types.js +8 -1
- package/src/walk/run.js +34 -7
- package/src/watch/panel.js +974 -207
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "staysfixed",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Prove that what already worked still works after an agent changed the code. Picture checks, guards for fixed bugs, a pre-release walkthrough, and known-good markers — as a CLI and as an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/core/events.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* run and still draw the screens that were photographed before it opened.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { pathToFileURL } from 'node:url';
|
|
16
17
|
import { detail } from './log.js';
|
|
17
18
|
import { messageOf } from './errors.js';
|
|
18
19
|
|
|
@@ -111,6 +112,33 @@ export function emitEvent(events, event) {
|
|
|
111
112
|
events.emit(/** @type {RunEvent} */ (event));
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
/**
|
|
116
|
+
* A file on disk, as an address a local page can load.
|
|
117
|
+
*
|
|
118
|
+
* The watch panel is itself a local `file://` page, which means it can open the real
|
|
119
|
+
* full-resolution PNGs this run just wrote instead of a shrunken copy pasted into the
|
|
120
|
+
* event. That is the difference between a picture you can zoom into and a picture you
|
|
121
|
+
* cannot read — and it costs nothing to send, because the address is a few dozen
|
|
122
|
+
* characters and the pixels never move.
|
|
123
|
+
*
|
|
124
|
+
* Hands back `undefined` rather than a broken address for anything that is not a real
|
|
125
|
+
* path: an <img> pointed at a file that is not there draws the browser's torn-page
|
|
126
|
+
* icon, which looks like the tool is broken. Nothing at all looks like nothing at all.
|
|
127
|
+
*
|
|
128
|
+
* @param {string|undefined|null} file An absolute path to a file that EXISTS. Callers
|
|
129
|
+
* pass the path they have just written, or one they have just read — this function
|
|
130
|
+
* does not touch the disk, so it cannot tell the difference itself.
|
|
131
|
+
* @returns {string|undefined}
|
|
132
|
+
*/
|
|
133
|
+
export function fileUrl(file) {
|
|
134
|
+
if (typeof file !== 'string' || file === '') return undefined;
|
|
135
|
+
try {
|
|
136
|
+
return pathToFileURL(file).href;
|
|
137
|
+
} catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
114
142
|
/**
|
|
115
143
|
* Where a run spent its time.
|
|
116
144
|
*
|
package/src/picture/capture.js
CHANGED
|
@@ -53,7 +53,8 @@ const KNOWN_KEYS = new Set([...ACTION_ORDER, 'text', 'note']);
|
|
|
53
53
|
* }} settings
|
|
54
54
|
* @param {{fixturesDir: string, record?: boolean, timeoutMs?: number, thumbnail?: boolean}} ctx
|
|
55
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
|
|
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.
|
|
57
58
|
* @returns {Promise<import('../types.js').CaptureReport & {
|
|
58
59
|
* masks: import('../types.js').MaskRect[],
|
|
59
60
|
* timings: {steps: number, prepare: number, settle: number},
|
|
@@ -134,7 +135,12 @@ export async function captureScreen(page, screen, settings, ctx) {
|
|
|
134
135
|
spent.settle = since(startedSettle, clock());
|
|
135
136
|
|
|
136
137
|
const rects = await resolveMasks(page, settings.masks ?? [], { deviceScaleFactor });
|
|
137
|
-
|
|
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;
|
|
138
144
|
const size = pngSize(png);
|
|
139
145
|
|
|
140
146
|
// The picture is taken; now put the app back if this screen asked us to.
|
|
@@ -164,7 +170,7 @@ export async function captureScreen(page, screen, settings, ctx) {
|
|
|
164
170
|
spent,
|
|
165
171
|
};
|
|
166
172
|
if (ctx.thumbnail === true) {
|
|
167
|
-
const small = await thumbnailOf(png);
|
|
173
|
+
const small = await thumbnailOf(painted ? painted.image : png);
|
|
168
174
|
if (small) report.thumbnail = small;
|
|
169
175
|
}
|
|
170
176
|
return report;
|
|
@@ -190,14 +196,19 @@ function since(from, to) {
|
|
|
190
196
|
|
|
191
197
|
/**
|
|
192
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
|
+
*
|
|
193
204
|
* @param {Buffer} buffer
|
|
194
205
|
* @param {import('../types.js').MaskRect[]} rects
|
|
195
|
-
* @returns {Buffer}
|
|
206
|
+
* @returns {{png: Buffer, image: import('pngjs').PNG}}
|
|
196
207
|
*/
|
|
197
208
|
function paintInto(buffer, rects) {
|
|
198
209
|
const image = PNG.sync.read(buffer);
|
|
199
210
|
paintMasks(image, rects);
|
|
200
|
-
return PNG.sync.write(image);
|
|
211
|
+
return { png: PNG.sync.write(image), image };
|
|
201
212
|
}
|
|
202
213
|
|
|
203
214
|
/**
|
package/src/picture/run.js
CHANGED
|
@@ -21,7 +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
|
+
import { emitEvent, fileUrl } from '../core/events.js';
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* A picture result plus the one extra fact the flake register needs: whether it
|
|
@@ -30,16 +30,26 @@ import { emitEvent } from '../core/events.js';
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
33
|
+
* What a watcher is shown of one screen, filled in as it is worked on.
|
|
34
34
|
*
|
|
35
|
-
*
|
|
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
|
|
36
43
|
* read back by `approve` and `status`, and a base64 picture in there would bloat
|
|
37
44
|
* every saved run for the sake of a panel that was only open for a minute.
|
|
38
45
|
*
|
|
39
46
|
* @typedef {object} Thumbs
|
|
40
|
-
* @property {string} [shot]
|
|
41
|
-
* @property {string} [approved]
|
|
42
|
-
* @property {string} [diff]
|
|
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.
|
|
43
53
|
*/
|
|
44
54
|
|
|
45
55
|
/**
|
|
@@ -153,6 +163,12 @@ function emitDone(events, result, thumbs) {
|
|
|
153
163
|
thumbnail: thumbs.shot,
|
|
154
164
|
approvedThumb: thumbs.approved,
|
|
155
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,
|
|
156
172
|
});
|
|
157
173
|
}
|
|
158
174
|
|
|
@@ -194,6 +210,11 @@ async function runOneScreen(project, page, screen, ctx) {
|
|
|
194
210
|
const approved = await readApproved(paths, screen.name);
|
|
195
211
|
const platformNote = platformWarning(approved?.meta?.platform, ctx.here);
|
|
196
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
|
+
|
|
197
218
|
/** @type {string[]} */
|
|
198
219
|
let consoleErrors = [];
|
|
199
220
|
/** @type {{width: number, height: number}|undefined} */
|
|
@@ -225,19 +246,28 @@ async function runOneScreen(project, page, screen, ctx) {
|
|
|
225
246
|
consoleErrors = shot.consoleErrors;
|
|
226
247
|
size = { width: shot.width, height: shot.height };
|
|
227
248
|
|
|
228
|
-
|
|
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
|
-
|
|
236
|
-
await writeResult(paths, screen.name, shot.png, {
|
|
249
|
+
const shotFile = await writeResult(paths, screen.name, shot.png, {
|
|
237
250
|
deviceScaleFactor: settings.viewport.deviceScaleFactor,
|
|
238
251
|
describe: screen.describe,
|
|
239
252
|
});
|
|
240
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
|
+
|
|
241
271
|
if (!approved) break;
|
|
242
272
|
|
|
243
273
|
const stopCompare = ctx.timings?.mark('compare');
|
|
@@ -287,6 +317,8 @@ async function runOneScreen(project, page, screen, ctx) {
|
|
|
287
317
|
describe: screen.describe,
|
|
288
318
|
deviceScaleFactor: settings.viewport.deviceScaleFactor,
|
|
289
319
|
});
|
|
320
|
+
// It exists now, because that call is what wrote it.
|
|
321
|
+
if (ctx.thumbs) ctx.thumbs.approvedFile = fileUrl(approvedPaths.png);
|
|
290
322
|
return finish(ctx, {
|
|
291
323
|
...base,
|
|
292
324
|
status: 'new',
|
|
@@ -340,11 +372,23 @@ async function runOneScreen(project, page, screen, ctx) {
|
|
|
340
372
|
|
|
341
373
|
/** @type {string|undefined} */
|
|
342
374
|
let diffPath;
|
|
343
|
-
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
|
+
}
|
|
344
382
|
|
|
345
383
|
// Only for a screen that actually moved, and only when somebody is watching:
|
|
346
384
|
// this is the one moment a person wants the approved picture and the difference
|
|
347
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.
|
|
348
392
|
if (ctx.thumbnail === true && ctx.thumbs) {
|
|
349
393
|
ctx.thumbs.approved = (await thumbnailOf(approved.png)) ?? undefined;
|
|
350
394
|
if (compare.diffPng) ctx.thumbs.diff = (await thumbnailOf(compare.diffPng)) ?? undefined;
|
package/src/picture/store.js
CHANGED
|
@@ -17,11 +17,16 @@ import { platformTag } from '../drive/find.js';
|
|
|
17
17
|
import { pngSize } from './capture.js';
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* How wide a
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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.
|
|
23
28
|
*/
|
|
24
|
-
const
|
|
29
|
+
const PREVIEW_WIDTH = 900;
|
|
25
30
|
|
|
26
31
|
/**
|
|
27
32
|
* A small note written beside a result picture so `approve` knows things the PNG
|
|
@@ -31,39 +36,93 @@ const THUMBNAIL_WIDTH = 320;
|
|
|
31
36
|
* @property {string} [describe]
|
|
32
37
|
*/
|
|
33
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
|
+
|
|
34
46
|
/**
|
|
35
47
|
* A small copy of a picture, ready to drop straight into a page.
|
|
36
48
|
*
|
|
37
|
-
* This is
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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.
|
|
41
53
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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.
|
|
45
57
|
*
|
|
46
|
-
*
|
|
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.
|
|
47
70
|
* @returns {Promise<string|null>} a data: address for an <img>, or null if it cannot be read
|
|
48
71
|
*/
|
|
49
|
-
export async function thumbnailOf(
|
|
72
|
+
export async function thumbnailOf(source) {
|
|
50
73
|
try {
|
|
51
|
-
|
|
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;
|
|
52
89
|
if (!(full.width > 0) || !(full.height > 0)) return null;
|
|
53
|
-
const
|
|
54
|
-
const width = Math.max(1, Math.round(full.width /
|
|
55
|
-
const height = Math.max(1, Math.round(full.height /
|
|
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));
|
|
56
93
|
|
|
57
94
|
const small = new PNG({ width, height });
|
|
95
|
+
const from = full.data;
|
|
96
|
+
const into = small.data;
|
|
97
|
+
|
|
58
98
|
for (let y = 0; y < height; y += 1) {
|
|
59
|
-
const
|
|
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)));
|
|
60
102
|
for (let x = 0; x < width; x += 1) {
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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;
|
|
67
126
|
}
|
|
68
127
|
}
|
|
69
128
|
return `data:image/png;base64,${PNG.sync.write(small).toString('base64')}`;
|
package/src/types.js
CHANGED
|
@@ -495,7 +495,14 @@ export {};
|
|
|
495
495
|
* @property {string} [message]
|
|
496
496
|
* @property {string} [failedAt] The plain-language expectation that failed.
|
|
497
497
|
* @property {string} [because] Why a guard exists.
|
|
498
|
-
* @property {string} [thumbnail] A small JPEG as a data: URI,
|
|
498
|
+
* @property {string} [thumbnail] A small JPEG as a data: URI — an instant preview, shown
|
|
499
|
+
* while the real file is still being written.
|
|
500
|
+
* @property {string} [shotFile] file:// URL of the FULL-RESOLUTION picture just taken.
|
|
501
|
+
* The watch panel is itself a local page, so it can load the
|
|
502
|
+
* real PNG off disk and zoom into actual pixels — a scaled-up
|
|
503
|
+
* thumbnail is unreadable, which is the whole point of looking.
|
|
504
|
+
* @property {string} [approvedFile] file:// URL of the approved picture.
|
|
505
|
+
* @property {string} [diffFile] file:// URL of the difference image.
|
|
499
506
|
* @property {string} [approvedThumb]
|
|
500
507
|
* @property {string} [diffThumb]
|
|
501
508
|
* @property {RunSummary} [summary] Only on 'run:done'.
|
package/src/walk/run.js
CHANGED
|
@@ -16,7 +16,7 @@ import { settingsForScreen } from '../core/config.js';
|
|
|
16
16
|
import { gitInfo } from '../core/git.js';
|
|
17
17
|
import { StaysFixedError, messageOf } from '../core/errors.js';
|
|
18
18
|
import { safeName } from '../core/paths.js';
|
|
19
|
-
import { emitEvent } from '../core/events.js';
|
|
19
|
+
import { emitEvent, fileUrl } from '../core/events.js';
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Progress handed to `opts.onStep`, once when a step starts and once when it is done.
|
|
@@ -87,7 +87,7 @@ export async function walkApp(project, app, opts = {}) {
|
|
|
87
87
|
total: chosen.length,
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
/** @type {{shot?: string}} */
|
|
90
|
+
/** @type {{shot?: string, shotFile?: string}} */
|
|
91
91
|
const thumbs = {};
|
|
92
92
|
const step = await walkOneStep(page, screen, {
|
|
93
93
|
index,
|
|
@@ -112,6 +112,9 @@ export async function walkApp(project, app, opts = {}) {
|
|
|
112
112
|
durationMs: step.durationMs,
|
|
113
113
|
message: stepMessage(step),
|
|
114
114
|
thumbnail: thumbs.shot,
|
|
115
|
+
// The real photo of this step, at full resolution. A walk has nothing to compare
|
|
116
|
+
// against, so there is no approved picture and no difference to point at.
|
|
117
|
+
shotFile: thumbs.shotFile,
|
|
115
118
|
});
|
|
116
119
|
|
|
117
120
|
opts.onStep?.({
|
|
@@ -151,7 +154,7 @@ export async function walkApp(project, app, opts = {}) {
|
|
|
151
154
|
* record: boolean,
|
|
152
155
|
* events?: import('../types.js').RunEvents,
|
|
153
156
|
* thumbnail?: boolean,
|
|
154
|
-
* thumbs?: {shot?: string},
|
|
157
|
+
* thumbs?: {shot?: string, shotFile?: string},
|
|
155
158
|
* timings?: ReturnType<typeof import('../core/events.js').makeTimings>,
|
|
156
159
|
* }} ctx
|
|
157
160
|
* @returns {Promise<import('../types.js').WalkStep>}
|
|
@@ -180,10 +183,7 @@ async function walkOneStep(page, screen, ctx) {
|
|
|
180
183
|
await fsp.writeFile(target, shot.png);
|
|
181
184
|
file = target;
|
|
182
185
|
consoleErrors = shot.consoleErrors;
|
|
183
|
-
|
|
184
|
-
if (ctx.thumbs) ctx.thumbs.shot = shot.thumbnail;
|
|
185
|
-
emitEvent(ctx.events, { type: 'screen:shot', name: screen.name, thumbnail: shot.thumbnail });
|
|
186
|
-
}
|
|
186
|
+
announceShot(ctx, screen.name, target, shot.thumbnail);
|
|
187
187
|
} catch (cause) {
|
|
188
188
|
error = messageOf(cause);
|
|
189
189
|
consoleErrors = readConsole(page);
|
|
@@ -193,6 +193,10 @@ async function walkOneStep(page, screen, ctx) {
|
|
|
193
193
|
try {
|
|
194
194
|
await fsp.writeFile(target, await page.shoot());
|
|
195
195
|
file = target;
|
|
196
|
+
// No preview for this one: the picture was taken by hand after the capture
|
|
197
|
+
// broke, so nothing shrank it. The panel loads the real file instead, which is
|
|
198
|
+
// the one a person needs to see anyway.
|
|
199
|
+
announceShot(ctx, screen.name, target, undefined);
|
|
196
200
|
} catch {
|
|
197
201
|
file = '';
|
|
198
202
|
}
|
|
@@ -217,6 +221,29 @@ async function walkOneStep(page, screen, ctx) {
|
|
|
217
221
|
return step;
|
|
218
222
|
}
|
|
219
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Tell anyone watching that this step has a picture now.
|
|
226
|
+
*
|
|
227
|
+
* Called only once the photo is ON DISK. The watch panel loads the real file so a
|
|
228
|
+
* person can zoom into true pixels, and an <img> pointed at a file that does not exist
|
|
229
|
+
* yet draws a torn page and never tries again — so the address is never announced early.
|
|
230
|
+
* The shrunken preview rides along on the same event and covers the moment the real
|
|
231
|
+
* file takes to load.
|
|
232
|
+
*
|
|
233
|
+
* @param {{events?: import('../types.js').RunEvents, thumbs?: {shot?: string, shotFile?: string}}} ctx
|
|
234
|
+
* @param {string} name
|
|
235
|
+
* @param {string} file Absolute path to the photo, already written.
|
|
236
|
+
* @param {string|undefined} thumbnail
|
|
237
|
+
* @returns {void}
|
|
238
|
+
*/
|
|
239
|
+
function announceShot(ctx, name, file, thumbnail) {
|
|
240
|
+
if (ctx.thumbs) {
|
|
241
|
+
if (thumbnail) ctx.thumbs.shot = thumbnail;
|
|
242
|
+
ctx.thumbs.shotFile = fileUrl(file);
|
|
243
|
+
}
|
|
244
|
+
emitEvent(ctx.events, { type: 'screen:shot', name, thumbnail, shotFile: fileUrl(file) });
|
|
245
|
+
}
|
|
246
|
+
|
|
220
247
|
/**
|
|
221
248
|
* How many screens a walk is about to visit.
|
|
222
249
|
*
|