@qaguardian/playwright-score 1.0.0 → 2.1.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/CHANGELOG.md +817 -0
- package/METHODOLOGY.md +53 -6
- package/README.md +52 -31
- package/dist/cli.js +8 -2
- package/dist/eslint-runner.d.ts +2 -0
- package/dist/eslint-runner.js +57 -3
- package/dist/formatters/markdown.js +8 -0
- package/dist/formatters/text.js +7 -0
- package/dist/import-graph.d.ts +1 -0
- package/dist/import-graph.js +1 -1
- package/dist/index.js +130 -12
- package/dist/metrics.d.ts +18 -4
- package/dist/metrics.js +59 -9
- package/dist/playwright-config.d.ts +114 -0
- package/dist/playwright-config.js +520 -0
- package/dist/profiles.d.ts +15 -0
- package/dist/profiles.js +34 -0
- package/dist/rules/base-plugin.d.ts +4 -0
- package/dist/rules/base-plugin.js +698 -7
- package/dist/score-engine.d.ts +9 -1
- package/dist/score-engine.js +9 -1
- package/dist/test-aliases.d.ts +18 -0
- package/dist/test-aliases.js +218 -0
- package/dist/types.d.ts +21 -1
- package/package.json +3 -2
|
@@ -1,18 +1,28 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* `testNames` is `'test'` plus any `test.extend(...)`-derived fixture
|
|
3
|
+
* aliases detected by test-aliases.ts (see eslint-runner.ts, which feeds
|
|
4
|
+
* this rule's `testAliasNames` option) — a suite that always declares its
|
|
5
|
+
* tests via `loggedTest.skip(...)` instead of `test.skip(...)` still needs
|
|
6
|
+
* the always-skipped-declaration check below, same as upstream
|
|
7
|
+
* eslint-plugin-playwright's own rules need the alias for no-standalone-
|
|
8
|
+
* expect (see METHODOLOGY.md/test-aliases.ts).
|
|
9
|
+
*/
|
|
10
|
+
function isSkipCallee(callee, testNames) {
|
|
2
11
|
if (!callee || callee.type !== 'MemberExpression' || callee.computed)
|
|
3
12
|
return false;
|
|
4
13
|
const obj = callee.object;
|
|
5
14
|
const prop = callee.property;
|
|
6
15
|
if (prop?.type !== 'Identifier' || prop.name !== 'skip')
|
|
7
16
|
return false;
|
|
8
|
-
// test.skip(...)
|
|
9
|
-
if (obj?.type === 'Identifier' && obj.name
|
|
17
|
+
// test.skip(...) / loggedTest.skip(...)
|
|
18
|
+
if (obj?.type === 'Identifier' && obj.name && testNames.has(obj.name))
|
|
10
19
|
return true;
|
|
11
|
-
// test.describe.skip(...)
|
|
20
|
+
// test.describe.skip(...) / loggedTest.describe.skip(...)
|
|
12
21
|
if (obj?.type === 'MemberExpression' &&
|
|
13
22
|
!obj.computed &&
|
|
14
23
|
obj.object?.type === 'Identifier' &&
|
|
15
|
-
obj.object.name
|
|
24
|
+
obj.object.name &&
|
|
25
|
+
testNames.has(obj.object.name) &&
|
|
16
26
|
obj.property?.type === 'Identifier' &&
|
|
17
27
|
obj.property.name === 'describe') {
|
|
18
28
|
return true;
|
|
@@ -52,12 +62,23 @@ const noSkippedTestDeclaration = {
|
|
|
52
62
|
messages: {
|
|
53
63
|
skipped: 'Unexpected use of the .skip() annotation to declare an always-skipped test.',
|
|
54
64
|
},
|
|
55
|
-
schema: [
|
|
65
|
+
schema: [
|
|
66
|
+
{
|
|
67
|
+
additionalProperties: false,
|
|
68
|
+
properties: {
|
|
69
|
+
testAliasNames: { items: { type: 'string' }, type: 'array' },
|
|
70
|
+
},
|
|
71
|
+
type: 'object',
|
|
72
|
+
},
|
|
73
|
+
],
|
|
56
74
|
},
|
|
57
75
|
create(context) {
|
|
76
|
+
const testAliasNames = context.options[0]
|
|
77
|
+
?.testAliasNames ?? [];
|
|
78
|
+
const testNames = new Set(['test', ...testAliasNames]);
|
|
58
79
|
return {
|
|
59
80
|
CallExpression(node) {
|
|
60
|
-
if (!isSkipCallee(node.callee))
|
|
81
|
+
if (!isSkipCallee(node.callee, testNames))
|
|
61
82
|
return;
|
|
62
83
|
const args = node.arguments ?? [];
|
|
63
84
|
const lastArg = args[args.length - 1];
|
|
@@ -69,9 +90,679 @@ const noSkippedTestDeclaration = {
|
|
|
69
90
|
};
|
|
70
91
|
},
|
|
71
92
|
};
|
|
93
|
+
/**
|
|
94
|
+
* Generic ESTree/TS-ESTree subtree walk — every AST node here is a plain
|
|
95
|
+
* object (or array of them) with a `type` string; no visitorKeys table is
|
|
96
|
+
* needed to find nested nodes of interest, just recursion over own
|
|
97
|
+
* enumerable properties. `parent` is skipped to avoid the cycle ESLint
|
|
98
|
+
* attaches during traversal.
|
|
99
|
+
*/
|
|
100
|
+
function walkNode(node, visit) {
|
|
101
|
+
if (!node || typeof node !== 'object')
|
|
102
|
+
return;
|
|
103
|
+
if (Array.isArray(node)) {
|
|
104
|
+
for (const item of node)
|
|
105
|
+
walkNode(item, visit);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const record = node;
|
|
109
|
+
if (typeof record.type !== 'string')
|
|
110
|
+
return;
|
|
111
|
+
visit(node);
|
|
112
|
+
for (const key of Object.keys(record)) {
|
|
113
|
+
if (key === 'parent')
|
|
114
|
+
continue;
|
|
115
|
+
const value = record[key];
|
|
116
|
+
if (value && typeof value === 'object')
|
|
117
|
+
walkNode(value, visit);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* QAG-196 baseline (2026-09-21): a spec with two timer sleeps, a
|
|
122
|
+
* coordinate click and a tautological assertion scored 93/A under model
|
|
123
|
+
* v3 — all four are real, common ways AI- and human-written specs fake
|
|
124
|
+
* stability or coverage while contributing zero real signal. These four
|
|
125
|
+
* rules close that gap; see CHANGELOG.md for the corpus before/after.
|
|
126
|
+
*/
|
|
127
|
+
/** Any object whose `.setTimeout` member resolves to the same ambient function as a bare `setTimeout` call. */
|
|
128
|
+
const SET_TIMEOUT_HOST_OBJECTS = new Set(['globalThis', 'window']);
|
|
129
|
+
/**
|
|
130
|
+
* True when `callee` is `setTimeout` itself, `globalThis.setTimeout`, or
|
|
131
|
+
* `window.setTimeout` — all three resolve to the exact same ambient
|
|
132
|
+
* function; `window.` is just as easy to reach for in a browser-flavored
|
|
133
|
+
* test file (and just as invisible to a rule that only matches a bare
|
|
134
|
+
* `Identifier` or the `globalThis.` spelling) as either of the others.
|
|
135
|
+
*/
|
|
136
|
+
function isSetTimeoutCallee(callee) {
|
|
137
|
+
if (!callee)
|
|
138
|
+
return false;
|
|
139
|
+
if (callee.type === 'Identifier' && callee.name === 'setTimeout')
|
|
140
|
+
return true;
|
|
141
|
+
return (callee.type === 'MemberExpression' &&
|
|
142
|
+
!callee.computed &&
|
|
143
|
+
callee.object?.type === 'Identifier' &&
|
|
144
|
+
typeof callee.object.name === 'string' &&
|
|
145
|
+
SET_TIMEOUT_HOST_OBJECTS.has(callee.object.name) &&
|
|
146
|
+
callee.property?.type === 'Identifier' &&
|
|
147
|
+
callee.property.name === 'setTimeout');
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* True when `fn` is a zero-argument wrapper whose entire body is a call
|
|
151
|
+
* to `resolveName` — `() => r()` or `() => { r(); }`. `setTimeout(resolve,
|
|
152
|
+
* ms)` and `setTimeout(() => resolve(), ms)` do the exact same thing; the
|
|
153
|
+
* wrapper adds nothing except making the resolve-callback shape invisible
|
|
154
|
+
* to a check that only matched a bare identifier argument.
|
|
155
|
+
*/
|
|
156
|
+
function callsResolve(fn, resolveName) {
|
|
157
|
+
if (!fn || !FUNCTION_TYPES.has(fn.type))
|
|
158
|
+
return false;
|
|
159
|
+
if ((fn.params ?? []).length > 0)
|
|
160
|
+
return false;
|
|
161
|
+
const body = fn.body;
|
|
162
|
+
let call;
|
|
163
|
+
if (body && !Array.isArray(body) && body.type === 'BlockStatement') {
|
|
164
|
+
const stmts = (body.body ?? []);
|
|
165
|
+
const only = Array.isArray(stmts) && stmts.length === 1 ? stmts[0] : undefined;
|
|
166
|
+
if (only?.type === 'ExpressionStatement') {
|
|
167
|
+
call = only.expression;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else if (body && !Array.isArray(body)) {
|
|
171
|
+
call = body; // arrow expression body
|
|
172
|
+
}
|
|
173
|
+
return (!!call &&
|
|
174
|
+
call.type === 'CallExpression' &&
|
|
175
|
+
call.callee?.type === 'Identifier' &&
|
|
176
|
+
call.callee.name === resolveName);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* `await new Promise((resolve) => setTimeout(resolve, ms))` is the same
|
|
180
|
+
* hard-coded sleep as `page.waitForTimeout(ms)` (already flagged, as an
|
|
181
|
+
* error, by upstream `playwright/no-wait-for-timeout`) wearing a disguise
|
|
182
|
+
* that rule cannot see through — it has nothing to do with `page` at all.
|
|
183
|
+
* Verified against the QAG-196 gameability sample: this exact pattern
|
|
184
|
+
* scored zero findings before this rule existed. Also catches respellings
|
|
185
|
+
* found in review: `globalThis.setTimeout(...)`/`window.setTimeout(...)`
|
|
186
|
+
* (same function, different spelling — see isSetTimeoutCallee) and
|
|
187
|
+
* wrapping the resolve callback in a no-op arrow (`setTimeout(() => r(),
|
|
188
|
+
* ms)`) instead of passing it directly — plus the unrelated but equally
|
|
189
|
+
* disguised `node:timers/promises` sleep (`await setTimeout(ms)` from
|
|
190
|
+
* that module is an async sleep on its own, no `new Promise` wrapper
|
|
191
|
+
* needed), including a namespace-import/`require()` spelling of the same
|
|
192
|
+
* module (`import * as timers from 'node:timers/promises'` or `const
|
|
193
|
+
* timers = require('node:timers/promises')`, then `await
|
|
194
|
+
* timers.setTimeout(ms)`) — not just the named-import form.
|
|
195
|
+
*/
|
|
196
|
+
const noTimerSleep = {
|
|
197
|
+
meta: {
|
|
198
|
+
type: 'suggestion',
|
|
199
|
+
docs: {
|
|
200
|
+
description: 'Disallow sleeping via new Promise((resolve) => setTimeout(resolve, ms)), its globalThis/wrapped-callback respellings, or node:timers/promises setTimeout',
|
|
201
|
+
},
|
|
202
|
+
messages: {
|
|
203
|
+
sleep: 'Unexpected hard-coded sleep via new Promise + setTimeout. Wait for a specific condition (a locator, a response, an event) instead.',
|
|
204
|
+
timersPromisesSleep: "Unexpected hard-coded sleep via node:timers/promises' setTimeout(ms). Wait for a specific condition (a locator, a response, an event) instead.",
|
|
205
|
+
},
|
|
206
|
+
schema: [],
|
|
207
|
+
},
|
|
208
|
+
create(context) {
|
|
209
|
+
const timersPromisesLocalNames = new Set();
|
|
210
|
+
// Local names bound to the *module namespace* (not the bare
|
|
211
|
+
// `setTimeout` function itself): `import * as timers from
|
|
212
|
+
// 'node:timers/promises'` or `const timers =
|
|
213
|
+
// require('node:timers/promises')`. `timers.setTimeout(ms)` is the
|
|
214
|
+
// same sleep as the named-import form, just reached through a member
|
|
215
|
+
// access instead of a bare identifier call.
|
|
216
|
+
const timersPromisesNamespaceLocalNames = new Set();
|
|
217
|
+
const isTimersPromisesSource = (value) => value === 'node:timers/promises' || value === 'timers/promises';
|
|
218
|
+
return {
|
|
219
|
+
// Typed loosely (not our shared `Node`) — the real ESTree
|
|
220
|
+
// ImportDeclaration shape (e.g. `imported` can be a string Literal
|
|
221
|
+
// for `import { "setTimeout" as x }`) doesn't structurally match
|
|
222
|
+
// our simplified CallExpression-oriented Node type.
|
|
223
|
+
ImportDeclaration(raw) {
|
|
224
|
+
const node = raw;
|
|
225
|
+
if (!isTimersPromisesSource(node.source?.value))
|
|
226
|
+
return;
|
|
227
|
+
for (const spec of node.specifiers ?? []) {
|
|
228
|
+
if (spec.type === 'ImportSpecifier' && spec.imported?.name === 'setTimeout' && spec.local?.name) {
|
|
229
|
+
timersPromisesLocalNames.add(spec.local.name);
|
|
230
|
+
}
|
|
231
|
+
else if (spec.type === 'ImportNamespaceSpecifier' && spec.local?.name) {
|
|
232
|
+
timersPromisesNamespaceLocalNames.add(spec.local.name);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
// `const timers = require('node:timers/promises')` — CommonJS
|
|
237
|
+
// equivalent of the namespace import above. Typed loosely for the
|
|
238
|
+
// same reason as ImportDeclaration; `id`/`init` aren't on our
|
|
239
|
+
// shared Node type.
|
|
240
|
+
VariableDeclarator(raw) {
|
|
241
|
+
const node = raw;
|
|
242
|
+
if (node.id?.type !== 'Identifier' || !node.id.name || !node.init)
|
|
243
|
+
return;
|
|
244
|
+
const init = node.init;
|
|
245
|
+
const isRequireTimersPromises = init.type === 'CallExpression' &&
|
|
246
|
+
init.callee?.type === 'Identifier' &&
|
|
247
|
+
init.callee.name === 'require' &&
|
|
248
|
+
isTimersPromisesSource((init.arguments ?? [])[0]?.value);
|
|
249
|
+
if (isRequireTimersPromises) {
|
|
250
|
+
timersPromisesNamespaceLocalNames.add(node.id.name);
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
CallExpression(node) {
|
|
254
|
+
if (node.callee?.type === 'Identifier' &&
|
|
255
|
+
typeof node.callee.name === 'string' &&
|
|
256
|
+
timersPromisesLocalNames.has(node.callee.name)) {
|
|
257
|
+
context.report({ node: node, messageId: 'timersPromisesSleep' });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (node.callee?.type === 'MemberExpression' &&
|
|
261
|
+
!node.callee.computed &&
|
|
262
|
+
node.callee.object?.type === 'Identifier' &&
|
|
263
|
+
typeof node.callee.object.name === 'string' &&
|
|
264
|
+
timersPromisesNamespaceLocalNames.has(node.callee.object.name) &&
|
|
265
|
+
node.callee.property?.type === 'Identifier' &&
|
|
266
|
+
node.callee.property.name === 'setTimeout') {
|
|
267
|
+
context.report({ node: node, messageId: 'timersPromisesSleep' });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (!isSetTimeoutCallee(node.callee))
|
|
271
|
+
return;
|
|
272
|
+
const ancestors = (context.sourceCode?.getAncestors
|
|
273
|
+
? context.sourceCode.getAncestors(node)
|
|
274
|
+
: context.getAncestors());
|
|
275
|
+
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
276
|
+
const anc = ancestors[i];
|
|
277
|
+
if (!FUNCTION_TYPES.has(anc.type))
|
|
278
|
+
continue;
|
|
279
|
+
const parent = ancestors[i - 1];
|
|
280
|
+
const executorArgs = (parent?.arguments ?? []);
|
|
281
|
+
if (parent?.type === 'NewExpression' &&
|
|
282
|
+
parent.callee?.type === 'Identifier' &&
|
|
283
|
+
parent.callee.name === 'Promise' &&
|
|
284
|
+
executorArgs[0] === anc) {
|
|
285
|
+
const resolveParam = (anc.params ?? [])[0];
|
|
286
|
+
const firstArg = (node.arguments ?? [])[0];
|
|
287
|
+
const isDirectResolve = resolveParam?.type === 'Identifier' &&
|
|
288
|
+
firstArg?.type === 'Identifier' &&
|
|
289
|
+
firstArg.name === resolveParam.name;
|
|
290
|
+
const resolveName = resolveParam?.type === 'Identifier' ? resolveParam.name : undefined;
|
|
291
|
+
const isWrappedResolve = typeof resolveName === 'string' && callsResolve(firstArg, resolveName);
|
|
292
|
+
if (isDirectResolve || isWrappedResolve) {
|
|
293
|
+
context.report({ node: node, messageId: 'sleep' });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
break; // only the nearest enclosing function matters
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
/**
|
|
303
|
+
* `page.mouse.click(x, y)` (or `this.page.mouse.click(...)`, any object
|
|
304
|
+
* expression ending in `.mouse.click`) clicks a fixed viewport coordinate
|
|
305
|
+
* instead of an element — brittle by construction (breaks on any layout,
|
|
306
|
+
* zoom, or viewport-size change) and, unlike `locator.click({ force:
|
|
307
|
+
* true })`, has no upstream rule at all. Also catches three respellings
|
|
308
|
+
* found in a pre-1.1.0 review, all still coordinate-based interaction
|
|
309
|
+
* through the same `Mouse` API: `const { mouse } = page; mouse.click(...)`
|
|
310
|
+
* (destructuring hides the `.mouse.` member access this rule originally
|
|
311
|
+
* looked for), `mouse.dblclick(x, y)` (same brittleness, different
|
|
312
|
+
* method), and the manual `move(x, y)` + `down()` + `up()` sequence that
|
|
313
|
+
* reimplements a click one step at a time — still landing at a fixed
|
|
314
|
+
* pixel position, just spread across three calls instead of one.
|
|
315
|
+
*/
|
|
316
|
+
const MOUSE_COORDINATE_METHODS = new Set(['click', 'dblclick', 'move', 'down', 'up']);
|
|
317
|
+
const noCoordinateClick = {
|
|
318
|
+
meta: {
|
|
319
|
+
type: 'suggestion',
|
|
320
|
+
docs: {
|
|
321
|
+
description: 'Disallow page.mouse.click/dblclick/move/down/up(...) coordinate-based interaction — click a locator instead',
|
|
322
|
+
},
|
|
323
|
+
messages: {
|
|
324
|
+
coordinateClick: 'Unexpected coordinate-based interaction via *.mouse.{{method}}(...). Interact through a locator (locator.click()/locator.hover()) so the action targets an element, not a fixed pixel position.',
|
|
325
|
+
},
|
|
326
|
+
schema: [],
|
|
327
|
+
},
|
|
328
|
+
create(context) {
|
|
329
|
+
return {
|
|
330
|
+
// Single full-tree pass at the end of the file rather than the
|
|
331
|
+
// usual incremental per-node visitor: alias detection (`const m =
|
|
332
|
+
// page.mouse`) needs every declaration collected before any
|
|
333
|
+
// CallExpression can be checked against it, and declarations don't
|
|
334
|
+
// reliably precede their uses in a single top-down traversal
|
|
335
|
+
// ordering guarantee otherwise. Typed loosely (not our shared
|
|
336
|
+
// `Node`, nor the real ESTree `Program`) for the same reason as
|
|
337
|
+
// ImportDeclaration/VariableDeclarator above — walkNode only needs
|
|
338
|
+
// a `type` string and recurses over own enumerable properties, so
|
|
339
|
+
// an `unknown` in is enough.
|
|
340
|
+
'Program:exit'(raw) {
|
|
341
|
+
const program = raw;
|
|
342
|
+
// Any variable initialized directly from `.mouse` (`page.mouse`,
|
|
343
|
+
// `this.page.mouse`, ...) is the same Mouse API under an
|
|
344
|
+
// arbitrary local name — `const m = page.mouse; m.click(x, y)` is
|
|
345
|
+
// exactly as brittle as `page.mouse.click(x, y)`, just harder to
|
|
346
|
+
// grep for. Seeded with the bare name `mouse` so `const { mouse }
|
|
347
|
+
// = page; mouse.click(...)` (destructuring, not an assignment
|
|
348
|
+
// this pass's MemberExpression check would match) is still
|
|
349
|
+
// caught, same as before this alias tracking existed. A simple
|
|
350
|
+
// identifier-to-identifier chain (`const m2 = m1`) is also
|
|
351
|
+
// followed, since the walk below visits declarations in source
|
|
352
|
+
// order for the common sequential-declaration case.
|
|
353
|
+
const mouseAliasNames = new Set(['mouse']);
|
|
354
|
+
walkNode(program, (n) => {
|
|
355
|
+
if (n.type !== 'VariableDeclarator')
|
|
356
|
+
return;
|
|
357
|
+
const decl = n;
|
|
358
|
+
if (decl.id?.type !== 'Identifier' || !decl.id.name || !decl.init)
|
|
359
|
+
return;
|
|
360
|
+
const init = decl.init;
|
|
361
|
+
const isDotMouseInit = init.type === 'MemberExpression' &&
|
|
362
|
+
!init.computed &&
|
|
363
|
+
init.property?.type === 'Identifier' &&
|
|
364
|
+
init.property.name === 'mouse';
|
|
365
|
+
const isChainedAlias = init.type === 'Identifier' &&
|
|
366
|
+
typeof init.name === 'string' &&
|
|
367
|
+
mouseAliasNames.has(init.name);
|
|
368
|
+
if (isDotMouseInit || isChainedAlias) {
|
|
369
|
+
mouseAliasNames.add(decl.id.name);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
walkNode(program, (n) => {
|
|
373
|
+
if (n.type !== 'CallExpression')
|
|
374
|
+
return;
|
|
375
|
+
const callee = n.callee;
|
|
376
|
+
if (!callee || callee.type !== 'MemberExpression' || callee.computed)
|
|
377
|
+
return;
|
|
378
|
+
const methodName = callee.property?.type === 'Identifier' ? callee.property.name : undefined;
|
|
379
|
+
if (!methodName || !MOUSE_COORDINATE_METHODS.has(methodName))
|
|
380
|
+
return;
|
|
381
|
+
const obj = callee.object;
|
|
382
|
+
if (!obj)
|
|
383
|
+
return;
|
|
384
|
+
// `page.mouse.click(...)` / `this.page.mouse.click(...)` — any
|
|
385
|
+
// expression ending in `.mouse`.
|
|
386
|
+
const isDotMouse = obj.type === 'MemberExpression' &&
|
|
387
|
+
!obj.computed &&
|
|
388
|
+
obj.property?.type === 'Identifier' &&
|
|
389
|
+
obj.property.name === 'mouse';
|
|
390
|
+
// `const { mouse } = page; mouse.click(...)` (bare `mouse`,
|
|
391
|
+
// always in the set) or `const m = page.mouse; m.click(...)`
|
|
392
|
+
// (any other name aliased above) — either way, the exact same
|
|
393
|
+
// Mouse API under a name that isn't a `.mouse` member access.
|
|
394
|
+
const isAliasedMouseIdentifier = obj.type === 'Identifier' && typeof obj.name === 'string' && mouseAliasNames.has(obj.name);
|
|
395
|
+
if (!isDotMouse && !isAliasedMouseIdentifier)
|
|
396
|
+
return;
|
|
397
|
+
context.report({
|
|
398
|
+
node: n,
|
|
399
|
+
messageId: 'coordinateClick',
|
|
400
|
+
data: { method: methodName },
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
const NOT_CONST = { ok: false };
|
|
408
|
+
/**
|
|
409
|
+
* Statically evaluates the small subset of constant-expression shapes a
|
|
410
|
+
* literal-assertion idiom actually uses: literals, `!`/`-`/`+`/`~` on a
|
|
411
|
+
* constant, simple arithmetic (`1 + 1`), a no-expression template literal
|
|
412
|
+
* (`` `hello` ``, indistinguishable in intent from `'hello'`), an array
|
|
413
|
+
* of constants (for `toEqual`/`toStrictEqual`, e.g. `[]`), and — given a
|
|
414
|
+
* `resolve` callback — a bare `Identifier` bound to one of these shapes
|
|
415
|
+
* by a `const` declaration (`const t = true; expect(t).toBe(true)` is
|
|
416
|
+
* the exact same always-passing assertion as `expect(true).toBe(true)`,
|
|
417
|
+
* one local rename away from this rule's original identifier-only
|
|
418
|
+
* check). Anything else (a `let`/`var` binding, a function call, an
|
|
419
|
+
* array containing a non-constant) is deliberately NOT evaluated — a
|
|
420
|
+
* false negative here is safe (we just don't flag it); a false positive
|
|
421
|
+
* would mean claiming real app state "always passes", which would be
|
|
422
|
+
* wrong.
|
|
423
|
+
*/
|
|
424
|
+
function evalConst(node, resolve) {
|
|
425
|
+
if (!node)
|
|
426
|
+
return NOT_CONST;
|
|
427
|
+
if (node.type === 'Literal')
|
|
428
|
+
return { ok: true, value: node.value };
|
|
429
|
+
if (node.type === 'Identifier' && typeof node.name === 'string') {
|
|
430
|
+
return resolve?.(node.name) ?? NOT_CONST;
|
|
431
|
+
}
|
|
432
|
+
if (node.type === 'TemplateLiteral') {
|
|
433
|
+
if ((node.expressions ?? []).length > 0)
|
|
434
|
+
return NOT_CONST;
|
|
435
|
+
const quasi = (node.quasis ?? [])[0];
|
|
436
|
+
if (!quasi)
|
|
437
|
+
return NOT_CONST;
|
|
438
|
+
return { ok: true, value: quasi.value.cooked ?? quasi.value.raw };
|
|
439
|
+
}
|
|
440
|
+
if (node.type === 'UnaryExpression' && node.operator) {
|
|
441
|
+
const arg = evalConst(node.argument, resolve);
|
|
442
|
+
if (!arg.ok)
|
|
443
|
+
return NOT_CONST;
|
|
444
|
+
switch (node.operator) {
|
|
445
|
+
case '!':
|
|
446
|
+
return { ok: true, value: !arg.value };
|
|
447
|
+
case '-':
|
|
448
|
+
return { ok: true, value: -arg.value };
|
|
449
|
+
case '+':
|
|
450
|
+
return { ok: true, value: +arg.value };
|
|
451
|
+
case '~':
|
|
452
|
+
return { ok: true, value: ~arg.value };
|
|
453
|
+
default:
|
|
454
|
+
return NOT_CONST;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (node.type === 'BinaryExpression' && node.left && node.right) {
|
|
458
|
+
const left = evalConst(node.left, resolve);
|
|
459
|
+
const right = evalConst(node.right, resolve);
|
|
460
|
+
if (!left.ok || !right.ok)
|
|
461
|
+
return NOT_CONST;
|
|
462
|
+
const l = left.value;
|
|
463
|
+
const r = right.value;
|
|
464
|
+
switch (node.operator) {
|
|
465
|
+
case '+':
|
|
466
|
+
return { ok: true, value: l + r };
|
|
467
|
+
case '-':
|
|
468
|
+
return { ok: true, value: l - r };
|
|
469
|
+
case '*':
|
|
470
|
+
return { ok: true, value: l * r };
|
|
471
|
+
case '/':
|
|
472
|
+
return { ok: true, value: l / r };
|
|
473
|
+
case '%':
|
|
474
|
+
return { ok: true, value: l % r };
|
|
475
|
+
case '**':
|
|
476
|
+
return { ok: true, value: l ** r };
|
|
477
|
+
default:
|
|
478
|
+
return NOT_CONST;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (node.type === 'ArrayExpression') {
|
|
482
|
+
const values = [];
|
|
483
|
+
for (const el of node.elements ?? []) {
|
|
484
|
+
const r = evalConst(el, resolve);
|
|
485
|
+
if (!r.ok)
|
|
486
|
+
return NOT_CONST; // includes sparse-array holes (el === null)
|
|
487
|
+
values.push(r.value);
|
|
488
|
+
}
|
|
489
|
+
return { ok: true, value: values };
|
|
490
|
+
}
|
|
491
|
+
return NOT_CONST;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Collects every `const <Identifier> = <constant>` binding in `root`
|
|
495
|
+
* (typically a single test's function body — see `noTrivialAssertion`)
|
|
496
|
+
* into a name → value map, chasing `const a = <constant>; const b = a;`
|
|
497
|
+
* chains via `evalConst`'s own `resolve` callback closing over the map
|
|
498
|
+
* being built. `let`/`var` are deliberately excluded: they can be
|
|
499
|
+
* reassigned after declaration, so a value captured at declaration time
|
|
500
|
+
* could be stale by the time the assertion actually runs — a false
|
|
501
|
+
* positive risk this rule does not take.
|
|
502
|
+
*/
|
|
503
|
+
function collectConstBindings(root) {
|
|
504
|
+
const bindings = new Map();
|
|
505
|
+
const resolve = (name) => bindings.get(name);
|
|
506
|
+
walkNode(root, (n) => {
|
|
507
|
+
if (n.type !== 'VariableDeclaration')
|
|
508
|
+
return;
|
|
509
|
+
const decl = n;
|
|
510
|
+
if (decl.kind !== 'const')
|
|
511
|
+
return;
|
|
512
|
+
for (const d of decl.declarations ?? []) {
|
|
513
|
+
if (d.id?.type !== 'Identifier' || !d.id.name || !d.init)
|
|
514
|
+
continue;
|
|
515
|
+
const value = evalConst(d.init, resolve);
|
|
516
|
+
if (value.ok)
|
|
517
|
+
bindings.set(d.id.name, value);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
return bindings;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Climbs from `node` to the body of the nearest enclosing function (the
|
|
524
|
+
* test/hook callback in the common case) — the scope `noTrivialAssertion`
|
|
525
|
+
* resolves local `const` bindings within, so a `const` of the same name
|
|
526
|
+
* in a different test can't be mistaken for this one's.
|
|
527
|
+
*/
|
|
528
|
+
function nearestEnclosingFunctionBody(context, node) {
|
|
529
|
+
const ancestors = (context.sourceCode?.getAncestors
|
|
530
|
+
? context.sourceCode.getAncestors(node)
|
|
531
|
+
: context.getAncestors());
|
|
532
|
+
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
533
|
+
if (FUNCTION_TYPES.has(ancestors[i].type))
|
|
534
|
+
return ancestors[i].body;
|
|
535
|
+
}
|
|
536
|
+
return undefined;
|
|
537
|
+
}
|
|
538
|
+
function deepEqual(a, b) {
|
|
539
|
+
if (Object.is(a, b))
|
|
540
|
+
return true;
|
|
541
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
542
|
+
return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
|
|
543
|
+
}
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Given a matcher name and its (already-evaluated) subject, returns
|
|
548
|
+
* whether `expect(subject).<matcher>(...expectedNode)` always evaluates
|
|
549
|
+
* true — or `undefined` when the matcher isn't one we understand well
|
|
550
|
+
* enough to say either way (e.g. its expected argument isn't a constant
|
|
551
|
+
* we can evaluate). Only a small, common subset of matchers is covered;
|
|
552
|
+
* an unrecognized matcher is a false negative, never a false positive.
|
|
553
|
+
*/
|
|
554
|
+
function matcherAlwaysPasses(matcherName, subject, expectedNode, resolve) {
|
|
555
|
+
switch (matcherName) {
|
|
556
|
+
case 'toBe': {
|
|
557
|
+
const expected = evalConst(expectedNode, resolve);
|
|
558
|
+
return expected.ok ? Object.is(subject, expected.value) : undefined;
|
|
559
|
+
}
|
|
560
|
+
case 'toEqual':
|
|
561
|
+
case 'toStrictEqual': {
|
|
562
|
+
const expected = evalConst(expectedNode, resolve);
|
|
563
|
+
return expected.ok ? deepEqual(subject, expected.value) : undefined;
|
|
564
|
+
}
|
|
565
|
+
case 'toBeTruthy':
|
|
566
|
+
return Boolean(subject);
|
|
567
|
+
case 'toBeFalsy':
|
|
568
|
+
return !subject;
|
|
569
|
+
case 'toBeNull':
|
|
570
|
+
return subject === null;
|
|
571
|
+
case 'toBeNaN':
|
|
572
|
+
return typeof subject === 'number' && Number.isNaN(subject);
|
|
573
|
+
default:
|
|
574
|
+
return undefined;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/** `expect(...)` / `expect.soft(...)` / `expect.poll(...)`. */
|
|
578
|
+
function isExpectCallNode(node) {
|
|
579
|
+
if (!node || node.type !== 'CallExpression')
|
|
580
|
+
return false;
|
|
581
|
+
const callee = node.callee;
|
|
582
|
+
if (callee?.type === 'Identifier' && callee.name === 'expect')
|
|
583
|
+
return true;
|
|
584
|
+
return (callee?.type === 'MemberExpression' &&
|
|
585
|
+
!callee.computed &&
|
|
586
|
+
callee.object?.type === 'Identifier' &&
|
|
587
|
+
callee.object.name === 'expect' &&
|
|
588
|
+
callee.property?.type === 'Identifier' &&
|
|
589
|
+
(callee.property.name === 'soft' || callee.property.name === 'poll'));
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* `expect(true).toBe(true)`, `` expect(`x`).toBe(`x`) ``, `expect(1 +
|
|
593
|
+
* 1).toBe(2)`, `expect([]).toEqual([])` — any assertion whose subject and
|
|
594
|
+
* matcher combination can only ever pass, regardless of anything the app
|
|
595
|
+
* under test does. It satisfies `playwright/expect-expect` (a test "has
|
|
596
|
+
* an assertion") while asserting nothing real. Also catches the same
|
|
597
|
+
* idiom one local rename away: `const t = true; expect(t).toBe(true)` —
|
|
598
|
+
* a `const` bound directly to a statically-evaluable constant within the
|
|
599
|
+
* same test is resolved via `evalConst`'s `resolve` callback (see
|
|
600
|
+
* `collectConstBindings`/`nearestEnclosingFunctionBody`), including a
|
|
601
|
+
* short `const a = ...; const b = a;` chain. Deliberately conservative in
|
|
602
|
+
* two directions beyond that: a `let`/`var` binding is never resolved
|
|
603
|
+
* (it can be reassigned before the assertion runs, so a value captured
|
|
604
|
+
* at declaration time could be stale — a false negative we accept rather
|
|
605
|
+
* than risk flagging real app state), and the matcher + `.not`
|
|
606
|
+
* combination is actually evaluated rather than assumed — `expect(true).
|
|
607
|
+
* toBe(false)` and `expect(true, msg).toBeFalsy()` are the deliberate
|
|
608
|
+
* force-fail idiom (asserting something that can only ever be false, to
|
|
609
|
+
* fail a test unconditionally), the opposite of this rule's target, and
|
|
610
|
+
* are never flagged: the message says "always passes" and must stay true
|
|
611
|
+
* every time it fires.
|
|
612
|
+
*/
|
|
613
|
+
const noTrivialAssertion = {
|
|
614
|
+
meta: {
|
|
615
|
+
type: 'suggestion',
|
|
616
|
+
docs: {
|
|
617
|
+
description: 'Disallow an assertion whose subject and matcher can only ever pass (expect(true).toBe(true), expect([]).toEqual([]), expect(1 + 1).toBe(2)) — proves nothing about the application under test. Does not flag a deliberate force-fail assertion (e.g. expect(true).toBe(false)), which always fails, not passes.',
|
|
618
|
+
},
|
|
619
|
+
messages: {
|
|
620
|
+
trivial: 'Unexpected assertion that always passes on a constant — it can never fail and verifies nothing about the application under test.',
|
|
621
|
+
},
|
|
622
|
+
schema: [],
|
|
623
|
+
},
|
|
624
|
+
create(context) {
|
|
625
|
+
return {
|
|
626
|
+
CallExpression(node) {
|
|
627
|
+
const callee = node.callee;
|
|
628
|
+
if (!callee || callee.type !== 'MemberExpression' || callee.computed)
|
|
629
|
+
return;
|
|
630
|
+
const matcherName = callee.property?.type === 'Identifier' ? callee.property.name : undefined;
|
|
631
|
+
if (!matcherName)
|
|
632
|
+
return;
|
|
633
|
+
let expectCall;
|
|
634
|
+
let hasNot = false;
|
|
635
|
+
if (isExpectCallNode(callee.object)) {
|
|
636
|
+
expectCall = callee.object;
|
|
637
|
+
}
|
|
638
|
+
else if (callee.object?.type === 'MemberExpression' &&
|
|
639
|
+
!callee.object.computed &&
|
|
640
|
+
callee.object.property?.type === 'Identifier' &&
|
|
641
|
+
callee.object.property.name === 'not' &&
|
|
642
|
+
isExpectCallNode(callee.object.object)) {
|
|
643
|
+
expectCall = callee.object.object;
|
|
644
|
+
hasNot = true;
|
|
645
|
+
}
|
|
646
|
+
if (!expectCall)
|
|
647
|
+
return;
|
|
648
|
+
// Resolve a bare-identifier subject/expected-arg against any
|
|
649
|
+
// `const` bound to a constant within the nearest enclosing
|
|
650
|
+
// function (typically the test/hook callback) — see
|
|
651
|
+
// collectConstBindings's docs for why only `const` qualifies.
|
|
652
|
+
// Computed once per assertion call, not per identifier resolved.
|
|
653
|
+
const scopeBindings = collectConstBindings(nearestEnclosingFunctionBody(context, node));
|
|
654
|
+
const resolve = (name) => scopeBindings.get(name);
|
|
655
|
+
const subject = evalConst((expectCall.arguments ?? [])[0], resolve);
|
|
656
|
+
if (!subject.ok)
|
|
657
|
+
return;
|
|
658
|
+
const expectedArg = (node.arguments ?? [])[0];
|
|
659
|
+
const alwaysPasses = matcherAlwaysPasses(matcherName, subject.value, expectedArg, resolve);
|
|
660
|
+
if (alwaysPasses === undefined)
|
|
661
|
+
return;
|
|
662
|
+
if (hasNot ? !alwaysPasses : alwaysPasses) {
|
|
663
|
+
context.report({ node: node, messageId: 'trivial' });
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
};
|
|
667
|
+
},
|
|
668
|
+
};
|
|
669
|
+
/** `testNames` — see isSkipCallee's doc comment above; same alias treatment. */
|
|
670
|
+
function isTestDeclarationCallee(callee, testNames) {
|
|
671
|
+
if (!callee)
|
|
672
|
+
return false;
|
|
673
|
+
if (callee.type === 'Identifier' && callee.name && testNames.has(callee.name))
|
|
674
|
+
return true;
|
|
675
|
+
if (callee.type === 'MemberExpression' &&
|
|
676
|
+
!callee.computed &&
|
|
677
|
+
callee.object?.type === 'Identifier' &&
|
|
678
|
+
callee.object.name &&
|
|
679
|
+
testNames.has(callee.object.name) &&
|
|
680
|
+
callee.property?.type === 'Identifier' &&
|
|
681
|
+
(callee.property.name === 'only' || callee.property.name === 'fixme')) {
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* A test whose every assertion is `expect.soft(...)` never actually
|
|
688
|
+
* fails: a soft assertion records a failure but lets the test keep
|
|
689
|
+
* running and only fails it at the very end via `test.info().errors` —
|
|
690
|
+
* fine for "gather every problem in one run" audit steps, poor default
|
|
691
|
+
* for ordinary flow verification, where a failed precondition should
|
|
692
|
+
* stop the test rather than cascade into confusing downstream failures.
|
|
693
|
+
* A contentious, opinionated check (soft assertions are a legitimate,
|
|
694
|
+
* deliberate choice, not always a defect) — scored as a warning-level
|
|
695
|
+
* demerit as of 2.0.0/model v4, same as the rest of the QAG-196 rules
|
|
696
|
+
* (see profiles.ts and CHANGELOG.md). `expect.poll(...)` counts as a hard
|
|
697
|
+
* assertion, not soft: unlike `expect.soft`, a failed poll still throws
|
|
698
|
+
* and fails the test — it just retries first.
|
|
699
|
+
*/
|
|
700
|
+
const noSoftAssertionOnlyTest = {
|
|
701
|
+
meta: {
|
|
702
|
+
type: 'suggestion',
|
|
703
|
+
docs: {
|
|
704
|
+
description: 'Flag a test whose only assertions are expect.soft(...) — a failed check never fails the test run',
|
|
705
|
+
},
|
|
706
|
+
messages: {
|
|
707
|
+
softOnly: 'This test only uses expect.soft(...) assertions — a failed check is recorded but never fails the test. Use a hard expect() for anything that should stop the test.',
|
|
708
|
+
},
|
|
709
|
+
schema: [
|
|
710
|
+
{
|
|
711
|
+
additionalProperties: false,
|
|
712
|
+
properties: {
|
|
713
|
+
testAliasNames: { items: { type: 'string' }, type: 'array' },
|
|
714
|
+
},
|
|
715
|
+
type: 'object',
|
|
716
|
+
},
|
|
717
|
+
],
|
|
718
|
+
},
|
|
719
|
+
create(context) {
|
|
720
|
+
const testAliasNames = context.options[0]
|
|
721
|
+
?.testAliasNames ?? [];
|
|
722
|
+
const testNames = new Set(['test', ...testAliasNames]);
|
|
723
|
+
return {
|
|
724
|
+
CallExpression(node) {
|
|
725
|
+
if (!isTestDeclarationCallee(node.callee, testNames))
|
|
726
|
+
return;
|
|
727
|
+
const args = node.arguments ?? [];
|
|
728
|
+
const body = args[args.length - 1];
|
|
729
|
+
if (!body || !FUNCTION_TYPES.has(body.type))
|
|
730
|
+
return;
|
|
731
|
+
let hard = 0;
|
|
732
|
+
let soft = 0;
|
|
733
|
+
walkNode(body.body, (n) => {
|
|
734
|
+
if (n.type !== 'CallExpression')
|
|
735
|
+
return;
|
|
736
|
+
const c = n.callee;
|
|
737
|
+
if (!c)
|
|
738
|
+
return;
|
|
739
|
+
if (c.type === 'Identifier' && c.name === 'expect')
|
|
740
|
+
hard += 1;
|
|
741
|
+
else if (c.type === 'MemberExpression' &&
|
|
742
|
+
!c.computed &&
|
|
743
|
+
c.object?.type === 'Identifier' &&
|
|
744
|
+
c.object.name === 'expect' &&
|
|
745
|
+
c.property?.type === 'Identifier') {
|
|
746
|
+
if (c.property.name === 'soft')
|
|
747
|
+
soft += 1;
|
|
748
|
+
else if (c.property.name === 'poll')
|
|
749
|
+
hard += 1;
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
if (soft > 0 && hard === 0) {
|
|
753
|
+
context.report({ node: node, messageId: 'softOnly' });
|
|
754
|
+
}
|
|
755
|
+
},
|
|
756
|
+
};
|
|
757
|
+
},
|
|
758
|
+
};
|
|
72
759
|
export const basePlugin = {
|
|
73
760
|
meta: { name: 'pwscore-base', version: '0.1.0' },
|
|
74
761
|
rules: {
|
|
75
762
|
'no-skipped-test-declaration': noSkippedTestDeclaration,
|
|
763
|
+
'no-timer-sleep': noTimerSleep,
|
|
764
|
+
'no-coordinate-click': noCoordinateClick,
|
|
765
|
+
'no-trivial-assertion': noTrivialAssertion,
|
|
766
|
+
'no-soft-assertion-only-test': noSoftAssertionOnlyTest,
|
|
76
767
|
},
|
|
77
768
|
};
|