@specific.dev/spectest 0.88.3 → 0.89.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,202 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { expect as assertValue } from "./index.js";
3
+ import { readRaw, readTag, wrap, wrapResponse } from "./inspect.js";
4
+ import { recordHttp, startRecording, stopRecording, type AssertionEvent } from "./recorder.js";
5
+
6
+ let seq: number;
7
+ beforeEach(() => {
8
+ startRecording();
9
+ seq = recordHttp({ method: "GET", url: "http://test/", status: 200, durationMs: 0 })!;
10
+ });
11
+ afterEach(() => { stopRecording(); });
12
+
13
+ function paths() {
14
+ const assertions = stopRecording().filter((e): e is AssertionEvent => e.kind === "assertion");
15
+ for (const assertion of assertions) expect(assertion.sourceSeq).toBe(seq);
16
+ return assertions.map((e) => e.path);
17
+ }
18
+
19
+ describe("fetch body provenance", () => {
20
+ test("bytes retain indexes, sizes, derived values and native escape hatches", async () => {
21
+ const raw = new Uint8Array([0x89, 0x50, 0, 0xff]);
22
+ const res = wrapResponse(new Response(raw), seq);
23
+ assertValue(res.bodyUsed).toBe(false);
24
+ const bytes = await res.bytes();
25
+ assertValue(res.bodyUsed).toBe(true);
26
+ assertValue(bytes).toEqual(raw);
27
+ assertValue(bytes).toHaveLength(4);
28
+ assertValue(bytes.byteLength).toBe(4);
29
+ assertValue(bytes[0]).toBe(0x89);
30
+ assertValue(bytes.slice(1, 3)).toEqual(new Uint8Array([0x50, 0]));
31
+ assertValue(bytes.subarray(1).at(0)).toBe(0x50);
32
+ assertValue(bytes.includes(0xff)).toBe(true);
33
+ assertValue(bytes.filter((v) => v === 0)).toHaveLength(1);
34
+ assertValue(bytes.map((v) => v + 1)[2]).toBe(1);
35
+ assertValue(bytes.at(99)).toBe(undefined);
36
+ expect(bytes.length).toBe(4);
37
+ expect([...bytes]).toEqual([...raw]);
38
+ expect(bytes.unwrap()).toBeInstanceOf(Uint8Array);
39
+ expect(readRaw<unknown>(bytes)).toBe(bytes.unwrap());
40
+ expect(new TextDecoder().decode(bytes.unwrap())).toBe(new TextDecoder().decode(raw));
41
+ expect(paths()).toContainEqual(["body", "<subarray>", "<at>"]);
42
+ });
43
+
44
+ test("array buffers and derived DataViews retain provenance", async () => {
45
+ const res = wrapResponse(new Response(new Uint8Array([1, 2, 3])), seq);
46
+ const buffer = await res.arrayBuffer();
47
+ assertValue(buffer.byteLength).toBe(3);
48
+ assertValue(buffer.slice(1).byteLength).toBe(2);
49
+ const bytes = buffer.transform("bytes", (b) => new Uint8Array(b));
50
+ assertValue(bytes.buffer.byteLength).toBe(3);
51
+ const view = buffer.transform("view", (b) => new DataView(b));
52
+ assertValue(view.getUint16(0)).toBe(258);
53
+ expect(new Uint8Array(buffer.unwrap())).toEqual(new Uint8Array([1, 2, 3]));
54
+ expect(paths()).toEqual([
55
+ ["body", "byteLength"], ["body", "<slice>", "byteLength"],
56
+ ["body", "<bytes>", "buffer", "byteLength"], ["body", "<view>", "<getUint16>"],
57
+ ]);
58
+ });
59
+
60
+ test("blobs wrap metadata, slices, decoded bodies and streams", async () => {
61
+ const res = wrapResponse(new Response("hello", { headers: { "content-type": "text/plain" } }), seq);
62
+ const blob = await res.blob();
63
+ assertValue(blob.size).toBe(5);
64
+ assertValue(blob.type).toBe(blob.unwrap().type);
65
+ assertValue(await blob.slice(1, 4).text()).toBe("ell");
66
+ assertValue((await blob.bytes())[0]).toBe(104);
67
+ assertValue((await blob.arrayBuffer()).byteLength).toBe(5);
68
+ const reader = blob.stream().getReader();
69
+ try {
70
+ const chunk = await reader.read();
71
+ expect(chunk.done).toBe(false);
72
+ assertValue(chunk.value).toEqual(new TextEncoder().encode("hello"));
73
+ } finally { await reader.cancel(); reader.releaseLock(); }
74
+ expect(blob.unwrap()).toBeInstanceOf(Blob);
75
+ expect(paths()).toContainEqual(["body", "<stream>", "<read>", "0"]);
76
+ });
77
+
78
+ test("form data wraps missing and repeated fields and uploaded files", async () => {
79
+ const form = new FormData();
80
+ form.append("tag", "one");
81
+ form.append("tag", "two");
82
+ form.append("file", new File(["hello"], "hello.txt", { type: "text/plain", lastModified: 123 }));
83
+ const res = wrapResponse(new Response(form), seq);
84
+ const parsed = await res.formData();
85
+ assertValue(parsed.get("tag")).toBe("one");
86
+ assertValue(parsed.getAll("tag")).toEqual(["one", "two"]);
87
+ assertValue(parsed.getAll("tag")[1]).toBe("two");
88
+ assertValue(parsed.has("tag")).toBe(true);
89
+ assertValue(parsed.has("missing")).toBe(false);
90
+ assertValue(parsed.get("missing")).toBe(null);
91
+ const file = parsed.get("file");
92
+ if (!file || !("name" in file)) throw new Error("Expected a file");
93
+ assertValue(file.name).toBe("hello.txt");
94
+ assertValue(file.type).toBe(file.unwrap().type);
95
+ assertValue(file.size).toBe(5);
96
+ assertValue(file.lastModified).toBe(file.unwrap().lastModified);
97
+ assertValue(await file.text()).toBe("hello");
98
+ assertValue((await file.bytes())[0]).toBe(104);
99
+ expect([...parsed][0]).toEqual(["tag", "one"]);
100
+ expect(parsed.unwrap()).toBeInstanceOf(FormData);
101
+ expect(paths()).toContainEqual(["body", "file", "<text>"]);
102
+ });
103
+
104
+ test("top-level JSON null and absent bodies retain provenance", async () => {
105
+ const json = wrapResponse(new Response("null"), seq);
106
+ assertValue(await json.json<null>()).toBe(null);
107
+ const empty = wrapResponse(new Response(null, { status: 204 }), seq);
108
+ assertValue(empty.body).toBe(null);
109
+ expect(paths()).toEqual([["body"], ["body"]]);
110
+ });
111
+
112
+ test("Bun header helpers are wrapped and serialization stays native", () => {
113
+ const res = wrapResponse(new Response(null, { headers: [
114
+ ["x-test", "yes"], ["set-cookie", "a=1"], ["set-cookie", "b=2"],
115
+ ] }), seq);
116
+ assertValue(res.headers.getAll("Set-Cookie")).toEqual(["a=1", "b=2"]);
117
+ assertValue(res.headers.toJSON()["x-test"]).toBe("yes");
118
+ assertValue(res.headers.count).toBe(res.unwrap().headers.count);
119
+ expect(JSON.stringify(res.headers)).toBe(JSON.stringify(res.unwrap().headers));
120
+ expect(paths()).toEqual([["headers", "set-cookie"], ["headers", "x-test"], ["headers", "count"]]);
121
+ });
122
+
123
+ test("reader values are wrapped but done and locked remain control-flow booleans", async () => {
124
+ const res = wrapResponse(new Response("hello"), seq);
125
+ const body = res.body!;
126
+ expect(res.body).toBe(body);
127
+ const reader = body.getReader();
128
+ expect(body.locked).toBe(true);
129
+ expect(readRaw<unknown>(reader)).toBe(reader.unwrap());
130
+ let chunks = 0;
131
+ while (true) {
132
+ const { done, value } = await reader.read();
133
+ if (done) break;
134
+ assertValue(value.byteLength).toBe(5);
135
+ chunks++;
136
+ }
137
+ expect(chunks).toBe(1);
138
+ reader.releaseLock();
139
+ expect(body.locked).toBe(false);
140
+ expect(paths()).toEqual([["body", "<read>", "0", "byteLength"]]);
141
+ });
142
+
143
+ test("BYOB readers wrap the returned view while preserving buffer semantics", async () => {
144
+ const stream = new ReadableStream<Uint8Array>({
145
+ type: "bytes",
146
+ pull(controller) {
147
+ controller.enqueue(new Uint8Array([1, 2, 3]));
148
+ controller.close();
149
+ },
150
+ });
151
+ const res = wrapResponse(new Response(stream), seq);
152
+ const reader = res.body!.getReader({ mode: "byob" });
153
+ const chunk = await reader.read(new Uint8Array(8));
154
+ expect(chunk.done).toBe(false);
155
+ assertValue(chunk.value).toEqual(new Uint8Array([1, 2, 3]));
156
+ expect((await reader.read(new Uint8Array(8))).done).toBe(true);
157
+ reader.releaseLock();
158
+ expect(paths()).toEqual([["body", "<read>", "0"]]);
159
+ });
160
+
161
+ test("tee and pipeThrough preserve provenance and pipeTo consumes native chunks", async () => {
162
+ const res = wrapResponse(new Response("hi"), seq);
163
+ const [left, right] = res.body!.tee();
164
+ const decoded = left.pipeThrough(new TextDecoderStream());
165
+ const reader = decoded.getReader();
166
+ assertValue((await reader.read()).value).toBe("hi");
167
+ reader.releaseLock();
168
+ const chunks: Uint8Array[] = [];
169
+ await right.pipeTo(new WritableStream({ write(chunk) { chunks.push(chunk); } }));
170
+ expect(chunks).toEqual([new TextEncoder().encode("hi")]);
171
+ expect(paths()).toEqual([["body", "<tee>", "0", "<pipeThrough>", "<read>", "0"]]);
172
+ });
173
+
174
+ test("native reader errors and body consumption errors propagate", async () => {
175
+ const res = wrapResponse(new Response("once"), seq);
176
+ await res.bytes();
177
+ await expect(res.text()).rejects.toThrow();
178
+ const failure = new Error("stream failed");
179
+ const broken = wrapResponse(new Response(new ReadableStream({ start(c) { c.error(failure); } })), seq);
180
+ const reader = broken.body!.getReader();
181
+ await expect(reader.read()).rejects.toBe(failure);
182
+ reader.releaseLock();
183
+ });
184
+
185
+ test("native body wrappers are available without a recorded event", async () => {
186
+ const bytes = await wrapResponse(new Response("hi"), undefined).bytes();
187
+ expect(bytes.byteLength.unwrap()).toBe(2);
188
+ expect(readTag(bytes[0])).toEqual({ sourceSeq: undefined, path: ["body", "0"] });
189
+ });
190
+
191
+ test("retagging native values unwraps fully and replaces their provenance", async () => {
192
+ const bytes = await wrapResponse(new Response("hi"), seq).bytes();
193
+ const retagged = wrap(bytes, 99, ["poll"]);
194
+ expect(retagged.unwrap()).toBe(bytes.unwrap());
195
+ expect(readTag(retagged.byteLength)).toEqual({ sourceSeq: 99, path: ["poll", "byteLength"] });
196
+ expect(retagged.byteLength.unwrap()).toBe(2);
197
+ const blob = await wrapResponse(new Response("hi"), seq).blob();
198
+ const retaggedBlob = wrap(blob, 99, ["poll"]);
199
+ expect(retaggedBlob.unwrap()).toBe(blob.unwrap());
200
+ expect((await retaggedBlob.text()).unwrap()).toBe("hi");
201
+ });
202
+ });
package/src/inspect.ts CHANGED
@@ -240,6 +240,20 @@ export function wrap<T>(
240
240
  if (raw === null || raw === undefined) return raw;
241
241
  const t = typeof raw;
242
242
  if (t === "object") {
243
+ const native = readRaw(raw);
244
+ if (native instanceof ArrayBuffer || (typeof SharedArrayBuffer !== "undefined" && native instanceof SharedArrayBuffer)) {
245
+ return wrapNative(native, sourceSeq, path, new Set(["byteLength", "maxByteLength", "resizable", "growable", "detached"]),
246
+ new Set(["slice", "transfer", "transferToFixedLength"])) as T;
247
+ }
248
+ if (ArrayBuffer.isView(native)) {
249
+ return wrapNative(native, sourceSeq, path, new Set(["buffer", "byteLength", "byteOffset"]), BINARY_METHODS, true) as T;
250
+ }
251
+ if (native instanceof Blob) {
252
+ return wrapNative(native, sourceSeq, path, new Set(["size", "type", "name", "lastModified", "webkitRelativePath"]),
253
+ new Set(["slice", "text", "bytes", "arrayBuffer", "stream"])) as T;
254
+ }
255
+ if (native instanceof FormData) return wrapFormData(native, sourceSeq, path) as T;
256
+ if (native instanceof ReadableStream) return wrapStream(native, sourceSeq, path) as T;
243
257
  return wrapObject(raw as object, sourceSeq, path) as unknown as T;
244
258
  }
245
259
  if (t === "function") return raw;
@@ -578,12 +592,23 @@ export type Wrapped<T> = T extends null | undefined
578
592
  ? T
579
593
  : T extends readonly (infer U)[]
580
594
  ? WrappedArray<U>
581
- : // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
582
- T extends (...args: never[]) => unknown
583
- ? T
584
- : T extends object
585
- ? WrappedObject<T>
586
- : Carrier<T>;
595
+ : T extends ArrayBuffer | SharedArrayBuffer
596
+ ? WrappedBuffer<T>
597
+ : T extends ArrayBufferView
598
+ ? WrappedBinaryView<T>
599
+ : T extends File
600
+ ? WrappedFile
601
+ : T extends Blob
602
+ ? WrappedBlob
603
+ : T extends FormData
604
+ ? WrappedFormData
605
+ : T extends ReadableStream<infer C>
606
+ ? WrappedStream<C>
607
+ : T extends (...args: never[]) => unknown
608
+ ? T
609
+ : T extends object
610
+ ? WrappedObject<T>
611
+ : Carrier<T>;
587
612
 
588
613
  /** A wrapped object: every own property is itself {@link Wrapped}, plus an
589
614
  * `.unwrap()` that recovers the fully-raw value (all nested leaves raw). */
@@ -645,6 +670,188 @@ export interface WrappedArray<U> {
645
670
  [Symbol.iterator](): IterableIterator<U>;
646
671
  }
647
672
 
673
+ /** Native objects need their own views: getters and methods must receive the
674
+ * real native receiver, and method results must be wrapped after they resolve.
675
+ * Iteration, callbacks, and control-flow flags on stream readers remain raw.
676
+ */
677
+ type NativeResult<T> = T extends Promise<infer R> ? Promise<Wrapped<R>> : Wrapped<T>;
678
+ type NativeView<T, K extends keyof T> = Omit<T, K> & {
679
+ readonly [P in K]: T[P] extends (...args: infer A) => infer R
680
+ ? (...args: A) => NativeResult<R> : Wrapped<T[P]>;
681
+ } & {
682
+ unwrap(): T;
683
+ transform<R>(label: string, fn: (raw: T) => R): Wrapped<R>;
684
+ };
685
+
686
+ export type WrappedBuffer<T extends ArrayBuffer | SharedArrayBuffer = ArrayBuffer> = NativeView<T,
687
+ Extract<keyof T, "byteLength" | "maxByteLength" | "resizable" | "growable" | "detached" |
688
+ "slice" | "transfer" | "transferToFixedLength">>;
689
+
690
+ const BINARY_METHOD_NAMES = [
691
+ "at", "slice", "subarray", "includes", "indexOf", "lastIndexOf", "find", "findLast",
692
+ "findIndex", "findLastIndex", "some", "every", "filter", "map", "join",
693
+ "fill", "copyWithin", "reverse", "sort", "toReversed", "toSorted", "with", "toHex", "toBase64",
694
+ "getInt8", "getUint8", "getInt16", "getUint16", "getInt32", "getUint32",
695
+ "getFloat16", "getFloat32", "getFloat64", "getBigInt64", "getBigUint64",
696
+ ] as const;
697
+ const BINARY_METHODS = new Set<string>(BINARY_METHOD_NAMES);
698
+ /** Numeric indexes carry provenance. Like WrappedArray, length and iteration
699
+ * stay raw; callbacks receive native values. Unwrap for native API inputs. */
700
+ export type WrappedBinaryView<T extends ArrayBufferView> = NativeView<T,
701
+ Extract<keyof T, number | "buffer" | "byteLength" | "byteOffset" | typeof BINARY_METHOD_NAMES[number]>>;
702
+ export type WrappedBytes = WrappedBinaryView<Uint8Array>;
703
+ export type WrappedBlob = NativeView<Blob,
704
+ "size" | "type" | "slice" | "text" | "bytes" | "arrayBuffer" | "stream">;
705
+ export type WrappedFile = NativeView<File,
706
+ "name" | "lastModified" | "webkitRelativePath" | "size" | "type" |
707
+ "slice" | "text" | "bytes" | "arrayBuffer" | "stream">;
708
+ export type WrappedFormData = NativeView<FormData, "get" | "getAll" | "has">;
709
+
710
+ export type WrappedReadResult<T> =
711
+ | { done: false; value: Wrapped<T> }
712
+ | { done: true; value: Wrapped<T> | undefined };
713
+ export interface WrappedReader<T> extends Omit<ReadableStreamDefaultReader<T>, "read"> {
714
+ read(): Promise<WrappedReadResult<T>>;
715
+ unwrap(): ReadableStreamDefaultReader<T>;
716
+ }
717
+ export interface WrappedBYOBReader extends Omit<ReadableStreamBYOBReader, "read"> {
718
+ read<T extends ArrayBufferView>(view: T, options?: { min?: number }): Promise<WrappedReadResult<T>>;
719
+ unwrap(): ReadableStreamBYOBReader;
720
+ }
721
+ export interface WrappedStream<T> extends Omit<ReadableStream<T>, "getReader" | "tee" | "pipeThrough"> {
722
+ getReader(): WrappedReader<T>;
723
+ getReader(options: { mode: "byob" }): WrappedBYOBReader;
724
+ getReader(options: ReadableStreamGetReaderOptions): WrappedReader<T> | WrappedBYOBReader;
725
+ tee(): [WrappedStream<T>, WrappedStream<T>];
726
+ pipeThrough<R>(transform: ReadableWritablePair<R, any>, options?: StreamPipeOptions): WrappedStream<R>;
727
+ unwrap(): ReadableStream<T>;
728
+ transform<R>(label: string, fn: (raw: ReadableStream<T>) => R): Wrapped<R>;
729
+ }
730
+
731
+ function wrapNative<T extends object>(
732
+ raw: T, sourceSeq: number | undefined, path: readonly string[],
733
+ properties: ReadonlySet<string>, methods: ReadonlySet<string>, indexes = false,
734
+ ): T {
735
+ const tag: OpTag = { sourceSeq, path };
736
+ return new Proxy(raw, {
737
+ get(target, prop) {
738
+ if (prop === OP_TAG) return tag;
739
+ if (prop === UNWRAP) return target;
740
+ if (prop === "unwrap") return () => target;
741
+ if (prop === "transform") return makeTransform(target, sourceSeq, path);
742
+ const value = Reflect.get(target, prop, target);
743
+ if (typeof prop === "string") {
744
+ if (properties.has(prop) || (indexes && /^(0|[1-9]\d*)$/.test(prop))) {
745
+ return wrapChild(value, sourceSeq, [...path, prop]);
746
+ }
747
+ if (methods.has(prop) && typeof value === "function") {
748
+ return (...args: unknown[]) => {
749
+ const result = value.apply(target, args);
750
+ const resultPath = [...path, `<${prop}>`];
751
+ return result instanceof Promise
752
+ ? result.then((v) => wrapChild(v, sourceSeq, resultPath))
753
+ : wrapChild(result, sourceSeq, resultPath);
754
+ };
755
+ }
756
+ }
757
+ return typeof value === "function" ? value.bind(target) : value;
758
+ },
759
+ has(target, prop) {
760
+ return prop === OP_TAG || prop === UNWRAP || Reflect.has(target, prop);
761
+ },
762
+ });
763
+ }
764
+
765
+ function wrapFormData(raw: FormData, sourceSeq: number | undefined, path: readonly string[]): FormData {
766
+ const base = wrapNative(raw, sourceSeq, path, new Set(), new Set());
767
+ return new Proxy(base, {
768
+ get(target, prop) {
769
+ if (prop === "get" || prop === "getAll" || prop === "has") {
770
+ return (name: string) => wrapChild(raw[prop](name), sourceSeq,
771
+ prop === "get" ? [...path, name] : [...path, name, `<${prop}>`]);
772
+ }
773
+ return Reflect.get(target, prop);
774
+ },
775
+ });
776
+ }
777
+
778
+ function wrapStream<T>(raw: ReadableStream<T>, sourceSeq: number | undefined, path: readonly string[]): ReadableStream<T> {
779
+ const base = wrapNative(raw, sourceSeq, path, new Set(), new Set(["pipeThrough"]));
780
+ return new Proxy(base, {
781
+ get(target, prop) {
782
+ if (prop === "tee") return () => raw.tee().map((s, i) => wrapStream(s, sourceSeq, [...path, `<tee>`, String(i)]));
783
+ if (prop === "getReader") return (options?: ReadableStreamGetReaderOptions) => {
784
+ const reader = raw.getReader(options!);
785
+ let index = 0;
786
+ return new Proxy(reader, {
787
+ get(r, key) {
788
+ if (key === "unwrap" || key === UNWRAP) return key === "unwrap" ? () => r : r;
789
+ if (key === OP_TAG) return { sourceSeq, path };
790
+ if (key === "read") return async (...args: unknown[]) => {
791
+ const read = r.read as (...args: unknown[]) => Promise<ReadableStreamReadResult<T>>;
792
+ const result = await read.apply(r, args);
793
+ const valuePath = [...path, "<read>", String(index++)];
794
+ // done stays a real boolean so normal reader loops terminate.
795
+ return { done: result.done, get value() { return wrapChild(result.value, sourceSeq, valuePath); } };
796
+ };
797
+ const value = Reflect.get(r, key, r);
798
+ return typeof value === "function" ? value.bind(r) : value;
799
+ },
800
+ has(r, key) { return key === UNWRAP || key === OP_TAG || Reflect.has(r, key); },
801
+ });
802
+ };
803
+ return Reflect.get(target, prop);
804
+ },
805
+ });
806
+ }
807
+
808
+ /** Response headers whose lookups retain the originating HTTP call's provenance.
809
+ * Missing headers remain `null`; `expect(headers.get(name)).toBe(null)` still
810
+ * links to the call. Iterators and other native methods return raw values.
811
+ */
812
+ export interface WrappedHeaders extends Omit<Headers, "get" | "has" | "getSetCookie" | "getAll" | "toJSON" | "count"> {
813
+ get(name: string): Carrier<string> | null;
814
+ /** Unwrap before using the result as a condition. */
815
+ has(name: string): Carrier<boolean>;
816
+ getSetCookie(): WrappedArray<string>;
817
+ getAll(name: "set-cookie" | "Set-Cookie"): WrappedArray<string>;
818
+ toJSON(): Wrapped<Record<string, string | string[]>>;
819
+ readonly count: Carrier<number>;
820
+ unwrap(): Headers;
821
+ transform<R = unknown>(label: string, fn: (raw: Headers) => R): Wrapped<R>;
822
+ }
823
+
824
+ function wrapHeaders(headers: Headers, sourceSeq: number | undefined): WrappedHeaders {
825
+ const path = ["headers"];
826
+ const tag: OpTag = { sourceSeq, path };
827
+ return new Proxy(headers, {
828
+ get(target, prop) {
829
+ if (prop === OP_TAG) return tag;
830
+ if (prop === UNWRAP) return target;
831
+ if (prop === "unwrap") return () => target;
832
+ if (prop === "transform") return makeTransform(target, sourceSeq, path);
833
+ if (prop === "get" || prop === "has") {
834
+ return (name: string) => wrapChild(
835
+ target[prop](name), sourceSeq,
836
+ prop === "get" ? [...path, name.toLowerCase()] : [...path, name.toLowerCase(), "<has>"],
837
+ );
838
+ }
839
+ if (prop === "count") return wrap(target.count, sourceSeq, [...path, "count"]);
840
+ if (prop === "getAll") return (name: "set-cookie" | "Set-Cookie") => wrap(target.getAll(name), sourceSeq, [...path, name.toLowerCase()]);
841
+ if (prop === "toJSON") return () => wrap(target.toJSON(), sourceSeq, path);
842
+ if (prop === "getSetCookie") {
843
+ return () => wrap(target.getSetCookie(), sourceSeq, [...path, "set-cookie"]);
844
+ }
845
+ const value = Reflect.get(target, prop, target);
846
+ return typeof value === "function" ? value.bind(target) : value;
847
+ },
848
+ has(target, prop) {
849
+ if (prop === OP_TAG || prop === UNWRAP) return true;
850
+ return Reflect.has(target, prop);
851
+ },
852
+ }) as unknown as WrappedHeaders;
853
+ }
854
+
648
855
  /**
649
856
  * The view `ctx.fetch` resolves to in every context (a spectest op wraps
650
857
  * unconditionally): a {@link Response} whose status-line accessors are
@@ -653,8 +860,9 @@ export interface WrappedArray<U> {
653
860
  * used to silently always be false. Compare `res.status.unwrap() === 200`
654
861
  * or `res.unwrap().status === 200`, or assert with `expect(res.status)`.
655
862
  *
656
- * `json<T>()` / `text()` return {@link Wrapped} body values; `.unwrap()`
657
- * (or {@link unwrap the whole response}) recovers the plain `Response`.
863
+ * All body readers return provenance-carrying values, including binary bodies,
864
+ * blobs and form data. `.unwrap()` recovers the native response or body value.
865
+ * Stream reader `done` and stream `locked` remain raw control-flow booleans.
658
866
  */
659
867
  export interface WrappedResponse {
660
868
  readonly status: Carrier<number>;
@@ -663,13 +871,15 @@ export interface WrappedResponse {
663
871
  readonly url: Carrier<string>;
664
872
  readonly redirected: Carrier<boolean>;
665
873
  readonly type: Carrier<string>;
666
- readonly headers: Headers;
667
- readonly bodyUsed: boolean;
874
+ readonly headers: WrappedHeaders;
875
+ readonly bodyUsed: Carrier<boolean>;
876
+ readonly body: WrappedStream<Uint8Array> | null;
668
877
  json<T = unknown>(): Promise<Wrapped<T>>;
669
878
  text(): Promise<Carrier<string>>;
670
- arrayBuffer(): Promise<ArrayBuffer>;
671
- blob(): Promise<Blob>;
672
- formData(): Promise<FormData>;
879
+ arrayBuffer(): Promise<WrappedBuffer>;
880
+ bytes(): Promise<WrappedBytes>;
881
+ blob(): Promise<WrappedBlob>;
882
+ formData(): Promise<WrappedFormData>;
673
883
  clone(): WrappedResponse;
674
884
  /** Recover the underlying raw {@link Response} (a real `number` status,
675
885
  * an unwrapped body, etc.). */
@@ -680,18 +890,27 @@ export interface WrappedResponse {
680
890
  * {@link WrappedResponse} so reads carry provenance into assertions. */
681
891
  export type SpectestFetch = (
682
892
  input: RequestInfo | URL,
683
- init?: RequestInit,
893
+ init?: SpectestRequestInit,
684
894
  ) => Promise<WrappedResponse>;
685
895
 
896
+ export interface SpectestRequestInit extends RequestInit {
897
+ /** Skip the recorder's body capture for open-ended streams. SSE skips it
898
+ * automatically. The response and assertions remain recorded. */
899
+ captureBody?: boolean;
900
+ }
901
+
686
902
  /**
687
903
  * Bespoke wrapper for `fetch` responses. Reads on `status` / `ok` /
688
904
  * `statusText` / `url` / `redirected` / `type` return carriers tagged
689
- * to `sourceSeq`. The body-reading methods (`json`, `text`) return the
690
- * resolved value wrapped under `path: ["body"]`. Everything else passes
691
- * through bound to the real Response.
905
+ * to `sourceSeq`. Every body-reading method returns its resolved value
906
+ * wrapped under `path: ["body"]`. Header lookups carry paths
907
+ * under `headers`, and clones retain the same provenance. Everything else
908
+ * passes through bound to the real Response.
692
909
  */
693
910
  export function wrapResponse(res: Response, sourceSeq: number | undefined): WrappedResponse {
694
911
  const tag: OpTag = { sourceSeq, path: [] };
912
+ let headers: WrappedHeaders | undefined;
913
+ let body: ReadableStream<Uint8Array> | undefined;
695
914
  const carrierProps = new Set([
696
915
  "status",
697
916
  "ok",
@@ -699,8 +918,9 @@ export function wrapResponse(res: Response, sourceSeq: number | undefined): Wrap
699
918
  "url",
700
919
  "redirected",
701
920
  "type",
921
+ "bodyUsed",
702
922
  ]);
703
- const bodyMethods = new Set(["json", "text"]);
923
+ const bodyMethods = new Set(["json", "text", "bytes", "arrayBuffer", "blob", "formData"]);
704
924
  const handler: ProxyHandler<Response> = {
705
925
  get(target, prop) {
706
926
  if (prop === OP_TAG) return tag;
@@ -708,6 +928,12 @@ export function wrapResponse(res: Response, sourceSeq: number | undefined): Wrap
708
928
  // Full recursive unwrap (see the note in `wrapObject`).
709
929
  if (prop === "unwrap") return () => readRaw(target);
710
930
  if (prop === "then") return undefined;
931
+ if (prop === "headers") return headers ??= wrapHeaders(target.headers, sourceSeq);
932
+ if (prop === "body") {
933
+ if (target.body === null) return wrapChild(null, sourceSeq, ["body"]);
934
+ return body ??= wrapStream(target.body, sourceSeq, ["body"]);
935
+ }
936
+ if (prop === "clone") return () => wrapResponse(target.clone(), sourceSeq);
711
937
 
712
938
  if (typeof prop === "string" && carrierProps.has(prop)) {
713
939
  const value = (target as unknown as Record<string, unknown>)[prop];
@@ -720,7 +946,7 @@ export function wrapResponse(res: Response, sourceSeq: number | undefined): Wrap
720
946
  if (typeof fn !== "function") return fn;
721
947
  return async (...args: unknown[]) => {
722
948
  const value = await fn.apply(target, args);
723
- return wrap(value, sourceSeq, ["body"]);
949
+ return wrapChild(value, sourceSeq, ["body"]);
724
950
  };
725
951
  }
726
952
  const value = (target as unknown as Record<string | symbol, unknown>)[