@specific.dev/spectest 0.100.0 → 0.102.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.
@@ -0,0 +1,166 @@
1
+ import http from "node:http";
2
+ import { Readable } from "node:stream";
3
+ import { pipeline } from "node:stream/promises";
4
+ import { createRequire } from "node:module";
5
+ import { dirname, join } from "node:path";
6
+ import { WS_BRIDGE_HANDLERS } from "./ws-bridge.js";
7
+ // Load the installed implementation, not Bun's built-in `ws` replacement.
8
+ // On Bun 1.4.0 that replacement cannot upgrade node:http sockets after the
9
+ // asynchronous upstream handshake. The package uses the ordinary socket API.
10
+ const require = createRequire(import.meta.url);
11
+ const { WebSocketServer } = require(join(dirname(require.resolve("ws/package.json")), "index.js"));
12
+ /**
13
+ * Serve the ingress's Fetch handlers through node:http. Bun.serve discards
14
+ * Content-Length on a still-streaming Response, even when its bytes are
15
+ * unchanged. ServerResponse preserves the declared length while pipeline
16
+ * propagates backpressure and cancellation without buffering the whole body.
17
+ * This is also the HTTP/1 listener behind the TLS terminator.
18
+ */
19
+ export async function startIngressServer(options) {
20
+ const peers = new WeakMap();
21
+ const upgrades = new WeakMap();
22
+ const protocols = new WeakMap();
23
+ const sockets = new Set();
24
+ const websockets = new WebSocketServer({
25
+ noServer: true,
26
+ // The upstream handshake has already selected a protocol. Never select
27
+ // the client's first offer on its behalf.
28
+ handleProtocols: (_offered, req) => protocols.get(req) || false,
29
+ });
30
+ const server = http.createServer({ requestTimeout: 0 }, (req, res) => {
31
+ void dispatch(req, res);
32
+ });
33
+ server.on("connection", socket => {
34
+ sockets.add(socket);
35
+ socket.once("close", () => sockets.delete(socket));
36
+ });
37
+ server.on("upgrade", (req, socket, head) => {
38
+ void dispatch(req, undefined, { req, socket: socket, head, upgraded: false });
39
+ });
40
+ const facade = {
41
+ port: 0,
42
+ stop(force = false) {
43
+ if (force) {
44
+ for (const ws of websockets.clients)
45
+ ws.terminate();
46
+ for (const socket of sockets)
47
+ socket.destroy();
48
+ server.closeAllConnections();
49
+ }
50
+ websockets.close();
51
+ server.close();
52
+ },
53
+ requestIP(req) {
54
+ const socket = peers.get(req);
55
+ return socket?.remoteAddress && socket.remotePort
56
+ ? { address: socket.remoteAddress, port: socket.remotePort } : undefined;
57
+ },
58
+ upgrade(req, opts) {
59
+ const pending = upgrades.get(req);
60
+ if (!pending || pending.upgraded || pending.socket.destroyed)
61
+ return false;
62
+ protocols.set(pending.req, opts.headers?.["sec-websocket-protocol"]);
63
+ websockets.handleUpgrade(pending.req, pending.socket, pending.head, ws => {
64
+ pending.upgraded = true;
65
+ const client = {
66
+ data: opts.data,
67
+ send: (data) => ws.send(data),
68
+ close: (code, reason) => ws.close(code, reason),
69
+ };
70
+ ws.on("message", (data, binary) => WS_BRIDGE_HANDLERS.message(client, binary ? Buffer.from(data) : data.toString()));
71
+ ws.on("close", (code, reason) => WS_BRIDGE_HANDLERS.close(client, code, reason.toString()));
72
+ ws.on("error", () => opts.data.upstream.close());
73
+ WS_BRIDGE_HANDLERS.open(client);
74
+ });
75
+ return pending.upgraded;
76
+ },
77
+ };
78
+ async function dispatch(incoming, outgoing, upgrade) {
79
+ const abort = new AbortController();
80
+ const onClose = () => {
81
+ if (!outgoing?.writableFinished && !upgrade?.upgraded)
82
+ abort.abort();
83
+ };
84
+ const connection = outgoing ?? upgrade.socket;
85
+ connection.once("close", onClose);
86
+ const responseWriter = () => {
87
+ if (!outgoing) {
88
+ // Only attach an HTTP response after a refused upgrade. Attaching
89
+ // one before handleUpgrade conflicts with Bun's native handshake.
90
+ outgoing = new http.ServerResponse(incoming);
91
+ outgoing.assignSocket(upgrade.socket);
92
+ outgoing.once("finish", () => upgrade.socket.end());
93
+ }
94
+ return outgoing;
95
+ };
96
+ let response;
97
+ try {
98
+ const headers = new Headers();
99
+ for (let i = 0; i < incoming.rawHeaders.length; i += 2) {
100
+ headers.append(incoming.rawHeaders[i], incoming.rawHeaders[i + 1]);
101
+ }
102
+ const method = incoming.method ?? "GET";
103
+ const req = new Request(new URL(incoming.url ?? "/", `http://${headers.get("host") ?? "localhost"}`), {
104
+ method, headers, signal: abort.signal,
105
+ body: method === "GET" || method === "HEAD" ? undefined : Readable.toWeb(incoming),
106
+ duplex: "half",
107
+ });
108
+ peers.set(req, incoming.socket);
109
+ if (upgrade)
110
+ upgrades.set(req, upgrade);
111
+ response = await options.fetch(req, facade);
112
+ if (upgrade?.upgraded)
113
+ return;
114
+ if (!response)
115
+ throw new Error("Ingress handler returned no response");
116
+ if (abort.signal.aborted) {
117
+ await response.body?.cancel();
118
+ return;
119
+ }
120
+ const writer = responseWriter();
121
+ writer.statusCode = response.status;
122
+ writer.statusMessage = response.statusText;
123
+ response.headers.forEach((value, key) => {
124
+ if (key !== "set-cookie")
125
+ writer.setHeader(key, value);
126
+ });
127
+ const cookies = response.headers.getSetCookie();
128
+ if (cookies.length)
129
+ writer.setHeader("set-cookie", cookies);
130
+ // Flush even for a body already buffered by fetch. An upstream with
131
+ // no length must not acquire an invented Content-Length from end().
132
+ writer.flushHeaders();
133
+ if (method === "HEAD" || response.status === 204 || response.status === 304 || !response.body) {
134
+ await response.body?.cancel();
135
+ writer.end();
136
+ }
137
+ else {
138
+ await pipeline(Readable.fromWeb(response.body), writer);
139
+ }
140
+ }
141
+ catch (error) {
142
+ if (upgrade?.upgraded)
143
+ return;
144
+ outgoing = responseWriter();
145
+ if (!outgoing.headersSent && !outgoing.destroyed) {
146
+ outgoing.writeHead(500, { "content-type": "text/plain" });
147
+ outgoing.end("spectest-daemon: ingress response failed\n");
148
+ }
149
+ else {
150
+ outgoing.destroy(error instanceof Error ? error : new Error(String(error)));
151
+ }
152
+ }
153
+ finally {
154
+ connection.off("close", onClose);
155
+ }
156
+ }
157
+ await new Promise((resolve, reject) => {
158
+ server.once("error", reject);
159
+ server.listen({ port: options.port, host: options.hostname ?? "0.0.0.0", reusePort: options.reusePort }, () => {
160
+ server.off("error", reject);
161
+ resolve();
162
+ });
163
+ });
164
+ facade.port = server.address().port;
165
+ return facade;
166
+ }
@@ -33,15 +33,11 @@
33
33
  * ## The shape, and why it is a byte pipe
