@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/src/daemon.ts
CHANGED
|
@@ -53,9 +53,6 @@ import {
|
|
|
53
53
|
coverageAdapters,
|
|
54
54
|
coverageReportsMode,
|
|
55
55
|
validateCoverage,
|
|
56
|
-
nodeCoverageToolsAvailable,
|
|
57
|
-
nodeCoverageToolsDir,
|
|
58
|
-
NODE_COVERAGE_TOOLS_CONTAINER_DIR,
|
|
59
56
|
type CoverageAdapter,
|
|
60
57
|
type CoverageCaptureContext,
|
|
61
58
|
} from "./coverage.js";
|
|
@@ -95,12 +92,13 @@ import { runWrapperRules } from "./harness/wrapper-rules.js";
|
|
|
95
92
|
import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
|
|
96
93
|
import { cpus } from "node:os";
|
|
97
94
|
import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath } from "./project-files.js";
|
|
95
|
+
import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
|
|
98
96
|
import {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
} from "./harness/
|
|
97
|
+
bindInstrumentationScope,
|
|
98
|
+
createInstrumentationScope,
|
|
99
|
+
runInstrumented,
|
|
100
|
+
runUninstrumented,
|
|
101
|
+
} from "./harness/instrumentation-scope.js";
|
|
104
102
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
105
103
|
import {
|
|
106
104
|
InterceptRegistry,
|
|
@@ -169,16 +167,13 @@ import { openTerminal } from "./terminal.js";
|
|
|
169
167
|
// `ctx.mcp(url)`. One client per call, no registry: an authenticated
|
|
170
168
|
// client is passed to descendants as a test's return value.
|
|
171
169
|
import { openMcp } from "./mcp.js";
|
|
172
|
-
import { setRawFetch } from "./harness/raw-fetch.js";
|
|
173
170
|
import { readAnnotation, type RenderAnnotation } from "./annotate.js";
|
|
174
171
|
import {
|
|
175
172
|
isRecording,
|
|
176
|
-
pauseRecording,
|
|
177
173
|
recordEmail,
|
|
178
174
|
recordEnv,
|
|
179
175
|
recordExec,
|
|
180
176
|
recordFake,
|
|
181
|
-
recordHttp,
|
|
182
177
|
recordStep,
|
|
183
178
|
recordTerminal,
|
|
184
179
|
recordWait,
|
|
@@ -186,16 +181,14 @@ import {
|
|
|
186
181
|
recorderEventCount,
|
|
187
182
|
recorderMarkChildren,
|
|
188
183
|
recorderTruncate,
|
|
189
|
-
resumeRecording,
|
|
190
184
|
startRecording,
|
|
191
185
|
stopRecording,
|
|
192
186
|
truncateUtf8,
|
|
193
187
|
type StepBlock,
|
|
194
188
|
type TestEvent,
|
|
195
|
-
type OmittedBody,
|
|
196
189
|
} from "./recorder.js";
|
|
197
|
-
import { deepUnwrap, readRaw, wrap
|
|
198
|
-
import type { Wrapped
|
|
190
|
+
import { deepUnwrap, readRaw, wrap } from "./inspect.js";
|
|
191
|
+
import type { Wrapped } from "./inspect.js";
|
|
199
192
|
import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
|
|
200
193
|
import { generateId } from "./ids.js";
|
|
201
194
|
import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
|
|
@@ -983,16 +976,9 @@ async function ensureCoverage(svc: NamedService): Promise<string[]> {
|
|
|
983
976
|
const host = coverageHostDir(WORKSPACE, svc.name);
|
|
984
977
|
await fs.mkdir(host, { recursive: true });
|
|
985
978
|
await fs.chmod(host, 0o777);
|
|
986
|
-
|
|
987
|
-
//
|
|
988
|
-
|
|
989
|
-
// — so the VM and the container agree on whether they are there. A
|
|
990
|
-
// plain `--volume`, never a config `volumes` entry: an absolute-source
|
|
991
|
-
// volume dir is listed in the delta-restore manifest and wiped.
|
|
992
|
-
if (await nodeCoverageToolsAvailable()) {
|
|
993
|
-
flags.push(`--volume=${nodeCoverageToolsDir()}:${NODE_COVERAGE_TOOLS_CONTAINER_DIR}:ro`);
|
|
994
|
-
}
|
|
995
|
-
return flags;
|
|
979
|
+
// Nothing else is mounted: no conversion runs in the container (the
|
|
980
|
+
// c8 tools mount went with SDK 0.80).
|
|
981
|
+
return [`--volume=${host}:${COVERAGE_CONTAINER_DIR}`];
|
|
996
982
|
}
|
|
997
983
|
|
|
998
984
|
/** A `user`/`group` pair resolved to the numeric ids the kernel wants.
|
|
@@ -2150,7 +2136,7 @@ async function startIngress(): Promise<void> {
|
|
|
2150
2136
|
for (const [name, fake] of FAKES) {
|
|
2151
2137
|
if (fake.def.state) {
|
|
2152
2138
|
try {
|
|
2153
|
-
fake.state = await fake.def.state();
|
|
2139
|
+
fake.state = await runUninstrumented(() => fake.def.state!());
|
|
2154
2140
|
} catch (err) {
|
|
2155
2141
|
throw new Error(
|
|
2156
2142
|
`fake ${JSON.stringify(name)} state() factory threw: ${(err as Error).message}`,
|
|
@@ -2604,7 +2590,9 @@ async function dispatchIngress(
|
|
|
2604
2590
|
const upstream = async (current: Request): Promise<Response> => {
|
|
2605
2591
|
if (route.kind === "fake") {
|
|
2606
2592
|
try {
|
|
2607
|
-
return await
|
|
2593
|
+
return await runUninstrumented(() =>
|
|
2594
|
+
route.fake.def.handler(current, route.fake.state, FAKE_CTX),
|
|
2595
|
+
);
|
|
2608
2596
|
} catch (err) {
|
|
2609
2597
|
const e = err as Error;
|
|
2610
2598
|
return new Response(
|
|
@@ -3057,7 +3045,7 @@ function registerInterceptor(
|
|
|
3057
3045
|
);
|
|
3058
3046
|
}
|
|
3059
3047
|
const resv = reserveEvent();
|
|
3060
|
-
const it = INTERCEPTORS.register(host, path, handler);
|
|
3048
|
+
const it = INTERCEPTORS.register(host, path, bindInstrumentationScope(handler));
|
|
3061
3049
|
const where = `${host}${it.path === "/" ? "" : it.path}`;
|
|
3062
3050
|
const seq = recordStep(
|
|
3063
3051
|
{
|
|
@@ -3288,11 +3276,11 @@ async function ensureFakeHelpers(name: string): Promise<Record<string, unknown>>
|
|
|
3288
3276
|
if (!fake) throw new Error(`fake ${JSON.stringify(name)} is not loaded`);
|
|
3289
3277
|
if (fake.trackedHelpers) return fake.trackedHelpers;
|
|
3290
3278
|
fake.helpers = fake.def.helpers
|
|
3291
|
-
? ((await fake.def.helpers({
|
|
3279
|
+
? ((await runUninstrumented(() => fake.def.helpers!({
|
|
3292
3280
|
name,
|
|
3293
3281
|
state: fake.state,
|
|
3294
3282
|
ctx: FAKE_CTX,
|
|
3295
|
-
})) as Record<string, unknown>)
|
|
3283
|
+
}))) as Record<string, unknown>)
|
|
3296
3284
|
: {};
|
|
3297
3285
|
fake.trackedHelpers = trackFakeHelpers(name, fake.helpers);
|
|
3298
3286
|
return fake.trackedHelpers;
|
|
@@ -3386,41 +3374,24 @@ function invokeFakeHelper(
|
|
|
3386
3374
|
}, resv);
|
|
3387
3375
|
};
|
|
3388
3376
|
|
|
3389
|
-
//
|
|
3390
|
-
//
|
|
3391
|
-
// something the test did, and it would otherwise land on the timeline as
|
|
3392
|
-
// an `http` step the test never made. The built-in `email()` helpers
|
|
3393
|
-
// already pause around their internal polls by hand for exactly this
|
|
3394
|
-
// reason (`components/email.ts`); doing it here gives every user-authored
|
|
3395
|
-
// fake the same contract without having to know about it.
|
|
3396
|
-
//
|
|
3397
|
-
// The pause spans the helper's `await`s, so genuinely concurrent test work
|
|
3398
|
-
// (`Promise.all([helper(), ctx.fetch(…)])`) loses its events too. That is
|
|
3399
|
-
// the same trade `email()` has always made, and sequential test code — all
|
|
3400
|
-
// of it, in practice — is unaffected.
|
|
3401
|
-
pauseRecording();
|
|
3377
|
+
// Record the public helper call in the caller's scope, while its body
|
|
3378
|
+
// and async descendants use native fetch and emit no internal events.
|
|
3402
3379
|
let result: unknown;
|
|
3403
3380
|
try {
|
|
3404
|
-
result = fn.apply(thisArg, args);
|
|
3381
|
+
result = runUninstrumented(() => fn.apply(thisArg, args));
|
|
3405
3382
|
} catch (err) {
|
|
3406
|
-
resumeRecording();
|
|
3407
3383
|
recordError(err);
|
|
3408
3384
|
throw err;
|
|
3409
3385
|
}
|
|
3410
3386
|
if (result instanceof Promise) {
|
|
3411
3387
|
return result.then(
|
|
3412
|
-
|
|
3413
|
-
resumeRecording();
|
|
3414
|
-
return recordResult(value);
|
|
3415
|
-
},
|
|
3388
|
+
recordResult,
|
|
3416
3389
|
(err) => {
|
|
3417
|
-
resumeRecording();
|
|
3418
3390
|
recordError(err);
|
|
3419
3391
|
throw err;
|
|
3420
3392
|
},
|
|
3421
3393
|
);
|
|
3422
3394
|
}
|
|
3423
|
-
resumeRecording();
|
|
3424
3395
|
return recordResult(result);
|
|
3425
3396
|
}
|
|
3426
3397
|
|
|
@@ -3756,13 +3727,15 @@ async function runProjectSetupInner(): Promise<ProjectSetupResult> {
|
|
|
3756
3727
|
// as in a test. No recorder is active here, so it wraps without provenance —
|
|
3757
3728
|
// but the wrapped type stays honest at runtime (`.unwrap()` works).
|
|
3758
3729
|
const restoreFetch = installFetchWrapper();
|
|
3730
|
+
const fetchScope = createInstrumentationScope(false);
|
|
3759
3731
|
// The full context, unscoped: every service is up by project-setup time,
|
|
3760
3732
|
// so `ctx.svc` is the same map tests see (and shares helper instances with
|
|
3761
3733
|
// them — a Bun.SQL pool created here is reused later).
|
|
3762
3734
|
const ctx: ProjectSetupContext = await spectestContext();
|
|
3763
3735
|
try {
|
|
3764
|
-
await proj.setup(ctx);
|
|
3736
|
+
await runInstrumented(fetchScope, () => proj.setup!(ctx));
|
|
3765
3737
|
} finally {
|
|
3738
|
+
fetchScope.active = false;
|
|
3766
3739
|
restoreFetch();
|
|
3767
3740
|
}
|
|
3768
3741
|
return { ran: true, durationMs: Date.now() - start };
|
|
@@ -4753,173 +4726,6 @@ async function buildServiceHandles(cfg: EnvironmentConfig): Promise<ServiceHandl
|
|
|
4753
4726
|
// Instrumentation helpers
|
|
4754
4727
|
// ────────────────────────────────────────────────────────────────────────
|
|
4755
4728
|
|
|
4756
|
-
function describeFetchInput(input: Parameters<typeof fetch>[0]): {
|
|
4757
|
-
url: string;
|
|
4758
|
-
methodFromInput?: string;
|
|
4759
|
-
} {
|
|
4760
|
-
if (typeof input === "string") return { url: input };
|
|
4761
|
-
if (input instanceof URL) return { url: input.toString() };
|
|
4762
|
-
// Request instance
|
|
4763
|
-
const req = input as Request;
|
|
4764
|
-
return { url: req.url, methodFromInput: req.method };
|
|
4765
|
-
}
|
|
4766
|
-
|
|
4767
|
-
function describeRequestBody(
|
|
4768
|
-
input: Parameters<typeof fetch>[0],
|
|
4769
|
-
init: Parameters<typeof fetch>[1],
|
|
4770
|
-
): { body?: string; truncated?: boolean } {
|
|
4771
|
-
// For Request objects, body has already been consumed into the request;
|
|
4772
|
-
// we can't read it back without cloning, which costs. Skip unless init.body
|
|
4773
|
-
// is provided directly.
|
|
4774
|
-
const body = init?.body;
|
|
4775
|
-
if (body === undefined || body === null) {
|
|
4776
|
-
if (input instanceof Request && input.bodyUsed === false) {
|
|
4777
|
-
// Don't drain the request's body here — leaving it for the actual
|
|
4778
|
-
// fetch. Return a marker.
|
|
4779
|
-
return { body: "[Request body not captured]", truncated: false };
|
|
4780
|
-
}
|
|
4781
|
-
return {};
|
|
4782
|
-
}
|
|
4783
|
-
if (typeof body === "string") {
|
|
4784
|
-
const t = truncateUtf8(body);
|
|
4785
|
-
return { body: t.value, truncated: t.truncated };
|
|
4786
|
-
}
|
|
4787
|
-
if (body instanceof URLSearchParams) {
|
|
4788
|
-
const t = truncateUtf8(body.toString());
|
|
4789
|
-
return { body: t.value, truncated: t.truncated };
|
|
4790
|
-
}
|
|
4791
|
-
return { body: `[non-text body: ${body.constructor?.name ?? typeof body}]` };
|
|
4792
|
-
}
|
|
4793
|
-
|
|
4794
|
-
/**
|
|
4795
|
-
* Marks an error thrown by the instrumented fetch as *transport-level* —
|
|
4796
|
-
* the connection itself failed (refused, unresolvable, reset) before any
|
|
4797
|
-
* HTTP reply existed. `fetch` never rejects for an HTTP status, so every
|
|
4798
|
-
* rejection short of an abort is transport. `Symbol.for` so a duplicated
|
|
4799
|
-
* SDK module instance (the bun hardlink landmine) still recognises it.
|
|
4800
|
-
*
|
|
4801
|
-
* Why it exists: `ctx.poll` waits for convergence, and right after a fork
|
|
4802
|
-
* restore the guest can serve a ~10 s window where a connect or a DNS
|
|
4803
|
-
* lookup fails once and then heals (measured 2026-08-21: a poll's first
|
|
4804
|
-
* fetch hung 12 s in resolution, threw, and killed a 60 s poll on attempt
|
|
4805
|
-
* 1 while attempt 2 would have passed). During a poll, a dead connection
|
|
4806
|
-
* is just "not ready yet"; outside one it stays a hard error.
|
|
4807
|
-
*/
|
|
4808
|
-
const TRANSPORT_ERROR = Symbol.for("spectest.transportError");
|
|
4809
|
-
|
|
4810
|
-
function isTransportError(err: unknown): boolean {
|
|
4811
|
-
return (
|
|
4812
|
-
typeof err === "object" &&
|
|
4813
|
-
err !== null &&
|
|
4814
|
-
(err as Record<symbol, unknown>)[TRANSPORT_ERROR] === true
|
|
4815
|
-
);
|
|
4816
|
-
}
|
|
4817
|
-
|
|
4818
|
-
/**
|
|
4819
|
-
* Install a fetch wrapper on `globalThis` that emits HTTP events into the
|
|
4820
|
-
* active recorder. Returns a restore function. Calls outside of a running
|
|
4821
|
-
* test still hit the original fetch (the recorder is null then; the
|
|
4822
|
-
* wrapper just adds a tiny amount of overhead — but we restore after each
|
|
4823
|
-
* test anyway, so this only matters mid-test).
|
|
4824
|
-
*/
|
|
4825
|
-
function installFetchWrapper(): () => void {
|
|
4826
|
-
const original = globalThis.fetch;
|
|
4827
|
-
// SDK internals (the MCP client, its OAuth flow) must not see the
|
|
4828
|
-
// wrapper: a wrapped `res.ok` is an object, and the wrapper reads every
|
|
4829
|
-
// body to the end, which never finishes for an SSE stream.
|
|
4830
|
-
setRawFetch(original);
|
|
4831
|
-
const wrappedFn = async (
|
|
4832
|
-
input: Parameters<typeof fetch>[0],
|
|
4833
|
-
init?: Parameters<typeof fetch>[1],
|
|
4834
|
-
): Promise<Response | WrappedResponse> => {
|
|
4835
|
-
const start = Date.now();
|
|
4836
|
-
const resv = reserveEvent();
|
|
4837
|
-
const { url, methodFromInput } = describeFetchInput(input);
|
|
4838
|
-
const method = (init?.method ?? methodFromInput ?? "GET").toUpperCase();
|
|
4839
|
-
const reqBody = describeRequestBody(input, init);
|
|
4840
|
-
try {
|
|
4841
|
-
const res = await original(input as RequestInfo, init);
|
|
4842
|
-
let responseBody: string | OmittedBody | undefined;
|
|
4843
|
-
let responseBodyTruncated: boolean | undefined;
|
|
4844
|
-
// The reply the test gets is untouched: everything here reads a clone,
|
|
4845
|
-
// and a body known to be binary is not read at all. See
|
|
4846
|
-
// ./harness/http-body.ts for why a blanket `.text()` was wrong.
|
|
4847
|
-
const contentType = res.headers.get("content-type");
|
|
4848
|
-
const contentLength = () => parseContentLength(res.headers.get("content-length"));
|
|
4849
|
-
const textual = isTextualContentType(contentType);
|
|
4850
|
-
if (textual === false) {
|
|
4851
|
-
responseBody = omittedBody("binary", contentType, contentLength());
|
|
4852
|
-
} else {
|
|
4853
|
-
try {
|
|
4854
|
-
const cloned = res.clone();
|
|
4855
|
-
const text = await cloned.text();
|
|
4856
|
-
if (textual === undefined && looksBinary(text)) {
|
|
4857
|
-
// No content type (or a multipart one), and the bytes say this
|
|
4858
|
-
// was never text.
|
|
4859
|
-
responseBody = omittedBody("binary", contentType, contentLength());
|
|
4860
|
-
} else {
|
|
4861
|
-
const t = truncateUtf8(text);
|
|
4862
|
-
responseBody = t.value;
|
|
4863
|
-
responseBodyTruncated = t.truncated;
|
|
4864
|
-
}
|
|
4865
|
-
} catch {
|
|
4866
|
-
// The stream failed, or something had already consumed the body.
|
|
4867
|
-
responseBody = omittedBody("unreadable", contentType, contentLength());
|
|
4868
|
-
}
|
|
4869
|
-
}
|
|
4870
|
-
const seq = recordHttp({
|
|
4871
|
-
method,
|
|
4872
|
-
url,
|
|
4873
|
-
requestBody: reqBody.body,
|
|
4874
|
-
requestBodyTruncated: reqBody.truncated,
|
|
4875
|
-
status: res.status,
|
|
4876
|
-
responseBody,
|
|
4877
|
-
responseBodyTruncated,
|
|
4878
|
-
durationMs: Date.now() - start,
|
|
4879
|
-
}, resv);
|
|
4880
|
-
return wrapResponse(res, seq);
|
|
4881
|
-
} catch (err) {
|
|
4882
|
-
const e = err as Error;
|
|
4883
|
-
recordHttp({
|
|
4884
|
-
method,
|
|
4885
|
-
url,
|
|
4886
|
-
requestBody: reqBody.body,
|
|
4887
|
-
requestBodyTruncated: reqBody.truncated,
|
|
4888
|
-
durationMs: Date.now() - start,
|
|
4889
|
-
error: e?.message ?? String(err),
|
|
4890
|
-
}, resv);
|
|
4891
|
-
// Tag transport failures for ctx.poll (see TRANSPORT_ERROR). An abort
|
|
4892
|
-
// is the caller's own signal (their AbortController or their
|
|
4893
|
-
// AbortSignal.timeout) — their semantics, never retried for them.
|
|
4894
|
-
if (
|
|
4895
|
-
typeof err === "object" &&
|
|
4896
|
-
err !== null &&
|
|
4897
|
-
e?.name !== "AbortError" &&
|
|
4898
|
-
e?.name !== "TimeoutError"
|
|
4899
|
-
) {
|
|
4900
|
-
try {
|
|
4901
|
-
(err as Record<symbol, unknown>)[TRANSPORT_ERROR] = true;
|
|
4902
|
-
} catch {
|
|
4903
|
-
/* frozen error object — stays a hard error */
|
|
4904
|
-
}
|
|
4905
|
-
}
|
|
4906
|
-
throw err;
|
|
4907
|
-
}
|
|
4908
|
-
};
|
|
4909
|
-
// Preserve any provider-specific statics on `fetch` (e.g. Bun's
|
|
4910
|
-
// `fetch.preconnect`) so consumers that touch them keep working.
|
|
4911
|
-
const wrapped = wrappedFn as unknown as typeof fetch;
|
|
4912
|
-
for (const key of Object.keys(original) as (keyof typeof original)[]) {
|
|
4913
|
-
(wrapped as unknown as Record<string, unknown>)[key as string] = (
|
|
4914
|
-
original as unknown as Record<string, unknown>
|
|
4915
|
-
)[key as string];
|
|
4916
|
-
}
|
|
4917
|
-
globalThis.fetch = wrapped;
|
|
4918
|
-
return () => {
|
|
4919
|
-
globalThis.fetch = original;
|
|
4920
|
-
};
|
|
4921
|
-
}
|
|
4922
|
-
|
|
4923
4729
|
/** Build the `docker exec` argv for a service command. A string command runs
|
|
4924
4730
|
* through `sh -lc`; an array is exact argv with no shell (the
|
|
4925
4731
|
* `["psql", "-f", "-"]` shape). An optional `cwd` becomes `-w <cwd>` (the
|
|
@@ -5481,6 +5287,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5481
5287
|
|
|
5482
5288
|
startRecording();
|
|
5483
5289
|
const restoreFetch = installFetchWrapper();
|
|
5290
|
+
const fetchScope = createInstrumentationScope();
|
|
5484
5291
|
|
|
5485
5292
|
// Look up the parent's stored return value (if any). The parent ran in
|
|
5486
5293
|
// an ancestor fork; its TEST_DATA entry travels with the snapshot.
|
|
@@ -5622,7 +5429,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5622
5429
|
let outcome: { status: "passed" | "failed"; error?: RunResult["error"] };
|
|
5623
5430
|
try {
|
|
5624
5431
|
const value = await Promise.race([
|
|
5625
|
-
Promise.resolve(testCase.run(ctx)),
|
|
5432
|
+
Promise.resolve(runInstrumented(fetchScope, () => testCase.run(ctx))),
|
|
5626
5433
|
timedOut,
|
|
5627
5434
|
]);
|
|
5628
5435
|
// Stash the return value so child cases — which fork from the snapshot
|
|
@@ -5639,6 +5446,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5639
5446
|
if (timer) clearTimeout(timer);
|
|
5640
5447
|
RECORDING_EXEC = undefined;
|
|
5641
5448
|
INTERCEPTORS.endScope();
|
|
5449
|
+
fetchScope.active = false;
|
|
5642
5450
|
restoreFetch();
|
|
5643
5451
|
restoreConsole();
|
|
5644
5452
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -6013,6 +5821,7 @@ async function evalCode(
|
|
|
6013
5821
|
// Response just like in a test (no recorder here, so no provenance — but the
|
|
6014
5822
|
// wrapped type is honest at runtime). Restored in the `finally` below.
|
|
6015
5823
|
const restoreFetch = installFetchWrapper();
|
|
5824
|
+
const fetchScope = createInstrumentationScope(false);
|
|
6016
5825
|
|
|
6017
5826
|
// Same persistent acquire/detach as a test run (see runOne): the browser
|
|
6018
5827
|
// survives the eval, so successive `spectest env eval` calls continue one
|
|
@@ -6177,7 +5986,8 @@ async function evalCode(
|
|
|
6177
5986
|
await fs.mkdir(EVAL_DIR, { recursive: true });
|
|
6178
5987
|
filePath = path.join(EVAL_DIR, `${randomUUID()}.ts`);
|
|
6179
5988
|
await fs.writeFile(filePath, prepared.code);
|
|
6180
|
-
const
|
|
5989
|
+
const moduleUrl = pathToFileURL(filePath).href;
|
|
5990
|
+
const mod = (await runInstrumented(fetchScope, () => import(moduleUrl))) as { default?: unknown };
|
|
6181
5991
|
outcome = { ok: true, result: safeSerialize(mod.default) };
|
|
6182
5992
|
} catch (err) {
|
|
6183
5993
|
const e = err as Error;
|
|
@@ -6188,6 +5998,7 @@ async function evalCode(
|
|
|
6188
5998
|
};
|
|
6189
5999
|
} finally {
|
|
6190
6000
|
clearRecordSecrets();
|
|
6001
|
+
fetchScope.active = false;
|
|
6191
6002
|
restoreFetch();
|
|
6192
6003
|
restoreConsole();
|
|
6193
6004
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -6255,8 +6066,7 @@ async function evalCode(
|
|
|
6255
6066
|
|
|
6256
6067
|
/** Where the baked compiler lives. Overridable so the typecheck — and the
|
|
6257
6068
|
* blocking rules, which always use THIS copy rather than the project's own —
|
|
6258
|
-
* can be driven outside a VM
|
|
6259
|
-
* `SPECTEST_COVERAGE_TOOLS_DIR`). */
|
|
6069
|
+
* can be driven outside a VM. */
|
|
6260
6070
|
const TYPECHECK_DIR = process.env.SPECTEST_TYPECHECK_DIR ?? "/opt/spectest/typecheck";
|
|
6261
6071
|
/** Cap on errors shipped in the report; `totalErrors` carries the true count. */
|
|
6262
6072
|
const TYPECHECK_ERROR_CAP = 50;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { expect as assertValue } from "../index.js";
|
|
3
|
+
import { type WrappedResponse } from "../inspect.js";
|
|
4
|
+
import { recordFake, startRecording, stopRecording } from "../recorder.js";
|
|
5
|
+
import { installFetchWrapper, isTransportError } from "./fetch.js";
|
|
6
|
+
import {
|
|
7
|
+
bindInstrumentationScope,
|
|
8
|
+
createInstrumentationScope,
|
|
9
|
+
runInstrumented,
|
|
10
|
+
runUninstrumented,
|
|
11
|
+
type InstrumentationScope,
|
|
12
|
+
} from "./instrumentation-scope.js";
|
|
13
|
+
|
|
14
|
+
function deferred<T = void>() {
|
|
15
|
+
let resolve!: (value: T) => void;
|
|
16
|
+
const promise = new Promise<T>((done) => { resolve = done; });
|
|
17
|
+
return { promise, resolve };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("fetch execution scopes", () => {
|
|
21
|
+
let scope: InstrumentationScope;
|
|
22
|
+
let restore: () => void;
|
|
23
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
24
|
+
let handler: (req: Request) => Response | Promise<Response>;
|
|
25
|
+
const url = (path: string) => new URL(path, server.url).href;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
handler = () => Response.json({ value: 42 });
|
|
29
|
+
server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (req) => handler(req) });
|
|
30
|
+
startRecording();
|
|
31
|
+
scope = createInstrumentationScope();
|
|
32
|
+
restore = installFetchWrapper();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
scope.active = false;
|
|
37
|
+
restore();
|
|
38
|
+
stopRecording();
|
|
39
|
+
server.stop(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("test helpers retain HTTP and assertion provenance across awaits", async () => {
|
|
43
|
+
await runInstrumented(scope, async () => {
|
|
44
|
+
const helper = async () => {
|
|
45
|
+
await Promise.resolve();
|
|
46
|
+
return await fetch(url("/test")) as unknown as WrappedResponse;
|
|
47
|
+
};
|
|
48
|
+
const res = await helper();
|
|
49
|
+
expect(res.status.unwrap()).toBe(200);
|
|
50
|
+
assertValue(res.status).toBe(200);
|
|
51
|
+
assertValue((await res.json<{ value: number }>()).value).toBe(42);
|
|
52
|
+
});
|
|
53
|
+
const events = stopRecording();
|
|
54
|
+
expect(events.map((e) => e.kind)).toEqual(["http", "assertion", "assertion"]);
|
|
55
|
+
expect(events[1]).toMatchObject({ sourceSeq: events[0]!.seq, path: ["status"] });
|
|
56
|
+
expect(events[2]).toMatchObject({ sourceSeq: events[0]!.seq, path: ["body", "value"] });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("background fetch is native even while a test recorder is active", async () => {
|
|
60
|
+
handler = () => Response.json({ error: "missing" }, { status: 404 });
|
|
61
|
+
const res = await fetch(url("/background"));
|
|
62
|
+
expect(res.status).toBe(404);
|
|
63
|
+
expect(res.ok).toBe(false);
|
|
64
|
+
expect(await res.json()).toEqual({ error: "missing" });
|
|
65
|
+
expect(stopRecording()).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("an HTTP fake records only the test's outer request", async () => {
|
|
69
|
+
handler = (req) => {
|
|
70
|
+
if (new URL(req.url).pathname === "/fake") {
|
|
71
|
+
return runUninstrumented(async () => {
|
|
72
|
+
await Promise.resolve();
|
|
73
|
+
const res = await fetch(url("/upstream"));
|
|
74
|
+
// Ordinary HTTP status/error handling must work inside the fake.
|
|
75
|
+
if (!res.ok) return new Response(await res.text(), { status: res.status });
|
|
76
|
+
return new Response("unexpected success");
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return new Response("upstream unavailable", { status: 503 });
|
|
80
|
+
};
|
|
81
|
+
await runInstrumented(scope, async () => {
|
|
82
|
+
const res = await fetch(url("/fake")) as unknown as WrappedResponse;
|
|
83
|
+
expect(res.status.unwrap()).toBe(503);
|
|
84
|
+
expect((await res.text()).unwrap()).toBe("upstream unavailable");
|
|
85
|
+
});
|
|
86
|
+
expect(stopRecording()).toMatchObject([{ kind: "http", url: url("/fake"), status: 503 }]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("async fake internals do not suppress concurrent test events", async () => {
|
|
90
|
+
const entered = deferred();
|
|
91
|
+
const release = deferred();
|
|
92
|
+
await runInstrumented(scope, async () => {
|
|
93
|
+
const fake = runUninstrumented(async () => {
|
|
94
|
+
entered.resolve();
|
|
95
|
+
await release.promise;
|
|
96
|
+
const res = await fetch(url("/fake-internal"));
|
|
97
|
+
expect(res.status).toBe(200);
|
|
98
|
+
recordFake({ fake: "nested", member: "internal", args: [], durationMs: 0 });
|
|
99
|
+
return (await res.json()).value;
|
|
100
|
+
});
|
|
101
|
+
// Like invokeFakeHelper, the public call's completion is recorded in
|
|
102
|
+
// its caller's scope; internal operations stay in the fake scope.
|
|
103
|
+
const completed = fake.then((value) => {
|
|
104
|
+
recordFake({ fake: "example", member: "read", args: [], result: value, durationMs: 0 });
|
|
105
|
+
});
|
|
106
|
+
await entered.promise;
|
|
107
|
+
try {
|
|
108
|
+
await fetch(url("/test-during-fake"));
|
|
109
|
+
} finally {
|
|
110
|
+
release.resolve();
|
|
111
|
+
}
|
|
112
|
+
await completed;
|
|
113
|
+
await fetch(url("/test-after-fake"));
|
|
114
|
+
});
|
|
115
|
+
expect(stopRecording()).toMatchObject([
|
|
116
|
+
{ kind: "http", url: url("/test-during-fake") },
|
|
117
|
+
{ kind: "fake", fake: "example", member: "read", result: 42 },
|
|
118
|
+
{ kind: "http", url: url("/test-after-fake") },
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("fake streams are returned without cloning or waiting for the body to end", async () => {
|
|
123
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
124
|
+
handler = () => new Response(new ReadableStream<Uint8Array>({
|
|
125
|
+
start(c) {
|
|
126
|
+
controller = c;
|
|
127
|
+
c.enqueue(new TextEncoder().encode("data: first\n\n"));
|
|
128
|
+
},
|
|
129
|
+
}), { headers: { "content-type": "text/event-stream" } });
|
|
130
|
+
await runInstrumented(scope, () => runUninstrumented(async () => {
|
|
131
|
+
const res = await fetch(url("/stream"));
|
|
132
|
+
expect(res.status).toBe(200);
|
|
133
|
+
expect(res.bodyUsed).toBe(false);
|
|
134
|
+
const reader = res.body!.getReader();
|
|
135
|
+
try {
|
|
136
|
+
expect(new TextDecoder().decode((await reader.read()).value)).toBe("data: first\n\n");
|
|
137
|
+
} finally {
|
|
138
|
+
controller.close();
|
|
139
|
+
await reader.cancel();
|
|
140
|
+
}
|
|
141
|
+
}));
|
|
142
|
+
expect(stopRecording()).toEqual([]);
|
|
143
|
+
}, 2_000);
|
|
144
|
+
|
|
145
|
+
test("test-authored ingress callbacks retain their scope but their fake upstream does not", async () => {
|
|
146
|
+
const callback = runInstrumented(scope, () => bindInstrumentationScope(async () => {
|
|
147
|
+
const res = await fetch(url("/interceptor")) as unknown as WrappedResponse;
|
|
148
|
+
expect(res.status.unwrap()).toBe(200);
|
|
149
|
+
await runUninstrumented(async () => {
|
|
150
|
+
const upstream = await fetch(url("/fake-upstream"));
|
|
151
|
+
expect(upstream.status).toBe(200);
|
|
152
|
+
});
|
|
153
|
+
}));
|
|
154
|
+
// Dispatch from outside the test's async call chain, like ingress does.
|
|
155
|
+
await callback();
|
|
156
|
+
expect(stopRecording()).toMatchObject([{ kind: "http", url: url("/interceptor") }]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("fake errors keep native semantics and throwing restores the test scope", async () => {
|
|
160
|
+
await runInstrumented(scope, async () => {
|
|
161
|
+
let rawError: unknown;
|
|
162
|
+
try {
|
|
163
|
+
await runUninstrumented(async () => {
|
|
164
|
+
await Promise.resolve();
|
|
165
|
+
await fetch("http://[invalid");
|
|
166
|
+
});
|
|
167
|
+
} catch (err) {
|
|
168
|
+
rawError = err;
|
|
169
|
+
}
|
|
170
|
+
expect(rawError).toBeInstanceOf(Error);
|
|
171
|
+
expect(isTransportError(rawError)).toBe(false);
|
|
172
|
+
let recordedError: unknown;
|
|
173
|
+
try { await fetch("http://[invalid"); } catch (err) { recordedError = err; }
|
|
174
|
+
expect(isTransportError(recordedError)).toBe(true);
|
|
175
|
+
await fetch(url("/test"));
|
|
176
|
+
});
|
|
177
|
+
expect(stopRecording().map((e) => e.kind)).toEqual(["http", "http"]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("late responses and detached callbacks cannot record into the next test", async () => {
|
|
181
|
+
const entered = deferred();
|
|
182
|
+
const release = deferred();
|
|
183
|
+
handler = async (req) => {
|
|
184
|
+
if (new URL(req.url).pathname === "/late") {
|
|
185
|
+
entered.resolve();
|
|
186
|
+
await release.promise;
|
|
187
|
+
}
|
|
188
|
+
return Response.json({ value: 42 });
|
|
189
|
+
};
|
|
190
|
+
const late = runInstrumented(scope, async () => {
|
|
191
|
+
const res = await fetch(url("/late"));
|
|
192
|
+
expect(res.status).toBe(200);
|
|
193
|
+
await fetch(url("/detached"));
|
|
194
|
+
});
|
|
195
|
+
await entered.promise;
|
|
196
|
+
scope.active = false;
|
|
197
|
+
restore();
|
|
198
|
+
expect(stopRecording()).toEqual([]);
|
|
199
|
+
startRecording();
|
|
200
|
+
restore = installFetchWrapper();
|
|
201
|
+
const next = createInstrumentationScope();
|
|
202
|
+
try {
|
|
203
|
+
release.resolve();
|
|
204
|
+
await late;
|
|
205
|
+
await runInstrumented(next, () => fetch(url("/next-test")));
|
|
206
|
+
expect(stopRecording()).toMatchObject([{ kind: "http", url: url("/next-test"), seq: 0 }]);
|
|
207
|
+
} finally {
|
|
208
|
+
next.active = false;
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("a response body finishing after scope closure cannot record into a new test", async () => {
|
|
213
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
214
|
+
const reading = deferred();
|
|
215
|
+
handler = () => new Response(new ReadableStream<Uint8Array>({
|
|
216
|
+
start(c) { controller = c; },
|
|
217
|
+
pull(c) {
|
|
218
|
+
c.enqueue(new TextEncoder().encode("part"));
|
|
219
|
+
reading.resolve();
|
|
220
|
+
// Keep the body open until the test closes the scope.
|
|
221
|
+
return new Promise(() => {});
|
|
222
|
+
},
|
|
223
|
+
}), { headers: { "content-type": "text/plain" } });
|
|
224
|
+
const pending = runInstrumented(scope, () => fetch(url("/body")));
|
|
225
|
+
await reading.promise;
|
|
226
|
+
// Yield so the fetch wrapper can start reading its clone.
|
|
227
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
228
|
+
scope.active = false;
|
|
229
|
+
stopRecording();
|
|
230
|
+
startRecording();
|
|
231
|
+
controller.close();
|
|
232
|
+
const res = await pending;
|
|
233
|
+
expect(res.status).toBe(200);
|
|
234
|
+
expect(await res.text()).toBe("part");
|
|
235
|
+
expect(stopRecording()).toEqual([]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("setup/eval keep wrapped responses without recording or wrapping fake internals", async () => {
|
|
239
|
+
const setup = createInstrumentationScope(false);
|
|
240
|
+
try {
|
|
241
|
+
await runInstrumented(setup, async () => {
|
|
242
|
+
const res = await fetch(url("/setup")) as unknown as WrappedResponse;
|
|
243
|
+
expect(res.status.unwrap()).toBe(200);
|
|
244
|
+
await runUninstrumented(async () => {
|
|
245
|
+
expect((await fetch(url("/setup-fake"))).status).toBe(200);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
expect(stopRecording()).toEqual([]);
|
|
249
|
+
} finally {
|
|
250
|
+
setup.active = false;
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
});
|