@specific.dev/spectest 0.66.0 → 0.68.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/browser.js +42 -1
- package/dist/components/supabase.d.ts +0 -14
- package/dist/components/supabase.js +2 -8
- package/dist/daemon.js +155 -35
- package/dist/harness/intercept.d.ts +22 -0
- package/dist/harness/intercept.js +29 -0
- package/dist/harness/wrapper-rules.d.ts +149 -0
- package/dist/harness/wrapper-rules.js +422 -0
- package/dist/index.d.ts +52 -16
- package/dist/index.js +76 -17
- package/dist/locator-errors.d.ts +19 -10
- package/dist/locator-errors.js +80 -20
- package/dist/locator-hints.d.ts +96 -0
- package/dist/locator-hints.js +403 -0
- package/dist/locator.d.ts +22 -0
- package/dist/locator.js +63 -9
- package/dist/page-snapshot.d.ts +42 -0
- package/dist/page-snapshot.js +149 -0
- package/dist/recorder.d.ts +16 -0
- package/dist/text-match.d.ts +39 -0
- package/dist/text-match.js +239 -0
- package/package.json +1 -1
- package/src/browser.ts +43 -1
- package/src/components/supabase.ts +2 -20
- package/src/daemon.ts +171 -34
- package/src/harness/intercept.test.ts +36 -0
- package/src/harness/intercept.ts +40 -0
- package/src/harness/wrapper-rules.test.ts +170 -0
- package/src/harness/wrapper-rules.ts +547 -0
- package/src/index.ts +159 -32
- package/src/locator-errors.test.ts +99 -11
- package/src/locator-errors.ts +98 -19
- package/src/locator-hints.test.ts +188 -0
- package/src/locator-hints.ts +514 -0
- package/src/locator.ts +72 -9
- package/src/page-snapshot.test.ts +100 -0
- package/src/page-snapshot.ts +180 -0
- package/src/recorder.ts +16 -0
- package/src/text-match.test.ts +132 -0
- package/src/text-match.ts +285 -0
package/dist/browser.js
CHANGED
|
@@ -34,6 +34,7 @@ import { fileURLToPath } from "node:url";
|
|
|
34
34
|
import { generateId } from "./ids.js";
|
|
35
35
|
import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
|
|
36
36
|
import { wrap } from "./inspect.js";
|
|
37
|
+
import { capturePageStructure } from "./page-snapshot.js";
|
|
37
38
|
import { describeUrlPattern, matchesUrl } from "./url-match.js";
|
|
38
39
|
import { attachBrowserCoverage, browserCoverageActive, harvestBrowserCoverage, } from "./browser-coverage.js";
|
|
39
40
|
import { attachBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, desktopStrategy, makeLocator, mobileStrategy, } from "./locator.js";
|
|
@@ -1122,6 +1123,37 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1122
1123
|
// when rrweb's load-deferred full snapshot is most likely to be
|
|
1123
1124
|
// missing from the chunk we're about to drain (see `drainExpr`).
|
|
1124
1125
|
let lastDrainUrl = null;
|
|
1126
|
+
// Failed steps that captured their page. Bounded per session per case: a
|
|
1127
|
+
// test that catches its own locator errors in a loop must not pay for a
|
|
1128
|
+
// capture on each one, and three pages is already more than a reader will
|
|
1129
|
+
// open. `ctx.poll` needs no bookkeeping here — it truncates the events of
|
|
1130
|
+
// every superseded attempt, and each capture rides its own event.
|
|
1131
|
+
let pageCaptures = 0;
|
|
1132
|
+
const PAGE_CAPTURE_LIMIT = 3;
|
|
1133
|
+
/** The page behind a failed step, for its event (see page-snapshot.ts).
|
|
1134
|
+
* Best-effort and quiet: this runs while an error is already on its way
|
|
1135
|
+
* up, and must never become the failure the author sees. */
|
|
1136
|
+
async function failedPageStructure(action, error) {
|
|
1137
|
+
// No recorder means nothing is recording this op (an eval, a library
|
|
1138
|
+
// caller), so there is no event for the capture to ride out on.
|
|
1139
|
+
if (!recorder || recordingEnded || viewClosed)
|
|
1140
|
+
return undefined;
|
|
1141
|
+
if (pageCaptures >= PAGE_CAPTURE_LIMIT)
|
|
1142
|
+
return undefined;
|
|
1143
|
+
pageCaptures += 1;
|
|
1144
|
+
try {
|
|
1145
|
+
return await capturePageStructure(holder.page, {
|
|
1146
|
+
session: recorder.sessionName ?? "",
|
|
1147
|
+
// A desktop view has no device preset; a phone-emulated one does.
|
|
1148
|
+
kind: holder.device ? "mobile" : "browser",
|
|
1149
|
+
action,
|
|
1150
|
+
error,
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
catch {
|
|
1154
|
+
return undefined;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1125
1157
|
/** Session provenance stamped on every recorded op: which replay player
|
|
1126
1158
|
* the step belongs to, which named browser it acted on, and where in
|
|
1127
1159
|
* the player to seek (`endT`, in rrweb's clock). */
|
|
@@ -1203,12 +1235,17 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1203
1235
|
catch (err) {
|
|
1204
1236
|
const e = err;
|
|
1205
1237
|
const endT = Date.now();
|
|
1238
|
+
const error = e?.message ?? String(err);
|
|
1239
|
+
// Captured after the clock stops, so the diagnostic is not billed to
|
|
1240
|
+
// the step, and before the event is recorded, so it rides it.
|
|
1241
|
+
const pageStructure = await failedPageStructure(action, error);
|
|
1206
1242
|
recordBrowser({
|
|
1207
1243
|
action,
|
|
1208
1244
|
...fields,
|
|
1209
1245
|
...sessionFields(endT),
|
|
1210
1246
|
durationMs: endT - t,
|
|
1211
|
-
error
|
|
1247
|
+
error,
|
|
1248
|
+
...(pageStructure ? { pageStructure } : {}),
|
|
1212
1249
|
}, resv);
|
|
1213
1250
|
// Still try to drain — the failure itself may have produced
|
|
1214
1251
|
// useful rrweb events (mutations from a half-loaded page, etc.).
|
|
@@ -1536,12 +1573,16 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1536
1573
|
// where the matcher started polling, since that's what `tOffsetMs` means
|
|
1537
1574
|
// everywhere else (the ops that reserve up front stamp their start).
|
|
1538
1575
|
const endT = Date.now();
|
|
1576
|
+
// A matcher that ran out of budget is the other half of a failed
|
|
1577
|
+
// browser step, and the page it settled on is the same evidence.
|
|
1578
|
+
const pageStructure = error ? await failedPageStructure(action, error) : undefined;
|
|
1539
1579
|
const seq = recordBrowser({
|
|
1540
1580
|
action,
|
|
1541
1581
|
...fields,
|
|
1542
1582
|
...sessionFields(endT),
|
|
1543
1583
|
durationMs: waitedMs,
|
|
1544
1584
|
...(error ? { error } : {}),
|
|
1585
|
+
...(pageStructure ? { pageStructure } : {}),
|
|
1545
1586
|
}, reserveBackdated(waitedMs));
|
|
1546
1587
|
// Drain the rrweb the page buffered while the matcher waited into this
|
|
1547
1588
|
// step's chunk, so `settledTarget` has bounds to seek into.
|
|
@@ -33,20 +33,6 @@ export interface SupabaseOptions {
|
|
|
33
33
|
storage?: boolean;
|
|
34
34
|
/** Include Realtime (`<name>-realtime`). Default `true`. */
|
|
35
35
|
realtime?: boolean;
|
|
36
|
-
/**
|
|
37
|
-
* Edge functions. `isolation` picks how they run on the edge runtime:
|
|
38
|
-
* `"shared"` (default) loads every function into **one** isolate, so a
|
|
39
|
-
* shared module graph (`_shared/`, npm packages) is loaded once rather than
|
|
40
|
-
* once per function — measured on a 51-function project as 230 MB against
|
|
41
|
-
* 3.6 GB. Hosted Supabase runs one isolate per function, and the one thing
|
|
42
|
-
* that differs is module-level state: a singleton in `_shared` is one
|
|
43
|
-
* object for all functions here, one per function there. `"per-function"`
|
|
44
|
-
* runs it the hosted way. Either way the runtime, the request each
|
|
45
|
-
* function sees and the per-function `verify_jwt` are the same.
|
|
46
|
-
*/
|
|
47
|
-
functions?: {
|
|
48
|
-
isolation?: "shared" | "per-function";
|
|
49
|
-
};
|
|
50
36
|
/**
|
|
51
37
|
* Also serve the gateway over **HTTPS** at `https://<hostname>` via the
|
|
52
38
|
* daemon's CA-trusted TLS reverse proxy (in addition to the plain
|
|
@@ -698,9 +698,6 @@ async function validJwt(token: string): Promise<boolean> {
|
|
|
698
698
|
}
|
|
699
699
|
}
|
|
700
700
|
|
|
701
|
-
/** How the functions run: every function in one isolate (the default), or
|
|
702
|
-
* one isolate per function as hosted Supabase does — see sharedWorker. */
|
|
703
|
-
const ISOLATION = Deno.env.get("FUNCTIONS_ISOLATION") === "per-function" ? "per-function" : "shared";
|
|
704
701
|
/** Where the generated entry of the shared isolate lives — outside the repo
|
|
705
702
|
* mount, which is read-only and the user's checkout, and NOT under /tmp:
|
|
706
703
|
* the runtime gives its workers an in-memory /tmp, so a file the main
|
|
@@ -780,7 +777,8 @@ async function stdServerUrls(dir: string, out: Set<string>): Promise<void> {
|
|
|
780
777
|
// what differs from hosted Supabase is that module-level state is one
|
|
781
778
|
// object for every function rather than one per function.
|
|
782
779
|
//
|
|
783
|
-
//
|
|
780
|
+
// There is deliberately no opt-out: it stands in wherever it can be exact,
|
|
781
|
+
// and where it cannot the per-function isolates are used. A function with its own
|
|
784
782
|
// deno.json / import map (per-function resolution the one config cannot
|
|
785
783
|
// express), a functions/deno.jsonc (comments; not merged) or a config that
|
|
786
784
|
// names an importMap keep the per-function isolates, and so does a shared
|
|
@@ -929,7 +927,6 @@ async function writeShared(names: string[]): Promise<string | null> {
|
|
|
929
927
|
/** The shared isolate (pooled by the runtime under SHARED_DIR), or null when
|
|
930
928
|
* the functions run one isolate each — decided once, with the reason logged. */
|
|
931
929
|
async function sharedWorker(): Promise<any | null> {
|
|
932
|
-
if (ISOLATION !== "shared") return null;
|
|
933
930
|
if (!sharedSpec) {
|
|
934
931
|
sharedSpec = (async () => {
|
|
935
932
|
const names = await functionNames();
|
|
@@ -1729,9 +1726,6 @@ export function supabase(opts = {}) {
|
|
|
1729
1726
|
JWT_SECRET: jwtSecret,
|
|
1730
1727
|
// Where the router looks for `<name>/`, inside the repo mount.
|
|
1731
1728
|
FUNCTIONS_DIR: fnServePath,
|
|
1732
|
-
// One isolate for all functions (default) or one each — see the
|
|
1733
|
-
// `functions` option and the router's shared-isolate notes.
|
|
1734
|
-
FUNCTIONS_ISOLATION: opts.functions?.isolation ?? "shared",
|
|
1735
1729
|
// The default a function inherits when the config says nothing:
|
|
1736
1730
|
// reject an unauthenticated call, as hosted Supabase does.
|
|
1737
1731
|
VERIFY_JWT: "true",
|
package/dist/daemon.js
CHANGED
|
@@ -39,10 +39,11 @@ import { summarizeBuildKit } from "./harness/buildkit-progress.js";
|
|
|
39
39
|
import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
|
|
40
40
|
import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
|
|
41
41
|
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
42
|
+
import { runWrapperRules } from "./harness/wrapper-rules.js";
|
|
42
43
|
import { APP_DIR, WORKSPACE, resolveProjectPath } from "./project-files.js";
|
|
43
44
|
import { isTextualContentType, looksBinary, omittedBody, parseContentLength, } from "./harness/http-body.js";
|
|
44
45
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
45
|
-
import { InterceptRegistry, runChain, } from "./harness/intercept.js";
|
|
46
|
+
import { InterceptRegistry, parseTarget, runChain, } from "./harness/intercept.js";
|
|
46
47
|
import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
|
|
47
48
|
import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, wildcardSuffix, } from "./harness/hostmatch.js";
|
|
48
49
|
import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, certEntries, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
|
|
@@ -2301,35 +2302,61 @@ function ingressClaimsHostname(hostname) {
|
|
|
2301
2302
|
}
|
|
2302
2303
|
return false;
|
|
2303
2304
|
}
|
|
2304
|
-
/** Record one request an interceptor saw, nested under its `intercept` step.
|
|
2305
|
+
/** Record one request an interceptor saw, nested under its `intercept` step.
|
|
2306
|
+
*
|
|
2307
|
+
* A forced 503 is the step doing exactly what its description says, so it is
|
|
2308
|
+
* **not** marked failed — reddening the outcome the test asked for trains the
|
|
2309
|
+
* reader to ignore the colour. Only a handler that threw is marked, because
|
|
2310
|
+
* that 500 is a bug in the middleware rather than the outage it stands for. */
|
|
2305
2311
|
function recordInterceptedRequest(it, rec, durationMs) {
|
|
2306
2312
|
const parentSeq = INTERCEPT_STEP_SEQ.get(it.id);
|
|
2307
2313
|
if (parentSeq === undefined || !isRecording())
|
|
2308
2314
|
return;
|
|
2309
|
-
const by = rec.
|
|
2310
|
-
? "
|
|
2311
|
-
: rec.answeredBy === "
|
|
2312
|
-
? "
|
|
2313
|
-
:
|
|
2315
|
+
const by = rec.threw
|
|
2316
|
+
? "the interceptor threw — 500 from spectest, not from your handler"
|
|
2317
|
+
: rec.answeredBy === "handler"
|
|
2318
|
+
? "answered by the interceptor"
|
|
2319
|
+
: rec.answeredBy === "modified"
|
|
2320
|
+
? "upstream answer replaced by the interceptor"
|
|
2321
|
+
: "passed through to the upstream";
|
|
2314
2322
|
recordStep({
|
|
2315
2323
|
kind: "intercept-request",
|
|
2316
2324
|
parentSeq,
|
|
2317
2325
|
title: `${rec.method} ${rec.path} → ${rec.status}`,
|
|
2318
|
-
status: rec.
|
|
2326
|
+
status: rec.threw ? "failed" : "passed",
|
|
2327
|
+
// Method, path and status are already the title; the one thing the row
|
|
2328
|
+
// cannot say for itself is who produced that status.
|
|
2319
2329
|
blocks: [
|
|
2320
|
-
{
|
|
2321
|
-
type: "kv",
|
|
2322
|
-
rows: [
|
|
2323
|
-
{ label: "Host", value: it.hostname },
|
|
2324
|
-
{ label: "Request", value: `${rec.method} ${rec.path}` },
|
|
2325
|
-
{ label: "Status", value: String(rec.status) },
|
|
2326
|
-
{ label: "Answered", value: by },
|
|
2327
|
-
],
|
|
2328
|
-
},
|
|
2330
|
+
{ type: "kv", rows: [{ label: "Answered", value: by, error: rec.threw === true }] },
|
|
2329
2331
|
],
|
|
2330
2332
|
durationMs,
|
|
2331
2333
|
});
|
|
2332
2334
|
}
|
|
2335
|
+
/** The handler's own source, which is what the interceptor actually does.
|
|
2336
|
+
*
|
|
2337
|
+
* Free and impossible to let rot: Bun runs the project's TypeScript
|
|
2338
|
+
* directly, so `toString()` is the text the author wrote. It is the step's
|
|
2339
|
+
* body, under the description — a mechanism the reader can check against
|
|
2340
|
+
* the intent the description claims. A handler long enough to be a wall of
|
|
2341
|
+
* code folds into a disclosure instead of pushing the requests off screen.
|
|
2342
|
+
*/
|
|
2343
|
+
function handlerBlocks(handler) {
|
|
2344
|
+
let src;
|
|
2345
|
+
try {
|
|
2346
|
+
src = String(handler);
|
|
2347
|
+
}
|
|
2348
|
+
catch {
|
|
2349
|
+
return [];
|
|
2350
|
+
}
|
|
2351
|
+
const code = { type: "code", lang: "ts", label: "Handler", code: src };
|
|
2352
|
+
return src.split("\n").length > HANDLER_FOLD_LINES
|
|
2353
|
+
? [{ type: "details", summary: "Handler", blocks: [code] }]
|
|
2354
|
+
: [code];
|
|
2355
|
+
}
|
|
2356
|
+
/** Past this many lines the handler source is folded away. Chosen so the
|
|
2357
|
+
* shapes this feature is for — a one-line forced status, a short
|
|
2358
|
+
* fail-twice-then-pass — always show, and only a real program hides. */
|
|
2359
|
+
const HANDLER_FOLD_LINES = 15;
|
|
2333
2360
|
/**
|
|
2334
2361
|
* Put middleware in front of a hostname the ingress serves — the
|
|
2335
2362
|
* implementation behind `ctx.intercept`.
|
|
@@ -2338,15 +2365,19 @@ function recordInterceptedRequest(it, rec, durationMs) {
|
|
|
2338
2365
|
* the daemon (DNS does not point here), so the interceptor could only be
|
|
2339
2366
|
* silent — and silence is the failure mode this whole layer is designed
|
|
2340
2367
|
* against. The message names the two ways to get a route.
|
|
2368
|
+
*
|
|
2369
|
+
* The `description` is required because it is the step's whole title in the
|
|
2370
|
+
* timeline and in the CLI's failure detail: it is what tells a reader why
|
|
2371
|
+
* the UI under test went to its error state. The handler source says how;
|
|
2372
|
+
* only the author can say what it means.
|
|
2341
2373
|
*/
|
|
2342
|
-
function registerInterceptor(
|
|
2343
|
-
const
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
}
|
|
2348
|
-
|
|
2349
|
-
if (!handler) {
|
|
2374
|
+
function registerInterceptor(target, description, handler) {
|
|
2375
|
+
const { hostname: host, path } = parseTarget(target);
|
|
2376
|
+
if (typeof description !== "string" || description.trim() === "") {
|
|
2377
|
+
throw new Error(`ctx.intercept(${JSON.stringify(target)}): a description is required — what the ` +
|
|
2378
|
+
`environment now does, as the timeline reads it, e.g. "the sync endpoint returns 503".`);
|
|
2379
|
+
}
|
|
2380
|
+
if (typeof handler !== "function") {
|
|
2350
2381
|
throw new Error("ctx.intercept: a handler (req, next) => Response is required");
|
|
2351
2382
|
}
|
|
2352
2383
|
if (!ingressClaimsHostname(host)) {
|
|
@@ -2357,19 +2388,41 @@ function registerInterceptor(hostname, pathOrHandler, maybeHandler) {
|
|
|
2357
2388
|
}
|
|
2358
2389
|
const resv = reserveEvent();
|
|
2359
2390
|
const it = INTERCEPTORS.register(host, path, handler);
|
|
2391
|
+
const where = `${host}${it.path === "/" ? "" : it.path}`;
|
|
2360
2392
|
const seq = recordStep({
|
|
2361
2393
|
kind: "intercept",
|
|
2362
|
-
|
|
2394
|
+
// The description alone. The pill already says INTERCEPT, and the
|
|
2395
|
+
// target is in the panel — a row that repeats both spends its width
|
|
2396
|
+
// on what the reader can already see and none of it on the one thing
|
|
2397
|
+
// only the author knows.
|
|
2398
|
+
title: description.trim(),
|
|
2363
2399
|
blocks: [
|
|
2400
|
+
...handlerBlocks(handler),
|
|
2364
2401
|
{
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2402
|
+
// The target IS the summary line, so the closed disclosure still
|
|
2403
|
+
// says where the interceptor sits — a generic "Target" label
|
|
2404
|
+
// would spend the one visible line saying nothing.
|
|
2405
|
+
type: "details",
|
|
2406
|
+
summary: where,
|
|
2407
|
+
blocks: [
|
|
2408
|
+
{
|
|
2409
|
+
type: "kv",
|
|
2410
|
+
rows: [
|
|
2411
|
+
{
|
|
2412
|
+
label: "Matches",
|
|
2413
|
+
value: it.path === "/" ? "every path on this host" : `${it.path} and below`,
|
|
2414
|
+
},
|
|
2415
|
+
{
|
|
2416
|
+
label: "Until",
|
|
2417
|
+
value: it.scope === undefined ? "remove()" : "the end of this test",
|
|
2418
|
+
},
|
|
2419
|
+
],
|
|
2420
|
+
},
|
|
2369
2421
|
],
|
|
2370
2422
|
},
|
|
2371
2423
|
],
|
|
2372
|
-
|
|
2424
|
+
// No duration: this is a marker for the moment the interceptor went
|
|
2425
|
+
// up, and "(0 ms)" reads as a step that did nothing.
|
|
2373
2426
|
}, resv);
|
|
2374
2427
|
if (seq !== undefined)
|
|
2375
2428
|
INTERCEPT_STEP_SEQ.set(it.id, seq);
|
|
@@ -4081,7 +4134,16 @@ async function pollCall(description, fn, opts) {
|
|
|
4081
4134
|
lastIterStartIdx = recorderEventCount();
|
|
4082
4135
|
try {
|
|
4083
4136
|
const v = await fn();
|
|
4084
|
-
|
|
4137
|
+
// Decide on the RAW value. A predicate that hands back a wrapped leaf
|
|
4138
|
+
// (`rows[0].written` off an instrumented query) returns a `Carrier`,
|
|
4139
|
+
// and a carrier around `false` is an object — truthy, and `!== false`.
|
|
4140
|
+
// Without this a poll accepted a condition that was never met and the
|
|
4141
|
+
// test ran on against stale data, which is worse than a timeout: there
|
|
4142
|
+
// is no failure to read (reported 2026-08-30, run_0cjtx0dbzjs5yq6vwefkk,
|
|
4143
|
+
// where `written: false` passed the wait on attempt 1 in 3 ms).
|
|
4144
|
+
// `value` keeps the WRAPPED form so the return still carries provenance.
|
|
4145
|
+
const ready = readRaw(v);
|
|
4146
|
+
if (ready !== null && ready !== undefined && ready !== false) {
|
|
4085
4147
|
value = v;
|
|
4086
4148
|
success = true;
|
|
4087
4149
|
break;
|
|
@@ -5009,7 +5071,11 @@ async function evalCode(code, secrets) {
|
|
|
5009
5071
|
// its own `typescript` in spectest/package.json wins. A project tsconfig.json
|
|
5010
5072
|
// wins over the generated one the same way.
|
|
5011
5073
|
// ────────────────────────────────────────────────────────────────────────
|
|
5012
|
-
|
|
5074
|
+
/** Where the baked compiler lives. Overridable so the typecheck — and the
|
|
5075
|
+
* blocking rules, which always use THIS copy rather than the project's own —
|
|
5076
|
+
* can be driven outside a VM (same convention as
|
|
5077
|
+
* `SPECTEST_COVERAGE_TOOLS_DIR`). */
|
|
5078
|
+
const TYPECHECK_DIR = process.env.SPECTEST_TYPECHECK_DIR ?? "/opt/spectest/typecheck";
|
|
5013
5079
|
/** Cap on errors shipped in the report; `totalErrors` carries the true count. */
|
|
5014
5080
|
const TYPECHECK_ERROR_CAP = 50;
|
|
5015
5081
|
let TYPECHECK = null;
|
|
@@ -5124,7 +5190,17 @@ async function runTypecheck() {
|
|
|
5124
5190
|
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
|
|
5125
5191
|
}
|
|
5126
5192
|
const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
|
|
5127
|
-
|
|
5193
|
+
const wrapper = await runWrapperRulesReport(config);
|
|
5194
|
+
const strip = ({ file, line, column, code, message }) => ({
|
|
5195
|
+
file,
|
|
5196
|
+
line,
|
|
5197
|
+
column,
|
|
5198
|
+
code,
|
|
5199
|
+
message,
|
|
5200
|
+
});
|
|
5201
|
+
const blocking = wrapper.filter((d) => d.blocking).map(strip);
|
|
5202
|
+
const advisory = wrapper.filter((d) => !d.blocking).map(strip);
|
|
5203
|
+
if (errors.length === 0 && wrapper.length === 0) {
|
|
5128
5204
|
// Exit 0 → clean. Non-zero with no *user-file* diagnostics is either
|
|
5129
5205
|
// all-suppressed (still ok from the user's perspective) or a compiler
|
|
5130
5206
|
// crash (config not found, OOM) — surface the latter.
|
|
@@ -5137,7 +5213,51 @@ async function runTypecheck() {
|
|
|
5137
5213
|
}
|
|
5138
5214
|
return { status: "ok", errors: [], totalErrors: 0, durationMs };
|
|
5139
5215
|
}
|
|
5140
|
-
|
|
5216
|
+
// The wrapper findings ride the same list the CLI and the dashboard already
|
|
5217
|
+
// render; `blocking` is a second view of the ones that also fail the run, so
|
|
5218
|
+
// nothing has to learn a new shape to show them. Blocking leads, because
|
|
5219
|
+
// `errors` is capped and the entries that stopped the run must never be the
|
|
5220
|
+
// ones the cap drops.
|
|
5221
|
+
const all = [...blocking, ...advisory, ...errors];
|
|
5222
|
+
return {
|
|
5223
|
+
status: "errors",
|
|
5224
|
+
errors: all.slice(0, TYPECHECK_ERROR_CAP),
|
|
5225
|
+
totalErrors: total + wrapper.length,
|
|
5226
|
+
durationMs,
|
|
5227
|
+
...(blocking.length > 0 ? { blocking } : {}),
|
|
5228
|
+
};
|
|
5229
|
+
}
|
|
5230
|
+
/**
|
|
5231
|
+
* The blocking rules, run against the BAKED compiler whatever the project
|
|
5232
|
+
* pins. Best-effort in every direction: an install that predates the API, a
|
|
5233
|
+
* config the rules cannot open, or a throw from `typescript/unstable/*` all
|
|
5234
|
+
* yield no findings, so a run proceeds exactly as it does today. Only a
|
|
5235
|
+
* definite finding can stop one.
|
|
5236
|
+
*/
|
|
5237
|
+
async function runWrapperRulesReport(config) {
|
|
5238
|
+
const typescriptDir = path.join(TYPECHECK_DIR, "node_modules", "typescript");
|
|
5239
|
+
if (!existsSync(path.join(typescriptDir, "dist", "api", "async", "api.js")))
|
|
5240
|
+
return [];
|
|
5241
|
+
try {
|
|
5242
|
+
const run = await runWrapperRules({
|
|
5243
|
+
typescriptDir,
|
|
5244
|
+
configFile: config,
|
|
5245
|
+
appDir: APP_DIR,
|
|
5246
|
+
readFile: (f) => fs.readFile(f, "utf8"),
|
|
5247
|
+
relative: path.relative,
|
|
5248
|
+
join: path.join,
|
|
5249
|
+
dirname: path.dirname,
|
|
5250
|
+
});
|
|
5251
|
+
if (run.status !== "ok") {
|
|
5252
|
+
console.warn(`[typecheck] wrapper rules ${run.status}: ${run.detail ?? "no detail"}`);
|
|
5253
|
+
return [];
|
|
5254
|
+
}
|
|
5255
|
+
return run.diagnostics;
|
|
5256
|
+
}
|
|
5257
|
+
catch (err) {
|
|
5258
|
+
console.warn(`[typecheck] wrapper rules threw: ${err?.message ?? err}`);
|
|
5259
|
+
return [];
|
|
5260
|
+
}
|
|
5141
5261
|
}
|
|
5142
5262
|
/** Run `job` under a mutual-exclusion slot, refusing if one is held. */
|
|
5143
5263
|
async function exclusive(state, slot, busy, job) {
|
|
@@ -48,6 +48,12 @@ export interface InterceptedRequest {
|
|
|
48
48
|
* upstream via `next()` untouched (`upstream`), or the upstream's answer
|
|
49
49
|
* replaced by the interceptor after `next()` (`modified`). */
|
|
50
50
|
answeredBy: "handler" | "upstream" | "modified";
|
|
51
|
+
/** The handler threw (or returned something that is not a `Response`), so
|
|
52
|
+
* the 500 below is a bug in the test's own middleware rather than the
|
|
53
|
+
* outage it was asked to produce. The distinction cannot be recovered
|
|
54
|
+
* from the status: a deliberate 500 and a crashed handler are the same
|
|
55
|
+
* number, and only this tells the timeline which one to mark as wrong. */
|
|
56
|
+
threw?: boolean;
|
|
51
57
|
}
|
|
52
58
|
export interface Interceptor {
|
|
53
59
|
id: number;
|
|
@@ -61,6 +67,22 @@ export interface Interceptor {
|
|
|
61
67
|
calls: number;
|
|
62
68
|
requests: InterceptedRequest[];
|
|
63
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Split an intercept target — `app.test`, `app.test/api/sync`,
|
|
72
|
+
* `*.example.com/v1` — into the hostname and the mount path.
|
|
73
|
+
*
|
|
74
|
+
* The path rides the target rather than a second argument because that is
|
|
75
|
+
* what it is: part of what the interceptor claims, not a separate knob. It
|
|
76
|
+
* also keeps the signature down to one string, so the description that
|
|
77
|
+
* follows can never be mistaken for a path.
|
|
78
|
+
*
|
|
79
|
+
* A scheme is tolerated (`https://app.test/api`) since that is how the same
|
|
80
|
+
* address is written everywhere else.
|
|
81
|
+
*/
|
|
82
|
+
export declare function parseTarget(target: string): {
|
|
83
|
+
hostname: string;
|
|
84
|
+
path: string;
|
|
85
|
+
};
|
|
64
86
|
/** Mount semantics (`app.use(path, fn)`): `/api` matches `/api`, `/api/`, `/api/x`, and
|
|
65
87
|
* never `/apix`. `/` matches everything. Query strings do not take part. */
|
|
66
88
|
export declare function pathMounts(mount: string, pathname: string): boolean;
|
|
@@ -33,6 +33,34 @@
|
|
|
33
33
|
* plain `Request`/`Response` objects.
|
|
34
34
|
*/
|
|
35
35
|
import { isWildcard, wildcardSuffix } from "./hostmatch";
|
|
36
|
+
/**
|
|
37
|
+
* Split an intercept target — `app.test`, `app.test/api/sync`,
|
|
38
|
+
* `*.example.com/v1` — into the hostname and the mount path.
|
|
39
|
+
*
|
|
40
|
+
* The path rides the target rather than a second argument because that is
|
|
41
|
+
* what it is: part of what the interceptor claims, not a separate knob. It
|
|
42
|
+
* also keeps the signature down to one string, so the description that
|
|
43
|
+
* follows can never be mistaken for a path.
|
|
44
|
+
*
|
|
45
|
+
* A scheme is tolerated (`https://app.test/api`) since that is how the same
|
|
46
|
+
* address is written everywhere else.
|
|
47
|
+
*/
|
|
48
|
+
export function parseTarget(target) {
|
|
49
|
+
if (typeof target !== "string" || target.trim() === "") {
|
|
50
|
+
throw new Error("intercept: a target hostname is required, e.g. \"app.test\" or \"app.test/api\"");
|
|
51
|
+
}
|
|
52
|
+
const bare = target.trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
|
|
53
|
+
const slash = bare.indexOf("/");
|
|
54
|
+
const hostname = slash === -1 ? bare : bare.slice(0, slash);
|
|
55
|
+
const rest = slash === -1 ? undefined : bare.slice(slash);
|
|
56
|
+
if (hostname === "") {
|
|
57
|
+
throw new Error(`intercept: target ${JSON.stringify(target)} names no hostname — write it as "app.test/api", not "/api"`);
|
|
58
|
+
}
|
|
59
|
+
if (hostname.includes("?") || hostname.includes("#")) {
|
|
60
|
+
throw new Error(`intercept: target ${JSON.stringify(target)} cannot carry a query or fragment`);
|
|
61
|
+
}
|
|
62
|
+
return { hostname, path: normalizeMount(rest) };
|
|
63
|
+
}
|
|
36
64
|
/** Mount semantics (`app.use(path, fn)`): `/api` matches `/api`, `/api/`, `/api/x`, and
|
|
37
65
|
* never `/apix`. `/` matches everything. Query strings do not take part. */
|
|
38
66
|
export function pathMounts(mount, pathname) {
|
|
@@ -159,6 +187,7 @@ export async function runChain(chain, req, upstream, observe) {
|
|
|
159
187
|
res = new Response(`spectest-daemon: interceptor for ${it.hostname} threw: ${e?.message ?? String(err)}\n`, { status: 500, headers: { "content-type": "text/plain" } });
|
|
160
188
|
record.answeredBy = "handler";
|
|
161
189
|
record.status = 500;
|
|
190
|
+
record.threw = true;
|
|
162
191
|
it.calls++;
|
|
163
192
|
it.requests.push(record);
|
|
164
193
|
observe?.(it, record, Date.now() - started);
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** A finding, shaped like the `TypecheckError` the report already carries. */
|
|
2
|
+
export interface WrapperDiagnostic {
|
|
3
|
+
/** Path relative to the app dir, matching the advisory diagnostics. */
|
|
4
|
+
file: string;
|
|
5
|
+
line: number;
|
|
6
|
+
column: number;
|
|
7
|
+
code: string;
|
|
8
|
+
message: string;
|
|
9
|
+
/**
|
|
10
|
+
* Whether this finding may fail the run. Only a rule that is true of the
|
|
11
|
+
* RUN, not merely of the declared types, may block — see the header and the
|
|
12
|
+
* note on {@link CODE_TRUTHY}.
|
|
13
|
+
*/
|
|
14
|
+
blocking: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A condition on a wrapped value — always true. **Blocking**, but on different
|
|
18
|
+
* grounds from {@link CODE_EQUALITY}, and the difference is worth knowing.
|
|
19
|
+
*
|
|
20
|
+
* This rule is NOT a proof. Two things can hide a runtime nullish from the
|
|
21
|
+
* compiler: a row generic that overstates a column
|
|
22
|
+
* (`client<{ names: string }>` over a `string_agg` that returns NULL) and
|
|
23
|
+
* TypeScript's deliberately unsound array indexing (`rows[0]` is typed
|
|
24
|
+
* non-optional even when the array is empty). A nullish leaf comes back RAW
|
|
25
|
+
* from `wrapChild`, so in those shapes `if (rows[0]?.names)` really does tell
|
|
26
|
+
* present from absent, and "always true" is false about it — measured against
|
|
27
|
+
* a real suite on 2026-08-30 (`journal-note.ts:200`).
|
|
28
|
+
*
|
|
29
|
+
* It blocks anyway, as a CONVENTION rather than a verdict: *unwrap before
|
|
30
|
+
* branching on a wrapped value*, the same discipline the docs already teach
|
|
31
|
+
* for `===`. What makes that acceptable is that the fix is safe in every case
|
|
32
|
+
* — `?.unwrap()` short-circuits, so it never throws and never changes code
|
|
33
|
+
* that was already correct; it only removes the accident. And the accident is
|
|
34
|
+
* the worst failure this SDK has: a `ctx.poll` predicate that collapses a
|
|
35
|
+
* wrapped `false` to `true` passes on attempt 1, the suite runs on against
|
|
36
|
+
* data that never arrived, and neither the compiler nor the runtime says a
|
|
37
|
+
* word (reported 2026-08-30).
|
|
38
|
+
*
|
|
39
|
+
* The line this does not cross: a rule may demand more of the user's own code,
|
|
40
|
+
* but it may never fail a run over OUR stale types (the patched global
|
|
41
|
+
* `fetch`) or over the compiler's own bad inference (TS2367 across a
|
|
42
|
+
* callback). Those cost the user a fix they cannot make.
|
|
43
|
+
*/
|
|
44
|
+
export declare const CODE_TRUTHY = "SPECTEST2001";
|
|
45
|
+
/**
|
|
46
|
+
* `===`/`!==` between a wrapped value and a plain one — always false/true.
|
|
47
|
+
* **Blocking.** Sound even when a row generic understates nullability: if the
|
|
48
|
+
* leaf is a carrier the comparison is false because an object never equals a
|
|
49
|
+
* primitive, and if it is raw nullish it is false because nullish does not
|
|
50
|
+
* equal the literal either. A nullish literal on the other side is excluded —
|
|
51
|
+
* see {@link equalityIsConstant}.
|
|
52
|
+
*/
|
|
53
|
+
export declare const CODE_EQUALITY = "SPECTEST2002";
|
|
54
|
+
/**
|
|
55
|
+
* Split a printed type on its TOP-LEVEL `|`, leaving nested unions alone
|
|
56
|
+
* (`Carrier<A | B> | undefined` → [`Carrier<A | B>`, `undefined`]). Depth is
|
|
57
|
+
* tracked across every bracket kind because a printed type can hold object
|
|
58
|
+
* literals (`{ a: 1 | 2 }`), tuples and parenthesised function types.
|
|
59
|
+
*/
|
|
60
|
+
export declare function splitUnion(text: string): string[];
|
|
61
|
+
/** One union member that is the primitive carrier. Object/array/response
|
|
62
|
+
* wrappers are deliberately NOT included: they are objects whether or not we
|
|
63
|
+
* wrap them, so a condition on one is not made constant by the wrapper. */
|
|
64
|
+
export declare function isCarrierMember(member: string): boolean;
|
|
65
|
+
export type TypeVerdict =
|
|
66
|
+
/** Every member is a carrier and none is nullish — constant at runtime. */
|
|
67
|
+
"carrier"
|
|
68
|
+
/** A carrier that may also be absent — a real presence test, leave alone. */
|
|
69
|
+
| "nullable-carrier"
|
|
70
|
+
/** Not a wrapper. */
|
|
71
|
+
| "plain";
|
|
72
|
+
/**
|
|
73
|
+
* Classify a printed type for the rules. `unknown`/`any`/an error type read as
|
|
74
|
+
* `plain`: the checker could not say what the value is, and a rule that blocks
|
|
75
|
+
* a run must not guess.
|
|
76
|
+
*/
|
|
77
|
+
export declare function classifyType(text: string | undefined): TypeVerdict;
|
|
78
|
+
/** Source text that denotes a nullish literal. */
|
|
79
|
+
export declare function isNullishLiteral(exprText: string): boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Verdict for a strict `===`/`!==`. Blocking only when exactly one side is a
|
|
82
|
+
* definite carrier: two carriers compare object identity, which is a different
|
|
83
|
+
* mistake and not one this rule can prove constant.
|
|
84
|
+
*
|
|
85
|
+
* A comparison against a NULLISH LITERAL is never constant, whatever the type
|
|
86
|
+
* says. `wrapChild` hands a `null`/`undefined` leaf back RAW, so
|
|
87
|
+
* `row.setting === null` is the correct way to ask whether a column is null —
|
|
88
|
+
* and it answers correctly in both directions. The declared type cannot show
|
|
89
|
+
* this, because a row generic is the caller's own assertion
|
|
90
|
+
* (`client<{ setting: unknown }>`) and routinely understates nullability.
|
|
91
|
+
* Found in a real suite on 2026-08-30 (`document-export-settings.ts:64`),
|
|
92
|
+
* where flagging it would have blocked a passing test.
|
|
93
|
+
*/
|
|
94
|
+
export declare function equalityIsConstant(left: string | undefined, right: string | undefined, leftExpr?: string, rightExpr?: string): boolean;
|
|
95
|
+
/**
|
|
96
|
+
* The real start of a node. A TypeScript node's `pos` is the end of the
|
|
97
|
+
* PREVIOUS node, so it includes the leading whitespace and comments; `tsc`
|
|
98
|
+
* reports the first meaningful character. Without this every column is a few
|
|
99
|
+
* places to the left and a finding above a comment points at the comment.
|
|
100
|
+
*/
|
|
101
|
+
export declare function startOfNode(text: string, pos: number): number;
|
|
102
|
+
/** 1-indexed line/column for a character offset, matching `tsc` output. */
|
|
103
|
+
export declare function lineColumnAt(text: string, pos: number): {
|
|
104
|
+
line: number;
|
|
105
|
+
column: number;
|
|
106
|
+
};
|
|
107
|
+
/** Trim a source snippet for a message: one line, bounded. */
|
|
108
|
+
export declare function snippet(text: string, pos: number, end: number): string;
|
|
109
|
+
export declare function truthyMessage(expr: string, typeText: string): string;
|
|
110
|
+
export declare function equalityMessage(expr: string, typeText: string, negated: boolean): string;
|
|
111
|
+
/** Minimal shape of the bits of the TS 7 API this uses. */
|
|
112
|
+
interface TsNode {
|
|
113
|
+
kind: number;
|
|
114
|
+
pos: number;
|
|
115
|
+
end: number;
|
|
116
|
+
[k: string]: unknown;
|
|
117
|
+
}
|
|
118
|
+
export interface WrapperRuleRun {
|
|
119
|
+
status: "ok" | "skipped" | "failed";
|
|
120
|
+
diagnostics: WrapperDiagnostic[];
|
|
121
|
+
detail?: string;
|
|
122
|
+
durationMs: number;
|
|
123
|
+
}
|
|
124
|
+
/** Candidate positions, and what each one means if the operand is a carrier. */
|
|
125
|
+
interface Candidate {
|
|
126
|
+
node: TsNode;
|
|
127
|
+
kind: "truthy" | "equality";
|
|
128
|
+
/** For an equality, the other side, whose type decides with this one. */
|
|
129
|
+
other?: TsNode;
|
|
130
|
+
negated?: boolean;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Collect the positions worth asking about in one file. Kept separate from the
|
|
134
|
+
* checker so the walk can be reasoned about (and extended) on its own.
|
|
135
|
+
*/
|
|
136
|
+
export declare function collectCandidates(statements: readonly TsNode[], kindName: (n: TsNode) => string, walk: (n: TsNode, visit: (c: TsNode) => void) => void): Candidate[];
|
|
137
|
+
export declare function runWrapperRules(opts: {
|
|
138
|
+
/** Directory of the baked `typescript` package. */
|
|
139
|
+
typescriptDir: string;
|
|
140
|
+
/** Absolute path of the tsconfig to open. */
|
|
141
|
+
configFile: string;
|
|
142
|
+
/** Only files under here produce diagnostics. */
|
|
143
|
+
appDir: string;
|
|
144
|
+
readFile: (p: string) => Promise<string>;
|
|
145
|
+
relative: (from: string, to: string) => string;
|
|
146
|
+
join: (...parts: string[]) => string;
|
|
147
|
+
dirname: (p: string) => string;
|
|
148
|
+
}): Promise<WrapperRuleRun>;
|
|
149
|
+
export {};
|