@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.
- package/dist/components/index.d.ts +1 -1
- package/dist/components/replay-key.d.ts +4 -0
- package/dist/components/replay-key.js +1 -1
- package/dist/components/replay-websocket.d.ts +75 -0
- package/dist/components/replay-websocket.js +414 -0
- package/dist/components/replayFake.d.ts +21 -4
- package/dist/components/replayFake.js +115 -13
- package/dist/daemon.js +17 -2
- package/dist/fake-websocket.d.ts +2 -0
- package/dist/harness/background-failures.d.ts +2 -0
- package/dist/harness/background-failures.js +19 -0
- package/dist/harness/fake-websocket-connection.d.ts +2 -2
- package/dist/harness/fake-websocket-connection.js +61 -27
- package/dist/harness/fake-websocket-lifetime.d.ts +22 -0
- package/dist/harness/fake-websocket-lifetime.js +50 -0
- package/dist/harness/fake-websocket.js +28 -9
- package/dist/replay-mode.d.ts +2 -0
- package/dist/replay-mode.js +12 -0
- package/package.json +1 -1
- package/src/components/index.ts +5 -0
- package/src/components/replay-key.ts +1 -1
- package/src/components/replay-websocket.ts +387 -0
- package/src/components/replayFake-handler.test.ts +2 -1
- package/src/components/replayFake-record.test.ts +7 -1
- package/src/components/replayFake-websocket-record.test.ts +110 -0
- package/src/components/replayFake-websocket.test.ts +264 -0
- package/src/components/replayFake.ts +125 -15
- package/src/daemon.ts +18 -2
- package/src/fake-websocket.ts +2 -0
- package/src/harness/background-failures.ts +19 -0
- package/src/harness/fake-websocket-connection.ts +45 -24
- package/src/harness/fake-websocket-lifetime.test.ts +185 -0
- package/src/harness/fake-websocket-lifetime.ts +52 -0
- package/src/harness/fake-websocket.ts +22 -10
- package/src/replay-mode.test.ts +96 -0
- 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
|
|
35
|
-
//
|
|
36
|
-
//
|
|
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 {
|
|
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
|
package/dist/fake-websocket.d.ts
CHANGED
|
@@ -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,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
|
|
4
|
-
*
|
|
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
|
|
4
|
-
*
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
throw
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
44
|
+
const drain = async () => {
|
|
45
|
+
if (running)
|
|
46
|
+
return;
|
|
47
|
+
running = true;
|
|
28
48
|
try {
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
64
|
+
finally {
|
|
65
|
+
running = false;
|
|
33
66
|
}
|
|
34
67
|
};
|
|
35
|
-
const enqueue = (
|
|
36
|
-
if (
|
|
68
|
+
const enqueue = (run, size = 0, closing = false) => {
|
|
69
|
+
if (lifetime.ended && !closing)
|
|
37
70
|
return;
|
|
38
|
-
if (!closing && (
|
|
39
|
-
|
|
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
|
-
|
|
75
|
+
queue.push({ run, size });
|
|
44
76
|
bytes += size;
|
|
45
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
29
|
-
|
|
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,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
package/src/components/index.ts
CHANGED
|
@@ -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 => {
|