@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/src/daemon.ts
CHANGED
|
@@ -90,6 +90,8 @@ import {
|
|
|
90
90
|
sanitizeSegment,
|
|
91
91
|
} from "./harness/volume-paths.js";
|
|
92
92
|
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
93
|
+
import { runWrapperRules } from "./harness/wrapper-rules.js";
|
|
94
|
+
import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
|
|
93
95
|
import { APP_DIR, WORKSPACE, resolveProjectPath } from "./project-files.js";
|
|
94
96
|
import {
|
|
95
97
|
isTextualContentType,
|
|
@@ -100,6 +102,7 @@ import {
|
|
|
100
102
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
101
103
|
import {
|
|
102
104
|
InterceptRegistry,
|
|
105
|
+
parseTarget,
|
|
103
106
|
runChain,
|
|
104
107
|
type Interceptor,
|
|
105
108
|
} from "./harness/intercept.js";
|
|
@@ -174,6 +177,7 @@ import {
|
|
|
174
177
|
startRecording,
|
|
175
178
|
stopRecording,
|
|
176
179
|
truncateUtf8,
|
|
180
|
+
type StepBlock,
|
|
177
181
|
type TestEvent,
|
|
178
182
|
type OmittedBody,
|
|
179
183
|
} from "./recorder.js";
|
|
@@ -2796,16 +2800,22 @@ function ingressClaimsHostname(hostname: string): boolean {
|
|
|
2796
2800
|
return false;
|
|
2797
2801
|
}
|
|
2798
2802
|
|
|
2799
|
-
/** Record one request an interceptor saw, nested under its `intercept` step.
|
|
2803
|
+
/** Record one request an interceptor saw, nested under its `intercept` step.
|
|
2804
|
+
*
|
|
2805
|
+
* A forced 503 is the step doing exactly what its description says, so it is
|
|
2806
|
+
* **not** marked failed — reddening the outcome the test asked for trains the
|
|
2807
|
+
* reader to ignore the colour. Only a handler that threw is marked, because
|
|
2808
|
+
* that 500 is a bug in the middleware rather than the outage it stands for. */
|
|
2800
2809
|
function recordInterceptedRequest(
|
|
2801
2810
|
it: Interceptor,
|
|
2802
|
-
rec: { method: string; path: string; status: number; answeredBy: string },
|
|
2811
|
+
rec: { method: string; path: string; status: number; answeredBy: string; threw?: boolean },
|
|
2803
2812
|
durationMs: number,
|
|
2804
2813
|
): void {
|
|
2805
2814
|
const parentSeq = INTERCEPT_STEP_SEQ.get(it.id);
|
|
2806
2815
|
if (parentSeq === undefined || !isRecording()) return;
|
|
2807
|
-
const by =
|
|
2808
|
-
|
|
2816
|
+
const by = rec.threw
|
|
2817
|
+
? "the interceptor threw — 500 from spectest, not from your handler"
|
|
2818
|
+
: rec.answeredBy === "handler"
|
|
2809
2819
|
? "answered by the interceptor"
|
|
2810
2820
|
: rec.answeredBy === "modified"
|
|
2811
2821
|
? "upstream answer replaced by the interceptor"
|
|
@@ -2814,22 +2824,42 @@ function recordInterceptedRequest(
|
|
|
2814
2824
|
kind: "intercept-request",
|
|
2815
2825
|
parentSeq,
|
|
2816
2826
|
title: `${rec.method} ${rec.path} → ${rec.status}`,
|
|
2817
|
-
status: rec.
|
|
2827
|
+
status: rec.threw ? "failed" : "passed",
|
|
2828
|
+
// Method, path and status are already the title; the one thing the row
|
|
2829
|
+
// cannot say for itself is who produced that status.
|
|
2818
2830
|
blocks: [
|
|
2819
|
-
{
|
|
2820
|
-
type: "kv",
|
|
2821
|
-
rows: [
|
|
2822
|
-
{ label: "Host", value: it.hostname },
|
|
2823
|
-
{ label: "Request", value: `${rec.method} ${rec.path}` },
|
|
2824
|
-
{ label: "Status", value: String(rec.status) },
|
|
2825
|
-
{ label: "Answered", value: by },
|
|
2826
|
-
],
|
|
2827
|
-
},
|
|
2831
|
+
{ type: "kv", rows: [{ label: "Answered", value: by, error: rec.threw === true }] },
|
|
2828
2832
|
],
|
|
2829
2833
|
durationMs,
|
|
2830
2834
|
});
|
|
2831
2835
|
}
|
|
2832
2836
|
|
|
2837
|
+
/** The handler's own source, which is what the interceptor actually does.
|
|
2838
|
+
*
|
|
2839
|
+
* Free and impossible to let rot: Bun runs the project's TypeScript
|
|
2840
|
+
* directly, so `toString()` is the text the author wrote. It is the step's
|
|
2841
|
+
* body, under the description — a mechanism the reader can check against
|
|
2842
|
+
* the intent the description claims. A handler long enough to be a wall of
|
|
2843
|
+
* code folds into a disclosure instead of pushing the requests off screen.
|
|
2844
|
+
*/
|
|
2845
|
+
function handlerBlocks(handler: InterceptHandler): StepBlock[] {
|
|
2846
|
+
let src: string;
|
|
2847
|
+
try {
|
|
2848
|
+
src = String(handler);
|
|
2849
|
+
} catch {
|
|
2850
|
+
return [];
|
|
2851
|
+
}
|
|
2852
|
+
const code: StepBlock = { type: "code", lang: "ts", label: "Handler", code: src };
|
|
2853
|
+
return src.split("\n").length > HANDLER_FOLD_LINES
|
|
2854
|
+
? [{ type: "details", summary: "Handler", blocks: [code] }]
|
|
2855
|
+
: [code];
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
/** Past this many lines the handler source is folded away. Chosen so the
|
|
2859
|
+
* shapes this feature is for — a one-line forced status, a short
|
|
2860
|
+
* fail-twice-then-pass — always show, and only a real program hides. */
|
|
2861
|
+
const HANDLER_FOLD_LINES = 15;
|
|
2862
|
+
|
|
2833
2863
|
/**
|
|
2834
2864
|
* Put middleware in front of a hostname the ingress serves — the
|
|
2835
2865
|
* implementation behind `ctx.intercept`.
|
|
@@ -2838,19 +2868,25 @@ function recordInterceptedRequest(
|
|
|
2838
2868
|
* the daemon (DNS does not point here), so the interceptor could only be
|
|
2839
2869
|
* silent — and silence is the failure mode this whole layer is designed
|
|
2840
2870
|
* against. The message names the two ways to get a route.
|
|
2871
|
+
*
|
|
2872
|
+
* The `description` is required because it is the step's whole title in the
|
|
2873
|
+
* timeline and in the CLI's failure detail: it is what tells a reader why
|
|
2874
|
+
* the UI under test went to its error state. The handler source says how;
|
|
2875
|
+
* only the author can say what it means.
|
|
2841
2876
|
*/
|
|
2842
2877
|
function registerInterceptor(
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2878
|
+
target: string,
|
|
2879
|
+
description: string,
|
|
2880
|
+
handler: InterceptHandler,
|
|
2846
2881
|
): Interception {
|
|
2847
|
-
const
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2882
|
+
const { hostname: host, path } = parseTarget(target);
|
|
2883
|
+
if (typeof description !== "string" || description.trim() === "") {
|
|
2884
|
+
throw new Error(
|
|
2885
|
+
`ctx.intercept(${JSON.stringify(target)}): a description is required — what the ` +
|
|
2886
|
+
`environment now does, as the timeline reads it, e.g. "the sync endpoint returns 503".`,
|
|
2887
|
+
);
|
|
2851
2888
|
}
|
|
2852
|
-
|
|
2853
|
-
if (!handler) {
|
|
2889
|
+
if (typeof handler !== "function") {
|
|
2854
2890
|
throw new Error("ctx.intercept: a handler (req, next) => Response is required");
|
|
2855
2891
|
}
|
|
2856
2892
|
if (!ingressClaimsHostname(host)) {
|
|
@@ -2863,20 +2899,42 @@ function registerInterceptor(
|
|
|
2863
2899
|
}
|
|
2864
2900
|
const resv = reserveEvent();
|
|
2865
2901
|
const it = INTERCEPTORS.register(host, path, handler);
|
|
2902
|
+
const where = `${host}${it.path === "/" ? "" : it.path}`;
|
|
2866
2903
|
const seq = recordStep(
|
|
2867
2904
|
{
|
|
2868
2905
|
kind: "intercept",
|
|
2869
|
-
|
|
2906
|
+
// The description alone. The pill already says INTERCEPT, and the
|
|
2907
|
+
// target is in the panel — a row that repeats both spends its width
|
|
2908
|
+
// on what the reader can already see and none of it on the one thing
|
|
2909
|
+
// only the author knows.
|
|
2910
|
+
title: description.trim(),
|
|
2870
2911
|
blocks: [
|
|
2912
|
+
...handlerBlocks(handler),
|
|
2871
2913
|
{
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2914
|
+
// The target IS the summary line, so the closed disclosure still
|
|
2915
|
+
// says where the interceptor sits — a generic "Target" label
|
|
2916
|
+
// would spend the one visible line saying nothing.
|
|
2917
|
+
type: "details",
|
|
2918
|
+
summary: where,
|
|
2919
|
+
blocks: [
|
|
2920
|
+
{
|
|
2921
|
+
type: "kv",
|
|
2922
|
+
rows: [
|
|
2923
|
+
{
|
|
2924
|
+
label: "Matches",
|
|
2925
|
+
value: it.path === "/" ? "every path on this host" : `${it.path} and below`,
|
|
2926
|
+
},
|
|
2927
|
+
{
|
|
2928
|
+
label: "Until",
|
|
2929
|
+
value: it.scope === undefined ? "remove()" : "the end of this test",
|
|
2930
|
+
},
|
|
2931
|
+
],
|
|
2932
|
+
},
|
|
2876
2933
|
],
|
|
2877
2934
|
},
|
|
2878
2935
|
],
|
|
2879
|
-
|
|
2936
|
+
// No duration: this is a marker for the moment the interceptor went
|
|
2937
|
+
// up, and "(0 ms)" reads as a step that did nothing.
|
|
2880
2938
|
},
|
|
2881
2939
|
resv,
|
|
2882
2940
|
);
|
|
@@ -4982,7 +5040,16 @@ async function pollCall<T>(
|
|
|
4982
5040
|
lastIterStartIdx = recorderEventCount();
|
|
4983
5041
|
try {
|
|
4984
5042
|
const v = await fn();
|
|
4985
|
-
|
|
5043
|
+
// Decide on the RAW value. A predicate that hands back a wrapped leaf
|
|
5044
|
+
// (`rows[0].written` off an instrumented query) returns a `Carrier`,
|
|
5045
|
+
// and a carrier around `false` is an object — truthy, and `!== false`.
|
|
5046
|
+
// Without this a poll accepted a condition that was never met and the
|
|
5047
|
+
// test ran on against stale data, which is worse than a timeout: there
|
|
5048
|
+
// is no failure to read (reported 2026-08-30, run_0cjtx0dbzjs5yq6vwefkk,
|
|
5049
|
+
// where `written: false` passed the wait on attempt 1 in 3 ms).
|
|
5050
|
+
// `value` keeps the WRAPPED form so the return still carries provenance.
|
|
5051
|
+
const ready = readRaw(v);
|
|
5052
|
+
if (ready !== null && ready !== undefined && ready !== false) {
|
|
4986
5053
|
value = v as T;
|
|
4987
5054
|
success = true;
|
|
4988
5055
|
break;
|
|
@@ -6047,7 +6114,11 @@ async function evalCode(
|
|
|
6047
6114
|
// wins over the generated one the same way.
|
|
6048
6115
|
// ────────────────────────────────────────────────────────────────────────
|
|
6049
6116
|
|
|
6050
|
-
|
|
6117
|
+
/** Where the baked compiler lives. Overridable so the typecheck — and the
|
|
6118
|
+
* blocking rules, which always use THIS copy rather than the project's own —
|
|
6119
|
+
* can be driven outside a VM (same convention as
|
|
6120
|
+
* `SPECTEST_COVERAGE_TOOLS_DIR`). */
|
|
6121
|
+
const TYPECHECK_DIR = process.env.SPECTEST_TYPECHECK_DIR ?? "/opt/spectest/typecheck";
|
|
6051
6122
|
/** Cap on errors shipped in the report; `totalErrors` carries the true count. */
|
|
6052
6123
|
const TYPECHECK_ERROR_CAP = 50;
|
|
6053
6124
|
|
|
@@ -6056,7 +6127,7 @@ interface TypecheckError {
|
|
|
6056
6127
|
file: string;
|
|
6057
6128
|
line: number;
|
|
6058
6129
|
column: number;
|
|
6059
|
-
/** `TS2345`
|
|
6130
|
+
/** `TS2345` from the compiler, or a `SPECTEST….` rule code. */
|
|
6060
6131
|
code: string;
|
|
6061
6132
|
message: string;
|
|
6062
6133
|
}
|
|
@@ -6068,6 +6139,19 @@ interface TypecheckReport {
|
|
|
6068
6139
|
durationMs: number;
|
|
6069
6140
|
/** Why the check was skipped / how it failed. */
|
|
6070
6141
|
detail?: string;
|
|
6142
|
+
/**
|
|
6143
|
+
* Findings that FAIL the run, as opposed to `errors`, which only advise.
|
|
6144
|
+
* A diagnostic qualifies only when it is true of the program that runs
|
|
6145
|
+
* rather than of the declared types alone — see `harness/wrapper-rules.ts`
|
|
6146
|
+
* for why no `tsc` code can be in here and why these can.
|
|
6147
|
+
*
|
|
6148
|
+
* The list is the rollout mechanism as well as the payload: an older SDK
|
|
6149
|
+
* never sends the field, `#[serde(default)]` leaves it empty on the server,
|
|
6150
|
+
* and the gate is inert for every project that has not moved to a version
|
|
6151
|
+
* carrying it. The version a project runs is already its own choice
|
|
6152
|
+
* (`sdk.rs` honours the declared lower bound), so the bump is the consent.
|
|
6153
|
+
*/
|
|
6154
|
+
blocking?: TypecheckError[];
|
|
6071
6155
|
}
|
|
6072
6156
|
|
|
6073
6157
|
let TYPECHECK: Promise<TypecheckReport> | null = null;
|
|
@@ -6192,7 +6276,17 @@ async function runTypecheck(): Promise<TypecheckReport> {
|
|
|
6192
6276
|
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
|
|
6193
6277
|
}
|
|
6194
6278
|
const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
|
|
6195
|
-
|
|
6279
|
+
const wrapper = await runWrapperRulesReport(config);
|
|
6280
|
+
const strip = ({ file, line, column, code, message }: WrapperDiagnostic): TypecheckError => ({
|
|
6281
|
+
file,
|
|
6282
|
+
line,
|
|
6283
|
+
column,
|
|
6284
|
+
code,
|
|
6285
|
+
message,
|
|
6286
|
+
});
|
|
6287
|
+
const blocking = wrapper.filter((d) => d.blocking).map(strip);
|
|
6288
|
+
const advisory = wrapper.filter((d) => !d.blocking).map(strip);
|
|
6289
|
+
if (errors.length === 0 && wrapper.length === 0) {
|
|
6196
6290
|
// Exit 0 → clean. Non-zero with no *user-file* diagnostics is either
|
|
6197
6291
|
// all-suppressed (still ok from the user's perspective) or a compiler
|
|
6198
6292
|
// crash (config not found, OOM) — surface the latter.
|
|
@@ -6205,7 +6299,50 @@ async function runTypecheck(): Promise<TypecheckReport> {
|
|
|
6205
6299
|
}
|
|
6206
6300
|
return { status: "ok", errors: [], totalErrors: 0, durationMs };
|
|
6207
6301
|
}
|
|
6208
|
-
|
|
6302
|
+
// The wrapper findings ride the same list the CLI and the dashboard already
|
|
6303
|
+
// render; `blocking` is a second view of the ones that also fail the run, so
|
|
6304
|
+
// nothing has to learn a new shape to show them. Blocking leads, because
|
|
6305
|
+
// `errors` is capped and the entries that stopped the run must never be the
|
|
6306
|
+
// ones the cap drops.
|
|
6307
|
+
const all = [...blocking, ...advisory, ...errors];
|
|
6308
|
+
return {
|
|
6309
|
+
status: "errors",
|
|
6310
|
+
errors: all.slice(0, TYPECHECK_ERROR_CAP),
|
|
6311
|
+
totalErrors: total + wrapper.length,
|
|
6312
|
+
durationMs,
|
|
6313
|
+
...(blocking.length > 0 ? { blocking } : {}),
|
|
6314
|
+
};
|
|
6315
|
+
}
|
|
6316
|
+
|
|
6317
|
+
/**
|
|
6318
|
+
* The blocking rules, run against the BAKED compiler whatever the project
|
|
6319
|
+
* pins. Best-effort in every direction: an install that predates the API, a
|
|
6320
|
+
* config the rules cannot open, or a throw from `typescript/unstable/*` all
|
|
6321
|
+
* yield no findings, so a run proceeds exactly as it does today. Only a
|
|
6322
|
+
* definite finding can stop one.
|
|
6323
|
+
*/
|
|
6324
|
+
async function runWrapperRulesReport(config: string): Promise<WrapperDiagnostic[]> {
|
|
6325
|
+
const typescriptDir = path.join(TYPECHECK_DIR, "node_modules", "typescript");
|
|
6326
|
+
if (!existsSync(path.join(typescriptDir, "dist", "api", "async", "api.js"))) return [];
|
|
6327
|
+
try {
|
|
6328
|
+
const run = await runWrapperRules({
|
|
6329
|
+
typescriptDir,
|
|
6330
|
+
configFile: config,
|
|
6331
|
+
appDir: APP_DIR,
|
|
6332
|
+
readFile: (f) => fs.readFile(f, "utf8"),
|
|
6333
|
+
relative: path.relative,
|
|
6334
|
+
join: path.join,
|
|
6335
|
+
dirname: path.dirname,
|
|
6336
|
+
});
|
|
6337
|
+
if (run.status !== "ok") {
|
|
6338
|
+
console.warn(`[typecheck] wrapper rules ${run.status}: ${run.detail ?? "no detail"}`);
|
|
6339
|
+
return [];
|
|
6340
|
+
}
|
|
6341
|
+
return run.diagnostics;
|
|
6342
|
+
} catch (err) {
|
|
6343
|
+
console.warn(`[typecheck] wrapper rules threw: ${(err as Error)?.message ?? err}`);
|
|
6344
|
+
return [];
|
|
6345
|
+
}
|
|
6209
6346
|
}
|
|
6210
6347
|
|
|
6211
6348
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
InterceptRegistry,
|
|
4
4
|
hostnameMatches,
|
|
5
5
|
normalizeMount,
|
|
6
|
+
parseTarget,
|
|
6
7
|
pathMounts,
|
|
7
8
|
runChain,
|
|
8
9
|
type Interceptor,
|
|
@@ -39,6 +40,41 @@ describe("normalizeMount", () => {
|
|
|
39
40
|
});
|
|
40
41
|
});
|
|
41
42
|
|
|
43
|
+
describe("parseTarget", () => {
|
|
44
|
+
test("a bare hostname mounts at the root", () => {
|
|
45
|
+
expect(parseTarget("app.test")).toEqual({ hostname: "app.test", path: "/" });
|
|
46
|
+
});
|
|
47
|
+
test("a path rides the target", () => {
|
|
48
|
+
expect(parseTarget("app.test/api/sync")).toEqual({
|
|
49
|
+
hostname: "app.test",
|
|
50
|
+
path: "/api/sync",
|
|
51
|
+
});
|
|
52
|
+
expect(parseTarget("app.test/api/")).toEqual({ hostname: "app.test", path: "/api" });
|
|
53
|
+
expect(parseTarget("app.test/")).toEqual({ hostname: "app.test", path: "/" });
|
|
54
|
+
});
|
|
55
|
+
test("a scheme and case are tolerated", () => {
|
|
56
|
+
expect(parseTarget("https://App.Test/API")).toEqual({
|
|
57
|
+
hostname: "app.test",
|
|
58
|
+
path: "/api",
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
test("a wildcard host keeps its pattern", () => {
|
|
62
|
+
expect(parseTarget("*.example.com/v1")).toEqual({
|
|
63
|
+
hostname: "*.example.com",
|
|
64
|
+
path: "/v1",
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
test("rejects a target that is only a path, or none at all", () => {
|
|
68
|
+
expect(() => parseTarget("/api")).toThrow(/names no hostname/);
|
|
69
|
+
expect(() => parseTarget("")).toThrow(/target hostname is required/);
|
|
70
|
+
expect(() => parseTarget(" ")).toThrow(/target hostname is required/);
|
|
71
|
+
});
|
|
72
|
+
test("rejects a query or fragment", () => {
|
|
73
|
+
expect(() => parseTarget("app.test/api?x=1")).toThrow(/mount prefix/);
|
|
74
|
+
expect(() => parseTarget("app.test?x=1")).toThrow(/query or fragment/);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
42
78
|
describe("hostnameMatches", () => {
|
|
43
79
|
test("exact and wildcard", () => {
|
|
44
80
|
expect(hostnameMatches("api.test", "api.test")).toBe(true);
|
package/src/harness/intercept.ts
CHANGED
|
@@ -56,6 +56,12 @@ export interface InterceptedRequest {
|
|
|
56
56
|
* upstream via `next()` untouched (`upstream`), or the upstream's answer
|
|
57
57
|
* replaced by the interceptor after `next()` (`modified`). */
|
|
58
58
|
answeredBy: "handler" | "upstream" | "modified";
|
|
59
|
+
/** The handler threw (or returned something that is not a `Response`), so
|
|
60
|
+
* the 500 below is a bug in the test's own middleware rather than the
|
|
61
|
+
* outage it was asked to produce. The distinction cannot be recovered
|
|
62
|
+
* from the status: a deliberate 500 and a crashed handler are the same
|
|
63
|
+
* number, and only this tells the timeline which one to mark as wrong. */
|
|
64
|
+
threw?: boolean;
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
export interface Interceptor {
|
|
@@ -71,6 +77,39 @@ export interface Interceptor {
|
|
|
71
77
|
requests: InterceptedRequest[];
|
|
72
78
|
}
|
|
73
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Split an intercept target — `app.test`, `app.test/api/sync`,
|
|
82
|
+
* `*.example.com/v1` — into the hostname and the mount path.
|
|
83
|
+
*
|
|
84
|
+
* The path rides the target rather than a second argument because that is
|
|
85
|
+
* what it is: part of what the interceptor claims, not a separate knob. It
|
|
86
|
+
* also keeps the signature down to one string, so the description that
|
|
87
|
+
* follows can never be mistaken for a path.
|
|
88
|
+
*
|
|
89
|
+
* A scheme is tolerated (`https://app.test/api`) since that is how the same
|
|
90
|
+
* address is written everywhere else.
|
|
91
|
+
*/
|
|
92
|
+
export function parseTarget(target: string): { hostname: string; path: string } {
|
|
93
|
+
if (typeof target !== "string" || target.trim() === "") {
|
|
94
|
+
throw new Error("intercept: a target hostname is required, e.g. \"app.test\" or \"app.test/api\"");
|
|
95
|
+
}
|
|
96
|
+
const bare = target.trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
|
|
97
|
+
const slash = bare.indexOf("/");
|
|
98
|
+
const hostname = slash === -1 ? bare : bare.slice(0, slash);
|
|
99
|
+
const rest = slash === -1 ? undefined : bare.slice(slash);
|
|
100
|
+
if (hostname === "") {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`intercept: target ${JSON.stringify(target)} names no hostname — write it as "app.test/api", not "/api"`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (hostname.includes("?") || hostname.includes("#")) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`intercept: target ${JSON.stringify(target)} cannot carry a query or fragment`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return { hostname, path: normalizeMount(rest) };
|
|
111
|
+
}
|
|
112
|
+
|
|
74
113
|
/** Mount semantics (`app.use(path, fn)`): `/api` matches `/api`, `/api/`, `/api/x`, and
|
|
75
114
|
* never `/apix`. `/` matches everything. Query strings do not take part. */
|
|
76
115
|
export function pathMounts(mount: string, pathname: string): boolean {
|
|
@@ -222,6 +261,7 @@ export async function runChain(
|
|
|
222
261
|
);
|
|
223
262
|
record.answeredBy = "handler";
|
|
224
263
|
record.status = 500;
|
|
264
|
+
record.threw = true;
|
|
225
265
|
it.calls++;
|
|
226
266
|
it.requests.push(record);
|
|
227
267
|
observe?.(it, record, Date.now() - started);
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
classifyType,
|
|
4
|
+
equalityIsConstant,
|
|
5
|
+
equalityMessage,
|
|
6
|
+
isCarrierMember,
|
|
7
|
+
isNullishLiteral,
|
|
8
|
+
lineColumnAt,
|
|
9
|
+
snippet,
|
|
10
|
+
splitUnion,
|
|
11
|
+
startOfNode,
|
|
12
|
+
truthyMessage,
|
|
13
|
+
} from "./wrapper-rules.js";
|
|
14
|
+
|
|
15
|
+
describe("splitUnion", () => {
|
|
16
|
+
test("splits only at the top level", () => {
|
|
17
|
+
expect(splitUnion("Carrier<string> | undefined")).toEqual([
|
|
18
|
+
"Carrier<string>",
|
|
19
|
+
"undefined",
|
|
20
|
+
]);
|
|
21
|
+
// A nested union must survive whole, or its member reads as a bare `A`.
|
|
22
|
+
expect(splitUnion("Carrier<A | B>")).toEqual(["Carrier<A | B>"]);
|
|
23
|
+
expect(splitUnion("Carrier<{ a: 1 | 2 }> | null")).toEqual([
|
|
24
|
+
"Carrier<{ a: 1 | 2 }>",
|
|
25
|
+
"null",
|
|
26
|
+
]);
|
|
27
|
+
expect(splitUnion("Carrier<(a: 1 | 2) => void>")).toEqual([
|
|
28
|
+
"Carrier<(a: 1 | 2) => void>",
|
|
29
|
+
]);
|
|
30
|
+
expect(splitUnion("number")).toEqual(["number"]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("isCarrierMember", () => {
|
|
35
|
+
test("only the primitive carrier", () => {
|
|
36
|
+
expect(isCarrierMember("Carrier<boolean>")).toBe(true);
|
|
37
|
+
expect(isCarrierMember("Carrier<string>")).toBe(true);
|
|
38
|
+
// Object-shaped wrappers are objects with or without us, so a condition on
|
|
39
|
+
// one is not made constant by the wrapper.
|
|
40
|
+
expect(isCarrierMember("WrappedObject<Row>")).toBe(false);
|
|
41
|
+
expect(isCarrierMember("WrappedArray<Row>")).toBe(false);
|
|
42
|
+
expect(isCarrierMember("WrappedResponse")).toBe(false);
|
|
43
|
+
expect(isCarrierMember("boolean")).toBe(false);
|
|
44
|
+
// Not a generic reference — a value merely *named* Carrier is not one.
|
|
45
|
+
expect(isCarrierMember("Carrier")).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("classifyType", () => {
|
|
50
|
+
test("a bare carrier is constant", () => {
|
|
51
|
+
expect(classifyType("Carrier<boolean>")).toBe("carrier");
|
|
52
|
+
expect(classifyType("Carrier<number>")).toBe("carrier");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a nullish union is a presence test and must never be flagged", () => {
|
|
56
|
+
// `wrapChild` returns null/undefined RAW, so `if (row.opt)` correctly
|
|
57
|
+
// tells present from absent. This is the case that would break the rule.
|
|
58
|
+
expect(classifyType("Carrier<string> | undefined")).toBe("nullable-carrier");
|
|
59
|
+
expect(classifyType("Carrier<number> | null")).toBe("nullable-carrier");
|
|
60
|
+
expect(classifyType("Carrier<number> | null | undefined")).toBe("nullable-carrier");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("anything the checker could not pin down reads as plain", () => {
|
|
64
|
+
for (const t of ["any", "unknown", "boolean", "number", "error", undefined, ""]) {
|
|
65
|
+
expect(classifyType(t)).toBe("plain");
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("an unrecognised mixed union does not block", () => {
|
|
70
|
+
expect(classifyType("Carrier<string> | number")).toBe("plain");
|
|
71
|
+
expect(classifyType("null | undefined")).toBe("plain");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("equalityIsConstant", () => {
|
|
76
|
+
test("exactly one carrier side is constant", () => {
|
|
77
|
+
expect(equalityIsConstant("Carrier<number>", "2")).toBe(true);
|
|
78
|
+
expect(equalityIsConstant("number", "Carrier<number>")).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("two carriers compare identity — a different mistake, not proven", () => {
|
|
82
|
+
expect(equalityIsConstant("Carrier<number>", "Carrier<number>")).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a maybe-absent carrier is not proven either", () => {
|
|
86
|
+
expect(equalityIsConstant("Carrier<string> | undefined", "\"a\"")).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("no carrier at all is not our business", () => {
|
|
90
|
+
expect(equalityIsConstant("number", "2")).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a nullish literal is never constant — that is the correct null check", () => {
|
|
94
|
+
// `wrapChild` returns a null/undefined leaf RAW, so `row.setting === null`
|
|
95
|
+
// answers correctly in both directions however the generic was declared.
|
|
96
|
+
expect(equalityIsConstant("Carrier<unknown>", "null", "row.setting", "null")).toBe(false);
|
|
97
|
+
expect(equalityIsConstant("Carrier<string>", "undefined", "row.x", "undefined")).toBe(false);
|
|
98
|
+
expect(equalityIsConstant("null", "Carrier<string>", "null", "row.x")).toBe(false);
|
|
99
|
+
// and a real literal on the other side still is
|
|
100
|
+
expect(equalityIsConstant("Carrier<number>", "2", "row.n", "2")).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("isNullishLiteral", () => {
|
|
105
|
+
test("the three spellings", () => {
|
|
106
|
+
expect(isNullishLiteral("null")).toBe(true);
|
|
107
|
+
expect(isNullishLiteral(" undefined ")).toBe(true);
|
|
108
|
+
expect(isNullishLiteral("void 0")).toBe(true);
|
|
109
|
+
expect(isNullishLiteral("nullish")).toBe(false);
|
|
110
|
+
expect(isNullishLiteral("row.nullable")).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("lineColumnAt", () => {
|
|
115
|
+
test("1-indexed, matching tsc", () => {
|
|
116
|
+
const text = "a\nbb\nccc";
|
|
117
|
+
expect(lineColumnAt(text, 0)).toEqual({ line: 1, column: 1 });
|
|
118
|
+
expect(lineColumnAt(text, 2)).toEqual({ line: 2, column: 1 });
|
|
119
|
+
expect(lineColumnAt(text, 3)).toEqual({ line: 2, column: 2 });
|
|
120
|
+
expect(lineColumnAt(text, 5)).toEqual({ line: 3, column: 1 });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("out-of-range offsets clamp instead of throwing", () => {
|
|
124
|
+
expect(lineColumnAt("ab", -5)).toEqual({ line: 1, column: 1 });
|
|
125
|
+
expect(lineColumnAt("ab", 99)).toEqual({ line: 1, column: 3 });
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("snippet", () => {
|
|
130
|
+
test("one line, bounded", () => {
|
|
131
|
+
expect(snippet(" row.written ", 0, 15)).toBe("row.written");
|
|
132
|
+
expect(snippet("a\nb", 0, 3)).toBe("a");
|
|
133
|
+
const long = "x".repeat(200);
|
|
134
|
+
expect(snippet(long, 0, 200).length).toBe(58);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("messages", () => {
|
|
139
|
+
test("name the value, the type and the fix", () => {
|
|
140
|
+
const m = truthyMessage("row.written", "Carrier<boolean>");
|
|
141
|
+
expect(m).toContain("row.written");
|
|
142
|
+
expect(m).toContain("Carrier<boolean>");
|
|
143
|
+
expect(m).toContain(".unwrap()");
|
|
144
|
+
expect(m).toContain("always true");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("a negated comparison is always true, not always false", () => {
|
|
148
|
+
expect(equalityMessage("m.rows", "Carrier<number>", false)).toContain("always false");
|
|
149
|
+
expect(equalityMessage("m.rows", "Carrier<number>", true)).toContain("always true");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("startOfNode", () => {
|
|
154
|
+
test("skips the leading trivia a node's pos includes", () => {
|
|
155
|
+
// `pos` is the end of the previous node, so it points at the whitespace.
|
|
156
|
+
expect(startOfNode("a; b", 2)).toBe(5);
|
|
157
|
+
expect(startOfNode("a;\n b", 2)).toBe(5);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("skips comments, so a finding never points at the comment above it", () => {
|
|
161
|
+
const text = "a;\n// note\nb";
|
|
162
|
+
expect(startOfNode(text, 2)).toBe(text.indexOf("b"));
|
|
163
|
+
const block = "a;\n/* note */ b";
|
|
164
|
+
expect(startOfNode(block, 2)).toBe(block.indexOf("b"));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("an unterminated comment stops at the end instead of looping", () => {
|
|
168
|
+
expect(startOfNode("a;\n/* open", 2)).toBe(10);
|
|
169
|
+
});
|
|
170
|
+
});
|