34
34
  *
35
35
  * The terminator does TLS and *nothing else*: it accepts on :443, picks a
36
- * leaf by SNI, and pipes the decrypted stream to a plaintext `Bun.serve`
37
- * bound on loopback. Opted-in HTTP/2 routes select a separate stream router
38
- * by ALPN; HTTP/1.1 and WebSockets retain the existing listener. Everything downstream — routing, fakes,
39
- * interceptors, reverse-proxying, and `server.upgrade()` WebSocket
40
- * bridging — is the same `Bun.serve` code that already serves :80, run
41
- * unchanged. Terminating HTTP here instead (`node:https`, `(req, res)`)
42
- * would have meant reimplementing the WebSocket bridge against a
43
- * different API, which is exactly the kind of trade this change exists to
44
- * avoid: one flake removed, another introduced somewhere subtler.
36
+ * leaf by SNI, and pipes the decrypted stream to the plaintext ingress
37
+ * server bound on loopback. Opted-in HTTP/2 routes select a separate stream
38
+ * router by ALPN; HTTP/1.1 and WebSockets use the shared ingress listener.
39
+ * Routing, fakes, interceptors and reverse-proxying stay in that listener;
40
+ * this layer never parses or rewrites HTTP response framing.
45
41
  *
46
42
  * Measured cost of the extra hop on this host (Bun 1.4.0, undici client,
47
43
  * 50 MB bodies): ~11 % fewer small requests per second, download
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.100.0",
3
+ "version": "0.102.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -56,11 +56,13 @@
56
56
  "@kubernetes/client-node": "^1.0.0",
