@specific.dev/spectest 0.88.2 → 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.
- package/README.md +108 -0
- package/dist/components/electron-chrome.d.ts +1 -0
- package/dist/components/electron-chrome.js +47 -0
- package/dist/components/electron.d.ts +11 -1
- package/dist/components/electron.js +42 -10
- package/dist/components/emulate/entry.mjs +242 -0
- package/dist/harness/fetch.js +7 -3
- package/dist/index.d.ts +6 -2
- package/dist/inspect.d.ts +85 -12
- package/dist/inspect.js +154 -5
- package/package.json +1 -1
- package/src/components/electron-chrome.ts +48 -0
- package/src/components/electron.test.ts +84 -1
- package/src/components/electron.ts +54 -11
- package/src/harness/fetch.test.ts +108 -2
- package/src/harness/fetch.ts +8 -5
- package/src/index.ts +17 -1
- package/src/inspect-fetch.test.ts +202 -0
- package/src/inspect.ts +245 -19
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { expect as assertValue } from "../index.js";
|
|
3
|
-
import { type WrappedResponse } from "../inspect.js";
|
|
4
|
-
import { recordFake, startRecording, stopRecording } from "../recorder.js";
|
|
3
|
+
import { readRaw, readTag, type SpectestFetch, type WrappedResponse } from "../inspect.js";
|
|
4
|
+
import { recordFake, startRecording, stopRecording, type HttpEvent } from "../recorder.js";
|
|
5
5
|
import { installFetchWrapper, isTransportError } from "./fetch.js";
|
|
6
6
|
import {
|
|
7
7
|
bindInstrumentationScope,
|
|
@@ -65,6 +65,72 @@ describe("fetch execution scopes", () => {
|
|
|
65
65
|
expect(stopRecording()).toEqual([]);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
test("header assertions retain HTTP provenance, including missing headers and cookies", async () => {
|
|
69
|
+
handler = () => new Response("ok", { headers: [
|
|
70
|
+
["content-type", "text/plain"],
|
|
71
|
+
["set-cookie", "a=1; Path=/"],
|
|
72
|
+
["set-cookie", "b=2; Path=/"],
|
|
73
|
+
] });
|
|
74
|
+
await runInstrumented(scope, async () => {
|
|
75
|
+
const res = await fetch(url("/headers")) as unknown as WrappedResponse;
|
|
76
|
+
assertValue(res.headers.get("Content-Type")).toBe("text/plain");
|
|
77
|
+
assertValue(res.headers.get("x-missing")).toBe(null);
|
|
78
|
+
assertValue(res.headers.has("CONTENT-TYPE")).toBe(true);
|
|
79
|
+
assertValue(res.headers.has("x-missing")).toBe(false);
|
|
80
|
+
assertValue(res.headers.getSetCookie()).toEqual(["a=1; Path=/", "b=2; Path=/"]);
|
|
81
|
+
assertValue(res.headers.getSetCookie()[1]).toBe("b=2; Path=/");
|
|
82
|
+
});
|
|
83
|
+
const events = stopRecording();
|
|
84
|
+
expect(events.map((e) => e.kind)).toEqual(["http", ...Array(6).fill("assertion")]);
|
|
85
|
+
expect(events.slice(1)).toMatchObject([
|
|
86
|
+
{ sourceSeq: events[0]!.seq, path: ["headers", "content-type"] },
|
|
87
|
+
{ sourceSeq: events[0]!.seq, path: ["headers", "x-missing"] },
|
|
88
|
+
{ sourceSeq: events[0]!.seq, path: ["headers", "content-type", "<has>"] },
|
|
89
|
+
{ sourceSeq: events[0]!.seq, path: ["headers", "x-missing", "<has>"] },
|
|
90
|
+
{ sourceSeq: events[0]!.seq, path: ["headers", "set-cookie"] },
|
|
91
|
+
{ sourceSeq: events[0]!.seq, path: ["headers", "set-cookie", "1"] },
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("wrapped headers keep native iteration and expose raw headers for ordinary APIs", async () => {
|
|
96
|
+
await runInstrumented(scope, async () => {
|
|
97
|
+
const res = await fetch(url("/headers")) as unknown as WrappedResponse;
|
|
98
|
+
const headers = res.headers;
|
|
99
|
+
const raw = res.unwrap().headers;
|
|
100
|
+
expect(res.headers).toBe(headers);
|
|
101
|
+
expect(headers.unwrap()).toBe(raw);
|
|
102
|
+
expect<unknown>(readRaw(headers)).toBe(raw);
|
|
103
|
+
expect(headers.get("content-type")?.unwrap() ?? null).toBe(raw.get("content-type"));
|
|
104
|
+
expect(headers.get("missing")).toBe(null);
|
|
105
|
+
expect(headers.has("missing").unwrap()).toBe(false);
|
|
106
|
+
expect([...headers]).toEqual([...raw]);
|
|
107
|
+
expect([...headers.entries()]).toEqual([...raw.entries()]);
|
|
108
|
+
expect([...headers.keys()]).toEqual([...raw.keys()]);
|
|
109
|
+
expect([...headers.values()]).toEqual([...raw.values()]);
|
|
110
|
+
const entries: [string, string][] = [];
|
|
111
|
+
headers.forEach((value, key) => entries.push([key, value]));
|
|
112
|
+
expect(entries).toEqual([...raw]);
|
|
113
|
+
assertValue(headers.transform("entries", (h) => Object.fromEntries(h))["content-type"])
|
|
114
|
+
.toBe(raw.get("content-type"));
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("cloned responses keep header provenance and independent bodies", async () => {
|
|
119
|
+
await runInstrumented(scope, async () => {
|
|
120
|
+
const res = await fetch(url("/clone")) as unknown as WrappedResponse;
|
|
121
|
+
const clone = res.clone();
|
|
122
|
+
assertValue(clone.headers.get("content-type")).toBe("application/json;charset=utf-8");
|
|
123
|
+
expect(clone.unwrap()).not.toBe(res.unwrap());
|
|
124
|
+
expect((await clone.text()).unwrap()).toBe('{"value":42}');
|
|
125
|
+
expect((await res.json<{ value: number }>()).value.unwrap()).toBe(42);
|
|
126
|
+
});
|
|
127
|
+
const events = stopRecording();
|
|
128
|
+
expect(events).toMatchObject([
|
|
129
|
+
{ kind: "http" },
|
|
130
|
+
{ kind: "assertion", sourceSeq: events[0]!.seq, path: ["headers", "content-type"] },
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
68
134
|
test("an HTTP fake records only the test's outer request", async () => {
|
|
69
135
|
handler = (req) => {
|
|
70
136
|
if (new URL(req.url).pathname === "/fake") {
|
|
@@ -142,6 +208,42 @@ describe("fetch execution scopes", () => {
|
|
|
142
208
|
expect(stopRecording()).toEqual([]);
|
|
143
209
|
}, 2_000);
|
|
144
210
|
|
|
211
|
+
test.each([
|
|
212
|
+
{ contentType: "text/event-stream; charset=utf-8", captureBody: undefined },
|
|
213
|
+
{ contentType: "text/plain", captureBody: false },
|
|
214
|
+
])("instrumented streams return before closing: %j", async ({ contentType, captureBody }) => {
|
|
215
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
216
|
+
handler = () => new Response(new ReadableStream<Uint8Array>({
|
|
217
|
+
start(c) {
|
|
218
|
+
controller = c;
|
|
219
|
+
c.enqueue(new TextEncoder().encode("first chunk"));
|
|
220
|
+
},
|
|
221
|
+
}), { headers: { "content-type": contentType } });
|
|
222
|
+
try {
|
|
223
|
+
await runInstrumented(scope, async () => {
|
|
224
|
+
const res = await (fetch as unknown as SpectestFetch)(url("/stream"), {
|
|
225
|
+
captureBody, signal: AbortSignal.timeout(1_000),
|
|
226
|
+
});
|
|
227
|
+
assertValue(res.bodyUsed).toBe(false);
|
|
228
|
+
const reader = res.body!.getReader();
|
|
229
|
+
try {
|
|
230
|
+
const chunk = await reader.read();
|
|
231
|
+
expect(chunk.done).toBe(false);
|
|
232
|
+
assertValue(chunk.value!.transform("text", (bytes) => new TextDecoder().decode(bytes)))
|
|
233
|
+
.toBe("first chunk");
|
|
234
|
+
} finally { reader.releaseLock(); }
|
|
235
|
+
});
|
|
236
|
+
} finally { controller?.close(); }
|
|
237
|
+
const events = stopRecording();
|
|
238
|
+
expect(events).toMatchObject([
|
|
239
|
+
{ kind: "http", status: 200 },
|
|
240
|
+
{ kind: "assertion", sourceSeq: events[0]!.seq, path: ["bodyUsed"] },
|
|
241
|
+
{ kind: "assertion", sourceSeq: events[0]!.seq, path: ["body", "<read>", "0", "<text>"] },
|
|
242
|
+
]);
|
|
243
|
+
if (events[0]?.kind !== "http") throw new Error("Missing HTTP event");
|
|
244
|
+
expect((events[0] as HttpEvent).responseBody).toBeUndefined();
|
|
245
|
+
}, 2_000);
|
|
246
|
+
|
|
145
247
|
test("test-authored ingress callbacks retain their scope but their fake upstream does not", async () => {
|
|
146
248
|
const callback = runInstrumented(scope, () => bindInstrumentationScope(async () => {
|
|
147
249
|
const res = await fetch(url("/interceptor")) as unknown as WrappedResponse;
|
|
@@ -241,6 +343,10 @@ describe("fetch execution scopes", () => {
|
|
|
241
343
|
await runInstrumented(setup, async () => {
|
|
242
344
|
const res = await fetch(url("/setup")) as unknown as WrappedResponse;
|
|
243
345
|
expect(res.status.unwrap()).toBe(200);
|
|
346
|
+
expect(res.headers.get("content-type")?.unwrap()).toBe("application/json;charset=utf-8");
|
|
347
|
+
expect(readTag(res.headers.get("content-type"))).toEqual({
|
|
348
|
+
sourceSeq: undefined, path: ["headers", "content-type"],
|
|
349
|
+
});
|
|
244
350
|
await runUninstrumented(async () => {
|
|
245
351
|
expect((await fetch(url("/setup-fake"))).status).toBe(200);
|
|
246
352
|
});
|
package/src/harness/fetch.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { wrapResponse, type WrappedResponse } from "../inspect.js";
|
|
1
|
+
import { wrapResponse, type SpectestRequestInit, type WrappedResponse } from "../inspect.js";
|
|
2
2
|
import { recordHttp, reserveEvent, truncateUtf8, type OmittedBody } from "../recorder.js";
|
|
3
3
|
import { isTextualContentType, looksBinary, omittedBody, parseContentLength } from "./http-body.js";
|
|
4
4
|
import { currentInstrumentationScope } from "./instrumentation-scope.js";
|
|
@@ -72,12 +72,11 @@ export function isTransportError(err: unknown): boolean {
|
|
|
72
72
|
export function installFetchWrapper(): () => void {
|
|
73
73
|
const original = globalThis.fetch;
|
|
74
74
|
// SDK internals (the MCP client, its OAuth flow) must not see the
|
|
75
|
-
// wrapper:
|
|
76
|
-
// body to the end, which never finishes for an SSE stream.
|
|
75
|
+
// wrapper: its response properties and body readers return wrapped values.
|
|
77
76
|
setRawFetch(original);
|
|
78
77
|
const wrappedFn = async (
|
|
79
78
|
input: Parameters<typeof fetch>[0],
|
|
80
|
-
init?:
|
|
79
|
+
init?: SpectestRequestInit,
|
|
81
80
|
): Promise<Response | WrappedResponse> => {
|
|
82
81
|
const scope = currentInstrumentationScope();
|
|
83
82
|
if (!scope?.active) return original(input, init);
|
|
@@ -97,7 +96,11 @@ export function installFetchWrapper(): () => void {
|
|
|
97
96
|
const contentType = res.headers.get("content-type");
|
|
98
97
|
const contentLength = () => parseContentLength(res.headers.get("content-length"));
|
|
99
98
|
const textual = isTextualContentType(contentType);
|
|
100
|
-
|
|
99
|
+
const streaming = contentType?.split(";")[0]?.trim().toLowerCase() === "text/event-stream";
|
|
100
|
+
if (init?.captureBody === false || streaming) {
|
|
101
|
+
// Return on headers: an open-ended stream must not be drained (or
|
|
102
|
+
// cloned into an unbounded background buffer) by the recorder.
|
|
103
|
+
} else if (textual === false) {
|
|
101
104
|
responseBody = omittedBody("binary", contentType, contentLength());
|
|
102
105
|
} else {
|
|
103
106
|
try {
|
package/src/index.ts
CHANGED
|
@@ -17,9 +17,21 @@ export type {
|
|
|
17
17
|
Wrapped,
|
|
18
18
|
WrappedObject,
|
|
19
19
|
WrappedArray,
|
|
20
|
+
WrappedHeaders,
|
|
21
|
+
WrappedBuffer,
|
|
22
|
+
WrappedBinaryView,
|
|
23
|
+
WrappedBytes,
|
|
24
|
+
WrappedBlob,
|
|
25
|
+
WrappedFile,
|
|
26
|
+
WrappedFormData,
|
|
27
|
+
WrappedStream,
|
|
28
|
+
WrappedReader,
|
|
29
|
+
WrappedBYOBReader,
|
|
30
|
+
WrappedReadResult,
|
|
20
31
|
WrappedResponse,
|
|
21
32
|
Provenanced,
|
|
22
33
|
SpectestFetch,
|
|
34
|
+
SpectestRequestInit,
|
|
23
35
|
Unwrap,
|
|
24
36
|
} from "./inspect.js";
|
|
25
37
|
import type { OpTag, Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
|
|
@@ -475,11 +487,15 @@ export interface SpectestContext<
|
|
|
475
487
|
/**
|
|
476
488
|
* Instrumented `fetch`. In a test each call is recorded on the timeline
|
|
477
489
|
* and resolves to a {@link WrappedResponse}: reads carry provenance so
|
|
478
|
-
* `expect(res.status)
|
|
490
|
+
* `expect(res.status)`, `expect(res.headers.get("content-type"))`, and
|
|
491
|
+
* `expect(await res.json())` nest under the HTTP
|
|
479
492
|
* call. Because the status-line accessors are {@link Carrier}s, a raw
|
|
480
493
|
* `res.status === 200` is a *type error* — use `res.status.unwrap()` /
|
|
481
494
|
* `res.unwrap().status`, or assert via `expect`. Outside a test the
|
|
482
495
|
* result is wrapped the same way, just with no timeline to link to.
|
|
496
|
+
* Body readers (`bytes`, `arrayBuffer`, `blob`, `formData`, `json`, `text`)
|
|
497
|
+
* and stream reader values also retain provenance. Use `captureBody: false`
|
|
498
|
+
* for open-ended streams; SSE responses skip recorder body capture automatically.
|
|
483
499
|
*
|
|
484
500
|
* (The plain global `fetch` is wrapped the same way at runtime but keeps
|
|
485
501
|
* the standard `Response` type, so prefer `ctx.fetch` for honestly-typed
|
|
@@ -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
|
+
});
|