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,540 @@
1
+ /**
2
+ * Headless Chrome, opened the same way every single time.
3
+ *
4
+ * A picture check is only worth anything if the browser that took today's
5
+ * picture behaved exactly like the browser that took the approved one. Almost
6
+ * everything in this file exists to remove a source of difference: a leftover
7
+ * profile, a font smoothing setting, a background download, a throttled timer.
8
+ */
9
+
10
+ import { spawn } from 'node:child_process';
11
+ import fsp from 'node:fs/promises';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ import { StaysFixedError, isExpected } from '../core/errors.js';
16
+ import { detail } from '../core/log.js';
17
+ import { DEFAULT_VIEWPORT } from '../core/config.js';
18
+ import { waitForEndpoint, listTargets, connect } from './cdp.js';
19
+ import { requireChrome, freePort } from './find.js';
20
+ import { createPage } from './page.js';
21
+
22
+ /**
23
+ * @typedef {object} LaunchOptions
24
+ * @property {AbortSignal} [signal] Abort while we are waiting; the child is taken down with us.
25
+ * @property {string} [userDataDir] Use this profile instead of a throwaway one.
26
+ * @property {import('../types.js').ViewportConfig} [viewport]
27
+ * @property {string} [timezone] IANA zone forced on the process (from freeze.timezone).
28
+ * @property {string} [locale] BCP-47 locale forced on the process (from freeze.locale).
29
+ */
30
+
31
+ /** How many lines of the child's output we keep to quote back when it fails. */
32
+ const OUTPUT_KEEP = 40;
33
+
34
+ /**
35
+ * Sleep. Deliberately a normal timer: every wait in this file is something we
36
+ * are genuinely waiting for, and an unref'd one lets Node decide the program is
37
+ * idle and walk out in the middle of a poll.
38
+ * @param {number} ms
39
+ * @returns {Promise<void>}
40
+ */
41
+ export function delay(ms) {
42
+ return new Promise((resolve) => {
43
+ setTimeout(resolve, ms);
44
+ });
45
+ }
46
+
47
+ /**
48
+ * A timer made for racing, which can be called off. Without the cancel, a
49
+ * command that finishes early still sits in the terminal until the timeout it
50
+ * already beat runs out.
51
+ * @template T
52
+ * @param {number} ms
53
+ * @param {T} value
54
+ * @returns {{promise: Promise<T>, cancel: () => void}}
55
+ */
56
+ function raceTimer(ms, value) {
57
+ /** @type {any} */
58
+ let handle;
59
+ const promise = /** @type {Promise<T>} */ (
60
+ new Promise((resolve) => {
61
+ handle = setTimeout(() => resolve(value), ms);
62
+ })
63
+ );
64
+ return { promise, cancel: () => clearTimeout(handle) };
65
+ }
66
+
67
+ /**
68
+ * Keep the tail of everything a child says. When a browser or a dev server
69
+ * refuses to start, its last few lines are the only useful thing we can show a
70
+ * person — "could not start" on its own helps nobody.
71
+ * @param {import('node:child_process').ChildProcess} child
72
+ * @returns {() => string} the last few lines, newest last
73
+ */
74
+ export function keepOutput(child) {
75
+ /** @type {string[]} */
76
+ const lines = [];
77
+ /** @param {string|Buffer} chunk */
78
+ const take = (chunk) => {
79
+ for (const line of String(chunk).split('\n')) {
80
+ const trimmed = line.trim();
81
+ if (!trimmed) continue;
82
+ lines.push(trimmed);
83
+ if (lines.length > OUTPUT_KEEP) lines.shift();
84
+ }
85
+ };
86
+ child.stdout?.on('data', take);
87
+ child.stderr?.on('data', take);
88
+ return () => lines.slice(-8).join('\n');
89
+ }
90
+
91
+ /**
92
+ * @param {import('node:child_process').ChildProcess} child
93
+ * @returns {boolean}
94
+ */
95
+ function isGone(child) {
96
+ return child.exitCode !== null || child.signalCode !== null;
97
+ }
98
+
99
+ /**
100
+ * @param {import('node:child_process').ChildProcess} child
101
+ * @returns {Promise<void>}
102
+ */
103
+ export function whenExited(child) {
104
+ if (isGone(child)) return Promise.resolve();
105
+ return new Promise((resolve) => {
106
+ child.once('exit', () => resolve());
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Ask a process to stop, then insist. Safe to call on something already gone.
112
+ * @param {import('node:child_process').ChildProcess} child
113
+ * @param {number} graceMs how long politeness gets before SIGKILL
114
+ * @returns {Promise<void>}
115
+ */
116
+ export async function stopProcess(child, graceMs) {
117
+ if (isGone(child)) return;
118
+ const exited = whenExited(child);
119
+ try {
120
+ child.kill('SIGTERM');
121
+ } catch {
122
+ // Already gone between the check and the signal. Nothing to do.
123
+ }
124
+ const grace = raceTimer(graceMs, false);
125
+ const stopped = await Promise.race([exited.then(() => true), grace.promise]);
126
+ grace.cancel();
127
+ if (stopped) return;
128
+ try {
129
+ child.kill('SIGKILL');
130
+ } catch {
131
+ // Same race as above.
132
+ }
133
+ const last = raceTimer(1000, false);
134
+ await Promise.race([exited, last.promise]);
135
+ last.cancel();
136
+ }
137
+
138
+ /**
139
+ * The environment a launched app runs in.
140
+ * @param {import('../types.js').AppConfig} app
141
+ * @param {LaunchOptions} opts
142
+ * @returns {NodeJS.ProcessEnv}
143
+ */
144
+ export function childEnv(app, opts) {
145
+ /** @type {NodeJS.ProcessEnv} */
146
+ const env = { ...process.env, ...(app.env ?? {}) };
147
+ // The timezone has to be settled before the process starts. A timestamp
148
+ // rendered in Karachi and the same timestamp rendered in UTC are two
149
+ // different pictures, and the page can only override so much after the fact.
150
+ if (opts.timezone) env.TZ = opts.timezone;
151
+ if (opts.locale) {
152
+ // BCP-47 ('en-US') is what the page speaks; POSIX ('en_US.UTF-8') is what
153
+ // the process speaks. Same locale, two spellings.
154
+ const posix = opts.locale.replace('-', '_');
155
+ env.LANG = `${posix}.UTF-8`;
156
+ env.LC_ALL = `${posix}.UTF-8`;
157
+ }
158
+ return env;
159
+ }
160
+
161
+ /**
162
+ * The flags. Each group removes one way today's picture could differ from the
163
+ * approved one for a reason that has nothing to do with the code.
164
+ * @param {import('../types.js').AppConfig} app
165
+ * @param {{port: number, profileDir: string, viewport: Required<import('../types.js').ViewportConfig>}} ctx
166
+ * @returns {string[]}
167
+ */
168
+ function chromeArgs(app, ctx) {
169
+ const { port, profileDir, viewport } = ctx;
170
+ /** @type {string[]} */
171
+ const args = [];
172
+
173
+ if (app.headless !== false) args.push('--headless=new');
174
+ args.push(
175
+ `--remote-debugging-port=${port}`,
176
+ // Node's WebSocket sends no Origin header, but Electron-era Chrome refuses
177
+ // sockets from an unknown one; saying "any" costs nothing on a throwaway browser.
178
+ '--remote-allow-origins=*',
179
+ // Never, ever the user's real profile: their cookies, extensions and
180
+ // half-open tabs are exactly the kind of difference this tool must not have.
181
+ `--user-data-dir=${profileDir}`,
182
+ );
183
+
184
+ // Pixels must land in the same place run after run. Subpixel text, the GPU's
185
+ // idea of anti-aliasing and a colour profile read from the monitor are the
186
+ // three classic reasons an identical page photographs differently.
187
+ args.push(
188
+ `--force-device-scale-factor=${viewport.deviceScaleFactor}`,
189
+ '--force-color-profile=srgb',
190
+ '--font-render-hinting=none',
191
+ '--disable-lcd-text',
192
+ '--disable-font-subpixel-positioning',
193
+ '--disable-gpu',
194
+ '--disable-skia-runtime-opts',
195
+ '--disable-partial-raster',
196
+ '--disable-composited-antialiasing',
197
+ '--hide-scrollbars',
198
+ '--disable-smooth-scrolling',
199
+ '--force-prefers-reduced-motion',
200
+ );
201
+
202
+ // Nothing may pop up in front of the app, and nothing may go out to the
203
+ // network on Chrome's own account. Both would show up in the picture, and the
204
+ // second one also makes runs depend on somebody else's server being awake.
205
+ args.push(
206
+ '--no-first-run',
207
+ '--no-default-browser-check',
208
+ '--disable-extensions',
209
+ '--disable-background-networking',
210
+ '--disable-component-update',
211
+ '--disable-default-apps',
212
+ '--disable-sync',
213
+ '--metrics-recording-only',
214
+ '--disable-client-side-phishing-detection',
215
+ '--no-service-autorun',
216
+ '--password-store=basic',
217
+ '--use-mock-keychain',
218
+ '--mute-audio',
219
+ '--disable-notifications',
220
+ '--deny-permission-prompts',
221
+ );
222
+
223
+ // A headless window counts as hidden, and Chrome slows hidden pages down.
224
+ // That turns "wait for the list to load" into a flaky timeout.
225
+ args.push(
226
+ '--disable-background-timer-throttling',
227
+ '--disable-backgrounding-occluded-windows',
228
+ '--disable-renderer-backgrounding',
229
+ '--disable-ipc-flooding-protection',
230
+ );
231
+
232
+ // Containers give /dev/shm 64MB, which crashes tabs mid-screenshot.
233
+ args.push('--disable-dev-shm-usage');
234
+ // Chrome refuses to run as root with a sandbox. CI images often are root;
235
+ // a person's laptop never is, so this stays off unless we truly have to.
236
+ if (process.platform === 'linux' && process.getuid?.() === 0) args.push('--no-sandbox');
237
+
238
+ args.push(`--window-size=${Math.round(viewport.width)},${Math.round(viewport.height)}`);
239
+ // Open on a blank page on purpose: the new-tab page talks to Google and
240
+ // renders differently depending on who is signed in.
241
+ args.push('about:blank');
242
+ return args;
243
+ }
244
+
245
+ /**
246
+ * Find the tab we are going to drive. A freshly started Chrome sometimes lists
247
+ * its page a beat after it starts answering, so we ask again rather than fail.
248
+ * @param {string} endpoint
249
+ * @param {number} timeoutMs
250
+ * @returns {Promise<any>}
251
+ */
252
+ async function firstPageTarget(endpoint, timeoutMs) {
253
+ const deadline = Date.now() + timeoutMs;
254
+ for (;;) {
255
+ const targets = /** @type {any[]} */ (await listTargets(endpoint));
256
+ const page = targets.find(
257
+ (t) => t && t.type === 'page' && !String(t.url ?? '').startsWith('devtools://'),
258
+ );
259
+ if (page) return page;
260
+ if (Date.now() > deadline) {
261
+ throw new StaysFixedError('The browser started but never opened a page to look at.', {
262
+ hint: 'This usually means Chrome was killed while starting. Try running the same check again.',
263
+ });
264
+ }
265
+ await delay(150);
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Start a browser and attach to its first page.
271
+ * @param {import('../types.js').AppConfig} app
272
+ * @param {LaunchOptions} [opts]
273
+ * @returns {Promise<import('../types.js').LaunchedApp>}
274
+ */
275
+ export async function launchBrowser(app, opts = {}) {
276
+ if (opts.signal?.aborted) {
277
+ throw new StaysFixedError('Stopped before the browser could start.');
278
+ }
279
+
280
+ const chrome = await requireChrome(app.browser);
281
+ const port = await freePort(app.debugPort);
282
+ const viewport = { ...DEFAULT_VIEWPORT, ...(opts.viewport ?? {}) };
283
+ const startTimeoutMs = app.startTimeoutMs ?? 60_000;
284
+
285
+ // A throwaway profile is what lets us promise we never touched the browser
286
+ // the person actually uses.
287
+ const ownProfile = !opts.userDataDir;
288
+ const profileDir = opts.userDataDir ?? (await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-chrome-')));
289
+
290
+ const args = chromeArgs(app, { port, profileDir, viewport });
291
+ detail(`browser: ${chrome}`);
292
+ detail(`debugging port: ${port}`);
293
+
294
+ const child = spawn(chrome, args, {
295
+ cwd: app.cwd,
296
+ env: childEnv(app, opts),
297
+ stdio: ['ignore', 'pipe', 'pipe'],
298
+ signal: opts.signal,
299
+ });
300
+ const output = keepOutput(child);
301
+
302
+ /**
303
+ * If the browser dies while we are waiting, say so immediately instead of
304
+ * sitting out the whole timeout.
305
+ */
306
+ const diedEarly = /** @type {Promise<never>} */ (
307
+ new Promise((_resolve, reject) => {
308
+ child.once('error', (cause) => {
309
+ reject(
310
+ new StaysFixedError(`Could not run the browser at ${chrome}.`, {
311
+ hint: 'Set `app.browser` in your config to the Chrome or Chromium you want used.',
312
+ cause,
313
+ }),
314
+ );
315
+ });
316
+ child.once('exit', (code, signal) => {
317
+ reject(
318
+ new StaysFixedError(
319
+ `The browser quit before it was ready (${signal ? `signal ${signal}` : `exit code ${code}`}).`,
320
+ { hint: quote(output()) },
321
+ ),
322
+ );
323
+ });
324
+ })
325
+ );
326
+ // The browser exiting later, on purpose, must not crash the CLI with an
327
+ // unhandled rejection. This catch does not stop the race below from seeing it.
328
+ diedEarly.catch(() => {});
329
+
330
+ const endpoint = `http://127.0.0.1:${port}`;
331
+ /** @type {import('../types.js').CdpSession|null} */
332
+ let session = null;
333
+
334
+ try {
335
+ // waitForEndpoint hands back the browser's own description of itself,
336
+ // which is where the debugging address lives.
337
+ /** @type {any} */
338
+ let version;
339
+ try {
340
+ version = await Promise.race([
341
+ waitForEndpoint(endpoint, { timeoutMs: startTimeoutMs, intervalMs: 100 }),
342
+ diedEarly,
343
+ ]);
344
+ } catch (cause) {
345
+ if (isExpected(cause)) throw cause;
346
+ throw new StaysFixedError('The browser started but never answered, so nothing could be photographed.', {
347
+ hint: quote(output()) || 'Run the same command again with --verbose to see what the browser printed.',
348
+ cause,
349
+ });
350
+ }
351
+
352
+ const wsUrl = version?.webSocketDebuggerUrl;
353
+ if (!wsUrl) {
354
+ throw new StaysFixedError('The browser answered but did not offer a debugging connection.');
355
+ }
356
+
357
+ const cdp = /** @type {import('../types.js').CdpSession} */ (await connect(wsUrl, { timeoutMs: 15_000 }));
358
+ session = cdp;
359
+
360
+ const target = await firstPageTarget(endpoint, Math.min(startTimeoutMs, 20_000));
361
+ const targetId = String(target.id ?? target.targetId);
362
+ const attached = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
363
+ const sessionId = String(attached.sessionId);
364
+
365
+ const page = await createPage(
366
+ cdp,
367
+ /** @type {any} */ ({ sessionId, targetId, baseUrl: app.url ?? null, timeoutMs: startTimeoutMs }),
368
+ );
369
+
370
+ /** @type {Promise<void>|null} */
371
+ let closing = null;
372
+ const close = () => {
373
+ // Safe to call twice: the second caller waits on the first close.
374
+ closing ??= (async () => {
375
+ try {
376
+ await cdp.send('Target.detachFromTarget', { sessionId });
377
+ } catch {
378
+ // The page may already be gone; that is the outcome we wanted anyway.
379
+ }
380
+ try {
381
+ await cdp.send('Browser.close');
382
+ } catch {
383
+ // Asking politely can fail if it is already quitting. The signals below finish the job.
384
+ }
385
+ try {
386
+ await cdp.close();
387
+ } catch {
388
+ // Hanging up cannot meaningfully fail.
389
+ }
390
+ await stopProcess(child, 3000);
391
+ if (ownProfile) {
392
+ // Throwaway profiles are small but a check runs hundreds of times.
393
+ await fsp.rm(profileDir, { recursive: true, force: true }).catch(() => {});
394
+ }
395
+ })();
396
+ return closing;
397
+ };
398
+
399
+ return { cdp, page, close, endpoint, pid: child.pid ?? null, kind: 'web' };
400
+ } catch (e) {
401
+ // Never leave a browser behind because starting failed halfway.
402
+ if (session) await session.close().catch(() => {});
403
+ await stopProcess(child, 2000);
404
+ if (ownProfile) await fsp.rm(profileDir, { recursive: true, force: true }).catch(() => {});
405
+ throw e;
406
+ }
407
+ }
408
+
409
+ /**
410
+ * @param {string} text
411
+ * @returns {string|undefined}
412
+ */
413
+ function quote(text) {
414
+ return text ? `The last thing it said:\n${text}` : undefined;
415
+ }
416
+
417
+ /**
418
+ * Does anything answer at this address? Any HTTP reply counts, including a 404
419
+ * or a 500 — a dev server that says "not found" is a dev server that is up.
420
+ * @param {string} url
421
+ * @returns {Promise<boolean>}
422
+ */
423
+ async function answers(url) {
424
+ for (const method of ['HEAD', 'GET']) {
425
+ try {
426
+ const res = await fetch(url, { method, redirect: 'manual', signal: AbortSignal.timeout(3000) });
427
+ // Let the body go, otherwise the socket stays open for the whole run.
428
+ await res.body?.cancel().catch(() => {});
429
+ return true;
430
+ } catch {
431
+ // HEAD is refused by some dev servers; GET gets a second chance below.
432
+ }
433
+ }
434
+ return false;
435
+ }
436
+
437
+ /**
438
+ * Start the app's own dev server and wait until it answers.
439
+ * @param {import('../types.js').AppConfig} app
440
+ * @param {LaunchOptions} [opts]
441
+ * @returns {Promise<{stop: () => Promise<void>, pid: number|null}>}
442
+ */
443
+ export async function startWebApp(app, opts = {}) {
444
+ if (!app.start) {
445
+ throw new StaysFixedError('There is no start command to run.', {
446
+ hint: 'Set `app.start` in your config, or start the app yourself before running this.',
447
+ });
448
+ }
449
+ const url = app.url;
450
+ if (!url) {
451
+ throw new StaysFixedError('`app.url` is missing, so there is no address to wait for.');
452
+ }
453
+
454
+ const child = spawn(app.start, {
455
+ cwd: app.cwd ?? process.cwd(),
456
+ env: childEnv(app, opts),
457
+ shell: true,
458
+ // Its own process group. A dev server is really a shell that spawns a
459
+ // bundler that spawns a watcher; killing only the shell leaves the port held
460
+ // and the next run fails for a reason nobody can see.
461
+ detached: true,
462
+ stdio: ['ignore', 'pipe', 'pipe'],
463
+ });
464
+ const output = keepOutput(child);
465
+ // A shell that cannot even be started emits 'error' and never 'exit', so we
466
+ // hold on to it and report it from the wait loop instead of waiting it out.
467
+ /** @type {{ failure: Error|null }} */
468
+ const startFailure = { failure: null };
469
+ child.on('error', (e) => {
470
+ startFailure.failure = e;
471
+ });
472
+
473
+ const pid = child.pid ?? null;
474
+
475
+ /** @type {Promise<void>|null} */
476
+ let stopping = null;
477
+ const stop = () => {
478
+ stopping ??= (async () => {
479
+ if (isGone(child)) return;
480
+ const exited = whenExited(child);
481
+ try {
482
+ // Negative pid means the whole group — the only way to take the
483
+ // watchers down with the server.
484
+ if (pid) process.kill(-pid, 'SIGTERM');
485
+ } catch {
486
+ try {
487
+ child.kill('SIGTERM');
488
+ } catch {
489
+ // Already gone.
490
+ }
491
+ }
492
+ const grace = raceTimer(5000, false);
493
+ const gone = await Promise.race([exited.then(() => true), grace.promise]);
494
+ grace.cancel();
495
+ if (gone) return;
496
+ try {
497
+ if (pid) process.kill(-pid, 'SIGKILL');
498
+ } catch {
499
+ try {
500
+ child.kill('SIGKILL');
501
+ } catch {
502
+ // Already gone.
503
+ }
504
+ }
505
+ const last = raceTimer(1000, false);
506
+ await Promise.race([exited, last.promise]);
507
+ last.cancel();
508
+ })();
509
+ return stopping;
510
+ };
511
+
512
+ detail(`starting the app: ${app.start}`);
513
+ const deadline = Date.now() + (app.startTimeoutMs ?? 60_000);
514
+ for (;;) {
515
+ if (await answers(url)) {
516
+ detail(`the app answered at ${url}`);
517
+ return { stop, pid };
518
+ }
519
+ if (startFailure.failure) {
520
+ throw new StaysFixedError(`Could not run the start command: ${app.start}`, {
521
+ hint: 'Check `app.start` and `app.cwd` in your config.',
522
+ cause: startFailure.failure,
523
+ });
524
+ }
525
+ if (isGone(child)) {
526
+ throw new StaysFixedError(`The start command stopped before ${url} answered.`, {
527
+ hint: quote(output()) ?? 'Try running the start command yourself to see what it says.',
528
+ });
529
+ }
530
+ if (Date.now() > deadline) {
531
+ await stop();
532
+ throw new StaysFixedError(`Waited for ${url} but nothing answered.`, {
533
+ hint:
534
+ quote(output()) ??
535
+ 'Check that `app.start` really serves `app.url`, or raise `app.startTimeoutMs` if it is just slow.',
536
+ });
537
+ }
538
+ await delay(250);
539
+ }
540
+ }