@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
@@ -31,10 +31,9 @@
31
31
  // with our in-VM-CA leaf; the upstream leg is a genuine public-cert TLS
32
32
  // handshake to the real host.
33
33
  //
34
- // Mode is chosen by `isRecording()`: it is true only inside an active
35
- // recorder (a `spectest test` case), false in eval/manual. So `auto`
36
- // (the default) replays under test and records during manual use — no new
37
- // control-plane mode flag. `mode: "record" | "replay"` overrides it.
34
+ // Mode belongs to the environment, not the per-case event recorder. Tests
35
+ // and shared baselines always replay, including startup and between cases.
36
+ // Interactive environments record unless `mode: "replay"` is requested.
38
37
  // An optional custom handler can return scripted responses or explicitly
39
38
  // delegate with ctx.replay(request). Only delegated traffic uses this engine.
40
39
  //
@@ -62,8 +61,11 @@ import { request as httpsRequest } from "node:https";
62
61
  import path from "node:path";
63
62
  import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
64
63
  import * as dnsPacket from "dns-packet";
65
- import { isRecording } from "../recorder.js";
64
+ import { resolveReplayMode } from "../replay-mode.js";
65
+ import { getWebSocketLifetime } from "../harness/fake-websocket-lifetime.js";
66
+ import { reportBackgroundFailure } from "../harness/background-failures.js";
66
67
  import { getRecordSecret } from "../record-secrets.js";
68
+ import { recordWebSocket, replayWebSocket, validateWebSocketKey, validateWebSocketRecording, websocketMatcher, } from "./replay-websocket.js";
67
69
  import { replaySignature } from "./replay-key.js";
68
70
  import { inferSigV4Scope, signAwsV4Request, } from "../aws-sigv4.js";
69
71
  // ────────────────────────────────────────────────────────────────────────
@@ -162,6 +164,7 @@ function coerceCassette(parsed, name, host) {
162
164
  fake: parsed.fake ?? name,
163
165
  host: parsed.host ?? host,
164
166
  interactions: structuredClone(parsed.interactions),
167
+ ...(parsed.websockets !== undefined ? { websockets: structuredClone(parsed.websockets) } : {}),
165
168
  };
166
169
  }
