@specific.dev/spectest 0.128.0 → 0.129.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/README.md CHANGED
@@ -24,6 +24,60 @@ work; do not replace readiness with sleeps or arbitrary `.first()` selections.
24
24
  SDK 0.123.0 uses native Playwright assertions while retaining Spectest's API and
25
25
  recording. Ambiguous single-element selectors fail immediately; make them unique.
26
26
 
27
+ For terminal readiness, use `terminal.waitFor` directly. It retries the condition
28
+ and throws on timeout, so neither an outer `ctx.wait` nor an extra assertion is
29
+ needed to check that text appears:
30
+
31
+ ```ts
32
+ await terminal.waitFor("Rack is initialized", /Current rack status:\s*Initialized/, {
33
+ timeoutMs: 35 * 60_000,
34
+ intervalMs: 5_000,
35
+ failOn: [
36
+ { description: "Setup was rejected", matcher: "Start Rack Setup Failed" },
37
+ { description: "Initialization failed", matcher: "Initialization Failed" },
38
+ { description: "Initialization panicked", matcher: "Initialization Panicked" },
39
+ ],
40
+ });
41
+ await terminal.press("Ctrl+C"); // Separate cleanup after the condition succeeds.
42
+ ```
43
+
44
+ SDK 0.129.0 records the string/regex matcher (or identifies a custom predicate),
45
+ timeout, and start/end positions for this wait. The dashboard shows its condition
46
+ and outcome and replays up to the result. Later inputs belong to their own steps;
47
+ the full terminal session can be opened separately. `ctx.wait` remains useful
48
+ when a retry must repeat actions, rather than just observe terminal state.
49
+
50
+ Terminal waits record matched, timed-out, failure-condition, predicate-error,
51
+ process-exit and cancelled outcomes. `failOn` checks the rendered screen before
52
+ checking success; string and regex conditions are recorded explicitly. For a
53
+ custom predicate, provide `condition: "Expected sled has a valid bootstrap IPv6 address"`
54
+ to explain the actual check separately from its step title. Async predicates are
55
+ awaited, and their rejection is recorded. A final matching screen succeeds even
56
+ if the process has exited; otherwise exit fails the wait after final output is
57
+ drained. No `Promise.race` with `terminal.exited` is needed.
58
+
59
+ Pass `signal: controller.signal` to cancel a wait without closing its terminal.
60
+ Cancellation stops SDK polling; it cannot forcibly stop work started inside a
61
+ custom predicate. `terminal.close({ timeoutMs: 10_000 })` cancels pending waits,
62
+ sends EOF, escalates to a kill, and bounds cleanup including output drain. The
63
+ default close deadline is 10 seconds; repeated calls share the same result or
64
+ failure. `terminal.exited` records the exit when observed after completion, not
65
+ when its promise is first read. A stalled output drain fails after 5 seconds
66
+ instead of hanging indefinitely. Test code should preserve its original failure
67
+ if diagnostic or cleanup operations also fail.
68
+
69
+ Browser navigation accepts public Playwright-style options, without a backend
70
+ cast or `pageOp` call:
71
+
72
+ ```ts
73
+ await browser.goto(consoleUrl, { timeout: 120_000, waitUntil: "domcontentloaded" });
74
+ await browser.reload({ timeout: 120_000 });
75
+ ```
76
+
77
+ These options also apply to `goBack` and `goForward`. Navigation deadlines do not
78
+ cancel a surrounding `ctx.poll`, and retrying a navigation after a visible error
79
+ is a recovery action that should remain visible in the test results.
80
+
27
81
  Pub/Sub integrations are project-owned as of SDK 0.98.0. The former
28
82
  `pubsub`/`pubsubClient` exports and `/components/gcp` entry point are removed.
29
83
  Use a local service definition or `serviceGroup()` wrapper; the repository’s
package/dist/browser.d.ts CHANGED
@@ -124,6 +124,11 @@ export interface Mouse {
124
124
  /** Wheel-scroll by a pixel delta. */
125
125
  wheel(dx: number, dy: number): Promise<void>;
126
126
  }
