@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/src/daemon.ts CHANGED
@@ -53,9 +53,6 @@ import {
53
53
  coverageAdapters,
54
54
  coverageReportsMode,
55
55
  validateCoverage,
56
- nodeCoverageToolsAvailable,
57
- nodeCoverageToolsDir,
58
- NODE_COVERAGE_TOOLS_CONTAINER_DIR,
59
56
  type CoverageAdapter,
60
57
  type CoverageCaptureContext,
61
58
  } from "./coverage.js";
@@ -95,12 +92,13 @@ import { runWrapperRules } from "./harness/wrapper-rules.js";
95
92
  import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
96
93
  import { cpus } from "node:os";
97
94
  import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath } from "./project-files.js";
95
+ import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
98
96
  import {
99
- isTextualContentType,
100
- looksBinary,
101
- omittedBody,
102
- parseContentLength,
103
- } from "./harness/http-body.js";
97
+ bindInstrumentationScope,
98
+ createInstrumentationScope,
99
+ runInstrumented,
100
+ runUninstrumented,
101
+ } from "./harness/instrumentation-scope.js";
104
102
  import { encodeRegistry } from "./harness/names-registry.js";
105
103
  import {
106
104
  InterceptRegistry,
@@ -169,16 +167,13 @@ import { openTerminal } from "./terminal.js";
169
167
  // `ctx.mcp(url)`. One client per call, no registry: an authenticated
170
168
  // client is passed to descendants as a test's return value.
171
169
  import { openMcp } from "./mcp.js";
172
- import { setRawFetch } from "./harness/raw-fetch.js";
173
170
  import { readAnnotation, type RenderAnnotation } from "./annotate.js";
174
171
  import {
175
172
  isRecording,
176
- pauseRecording,
177
173
  recordEmail,
178
174
  recordEnv,
179
175
  recordExec,
180
176
  recordFake,
181
- recordHttp,
182
177
  recordStep,
183
178
  recordTerminal,
184
179
  recordWait,
@@ -186,16 +181,14 @@ import {
186
181
  recorderEventCount,
187
182
  recorderMarkChildren,
188
183
  recorderTruncate,
189
- resumeRecording,
190
184
  startRecording,
191
185
  stopRecording,
192
186
  truncateUtf8,
193
187
  type StepBlock,
194
188
  type TestEvent,
195
- type OmittedBody,
196
189
  } from "./recorder.js";
197
- import { deepUnwrap, readRaw, wrap, wrapResponse } from "./inspect.js";
198
- import type { Wrapped, WrappedResponse } from "./inspect.js";
190
+ import { deepUnwrap, readRaw, wrap } from "./inspect.js";
191
+ import type { Wrapped } from "./inspect.js";
199
192
  import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
200
193
  import { generateId } from "./ids.js";
201
194
  import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
@@ -983,16 +976,9 @@ async function ensureCoverage(svc: NamedService): Promise<string[]> {
983
976
  const host = coverageHostDir(WORKSPACE, svc.name);
984
977
  await fs.mkdir(host, { recursive: true });
985
978
  await fs.chmod(host, 0o777);
986
- const flags = [`--volume=${host}:${COVERAGE_CONTAINER_DIR}`];
987
- // The conversion tools golden ships (`c8` for `coverage.node()`),
988
- // read-only, on the same existence check the adapter makes at capture
989
- // — so the VM and the container agree on whether they are there. A
990
- // plain `--volume`, never a config `volumes` entry: an absolute-source
991
- // volume dir is listed in the delta-restore manifest and wiped.
992
- if (await nodeCoverageToolsAvailable()) {
993
- flags.push(`--volume=${nodeCoverageToolsDir()}:${NODE_COVERAGE_TOOLS_CONTAINER_DIR}:ro`);
994
- }
995
- return flags;
979
+ // Nothing else is mounted: no conversion runs in the container (the
980
+ // c8 tools mount went with SDK 0.80).
981
+ return [`--volume=${host}:${COVERAGE_CONTAINER_DIR}`];
996
982
  }
997
983
 
998
984
  /** A `user`/`group` pair resolved to the numeric ids the kernel wants.
@@ -2150,7 +2136,7 @@ async function startIngress(): Promise<void> {
2150
2136
  for (const [name, fake] of FAKES) {
2151
2137
  if (fake.def.state) {
2152
2138
  try {
2153
- fake.state = await fake.def.state();
2139
+ fake.state = await runUninstrumented(() => fake.def.state!());
2154
2140
  } catch (err) {
2155
2141
  throw new Error(
2156
2142
  `fake ${JSON.stringify(name)} state() factory threw: ${(err as Error).message}`,
@@ -2604,7 +2590,9 @@ async function dispatchIngress(
2604
2590
  const upstream = async (current: Request): Promise<Response> => {
2605
2591
  if (route.kind === "fake") {
2606
2592
  try {
2607
- return await route.fake.def.handler(current, route.fake.state, FAKE_CTX);
2593
+ return await runUninstrumented(() =>
2594
+ route.fake.def.handler(current, route.fake.state, FAKE_CTX),
2595
+ );
2608
2596
  } catch (err) {
2609
2597
  const e = err as Error;
2610
2598
  return new Response(
@@ -3057,7 +3045,7 @@ function registerInterceptor(
3057
3045
  );
3058
3046
  }
3059
3047
  const resv = reserveEvent();
3060
- const it = INTERCEPTORS.register(host, path, handler);
3048
+ const it = INTERCEPTORS.register(host, path, bindInstrumentationScope(handler));
3061
3049
  const where = `${host}${it.path === "/" ? "" : it.path}`;
3062
3050
  const seq = recordStep(
3063
3051
  {
@@ -3288,11 +3276,11 @@ async function ensureFakeHelpers(name: string): Promise<Record<string, unknown>>
3288
3276
  if (!fake) throw new Error(`fake ${JSON.stringify(name)} is not loaded`);
3289
3277
  if (fake.trackedHelpers) return fake.trackedHelpers;
3290
3278
  fake.helpers = fake.def.helpers
3291
- ? ((await fake.def.helpers({
3279
+ ? ((await runUninstrumented(() => fake.def.helpers!({
3292
3280
  name,
3293
3281
  state: fake.state,
3294
3282
  ctx: FAKE_CTX,
3295
- })) as Record<string, unknown>)
3283
+ }))) as Record<string, unknown>)
3296
3284
  : {};
3297
3285
  fake.trackedHelpers = trackFakeHelpers(name, fake.helpers);
3298
3286
  return fake.trackedHelpers;
@@ -3386,41 +3374,24 @@ function invokeFakeHelper(
3386
3374
  }, resv);
3387
3375
  };
3388
3376
 
3389
- // A helper call is ONE step. Whatever the fake does inside it — a fetch to
3390
- // deliver a webhook, a call to its own API — is the fake's plumbing, not
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();
3377
+ // Record the public helper call in the caller's scope, while its body
3378
+ // and async descendants use native fetch and emit no internal events.
3402
3379
  let result: unknown;
3403
3380
  try {
3404
- result = fn.apply(thisArg, args);
3381
+ result = runUninstrumented(() => fn.apply(thisArg, args));
3405
3382
  } catch (err) {
3406
- resumeRecording();
3407
3383
  recordError(err);
3408
3384
  throw err;
3409
3385
  }
3410
3386
  if (result instanceof Promise) {
3411
3387
  return result.then(
3412
- (value) => {
3413
- resumeRecording();
3414
- return recordResult(value);
3415
- },
3388
+ recordResult,
3416
3389
  (err) => {
3417
- resumeRecording();
3418
3390
  recordError(err);
3419
3391
  throw err;
3420
3392
  },
3421
3393
  );
3422
3394
  }
3423
- resumeRecording();
3424
3395
  return recordResult(result);
3425
3396
  }
3426
3397
 
@@ -3756,13 +3727,15 @@ async function runProjectSetupInner(): Promise<ProjectSetupResult> {
3756
3727
  // as in a test. No recorder is active here, so it wraps without provenance —
3757
3728
  // but the wrapped type stays honest at runtime (`.unwrap()` works).
3758
3729
  const restoreFetch = installFetchWrapper();
3730
+ const fetchScope = createInstrumentationScope(false);
3759
3731
  // The full context, unscoped: every service is up by project-setup time,
3760
3732
  // so `ctx.svc` is the same map tests see (and shares helper instances with
3761
3733
  // them — a Bun.SQL pool created here is reused later).
3762
3734
  const ctx: ProjectSetupContext = await spectestContext();
3763
3735
  try {
3764
- await proj.setup(ctx);
3736
+ await runInstrumented(fetchScope, () => proj.setup!(ctx));
3765
3737
  } finally {
3738
+ fetchScope.active = false;
3766
3739
  restoreFetch();
3767
3740
  }
3768
3741
  return { ran: true, durationMs: Date.now() - start };
@@ -4753,173 +4726,6 @@ async function buildServiceHandles(cfg: EnvironmentConfig): Promise<ServiceHandl
4753
4726
  // Instrumentation helpers
4754
4727
  // ────────────────────────────────────────────────────────────────────────
4755
4728
 
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
4729
  /** Build the `docker exec` argv for a service command. A string command runs
4924
4730
  * through `sh -lc`; an array is exact argv with no shell (the
4925
4731
  * `["psql", "-f", "-"]` shape). An optional `cwd` becomes `-w <cwd>` (the
@@ -5481,6 +5287,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
5481
5287
 
5482
5288
  startRecording();
5483
5289
  const restoreFetch = installFetchWrapper();
5290
+ const fetchScope = createInstrumentationScope();
5484
5291
 
5485
5292
  // Look up the parent's stored return value (if any). The parent ran in
5486
5293
  // an ancestor fork; its TEST_DATA entry travels with the snapshot.
@@ -5622,7 +5429,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
5622
5429
  let outcome: { status: "passed" | "failed"; error?: RunResult["error"] };
5623
5430
  try {
5624
5431
  const value = await Promise.race([
5625
- Promise.resolve(testCase.run(ctx)),
5432
+ Promise.resolve(runInstrumented(fetchScope, () => testCase.run(ctx))),
5626
5433
  timedOut,
5627
5434
  ]);
5628
5435
  // Stash the return value so child cases — which fork from the snapshot
@@ -5639,6 +5446,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
5639
5446
  if (timer) clearTimeout(timer);
5640
5447
  RECORDING_EXEC = undefined;
5641
5448
  INTERCEPTORS.endScope();
5449
+ fetchScope.active = false;
5642
5450
  restoreFetch();
5643
5451
  restoreConsole();
5644
5452
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -6013,6 +5821,7 @@ async function evalCode(
6013
5821
  // Response just like in a test (no recorder here, so no provenance — but the
6014
5822
  // wrapped type is honest at runtime). Restored in the `finally` below.
6015
5823
  const restoreFetch = installFetchWrapper();
5824
+ const fetchScope = createInstrumentationScope(false);
6016
5825
 
6017
5826
  // Same persistent acquire/detach as a test run (see runOne): the browser
6018
5827
  // survives the eval, so successive `spectest env eval` calls continue one
@@ -6177,7 +5986,8 @@ async function evalCode(
6177
5986
  await fs.mkdir(EVAL_DIR, { recursive: true });
6178
5987
  filePath = path.join(EVAL_DIR, `${randomUUID()}.ts`);
6179
5988
  await fs.writeFile(filePath, prepared.code);
6180
- const mod = (await import(pathToFileURL(filePath).href)) as { default?: unknown };
5989
+ const moduleUrl = pathToFileURL(filePath).href;
5990
+ const mod = (await runInstrumented(fetchScope, () => import(moduleUrl))) as { default?: unknown };
6181
5991
  outcome = { ok: true, result: safeSerialize(mod.default) };
6182
5992
  } catch (err) {
6183
5993
  const e = err as Error;
@@ -6188,6 +5998,7 @@ async function evalCode(
6188
5998
  };
6189
5999
  } finally {
6190
6000
  clearRecordSecrets();
6001
+ fetchScope.active = false;
6191
6002
  restoreFetch();
6192
6003
  restoreConsole();
6193
6004
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -6255,8 +6066,7 @@ async function evalCode(
6255
6066
 
6256
6067
  /** Where the baked compiler lives. Overridable so the typecheck — and the
6257
6068
  * blocking rules, which always use THIS copy rather than the project's own —
6258
- * can be driven outside a VM (same convention as
6259
- * `SPECTEST_COVERAGE_TOOLS_DIR`). */
6069
+ * can be driven outside a VM. */
6260
6070
  const TYPECHECK_DIR = process.env.SPECTEST_TYPECHECK_DIR ?? "/opt/spectest/typecheck";
6261
6071
  /** Cap on errors shipped in the report; `totalErrors` carries the true count. */
6262
6072
  const TYPECHECK_ERROR_CAP = 50;
@@ -0,0 +1,253 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { expect as assertValue } from "../index.js";
3
+ import { type WrappedResponse } from "../inspect.js";
4
+ import { recordFake, startRecording, stopRecording } from "../recorder.js";
5
+ import { installFetchWrapper, isTransportError } from "./fetch.js";
6
+ import {
7
+ bindInstrumentationScope,
8
+ createInstrumentationScope,
9
+ runInstrumented,
10
+ runUninstrumented,
11
+ type InstrumentationScope,
12
+ } from "./instrumentation-scope.js";
13
+
14
+ function deferred<T = void>() {
15
+ let resolve!: (value: T) => void;
16
+ const promise = new Promise<T>((done) => { resolve = done; });
17
+ return { promise, resolve };
18
+ }
19
+
20
+ describe("fetch execution scopes", () => {
21
+ let scope: InstrumentationScope;
22
+ let restore: () => void;
23
+ let server: ReturnType<typeof Bun.serve>;
24
+ let handler: (req: Request) => Response | Promise<Response>;
25
+ const url = (path: string) => new URL(path, server.url).href;
26
+
27
+ beforeEach(() => {
28
+ handler = () => Response.json({ value: 42 });
29
+ server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (req) => handler(req) });
30
+ startRecording();
31
+ scope = createInstrumentationScope();
32
+ restore = installFetchWrapper();
33
+ });
34
+
35
+ afterEach(() => {
36
+ scope.active = false;
37
+ restore();
38
+ stopRecording();
39
+ server.stop(true);
40
+ });
41
+
42
+ test("test helpers retain HTTP and assertion provenance across awaits", async () => {
43
+ await runInstrumented(scope, async () => {
44
+ const helper = async () => {
45
+ await Promise.resolve();
46
+ return await fetch(url("/test")) as unknown as WrappedResponse;
47
+ };
48
+ const res = await helper();
49
+ expect(res.status.unwrap()).toBe(200);
50
+ assertValue(res.status).toBe(200);
51
+ assertValue((await res.json<{ value: number }>()).value).toBe(42);
52
+ });
53
+ const events = stopRecording();
54
+ expect(events.map((e) => e.kind)).toEqual(["http", "assertion", "assertion"]);
55
+ expect(events[1]).toMatchObject({ sourceSeq: events[0]!.seq, path: ["status"] });
56
+ expect(events[2]).toMatchObject({ sourceSeq: events[0]!.seq, path: ["body", "value"] });
57
+ });
58
+
59
+ test("background fetch is native even while a test recorder is active", async () => {
60
+ handler = () => Response.json({ error: "missing" }, { status: 404 });
61
+ const res = await fetch(url("/background"));
62
+ expect(res.status).toBe(404);
63
+ expect(res.ok).toBe(false);
64
+ expect(await res.json()).toEqual({ error: "missing" });
65
+ expect(stopRecording()).toEqual([]);
66
+ });
67
+
68
+ test("an HTTP fake records only the test's outer request", async () => {
69
+ handler = (req) => {
70
+ if (new URL(req.url).pathname === "/fake") {
71
+ return runUninstrumented(async () => {
72
+ await Promise.resolve();
73
+ const res = await fetch(url("/upstream"));
74
+ // Ordinary HTTP status/error handling must work inside the fake.
75
+ if (!res.ok) return new Response(await res.text(), { status: res.status });
76
+ return new Response("unexpected success");
77
+ });
78
+ }
79
+ return new Response("upstream unavailable", { status: 503 });
80
+ };
81
+ await runInstrumented(scope, async () => {
82
+ const res = await fetch(url("/fake")) as unknown as WrappedResponse;
83
+ expect(res.status.unwrap()).toBe(503);
84
+ expect((await res.text()).unwrap()).toBe("upstream unavailable");
85
+ });
86
+ expect(stopRecording()).toMatchObject([{ kind: "http", url: url("/fake"), status: 503 }]);
87
+ });
88
+
89
+ test("async fake internals do not suppress concurrent test events", async () => {
90
+ const entered = deferred();
91
+ const release = deferred();
92
+ await runInstrumented(scope, async () => {
93
+ const fake = runUninstrumented(async () => {
94
+ entered.resolve();
95
+ await release.promise;
96
+ const res = await fetch(url("/fake-internal"));
97
+ expect(res.status).toBe(200);
98
+ recordFake({ fake: "nested", member: "internal", args: [], durationMs: 0 });
99
+ return (await res.json()).value;
100
+ });
101
+ // Like invokeFakeHelper, the public call's completion is recorded in
102
+ // its caller's scope; internal operations stay in the fake scope.
103
+ const completed = fake.then((value) => {
104
+ recordFake({ fake: "example", member: "read", args: [], result: value, durationMs: 0 });
105
+ });
106
+ await entered.promise;
107
+ try {
108
+ await fetch(url("/test-during-fake"));
109
+ } finally {
110
+ release.resolve();
111
+ }
112
+ await completed;
113
+ await fetch(url("/test-after-fake"));
114
+ });
115
+ expect(stopRecording()).toMatchObject([
116
+ { kind: "http", url: url("/test-during-fake") },
117
+ { kind: "fake", fake: "example", member: "read", result: 42 },
118
+ { kind: "http", url: url("/test-after-fake") },
119
+ ]);
120
+ });
121
+
122
+ test("fake streams are returned without cloning or waiting for the body to end", async () => {
123
+ let controller!: ReadableStreamDefaultController<Uint8Array>;
124
+ handler = () => new Response(new ReadableStream<Uint8Array>({
125
+ start(c) {
126
+ controller = c;
127
+ c.enqueue(new TextEncoder().encode("data: first\n\n"));
128
+ },
129
+ }), { headers: { "content-type": "text/event-stream" } });
130
+ await runInstrumented(scope, () => runUninstrumented(async () => {
131
+ const res = await fetch(url("/stream"));
132
+ expect(res.status).toBe(200);
133
+ expect(res.bodyUsed).toBe(false);
134
+ const reader = res.body!.getReader();
135
+ try {
136
+ expect(new TextDecoder().decode((await reader.read()).value)).toBe("data: first\n\n");
137
+ } finally {
138
+ controller.close();
139
+ await reader.cancel();
140
+ }
141
+ }));
142
+ expect(stopRecording()).toEqual([]);
143
+ }, 2_000);
144
+
145
+ test("test-authored ingress callbacks retain their scope but their fake upstream does not", async () => {
146
+ const callback = runInstrumented(scope, () => bindInstrumentationScope(async () => {
147
+ const res = await fetch(url("/interceptor")) as unknown as WrappedResponse;
148
+ expect(res.status.unwrap()).toBe(200);
149
+ await runUninstrumented(async () => {
150
+ const upstream = await fetch(url("/fake-upstream"));
151
+ expect(upstream.status).toBe(200);
152
+ });
153
+ }));
154
+ // Dispatch from outside the test's async call chain, like ingress does.
155
+ await callback();
156
+ expect(stopRecording()).toMatchObject([{ kind: "http", url: url("/interceptor") }]);
157
+ });
158
+
159
+ test("fake errors keep native semantics and throwing restores the test scope", async () => {
160
+ await runInstrumented(scope, async () => {
161
+ let rawError: unknown;
162
+ try {
163
+ await runUninstrumented(async () => {
164
+ await Promise.resolve();
165
+ await fetch("http://[invalid");
166
+ });
167
+ } catch (err) {
168
+ rawError = err;
169
+ }
170
+ expect(rawError).toBeInstanceOf(Error);
171
+ expect(isTransportError(rawError)).toBe(false);
172
+ let recordedError: unknown;
173
+ try { await fetch("http://[invalid"); } catch (err) { recordedError = err; }
174
+ expect(isTransportError(recordedError)).toBe(true);
175
+ await fetch(url("/test"));
176
+ });
177
+ expect(stopRecording().map((e) => e.kind)).toEqual(["http", "http"]);
178
+ });
179
+
180
+ test("late responses and detached callbacks cannot record into the next test", async () => {
181
+ const entered = deferred();
182
+ const release = deferred();
183
+ handler = async (req) => {
184
+ if (new URL(req.url).pathname === "/late") {
185
+ entered.resolve();
186
+ await release.promise;
187
+ }
188
+ return Response.json({ value: 42 });
189
+ };
190
+ const late = runInstrumented(scope, async () => {
191
+ const res = await fetch(url("/late"));
192
+ expect(res.status).toBe(200);
193
+ await fetch(url("/detached"));
194
+ });
195
+ await entered.promise;
196
+ scope.active = false;
197
+ restore();
198
+ expect(stopRecording()).toEqual([]);
199
+ startRecording();
200
+ restore = installFetchWrapper();
201
+ const next = createInstrumentationScope();
202
+ try {
203
+ release.resolve();
204
+ await late;
205
+ await runInstrumented(next, () => fetch(url("/next-test")));
206
+ expect(stopRecording()).toMatchObject([{ kind: "http", url: url("/next-test"), seq: 0 }]);
207
+ } finally {
208
+ next.active = false;
209
+ }
210
+ });
211
+
212
+ test("a response body finishing after scope closure cannot record into a new test", async () => {
213
+ let controller!: ReadableStreamDefaultController<Uint8Array>;
214
+ const reading = deferred();
215
+ handler = () => new Response(new ReadableStream<Uint8Array>({
216
+ start(c) { controller = c; },
217
+ pull(c) {
218
+ c.enqueue(new TextEncoder().encode("part"));
219
+ reading.resolve();
220
+ // Keep the body open until the test closes the scope.
221
+ return new Promise(() => {});
222
+ },
223
+ }), { headers: { "content-type": "text/plain" } });
224
+ const pending = runInstrumented(scope, () => fetch(url("/body")));
225
+ await reading.promise;
226
+ // Yield so the fetch wrapper can start reading its clone.
227
+ await new Promise((resolve) => setTimeout(resolve, 0));
228
+ scope.active = false;
229
+ stopRecording();
230
+ startRecording();
231
+ controller.close();
232
+ const res = await pending;
233
+ expect(res.status).toBe(200);
234
+ expect(await res.text()).toBe("part");
235
+ expect(stopRecording()).toEqual([]);
236
+ });
237
+
238
+ test("setup/eval keep wrapped responses without recording or wrapping fake internals", async () => {
239
+ const setup = createInstrumentationScope(false);
240
+ try {
241
+ await runInstrumented(setup, async () => {
242
+ const res = await fetch(url("/setup")) as unknown as WrappedResponse;
243
+ expect(res.status.unwrap()).toBe(200);
244
+ await runUninstrumented(async () => {
245
+ expect((await fetch(url("/setup-fake"))).status).toBe(200);
246
+ });
247
+ });
248
+ expect(stopRecording()).toEqual([]);
249
+ } finally {
250
+ setup.active = false;
251
+ }
252
+ });
253
+ });