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,455 @@
1
+ /**
2
+ * Cutting the page off from the internet.
3
+ *
4
+ * This is the single biggest reason a picture stays the same tomorrow. A page that fetches
5
+ * an avatar from a CDN, a font from a third party, an analytics beacon or a live feed is a
6
+ * page whose picture depends on somebody else's server, today's weather in their data
7
+ * centre, and the office wifi. Block all of it and the picture only depends on your code.
8
+ *
9
+ * Three modes:
10
+ * live - let everything through (still counted, so --verbose can show it)
11
+ * block-external - only the app's own origin, localhost and an allow list get out
12
+ * replay - record every reply once, then serve those same bytes forever
13
+ *
14
+ * Every paused request gets exactly one answer — continue, fail or fulfil. A request that
15
+ * is paused and never answered stalls the page silently, which looks like a hung app.
16
+ */
17
+
18
+ import fsp from 'node:fs/promises';
19
+ import path from 'node:path';
20
+ import { sha256 } from '../core/hash.js';
21
+ import { safeName } from '../core/paths.js';
22
+ import { StaysFixedError } from '../core/errors.js';
23
+ import { detail } from '../core/log.js';
24
+
25
+ /**
26
+ * @typedef {object} Fixture
27
+ * @property {string} url
28
+ * @property {string} method
29
+ * @property {number} status
30
+ * @property {Record<string,string>} headers
31
+ * @property {string} bodyBase64
32
+ */
33
+
34
+ /**
35
+ * Headers we never replay. The body we recorded was handed to us already decoded and
36
+ * already whole, so telling the browser it is gzipped or 4021 bytes long makes it throw
37
+ * the reply away — and a "replayed" run then shows a blank page for no visible reason.
38
+ */
39
+ const SKIP_HEADERS = new Set([
40
+ 'content-encoding',
41
+ 'content-length',
42
+ 'transfer-encoding',
43
+ 'connection',
44
+ 'keep-alive',
45
+ ]);
46
+
47
+ const MAX_BLOCKED_LISTED = 20;
48
+
49
+ /** @type {Map<string, RegExp>} */
50
+ const globCache = new Map();
51
+
52
+ /**
53
+ * A tiny glob: `*` stops at a path separator, `**` crosses them, everything else is
54
+ * matched literally. Small enough to read, which matters more here than being complete.
55
+ *
56
+ * @param {string} glob
57
+ * @returns {RegExp}
58
+ */
59
+ export function globToRegExp(glob) {
60
+ const cached = globCache.get(glob);
61
+ if (cached) return cached;
62
+ let out = '';
63
+ for (let i = 0; i < glob.length; i += 1) {
64
+ const ch = glob[i];
65
+ if (ch === '*') {
66
+ if (glob[i + 1] === '*') {
67
+ out += '.*';
68
+ i += 1;
69
+ } else {
70
+ out += '[^/]*';
71
+ }
72
+ } else {
73
+ out += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
74
+ }
75
+ }
76
+ const re = new RegExp('^' + out + '$');
77
+ globCache.set(glob, re);
78
+ return re;
79
+ }
80
+
81
+ /**
82
+ * @param {string|null|undefined} url
83
+ * @returns {string|null}
84
+ */
85
+ function originOf(url) {
86
+ if (!url) return null;
87
+ try {
88
+ return new URL(url).origin;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Is this request allowed out in `block-external` mode?
96
+ *
97
+ * @param {string} url
98
+ * @param {string|null} ownOrigin
99
+ * @param {RegExp[]} allow
100
+ * @returns {boolean}
101
+ */
102
+ function isAllowed(url, ownOrigin, allow) {
103
+ if (!url) return true;
104
+ const lower = url.toLowerCase();
105
+ // Already local by definition — the bytes are in the page or on this disk.
106
+ if (
107
+ lower.startsWith('data:') ||
108
+ lower.startsWith('blob:') ||
109
+ lower.startsWith('file:') ||
110
+ lower.startsWith('about:') ||
111
+ lower.startsWith('chrome-extension:')
112
+ ) {
113
+ return true;
114
+ }
115
+
116
+ let u = null;
117
+ try {
118
+ u = new URL(url);
119
+ } catch {
120
+ // Not something we can reason about. Letting it through is the safer mistake:
121
+ // blocking a URL we failed to parse would break apps for no benefit.
122
+ return true;
123
+ }
124
+
125
+ const host = u.hostname.toLowerCase();
126
+ if (
127
+ host === 'localhost' ||
128
+ host.endsWith('.localhost') ||
129
+ host === '127.0.0.1' ||
130
+ host === '0.0.0.0' ||
131
+ host === '[::1]' ||
132
+ host === '::1'
133
+ ) {
134
+ return true;
135
+ }
136
+
137
+ if (ownOrigin && u.origin === ownOrigin) return true;
138
+
139
+ // Match the whole URL and the bare host, so both 'https://cdn.example.com/**' and
140
+ // '*.example.com' do what a person expects when they write them.
141
+ for (const re of allow) {
142
+ if (re.test(url) || re.test(host)) return true;
143
+ }
144
+ return false;
145
+ }
146
+
147
+ /**
148
+ * @param {string} dir
149
+ * @returns {Promise<boolean>}
150
+ */
151
+ async function hasFixtures(dir) {
152
+ try {
153
+ const files = await fsp.readdir(dir);
154
+ return files.some((f) => f.endsWith('.json'));
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * @param {string} dir
162
+ * @param {Map<string, Fixture>} into
163
+ * @returns {Promise<void>}
164
+ */
165
+ async function loadFixtures(dir, into) {
166
+ /** @type {string[]} */
167
+ let files = [];
168
+ try {
169
+ files = await fsp.readdir(dir);
170
+ } catch {
171
+ return;
172
+ }
173
+ for (const f of files) {
174
+ if (!f.endsWith('.json')) continue;
175
+ try {
176
+ const raw = await fsp.readFile(path.join(dir, f), 'utf8');
177
+ into.set(f.slice(0, -5), /** @type {Fixture} */ (JSON.parse(raw)));
178
+ } catch {
179
+ // A half-written fixture from a killed run. Treat it as missing.
180
+ }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Put network interception in place. Must be called before the app navigates, or the very
186
+ * first page load pulls in exactly the things we are trying to keep out.
187
+ *
188
+ * @param {import('../types.js').PageHandle} page
189
+ * @param {{mode?: 'live'|'block-external'|'replay', allow?: string[], fixturesDir?: string, screenName?: string, record?: boolean}} opts
190
+ * @returns {Promise<{release: () => Promise<void>, stats: () => import('../types.js').FreezeStats}>}
191
+ */
192
+ export async function installNetwork(page, opts) {
193
+ const mode = opts.mode ?? 'block-external';
194
+ const allow = (opts.allow ?? []).map(globToRegExp);
195
+ const ownOrigin = originOf(page.baseUrl);
196
+
197
+ /** @type {import('../types.js').FreezeStats} */
198
+ const counts = {
199
+ requestsAllowed: 0,
200
+ requestsBlocked: 0,
201
+ requestsReplayed: 0,
202
+ requestsRecorded: 0,
203
+ blockedUrls: [],
204
+ };
205
+ /** @type {Set<string>} */
206
+ const blockedOrigins = new Set();
207
+
208
+ /** @type {string|null} */
209
+ let screenDir = null;
210
+ /** @type {Map<string, Fixture>} */
211
+ const fixtures = new Map();
212
+ let recording = false;
213
+
214
+ if (mode === 'replay') {
215
+ if (!opts.fixturesDir || !opts.screenName) {
216
+ throw new StaysFixedError('Replay mode needs to know which screen it is recording for.', {
217
+ hint: 'This is a wiring problem inside Stays Fixed, not something in your config.',
218
+ });
219
+ }
220
+ screenDir = path.join(opts.fixturesDir, safeName(opts.screenName));
221
+ if (opts.record === true) {
222
+ // Re-recording keeps nothing: a leftover reply for a URL the app no longer asks for
223
+ // would sit in the folder forever, and nobody would ever know it was stale.
224
+ await fsp.rm(screenDir, { recursive: true, force: true });
225
+ recording = true;
226
+ } else {
227
+ recording = !(await hasFixtures(screenDir));
228
+ if (!recording) await loadFixtures(screenDir, fixtures);
229
+ }
230
+ detail('network: replay', recording ? '(recording this run)' : `(${fixtures.size} saved replies)`);
231
+ }
232
+
233
+ /** One decision per pause, ever. Keyed by stage as well as id, because a single request
234
+ * can legitimately pause twice: once on the way out, once on the way back. */
235
+ /** @type {Set<string>} */
236
+ const decided = new Set();
237
+ /** Requests paused right now and not yet answered. @type {Set<string>} */
238
+ const paused = new Set();
239
+ /** Requests we let out purely so we could record the reply. @type {Map<string, {url: string, method: string, key: string}>} */
240
+ const awaitingBody = new Map();
241
+ let closed = false;
242
+
243
+ /**
244
+ * @param {string} url
245
+ */
246
+ function noteBlocked(url) {
247
+ const origin = originOf(url) ?? url;
248
+ if (blockedOrigins.has(origin)) return;
249
+ blockedOrigins.add(origin);
250
+ if (counts.blockedUrls.length < MAX_BLOCKED_LISTED) counts.blockedUrls.push(origin);
251
+ }
252
+
253
+ /**
254
+ * @param {string} id
255
+ * @param {boolean} [interceptResponse]
256
+ */
257
+ async function letThrough(id, interceptResponse = false) {
258
+ try {
259
+ await page.send(
260
+ 'Fetch.continueRequest',
261
+ interceptResponse ? { requestId: id, interceptResponse: true } : { requestId: id }
262
+ );
263
+ } catch {
264
+ // The tab navigated away and took the request with it.
265
+ }
266
+ }
267
+
268
+ /** @param {string} id */
269
+ async function letResponseThrough(id) {
270
+ try {
271
+ await page.send('Fetch.continueResponse', { requestId: id });
272
+ } catch {
273
+ try {
274
+ await page.send('Fetch.continueRequest', { requestId: id });
275
+ } catch {
276
+ // Gone. Nothing left to answer.
277
+ }
278
+ }
279
+ }
280
+
281
+ /** @param {string} id */
282
+ async function refuse(id) {
283
+ try {
284
+ await page.send('Fetch.failRequest', { requestId: id, errorReason: 'BlockedByClient' });
285
+ } catch {
286
+ // Same as above.
287
+ }
288
+ }
289
+
290
+ /**
291
+ * @param {string} id
292
+ * @param {Fixture} fx
293
+ */
294
+ async function serveRecorded(id, fx) {
295
+ const responseHeaders = Object.entries(fx.headers ?? {})
296
+ .filter(([name]) => !SKIP_HEADERS.has(name.toLowerCase()))
297
+ .map(([name, value]) => ({ name, value: String(value) }));
298
+ try {
299
+ await page.send('Fetch.fulfillRequest', {
300
+ requestId: id,
301
+ responseCode: fx.status || 200,
302
+ responseHeaders,
303
+ body: fx.bodyBase64 ?? '',
304
+ });
305
+ } catch {
306
+ await refuse(id);
307
+ }
308
+ }
309
+
310
+ /** @param {any} ev */
311
+ async function onRequestStage(ev) {
312
+ const id = String(ev.requestId);
313
+ const url = String(ev.request?.url ?? '');
314
+ const method = String(ev.request?.method ?? 'GET');
315
+
316
+ if (mode === 'live') {
317
+ counts.requestsAllowed += 1;
318
+ await letThrough(id);
319
+ return;
320
+ }
321
+
322
+ if (mode === 'replay') {
323
+ const key = sha256(`${method} ${url}`);
324
+ if (!recording) {
325
+ const fx = fixtures.get(key);
326
+ if (fx) {
327
+ counts.requestsReplayed += 1;
328
+ await serveRecorded(id, fx);
329
+ return;
330
+ }
331
+ // Nothing recorded for this one. Failing is the honest answer: quietly going to
332
+ // the real network would put the picture back at the internet's mercy.
333
+ noteBlocked(url);
334
+ counts.requestsBlocked += 1;
335
+ await refuse(id);
336
+ return;
337
+ }
338
+ awaitingBody.set(id, { url, method, key });
339
+ counts.requestsAllowed += 1;
340
+ await letThrough(id, true);
341
+ return;
342
+ }
343
+
344
+ if (isAllowed(url, ownOrigin, allow)) {
345
+ counts.requestsAllowed += 1;
346
+ await letThrough(id);
347
+ return;
348
+ }
349
+ noteBlocked(url);
350
+ counts.requestsBlocked += 1;
351
+ await refuse(id);
352
+ }
353
+
354
+ /** @param {any} ev */
355
+ async function onResponseStage(ev) {
356
+ const id = String(ev.requestId);
357
+ const info = awaitingBody.get(id);
358
+ awaitingBody.delete(id);
359
+
360
+ if (info && screenDir) {
361
+ try {
362
+ const body = await page.send('Fetch.getResponseBody', { requestId: id });
363
+ const bodyBase64 = body?.base64Encoded
364
+ ? String(body.body ?? '')
365
+ : Buffer.from(String(body?.body ?? ''), 'utf8').toString('base64');
366
+ /** @type {Record<string,string>} */
367
+ const headers = {};
368
+ for (const h of ev.responseHeaders ?? []) {
369
+ headers[String(h.name).toLowerCase()] = String(h.value);
370
+ }
371
+ /** @type {Fixture} */
372
+ const fx = {
373
+ url: info.url,
374
+ method: info.method,
375
+ status: Number(ev.responseStatusCode ?? 200),
376
+ headers,
377
+ bodyBase64,
378
+ };
379
+ await fsp.mkdir(screenDir, { recursive: true });
380
+ await fsp.writeFile(path.join(screenDir, `${info.key}.json`), JSON.stringify(fx, null, 2));
381
+ fixtures.set(info.key, fx);
382
+ counts.requestsRecorded += 1;
383
+ } catch {
384
+ // Some replies have no readable body (a redirect, a 204, a stream the browser
385
+ // already consumed). Not recording it is better than failing the run.
386
+ }
387
+ }
388
+
389
+ await letResponseThrough(id);
390
+ }
391
+
392
+ /** @param {any} ev */
393
+ async function handle(ev) {
394
+ if (closed) return;
395
+ const id = ev?.requestId ? String(ev.requestId) : '';
396
+ if (!id) return;
397
+ // The response stage is the one that carries a status or an error reason.
398
+ const isResponse = ev.responseStatusCode !== undefined || ev.responseErrorReason !== undefined;
399
+ const key = `${isResponse ? 'res' : 'req'}:${id}`;
400
+ if (decided.has(key)) return;
401
+ decided.add(key);
402
+ paused.add(id);
403
+ try {
404
+ if (isResponse) await onResponseStage(ev);
405
+ else await onRequestStage(ev);
406
+ } catch (e) {
407
+ detail('network: could not answer a paused request —', e instanceof Error ? e.message : String(e));
408
+ await letThrough(id);
409
+ } finally {
410
+ paused.delete(id);
411
+ }
412
+ }
413
+
414
+ const off = page.on('Fetch.requestPaused', (params) => {
415
+ void handle(params);
416
+ });
417
+
418
+ try {
419
+ await page.send('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
420
+ } catch (cause) {
421
+ off();
422
+ throw new StaysFixedError('This app would not let me watch its network requests.', {
423
+ hint: "Set freeze.network to 'live' in your config to run without network control.",
424
+ cause,
425
+ });
426
+ }
427
+
428
+ return {
429
+ async release() {
430
+ closed = true;
431
+ off();
432
+ // Answer anything still hanging before turning interception off. Fetch.disable does
433
+ // release paused requests, but a request that was paused and then abandoned can
434
+ // stall the next navigation, and that looks exactly like a hung app.
435
+ for (const id of Array.from(paused)) {
436
+ await letThrough(id);
437
+ }
438
+ paused.clear();
439
+ try {
440
+ await page.send('Fetch.disable');
441
+ } catch {
442
+ // Target already gone.
443
+ }
444
+ },
445
+ stats() {
446
+ return {
447
+ requestsAllowed: counts.requestsAllowed,
448
+ requestsBlocked: counts.requestsBlocked,
449
+ requestsReplayed: counts.requestsReplayed,
450
+ requestsRecorded: counts.requestsRecorded,
451
+ blockedUrls: [...counts.blockedUrls],
452
+ };
453
+ },
454
+ };
455
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Seeding randomness.
3
+ *
4
+ * Randomness reaches a picture in more places than people expect: a shuffled list, a
5
+ * placeholder avatar colour, a chart's jitter, a React key printed into a data attribute,
6
+ * an "id" on a tooltip that ends up in the accessibility tree. Any one of them makes a
7
+ * screen that never matches itself twice.
8
+ *
9
+ * The real functions stay reachable on window.__staysfixed_realRandom, because a handful
10
+ * of apps genuinely need unpredictable bytes (a crypto key, a WebRTC session) and would
11
+ * break rather than merely look different.
12
+ */
13
+
14
+ /**
15
+ * @param {number} seed
16
+ * @returns {string} JavaScript to evaluate in the page
17
+ */
18
+ export function randomScript(seed) {
19
+ const start = Number.isFinite(seed) ? Math.trunc(seed) : 20260101;
20
+
21
+ return `(function () {
22
+ if (window.__staysfixed_realRandom) return;
23
+
24
+ var SEED = ${start};
25
+
26
+ // mulberry32: 32 bits of state, no dependencies, well-distributed enough that a
27
+ // shuffled list still looks shuffled — and identical on every machine, every run.
28
+ function mulberry32(a) {
29
+ return function () {
30
+ a |= 0;
31
+ a = (a + 0x6D2B79F5) | 0;
32
+ var t = Math.imul(a ^ (a >>> 15), 1 | a);
33
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
34
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
35
+ };
36
+ }
37
+
38
+ var rand = mulberry32(SEED);
39
+ var cryptoObj = window.crypto || null;
40
+
41
+ var real = {
42
+ random: Math.random,
43
+ getRandomValues: cryptoObj && cryptoObj.getRandomValues ? cryptoObj.getRandomValues.bind(cryptoObj) : null,
44
+ randomUUID: cryptoObj && cryptoObj.randomUUID ? cryptoObj.randomUUID.bind(cryptoObj) : null,
45
+ reseed: function (n) { rand = mulberry32(n | 0); uuidCount = 0; }
46
+ };
47
+ window.__staysfixed_realRandom = real;
48
+
49
+ Math.random = rand;
50
+
51
+ function seededFill(arr) {
52
+ if (!arr || typeof arr !== 'object' || !('byteLength' in arr)) {
53
+ throw new TypeError('Expected a typed array');
54
+ }
55
+ // Fill the underlying bytes rather than the elements, so a Uint32Array and a
56
+ // Uint8Array over the same buffer both come out of the same stream.
57
+ var view = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
58
+ for (var i = 0; i < view.length; i++) view[i] = (rand() * 256) & 255;
59
+ return arr;
60
+ }
61
+
62
+ var uuidCount = 0;
63
+ function seededUUID() {
64
+ // Counter-based, but shaped like a real v4 (version nibble 4, variant nibble 8) so
65
+ // anything that validates the format still accepts it.
66
+ var hex = (++uuidCount).toString(16);
67
+ while (hex.length < 32) hex = '0' + hex;
68
+ return hex.slice(0, 8) + '-' + hex.slice(8, 12) + '-4' + hex.slice(13, 16) +
69
+ '-8' + hex.slice(17, 20) + '-' + hex.slice(20, 32);
70
+ }
71
+
72
+ if (cryptoObj) {
73
+ try { cryptoObj.getRandomValues = seededFill; } catch (e) {}
74
+ if (cryptoObj.getRandomValues !== seededFill) {
75
+ try {
76
+ Object.defineProperty(cryptoObj, 'getRandomValues', { value: seededFill, configurable: true, writable: true });
77
+ } catch (e) {}
78
+ }
79
+ try { cryptoObj.randomUUID = seededUUID; } catch (e) {}
80
+ if (cryptoObj.randomUUID !== seededUUID) {
81
+ try {
82
+ Object.defineProperty(cryptoObj, 'randomUUID', { value: seededUUID, configurable: true, writable: true });
83
+ } catch (e) {}
84
+ }
85
+ }
86
+ })();`;
87
+ }