@specific.dev/spectest 0.109.1 → 0.111.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 (40) hide show
  1. package/dist/components/index.d.ts +1 -1
  2. package/dist/components/replay-key.d.ts +4 -0
  3. package/dist/components/replay-key.js +1 -1
  4. package/dist/components/replay-websocket.d.ts +75 -0
  5. package/dist/components/replay-websocket.js +414 -0
  6. package/dist/components/replayFake.d.ts +21 -4
  7. package/dist/components/replayFake.js +115 -13
  8. package/dist/daemon.js +21 -5
  9. package/dist/fake-websocket.d.ts +2 -0
  10. package/dist/harness/background-failures.d.ts +2 -0
  11. package/dist/harness/background-failures.js +19 -0
  12. package/dist/harness/fake-websocket-connection.d.ts +2 -2
  13. package/dist/harness/fake-websocket-connection.js +61 -27
  14. package/dist/harness/fake-websocket-lifetime.d.ts +22 -0
  15. package/dist/harness/fake-websocket-lifetime.js +50 -0
  16. package/dist/harness/fake-websocket.js +28 -9
  17. package/dist/index.d.ts +12 -7
  18. package/dist/index.js +2 -2
  19. package/dist/replay-mode.d.ts +2 -0
  20. package/dist/replay-mode.js +12 -0
  21. package/package.json +1 -1
  22. package/src/components/index.ts +5 -0
  23. package/src/components/replay-key.ts +1 -1
  24. package/src/components/replay-websocket.ts +387 -0
  25. package/src/components/replayFake-handler.test.ts +2 -1
  26. package/src/components/replayFake-record.test.ts +7 -1
  27. package/src/components/replayFake-websocket-record.test.ts +110 -0
  28. package/src/components/replayFake-websocket.test.ts +264 -0
  29. package/src/components/replayFake.ts +125 -15
  30. package/src/daemon.ts +22 -5
  31. package/src/fake-websocket.ts +2 -0
  32. package/src/harness/background-failures.ts +19 -0
  33. package/src/harness/fake-websocket-connection.ts +45 -24
  34. package/src/harness/fake-websocket-lifetime.test.ts +185 -0
  35. package/src/harness/fake-websocket-lifetime.ts +52 -0
  36. package/src/harness/fake-websocket.ts +22 -10
  37. package/src/index.ts +9 -8
  38. package/src/interactive-setup.test.ts +2 -2
  39. package/src/replay-mode.test.ts +96 -0
  40. package/src/replay-mode.ts +14 -0
@@ -1,54 +1,75 @@
1
1
  import type WebSocket from "ws";
2
2
  import type { FakeWebSocket, FakeWebSocketHandlers } from "../fake-websocket.js";
3
+ import { getWebSocketLifetime } from "./fake-websocket-lifetime.js";
4
+ import { reportBackgroundFailure } from "./background-failures.js";
3
5
 
4
6
  const MAX_BUFFER = 16 * 1024 * 1024;
5
7
  const MAX_PENDING = 1024;
6
8
 
