@specific.dev/spectest 0.79.1 → 0.79.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/daemon.js +23 -187
- package/dist/harness/fetch.d.ts +5 -0
- package/dist/harness/fetch.js +165 -0
- package/dist/harness/instrumentation-scope.d.ts +18 -0
- package/dist/harness/instrumentation-scope.js +32 -0
- package/dist/harness/raw-fetch.js +5 -4
- package/dist/index.d.ts +5 -0
- package/dist/recorder.js +2 -1
- package/package.json +1 -1
- package/src/daemon.ts +29 -208
- package/src/harness/fetch.test.ts +253 -0
- package/src/harness/fetch.ts +174 -0
- package/src/harness/instrumentation-scope.ts +46 -0
- package/src/harness/raw-fetch.ts +5 -4
- package/src/index.ts +5 -0
- package/src/recorder.ts +2 -1
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 {
|
|
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,
|
|
63
|
-
import { deepUnwrap, readRaw, wrap
|
|
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
|
-
//
|
|
2853
|
-
//
|
|
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((
|
|
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
|
|
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
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The real `fetch`, for SDK internals that must not go through the test
|
|
2
2
|
// recorder's wrapper.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// The daemon's `globalThis.fetch` dispatcher instruments calls in the async
|
|
5
|
+
// scope of a test (or setup/eval): it records an `http` event, and hands back
|
|
6
6
|
// a WRAPPED response whose `ok` / `status` are provenance handles rather
|
|
7
7
|
// than a boolean and a number. That is exactly right for a test's own
|
|
8
8
|
// calls, and exactly wrong inside the SDK, in two ways:
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
// handed its response only after the stream it was waiting to read
|
|
18
18
|
// has already ended. A long-lived stream deadlocks.
|
|
19
19
|
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
20
|
+
// Fake execution is explicitly outside that scope. SDK-internal HTTP that
|
|
21
|
+
// runs WITHIN a test's scope goes through `rawFetch` instead. The daemon
|
|
22
|
+
// publishes the original here when it installs its wrapper.
|
|
22
23
|
let original;
|
|
23
24
|
/** Called by the daemon with the real `fetch`, before it installs its
|
|
24
25
|
* instrumented one. */
|
package/dist/index.d.ts
CHANGED
|
@@ -1405,6 +1405,9 @@ export interface FakeDefinition<S = any, H extends Record<string, unknown> = Rec
|
|
|
1405
1405
|
* real backing instance, `ctx.dnsName(...)` to name it). Return any
|
|
1406
1406
|
* `Response`. Thrown errors surface as 500s. The request URL is the
|
|
1407
1407
|
* absolute URL the client used — useful for routing on the path.
|
|
1408
|
+
* Fake code uses native `fetch`: responses are ordinary `Response` objects
|
|
1409
|
+
* and internal requests are not added to a running test's timeline. This
|
|
1410
|
+
* also applies to state/helper factories and helper implementations.
|
|
1408
1411
|
*/
|
|
1409
1412
|
handler: (req: Request, state: S, ctx: FakeContext) => Response | Promise<Response>;
|
|
1410
1413
|
/**
|
|
@@ -1420,6 +1423,8 @@ export interface FakeDefinition<S = any, H extends Record<string, unknown> = Rec
|
|
|
1420
1423
|
* Every call is tracked in the test timeline: it records a `fake` step
|
|
1421
1424
|
* and the return value is tagged so a later `expect(...)` on it nests
|
|
1422
1425
|
* under that step in the UI (same provenance as `fetch`/db results).
|
|
1426
|
+
* Operations inside the helper are uninstrumented, including across awaits;
|
|
1427
|
+
* concurrent operations in test code continue to record normally.
|
|
1423
1428
|
* The step renders the return value as JSON; wrap it in {@link annotate}
|
|
1424
1429
|
* to add a richer view (an email, today) that the step's panel leads with,
|
|
1425
1430
|
* the JSON one tab away — the test still receives the raw value, unchanged
|
package/dist/recorder.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// current recorder is `null` and the `record*` helpers are no-ops, so
|
|
9
9
|
// callers can invoke them unconditionally.
|
|
10
10
|
import { clearPendingNullish } from "./inspect.js";
|
|
11
|
+
import { scopeAllowsRecording } from "./harness/instrumentation-scope.js";
|
|
11
12
|
const OUTPUT_SNIPPET_BYTES = 256 * 1024;
|
|
12
13
|
class Recorder {
|
|
13
14
|
events = [];
|
|
@@ -117,7 +118,7 @@ export function resumeRecording() {
|
|
|
117
118
|
paused = Math.max(0, paused - 1);
|
|
118
119
|
}
|
|
119
120
|
function active() {
|
|
120
|
-
return current !== null && paused === 0;
|
|
121
|
+
return current !== null && paused === 0 && scopeAllowsRecording();
|
|
121
122
|
}
|
|
122
123
|
export function startRecording() {
|
|
123
124
|
current = new Recorder();
|