staysfixed 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +529 -0
- package/bin/staysfixed.js +18 -0
- package/examples/guards/the-sidebar-still-collapses.js +91 -0
- package/examples/staysfixed.config.electron.js +172 -0
- package/examples/staysfixed.config.web.js +277 -0
- package/package.json +61 -0
- package/src/cli/approve.js +126 -0
- package/src/cli/check.js +73 -0
- package/src/cli/doctor.js +379 -0
- package/src/cli/flake.js +61 -0
- package/src/cli/index.js +519 -0
- package/src/cli/init.js +564 -0
- package/src/cli/mark.js +69 -0
- package/src/cli/status.js +19 -0
- package/src/cli/trace.js +73 -0
- package/src/cli/walk.js +57 -0
- package/src/core/config.js +226 -0
- package/src/core/errors.js +48 -0
- package/src/core/git.js +90 -0
- package/src/core/hash.js +32 -0
- package/src/core/history.js +173 -0
- package/src/core/log.js +144 -0
- package/src/core/paths.js +135 -0
- package/src/drive/browser.js +540 -0
- package/src/drive/cdp.js +382 -0
- package/src/drive/electron.js +326 -0
- package/src/drive/find.js +331 -0
- package/src/drive/launch.js +263 -0
- package/src/drive/page.js +1042 -0
- package/src/freeze/clock.js +213 -0
- package/src/freeze/fonts.js +243 -0
- package/src/freeze/index.js +234 -0
- package/src/freeze/mask.js +187 -0
- package/src/freeze/motion.js +206 -0
- package/src/freeze/network.js +455 -0
- package/src/freeze/random.js +87 -0
- package/src/freeze/settle.js +178 -0
- package/src/guard/api.js +197 -0
- package/src/guard/load.js +324 -0
- package/src/guard/name.js +327 -0
- package/src/guard/run.js +224 -0
- package/src/index.js +61 -0
- package/src/marker/mark.js +260 -0
- package/src/marker/trace.js +293 -0
- package/src/mcp/server.js +377 -0
- package/src/mcp/tools.js +978 -0
- package/src/picture/capture.js +276 -0
- package/src/picture/compare.js +103 -0
- package/src/picture/run.js +284 -0
- package/src/picture/store.js +208 -0
- package/src/report/console.js +540 -0
- package/src/report/html.js +579 -0
- package/src/run.js +614 -0
- package/src/types.js +471 -0
- package/src/walk/run.js +541 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Freezing time.
|
|
3
|
+
*
|
|
4
|
+
* Two layers, because neither is enough on its own:
|
|
5
|
+
*
|
|
6
|
+
* - The protocol overrides (`clockCdp`) change what the browser itself believes about
|
|
7
|
+
* time zone and locale, so `Date.prototype.toString`, `getTimezoneOffset` and the
|
|
8
|
+
* ICU formatters all agree. Page script cannot reach those.
|
|
9
|
+
* - The injected script (`clockScript`) pins the *instant*. The protocol can only do
|
|
10
|
+
* that with `Emulation.setVirtualTimePolicy`, which we refuse to use (see below).
|
|
11
|
+
*
|
|
12
|
+
* The instant is frozen but not dead: a spinner that waits 300ms still finishes,
|
|
13
|
+
* because real timers still fire. Only the *reading* of the clock is pinned, and a
|
|
14
|
+
* screen recipe can step it forward on purpose.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { StaysFixedError } from '../core/errors.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Page-side source that pins the clock. Meant to run before any app script.
|
|
21
|
+
*
|
|
22
|
+
* @param {{iso: string, timezone?: string, locale?: string, seed?: number}} opts
|
|
23
|
+
* @returns {string} JavaScript to evaluate in the page
|
|
24
|
+
*/
|
|
25
|
+
export function clockScript(opts) {
|
|
26
|
+
const base = Date.parse(opts.iso);
|
|
27
|
+
if (Number.isNaN(base)) {
|
|
28
|
+
throw new StaysFixedError(`I cannot read "${opts.iso}" as a time.`, {
|
|
29
|
+
hint: "Use an ISO timestamp like '2026-01-01T12:00:00.000Z', or set freeze.clock to false.",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const timezone = opts.timezone ?? 'UTC';
|
|
33
|
+
const locale = opts.locale ?? 'en-US';
|
|
34
|
+
|
|
35
|
+
return `(function () {
|
|
36
|
+
if (window.__staysfixed_clock) return;
|
|
37
|
+
|
|
38
|
+
var BASE = ${base};
|
|
39
|
+
var TZ = ${JSON.stringify(timezone)};
|
|
40
|
+
var LOCALE = ${JSON.stringify(locale)};
|
|
41
|
+
|
|
42
|
+
var ticks = 0; // virtual milliseconds elapsed since BASE
|
|
43
|
+
var auto = false; // when on, every animation frame moves time forward one frame
|
|
44
|
+
var FRAME_MS = 16;
|
|
45
|
+
|
|
46
|
+
var RealDate = Date;
|
|
47
|
+
var realRaf = typeof window.requestAnimationFrame === 'function'
|
|
48
|
+
? window.requestAnimationFrame.bind(window)
|
|
49
|
+
: null;
|
|
50
|
+
|
|
51
|
+
// A subclass rather than a Proxy: instanceof, every Date.prototype method and all
|
|
52
|
+
// date maths keep working untouched, and only the two things that read "now" change.
|
|
53
|
+
// The one thing this gives up is calling Date() with no 'new', which returns a string.
|
|
54
|
+
// App code effectively never does that; minifiers never produce it.
|
|
55
|
+
class FrozenDate extends RealDate {
|
|
56
|
+
constructor() {
|
|
57
|
+
var args = Array.prototype.slice.call(arguments);
|
|
58
|
+
if (args.length === 0) super(BASE + ticks);
|
|
59
|
+
else super(...args);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
FrozenDate.now = function () { return BASE + ticks; };
|
|
63
|
+
try { Object.defineProperty(FrozenDate, 'name', { value: 'Date', configurable: true }); } catch (e) {}
|
|
64
|
+
window.Date = FrozenDate;
|
|
65
|
+
try { globalThis.Date = FrozenDate; } catch (e) {}
|
|
66
|
+
|
|
67
|
+
// performance.now() and Date.now() must tell the same story, or code that mixes them
|
|
68
|
+
// (almost every animation library does) computes a nonsense elapsed time and loops.
|
|
69
|
+
if (window.performance) {
|
|
70
|
+
try {
|
|
71
|
+
Object.defineProperty(window.performance, 'timeOrigin', {
|
|
72
|
+
get: function () { return BASE; },
|
|
73
|
+
configurable: true
|
|
74
|
+
});
|
|
75
|
+
} catch (e) {}
|
|
76
|
+
try { window.performance.now = function () { return ticks; }; } catch (e) {}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A fixed frame timestamp is what stops requestAnimationFrame loops from drifting the
|
|
80
|
+
// picture: every frame looks like the same moment, so nothing eases, tweens or scrolls.
|
|
81
|
+
if (realRaf) {
|
|
82
|
+
window.requestAnimationFrame = function (cb) {
|
|
83
|
+
return realRaf(function () {
|
|
84
|
+
if (auto) ticks += FRAME_MS;
|
|
85
|
+
cb(ticks);
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function withZone(options) {
|
|
91
|
+
var o = {};
|
|
92
|
+
if (options) { for (var k in options) o[k] = options[k]; }
|
|
93
|
+
if (!o.timeZone) o.timeZone = TZ;
|
|
94
|
+
return o;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
var Intl_ = window.Intl;
|
|
98
|
+
if (Intl_ && typeof Intl_.DateTimeFormat === 'function') {
|
|
99
|
+
var RealDTF = Intl_.DateTimeFormat;
|
|
100
|
+
// Works with and without 'new': returning an object from a call overrides 'this'.
|
|
101
|
+
var PatchedDTF = function DateTimeFormat(locales, options) {
|
|
102
|
+
return new RealDTF(locales === undefined ? LOCALE : locales, withZone(options));
|
|
103
|
+
};
|
|
104
|
+
PatchedDTF.prototype = RealDTF.prototype;
|
|
105
|
+
if (RealDTF.supportedLocalesOf) {
|
|
106
|
+
PatchedDTF.supportedLocalesOf = function (l, o) { return RealDTF.supportedLocalesOf(l, o); };
|
|
107
|
+
}
|
|
108
|
+
try { Intl_.DateTimeFormat = PatchedDTF; } catch (e) {}
|
|
109
|
+
|
|
110
|
+
// toLocaleString and friends do NOT go through Intl.DateTimeFormat, so they need
|
|
111
|
+
// their own patch. We only fill in the locale and the zone; the default set of
|
|
112
|
+
// components stays exactly as the browser would have chosen it.
|
|
113
|
+
var names = ['toLocaleString', 'toLocaleDateString', 'toLocaleTimeString'];
|
|
114
|
+
for (var n = 0; n < names.length; n++) {
|
|
115
|
+
(function (name) {
|
|
116
|
+
var real = RealDate.prototype[name];
|
|
117
|
+
if (typeof real !== 'function') return;
|
|
118
|
+
RealDate.prototype[name] = function (locales, options) {
|
|
119
|
+
return real.call(this, locales === undefined ? LOCALE : locales, withZone(options));
|
|
120
|
+
};
|
|
121
|
+
})(names[n]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (Intl_) {
|
|
126
|
+
var localeOnly = ['NumberFormat', 'Collator', 'RelativeTimeFormat', 'ListFormat', 'PluralRules', 'DisplayNames', 'Segmenter'];
|
|
127
|
+
for (var i = 0; i < localeOnly.length; i++) {
|
|
128
|
+
(function (name) {
|
|
129
|
+
var Real = Intl_[name];
|
|
130
|
+
if (typeof Real !== 'function') return;
|
|
131
|
+
var Patched = function (locales, options) {
|
|
132
|
+
return new Real(locales === undefined ? LOCALE : locales, options);
|
|
133
|
+
};
|
|
134
|
+
Patched.prototype = Real.prototype;
|
|
135
|
+
if (Real.supportedLocalesOf) {
|
|
136
|
+
Patched.supportedLocalesOf = function (l, o) { return Real.supportedLocalesOf(l, o); };
|
|
137
|
+
}
|
|
138
|
+
try { Intl_[name] = Patched; } catch (e) {}
|
|
139
|
+
})(localeOnly[i]);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
var realNumToLocale = Number.prototype.toLocaleString;
|
|
144
|
+
if (typeof realNumToLocale === 'function') {
|
|
145
|
+
Number.prototype.toLocaleString = function (locales, options) {
|
|
146
|
+
return realNumToLocale.call(this, locales === undefined ? LOCALE : locales, options);
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Apps branch on the browser language to pick a format. Pin it or the picture depends
|
|
151
|
+
// on whichever machine took it.
|
|
152
|
+
try {
|
|
153
|
+
Object.defineProperty(window.navigator, 'language', {
|
|
154
|
+
get: function () { return LOCALE; }, configurable: true
|
|
155
|
+
});
|
|
156
|
+
Object.defineProperty(window.navigator, 'languages', {
|
|
157
|
+
get: function () { return Object.freeze([LOCALE]); }, configurable: true
|
|
158
|
+
});
|
|
159
|
+
} catch (e) {}
|
|
160
|
+
|
|
161
|
+
window.__staysfixed_clock = {
|
|
162
|
+
base: BASE,
|
|
163
|
+
now: function () { return BASE + ticks; },
|
|
164
|
+
elapsed: function () { return ticks; },
|
|
165
|
+
// Step time forward deliberately, e.g. to photograph "5 minutes into the session".
|
|
166
|
+
advance: function (ms) {
|
|
167
|
+
var n = Number(ms);
|
|
168
|
+
ticks += isFinite(n) ? n : 0;
|
|
169
|
+
return BASE + ticks;
|
|
170
|
+
},
|
|
171
|
+
// Let time creep forward one frame at a time. Off by default: fully frozen is the
|
|
172
|
+
// only setting that gives byte-identical pictures run after run.
|
|
173
|
+
auto: function (on) { auto = on !== false; return auto; }
|
|
174
|
+
};
|
|
175
|
+
})();`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The protocol half. Stronger than script patching because it changes what the renderer
|
|
180
|
+
* itself believes, before a single byte of the app is parsed.
|
|
181
|
+
*
|
|
182
|
+
* @param {import('../types.js').PageHandle} page
|
|
183
|
+
* @param {{iso?: string, timezone?: string, locale?: string}} [opts]
|
|
184
|
+
* @returns {Promise<void>}
|
|
185
|
+
*/
|
|
186
|
+
export async function clockCdp(page, opts = {}) {
|
|
187
|
+
const timezone = opts.timezone ?? 'UTC';
|
|
188
|
+
const locale = opts.locale ?? 'en-US';
|
|
189
|
+
|
|
190
|
+
// Older targets and some Electron builds do not carry every Emulation command.
|
|
191
|
+
// A missing override is a slightly less frozen page, never a failed run.
|
|
192
|
+
await tolerate(page, 'Emulation.setTimezoneOverride', { timezoneId: timezone });
|
|
193
|
+
await tolerate(page, 'Emulation.setLocaleOverride', { locale });
|
|
194
|
+
|
|
195
|
+
// Emulation.setVirtualTimePolicy is deliberately NOT used. It hands the clock to the
|
|
196
|
+
// browser and only advances it when the renderer says it is idle — an app holding a
|
|
197
|
+
// long-poll or a WebSocket open never goes idle, so virtual time never advances and
|
|
198
|
+
// the whole run deadlocks with no error. The injected clock has no such failure mode.
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {import('../types.js').PageHandle} page
|
|
203
|
+
* @param {string} method
|
|
204
|
+
* @param {Record<string, unknown>} params
|
|
205
|
+
* @returns {Promise<void>}
|
|
206
|
+
*/
|
|
207
|
+
async function tolerate(page, method, params) {
|
|
208
|
+
try {
|
|
209
|
+
await page.send(method, params);
|
|
210
|
+
} catch {
|
|
211
|
+
// Command not supported here. Carry on.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waiting for the page to finish arriving.
|
|
3
|
+
*
|
|
4
|
+
* The single most common cause of a picture that "randomly" fails is a font or an image
|
|
5
|
+
* that had not landed yet. Text reflows when the real face replaces the fallback; a
|
|
6
|
+
* missing image collapses a card. Neither is a bug in the app and neither is worth
|
|
7
|
+
* waking a human for, so we wait for both before the shutter.
|
|
8
|
+
*
|
|
9
|
+
* Every wait in here races a real setTimeout rather than a clock reading, because the
|
|
10
|
+
* clock is frozen: Date.now() would never move past the deadline.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { detail } from '../core/log.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Page-side helper that resolves when the browser says every face is in.
|
|
17
|
+
* @returns {string} JavaScript to evaluate in the page
|
|
18
|
+
*/
|
|
19
|
+
export function fontsScript() {
|
|
20
|
+
return `(function () {
|
|
21
|
+
if (window.__staysfixed_fontsReady) return;
|
|
22
|
+
|
|
23
|
+
function faces() {
|
|
24
|
+
var out = [];
|
|
25
|
+
try { document.fonts.forEach(function (f) { out.push(f); }); } catch (e) { /* older set */ }
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The trap that cost us the first green run, and it is worth spelling out.
|
|
30
|
+
//
|
|
31
|
+
// A face declared in @font-face is not fetched until layout actually needs it.
|
|
32
|
+
// Until then its status is 'unloaded', NOTHING is pending, document.fonts.status
|
|
33
|
+
// reads 'loaded' and document.fonts.ready resolves immediately — so a naive wait
|
|
34
|
+
// returns at once, the shutter fires on the fallback font, and a moment later the
|
|
35
|
+
// real face lands and every line of text shifts by a fraction of a pixel. That is
|
|
36
|
+
// exactly what made take 1 of twenty differ from takes 2 to 20: one picture in
|
|
37
|
+
// Helvetica, nineteen in the web font.
|
|
38
|
+
//
|
|
39
|
+
// So we do not wait for the fonts to arrive. We ask for them.
|
|
40
|
+
function forceLoad() {
|
|
41
|
+
var pending = [];
|
|
42
|
+
faces().forEach(function (f) {
|
|
43
|
+
try {
|
|
44
|
+
if (f.status === 'unloaded') pending.push(f.load().catch(function () {}));
|
|
45
|
+
else if (f.status === 'loading') pending.push(Promise.resolve(f.loaded).catch(function () {}));
|
|
46
|
+
} catch (e) { /* a face the browser refuses to load is not our problem */ }
|
|
47
|
+
});
|
|
48
|
+
return Promise.all(pending);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
window.__staysfixed_fontsReady = function (ms) {
|
|
52
|
+
var limit = typeof ms === 'number' && ms > 0 ? ms : 5000;
|
|
53
|
+
if (!document.fonts) return Promise.resolve('no-font-api');
|
|
54
|
+
|
|
55
|
+
var ready = (async function () {
|
|
56
|
+
// Loading one face can pull in another (a bold weight referenced by a rule that
|
|
57
|
+
// only matched once the first face changed the layout), so go round a few times
|
|
58
|
+
// until nothing is left unloaded.
|
|
59
|
+
for (var round = 0; round < 5; round++) {
|
|
60
|
+
await forceLoad();
|
|
61
|
+
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
|
+
}
|
|
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
|
+
});
|
|
71
|
+
return document.fonts.status || 'loaded';
|
|
72
|
+
})();
|
|
73
|
+
|
|
74
|
+
var timer = new Promise(function (resolve) {
|
|
75
|
+
setTimeout(function () { resolve('timeout'); }, limit);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return Promise.race([ready, timer]);
|
|
79
|
+
};
|
|
80
|
+
})();`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* CSS that pins how text is rasterised.
|
|
85
|
+
* @returns {string} CSS
|
|
86
|
+
*/
|
|
87
|
+
export function fontsCss() {
|
|
88
|
+
// This trades a little fidelity for pictures that do not change when the OS decides to
|
|
89
|
+
// smooth text differently. Subpixel antialiasing depends on the display, on whether the
|
|
90
|
+
// window is on an external monitor, and on GPU driver version; geometricPrecision stops
|
|
91
|
+
// glyph advances being rounded to whole pixels, which is what makes a line of text
|
|
92
|
+
// reflow by one pixel between runs; font-synthesis: none stops a fake bold or fake
|
|
93
|
+
// italic being invented when a weight is missing, which is a per-machine decision.
|
|
94
|
+
return `* , *::before, *::after {
|
|
95
|
+
-webkit-font-smoothing: antialiased !important;
|
|
96
|
+
text-rendering: geometricPrecision !important;
|
|
97
|
+
font-synthesis: none !important;
|
|
98
|
+
}
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Insert the text-rendering CSS.
|
|
104
|
+
*
|
|
105
|
+
* Called once per capture, never cached. It used to be cached against the page in a
|
|
106
|
+
* WeakSet, and that was a real bug with a very confusing signature: the first picture
|
|
107
|
+
* of a fresh browser came out with smoothed (thinner) text and every picture after it
|
|
108
|
+
* came out unsmoothed, because the stylesheet died with the document the screen recipe
|
|
109
|
+
* navigated away from and the cache refused to put it back. Identical layout, different
|
|
110
|
+
* pixels, one wrong picture in twenty. Insert it every time.
|
|
111
|
+
*
|
|
112
|
+
* @param {import('../types.js').PageHandle} page
|
|
113
|
+
* @returns {Promise<string|null>} the stylesheet id, or null when the page refused it
|
|
114
|
+
*/
|
|
115
|
+
export async function insertFontCss(page) {
|
|
116
|
+
try {
|
|
117
|
+
return await page.insertCss(fontsCss());
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Wait until every font face has loaded. Tolerates a page with no font API at all.
|
|
125
|
+
* @param {import('../types.js').PageHandle} page
|
|
126
|
+
* @param {{timeoutMs?: number}} [opts]
|
|
127
|
+
* @returns {Promise<void>}
|
|
128
|
+
*/
|
|
129
|
+
export async function waitForFonts(page, opts = {}) {
|
|
130
|
+
const timeoutMs = opts.timeoutMs ?? 5000;
|
|
131
|
+
|
|
132
|
+
const source = `(async () => {
|
|
133
|
+
if (typeof window.__staysfixed_fontsReady === 'function') {
|
|
134
|
+
return await window.__staysfixed_fontsReady(${timeoutMs});
|
|
135
|
+
}
|
|
136
|
+
if (!document.fonts) return 'no-font-api';
|
|
137
|
+
await Promise.race([
|
|
138
|
+
Promise.resolve(document.fonts.ready),
|
|
139
|
+
new Promise(function (r) { setTimeout(r, ${timeoutMs}); })
|
|
140
|
+
]);
|
|
141
|
+
return document.fonts.status || 'unknown';
|
|
142
|
+
})()`;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const status = await page.evaluate(source);
|
|
146
|
+
if (status === 'timeout') {
|
|
147
|
+
detail('fonts: gave up waiting after', `${timeoutMs}ms`, '— a face never finished loading');
|
|
148
|
+
} else {
|
|
149
|
+
detail('fonts:', String(status));
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// A page that navigated out from under us. The settle loop will catch any wobble.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Wait until pictures and stylesheets have landed: every <img> complete, every CSS
|
|
158
|
+
* background image fetched, no stylesheet still on its way.
|
|
159
|
+
* @param {import('../types.js').PageHandle} page
|
|
160
|
+
* @param {{timeoutMs?: number}} [opts]
|
|
161
|
+
* @returns {Promise<void>}
|
|
162
|
+
*/
|
|
163
|
+
export async function waitForImages(page, opts = {}) {
|
|
164
|
+
const timeoutMs = opts.timeoutMs ?? 10000;
|
|
165
|
+
|
|
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.
|
|
168
|
+
const source = `(async () => {
|
|
169
|
+
var LIMIT = ${timeoutMs};
|
|
170
|
+
var MAX_ELEMENTS = 4000;
|
|
171
|
+
var waits = [];
|
|
172
|
+
|
|
173
|
+
var imgs = document.images ? Array.prototype.slice.call(document.images) : [];
|
|
174
|
+
for (var i = 0; i < imgs.length; i++) {
|
|
175
|
+
(function (img) {
|
|
176
|
+
if (img.complete) return;
|
|
177
|
+
waits.push(new Promise(function (r) {
|
|
178
|
+
img.addEventListener('load', r, { once: true });
|
|
179
|
+
img.addEventListener('error', r, { once: true });
|
|
180
|
+
}));
|
|
181
|
+
})(imgs[i]);
|
|
182
|
+
}
|
|
183
|
+
|
|
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
|
+
// A stylesheet still in flight repaints the whole page the instant it applies — the
|
|
213
|
+
// worst possible moment for that is one millisecond after the shutter.
|
|
214
|
+
var links = document.querySelectorAll('link[rel~="stylesheet"]');
|
|
215
|
+
for (var l = 0; l < links.length; l++) {
|
|
216
|
+
(function (link) {
|
|
217
|
+
var loaded = false;
|
|
218
|
+
// A cross-origin sheet that HAS loaded exposes a sheet object but throws on its
|
|
219
|
+
// rules; a sheet that has not loaded exposes nothing. Presence is the right test.
|
|
220
|
+
try { loaded = Boolean(link.sheet); } catch (err) { loaded = true; }
|
|
221
|
+
if (loaded || link.disabled) return;
|
|
222
|
+
waits.push(new Promise(function (r) {
|
|
223
|
+
link.addEventListener('load', r, { once: true });
|
|
224
|
+
link.addEventListener('error', r, { once: true });
|
|
225
|
+
}));
|
|
226
|
+
})(links[l]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (waits.length === 0) return 0;
|
|
230
|
+
await Promise.race([
|
|
231
|
+
Promise.all(waits),
|
|
232
|
+
new Promise(function (r) { setTimeout(r, LIMIT); })
|
|
233
|
+
]);
|
|
234
|
+
return waits.length;
|
|
235
|
+
})()`;
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const waited = await page.evaluate(source);
|
|
239
|
+
if (waited) detail('waited on', String(waited), 'image or stylesheet loads');
|
|
240
|
+
} catch {
|
|
241
|
+
// Same as above: a navigation mid-wait is not a reason to fail a run.
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The freeze layer, assembled.
|
|
3
|
+
*
|
|
4
|
+
* This is the part of Stays Fixed that makes it worth using. A picture check that fails
|
|
5
|
+
* for no reason is worse than no check at all, because people learn to ignore it — and
|
|
6
|
+
* once they ignore it, the real regression slides through with everything else.
|
|
7
|
+
*
|
|
8
|
+
* Order is not a style choice here; each stage depends on the one before it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { clockScript, clockCdp } from './clock.js';
|
|
12
|
+
import { motionCss, motionScript, reduceMotionCdp } from './motion.js';
|
|
13
|
+
import { randomScript } from './random.js';
|
|
14
|
+
import { insertFontCss, fontsScript, waitForFonts, waitForImages } from './fonts.js';
|
|
15
|
+
import { installNetwork } from './network.js';
|
|
16
|
+
import { detail, warn } from '../core/log.js';
|
|
17
|
+
|
|
18
|
+
/** Used only when a caller asks for a frozen clock without saying which instant. */
|
|
19
|
+
const FALLBACK_INSTANT = '2026-01-01T12:00:00.000Z';
|
|
20
|
+
|
|
21
|
+
/** @type {import('../types.js').FreezeStats} */
|
|
22
|
+
const NO_NETWORK_STATS = {
|
|
23
|
+
requestsAllowed: 0,
|
|
24
|
+
requestsBlocked: 0,
|
|
25
|
+
requestsReplayed: 0,
|
|
26
|
+
requestsRecorded: 0,
|
|
27
|
+
blockedUrls: [],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Make a page behave the same way every time it is opened.
|
|
32
|
+
*
|
|
33
|
+
* @param {import('../types.js').PageHandle} page
|
|
34
|
+
* @param {import('../types.js').FreezeConfig} freeze
|
|
35
|
+
* @param {{fixturesDir?: string, screenName?: string, record?: boolean, deviceScaleFactor?: number, colorScheme?: 'light'|'dark'}} [ctx]
|
|
36
|
+
* @returns {Promise<import('../types.js').FreezeHandle>}
|
|
37
|
+
*/
|
|
38
|
+
export async function applyFreeze(page, freeze, ctx = {}) {
|
|
39
|
+
/** @type {string[]} */
|
|
40
|
+
const scriptIds = [];
|
|
41
|
+
/** @type {string[]} */
|
|
42
|
+
const cssIds = [];
|
|
43
|
+
/** @type {{release: () => Promise<void>, stats: () => import('../types.js').FreezeStats}|null} */
|
|
44
|
+
let network = null;
|
|
45
|
+
|
|
46
|
+
const wantClock = freeze.clock !== false;
|
|
47
|
+
const wantMotion = freeze.motion !== false;
|
|
48
|
+
const wantFonts = freeze.fonts !== false;
|
|
49
|
+
const wantRandom = freeze.random !== 'off';
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// 1. Protocol overrides first.
|
|
53
|
+
//
|
|
54
|
+
// These change what the renderer itself believes, and they take effect before a single
|
|
55
|
+
// byte of the app is parsed. Nothing injected into the page can reach the time zone the
|
|
56
|
+
// browser formats dates in, or the media query it answers for reduced motion.
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
if (wantClock) {
|
|
59
|
+
await clockCdp(page, { timezone: freeze.timezone, locale: freeze.locale });
|
|
60
|
+
}
|
|
61
|
+
if (wantMotion) {
|
|
62
|
+
await reduceMotionCdp(page, ctx.colorScheme ? { colorScheme: ctx.colorScheme } : {});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Network interception belongs in this first stage too, and for the same reason: if it
|
|
66
|
+
// goes up after the app navigates, the first page load has already pulled in the very
|
|
67
|
+
// avatars, fonts and beacons we are trying to keep out.
|
|
68
|
+
try {
|
|
69
|
+
network = await installNetwork(page, {
|
|
70
|
+
mode: freeze.network ?? 'block-external',
|
|
71
|
+
allow: freeze.networkAllow ?? [],
|
|
72
|
+
fixturesDir: ctx.fixturesDir,
|
|
73
|
+
screenName: ctx.screenName,
|
|
74
|
+
record: ctx.record,
|
|
75
|
+
});
|
|
76
|
+
} catch (e) {
|
|
77
|
+
warn('Could not take control of this app\'s network requests, so pictures may change on their own.');
|
|
78
|
+
detail(e instanceof Error ? e.message : String(e));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// 2. Page scripts, registered to run before anything the app loads.
|
|
83
|
+
//
|
|
84
|
+
// Order inside this list matters less than the fact that all of it lands before the
|
|
85
|
+
// app's own first line: a framework that reads Date.now() or Math.random() while it is
|
|
86
|
+
// booting has already baked the answer into the DOM by the time we could patch it.
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
/** @type {string[]} */
|
|
89
|
+
const sources = [];
|
|
90
|
+
if (wantClock) {
|
|
91
|
+
const iso = typeof freeze.clock === 'string' ? freeze.clock : FALLBACK_INSTANT;
|
|
92
|
+
sources.push(clockScript({ iso, timezone: freeze.timezone, locale: freeze.locale, seed: freeze.seed }));
|
|
93
|
+
}
|
|
94
|
+
if (wantRandom) sources.push(randomScript(freeze.seed ?? 20260101));
|
|
95
|
+
if (wantMotion) sources.push(motionScript());
|
|
96
|
+
if (wantFonts) sources.push(fontsScript());
|
|
97
|
+
|
|
98
|
+
for (const source of sources) {
|
|
99
|
+
try {
|
|
100
|
+
scriptIds.push(await page.addInitScript(source));
|
|
101
|
+
} catch (e) {
|
|
102
|
+
detail('freeze: could not register a page script —', e instanceof Error ? e.message : String(e));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// An init script only reaches documents that have not loaded yet, and the caller may
|
|
107
|
+
// already be sitting on a page. Run the same sources against the document we have.
|
|
108
|
+
// Every one of them refuses to install itself twice, so this is safe either way.
|
|
109
|
+
for (const source of sources) {
|
|
110
|
+
try {
|
|
111
|
+
await page.evaluate(source);
|
|
112
|
+
} catch {
|
|
113
|
+
// No document yet, or it navigated. The init script covers the next one.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// 3. CSS last, because a stylesheet needs a document to attach to.
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
if (wantMotion) {
|
|
121
|
+
try {
|
|
122
|
+
cssIds.push(
|
|
123
|
+
await page.insertCss(
|
|
124
|
+
motionCss({ hideScrollbars: freeze.hideScrollbars !== false, hideCaret: freeze.hideCaret !== false })
|
|
125
|
+
)
|
|
126
|
+
);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
detail('freeze: could not insert the motion stylesheet —', e instanceof Error ? e.message : String(e));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (wantFonts) {
|
|
132
|
+
const id = await insertFontCss(page);
|
|
133
|
+
if (id) cssIds.push(id);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
async release() {
|
|
138
|
+
try {
|
|
139
|
+
for (const id of cssIds) {
|
|
140
|
+
try {
|
|
141
|
+
await page.removeCss(id);
|
|
142
|
+
} catch {
|
|
143
|
+
// Already gone with the document.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const id of scriptIds) {
|
|
147
|
+
try {
|
|
148
|
+
await page.removeInitScript(id);
|
|
149
|
+
} catch {
|
|
150
|
+
// Same.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
await page.evaluate(
|
|
155
|
+
"(() => { const mo = window.__staysfixed_motionObserver; if (mo && mo.disconnect) mo.disconnect(); })()"
|
|
156
|
+
);
|
|
157
|
+
} catch {
|
|
158
|
+
// The observer dies with the document anyway.
|
|
159
|
+
}
|
|
160
|
+
} finally {
|
|
161
|
+
// Always, even if everything above threw: a live Fetch interception left behind
|
|
162
|
+
// stalls the next navigation, and that looks like the app hanging.
|
|
163
|
+
if (network) await network.release();
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
stats() {
|
|
167
|
+
return network ? network.stats() : { ...NO_NETWORK_STATS, blockedUrls: [] };
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The last-moment checks, run immediately before the shutter.
|
|
174
|
+
*
|
|
175
|
+
* @param {import('../types.js').PageHandle} page
|
|
176
|
+
* @param {{fonts?: boolean, timeoutMs?: number, keepScroll?: boolean, keepHover?: boolean}} [opts]
|
|
177
|
+
* @returns {Promise<void>}
|
|
178
|
+
*/
|
|
179
|
+
export async function prepareForShutter(page, opts = {}) {
|
|
180
|
+
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
181
|
+
|
|
182
|
+
if (opts.fonts !== false) await waitForFonts(page, { timeoutMs: Math.min(timeoutMs, 5000) });
|
|
183
|
+
await waitForImages(page, { timeoutMs });
|
|
184
|
+
|
|
185
|
+
// Park the mouse before anything else.
|
|
186
|
+
//
|
|
187
|
+
// The pointer stays wherever the last click left it, and the thing under it stays
|
|
188
|
+
// hovered: a highlighted row, a tooltip that fades in a beat later, a button that is
|
|
189
|
+
// a different colour than it will be tomorrow when the recipe clicks in a slightly
|
|
190
|
+
// different order. The first real app this tool was pointed at photographed a tooltip
|
|
191
|
+
// reading "Keep the sidebar open" that nobody meant to be in the picture, and a
|
|
192
|
+
// sidebar that had refused to collapse *because the mouse was still resting on it*.
|
|
193
|
+
//
|
|
194
|
+
// So: move the pointer out to the far corner, then let the page settle. The corner is
|
|
195
|
+
// (1,1) rather than (0,0) because some apps treat the exact origin as "no pointer" and
|
|
196
|
+
// never fire the leave.
|
|
197
|
+
if (opts.keepHover !== true) await page.moveMouseAway();
|
|
198
|
+
|
|
199
|
+
// A focus ring is a real source of wobble. Whichever element happened to be focused when
|
|
200
|
+
// the last step finished draws an outline the approved picture may not have — and which
|
|
201
|
+
// element that is depends on click timing, so it changes between runs on the same code.
|
|
202
|
+
const blur = `try {
|
|
203
|
+
var el = document.activeElement;
|
|
204
|
+
if (el && el !== document.body && typeof el.blur === 'function') el.blur();
|
|
205
|
+
} catch (e) {}`;
|
|
206
|
+
|
|
207
|
+
// Unless the recipe scrolled somewhere on purpose, start from the top: a page that was
|
|
208
|
+
// left scrolled by a click-into-view photographs differently every run.
|
|
209
|
+
const scroll = opts.keepScroll
|
|
210
|
+
? ''
|
|
211
|
+
: `try {
|
|
212
|
+
window.scrollTo(0, 0);
|
|
213
|
+
if (document.documentElement) document.documentElement.scrollTop = 0;
|
|
214
|
+
if (document.body) document.body.scrollTop = 0;
|
|
215
|
+
} catch (e) {}`;
|
|
216
|
+
|
|
217
|
+
const source = `(async () => {
|
|
218
|
+
${blur}
|
|
219
|
+
${scroll}
|
|
220
|
+
// Read a layout property to force the browser to flush whatever the blur and the scroll
|
|
221
|
+
// changed, then wait two frames so it is actually on screen and not merely calculated.
|
|
222
|
+
try { void document.documentElement.offsetHeight; } catch (e) {}
|
|
223
|
+
await new Promise(function (r) {
|
|
224
|
+
requestAnimationFrame(function () { requestAnimationFrame(function () { r(undefined); }); });
|
|
225
|
+
});
|
|
226
|
+
return true;
|
|
227
|
+
})()`;
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
await page.evaluate(source);
|
|
231
|
+
} catch {
|
|
232
|
+
// Nothing here is worth failing a run over; the settle loop is the real guarantee.
|
|
233
|
+
}
|
|
234
|
+
}
|