7
- /** Serialize async fake callbacks without allowing a slow handler or peer to
8
- * retain unbounded audio. A handler failure closes just this connection. */
9
+ /** Serialize callbacks, but release connection resources immediately when the
10
+ * transport ends, even if an async callback is still running. */
9
11
  export function attachFakeWebSocket(ws: WebSocket, handlers: FakeWebSocketHandlers): void {
10
- let tail = Promise.resolve();
11
- let pending = 0;
12
+ const lifetime = getWebSocketLifetime(handlers);
13
+ const queue: { run: () => void | Promise<void>; size: number }[] = [];
14
+ let running = false;
15
+ let inFlightBytes = 0;
12
16
  let bytes = 0;
13
17
  let failed = false;
18
+ lifetime.onEnd(() => { queue.length = 0; bytes = inFlightBytes; });
19
+
20
+ const fail = (err: unknown, code = 1011, reason = "fake handler failed") => {
21
+ if (failed) return;
22
+ failed = true;
23
+ reportBackgroundFailure(err);
24
+ lifetime.end({ kind: "failed", error: err });
25
+ try { ws.close(code, reason); } catch { ws.terminate(); }
26
+ };
14
27
  const socket: FakeWebSocket = {
15
28
  get readyState() { return ws.readyState; },
16
29
  send(message) {
17
30
  if (ws.readyState !== 1) throw new Error("WebSocket is not open");
18
31
  const size = typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
19
32
  if (ws.bufferedAmount + size > MAX_BUFFER) {
20
- failed = true;
21
- ws.close(1009, "fake output buffer exceeded");
22
- throw new Error("fake WebSocket output buffer exceeded");
33
+ const error = new Error("fake WebSocket output buffer exceeded");
34
+ fail(error, 1009, "fake output buffer exceeded");
35
+ throw error;
23
36
  }
24
37
  ws.send(message);
25
38
  },
26
39
  close: (code, reason) => ws.close(code, reason),
40
+ terminate: () => ws.terminate(),
27
41
  };
28
- const fail = (err: unknown) => {
29
- failed = true;
30
- console.error("[spectest] fake WebSocket callback failed:", err);
31
- try { ws.close(1011, "fake handler failed"); } catch { ws.terminate(); }
42
+ const drain = async () => {
43
+ if (running) return;
44
+ running = true;
45
+ try {
46
+ while (queue.length) {
47
+ const task = queue.shift()!;
48
+ inFlightBytes = task.size;
49
+ try { await task.run(); } catch (error) { fail(error); }
50
+ finally { bytes -= task.size; inFlightBytes = 0; }
51
+ }
52
+ } finally { running = false; }
32
53
  };
33
- const enqueue = (fn: () => void | Promise<void>, size = 0, closing = false) => {
34
- if (failed && !closing) return;
35
- if (!closing && (pending >= MAX_PENDING || bytes + size > MAX_BUFFER)) {
36
- failed = true;
37
- ws.close(1009, "fake input buffer exceeded");
54
+ const enqueue = (run: () => void | Promise<void>, size = 0, closing = false) => {
55
+ if (lifetime.ended && !closing) return;
56
+ if (!closing && (queue.length + Number(running) >= MAX_PENDING || bytes + size > MAX_BUFFER)) {
57
+ fail(new Error("fake WebSocket input buffer exceeded"), 1009, "fake input buffer exceeded");
38
58
  return;
39
59
  }
40
- pending++;
60
+ queue.push({ run, size });
41
61
  bytes += size;
42
- tail = tail.then(async () => {
43
- if (!failed || closing) await fn();
44
- }).catch(fail).finally(() => { pending--; bytes -= size; });
62
+ void drain();
45
63
  };
46
64
  ws.on("message", (data, binary) => {
47
65
  const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as ArrayBuffer);
48
- const message = binary ? new Uint8Array(buffer) : buffer.toString();
49
- enqueue(() => handlers.message?.(socket, message), buffer.length);
66
+ enqueue(() => handlers.message?.(socket, binary ? new Uint8Array(buffer) : buffer.toString()), buffer.length);
67
+ });
68
+ ws.on("close", (code, reason) => {
69
+ lifetime.end({ kind: "closed", code, reason: reason.toString() });
70
+ enqueue(() => handlers.close?.(socket, code, reason.toString()), 0, true);
50
71
  });
51
- ws.on("close", (code, reason) => enqueue(() => handlers.close?.(socket, code, reason.toString()), 0, true));
52
72
  ws.on("error", fail);
53
- enqueue(() => handlers.open?.(socket));
73
+ if (lifetime.ended) ws.terminate();
74
+ else enqueue(() => handlers.open?.(socket));
54
75
  }
@@ -0,0 +1,185 @@
1
+ import { afterEach, expect, spyOn, test } from "bun:test";
2
+ import { once } from "node:events";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join } from "node:path";
5
+ import { createServer } from "node:http";
6
+ import type { FakeContext, FakeWebSocketHandlers } from "../index.js";
7
+ import { defineFake } from "../index.js";
8
+ import { FakeWebSocketLifetime, getWebSocketLifetime } from "./fake-websocket-lifetime.js";
9
+ import { runWithBackgroundFailures, reportBackgroundFailure } from "./background-failures.js";
10
+ import { dispatchFake } from "./fake-websocket.js";
11
+ import { startIngressServer } from "./ingress-server.js";
12
+ import { recordWebSocket } from "../components/replay-websocket.js";
13
+ import { replayFake } from "../components/replayFake.js";
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const WS = require(join(dirname(require.resolve("ws/package.json")), "index.js")) as typeof import("ws");
17
+ const cleanup: (() => void)[] = [];
18
+ afterEach(() => { for (const fn of cleanup.splice(0).reverse()) fn(); });
19
+ const ctx = {} as FakeContext;
20
+
21
+ function silenceErrors() {
22
+ const errors = spyOn(console, "error").mockImplementation(() => {});
23
+ cleanup.push(() => errors.mockRestore());
24
+ }
25
+
26
+ test("transport disconnect disposes immediately while callbacks are blocked, discarding queued messages", async () => {
27
+ let release!: () => void;
28
+ const blocked = new Promise<void>(resolve => release = resolve);
29
+ cleanup.push(release);
30
+ let entered!: () => void;
31
+ const started = new Promise<void>(resolve => entered = resolve);
32
+ let ended!: () => void;
33
+ const disposed = new Promise<void>(resolve => ended = resolve);
34
+ let closed!: () => void;
35
+ const closeCallback = new Promise<void>(resolve => closed = resolve);
36
+ let disposals = 0;
37
+ const messages: string[] = [];
38
+ const fake = defineFake({ name: "slow", hostnames: ["slow.test"], handler: () => new Response("http"),
39
+ websocket(req) {
40
+ getWebSocketLifetime(req).onEnd(() => { disposals++; ended(); });
41
+ return {
42
+ async message(_socket, message) { messages.push(String(message)); entered(); await blocked; },
43
+ close() { closed(); },
44
+ };
45
+ },
46
+ });
47
+ const server = await startIngressServer({ port: 0, hostname: "127.0.0.1", fetch: (req, server) => dispatchFake(req, server, fake, undefined, ctx) });
48
+ cleanup.push(() => server.stop(true));
49
+ const client = new WS(`ws://127.0.0.1:${server.port}`);
50
+ await once(client, "open");
51
+ const disconnected = once(client, "close");
52
+ client.send("first"); await started;
53
+ client.send("queued"); client.close();
54
+ await disconnected; await disposed;
55
+ expect(disposals).toBe(1);
56
+ expect(messages).toEqual(["first"]);
57
+ release(); await closeCallback;
58
+ expect(messages).toEqual(["first"]);
59
+ expect(disposals).toBe(1);
60
+ });
61
+
62
+ test.each(["refused", "invalid-protocol", "throw", "aborted"])("failed %s handshake disposes once before any open callback", async failure => {
63
+ const controller = new AbortController();
64
+ const req = new Request("https://voice.test/live", { signal: controller.signal, headers: { upgrade: "websocket" } });
65
+ let disposals = 0;
66
+ let opened = false;
67
+ const fake = defineFake({ name: "voice", hostnames: ["voice.test"], handler: () => new Response("http"),
68
+ async websocket(request) {
69
+ getWebSocketLifetime(request).onEnd(() => disposals++);
70
+ if (failure === "throw") throw new Error("handshake failed");
71
+ if (failure === "aborted") { controller.abort(); await Bun.sleep(1); }
72
+ return { protocol: failure === "invalid-protocol" ? "unoffered" : undefined, open() { opened = true; } };
73
+ },
74
+ });
75
+ const server = { port: 0, stop() {}, requestIP: () => undefined, upgrade: () => false };
76
+ const response = await dispatchFake(req, server, fake, undefined, ctx);
77
+ expect(response.status).toBe(failure === "throw" || failure === "invalid-protocol" ? 500 : 426);
78
+ expect(disposals).toBe(1); expect(opened).toBe(false);
79
+ controller.abort(); expect(disposals).toBe(1);
80
+ });
81
+
82
+ test("aborting a pending upstream handshake releases the socket without waiting for timeout", async () => {
83
+ const server = createServer();
84
+ const accepted = once(server, "upgrade");
85
+ server.listen(0, "127.0.0.1"); await once(server, "listening");
86
+ cleanup.push(() => server.close());
87
+ const lifetime = new FakeWebSocketLifetime();
88
+ cleanup.push(() => lifetime.end({ kind: "aborted" }));
89
+ let finished = 0;
90
+ const recording = recordWebSocket({
91
+ url: `ws://voice.test:${(server.address() as { port: number }).port}/live`, host: "voice.test", ip: "127.0.0.1",
92
+ headers: {}, protocols: [], request: { method: "GET", path: "/live", query: {}, headers: {} },
93
+ match: () => null, redact: s => s, secrets: [], label: "pending", lifetime,
94
+ captured() { throw new Error("should not finish handshake"); }, finished() { finished++; },
95
+ });
96
+ const rejected = recording.then(() => undefined, error => error);
97
+ const [, socket] = await accepted;
98
+ cleanup.push(() => socket.destroy());
99
+ const disconnected = once(socket, "end");
100
+ socket.resume();
101
+ lifetime.end({ kind: "aborted" });
102
+ expect((await rejected)?.message).toContain("handshake aborted");
103
+ await disconnected;
104
+ expect(finished).toBe(1);
105
+ });
106
+
107
+ test("callback failures automatically reject the running test and release connection resources", async () => {
108
+ silenceErrors();
109
+ let disposals = 0;
110
+ const fake = defineFake({ name: "broken", hostnames: ["broken.test"], handler: () => new Response("http"), websocket(req) {
111
+ getWebSocketLifetime(req).onEnd(() => disposals++);
112
+ return { message() { throw new Error("callback exploded"); } };
113
+ } });
114
+ const server = await startIngressServer({ port: 0, hostname: "127.0.0.1", fetch: (req, server) => dispatchFake(req, server, fake, undefined, ctx) });
115
+ cleanup.push(() => server.stop(true));
116
+ const client = new WS(`ws://127.0.0.1:${server.port}`);
117
+ await once(client, "open");
118
+ const closed = once(client, "close");
119
+ await expect(runWithBackgroundFailures(async () => {
120
+ client.send("fail");
121
+ await new Promise(() => {}); // Represents a test waiting for an app response.
122
+ })).rejects.toThrow("callback exploded");
123
+ expect(disposals).toBe(1);
124
+ expect((await closed)[0]).toBe(1011);
125
+ expect(await runWithBackgroundFailures(async () => "next test")).toBe("next test");
126
+ });
127
+
128
+ test("replay timeout automatically fails a test; open connections do not require end-of-test verification", async () => {
129
+ silenceErrors();
130
+ const fake = replayFake({ name: "voice", host: "voice.test", mode: "replay", websocketTimeoutMs: 50 });
131
+ const state = await fake.state!();
132
+ const helpers = await fake.helpers!({ name: "voice", state, ctx });
133
+ expect(Object.keys(helpers).sort()).toEqual(["count", "dump", "recorded", "replay"]);
134
+ helpers.replay({ version: 1, fake: "voice", host: "voice.test", interactions: [], websockets: [{
135
+ request: { method: "GET", path: "/", query: {}, headers: {} }, inputs: ["a".repeat(64)], messages: [],
136
+ }] });
137
+ const server = await startIngressServer({ port: 0, hostname: "127.0.0.1", fetch: (req, server) => dispatchFake(req, server, fake, state, ctx) });
138
+ cleanup.push(() => server.stop(true));
139
+ let client!: InstanceType<typeof WS>;
140
+ let closed!: Promise<unknown[]>;
141
+ await expect(runWithBackgroundFailures(async () => {
142
+ client = new WS(`ws://127.0.0.1:${server.port}`);
143
+ closed = once(client, "close");
144
+ await once(client, "open");
145
+ await new Promise(() => {});
146
+ })).rejects.toThrow("required input never arrived");
147
+ expect((await closed)[0]).toBe(1011);
148
+ expect(state.websocketSessions.size).toBe(0);
149
+ helpers.replay({ version: 1, fake: "voice", host: "voice.test", interactions: [], websockets: [{
150
+ request: { method: "GET", path: "/", query: {}, headers: {} }, inputs: [], messages: [],
151
+ }] });
152
+ await runWithBackgroundFailures(async () => {
153
+ client = new WS(`ws://127.0.0.1:${server.port}`); closed = once(client, "close");
154
+ await once(client, "open");
155
+ });
156
+ expect(state.websocketSessions.size).toBe(1); // Test returned; connection is still usable.
157
+ client.close(); await closed;
158
+ await Bun.sleep(5);
159
+ expect(state.websocketSessions.size).toBe(0);
160
+ });
161
+
162
+ test("connection failure cancels replay timers immediately without a later timeout failure", async () => {
163
+ silenceErrors();
164
+ const fake = replayFake({ name: "voice", host: "voice.test", mode: "replay", websocketTimeoutMs: 20 });
165
+ const state = await fake.state!();
166
+ const helpers = await fake.helpers!({ name: "voice", state, ctx });
167
+ helpers.replay({ version: 1, fake: "voice", host: "voice.test", interactions: [], websockets: [{
168
+ request: { method: "GET", path: "/", query: {}, headers: {} }, inputs: ["a".repeat(64)],
169
+ messages: [{ afterInput: 0, delayMs: 10, type: "text", data: "late" }],
170
+ }] });
171
+ const req = new Request("https://voice.test/");
172
+ const handlers = await fake.websocket!(req, state, ctx) as FakeWebSocketHandlers;
173
+ const sent: unknown[] = [];
174
+ await handlers.open!({ readyState: 1, send: value => sent.push(value), close() {} });
175
+ getWebSocketLifetime(req).end({ kind: "failed", error: new Error("transport error") });
176
+ await Bun.sleep(40);
177
+ expect(sent).toEqual([]); expect(state.websocketSessions.size).toBe(0);
178
+ expect(state.websocketErrors).toEqual([]); // The transport owns reporting the original failure.
179
+ });
180
+
181
+ test("background failures outside a running test are logged without poisoning the next test", async () => {
182
+ silenceErrors();
183
+ reportBackgroundFailure(new Error("manual recording failed"));
184
+ expect(await runWithBackgroundFailures(async () => 42)).toBe(42);
185
+ });
@@ -0,0 +1,52 @@
1
+ import type { FakeWebSocketHandlers } from "../fake-websocket.js";
2
+ import { reportBackgroundFailure } from "./background-failures.js";
3
+
4
+ export type WebSocketEnd =
5
+ | { kind: "closed"; code: number; reason: string }
6
+ | { kind: "failed"; error: unknown }
7
+ | { kind: "aborted" };
8
+
9
+ /** Internal connection ownership, independent of serialized user callbacks.
10
+ * Runs once on disconnect, failure or abandoned handshake, never at test end. */
11
+ export class FakeWebSocketLifetime {
12
+ private result?: WebSocketEnd;
13
+ private callbacks = new Set<(event: WebSocketEnd) => void>();
14
+ get ended(): boolean { return this.result !== undefined; }
15
+
16
+ onEnd(callback: (event: WebSocketEnd) => void): () => void {
17
+ if (this.result) callback(this.result);
18
+ else this.callbacks.add(callback);
19
+ return () => { this.callbacks.delete(callback); };
20
+ }
21
+
22
+ end(event: WebSocketEnd): void {
23
+ if (this.result) return;
24
+ this.result = event;
25
+ const callbacks = [...this.callbacks];
26
+ this.callbacks.clear();
27
+ for (const callback of callbacks) {
28
+ try { callback(event); } catch (error) { reportBackgroundFailure(error); }
29
+ }
30
+ }
31
+ }
32
+
33
+ const lifetimes = new WeakMap<object, FakeWebSocketLifetime>();
34
+
35
+ export function getWebSocketLifetime(target: Request | FakeWebSocketHandlers): FakeWebSocketLifetime {
36
+ let lifetime = lifetimes.get(target);
37
+ if (lifetime) return lifetime;
38
+ lifetime = new FakeWebSocketLifetime();
39
+ lifetimes.set(target, lifetime);
40
+ if (target instanceof Request) {
41
+ const abort = () => lifetime!.end({ kind: "aborted" });
42
+ target.signal.addEventListener("abort", abort, { once: true });
43
+ lifetime.onEnd(() => target.signal.removeEventListener("abort", abort));
44
+ if (target.signal.aborted) abort();
45
+ }
46
+ return lifetime;
47
+ }
48
+
49
+ export function bindWebSocketLifetime(handlers: FakeWebSocketHandlers, lifetime: FakeWebSocketLifetime): FakeWebSocketHandlers {
50
+ lifetimes.set(handlers, lifetime);
51
+ return handlers;
52
+ }
@@ -2,6 +2,8 @@ import type { FakeWebSocketHandlers } from "../fake-websocket.js";
2
2
  import type { FakeContext, FakeDefinition } from "../index.js";
3
3
  import type { IngressServer } from "./ingress-server.js";
4
4
  import { runUninstrumented } from "./instrumentation-scope.js";
5
+ import { bindWebSocketLifetime, getWebSocketLifetime } from "./fake-websocket-lifetime.js";
6
+ import { reportBackgroundFailure } from "./background-failures.js";
5
7
  import { parseSubprotocols } from "./ws-bridge.js";
6
8
 
7
9
  /** HTTP and WebSocket dispatch share fake state, helpers, and execution scope. */
@@ -12,12 +14,14 @@ export async function dispatchFake<S>(
12
14
  state: S,
13
15
  ctx: FakeContext,
14
16
  ): Promise<Response> {
17
+ if (req.headers.get("upgrade")?.toLowerCase() !== "websocket" || !def.websocket) {
18
+ try { return await runUninstrumented(() => def.handler(req, state, ctx)); }
19
+ catch (err) { return fakeError(def.name, err); }
20
+ }
21
+ const lifetime = getWebSocketLifetime(req);
15
22
  try {
16
- if (req.headers.get("upgrade")?.toLowerCase() !== "websocket" || !def.websocket) {
17
- return await runUninstrumented(() => def.handler(req, state, ctx));
18
- }
19
23
  const accepted = await runUninstrumented(() => def.websocket!(req, state, ctx));
20
- if (accepted instanceof Response) return accepted;
24
+ if (accepted instanceof Response) { lifetime.end({ kind: "aborted" }); return accepted; }
21
25
  if (!accepted || typeof accepted !== "object") throw new Error("websocket must return handlers or a Response");
22
26
  if (accepted.protocol !== undefined && !parseSubprotocols(req.headers.get("sec-websocket-protocol")).includes(accepted.protocol)) {
23
27
  throw new Error("websocket selected a protocol the client did not offer");
@@ -27,13 +31,21 @@ export async function dispatchFake<S>(
27
31
  message: (socket, message) => runUninstrumented(() => accepted.message?.(socket, message)),
28
32
  close: (socket, code, reason) => runUninstrumented(() => accepted.close?.(socket, code, reason)),
29
33
  };
30
- return server.upgrade(req, {
31
- handlers,
34
+ const upgraded = server.upgrade(req, {
35
+ handlers: bindWebSocketLifetime(handlers, lifetime),
32
36
  headers: accepted.protocol ? { "sec-websocket-protocol": accepted.protocol } : undefined,
33
- }) ? new Response(null, { status: 101 }) : new Response("WebSocket upgrade refused", { status: 426 });
34
- } catch (err) {
35
- return new Response(`spectest-daemon: fake ${def.name} threw: ${err instanceof Error ? err.message : String(err)}\n`, {
36
- status: 500, headers: { "content-type": "text/plain" },
37
37
  });
38
+ if (!upgraded) lifetime.end({ kind: "aborted" });
39
+ return upgraded ? new Response(null, { status: 101 }) : new Response("WebSocket upgrade refused", { status: 426 });
40
+ } catch (err) {
41
+ if (!lifetime.ended) reportBackgroundFailure(new Error(`fake ${def.name} WebSocket handshake failed: ${err instanceof Error ? err.message : String(err)}`));
42
+ lifetime.end({ kind: "failed", error: err });
43
+ return fakeError(def.name, err);
38
44
  }
39
45
  }
46
+
47
+ function fakeError(name: string, error: unknown): Response {
48
+ return new Response(`spectest-daemon: fake ${name} threw: ${error instanceof Error ? error.message : String(error)}\n`, {
49
+ status: 500, headers: { "content-type": "text/plain" },
50
+ });
51
+ }
package/src/index.ts CHANGED
@@ -2380,22 +2380,23 @@ export interface Project<
2380
2380
  /**
2381
2381
  * Prepare a new interactive environment, after normal setup and the shared
2382
2382
  * warm-template snapshot. Runs on both cold starts and warm restores from
2383
- * the dashboard or `spectest env start`, before the environment is ready.
2383
+ * the dashboard (seed checkbox on) or `spectest env start --seed`, before
2384
+ * the environment is ready. Skipped when seeding is not requested.
2384
2385
  * Never runs for tests, snapshot forks, or resuming a stopped environment.
2385
2386
  * Uses the same context as `setup`; throw to fail the start.
2386
2387
  */
2387
- setupInteractive?: ProjectSetupFn<S, F>;
2388
+ interactive?: { seed?: ProjectSetupFn<S, F> };
2388
2389
  tests?: TestSuite<S, F>;
2389
2390
  }
2390
2391
 
2391
2392
  /**
2392
- * Context handed to the project-level `setup` and `setupInteractive` hooks: the full
2393
+ * Context handed to the project-level `setup` and `interactive.seed` hooks: the full
2393
2394
  * {@link SpectestContext}, minus only what a timeline gives a test
2394
2395
  * (testName, parent, browser/mobile/terminal). Everything a test can do
2395
2396
  * to the environment — exec, fetch, `svc`/`fakes` helpers, `poll`,
2396
2397
  * `dnsName`, `certificate`, `startService`, and project-file access via
2397
2398
  * `projectRoot`/`readProjectFile` — is available here too. Normal `setup`
2398
- * state is captured into the shared warm template; `setupInteractive`
2399
+ * state is captured into the shared warm template; `interactive.seed`
2399
2400
  * state belongs only to the new interactive environment.
2400
2401
  */
2401
2402
  export interface ProjectSetupContext<
@@ -2445,8 +2446,8 @@ export interface ProjectOpts<
2445
2446
  > {
2446
2447
  /** Optional project-level setup. See `Project.setup`. */
2447
2448
  setup?: ProjectSetupFn<S, F>;
2448
- /** Optional interactive-only setup. See `Project.setupInteractive`. */
2449
- setupInteractive?: ProjectSetupFn<S, F>;
2449
+ /** Optional interactive-only setup. See `Project.interactive`. */
2450
+ interactive?: { seed?: ProjectSetupFn<S, F> };
2450
2451
  /** Test cases for this project's suite. */
2451
2452
  tests?: TestCase<unknown, S, F>[];
2452
2453
  }
@@ -2511,7 +2512,7 @@ export interface DefinedEnvironment<
2511
2512
  ): TestGroup<P, S, F>;
2512
2513
  /** Bundle this environment with a test suite into the project default
2513
2514
  * export. Pass tests as a plain array for the common case, or an
2514
- * options bag to attach `setup` or `setupInteractive`. (Fakes are declared on
2515
+ * options bag to attach `setup` or `interactive.seed`. (Fakes are declared on
2515
2516
  * `defineEnvironment`, not here.) */
2516
2517
  project(tests?: TestCase<unknown, S, F>[]): Project<S, F>;
2517
2518
  project(opts: ProjectOpts<S, F>): Project<S, F>;
@@ -2663,7 +2664,7 @@ export function defineEnvironment<
2663
2664
  : (arg ?? {});
2664
2665
  const proj: Project<S, F> = { environment: config };
2665
2666
  if (opts.setup) proj.setup = opts.setup;
2666
- if (opts.setupInteractive) proj.setupInteractive = opts.setupInteractive;
2667
+ if (opts.interactive) proj.interactive = opts.interactive;
2667
2668
  if (fakes) proj.fakes = fakes;
2668
2669
  if (opts.tests && opts.tests.length > 0) {
2669
2670
  // Explicit suite (single-file / legacy layout): the tests are listed
@@ -22,7 +22,7 @@ for (const withHook of [false, true]) {
22
22
  }});
23
23
  export default env.project({
24
24
  setup: () => { calls.push("baseline"); },
25
- ${withHook ? `setupInteractive: async (ctx) => {
25
+ ${withHook ? `interactive: { seed: async (ctx) => {
26
26
  calls.push("interactive");
27
27
  console.log("seeding demo data");
28
28
  await globalThis.seedGate;
@@ -35,7 +35,7 @@ for (const withHook of [false, true]) {
35
35
  if (typeof result.exitCode !== "number" || !result.stdout.includes("seed demo")) {
36
36
  throw new Error("exec result not plain: " + JSON.stringify(result));
37
37
  }
38
- },` : ""}
38
+ } },` : ""}
39
39
  });
40
40
  `);
41
41
  const script = `
@@ -0,0 +1,96 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ test("test environments replay HTTP and WebSockets throughout the recorder lifecycle, even with credentials", async () => {
7
+ // Isolate daemon state and transport mocks. Any attempted external I/O fails
8
+ // locally, even if this regression returns; no real provider is contacted.
9
+ const root = await mkdtemp(join(tmpdir(), "spectest-replay-mode-"));
10
+ const module = (file: string) => JSON.stringify(new URL(file, import.meta.url).href);
11
+ try {
12
+ const file = join(root, "mode.test.ts");
13
+ await writeFile(file, `
14
+ import { test, mock } from "bun:test";
15
+ import { strict as assert } from "node:assert";
16
+ test("environment lifetime", async () => {
17
+ let networkCalls = 0;
18
+ const forbidden = () => { networkCalls++; throw new Error("unexpected upstream I/O"); };
19
+ mock.module("node:dgram", () => ({ createSocket: forbidden }));
20
+ mock.module("node:https", () => ({ request: forbidden }));
21
+ const { METHODS } = await import(${module("./daemon.ts")});
22
+ const { replayFake } = await import(${module("./components/replayFake.ts")});
23
+ const { resolveReplayMode } = await import(${module("./replay-mode.ts")});
24
+ const { startRecording, stopRecording, pauseRecording, resumeRecording } = await import(${module("./recorder.ts")});
25
+ assert.equal((await METHODS.health({})).replayEnvironmentMode, true);
26
+ await METHODS.setRecordSecrets({ secrets: { API_KEY: "available-credential" } });
27
+ await assert.rejects(METHODS.setReplayEnvironmentMode({ mode: "typo" }));
28
+ assert.equal(resolveReplayMode(), "replay");
29
+ const request = { method: "GET", path: "/messages", query: {}, headers: {} };
30
+ const cassette = {
31
+ version: 1, fake: "test", host: "provider.test",
32
+ interactions: [{ request, response: { status: 200, headers: {}, body: "cassette" } }],
33
+ websockets: [{ request, inputs: [], messages: [] }],
34
+ };
35
+ const fakes = await Promise.all([undefined, "auto", "record", "replay"].map(async mode => {
36
+ const fake = replayFake({ name: "test", host: "provider.test", mode,
37
+ inject: [{ headers: { authorization: "Bearer {{secret:API_KEY}}" } }],
38
+ });
39
+ const state = await fake.state();
40
+ return { fake, state, helpers: await fake.helpers({ name: "test", state, ctx: {} }) };
41
+ }));
42
+ const check = async () => {
43
+ for (const { fake, state, helpers } of fakes) {
44
+ const http = () => fake.handler(new Request("https://provider.test/messages"), state, {});
45
+ const ws = () => fake.websocket(new Request("https://provider.test/messages", {
46
+ headers: { upgrade: "websocket", "sec-websocket-key": "test" },
47
+ }), state, {});
48
+ helpers.replay({ ...cassette, interactions: [], websockets: [] });
49
+ assert.equal((await http()).status, 599); // no cassette
50
+ assert.equal((await ws()).status, 599);
51
+ helpers.replay(cassette);
52
+ assert.equal(await (await http()).text(), "cassette");
53
+ const connection = await ws();
54
+ assert.equal(connection instanceof Response, false);
55
+ for (const session of state.websocketSessions) session.cancel();
56
+ assert.equal((await http()).status, 599); // exhausted cassette
57
+ assert.equal((await ws()).status, 599);
58
+ assert.equal(helpers.recorded().length, 0);
59
+ }
60
+ assert.equal(networkCalls, 0);
61
+ };
62
+ await check(); // startup, before any case has ever run
63
+ startRecording();
64
+ await check();
65
+ pauseRecording();
66
+ await check();
67
+ resumeRecording();
68
+ stopRecording();
69
+ await check(); // background traffic between cases / before a snapshot
70
+ startRecording();
71
+ stopRecording();
72
+ await check(); // another case cannot reset the environment mode
73
+ assert.equal((await METHODS.eval({ code: "export default 42;" })).ok, true);
74
+ assert.equal((await METHODS.eval({ code: 'throw new Error("expected");' })).ok, false);
75
+ await check(); // eval on a test environment cannot enable recording
76
+ await METHODS.setReplayEnvironmentMode({ mode: "interactive" });
77
+ assert.equal(resolveReplayMode(), "record");
78
+ assert.equal(resolveReplayMode("record"), "record");
79
+ assert.equal(resolveReplayMode("replay"), "replay");
80
+ startRecording(); stopRecording();
81
+ assert.equal(resolveReplayMode(), "record");
82
+ await METHODS.setReplayEnvironmentMode({ mode: "test" });
83
+ await check();
84
+ });
85
+ `);
86
+ const child = Bun.spawn([process.execPath, "test", file], {
87
+ env: { ...process.env, SPECTEST_APP_DIR: root }, stdout: "pipe", stderr: "pipe",
88
+ });
89
+ const [code, stdout, stderr] = await Promise.all([
90
+ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
91
+ ]);
92
+ expect(code, stdout + stderr).toBe(0);
93
+ } finally {
94
+ await rm(root, { recursive: true, force: true });
95
+ }
96
+ }, 20_000);
@@ -0,0 +1,14 @@
1
+ // Environment state: travels with memory snapshots, independently of the
2
+ // per-case event recorder. Shared/warm baselines must never record upstream.
3
+ // The control plane enables recording only on an interactive environment,
4
+ // after capturing/restoring the shared baseline and before interactive setup.
5
+ let mode: "test" | "interactive" = "test";
6
+
7
+ export function setReplayEnvironmentMode(value: "test" | "interactive"): void {
8
+ mode = value;
9
+ }
10
+
11
+ export function resolveReplayMode(requested: "auto" | "record" | "replay" = "auto"): "record" | "replay" {
12
+ // Even an explicit record option cannot send test traffic to real providers.
13
+ return mode === "test" || requested === "replay" ? "replay" : "record";
14
+ }