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