@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
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// Blocking typecheck rules: control flow that a provenance wrapper makes
|
|
2
|
+
// constant.
|
|
3
|
+
//
|
|
4
|
+
// `sdk/src/inspect.ts` returns every primitive leaf of a recorded op as a
|
|
5
|
+
// `Carrier` — an object holding the value, with coercion sinks so template
|
|
6
|
+
// interpolation, arithmetic, `==` and `JSON.stringify` all behave. Two things
|
|
7
|
+
// a carrier cannot rescue, because JavaScript exposes no hook for either:
|
|
8
|
+
//
|
|
9
|
+
// if (row.written) // an object is ALWAYS truthy, even around `false`
|
|
10
|
+
// measured.rows === 2 // an object is NEVER === a primitive
|
|
11
|
+
//
|
|
12
|
+
// Both are constant, and both are silent. The first is the more expensive: a
|
|
13
|
+
// `ctx.poll` predicate that collapses a wrapped `false` to `true` reports
|
|
14
|
+
// success on attempt 1 and the suite runs on against data that never arrived
|
|
15
|
+
// (reported 2026-08-30). `tsc` catches the second as TS2367 and says nothing
|
|
16
|
+
// about the first — a truthiness test on an object type is legal TypeScript.
|
|
17
|
+
//
|
|
18
|
+
// WHY THESE MAY BLOCK A RUN WHEN `tsc`'s OWN DIAGNOSTICS MAY NOT
|
|
19
|
+
// ---------------------------------------------------------------
|
|
20
|
+
// The typecheck report is advisory because a `tsc` verdict is about the
|
|
21
|
+
// DECLARED TYPES, and those can disagree with the program that actually runs,
|
|
22
|
+
// in both directions:
|
|
23
|
+
//
|
|
24
|
+
// - the types go stale: the global `fetch` is patched at runtime to record
|
|
25
|
+
// but keeps its raw `Response` type, so correct code is flagged;
|
|
26
|
+
// - narrowing is unsound: TypeScript does not reset a narrowed `let` across
|
|
27
|
+
// a callback, so `phase === "end"` after `[1,2].forEach(() => phase = "end")`
|
|
28
|
+
// is reported as having no overlap when at runtime it matches.
|
|
29
|
+
//
|
|
30
|
+
// The second is why TS2367 cannot gate a run even though it is exactly the
|
|
31
|
+
// diagnostic that would have caught the reported bug.
|
|
32
|
+
//
|
|
33
|
+
// These rules ask a different question — *is this value a `Carrier`* — which
|
|
34
|
+
// is a fact about the runtime representation, not an inference. Wrapping is
|
|
35
|
+
// unconditional (see inspect.ts), so a value typed `Carrier<T>` IS an object
|
|
36
|
+
// when the line executes. Narrowing cannot turn a carrier into a number, so
|
|
37
|
+
// there is no unsoundness to inherit.
|
|
38
|
+
//
|
|
39
|
+
// {@link CODE_EQUALITY} is true of the run outright. {@link CODE_TRUTHY} is a
|
|
40
|
+
// convention on top of it — read its note for why that still earns a gate, and
|
|
41
|
+
// for the line neither rule crosses.
|
|
42
|
+
//
|
|
43
|
+
// SOUNDNESS CONDITIONS, both load-bearing:
|
|
44
|
+
//
|
|
45
|
+
// 1. `strictNullChecks` must be on. With it off, an optional leaf
|
|
46
|
+
// (`row.opt?: Carrier<string>`) reports as `Carrier<string>` rather than
|
|
47
|
+
// `Carrier<string> | undefined`, and `if (row.opt)` — a legitimate
|
|
48
|
+
// presence test, since `wrapChild` returns nullish RAW — would be flagged
|
|
49
|
+
// as a bug. The driver refuses to run rather than guess.
|
|
50
|
+
// 2. A union carrying `null`/`undefined` is never flagged, for the same
|
|
51
|
+
// reason: that condition distinguishes present from absent and is correct.
|
|
52
|
+
//
|
|
53
|
+
// The type is identified from its printed form rather than from its symbol:
|
|
54
|
+
// one round trip instead of several, and the whole decision stays a pure
|
|
55
|
+
// function over a string, which is what the tests below drive. A user type
|
|
56
|
+
// that happens to be called `Carrier<T>` would also be flagged — and would
|
|
57
|
+
// also be an object at runtime, so the finding stays true.
|
|
58
|
+
/**
|
|
59
|
+
* A condition on a wrapped value — always true. **Blocking**, but on different
|
|
60
|
+
* grounds from {@link CODE_EQUALITY}, and the difference is worth knowing.
|
|
61
|
+
*
|
|
62
|
+
* This rule is NOT a proof. Two things can hide a runtime nullish from the
|
|
63
|
+
* compiler: a row generic that overstates a column
|
|
64
|
+
* (`client<{ names: string }>` over a `string_agg` that returns NULL) and
|
|
65
|
+
* TypeScript's deliberately unsound array indexing (`rows[0]` is typed
|
|
66
|
+
* non-optional even when the array is empty). A nullish leaf comes back RAW
|
|
67
|
+
* from `wrapChild`, so in those shapes `if (rows[0]?.names)` really does tell
|
|
68
|
+
* present from absent, and "always true" is false about it — measured against
|
|
69
|
+
* a real suite on 2026-08-30 (`journal-note.ts:200`).
|
|
70
|
+
*
|
|
71
|
+
* It blocks anyway, as a CONVENTION rather than a verdict: *unwrap before
|
|
72
|
+
* branching on a wrapped value*, the same discipline the docs already teach
|
|
73
|
+
* for `===`. What makes that acceptable is that the fix is safe in every case
|
|
74
|
+
* — `?.unwrap()` short-circuits, so it never throws and never changes code
|
|
75
|
+
* that was already correct; it only removes the accident. And the accident is
|
|
76
|
+
* the worst failure this SDK has: a `ctx.poll` predicate that collapses a
|
|
77
|
+
* wrapped `false` to `true` passes on attempt 1, the suite runs on against
|
|
78
|
+
* data that never arrived, and neither the compiler nor the runtime says a
|
|
79
|
+
* word (reported 2026-08-30).
|
|
80
|
+
*
|
|
81
|
+
* The line this does not cross: a rule may demand more of the user's own code,
|
|
82
|
+
* but it may never fail a run over OUR stale types (the patched global
|
|
83
|
+
* `fetch`) or over the compiler's own bad inference (TS2367 across a
|
|
84
|
+
* callback). Those cost the user a fix they cannot make.
|
|
85
|
+
*/
|
|
86
|
+
export const CODE_TRUTHY = "SPECTEST2001";
|
|
87
|
+
/**
|
|
88
|
+
* `===`/`!==` between a wrapped value and a plain one — always false/true.
|
|
89
|
+
* **Blocking.** Sound even when a row generic understates nullability: if the
|
|
90
|
+
* leaf is a carrier the comparison is false because an object never equals a
|
|
91
|
+
* primitive, and if it is raw nullish it is false because nullish does not
|
|
92
|
+
* equal the literal either. A nullish literal on the other side is excluded —
|
|
93
|
+
* see {@link equalityIsConstant}.
|
|
94
|
+
*/
|
|
95
|
+
export const CODE_EQUALITY = "SPECTEST2002";
|
|
96
|
+
/**
|
|
97
|
+
* Split a printed type on its TOP-LEVEL `|`, leaving nested unions alone
|
|
98
|
+
* (`Carrier<A | B> | undefined` → [`Carrier<A | B>`, `undefined`]). Depth is
|
|
99
|
+
* tracked across every bracket kind because a printed type can hold object
|
|
100
|
+
* literals (`{ a: 1 | 2 }`), tuples and parenthesised function types.
|
|
101
|
+
*/
|
|
102
|
+
export function splitUnion(text) {
|
|
103
|
+
const parts = [];
|
|
104
|
+
let depth = 0;
|
|
105
|
+
let start = 0;
|
|
106
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
107
|
+
const c = text[i];
|
|
108
|
+
if (c === "<" || c === "(" || c === "[" || c === "{")
|
|
109
|
+
depth += 1;
|
|
110
|
+
else if (c === ">" || c === ")" || c === "]" || c === "}")
|
|
111
|
+
depth -= 1;
|
|
112
|
+
else if (c === "|" && depth === 0) {
|
|
113
|
+
parts.push(text.slice(start, i).trim());
|
|
114
|
+
start = i + 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
parts.push(text.slice(start).trim());
|
|
118
|
+
return parts.filter((p) => p.length > 0);
|
|
119
|
+
}
|
|
120
|
+
/** One union member that is the primitive carrier. Object/array/response
|
|
121
|
+
* wrappers are deliberately NOT included: they are objects whether or not we
|
|
122
|
+
* wrap them, so a condition on one is not made constant by the wrapper. */
|
|
123
|
+
export function isCarrierMember(member) {
|
|
124
|
+
return /^Carrier<[\s\S]*>$/.test(member.trim());
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Classify a printed type for the rules. `unknown`/`any`/an error type read as
|
|
128
|
+
* `plain`: the checker could not say what the value is, and a rule that blocks
|
|
129
|
+
* a run must not guess.
|
|
130
|
+
*/
|
|
131
|
+
export function classifyType(text) {
|
|
132
|
+
if (!text)
|
|
133
|
+
return "plain";
|
|
134
|
+
const members = splitUnion(text);
|
|
135
|
+
const nullish = members.filter((m) => m === "null" || m === "undefined");
|
|
136
|
+
const rest = members.filter((m) => m !== "null" && m !== "undefined");
|
|
137
|
+
if (rest.length === 0)
|
|
138
|
+
return "plain";
|
|
139
|
+
// A union mixing a carrier with a non-nullish plain type is not a shape the
|
|
140
|
+
// SDK produces. Blocking a run needs certainty, so an unrecognised shape
|
|
141
|
+
// reads as plain rather than as a finding.
|
|
142
|
+
if (!rest.every((m) => isCarrierMember(m)))
|
|
143
|
+
return "plain";
|
|
144
|
+
return nullish.length > 0 ? "nullable-carrier" : "carrier";
|
|
145
|
+
}
|
|
146
|
+
/** Source text that denotes a nullish literal. */
|
|
147
|
+
export function isNullishLiteral(exprText) {
|
|
148
|
+
const t = exprText.trim();
|
|
149
|
+
return t === "null" || t === "undefined" || /^void\s+0$/.test(t);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Verdict for a strict `===`/`!==`. Blocking only when exactly one side is a
|
|
153
|
+
* definite carrier: two carriers compare object identity, which is a different
|
|
154
|
+
* mistake and not one this rule can prove constant.
|
|
155
|
+
*
|
|
156
|
+
* A comparison against a NULLISH LITERAL is never constant, whatever the type
|
|
157
|
+
* says. `wrapChild` hands a `null`/`undefined` leaf back RAW, so
|
|
158
|
+
* `row.setting === null` is the correct way to ask whether a column is null —
|
|
159
|
+
* and it answers correctly in both directions. The declared type cannot show
|
|
160
|
+
* this, because a row generic is the caller's own assertion
|
|
161
|
+
* (`client<{ setting: unknown }>`) and routinely understates nullability.
|
|
162
|
+
* Found in a real suite on 2026-08-30 (`document-export-settings.ts:64`),
|
|
163
|
+
* where flagging it would have blocked a passing test.
|
|
164
|
+
*/
|
|
165
|
+
export function equalityIsConstant(left, right, leftExpr = "", rightExpr = "") {
|
|
166
|
+
if (isNullishLiteral(leftExpr) || isNullishLiteral(rightExpr))
|
|
167
|
+
return false;
|
|
168
|
+
const l = classifyType(left);
|
|
169
|
+
const r = classifyType(right);
|
|
170
|
+
return (l === "carrier") !== (r === "carrier");
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The real start of a node. A TypeScript node's `pos` is the end of the
|
|
174
|
+
* PREVIOUS node, so it includes the leading whitespace and comments; `tsc`
|
|
175
|
+
* reports the first meaningful character. Without this every column is a few
|
|
176
|
+
* places to the left and a finding above a comment points at the comment.
|
|
177
|
+
*/
|
|
178
|
+
export function startOfNode(text, pos) {
|
|
179
|
+
let i = Math.max(0, Math.min(pos, text.length));
|
|
180
|
+
while (i < text.length) {
|
|
181
|
+
const c = text[i];
|
|
182
|
+
if (c === " " || c === "\t" || c === "\r" || c === "\n") {
|
|
183
|
+
i += 1;
|
|
184
|
+
}
|
|
185
|
+
else if (c === "/" && text[i + 1] === "/") {
|
|
186
|
+
const nl = text.indexOf("\n", i);
|
|
187
|
+
i = nl === -1 ? text.length : nl + 1;
|
|
188
|
+
}
|
|
189
|
+
else if (c === "/" && text[i + 1] === "*") {
|
|
190
|
+
const close = text.indexOf("*/", i + 2);
|
|
191
|
+
i = close === -1 ? text.length : close + 2;
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return i;
|
|
198
|
+
}
|
|
199
|
+
/** 1-indexed line/column for a character offset, matching `tsc` output. */
|
|
200
|
+
export function lineColumnAt(text, pos) {
|
|
201
|
+
const clamped = Math.max(0, Math.min(pos, text.length));
|
|
202
|
+
let line = 1;
|
|
203
|
+
let lineStart = 0;
|
|
204
|
+
for (let i = 0; i < clamped; i += 1) {
|
|
205
|
+
if (text.charCodeAt(i) === 10) {
|
|
206
|
+
line += 1;
|
|
207
|
+
lineStart = i + 1;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { line, column: clamped - lineStart + 1 };
|
|
211
|
+
}
|
|
212
|
+
/** Trim a source snippet for a message: one line, bounded. */
|
|
213
|
+
export function snippet(text, pos, end) {
|
|
214
|
+
const raw = text.slice(Math.max(0, pos), Math.max(0, end)).trim();
|
|
215
|
+
const oneLine = raw.split("\n")[0]?.trim() ?? "";
|
|
216
|
+
return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine;
|
|
217
|
+
}
|
|
218
|
+
export function truthyMessage(expr, typeText) {
|
|
219
|
+
const subject = expr ? `\`${expr}\`` : "This value";
|
|
220
|
+
return (`${subject} is \`${typeText}\` — a provenance wrapper, which is an object at ` +
|
|
221
|
+
`runtime, so this condition is always true. A wrapped \`false\`, \`0\` or ` +
|
|
222
|
+
`\`""\` reads as truthy. Call \`.unwrap()\` before testing it.`);
|
|
223
|
+
}
|
|
224
|
+
export function equalityMessage(expr, typeText, negated) {
|
|
225
|
+
const subject = expr ? `\`${expr}\`` : "This value";
|
|
226
|
+
const verdict = negated ? "always true" : "always false";
|
|
227
|
+
return (`${subject} is \`${typeText}\` — a provenance wrapper, which is an object at ` +
|
|
228
|
+
`runtime and can never be strictly equal to a plain value, so this ` +
|
|
229
|
+
`comparison is ${verdict}. Call \`.unwrap()\` first, or assert with ` +
|
|
230
|
+
`\`expect(...)\`, which unwraps for you.`);
|
|
231
|
+
}
|
|
232
|
+
/** `TypeFlags.Object | TypeFlags.Union` — the only shapes a carrier can wear.
|
|
233
|
+
* Filtering on the flags the batch call already returned keeps the printed
|
|
234
|
+
* name (one round trip each) off every ordinary `boolean` condition. */
|
|
235
|
+
const OBJECT_OR_UNION = (1 << 19) | (1 << 20);
|
|
236
|
+
const SYNTAX = {
|
|
237
|
+
If: "IfStatement",
|
|
238
|
+
While: "WhileStatement",
|
|
239
|
+
DoWhile: "DoStatement",
|
|
240
|
+
For: "ForStatement",
|
|
241
|
+
Conditional: "ConditionalExpression",
|
|
242
|
+
Prefix: "PrefixUnaryExpression",
|
|
243
|
+
Binary: "BinaryExpression",
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* Collect the positions worth asking about in one file. Kept separate from the
|
|
247
|
+
* checker so the walk can be reasoned about (and extended) on its own.
|
|
248
|
+
*/
|
|
249
|
+
export function collectCandidates(statements, kindName, walk) {
|
|
250
|
+
const all = [];
|
|
251
|
+
const push = (n) => {
|
|
252
|
+
all.push(n);
|
|
253
|
+
walk(n, push);
|
|
254
|
+
};
|
|
255
|
+
for (const s of statements)
|
|
256
|
+
push(s);
|
|
257
|
+
const out = [];
|
|
258
|
+
for (const n of all) {
|
|
259
|
+
const k = kindName(n);
|
|
260
|
+
if (k === SYNTAX.If || k === SYNTAX.While || k === SYNTAX.DoWhile) {
|
|
261
|
+
const e = n.expression;
|
|
262
|
+
if (e)
|
|
263
|
+
out.push({ node: e, kind: "truthy" });
|
|
264
|
+
}
|
|
265
|
+
else if (k === SYNTAX.For || k === SYNTAX.Conditional) {
|
|
266
|
+
const e = n.condition;
|
|
267
|
+
if (e)
|
|
268
|
+
out.push({ node: e, kind: "truthy" });
|
|
269
|
+
}
|
|
270
|
+
else if (k === SYNTAX.Prefix) {
|
|
271
|
+
// `!x` — TypeScript's own always-truthy check misses this shape, which
|
|
272
|
+
// is why the rule cannot lean on TS2774.
|
|
273
|
+
if (kindName({ kind: n.operator }) === "ExclamationToken") {
|
|
274
|
+
const e = n.operand;
|
|
275
|
+
if (e)
|
|
276
|
+
out.push({ node: e, kind: "truthy" });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
else if (k === SYNTAX.Binary) {
|
|
280
|
+
const op = kindName({ kind: n.operatorToken?.kind });
|
|
281
|
+
const left = n.left;
|
|
282
|
+
const right = n.right;
|
|
283
|
+
if (!left || !right)
|
|
284
|
+
continue;
|
|
285
|
+
if (op === "AmpersandAmpersandToken" || op === "BarBarToken") {
|
|
286
|
+
out.push({ node: left, kind: "truthy" });
|
|
287
|
+
}
|
|
288
|
+
else if (op === "EqualsEqualsEqualsToken") {
|
|
289
|
+
out.push({ node: left, kind: "equality", other: right, negated: false });
|
|
290
|
+
}
|
|
291
|
+
else if (op === "ExclamationEqualsEqualsToken") {
|
|
292
|
+
out.push({ node: left, kind: "equality", other: right, negated: true });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
export async function runWrapperRules(opts) {
|
|
299
|
+
const started = Date.now();
|
|
300
|
+
const done = (r) => ({
|
|
301
|
+
...r,
|
|
302
|
+
durationMs: Date.now() - started,
|
|
303
|
+
});
|
|
304
|
+
let api;
|
|
305
|
+
try {
|
|
306
|
+
const base = opts.join(opts.typescriptDir, "dist");
|
|
307
|
+
const apiMod = (await import(opts.join(base, "api", "async", "api.js")));
|
|
308
|
+
const utils = (await import(opts.join(base, "ast", "utils.js")));
|
|
309
|
+
const visitor = (await import(opts.join(base, "ast", "visitor.js")));
|
|
310
|
+
const inst = new apiMod.API({ cwd: opts.dirname(opts.configFile) });
|
|
311
|
+
api = inst;
|
|
312
|
+
const snapshot = await inst.updateSnapshot({ openProjects: [opts.configFile] });
|
|
313
|
+
const project = (await snapshot.getProjects())[0];
|
|
314
|
+
if (!project)
|
|
315
|
+
return done({ status: "failed", diagnostics: [], detail: "no project" });
|
|
316
|
+
// Soundness condition 1 — see the header. Without strictNullChecks an
|
|
317
|
+
// optional carrier loses its `| undefined` and every presence test in the
|
|
318
|
+
// suite becomes a finding, so refuse rather than guess.
|
|
319
|
+
// `compilerOptions` is the file's raw options, not the resolved ones, so
|
|
320
|
+
// the `strict` family has to be folded by hand: an explicit
|
|
321
|
+
// `strictNullChecks` wins, otherwise `strict` supplies it.
|
|
322
|
+
const strictNullChecks = project.compilerOptions.strictNullChecks ??
|
|
323
|
+
project.compilerOptions.strict ??
|
|
324
|
+
false;
|
|
325
|
+
if (strictNullChecks !== true) {
|
|
326
|
+
return done({
|
|
327
|
+
status: "skipped",
|
|
328
|
+
diagnostics: [],
|
|
329
|
+
detail: "strictNullChecks is off; a wrapped optional cannot be told from a wrapped value",
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
const kindName = (n) => typeof n?.kind === "number" ? utils.formatSyntaxKind(n.kind) : "";
|
|
333
|
+
const walk = (n, visit) => {
|
|
334
|
+
try {
|
|
335
|
+
visitor.visitEachChild(n, (c) => {
|
|
336
|
+
visit(c);
|
|
337
|
+
return c;
|
|
338
|
+
}, undefined);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
/* a node shape the walker cannot descend — its children are skipped */
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
const names = await project.program.getSourceFileNames();
|
|
345
|
+
const files = names.filter((f) => f.startsWith(`${opts.appDir}/`) && !f.includes("/node_modules/") && !f.endsWith(".d.ts"));
|
|
346
|
+
const diagnostics = [];
|
|
347
|
+
for (const file of files) {
|
|
348
|
+
const sf = await project.program.getSourceFile(file);
|
|
349
|
+
if (!sf)
|
|
350
|
+
continue;
|
|
351
|
+
const candidates = collectCandidates(sf.statements, kindName, walk);
|
|
352
|
+
if (candidates.length === 0)
|
|
353
|
+
continue;
|
|
354
|
+
// One batched checker call per file, then a printed name only for the
|
|
355
|
+
// types that could be a wrapper at all.
|
|
356
|
+
const nodes = candidates.flatMap((c) => (c.other ? [c.node, c.other] : [c.node]));
|
|
357
|
+
const types = await project.checker.getTypeAtLocation(nodes);
|
|
358
|
+
const printed = new Map();
|
|
359
|
+
for (let i = 0; i < nodes.length; i += 1) {
|
|
360
|
+
const t = types[i];
|
|
361
|
+
if (t && (t.flags & OBJECT_OR_UNION) !== 0) {
|
|
362
|
+
printed.set(i, await project.checker.typeToString(t));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const text = await opts.readFile(file);
|
|
366
|
+
const rel = opts.relative(opts.appDir, file);
|
|
367
|
+
let idx = 0;
|
|
368
|
+
for (const c of candidates) {
|
|
369
|
+
const leftText = printed.get(idx);
|
|
370
|
+
idx += 1;
|
|
371
|
+
const rightText = c.other ? printed.get(idx) : undefined;
|
|
372
|
+
if (c.other)
|
|
373
|
+
idx += 1;
|
|
374
|
+
const start = startOfNode(text, c.node.pos);
|
|
375
|
+
const where = lineColumnAt(text, start);
|
|
376
|
+
const expr = snippet(text, start, c.node.end);
|
|
377
|
+
if (c.kind === "truthy") {
|
|
378
|
+
if (classifyType(leftText) !== "carrier")
|
|
379
|
+
continue;
|
|
380
|
+
diagnostics.push({
|
|
381
|
+
file: rel,
|
|
382
|
+
...where,
|
|
383
|
+
code: CODE_TRUTHY,
|
|
384
|
+
message: truthyMessage(expr, leftText),
|
|
385
|
+
blocking: true,
|
|
386
|
+
});
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const otherStart = startOfNode(text, c.other.pos);
|
|
390
|
+
const otherExpr = snippet(text, otherStart, c.other.end);
|
|
391
|
+
if (!equalityIsConstant(leftText, rightText, expr, otherExpr))
|
|
392
|
+
continue;
|
|
393
|
+
const carrierIsLeft = classifyType(leftText) === "carrier";
|
|
394
|
+
const node = carrierIsLeft ? c.node : c.other;
|
|
395
|
+
const at = carrierIsLeft ? start : otherStart;
|
|
396
|
+
diagnostics.push({
|
|
397
|
+
file: rel,
|
|
398
|
+
...lineColumnAt(text, at),
|
|
399
|
+
code: CODE_EQUALITY,
|
|
400
|
+
message: equalityMessage(snippet(text, at, node.end), (carrierIsLeft ? leftText : rightText), c.negated === true),
|
|
401
|
+
blocking: true,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return done({ status: "ok", diagnostics });
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
return done({
|
|
409
|
+
status: "failed",
|
|
410
|
+
diagnostics: [],
|
|
411
|
+
detail: err?.message ?? String(err),
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
finally {
|
|
415
|
+
try {
|
|
416
|
+
api?.close();
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
/* the session is already gone */
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export { McpHttpError, McpRpcError, McpAuthDeniedError, type Mcp, type McpOption
|
|
|
14
14
|
import type { Mcp, McpOptions } from "./mcp.js";
|
|
15
15
|
export type { Locator, GetByRoleOptions, GetByTextOptions, FilterOptions, ClickOptions, BoundingBox, FilePayload, InputFiles, } from "./locator.js";
|
|
16
16
|
import type { Locator } from "./locator.js";
|
|
17
|
+
import { type TextMatchOptions } from "./text-match.js";
|
|
17
18
|
export type { UrlPattern } from "./url-match.js";
|
|
18
19
|
import type { UrlPattern } from "./url-match.js";
|
|
19
20
|
export type { Mobile, MobileApp } from "./mobile.js";
|
|
@@ -34,7 +35,8 @@ export type { InterceptHandler, InterceptNext, InterceptedRequest };
|
|
|
34
35
|
*/
|
|
35
36
|
export interface Interception {
|
|
36
37
|
hostname: string;
|
|
37
|
-
/** The normalised mount path (`"/"` when
|
|
38
|
+
/** The normalised mount path parsed off the target (`"/"` when the
|
|
39
|
+
* target was a bare hostname). */
|
|
38
40
|
path: string;
|
|
39
41
|
/** How many requests the interceptor has seen. Provenance-wrapped, so an
|
|
40
42
|
* `expect(outage.calls)` nests under the intercept step; `.unwrap()` for
|
|
@@ -381,12 +383,23 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
|
|
|
381
383
|
* make your **own** backend misbehave for one test: force a 500 from an
|
|
382
384
|
* API route, add latency, fail twice then pass, or just count calls.
|
|
383
385
|
*
|
|
386
|
+
* `target` is the hostname, optionally with a mount path:
|
|
387
|
+
* `"app.test"` claims every request to that host, `"app.test/api/sync"`
|
|
388
|
+
* claims that path and everything below it (like `app.use(path, fn)`).
|
|
389
|
+
*
|
|
390
|
+
* `description` is what the environment now **does**, in the present
|
|
391
|
+
* tense — it is the step's whole title in the timeline and in the CLI's
|
|
392
|
+
* failure detail, so it is what tells a reader why the page under test
|
|
393
|
+
* went to its error state. Write the effect, not the act of intercepting
|
|
394
|
+
* (the step is already labelled INTERCEPT) and not the test's goal:
|
|
395
|
+
* `"the sync endpoint returns 503"`, not `"intercept sync"`, `"mock the
|
|
396
|
+
* API"` or `"test error handling"`. The handler's own source is shown
|
|
397
|
+
* under it, so the description carries the intent, never the mechanism.
|
|
398
|
+
*
|
|
384
399
|
* Hono/Koa-style middleware: `(req, next)` where `next()` returns the real upstream's
|
|
385
400
|
* response (a proxied service, or a fake). Return a `Response` to answer
|
|
386
401
|
* yourself, `next()` to pass through, or change what `next()` returned.
|
|
387
|
-
*
|
|
388
|
-
* (`"/functions/v1/sync"`), like `app.use(path, fn)`. Interceptors run in
|
|
389
|
-
* registration order.
|
|
402
|
+
* Interceptors run in registration order.
|
|
390
403
|
*
|
|
391
404
|
* Only traffic that reaches the daemon can be intercepted: the browser,
|
|
392
405
|
* `ctx.fetch`, and any container that calls the hostname — so the service
|
|
@@ -400,16 +413,18 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
|
|
|
400
413
|
* `remove()`. Every request it sees is recorded under the intercept step.
|
|
401
414
|
*
|
|
402
415
|
* ```ts
|
|
403
|
-
* const outage = ctx.intercept(
|
|
404
|
-
*
|
|
416
|
+
* const outage = ctx.intercept(
|
|
417
|
+
* "api.test/functions/v1/sync",
|
|
418
|
+
* "the sync function returns 500",
|
|
419
|
+
* () => new Response("boom", { status: 500 }),
|
|
420
|
+
* );
|
|
405
421
|
* await page.getByRole("button", { name: "Sync" }).click();
|
|
406
422
|
* await expect(page.getByText("Retry")).toBeVisible();
|
|
407
423
|
* expect(outage.calls).toBe(1);
|
|
408
424
|
* outage.remove(); // the retry now reaches the real function
|
|
409
425
|
* ```
|
|
410
426
|
*/
|
|
411
|
-
intercept(
|
|
412
|
-
intercept(hostname: string, path: string, handler: InterceptHandler): Interception;
|
|
427
|
+
intercept(target: string, description: string, handler: InterceptHandler): Interception;
|
|
413
428
|
/**
|
|
414
429
|
* Mint a leaf certificate from the in-VM root CA and return the PEMs.
|
|
415
430
|
*
|
|
@@ -1672,6 +1687,14 @@ interface Matchers {
|
|
|
1672
1687
|
export interface Expectation extends Matchers {
|
|
1673
1688
|
not: Matchers;
|
|
1674
1689
|
}
|
|
1690
|
+
/** Options for the text matchers — playwright's set, same names, same
|
|
1691
|
+
* meanings. */
|
|
1692
|
+
export interface TextMatcherOptions extends TextMatchOptions {
|
|
1693
|
+
timeout?: number;
|
|
1694
|
+
/** Read `innerText` (what the page renders — hidden elements dropped,
|
|
1695
|
+
* `text-transform` applied) instead of `textContent`. */
|
|
1696
|
+
useInnerText?: boolean;
|
|
1697
|
+
}
|
|
1675
1698
|
/**
|
|
1676
1699
|
* Auto-retrying web-first assertions for a {@link Locator} — Playwright's
|
|
1677
1700
|
* `expect(locator)` matchers. Each polls the element until it passes or a
|
|
@@ -1687,14 +1710,27 @@ export interface LocatorMatchers {
|
|
|
1687
1710
|
toBeHidden(opts?: {
|
|
1688
1711
|
timeout?: number;
|
|
1689
1712
|
}): Promise<void>;
|
|
1690
|
-
/**
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1713
|
+
/**
|
|
1714
|
+
* The element's text equals `expected`, or matches it when it is a RegExp.
|
|
1715
|
+
*
|
|
1716
|
+
* **Whitespace is normalized on both sides**, exactly as playwright does
|
|
1717
|
+
* it: the text is trimmed and every run of whitespace becomes one space.
|
|
1718
|
+
* So a typed space matches the no-break space (U+00A0) that
|
|
1719
|
+
* `Intl.NumberFormat` puts between thousands, and a value wrapped across
|
|
1720
|
+
* two lines in the markup matches the one-line string you wrote.
|
|
1721
|
+
*
|
|
1722
|
+
* Pass an **array** to assert over every element the locator matches, in
|
|
1723
|
+
* order; the counts must then agree.
|
|
1724
|
+
*/
|
|
1725
|
+
toHaveText(expected: string | RegExp | Array<string | RegExp>, opts?: TextMatcherOptions): Promise<void>;
|
|
1726
|
+
/**
|
|
1727
|
+
* The element's text contains `expected` — a substring, or a RegExp tested
|
|
1728
|
+
* against the text. Whitespace is normalized as in {@link toHaveText}.
|
|
1729
|
+
*
|
|
1730
|
+
* An **array** asserts that the matched elements contain these texts in
|
|
1731
|
+
* order; unlike `toHaveText` extra elements between them are allowed.
|
|
1732
|
+
*/
|
|
1733
|
+
toContainText(expected: string | RegExp | Array<string | RegExp>, opts?: TextMatcherOptions): Promise<void>;
|
|
1698
1734
|
/** The input's value equals `expected` (or matches a RegExp). */
|
|
1699
1735
|
toHaveValue(expected: string | RegExp, opts?: {
|
|
1700
1736
|
timeout?: number;
|