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,331 @@
1
+ /**
2
+ * Finding the pieces we need on whatever machine this is running on:
3
+ * a browser to drive, the real executable inside a Mac app bundle, and a free port.
4
+ *
5
+ * Everything here is best-effort and never throws unless the caller asked for a
6
+ * guarantee (`requireChrome`, `resolveElectronBinary`).
7
+ */
8
+
9
+ import { existsSync, statSync, readdirSync, readFileSync } from 'node:fs';
10
+ import { execFileSync } from 'node:child_process';
11
+ import { createServer, connect as netConnect } from 'node:net';
12
+ import { join, basename, sep } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+
15
+ import { StaysFixedError } from '../core/errors.js';
16
+
17
+ /** Env vars people already set for other tools — honour them before guessing. */
18
+ const ENV_KEYS = ['STAYSFIXED_CHROME', 'CHROME_PATH', 'PUPPETEER_EXECUTABLE_PATH'];
19
+
20
+ /**
21
+ * @param {string} p
22
+ * @returns {boolean}
23
+ */
24
+ function isFileOnDisk(p) {
25
+ const s = statSync(p, { throwIfNoEntry: false });
26
+ return Boolean(s && s.isFile());
27
+ }
28
+
29
+ /**
30
+ * Bare names get looked up on PATH; anything with a separator is a real path.
31
+ * @param {string} name
32
+ * @returns {boolean}
33
+ */
34
+ function isBareName(name) {
35
+ return !name.includes('/') && !name.includes(sep);
36
+ }
37
+
38
+ /**
39
+ * Every place a browser might be on this machine, best first.
40
+ * @returns {string[]}
41
+ */
42
+ export function chromeCandidates() {
43
+ /** @type {string[]} */
44
+ const out = [];
45
+ for (const key of ENV_KEYS) {
46
+ const value = process.env[key];
47
+ if (value) out.push(value);
48
+ }
49
+
50
+ const home = homedir();
51
+
52
+ if (process.platform === 'darwin') {
53
+ const apps = [
54
+ ['Google Chrome.app', 'Google Chrome'],
55
+ ['Google Chrome Canary.app', 'Google Chrome Canary'],
56
+ ['Chromium.app', 'Chromium'],
57
+ ['Brave Browser.app', 'Brave Browser'],
58
+ ['Microsoft Edge.app', 'Microsoft Edge'],
59
+ ];
60
+ for (const root of ['/Applications', join(home, 'Applications')]) {
61
+ for (const [bundle, exe] of apps) {
62
+ out.push(join(root, bundle, 'Contents', 'MacOS', exe));
63
+ }
64
+ }
65
+ } else if (process.platform === 'win32') {
66
+ const roots = [
67
+ process.env.PROGRAMFILES,
68
+ process.env['PROGRAMFILES(X86)'],
69
+ process.env.LOCALAPPDATA,
70
+ ].filter((/** @type {string|undefined} */ v) => Boolean(v));
71
+ for (const root of /** @type {string[]} */ (roots)) {
72
+ out.push(join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'));
73
+ out.push(join(root, 'Google', 'Chrome SxS', 'Application', 'chrome.exe'));
74
+ out.push(join(root, 'Chromium', 'Application', 'chrome.exe'));
75
+ out.push(join(root, 'BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'));
76
+ out.push(join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'));
77
+ }
78
+ out.push('chrome.exe', 'msedge.exe');
79
+ } else {
80
+ const names = [
81
+ 'google-chrome',
82
+ 'google-chrome-stable',
83
+ 'chromium',
84
+ 'chromium-browser',
85
+ 'microsoft-edge',
86
+ 'microsoft-edge-stable',
87
+ 'brave-browser',
88
+ ];
89
+ for (const dir of ['/usr/bin', '/usr/local/bin', '/snap/bin', '/opt/google/chrome']) {
90
+ for (const name of names) out.push(join(dir, name));
91
+ }
92
+ // /opt/google/chrome ships the binary as plain "chrome".
93
+ out.push('/opt/google/chrome/chrome');
94
+ out.push(...names);
95
+ }
96
+
97
+ // Keep the order, drop repeats.
98
+ return [...new Set(out)];
99
+ }
100
+
101
+ /**
102
+ * Ask the shell where a bare command lives.
103
+ * @param {string} name
104
+ * @returns {string|null}
105
+ */
106
+ function lookupOnPath(name) {
107
+ const finder = process.platform === 'win32' ? 'where' : 'which';
108
+ try {
109
+ const found = execFileSync(finder, [name], {
110
+ encoding: 'utf8',
111
+ stdio: ['ignore', 'pipe', 'ignore'],
112
+ timeout: 5000,
113
+ });
114
+ const first = String(found)
115
+ .split(/\r?\n/)
116
+ .map((line) => line.trim())
117
+ .find((line) => line.length > 0);
118
+ if (first && isFileOnDisk(first)) return first;
119
+ } catch {
120
+ // Not on PATH. That is an answer, not a failure.
121
+ }
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * A path the user gave us may point at a Mac app bundle; unwrap it quietly.
127
+ * @param {string} p
128
+ * @returns {string}
129
+ */
130
+ function unwrapIfBundle(p) {
131
+ if (!p.endsWith('.app')) return p;
132
+ try {
133
+ return resolveElectronBinary(p);
134
+ } catch {
135
+ return p;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * @param {string} [explicit] A path from config or the command line.
141
+ * @returns {string|null}
142
+ */
143
+ export function findChrome(explicit) {
144
+ if (explicit) {
145
+ const unwrapped = unwrapIfBundle(explicit);
146
+ if (existsSync(unwrapped)) return unwrapped;
147
+ if (isBareName(explicit)) {
148
+ const onPath = lookupOnPath(explicit);
149
+ if (onPath) return onPath;
150
+ }
151
+ return null;
152
+ }
153
+
154
+ const candidates = chromeCandidates();
155
+ for (const candidate of candidates) {
156
+ if (isBareName(candidate)) continue;
157
+ const unwrapped = unwrapIfBundle(candidate);
158
+ if (isFileOnDisk(unwrapped)) return unwrapped;
159
+ }
160
+ for (const candidate of candidates) {
161
+ if (!isBareName(candidate)) continue;
162
+ const onPath = lookupOnPath(candidate);
163
+ if (onPath) return onPath;
164
+ }
165
+ return null;
166
+ }
167
+
168
+ /**
169
+ * @param {string} [explicit]
170
+ * @returns {string}
171
+ */
172
+ export function requireChrome(explicit) {
173
+ const found = findChrome(explicit);
174
+ if (found) return found;
175
+
176
+ if (explicit) {
177
+ throw new StaysFixedError(`There is no browser at "${explicit}".`, {
178
+ hint: 'Check the path in your config under app.browser, or remove it and let Stays Fixed find one.',
179
+ });
180
+ }
181
+
182
+ const looked = chromeCandidates()
183
+ .filter((c) => !isBareName(c))
184
+ .slice(0, 6)
185
+ .map((c) => ` ${c}`)
186
+ .join('\n');
187
+
188
+ throw new StaysFixedError('Stays Fixed could not find Chrome, Chromium, Brave or Edge on this machine.', {
189
+ hint:
190
+ 'Install Google Chrome, or point Stays Fixed at the browser you have: set app.browser in your config, or the STAYSFIXED_CHROME environment variable.\n' +
191
+ `It looked in places like:\n${looked}`,
192
+ });
193
+ }
194
+
195
+ /**
196
+ * Turn a Mac `.app` bundle into the executable inside it. Anything else is
197
+ * handed straight back, so this is safe to call on every platform.
198
+ * @param {string} p
199
+ * @returns {string}
200
+ */
201
+ export function resolveElectronBinary(p) {
202
+ if (!p) {
203
+ throw new StaysFixedError('No app was given to open.', {
204
+ hint: 'Set app.binary in your config to the app you want checked.',
205
+ });
206
+ }
207
+ const stat = statSync(p, { throwIfNoEntry: false });
208
+ if (!stat) {
209
+ throw new StaysFixedError(`There is nothing at "${p}".`, {
210
+ hint: 'Check app.binary in your config — it should point at your built app.',
211
+ });
212
+ }
213
+ if (stat.isFile()) return p;
214
+
215
+ const macos = join(p, 'Contents', 'MacOS');
216
+ const macosStat = statSync(macos, { throwIfNoEntry: false });
217
+ if (!macosStat || !macosStat.isDirectory()) {
218
+ throw new StaysFixedError(`"${p}" is a folder, not an app Stays Fixed can open.`, {
219
+ hint: 'Point app.binary at the built app itself — on a Mac that is the .app bundle, elsewhere the executable file.',
220
+ });
221
+ }
222
+
223
+ // Info.plist is usually XML; a plain regex beats adding a plist library.
224
+ // When it is the binary format the regex simply misses and we fall through.
225
+ const plist = join(p, 'Contents', 'Info.plist');
226
+ try {
227
+ const text = readFileSync(plist, 'utf8');
228
+ const match = text.match(/<key>\s*CFBundleExecutable\s*<\/key>\s*<string>([^<]+)<\/string>/);
229
+ if (match) {
230
+ const named = join(macos, match[1].trim());
231
+ if (isFileOnDisk(named)) return named;
232
+ }
233
+ } catch {
234
+ // No readable Info.plist. Fall back to looking in the folder.
235
+ }
236
+
237
+ const entries = readdirSync(macos).filter((name) => isFileOnDisk(join(macos, name)));
238
+ if (entries.length === 1) return join(macos, entries[0]);
239
+
240
+ const wanted = basename(p).replace(/\.app$/, '');
241
+ const guess = entries.find((name) => name === wanted);
242
+ if (guess) return join(macos, guess);
243
+
244
+ throw new StaysFixedError(`Stays Fixed could not tell which program to run inside "${p}".`, {
245
+ hint: `Point app.binary straight at the executable, for example ${join(macos, entries[0] ?? wanted)}.`,
246
+ });
247
+ }
248
+
249
+ /**
250
+ * @param {number} port
251
+ * @returns {Promise<boolean>}
252
+ */
253
+ function canBind(port) {
254
+ return new Promise((resolve) => {
255
+ const server = createServer();
256
+ const done = (/** @type {boolean} */ answer) => {
257
+ server.removeAllListeners();
258
+ try {
259
+ server.close();
260
+ } catch {
261
+ /* already closed */
262
+ }
263
+ resolve(answer);
264
+ };
265
+ server.once('error', () => done(false));
266
+ server.once('listening', () => done(true));
267
+ try {
268
+ server.listen(port, '127.0.0.1');
269
+ } catch {
270
+ done(false);
271
+ }
272
+ });
273
+ }
274
+
275
+ /**
276
+ * A port nothing else is using. Asking the operating system for one beats
277
+ * guessing — two runs at once must never fight over the same number.
278
+ * @param {number} [preferred]
279
+ * @returns {Promise<number>}
280
+ */
281
+ export async function freePort(preferred) {
282
+ if (typeof preferred === 'number' && Number.isInteger(preferred) && preferred > 0 && preferred < 65_536) {
283
+ if (await canBind(preferred)) return preferred;
284
+ }
285
+ return await new Promise((resolve, reject) => {
286
+ const server = createServer();
287
+ server.once('error', (e) => {
288
+ reject(new StaysFixedError('Stays Fixed could not reserve a port to talk to the app on.', { cause: e }));
289
+ });
290
+ server.listen(0, '127.0.0.1', () => {
291
+ const address = server.address();
292
+ const port = address && typeof address === 'object' ? address.port : 0;
293
+ server.close(() => {
294
+ if (port) resolve(port);
295
+ else reject(new StaysFixedError('Stays Fixed could not reserve a port to talk to the app on.'));
296
+ });
297
+ });
298
+ });
299
+ }
300
+
301
+ /**
302
+ * @param {number} port
303
+ * @param {string} [host]
304
+ * @returns {Promise<boolean>}
305
+ */
306
+ export function isPortOpen(port, host = '127.0.0.1') {
307
+ return new Promise((resolve) => {
308
+ const socket = netConnect({ port, host });
309
+ let answered = false;
310
+ const done = (/** @type {boolean} */ answer) => {
311
+ if (answered) return;
312
+ answered = true;
313
+ socket.removeAllListeners();
314
+ socket.destroy();
315
+ resolve(answer);
316
+ };
317
+ socket.setTimeout(1000);
318
+ socket.once('connect', () => done(true));
319
+ socket.once('timeout', () => done(false));
320
+ socket.once('error', () => done(false));
321
+ });
322
+ }
323
+
324
+ /**
325
+ * Pictures are tagged with this because text is drawn differently on every
326
+ * operating system — a Mac picture will never match a Linux one.
327
+ * @returns {string}
328
+ */
329
+ export function platformTag() {
330
+ return `${process.platform}-${process.arch}`;
331
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * One door in. Everything that needs an app open — a picture check, a guard, a
3
+ * walk, the MCP server — comes through here and gets back the same thing back:
4
+ * a page it can drive and a `close()` that cleans up exactly what we started.
5
+ */
6
+
7
+ import { StaysFixedError } from '../core/errors.js';
8
+ import { detail } from '../core/log.js';
9
+ import { fetchVersion, listTargets, connect } from './cdp.js';
10
+ import { createPage } from './page.js';
11
+ import { launchBrowser, startWebApp, delay } from './browser.js';
12
+ import { launchElectron } from './electron.js';
13
+
14
+ /**
15
+ * Accept the endpoint however a person naturally writes it: a full address, a
16
+ * host and port, or just the port.
17
+ * @param {string} value
18
+ * @returns {string}
19
+ */
20
+ function normalizeEndpoint(value) {
21
+ const v = String(value).trim();
22
+ if (/^\d+$/.test(v)) return `http://127.0.0.1:${v}`;
23
+ if (!/^https?:\/\//i.test(v)) return `http://${v}`;
24
+ return v.replace(/\/+$/, '');
25
+ }
26
+
27
+ /**
28
+ * @param {string} endpoint
29
+ * @param {string|undefined} match
30
+ * @param {number} deadline epoch ms
31
+ * @returns {Promise<any>}
32
+ */
33
+ async function pickTarget(endpoint, match, deadline) {
34
+ /** @type {any[]} */
35
+ let pages = [];
36
+ for (;;) {
37
+ const targets = /** @type {any[]} */ (await listTargets(endpoint));
38
+ pages = targets.filter((t) => t && t.type === 'page' && !String(t.url ?? '').startsWith('devtools://'));
39
+ const hit = match
40
+ ? pages.find((t) => String(t.title ?? '').includes(match) || String(t.url ?? '').includes(match))
41
+ : pages[0];
42
+ if (hit) return hit;
43
+ if (Date.now() > deadline) break;
44
+ await delay(250);
45
+ }
46
+ const open = pages.map((t) => ` ${String(t.title ?? '(no title)')} — ${String(t.url ?? '')}`).join('\n');
47
+ throw new StaysFixedError(
48
+ match ? `Nothing open there matches "${match}".` : 'That app is running, but it has no window open to look at.',
49
+ { hint: open ? `What it does have open:\n${open}` : undefined },
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Attach to something that is already running.
55
+ *
56
+ * We did not start this process, so we must NEVER stop it. `close()` here only
57
+ * hangs up the debugging socket. Killing a person's running app because a check
58
+ * finished would be unforgivable, and it is a one-line mistake to make.
59
+ * @param {import('../types.js').AppConfig} app
60
+ * @returns {Promise<import('../types.js').LaunchedApp>}
61
+ */
62
+ async function attachToApp(app) {
63
+ const endpoint = normalizeEndpoint(String(app.attach));
64
+ const timeoutMs = app.startTimeoutMs ?? 30_000;
65
+
66
+ /** @type {any} */
67
+ let version;
68
+ try {
69
+ version = await fetchVersion(endpoint);
70
+ } catch (cause) {
71
+ throw new StaysFixedError(`Nothing is answering at ${endpoint}.`, {
72
+ hint: 'Start the app with a debugging port open, or remove `app.attach` to let Stays Fixed start it.',
73
+ cause,
74
+ });
75
+ }
76
+ const wsUrl = version?.webSocketDebuggerUrl;
77
+ if (!wsUrl) {
78
+ throw new StaysFixedError(`Something answered at ${endpoint}, but it is not an app I can drive.`);
79
+ }
80
+
81
+ const cdp = /** @type {import('../types.js').CdpSession} */ (await connect(wsUrl, { timeoutMs: 15_000 }));
82
+ try {
83
+ const target = await pickTarget(endpoint, app.windowMatch, Date.now() + timeoutMs);
84
+ const targetId = String(target.id ?? target.targetId);
85
+ detail(`attached to: ${String(target.title ?? '(no title)')} — ${String(target.url ?? '')}`);
86
+
87
+ const attached = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
88
+ const sessionId = String(attached.sessionId);
89
+ const page = await createPage(
90
+ cdp,
91
+ /** @type {any} */ ({ sessionId, targetId, baseUrl: app.url ?? null, timeoutMs }),
92
+ );
93
+
94
+ /** @type {Promise<void>|null} */
95
+ let closing = null;
96
+ const close = () => {
97
+ closing ??= (async () => {
98
+ try {
99
+ await cdp.send('Target.detachFromTarget', { sessionId });
100
+ } catch {
101
+ // Detaching from something that already went away is fine.
102
+ }
103
+ try {
104
+ await cdp.close();
105
+ } catch {
106
+ // Hanging up cannot meaningfully fail.
107
+ }
108
+ // Deliberately nothing else. We did not start it; we do not stop it.
109
+ })();
110
+ return closing;
111
+ };
112
+
113
+ return { cdp, page, close, endpoint, pid: null, kind: app.kind };
114
+ } catch (e) {
115
+ await cdp.close().catch(() => {});
116
+ throw e;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Put the window at the size the pictures were approved at. Doing this before
122
+ * anyone gets the handle means every caller starts from the same geometry.
123
+ * @param {import('../types.js').LaunchedApp} launched
124
+ * @param {import('../types.js').ViewportConfig} viewport
125
+ * @returns {Promise<import('../types.js').LaunchedApp>}
126
+ */
127
+ async function withViewport(launched, viewport) {
128
+ try {
129
+ await launched.page.setViewport(viewport);
130
+ return launched;
131
+ } catch (e) {
132
+ // A half-launched app is a leaked process on somebody's machine.
133
+ await launched.close().catch(() => {});
134
+ throw e;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Open the project's app, whatever kind it is, and hand back a page to drive.
140
+ * @param {import('../types.js').Project} project
141
+ * @param {import('./browser.js').LaunchOptions} [opts]
142
+ * @returns {Promise<import('../types.js').LaunchedApp>}
143
+ */
144
+ export async function launchApp(project, opts = {}) {
145
+ const { app, viewport, freeze } = project.config;
146
+
147
+ /** @type {import('./browser.js').LaunchOptions} */
148
+ const launchOpts = {
149
+ ...opts,
150
+ viewport: opts.viewport ?? viewport,
151
+ // The clock's timezone and the locale have to be set on the process before
152
+ // it starts, so they travel down from the freeze settings with the launch.
153
+ timezone: opts.timezone ?? freeze.timezone,
154
+ locale: opts.locale ?? freeze.locale,
155
+ };
156
+
157
+ if (app.attach) {
158
+ return await withViewport(await attachToApp(app), viewport);
159
+ }
160
+
161
+ if (app.kind === 'electron') {
162
+ return await withViewport(await launchElectron(app, launchOpts), viewport);
163
+ }
164
+
165
+ /** @type {{stop: () => Promise<void>, pid: number|null}|null} */
166
+ let server = null;
167
+ try {
168
+ // The app has to be answering before the browser goes looking for it.
169
+ if (app.start) server = await startWebApp(app, launchOpts);
170
+ const browser = await launchBrowser(app, launchOpts);
171
+ const startedServer = server;
172
+
173
+ /** @type {import('../types.js').LaunchedApp} */
174
+ const launched = {
175
+ ...browser,
176
+ close: async () => {
177
+ // Browser first: the dev server is what it is looking at, and pulling
178
+ // the floor out first produces a page of connection errors.
179
+ await browser.close();
180
+ if (startedServer) await startedServer.stop();
181
+ },
182
+ };
183
+ return await withViewport(launched, viewport);
184
+ } catch (e) {
185
+ if (server) await server.stop().catch(() => {});
186
+ throw e;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Settle a desktop app between checks.
192
+ *
193
+ * This deliberately does NOT reload the window, and the reason is worth keeping.
194
+ *
195
+ * Reloading looked like the obvious way to give a desktop app the isolation a web app
196
+ * gets for free from its `goto`. It was tried, and it made a guard fail one run in two:
197
+ * a complex Electron renderer re-initialises on reload and asks its main process for
198
+ * state that the main process had already sent once and does not send again, so the app
199
+ * sometimes came back half-wired — a window that looked right with half its controls
200
+ * missing. A reset that works half the time is worse than no reset, because it turns a
201
+ * green suite into a coin toss and teaches people to re-run until it passes.
202
+ *
203
+ * So a desktop app is left alone. Its screens run in the order they are written, the
204
+ * pointer is parked, the DOM is allowed to go quiet, and a screen that changes something
205
+ * the app SAVES puts it back with its own `after` steps. That is honest about what a
206
+ * desktop app is, and it holds still.
207
+ *
208
+ * @param {import('../types.js').LaunchedApp} app
209
+ * @returns {Promise<void>}
210
+ */
211
+ export async function resetWindow(app) {
212
+ try {
213
+ await app.page.moveMouseAway();
214
+ await waitForQuietDom(app, { quietMs: 150, timeoutMs: 3000 });
215
+ } catch {
216
+ // Nothing here is worth failing a check over.
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Wait until the page stops changing itself.
222
+ *
223
+ * Resolves after `quietMs` with no DOM mutation, or gives up at `timeoutMs` — giving up
224
+ * is not an error, it just means the app never stops fidgeting and the settle loop will
225
+ * have to carry it.
226
+ *
227
+ * @param {import('../types.js').LaunchedApp} app
228
+ * @param {{quietMs?: number, timeoutMs?: number}} [opts]
229
+ * @returns {Promise<void>}
230
+ */
231
+ export async function waitForQuietDom(app, opts = {}) {
232
+ const quietMs = opts.quietMs ?? 250;
233
+ const timeoutMs = opts.timeoutMs ?? 5000;
234
+ const source = `(() => new Promise((resolve) => {
235
+ var done = false;
236
+ var timer = null;
237
+ var finish = function (why) {
238
+ if (done) return;
239
+ done = true;
240
+ try { obs.disconnect(); } catch (e) {}
241
+ if (timer) clearTimeout(timer);
242
+ clearTimeout(cap);
243
+ resolve(why);
244
+ };
245
+ var arm = function () {
246
+ if (timer) clearTimeout(timer);
247
+ timer = setTimeout(function () { finish('quiet'); }, ${quietMs});
248
+ };
249
+ var obs = new MutationObserver(arm);
250
+ try {
251
+ obs.observe(document.documentElement, { childList: true, subtree: true, attributes: true, characterData: true });
252
+ } catch (e) {
253
+ return finish('no-observer');
254
+ }
255
+ var cap = setTimeout(function () { finish('gave-up'); }, ${timeoutMs});
256
+ arm();
257
+ }))()`;
258
+ try {
259
+ await app.page.evaluate(source);
260
+ } catch {
261
+ // A context that vanished mid-wait is not worth failing over.
262
+ }
263
+ }