staysfixed 0.1.1 → 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/src/drive/page.js CHANGED
@@ -15,6 +15,7 @@ import { Buffer } from 'node:buffer';
15
15
  import { setTimeout as sleep } from 'node:timers/promises';
16
16
  import { StaysFixedError } from '../core/errors.js';
17
17
  import { detail } from '../core/log.js';
18
+ import { waitForQuietDom } from './launch.js';
18
19
 
19
20
  /**
20
21
  * Keys `press()` understands by name. Anything else is sent as literal text.
@@ -50,6 +51,18 @@ const MAX_CONSOLE_ERRORS = 50;
50
51
  /** Chrome cannot paint a picture wider or taller than this. */
51
52
  const MAX_CAPTURE_SIDE = 16384;
52
53
 
54
+ /**
55
+ * After a click, how long the page has to stay unchanged before we call it finished,
56
+ * and how long we are prepared to wait for that.
57
+ *
58
+ * Both are deliberately small. This is here to replace the `{ wait: 400 }` people write
59
+ * because they are guessing, so it has to be quicker than the guess on a page that has
60
+ * already finished and it must never become the slow part of a run on a page that fidgets
61
+ * forever — the settle loop is the real guarantee, not this.
62
+ */
63
+ const CLICK_QUIET_MS = 120;
64
+ const CLICK_QUIET_CAP_MS = 1500;
65
+
53
66
  /**
54
67
  * Seconds, written the way a person says them: 15, 1.5, 0.5.
55
68
  * @param {number} ms
@@ -518,8 +531,17 @@ export async function createPage(cdp, opts) {
518
531
  }
519
532
 
520
533
  /**
534
+ * Click something, and by default wait for the page to finish reacting.
535
+ *
536
+ * `settle` is the reason a screen recipe does not need `{ wait: 400 }` after a click.
537
+ * A hand-written wait is always a guess: too short and the picture catches the screen
538
+ * half-drawn, too long and every run pays for it forever. Instead we watch the page
539
+ * itself and carry on the moment it stops changing — usually in a fraction of the time
540
+ * somebody would have guessed. Pass `{ settle: false }` for a click that deliberately
541
+ * starts something you want to photograph while it is still happening.
542
+ *
521
543
  * @param {string} selector
522
- * @param {{timeoutMs?: number}} [o]
544
+ * @param {{timeoutMs?: number, settle?: boolean}} [o]
523
545
  * @returns {Promise<void>}
524
546
  */
