@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 CHANGED
@@ -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) {
@@ -1705,7 +1705,7 @@ async function startIngress() {
1705
1705
  for (const [name, fake] of FAKES) {
1706
1706
  if (fake.def.state) {
1707
1707
  try {
1708
- fake.state = await fake.def.state();
1708
+ fake.state = await runUninstrumented(() => fake.def.state());
1709
1709
  }
1710
1710
  catch (err) {
1711
1711
  throw new Error(`fake ${JSON.stringify(name)} state() factory threw: ${err.message}`);
@@ -2135,7 +2135,7 @@ server, byHost, listenerLabel, proto) {
2135
2135
  const upstream = async (current) => {
2136
2136
  if (route.kind === "fake") {
2137
2137
  try {
2138
- return await route.fake.def.handler(current, route.fake.state, FAKE_CTX);
2138
+ return await runUninstrumented(() => route.fake.def.handler(current, route.fake.state, FAKE_CTX));
2139
2139
  }
2140
2140
  catch (err) {
2141
2141
  const e = err;
@@ -2545,7 +2545,7 @@ function registerInterceptor(target, description, handler) {
2545
2545
  `(\`http://<service>:<port>\`) is container-to-container traffic and never passes through here.`);
2546
2546
  }
2547
2547
  const resv = reserveEvent();
2548
- const it = INTERCEPTORS.register(host, path, handler);
2548
+ const it = INTERCEPTORS.register(host, path, bindInstrumentationScope(handler));
2549
2549
  const where = `${host}${it.path === "/" ? "" : it.path}`;
2550
2550
  const seq = recordStep({
2551
2551
  kind: "intercept",
@@ -2763,11 +2763,11 @@ async function ensureFakeHelpers(name) {
2763
2763
  if (fake.trackedHelpers)
2764
2764
  return fake.trackedHelpers;
2765
2765
  fake.helpers = fake.def.helpers
2766
- ? (await fake.def.helpers({
2766
+ ? (await runUninstrumented(() => fake.def.helpers({
2767
2767
  name,
2768
2768
  state: fake.state,
2769
2769
  ctx: FAKE_CTX,
2770
- }))
2770
+ })))
2771
2771
  : {};
2772
2772
  fake.trackedHelpers = trackFakeHelpers(name, fake.helpers);
2773
2773
  return fake.trackedHelpers;
@@ -2849,39 +2849,22 @@ function invokeFakeHelper(fakeName, member, fn, thisArg, callArgs) {
2849
2849
  error: errMessage(err),
2850
2850
  }, resv);
2851
2851
  };
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();
2852
+ // Record the public helper call in the caller's scope, while its body
2853
+ // and async descendants use native fetch and emit no internal events.
2865
2854
  let result;
2866
2855
  try {
2867
- result = fn.apply(thisArg, args);
2856
+ result = runUninstrumented(() => fn.apply(thisArg, args));
2868
2857
  }
2869
2858
  catch (err) {
2870
- resumeRecording();
2871
2859
  recordError(err);
2872
2860
  throw err;
2873
2861
  }
2874
2862
  if (result instanceof Promise) {
2875
- return result.then((value) => {
2876
- resumeRecording();
2877
- return recordResult(value);
2878
- }, (err) => {
2879
- resumeRecording();
2863
+ return result.then(recordResult, (err) => {
2880
2864
  recordError(err);
2881
2865
  throw err;
2882
2866
  });
2883
2867
  }
2884
- resumeRecording();
2885
2868
  return recordResult(result);
2886
2869
  }
2887
2870
  /** Record a fake-helper call's render annotation as a child of the call's
@@ -3176,14 +3159,16 @@ async function runProjectSetupInner() {
3176
3159
  // as in a test. No recorder is active here, so it wraps without provenance —
3177
3160
  // but the wrapped type stays honest at runtime (`.unwrap()` works).
3178
3161
  const restoreFetch = installFetchWrapper();
3162
+ const fetchScope = createInstrumentationScope(false);
3179
3163
  // The full context, unscoped: every service is up by project-setup time,
3180
3164
  // so `ctx.svc` is the same map tests see (and shares helper instances with
3181
3165
  // them — a Bun.SQL pool created here is reused later).
3182
3166
  const ctx = await spectestContext();
3183
3167
  try {
3184
- await proj.setup(ctx);
3168
+ await runInstrumented(fetchScope, () => proj.setup(ctx));
3185
3169
  }
3186
3170
  finally {
3171
+ fetchScope.active = false;
3187
3172
  restoreFetch();
3188
3173
  }
3189
3174
  return { ran: true, durationMs: Date.now() - start };
@@ -3906,160 +3891,6 @@ async function buildServiceHandles(cfg) {
3906
3891
  // ────────────────────────────────────────────────────────────────────────
3907
3892
  // Instrumentation helpers
3908
3893
  // ────────────────────────────────────────────────────────────────────────
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
3894
  /** Build the `docker exec` argv for a service command. A string command runs
4064
3895
  * through `sh -lc`; an array is exact argv with no shell (the
4065
3896
  * `["psql", "-f", "-"]` shape). An optional `cwd` becomes `-w <cwd>` (the
@@ -4522,6 +4353,7 @@ async function runOne(testCase) {
4522
4353
  };
4523
4354
  startRecording();
4524
4355
  const restoreFetch = installFetchWrapper();
4356
+ const fetchScope = createInstrumentationScope();
4525
4357
  // Look up the parent's stored return value (if any). The parent ran in
4526
4358
  // an ancestor fork; its TEST_DATA entry travels with the snapshot.
4527
4359
  const parentId = testCase.dependsOn?.id;
@@ -4653,7 +4485,7 @@ async function runOne(testCase) {
4653
4485
  let outcome;
4654
4486
  try {
4655
4487
  const value = await Promise.race([
4656
- Promise.resolve(testCase.run(ctx)),
4488
+ Promise.resolve(runInstrumented(fetchScope, () => testCase.run(ctx))),
4657
4489
  timedOut,
4658
4490
  ]);
4659
4491
  // Stash the return value so child cases — which fork from the snapshot
@@ -4673,6 +4505,7 @@ async function runOne(testCase) {
4673
4505
  clearTimeout(timer);
4674
4506
  RECORDING_EXEC = undefined;
4675
4507
  INTERCEPTORS.endScope();
4508
+ fetchScope.active = false;
4676
4509
  restoreFetch();
4677
4510
  restoreConsole();
4678
4511
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -5004,6 +4837,7 @@ async function evalCode(code, secrets) {
5004
4837
  // Response just like in a test (no recorder here, so no provenance — but the
5005
4838
  // wrapped type is honest at runtime). Restored in the `finally` below.
5006
4839
  const restoreFetch = installFetchWrapper();
4840
+ const fetchScope = createInstrumentationScope(false);
5007
4841
  // Same persistent acquire/detach as a test run (see runOne): the browser
5008
4842
  // survives the eval, so successive `spectest env eval` calls continue one
5009
4843
  // live session — and a snapshot taken afterwards carries it.
@@ -5139,7 +4973,8 @@ async function evalCode(code, secrets) {
5139
4973
  await fs.mkdir(EVAL_DIR, { recursive: true });
5140
4974
  filePath = path.join(EVAL_DIR, `${randomUUID()}.ts`);
5141
4975
  await fs.writeFile(filePath, prepared.code);
5142
- const mod = (await import(pathToFileURL(filePath).href));
4976
+ const moduleUrl = pathToFileURL(filePath).href;
4977
+ const mod = (await runInstrumented(fetchScope, () => import(moduleUrl)));
5143
4978
  outcome = { ok: true, result: safeSerialize(mod.default) };
5144
4979
  }
5145
4980
  catch (err) {
@@ -5152,6 +4987,7 @@ async function evalCode(code, secrets) {
5152
4987
  }
5153
4988
  finally {
5154
4989
  clearRecordSecrets();
4990
+ fetchScope.active = false;
5155
4991
  restoreFetch();
5156
4992
  restoreConsole();
5157
4993
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -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
+ }
@@ -35,7 +35,7 @@
35
35
  import net from "node:net";
36
36
  import { METHODS } from "../daemon";
37
37
  import { codeOf } from "./methods";
38
- import { PROTOCOL_VERSION, decodeFrame, encodeFrame, fail, ok, } from "./protocol";
38
+ import { PROTOCOL_VERSION, decodeFrame, describeThrow, encodeFrame, fail, ok, } from "./protocol";
39
39
  /** Capabilities this build announces. Empty is fine — the slot is what
40
40
  * matters, so a later release has something to negotiate against. */
41
41
  const CAPABILITIES = [];
@@ -87,11 +87,11 @@ export function serve(socket, registry) {
87
87
  //
88
88
  // A refusal the method meant to make (an unknown case, a second test
89
89
  // while one is running) reports its own kind and just its message. An
90
- // unexpected throw is a bug in the method and carries the stack,
91
- // which is the only place that information exists.
90
+ // unexpected throw carries its message AND its stack — in that order,
91
+ // because a stack alone is sometimes headerless (`describeThrow`).
92
92
  const e = err;
93
93
  const code = codeOf(err);
94
- const detail = code === "handler_error" ? (e?.stack ?? e?.message ?? String(err)) : e.message;
94
+ const detail = code === "handler_error" ? describeThrow(err) : e.message;
95
95
  send(fail(req.id, detail, code));
96
96
  }
97
97
  };
@@ -86,3 +86,23 @@ export declare function ok(id: string, result?: Record<string, unknown>): Respon
86
86
  /** A failed reply to `id`. Always carries the id: a request that failed must
87
87
  * never leave its caller waiting. */
88
88
  export declare function fail(id: string, message: string, code?: string): ResponseFrame;
89
+ /**
90
+ * Describe a thrown value for the wire: the **message first**, then the
91
+ * frames — never the stack on its own.
92
+ *
93
+ * `error.stack` normally opens with `Error: <message>`, which is why it
94
+ * looked like the richer of the two. It is not always: Bun drops that
95
+ * header when the rejection is observed a turn later than it settled —
96
+ * a promise that rejected while nobody was awaiting it yet, which is
97
+ * exactly what a bootstrap image build is (`daemon.ts` starts every
98
+ * build at once and each service awaits its own prep when the DAG
99
+ * reaches it). The whole diagnosis of a failed build IS the message
100
+ * there: `docker build for <svc> failed:` plus the build log. Sending
101
+ * the stack alone published four frames and nothing else — no docker,
102
+ * no BuildKit, no compiler error (reported by the Harmony project,
103
+ * 2026-09-09; the same run's log had the message intact).
104
+ *
105
+ * A stack that already carries the message is returned as it stands, so
106
+ * the ordinary case is unchanged.
107
+ */
108
+ export declare function describeThrow(err: unknown): string;
@@ -94,3 +94,37 @@ export function ok(id, result = {}) {
94
94
  export function fail(id, message, code) {
95
95
  return { kind: "response", id, ok: false, error: code ? { message, code } : { message } };
96
96
  }
97
+ /**
98
+ * Describe a thrown value for the wire: the **message first**, then the
99
+ * frames — never the stack on its own.
100
+ *
101
+ * `error.stack` normally opens with `Error: <message>`, which is why it
102
+ * looked like the richer of the two. It is not always: Bun drops that
103
+ * header when the rejection is observed a turn later than it settled —
104
+ * a promise that rejected while nobody was awaiting it yet, which is
105
+ * exactly what a bootstrap image build is (`daemon.ts` starts every
106
+ * build at once and each service awaits its own prep when the DAG
107
+ * reaches it). The whole diagnosis of a failed build IS the message
108
+ * there: `docker build for <svc> failed:` plus the build log. Sending
109
+ * the stack alone published four frames and nothing else — no docker,
110
+ * no BuildKit, no compiler error (reported by the Harmony project,
111
+ * 2026-09-09; the same run's log had the message intact).
112
+ *
113
+ * A stack that already carries the message is returned as it stands, so
114
+ * the ordinary case is unchanged.
115
+ */
116
+ export function describeThrow(err) {
117
+ const e = err;
118
+ const message = typeof e?.message === "string" ? e.message : "";
119
+ const stack = typeof e?.stack === "string" ? e.stack : "";
120
+ if (!message)
121
+ return stack || String(err);
122
+ if (stack.includes(message))
123
+ return stack;
124
+ const name = typeof e?.name === "string" && e.name.length > 0 ? e.name : "Error";
125
+ // The headerless form is `<Name>\n at …`. Drop that bare first line
126
+ // and write the header ourselves, so the result reads like the stack a
127
+ // reader expects rather than a message with a stray `Error` in it.
128
+ const frames = stack.startsWith(`${name}\n`) ? stack.slice(name.length + 1) : stack;
129
+ return frames.length > 0 ? `${name}: ${message}\n${frames}` : `${name}: ${message}`;
130
+ }