167
170
  /** Read a cassette by its path relative to `spectest/tests/` (e.g.
@@ -411,18 +414,17 @@ export function replayFake(opts) {
411
414
  if (opts.key !== undefined && typeof opts.key !== "function") {
412
415
  throw new Error(`replayFake(${opts.name}): \`key\` must be a synchronous function`);
413
416
  }
417
+ validateWebSocketKey(opts.websocketKey);
418
+ const websocketTimeoutMs = opts.websocketTimeoutMs ?? 30_000;
419
+ if (!Number.isFinite(websocketTimeoutMs) || websocketTimeoutMs <= 0 || websocketTimeoutMs > 2_147_483_647) {
420
+ throw new Error(`replayFake(${opts.name}): websocketTimeoutMs must be positive and at most 2147483647`);
421
+ }
414
422
  const injectRules = opts.inject ?? [];
415
423
  // Refs every header template references — the control plane resolves these
416
424
  // server-side and pushes them for the environment lifetime (see the daemon's
417
425
  // /record-secret-refs endpoint, which reads `def.secretRefs`).
418
426
  const secretRefs = collectSecretRefs(injectRules, opts.sign);
419
- const resolveMode = () => {
420
- if (opts.mode === "record")
421
- return "record";
422
- if (opts.mode === "replay")
423
- return "replay";
424
- return isRecording() ? "replay" : "record";
425
- };
427
+ const resolveMode = () => resolveReplayMode(opts.mode);
426
428
  // ── REPLAY ──────────────────────────────────────────────────────────
427
429
  function signature(request, location) {
428
430
  try {
@@ -673,6 +675,83 @@ export function replayFake(opts) {
673
675
  return replay(reqLike, state);
674
676
  return record(req, url, reqLike, bodyBytes, state);
675
677
  };
678
+ const websocket = async (req, state) => {
679
+ const url = new URL(req.url);
680
+ const request = {
681
+ method: req.method.toUpperCase(), path: url.pathname,
682
+ query: normalizeQuery(url.searchParams, opts.sign !== undefined),
683
+ headers: recordable(req.headers, [...req.headers.keys()].filter(k => !["sec-websocket-key", "sec-websocket-accept"].includes(k))),
684
+ };
685
+ const sig = signature(request, "WebSocket handshake");
686
+ const match = websocketMatcher(opts.websocketKey);
687
+ const lifetime = getWebSocketLifetime(req);
688
+ const track = (session) => {
689
+ if (!lifetime.ended)
690
+ state.websocketSessions.add(session);
691
+ };
692
+ const finished = (session, error) => {
693
+ state.websocketSessions.delete(session);
694
+ if (error) {
695
+ state.websocketErrors.push(error.message);
696
+ reportBackgroundFailure(error);
697
+ }
698
+ };
699
+ const offered = (req.headers.get("sec-websocket-protocol") ?? "").split(",").map(p => p.trim()).filter(Boolean);
700
+ if (resolveMode() === "replay") {
701
+ const position = state.websocketCursor.get(sig) ?? 0;
702
+ const index = state.websocketIndex.get(sig)?.[position];
703
+ const label = `replayFake(${opts.name}) WebSocket ${sig} connection #${position + 1}`;
704
+ if (index === undefined) {
705
+ const error = `${label}: no recording available (handshake unmatched or exhausted)`;
706
+ state.websocketErrors.push(error);
707
+ reportBackgroundFailure(new Error(error));
708
+ return new Response(error, { status: 599 });
709
+ }
710
+ const recording = state.cassette.websockets[index];
711
+ if (recording.protocol && !offered.includes(recording.protocol)) {
712
+ throw new Error(`${label}: client did not offer recorded subprotocol`);
713
+ }
714
+ const { handlers, session } = replayWebSocket({ recording, match, label, timeoutMs: websocketTimeoutMs, lifetime, finished });
715
+ state.websocketCursor.set(sig, position + 1);
716
+ track(session);
717
+ return handlers;
718
+ }
719
+ if (opts.sign)
720
+ throw new Error(`replayFake(${opts.name}): SigV4 WebSocket signing is not supported`);
721
+ const rule = injectRules.find(r => ruleMatches(r.match, {
722
+ method: request.method, path: request.path, query: url.searchParams, headers: req.headers,
723
+ }));
724
+ const brokered = rule ? brokerHeaders(rule) : { headers: {}, secrets: [], missing: [] };
725
+ if (brokered.missing.length)
726
+ throw new Error(`replayFake(${opts.name}): missing WebSocket secrets ${brokered.missing.join(", ")}`);
727
+ const redact = makeRedactor(brokered.secrets, opts.redactPatterns);
728
+ const storedRequest = {
729
+ ...request,
730
+ query: Object.fromEntries(Object.entries(request.query).map(([k, v]) => [k, v.map(redact)])),
731
+ headers: Object.fromEntries(Object.entries(request.headers).map(([k, v]) => [k, redact(v)])),
732
+ };
733
+ for (const { ref, value } of brokered.secrets)
734
+ if (value && JSON.stringify(storedRequest).includes(value))
735
+ throw new Error(`replayFake(${opts.name}): secret ${ref} survived WebSocket handshake redaction`);
736
+ const ips = await resolveExternalA(new URL(`https://${host}`).hostname);
737
+ if (!ips.length)
738
+ throw new Error(`replayFake(${opts.name}): could not resolve real upstream ${host}`);
739
+ const headers = {};
740
+ req.headers.forEach((v, k) => {
741
+ if (!STRIP_FORWARD_HEADERS.has(k) && !k.startsWith("sec-websocket-"))
742
+ headers[k] = v;
743
+ });
744
+ Object.assign(headers, brokered.headers);
745
+ const { handlers, session } = await recordWebSocket({
746
+ url: `wss://${host}${url.pathname}${url.search}`, ip: ips[0], host, headers, protocols: offered,
747
+ request: storedRequest, match, redact, secrets: brokered.secrets,
748
+ label: `replayFake(${opts.name}) WebSocket ${sig} connection #${state.recordedWebSockets.length + 1}`,
749
+ captured(recording) { state.recordedWebSockets.push(recording); state.dirty = true; },
750
+ lifetime, finished,
751
+ });
752
+ track(session);
753
+ return handlers;
754
+ };
676
755
  const replayHelpers = (state) => ({
677
756
  replay(file) {
678
757
  let loaded;
@@ -700,12 +779,29 @@ export function replayFake(opts) {
700
779
  else
701
780
  index.set(sig, [i]);
702
781
  }
782
+ const websocketIndex = new Map();
783
+ if (loaded.websockets !== undefined && !Array.isArray(loaded.websockets))
784
+ throw new Error("cassette websockets must be an array");
785
+ for (const [i, recording] of (loaded.websockets ?? []).entries()) {
786
+ validateWebSocketRecording(recording);
787
+ const sig = signature(recording.request, `WebSocket connection #${i + 1}`);
788
+ const queue = websocketIndex.get(sig);
789
+ if (queue)
790
+ queue.push(i);
791
+ else
792
+ websocketIndex.set(sig, [i]);
793
+ }
794
+ if (state.websocketSessions.size)
795
+ throw new Error("close active WebSockets before loading a cassette");
796
+ state.websocketIndex = websocketIndex;
797
+ state.websocketCursor = new Map();
798
+ state.websocketErrors = [];
703
799
  // Mutate the live state object (the handler reads the same
704
800
  // reference); reset the cursor so replay starts from the top.
705
801
  state.cassette = loaded;
706
802
  state.index = index;
707
803
  state.cursor = new Map();
708
- return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
804
+ return { cassette: label, host: loaded.host, interactions: loaded.interactions.length, ...(loaded.websockets ? { websockets: loaded.websockets.length } : {}) };
709
805
  },
710
806
  dump() {
711
807
  return structuredClone({
@@ -713,6 +809,9 @@ export function replayFake(opts) {
713
809
  fake: opts.name,
714
810
  host,
715
811
  interactions: [...state.cassette.interactions, ...state.recorded],
812
+ ...((state.cassette.websockets?.length || state.recordedWebSockets.length) ? {
813
+ websockets: [...(state.cassette.websockets ?? []), ...state.recordedWebSockets],
814
+ } : {}),
716
815
  });
717
816
  },
718
817
  recorded() {
@@ -728,12 +827,15 @@ export function replayFake(opts) {
728
827
  cursor: new Map(),
729
828
  recorded: [],
730
829
  dirty: false,
830
+ websocketIndex: new Map(), websocketCursor: new Map(), recordedWebSockets: [],
831
+ websocketSessions: new Set(), websocketErrors: [],
731
832
  custom,
732
833
  });
733
834
  const def = {
734
835
  name: opts.name,
735
836
  hostnames: [host],
736
837
  secretRefs,
838
+ websocket,
737
839
  state: () => opts.state
738
840
  ? Promise.resolve(opts.state()).then(initialState)
739
841
  : initialState(undefined),
package/dist/daemon.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { runWithBackgroundFailures } from "./harness/background-failures.js";
1
2
  import { generateHostCert as mintHostCert } from "./harness/certificate.js";
2
3
  import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
3
4
  import { waitForAll } from "./harness/concurrency.js";
@@ -77,6 +78,7 @@ import { trackHelpers } from "./helper-tracking.js";
77
78
  import { isRecording, recordEnv, recordExec, recordStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
78
79
  import { readRaw, wrap } from "./inspect.js";
79
80
  import { setRecordSecrets } from "./record-secrets.js";
81
+ import { setReplayEnvironmentMode } from "./replay-mode.js";
80
82
  import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
81
83
  function namedServices(cfg) {
82
84
  return Object.entries(cfg.services).map(([name, def]) => ({ name, ...def, image: normalizeServiceImage(def.image) }));
@@ -2930,7 +2932,7 @@ async function bootstrapInner(prepared, identity) {
2930
2932
  let interactiveSetupCompleted = false;
2931
2933
  /**
2932
2934
  * Run a project setup hook. Normal `setup` runs before the shared warm
2933
- * snapshot; `setupInteractive` runs only after it, in a fresh user start.
2935
+ * snapshot; `interactive.seed` runs only after it, in a fresh user start.
2934
2936
  * Both use the same context and boot-log capture.
2935
2937
  *
2936
2938
  * Unlike test runs, this is NOT instrumented — no recorder, no event
@@ -2949,7 +2951,8 @@ async function runProjectSetup(hook = "setup") {
2949
2951
  }
2950
2952
  }
2951
2953
  async function runProjectSetupInner(hook) {
2952
- const setup = requireLoaded().project[hook];
2954
+ const project = requireLoaded().project;
2955
+ const setup = hook === "setup" ? project.setup : project.interactive?.seed;
2953
2956
  if (!setup || (hook === "setupInteractive" && interactiveSetupCompleted)) {
2954
2957
  return { ran: false, durationMs: 0 };
2955
2958
  }
@@ -4470,10 +4473,10 @@ async function runOne(testCase) {
4470
4473
  // drains) so we hoist these out of the try/catch.
4471
4474
  let outcome = { status: "passed" };
4472
4475
  try {
4473
- const value = await Promise.race([
4476
+ const value = await runWithBackgroundFailures(() => Promise.race([
4474
4477
  Promise.resolve(runInstrumented(fetchScope, async () => { await nativeAndroid.begin(Object.values(base.svc).filter(isAndroidApp)); return testCase.run(ctx); })),
4475
4478
  timedOut,
4476
- ]);
4479
+ ]));
4477
4480
  // Stash the return value so child cases — which fork from the snapshot
4478
4481
  // we're about to capture — can read it off ctx.parent.
4479
4482
  TEST_DATA.set(testCase.id, value);
@@ -5141,7 +5144,8 @@ export function harnessMethods(state) {
5141
5144
  capabilities: ["prepared-images-v1"],
5142
5145
  // Optional discovery: old SDKs omit this field and are never sent the
5143
5146
  // new method. Check the loaded project, including after warm restore.
5144
- setupInteractive: typeof loaded?.project.setupInteractive === "function",
5147
+ setupInteractive: typeof loaded?.project.interactive?.seed === "function",
5148
+ replayEnvironmentMode: true,
5145
5149
  }),
5146
5150
  // Live bootstrap progress, polled by the control plane during
5147
5151
  // `bootstrap` and streamed into the test-run row. `{}` before the
@@ -5241,6 +5245,16 @@ export function harnessMethods(state) {
5241
5245
  }
5242
5246
  return { refs: [...refs], environmentScoped: true };
5243
5247
  },
5248
+ setReplayEnvironmentMode: async (params) => {
5249
+ if (params.mode !== "test" && params.mode !== "interactive") {
5250
+ throw badRequest("mode must be test or interactive");
5251
+ }
5252
+ if (state.inFlightTest || state.inFlightBootstrap || state.inFlightProjectSetup) {
5253
+ throw conflict("cannot change replay mode while work is in progress");
5254
+ }
5255
+ setReplayEnvironmentMode(params.mode);
5256
+ return { ok: true };
5257
+ },
5244
5258
  setRecordSecrets: async (params) => {
5245
5259
  configureSecrets(params);
5246
5260
  return { ok: true };
@@ -5319,6 +5333,8 @@ export function harnessMethods(state) {
5319
5333
  if (!tc)
5320
5334
  throw notFound(`unknown caseId: ${caseId}`);
5321
5335
  return exclusive(state, "inFlightTest", "another test is already running", async () => {
5336
+ // A manually invoked case also stays in replay after its recorder stops.
5337
+ setReplayEnvironmentMode("test");
5322
5338
  const result = await runOne(tc);
5323
5339
  // Recordings go out-of-band: encode and park the bundle, reply with
5324
5340
  // a size ref only. A bundle-encoding failure drops the recordings
@@ -3,6 +3,8 @@ export interface FakeWebSocket {
3
3
  readonly readyState: number;
4
4
  send(message: string | Uint8Array | ArrayBuffer): void;
5
5
  close(code?: number, reason?: string): void;
6
+ /** Abrupt transport closure (no close frame), used for recorded disconnects. */
7
+ terminate?(): void;
6
8
  }
