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/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 +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/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())
|
|
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
|
-
*
|
|
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);
|
package/src/freeze/fonts.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
//
|
|
167
|
-
//
|
|
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),
|
package/src/freeze/settle.js
CHANGED
|
@@ -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 {{
|
|
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
|
|
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 {{
|
|
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 (
|
|
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
|
*
|