@specific.dev/spectest 0.109.1 → 0.110.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 (36) 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 +17 -2
  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/replay-mode.d.ts +2 -0
  18. package/dist/replay-mode.js +12 -0
  19. package/package.json +1 -1
  20. package/src/components/index.ts +5 -0
  21. package/src/components/replay-key.ts +1 -1
  22. package/src/components/replay-websocket.ts +387 -0
  23. package/src/components/replayFake-handler.test.ts +2 -1
  24. package/src/components/replayFake-record.test.ts +7 -1
  25. package/src/components/replayFake-websocket-record.test.ts +110 -0
  26. package/src/components/replayFake-websocket.test.ts +264 -0
  27. package/src/components/replayFake.ts +125 -15
  28. package/src/daemon.ts +18 -2
  29. package/src/fake-websocket.ts +2 -0
  30. package/src/harness/background-failures.ts +19 -0
  31. package/src/harness/fake-websocket-connection.ts +45 -24
  32. package/src/harness/fake-websocket-lifetime.test.ts +185 -0
  33. package/src/harness/fake-websocket-lifetime.ts +52 -0
  34. package/src/harness/fake-websocket.ts +22 -10
  35. package/src/replay-mode.test.ts +96 -0
  36. 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) }));
@@ -4470,10 +4472,10 @@ async function runOne(testCase) {
4470
4472
  // drains) so we hoist these out of the try/catch.
4471
4473
  let outcome = { status: "passed" };
4472
4474
  try {
4473
- const value = await Promise.race([
4475
+ const value = await runWithBackgroundFailures(() => Promise.race([
4474
4476
  Promise.resolve(runInstrumented(fetchScope, async () => { await nativeAndroid.begin(Object.values(base.svc).filter(isAndroidApp)); return testCase.run(ctx); })),
4475
4477
  timedOut,
4476
- ]);
4478
+ ]));
4477
4479
  // Stash the return value so child cases — which fork from the snapshot
4478
4480
  // we're about to capture — can read it off ctx.parent.
4479
4481
  TEST_DATA.set(testCase.id, value);
@@ -5142,6 +5144,7 @@ export function harnessMethods(state) {
5142
5144
  // Optional discovery: old SDKs omit this field and are never sent the
5143
5145
  // new method. Check the loaded project, including after warm restore.
5144
5146
  setupInteractive: typeof loaded?.project.setupInteractive === "function",
5147
+ replayEnvironmentMode: true,
5145
5148
  }),
5146
5149
  // Live bootstrap progress, polled by the control plane during
5147
5150
  // `bootstrap` and streamed into the test-run row. `{}` before the
@@ -5241,6 +5244,16 @@ export function harnessMethods(state) {
5241
5244
  }
5242
5245
  return { refs: [...refs], environmentScoped: true };
5243
5246
  },
5247
+ setReplayEnvironmentMode: async (params) => {
5248
+ if (params.mode !== "test" && params.mode !== "interactive") {
5249
+ throw badRequest("mode must be test or interactive");
5250
+ }
5251
+ if (state.inFlightTest || state.inFlightBootstrap || state.inFlightProjectSetup) {
5252
+ throw conflict("cannot change replay mode while work is in progress");
5253
+ }
5254
+ setReplayEnvironmentMode(params.mode);
5255
+ return { ok: true };
5256
+ },
5244
5257
  setRecordSecrets: async (params) => {
5245
5258
  configureSecrets(params);
5246
5259
  return { ok: true };
@@ -5319,6 +5332,8 @@ export function harnessMethods(state) {
5319
5332
  if (!tc)
5320
5333
  throw notFound(`unknown caseId: ${caseId}`);
5321
5334
  return exclusive(state, "inFlightTest", "another test is already running", async () => {
5335
+ // A manually invoked case also stays in replay after its recorder stops.
5336
+ setReplayEnvironmentMode("test");
5322
5337
  const result = await runOne(tc);
5323
5338
  // Recordings go out-of-band: encode and park the bundle, reply with
5324
5339
  // 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
+ }
@@ -0,0 +1,2 @@
1
+ export declare function setReplayEnvironmentMode(value: "test" | "interactive"): void;
2
+ export declare function resolveReplayMode(requested?: "auto" | "record" | "replay"): "record" | "replay";
@@ -0,0 +1,12 @@
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";
6
+ export function setReplayEnvironmentMode(value) {
7
+ mode = value;
8
+ }
9
+ export function resolveReplayMode(requested = "auto") {
10
+ // Even an explicit record option cannot send test traffic to real providers.
11
+ return mode === "test" || requested === "replay" ? "replay" : "record";
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.109.1",
3
+ "version": "0.110.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -88,6 +88,11 @@ export {
88
88
  type ReplayFakeContext,
89
89
  type ReplayHandler,
90
90
  type ReplayKey,
91
+ type WebSocketKey,
92
+ type ReplayWebSocketMessage,
93
+ type CassetteWebSocket,
94
+ type CassetteWebSocketMessage,
95
+ type WebSocketTiming,
91
96
  type ReplayRequest,
92
97
  type MatchKey,
93
98
  type JsonValue,
@@ -34,7 +34,7 @@ export type ReplayKey = (request: ReplayRequest) => MatchKey;
34
34
  /** Stream a typed, length-delimited canonical representation into the hash.
35
35
  * Never call toJSON: unsupported values must fail rather than silently lose
36
36
  * identity fields. In particular, an async callback must not become `{}`. */
37
- function hashKey(key: MatchKey): string {
37
+ export function hashKey(key: MatchKey): string {
38
38
  const hash = createHash("sha256");
39
39
  const ancestors = new Set<object>();
40
40
  const visit = (value: unknown): void => {