7
9
  /** Returned by a fake's websocket handshake callback to accept a connection.
8
10
  * Callbacks run in order per connection, including async callbacks. Capture
@@ -0,0 +1,2 @@
1
+ export declare function reportBackgroundFailure(error: unknown): void;
2
+ export declare function runWithBackgroundFailures<T>(run: () => Promise<T>): Promise<T>;
@@ -0,0 +1,19 @@
1
+ // The harness runs one test at a time. Transport callbacks run outside the
2
+ // test's async stack, so report their failures through its existing race.
3
+ let rejectActiveTest;
4
+ export function reportBackgroundFailure(error) {
5
+ const failure = error instanceof Error ? error : new Error(String(error));
6
+ console.error("[spectest] background callback failed:", failure);
7
+ rejectActiveTest?.(failure);
8
+ }
9
+ export async function runWithBackgroundFailures(run) {
10
+ if (rejectActiveTest)
11
+ throw new Error("A test is already handling background failures");
12
+ const failed = new Promise((_resolve, reject) => { rejectActiveTest = reject; });
13
+ try {
14
+ return await Promise.race([failed, Promise.resolve().then(run)]);
15
+ }
16
+ finally {
17
+ rejectActiveTest = undefined;
18
+ }
19
+ }
@@ -1,5 +1,5 @@
1
1
  import type WebSocket from "ws";
2
2
  import type { FakeWebSocketHandlers } from "../fake-websocket.js";
3
- /** Serialize async fake callbacks without allowing a slow handler or peer to
4
- * retain unbounded audio. A handler failure closes just this connection. */
3
+ /** Serialize callbacks, but release connection resources immediately when the
4
+ * transport ends, even if an async callback is still running. */
5
5
  export declare function attachFakeWebSocket(ws: WebSocket, handlers: FakeWebSocketHandlers): void;