57
57
  "@xterm/headless": "^5.5.0",
58
58
  "dns-packet": "^5.6.1",
59
- "playwright-core": "^1.61.1"
59
+ "playwright-core": "^1.61.1",
60
+ "ws": "^8.20.1"
60
61
  },
61
62
  "devDependencies": {
62
63
  "@types/dns-packet": "^5.6.5",
63
64
  "@types/bun": "^1.3.0",
65
+ "@types/ws": "^8",
64
66
  "typescript": "^5.6.3"
65
67
  },
66
68
  "spectestCapabilities": [
@@ -85,6 +85,8 @@ export { okta, type OktaOptions } from "./okta.js";
85
85
  export {
86
86
  replayFake,
87
87
  type ReplayFakeOptions,
88
+ type ReplayFakeContext,
89
+ type ReplayHandler,
88
90
  type ReplayKey,
89
91
  type ReplayRequest,
90
92
  type MatchKey,
@@ -0,0 +1,217 @@
1
+ import { describe, 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
+ import { fileURLToPath } from "node:url";
6
+ import { defineEnvironment, type FakeContext } from "../index.js";
7
+ import { replayFake, type Cassette, type ReplayFakeContext, type ReplayHandler } from "./index.js";
8
+
9
+ const host = "api.example.test";
10
+ const context = {} as FakeContext;
11
+ const fixture: Cassette = {
12
+ version: 1, fake: "mixed", host,
13
+ interactions: ["one", "two"].map((task) => ({
14
+ request: {
15
+ method: "POST", path: "/messages", query: {}, headers: {},
16
+ body: JSON.stringify({ task }), bodyEncoding: "utf8",
17
+ },
18
+ response: { status: 200, headers: {}, body: `recorded-${task}`, bodyEncoding: "utf8" },
19
+ })),
20
+ };
21
+ const request = (task = "one", path = "/messages") => new Request(`https://${host}${path}`, {
22
+ method: "POST", body: JSON.stringify({ task }),
23
+ });
24
+
25
+ function mixedFake() {
26
+ return replayFake({
27
+ name: "mixed", host, mode: "replay",
28
+ state: () => ({ useRecordings: false, reply: "scripted", seen: [] as string[] }),
29
+ helpers: ({ state }) => ({
30
+ useRecordings(enabled = true) { state.useRecordings = enabled; },
31
+ reply(text: string) { state.reply = text; },
32
+ seen() { return [...state.seen]; },
33
+ }),
34
+ async handler(req, state, ctx) {
35
+ const body = await req.clone().json() as { task: string };
36
+ state.seen.push(body.task);
37
+ if (state.useRecordings && new URL(req.url).pathname === "/messages") {
38
+ return ctx.replay(req);
39
+ }
40
+ return new Response(state.reply);
41
+ },
42
+ });
43
+ }
44
+
45
+ describe("scripted behavior on replay fakes", () => {
46
+ test("loading only loads; custom helpers select behavior and misses never fall back", async () => {
47
+ const fake = mixedFake();
48
+ const state = await fake.state!();
49
+ const helpers = await fake.helpers!({ name: "mixed", state, ctx: context });
50
+ const send = (task?: string, path?: string) => fake.handler(request(task, path), state, context);
51
+
52
+ expect(await (await send()).text()).toBe("scripted");
53
+ helpers.reply("configured");
54
+ helpers.replay(fixture);
55
+ expect(await (await send()).text()).toBe("configured");
56
+ helpers.useRecordings();
57
+ expect((await send("missing")).status).toBe(599);
58
+ // The same fake mixes scripted and replayed endpoints concurrently.
59
+ expect(await Promise.all([
60
+ send("two"), send("one", "/transcription"), send("one"),
61
+ ].map(async (response) => (await response).text()))).toEqual([
62
+ "recorded-two", "configured", "recorded-one",
63
+ ]);
64
+ expect((await send()).status).toBe(599);
65
+ helpers.useRecordings(false);
66
+ expect(await (await send()).text()).toBe("configured");
67
+ expect(helpers.count()).toBe(0);
68
+ expect(helpers.recorded()).toEqual([]);
69
+ expect(helpers.seen()).toHaveLength(8);
70
+ });
71
+
72
+ test("delegating without a cassette fails even when scripted responses are available", async () => {
73
+ const fake = mixedFake();
74
+ const state = await fake.state!();
75
+ const helpers = await fake.helpers!({ name: "mixed", state, ctx: context });
76
+ helpers.useRecordings();
77
+ expect((await fake.handler(request(), state, context)).status).toBe(599);
78
+ });
79
+
80
+ test("custom state and cassette cursors survive cloning and siblings remain independent", async () => {
81
+ const fake = mixedFake();
82
+ const parent = await fake.state!();
83
+ const parentHelpers = await fake.helpers!({ name: "mixed", state: parent, ctx: context });
84
+ parentHelpers.replay(fixture);
85
+ parentHelpers.useRecordings();
86
+ await fake.handler(request("one"), parent, context);
87
+ // Model the state inherited by forks, including custom state and cursors.
88
+ const left = structuredClone(parent);
89
+ const right = structuredClone(parent);
90
+ const leftHelpers = await fake.helpers!({ name: "mixed", state: left, ctx: context });
91
+ leftHelpers.reply("left only");
92
+ expect((await fake.handler(request("one"), right, context)).status).toBe(599);
93
+ expect(await (await fake.handler(request("two"), left, context)).text()).toBe("recorded-two");
94
+ expect(await (await fake.handler(request("two"), right, context)).text()).toBe("recorded-two");
95
+ leftHelpers.useRecordings(false);
96
+ expect(right.custom.useRecordings).toBe(true);
97
+ expect(right.custom.reply).toBe("scripted");
98
+ expect(parent.cursor.size).toBe(1);
99
+ });
100
+
101
+ test("async factories receive custom state and the normal fake context", async () => {
102
+ const ctx = { dnsName: async () => {} } as unknown as FakeContext;
103
+ const fake = replayFake({
104
+ name: "mixed", host, mode: "replay",
105
+ state: async () => ({ reply: "async" }),
106
+ helpers: async ({ state, name, ctx: received }) => {
107
+ expect(name).toBe("mixed");
108
+ expect(received).toBe(ctx);
109
+ return { reply: (text: string) => { state.reply = text; } };
110
+ },
111
+ handler: (_req, state, received) => {
112
+ expect(received.dnsName).toBe(ctx.dnsName);
113
+ expect(typeof received.replay).toBe("function");
114
+ expect("replay" in ctx).toBe(false);
115
+ return new Response(state.reply);
116
+ },
117
+ });
118
+ const state = await fake.state!();
119
+ const helpers = await fake.helpers!({ name: "mixed", state, ctx });
120
+ helpers.reply("updated");
121
+ expect(await (await fake.handler(request(), state, ctx)).text()).toBe("updated");
122
+ });
123
+
124
+ test.each(["replay", "dump", "recorded", "count"])("rejects reserved helper name %s", async (name) => {
125
+ const fake = replayFake({ name: "mixed", host, helpers: () => ({ [name]: () => {} }) });
126
+ const state = await fake.state!();
127
+ await expect(Promise.resolve().then(() => fake.helpers!({ name: "mixed", state, ctx: context })))
128
+ .rejects.toThrow(`custom helper "${name}" is reserved`);
129
+ });
130
+
131
+ test("handler-only customization needs no state or helpers", async () => {
132
+ const fake = replayFake({
133
+ name: "mixed", host, mode: "replay",
134
+ handler(req, state, ctx) {
135
+ expect(state).toBeUndefined();
136
+ return ctx.replay(req);
137
+ },
138
+ });
139
+ const state = await fake.state!();
140
+ const helpers = await fake.helpers!({ name: "mixed", state, ctx: context });
141
+ helpers.replay(fixture);
142
+ expect(await (await fake.handler(request(), state, context)).text()).toBe("recorded-one");
143
+ });
144
+
145
+ test("a consumed body reports how to inspect safely before delegation", async () => {
146
+ const fake = replayFake({
147
+ name: "mixed", host, mode: "replay",
148
+ async handler(req, _state, ctx) {
149
+ await req.json();
150
+ return ctx.replay(req);
151
+ },
152
+ });
153
+ const state = await fake.state!();
154
+ await expect(fake.handler(request(), state, context)).rejects.toThrow("inspect request.clone()");
155
+ });
156
+
157
+ test("public state, context, and helper types stay inferred through defineEnvironment", async () => {
158
+ const dir = await mkdtemp(join(tmpdir(), "spectest-replay-types-"));
159
+ try {
160
+ const config = join(dir, "tsconfig.json");
161
+ await writeFile(config, JSON.stringify({
162
+ extends: fileURLToPath(new URL("../../tsconfig.json", import.meta.url)),
163
+ compilerOptions: {
164
+ noEmit: true,
165
+ typeRoots: [fileURLToPath(new URL("../../node_modules/@types", import.meta.url))],
166
+ },
167
+ include: [fileURLToPath(import.meta.url)],
168
+ exclude: [],
169
+ }));
170
+ const child = Bun.spawn([process.execPath, fileURLToPath(import.meta.resolve("typescript/bin/tsc")), "-p", config], {
171
+ stdout: "pipe", stderr: "pipe",
172
+ });
173
+ const [exit, stdout, stderr] = await Promise.all([
174
+ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
175
+ ]);
176
+ expect(exit, stdout + stderr).toBe(0);
177
+ } finally {
178
+ await rm(dir, { recursive: true, force: true });
179
+ }
180
+ }, 30_000);
181
+ });
182
+
183
+ // Checked with tsc as well as the runtime tests: inference must survive the
184
+ // public components export and defineEnvironment's wrapped helper surface.
185
+ function checkPublicTypes() {
186
+ const fake = replayFake({
187
+ name: "typed", host,
188
+ state: () => ({ enabled: false }),
189
+ helpers: ({ state }) => ({
190
+ enable(value: boolean) {
191
+ state.enabled = value;
192
+ // @ts-expect-error custom state is inferred, not any
193
+ state.missing;
194
+ },
195
+ }),
196
+ handler(req, state, ctx) {
197
+ const enabled: boolean = state.enabled;
198
+ const typedContext: ReplayFakeContext = ctx;
199
+ // @ts-expect-error internal cassette state is not exposed to the handler
200
+ state.cassette;
201
+ return enabled ? typedContext.replay(req) : new Response("scripted");
202
+ },
203
+ });
204
+ const env = defineEnvironment({ name: "typed", services: {}, fakes: { typed: fake } });
205
+ env.test("types", (ctx) => {
206
+ ctx.fakes.typed.enable(true);
207
+ ctx.fakes.typed.replay(fixture);
208
+ ctx.fakes.typed.dump();
209
+ // @ts-expect-error helper arguments retain their inferred types
210
+ ctx.fakes.typed.enable("yes");
211
+ // @ts-expect-error unknown helpers are not callable
212
+ ctx.fakes.typed.missing();
213
+ });
214
+ const handler: ReplayHandler<{ enabled: boolean }> = (req, state, ctx) =>
215
+ state.enabled ? ctx.replay(req) : new Response("scripted");
216
+ return handler;
217
+ }
@@ -95,6 +95,48 @@ test("recorded headers, redaction, and exact body bytes survive record → repla
95
95
  }
96
96
  }
97
97
  assert.equal(upstreamHeaders.length, 3); // Replay never forwards.
98
+
99
+ // The custom handler delegates through the same auto-mode pipeline.
100
+ // Scripted requests must neither forward nor enter the cassette.
101
+ const { startRecording, stopRecording } = await import(${moduleUrl("../recorder.ts")});
102
+ const mixed = replayFake({
103
+ ...options,
104
+ state: () => ({ useRecordings: false }),
105
+ helpers: ({ state }) => ({ useRecordings() { state.useRecordings = true; } }),
106
+ async handler(request, state, ctx) {
107
+ const body = await request.clone().text();
108
+ return state.useRecordings && body !== "scripted"
109
+ ? ctx.replay(request)
110
+ : new Response("scripted response");
111
+ },
112
+ });
113
+ const mixedState = await mixed.state();
114
+ const mixedHelpers = await mixed.helpers({ name: "test", state: mixedState, ctx: {} });
115
+ const send = (body) => mixed.handler(new Request("https://api.example.test/messages", {
116
+ method: "POST", body,
117
+ }), mixedState, {});
118
+ assert.equal(await (await send("delegated")).text(), "scripted response");
119
+ assert.equal(upstreamHeaders.length, 3);
120
+ mixedHelpers.useRecordings();
121
+ assert.equal(await (await send("scripted")).text(), "scripted response");
122
+ assert.equal(await (await send("delegated")).text(), "response");
123
+ assert.equal(upstreamHeaders.length, 4);
124
+ assert.equal(upstreamHeaders[3].authorization, "Bearer recording-secret");
125
+ assert.equal(mixedHelpers.count(), 1);
126
+ const mixedCassette = mixedHelpers.dump();
127
+ assert.equal(mixedCassette.interactions[0].request.body, "delegated");
128
+ mixedHelpers.replay(mixedCassette);
129
+ startRecording(); // Enter a test: auto now replays instead of forwarding.
130
+ try {
131
+ assert.equal(await (await send("scripted")).text(), "scripted response");
132
+ assert.equal(await (await send("delegated")).text(), "response");
133
+ assert.equal((await send("delegated")).status, 599);
134
+ assert.equal((await send("unrecorded")).status, 599);
135
+ assert.equal(mixedHelpers.count(), 1);
136
+ assert.equal(upstreamHeaders.length, 4);
137
+ } finally {
138
+ stopRecording();
139
+ }
98
140
  });
99
141
  `;
100
142
  const dir = await mkdtemp(join(tmpdir(), "spectest-replay-record-"));