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