@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.
@@ -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
+ }
@@ -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
- // While a test (or an eval) runs, the daemon replaces `globalThis.fetch`
5
- // with an instrumented one: it records an `http` event, and it hands back
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
- // So SDK-internal HTTP goes through `rawFetch`. The daemon publishes the
21
- // original here when it installs its wrapper.
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 {