@specific.dev/spectest 0.109.0 → 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/helper-tracking.js +4 -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 +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/helper-tracking.test.ts +36 -0
- package/src/helper-tracking.ts +4 -2
- package/src/replay-mode.test.ts +96 -0
- package/src/replay-mode.ts +14 -0
|
@@ -1,54 +1,75 @@
|
|
|
1
1
|
import type WebSocket from "ws";
|
|
2
2
|
import type { FakeWebSocket, FakeWebSocketHandlers } from "../fake-websocket.js";
|
|
3
|
+
import { getWebSocketLifetime } from "./fake-websocket-lifetime.js";
|
|
4
|
+
import { reportBackgroundFailure } from "./background-failures.js";
|
|
3
5
|
|
|
4
6
|
const MAX_BUFFER = 16 * 1024 * 1024;
|
|
5
7
|
const MAX_PENDING = 1024;
|
|
6
8
|
|
|
7
|
-
/** Serialize
|
|
8
|
-
*
|
|
9
|
+
/** Serialize callbacks, but release connection resources immediately when the
|
|
10
|
+
* transport ends, even if an async callback is still running. */
|
|
9
11
|
export function attachFakeWebSocket(ws: WebSocket, handlers: FakeWebSocketHandlers): void {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
const lifetime = getWebSocketLifetime(handlers);
|
|
13
|
+
const queue: { run: () => void | Promise<void>; size: number }[] = [];
|
|
14
|
+
let running = false;
|
|
15
|
+
let inFlightBytes = 0;
|
|
12
16
|
let bytes = 0;
|
|
13
17
|
let failed = false;
|
|
18
|
+
lifetime.onEnd(() => { queue.length = 0; bytes = inFlightBytes; });
|
|
19
|
+
|
|
20
|
+
const fail = (err: unknown, code = 1011, reason = "fake handler failed") => {
|
|
21
|
+
if (failed) return;
|
|
22
|
+
failed = true;
|
|
23
|
+
reportBackgroundFailure(err);
|
|
24
|
+
lifetime.end({ kind: "failed", error: err });
|
|
25
|
+
try { ws.close(code, reason); } catch { ws.terminate(); }
|
|
26
|
+
};
|
|
14
27
|
const socket: FakeWebSocket = {
|
|
15
28
|
get readyState() { return ws.readyState; },
|
|
16
29
|
send(message) {
|
|
17
30
|
if (ws.readyState !== 1) throw new Error("WebSocket is not open");
|
|
18
31
|
const size = typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
|
|
19
32
|
if (ws.bufferedAmount + size > MAX_BUFFER) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
throw
|
|
33
|
+
const error = new Error("fake WebSocket output buffer exceeded");
|
|
34
|
+
fail(error, 1009, "fake output buffer exceeded");
|
|
35
|
+
throw error;
|
|
23
36
|
}
|
|
24
37
|
ws.send(message);
|
|
25
38
|
},
|
|
26
39
|
close: (code, reason) => ws.close(code, reason),
|
|
40
|
+
terminate: () => ws.terminate(),
|
|
27
41
|
};
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
try {
|
|
42
|
+
const drain = async () => {
|
|
43
|
+
if (running) return;
|
|
44
|
+
running = true;
|
|
45
|
+
try {
|
|
46
|
+
while (queue.length) {
|
|
47
|
+
const task = queue.shift()!;
|
|
48
|
+
inFlightBytes = task.size;
|
|
49
|
+
try { await task.run(); } catch (error) { fail(error); }
|
|
50
|
+
finally { bytes -= task.size; inFlightBytes = 0; }
|
|
51
|
+
}
|
|
52
|
+
} finally { running = false; }
|
|
32
53
|
};
|
|
33
|
-
const enqueue = (
|
|
34
|
-
if (
|
|
35
|
-
if (!closing && (
|
|
36
|
-
|
|
37
|
-
ws.close(1009, "fake input buffer exceeded");
|
|
54
|
+
const enqueue = (run: () => void | Promise<void>, size = 0, closing = false) => {
|
|
55
|
+
if (lifetime.ended && !closing) return;
|
|
56
|
+
if (!closing && (queue.length + Number(running) >= MAX_PENDING || bytes + size > MAX_BUFFER)) {
|
|
57
|
+
fail(new Error("fake WebSocket input buffer exceeded"), 1009, "fake input buffer exceeded");
|
|
38
58
|
return;
|
|
39
59
|
}
|
|
40
|
-
|
|
60
|
+
queue.push({ run, size });
|
|
41
61
|
bytes += size;
|
|
42
|
-
|
|
43
|
-
if (!failed || closing) await fn();
|
|
44
|
-
}).catch(fail).finally(() => { pending--; bytes -= size; });
|
|
62
|
+
void drain();
|
|
45
63
|
};
|
|
46
64
|
ws.on("message", (data, binary) => {
|
|
47
65
|
const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as ArrayBuffer);
|
|
48
|
-
|
|
49
|
-
|
|
66
|
+
enqueue(() => handlers.message?.(socket, binary ? new Uint8Array(buffer) : buffer.toString()), buffer.length);
|
|
67
|
+
});
|
|
68
|
+
ws.on("close", (code, reason) => {
|
|
69
|
+
lifetime.end({ kind: "closed", code, reason: reason.toString() });
|
|
70
|
+
enqueue(() => handlers.close?.(socket, code, reason.toString()), 0, true);
|
|
50
71
|
});
|
|
51
|
-
ws.on("close", (code, reason) => enqueue(() => handlers.close?.(socket, code, reason.toString()), 0, true));
|
|
52
72
|
ws.on("error", fail);
|
|
53
|
-
|
|
73
|
+
if (lifetime.ended) ws.terminate();
|
|
74
|
+
else enqueue(() => handlers.open?.(socket));
|
|
54
75
|
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { afterEach, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import type { FakeContext, FakeWebSocketHandlers } from "../index.js";
|
|
7
|
+
import { defineFake } from "../index.js";
|
|
8
|
+
import { FakeWebSocketLifetime, getWebSocketLifetime } from "./fake-websocket-lifetime.js";
|
|
9
|
+
import { runWithBackgroundFailures, reportBackgroundFailure } from "./background-failures.js";
|
|
10
|
+
import { dispatchFake } from "./fake-websocket.js";
|
|
11
|
+
import { startIngressServer } from "./ingress-server.js";
|
|
12
|
+
import { recordWebSocket } from "../components/replay-websocket.js";
|
|
13
|
+
import { replayFake } from "../components/replayFake.js";
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const WS = require(join(dirname(require.resolve("ws/package.json")), "index.js")) as typeof import("ws");
|
|
17
|
+
const cleanup: (() => void)[] = [];
|
|
18
|
+
afterEach(() => { for (const fn of cleanup.splice(0).reverse()) fn(); });
|
|
19
|
+
const ctx = {} as FakeContext;
|
|
20
|
+
|
|
21
|
+
function silenceErrors() {
|
|
22
|
+
const errors = spyOn(console, "error").mockImplementation(() => {});
|
|
23
|
+
cleanup.push(() => errors.mockRestore());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("transport disconnect disposes immediately while callbacks are blocked, discarding queued messages", async () => {
|
|
27
|
+
let release!: () => void;
|
|
28
|
+
const blocked = new Promise<void>(resolve => release = resolve);
|
|
29
|
+
cleanup.push(release);
|
|
30
|
+
let entered!: () => void;
|
|
31
|
+
const started = new Promise<void>(resolve => entered = resolve);
|
|
32
|
+
let ended!: () => void;
|
|
33
|
+
const disposed = new Promise<void>(resolve => ended = resolve);
|
|
34
|
+
let closed!: () => void;
|
|
35
|
+
const closeCallback = new Promise<void>(resolve => closed = resolve);
|
|
36
|
+
let disposals = 0;
|
|
37
|
+
const messages: string[] = [];
|
|
38
|
+
const fake = defineFake({ name: "slow", hostnames: ["slow.test"], handler: () => new Response("http"),
|
|
39
|
+
websocket(req) {
|
|
40
|
+
getWebSocketLifetime(req).onEnd(() => { disposals++; ended(); });
|
|
41
|
+
return {
|
|
42
|
+
async message(_socket, message) { messages.push(String(message)); entered(); await blocked; },
|
|
43
|
+
close() { closed(); },
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const server = await startIngressServer({ port: 0, hostname: "127.0.0.1", fetch: (req, server) => dispatchFake(req, server, fake, undefined, ctx) });
|
|
48
|
+
cleanup.push(() => server.stop(true));
|
|
49
|
+
const client = new WS(`ws://127.0.0.1:${server.port}`);
|
|
50
|
+
await once(client, "open");
|
|
51
|
+
const disconnected = once(client, "close");
|
|
52
|
+
client.send("first"); await started;
|
|
53
|
+
client.send("queued"); client.close();
|
|
54
|
+
await disconnected; await disposed;
|
|
55
|
+
expect(disposals).toBe(1);
|
|
56
|
+
expect(messages).toEqual(["first"]);
|
|
57
|
+
release(); await closeCallback;
|
|
58
|
+
expect(messages).toEqual(["first"]);
|
|
59
|
+
expect(disposals).toBe(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test.each(["refused", "invalid-protocol", "throw", "aborted"])("failed %s handshake disposes once before any open callback", async failure => {
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
const req = new Request("https://voice.test/live", { signal: controller.signal, headers: { upgrade: "websocket" } });
|
|
65
|
+
let disposals = 0;
|
|
66
|
+
let opened = false;
|
|
67
|
+
const fake = defineFake({ name: "voice", hostnames: ["voice.test"], handler: () => new Response("http"),
|
|
68
|
+
async websocket(request) {
|
|
69
|
+
getWebSocketLifetime(request).onEnd(() => disposals++);
|
|
70
|
+
if (failure === "throw") throw new Error("handshake failed");
|
|
71
|
+
if (failure === "aborted") { controller.abort(); await Bun.sleep(1); }
|
|
72
|
+
return { protocol: failure === "invalid-protocol" ? "unoffered" : undefined, open() { opened = true; } };
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
const server = { port: 0, stop() {}, requestIP: () => undefined, upgrade: () => false };
|
|
76
|
+
const response = await dispatchFake(req, server, fake, undefined, ctx);
|
|
77
|
+
expect(response.status).toBe(failure === "throw" || failure === "invalid-protocol" ? 500 : 426);
|
|
78
|
+
expect(disposals).toBe(1); expect(opened).toBe(false);
|
|
79
|
+
controller.abort(); expect(disposals).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("aborting a pending upstream handshake releases the socket without waiting for timeout", async () => {
|
|
83
|
+
const server = createServer();
|
|
84
|
+
const accepted = once(server, "upgrade");
|
|
85
|
+
server.listen(0, "127.0.0.1"); await once(server, "listening");
|
|
86
|
+
cleanup.push(() => server.close());
|
|
87
|
+
const lifetime = new FakeWebSocketLifetime();
|
|
88
|
+
cleanup.push(() => lifetime.end({ kind: "aborted" }));
|
|
89
|
+
let finished = 0;
|
|
90
|
+
const recording = recordWebSocket({
|
|
91
|
+
url: `ws://voice.test:${(server.address() as { port: number }).port}/live`, host: "voice.test", ip: "127.0.0.1",
|
|
92
|
+
headers: {}, protocols: [], request: { method: "GET", path: "/live", query: {}, headers: {} },
|
|
93
|
+
match: () => null, redact: s => s, secrets: [], label: "pending", lifetime,
|
|
94
|
+
captured() { throw new Error("should not finish handshake"); }, finished() { finished++; },
|
|
95
|
+
});
|
|
96
|
+
const rejected = recording.then(() => undefined, error => error);
|
|
97
|
+
const [, socket] = await accepted;
|
|
98
|
+
cleanup.push(() => socket.destroy());
|
|
99
|
+
const disconnected = once(socket, "end");
|
|
100
|
+
socket.resume();
|
|
101
|
+
lifetime.end({ kind: "aborted" });
|
|
102
|
+
expect((await rejected)?.message).toContain("handshake aborted");
|
|
103
|
+
await disconnected;
|
|
104
|
+
expect(finished).toBe(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("callback failures automatically reject the running test and release connection resources", async () => {
|
|
108
|
+
silenceErrors();
|
|
109
|
+
let disposals = 0;
|
|
110
|
+
const fake = defineFake({ name: "broken", hostnames: ["broken.test"], handler: () => new Response("http"), websocket(req) {
|
|
111
|
+
getWebSocketLifetime(req).onEnd(() => disposals++);
|
|
112
|
+
return { message() { throw new Error("callback exploded"); } };
|
|
113
|
+
} });
|
|
114
|
+
const server = await startIngressServer({ port: 0, hostname: "127.0.0.1", fetch: (req, server) => dispatchFake(req, server, fake, undefined, ctx) });
|
|
115
|
+
cleanup.push(() => server.stop(true));
|
|
116
|
+
const client = new WS(`ws://127.0.0.1:${server.port}`);
|
|
117
|
+
await once(client, "open");
|
|
118
|
+
const closed = once(client, "close");
|
|
119
|
+
await expect(runWithBackgroundFailures(async () => {
|
|
120
|
+
client.send("fail");
|
|
121
|
+
await new Promise(() => {}); // Represents a test waiting for an app response.
|
|
122
|
+
})).rejects.toThrow("callback exploded");
|
|
123
|
+
expect(disposals).toBe(1);
|
|
124
|
+
expect((await closed)[0]).toBe(1011);
|
|
125
|
+
expect(await runWithBackgroundFailures(async () => "next test")).toBe("next test");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("replay timeout automatically fails a test; open connections do not require end-of-test verification", async () => {
|
|
129
|
+
silenceErrors();
|
|
130
|
+
const fake = replayFake({ name: "voice", host: "voice.test", mode: "replay", websocketTimeoutMs: 50 });
|
|
131
|
+
const state = await fake.state!();
|
|
132
|
+
const helpers = await fake.helpers!({ name: "voice", state, ctx });
|
|
133
|
+
expect(Object.keys(helpers).sort()).toEqual(["count", "dump", "recorded", "replay"]);
|
|
134
|
+
helpers.replay({ version: 1, fake: "voice", host: "voice.test", interactions: [], websockets: [{
|
|
135
|
+
request: { method: "GET", path: "/", query: {}, headers: {} }, inputs: ["a".repeat(64)], messages: [],
|
|
136
|
+
}] });
|
|
137
|
+
const server = await startIngressServer({ port: 0, hostname: "127.0.0.1", fetch: (req, server) => dispatchFake(req, server, fake, state, ctx) });
|
|
138
|
+
cleanup.push(() => server.stop(true));
|
|
139
|
+
let client!: InstanceType<typeof WS>;
|
|
140
|
+
let closed!: Promise<unknown[]>;
|
|
141
|
+
await expect(runWithBackgroundFailures(async () => {
|
|
142
|
+
client = new WS(`ws://127.0.0.1:${server.port}`);
|
|
143
|
+
closed = once(client, "close");
|
|
144
|
+
await once(client, "open");
|
|
145
|
+
await new Promise(() => {});
|
|
146
|
+
})).rejects.toThrow("required input never arrived");
|
|
147
|
+
expect((await closed)[0]).toBe(1011);
|
|
148
|
+
expect(state.websocketSessions.size).toBe(0);
|
|
149
|
+
helpers.replay({ version: 1, fake: "voice", host: "voice.test", interactions: [], websockets: [{
|
|
150
|
+
request: { method: "GET", path: "/", query: {}, headers: {} }, inputs: [], messages: [],
|
|
151
|
+
}] });
|
|
152
|
+
await runWithBackgroundFailures(async () => {
|
|
153
|
+
client = new WS(`ws://127.0.0.1:${server.port}`); closed = once(client, "close");
|
|
154
|
+
await once(client, "open");
|
|
155
|
+
});
|
|
156
|
+
expect(state.websocketSessions.size).toBe(1); // Test returned; connection is still usable.
|
|
157
|
+
client.close(); await closed;
|
|
158
|
+
await Bun.sleep(5);
|
|
159
|
+
expect(state.websocketSessions.size).toBe(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("connection failure cancels replay timers immediately without a later timeout failure", async () => {
|
|
163
|
+
silenceErrors();
|
|
164
|
+
const fake = replayFake({ name: "voice", host: "voice.test", mode: "replay", websocketTimeoutMs: 20 });
|
|
165
|
+
const state = await fake.state!();
|
|
166
|
+
const helpers = await fake.helpers!({ name: "voice", state, ctx });
|
|
167
|
+
helpers.replay({ version: 1, fake: "voice", host: "voice.test", interactions: [], websockets: [{
|
|
168
|
+
request: { method: "GET", path: "/", query: {}, headers: {} }, inputs: ["a".repeat(64)],
|
|
169
|
+
messages: [{ afterInput: 0, delayMs: 10, type: "text", data: "late" }],
|
|
170
|
+
}] });
|
|
171
|
+
const req = new Request("https://voice.test/");
|
|
172
|
+
const handlers = await fake.websocket!(req, state, ctx) as FakeWebSocketHandlers;
|
|
173
|
+
const sent: unknown[] = [];
|
|
174
|
+
await handlers.open!({ readyState: 1, send: value => sent.push(value), close() {} });
|
|
175
|
+
getWebSocketLifetime(req).end({ kind: "failed", error: new Error("transport error") });
|
|
176
|
+
await Bun.sleep(40);
|
|
177
|
+
expect(sent).toEqual([]); expect(state.websocketSessions.size).toBe(0);
|
|
178
|
+
expect(state.websocketErrors).toEqual([]); // The transport owns reporting the original failure.
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("background failures outside a running test are logged without poisoning the next test", async () => {
|
|
182
|
+
silenceErrors();
|
|
183
|
+
reportBackgroundFailure(new Error("manual recording failed"));
|
|
184
|
+
expect(await runWithBackgroundFailures(async () => 42)).toBe(42);
|
|
185
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { FakeWebSocketHandlers } from "../fake-websocket.js";
|
|
2
|
+
import { reportBackgroundFailure } from "./background-failures.js";
|
|
3
|
+
|
|
4
|
+
export type WebSocketEnd =
|
|
5
|
+
| { kind: "closed"; code: number; reason: string }
|
|
6
|
+
| { kind: "failed"; error: unknown }
|
|
7
|
+
| { kind: "aborted" };
|
|
8
|
+
|
|
9
|
+
/** Internal connection ownership, independent of serialized user callbacks.
|
|
10
|
+
* Runs once on disconnect, failure or abandoned handshake, never at test end. */
|
|
11
|
+
export class FakeWebSocketLifetime {
|
|
12
|
+
private result?: WebSocketEnd;
|
|
13
|
+
private callbacks = new Set<(event: WebSocketEnd) => void>();
|
|
14
|
+
get ended(): boolean { return this.result !== undefined; }
|
|
15
|
+
|
|
16
|
+
onEnd(callback: (event: WebSocketEnd) => void): () => void {
|
|
17
|
+
if (this.result) callback(this.result);
|
|
18
|
+
else this.callbacks.add(callback);
|
|
19
|
+
return () => { this.callbacks.delete(callback); };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
end(event: WebSocketEnd): void {
|
|
23
|
+
if (this.result) return;
|
|
24
|
+
this.result = event;
|
|
25
|
+
const callbacks = [...this.callbacks];
|
|
26
|
+
this.callbacks.clear();
|
|
27
|
+
for (const callback of callbacks) {
|
|
28
|
+
try { callback(event); } catch (error) { reportBackgroundFailure(error); }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const lifetimes = new WeakMap<object, FakeWebSocketLifetime>();
|
|
34
|
+
|
|
35
|
+
export function getWebSocketLifetime(target: Request | FakeWebSocketHandlers): FakeWebSocketLifetime {
|
|
36
|
+
let lifetime = lifetimes.get(target);
|
|
37
|
+
if (lifetime) return lifetime;
|
|
38
|
+
lifetime = new FakeWebSocketLifetime();
|
|
39
|
+
lifetimes.set(target, lifetime);
|
|
40
|
+
if (target instanceof Request) {
|
|
41
|
+
const abort = () => lifetime!.end({ kind: "aborted" });
|
|
42
|
+
target.signal.addEventListener("abort", abort, { once: true });
|
|
43
|
+
lifetime.onEnd(() => target.signal.removeEventListener("abort", abort));
|
|
44
|
+
if (target.signal.aborted) abort();
|
|
45
|
+
}
|
|
46
|
+
return lifetime;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function bindWebSocketLifetime(handlers: FakeWebSocketHandlers, lifetime: FakeWebSocketLifetime): FakeWebSocketHandlers {
|
|
50
|
+
lifetimes.set(handlers, lifetime);
|
|
51
|
+
return handlers;
|
|
52
|
+
}
|
|
@@ -2,6 +2,8 @@ import type { FakeWebSocketHandlers } from "../fake-websocket.js";
|
|
|
2
2
|
import type { FakeContext, FakeDefinition } from "../index.js";
|
|
3
3
|
import type { IngressServer } from "./ingress-server.js";
|
|
4
4
|
import { runUninstrumented } from "./instrumentation-scope.js";
|
|
5
|
+
import { bindWebSocketLifetime, getWebSocketLifetime } from "./fake-websocket-lifetime.js";
|
|
6
|
+
import { reportBackgroundFailure } from "./background-failures.js";
|
|
5
7
|
import { parseSubprotocols } from "./ws-bridge.js";
|
|
6
8
|
|
|
7
9
|
/** HTTP and WebSocket dispatch share fake state, helpers, and execution scope. */
|
|
@@ -12,12 +14,14 @@ export async function dispatchFake<S>(
|
|
|
12
14
|
state: S,
|
|
13
15
|
ctx: FakeContext,
|
|
14
16
|
): Promise<Response> {
|
|
17
|
+
if (req.headers.get("upgrade")?.toLowerCase() !== "websocket" || !def.websocket) {
|
|
18
|
+
try { return await runUninstrumented(() => def.handler(req, state, ctx)); }
|
|
19
|
+
catch (err) { return fakeError(def.name, err); }
|
|
20
|
+
}
|
|
21
|
+
const lifetime = getWebSocketLifetime(req);
|
|
15
22
|
try {
|
|
16
|
-
if (req.headers.get("upgrade")?.toLowerCase() !== "websocket" || !def.websocket) {
|
|
17
|
-
return await runUninstrumented(() => def.handler(req, state, ctx));
|
|
18
|
-
}
|
|
19
23
|
const accepted = await runUninstrumented(() => def.websocket!(req, state, ctx));
|
|
20
|
-
if (accepted instanceof Response) return accepted;
|
|
24
|
+
if (accepted instanceof Response) { lifetime.end({ kind: "aborted" }); return accepted; }
|
|
21
25
|
if (!accepted || typeof accepted !== "object") throw new Error("websocket must return handlers or a Response");
|
|
22
26
|
if (accepted.protocol !== undefined && !parseSubprotocols(req.headers.get("sec-websocket-protocol")).includes(accepted.protocol)) {
|
|
23
27
|
throw new Error("websocket selected a protocol the client did not offer");
|
|
@@ -27,13 +31,21 @@ export async function dispatchFake<S>(
|
|
|
27
31
|
message: (socket, message) => runUninstrumented(() => accepted.message?.(socket, message)),
|
|
28
32
|
close: (socket, code, reason) => runUninstrumented(() => accepted.close?.(socket, code, reason)),
|
|
29
33
|
};
|
|
30
|
-
|
|
31
|
-
handlers,
|
|
34
|
+
const upgraded = server.upgrade(req, {
|
|
35
|
+
handlers: bindWebSocketLifetime(handlers, lifetime),
|
|
32
36
|
headers: accepted.protocol ? { "sec-websocket-protocol": accepted.protocol } : undefined,
|
|
33
|
-
}) ? new Response(null, { status: 101 }) : new Response("WebSocket upgrade refused", { status: 426 });
|
|
34
|
-
} catch (err) {
|
|
35
|
-
return new Response(`spectest-daemon: fake ${def.name} threw: ${err instanceof Error ? err.message : String(err)}\n`, {
|
|
36
|
-
status: 500, headers: { "content-type": "text/plain" },
|
|
37
37
|
});
|
|
38
|
+
if (!upgraded) lifetime.end({ kind: "aborted" });
|
|
39
|
+
return upgraded ? new Response(null, { status: 101 }) : new Response("WebSocket upgrade refused", { status: 426 });
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (!lifetime.ended) reportBackgroundFailure(new Error(`fake ${def.name} WebSocket handshake failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
42
|
+
lifetime.end({ kind: "failed", error: err });
|
|
43
|
+
return fakeError(def.name, err);
|
|
38
44
|
}
|
|
39
45
|
}
|
|
46
|
+
|
|
47
|
+
function fakeError(name: string, error: unknown): Response {
|
|
48
|
+
return new Response(`spectest-daemon: fake ${name} threw: ${error instanceof Error ? error.message : String(error)}\n`, {
|
|
49
|
+
status: 500, headers: { "content-type": "text/plain" },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -3,6 +3,7 @@ import { annotate } from "./annotate.js";
|
|
|
3
3
|
import { expect as assertValue, type ServiceHandlesFor, type Wrapped } from "./index.js";
|
|
4
4
|
import { readRaw, readTag, wrap } from "./inspect.js";
|
|
5
5
|
import { trackHelpers } from "./helper-tracking.js";
|
|
6
|
+
import { SQL } from "./sql.js";
|
|
6
7
|
import {
|
|
7
8
|
recordHttp, recordWait, recorderEventCount, recorderMarkChildren, recorderTruncate,
|
|
8
9
|
startRecording, stopRecording,
|
|
@@ -175,6 +176,41 @@ test("live objects, callable clients, function properties and receivers survive"
|
|
|
175
176
|
expect<unknown>(readRaw(helper.latest.call({...raw, subject: "Other"}))).toEqual({subject: "Other"});
|
|
176
177
|
});
|
|
177
178
|
|
|
179
|
+
test("a tracked SQL client keeps tagged queries and attached methods instrumented", async () => {
|
|
180
|
+
const client = new SQL("sqlite://:memory:", { label: "db" });
|
|
181
|
+
const helper = trackHelpers("service", "db", { client });
|
|
182
|
+
startRecording();
|
|
183
|
+
try {
|
|
184
|
+
const rows = await helper.client<{ id: number }>`SELECT ${7} AS id`;
|
|
185
|
+
assertValue(rows.transform("ids", (rows) => Array.from(rows, (row) => row.id))).toEqual([7]);
|
|
186
|
+
const other = await helper.client.unsafe<{ id: number }>("SELECT ? AS id", [8]);
|
|
187
|
+
assertValue(other[0]!.id).toBe(8);
|
|
188
|
+
const events = stopRecording();
|
|
189
|
+
expect(events.map((event) => event.kind)).toEqual(["db", "assertion", "db", "assertion"]);
|
|
190
|
+
expect(events[0]).toMatchObject({ service: "db", query: "SELECT $1 AS id", params: [7], rows: [{ id: 7 }] });
|
|
191
|
+
expect(events[1]).toMatchObject({ sourceSeq: events[0]!.seq, path: ["<ids>"] });
|
|
192
|
+
expect(events[2]).toMatchObject({ service: "db", query: "SELECT ? AS id", params: [8], rows: [{ id: 8 }] });
|
|
193
|
+
expect(events[3]).toMatchObject({ sourceSeq: events[2]!.seq, path: ["0", "id"] });
|
|
194
|
+
} finally {
|
|
195
|
+
await helper.client.close!();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("a tracked SQL client preserves query failure diagnostics", async () => {
|
|
200
|
+
const client = new SQL("sqlite://:memory:", { label: "db" });
|
|
201
|
+
const helper = trackHelpers("service", "db", { client });
|
|
202
|
+
startRecording();
|
|
203
|
+
try {
|
|
204
|
+
await expect(helper.client`SELECT * FROM missing_table`).rejects.toThrow("no such table");
|
|
205
|
+
const events = stopRecording();
|
|
206
|
+
expect(events).toHaveLength(1);
|
|
207
|
+
expect(events[0]).toMatchObject({ kind: "db", service: "db", query: "SELECT * FROM missing_table",
|
|
208
|
+
error: expect.stringContaining("no such table") });
|
|
209
|
+
} finally {
|
|
210
|
+
await helper.client.close!();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
178
214
|
test("setup/eval unwrap annotated returns without recording", async () => {
|
|
179
215
|
const scope = createInstrumentationScope(false);
|
|
180
216
|
const helper = trackHelpers("service", "mail", { latest: () => annotate({subject: "Invoice"}, "email") });
|
package/src/helper-tracking.ts
CHANGED
|
@@ -93,9 +93,11 @@ function invokeHelper(
|
|
|
93
93
|
};
|
|
94
94
|
let result: unknown;
|
|
95
95
|
try {
|
|
96
|
+
// Reading a callable proxy's `.apply` can bind directly to its raw client
|
|
97
|
+
// and bypass instrumentation (SQL does this). Invoke the proxy itself.
|
|
96
98
|
result = recording.run(() => kind === "fake"
|
|
97
|
-
? runUninstrumented(() =>
|
|
98
|
-
:
|
|
99
|
+
? runUninstrumented(() => Reflect.apply(fn, receiver, args))
|
|
100
|
+
: Reflect.apply(fn, receiver, args));
|
|
99
101
|
} catch (error) {
|
|
100
102
|
return fail(error);
|
|
101
103
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
test("test environments replay HTTP and WebSockets throughout the recorder lifecycle, even with credentials", async () => {
|
|
7
|
+
// Isolate daemon state and transport mocks. Any attempted external I/O fails
|
|
8
|
+
// locally, even if this regression returns; no real provider is contacted.
|
|
9
|
+
const root = await mkdtemp(join(tmpdir(), "spectest-replay-mode-"));
|
|
10
|
+
const module = (file: string) => JSON.stringify(new URL(file, import.meta.url).href);
|
|
11
|
+
try {
|
|
12
|
+
const file = join(root, "mode.test.ts");
|
|
13
|
+
await writeFile(file, `
|
|
14
|
+
import { test, mock } from "bun:test";
|
|
15
|
+
import { strict as assert } from "node:assert";
|
|
16
|
+
test("environment lifetime", async () => {
|
|
17
|
+
let networkCalls = 0;
|
|
18
|
+
const forbidden = () => { networkCalls++; throw new Error("unexpected upstream I/O"); };
|
|
19
|
+
mock.module("node:dgram", () => ({ createSocket: forbidden }));
|
|
20
|
+
mock.module("node:https", () => ({ request: forbidden }));
|
|
21
|
+
const { METHODS } = await import(${module("./daemon.ts")});
|
|
22
|
+
const { replayFake } = await import(${module("./components/replayFake.ts")});
|
|
23
|
+
const { resolveReplayMode } = await import(${module("./replay-mode.ts")});
|
|
24
|
+
const { startRecording, stopRecording, pauseRecording, resumeRecording } = await import(${module("./recorder.ts")});
|
|
25
|
+
assert.equal((await METHODS.health({})).replayEnvironmentMode, true);
|
|
26
|
+
await METHODS.setRecordSecrets({ secrets: { API_KEY: "available-credential" } });
|
|
27
|
+
await assert.rejects(METHODS.setReplayEnvironmentMode({ mode: "typo" }));
|
|
28
|
+
assert.equal(resolveReplayMode(), "replay");
|
|
29
|
+
const request = { method: "GET", path: "/messages", query: {}, headers: {} };
|
|
30
|
+
const cassette = {
|
|
31
|
+
version: 1, fake: "test", host: "provider.test",
|
|
32
|
+
interactions: [{ request, response: { status: 200, headers: {}, body: "cassette" } }],
|
|
33
|
+
websockets: [{ request, inputs: [], messages: [] }],
|
|
34
|
+
};
|
|
35
|
+
const fakes = await Promise.all([undefined, "auto", "record", "replay"].map(async mode => {
|
|
36
|
+
const fake = replayFake({ name: "test", host: "provider.test", mode,
|
|
37
|
+
inject: [{ headers: { authorization: "Bearer {{secret:API_KEY}}" } }],
|
|
38
|
+
});
|
|
39
|
+
const state = await fake.state();
|
|
40
|
+
return { fake, state, helpers: await fake.helpers({ name: "test", state, ctx: {} }) };
|
|
41
|
+
}));
|
|
42
|
+
const check = async () => {
|
|
43
|
+
for (const { fake, state, helpers } of fakes) {
|
|
44
|
+
const http = () => fake.handler(new Request("https://provider.test/messages"), state, {});
|
|
45
|
+
const ws = () => fake.websocket(new Request("https://provider.test/messages", {
|
|
46
|
+
headers: { upgrade: "websocket", "sec-websocket-key": "test" },
|
|
47
|
+
}), state, {});
|
|
48
|
+
helpers.replay({ ...cassette, interactions: [], websockets: [] });
|
|
49
|
+
assert.equal((await http()).status, 599); // no cassette
|
|
50
|
+
assert.equal((await ws()).status, 599);
|
|
51
|
+
helpers.replay(cassette);
|
|
52
|
+
assert.equal(await (await http()).text(), "cassette");
|
|
53
|
+
const connection = await ws();
|
|
54
|
+
assert.equal(connection instanceof Response, false);
|
|
55
|
+
for (const session of state.websocketSessions) session.cancel();
|
|
56
|
+
assert.equal((await http()).status, 599); // exhausted cassette
|
|
57
|
+
assert.equal((await ws()).status, 599);
|
|
58
|
+
assert.equal(helpers.recorded().length, 0);
|
|
59
|
+
}
|
|
60
|
+
assert.equal(networkCalls, 0);
|
|
61
|
+
};
|
|
62
|
+
await check(); // startup, before any case has ever run
|
|
63
|
+
startRecording();
|
|
64
|
+
await check();
|
|
65
|
+
pauseRecording();
|
|
66
|
+
await check();
|
|
67
|
+
resumeRecording();
|
|
68
|
+
stopRecording();
|
|
69
|
+
await check(); // background traffic between cases / before a snapshot
|
|
70
|
+
startRecording();
|
|
71
|
+
stopRecording();
|
|
72
|
+
await check(); // another case cannot reset the environment mode
|
|
73
|
+
assert.equal((await METHODS.eval({ code: "export default 42;" })).ok, true);
|
|
74
|
+
assert.equal((await METHODS.eval({ code: 'throw new Error("expected");' })).ok, false);
|
|
75
|
+
await check(); // eval on a test environment cannot enable recording
|
|
76
|
+
await METHODS.setReplayEnvironmentMode({ mode: "interactive" });
|
|
77
|
+
assert.equal(resolveReplayMode(), "record");
|
|
78
|
+
assert.equal(resolveReplayMode("record"), "record");
|
|
79
|
+
assert.equal(resolveReplayMode("replay"), "replay");
|
|
80
|
+
startRecording(); stopRecording();
|
|
81
|
+
assert.equal(resolveReplayMode(), "record");
|
|
82
|
+
await METHODS.setReplayEnvironmentMode({ mode: "test" });
|
|
83
|
+
await check();
|
|
84
|
+
});
|
|
85
|
+
`);
|
|
86
|
+
const child = Bun.spawn([process.execPath, "test", file], {
|
|
87
|
+
env: { ...process.env, SPECTEST_APP_DIR: root }, stdout: "pipe", stderr: "pipe",
|
|
88
|
+
});
|
|
89
|
+
const [code, stdout, stderr] = await Promise.all([
|
|
90
|
+
child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
|
|
91
|
+
]);
|
|
92
|
+
expect(code, stdout + stderr).toBe(0);
|
|
93
|
+
} finally {
|
|
94
|
+
await rm(root, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
}, 20_000);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Environment state: travels with memory snapshots, independently of the
|
|
2
|
+
// per-case event recorder. Shared/warm baselines must never record upstream.
|
|
3
|
+
// The control plane enables recording only on an interactive environment,
|
|
4
|
+
// after capturing/restoring the shared baseline and before interactive setup.
|
|
5
|
+
let mode: "test" | "interactive" = "test";
|
|
6
|
+
|
|
7
|
+
export function setReplayEnvironmentMode(value: "test" | "interactive"): void {
|
|
8
|
+
mode = value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function resolveReplayMode(requested: "auto" | "record" | "replay" = "auto"): "record" | "replay" {
|
|
12
|
+
// Even an explicit record option cannot send test traffic to real providers.
|
|
13
|
+
return mode === "test" || requested === "replay" ? "replay" : "record";
|
|
14
|
+
}
|