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.
Files changed (57) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +529 -0
  4. package/bin/staysfixed.js +18 -0
  5. package/examples/guards/the-sidebar-still-collapses.js +91 -0
  6. package/examples/staysfixed.config.electron.js +172 -0
  7. package/examples/staysfixed.config.web.js +277 -0
  8. package/package.json +61 -0
  9. package/src/cli/approve.js +126 -0
  10. package/src/cli/check.js +73 -0
  11. package/src/cli/doctor.js +379 -0
  12. package/src/cli/flake.js +61 -0
  13. package/src/cli/index.js +519 -0
  14. package/src/cli/init.js +564 -0
  15. package/src/cli/mark.js +69 -0
  16. package/src/cli/status.js +19 -0
  17. package/src/cli/trace.js +73 -0
  18. package/src/cli/walk.js +57 -0
  19. package/src/core/config.js +226 -0
  20. package/src/core/errors.js +48 -0
  21. package/src/core/git.js +90 -0
  22. package/src/core/hash.js +32 -0
  23. package/src/core/history.js +173 -0
  24. package/src/core/log.js +144 -0
  25. package/src/core/paths.js +135 -0
  26. package/src/drive/browser.js +540 -0
  27. package/src/drive/cdp.js +382 -0
  28. package/src/drive/electron.js +326 -0
  29. package/src/drive/find.js +331 -0
  30. package/src/drive/launch.js +263 -0
  31. package/src/drive/page.js +1042 -0
  32. package/src/freeze/clock.js +213 -0
  33. package/src/freeze/fonts.js +243 -0
  34. package/src/freeze/index.js +234 -0
  35. package/src/freeze/mask.js +187 -0
  36. package/src/freeze/motion.js +206 -0
  37. package/src/freeze/network.js +455 -0
  38. package/src/freeze/random.js +87 -0
  39. package/src/freeze/settle.js +178 -0
  40. package/src/guard/api.js +197 -0
  41. package/src/guard/load.js +324 -0
  42. package/src/guard/name.js +327 -0
  43. package/src/guard/run.js +224 -0
  44. package/src/index.js +61 -0
  45. package/src/marker/mark.js +260 -0
  46. package/src/marker/trace.js +293 -0
  47. package/src/mcp/server.js +377 -0
  48. package/src/mcp/tools.js +978 -0
  49. package/src/picture/capture.js +276 -0
  50. package/src/picture/compare.js +103 -0
  51. package/src/picture/run.js +284 -0
  52. package/src/picture/store.js +208 -0
  53. package/src/report/console.js +540 -0
  54. package/src/report/html.js +579 -0
  55. package/src/run.js +614 -0
  56. package/src/types.js +471 -0
  57. 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
+ }