@specific.dev/spectest 0.128.0 → 0.130.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/terminal.js CHANGED
@@ -20,7 +20,7 @@
20
20
  //
21
21
  // PTY-on-the-host trick: the daemon runs under systemd with no controlling
22
22
  // TTY, so `docker exec -it` (the docker CLI rejects -it when its own stdin
23
- // isn't a TTY) needs a PTY wrapper. `script -qfc 'CMD' /dev/null` allocates
23
+ // isn't a TTY) needs a PTY wrapper. `script -qfec 'CMD' /dev/null` allocates
24
24
  // a PTY for CMD; we set the slave's size with `stty rows R cols C` inside
25
25
  // the wrapped command so the container app sees the cols/rows the caller
26
26
  // asked for. `script(1)` ships in util-linux which is essential on Debian,
@@ -97,9 +97,10 @@ export async function openTerminal(args) {
97
97
  // 80x24, but the daemon has none, so it's whatever util-linux picks
98
98
  // as the fallback).
99
99
  const inner = `stty rows ${rows} cols ${cols} 2>/dev/null; exec ${shellQuote(["docker", ...dockerArgs])}`;
100
- // `script -q -f -c CMD /dev/null` runs CMD in a PTY (no preamble, flushed
100
+ // `script -q -f -e -c CMD /dev/null` runs CMD in a PTY (no preamble, flushed
101
101
  // after each write, output discarded — we capture via stdout pipe).
102
- const spawnArgs = ["script", "-qfc", inner, "/dev/null"];
102
+ // -e preserves the child command status; without it script reports 0 on failure.
103
+ const spawnArgs = ["script", "-qfec", inner, "/dev/null"];
103
104
  const start = Date.now();
104
105
  const proc = Bun.spawn(spawnArgs, {
105
106
  stdin: "pipe",
@@ -119,14 +120,22 @@ export async function openTerminal(args) {
119
120
  let exitCode;
120
121
  let closed = false;
121
122
  let resolveExit;
122
- const rawExited = new Promise((res) => {
123
+ let rejectExit;
124
+ let exitSettled = false;
125
+ const exitWaiters = new Set();
126
+ const cancelWaits = new Set();
127
+ const rawExited = new Promise((res, reject) => {
123
128
  resolveExit = res;
129
+ rejectExit = reject;
124
130
  });
131
+ // Lifecycle errors are observed by waitFor/exited/close, possibly much later.
132
+ void rawExited.catch(() => { });
125
133
  // Cast time at the moment the process actually exited. Captured by
126
134
  // the IIFE below so the `exit` step we record points the player at
127
135
  // the very end of the visible session.
128
136
  let exitCastTimeSec;
129
- const decoder = new TextDecoder("utf-8", { fatal: false });
137
+ let outputEnded = false;
138
+ const readers = new Set();
130
139
  // Subscribers waiting for the next frame to arrive. Used by send /
131
140
  // sendLine / press so the recorded `castTimeSec` lands on the echo's
132
141
  // frame instead of microseconds before it.
@@ -143,13 +152,16 @@ export async function openTerminal(args) {
143
152
  // millisecond as the following `sendLine`'s echo showed that echo
144
153
  // — the replay ran one step ahead of the step list.
145
154
  let lastFrameT = 0;
155
+ let lastRenderedFrameT = 0;
146
156
  const ingest = (chunk) => {
157
+ if (outputEnded)
158
+ return;
147
159
  let t = (Date.now() - start) / 1000;
148
160
  if (t <= lastFrameT)
149
161
  t = lastFrameT + 0.001;
150
162
  lastFrameT = t;
151
163
  sink.pushFrame(t, chunk);
152
- emu.write(chunk);
164
+ emu.write(chunk, () => { lastRenderedFrameT = t; });
153
165
  if (output.length < OUTPUT_CAP_BYTES) {
154
166
  const room = OUTPUT_CAP_BYTES - output.length;
155
167
  output += chunk.length > room ? chunk.slice(0, room) : chunk;
@@ -173,21 +185,31 @@ export async function openTerminal(args) {
173
185
  if (done)
174
186
  return;
175
187
  done = true;
188
+ clearTimeout(timeout);
189
+ const index = frameWaiters.indexOf(finish);
190
+ if (index >= 0)
191
+ frameWaiters.splice(index, 1);
176
192
  resolve();
177
193
  };
194
+ const timeout = setTimeout(finish, timeoutMs);
178
195
  frameWaiters.push(finish);
179
- setTimeout(finish, timeoutMs);
180
196
  });
181
197
  };
182
198
  const drainStream = async (stream) => {
183
199
  if (!stream)
184
200
  return;
185
201
  const reader = stream.getReader();
202
+ const decoder = new TextDecoder("utf-8", { fatal: false });
203
+ readers.add(reader);
186
204
  try {
187
205
  while (true) {
188
206
  const { done, value } = await reader.read();
189
- if (done)
207
+ if (done) {
208
+ const tail = decoder.decode();
209
+ if (tail)
210
+ ingest(tail);
190
211
  return;
212
+ }
191
213
  if (!value || value.byteLength === 0)
192
214
  continue;
193
215
  const chunk = decoder.decode(value, { stream: true });
@@ -197,15 +219,23 @@ export async function openTerminal(args) {
197
219
  }
198
220
  }
199
221
  finally {
222
+ readers.delete(reader);
200
223
  reader.releaseLock();
201
224
  }
202
225
  };
226
+ const stopOutput = () => {
227
+ outputEnded = true;
228
+ for (const reader of readers)
229
+ void reader.cancel().catch(() => { });
230
+ };
231
+ const flushScreen = () => new Promise((resolve) => emu.write("", resolve));
203
232
  // Drain stdout + stderr concurrently. `script` writes everything to
204
233
  // stdout under `-t`, but we drain stderr anyway in case script(1)
205
234
  // itself emits diagnostics there.
206
235
  const stdout = proc.stdout;
207
236
  const stderr = proc.stderr;
208
237
  const drained = Promise.all([drainStream(stdout), drainStream(stderr)]);
238
+ void drained.catch(() => { });
209
239
  // Race the optional session-level timeout. For interactive terminals
210
240
  // we usually have no timeout; the wrapper for one-shot supplies one.
211
241
  let timer;
@@ -221,39 +251,42 @@ export async function openTerminal(args) {
221
251
  }
222
252
  }, opts.timeoutMs);
223
253
  }
224
- // When the process exits, drain whatever's left and resolve `exited`.
225
- // We don't await `drained` here — it's already running and will
226
- // settle on its own; the explicit `await drained` happens in close().
227
- (async () => {
228
- const code = await proc.exited;
229
- exited = true;
230
- exitCode = code;
231
- if (timer)
232
- clearTimeout(timer);
233
- // Let `drained` finish so the final asciicast frames land before we
234
- // mark the sink closed. drainStream is bounded by the OS pipe being
235
- // closed when proc exits, so this won't hang.
254
+ // A child may inherit a pipe after the PTY wrapper exits. Bound the drain,
255
+ // and flush xterm's asynchronous writes before exposing the final screen.
256
+ void (async () => {
236
257
  try {
237
- await drained;
258
+ const code = await proc.exited;
259
+ exited = true;
260
+ exitCode = code;
261
+ if (timer)
262
+ clearTimeout(timer);
263
+ await within(drained.then(flushScreen), 5_000, "Terminal output did not finish draining within 5000ms");
264
+ exitCastTimeSec = lastRenderedFrameT + 0.0005;
265
+ sink.markClosed();
266
+ resolveExit(code);
238
267
  }
239
- catch {
240
- /* ignore */
268
+ catch (error) {
269
+ stopOutput();
270
+ sink.markClosed();
271
+ rejectExit(error);
272
+ }
273
+ finally {
274
+ exitSettled = true;
275
+ if (timer)
276
+ clearTimeout(timer);
277
+ for (const wake of exitWaiters)
278
+ wake();
279
+ exitWaiters.clear();
241
280
  }
242
- // Record cast time AFTER drain, so the `exit` step we record below
243
- // points the player at the very last frame (the program's final
244
- // output before EOF). Same between-frames stamp as `recordOpEvent`.
245
- exitCastTimeSec = lastFrameT + 0.0005;
246
- sink.markClosed();
247
- resolveExit(code);
248
281
  })();
249
282
  // Helper to write raw bytes to the PTY. We treat stdin as binary
250
283
  // pipes (Bun's `proc.stdin` is a Web `WritableStream<Uint8Array>` or
251
284
  // a FileSink-like depending on the version — use the FileSink-style
252
285
  // `write(string)` which both shapes accept).
253
286
  const stdinWriter = proc.stdin;
254
- const writeStdin = async (data) => {
255
- if (exited || closed || !stdinWriter)
256
- return;
287
+ const writeStdin = async (data, closing = false) => {
288
+ if (exited || (closed && !closing) || !stdinWriter)
289
+ throw new Error(`Terminal ${JSON.stringify(service)} is ${exited ? "exited" : "closed"}`);
257
290
  stdinWriter.write(data);
258
291
  try {
259
292
  const r = stdinWriter.flush?.();
@@ -326,9 +359,17 @@ export async function openTerminal(args) {
326
359
  const getExited = () => {
327
360
  if (exitedAccessor)
328
361
  return exitedAccessor;
329
- const resv = reserveEvent();
330
362
  exitedAccessor = (async () => {
331
- const code = await rawExited;
363
+ let code;
364
+ try {
365
+ code = await rawExited;
366
+ }
367
+ catch (error) {
368
+ recordOpEvent("exit", { exitCode }, Date.now(), errorMessage(error));
369
+ throw error;
370
+ }
371
+ // Reserve on actual completion, not when .exited is read for a race.
372
+ const resv = reserveEvent();
332
373
  const result = buildResult(code);
333
374
  // No recorder (eval/setup): there's no event to nest under, but we
334
375
  // still wrap (with an undefined seq) so the accessor's wrapped type is
@@ -353,6 +394,7 @@ export async function openTerminal(args) {
353
394
  })();
354
395
  return exitedAccessor;
355
396
  };
397
+ let closePromise;
356
398
  const handle = {
357
399
  cols,
358
400
  rows,
@@ -424,93 +466,207 @@ export async function openTerminal(args) {
424
466
  return renderScreen();
425
467
  },
426
468
  async waitFor(description, matcher, waitOpts) {
427
- const timeoutMs = waitOpts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS;
428
- const intervalMs = waitOpts?.intervalMs ?? 100;
469
+ const timeoutMs = durationOption(waitOpts?.timeoutMs, DEFAULT_ACTION_TIMEOUT_MS, "timeoutMs");
470
+ const intervalMs = durationOption(waitOpts?.intervalMs, 100, "intervalMs");
429
471
  const t = Date.now();
430
472
  const resv = reserveEvent();
431
- const deadline = t + timeoutMs;
473
+ const castStartTimeSec = Math.max((t - start) / 1000, lastFrameT + 0.0005);
474
+ const condition = typeof matcher === "function"
475
+ ? { kind: "custom", ...(waitOpts?.condition ? { description: waitOpts.condition } : {}) }
476
+ : describeMatcher(matcher);
477
+ const failureMatchers = waitOpts?.failOn?.map(({ description, matcher }) => ({
478
+ description, matcher: describeMatcher(matcher),
479
+ }));
432
480
  let attempts = 0;
433
- const check = () => {
434
- attempts++;
435
- const scr = renderScreen();
436
- if (typeof matcher === "string") {
437
- return scr.includes(matcher) ? matcher : null;
438
- }
439
- if (matcher instanceof RegExp) {
440
- const m = scr.match(matcher);
441
- return m ?? null;
442
- }
443
- return matcher(scr, output);
481
+ let observed;
482
+ let outcome = "predicate_error";
483
+ const control = new AbortController();
484
+ const cancel = () => {
485
+ outcome = "cancelled";
486
+ control.abort(waitOpts?.signal?.reason ?? new Error(`waitFor ${JSON.stringify(description)} cancelled: terminal closed`));
444
487
  };
445
- while (true) {
446
- const v = check();
447
- if (v) {
448
- const seq = recordOpEvent("waitFor", { description, attempts, matched: true }, t, undefined, resv);
449
- // Tag the matched value so an `expect(...)` on it links its
450
- // assertion back to this waitFor step (sourceSeq) instead of
451
- // rendering as an orphan sidebar row. Mirrors how http/db/exec
452
- // results carry their op's seq. `as never` lets this single
453
- // body satisfy the three overload return types.
454
- return wrap(v, seq);
455
- }
456
- if (Date.now() >= deadline) {
457
- const err = `waitFor ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${attempts} attempts)`;
458
- recordOpEvent("waitFor", { description, attempts, matched: false }, t, err, resv);
459
- throw new Error(err);
488
+ const timeout = setTimeout(() => {
489
+ outcome = "timed_out";
490
+ control.abort(new Error(`waitFor ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${attempts} attempts)`));
491
+ }, timeoutMs);
492
+ cancelWaits.add(cancel);
493
+ waitOpts?.signal?.addEventListener("abort", cancel, { once: true });
494
+ if (waitOpts?.signal?.aborted || closed)
495
+ cancel();
496
+ const waitFields = () => ({
497
+ description, attempts, matched: outcome === "matched", outcome,
498
+ matcher: condition, failureMatchers, timeoutMs, intervalMs,
499
+ ...(outcome === "process_exited" ? { exitCode } : {}),
500
+ castStartTimeSec,
501
+ castEndTimeSec: castStartTimeSec + (Date.now() - t) / 1000,
502
+ });
503
+ const pause = () => new Promise((resolve, reject) => {
504
+ const cleanup = () => {
505
+ clearTimeout(poll);
506
+ exitWaiters.delete(wake);
507
+ control.signal.removeEventListener("abort", abort);
508
+ };
509
+ const wake = () => { cleanup(); resolve(); };
510
+ const abort = () => { cleanup(); reject(control.signal.reason); };
511
+ const poll = setTimeout(wake, intervalMs);
512
+ exitWaiters.add(wake);
513
+ control.signal.addEventListener("abort", abort, { once: true });
514
+ if (control.signal.aborted)
515
+ abort();
516
+ else if (exitSettled)
517
+ wake();
518
+ });
519
+ try {
520
+ while (true) {
521
+ control.signal.throwIfAborted();
522
+ if (exited || exitSettled) {
523
+ try {
524
+ await withSignal(rawExited, control.signal);
525
+ }
526
+ catch (error) {
527
+ if (!control.signal.aborted)
528
+ outcome = "process_exited";
529
+ throw error;
530
+ }
531
+ }
532
+ control.signal.throwIfAborted();
533
+ const checkedFinalScreen = exitSettled;
534
+ const screen = renderScreen();
535
+ const preview = truncateUtf8(screen);
536
+ observed = { castTimeSec: lastRenderedFrameT + 0.0005,
537
+ screenPreview: preview.value, screenTruncated: preview.truncated };
538
+ attempts++;
539
+ for (const failure of waitOpts?.failOn ?? []) {
540
+ if (matchScreen(screen, failure.matcher)) {
541
+ outcome = "failure_matched";
542
+ throw new Error(`waitFor ${JSON.stringify(description)} failed: ${failure.description}`);
543
+ }
544
+ }
545
+ const observedOutput = output;
546
+ const value = typeof matcher === "function"
547
+ ? await withSignal(Promise.resolve().then(() => {
548
+ control.signal.throwIfAborted();
549
+ return matcher(screen, observedOutput);
550
+ }), control.signal)
551
+ : matchScreen(screen, matcher);
552
+ control.signal.throwIfAborted();
553
+ if (value) {
554
+ outcome = "matched";
555
+ const seq = recordOpEvent("waitFor", { ...waitFields(), ...observed }, t, undefined, resv);
556
+ return wrap(value, seq);
557
+ }
558
+ if (exitSettled && !checkedFinalScreen)
559
+ continue;
560
+ if (exitSettled) {
561
+ outcome = "process_exited";
562
+ throw new Error(`waitFor ${JSON.stringify(description)} failed: terminal exited with code ${exitCode} before the condition matched`);
563
+ }
564
+ await pause();
460
565
  }
461
- await new Promise((r) => setTimeout(r, intervalMs));
566
+ }
567
+ catch (error) {
568
+ recordOpEvent("waitFor", { ...waitFields(),
569
+ ...((outcome === "predicate_error" || outcome === "failure_matched") ? observed : {}),
570
+ }, t, errorMessage(error), resv);
571
+ throw error;
572
+ }
573
+ finally {
574
+ clearTimeout(timeout);
575
+ cancelWaits.delete(cancel);
576
+ waitOpts?.signal?.removeEventListener("abort", cancel);
462
577
  }
463
578
  },
464
- async close() {
465
- // Use `rawExited` rather than `term.exited` everywhere we need a
466
- // raw number internally — the public getter resolves to a wrapped
467
- // result (and records an `exit` step on first read) which we don't
468
- // want as a side-effect of internal teardown logic.
469
- if (closed)
470
- return buildResult(exitCode ?? (await rawExited));
471
- closed = true;
579
+ close(closeOpts) {
580
+ if (closePromise)
581
+ return closePromise;
582
+ const timeoutMs = durationOption(closeOpts?.timeoutMs, 10_000, "timeoutMs");
472
583
  const t = Date.now();
473
584
  const resv = reserveEvent();
474
- // Polite first: send EOF (Ctrl-D). For a login shell this exits
475
- // cleanly. For a long-running command it's a no-op and we fall
476
- // through to SIGKILL.
477
- try {
478
- await writeStdin("\x04");
479
- }
480
- catch {
481
- /* ignore */
482
- }
483
- // Closing stdin signals EOF to the script wrapper too.
484
- try {
485
- proc.stdin?.end?.();
486
- }
487
- catch {
488
- /* ignore */
489
- }
490
- // Grace period before forcibly killing.
491
- const grace = 1_500;
492
- const raced = await Promise.race([
493
- rawExited,
494
- new Promise((r) => setTimeout(() => r("timeout"), grace)),
495
- ]);
496
- if (raced === "timeout") {
585
+ closed = true;
586
+ for (const cancel of cancelWaits)
587
+ cancel();
588
+ closePromise = (async () => {
497
589
  try {
498
- proc.kill();
590
+ if (!exited) {
591
+ // Send EOF even though new user operations are now closed.
592
+ try {
593
+ await writeStdin("\x04", true);
594
+ }
595
+ catch { /* already exited */ }
596
+ try {
597
+ proc.stdin?.end?.();
598
+ }
599
+ catch { /* already closed */ }
600
+ }
601
+ const grace = Math.min(1_500, timeoutMs / 2);
602
+ let graceTimer;
603
+ const finished = await Promise.race([
604
+ rawExited.then(() => true),
605
+ new Promise((resolve) => { graceTimer = setTimeout(() => resolve(false), grace); }),
606
+ ]).finally(() => clearTimeout(graceTimer));
607
+ if (!finished && !exited) {
608
+ try {
609
+ proc.kill("SIGKILL");
610
+ }
611
+ catch { /* process already gone */ }
612
+ }
613
+ const code = await within(rawExited, Math.max(0, timeoutMs - (Date.now() - t)), `Terminal close timed out after ${timeoutMs}ms`);
614
+ const result = buildResult(code);
615
+ const seq = recordOpEvent("close", { exitCode: code }, t, undefined, resv);
616
+ return wrap(result, seq);
499
617
  }
500
- catch {
501
- /* ignore */
618
+ catch (error) {
619
+ if (!exited) {
620
+ try {
621
+ proc.kill("SIGKILL");
622
+ }
623
+ catch { /* best effort */ }
624
+ }
625
+ stopOutput();
626
+ if (timer)
627
+ clearTimeout(timer);
628
+ recordOpEvent("close", { exitCode }, t, errorMessage(error), resv);
629
+ throw error;
502
630
  }
503
- }
504
- const code = await rawExited;
505
- const result = buildResult(code);
506
- const seq = recordOpEvent("close", { exitCode: code }, t, undefined, resv);
507
- // Tag the result against the close step so assertions on the
508
- // transcript of a non-self-terminating session nest under it.
509
- return wrap(result, seq);
631
+ })();
632
+ return closePromise;
510
633
  },
511
634
  };
512
635
  return handle;
513
636
  }
637
+ function errorMessage(error) {
638
+ return error instanceof Error ? error.message : String(error);
639
+ }
640
+ function durationOption(value, fallback, name) {
641
+ const duration = value ?? fallback;
642
+ if (!Number.isFinite(duration) || duration < 0)
643
+ throw new Error(`${name} must be a finite non-negative number`);
644
+ return duration;
645
+ }
646
+ function describeMatcher(matcher) {
647
+ return typeof matcher === "string" ? { kind: "text", value: matcher }
648
+ : { kind: "regex", value: matcher.source, flags: matcher.flags };
649
+ }
650
+ function matchScreen(screen, matcher) {
651
+ return typeof matcher === "string" ? (screen.includes(matcher) ? matcher : null)
652
+ : screen.match(new RegExp(matcher.source, matcher.flags));
653
+ }
654
+ function withSignal(work, signal) {
655
+ return new Promise((resolve, reject) => {
656
+ const abort = () => reject(signal.reason);
657
+ signal.addEventListener("abort", abort, { once: true });
658
+ work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
659
+ if (signal.aborted) {
660
+ signal.removeEventListener("abort", abort);
661
+ abort();
662
+ }
663
+ });
664
+ }
665
+ function within(work, ms, message) {
666
+ const control = new AbortController();
667
+ const timer = setTimeout(() => control.abort(new Error(message)), ms);
668
+ return withSignal(work, control.signal).finally(() => clearTimeout(timer));
669
+ }
514
670
  // ────────────────────────────────────────────────────────────────────────
515
671
  // Key name → byte sequence
516
672
  // ────────────────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.128.0",
3
+ "version": "0.130.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,36 @@
1
+ import { expect, test } from "bun:test";
2
+ import { openBrowser } from "./browser.js";
3
+ import { startRecording, stopRecording } from "./recorder.js";
4
+
5
+ const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome"));
6
+
7
+ test.skipIf(!available)("public navigation options control goto/reload and remain visible in recordings", async () => {
8
+ // The document commits immediately but load waits for an image. This
9
+ // distinguishes waitUntil behavior from simply extending a deadline.
10
+ const server = Bun.serve({port:0,hostname:"127.0.0.1", async fetch(request) {
11
+ if (new URL(request.url).pathname === "/slow.png") {
12
+ await Bun.sleep(500);
13
+ return new Response("", {status:404});
14
+ }
15
+ return new Response('<title>Navigation options</title><img src="/slow.png">',
16
+ {headers:{"content-type":"text/html","cache-control":"no-store"}});
17
+ }});
18
+ const browser = await openBrowser({recorder:{sessionId:"navigation",recordStep() {}}});
19
+ startRecording();
20
+ try {
21
+ await browser.goto(server.url.href, {waitUntil:"domcontentloaded",timeout:2000});
22
+ expect(await browser.title()).toBe("Navigation options");
23
+ await expect(browser.reload({waitUntil:"load",timeout:50})).rejects.toThrow(/Timeout/);
24
+ await browser.reload({waitUntil:"load",timeout:2000});
25
+ const events = stopRecording().filter(e => e.kind === "browser");
26
+ expect(events.filter(e => e.action === "goto" || e.action === "reload")).toMatchObject([
27
+ {action:"goto",waitUntil:"domcontentloaded",timeout:2000},
28
+ {action:"reload",waitUntil:"load",timeout:50,error:expect.any(String)},
29
+ {action:"reload",waitUntil:"load",timeout:2000},
30
+ ]);
31
+ } finally {
32
+ stopRecording();
33
+ await browser.close();
34
+ server.stop(true);
35
+ }
36
+ }, 15000);
package/src/browser.ts CHANGED
@@ -208,6 +208,12 @@ export interface Mouse {
208
208
  wheel(dx: number, dy: number): Promise<void>;
209
209
  }
210
210
 
211
+ export interface NavigationOptions {
212
+ /** Playwright navigation timeout in milliseconds; 0 disables the deadline. */
213
+ timeout?: number;
214
+ waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit";
215
+ }
216
+
211
217
  /**
212
218
  * Headless browser session — Playwright `Page`-shaped. Select elements with
213
219
  * the `getBy*`/`locator` roots (returning a {@link Locator}) and act on them;
@@ -220,14 +226,14 @@ export interface Browser {
220
226
  url(): string;
221
227
  /** Current page `<title>` (async, like Playwright's `page.title()`). */
222
228
  title(): Promise<string>;
223
- /** Navigate to a URL; resolves when the main frame's load completes. */
224
- goto(url: string): Promise<void>;
229
+ /** Navigate to a URL; waits for the main frame load unless waitUntil is supplied. */
230
+ goto(url: string, options?: NavigationOptions): Promise<void>;
225
231
  /** Navigate back in session history. */
226
- goBack(): Promise<void>;
232
+ goBack(options?: NavigationOptions): Promise<void>;
227
233
  /** Navigate forward in session history. */
228
- goForward(): Promise<void>;
234
+ goForward(options?: NavigationOptions): Promise<void>;
229
235
  /** Reload the current page. */
230
- reload(): Promise<void>;
236
+ reload(options?: NavigationOptions): Promise<void>;
231
237
 
232
238
  readonly keyboard: Keyboard;
233
239
  readonly mouse: Mouse;
@@ -1995,11 +2001,11 @@ function buildBackend(
1995
2001
  keyboard,
1996
2002
  mouse,
1997
2003
  touchscreen,
1998
- goto(url) {
2004
+ goto(url, options) {
1999
2005
  recorder?.noteNavigation?.(url);
2000
- return instrumented("goto", { url }, async () => {
2006
+ return instrumented("goto", { url, ...options }, async () => {
2001
2007
  try {
2002
- await holder.page.goto(url, { waitUntil: "load" });
2008
+ await holder.page.goto(url, { waitUntil: "load", ...options });
2003
2009
  } catch (err) {
2004
2010
  // Restored-renderer DNS bug (see `rebuildView`): only when the
2005
2011
  // view is persistent (so it may have lived through a snapshot
@@ -2009,23 +2015,23 @@ function buildBackend(
2009
2015
  if (!buildOpts.persistent || !isNameNotResolved(err)) throw err;
2010
2016
  if (!(await daemonResolves(url))) throw err;
2011
2017
  await rebuildView(holder);
2012
- await holder.page.goto(url, { waitUntil: "load" });
2018
+ await holder.page.goto(url, { waitUntil: "load", ...options });
2013
2019
  }
2014
2020
  });
2015
2021
  },
2016
- goBack() {
2017
- return instrumented("goBack", {}, async () => {
2018
- await holder.page.goBack();
2022
+ goBack(options) {
2023
+ return instrumented("goBack", { ...options }, async () => {
2024
+ await holder.page.goBack(options);
2019
2025
  });
2020
2026
  },
2021
- goForward() {
2022
- return instrumented("goForward", {}, async () => {
2023
- await holder.page.goForward();
2027
+ goForward(options) {
2028
+ return instrumented("goForward", { ...options }, async () => {
2029
+ await holder.page.goForward(options);
2024
2030
  });
2025
2031
  },
2026
- reload() {
2027
- return instrumented("reload", {}, async () => {
2028
- await holder.page.reload();
2032
+ reload(options) {
2033
+ return instrumented("reload", { ...options }, async () => {
2034
+ await holder.page.reload(options);
2029
2035
  });
2030
2036
  },
2031
2037
 
@@ -2325,6 +2331,8 @@ function buildBackend(
2325
2331
  }
2326
2332
 
2327
2333
  export interface RecordableFields {
2334
+ timeout: number;
2335
+ waitUntil: NonNullable<NavigationOptions["waitUntil"]>;
2328
2336
  target: RecordedBrowserLocatorPart[];
2329
2337
  destinationTarget: RecordedBrowserLocatorPart[];
2330
2338
  url: string;