@specific.dev/spectest 0.127.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.
Files changed (46) hide show
  1. package/README.md +54 -0
  2. package/dist/browser.d.ts +12 -5
  3. package/dist/browser.js +13 -13
  4. package/dist/components/k3s-coverage.d.ts +24 -0
  5. package/dist/components/k3s-coverage.js +259 -0
  6. package/dist/components/k3s-trust.d.ts +4 -2
  7. package/dist/components/k3s-trust.js +13 -0
  8. package/dist/components/k3s.js +31 -0
  9. package/dist/coverage/rust.rs +102 -0
  10. package/dist/coverage-bun.d.ts +10 -0
  11. package/dist/coverage-bun.js +32 -0
  12. package/dist/coverage-rust.d.ts +23 -0
  13. package/dist/coverage-rust.js +71 -0
  14. package/dist/coverage.d.ts +11 -5
  15. package/dist/coverage.js +70 -13
  16. package/dist/daemon.js +27 -2
  17. package/dist/harness/component-images.d.ts +1 -0
  18. package/dist/harness/component-images.js +4 -2
  19. package/dist/harness/coverage-target.d.ts +10 -0
  20. package/dist/harness/coverage-target.js +54 -0
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.js +10 -1
  23. package/dist/recorder.d.ts +29 -2
  24. package/dist/terminal.d.ts +31 -17
  25. package/dist/terminal.js +261 -105
  26. package/package.json +1 -1
  27. package/src/browser-navigation.test.ts +36 -0
  28. package/src/browser.ts +26 -18
  29. package/src/components/k3s-coverage.test.ts +160 -0
  30. package/src/components/k3s-coverage.ts +234 -0
  31. package/src/components/k3s-trust.ts +14 -2
  32. package/src/components/k3s.ts +26 -0
  33. package/src/coverage/rust.rs +102 -0
  34. package/src/coverage-bun.test.ts +103 -0
  35. package/src/coverage-bun.ts +40 -0
  36. package/src/coverage-rust.test.ts +74 -0
  37. package/src/coverage-rust.ts +79 -0
  38. package/src/coverage.test.ts +4 -4
  39. package/src/coverage.ts +74 -13
  40. package/src/daemon.ts +22 -2
  41. package/src/harness/component-images.ts +5 -2
  42. package/src/harness/coverage-target.ts +55 -0
  43. package/src/index.ts +12 -3
  44. package/src/recorder.ts +22 -2
  45. package/src/terminal.test.ts +324 -0
  46. package/src/terminal.ts +269 -119
package/src/daemon.ts CHANGED
@@ -3873,7 +3873,11 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
3873
3873
  for (const s of namedServices(l.project.environment)) byName.set(s.name, s);
3874
3874
  for (const [name, s] of RUNTIME_SERVICES) byName.set(name, s);
3875
3875
  const all = [...byName.values()];
3876
- const services = all.filter((s) => s.coverage !== undefined);
3876
+ const covered = all.filter((s) => s.coverage !== undefined);
3877
+ const services = covered.filter(s => {
3878
+ const adapters = coverageAdapters(s.coverage);
3879
+ return !adapters.some(a => a.collect) || adapters.some(a => !a.collect);
3880
+ });
3877
3881
  // The environment's own capture is unconditional (see above); the
3878
3882
  // services' come from the ones that opted in, none included.
3879
3883
  const environment = captureEnvironmentCoverage(all, baseline);
@@ -3922,7 +3926,23 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
3922
3926
  return applyCoverageDelta(read, coverageReportsMode(svc.coverage), deltaReports);
3923
3927
  }),
3924
3928
  );
