@specific.dev/spectest 0.115.1 → 0.116.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/README.md +44 -0
- package/dist/components/replay-websocket.d.ts +2 -3
- package/dist/components/replay-websocket.js +19 -185
- package/dist/components/replayFake.d.ts +2 -53
- package/dist/components/replayFake.js +7 -348
- package/dist/daemon.js +4 -1
- package/dist/fake-upstream-websocket.d.ts +27 -0
- package/dist/fake-upstream-websocket.js +184 -0
- package/dist/fake-upstream.d.ts +114 -0
- package/dist/fake-upstream.js +348 -0
- package/dist/harness/fake-websocket.js +6 -3
- package/dist/harness/live-fake.d.ts +3 -0
- package/dist/harness/live-fake.js +66 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +3 -0
- package/dist/record-secrets.d.ts +1 -1
- package/dist/record-secrets.js +3 -3
- package/dist/replay-mode.d.ts +2 -0
- package/dist/replay-mode.js +6 -2
- package/package.json +1 -1
- package/src/components/replay-websocket.ts +25 -145
- package/src/components/replayFake.ts +8 -459
- package/src/daemon.ts +3 -1
- package/src/fake-upstream-websocket.ts +157 -0
- package/src/fake-upstream.ts +465 -0
- package/src/harness/fake-websocket.ts +6 -3
- package/src/harness/live-fake.test.ts +227 -0
- package/src/harness/live-fake.ts +62 -0
- package/src/index.ts +19 -1
- package/src/record-secrets.ts +3 -3
- package/src/replay-mode.ts +7 -2
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const moduleUrl = (file: string) => JSON.stringify(new URL(file, import.meta.url).href);
|
|
8
|
+
|
|
9
|
+
// Isolate DNS/transport mocks, platform secrets and recorder globals. All egress
|
|
10
|
+
// goes to a local stub; no real provider or credential is needed.
|
|
11
|
+
test("whole-fake live HTTP uses the environment gate, brokers secrets and never captures", async () => {
|
|
12
|
+
const root = await mkdtemp(join(tmpdir(), "spectest-live-http-"));
|
|
13
|
+
try {
|
|
14
|
+
await mkdir(join(root, "spectest"));
|
|
15
|
+
await writeFile(join(root, "spectest/index.ts"), `
|
|
16
|
+
import { defineEnvironment, defineFake } from ${moduleUrl("../index.ts")};
|
|
17
|
+
export default defineEnvironment({ name: "live", services: {}, fakes: {
|
|
18
|
+
live: defineFake({ name: "live", hostnames: ["api.test"], handler: () => new Response(),
|
|
19
|
+
live: { inject: [{ headers: { authorization: "Bearer {{secret:TOKEN}}" } }],
|
|
20
|
+
sign: { type: "awsSigv4", accessKeyId: "ID", secretAccessKey: "KEY", sessionToken: "SESSION" } },
|
|
21
|
+
secretRefs: ["LEGACY", "TOKEN"],
|
|
22
|
+
}),
|
|
23
|
+
} }).project({});
|
|
24
|
+
`);
|
|
25
|
+
const script = `
|
|
26
|
+
import { strict as assert } from "node:assert";
|
|
27
|
+
import { EventEmitter } from "node:events";
|
|
28
|
+
import { test, mock } from "bun:test";
|
|
29
|
+
import * as dnsPacket from ${JSON.stringify(import.meta.resolve("dns-packet"))};
|
|
30
|
+
test("live HTTP", async () => {
|
|
31
|
+
let dns = 0, requests = [];
|
|
32
|
+
mock.module("node:dgram", () => ({ createSocket() {
|
|
33
|
+
dns++;
|
|
34
|
+
const socket = new EventEmitter(); socket.close = () => {};
|
|
35
|
+
socket.send = (query, _port, _host, done) => { done(); queueMicrotask(() => socket.emit("message", dnsPacket.encode({
|
|
36
|
+
type: "response", id: 1, answers: [{ type: "A", name: dnsPacket.decode(query).questions[0].name, ttl: 60, data: "127.0.0.1" }],
|
|
37
|
+
}))); }; return socket;
|
|
38
|
+
} }));
|
|
39
|
+
mock.module("node:https", () => ({ request(options, callback) {
|
|
40
|
+
requests.push(options);
|
|
41
|
+
const req = new EventEmitter(); let body = Buffer.alloc(0);
|
|
42
|
+
req.write = chunk => { body = Buffer.concat([body, chunk]); };
|
|
43
|
+
req.end = () => queueMicrotask(() => {
|
|
44
|
+
if (options.path === "/broken") { req.emit("error", new Error("upstream unavailable")); return; }
|
|
45
|
+
const res = new EventEmitter(); res.statusCode = options.path === "/empty" ? 204 : options.path === "/denied" ? 401 : 200;
|
|
46
|
+
res.headers = { "content-type": "application/octet-stream", "set-cookie": ["session=real; Path=/", "other=value; Expires=Wed, 21 Oct 2037 07:28:00 GMT"], "content-length": "999" };
|
|
47
|
+
callback(res); res.emit("data", body.length ? body : Buffer.from("real transcript")); res.emit("end");
|
|
48
|
+
}); return req;
|
|
49
|
+
} }));
|
|
50
|
+
const { defineFake } = await import(${moduleUrl("../index.ts")});
|
|
51
|
+
const { METHODS } = await import(${moduleUrl("../daemon.ts")});
|
|
52
|
+
const { dispatchFake } = await import(${moduleUrl("./fake-websocket.ts")});
|
|
53
|
+
const { setReplayEnvironmentMode } = await import(${moduleUrl("../replay-mode.ts")});
|
|
54
|
+
const { setRecordSecrets } = await import(${moduleUrl("../record-secrets.ts")});
|
|
55
|
+
const { startRecording, stopRecording, pauseRecording, resumeRecording } = await import(${moduleUrl("../recorder.ts")});
|
|
56
|
+
await METHODS.load({});
|
|
57
|
+
assert.deepEqual((await METHODS.recordSecretRefs({})).refs.sort(), ["ID", "KEY", "LEGACY", "SESSION", "TOKEN"]);
|
|
58
|
+
const state = { calls: 0 };
|
|
59
|
+
const def = defineFake({ name: "speech", hostnames: ["api.test", "speech.test"],
|
|
60
|
+
handler: () => { state.calls++; return new Response("scripted"); },
|
|
61
|
+
websocket: () => { state.calls++; return new Response("scripted websocket", { status: 403 }); },
|
|
62
|
+
helpers: () => ({ count: () => state.calls }),
|
|
63
|
+
live: { inject: [{ headers: { authorization: "Bearer {{secret:TOKEN}}" } }] },
|
|
64
|
+
});
|
|
65
|
+
const helpers = def.helpers({ name: def.name, state, ctx: {} });
|
|
66
|
+
const call = (host = "api.test", path = "/transcribe", options = {}) => dispatchFake(new Request("https://" + host + path, options), {}, def, state, {});
|
|
67
|
+
setRecordSecrets({ TOKEN: "real-key" });
|
|
68
|
+
async function checkScripted() {
|
|
69
|
+
assert.equal(await (await call()).text(), "scripted");
|
|
70
|
+
assert.equal((await call("speech.test", "/socket", { headers: { upgrade: "websocket" } })).status, 403);
|
|
71
|
+
assert.equal(dns, 0); assert.equal(requests.length, 0);
|
|
72
|
+
}
|
|
73
|
+
await checkScripted(); // baseline startup
|
|
74
|
+
startRecording(); await checkScripted(); pauseRecording(); await checkScripted();
|
|
75
|
+
resumeRecording(); stopRecording(); await checkScripted(); // between tests
|
|
76
|
+
setReplayEnvironmentMode("interactive");
|
|
77
|
+
const calls = helpers.count();
|
|
78
|
+
startRecording();
|
|
79
|
+
for (const host of def.hostnames) {
|
|
80
|
+
const response = await call(host, "/transcribe?lang=sv&word=a%20b", {
|
|
81
|
+
method: "POST", body: new Uint8Array([0, 128, 255]), headers: { authorization: "Bearer dummy" },
|
|
82
|
+
});
|
|
83
|
+
assert.deepEqual(new Uint8Array(await response.arrayBuffer()), new Uint8Array([0, 128, 255]));
|
|
84
|
+
assert.deepEqual(response.headers.getSetCookie(), ["session=real; Path=/", "other=value; Expires=Wed, 21 Oct 2037 07:28:00 GMT"]);
|
|
85
|
+
assert.equal(response.headers.get("content-length"), null);
|
|
86
|
+
const sent = requests.at(-1);
|
|
87
|
+
assert.equal(sent.host, "127.0.0.1"); assert.equal(sent.servername, host);
|
|
88
|
+
assert.equal(sent.headers.host, host); assert.equal(sent.headers.authorization, "Bearer real-key");
|
|
89
|
+
assert.equal(sent.path, "/transcribe?lang=sv&word=a%20b"); assert.equal(sent.method, "POST");
|
|
90
|
+
}
|
|
91
|
+
assert.equal(await (await call()).text(), "real transcript");
|
|
92
|
+
assert.deepEqual(stopRecording(), []);
|
|
93
|
+
const compressed = new Uint8Array([31, 139, 8, 0]);
|
|
94
|
+
await call("api.test", "/compressed", { method: "POST", body: compressed, headers: { "content-encoding": "gzip" } });
|
|
95
|
+
assert.equal(requests.at(-1).headers["content-encoding"], "gzip");
|
|
96
|
+
assert.equal(helpers.count(), calls);
|
|
97
|
+
assert.equal((await call("api.test", "/empty")).status, 204);
|
|
98
|
+
assert.equal((await call("api.test", "/denied")).status, 401);
|
|
99
|
+
assert.equal((await call("api.test", "/broken")).status, 599);
|
|
100
|
+
assert.equal(await (await call("api.test", "/transcribe", { method: "HEAD" })).text(), "");
|
|
101
|
+
let before = dns;
|
|
102
|
+
assert.equal((await call("outside.test")).status, 500); assert.equal(dns, before);
|
|
103
|
+
setRecordSecrets({});
|
|
104
|
+
assert.equal((await call()).status, 599); assert.equal(dns, before);
|
|
105
|
+
// Missing credentials never change routing or fall back to the fake.
|
|
106
|
+
assert.equal(helpers.count(), calls);
|
|
107
|
+
def.live = {};
|
|
108
|
+
assert.equal(await (await call()).text(), "real transcript");
|
|
109
|
+
def.live = { sign: { type: "awsSigv4", accessKeyId: "ID", secretAccessKey: "KEY" } };
|
|
110
|
+
def.hostnames = ["s3.eu-north-1.amazonaws.com"];
|
|
111
|
+
setRecordSecrets({ ID: "real-id", KEY: "real-signing-key" });
|
|
112
|
+
await call(def.hostnames[0]);
|
|
113
|
+
assert.ok(requests.at(-1).headers.authorization.startsWith("AWS4-HMAC-SHA256 Credential=real-id/"));
|
|
114
|
+
delete def.live;
|
|
115
|
+
before = dns; assert.equal(await (await call()).text(), "scripted"); assert.equal(dns, before);
|
|
116
|
+
def.live = {};
|
|
117
|
+
setReplayEnvironmentMode("test");
|
|
118
|
+
before = dns; await call(); assert.equal(dns, before);
|
|
119
|
+
});
|
|
120
|
+
`;
|
|
121
|
+
const file = join(root, "http.test.ts"); await writeFile(file, script);
|
|
122
|
+
const child = Bun.spawn([process.execPath, "test", file], {
|
|
123
|
+
env: { ...process.env, SPECTEST_APP_DIR: root }, stdout: "pipe", stderr: "pipe",
|
|
124
|
+
});
|
|
125
|
+
const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
|
126
|
+
expect(exit, stdout + stderr).toBe(0);
|
|
127
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
128
|
+
}, 20_000);
|
|
129
|
+
|
|
130
|
+
// Real TLS and ingress sockets. Only DNS and the upstream port are redirected.
|
|
131
|
+
test("live WSS forwards binary/text without recording and cleans up both sides", async () => {
|
|
132
|
+
const root = await mkdtemp(join(tmpdir(), "spectest-live-ws-"));
|
|
133
|
+
try {
|
|
134
|
+
const key = join(root, "key.pem"), cert = join(root, "cert.pem");
|
|
135
|
+
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", key, "-out", cert,
|
|
136
|
+
"-days", "1", "-subj", "/CN=speech.test", "-addext", "subjectAltName=DNS:speech.test,DNS:api.test"], { stdio: "ignore" });
|
|
137
|
+
const script = `
|
|
138
|
+
import { test, mock } from "bun:test";
|
|
139
|
+
import { strict as assert } from "node:assert";
|
|
140
|
+
import { EventEmitter, once } from "node:events";
|
|
141
|
+
import { createServer } from "node:https";
|
|
142
|
+
import { readFileSync } from "node:fs";
|
|
143
|
+
import * as dnsPacket from ${JSON.stringify(import.meta.resolve("dns-packet"))};
|
|
144
|
+
import WS from ${JSON.stringify(import.meta.resolve("ws").replace(/wrapper\.mjs$/, "index.js"))};
|
|
145
|
+
test("live WSS", async () => {
|
|
146
|
+
mock.module("node:dgram", () => ({ createSocket() {
|
|
147
|
+
const socket = new EventEmitter(); socket.close = () => {};
|
|
148
|
+
socket.send = (_query, _port, _host, done) => { done(); queueMicrotask(() => socket.emit("message", dnsPacket.encode({
|
|
149
|
+
type: "response", id: 1, answers: [{ type: "A", name: "speech.test", ttl: 60, data: "127.0.0.1" }],
|
|
150
|
+
}))); }; return socket;
|
|
151
|
+
} }));
|
|
152
|
+
const upstream = createServer({ key: readFileSync(${JSON.stringify(key)}), cert: readFileSync(${JSON.stringify(cert)}) });
|
|
153
|
+
const wss = new WS.WebSocketServer({ server: upstream, handleProtocols: () => "voice" });
|
|
154
|
+
upstream.listen(0, "127.0.0.1"); await once(upstream, "listening");
|
|
155
|
+
const { forwardWebSocket } = await import(${moduleUrl("../fake-upstream-websocket.ts")});
|
|
156
|
+
mock.module(${moduleUrl("../fake-upstream-websocket.ts")}, () => ({ forwardWebSocket(args) {
|
|
157
|
+
assert.equal(args.observe, undefined); // no recorder, matcher or capture callback
|
|
158
|
+
assert.equal(args.url, "wss://" + args.host + "/live?lang=sv");
|
|
159
|
+
const url = new URL(args.url); url.port = String(upstream.address().port);
|
|
160
|
+
return forwardWebSocket({ ...args, url: url.href });
|
|
161
|
+
} }));
|
|
162
|
+
const { defineFake } = await import(${moduleUrl("../index.ts")});
|
|
163
|
+
const { setReplayEnvironmentMode } = await import(${moduleUrl("../replay-mode.ts")});
|
|
164
|
+
const { setRecordSecrets } = await import(${moduleUrl("../record-secrets.ts")});
|
|
165
|
+
const { startRecording, stopRecording } = await import(${moduleUrl("../recorder.ts")});
|
|
166
|
+
const { startIngressServer } = await import(${moduleUrl("./ingress-server.ts")});
|
|
167
|
+
const { dispatchFake } = await import(${moduleUrl("./fake-websocket.ts")});
|
|
168
|
+
setReplayEnvironmentMode("interactive"); setRecordSecrets({ TOKEN: "real-key" });
|
|
169
|
+
const state = { calls: 0 };
|
|
170
|
+
// Deliberately no scripted WebSocket handler: live routing still upgrades.
|
|
171
|
+
const def = defineFake({ name: "speech", hostnames: ["api.test", "speech.test"],
|
|
172
|
+
handler: () => { state.calls++; return new Response("scripted"); },
|
|
173
|
+
live: { inject: [{ headers: { authorization: "Bearer {{secret:TOKEN}}" } }] },
|
|
174
|
+
});
|
|
175
|
+
const ingress = await startIngressServer({ port: 0, hostname: "127.0.0.1", fetch: (req, server) => dispatchFake(req, server, def, state, {}) });
|
|
176
|
+
let connections = 0;
|
|
177
|
+
wss.on("connection", (socket, req) => {
|
|
178
|
+
connections++; assert.equal(req.headers.authorization, "Bearer real-key");
|
|
179
|
+
assert.equal(req.url, "/live?lang=sv");
|
|
180
|
+
socket.send("real transcript");
|
|
181
|
+
socket.on("message", (data, binary) => {
|
|
182
|
+
if (!binary && data.toString() === "finish") socket.close(4003, "provider done");
|
|
183
|
+
else socket.send(data, { binary });
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
try {
|
|
187
|
+
startRecording();
|
|
188
|
+
for (const host of def.hostnames) {
|
|
189
|
+
const client = new WS("ws://127.0.0.1:" + ingress.port + "/live?lang=sv", ["voice"], {
|
|
190
|
+
headers: { host, authorization: "Bearer dummy" },
|
|
191
|
+
});
|
|
192
|
+
const received = []; client.on("message", (data, binary) => received.push({ data: Buffer.from(data), binary }));
|
|
193
|
+
await once(client, "open"); assert.equal(client.protocol, "voice");
|
|
194
|
+
client.send('{"model":"test"}'); client.send(new Uint8Array([0, 128, 255]));
|
|
195
|
+
for (let i = 0; received.length < 3 && i < 200; i++) await Bun.sleep(5);
|
|
196
|
+
assert.deepEqual(received, [
|
|
197
|
+
{ data: Buffer.from("real transcript"), binary: false },
|
|
198
|
+
{ data: Buffer.from('{"model":"test"}'), binary: false },
|
|
199
|
+
{ data: Buffer.from([0, 128, 255]), binary: true },
|
|
200
|
+
]);
|
|
201
|
+
const closed = once(client, "close");
|
|
202
|
+
if (host === "api.test") client.close(4002, "client done"); else client.send("finish");
|
|
203
|
+
const [code, reason] = await closed;
|
|
204
|
+
assert.equal(code, host === "api.test" ? 4002 : 4003);
|
|
205
|
+
assert.equal(reason.toString(), host === "api.test" ? "client done" : "provider done");
|
|
206
|
+
}
|
|
207
|
+
assert.equal(connections, 2); assert.equal(state.calls, 0);
|
|
208
|
+
assert.deepEqual(stopRecording(), []);
|
|
209
|
+
for (let i = 0; wss.clients.size && i < 100; i++) await Bun.sleep(5);
|
|
210
|
+
assert.equal(wss.clients.size, 0);
|
|
211
|
+
setRecordSecrets({});
|
|
212
|
+
const response = await dispatchFake(new Request("https://speech.test/live", { headers: { upgrade: "websocket" } }), {}, def, state, {});
|
|
213
|
+
assert.equal(response.status, 500); assert.equal(connections, 2); assert.equal(state.calls, 0);
|
|
214
|
+
def.live.sign = { type: "awsSigv4", accessKeyId: "ID", secretAccessKey: "KEY" };
|
|
215
|
+
const signed = await dispatchFake(new Request("https://speech.test/live", { headers: { upgrade: "websocket" } }), {}, def, state, {});
|
|
216
|
+
assert.match(await signed.text(), /SigV4 WebSocket signing is not supported/);
|
|
217
|
+
} finally {
|
|
218
|
+
ingress.stop(true); for (const socket of wss.clients) socket.terminate(); wss.close(); upstream.close();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
`;
|
|
222
|
+
const file = join(root, "ws.test.ts"); await writeFile(file, script);
|
|
223
|
+
const child = Bun.spawn([process.execPath, "test", file], { env: { ...process.env, NODE_EXTRA_CA_CERTS: cert }, stdout: "pipe", stderr: "pipe" });
|
|
224
|
+
const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
|
225
|
+
expect(exit, stdout + stderr).toBe(0);
|
|
226
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
227
|
+
}, 20_000);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { FakeDefinition } from "../index.js";
|
|
2
|
+
import {
|
|
3
|
+
brokerHeaders, forwardHttp, resolveExternalA, ruleMatches, STRIP_FORWARD_HEADERS,
|
|
4
|
+
} from "../fake-upstream.js";
|
|
5
|
+
import { forwardWebSocket } from "../fake-upstream-websocket.js";
|
|
6
|
+
import { getWebSocketLifetime } from "./fake-websocket-lifetime.js";
|
|
7
|
+
import { reportBackgroundFailure } from "./background-failures.js";
|
|
8
|
+
import { isInteractiveEnvironment } from "../replay-mode.js";
|
|
9
|
+
|
|
10
|
+
/** Select at request time, after the baseline has become interactive. */
|
|
11
|
+
export function liveFakeHandlers(def: FakeDefinition): Pick<FakeDefinition, "handler" | "websocket"> {
|
|
12
|
+
function target(req: Request): string {
|
|
13
|
+
if (!isInteractiveEnvironment()) throw new Error("live forwarding requires an interactive environment");
|
|
14
|
+
const url = new URL(req.url);
|
|
15
|
+
if (!def.hostnames.some(host => host.toLowerCase() === url.hostname.toLowerCase())) {
|
|
16
|
+
throw new Error(`fake ${def.name}: upstream hostname is not declared on this fake`);
|
|
17
|
+
}
|
|
18
|
+
return url.hostname;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
async handler(req) {
|
|
23
|
+
const host = target(req);
|
|
24
|
+
const bytes = new Uint8Array(await req.arrayBuffer());
|
|
25
|
+
const result = await forwardHttp(req, host, def.live!, bytes, `fake(${def.name})`);
|
|
26
|
+
if (result instanceof Response) return result;
|
|
27
|
+
const { upstream } = result;
|
|
28
|
+
const headers = new Headers();
|
|
29
|
+
for (const [key, value] of Object.entries(upstream.headers)) {
|
|
30
|
+
if (key !== "set-cookie" && !STRIP_FORWARD_HEADERS.has(key)) headers.set(key, value);
|
|
31
|
+
}
|
|
32
|
+
for (const cookie of upstream.cookies ?? []) headers.append("set-cookie", cookie);
|
|
33
|
+
return new Response(req.method === "HEAD" || [204, 205, 304].includes(upstream.status)
|
|
34
|
+
? null : upstream.bytes as unknown as BodyInit, { status: upstream.status, headers });
|
|
35
|
+
},
|
|
36
|
+
async websocket(req) {
|
|
37
|
+
const host = target(req);
|
|
38
|
+
const opts = def.live!;
|
|
39
|
+
if (opts.sign) throw new Error(`fake(${def.name}): SigV4 WebSocket signing is not supported`);
|
|
40
|
+
const url = new URL(req.url);
|
|
41
|
+
const rule = opts.inject?.find(rule => ruleMatches(rule.match, {
|
|
42
|
+
method: req.method, path: url.pathname, query: url.searchParams, headers: req.headers,
|
|
43
|
+
}));
|
|
44
|
+
const brokered = rule ? brokerHeaders(rule) : { headers: {}, missing: [] };
|
|
45
|
+
if (brokered.missing.length) throw new Error(`fake(${def.name}): missing WebSocket secrets ${brokered.missing.join(", ")}`);
|
|
46
|
+
const ips = await resolveExternalA(host);
|
|
47
|
+
if (!ips.length) throw new Error(`fake(${def.name}): could not resolve real upstream ${host}`);
|
|
48
|
+
const headers: Record<string, string> = {};
|
|
49
|
+
req.headers.forEach((value, key) => {
|
|
50
|
+
if (!STRIP_FORWARD_HEADERS.has(key) && !key.startsWith("sec-websocket-")) headers[key] = value;
|
|
51
|
+
});
|
|
52
|
+
Object.assign(headers, brokered.headers);
|
|
53
|
+
const { handlers } = await forwardWebSocket({
|
|
54
|
+
url: `wss://${host}${url.pathname}${url.search}`, ip: ips[0], host, headers,
|
|
55
|
+
protocols: (req.headers.get("sec-websocket-protocol") ?? "").split(",").map(p => p.trim()).filter(Boolean),
|
|
56
|
+
label: `fake(${def.name}) WebSocket`, lifetime: getWebSocketLifetime(req),
|
|
57
|
+
finished(_session, error) { if (error) reportBackgroundFailure(error); },
|
|
58
|
+
});
|
|
59
|
+
return handlers;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { InjectRule, SignConfig } from "./fake-upstream.js";
|
|
2
|
+
export type { InjectRule, InjectMatch, StringMatch, SignConfig, AwsSigV4Sign } from "./fake-upstream.js";
|
|
1
3
|
export type { Phone, PhoneOptions, CallOptions, SpeechOptions } from "./phone.js";
|
|
2
4
|
import type { Phone, PhoneOptions } from "./phone.js";
|
|
3
5
|
import type { Android, AndroidApp } from "./android.js";
|
|
@@ -2140,6 +2142,15 @@ export interface TestSuite<
|
|
|
2140
2142
|
// Fakes — in-daemon HTTP servers that masquerade as external APIs.
|
|
2141
2143
|
// ──────────────────────────────────────────────────────────────────────────
|
|
2142
2144
|
|
|
2145
|
+
/** Forward the entire fake to real HTTPS/WSS upstreams in interactive environments.
|
|
2146
|
+
* Tests and shared baselines always use the scripted handlers. No traffic is recorded. */
|
|
2147
|
+
export interface FakeLiveOptions {
|
|
2148
|
+
/** Same header injection rules as replayFake, applied to HTTP and WebSocket handshakes. */
|
|
2149
|
+
inject?: InjectRule[];
|
|
2150
|
+
/** Re-sign HTTP requests using brokered AWS credentials. Not supported for WebSockets. */
|
|
2151
|
+
sign?: SignConfig;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2143
2154
|
/**
|
|
2144
2155
|
* A fake server hosted in the harness. Use this to stand in for
|
|
2145
2156
|
* external HTTP APIs (auth providers, payment gateways, …) that you can't
|
|
@@ -2177,6 +2188,10 @@ export interface FakeDefinition<
|
|
|
2177
2188
|
* other fakes.
|
|
2178
2189
|
*/
|
|
2179
2190
|
hostnames: readonly string[];
|
|
2191
|
+
/** Opt in to forwarding all HTTP/WebSocket traffic to the declared real hosts
|
|
2192
|
+
* over HTTPS/WSS (port 443) in interactive environments. Errors never fall back
|
|
2193
|
+
* to scripted handlers. Omit to keep the fake scripted in every environment. */
|
|
2194
|
+
live?: FakeLiveOptions;
|
|
2180
2195
|
/** TCP port the fake listens on. Default `80`. */
|
|
2181
2196
|
port?: number;
|
|
2182
2197
|
/** Preserve handler/interceptor CORS policy by default. "permissive"
|
|
@@ -2231,7 +2246,7 @@ export interface FakeDefinition<
|
|
|
2231
2246
|
*/
|
|
2232
2247
|
helpers?: (args: { name: string; state: S; ctx: FakeContext }) => H | Promise<H>;
|
|
2233
2248
|
/**
|
|
2234
|
-
* Internal. Platform secret references this fake needs
|
|
2249
|
+
* Internal. Platform secret references this fake needs for upstream access
|
|
2235
2250
|
* (set by {@link replayFake}). The daemon reports the union of these to
|
|
2236
2251
|
* the control plane, which resolves each via its `SecretResolver` and
|
|
2237
2252
|
* supplies them for the environment lifetime, including snapshots. Values
|
|
@@ -2270,6 +2285,9 @@ export function defineFake<
|
|
|
2270
2285
|
);
|
|
2271
2286
|
}
|
|
2272
2287
|
}
|
|
2288
|
+
if (opts.live !== undefined && (!opts.live || typeof opts.live !== "object" || Array.isArray(opts.live))) {
|
|
2289
|
+
throw new Error(`defineFake(${opts.name}): \`live\` must be an options object`);
|
|
2290
|
+
}
|
|
2273
2291
|
validateCorsMode(opts.cors);
|
|
2274
2292
|
if (typeof opts.handler !== "function") {
|
|
2275
2293
|
throw new Error(`defineFake(${opts.name}): \`handler\` is required`);
|
package/src/record-secrets.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Environment-lifetime credentials for record-mode fakes. The control plane
|
|
1
|
+
// Environment-lifetime credentials for live and record-mode fakes. The control plane
|
|
2
2
|
// resolves declared references from the owning project's Secrets store before
|
|
3
3
|
// service startup and refreshes them on warm starts and interactive forks.
|
|
4
4
|
// They persist across evals, background requests and memory snapshots, just
|
|
5
5
|
// like other environment state. Test forks inherit their parent's credentials;
|
|
6
|
-
//
|
|
6
|
+
// test mode still uses scripted handlers or cassettes and never forwards upstream.
|
|
7
7
|
// Values stay out of project files, config hashes and recorded cassettes.
|
|
8
8
|
|
|
9
9
|
const SECRETS = new Map<string, string>();
|
|
@@ -14,7 +14,7 @@ export function setRecordSecrets(secrets: Record<string, string>): void {
|
|
|
14
14
|
for (const [ref, value] of Object.entries(secrets)) SECRETS.set(ref, value);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/** Resolve a declared project secret for an outbound recording request. */
|
|
17
|
+
/** Resolve a declared project secret for an outbound live or recording request. */
|
|
18
18
|
export function getRecordSecret(ref: string): string | undefined {
|
|
19
19
|
return SECRETS.get(ref);
|
|
20
20
|
}
|
package/src/replay-mode.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Environment state: travels with memory snapshots, independently of the
|
|
2
|
-
// per-case event recorder. Shared/warm baselines must never
|
|
3
|
-
// The control plane enables
|
|
2
|
+
// per-case event recorder. Shared/warm baselines must never forward upstream.
|
|
3
|
+
// The control plane enables upstream access only on an interactive environment,
|
|
4
4
|
// after capturing/restoring the shared baseline and before interactive setup.
|
|
5
5
|
let mode: "test" | "interactive" = "test";
|
|
6
6
|
|
|
@@ -12,3 +12,8 @@ export function resolveReplayMode(requested: "auto" | "record" | "replay" = "aut
|
|
|
12
12
|
// Even an explicit record option cannot send test traffic to real providers.
|
|
13
13
|
return mode === "test" || requested === "replay" ? "replay" : "record";
|
|
14
14
|
}
|
|
15
|
+
|
|
16
|
+
/** Shared gate for live and replay fakes. Credentials never enable upstream access. */
|
|
17
|
+
export function isInteractiveEnvironment(): boolean {
|
|
18
|
+
return mode === "interactive";
|
|
19
|
+
}
|