@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.
@@ -1,18 +1,28 @@
1
- function isSkipCallee(callee) {
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 === 'test')
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 === 'test' &&
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
  };