3925
- return [...captures, await environment];
3929
+ const components = await Promise.all(covered.flatMap(svc => coverageAdapters(svc.coverage)
3930
+ .filter(a => a.collect).map(async adapter => {
3931
+ const signal = AbortSignal.timeout(COVERAGE_CAPTURE_TIMEOUT_MS);
3932
+ try {
3933
+ return await adapter.collect!({
3934
+ ...coverageCaptureContext(svc, signal, new Set()),
3935
+ projectFiles: () => walkProjectFiles(WORKSPACE),
3936
+ async readProjectFile(p) {
3937
+ try { return await fs.readFile(resolveProjectPath(p, { record: false }), "utf8"); }
3938
+ catch { return null; }
3939
+ },
3940
+ }, baseline);
3941
+ } catch (error) {
3942
+ return [{ service: `${svc.name}/${adapter.name}`, reports: [], error: String(error) }];
3943
+ }
3944
+ })));
3945
+ return [...captures, ...components.flat(), await environment];
3926
3946
  }
3927
3947
 
3928
3948
  /** Per-Browser rrweb session shipped to the control plane. */
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { BuildImage, ComponentImageBuild, ServiceConfig } from "../index.js";
3
3
 
4
- export type KubernetesImage = string | (BuildImage & { reference: string });
4
+ export type KubernetesImage = string | (BuildImage & { reference: string; coverage?: import("../coverage.js").ServiceCoverage });
5
5
 
6
6
  /** containerd/CRI use fully qualified names, including the default tag. */
7
7
  export function canonicalImageReference(reference: string): string {
@@ -45,7 +45,10 @@ export function kubernetesImages(images: readonly KubernetesImage[]): { pulls: s
45
45
  }
46
46
  seen.set(reference, identity);
47
47
  if (typeof image === "string") pulls.push(image);
48
- else builds.push({ reference, image });
48
+ else {
49
+ const { coverage: _coverage, ...build } = image;
50
+ builds.push({ reference, image: image.coverage === undefined ? image : build });
51
+ }
49
52
  }
50
53
  return { pulls, builds };
51
54
  }
@@ -0,0 +1,55 @@
1
+ // Runtime adapters operate on this same context in Docker and nested runtimes.
2
+ // Counter baselines belong to an instance; source identity belongs to its image.
3
+ import { promises as fs } from "node:fs";
4
+ import path from "node:path";
5
+ import { attachCoverageSourceOptions, coverageAdapters, coverageReportsMode, resolveCoverageSourceOptions,
6
+ type CoverageCaptureContext, type CoverageInventoryContext, type ServiceCoverage } from "../coverage.js";
7
+ import { applyCoverageDelta, readCoverageDir, type ServiceCoverageCapture } from "./coverage.js";
8
+
9
+ export async function captureCoverageTarget(options: {
10
+ context: CoverageCaptureContext & CoverageInventoryContext;
11
+ coverage: ServiceCoverage;
12
+ root: string;
13
+ baseline: boolean;
14
+ inventoryAvailable?: boolean;
15
+ counterKey?: string;
16
+ }): Promise<ServiceCoverageCapture> {
17
+ const { context: ctx, coverage, root, baseline } = options;
18
+ const adapters = coverageAdapters(coverage);
19
+ const written = new Set<string>();
20
+ const delta = new Set<string>();
21
+ const context = { ...ctx, async writeReport(name: string, content: string) {
22
+ const resolved = path.resolve(ctx.reportDir, name);
23
+ if (!resolved.startsWith(path.resolve(ctx.reportDir) + path.sep)) throw new Error("coverage report escapes directory");
24
+ await fs.mkdir(path.dirname(resolved), { recursive: true });
25
+ await fs.writeFile(resolved + ".tmp", content);
26
+ await fs.rename(resolved + ".tmp", resolved);
27
+ written.add(name);
28
+ } };
29
+ try {
30
+ ctx.signal.throwIfAborted();
31
+ const policies = await resolveCoverageSourceOptions(adapters, ctx, typeof coverage === "object" ? {
32
+ sourceRoot: coverage.sourceRoot, excludeSources: coverage.excludeSources,
33
+ } : undefined);
34
+ for (const adapter of adapters) {
35
+ if (adapter.collect || adapter.load) throw new Error(`adapter ${adapter.name} cannot run inside a container; attach browser coverage to the cluster service`);
36
+ written.clear();
37
+ await adapter.capture?.(context);
38
+ if (adapter.reports === "delta") for (const name of written) delta.add(name);
39
+ }
40
+ if (baseline && options.inventoryAvailable !== false) {
41
+ const units = new Set<string>();
42
+ for (const adapter of adapters) for (const unit of await adapter.inventory?.(context) ?? []) units.add(unit);
43
+ await context.writeReport("inventory.units", [...units].sort().join("\n"));
44
+ }
45
+ ctx.signal.throwIfAborted();
46
+ const capture = await readCoverageDir(ctx.service, ctx.reportDir);
47
+ if (baseline) await fs.rm(path.join(ctx.reportDir, "inventory.units"), { force: true });
48
+ capture.root = root;
49
+ attachCoverageSourceOptions(capture, policies);
50
+ const result = applyCoverageDelta({ ...capture, service: options.counterKey ?? capture.service }, coverageReportsMode(coverage), delta);
51
+ return { ...result, service: capture.service };
52
+ } catch (error) {
53
+ return { service: ctx.service, reports: [], error: String(error) };
54
+ }
55
+ }
package/src/index.ts CHANGED
@@ -84,7 +84,7 @@ export { SQL, type SqlClient, type SqlOptions } from "./sql.js";
84
84
  export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js";