@@ -1,12 +1,30 @@
1
+ import { getWebSocketLifetime } from "./fake-websocket-lifetime.js";
2
+ import { reportBackgroundFailure } from "./background-failures.js";
1
3
  const MAX_BUFFER = 16 * 1024 * 1024;
2
4
  const MAX_PENDING = 1024;
3
- /** Serialize async fake callbacks without allowing a slow handler or peer to
4
- * retain unbounded audio. A handler failure closes just this connection. */
5
+ /** Serialize callbacks, but release connection resources immediately when the
6
+ * transport ends, even if an async callback is still running. */
5
7
  export function attachFakeWebSocket(ws, handlers) {
6
- let tail = Promise.resolve();
7
- let pending = 0;
8
+ const lifetime = getWebSocketLifetime(handlers);
9
+ const queue = [];
10
+ let running = false;
11
+ let inFlightBytes = 0;
8
12
  let bytes = 0;
9
13
  let failed = false;
14
+ lifetime.onEnd(() => { queue.length = 0; bytes = inFlightBytes; });
15
+ const fail = (err, code = 1011, reason = "fake handler failed") => {
16
+ if (failed)
17
+ return;
18
+ failed = true;
19
+ reportBackgroundFailure(err);
20
+ lifetime.end({ kind: "failed", error: err });
21
+ try {
22
+ ws.close(code, reason);
23
+ }
24
+ catch {
25
+ ws.terminate();
26
+ }
27
+ };
10
28
  const socket = {
11
29
  get readyState() { return ws.readyState; },
12
30
  send(message) {
@@ -14,45 +32,61 @@ export function attachFakeWebSocket(ws, handlers) {
14
32
  throw new Error("WebSocket is not open");
15
33
  const size = typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
16
34
  if (ws.bufferedAmount + size > MAX_BUFFER) {
17
- failed = true;
18
- ws.close(1009, "fake output buffer exceeded");
19
- throw new Error("fake WebSocket output buffer exceeded");
35
+ const error = new Error("fake WebSocket output buffer exceeded");
36
+ fail(error, 1009, "fake output buffer exceeded");
37
+ throw error;
20
38
  }
21
39
  ws.send(message);
22
40
  },
23
41
  close: (code, reason) => ws.close(code, reason),
42
+ terminate: () => ws.terminate(),
24
43
  };
25
- const fail = (err) => {
26
- failed = true;
27
- console.error("[spectest] fake WebSocket callback failed:", err);
44
+ const drain = async () => {
45
+ if (running)
46
+ return;
47
+ running = true;
28
48
  try {
29
- ws.close(1011, "fake handler failed");
49
+ while (queue.length) {
50
+ const task = queue.shift();
51
+ inFlightBytes = task.size;
52
+ try {
53
+ await task.run();
54
+ }
55
+ catch (error) {
56
+ fail(error);
57
+ }
58
+ finally {
59
+ bytes -= task.size;
60
+ inFlightBytes = 0;
61
+ }
62
+ }
30
63
  }
31
- catch {
32
- ws.terminate();
64
+ finally {
65
+ running = false;
33
66
  }
34
67
  };
35
- const enqueue = (fn, size = 0, closing = false) => {
36
- if (failed && !closing)
68
+ const enqueue = (run, size = 0, closing = false) => {
69
+ if (lifetime.ended && !closing)
37
70
  return;
38
- if (!closing && (pending >= MAX_PENDING || bytes + size > MAX_BUFFER)) {
39
- failed = true;
40
- ws.close(1009, "fake input buffer exceeded");
71
+ if (!closing && (queue.length + Number(running) >= MAX_PENDING || bytes + size > MAX_BUFFER)) {
72
+ fail(new Error("fake WebSocket input buffer exceeded"), 1009, "fake input buffer exceeded");
41
73
  return;
42
74
  }
43
- pending++;
75
+ queue.push({ run, size });
44
76
  bytes += size;
45
- tail = tail.then(async () => {
46
- if (!failed || closing)
47
- await fn();
48
- }).catch(fail).finally(() => { pending--; bytes -= size; });
77
+ void drain();
49
78
  };
50
79
  ws.on("message", (data, binary) => {
51
80
  const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
52
- const message = binary ? new Uint8Array(buffer) : buffer.toString();
53
- enqueue(() => handlers.message?.(socket, message), buffer.length);
81
+ enqueue(() => handlers.message?.(socket, binary ? new Uint8Array(buffer) : buffer.toString()), buffer.length);
82
+ });
83
+ ws.on("close", (code, reason) => {
84
+ lifetime.end({ kind: "closed", code, reason: reason.toString() });
85
+ enqueue(() => handlers.close?.(socket, code, reason.toString()), 0, true);
54
86
  });
55
- ws.on("close", (code, reason) => enqueue(() => handlers.close?.(socket, code, reason.toString()), 0, true));
56
87
  ws.on("error", fail);
57
- enqueue(() => handlers.open?.(socket));
88
+ if (lifetime.ended)
89
+ ws.terminate();
90
+ else
91
+ enqueue(() => handlers.open?.(socket));
58
92
  }
@@ -0,0 +1,22 @@
1
+ import type { FakeWebSocketHandlers } from "../fake-websocket.js";
2
+ export type WebSocketEnd = {
3
+ kind: "closed";
4
+ code: number;
5
+ reason: string;
6
+ } | {
7
+ kind: "failed";
8
+ error: unknown;
9
+ } | {
10
+ kind: "aborted";
11
+ };
12
+ /** Internal connection ownership, independent of serialized user callbacks.
13
+ * Runs once on disconnect, failure or abandoned handshake, never at test end. */
14
+ export declare class FakeWebSocketLifetime {
15
+ private result?;
16
+ private callbacks;
17
+ get ended(): boolean;
18
+ onEnd(callback: (event: WebSocketEnd) => void): () => void;
19
+ end(event: WebSocketEnd): void;
20
+ }
21
+ export declare function getWebSocketLifetime(target: Request | FakeWebSocketHandlers): FakeWebSocketLifetime;
22
+ export declare function bindWebSocketLifetime(handlers: FakeWebSocketHandlers, lifetime: FakeWebSocketLifetime): FakeWebSocketHandlers;
@@ -0,0 +1,50 @@
1
+ import { reportBackgroundFailure } from "./background-failures.js";
2
+ /** Internal connection ownership, independent of serialized user callbacks.
3
+ * Runs once on disconnect, failure or abandoned handshake, never at test end. */
4
+ export class FakeWebSocketLifetime {
5
+ result;
6
+ callbacks = new Set();
7
+ get ended() { return this.result !== undefined; }
8
+ onEnd(callback) {
9
+ if (this.result)
10
+ callback(this.result);
11
+ else
12
+ this.callbacks.add(callback);
13
+ return () => { this.callbacks.delete(callback); };
14
+ }
15
+ end(event) {
16
+ if (this.result)
17
+ return;
18
+ this.result = event;
19
+ const callbacks = [...this.callbacks];
20
+ this.callbacks.clear();
21
+ for (const callback of callbacks) {
22
+ try {
23
+ callback(event);
24
+ }
25
+ catch (error) {
26
+ reportBackgroundFailure(error);
27
+ }
28
+ }
29
+ }
30
+ }
31
+ const lifetimes = new WeakMap();
32
+ export function getWebSocketLifetime(target) {
33
+ let lifetime = lifetimes.get(target);
34
+ if (lifetime)
35
+ return lifetime;
36
+ lifetime = new FakeWebSocketLifetime();
37
+ lifetimes.set(target, lifetime);
38
+ if (target instanceof Request) {
39
+ const abort = () => lifetime.end({ kind: "aborted" });
40
+ target.signal.addEventListener("abort", abort, { once: true });
41
+ lifetime.onEnd(() => target.signal.removeEventListener("abort", abort));
42
+ if (target.signal.aborted)
43
+ abort();
44
+ }
45
+ return lifetime;
46
+ }
47
+ export function bindWebSocketLifetime(handlers, lifetime) {
48
+ lifetimes.set(handlers, lifetime);
49
+ return handlers;
50
+ }
@@ -1,14 +1,24 @@
1
1
  import { runUninstrumented } from "./instrumentation-scope.js";
2
+ import { bindWebSocketLifetime, getWebSocketLifetime } from "./fake-websocket-lifetime.js";
3
+ import { reportBackgroundFailure } from "./background-failures.js";
2
4
  import { parseSubprotocols } from "./ws-bridge.js";
3
5
  /** HTTP and WebSocket dispatch share fake state, helpers, and execution scope. */
4
6
  export async function dispatchFake(req, server, def, state, ctx) {
5
- try {
6
- if (req.headers.get("upgrade")?.toLowerCase() !== "websocket" || !def.websocket) {
7
+ if (req.headers.get("upgrade")?.toLowerCase() !== "websocket" || !def.websocket) {
8
+ try {
7
9
  return await runUninstrumented(() => def.handler(req, state, ctx));
8
10
  }
11
+ catch (err) {
12
+ return fakeError(def.name, err);
13
+ }
14
+ }
15
+ const lifetime = getWebSocketLifetime(req);
16
+ try {
9
17
  const accepted = await runUninstrumented(() => def.websocket(req, state, ctx));
10
- if (accepted instanceof Response)
18
+ if (accepted instanceof Response) {
19
+ lifetime.end({ kind: "aborted" });
11
20
  return accepted;
21
+ }
12
22
  if (!accepted || typeof accepted !== "object")
13
23
  throw new Error("websocket must return handlers or a Response");
14
24
  if (accepted.protocol !== undefined && !parseSubprotocols(req.headers.get("sec-websocket-protocol")).includes(accepted.protocol)) {
@@ -19,14 +29,23 @@ export async function dispatchFake(req, server, def, state, ctx) {
19
29
  message: (socket, message) => runUninstrumented(() => accepted.message?.(socket, message)),
20
30
  close: (socket, code, reason) => runUninstrumented(() => accepted.close?.(socket, code, reason)),
21
31
  };
22
- return server.upgrade(req, {
23
- handlers,
32
+ const upgraded = server.upgrade(req, {
33
+ handlers: bindWebSocketLifetime(handlers, lifetime),
24
34
  headers: accepted.protocol ? { "sec-websocket-protocol": accepted.protocol } : undefined,
25
- }) ? new Response(null, { status: 101 }) : new Response("WebSocket upgrade refused", { status: 426 });
35
+ });
36
+ if (!upgraded)
37
+ lifetime.end({ kind: "aborted" });
38
+ return upgraded ? new Response(null, { status: 101 }) : new Response("WebSocket upgrade refused", { status: 426 });
26
39
  }
27
40
  catch (err) {
28
- return new Response(`spectest-daemon: fake ${def.name} threw: ${err instanceof Error ? err.message : String(err)}\n`, {
29
- status: 500, headers: { "content-type": "text/plain" },
30
- });
41
+ if (!lifetime.ended)
42
+ reportBackgroundFailure(new Error(`fake ${def.name} WebSocket handshake failed: ${err instanceof Error ? err.message : String(err)}`));
43
+ lifetime.end({ kind: "failed", error: err });
44
+ return fakeError(def.name, err);
31
45
  }
32
46
  }
47
+ function fakeError(name, error) {
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/dist/index.d.ts CHANGED
@@ -1627,21 +1627,24 @@ export interface Project<S extends ServicesMap = ServicesMap, F extends FakesMap
1627
1627
  /**
1628
1628
  * Prepare a new interactive environment, after normal setup and the shared
1629
1629
  * warm-template snapshot. Runs on both cold starts and warm restores from
1630
- * the dashboard or `spectest env start`, before the environment is ready.
1630
+ * the dashboard (seed checkbox on) or `spectest env start --seed`, before
1631
+ * the environment is ready. Skipped when seeding is not requested.
1631
1632
  * Never runs for tests, snapshot forks, or resuming a stopped environment.
1632
1633
  * Uses the same context as `setup`; throw to fail the start.
1633
1634
  */
1634
- setupInteractive?: ProjectSetupFn<S, F>;
1635
+ interactive?: {
1636
+ seed?: ProjectSetupFn<S, F>;
1637
+ };
1635
1638
  tests?: TestSuite<S, F>;
1636
1639
  }
1637
1640
  /**
1638
- * Context handed to the project-level `setup` and `setupInteractive` hooks: the full
1641
+ * Context handed to the project-level `setup` and `interactive.seed` hooks: the full
1639
1642
  * {@link SpectestContext}, minus only what a timeline gives a test
1640
1643
  * (testName, parent, browser/mobile/terminal). Everything a test can do
1641
1644
  * to the environment — exec, fetch, `svc`/`fakes` helpers, `poll`,
1642
1645
  * `dnsName`, `certificate`, `startService`, and project-file access via
1643
1646
  * `projectRoot`/`readProjectFile` — is available here too. Normal `setup`
1644
- * state is captured into the shared warm template; `setupInteractive`
1647
+ * state is captured into the shared warm template; `interactive.seed`
1645
1648
  * state belongs only to the new interactive environment.
1646
1649
  */
1647
1650
  export interface ProjectSetupContext<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> extends SpectestContext<S, F> {
@@ -1681,8 +1684,10 @@ export type ProjectSetupFn<S extends ServicesMap = ServicesMap, F extends FakesM
1681
1684
  export interface ProjectOpts<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
1682
1685
  /** Optional project-level setup. See `Project.setup`. */
1683
1686
  setup?: ProjectSetupFn<S, F>;
1684
- /** Optional interactive-only setup. See `Project.setupInteractive`. */
1685
- setupInteractive?: ProjectSetupFn<S, F>;
1687
+ /** Optional interactive-only setup. See `Project.interactive`. */
1688
+ interactive?: {
1689
+ seed?: ProjectSetupFn<S, F>;
1690
+ };
1686
1691
  /** Test cases for this project's suite. */
1687
1692
  tests?: TestCase<unknown, S, F>[];
1688
1693
  }
@@ -1736,7 +1741,7 @@ export interface DefinedEnvironment<S extends ServicesMap, F extends FakesMap =
1736
1741
  group<P = undefined>(name: string, opts?: GroupOpts<P, S, F>): TestGroup<P, S, F>;
1737
1742
  /** Bundle this environment with a test suite into the project default
1738
1743
  * export. Pass tests as a plain array for the common case, or an
1739
- * options bag to attach `setup` or `setupInteractive`. (Fakes are declared on
1744
+ * options bag to attach `setup` or `interactive.seed`. (Fakes are declared on
1740
1745
  * `defineEnvironment`, not here.) */
1741
1746
  project(tests?: TestCase<unknown, S, F>[]): Project<S, F>;
1742
1747
  project(opts: ProjectOpts<S, F>): Project<S, F>;
package/dist/index.js CHANGED
@@ -484,8 +484,8 @@ export function defineEnvironment(input) {
484
484
  const proj = { environment: config };
485
485
  if (opts.setup)
486
486
  proj.setup = opts.setup;
487
- if (opts.setupInteractive)
488
- proj.setupInteractive = opts.setupInteractive;
487
+ if (opts.interactive)
488
+ proj.interactive = opts.interactive;
489
489
  if (fakes)
490
490
  proj.fakes = fakes;
491
491
  if (opts.tests && opts.tests.length > 0) {
@@ -0,0 +1,2 @@
1
+ export declare function setReplayEnvironmentMode(value: "test" | "interactive"): void;
2
+ export declare function resolveReplayMode(requested?: "auto" | "record" | "replay"): "record" | "replay";