@specific.dev/spectest 0.79.0 → 0.79.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/dist/daemon.js +23 -187
- package/dist/harness/fetch.d.ts +5 -0
- package/dist/harness/fetch.js +165 -0
- package/dist/harness/instrumentation-scope.d.ts +18 -0
- package/dist/harness/instrumentation-scope.js +32 -0
- package/dist/harness/main.js +4 -4
- package/dist/harness/protocol.d.ts +20 -0
- package/dist/harness/protocol.js +34 -0
- package/dist/harness/raw-fetch.js +5 -4
- package/dist/index.d.ts +5 -0
- package/dist/recorder.js +2 -1
- package/package.json +1 -1
- package/src/daemon.ts +29 -208
- package/src/harness/fetch.test.ts +253 -0
- package/src/harness/fetch.ts +174 -0
- package/src/harness/instrumentation-scope.ts +46 -0
- package/src/harness/main.ts +4 -4
- package/src/harness/protocol.test.ts +42 -0
- package/src/harness/protocol.ts +33 -0
- package/src/harness/raw-fetch.ts +5 -4
- package/src/index.ts +5 -0
- package/src/recorder.ts +2 -1
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { expect as assertValue } from "../index.js";
|
|
3
|
+
import { type WrappedResponse } from "../inspect.js";
|
|
4
|
+
import { recordFake, startRecording, stopRecording } from "../recorder.js";
|
|
5
|
+
import { installFetchWrapper, isTransportError } from "./fetch.js";
|
|
6
|
+
import {
|
|
7
|
+
bindInstrumentationScope,
|
|
8
|
+
createInstrumentationScope,
|
|
9
|
+
runInstrumented,
|
|
10
|
+
runUninstrumented,
|
|
11
|
+
type InstrumentationScope,
|
|
12
|
+
} from "./instrumentation-scope.js";
|
|
13
|
+
|
|
14
|
+
function deferred<T = void>() {
|
|
15
|
+
let resolve!: (value: T) => void;
|
|
16
|
+
const promise = new Promise<T>((done) => { resolve = done; });
|
|
17
|
+
return { promise, resolve };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("fetch execution scopes", () => {
|
|
21
|
+
let scope: InstrumentationScope;
|
|
22
|
+
let restore: () => void;
|
|
23
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
24
|
+
let handler: (req: Request) => Response | Promise<Response>;
|
|
25
|
+
const url = (path: string) => new URL(path, server.url).href;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
handler = () => Response.json({ value: 42 });
|
|
29
|
+
server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (req) => handler(req) });
|
|
30
|
+
startRecording();
|
|
31
|
+
scope = createInstrumentationScope();
|
|
32
|
+
restore = installFetchWrapper();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
scope.active = false;
|
|
37
|
+
restore();
|
|
38
|
+
stopRecording();
|
|
39
|
+
server.stop(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("test helpers retain HTTP and assertion provenance across awaits", async () => {
|
|
43
|
+
await runInstrumented(scope, async () => {
|
|
44
|
+
const helper = async () => {
|
|
45
|
+
await Promise.resolve();
|
|
46
|
+
return await fetch(url("/test")) as unknown as WrappedResponse;
|
|
47
|
+
};
|
|
48
|
+
const res = await helper();
|
|
49
|
+
expect(res.status.unwrap()).toBe(200);
|
|
50
|
+
assertValue(res.status).toBe(200);
|
|
51
|
+
assertValue((await res.json<{ value: number }>()).value).toBe(42);
|
|
52
|
+
});
|
|
53
|
+
const events = stopRecording();
|
|
54
|
+
expect(events.map((e) => e.kind)).toEqual(["http", "assertion", "assertion"]);
|
|
55
|
+
expect(events[1]).toMatchObject({ sourceSeq: events[0]!.seq, path: ["status"] });
|
|
56
|
+
expect(events[2]).toMatchObject({ sourceSeq: events[0]!.seq, path: ["body", "value"] });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("background fetch is native even while a test recorder is active", async () => {
|
|
60
|
+
handler = () => Response.json({ error: "missing" }, { status: 404 });
|
|
61
|
+
const res = await fetch(url("/background"));
|
|
62
|
+
expect(res.status).toBe(404);
|
|
63
|
+
expect(res.ok).toBe(false);
|
|
64
|
+
expect(await res.json()).toEqual({ error: "missing" });
|
|
65
|
+
expect(stopRecording()).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("an HTTP fake records only the test's outer request", async () => {
|
|
69
|
+
handler = (req) => {
|
|
70
|
+
if (new URL(req.url).pathname === "/fake") {
|
|
71
|
+
return runUninstrumented(async () => {
|
|
72
|
+
await Promise.resolve();
|
|
73
|
+
const res = await fetch(url("/upstream"));
|
|
74
|
+
// Ordinary HTTP status/error handling must work inside the fake.
|
|
75
|
+
if (!res.ok) return new Response(await res.text(), { status: res.status });
|
|
76
|
+
return new Response("unexpected success");
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return new Response("upstream unavailable", { status: 503 });
|
|
80
|
+
};
|
|
81
|
+
await runInstrumented(scope, async () => {
|
|
82
|
+
const res = await fetch(url("/fake")) as unknown as WrappedResponse;
|
|
83
|
+
expect(res.status.unwrap()).toBe(503);
|
|
84
|
+
expect((await res.text()).unwrap()).toBe("upstream unavailable");
|
|
85
|
+
});
|
|
86
|
+
expect(stopRecording()).toMatchObject([{ kind: "http", url: url("/fake"), status: 503 }]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("async fake internals do not suppress concurrent test events", async () => {
|
|
90
|
+
const entered = deferred();
|
|
91
|
+
const release = deferred();
|
|
92
|
+
await runInstrumented(scope, async () => {
|
|
93
|
+
const fake = runUninstrumented(async () => {
|
|
94
|
+
entered.resolve();
|
|
95
|
+
await release.promise;
|
|
96
|
+
const res = await fetch(url("/fake-internal"));
|
|
97
|
+
expect(res.status).toBe(200);
|
|
98
|
+
recordFake({ fake: "nested", member: "internal", args: [], durationMs: 0 });
|
|
99
|
+
return (await res.json()).value;
|
|
100
|
+
});
|
|
101
|
+
// Like invokeFakeHelper, the public call's completion is recorded in
|
|
102
|
+
// its caller's scope; internal operations stay in the fake scope.
|
|
103
|
+
const completed = fake.then((value) => {
|
|
104
|
+
recordFake({ fake: "example", member: "read", args: [], result: value, durationMs: 0 });
|
|
105
|
+
});
|
|
106
|
+
await entered.promise;
|
|
107
|
+
try {
|
|
108
|
+
await fetch(url("/test-during-fake"));
|
|
109
|
+
} finally {
|
|
110
|
+
release.resolve();
|
|
111
|
+
}
|
|
112
|
+
await completed;
|
|
113
|
+
await fetch(url("/test-after-fake"));
|
|
114
|
+
});
|
|
115
|
+
expect(stopRecording()).toMatchObject([
|
|
116
|
+
{ kind: "http", url: url("/test-during-fake") },
|
|
117
|
+
{ kind: "fake", fake: "example", member: "read", result: 42 },
|
|
118
|
+
{ kind: "http", url: url("/test-after-fake") },
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("fake streams are returned without cloning or waiting for the body to end", async () => {
|
|
123
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
124
|
+
handler = () => new Response(new ReadableStream<Uint8Array>({
|
|
125
|
+
start(c) {
|
|
126
|
+
controller = c;
|
|
127
|
+
c.enqueue(new TextEncoder().encode("data: first\n\n"));
|
|
128
|
+
},
|
|
129
|
+
}), { headers: { "content-type": "text/event-stream" } });
|
|
130
|
+
await runInstrumented(scope, () => runUninstrumented(async () => {
|
|
131
|
+
const res = await fetch(url("/stream"));
|
|
132
|
+
expect(res.status).toBe(200);
|
|
133
|
+
expect(res.bodyUsed).toBe(false);
|
|
134
|
+
const reader = res.body!.getReader();
|
|
135
|
+
try {
|
|
136
|
+
expect(new TextDecoder().decode((await reader.read()).value)).toBe("data: first\n\n");
|
|
137
|
+
} finally {
|
|
138
|
+
controller.close();
|
|
139
|
+
await reader.cancel();
|
|
140
|
+
}
|
|
141
|
+
}));
|
|
142
|
+
expect(stopRecording()).toEqual([]);
|
|
143
|
+
}, 2_000);
|
|
144
|
+
|
|
145
|
+
test("test-authored ingress callbacks retain their scope but their fake upstream does not", async () => {
|
|
146
|
+
const callback = runInstrumented(scope, () => bindInstrumentationScope(async () => {
|
|
147
|
+
const res = await fetch(url("/interceptor")) as unknown as WrappedResponse;
|
|
148
|
+
expect(res.status.unwrap()).toBe(200);
|
|
149
|
+
await runUninstrumented(async () => {
|
|
150
|
+
const upstream = await fetch(url("/fake-upstream"));
|
|
151
|
+
expect(upstream.status).toBe(200);
|
|
152
|
+
});
|
|
153
|
+
}));
|
|
154
|
+
// Dispatch from outside the test's async call chain, like ingress does.
|
|
155
|
+
await callback();
|
|
156
|
+
expect(stopRecording()).toMatchObject([{ kind: "http", url: url("/interceptor") }]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("fake errors keep native semantics and throwing restores the test scope", async () => {
|
|
160
|
+
await runInstrumented(scope, async () => {
|
|
161
|
+
let rawError: unknown;
|
|
162
|
+
try {
|
|
163
|
+
await runUninstrumented(async () => {
|
|
164
|
+
await Promise.resolve();
|
|
165
|
+
await fetch("http://[invalid");
|
|
166
|
+
});
|
|
167
|
+
} catch (err) {
|
|
168
|
+
rawError = err;
|
|
169
|
+
}
|
|
170
|
+
expect(rawError).toBeInstanceOf(Error);
|
|
171
|
+
expect(isTransportError(rawError)).toBe(false);
|
|
172
|
+
let recordedError: unknown;
|
|
173
|
+
try { await fetch("http://[invalid"); } catch (err) { recordedError = err; }
|
|
174
|
+
expect(isTransportError(recordedError)).toBe(true);
|
|
175
|
+
await fetch(url("/test"));
|
|
176
|
+
});
|
|
177
|
+
expect(stopRecording().map((e) => e.kind)).toEqual(["http", "http"]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("late responses and detached callbacks cannot record into the next test", async () => {
|
|
181
|
+
const entered = deferred();
|
|
182
|
+
const release = deferred();
|
|
183
|
+
handler = async (req) => {
|
|
184
|
+
if (new URL(req.url).pathname === "/late") {
|
|
185
|
+
entered.resolve();
|
|
186
|
+
await release.promise;
|
|
187
|
+
}
|
|
188
|
+
return Response.json({ value: 42 });
|
|
189
|
+
};
|
|
190
|
+
const late = runInstrumented(scope, async () => {
|
|
191
|
+
const res = await fetch(url("/late"));
|
|
192
|
+
expect(res.status).toBe(200);
|
|
193
|
+
await fetch(url("/detached"));
|
|
194
|
+
});
|
|
195
|
+
await entered.promise;
|
|
196
|
+
scope.active = false;
|
|
197
|
+
restore();
|
|
198
|
+
expect(stopRecording()).toEqual([]);
|
|
199
|
+
startRecording();
|
|
200
|
+
restore = installFetchWrapper();
|
|
201
|
+
const next = createInstrumentationScope();
|
|
202
|
+
try {
|
|
203
|
+
release.resolve();
|
|
204
|
+
await late;
|
|
205
|
+
await runInstrumented(next, () => fetch(url("/next-test")));
|
|
206
|
+
expect(stopRecording()).toMatchObject([{ kind: "http", url: url("/next-test"), seq: 0 }]);
|
|
207
|
+
} finally {
|
|
208
|
+
next.active = false;
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("a response body finishing after scope closure cannot record into a new test", async () => {
|
|
213
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
214
|
+
const reading = deferred();
|
|
215
|
+
handler = () => new Response(new ReadableStream<Uint8Array>({
|
|
216
|
+
start(c) { controller = c; },
|
|
217
|
+
pull(c) {
|
|
218
|
+
c.enqueue(new TextEncoder().encode("part"));
|
|
219
|
+
reading.resolve();
|
|
220
|
+
// Keep the body open until the test closes the scope.
|
|
221
|
+
return new Promise(() => {});
|
|
222
|
+
},
|
|
223
|
+
}), { headers: { "content-type": "text/plain" } });
|
|
224
|
+
const pending = runInstrumented(scope, () => fetch(url("/body")));
|
|
225
|
+
await reading.promise;
|
|
226
|
+
// Yield so the fetch wrapper can start reading its clone.
|
|
227
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
228
|
+
scope.active = false;
|
|
229
|
+
stopRecording();
|
|
230
|
+
startRecording();
|
|
231
|
+
controller.close();
|
|
232
|
+
const res = await pending;
|
|
233
|
+
expect(res.status).toBe(200);
|
|
234
|
+
expect(await res.text()).toBe("part");
|
|
235
|
+
expect(stopRecording()).toEqual([]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("setup/eval keep wrapped responses without recording or wrapping fake internals", async () => {
|
|
239
|
+
const setup = createInstrumentationScope(false);
|
|
240
|
+
try {
|
|
241
|
+
await runInstrumented(setup, async () => {
|
|
242
|
+
const res = await fetch(url("/setup")) as unknown as WrappedResponse;
|
|
243
|
+
expect(res.status.unwrap()).toBe(200);
|
|
244
|
+
await runUninstrumented(async () => {
|
|
245
|
+
expect((await fetch(url("/setup-fake"))).status).toBe(200);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
expect(stopRecording()).toEqual([]);
|
|
249
|
+
} finally {
|
|
250
|
+
setup.active = false;
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { wrapResponse, type WrappedResponse } from "../inspect.js";
|
|
2
|
+
import { recordHttp, reserveEvent, truncateUtf8, type OmittedBody } from "../recorder.js";
|
|
3
|
+
import { isTextualContentType, looksBinary, omittedBody, parseContentLength } from "./http-body.js";
|
|
4
|
+
import { currentInstrumentationScope } from "./instrumentation-scope.js";
|
|
5
|
+
import { setRawFetch } from "./raw-fetch.js";
|
|
6
|
+
|
|
7
|
+
function describeFetchInput(input: Parameters<typeof fetch>[0]): {
|
|
8
|
+
url: string;
|
|
9
|
+
methodFromInput?: string;
|
|
10
|
+
} {
|
|
11
|
+
if (typeof input === "string") return { url: input };
|
|
12
|
+
if (input instanceof URL) return { url: input.toString() };
|
|
13
|
+
// Request instance
|
|
14
|
+
const req = input as Request;
|
|
15
|
+
return { url: req.url, methodFromInput: req.method };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function describeRequestBody(
|
|
19
|
+
input: Parameters<typeof fetch>[0],
|
|
20
|
+
init: Parameters<typeof fetch>[1],
|
|
21
|
+
): { body?: string; truncated?: boolean } {
|
|
22
|
+
// For Request objects, body has already been consumed into the request;
|
|
23
|
+
// we can't read it back without cloning, which costs. Skip unless init.body
|
|
24
|
+
// is provided directly.
|
|
25
|
+
const body = init?.body;
|
|
26
|
+
if (body === undefined || body === null) {
|
|
27
|
+
if (input instanceof Request && input.bodyUsed === false) {
|
|
28
|
+
// Don't drain the request's body here — leaving it for the actual
|
|
29
|
+
// fetch. Return a marker.
|
|
30
|
+
return { body: "[Request body not captured]", truncated: false };
|
|
31
|
+
}
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
if (typeof body === "string") {
|
|
35
|
+
const t = truncateUtf8(body);
|
|
36
|
+
return { body: t.value, truncated: t.truncated };
|
|
37
|
+
}
|
|
38
|
+
if (body instanceof URLSearchParams) {
|
|
39
|
+
const t = truncateUtf8(body.toString());
|
|
40
|
+
return { body: t.value, truncated: t.truncated };
|
|
41
|
+
}
|
|
42
|
+
return { body: `[non-text body: ${body.constructor?.name ?? typeof body}]` };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Marks an error thrown by the instrumented fetch as *transport-level* —
|
|
47
|
+
* the connection itself failed (refused, unresolvable, reset) before any
|
|
48
|
+
* HTTP reply existed. `fetch` never rejects for an HTTP status, so every
|
|
49
|
+
* rejection short of an abort is transport. `Symbol.for` so a duplicated
|
|
50
|
+
* SDK module instance (the bun hardlink landmine) still recognises it.
|
|
51
|
+
*
|
|
52
|
+
* Why it exists: `ctx.poll` waits for convergence, and right after a fork
|
|
53
|
+
* restore the guest can serve a ~10 s window where a connect or a DNS
|
|
54
|
+
* lookup fails once and then heals (measured 2026-08-21: a poll's first
|
|
55
|
+
* fetch hung 12 s in resolution, threw, and killed a 60 s poll on attempt
|
|
56
|
+
* 1 while attempt 2 would have passed). During a poll, a dead connection
|
|
57
|
+
* is just "not ready yet"; outside one it stays a hard error.
|
|
58
|
+
*/
|
|
59
|
+
const TRANSPORT_ERROR = Symbol.for("spectest.transportError");
|
|
60
|
+
|
|
61
|
+
export function isTransportError(err: unknown): boolean {
|
|
62
|
+
return (
|
|
63
|
+
typeof err === "object" &&
|
|
64
|
+
err !== null &&
|
|
65
|
+
(err as Record<symbol, unknown>)[TRANSPORT_ERROR] === true
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Install a dispatcher: only code in an active test/setup/eval scope gets
|
|
70
|
+
* wrapped responses. Fake handlers and background tasks use native fetch,
|
|
71
|
+
* without cloning bodies, tagging errors, or touching the recorder. */
|
|
72
|
+
export function installFetchWrapper(): () => void {
|
|
73
|
+
const original = globalThis.fetch;
|
|
74
|
+
// SDK internals (the MCP client, its OAuth flow) must not see the
|
|
75
|
+
// wrapper: a wrapped `res.ok` is an object, and the wrapper reads every
|
|
76
|
+
// body to the end, which never finishes for an SSE stream.
|
|
77
|
+
setRawFetch(original);
|
|
78
|
+
const wrappedFn = async (
|
|
79
|
+
input: Parameters<typeof fetch>[0],
|
|
80
|
+
init?: Parameters<typeof fetch>[1],
|
|
81
|
+
): Promise<Response | WrappedResponse> => {
|
|
82
|
+
const scope = currentInstrumentationScope();
|
|
83
|
+
if (!scope?.active) return original(input, init);
|
|
84
|
+
const start = Date.now();
|
|
85
|
+
const resv = reserveEvent();
|
|
86
|
+
const { url, methodFromInput } = describeFetchInput(input);
|
|
87
|
+
const method = (init?.method ?? methodFromInput ?? "GET").toUpperCase();
|
|
88
|
+
const reqBody = describeRequestBody(input, init);
|
|
89
|
+
try {
|
|
90
|
+
const res = await original(input as RequestInfo, init);
|
|
91
|
+
if (!scope.active) return res;
|
|
92
|
+
let responseBody: string | OmittedBody | undefined;
|
|
93
|
+
let responseBodyTruncated: boolean | undefined;
|
|
94
|
+
// The reply the test gets is untouched: everything here reads a clone,
|
|
95
|
+
// and a body known to be binary is not read at all. See
|
|
96
|
+
// ./http-body.ts for why a blanket `.text()` was wrong.
|
|
97
|
+
const contentType = res.headers.get("content-type");
|
|
98
|
+
const contentLength = () => parseContentLength(res.headers.get("content-length"));
|
|
99
|
+
const textual = isTextualContentType(contentType);
|
|
100
|
+
if (textual === false) {
|
|
101
|
+
responseBody = omittedBody("binary", contentType, contentLength());
|
|
102
|
+
} else {
|
|
103
|
+
try {
|
|
104
|
+
const cloned = res.clone();
|
|
105
|
+
const text = await cloned.text();
|
|
106
|
+
if (textual === undefined && looksBinary(text)) {
|
|
107
|
+
// No content type (or a multipart one), and the bytes say this
|
|
108
|
+
// was never text.
|
|
109
|
+
responseBody = omittedBody("binary", contentType, contentLength());
|
|
110
|
+
} else {
|
|
111
|
+
const t = truncateUtf8(text);
|
|
112
|
+
responseBody = t.value;
|
|
113
|
+
responseBodyTruncated = t.truncated;
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// The stream failed, or something had already consumed the body.
|
|
117
|
+
responseBody = omittedBody("unreadable", contentType, contentLength());
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// The test may have timed out while we awaited the response body.
|
|
121
|
+
if (!scope.active) return res;
|
|
122
|
+
const seq = recordHttp({
|
|
123
|
+
method,
|
|
124
|
+
url,
|
|
125
|
+
requestBody: reqBody.body,
|
|
126
|
+
requestBodyTruncated: reqBody.truncated,
|
|
127
|
+
status: res.status,
|
|
128
|
+
responseBody,
|
|
129
|
+
responseBodyTruncated,
|
|
130
|
+
durationMs: Date.now() - start,
|
|
131
|
+
}, resv);
|
|
132
|
+
return wrapResponse(res, seq);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (!scope.active) throw err;
|
|
135
|
+
const e = err as Error;
|
|
136
|
+
recordHttp({
|
|
137
|
+
method,
|
|
138
|
+
url,
|
|
139
|
+
requestBody: reqBody.body,
|
|
140
|
+
requestBodyTruncated: reqBody.truncated,
|
|
141
|
+
durationMs: Date.now() - start,
|
|
142
|
+
error: e?.message ?? String(err),
|
|
143
|
+
}, resv);
|
|
144
|
+
// Tag transport failures for ctx.poll (see TRANSPORT_ERROR). An abort
|
|
145
|
+
// is the caller's own signal (their AbortController or their
|
|
146
|
+
// AbortSignal.timeout) — their semantics, never retried for them.
|
|
147
|
+
if (
|
|
148
|
+
typeof err === "object" &&
|
|
149
|
+
err !== null &&
|
|
150
|
+
e?.name !== "AbortError" &&
|
|
151
|
+
e?.name !== "TimeoutError"
|
|
152
|
+
) {
|
|
153
|
+
try {
|
|
154
|
+
(err as Record<symbol, unknown>)[TRANSPORT_ERROR] = true;
|
|
155
|
+
} catch {
|
|
156
|
+
/* frozen error object — stays a hard error */
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
// Preserve any provider-specific statics on `fetch` (e.g. Bun's
|
|
163
|
+
// `fetch.preconnect`) so consumers that touch them keep working.
|
|
164
|
+
const wrapped = wrappedFn as unknown as typeof fetch;
|
|
165
|
+
for (const key of Object.keys(original) as (keyof typeof original)[]) {
|
|
166
|
+
(wrapped as unknown as Record<string, unknown>)[key as string] = (
|
|
167
|
+
original as unknown as Record<string, unknown>
|
|
168
|
+
)[key as string];
|
|
169
|
+
}
|
|
170
|
+
globalThis.fetch = wrapped;
|
|
171
|
+
return () => {
|
|
172
|
+
globalThis.fetch = original;
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
/** One test (or setup/eval invocation). Close it even on timeout: detached
|
|
4
|
+
* work retains its async context and must not write into the next test. */
|
|
5
|
+
export interface InstrumentationScope {
|
|
6
|
+
active: boolean;
|
|
7
|
+
recording: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// No scope means native fetch. `null` explicitly isolates fake execution,
|
|
11
|
+
// including helpers called directly from an instrumented test.
|
|
12
|
+
const scopes = new AsyncLocalStorage<InstrumentationScope | null>();
|
|
13
|
+
|
|
14
|
+
export function createInstrumentationScope(recording = true): InstrumentationScope {
|
|
15
|
+
return { active: true, recording };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function currentInstrumentationScope(): InstrumentationScope | null | undefined {
|
|
19
|
+
return scopes.getStore();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function runInstrumented<T>(scope: InstrumentationScope, fn: () => T): T {
|
|
23
|
+
return scopes.run(scope, fn);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Preserve native behavior through awaits, timers, and nested helpers without
|
|
27
|
+
* changing the instrumentation of concurrent test work. */
|
|
28
|
+
export function runUninstrumented<T>(fn: () => T): T {
|
|
29
|
+
return scopes.run(null, fn);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Test-authored callbacks invoked later by ingress (ctx.intercept) keep
|
|
33
|
+
* their registration scope, even though the incoming request has none. */
|
|
34
|
+
export function bindInstrumentationScope<A extends unknown[], R>(fn: (...args: A) => R): (...args: A) => R {
|
|
35
|
+
const scope = scopes.getStore();
|
|
36
|
+
return function (this: unknown, ...args: A): R {
|
|
37
|
+
return scopes.run(scope ?? null, () => fn.apply(this, args));
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Unscoped SDK sites (browser/interceptor callbacks, final drains) still use
|
|
42
|
+
* the active recorder. Fake internals and expired test contexts cannot. */
|
|
43
|
+
export function scopeAllowsRecording(): boolean {
|
|
44
|
+
const scope = scopes.getStore();
|
|
45
|
+
return scope === undefined || (scope !== null && scope.active && scope.recording);
|
|
46
|
+
}
|
package/src/harness/main.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { codeOf } from "./methods";
|
|
|
40
40
|
import {
|
|
41
41
|
PROTOCOL_VERSION,
|
|
42
42
|
decodeFrame,
|
|
43
|
+
describeThrow,
|
|
43
44
|
encodeFrame,
|
|
44
45
|
fail,
|
|
45
46
|
ok,
|
|
@@ -115,12 +116,11 @@ export function serve(
|
|
|
115
116
|
//
|
|
116
117
|
// A refusal the method meant to make (an unknown case, a second test
|
|
117
118
|
// while one is running) reports its own kind and just its message. An
|
|
118
|
-
// unexpected throw
|
|
119
|
-
//
|
|
119
|
+
// unexpected throw carries its message AND its stack — in that order,
|
|
120
|
+
// because a stack alone is sometimes headerless (`describeThrow`).
|
|
120
121
|
const e = err as Error;
|
|
121
122
|
const code = codeOf(err);
|
|
122
|
-
const detail =
|
|
123
|
-
code === "handler_error" ? (e?.stack ?? e?.message ?? String(err)) : e.message;
|
|
123
|
+
const detail = code === "handler_error" ? describeThrow(err) : e.message;
|
|
124
124
|
send(fail(req.id, detail, code));
|
|
125
125
|
}
|
|
126
126
|
};
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
KNOWN_METHODS,
|
|
19
19
|
PROTOCOL_VERSION,
|
|
20
20
|
decodeFrame,
|
|
21
|
+
describeThrow,
|
|
21
22
|
encodeFrame,
|
|
22
23
|
fail,
|
|
23
24
|
ok,
|
|
@@ -146,3 +147,44 @@ describe("replies", () => {
|
|
|
146
147
|
expect(fail("7", "boom", "c")!.error!.code).toBe("c");
|
|
147
148
|
});
|
|
148
149
|
});
|
|
150
|
+
|
|
151
|
+
describe("describeThrow", () => {
|
|
152
|
+
test("an ordinary stack, which already opens with the message, is untouched", () => {
|
|
153
|
+
const err = new Error("docker build for web failed:\nCOPY: not found");
|
|
154
|
+
expect(describeThrow(err)).toBe(err.stack);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The shape this function exists for, produced rather than written by
|
|
159
|
+
* hand: a promise that rejects while nobody awaits it yet and is
|
|
160
|
+
* observed a turn later comes back with a headerless stack. That is a
|
|
161
|
+
* bootstrap image build (`daemon.ts` starts every build at once and a
|
|
162
|
+
* service awaits its own prep when the DAG reaches it), and the build
|
|
163
|
+
* log lives in the message it drops.
|
|
164
|
+
*/
|
|
165
|
+
test("a headerless stack gets its message back", async () => {
|
|
166
|
+
const message = "docker build for client-app failed:\n#12 ERROR: killed";
|
|
167
|
+
const rejected = (async () => {
|
|
168
|
+
throw new Error(message);
|
|
169
|
+
})();
|
|
170
|
+
rejected.catch(() => undefined);
|
|
171
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
172
|
+
let caught: unknown;
|
|
173
|
+
try {
|
|
174
|
+
await rejected;
|
|
175
|
+
} catch (e) {
|
|
176
|
+
caught = e;
|
|
177
|
+
}
|
|
178
|
+
const described = describeThrow(caught);
|
|
179
|
+
expect(described).toContain(message);
|
|
180
|
+
expect(described.startsWith(`Error: ${message}`)).toBe(true);
|
|
181
|
+
// The frames survive, and the bare `Error` line they hung under does not.
|
|
182
|
+
expect(described).toContain(" at ");
|
|
183
|
+
expect(described).not.toContain("\nError\n");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a non-Error keeps whatever it can say for itself", () => {
|
|
187
|
+
expect(describeThrow("plain string")).toBe("plain string");
|
|
188
|
+
expect(describeThrow({ message: "no stack here" })).toBe("Error: no stack here");
|
|
189
|
+
});
|
|
190
|
+
});
|
package/src/harness/protocol.ts
CHANGED
|
@@ -161,3 +161,36 @@ export function ok(id: string, result: Record<string, unknown> = {}): ResponseFr
|
|
|
161
161
|
export function fail(id: string, message: string, code?: string): ResponseFrame {
|
|
162
162
|
return { kind: "response", id, ok: false, error: code ? { message, code } : { message } };
|
|
163
163
|
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Describe a thrown value for the wire: the **message first**, then the
|
|
167
|
+
* frames — never the stack on its own.
|
|
168
|
+
*
|
|
169
|
+
* `error.stack` normally opens with `Error: <message>`, which is why it
|
|
170
|
+
* looked like the richer of the two. It is not always: Bun drops that
|
|
171
|
+
* header when the rejection is observed a turn later than it settled —
|
|
172
|
+
* a promise that rejected while nobody was awaiting it yet, which is
|
|
173
|
+
* exactly what a bootstrap image build is (`daemon.ts` starts every
|
|
174
|
+
* build at once and each service awaits its own prep when the DAG
|
|
175
|
+
* reaches it). The whole diagnosis of a failed build IS the message
|
|
176
|
+
* there: `docker build for <svc> failed:` plus the build log. Sending
|
|
177
|
+
* the stack alone published four frames and nothing else — no docker,
|
|
178
|
+
* no BuildKit, no compiler error (reported by the Harmony project,
|
|
179
|
+
* 2026-09-09; the same run's log had the message intact).
|
|
180
|
+
*
|
|
181
|
+
* A stack that already carries the message is returned as it stands, so
|
|
182
|
+
* the ordinary case is unchanged.
|
|
183
|
+
*/
|
|
184
|
+
export function describeThrow(err: unknown): string {
|
|
185
|
+
const e = err as { message?: unknown; stack?: unknown; name?: unknown } | null | undefined;
|
|
186
|
+
const message = typeof e?.message === "string" ? e.message : "";
|
|
187
|
+
const stack = typeof e?.stack === "string" ? e.stack : "";
|
|
188
|
+
if (!message) return stack || String(err);
|
|
189
|
+
if (stack.includes(message)) return stack;
|
|
190
|
+
const name = typeof e?.name === "string" && e.name.length > 0 ? e.name : "Error";
|
|
191
|
+
// The headerless form is `<Name>\n at …`. Drop that bare first line
|
|
192
|
+
// and write the header ourselves, so the result reads like the stack a
|
|
193
|
+
// reader expects rather than a message with a stray `Error` in it.
|
|
194
|
+
const frames = stack.startsWith(`${name}\n`) ? stack.slice(name.length + 1) : stack;
|
|
195
|
+
return frames.length > 0 ? `${name}: ${message}\n${frames}` : `${name}: ${message}`;
|
|
196
|
+
}
|
package/src/harness/raw-fetch.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The real `fetch`, for SDK internals that must not go through the test
|
|
2
2
|
// recorder's wrapper.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// The daemon's `globalThis.fetch` dispatcher instruments calls in the async
|
|
5
|
+
// scope of a test (or setup/eval): it records an `http` event, and hands back
|
|
6
6
|
// a WRAPPED response whose `ok` / `status` are provenance handles rather
|
|
7
7
|
// than a boolean and a number. That is exactly right for a test's own
|
|
8
8
|
// calls, and exactly wrong inside the SDK, in two ways:
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
// handed its response only after the stream it was waiting to read
|
|
18
18
|
// has already ended. A long-lived stream deadlocks.
|
|
19
19
|
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
20
|
+
// Fake execution is explicitly outside that scope. SDK-internal HTTP that
|
|
21
|
+
// runs WITHIN a test's scope goes through `rawFetch` instead. The daemon
|
|
22
|
+
// publishes the original here when it installs its wrapper.
|
|
22
23
|
|
|
23
24
|
let original: typeof fetch | undefined;
|
|
24
25
|
|
package/src/index.ts
CHANGED
|
@@ -2053,6 +2053,9 @@ export interface FakeDefinition<
|
|
|
2053
2053
|
* real backing instance, `ctx.dnsName(...)` to name it). Return any
|
|
2054
2054
|
* `Response`. Thrown errors surface as 500s. The request URL is the
|
|
2055
2055
|
* absolute URL the client used — useful for routing on the path.
|
|
2056
|
+
* Fake code uses native `fetch`: responses are ordinary `Response` objects
|
|
2057
|
+
* and internal requests are not added to a running test's timeline. This
|
|
2058
|
+
* also applies to state/helper factories and helper implementations.
|
|
2056
2059
|
*/
|
|
2057
2060
|
handler: (req: Request, state: S, ctx: FakeContext) => Response | Promise<Response>;
|
|
2058
2061
|
/**
|
|
@@ -2068,6 +2071,8 @@ export interface FakeDefinition<
|
|
|
2068
2071
|
* Every call is tracked in the test timeline: it records a `fake` step
|
|
2069
2072
|
* and the return value is tagged so a later `expect(...)` on it nests
|
|
2070
2073
|
* under that step in the UI (same provenance as `fetch`/db results).
|
|
2074
|
+
* Operations inside the helper are uninstrumented, including across awaits;
|
|
2075
|
+
* concurrent operations in test code continue to record normally.
|
|
2071
2076
|
* The step renders the return value as JSON; wrap it in {@link annotate}
|
|
2072
2077
|
* to add a richer view (an email, today) that the step's panel leads with,
|
|
2073
2078
|
* the JSON one tab away — the test still receives the raw value, unchanged
|
package/src/recorder.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// callers can invoke them unconditionally.
|
|
10
10
|
|
|
11
11
|
import { clearPendingNullish } from "./inspect.js";
|
|
12
|
+
import { scopeAllowsRecording } from "./harness/instrumentation-scope.js";
|
|
12
13
|
|
|
13
14
|
const OUTPUT_SNIPPET_BYTES = 256 * 1024;
|
|
14
15
|
|
|
@@ -747,7 +748,7 @@ export function resumeRecording(): void {
|
|
|
747
748
|
}
|
|
748
749
|
|
|
749
750
|
function active(): boolean {
|
|
750
|
-
return current !== null && paused === 0;
|
|
751
|
+
return current !== null && paused === 0 && scopeAllowsRecording();
|
|
751
752
|
}
|
|
752
753
|
|
|
753
754
|
export function startRecording(): void {
|