85
85
  export { S3Client, type S3ClientLike, type S3File, type S3ClientOptions } from "./s3.js";
86
86
 
87
- export type { Browser, BrowserOptions, Keyboard, Mouse, Touchscreen } from "./browser.js";
87
+ export type { Browser, BrowserOptions, NavigationOptions, Keyboard, Mouse, Touchscreen } from "./browser.js";
88
88
  export type { MicrophoneFixture } from "./harness/microphone-fixture.js";
89
89
  export type { FakeWebSocket, FakeWebSocketHandlers } from "./fake-websocket.js";
90
90
  import type { FakeWebSocketHandlers } from "./fake-websocket.js";
@@ -158,7 +158,7 @@ import type { Mobile, MobileApp } from "./mobile.js";
158
158
  export type { Desktop, DesktopApp, DesktopOptions } from "./desktop.js";
159
159
  import type { Desktop, DesktopApp, DesktopOptions } from "./desktop.js";
160
160
 
161
- export type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
161
+ export type { Terminal, TerminalOpts, TerminalResult, TerminalWaitOptions, TerminalFailureCondition, TerminalCloseOptions } from "./terminal.js";
162
162
 
163
163
  import type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
164
164
 
@@ -175,7 +175,7 @@ export {
175
175
  type CoverageLoadContext,
176
176
  type ServiceCoverage,
177
177
  } from "./coverage.js";
178
- import { applyCoverageAdapters, validateCoverage, type ServiceCoverage } from "./coverage.js";
178
+ import { applyCoverageAdapters, coverageAdapters, validateCoverage, type ServiceCoverage } from "./coverage.js";
179
179
  import { resolveExistingProjectPath } from "./project-files.js";
180
180
  export { ca } from "./environment-ca.js";
181
181
 
