@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
|
@@ -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
|
let original;
|
|
23
24
|
/** Called by the daemon with the real `fetch`, before it installs its
|
|
24
25
|
* instrumented one. */
|
package/dist/index.d.ts
CHANGED
|
@@ -1405,6 +1405,9 @@ export interface FakeDefinition<S = any, H extends Record<string, unknown> = Rec
|
|
|
1405
1405
|
* real backing instance, `ctx.dnsName(...)` to name it). Return any
|
|
1406
1406
|
* `Response`. Thrown errors surface as 500s. The request URL is the
|
|
1407
1407
|
* absolute URL the client used — useful for routing on the path.
|
|
1408
|
+
* Fake code uses native `fetch`: responses are ordinary `Response` objects
|
|
1409
|
+
* and internal requests are not added to a running test's timeline. This
|
|
1410
|
+
* also applies to state/helper factories and helper implementations.
|
|
1408
1411
|
*/
|
|
1409
1412
|
handler: (req: Request, state: S, ctx: FakeContext) => Response | Promise<Response>;
|
|
1410
1413
|
/**
|
|
@@ -1420,6 +1423,8 @@ export interface FakeDefinition<S = any, H extends Record<string, unknown> = Rec
|
|
|
1420
1423
|
* Every call is tracked in the test timeline: it records a `fake` step
|
|
1421
1424
|
* and the return value is tagged so a later `expect(...)` on it nests
|
|
1422
1425
|
* under that step in the UI (same provenance as `fetch`/db results).
|
|
1426
|
+
* Operations inside the helper are uninstrumented, including across awaits;
|
|
1427
|
+
* concurrent operations in test code continue to record normally.
|
|
1423
1428
|
* The step renders the return value as JSON; wrap it in {@link annotate}
|
|
1424
1429
|
* to add a richer view (an email, today) that the step's panel leads with,
|
|
1425
1430
|
* the JSON one tab away — the test still receives the raw value, unchanged
|
package/dist/recorder.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// current recorder is `null` and the `record*` helpers are no-ops, so
|
|
9
9
|
// callers can invoke them unconditionally.
|
|
10
10
|
import { clearPendingNullish } from "./inspect.js";
|
|
11
|
+
import { scopeAllowsRecording } from "./harness/instrumentation-scope.js";
|
|
11
12
|
const OUTPUT_SNIPPET_BYTES = 256 * 1024;
|
|
12
13
|
class Recorder {
|
|
13
14
|
events = [];
|
|
@@ -117,7 +118,7 @@ export function resumeRecording() {
|
|
|
117
118
|
paused = Math.max(0, paused - 1);
|
|
118
119
|
}
|
|
119
120
|
function active() {
|
|
120
|
-
return current !== null && paused === 0;
|
|
121
|
+
return current !== null && paused === 0 && scopeAllowsRecording();
|
|
121
122
|
}
|
|
122
123
|
export function startRecording() {
|
|
123
124
|
current = new Recorder();
|
package/package.json
CHANGED
package/src/daemon.ts
CHANGED
|
@@ -95,12 +95,13 @@ import { runWrapperRules } from "./harness/wrapper-rules.js";
|
|
|
95
95
|
import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
|
|
96
96
|
import { cpus } from "node:os";
|
|
97
97
|
import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath } from "./project-files.js";
|
|
98
|
+
import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
|
|
98
99
|
import {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
} from "./harness/
|
|
100
|
+
bindInstrumentationScope,
|
|
101
|
+
createInstrumentationScope,
|
|
102
|
+
runInstrumented,
|
|
103
|
+
runUninstrumented,
|
|
104
|
+
} from "./harness/instrumentation-scope.js";
|
|
104
105
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
105
106
|
import {
|
|
106
107
|
InterceptRegistry,
|
|
@@ -169,16 +170,13 @@ import { openTerminal } from "./terminal.js";
|
|
|
169
170
|
// `ctx.mcp(url)`. One client per call, no registry: an authenticated
|
|
170
171
|
// client is passed to descendants as a test's return value.
|
|
171
172
|
import { openMcp } from "./mcp.js";
|
|
172
|
-
import { setRawFetch } from "./harness/raw-fetch.js";
|
|
173
173
|
import { readAnnotation, type RenderAnnotation } from "./annotate.js";
|
|
174
174
|
import {
|
|
175
175
|
isRecording,
|
|
176
|
-
pauseRecording,
|
|
177
176
|
recordEmail,
|
|
178
177
|
recordEnv,
|
|
179
178
|
recordExec,
|
|
180
179
|
recordFake,
|
|
181
|
-
recordHttp,
|
|
182
180
|
recordStep,
|
|
183
181
|
recordTerminal,
|
|
184
182
|
recordWait,
|
|
@@ -186,16 +184,14 @@ import {
|
|
|
186
184
|
recorderEventCount,
|
|
187
185
|
recorderMarkChildren,
|
|
188
186
|
recorderTruncate,
|
|
189
|
-
resumeRecording,
|
|
190
187
|
startRecording,
|
|
191
188
|
stopRecording,
|
|
192
189
|
truncateUtf8,
|
|
193
190
|
type StepBlock,
|
|
194
191
|
type TestEvent,
|
|
195
|
-
type OmittedBody,
|
|
196
192
|
} from "./recorder.js";
|
|
197
|
-
import { deepUnwrap, readRaw, wrap
|
|
198
|
-
import type { Wrapped
|
|
193
|
+
import { deepUnwrap, readRaw, wrap } from "./inspect.js";
|
|
194
|
+
import type { Wrapped } from "./inspect.js";
|
|
199
195
|
import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
|
|
200
196
|
import { generateId } from "./ids.js";
|
|
201
197
|
import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
|
|
@@ -2150,7 +2146,7 @@ async function startIngress(): Promise<void> {
|
|
|
2150
2146
|
for (const [name, fake] of FAKES) {
|
|
2151
2147
|
if (fake.def.state) {
|
|
2152
2148
|
try {
|
|
2153
|
-
fake.state = await fake.def.state();
|
|
2149
|
+
fake.state = await runUninstrumented(() => fake.def.state!());
|
|
2154
2150
|
} catch (err) {
|
|
2155
2151
|
throw new Error(
|
|
2156
2152
|
`fake ${JSON.stringify(name)} state() factory threw: ${(err as Error).message}`,
|
|
@@ -2604,7 +2600,9 @@ async function dispatchIngress(
|
|
|
2604
2600
|
const upstream = async (current: Request): Promise<Response> => {
|
|
2605
2601
|
if (route.kind === "fake") {
|
|
2606
2602
|
try {
|
|
2607
|
-
return await
|
|
2603
|
+
return await runUninstrumented(() =>
|
|
2604
|
+
route.fake.def.handler(current, route.fake.state, FAKE_CTX),
|
|
2605
|
+
);
|
|
2608
2606
|
} catch (err) {
|
|
2609
2607
|
const e = err as Error;
|
|
2610
2608
|
return new Response(
|
|
@@ -3057,7 +3055,7 @@ function registerInterceptor(
|
|
|
3057
3055
|
);
|
|
3058
3056
|
}
|
|
3059
3057
|
const resv = reserveEvent();
|
|
3060
|
-
const it = INTERCEPTORS.register(host, path, handler);
|
|
3058
|
+
const it = INTERCEPTORS.register(host, path, bindInstrumentationScope(handler));
|
|
3061
3059
|
const where = `${host}${it.path === "/" ? "" : it.path}`;
|
|
3062
3060
|
const seq = recordStep(
|
|
3063
3061
|
{
|
|
@@ -3288,11 +3286,11 @@ async function ensureFakeHelpers(name: string): Promise<Record<string, unknown>>
|
|
|
3288
3286
|
if (!fake) throw new Error(`fake ${JSON.stringify(name)} is not loaded`);
|
|
3289
3287
|
if (fake.trackedHelpers) return fake.trackedHelpers;
|
|
3290
3288
|
fake.helpers = fake.def.helpers
|
|
3291
|
-
? ((await fake.def.helpers({
|
|
3289
|
+
? ((await runUninstrumented(() => fake.def.helpers!({
|
|
3292
3290
|
name,
|
|
3293
3291
|
state: fake.state,
|
|
3294
3292
|
ctx: FAKE_CTX,
|
|
3295
|
-
})) as Record<string, unknown>)
|
|
3293
|
+
}))) as Record<string, unknown>)
|
|
3296
3294
|
: {};
|
|
3297
3295
|
fake.trackedHelpers = trackFakeHelpers(name, fake.helpers);
|
|
3298
3296
|
return fake.trackedHelpers;
|
|
@@ -3386,41 +3384,24 @@ function invokeFakeHelper(
|
|
|
3386
3384
|
}, resv);
|
|
3387
3385
|
};
|
|
3388
3386
|
|
|
3389
|
-
//
|
|
3390
|
-
//
|
|
3391
|
-
// something the test did, and it would otherwise land on the timeline as
|
|
3392
|
-
// an `http` step the test never made. The built-in `email()` helpers
|
|
3393
|
-
// already pause around their internal polls by hand for exactly this
|
|
3394
|
-
// reason (`components/email.ts`); doing it here gives every user-authored
|
|
3395
|
-
// fake the same contract without having to know about it.
|
|
3396
|
-
//
|
|
3397
|
-
// The pause spans the helper's `await`s, so genuinely concurrent test work
|
|
3398
|
-
// (`Promise.all([helper(), ctx.fetch(…)])`) loses its events too. That is
|
|
3399
|
-
// the same trade `email()` has always made, and sequential test code — all
|
|
3400
|
-
// of it, in practice — is unaffected.
|
|
3401
|
-
pauseRecording();
|
|
3387
|
+
// Record the public helper call in the caller's scope, while its body
|
|
3388
|
+
// and async descendants use native fetch and emit no internal events.
|
|
3402
3389
|
let result: unknown;
|
|
3403
3390
|
try {
|
|
3404
|
-
result = fn.apply(thisArg, args);
|
|
3391
|
+
result = runUninstrumented(() => fn.apply(thisArg, args));
|
|
3405
3392
|
} catch (err) {
|
|
3406
|
-
resumeRecording();
|
|
3407
3393
|
recordError(err);
|
|
3408
3394
|
throw err;
|
|
3409
3395
|
}
|
|
3410
3396
|
if (result instanceof Promise) {
|
|
3411
3397
|
return result.then(
|
|
3412
|
-
|
|
3413
|
-
resumeRecording();
|
|
3414
|
-
return recordResult(value);
|
|
3415
|
-
},
|
|
3398
|
+
recordResult,
|
|
3416
3399
|
(err) => {
|
|
3417
|
-
resumeRecording();
|
|
3418
3400
|
recordError(err);
|
|
3419
3401
|
throw err;
|
|
3420
3402
|
},
|
|
3421
3403
|
);
|
|
3422
3404
|
}
|
|
3423
|
-
resumeRecording();
|
|
3424
3405
|
return recordResult(result);
|
|
3425
3406
|
}
|
|
3426
3407
|
|
|
@@ -3756,13 +3737,15 @@ async function runProjectSetupInner(): Promise<ProjectSetupResult> {
|
|
|
3756
3737
|
// as in a test. No recorder is active here, so it wraps without provenance —
|
|
3757
3738
|
// but the wrapped type stays honest at runtime (`.unwrap()` works).
|
|
3758
3739
|
const restoreFetch = installFetchWrapper();
|
|
3740
|
+
const fetchScope = createInstrumentationScope(false);
|
|
3759
3741
|
// The full context, unscoped: every service is up by project-setup time,
|
|
3760
3742
|
// so `ctx.svc` is the same map tests see (and shares helper instances with
|
|
3761
3743
|
// them — a Bun.SQL pool created here is reused later).
|
|
3762
3744
|
const ctx: ProjectSetupContext = await spectestContext();
|
|
3763
3745
|
try {
|
|
3764
|
-
await proj.setup(ctx);
|
|
3746
|
+
await runInstrumented(fetchScope, () => proj.setup!(ctx));
|
|
3765
3747
|
} finally {
|
|
3748
|
+
fetchScope.active = false;
|
|
3766
3749
|
restoreFetch();
|
|
3767
3750
|
}
|
|
3768
3751
|
return { ran: true, durationMs: Date.now() - start };
|
|
@@ -4753,173 +4736,6 @@ async function buildServiceHandles(cfg: EnvironmentConfig): Promise<ServiceHandl
|
|
|
4753
4736
|
// Instrumentation helpers
|
|
4754
4737
|
// ────────────────────────────────────────────────────────────────────────
|
|
4755
4738
|
|
|
4756
|
-
function describeFetchInput(input: Parameters<typeof fetch>[0]): {
|
|
4757
|
-
url: string;
|
|
4758
|
-
methodFromInput?: string;
|
|
4759
|
-
} {
|
|
4760
|
-
if (typeof input === "string") return { url: input };
|
|
4761
|
-
if (input instanceof URL) return { url: input.toString() };
|
|
4762
|
-
// Request instance
|
|
4763
|
-
const req = input as Request;
|
|
4764
|
-
return { url: req.url, methodFromInput: req.method };
|
|
4765
|
-
}
|
|
4766
|
-
|
|
4767
|
-
function describeRequestBody(
|
|
4768
|
-
input: Parameters<typeof fetch>[0],
|
|
4769
|
-
init: Parameters<typeof fetch>[1],
|
|
4770
|
-
): { body?: string; truncated?: boolean } {
|
|
4771
|
-
// For Request objects, body has already been consumed into the request;
|
|
4772
|
-
// we can't read it back without cloning, which costs. Skip unless init.body
|
|
4773
|
-
// is provided directly.
|
|
4774
|
-
const body = init?.body;
|
|
4775
|
-
if (body === undefined || body === null) {
|
|
4776
|
-
if (input instanceof Request && input.bodyUsed === false) {
|
|
4777
|
-
// Don't drain the request's body here — leaving it for the actual
|
|
4778
|
-
// fetch. Return a marker.
|
|
4779
|
-
return { body: "[Request body not captured]", truncated: false };
|
|
4780
|
-
}
|
|
4781
|
-
return {};
|
|
4782
|
-
}
|
|
4783
|
-
if (typeof body === "string") {
|
|
4784
|
-
const t = truncateUtf8(body);
|
|
4785
|
-
return { body: t.value, truncated: t.truncated };
|
|
4786
|
-
}
|
|
4787
|
-
if (body instanceof URLSearchParams) {
|
|
4788
|
-
const t = truncateUtf8(body.toString());
|
|
4789
|
-
return { body: t.value, truncated: t.truncated };
|
|
4790
|
-
}
|
|
4791
|
-
return { body: `[non-text body: ${body.constructor?.name ?? typeof body}]` };
|
|
4792
|
-
}
|
|
4793
|
-
|
|
4794
|
-
/**
|
|
4795
|
-
* Marks an error thrown by the instrumented fetch as *transport-level* —
|
|
4796
|
-
* the connection itself failed (refused, unresolvable, reset) before any
|
|
4797
|
-
* HTTP reply existed. `fetch` never rejects for an HTTP status, so every
|
|
4798
|
-
* rejection short of an abort is transport. `Symbol.for` so a duplicated
|
|
4799
|
-
* SDK module instance (the bun hardlink landmine) still recognises it.
|
|
4800
|
-
*
|
|
4801
|
-
* Why it exists: `ctx.poll` waits for convergence, and right after a fork
|
|
4802
|
-
* restore the guest can serve a ~10 s window where a connect or a DNS
|
|
4803
|
-
* lookup fails once and then heals (measured 2026-08-21: a poll's first
|
|
4804
|
-
* fetch hung 12 s in resolution, threw, and killed a 60 s poll on attempt
|
|
4805
|
-
* 1 while attempt 2 would have passed). During a poll, a dead connection
|
|
4806
|
-
* is just "not ready yet"; outside one it stays a hard error.
|
|
4807
|
-
*/
|
|
4808
|
-
const TRANSPORT_ERROR = Symbol.for("spectest.transportError");
|
|
4809
|
-
|
|
4810
|
-
function isTransportError(err: unknown): boolean {
|
|
4811
|
-
return (
|
|
4812
|
-
typeof err === "object" &&
|
|
4813
|
-
err !== null &&
|
|
4814
|
-
(err as Record<symbol, unknown>)[TRANSPORT_ERROR] === true
|
|
4815
|
-
);
|
|
4816
|
-
}
|
|
4817
|
-
|
|
4818
|
-
/**
|
|
4819
|
-
* Install a fetch wrapper on `globalThis` that emits HTTP events into the
|
|
4820
|
-
* active recorder. Returns a restore function. Calls outside of a running
|
|
4821
|
-
* test still hit the original fetch (the recorder is null then; the
|
|
4822
|
-
* wrapper just adds a tiny amount of overhead — but we restore after each
|
|
4823
|
-
* test anyway, so this only matters mid-test).
|
|
4824
|
-
*/
|
|
4825
|
-
function installFetchWrapper(): () => void {
|
|
4826
|
-
const original = globalThis.fetch;
|
|
4827
|
-
// SDK internals (the MCP client, its OAuth flow) must not see the
|
|
4828
|
-
// wrapper: a wrapped `res.ok` is an object, and the wrapper reads every
|
|
4829
|
-
// body to the end, which never finishes for an SSE stream.
|
|
4830
|
-
setRawFetch(original);
|
|
4831
|
-
const wrappedFn = async (
|
|
4832
|
-
input: Parameters<typeof fetch>[0],
|
|
4833
|
-
init?: Parameters<typeof fetch>[1],
|
|
4834
|
-
): Promise<Response | WrappedResponse> => {
|
|
4835
|
-
const start = Date.now();
|
|
4836
|
-
const resv = reserveEvent();
|
|
4837
|
-
const { url, methodFromInput } = describeFetchInput(input);
|
|
4838
|
-
const method = (init?.method ?? methodFromInput ?? "GET").toUpperCase();
|
|
4839
|
-
const reqBody = describeRequestBody(input, init);
|
|
4840
|
-
try {
|
|
4841
|
-
const res = await original(input as RequestInfo, init);
|
|
4842
|
-
let responseBody: string | OmittedBody | undefined;
|
|
4843
|
-
let responseBodyTruncated: boolean | undefined;
|
|
4844
|
-
// The reply the test gets is untouched: everything here reads a clone,
|
|
4845
|
-
// and a body known to be binary is not read at all. See
|
|
4846
|
-
// ./harness/http-body.ts for why a blanket `.text()` was wrong.
|
|
4847
|
-
const contentType = res.headers.get("content-type");
|
|
4848
|
-
const contentLength = () => parseContentLength(res.headers.get("content-length"));
|
|
4849
|
-
const textual = isTextualContentType(contentType);
|
|
4850
|
-
if (textual === false) {
|
|
4851
|
-
responseBody = omittedBody("binary", contentType, contentLength());
|
|
4852
|
-
} else {
|
|
4853
|
-
try {
|
|
4854
|
-
const cloned = res.clone();
|
|
4855
|
-
const text = await cloned.text();
|
|
4856
|
-
if (textual === undefined && looksBinary(text)) {
|
|
4857
|
-
// No content type (or a multipart one), and the bytes say this
|
|
4858
|
-
// was never text.
|
|
4859
|
-
responseBody = omittedBody("binary", contentType, contentLength());
|
|
4860
|
-
} else {
|
|
4861
|
-
const t = truncateUtf8(text);
|
|
4862
|
-
responseBody = t.value;
|
|
4863
|
-
responseBodyTruncated = t.truncated;
|
|
4864
|
-
}
|
|
4865
|
-
} catch {
|
|
4866
|
-
// The stream failed, or something had already consumed the body.
|
|
4867
|
-
responseBody = omittedBody("unreadable", contentType, contentLength());
|
|
4868
|
-
}
|
|
4869
|
-
}
|
|
4870
|
-
const seq = recordHttp({
|
|
4871
|
-
method,
|
|
4872
|
-
url,
|
|
4873
|
-
requestBody: reqBody.body,
|
|
4874
|
-
requestBodyTruncated: reqBody.truncated,
|
|
4875
|
-
status: res.status,
|
|
4876
|
-
responseBody,
|
|
4877
|
-
responseBodyTruncated,
|
|
4878
|
-
durationMs: Date.now() - start,
|
|
4879
|
-
}, resv);
|
|
4880
|
-
return wrapResponse(res, seq);
|
|
4881
|
-
} catch (err) {
|
|
4882
|
-
const e = err as Error;
|
|
4883
|
-
recordHttp({
|
|
4884
|
-
method,
|
|
4885
|
-
url,
|
|
4886
|
-
requestBody: reqBody.body,
|
|
4887
|
-
requestBodyTruncated: reqBody.truncated,
|
|
4888
|
-
durationMs: Date.now() - start,
|
|
4889
|
-
error: e?.message ?? String(err),
|
|
4890
|
-
}, resv);
|
|
4891
|
-
// Tag transport failures for ctx.poll (see TRANSPORT_ERROR). An abort
|
|
4892
|
-
// is the caller's own signal (their AbortController or their
|
|
4893
|
-
// AbortSignal.timeout) — their semantics, never retried for them.
|
|
4894
|
-
if (
|
|
4895
|
-
typeof err === "object" &&
|
|
4896
|
-
err !== null &&
|
|
4897
|
-
e?.name !== "AbortError" &&
|
|
4898
|
-
e?.name !== "TimeoutError"
|
|
4899
|
-
) {
|
|
4900
|
-
try {
|
|
4901
|
-
(err as Record<symbol, unknown>)[TRANSPORT_ERROR] = true;
|
|
4902
|
-
} catch {
|
|
4903
|
-
/* frozen error object — stays a hard error */
|
|
4904
|
-
}
|
|
4905
|
-
}
|
|
4906
|
-
throw err;
|
|
4907
|
-
}
|
|
4908
|
-
};
|
|
4909
|
-
// Preserve any provider-specific statics on `fetch` (e.g. Bun's
|
|
4910
|
-
// `fetch.preconnect`) so consumers that touch them keep working.
|
|
4911
|
-
const wrapped = wrappedFn as unknown as typeof fetch;
|
|
4912
|
-
for (const key of Object.keys(original) as (keyof typeof original)[]) {
|
|
4913
|
-
(wrapped as unknown as Record<string, unknown>)[key as string] = (
|
|
4914
|
-
original as unknown as Record<string, unknown>
|
|
4915
|
-
)[key as string];
|
|
4916
|
-
}
|
|
4917
|
-
globalThis.fetch = wrapped;
|
|
4918
|
-
return () => {
|
|
4919
|
-
globalThis.fetch = original;
|
|
4920
|
-
};
|
|
4921
|
-
}
|
|
4922
|
-
|
|
4923
4739
|
/** Build the `docker exec` argv for a service command. A string command runs
|
|
4924
4740
|
* through `sh -lc`; an array is exact argv with no shell (the
|
|
4925
4741
|
* `["psql", "-f", "-"]` shape). An optional `cwd` becomes `-w <cwd>` (the
|
|
@@ -5481,6 +5297,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5481
5297
|
|
|
5482
5298
|
startRecording();
|
|
5483
5299
|
const restoreFetch = installFetchWrapper();
|
|
5300
|
+
const fetchScope = createInstrumentationScope();
|
|
5484
5301
|
|
|
5485
5302
|
// Look up the parent's stored return value (if any). The parent ran in
|
|
5486
5303
|
// an ancestor fork; its TEST_DATA entry travels with the snapshot.
|
|
@@ -5622,7 +5439,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5622
5439
|
let outcome: { status: "passed" | "failed"; error?: RunResult["error"] };
|
|
5623
5440
|
try {
|
|
5624
5441
|
const value = await Promise.race([
|
|
5625
|
-
Promise.resolve(testCase.run(ctx)),
|
|
5442
|
+
Promise.resolve(runInstrumented(fetchScope, () => testCase.run(ctx))),
|
|
5626
5443
|
timedOut,
|
|
5627
5444
|
]);
|
|
5628
5445
|
// Stash the return value so child cases — which fork from the snapshot
|
|
@@ -5639,6 +5456,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5639
5456
|
if (timer) clearTimeout(timer);
|
|
5640
5457
|
RECORDING_EXEC = undefined;
|
|
5641
5458
|
INTERCEPTORS.endScope();
|
|
5459
|
+
fetchScope.active = false;
|
|
5642
5460
|
restoreFetch();
|
|
5643
5461
|
restoreConsole();
|
|
5644
5462
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -6013,6 +5831,7 @@ async function evalCode(
|
|
|
6013
5831
|
// Response just like in a test (no recorder here, so no provenance — but the
|
|
6014
5832
|
// wrapped type is honest at runtime). Restored in the `finally` below.
|
|
6015
5833
|
const restoreFetch = installFetchWrapper();
|
|
5834
|
+
const fetchScope = createInstrumentationScope(false);
|
|
6016
5835
|
|
|
6017
5836
|
// Same persistent acquire/detach as a test run (see runOne): the browser
|
|
6018
5837
|
// survives the eval, so successive `spectest env eval` calls continue one
|
|
@@ -6177,7 +5996,8 @@ async function evalCode(
|
|
|
6177
5996
|
await fs.mkdir(EVAL_DIR, { recursive: true });
|
|
6178
5997
|
filePath = path.join(EVAL_DIR, `${randomUUID()}.ts`);
|
|
6179
5998
|
await fs.writeFile(filePath, prepared.code);
|
|
6180
|
-
const
|
|
5999
|
+
const moduleUrl = pathToFileURL(filePath).href;
|
|
6000
|
+
const mod = (await runInstrumented(fetchScope, () => import(moduleUrl))) as { default?: unknown };
|
|
6181
6001
|
outcome = { ok: true, result: safeSerialize(mod.default) };
|
|
6182
6002
|
} catch (err) {
|
|
6183
6003
|
const e = err as Error;
|
|
@@ -6188,6 +6008,7 @@ async function evalCode(
|
|
|
6188
6008
|
};
|
|
6189
6009
|
} finally {
|
|
6190
6010
|
clearRecordSecrets();
|
|
6011
|
+
fetchScope.active = false;
|
|
6191
6012
|
restoreFetch();
|
|
6192
6013
|
restoreConsole();
|
|
6193
6014
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|