@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 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 `replyTo` that names no earlier message of
118
- the same call draws nothing extra; the bubble is an ordinary message.
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
- // Existing cassettes retain the original byte hash even when their stored
93
- // text was redacted. Older/hand-written cassettes may only have the body.
94
- const body = request.bodyHash ?? `sha256:${createHash("sha256").update(bodyBytes(request)).digest("hex")}`;
95
- return hashKey({ method: request.method.toUpperCase(), path: request.path, query: request.query, body });
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 exact body bytes. The same synchronous, deterministic callback runs
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: method === "GET" || method === "HEAD" ? undefined : Readable.toWeb(incoming),
124
+ body: hasBody ? Readable.toWeb(incoming) : undefined,
119
125
  duplex: "half",
120
126
  });
121
127
  peers.set(req, incoming.socket);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.107.0",
3
+ "version": "0.107.2",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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
- // Existing cassettes retain the original byte hash even when their stored
124
- // text was redacted. Older/hand-written cassettes may only have the body.
125
- const body = request.bodyHash ?? `sha256:${createHash("sha256").update(bodyBytes(request)).digest("hex")}`;
126
- return hashKey({ method: request.method.toUpperCase(), path: request.path, query: request.query, body });
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 headers, redaction, and exact body bytes survive record → replay", async () => {
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("default matches 654 concurrent requests by body, independently of arrival order", async () => {
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/exact bytes, including old cassettes without hashes", async () => {
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":1}', "/messages?b=2&a=x&b=1")).text()).toBe("ok");
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 new default", async () => {
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 [undefined, (r: ReplayRequest) => r.bytes()]) {
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 exact body bytes. The same synchronous, deterministic callback runs
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, signal: current.signal,
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: method === "GET" || method === "HEAD" ? undefined : Readable.toWeb(incoming) as unknown as ReadableStream<Uint8Array>,
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);