@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/daemon.js CHANGED
@@ -24,7 +24,7 @@ import { pathToFileURL } from "node:url";
24
24
  import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, validateServiceImage, proxy as makeProxyDecl, } from "./index.js";
25
25
  import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, } from "./harness/coverage.js";
26
26
  import { configureBrowserCoverage } from "./browser-coverage.js";
27
- import { applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage, nodeCoverageToolsAvailable, nodeCoverageToolsDir, NODE_COVERAGE_TOOLS_CONTAINER_DIR, } from "./coverage.js";
27
+ import { applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage, } from "./coverage.js";
28
28
  import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
29
29
  import { acquirePersistentBrowser, mobileKey } from "./browser.js";
30
30
  import { isMobileApp, openPersistentMobile } from "./mobile.js";
@@ -42,7 +42,8 @@ import { pollUntilReady } from "./harness/ready-poll.js";
42
42
  import { runWrapperRules } from "./harness/wrapper-rules.js";
43
43
  import { cpus } from "node:os";
44
44
  import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath } from "./project-files.js";
45
- import { isTextualContentType, looksBinary, omittedBody, parseContentLength, } from "./harness/http-body.js";
45
+ import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
46
+ import { bindInstrumentationScope, createInstrumentationScope, runInstrumented, runUninstrumented, } from "./harness/instrumentation-scope.js";
46
47
  import { encodeRegistry } from "./harness/names-registry.js";
47
48
  import { InterceptRegistry, parseTarget, runChain, } from "./harness/intercept.js";
48
49
  import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
@@ -57,10 +58,9 @@ import { openTerminal } from "./terminal.js";
57
58
  // `ctx.mcp(url)`. One client per call, no registry: an authenticated
58
59
  // client is passed to descendants as a test's return value.
59
60
  import { openMcp } from "./mcp.js";
60
- import { setRawFetch } from "./harness/raw-fetch.js";
61
61
  import { readAnnotation } from "./annotate.js";
