@specific.dev/spectest 0.79.1 → 0.80.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/dist/coverage.d.ts +59 -69
- package/dist/coverage.js +110 -171
- package/dist/daemon.js +28 -200
- 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/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/coverage.test.ts +69 -134
- package/src/coverage.ts +135 -177
- package/src/daemon.ts +33 -223
- 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/raw-fetch.ts +5 -4
- package/src/index.ts +5 -0
- package/src/recorder.ts +2 -1
|
@@ -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/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 {
|