127
+ export interface NavigationOptions {
128
+ /** Playwright navigation timeout in milliseconds; 0 disables the deadline. */
129
+ timeout?: number;
130
+ waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit";
131
+ }
127
132
  /**
128
133
  * Headless browser session — Playwright `Page`-shaped. Select elements with
129
134
  * the `getBy*`/`locator` roots (returning a {@link Locator}) and act on them;
@@ -136,14 +141,14 @@ export interface Browser {
136
141
  url(): string;
137
142
  /** Current page `<title>` (async, like Playwright's `page.title()`). */
138
143
  title(): Promise<string>;
139
- /** Navigate to a URL; resolves when the main frame's load completes. */
140
- goto(url: string): Promise<void>;
144
+ /** Navigate to a URL; waits for the main frame load unless waitUntil is supplied. */
145
+ goto(url: string, options?: NavigationOptions): Promise<void>;
141
146
  /** Navigate back in session history. */
142
- goBack(): Promise<void>;
147
+ goBack(options?: NavigationOptions): Promise<void>;
143
148
  /** Navigate forward in session history. */
144
- goForward(): Promise<void>;
149
+ goForward(options?: NavigationOptions): Promise<void>;
145
150
  /** Reload the current page. */
146
- reload(): Promise<void>;
151
+ reload(options?: NavigationOptions): Promise<void>;
147
152
  readonly keyboard: Keyboard;
148
153
  readonly mouse: Mouse;
149
154
  /** Root CSS query. */
@@ -363,6 +368,8 @@ export declare function acquirePersistentDesktop(endpoint: string, windowIndex:
363
368
  desktopChrome?: DesktopChrome;
364
369
  }>;