@@ -1138,6 +1138,15 @@ function expandServiceGroups(input: InputServicesMap): ServicesMap {
1138
1138
  // Rewrite relative dependsOn to final keys; a spread keeps function
1139
1139
  // fields (helpers/setup) and the enumerable-symbol ingress decls.
1140
1140
  const final: ServiceConfig = { ...svc, ...(part === entry.primary ? primaryOverrides : {}) };
1141
+ // Component-owned nested containers keep their collection when callers
1142
+ // add ordinary service coverage (for example browser coverage on k3s).
1143
+ const collectors = coverageAdapters(svc.coverage).filter(adapter => adapter.collect);
1144
+ if (part === entry.primary && collectors.length && primaryOverrides.coverage !== undefined) {
1145
+ const extra = primaryOverrides.coverage;
1146
+ final.coverage = { ...(typeof extra === "object" ? extra : {}), adapters: [
1147
+ ...collectors, ...coverageAdapters(extra).filter(adapter => !collectors.includes(adapter)),
1148
+ ] };
1149
+ }
1141
1150
  if (final.dependsOn?.length) {
1142
1151
  final.dependsOn = final.dependsOn.map((d) =>
1143
1152
  partKeys.has(d) ? naming.key(d) : d,
package/src/recorder.ts CHANGED
@@ -467,6 +467,9 @@ export interface RecordedBrowserLocatorPart {
467
467
 
468
468
  export interface BrowserEvent extends BaseEvent {
469
469
  kind: "browser";
470
+ /** Explicit navigation options, when supplied by the test. */
471
+ timeout?: number;
472
+ waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit";
470
473
  action: BrowserAction;
471
474
  target?: RecordedBrowserLocatorPart[];
472
475
  destinationTarget?: RecordedBrowserLocatorPart[];
@@ -599,6 +602,14 @@ export type TerminalStepAction =
599
602
  | "exit"
600
603
  | "close";
601
604
 
605
+ export type TerminalWaitMatcher =
606
+ | { kind: "text"; value: string }
607
+ | { kind: "regex"; value: string; flags: string }
608
+ | { kind: "custom"; description?: string };
609
+
610
+ export type TerminalWaitOutcome = "matched" | "timed_out" | "failure_matched"
611
+ | "predicate_error" | "process_exited" | "cancelled";
612
+
602
613
  /**
603
614
  * One operation on an open interactive terminal — analogous to
604
615
  * `BrowserEvent`. The session's heavy asciicast frames live on the
@@ -622,9 +633,18 @@ export interface TerminalStepEvent extends BaseEvent {
622
633
  description?: string;
623
634
  /** How many polls `waitFor` made. */
624
635
  attempts?: number;
625
- /** Whether `waitFor` matched (false means it timed out). */
636
+ /** Whether `waitFor` matched; see outcome for failure reasons. */
626
637
  matched?: boolean;
627
- /** Exit code observed on `close`. */
638
+ /** The condition being observed, including an optional custom-predicate description. */
639
+ matcher?: TerminalWaitMatcher;
640
+ outcome?: TerminalWaitOutcome;
641
+ failureMatchers?: { description: string; matcher: TerminalWaitMatcher }[];
642
+ intervalMs?: number;
643
+ timeoutMs?: number;
644
+ /** Wait boundaries on the session clock, including time with no output. */
645
+ castStartTimeSec?: number;
646
+ castEndTimeSec?: number;
647
+ /** Exit code observed on exit, close, or a failed wait. */
628
648
  exitCode?: number;
629
649
  /** Rendered screen at the moment of the op (post-action), truncated. */
630
650
  screenPreview: string;
@@ -0,0 +1,324 @@
1
+ import { afterEach, expect, spyOn, test } from "bun:test";
2
+ import { openTerminal } from "./terminal.js";
3
+ import { startRecording, stopRecording } from "./recorder.js";
4
+
5
+ afterEach(() => stopRecording());
6
+
7
+ async function session(opts: { killExits?: boolean; eofExits?: boolean } = {}) {
8
+ let stream!: ReadableStreamDefaultController<Uint8Array>;
9
+ let resolveExit!: (code: number) => void;
10
+ let streamClosed = false;
11
+ let processExited = false;
12
+ const output = (text: string) => { if (!streamClosed) stream.enqueue(new TextEncoder().encode(text)); };
13
+ const endOutput = () => { if (!streamClosed) { streamClosed = true; stream.close(); } };
14
+ const exit = (code = 0, drain = true) => {
15
+ if (drain) endOutput();
16
+ if (!processExited) { processExited = true; resolveExit(code); }
17
+ };
18
+ const writes: string[] = [];
19
+ const kills: unknown[] = [];
20
+ const fake = {
21
+ stdout: new ReadableStream<Uint8Array>({ start(c) { stream = c; }, cancel() { streamClosed = true; } }),
22
+ stderr: new ReadableStream<Uint8Array>({ start(c) { c.close(); } }),
23
+ stdin: { write: (data: string) => {
24
+ writes.push(data);
25
+ if (data === "\x03") setTimeout(() => output("\r\n^C"), 0);
26
+ if (data === "\x04" && opts.eofExits) exit();
27
+ } },
28
+ exited: new Promise<number>((resolve) => { resolveExit = resolve; }),
29
+ kill: (signal: unknown) => { kills.push(signal); if (opts.killExits !== false) exit(137); },
30
+ };
31
+ const spawn = spyOn(Bun, "spawn").mockReturnValue(fake as unknown as ReturnType<typeof Bun.spawn>);
32
+ try {
33
+ const term = await openTerminal({
34
+ service: "rack", sessionId: "shell", recordEvents: true,
35
+ sink: { pushFrame() {}, markClosed() {} },
36
+ });
37
+ return { term, output, exit, endOutput, writes, kills, async close() {
38
+ exit();
39
+ try { await term.close(); } catch { /* a tested close failure remains memoized */ }
40
+ spawn.mockRestore();
41
+ } };
42
+ } catch (error) {
43
+ exit(); spawn.mockRestore(); throw error;
44
+ }
45
+ }
46
+
47
+ function waitEvents() {
48
+ return stopRecording().filter(e => e.kind === "terminal-step" && e.action === "waitFor");
49
+ }
50
+
51
+ test.each([
52
+ ["Initialized", { kind: "text", value: "Initialized" }],
53
+ [/Initialized/i, { kind: "regex", value: "Initialized", flags: "i" }],
54
+ [(screen: string) => screen.includes("Initialized"), { kind: "custom" }],
55
+ ] as const)("waitFor records its condition and bounds without an outer wait: %p", async (matcher, expected) => {
56
+ startRecording();
57
+ const s = await session();
58
+ try {
59
+ s.output("Booting\r\n");
60
+ const timer = setTimeout(() => s.output("Initialized\r\n"), 40);
61
+ try {
62
+ await s.term.waitFor("Rack is ready", matcher as string, { timeoutMs: 1000, intervalMs: 5 });
63
+ } finally { clearTimeout(timer); }
64
+ await s.term.press("Ctrl+C");
65
+ const events = stopRecording();
66
+ const wait = events.find(e => e.kind === "terminal-step" && e.action === "waitFor");
67
+ if (wait?.kind !== "terminal-step") throw new Error("Missing wait event");
68
+ expect(wait.matcher).toEqual(expected);
69
+ expect(wait.description).toBe("Rack is ready");
70
+ expect(wait.matched).toBe(true);
71
+ expect(wait.attempts).toBeGreaterThan(1);
72
+ expect(wait.timeoutMs).toBe(1000);
73
+ expect(wait.castEndTimeSec!).toBeGreaterThan(wait.castStartTimeSec!);
74
+ expect(wait.screenPreview).toContain("Initialized");
75
+ expect(wait.screenPreview).not.toContain("^C");
76
+ const press = events.find(e => e.kind === "terminal-step" && e.action === "press");
77
+ expect(press?.seq).toBeGreaterThan(wait.seq);
78
+ expect(events.filter(e => e.kind === "wait")).toHaveLength(0);
79
+ } finally { await s.close(); }
80
+ });
81
+
82
+ test("a quiet timeout records elapsed time even after the last terminal frame", async () => {
83
+ startRecording();
84
+ const s = await session();
85
+ try {
86
+ s.output("Still booting");
87
+ await expect(s.term.waitFor("Rack is ready", "Initialized", { timeoutMs: 60, intervalMs: 5 }))
88
+ .rejects.toThrow("timed out");
89
+ const wait = stopRecording().find(e => e.kind === "terminal-step" && e.action === "waitFor");
90
+ if (wait?.kind !== "terminal-step") throw new Error("Missing wait event");
91
+ expect(wait.matched).toBe(false);
92
+ expect(wait.durationMs).toBeGreaterThanOrEqual(60);
93
+ expect(wait.castEndTimeSec!).toBeGreaterThan(wait.castTimeSec);
94
+ expect(wait.screenPreview).toBe("Still booting");
95
+ } finally { await s.close(); }
96
+ });
97
+
98
+
99
+ test("records a thrown predicate error once, preserving its identity and observed screen", async () => {
100
+ const s = await session();
101
+ startRecording();
102
+ try {
103
+ s.output("Initialization Failed");
104
+ const failure = new Error("Rack initialization failed");
105
+ await expect(s.term.waitFor("Initialized", async screen => {
106
+ if (screen.includes("Failed")) {
107
+ // The screen can change while an async predicate is running. Keep
108
+ // the frame that was actually checked as the failure evidence.
109
+ s.output("\r\nLater unrelated output");
110
+ await Bun.sleep(5);
111
+ throw failure;
112
+ }
113
+ return false;
114
+ }, { intervalMs: 2 })).rejects.toBe(failure);
115
+ const events = waitEvents();
116
+ expect(events).toHaveLength(1);
117
+ expect(events[0]).toMatchObject({ outcome:"predicate_error", matched:false,
118
+ error:failure.message, screenPreview:"Initialization Failed" });
119
+ } finally { await s.close(); }
120
+ });
121
+
122
+ test("records declarative failure conditions and their outcome before accepting success", async () => {
123
+ const s = await session();
124
+ startRecording();
125
+ try {
126
+ s.output("Initialized\r\nInitialization Panicked");
127
+ await expect(s.term.waitFor("Rack initialized", "Initialized", { intervalMs:2,
128
+ failOn:[{description:"Rack initialization panicked",matcher:/Initialization Panicked/}],
129
+ })).rejects.toThrow("Rack initialization panicked");
130
+ expect(waitEvents()[0]).toMatchObject({outcome:"failure_matched", matched:false,
131
+ failureMatchers:[{description:"Rack initialization panicked",matcher:{kind:"regex",value:"Initialization Panicked",flags:""}}]});
132
+ } finally { await s.close(); }
133
+ });
134
+
135
+ test("checks final drained output before accepting process exit, without waiting a poll interval", async () => {
136
+ const s = await session();
137
+ startRecording();
138
+ try {
139
+ const pending = s.term.waitFor("Ready", "Initialized", {intervalMs:10000,timeoutMs:1000});
140
+ s.exit(0, false);
141
+ s.output("Initialized");
142
+ s.endOutput();
143
+ await pending;
144
+ expect(waitEvents()[0]).toMatchObject({outcome:"matched",screenPreview:"Initialized"});
145
+ } finally { await s.close(); }
146
+ });
147
+
148
+ test("a disconnect wakes a quiet wait and records the exit code", async () => {
149
+ const s = await session();
150
+ startRecording();
151
+ try {
152
+ const pending = s.term.waitFor("Ready", "Initialized", {intervalMs:10000,timeoutMs:1000});
153
+ s.output("Disconnected");
154
+ s.exit(255);
155
+ await expect(pending).rejects.toThrow("exited with code 255");
156
+ expect(waitEvents()[0]).toMatchObject({outcome:"process_exited",exitCode:255,screenPreview:"Disconnected"});
157
+ } finally { await s.close(); }
158
+ });
159
+
160
+ test("reading exited does not reserve an event before the process actually exits", async () => {
161
+ const s = await session();
162
+ startRecording();
163
+ try {
164
+ const exiting = s.term.exited;
165
+ expect(s.term.exited).toBe(exiting);
166
+ s.output("Ready");
167
+ await s.term.waitFor("Ready", "Ready", {intervalMs:2});
168
+ await s.term.press("Ctrl+C");
169
+ s.exit();
170
+ await exiting;
171
+ const events = stopRecording().filter(e => e.kind === "terminal-step");
172
+ expect(events.map(e => e.action)).toEqual(["waitFor", "press", "exit"]);
173
+ expect(events[2].tOffsetMs).toBeGreaterThanOrEqual(events[1].tOffsetMs);
174
+ } finally { await s.close(); }
175
+ });
176
+
177
+ test("abort stops polling, records cancellation once, and leaves the terminal usable", async () => {
178
+ const s = await session();
179
+ startRecording();
180
+ try {
181
+ const control = new AbortController();
182
+ let checks = 0;
183
+ const pending = s.term.waitFor("Ready", () => { checks++; return false; }, {signal:control.signal,intervalMs:5});
184
+ await Bun.sleep(10);
185
+ const failure = new Error("User cancelled");
186
+ control.abort(failure);
187
+ await expect(pending).rejects.toBe(failure);
188
+ const stopped = checks;
189
+ await Bun.sleep(15);
190
+ expect(checks).toBe(stopped);
191
+ expect(waitEvents()).toMatchObject([{outcome:"cancelled",error:"User cancelled"}]);
192
+ s.output("Ready");
193
+ await s.term.waitFor("Still usable", "Ready", {intervalMs:2});
194
+ } finally { await s.close(); }
195
+ });
196
+
197
+ test("async predicates are awaited and a hung predicate respects the deadline", async () => {
198
+ const s = await session();
199
+ startRecording();
200
+ try {
201
+ let checks = 0;
202
+ await s.term.waitFor("Async readiness", async () => ++checks === 2 ? "ready" : false,
203
+ {intervalMs:2,condition:"The async readiness check returns ready"});
204
+ expect(checks).toBe(2);
205
+ let finish!: (result: string) => void;
206
+ await expect(s.term.waitFor("Hung", () => new Promise<string>(resolve => {finish = resolve;}),
207
+ {timeoutMs:20})).rejects.toThrow("timed out");
208
+ finish("too late");
209
+ await Bun.sleep(5);
210
+ const events = waitEvents();
211
+ expect(events).toHaveLength(2);
212
+ expect(events[0]).toMatchObject({outcome:"matched",matcher:{kind:"custom",description:"The async readiness check returns ready"}});
213
+ expect(events[1]).toMatchObject({outcome:"timed_out",matched:false});
214
+ } finally { await s.close(); }
215
+ });
216
+
217
+ test("close sends EOF, cancels active waits, and is idempotent", async () => {
218
+ const s = await session({eofExits:true});
219
+ startRecording();
220
+ try {
221
+ const pending = s.term.waitFor("Ready", "Ready", {intervalMs:10000});
222
+ const rejected = pending.catch(error => error);
223
+ const closing = s.term.close();
224
+ expect(s.term.close()).toBe(closing);
225
+ await closing;
226
+ expect((await rejected).message).toContain("cancelled");
227
+ expect(s.writes).toContain("\x04");
228
+ expect(s.kills).toHaveLength(0);
229
+ const events = stopRecording().filter(e => e.kind === "terminal-step");
230
+ expect(events.filter(e => e.action === "close")).toHaveLength(1);
231
+ expect(events.find(e => e.action === "waitFor")).toMatchObject({outcome:"cancelled"});
232
+ } finally { await s.close(); }
233
+ });
234
+
235
+ test.each(["process", "pipe"])("close has a deadline when the %s never finishes", async (stuck) => {
236
+ const s = await session({killExits:false});
237
+ startRecording();
238
+ try {
239
+ if (stuck === "pipe") s.exit(0, false);
240
+ const closing = s.term.close({timeoutMs:40});
241
+ await expect(closing).rejects.toThrow("close timed out");
242
+ expect(s.term.close()).toBe(closing);
243
+ if (stuck === "process") expect(s.kills).toContain("SIGKILL");
244
+ expect(stopRecording().filter(e => e.kind === "terminal-step" && e.action === "close"))
245
+ .toMatchObject([{error:"Terminal close timed out after 40ms"}]);
246
+ } finally { await s.close(); }
247
+ });
248
+
249
+ test.skipIf(!Bun.which("script"))("real PTY drains its last screen and preserves the child exit status", async () => {
250
+ const spawn = Bun.spawn.bind(Bun);
251
+ const intercepted = spyOn(Bun, "spawn").mockImplementation(((args: string[], opts: unknown) => {
252
+ // Exercise the production PTY flags and streams, replacing only Docker.
253
+ return spawn([args[0], args[1], "printf 'Final status: Initialized\\n'; exit 7", args[3]], opts as never);
254
+ }) as typeof Bun.spawn);
255
+ startRecording();
256
+ let term: Awaited<ReturnType<typeof openTerminal>> | undefined;
257
+ try {
258
+ term = await openTerminal({service:"rack",sessionId:"real-pty",recordEvents:true,
259
+ sink:{pushFrame() {},markClosed() {}}});
260
+ await term.waitFor("Final status", "Final status: Initialized", {intervalMs:10000,timeoutMs:2000});
261
+ const result = await term.exited;
262
+ const { readRaw } = await import("./inspect.js");
263
+ expect(readRaw(result.exitCode)).toBe(7);
264
+ const events = stopRecording().filter(e => e.kind === "terminal-step");
265
+ expect(events.find(e => e.action === "waitFor")).toMatchObject({outcome:"matched",screenPreview:"Final status: Initialized"});
266
+ expect(events.find(e => e.action === "exit")).toMatchObject({exitCode:7});
267
+ } finally {
268
+ await term?.close();
269
+ intercepted.mockRestore();
270
+ }
271
+ });
272
+
273
+ test("an already-aborted wait never calls its predicate", async () => {
274
+ const s = await session();
275
+ startRecording();
276
+ try {
277
+ let checks = 0;
278
+ const signal = AbortSignal.abort(new Error("Already cancelled"));
279
+ await expect(s.term.waitFor("Ready", () => { checks++; return true; }, {signal}))
280
+ .rejects.toThrow("Already cancelled");
281
+ expect(checks).toBe(0);
282
+ expect(waitEvents()).toMatchObject([{outcome:"cancelled",attempts:0}]);
283
+ } finally { await s.close(); }
284
+ });
285
+
286
+ test("an async check interrupted by exit rechecks the final screen", async () => {
287
+ const s = await session();
288
+ startRecording();
289
+ try {
290
+ let release!: (value: boolean) => void;
291
+ const pending = s.term.waitFor("Ready", screen => screen.includes("Ready") ||
292
+ new Promise<boolean>(resolve => { release = resolve; }), {timeoutMs:1000});
293
+ await Bun.sleep(5);
294
+ s.output("Ready");
295
+ s.exit();
296
+ await Bun.sleep(5);
297
+ release(false);
298
+ await pending;
299
+ expect(waitEvents()).toMatchObject([{outcome:"matched",screenPreview:"Ready",attempts:2}]);
300
+ } finally { await s.close(); }
301
+ });
302
+
303
+ test("close escalates after EOF and returns the real killed status", async () => {
304
+ const s = await session();
305
+ try {
306
+ const result = await s.term.close({timeoutMs:100});
307
+ const { readRaw } = await import("./inspect.js");
308
+ expect(readRaw(result.exitCode)).toBe(137);
309
+ expect(s.writes).toEqual(["\x04"]);
310
+ expect(s.kills).toEqual(["SIGKILL"]);
311
+ } finally { await s.close(); }
312
+ });
313
+
314
+ test("exit cannot wait forever for an inherited output pipe", async () => {
315
+ const s = await session();
316
+ startRecording();
317
+ try {
318
+ const waiting = s.term.waitFor("Ready", "Ready", {timeoutMs:8000,intervalMs:10000});
319
+ s.exit(0, false);
320
+ await expect(waiting).rejects.toThrow("did not finish draining");
321
+ expect(waitEvents()).toMatchObject([{outcome:"process_exited",exitCode:0}]);
322
+ await expect(s.term.exited).rejects.toThrow("did not finish draining");
323
+ } finally { await s.close(); }
324
+ }, 10000);