@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.
- 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 +21 -5
- 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/index.d.ts +12 -7
- package/dist/index.js +2 -2
- 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 +22 -5
- 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/index.ts +9 -8
- package/src/interactive-setup.test.ts +2 -2
- 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) }));
|
|
@@ -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; `
|
|
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
|
|
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.
|
|
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
|
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
|
+
}
|
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
|
|
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
|
-
|
|
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 `
|
|
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; `
|
|
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.
|
|
1685
|
-
|
|
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 `
|
|
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.
|
|
488
|
-
proj.
|
|
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) {
|