365
370
  export interface RecordableFields {
371
+ timeout: number;
372
+ waitUntil: NonNullable<NavigationOptions["waitUntil"]>;
366
373
  target: RecordedBrowserLocatorPart[];
367
374
  destinationTarget: RecordedBrowserLocatorPart[];
368
375
  url: string;
package/dist/browser.js CHANGED
@@ -1491,11 +1491,11 @@ function buildBackend(holder, recorder, buildOpts) {
1491
1491
  keyboard,
1492
1492
  mouse,
1493
1493
  touchscreen,
1494
- goto(url) {
1494
+ goto(url, options) {
1495
1495
  recorder?.noteNavigation?.(url);
1496
- return instrumented("goto", { url }, async () => {
1496
+ return instrumented("goto", { url, ...options }, async () => {
1497
1497
  try {
1498
- await holder.page.goto(url, { waitUntil: "load" });
1498
+ await holder.page.goto(url, { waitUntil: "load", ...options });
1499
1499
  }
1500
1500
  catch (err) {
1501
1501
  // Restored-renderer DNS bug (see `rebuildView`): only when the
@@ -1508,23 +1508,23 @@ function buildBackend(holder, recorder, buildOpts) {
1508
1508
  if (!(await daemonResolves(url)))
1509
1509
  throw err;
1510
1510
  await rebuildView(holder);
1511
- await holder.page.goto(url, { waitUntil: "load" });
1511
+ await holder.page.goto(url, { waitUntil: "load", ...options });
1512
1512
  }
1513
1513
  });
1514
1514
  },
1515
- goBack() {
1516
- return instrumented("goBack", {}, async () => {
1517
- await holder.page.goBack();
1515
+ goBack(options) {
1516
+ return instrumented("goBack", { ...options }, async () => {
1517
+ await holder.page.goBack(options);
1518
1518
  });
1519
1519
  },
1520
- goForward() {
1521
- return instrumented("goForward", {}, async () => {
1522
- await holder.page.goForward();
1520
+ goForward(options) {
1521
+ return instrumented("goForward", { ...options }, async () => {
1522
+ await holder.page.goForward(options);
1523
1523
  });
1524
1524
  },
1525
- reload() {
1526
- return instrumented("reload", {}, async () => {
1527
- await holder.page.reload();
1525
+ reload(options) {
1526
+ return instrumented("reload", { ...options }, async () => {
1527
+ await holder.page.reload(options);
1528
1528
  });
1529
1529
  },
1530
1530
  locator: (css) => makeLocator(backend, strategy, { steps: [{ m: "locator", args: [css] }] }),
package/dist/index.d.ts CHANGED
@@ -18,7 +18,7 @@ import type { Annotated } from "./annotate.js";
18
18
  export { SQL, type SqlClient, type SqlOptions } from "./sql.js";
19
19
  export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js";
20
20
  export { S3Client, type S3ClientLike, type S3File, type S3ClientOptions } from "./s3.js";
21
- export type { Browser, BrowserOptions, Keyboard, Mouse, Touchscreen } from "./browser.js";
21
+ export type { Browser, BrowserOptions, NavigationOptions, Keyboard, Mouse, Touchscreen } from "./browser.js";
22
22
  export type { MicrophoneFixture } from "./harness/microphone-fixture.js";
23
23
  export type { FakeWebSocket, FakeWebSocketHandlers } from "./fake-websocket.js";
24
24
  import type { FakeWebSocketHandlers } from "./fake-websocket.js";
@@ -34,7 +34,7 @@ export type { Mobile, MobileApp } from "./mobile.js";
34
34
  import type { Mobile, MobileApp } from "./mobile.js";
35
35
  export type { Desktop, DesktopApp, DesktopOptions } from "./desktop.js";
36
36
  import type { Desktop, DesktopApp, DesktopOptions } from "./desktop.js";
37
- export type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
37
+ export type { Terminal, TerminalOpts, TerminalResult, TerminalWaitOptions, TerminalFailureCondition, TerminalCloseOptions } from "./terminal.js";
38
38
  import type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
39
39
  export { type CoverageAdapter, type CoverageCaptureContext, type CoverageConfigureInfo, type CoverageSourceOptions, type CoverageSourceContext, type CoverageLoadContext, type ServiceCoverage, } from "./coverage.js";
40
40
  import { type ServiceCoverage } from "./coverage.js";
@@ -407,6 +407,9 @@ export interface RecordedBrowserLocatorPart {
407
407
  }
408
408
  export interface BrowserEvent extends BaseEvent {
409
409
  kind: "browser";
410
+ /** Explicit navigation options, when supplied by the test. */
411
+ timeout?: number;
412
+ waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit";
410
413
  action: BrowserAction;
411
414
  target?: RecordedBrowserLocatorPart[];
412
415
  destinationTarget?: RecordedBrowserLocatorPart[];
@@ -534,6 +537,18 @@ export interface TerminalEvent extends BaseEvent {
534
537
  }
535
538
  /** Action taken on an open interactive terminal session. */
536
539
  export type TerminalStepAction = "send" | "sendLine" | "press" | "waitFor" | "exit" | "close";
540
+ export type TerminalWaitMatcher = {
541
+ kind: "text";
542
+ value: string;
543
+ } | {
544
+ kind: "regex";
545
+ value: string;
546
+ flags: string;
547
+ } | {
548
+ kind: "custom";
549
+ description?: string;
550
+ };
551
+ export type TerminalWaitOutcome = "matched" | "timed_out" | "failure_matched" | "predicate_error" | "process_exited" | "cancelled";
537
552
  /**
538
553
  * One operation on an open interactive terminal — analogous to
539
554
  * `BrowserEvent`. The session's heavy asciicast frames live on the
@@ -557,9 +572,21 @@ export interface TerminalStepEvent extends BaseEvent {
557
572
  description?: string;
558
573
  /** How many polls `waitFor` made. */
559
574
  attempts?: number;
560
- /** Whether `waitFor` matched (false means it timed out). */
575
+ /** Whether `waitFor` matched; see outcome for failure reasons. */
561
576
  matched?: boolean;
562
- /** Exit code observed on `close`. */
577
+ /** The condition being observed, including an optional custom-predicate description. */
578
+ matcher?: TerminalWaitMatcher;
579
+ outcome?: TerminalWaitOutcome;
580
+ failureMatchers?: {
581
+ description: string;
582
+ matcher: TerminalWaitMatcher;
583
+ }[];
584
+ intervalMs?: number;
585
+ timeoutMs?: number;
586
+ /** Wait boundaries on the session clock, including time with no output. */
587
+ castStartTimeSec?: number;
588
+ castEndTimeSec?: number;
589
+ /** Exit code observed on exit, close, or a failed wait. */
563
590
  exitCode?: number;
564
591
  /** Rendered screen at the moment of the op (post-action), truncated. */
565
592
  screenPreview: string;
@@ -25,6 +25,25 @@ export interface TerminalOpts {
25
25
  */
26
26
  timeoutMs?: number;
27
27
  }
28
+ /** A visible terminal failure that should stop a wait immediately. */
29
+ export interface TerminalFailureCondition {
30
+ description: string;
31
+ matcher: string | RegExp;
32
+ }
33
+ export interface TerminalWaitOptions {
34
+ timeoutMs?: number;
35
+ intervalMs?: number;
36
+ /** Cancel this observation without closing the terminal. */
37
+ signal?: AbortSignal;
38
+ /** Describe a custom predicate's condition separately from the step title. */
39
+ condition?: string;
40
+ /** Checked against the rendered screen before the success matcher. */
41
+ failOn?: readonly TerminalFailureCondition[];
42
+ }
43
+ export interface TerminalCloseOptions {
44
+ /** Total deadline including graceful exit, kill and pipe drain. Default 10 s. */
45
+ timeoutMs?: number;
46
+ }
28
47
  /** Result of the one-shot `ctx.terminal(...)` convenience. */
29
48
  export interface TerminalResult {
30
49
  /** Full decoded PTY output (raw bytes, ANSI escapes included). Capped. */
@@ -50,7 +69,8 @@ export interface Terminal {
50
69
  /**
51
70
  * Resolves when the underlying process ends — by `close()`, by the
52
71
  * inner command exiting, or by timeout — to a `TerminalResult` with
53
- * the full captured `output` and `exitCode`. Never rejects.
72
+ * the full captured `output` and `exitCode`. Rejects if output cannot
73
+ * be drained after exit.
54
74
  *
55
75
  * The result is provenance-tagged against the `exit` step, so an
56
76
  * `expect((await term.exited).output).toContain(...)` /
@@ -91,29 +111,23 @@ export interface Terminal {
91
111
  * `description` is a short human label that shows up in the event
92
112
  * log so timelines don't fill with anonymous waits.
93
113
  *
94
- * Defaults: 5 s total timeout, 100 ms between polls. Throws on
95
- * timeout.
114
+ * Defaults: 10 s total timeout, 100 ms between polls. Throws on timeout,
115
+ * a failure condition, predicate error, cancellation, or process exit before
116
+ * a match. On exit the final drained screen is checked first. Async custom
117
+ * predicates are supported; cancellation stops polling but cannot forcibly
118
+ * cancel work started by the predicate itself.
96
119
  */
97
- waitFor(description: string, matcher: string, opts?: {
98
- timeoutMs?: number;
99
- intervalMs?: number;
100
- }): Promise<Wrapped<string>>;
101
- waitFor(description: string, matcher: RegExp, opts?: {
102
- timeoutMs?: number;
103
- intervalMs?: number;
104
- }): Promise<Wrapped<RegExpMatchArray>>;
105
- waitFor<T = unknown>(description: string, matcher: (screen: string, output: string) => T, opts?: {
106
- timeoutMs?: number;
107
- intervalMs?: number;
108
- }): Promise<Wrapped<NonNullable<Awaited<T>>>>;
120
+ waitFor(description: string, matcher: string, opts?: TerminalWaitOptions): Promise<Wrapped<string>>;
121
+ waitFor(description: string, matcher: RegExp, opts?: TerminalWaitOptions): Promise<Wrapped<RegExpMatchArray>>;
122
+ waitFor<T = unknown>(description: string, matcher: (screen: string, output: string) => T, opts?: TerminalWaitOptions): Promise<Wrapped<NonNullable<Awaited<T>>>>;
109
123
  /**
110
124
  * Tear down. Writes Ctrl-D to stdin to nudge the shell out, then
111
125
  * SIGKILLs after a grace period. Idempotent. Resolves to a
112
126
  * `TerminalResult` (tagged against the `close` step) so you can assert
113
127
  * on the transcript of a session that doesn't self-terminate. Optional
114
- * — terminals are not auto-closed when a test ends.
128
+ * — terminals are not auto-closed when a test ends. Rejects on cleanup timeout.
115
129
  */
116
- close(): Promise<Wrapped<TerminalResult>>;
130
+ close(opts?: TerminalCloseOptions): Promise<Wrapped<TerminalResult>>;
117
131
  }
118
132
  /**
119
133
  * The concrete handle `openTerminal` returns. Extends the public