@specific.dev/spectest 0.107.0 → 0.107.2
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 +7 -2
- package/dist/components/replay-key.js +4 -4
- package/dist/components/replayFake.d.ts +4 -2
- package/dist/harness/ingress-server.js +7 -1
- package/package.json +1 -1
- package/src/components/replay-key.ts +4 -4
- package/src/components/replayFake-handler.test.ts +1 -0
- package/src/components/replayFake-record.test.ts +4 -4
- package/src/components/replayFake.test.ts +32 -8
- package/src/components/replayFake.ts +4 -2
- package/src/harness/ingress-server.test.ts +57 -5
- package/src/harness/ingress-server.ts +7 -1
package/README.md
CHANGED
|
@@ -114,8 +114,13 @@ the provider's message handle usually; it is never drawn — and a reply names
|
|
|
114
114
|
an earlier message's `id` as `replyTo`. The dashboard draws the reply the
|
|
115
115
|
way a messaging app threads one: an excerpt of the earlier message in a
|
|
116
116
|
muted mini-bubble directly above the reply, on the reply's side, with a
|
|
117
|
-
short connector between them. A
|
|
118
|
-
the
|
|
117
|
+
short connector between them. A reply to the bubble right above it draws
|
|
118
|
+
only the connector, since the message it answers is already on screen. A
|
|
119
|
+
run of consecutive replies to one message quotes it once, above the first
|
|
120
|
+
of them, and the rest draw only the connector so they read as one thread;
|
|
121
|
+
a reply to a different message, or an ordinary message, ends the run. A
|
|
122
|
+
`replyTo` that names no earlier message of the same call draws nothing
|
|
123
|
+
extra; the bubble is an ordinary message.
|
|
119
124
|
|
|
120
125
|
```ts
|
|
121
126
|
return annotate(turns, "chat", {
|
|
@@ -89,8 +89,8 @@ function requestView(request) {
|
|
|
89
89
|
export function replaySignature(request, key) {
|
|
90
90
|
if (key)
|
|
91
91
|
return hashKey(key(requestView(request)));
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
return hashKey({ method: request.method.toUpperCase(), path: request.path, query: request.query
|
|
92
|
+
// Preserve the original replay default: bodies and headers may change
|
|
93
|
+
// between recording and replay. Requests to the same endpoint consume
|
|
94
|
+
// responses in cassette order; body-sensitive identity is opt-in via key.
|
|
95
|
+
return hashKey({ method: request.method.toUpperCase(), path: request.path, query: request.query });
|
|
96
96
|
}
|
|
@@ -71,8 +71,10 @@ export interface ReplayFakeOptions<S = undefined, H extends Record<string, unkno
|
|
|
71
71
|
* resolved via an external DNS server — see the module header). Your
|
|
72
72
|
* app points at this real host directly; no separate stand-in. */
|
|
73
73
|
host: string;
|
|
74
|
-
/** Complete request identity. Default: method, path, normalized query
|
|
75
|
-
* and
|
|
74
|
+
/** Complete request identity. Default: method, path, and normalized query;
|
|
75
|
+
* bodies and headers are ignored, and matching responses replay in cassette
|
|
76
|
+
* order. Include body content in a custom key to distinguish concurrent
|
|
77
|
+
* calls to the same endpoint. The same synchronous, deterministic callback runs
|
|
76
78
|
* on recorded and incoming requests. Return JSON values and/or Uint8Array
|
|
77
79
|
* bytes; objects compare independently of property order. No implicit
|
|
78
80
|
* matching dimensions or fallback apply when a key is supplied. */
|
|
@@ -113,9 +113,15 @@ export async function startIngressServer(options) {
|
|
|
113
113
|
headers.append(incoming.rawHeaders[i], incoming.rawHeaders[i + 1]);
|
|
114
114
|
}
|
|
115
115
|
const method = incoming.method ?? "GET";
|
|
116
|
+
// An HTTP/1 request without framing has no body. Giving it even an
|
|
117
|
+
// empty stream makes fetch invent chunked framing upstream (e.g. a
|
|
118
|
+
// bodyless DELETE then fails Fastify's content-type validation).
|
|
119
|
+
// Keep explicitly chunked bodies streaming, including empty ones.
|
|
120
|
+
const hasBody = method !== "GET" && method !== "HEAD"
|
|
121
|
+
&& (headers.has("transfer-encoding") || Number(headers.get("content-length")) > 0);
|
|
116
122
|
const req = new Request(new URL(incoming.url ?? "/", `http://${headers.get("host") ?? "localhost"}`), {
|
|
117
123
|
method, headers, signal: abort.signal,
|
|
118
|
-
body:
|
|
124
|
+
body: hasBody ? Readable.toWeb(incoming) : undefined,
|
|
119
125
|
duplex: "half",
|
|
120
126
|
});
|
|
121
127
|
peers.set(req, incoming.socket);
|
package/package.json
CHANGED
|
@@ -120,8 +120,8 @@ function requestView(request: CassetteRequest): ReplayRequest {
|
|
|
120
120
|
/** Internal only; the SDK exports the request/key types, not this function. */
|
|
121
121
|
export function replaySignature(request: CassetteRequest, key?: ReplayKey): string {
|
|
122
122
|
if (key) return hashKey(key(requestView(request)));
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
return hashKey({ method: request.method.toUpperCase(), path: request.path, query: request.query
|
|
123
|
+
// Preserve the original replay default: bodies and headers may change
|
|
124
|
+
// between recording and replay. Requests to the same endpoint consume
|
|
125
|
+
// responses in cassette order; body-sensitive identity is opt-in via key.
|
|
126
|
+
return hashKey({ method: request.method.toUpperCase(), path: request.path, query: request.query });
|
|
127
127
|
}
|
|
@@ -25,6 +25,7 @@ const request = (task = "one", path = "/messages") => new Request(`https://${hos
|
|
|
25
25
|
function mixedFake() {
|
|
26
26
|
return replayFake({
|
|
27
27
|
name: "mixed", host, mode: "replay",
|
|
28
|
+
key: request => ({ method: request.method, path: request.path, task: request.json<{ task: string }>().task }),
|
|
28
29
|
state: () => ({ useRecordings: false, reply: "scripted", seen: [] as string[] }),
|
|
29
30
|
helpers: ({ state }) => ({
|
|
30
31
|
useRecordings(enabled = true) { state.useRecordings = enabled; },
|
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
|
|
6
6
|
// Isolate module mocks from other tests. Drive the actual record handler,
|
|
7
7
|
// replacing only DNS/HTTPS transport; no credentials or external egress.
|
|
8
|
-
test("recorded
|
|
8
|
+
test("recorded bodies and redaction survive; replay uses default order or an explicit header key", async () => {
|
|
9
9
|
const moduleUrl = (file: string) => JSON.stringify(new URL(file, import.meta.url).href);
|
|
10
10
|
const script = `
|
|
11
11
|
import { strict as assert } from "node:assert";
|
|
@@ -37,7 +37,7 @@ test("recorded headers, redaction, and exact body bytes survive record → repla
|
|
|
37
37
|
res.statusCode = 200;
|
|
38
38
|
res.headers = { "content-type": "text/plain" };
|
|
39
39
|
callback(res);
|
|
40
|
-
res.emit("data", Buffer.from("response"));
|
|
40
|
+
res.emit("data", Buffer.from(options.headers["x-task"] ? "response-" + options.headers["x-task"] : "response"));
|
|
41
41
|
res.emit("end");
|
|
42
42
|
});
|
|
43
43
|
return req;
|
|
@@ -86,12 +86,12 @@ test("recorded headers, redaction, and exact body bytes survive record → repla
|
|
|
86
86
|
const replay = replayFake({ ...options, mode: "replay", key });
|
|
87
87
|
const replayState = replay.state();
|
|
88
88
|
replay.helpers({ name: "test", state: replayState, ctx: {} }).replay(cassette);
|
|
89
|
-
for (const i of [2, 0, 1]) {
|
|
89
|
+
for (const [position, i] of [2, 0, 1].entries()) {
|
|
90
90
|
const res = await replay.handler(new Request("https://api.example.test/messages", {
|
|
91
91
|
method: "POST", body: bodies[i], headers: { "X-Task": "task-" + i },
|
|
92
92
|
}), replayState, {});
|
|
93
93
|
assert.equal(res.status, 200);
|
|
94
|
-
assert.equal(await res.text(), "response");
|
|
94
|
+
assert.equal(await res.text(), "response-task-" + (key ? i : position));
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
assert.equal(upstreamHeaders.length, 3); // Replay never forwards.
|
|
@@ -6,6 +6,9 @@ import { replayFake, type Cassette, type CassetteInteraction, type ReplayFakeOpt
|
|
|
6
6
|
const context = {} as FakeContext;
|
|
7
7
|
const host = "api.example.test";
|
|
8
8
|
const sha = (body: string | Uint8Array) => `sha256:${createHash("sha256").update(body).digest("hex")}`;
|
|
9
|
+
const exactBodyKey: ReplayKey = request => ({
|
|
10
|
+
method: request.method, path: request.path, query: request.query, body: request.bytes(),
|
|
11
|
+
});
|
|
9
12
|
|
|
10
13
|
function interaction(body: string | Uint8Array, answer: string, path = "/messages"): CassetteInteraction {
|
|
11
14
|
return {
|
|
@@ -38,8 +41,8 @@ async function harness(options: Partial<ReplayFakeOptions> = {}) {
|
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
describe("replay request identity", () => {
|
|
41
|
-
test("
|
|
42
|
-
const h = await harness();
|
|
44
|
+
test("explicit body key matches 654 concurrent requests independently of arrival order", async () => {
|
|
45
|
+
const h = await harness({ key: exactBodyKey });
|
|
43
46
|
const calls = Array.from({ length: 654 }, (_, id) => interaction(JSON.stringify({ id }), `answer-${id}`));
|
|
44
47
|
h.helpers.replay(cassette(calls));
|
|
45
48
|
// A permutation visits every identity once in a different order.
|
|
@@ -53,7 +56,7 @@ describe("replay request identity", () => {
|
|
|
53
56
|
expect(h.state.cursor.size).toBe(654);
|
|
54
57
|
});
|
|
55
58
|
|
|
56
|
-
test("default uses method/path/normalized query
|
|
59
|
+
test("default uses method/path/normalized query and ignores changed bodies and headers", async () => {
|
|
57
60
|
const h = await harness();
|
|
58
61
|
const it = interaction('{"a":1}', "ok");
|
|
59
62
|
delete it.request.bodyHash;
|
|
@@ -61,15 +64,36 @@ describe("replay request identity", () => {
|
|
|
61
64
|
it.request.query = { b: ["1", "2"], a: ["x"] };
|
|
62
65
|
h.helpers.replay(cassette([it]));
|
|
63
66
|
for (const [body, path, init] of [
|
|
64
|
-
['{ "a":1}', "/messages?a=x&b=2&b=1", {}],
|
|
65
67
|
['{"a":1}', "/other?a=x&b=2&b=1", {}],
|
|
66
68
|
['{"a":1}', "/messages?a=x&b=2&b=1", { method: "PUT" }],
|
|
67
69
|
['{"a":1}', "/messages?a=y&b=2&b=1", {}],
|
|
68
70
|
] as const) expect((await h.send(body, path, init)).status).toBe(599);
|
|
69
|
-
expect(await (await h.send('{"a":
|
|
71
|
+
expect(await (await h.send('{"a":2,"generatedId":"new"}', "/messages?b=2&a=x&b=1", {
|
|
72
|
+
headers: { "content-type": "text/plain", "x-request-id": "new" },
|
|
73
|
+
})).text()).toBe("ok");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test.each([true, false])("default replays version 1 cassettes in order despite body drift (stored hashes: %p)", async storedHashes => {
|
|
77
|
+
const h = await harness();
|
|
78
|
+
const c = cassette([
|
|
79
|
+
interaction('{"projectId":"old","turn":1}', "first"),
|
|
80
|
+
interaction('{"projectId":"old","turn":2}', "second"),
|
|
81
|
+
]);
|
|
82
|
+
for (const it of c.interactions) {
|
|
83
|
+
if (storedHashes) it.request.body = "<REDACTED>";
|
|
84
|
+
else { delete it.request.bodyHash; delete it.request.bodyEncoding; }
|
|
85
|
+
}
|
|
86
|
+
h.helpers.replay(JSON.parse(JSON.stringify(c)));
|
|
87
|
+
expect(await (await h.send('{"projectId":"new","turn":1}')).text()).toBe("first");
|
|
88
|
+
expect(await (await h.send('{"projectId":"new","turn":2}')).text()).toBe("second");
|
|
89
|
+
const exhausted = await h.send("another body");
|
|
90
|
+
expect(exhausted.status).toBe(599);
|
|
91
|
+
expect(await exhausted.text()).toContain("exhausted key");
|
|
92
|
+
h.helpers.replay(c);
|
|
93
|
+
expect(await (await h.send("yet another body")).text()).toBe("first");
|
|
70
94
|
});
|
|
71
95
|
|
|
72
|
-
test("the existing committed HTTP cassette still replays with the
|
|
96
|
+
test("the existing committed HTTP cassette still replays with the default", async () => {
|
|
73
97
|
const h = await harness();
|
|
74
98
|
const existing = await Bun.file(new URL("../../../examples/replay-fake/spectest/tests/recordings/httpbin.json", import.meta.url)).json() as Cassette;
|
|
75
99
|
h.helpers.replay(existing);
|
|
@@ -134,7 +158,7 @@ describe("replay request identity", () => {
|
|
|
134
158
|
});
|
|
135
159
|
|
|
136
160
|
test("empty and BOM-prefixed bodies preserve their bytes", async () => {
|
|
137
|
-
for (const key of [
|
|
161
|
+
for (const key of [exactBodyKey, (r: ReplayRequest) => r.bytes()]) {
|
|
138
162
|
const h = await harness({ key });
|
|
139
163
|
h.helpers.replay(cassette([interaction("", "empty"), interaction("\ufeffhello", "bom")]));
|
|
140
164
|
expect(await (await h.send("")).text()).toBe("empty");
|
|
@@ -144,7 +168,7 @@ describe("replay request identity", () => {
|
|
|
144
168
|
});
|
|
145
169
|
|
|
146
170
|
test("same-key queues consume once, fail on exhaustion, and reset on reload", async () => {
|
|
147
|
-
const h = await harness();
|
|
171
|
+
const h = await harness({ key: exactBodyKey });
|
|
148
172
|
const c = cassette([interaction("same", "first"), interaction("other", "other"), interaction("same", "second")]);
|
|
149
173
|
h.helpers.replay(c);
|
|
150
174
|
expect(await Promise.all([h.send("same"), h.send("same")].map(async p => (await p).text()))).toEqual(["first", "second"]);
|
|
@@ -161,8 +161,10 @@ export interface ReplayFakeOptions<
|
|
|
161
161
|
* resolved via an external DNS server — see the module header). Your
|
|
162
162
|
* app points at this real host directly; no separate stand-in. */
|
|
163
163
|
host: string;
|
|
164
|
-
/** Complete request identity. Default: method, path, normalized query
|
|
165
|
-
* and
|
|
164
|
+
/** Complete request identity. Default: method, path, and normalized query;
|
|
165
|
+
* bodies and headers are ignored, and matching responses replay in cassette
|
|
166
|
+
* order. Include body content in a custom key to distinguish concurrent
|
|
167
|
+
* calls to the same endpoint. The same synchronous, deterministic callback runs
|
|
166
168
|
* on recorded and incoming requests. Return JSON values and/or Uint8Array
|
|
167
169
|
* bytes; objects compare independently of property order. No implicit
|
|
168
170
|
* matching dimensions or fallback apply when a key is supplied. */
|
|
@@ -8,7 +8,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { execFileSync } from "node:child_process";
|
|
11
|
-
import { forwardableHeaders, withIngressCors } from "./http-proxy";
|
|
11
|
+
import { forwardableHeaders, upstreamRequestHeaders, withIngressCors } from "./http-proxy";
|
|
12
12
|
import { startIngressServer, type IngressServer } from "./ingress-server";
|
|
13
13
|
import { startTlsTerminator, type TlsTerminator } from "./tls-terminator";
|
|
14
14
|
|
|
@@ -24,8 +24,14 @@ let release: (() => void) | undefined;
|
|
|
24
24
|
let upstreamClosed: Promise<unknown>;
|
|
25
25
|
|
|
26
26
|
beforeAll(async () => {
|
|
27
|
-
upstream = http.createServer((req, res) => {
|
|
27
|
+
upstream = http.createServer(async (req, res) => {
|
|
28
28
|
const path = req.url!;
|
|
29
|
+
if (path === "/request") {
|
|
30
|
+
const body = await bytes(req);
|
|
31
|
+
res.setHeader("content-type", "application/json");
|
|
32
|
+
res.end(JSON.stringify({ method: req.method, headers: req.headers, body: body.toString() }));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
29
35
|
let body = path === "/8960" ? object.subarray(0, 8960) : path === "/gzip" ? compressed : object;
|
|
30
36
|
res.setHeader("content-type", "application/octet-stream");
|
|
31
37
|
res.setHeader("etag", '"original"');
|
|
@@ -59,7 +65,8 @@ beforeAll(async () => {
|
|
|
59
65
|
port: 0, hostname: "127.0.0.1",
|
|
60
66
|
fetch: req => withIngressCors(req, "permissive", async current => {
|
|
61
67
|
const res = await fetch(`http://127.0.0.1:${upstreamPort}${new URL(current.url).pathname}`, {
|
|
62
|
-
method: current.method, headers: current.headers,
|
|
68
|
+
method: current.method, headers: upstreamRequestHeaders(current.headers, { proto: "http" }),
|
|
69
|
+
body: current.body, signal: current.signal,
|
|
63
70
|
decompress: false, keepalive: false,
|
|
64
71
|
});
|
|
65
72
|
return new Response(res.body, {
|
|
@@ -83,7 +90,7 @@ afterAll(() => {
|
|
|
83
90
|
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
84
91
|
});
|
|
85
92
|
|
|
86
|
-
function request(path: string, options: { boundary?: "storage" | "http" | "https"; method?: string; headers?: Record<string, string
|
|
93
|
+
function request(path: string, options: { boundary?: "storage" | "http" | "https"; method?: string; headers?: Record<string, string>; body?: string } = {}) {
|
|
87
94
|
const boundary = options.boundary ?? "https";
|
|
88
95
|
return new Promise<http.IncomingMessage>((resolve, reject) => {
|
|
89
96
|
const req = (boundary === "https" ? https : http).request({
|
|
@@ -93,7 +100,7 @@ function request(path: string, options: { boundary?: "storage" | "http" | "https
|
|
|
93
100
|
}, resolve);
|
|
94
101
|
req.once("error", reject);
|
|
95
102
|
req.setTimeout(2000, () => req.destroy(new Error("ingress request timed out")));
|
|
96
|
-
req.end();
|
|
103
|
+
req.end(options.body);
|
|
97
104
|
});
|
|
98
105
|
}
|
|
99
106
|
|
|
@@ -180,6 +187,51 @@ describe("ingress response framing", () => {
|
|
|
180
187
|
}
|
|
181
188
|
});
|
|
182
189
|
|
|
190
|
+
describe("ingress request framing", () => {
|
|
191
|
+
for (const boundary of ["storage", "http", "https"] as const) {
|
|
192
|
+
test(`${boundary}: unframed requests do not acquire a chunked body`, async () => {
|
|
193
|
+
// fetch may send Content-Length: 0 and conceal this regression. Use
|
|
194
|
+
// node:http methods that send neither length nor transfer encoding.
|
|
195
|
+
for (const method of ["DELETE", "OPTIONS"]) {
|
|
196
|
+
const res = await request("/request", { boundary, method });
|
|
197
|
+
const received = JSON.parse((await bytes(res)).toString());
|
|
198
|
+
expect(res.statusCode).toBe(200);
|
|
199
|
+
expect(received.method).toBe(method);
|
|
200
|
+
// Bun fetch can normalize an absent body to Content-Length: 0.
|
|
201
|
+
// The direct boundary proves the client itself sent no framing.
|
|
202
|
+
if (boundary === "storage") expect(received.headers["content-length"]).toBeUndefined();
|
|
203
|
+
else expect(received.headers["content-length"] ?? "0").toBe("0");
|
|
204
|
+
expect(received.headers["transfer-encoding"]).toBeUndefined();
|
|
205
|
+
expect(received.body).toBe("");
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test(`${boundary}: explicit zero length stays empty`, async () => {
|
|
210
|
+
for (const method of ["DELETE", "OPTIONS", "POST", "PUT", "PATCH"]) {
|
|
211
|
+
const res = await request("/request", { boundary, method, headers: { "content-length": "0" } });
|
|
212
|
+
const received = JSON.parse((await bytes(res)).toString());
|
|
213
|
+
expect(received.headers["content-length"]).toBe("0");
|
|
214
|
+
expect(received.headers["transfer-encoding"]).toBeUndefined();
|
|
215
|
+
expect(received.body).toBe("");
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test(`${boundary}: framed bodies survive, including empty chunked requests`, async () => {
|
|
220
|
+
for (const body of ["", "request payload"]) {
|
|
221
|
+
for (const chunked of [false, true]) {
|
|
222
|
+
const res = await request("/request", { boundary, method: "DELETE", body,
|
|
223
|
+
headers: chunked ? { "transfer-encoding": "chunked" } : { "content-length": String(Buffer.byteLength(body)) },
|
|
224
|
+
});
|
|
225
|
+
const received = JSON.parse((await bytes(res)).toString());
|
|
226
|
+
expect(received.headers["content-length"]).toBe(chunked ? undefined : String(Buffer.byteLength(body)));
|
|
227
|
+
expect(received.headers["transfer-encoding"]).toBe(chunked ? "chunked" : undefined);
|
|
228
|
+
expect(received.body).toBe(body);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
183
235
|
describe("ingress request lifecycle", () => {
|
|
184
236
|
test("streams uploads and preserves the request URL, headers and peer", async () => {
|
|
185
237
|
const server = await startIngressServer({
|
|
@@ -131,9 +131,15 @@ export async function startIngressServer(options: {
|
|
|
131
131
|
headers.append(incoming.rawHeaders[i], incoming.rawHeaders[i + 1]);
|
|
132
132
|
}
|
|
133
133
|
const method = incoming.method ?? "GET";
|
|
134
|
+
// An HTTP/1 request without framing has no body. Giving it even an
|
|
135
|
+
// empty stream makes fetch invent chunked framing upstream (e.g. a
|
|
136
|
+
// bodyless DELETE then fails Fastify's content-type validation).
|
|
137
|
+
// Keep explicitly chunked bodies streaming, including empty ones.
|
|
138
|
+
const hasBody = method !== "GET" && method !== "HEAD"
|
|
139
|
+
&& (headers.has("transfer-encoding") || Number(headers.get("content-length")) > 0);
|
|
134
140
|
const req = new Request(new URL(incoming.url ?? "/", `http://${headers.get("host") ?? "localhost"}`), {
|
|
135
141
|
method, headers, signal: abort.signal,
|
|
136
|
-
body:
|
|
142
|
+
body: hasBody ? Readable.toWeb(incoming) as unknown as ReadableStream<Uint8Array> : undefined,
|
|
137
143
|
duplex: "half",
|
|
138
144
|
} as RequestInit);
|
|
139
145
|
peers.set(req, incoming.socket);
|