525
547
  async function click(selector, o) {
@@ -543,7 +565,10 @@ export async function createPage(cdp, opts) {
543
565
  await send('Input.dispatchMouseEvent', { ...base, type: 'mouseMoved', buttons: 0 });
544
566
  await send('Input.dispatchMouseEvent', { ...base, type: 'mousePressed', buttons: 1 });
545
567
  await send('Input.dispatchMouseEvent', { ...base, type: 'mouseReleased', buttons: 0 });
546
- if (await heard()) return;
568
+ if (await heard()) {
569
+ await settleAfterClick(o);
570
+ return;
571
+ }
547
572
  detail(`click on ${selector} was not delivered (try ${attempt} of ${attempts})`);
548
573
  await sleep(120);
549
574
  }
@@ -556,6 +581,25 @@ export async function createPage(cdp, opts) {
556
581
  if (!done) {
557
582
  throw new StaysFixedError(`Could not click "${selector}" — it disappeared while I was trying.`);
558
583
  }
584
+ await settleAfterClick(o);
585
+ }
586
+
587
+ /**
588
+ * Give whatever the click started a chance to finish.
589
+ *
590
+ * `waitForQuietDom` wants the whole launched app because that is what everything else
591
+ * hands it; all it ever touches is `page.evaluate`, and here the page is the one being
592
+ * built, so it is handed exactly that.
593
+ *
594
+ * @param {{settle?: boolean}} [o]
595
+ * @returns {Promise<void>}
596
+ */
597
+ async function settleAfterClick(o) {
598
+ if (o?.settle === false) return;
599
+ const asApp = /** @type {import('../types.js').LaunchedApp} */ (
600
+ /** @type {unknown} */ ({ page: { evaluate } })
601
+ );
602
+ await waitForQuietDom(asApp, { quietMs: CLICK_QUIET_MS, timeoutMs: CLICK_QUIET_CAP_MS });
559
603
  }
560
604
 
561
605
  /**
@@ -845,7 +889,16 @@ export async function createPage(cdp, opts) {
845
889
  }
846
890
 
847
891
  /**
848
- * @param {import('../types.js').CaptureOptions} [captureOpts]
892
+ * Take a picture of the window.
893
+ *
894
+ * `format: 'jpeg'` with a `quality` exists for one caller: the settle loop, which
895
+ * shoots the same screen over and over only to ask whether anything moved. Those frames
896
+ * are thrown away, never compared against an approved picture and never written to
897
+ * disk, so a cheap lossy encode is the right tool — it costs a fraction of a full-size
898
+ * retina PNG and two JPEGs of one unchanged frame are byte-for-byte identical, which is
899
+ * the whole question. Every kept picture is a PNG, which is the default.
900
+ *
901
+ * @param {import('../types.js').CaptureOptions & {format?: 'png'|'jpeg', quality?: number}} [captureOpts]
849
902
  * @returns {Promise<Buffer>}
850
903
  */
851
904
  async function shoot(captureOpts) {
@@ -891,12 +944,15 @@ export async function createPage(cdp, opts) {
891
944
  };
892
945
  }
893
946
 
947
+ const jpeg = o.format === 'jpeg';
894
948
  /** @type {Record<string, unknown>} */
895
949
  const params = {
896
- format: 'png',
950
+ format: jpeg ? 'jpeg' : 'png',
897
951
  captureBeyondViewport: Boolean(o.fullPage),
898
952
  fromSurface: true,
899
953
  };
954
+ // Chrome ignores quality on a PNG, and refuses a value outside 0..100.
955
+ if (jpeg) params.quality = Math.max(0, Math.min(100, Math.round(o.quality ?? 50)));
900
956
  if (clip) params.clip = clip;
901
957
 
902
958
  const res = await send('Page.captureScreenshot', params);
@@ -36,7 +36,9 @@ export function fontsScript() {
36
36
  // exactly what made take 1 of twenty differ from takes 2 to 20: one picture in
37
37
  // Helvetica, nineteen in the web font.
38
38
  //
39
- // So we do not wait for the fonts to arrive. We ask for them.
39
+ // So we do not wait for the fonts to arrive. We ask for them. The list of promises is
40
+ // handed back rather than awaited here, because how many faces needed asking is the
41
+ // thing that decides whether there is anything left to wait for at all.
40
42
  function forceLoad() {
41
43
  var pending = [];
42
44
  faces().forEach(function (f) {
@@ -45,7 +47,7 @@ export function fontsScript() {
45
47
  else if (f.status === 'loading') pending.push(Promise.resolve(f.loaded).catch(function () {}));
46
48
  } catch (e) { /* a face the browser refuses to load is not our problem */ }
47
49
  });
48
- return Promise.all(pending);
50
+ return pending;
49
51
  }
50
52
 
51
53
  window.__staysfixed_fontsReady = function (ms) {
@@ -53,21 +55,37 @@ export function fontsScript() {
53
55
  if (!document.fonts) return Promise.resolve('no-font-api');
54
56
 
55
57
  var ready = (async function () {
58
+ var asked = 0;
56
59
  // Loading one face can pull in another (a bold weight referenced by a rule that
57
60
  // only matched once the first face changed the layout), so go round a few times
58
61
  // until nothing is left unloaded.
62
+ //
63
+ // A round that asks for nothing is the finish line, and it is worth leaving on it
64
+ // rather than going round again: if no face is unloaded or loading then every face
65
+ // is in, document.fonts.ready would resolve on the spot, and asking it anyway costs
66
+ // a whole trip through the page for an answer we already have. Most screens of most
67
+ // apps land here on the first round, and every screen after the first one does,
68
+ // because the fonts arrived for the screen before it.
59
69
  for (var round = 0; round < 5; round++) {
60
- await forceLoad();
70
+ var pending = forceLoad();
71
+ // The one case where "nothing to ask for" is not the finish line: a document that
72
+ // is still parsing has not met its @font-face rules yet, so the face list can be
73
+ // empty and still grow. There, wait the old way at least once.
74
+ if (pending.length === 0 && (round > 0 || document.readyState === 'complete')) break;
75
+ asked += pending.length;
76
+ await Promise.all(pending);
61
77
  try { await document.fonts.ready; } catch (e) { /* ignore */ }
62
- var waiting = faces().some(function (f) { return f.status !== 'loaded'; });
63
- if (!waiting && document.fonts.status === 'loaded') break;
64
78
  }
65
- // Two frames, so the reflow the last face caused has actually been painted
66
- // rather than merely scheduled.
67
- await new Promise(function (r) {
68
- if (typeof requestAnimationFrame !== 'function') return r(undefined);
69
- requestAnimationFrame(function () { requestAnimationFrame(function () { r(undefined); }); });
70
- });
79
+ // Two frames, so the reflow the last face caused has actually been painted rather
80
+ // than merely scheduled. No face needed loading means no face changed the layout,
81
+ // so there is no reflow to wait for — and the shutter code waits two frames of its
82
+ // own after clearing focus and scroll, which is the real paint barrier.
83
+ if (asked > 0) {
84
+ await new Promise(function (r) {
85
+ if (typeof requestAnimationFrame !== 'function') return r(undefined);
86
+ requestAnimationFrame(function () { requestAnimationFrame(function () { r(undefined); }); });
87
+ });
88
+ }
71
89
  return document.fonts.status || 'loaded';
72
90
  })();
73
91
 
@@ -163,11 +181,25 @@ export async function waitForFonts(page, opts = {}) {
163
181
  export async function waitForImages(page, opts = {}) {
164
182
  const timeoutMs = opts.timeoutMs ?? 10000;
165
183
 
166
- // Walking every element to read computed styles is the expensive part, so it is capped.
167
- // Background images below the first few thousand elements are almost always off-screen.
184
+ // The cheap questions are asked first and the expensive one is earned.
185
+ //
186
+ // The expensive one is background images. There is no list of them anywhere, so the
187
+ // only honest way to find them is to read the computed style of every element — and on
188
+ // almost every app that walk costs more than the whole rest of the shutter and comes
189
+ // back with nothing, because no rule on the page ever says url(). So the stylesheets
190
+ // are asked first, in one pass that stops at the first sign of an image, and elements
191
+ // are only walked when the answer is yes. When they are walked, it is the ones on
192
+ // screen: an image below the fold is not what delays this paint.
193
+ //
194
+ // Being less than exhaustive here cannot produce a wrong picture. This is a head start,
195
+ // not the guarantee — the guarantee is the settle loop, which refuses to accept any
196
+ // photograph until two frames in a row are the same frame. An image nobody waited for
197
+ // costs one more round of settling and nothing else.
168
198
  const source = `(async () => {
169
199
  var LIMIT = ${timeoutMs};
170
200
  var MAX_ELEMENTS = 4000;
201
+ var MAX_STYLES = 1200;
202
+ var MAX_RULES = 4000;
171
203
  var waits = [];
172
204
 
173
205
  var imgs = document.images ? Array.prototype.slice.call(document.images) : [];
@@ -181,34 +213,6 @@ export async function waitForImages(page, opts = {}) {
181
213
  })(imgs[i]);
182
214
  }
183
215
 
184
- var urls = {};
185
- var all = document.querySelectorAll('*');
186
- var count = Math.min(all.length, MAX_ELEMENTS);
187
- for (var e = 0; e < count; e++) {
188
- var bg = '';
189
- try { bg = getComputedStyle(all[e]).backgroundImage; } catch (err) { bg = ''; }
190
- if (!bg || bg === 'none' || bg.indexOf('url(') === -1) continue;
191
- var chunks = bg.split('url(');
192
- for (var c = 1; c < chunks.length; c++) {
193
- var end = chunks[c].indexOf(')');
194
- if (end < 0) continue;
195
- var u = chunks[c].slice(0, end).trim();
196
- var first = u.charAt(0);
197
- var last = u.charAt(u.length - 1);
198
- if ((first === '"' && last === '"') || (first === "'" && last === "'")) u = u.slice(1, -1);
199
- // data: URLs are already here by definition; nothing to wait for.
200
- if (u && u.slice(0, 5) !== 'data:') urls[u] = true;
201
- }
202
- }
203
- Object.keys(urls).forEach(function (u) {
204
- waits.push(new Promise(function (r) {
205
- var probe = new Image();
206
- probe.onload = r;
207
- probe.onerror = r;
208
- probe.src = u;
209
- }));
210
- });
211
-
212
216
  // A stylesheet still in flight repaints the whole page the instant it applies — the
213
217
  // worst possible moment for that is one millisecond after the shutter.
214
218
  var links = document.querySelectorAll('link[rel~="stylesheet"]');
@@ -226,6 +230,92 @@ export async function waitForImages(page, opts = {}) {
226
230
  })(links[l]);
227
231
  }
228
232
 
233
+ // Does any style on this page mention an image at all? One pass, stopping early, over
234
+ // the declarations themselves rather than their serialised text.
235
+ var budget = MAX_RULES;
236
+ function mentionsUrl(style) {
237
+ if (!style) return false;
238
+ var props = ['backgroundImage', 'borderImageSource', 'maskImage', 'webkitMaskImage', 'listStyleImage', 'content'];
239
+ for (var p = 0; p < props.length; p++) {
240
+ var v = '';
241
+ try { v = style[props[p]]; } catch (err) { v = ''; }
242
+ if (v && String(v).indexOf('url(') !== -1) return true;
243
+ }
244
+ return false;
245
+ }
246
+ function scanRules(rules) {
247
+ for (var i = 0; i < rules.length; i++) {
248
+ if (budget <= 0) return true;
249
+ budget--;
250
+ var rule = rules[i];
251
+ if (mentionsUrl(rule.style)) return true;
252
+ // @media, @supports and @layer hold their rules inside themselves; @import holds a
253
+ // whole other stylesheet, which never appears in document.styleSheets on its own.
254
+ var inner = null;
255
+ try { inner = rule.cssRules || (rule.styleSheet && rule.styleSheet.cssRules); } catch (err) { inner = null; }
256
+ if (inner === null) return true;
257
+ if (inner && inner.length && scanRules(inner)) return true;
258
+ }
259
+ return false;
260
+ }
261
+ function anyImageInCss() {
262
+ // Inline styles are not in document.styleSheets at all, and this is one indexed query.
263
+ try { if (document.querySelector('[style*="url("]')) return true; } catch (err) { }
264
+ // Sheets a framework adopted straight onto the document are not in styleSheets.
265
+ var sheets = Array.prototype.slice.call(document.styleSheets || []);
266
+ if (document.adoptedStyleSheets) sheets = sheets.concat(Array.prototype.slice.call(document.adoptedStyleSheets));
267
+ for (var s = 0; s < sheets.length; s++) {
268
+ var rules = null;
269
+ try { rules = sheets[s].cssRules; } catch (err) { rules = null; }
270
+ // A sheet from another origin will not show its rules. Assume it could be hiding an
271
+ // image rather than pretend it cannot.
272
+ if (rules === null) return true;
273
+ if (scanRules(rules)) return true;
274
+ }
275
+ return false;
276
+ }
277
+
278
+ if (anyImageInCss()) {
279
+ var urls = {};
280
+ var vw = window.innerWidth || 0;
281
+ var vh = window.innerHeight || 0;
282
+ var all = document.querySelectorAll('*');
283
+ var count = Math.min(all.length, MAX_ELEMENTS);
284
+ var looked = 0;
285
+ for (var e = 0; e < count && looked < MAX_STYLES; e++) {
286
+ var node = all[e];
287
+ var box = null;
288
+ try { box = node.getBoundingClientRect(); } catch (err) { box = null; }
289
+ if (!box || box.width <= 0 || box.height <= 0) continue;
290
+ // On screen, or one screenful below it — far enough to cover a full-page picture's
291
+ // first fold without reading the style of a thousand things nobody can see.
292
+ if (box.bottom < 0 || box.right < 0 || box.left > vw || box.top > vh * 2) continue;
293
+ looked++;
294
+ var bg = '';
295
+ try { bg = getComputedStyle(node).backgroundImage; } catch (err) { bg = ''; }
296
+ if (!bg || bg === 'none' || bg.indexOf('url(') === -1) continue;
297
+ var chunks = bg.split('url(');
298
+ for (var c = 1; c < chunks.length; c++) {
299
+ var end = chunks[c].indexOf(')');
300
+ if (end < 0) continue;
301
+ var u = chunks[c].slice(0, end).trim();
302
+ var first = u.charAt(0);
303
+ var last = u.charAt(u.length - 1);
304
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) u = u.slice(1, -1);
305
+ // data: URLs are already here by definition; nothing to wait for.
306
+ if (u && u.slice(0, 5) !== 'data:') urls[u] = true;
307
+ }
308
+ }
309
+ Object.keys(urls).forEach(function (u) {
310
+ waits.push(new Promise(function (r) {
311
+ var probe = new Image();
312
+ probe.onload = r;
313
+ probe.onerror = r;
314
+ probe.src = u;
315
+ }));
316
+ });
317
+ }
318
+
229
319
  if (waits.length === 0) return 0;
230
320
  await Promise.race([
231
321
  Promise.all(waits),
@@ -7,6 +7,12 @@
7
7
  *
8
8
  * The rule is simple and it is the whole reason picture checks can be trusted: take the
9
9
  * photo, take it again, and only accept it once two photos in a row agree.
10
+ *
11
+ * Two photos of a retina window is a second of every screen's time, and almost all of it
12
+ * is spent turning pixels into PNG and sending them down the wire — for pictures nobody
13
+ * ever looks at, whose only job was to answer "did anything move". So a caller may hand
14
+ * in a cheap `probe` for that question and keep the expensive capture for the one picture
15
+ * that is actually kept. See `settleByProbe` for why a lossy probe is sound.
10
16
  */
11
17
 
12
18
  import { PNG } from 'pngjs';
@@ -15,7 +21,16 @@ import { detail } from '../core/log.js';
15
21
 
16
22
  /**
17
23
  * @param {import('../types.js').PageHandle} page
18
- * @param {{frames?: number, intervalMs?: number, timeoutMs?: number, maxDriftPixels?: number, capture: () => Promise<Buffer>}} opts
24
+ * @param {{
25
+ * frames?: number,
26
+ * intervalMs?: number,
27
+ * timeoutMs?: number,
28
+ * maxDriftPixels?: number,
29
+ * capture: () => Promise<Buffer>,
30
+ * probe?: () => Promise<Buffer>,
31
+ * }} opts
32
+ * `capture` takes the picture that is kept. `probe`, when given, is a cheap shot used
33
+ * only to decide whether the page has stopped moving — never the picture itself.
19
34
  * @returns {Promise<{report: import('../types.js').SettleReport, png: Buffer}>}
20
35
  */
21
36
  export async function settle(page, opts) {
@@ -25,11 +40,20 @@ export async function settle(page, opts) {
25
40
  const maxDriftPixels = Math.max(0, opts.maxDriftPixels ?? 0);
26
41
  const capture = opts.capture;
27
42
 
43
+ // A probe can only ever answer yes or no: it is not the picture, so counting how many
44
+ // pixels of it moved would be counting the wrong pixels. A project that allows a few
45
+ // drifting pixels is asking for a count, so it goes the long way round on real photos.
46
+ const probe = maxDriftPixels === 0 ? opts.probe : undefined;
47
+
28
48
  // Host-side Date.now, not the page's — the page's clock is frozen on purpose.
29
49
  const started = Date.now();
30
50
 
31
51
  await waitUntilQuiet(page, Math.min(timeoutMs, 5000));
32
52
 
53
+ if (probe) {
54
+ return await settleByProbe({ probe, capture, frames, intervalMs, timeoutMs, started });
55
+ }
56
+
33
57
  /** @type {Buffer|null} */
34
58
  let previous = null;
35
59
  /** @type {unknown} */
@@ -86,6 +110,146 @@ export async function settle(page, opts) {
86
110
  };
87
111
  }
88
112
 
113
+ /**
114
+ * The same rule, asked cheaply.
115
+ *
116
+ * WHY a lossy probe is safe. The probe is never the picture and never reaches disk; the
117
+ * only thing asked of it is "are these two frames the same frame". A JPEG encoder is
118
+ * deterministic, so two JPEGs of one unchanged frame are byte-for-byte identical, and two
119
+ * JPEGs that differ can only differ because the pixels underneath them did. That is
120
+ * exactly the question, and it is answered for about half the cost of a full-size retina
121
+ * PNG — and the picture that used to be thrown away is not taken at all.
122
+ *
123
+ * WHY the photograph is taken in the middle. The old loop shot the screen, waited, shot it
124
+ * again, and kept the second one. This one probes, takes the real photograph, and probes
125
+ * once more: the kept picture sits BETWEEN two frames that agree, instead of being the
126
+ * later of them. If anything moved while the shutter was open, the probe after it
127
+ * disagrees and the whole thing goes round again — which the old loop could not notice,
128
+ * because it kept a picture nothing had checked since.
129
+ *
130
+ * WHY there is no pause between agreeing frames. The old quarter-second sat between the
131
+ * two photographs so they could not both land inside one painted frame. Taking the real
132
+ * photograph between the probes already separates them by longer than a screenshot takes,
133
+ * so the separation is still bought and no longer paid for twice. The pause is kept for
134
+ * where it earns its keep: after two probes have actually disagreed, which is the only
135
+ * time a page is worth waiting for.
136
+ *
137
+ * @param {{
138
+ * probe: () => Promise<Buffer>,
139
+ * capture: () => Promise<Buffer>,
140
+ * frames: number,
141
+ * intervalMs: number,
142
+ * timeoutMs: number,
143
+ * started: number,
144
+ * }} o
145
+ * @returns {Promise<{report: import('../types.js').SettleReport, png: Buffer}>}
146
+ */
147
+ async function settleByProbe(o) {
148
+ // One frame is all this project asks for, so there is nothing to compare it against.
149
+ if (o.frames <= 1) {
150
+ const only = await o.capture();
151
+ return {
152
+ report: { settled: true, attempts: 0, lastDriftPixels: 0, waitedMs: Date.now() - o.started },
153
+ png: only,
154
+ };
155
+ }
156
+
157
+ // The photograph itself stands in for the last frame, so one fewer probe is needed than
158
+ // the number of agreeing frames the project asked for.
159
+ const probesNeeded = o.frames - 1;
160
+
161
+ /** @type {Buffer|null} */
162
+ let previous = null;
163
+ /** @type {Buffer|null} */
164
+ let taken = null;
165
+ /** @type {unknown} */
166
+ let lastError = null;
167
+ let agreed = 0;
168
+ let attempts = 0;
169
+
170
+ for (;;) {
171
+ /** @type {Buffer|null} */
172
+ let shot = null;
173
+ try {
174
+ shot = await o.probe();
175
+ attempts += 1;
176
+ } catch (e) {
177
+ lastError = e;
178
+ }
179
+
180
+ // Only a pair of frames that disagreed earns a pause before the next look.
181
+ let moved = true;
182
+ if (shot) {
183
+ moved = previous ? !sameBytes(previous, shot) : false;
184
+ agreed = moved ? 1 : agreed + 1;
185
+ previous = shot;
186
+ }
187
+
188
+ if (shot && !moved && agreed >= probesNeeded) {
189
+ const png = await o.capture();
190
+ /** @type {Buffer|null} */
191
+ let after = null;
192
+ try {
193
+ after = await o.probe();
194
+ attempts += 1;
195
+ } catch (e) {
196
+ // The window went away right after the shutter. The picture is already in hand and
197
+ // there is nothing left that could contradict it.
198
+ lastError = e;
199
+ }
200
+ // `shot` is the frame this picture was promised to match; it is also `previous`.
201
+ if (!after || sameBytes(shot, after)) {
202
+ return {
203
+ report: { settled: true, attempts, lastDriftPixels: 0, waitedMs: Date.now() - o.started },
204
+ png,
205
+ };
206
+ }
207
+ // It moved while the shutter was open — something the old loop could not notice,
208
+ // because it kept the last photograph it took rather than one it had checked
209
+ // afterwards. Hold on to it only as a last resort and start counting again.
210
+ taken = png;
211
+ previous = after;
212
+ agreed = 1;
213
+ moved = true;
214
+ }
215
+
216
+ if (Date.now() - o.started >= o.timeoutMs) break;
217
+ if (moved && o.intervalMs > 0) await sleep(o.intervalMs);
218
+ }
219
+
220
+ if (!taken) {
221
+ try {
222
+ taken = await o.capture();
223
+ } catch (e) {
224
+ lastError = e;
225
+ }
226
+ }
227
+ if (!taken) {
228
+ if (lastError instanceof Error) throw lastError;
229
+ throw new StaysFixedError('I could not take a picture of this screen at all.', {
230
+ hint: 'The window may have closed, or the app may have crashed mid-run.',
231
+ });
232
+ }
233
+
234
+ detail('settle: gave up after', String(attempts), 'tries; the screen was still moving');
235
+ // Probes answer yes or no, so the drift is reported as "something moved" rather than a
236
+ // figure nobody measured.
237
+ return {
238
+ report: { settled: false, attempts, lastDriftPixels: 1, waitedMs: Date.now() - o.started },
239
+ png: taken,
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Whether two shots are the very same bytes.
245
+ * @param {Buffer} a
246
+ * @param {Buffer} b
247
+ * @returns {boolean}
248
+ */
249
+ function sameBytes(a, b) {
250
+ return a.length === b.length && a.equals(b);
251
+ }
252
+
89
253
  /**
90
254
  * How many pixels differ between two photos.
91
255
  *
@@ -98,7 +262,7 @@ export async function settle(page, opts) {
98
262
  * @returns {number}
99
263
  */
100
264
  function driftBetween(a, b) {
101
- if (a.length === b.length && a.equals(b)) return 0;
265
+ if (sameBytes(a, b)) return 0;
102
266
  try {
103
267
  const pa = PNG.sync.read(a);
104
268
  const pb = PNG.sync.read(b);
@@ -143,6 +307,17 @@ async function waitUntilQuiet(page, timeoutMs) {
143
307
 
144
308
  // Anything the freeze layer missed gets a short grace period to end on its own. The
145
309
  // count is bounded rather than timed because the page clock is frozen.
310
+ //
311
+ // This reads like an easy half-second to save and it is not. On a real app two
312
+ // animations sit on this list permanently — a spinner, a pulse — so the loop runs all
313
+ // twenty rounds every screen and looks like pure waiting for nothing. It was cut to stop
314
+ // as soon as the count stopped falling, and the fixture app that exists to be
315
+ // impossible to photograph immediately produced two different pictures out of twenty.
316
+ // What the loop is really buying is time for things that arrive late and announce
317
+ // themselves to nobody: the fixture sets an image source on a timer, and until that
318
+ // fires there is no request, no pending resource and nothing on any page API to wait
319
+ // for. Only elapsed time finds it. So the half-second stays; the savings in this file
320
+ // come from the photographs, which used to be full-size and are now mostly probes.
146
321
  if (typeof document.getAnimations === 'function') {
147
322
  for (var i = 0; i < 20; i++) {
148
323
  var running = 0;
package/src/guard/run.js CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { makeGuardApi, ExpectationFailed } from './api.js';
12
12
  import { resetWindow } from '../drive/launch.js';
13
+ import { emitEvent } from '../core/events.js';
13
14
 
14
15
  const DEFAULT_TIMEOUT = 30_000;
15
16
 
@@ -30,11 +31,18 @@ const DEFAULT_TIMEOUT = 30_000;
30
31
  * @param {import('../types.js').Project} project
31
32
  * @param {import('../types.js').LaunchedApp} app
32
33
  * @param {import('../types.js').Guard[]} guards
33
- * @param {{onResult?: (result: GuardRunResult) => void, retries?: number, signal?: AbortSignal}} [opts]
34
+ * @param {{
35
+ * onResult?: (result: GuardRunResult) => void,
36
+ * retries?: number,
37
+ * signal?: AbortSignal,
38
+ * events?: import('../types.js').RunEvents,
39
+ * }} [opts]
34
40
  * @returns {Promise<import('../types.js').GuardResult[]>}
35
41
  */
36
42
  export async function runGuards(project, app, guards, opts = {}) {
37
43
  const retries = Math.max(0, Math.trunc(opts.retries ?? 0));
44
+ const events = opts.events;
45
+ const total = guards.length;
38
46
 
39
47
  // Electron apps have no address to go back to; for the web the configured url
40
48
  // is the guard's starting line.
@@ -43,13 +51,25 @@ export async function runGuards(project, app, guards, opts = {}) {
43
51
  /** @type {GuardRunResult[]} */
44
52
  const results = [];
45
53
 
46
- for (const guard of guards) {
54
+ for (let i = 0; i < guards.length; i++) {
55
+ const guard = guards[i];
47
56
  // Between guards only — stopping one halfway would leave the app in a state
48
57
  // the next run cannot reason about.
49
58
  if (opts.signal?.aborted) break;
50
59
 
51
60
  const startedAt = Date.now();
52
61
 
62
+ // The story of the bug goes out with the start, not only with a failure: a
63
+ // person watching a guard run wants to know what it is protecting while it is
64
+ // still running, not after it has already gone red.
65
+ emitEvent(events, {
66
+ type: 'guard:start',
67
+ name: guard.name,
68
+ because: guard.because,
69
+ index: i + 1,
70
+ total,
71
+ });
72
+
53
73
  if (guard.skip === true) {
54
74
  /** @type {GuardRunResult} */
55
75
  const skipped = {
@@ -63,6 +83,7 @@ export async function runGuards(project, app, guards, opts = {}) {
63
83
  };
64
84
  results.push(skipped);
65
85
  opts.onResult?.(skipped);
86
+ emitGuardDone(events, skipped);
66
87
  continue;
67
88
  }
68
89
 
@@ -100,11 +121,29 @@ export async function runGuards(project, app, guards, opts = {}) {
100
121
 
101
122
  results.push(result);
102
123
  opts.onResult?.(result);
124
+ emitGuardDone(events, result);
103
125
  }
104
126
 
105
127
  return results;
106
128
  }
107
129
 
130
+ /**
131
+ * @param {import('../types.js').RunEvents|undefined} events
132
+ * @param {GuardRunResult} result
133
+ * @returns {void}
134
+ */
135
+ function emitGuardDone(events, result) {
136
+ emitEvent(events, {
137
+ type: 'guard:done',
138
+ name: result.name,
139
+ status: result.status,
140
+ durationMs: result.durationMs,
141
+ message: result.message,
142
+ failedAt: result.failedAt,
143
+ because: result.because,
144
+ });
145
+ }
146
+
108
147
  /**
109
148
  * One attempt at one guard, from a clean start.
110
149
  *