@specific.dev/spectest 0.79.0 → 0.79.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/daemon.js +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/main.js +4 -4
- package/dist/harness/protocol.d.ts +20 -0
- package/dist/harness/protocol.js +34 -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/main.ts +4 -4
- package/src/harness/protocol.test.ts +42 -0
- package/src/harness/protocol.ts +33 -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
|
+
}
|
package/dist/harness/main.js
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
import net from "node:net";
|
|
36
36
|
import { METHODS } from "../daemon";
|
|
37
37
|
import { codeOf } from "./methods";
|
|
38
|
-
import { PROTOCOL_VERSION, decodeFrame, encodeFrame, fail, ok, } from "./protocol";
|
|
38
|
+
import { PROTOCOL_VERSION, decodeFrame, describeThrow, encodeFrame, fail, ok, } from "./protocol";
|
|
39
39
|
/** Capabilities this build announces. Empty is fine — the slot is what
|
|
40
40
|
* matters, so a later release has something to negotiate against. */
|
|
41
41
|
const CAPABILITIES = [];
|
|
@@ -87,11 +87,11 @@ export function serve(socket, registry) {
|
|
|
87
87
|
//
|
|
88
88
|
// A refusal the method meant to make (an unknown case, a second test
|
|
89
89
|
// while one is running) reports its own kind and just its message. An
|
|
90
|
-
// unexpected throw
|
|
91
|
-
//
|
|
90
|
+
// unexpected throw carries its message AND its stack — in that order,
|
|
91
|
+
// because a stack alone is sometimes headerless (`describeThrow`).
|
|
92
92
|
const e = err;
|
|
93
93
|
const code = codeOf(err);
|
|
94
|
-
const detail = code === "handler_error" ? (
|
|
94
|
+
const detail = code === "handler_error" ? describeThrow(err) : e.message;
|
|
95
95
|
send(fail(req.id, detail, code));
|
|
96
96
|
}
|
|
97
97
|
};
|
|
@@ -86,3 +86,23 @@ export declare function ok(id: string, result?: Record<string, unknown>): Respon
|
|
|
86
86
|
/** A failed reply to `id`. Always carries the id: a request that failed must
|
|
87
87
|
* never leave its caller waiting. */
|
|
88
88
|
export declare function fail(id: string, message: string, code?: string): ResponseFrame;
|
|
89
|
+
/**
|
|
90
|
+
* Describe a thrown value for the wire: the **message first**, then the
|
|
91
|
+
* frames — never the stack on its own.
|
|
92
|
+
*
|
|
93
|
+
* `error.stack` normally opens with `Error: <message>`, which is why it
|
|
94
|
+
* looked like the richer of the two. It is not always: Bun drops that
|
|
95
|
+
* header when the rejection is observed a turn later than it settled —
|
|
96
|
+
* a promise that rejected while nobody was awaiting it yet, which is
|
|
97
|
+
* exactly what a bootstrap image build is (`daemon.ts` starts every
|
|
98
|
+
* build at once and each service awaits its own prep when the DAG
|
|
99
|
+
* reaches it). The whole diagnosis of a failed build IS the message
|
|
100
|
+
* there: `docker build for <svc> failed:` plus the build log. Sending
|
|
101
|
+
* the stack alone published four frames and nothing else — no docker,
|
|
102
|
+
* no BuildKit, no compiler error (reported by the Harmony project,
|
|
103
|
+
* 2026-09-09; the same run's log had the message intact).
|
|
104
|
+
*
|
|
105
|
+
* A stack that already carries the message is returned as it stands, so
|
|
106
|
+
* the ordinary case is unchanged.
|
|
107
|
+
*/
|
|
108
|
+
export declare function describeThrow(err: unknown): string;
|
package/dist/harness/protocol.js
CHANGED
|
@@ -94,3 +94,37 @@ export function ok(id, result = {}) {
|
|
|
94
94
|
export function fail(id, message, code) {
|
|
95
95
|
return { kind: "response", id, ok: false, error: code ? { message, code } : { message } };
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Describe a thrown value for the wire: the **message first**, then the
|
|
99
|
+
* frames — never the stack on its own.
|
|
100
|
+
*
|
|
101
|
+
* `error.stack` normally opens with `Error: <message>`, which is why it
|
|
102
|
+
* looked like the richer of the two. It is not always: Bun drops that
|
|
103
|
+
* header when the rejection is observed a turn later than it settled —
|
|
104
|
+
* a promise that rejected while nobody was awaiting it yet, which is
|
|
105
|
+
* exactly what a bootstrap image build is (`daemon.ts` starts every
|
|
106
|
+
* build at once and each service awaits its own prep when the DAG
|
|
107
|
+
* reaches it). The whole diagnosis of a failed build IS the message
|
|
108
|
+
* there: `docker build for <svc> failed:` plus the build log. Sending
|
|
109
|
+
* the stack alone published four frames and nothing else — no docker,
|
|
110
|
+
* no BuildKit, no compiler error (reported by the Harmony project,
|
|
111
|
+
* 2026-09-09; the same run's log had the message intact).
|
|
112
|
+
*
|
|
113
|
+
* A stack that already carries the message is returned as it stands, so
|
|
114
|
+
* the ordinary case is unchanged.
|
|
115
|
+
*/
|
|
116
|
+
export function describeThrow(err) {
|
|
117
|
+
const e = err;
|
|
118
|
+
const message = typeof e?.message === "string" ? e.message : "";
|
|
119
|
+
const stack = typeof e?.stack === "string" ? e.stack : "";
|
|
120
|
+
if (!message)
|
|
121
|
+
return stack || String(err);
|
|
122
|
+
if (stack.includes(message))
|
|
123
|
+
return stack;
|
|
124
|
+
const name = typeof e?.name === "string" && e.name.length > 0 ? e.name : "Error";
|
|
125
|
+
// The headerless form is `<Name>\n at …`. Drop that bare first line
|
|
126
|
+
// and write the header ourselves, so the result reads like the stack a
|
|
127
|
+
// reader expects rather than a message with a stray `Error` in it.
|
|
128
|
+
const frames = stack.startsWith(`${name}\n`) ? stack.slice(name.length + 1) : stack;
|
|
129
|
+
return frames.length > 0 ? `${name}: ${message}\n${frames}` : `${name}: ${message}`;
|
|
130
|
+
}
|