62
- import { isRecording, pauseRecording, recordEmail, recordEnv, recordExec, recordFake, recordHttp, recordStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, resumeRecording, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
63
- import { deepUnwrap, readRaw, wrap, wrapResponse } from "./inspect.js";
62
+ import { isRecording, recordEmail, recordEnv, recordExec, recordFake, recordStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
63
+ import { deepUnwrap, readRaw, wrap } from "./inspect.js";
64
64
  import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
65
65
  import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
66
66
  function namedServices(cfg) {
@@ -690,16 +690,9 @@ async function ensureCoverage(svc) {
690
690
  const host = coverageHostDir(WORKSPACE, svc.name);
691
691
  await fs.mkdir(host, { recursive: true });
692
692
  await fs.chmod(host, 0o777);
693
- const flags = [`--volume=${host}:${COVERAGE_CONTAINER_DIR}`];
694
- // The conversion tools golden ships (`c8` for `coverage.node()`),
695
- // read-only, on the same existence check the adapter makes at capture
696
- // — so the VM and the container agree on whether they are there. A
697
- // plain `--volume`, never a config `volumes` entry: an absolute-source
698
- // volume dir is listed in the delta-restore manifest and wiped.
699
- if (await nodeCoverageToolsAvailable()) {
700
- flags.push(`--volume=${nodeCoverageToolsDir()}:${NODE_COVERAGE_TOOLS_CONTAINER_DIR}:ro`);
701
- }
702
- return flags;
693
+ // Nothing else is mounted: no conversion runs in the container (the
694
+ // c8 tools mount went with SDK 0.80).
695
+ return [`--volume=${host}:${COVERAGE_CONTAINER_DIR}`];
703
696
  }
704
697
  /** Keyed by image ID, not tag: `spectest/<svc>:latest` is retagged onto
705
698
  * new content every rebuild, and a stale uid is silently wrong. */
@@ -1705,7 +1698,7 @@ async function startIngress() {
1705
1698
  for (const [name, fake] of FAKES) {
1706
1699
  if (fake.def.state) {
1707
1700
  try {
1708
- fake.state = await fake.def.state();
1701
+ fake.state = await runUninstrumented(() => fake.def.state());
1709
1702
  }
1710
1703
  catch (err) {
1711
1704
  throw new Error(`fake ${JSON.stringify(name)} state() factory threw: ${err.message}`);
@@ -2135,7 +2128,7 @@ server, byHost, listenerLabel, proto) {
2135
2128
  const upstream = async (current) => {
2136
2129
  if (route.kind === "fake") {
2137
2130
  try {
2138
- return await route.fake.def.handler(current, route.fake.state, FAKE_CTX);
2131
+ return await runUninstrumented(() => route.fake.def.handler(current, route.fake.state, FAKE_CTX));
2139
2132
  }
2140
2133
  catch (err) {
2141
2134
  const e = err;
@@ -2545,7 +2538,7 @@ function registerInterceptor(target, description, handler) {
2545
2538
  `(\`http://<service>:<port>\`) is container-to-container traffic and never passes through here.`);
2546
2539
  }
2547
2540
  const resv = reserveEvent();
2548
- const it = INTERCEPTORS.register(host, path, handler);
2541
+ const it = INTERCEPTORS.register(host, path, bindInstrumentationScope(handler));
2549
2542
  const where = `${host}${it.path === "/" ? "" : it.path}`;
2550
2543
  const seq = recordStep({
2551
2544
  kind: "intercept",
@@ -2763,11 +2756,11 @@ async function ensureFakeHelpers(name) {
2763
2756
  if (fake.trackedHelpers)
2764
2757
  return fake.trackedHelpers;
2765
2758
  fake.helpers = fake.def.helpers
2766
- ? (await fake.def.helpers({
2759
+ ? (await runUninstrumented(() => fake.def.helpers({
2767
2760
  name,
2768
2761
  state: fake.state,
2769
2762
  ctx: FAKE_CTX,
2770
- }))
2763
+ })))
2771
2764
  : {};
2772
2765
  fake.trackedHelpers = trackFakeHelpers(name, fake.helpers);
2773
2766
  return fake.trackedHelpers;
@@ -2849,39 +2842,22 @@ function invokeFakeHelper(fakeName, member, fn, thisArg, callArgs) {
2849
2842
  error: errMessage(err),
2850
2843
  }, resv);
2851
2844
  };
2852
- // A helper call is ONE step. Whatever the fake does inside it — a fetch to
2853
- // deliver a webhook, a call to its own API — is the fake's plumbing, not
2854
- // something the test did, and it would otherwise land on the timeline as
2855
- // an `http` step the test never made. The built-in `email()` helpers
2856
- // already pause around their internal polls by hand for exactly this
2857
- // reason (`components/email.ts`); doing it here gives every user-authored
2858
- // fake the same contract without having to know about it.
2859
- //
2860
- // The pause spans the helper's `await`s, so genuinely concurrent test work
2861
- // (`Promise.all([helper(), ctx.fetch(…)])`) loses its events too. That is
2862
- // the same trade `email()` has always made, and sequential test code — all
2863
- // of it, in practice — is unaffected.
2864
- pauseRecording();
2845
+ // Record the public helper call in the caller's scope, while its body
2846
+ // and async descendants use native fetch and emit no internal events.
2865
2847
  let result;
2866
2848
  try {
2867
- result = fn.apply(thisArg, args);
2849
+ result = runUninstrumented(() => fn.apply(thisArg, args));
2868
2850
  }
2869
2851
  catch (err) {
2870
- resumeRecording();
2871
2852
  recordError(err);
2872
2853
  throw err;
2873
2854
  }
2874
2855
  if (result instanceof Promise) {
2875
- return result.then((value) => {
2876
- resumeRecording();
2877
- return recordResult(value);
2878
- }, (err) => {
2879
- resumeRecording();
2856
+ return result.then(recordResult, (err) => {
2880
2857
  recordError(err);
2881
2858
  throw err;
2882
2859
  });
2883
2860
  }
2884
- resumeRecording();
2885
2861
  return recordResult(result);
2886
2862
  }
2887
2863
  /** Record a fake-helper call's render annotation as a child of the call's
@@ -3176,14 +3152,16 @@ async function runProjectSetupInner() {
3176
3152
  // as in a test. No recorder is active here, so it wraps without provenance —
3177
3153
  // but the wrapped type stays honest at runtime (`.unwrap()` works).
3178
3154
  const restoreFetch = installFetchWrapper();
3155
+ const fetchScope = createInstrumentationScope(false);
3179
3156
  // The full context, unscoped: every service is up by project-setup time,
3180
3157
  // so `ctx.svc` is the same map tests see (and shares helper instances with
3181
3158
  // them — a Bun.SQL pool created here is reused later).
3182
3159
  const ctx = await spectestContext();
3183
3160
  try {
3184
- await proj.setup(ctx);
3161
+ await runInstrumented(fetchScope, () => proj.setup(ctx));
3185
3162
  }
3186
3163
  finally {
3164
+ fetchScope.active = false;
3187
3165
  restoreFetch();
3188
3166
  }
3189
3167
  return { ran: true, durationMs: Date.now() - start };
@@ -3906,160 +3884,6 @@ async function buildServiceHandles(cfg) {
3906
3884
  // ────────────────────────────────────────────────────────────────────────
3907
3885
  // Instrumentation helpers
3908
3886
  // ────────────────────────────────────────────────────────────────────────
3909
- function describeFetchInput(input) {
3910
- if (typeof input === "string")
3911
- return { url: input };
3912
- if (input instanceof URL)
3913
- return { url: input.toString() };
3914
- // Request instance
3915
- const req = input;
3916
- return { url: req.url, methodFromInput: req.method };
3917
- }
3918
- function describeRequestBody(input, init) {
3919
- // For Request objects, body has already been consumed into the request;
3920
- // we can't read it back without cloning, which costs. Skip unless init.body
3921
- // is provided directly.
3922
- const body = init?.body;
3923
- if (body === undefined || body === null) {
3924
- if (input instanceof Request && input.bodyUsed === false) {
3925
- // Don't drain the request's body here — leaving it for the actual
3926
- // fetch. Return a marker.
3927
- return { body: "[Request body not captured]", truncated: false };
3928
- }
3929
- return {};
3930
- }
3931
- if (typeof body === "string") {
3932
- const t = truncateUtf8(body);
3933
- return { body: t.value, truncated: t.truncated };
3934
- }
3935
- if (body instanceof URLSearchParams) {
3936
- const t = truncateUtf8(body.toString());
3937
- return { body: t.value, truncated: t.truncated };
3938
- }
3939
- return { body: `[non-text body: ${body.constructor?.name ?? typeof body}]` };
3940
- }
3941
- /**
3942
- * Marks an error thrown by the instrumented fetch as *transport-level* —
3943
- * the connection itself failed (refused, unresolvable, reset) before any
3944
- * HTTP reply existed. `fetch` never rejects for an HTTP status, so every
3945
- * rejection short of an abort is transport. `Symbol.for` so a duplicated
3946
- * SDK module instance (the bun hardlink landmine) still recognises it.
3947
- *
3948
- * Why it exists: `ctx.poll` waits for convergence, and right after a fork
3949
- * restore the guest can serve a ~10 s window where a connect or a DNS
3950
- * lookup fails once and then heals (measured 2026-08-21: a poll's first
3951
- * fetch hung 12 s in resolution, threw, and killed a 60 s poll on attempt
3952
- * 1 while attempt 2 would have passed). During a poll, a dead connection
3953
- * is just "not ready yet"; outside one it stays a hard error.
3954
- */
3955
- const TRANSPORT_ERROR = Symbol.for("spectest.transportError");
3956
- function isTransportError(err) {
3957
- return (typeof err === "object" &&
3958
- err !== null &&
3959
- err[TRANSPORT_ERROR] === true);
3960
- }
3961
- /**
3962
- * Install a fetch wrapper on `globalThis` that emits HTTP events into the
3963
- * active recorder. Returns a restore function. Calls outside of a running
3964
- * test still hit the original fetch (the recorder is null then; the
3965
- * wrapper just adds a tiny amount of overhead — but we restore after each
3966
- * test anyway, so this only matters mid-test).
3967
- */
3968
- function installFetchWrapper() {
3969
- const original = globalThis.fetch;
3970
- // SDK internals (the MCP client, its OAuth flow) must not see the
3971
- // wrapper: a wrapped `res.ok` is an object, and the wrapper reads every
3972
- // body to the end, which never finishes for an SSE stream.
3973
- setRawFetch(original);
3974
- const wrappedFn = async (input, init) => {
3975
- const start = Date.now();
3976
- const resv = reserveEvent();
3977
- const { url, methodFromInput } = describeFetchInput(input);
3978
- const method = (init?.method ?? methodFromInput ?? "GET").toUpperCase();
3979
- const reqBody = describeRequestBody(input, init);
3980
- try {
3981
- const res = await original(input, init);
3982
- let responseBody;
3983
- let responseBodyTruncated;
3984
- // The reply the test gets is untouched: everything here reads a clone,
3985
- // and a body known to be binary is not read at all. See
3986
- // ./harness/http-body.ts for why a blanket `.text()` was wrong.
3987
- const contentType = res.headers.get("content-type");
3988
- const contentLength = () => parseContentLength(res.headers.get("content-length"));
3989
- const textual = isTextualContentType(contentType);
3990
- if (textual === false) {
3991
- responseBody = omittedBody("binary", contentType, contentLength());
3992
- }
3993
- else {
3994
- try {
3995
- const cloned = res.clone();
3996
- const text = await cloned.text();
3997
- if (textual === undefined && looksBinary(text)) {
3998
- // No content type (or a multipart one), and the bytes say this
3999
- // was never text.
4000
- responseBody = omittedBody("binary", contentType, contentLength());
4001
- }
4002
- else {
4003
- const t = truncateUtf8(text);
4004
- responseBody = t.value;
4005
- responseBodyTruncated = t.truncated;
4006
- }
4007
- }
4008
- catch {
4009
- // The stream failed, or something had already consumed the body.
4010
- responseBody = omittedBody("unreadable", contentType, contentLength());
4011
- }
4012
- }
4013
- const seq = recordHttp({
4014
- method,
4015
- url,
4016
- requestBody: reqBody.body,
4017
- requestBodyTruncated: reqBody.truncated,
4018
- status: res.status,
4019
- responseBody,
4020
- responseBodyTruncated,
4021
- durationMs: Date.now() - start,
4022
- }, resv);
4023
- return wrapResponse(res, seq);
4024
- }
4025
- catch (err) {
4026
- const e = err;
4027
- recordHttp({
4028
- method,
4029
- url,
4030
- requestBody: reqBody.body,
4031
- requestBodyTruncated: reqBody.truncated,
4032
- durationMs: Date.now() - start,
4033
- error: e?.message ?? String(err),
4034
- }, resv);
4035
- // Tag transport failures for ctx.poll (see TRANSPORT_ERROR). An abort
4036
- // is the caller's own signal (their AbortController or their
4037
- // AbortSignal.timeout) — their semantics, never retried for them.
4038
- if (typeof err === "object" &&
4039
- err !== null &&
4040
- e?.name !== "AbortError" &&
4041
- e?.name !== "TimeoutError") {
4042
- try {
4043
- err[TRANSPORT_ERROR] = true;
4044
- }
4045
- catch {
4046
- /* frozen error object — stays a hard error */
4047
- }
4048
- }
4049
- throw err;
4050
- }
4051
- };
4052
- // Preserve any provider-specific statics on `fetch` (e.g. Bun's
4053
- // `fetch.preconnect`) so consumers that touch them keep working.
4054
- const wrapped = wrappedFn;
4055
- for (const key of Object.keys(original)) {
4056
- wrapped[key] = original[key];
4057
- }
4058
- globalThis.fetch = wrapped;
4059
- return () => {
4060
- globalThis.fetch = original;
4061
- };
4062
- }
4063
3887
  /** Build the `docker exec` argv for a service command. A string command runs
4064
3888
  * through `sh -lc`; an array is exact argv with no shell (the
4065
3889
  * `["psql", "-f", "-"]` shape). An optional `cwd` becomes `-w <cwd>` (the
@@ -4522,6 +4346,7 @@ async function runOne(testCase) {
4522
4346
  };
4523
4347
  startRecording();
4524
4348
  const restoreFetch = installFetchWrapper();
4349
+ const fetchScope = createInstrumentationScope();
4525
4350
  // Look up the parent's stored return value (if any). The parent ran in
4526
4351
  // an ancestor fork; its TEST_DATA entry travels with the snapshot.
4527
4352
  const parentId = testCase.dependsOn?.id;
@@ -4653,7 +4478,7 @@ async function runOne(testCase) {
4653
4478
  let outcome;
4654
4479
  try {
4655
4480
  const value = await Promise.race([
4656
- Promise.resolve(testCase.run(ctx)),
4481
+ Promise.resolve(runInstrumented(fetchScope, () => testCase.run(ctx))),
4657
4482
  timedOut,
4658
4483
  ]);
4659
4484
  // Stash the return value so child cases — which fork from the snapshot
@@ -4673,6 +4498,7 @@ async function runOne(testCase) {
4673
4498
  clearTimeout(timer);
4674
4499
  RECORDING_EXEC = undefined;
4675
4500
  INTERCEPTORS.endScope();
4501
+ fetchScope.active = false;
4676
4502
  restoreFetch();
4677
4503
  restoreConsole();
4678
4504
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -5004,6 +4830,7 @@ async function evalCode(code, secrets) {
5004
4830
  // Response just like in a test (no recorder here, so no provenance — but the
5005
4831
  // wrapped type is honest at runtime). Restored in the `finally` below.
5006
4832
  const restoreFetch = installFetchWrapper();
4833
+ const fetchScope = createInstrumentationScope(false);
5007
4834
  // Same persistent acquire/detach as a test run (see runOne): the browser
5008
4835
  // survives the eval, so successive `spectest env eval` calls continue one
5009
4836
  // live session — and a snapshot taken afterwards carries it.
@@ -5139,7 +4966,8 @@ async function evalCode(code, secrets) {
5139
4966
  await fs.mkdir(EVAL_DIR, { recursive: true });
5140
4967
  filePath = path.join(EVAL_DIR, `${randomUUID()}.ts`);
5141
4968
  await fs.writeFile(filePath, prepared.code);
5142
- const mod = (await import(pathToFileURL(filePath).href));
4969
+ const moduleUrl = pathToFileURL(filePath).href;
4970
+ const mod = (await runInstrumented(fetchScope, () => import(moduleUrl)));
5143
4971
  outcome = { ok: true, result: safeSerialize(mod.default) };
5144
4972
  }
5145
4973
  catch (err) {
@@ -5152,6 +4980,7 @@ async function evalCode(code, secrets) {
5152
4980
  }
5153
4981
  finally {
5154
4982
  clearRecordSecrets();
4983
+ fetchScope.active = false;
5155
4984
  restoreFetch();
5156
4985
  restoreConsole();
5157
4986
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -5219,8 +5048,7 @@ async function evalCode(code, secrets) {
5219
5048
  // ────────────────────────────────────────────────────────────────────────
5220
5049
  /** Where the baked compiler lives. Overridable so the typecheck — and the
5221
5050
  * blocking rules, which always use THIS copy rather than the project's own —
5222
- * can be driven outside a VM (same convention as
5223
- * `SPECTEST_COVERAGE_TOOLS_DIR`). */
5051
+ * can be driven outside a VM. */
5224
5052
  const TYPECHECK_DIR = process.env.SPECTEST_TYPECHECK_DIR ?? "/opt/spectest/typecheck";
5225
5053
  /** Cap on errors shipped in the report; `totalErrors` carries the true count. */
5226
5054
  const TYPECHECK_ERROR_CAP = 50;
@@ -0,0 +1,5 @@
1
+ export declare function isTransportError(err: unknown): boolean;
2
+ /** Install a dispatcher: only code in an active test/setup/eval scope gets
3
+ * wrapped responses. Fake handlers and background tasks use native fetch,
4
+ * without cloning bodies, tagging errors, or touching the recorder. */
5
+ export declare function installFetchWrapper(): () => void;
@@ -0,0 +1,165 @@
1
+ import { wrapResponse } from "../inspect.js";
2
+ import { recordHttp, reserveEvent, truncateUtf8 } 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
+ function describeFetchInput(input) {
7
+ if (typeof input === "string")
8
+ return { url: input };
9
+ if (input instanceof URL)
10
+ return { url: input.toString() };
11
+ // Request instance
12
+ const req = input;
13
+ return { url: req.url, methodFromInput: req.method };
14
+ }
15
+ function describeRequestBody(input, init) {
16
+ // For Request objects, body has already been consumed into the request;
17
+ // we can't read it back without cloning, which costs. Skip unless init.body
18
+ // is provided directly.
19
+ const body = init?.body;
20
+ if (body === undefined || body === null) {
21
+ if (input instanceof Request && input.bodyUsed === false) {
22
+ // Don't drain the request's body here — leaving it for the actual
23
+ // fetch. Return a marker.
24
+ return { body: "[Request body not captured]", truncated: false };
25
+ }
26
+ return {};
27
+ }
28
+ if (typeof body === "string") {
29
+ const t = truncateUtf8(body);
30
+ return { body: t.value, truncated: t.truncated };
31
+ }
32
+ if (body instanceof URLSearchParams) {
33
+ const t = truncateUtf8(body.toString());
34
+ return { body: t.value, truncated: t.truncated };
35
+ }
36
+ return { body: `[non-text body: ${body.constructor?.name ?? typeof body}]` };
37
+ }
38
+ /**
39
+ * Marks an error thrown by the instrumented fetch as *transport-level* —
40
+ * the connection itself failed (refused, unresolvable, reset) before any
41
+ * HTTP reply existed. `fetch` never rejects for an HTTP status, so every
42
+ * rejection short of an abort is transport. `Symbol.for` so a duplicated
43
+ * SDK module instance (the bun hardlink landmine) still recognises it.
44
+ *
45
+ * Why it exists: `ctx.poll` waits for convergence, and right after a fork
46
+ * restore the guest can serve a ~10 s window where a connect or a DNS
47
+ * lookup fails once and then heals (measured 2026-08-21: a poll's first
48
+ * fetch hung 12 s in resolution, threw, and killed a 60 s poll on attempt
49
+ * 1 while attempt 2 would have passed). During a poll, a dead connection
50
+ * is just "not ready yet"; outside one it stays a hard error.
51
+ */
52
+ const TRANSPORT_ERROR = Symbol.for("spectest.transportError");
53
+ export function isTransportError(err) {
54
+ return (typeof err === "object" &&
55
+ err !== null &&
56
+ err[TRANSPORT_ERROR] === true);
57
+ }
58
+ /** Install a dispatcher: only code in an active test/setup/eval scope gets
59
+ * wrapped responses. Fake handlers and background tasks use native fetch,
60
+ * without cloning bodies, tagging errors, or touching the recorder. */
61
+ export function installFetchWrapper() {
62
+ const original = globalThis.fetch;
63
+ // SDK internals (the MCP client, its OAuth flow) must not see the
64
+ // wrapper: a wrapped `res.ok` is an object, and the wrapper reads every
65
+ // body to the end, which never finishes for an SSE stream.
66
+ setRawFetch(original);
67
+ const wrappedFn = async (input, init) => {
68
+ const scope = currentInstrumentationScope();
69
+ if (!scope?.active)
70
+ return original(input, init);
71
+ const start = Date.now();
72
+ const resv = reserveEvent();
73
+ const { url, methodFromInput } = describeFetchInput(input);
74
+ const method = (init?.method ?? methodFromInput ?? "GET").toUpperCase();
75
+ const reqBody = describeRequestBody(input, init);
76
+ try {
77
+ const res = await original(input, init);
78
+ if (!scope.active)
79
+ return res;
80
+ let responseBody;
81
+ let responseBodyTruncated;
82
+ // The reply the test gets is untouched: everything here reads a clone,
83
+ // and a body known to be binary is not read at all. See
84
+ // ./http-body.ts for why a blanket `.text()` was wrong.
85
+ const contentType = res.headers.get("content-type");
86
+ const contentLength = () => parseContentLength(res.headers.get("content-length"));
87
+ const textual = isTextualContentType(contentType);
88
+ if (textual === false) {
89
+ responseBody = omittedBody("binary", contentType, contentLength());
90
+ }
91
+ else {
92
+ try {
93
+ const cloned = res.clone();
94
+ const text = await cloned.text();
95
+ if (textual === undefined && looksBinary(text)) {
96
+ // No content type (or a multipart one), and the bytes say this
97
+ // was never text.
98
+ responseBody = omittedBody("binary", contentType, contentLength());
99
+ }
100
+ else {
101
+ const t = truncateUtf8(text);
102
+ responseBody = t.value;
103
+ responseBodyTruncated = t.truncated;
104
+ }
105
+ }
106
+ catch {
107
+ // The stream failed, or something had already consumed the body.
108
+ responseBody = omittedBody("unreadable", contentType, contentLength());
109
+ }
110
+ }
111
+ // The test may have timed out while we awaited the response body.
112
+ if (!scope.active)
113
+ return res;
114
+ const seq = recordHttp({
115
+ method,
116
+ url,
117
+ requestBody: reqBody.body,
118
+ requestBodyTruncated: reqBody.truncated,
119
+ status: res.status,
120
+ responseBody,
121
+ responseBodyTruncated,
122
+ durationMs: Date.now() - start,
123
+ }, resv);
124
+ return wrapResponse(res, seq);
125
+ }
126
+ catch (err) {
127
+ if (!scope.active)
128
+ throw err;
129
+ const e = err;
130
+ recordHttp({
131
+ method,
132
+ url,
133
+ requestBody: reqBody.body,
134
+ requestBodyTruncated: reqBody.truncated,
135
+ durationMs: Date.now() - start,
136
+ error: e?.message ?? String(err),
137
+ }, resv);
138
+ // Tag transport failures for ctx.poll (see TRANSPORT_ERROR). An abort
139
+ // is the caller's own signal (their AbortController or their
140
+ // AbortSignal.timeout) — their semantics, never retried for them.
141
+ if (typeof err === "object" &&
142
+ err !== null &&
143
+ e?.name !== "AbortError" &&
144
+ e?.name !== "TimeoutError") {
145
+ try {
146
+ err[TRANSPORT_ERROR] = true;
147
+ }
148
+ catch {
149
+ /* frozen error object — stays a hard error */
150
+ }
151
+ }
152
+ throw err;
153
+ }
154
+ };
155
+ // Preserve any provider-specific statics on `fetch` (e.g. Bun's
156
+ // `fetch.preconnect`) so consumers that touch them keep working.
157
+ const wrapped = wrappedFn;
158
+ for (const key of Object.keys(original)) {
159
+ wrapped[key] = original[key];
160
+ }
161
+ globalThis.fetch = wrapped;
162
+ return () => {
163
+ globalThis.fetch = original;
164
+ };
165
+ }
@@ -0,0 +1,18 @@
1
+ /** One test (or setup/eval invocation). Close it even on timeout: detached
2
+ * work retains its async context and must not write into the next test. */
3
+ export interface InstrumentationScope {
4
+ active: boolean;
5
+ recording: boolean;
6
+ }
7
+ export declare function createInstrumentationScope(recording?: boolean): InstrumentationScope;
8
+ export declare function currentInstrumentationScope(): InstrumentationScope | null | undefined;
9
+ export declare function runInstrumented<T>(scope: InstrumentationScope, fn: () => T): T;
10
+ /** Preserve native behavior through awaits, timers, and nested helpers without
11
+ * changing the instrumentation of concurrent test work. */
12
+ export declare function runUninstrumented<T>(fn: () => T): T;
13
+ /** Test-authored callbacks invoked later by ingress (ctx.intercept) keep
14
+ * their registration scope, even though the incoming request has none. */
15
+ export declare function bindInstrumentationScope<A extends unknown[], R>(fn: (...args: A) => R): (...args: A) => R;
16
+ /** Unscoped SDK sites (browser/interceptor callbacks, final drains) still use
17
+ * the active recorder. Fake internals and expired test contexts cannot. */
18
+ export declare function scopeAllowsRecording(): boolean;
@@ -0,0 +1,32 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ // No scope means native fetch. `null` explicitly isolates fake execution,
3
+ // including helpers called directly from an instrumented test.
4
+ const scopes = new AsyncLocalStorage();
5
+ export function createInstrumentationScope(recording = true) {
6
+ return { active: true, recording };
7
+ }
8
+ export function currentInstrumentationScope() {
9
+ return scopes.getStore();
10
+ }
11
+ export function runInstrumented(scope, fn) {
12
+ return scopes.run(scope, fn);
13
+ }
14
+ /** Preserve native behavior through awaits, timers, and nested helpers without
15
+ * changing the instrumentation of concurrent test work. */
16
+ export function runUninstrumented(fn) {
17
+ return scopes.run(null, fn);
18
+ }
19
+ /** Test-authored callbacks invoked later by ingress (ctx.intercept) keep
20
+ * their registration scope, even though the incoming request has none. */
21
+ export function bindInstrumentationScope(fn) {
22
+ const scope = scopes.getStore();
23
+ return function (...args) {
24
+ return scopes.run(scope ?? null, () => fn.apply(this, args));
25
+ };
26
+ }
27
+ /** Unscoped SDK sites (browser/interceptor callbacks, final drains) still use
28
+ * the active recorder. Fake internals and expired test contexts cannot. */
29
+ export function scopeAllowsRecording() {
30
+ const scope = scopes.getStore();
31
+ return scope === undefined || (scope !== null && scope.active && scope.recording);
32
+ }
@@ -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
  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();