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