@qaguardian/playwright-score 2.1.1 → 2.2.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 CHANGED
@@ -10,6 +10,79 @@ parsing accuracy, not grading policy — can still move a suite's score
10
10
  without itself being a new model version; 2.1.0 below is exactly that
11
11
  case, and says so explicitly.
12
12
 
13
+ ## 2.2.0 — 2026-11-14
14
+
15
+ False-positive fixes found by hand-checking error-level findings on public
16
+ suites (7 of 18 were wrong), then tightened after review so the fixes cannot be used to hide a test with no assertion. No rule's weight or severity changed, so this
17
+ is not a new scoring model (still v4), but **scores can rise**: findings
18
+ that were wrong are no longer reported. Nothing gets stricter.
19
+
20
+ - `playwright/expect-expect`: a test that asserts through a fixture
21
+ function injected via the test args is no longer reported as having no
22
+ assertion, but only when the fixture is a callable that really asserts:
23
+ a `test.extend({ check: async ({ page }, use) => { await use(async () =>
24
+ { await expect(...) }) } })` entry whose function handed to `use(...)`
25
+ runs `expect`, the test destructures it AND calls it, and the fixtures
26
+ module is reachable from that spec through relative imports. An `expect`
27
+ in a fixture's setup or teardown does not count, nor does any fixture
28
+ named like a built-in one (`page`, `context`, `request`, ...). A test
29
+ also counts as asserting through an object-literal method / class-property
30
+ arrow function in a file it imports (a fixture factory's returned object,
31
+ a page object), except that a helper whose name collides with a Playwright
32
+ method (`goto`, `click`, `fill`, `check`, `get`, `post`, ...) is never
33
+ treated as an assertion helper, so it cannot turn every `page.goto()` into
34
+ an assertion. When a test has no `expect` of its own but calls an
35
+ assertion-named method (`assert`, `verify`, `check`, `validate`, `expect`,
36
+ `ensure`, `should`, `confirm` followed by an upper-case letter, `_`, a digit
37
+ or nothing, such as `checkTotals` but not `checkout`; or `toBe*`, `toHave*`,
38
+ `toContain*`, `toMatch*`, `toEqual*`, `toThrow*`) on an object this scan
39
+ cannot read (an injected fixture from another package, an unresolvable
40
+ import, a third-party package), it is no longer an error: it is reported
41
+ as the report-only finding `playwright-score/assertion-unverified`
42
+ ("needs a human check"), which does not lower the score and is not
43
+ counted in `summary.unassertedTests`. Those tests are counted separately
44
+ in `summary.unverifiedTests`, a new field that is always present in the
45
+ JSON output (`0` when nothing is unverified; the text and Markdown reports
46
+ show those tests as unverified, never as asserting): they are not known to
47
+ assert. A fixture a test calls, whose `test` comes from a module the scan
48
+ does not resolve (a tsconfig `paths` alias, a workspace package), is
49
+ unverified the same way. Names bound by destructuring
50
+ (`const { channelsPage } = await pw.login(user)`) take the kind of the
51
+ expression they come from. Query-style names (`is...`, `has...`,
52
+ `wait...`), helper methods named like Playwright actions (`fill`, `check`,
53
+ `goto`, ...), built-in calls (`new Date().toISOString()`,
54
+ `user.email.toLowerCase()`, `x.toString()`), `shop.checkout()` and
55
+ `loginPage.rememberMe.check()` are not treated as assertions: a call whose
56
+ name is not assertion-like, a test that only calls Playwright API on
57
+ `page`/`request`/..., and helpers whose body we can read and which do not
58
+ assert are still `expect-expect` errors.
59
+ - `pwscore/no-trivial-assertion`: `expect(pageErrors).toEqual([])` where
60
+ `pageErrors` is a `const` array that may be mutated (passed to a call as
61
+ an argument, the receiver of `push`/`pop`/`splice`/`sort`/..., an
62
+ assignment target, aliased, or captured by a closure that does one of
63
+ those, such as a listener, or an inner array reached through an index:
64
+ `rows[0].push(9)`) is no longer flagged. A `.length` or index
65
+ read, a non-mutating method such as `.map()`, and an unrelated property
66
+ with the same name (`obj.expected`) do not count as mutation; literals
67
+ and a `const` array that is only read are still flagged.
68
+ - `pwscore/no-timer-sleep`: a retry/backoff pause inside a plain helper
69
+ function (declaration, `const f = () =>`, class method) is no longer
70
+ flagged, but only when the sleep sits in a `catch` clause or in a loop
71
+ whose body contains a non-empty `try` with a `catch`, and the helper is not declared inside
72
+ a test, hook, describe or step callback. Sleeps directly in a test body or
73
+ hook, any loop without `try`/`catch` (including a hand-rolled
74
+ `while (await x.isVisible()) sleep()` polling wait), helpers declared
75
+ inside a test callback, and bare `sleep()` helpers are still flagged.
76
+ - `playwright/no-conditional-in-test`: a guard whose only body is `throw`
77
+ (`if (!id) throw new Error(...)`, no `else`) is no longer flagged.
78
+ - `playwright/valid-expect`: a helper whose name merely ends in `Expect`
79
+ (`clickSortableHeaderAndExpect(...)`) is no longer reported as an
80
+ `expect()` without a matcher.
81
+ - Known and not fixed: `playwright/no-standalone-expect` on tests declared
82
+ through a factory function that returns a `test.extend(...)` result
83
+ (`const t = testWithConfig({...}); t('name', ...)`). The alias detection
84
+ only follows direct `test.extend` chains.
85
+
13
86
  ## 2.1.1 — 2026-09-26
14
87
 
15
88
  Scoring model stays v4. Reviewer follow-ups from the 2.1.0 release review
package/METHODOLOGY.md CHANGED
@@ -82,7 +82,7 @@ owns this dimension; counting them as demerits too would double-penalize.)
82
82
  | Dimension | Weight | Demerit sources |
83
83
  |---|---:|---|
84
84
  | playwrightHygiene | 40 | hard waits (`page.waitForTimeout`, `new Promise`+`setTimeout` and its respellings, `node:timers/promises` setTimeout), networkidle, force, coordinate-based mouse interaction (`*.mouse.click/dblclick/move/down/up(x, y)`, partial demerit — see below), missing await, element handles, eval, conditionals, page.pause, useless await |
85
- | assertions | 25 | a test with no recognized assertion is fully flawed (`expect-expect`, after all delegation resolution); an assertion whose subject/matcher combination can only ever pass (`expect(true).toBe(true)`, `expect([]).toEqual([])`, never a deliberate force-fail like `expect(true).toBe(false)`); `valid-expect`, `no-standalone-expect`, `prefer-web-first-assertions`, a soft-assertion-only test (`expect.poll` counts as hard, not soft) all add partial demerits |
85
+ | assertions | 25 | a test with no recognized assertion is fully flawed (`expect-expect`, after all delegation resolution; a test with no `expect` of its own that calls an assertion-named method (`assert`, `verify`, `check`, `validate`, `expect`, `ensure`, `should`, `confirm` followed by an upper-case letter, `_`, a digit or nothing; or `toBe*`, `toHave*`, `toContain*`, `toMatch*`, `toEqual*`, `toThrow*`) on an object the scan cannot read, or calls a fixture that comes from a module the scan cannot resolve is a report-only `playwright-score/assertion-unverified` "needs a human check" finding, not a demerit, and is counted in `summary.unverifiedTests` rather than as asserting; any other call on such an object does not excuse the test, so query-style names (`is...`, `has...`, `wait...`), helper methods named like Playwright actions (`fill`, `check`, `goto`) and built-in calls are never treated as assertions; an assertion helper or fixture only counts when its body is readable, it is called by the test, and its name does not collide with a Playwright method); an assertion whose subject/matcher combination can only ever pass (`expect(true).toBe(true)`, `expect([]).toEqual([])`, never a deliberate force-fail like `expect(true).toBe(false)`, and never an array that may be mutated elsewhere: passed to a call, the receiver of a mutating method such as `push`, an assignment target, or captured by a closure such as an event listener doing so, including an inner array mutated through an index such as `rows[0].push(9)`); `valid-expect`, `no-standalone-expect`, `prefer-web-first-assertions`, a soft-assertion-only test (`expect.poll` counts as hard, not soft) all add partial demerits |
86
86
  | locators | 20 | usage ratio (above) |
87
87
  | structure | 15 | focused tests, always-skipped declarations, nested-describe depth, oversized files (module scope), an inline `eslint-disable` suppression comment (partial demerit — see "Known limitations" in VALIDATION.md) |
88
88
 
@@ -0,0 +1,13 @@
1
+ import type { Finding } from './types.js';
2
+ export declare const ASSERTION_UNVERIFIED_RULE = "playwright-score/assertion-unverified";
3
+ export interface TriageContext {
4
+ /** Directory the findings' `file` paths are relative to. */
5
+ filesBase: string;
6
+ /** Scored spec files, absolute path -> source. */
7
+ sources: Map<string, string>;
8
+ /** Locally imported dependency files (page objects, fixtures), absolute path -> source. */
9
+ importedFiles: Map<string, string>;
10
+ /** Directory relative imports may not climb out of (defaults to filesBase). */
11
+ importBoundary?: string;
12
+ }
13
+ export declare function triageFindings(findings: Finding[], ctx: TriageContext): Finding[];
@@ -0,0 +1,470 @@
1
+ /**
2
+ * Post-lint triage of two upstream rules whose own checks cannot see across
3
+ * files or tell a guard from real branching (QAG-287):
4
+ *
5
+ * - `playwright/expect-expect` only sees `expect(...)` written in the test
6
+ * body. A test that asserts through a page-object method, an injected
7
+ * fixture or an imported helper was reported as "no assertion" at error
8
+ * severity even though the assertion runs. Each such finding is resolved
9
+ * here: dropped when a fixture the test uses is known to assert, turned
10
+ * into a report-only "needs a human check" finding when the test calls
11
+ * something this static scan cannot read, and kept as the error it is
12
+ * when every call resolves to code we could read (or is plain Playwright
13
+ * API on `page`, `request`, ...).
14
+ * - `playwright/valid-expect` fires on a helper whose name ends in
15
+ * "Expect" (`clickSortableHeaderAndExpect(...)`) as if it were expect().
16
+ * - `playwright/no-conditional-in-test` fires on a guard whose only body is
17
+ * `throw`. That is an assertion in disguise, not branching test logic.
18
+ *
19
+ * Purely syntactic and name-based, like the rest of this package: a
20
+ * resolved-by-name match is the same trade-off `assertFunctionNames` makes.
21
+ */
22
+ import path from 'node:path';
23
+ import { parseForESLint } from '@typescript-eslint/parser';
24
+ import { resolveRelativeImport } from './import-graph.js';
25
+ import { PLAYWRIGHT_API_METHODS } from './playwright-api-names.js';
26
+ export const ASSERTION_UNVERIFIED_RULE = 'playwright-score/assertion-unverified';
27
+ /** Identifiers whose calls are Playwright API or the language runtime, never a hidden assertion helper. */
28
+ const NEUTRAL_ROOTS = new Set([
29
+ 'page', 'context', 'request', 'browser', 'browserName', 'baseURL', 'testInfo', 'playwright',
30
+ 'expect', 'test', 'it', 'describe', 'console', 'Promise', 'JSON', 'Math', 'Date', 'Object',
31
+ 'Array', 'String', 'Number', 'Boolean', 'process', 'setTimeout', 'setInterval', 'require',
32
+ 'URL', 'Buffer', 'fetch', 'undefined', 'globalThis', 'window', 'document',
33
+ ]);
34
+ // Word-boundary, case-sensitive (same idea as the assertFunctionPatterns in
35
+ // eslint-runner.ts): `checkout`, `toISOString`, `toLowerCase` or `shop.checkout()`
36
+ // are not assertions, `checkFoo`, `assert_ok` and `toBeVisible` are.
37
+ const ASSERTIONISH_PREFIX = /^(assert|verify|check|validate|expect|ensure|should|confirm)([A-Z_0-9]|$)/;
38
+ const ASSERTIONISH_TO = /^to(Be|Have|Contain|Match|Equal|Throw)/;
39
+ /** JS / Node built-ins: `new Date().toISOString()` is never a page-object call. */
40
+ const BUILTIN_CLASSES = new Set([
41
+ 'Date', 'Map', 'Set', 'WeakMap', 'WeakSet', 'URL', 'URLSearchParams', 'Array', 'Error', 'TypeError',
42
+ 'RangeError', 'RegExp', 'Promise', 'Object', 'String', 'Number', 'Boolean', 'Buffer', 'Uint8Array',
43
+ 'ArrayBuffer', 'Intl', 'AbortController', 'TextEncoder', 'TextDecoder', 'FormData', 'Headers', 'Blob',
44
+ ]);
45
+ /**
46
+ * Only a method whose NAME reads like an assertion may be a hidden one:
47
+ * `app.waitForLoad()` or `users.list()` on an unreadable object is just an
48
+ * unverified call, and the test stays an expect-expect error. A name that is
49
+ * a Playwright API verb (`check`, `fill`, `goto`) is an action whatever the
50
+ * receiver: `loginPage.rememberMe.check()` is a locator held in a property.
51
+ */
52
+ function isAssertionLikeCall(name) {
53
+ if (!ASSERTIONISH_PREFIX.test(name) && !ASSERTIONISH_TO.test(name))
54
+ return false;
55
+ return !PLAYWRIGHT_API_METHODS.has(name);
56
+ }
57
+ function walk(node, visit, parent) {
58
+ if (!node || typeof node !== 'object')
59
+ return;
60
+ if (Array.isArray(node)) {
61
+ for (const item of node)
62
+ walk(item, visit, parent);
63
+ return;
64
+ }
65
+ if (typeof node.type !== 'string')
66
+ return;
67
+ visit(node, parent);
68
+ for (const key of Object.keys(node)) {
69
+ if (key === 'parent')
70
+ continue;
71
+ const v = node[key];
72
+ if (v && typeof v === 'object')
73
+ walk(v, visit, node);
74
+ }
75
+ }
76
+ function parse(astCache, source, file) {
77
+ const hit = astCache.get(file);
78
+ if (hit && hit.source === source)
79
+ return hit.ast;
80
+ let ast;
81
+ try {
82
+ ast = parseForESLint(source, {
83
+ ecmaVersion: 2022,
84
+ sourceType: 'module',
85
+ ecmaFeatures: { jsx: /\.(tsx|jsx)$/.test(file) },
86
+ }).ast;
87
+ }
88
+ catch {
89
+ ast = undefined;
90
+ }
91
+ astCache.set(file, { source, ast });
92
+ return ast;
93
+ }
94
+ function isExpectCall(n) {
95
+ if (n?.type !== 'CallExpression')
96
+ return false;
97
+ const c = n.callee;
98
+ if (c?.type === 'Identifier' && c.name === 'expect')
99
+ return true;
100
+ return c?.type === 'MemberExpression' && c.object?.type === 'Identifier' && c.object.name === 'expect';
101
+ }
102
+ function containsExpect(node) {
103
+ let found = false;
104
+ walk(node, (n) => {
105
+ if (!found && isExpectCall(n))
106
+ found = true;
107
+ });
108
+ return found;
109
+ }
110
+ function isFn(n) {
111
+ return n?.type === 'ArrowFunctionExpression' || n?.type === 'FunctionExpression';
112
+ }
113
+ function propKeyName(p) {
114
+ if (p?.type !== 'Property' && p?.type !== 'ObjectProperty')
115
+ return undefined;
116
+ if (p.key?.type === 'Identifier' && !p.computed)
117
+ return p.key.name;
118
+ if (p.key?.type === 'Literal' && typeof p.key.value === 'string')
119
+ return p.key.value;
120
+ return undefined;
121
+ }
122
+ /** Does the fixture function hand a function containing expect(...) to use(...)? */
123
+ function usesAssertingCallable(fixtureFn) {
124
+ const p1 = fixtureFn.params?.[1];
125
+ const useName = p1?.type === 'Identifier' ? p1.name : 'use';
126
+ let found = false;
127
+ walk(fixtureFn.body, (n) => {
128
+ if (found || n.type !== 'CallExpression' || n.callee?.type !== 'Identifier' || n.callee.name !== useName)
129
+ return;
130
+ if ((n.arguments ?? []).some((a) => isFn(a) && containsExpect(a)))
131
+ found = true;
132
+ });
133
+ return found;
134
+ }
135
+ function gather(ctx, cache) {
136
+ const defined = new Set();
137
+ const assertingFixtures = new Map();
138
+ const fixtureKeys = new Map();
139
+ const all = [...ctx.sources, ...ctx.importedFiles];
140
+ for (const [file, source] of all) {
141
+ const ast = parse(cache, source, file);
142
+ if (!ast)
143
+ continue;
144
+ walk(ast, (n) => {
145
+ if (n.type === 'FunctionDeclaration' && n.id?.name)
146
+ defined.add(n.id.name);
147
+ else if (n.type === 'ClassDeclaration' && n.id?.name)
148
+ defined.add(n.id.name);
149
+ else if (n.type === 'VariableDeclarator' && n.id?.type === 'Identifier' && isFn(n.init)) {
150
+ defined.add(n.id.name);
151
+ }
152
+ else if (n.type === 'MethodDefinition' && !n.computed && n.key?.type === 'Identifier') {
153
+ defined.add(n.key.name);
154
+ }
155
+ else if (n.type === 'PropertyDefinition' && !n.computed && n.key?.type === 'Identifier' && isFn(n.value)) {
156
+ defined.add(n.key.name);
157
+ }
158
+ else if (n.type === 'Property' && isFn(n.value)) {
159
+ const k = propKeyName(n);
160
+ if (k)
161
+ defined.add(k);
162
+ }
163
+ else if (n.type === 'CallExpression' &&
164
+ n.callee?.type === 'MemberExpression' &&
165
+ n.callee.property?.name === 'extend' &&
166
+ n.arguments?.[0]?.type === 'ObjectExpression') {
167
+ for (const p of n.arguments[0].properties ?? []) {
168
+ const k = propKeyName(p);
169
+ if (k) {
170
+ if (!fixtureKeys.has(file))
171
+ fixtureKeys.set(file, new Set());
172
+ fixtureKeys.get(file).add(k);
173
+ }
174
+ if (!k || NEUTRAL_ROOTS.has(k) || !isFn(p.value) || !usesAssertingCallable(p.value))
175
+ continue;
176
+ if (!assertingFixtures.has(file))
177
+ assertingFixtures.set(file, new Set());
178
+ assertingFixtures.get(file).add(k);
179
+ }
180
+ }
181
+ });
182
+ }
183
+ return { defined, assertingFixtures, fixtureKeys, cache };
184
+ }
185
+ /** Names bound by an identifier, object or array pattern. */
186
+ function boundNames(pat, out = []) {
187
+ if (!pat)
188
+ return out;
189
+ if (pat.type === 'Identifier')
190
+ out.push(pat.name);
191
+ else if (pat.type === 'ObjectPattern')
192
+ for (const p of pat.properties ?? [])
193
+ boundNames(p.type === 'RestElement' ? p.argument : p.value, out);
194
+ else if (pat.type === 'ArrayPattern')
195
+ for (const e of pat.elements ?? [])
196
+ boundNames(e, out);
197
+ else if (pat.type === 'AssignmentPattern')
198
+ boundNames(pat.left, out);
199
+ else if (pat.type === 'RestElement')
200
+ boundNames(pat.argument, out);
201
+ return out;
202
+ }
203
+ /** Root identifier / expression of a call or member chain. */
204
+ function chainRoot(node) {
205
+ let cur = node;
206
+ for (let i = 0; i < 64 && cur; i++) {
207
+ if (cur.type === 'AwaitExpression')
208
+ cur = cur.argument;
209
+ else if (cur.type === 'TSNonNullExpression' || cur.type === 'ChainExpression')
210
+ cur = cur.expression;
211
+ else if (cur.type === 'TSAsExpression' || cur.type === 'TSTypeAssertion')
212
+ cur = cur.expression;
213
+ else if (cur.type === 'CallExpression')
214
+ cur = cur.callee;
215
+ else if (cur.type === 'MemberExpression')
216
+ cur = cur.object;
217
+ else
218
+ break;
219
+ }
220
+ return cur;
221
+ }
222
+ function importKind(specifier) {
223
+ if (specifier.startsWith('.') || /^(@\/|~\/|#|src\/|tests?\/|e2e\/|pages\/|fixtures\/|utils?\/|lib\/)/.test(specifier)) {
224
+ return 'external';
225
+ }
226
+ return 'lib';
227
+ }
228
+ /**
229
+ * Calls inside a test callback whose receiver lives somewhere this scan
230
+ * cannot read: names of those calls (empty when every call is plain
231
+ * Playwright API, a same-file/readable helper, or a utility library).
232
+ */
233
+ function unresolvedCalls(testCall, ast, know) {
234
+ const fn = [...(testCall.arguments ?? [])].reverse().find(isFn);
235
+ if (!fn)
236
+ return [];
237
+ const imports = new Map();
238
+ for (const stmt of ast.body ?? []) {
239
+ if (stmt.type !== 'ImportDeclaration' || typeof stmt.source?.value !== 'string')
240
+ continue;
241
+ for (const s of stmt.specifiers ?? []) {
242
+ if (s.local?.name)
243
+ imports.set(s.local.name, importKind(stmt.source.value));
244
+ }
245
+ }
246
+ const kinds = new Map();
247
+ const first = fn.params?.[0];
248
+ if (first?.type === 'ObjectPattern') {
249
+ for (const p of first.properties ?? []) {
250
+ const local = p.value?.type === 'AssignmentPattern' ? p.value.left : p.value;
251
+ const name = local?.type === 'Identifier' ? local.name : undefined;
252
+ if (name)
253
+ kinds.set(name, NEUTRAL_ROOTS.has(propKeyName(p) ?? name) ? 'neutral' : 'external');
254
+ }
255
+ }
256
+ const rootKind = (root) => {
257
+ if (root?.type === 'NewExpression') {
258
+ const cls = root.callee?.name;
259
+ if (cls && (BUILTIN_CLASSES.has(cls) || NEUTRAL_ROOTS.has(cls)))
260
+ return 'neutral';
261
+ return cls && know.defined.has(cls) ? 'neutral' : 'external';
262
+ }
263
+ if (root?.type !== 'Identifier')
264
+ return 'neutral';
265
+ const local = kinds.get(root.name);
266
+ if (local)
267
+ return local;
268
+ if (NEUTRAL_ROOTS.has(root.name))
269
+ return 'neutral';
270
+ return imports.get(root.name) ?? 'neutral';
271
+ };
272
+ const unresolved = [];
273
+ walk(fn.body, (n) => {
274
+ if (n.type === 'VariableDeclarator' && n.init) {
275
+ // `const x = ...` and destructuring (`const { a, b: [c] } = await ...`):
276
+ // every bound name takes the kind of the initializer's root.
277
+ const kind = rootKind(chainRoot(n.init));
278
+ for (const bound of boundNames(n.id))
279
+ kinds.set(bound, kind);
280
+ return;
281
+ }
282
+ if (n.type !== 'CallExpression')
283
+ return;
284
+ const c = n.callee;
285
+ let name;
286
+ if (c?.type === 'Identifier')
287
+ name = c.name;
288
+ else if (c?.type === 'MemberExpression' && !c.computed && c.property?.type === 'Identifier')
289
+ name = c.property.name;
290
+ if (!name || know.defined.has(name))
291
+ return;
292
+ const kind = rootKind(chainRoot(c));
293
+ if ((kind === 'external' || kind === 'lib') && isAssertionLikeCall(name))
294
+ unresolved.push(name);
295
+ });
296
+ return unresolved;
297
+ }
298
+ /** The spec file plus every file reachable from it through relative imports / re-exports. */
299
+ function reachableFiles(spec, ctx, know) {
300
+ const boundary = ctx.importBoundary ?? ctx.filesBase;
301
+ const seen = new Set([spec]);
302
+ const queue = [spec];
303
+ while (queue.length) {
304
+ const file = queue.pop();
305
+ const source = ctx.sources.get(file) ?? ctx.importedFiles.get(file);
306
+ const ast = source === undefined ? undefined : parse(know.cache, source, file);
307
+ for (const stmt of ast?.body ?? []) {
308
+ const spec2 = stmt.source?.value;
309
+ if (typeof spec2 !== 'string' || !spec2.startsWith('.'))
310
+ continue;
311
+ const target = resolveRelativeImport(file, spec2, boundary);
312
+ if (target && !seen.has(target)) {
313
+ seen.add(target);
314
+ queue.push(target);
315
+ }
316
+ }
317
+ }
318
+ return seen;
319
+ }
320
+ /** Local names of destructured test args that are asserting fixtures reachable from this spec. */
321
+ function assertingFixtureLocals(testFn, reachable, know) {
322
+ const locals = new Set();
323
+ for (const p of testFn?.params?.[0]?.properties ?? []) {
324
+ const key = propKeyName(p);
325
+ if (!key || NEUTRAL_ROOTS.has(key))
326
+ continue;
327
+ if (![...reachable].some((f) => know.assertingFixtures.get(f)?.has(key)))
328
+ continue;
329
+ const local = p.value?.type === 'AssignmentPattern' ? p.value.left : p.value;
330
+ if (local?.type === 'Identifier')
331
+ locals.add(local.name);
332
+ }
333
+ return locals;
334
+ }
335
+ /**
336
+ * Fixtures the test destructures AND calls (`a11y()`) that no reachable file
337
+ * defines, while its `test` comes from a non-relative module this scan does
338
+ * not resolve (a tsconfig `paths` alias such as `@fx/base`, a workspace
339
+ * package). The fixture may well assert there, so it is "unverified", not
340
+ * "no assertion". Relative imports are followed by reachableFiles instead.
341
+ */
342
+ function unresolvedFixtureCalls(call, ast, fn, reachable, know) {
343
+ const testRoot = chainRoot(call.callee);
344
+ if (testRoot?.type !== 'Identifier')
345
+ return [];
346
+ const fromUnresolvedModule = (ast.body ?? []).some((stmt) => stmt.type === 'ImportDeclaration' &&
347
+ typeof stmt.source?.value === 'string' &&
348
+ !stmt.source.value.startsWith('.') &&
349
+ !/^(@playwright\/|playwright|node:)/.test(stmt.source.value) &&
350
+ (stmt.specifiers ?? []).some((sp) => sp.local?.name === testRoot.name));
351
+ if (!fromUnresolvedModule)
352
+ return [];
353
+ const out = [];
354
+ for (const p of fn?.params?.[0]?.properties ?? []) {
355
+ const key = propKeyName(p);
356
+ if (!key || NEUTRAL_ROOTS.has(key))
357
+ continue;
358
+ if ([...reachable].some((f) => know.fixtureKeys.get(f)?.has(key)))
359
+ continue;
360
+ const local = p.value?.type === 'AssignmentPattern' ? p.value.left : p.value;
361
+ if (local?.type === 'Identifier' && callsAny(fn.body, new Set([local.name])))
362
+ out.push(key);
363
+ }
364
+ return out;
365
+ }
366
+ /** Does the body call `name(...)` directly (the fixture is invoked, not merely destructured)? */
367
+ function callsAny(body, names) {
368
+ let found = false;
369
+ walk(body, (n) => {
370
+ if (!found && n.type === 'CallExpression' && n.callee?.type === 'Identifier' && names.has(n.callee.name))
371
+ found = true;
372
+ });
373
+ return found;
374
+ }
375
+ function findCall(ast, line, col0) {
376
+ let hit;
377
+ walk(ast, (n) => {
378
+ if (!hit && n.type === 'CallExpression' && n.loc?.start.line === line && n.loc.start.column === col0)
379
+ hit = n;
380
+ });
381
+ return hit;
382
+ }
383
+ function findIf(ast, line, col0) {
384
+ let hit;
385
+ walk(ast, (n) => {
386
+ if (!hit && n.type === 'IfStatement' && n.loc?.start.line === line && n.loc.start.column === col0)
387
+ hit = n;
388
+ });
389
+ return hit;
390
+ }
391
+ function isThrowOnly(stmt) {
392
+ if (stmt?.type === 'ThrowStatement')
393
+ return true;
394
+ return stmt?.type === 'BlockStatement' && stmt.body?.length === 1 && stmt.body[0].type === 'ThrowStatement';
395
+ }
396
+ export function triageFindings(findings, ctx) {
397
+ const wants = findings.some((f) => f.rule === 'playwright/expect-expect' ||
398
+ f.rule === 'playwright/no-conditional-in-test' ||
399
+ f.rule === 'playwright/valid-expect');
400
+ if (!wants)
401
+ return findings;
402
+ const cache = new Map(); // per call: nothing leaks between scorePaths runs
403
+ const know = gather(ctx, cache);
404
+ const out = [];
405
+ for (const f of findings) {
406
+ if ((f.rule !== 'playwright/expect-expect' &&
407
+ f.rule !== 'playwright/no-conditional-in-test' &&
408
+ f.rule !== 'playwright/valid-expect') ||
409
+ f.line === undefined ||
410
+ f.column === undefined) {
411
+ out.push(f);
412
+ continue;
413
+ }
414
+ const abs = path.resolve(ctx.filesBase, f.file);
415
+ const source = ctx.sources.get(abs);
416
+ const ast = source === undefined ? undefined : parse(cache, source, abs);
417
+ if (!ast) {
418
+ out.push(f);
419
+ continue;
420
+ }
421
+ if (f.rule === 'playwright/valid-expect') {
422
+ // Upstream treats any callee whose name merely ends in "Expect"
423
+ // (`clickSortableHeaderAndExpect(header, 'ascending')`) as an expect()
424
+ // call missing its matcher. Only the real `expect` is one.
425
+ const node = findCall(ast, f.line, f.column - 1);
426
+ const callee = node?.callee;
427
+ const isRealExpect = callee?.type === 'Identifier'
428
+ ? callee.name === 'expect'
429
+ : callee?.type === 'MemberExpression' && callee.object?.name === 'expect';
430
+ if (node && !isRealExpect)
431
+ continue;
432
+ out.push(f);
433
+ continue;
434
+ }
435
+ if (f.rule === 'playwright/no-conditional-in-test') {
436
+ const node = findIf(ast, f.line, f.column - 1);
437
+ if (node && !node.alternate && isThrowOnly(node.consequent))
438
+ continue; // a guard, not branching
439
+ out.push(f);
440
+ continue;
441
+ }
442
+ const call = findCall(ast, f.line, f.column - 1);
443
+ if (!call) {
444
+ out.push(f);
445
+ continue;
446
+ }
447
+ const fn = [...(call.arguments ?? [])].reverse().find(isFn);
448
+ const reachable = reachableFiles(abs, ctx, know);
449
+ const fixtureLocals = assertingFixtureLocals(fn, reachable, know);
450
+ if (fixtureLocals.size > 0 && callsAny(fn.body, fixtureLocals))
451
+ continue; // a callable fixture that asserts is invoked
452
+ const calls = [...new Set([...unresolvedCalls(call, ast, know), ...unresolvedFixtureCalls(call, ast, fn, reachable, know)])];
453
+ if (calls.length === 0) {
454
+ out.push(f);
455
+ continue;
456
+ }
457
+ out.push({
458
+ ...f,
459
+ rule: ASSERTION_UNVERIFIED_RULE,
460
+ severity: 'info',
461
+ reportOnly: true,
462
+ message: `Needs a human check: no expect(...) in this test body, but it calls ${calls
463
+ .slice(0, 3)
464
+ .map((c) => `${c}()`)
465
+ .join(', ')}${calls.length > 3 ? ', ...' : ''} on an object defined elsewhere (page object, injected fixture or ` +
466
+ 'imported helper) that this scan cannot read, so the assertion may live there. Not counted against the score.',
467
+ });
468
+ }
469
+ return out;
470
+ }
@@ -12,7 +12,8 @@ export function formatMarkdown(result) {
12
12
  lines.push(`| ${k} | ${v} |`);
13
13
  }
14
14
  lines.push('');
15
- lines.push(`Files: ${result.summary.files} · Tests: ${result.summary.tests} · SLOC: ${result.summary.sloc} · Findings: ${result.summary.findings}`);
15
+ lines.push(`Files: ${result.summary.files} · Tests: ${result.summary.tests} · SLOC: ${result.summary.sloc} · Findings: ${result.summary.findings}` +
16
+ (result.summary.unverifiedTests ? ` · Unverified assertions: ${result.summary.unverifiedTests}` : ''));
16
17
  if (result.findings.length) {
17
18
  lines.push('');
18
19
  lines.push('## Findings');
@@ -13,7 +13,8 @@ export function formatText(result) {
13
13
  lines.push(`Summary: files=${result.summary.files} tests=${result.summary.tests} sloc=${result.summary.sloc} ` +
14
14
  `findings=${result.summary.findings} (errors=${result.summary.errors} warnings=${result.summary.warnings}) ` +
15
15
  `locators native/raw=${result.summary.nativeLocators}/${result.summary.rawLocators} ` +
16
- `tests asserting=${result.summary.tests - result.summary.unassertedTests}/${result.summary.tests} ` +
16
+ `tests asserting=${result.summary.tests - result.summary.unassertedTests - result.summary.unverifiedTests}/${result.summary.tests}` +
17
+ `${result.summary.unverifiedTests ? ` (unverified=${result.summary.unverifiedTests})` : ''} ` +
17
18
  `clean tests=${result.summary.cleanTests}/${result.summary.tests}`);
18
19
  if (result.findings.length) {
19
20
  lines.push('');
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { triageFindings } from './finding-triage.js';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { globSync, hasMagic, Ignore } from 'glob';
@@ -407,6 +408,7 @@ function hardFail(profile, threshold, findings, extra = {}, skippedFiles, config
407
408
  nativeLocators: 0,
408
409
  rawLocators: 0,
409
410
  unassertedTests: 0,
411
+ unverifiedTests: 0,
410
412
  cleanTests: 0,
411
413
  ...extra,
412
414
  },
@@ -514,13 +516,13 @@ export async function scorePaths(options) {
514
516
  // below) and this package's own AST checks (getTestSpans/countTests,
515
517
  // base-plugin.ts).
516
518
  const testAliasNames = detectExtendedTestAliases(sources, importedFiles, importBoundary);
517
- const eslintFindings = await runEslint({
519
+ const eslintFindings = triageFindings(await runEslint({
518
520
  files,
519
521
  profile,
520
522
  cwd,
521
523
  assertFunctionNames: [...assertFunctionNames],
522
524
  testAliasNames,
523
- });
525
+ }), { filesBase, sources, importedFiles, importBoundary });
524
526
  let totalSloc = 0;
525
527
  let totalTests = 0;
526
528
  let native = 0;
package/dist/metrics.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parseForESLint } from '@typescript-eslint/parser';
2
2
  import { countSloc } from './sloc.js';
3
+ import { PLAYWRIGHT_API_METHODS } from './playwright-api-names.js';
3
4
  // A positive "this IS a Playwright spec" check turned out to be unsafe:
4
5
  // Playwright's own custom-fixtures pattern (test/expect re-exported from a
5
6
  // project-local module built with base.extend()) is extremely common in
@@ -269,10 +270,25 @@ export function findLocalAssertionHelperNames(source, file) {
269
270
  if (!ast)
270
271
  return [];
271
272
  const candidates = [];
273
+ // `test.extend({ login: async ({page}, use) => {...} })` entries are
274
+ // fixtures, not callable helpers: an expect in their setup says nothing
275
+ // about whoever uses the name, so they never register as helper names
276
+ // (src/finding-triage.ts resolves a fixture that hands `use()` an
277
+ // asserting callable). Parents are visited before children, so marking
278
+ // the entries when the extend call is seen is early enough.
279
+ const fixtureEntries = new Set();
272
280
  walk(ast, (node) => {
273
281
  let name;
274
282
  let body;
275
- if (node.type === 'FunctionDeclaration') {
283
+ if (node.type === 'CallExpression') {
284
+ const callee = node.callee;
285
+ const arg0 = node.arguments?.[0];
286
+ if (callee?.type === 'MemberExpression' && callee.property?.name === 'extend' && arg0?.type === 'ObjectExpression') {
287
+ for (const p of arg0.properties ?? [])
288
+ fixtureEntries.add(p);
289
+ }
290
+ }
291
+ else if (node.type === 'FunctionDeclaration') {
276
292
  const id = node.id;
277
293
  if (id?.type === 'Identifier') {
278
294
  name = id.name;
@@ -288,6 +304,21 @@ export function findLocalAssertionHelperNames(source, file) {
288
304
  body = init;
289
305
  }
290
306
  }
307
+ else if (
308
+ // `{ checkTab: async () => { await expect(...) } }` — an object-literal
309
+ // method (a fixture factory's returned object, a test.extend entry) or
310
+ // a class property holding an arrow function (`check = async () => {}`).
311
+ (node.type === 'Property' || node.type === 'PropertyDefinition') &&
312
+ !node.computed &&
313
+ !fixtureEntries.has(node)) {
314
+ const key = node.key;
315
+ const value = node.value;
316
+ if (key?.type === 'Identifier' &&
317
+ (value?.type === 'ArrowFunctionExpression' || value?.type === 'FunctionExpression')) {
318
+ name = key.name;
319
+ body = value;
320
+ }
321
+ }
291
322
  else if (node.type === 'MethodDefinition') {
292
323
  const key = node.key;
293
324
  if (!node.computed && key?.type === 'Identifier' && node.kind === 'method') {
@@ -295,7 +326,7 @@ export function findLocalAssertionHelperNames(source, file) {
295
326
  body = node.value;
296
327
  }
297
328
  }
298
- if (name && body)
329
+ if (name && body && !PLAYWRIGHT_API_METHODS.has(name))
299
330
  candidates.push({ name, body });
300
331
  });
301
332
  const names = new Set();
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Method names of the Playwright objects a spec calls directly (Page,
3
+ * Locator, FrameLocator, Frame, APIRequestContext, Keyboard, Mouse,
4
+ * Touchscreen, BrowserContext). A user-defined helper that shares one of
5
+ * these names must never be registered as an "assertion helper": upstream
6
+ * `expect-expect` matches the LAST property of any call chain, so a helper
7
+ * `nav.goto` that asserts would make every `page.goto()` count as an
8
+ * assertion. One exported set, used by metrics.ts and finding-triage.ts.
9
+ */
10
+ export declare const PLAYWRIGHT_API_METHODS: ReadonlySet<string>;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Method names of the Playwright objects a spec calls directly (Page,
3
+ * Locator, FrameLocator, Frame, APIRequestContext, Keyboard, Mouse,
4
+ * Touchscreen, BrowserContext). A user-defined helper that shares one of
5
+ * these names must never be registered as an "assertion helper": upstream
6
+ * `expect-expect` matches the LAST property of any call chain, so a helper
7
+ * `nav.goto` that asserts would make every `page.goto()` count as an
8
+ * assertion. One exported set, used by metrics.ts and finding-triage.ts.
9
+ */
10
+ export const PLAYWRIGHT_API_METHODS = new Set([
11
+ // navigation / page
12
+ 'goto', 'goBack', 'goForward', 'reload', 'close', 'bringToFront', 'evaluate', 'evaluateHandle',
13
+ 'evaluateAll', 'setContent', 'content', 'title', 'url', 'screenshot', 'pdf', 'route', 'unroute',
14
+ 'routeFromHAR', 'on', 'once', 'off', 'addInitScript', 'addScriptTag', 'addStyleTag', 'exposeFunction',
15
+ 'exposeBinding', 'pause', 'frame', 'frames', 'mainFrame', 'opener', 'setViewportSize',
16
+ 'setExtraHTTPHeaders', 'emulateMedia', 'dragAndDrop', 'setDefaultTimeout',
17
+ 'setDefaultNavigationTimeout', 'storageState', 'newPage', 'cookies', 'addCookies', 'clearCookies',
18
+ 'grantPermissions', 'clearPermissions', 'setGeolocation', 'setOffline', 'newContext', 'newCDPSession',
19
+ // locators
20
+ 'locator', 'getByRole', 'getByText', 'getByLabel', 'getByPlaceholder', 'getByAltText', 'getByTitle',
21
+ 'getByTestId', 'frameLocator', 'filter', 'and', 'or', 'first', 'last', 'nth', 'all', 'count',
22
+ 'contentFrame', 'elementHandle', 'elementHandles', 'highlight', 'describe',
23
+ // actions
24
+ 'click', 'dblclick', 'tap', 'fill', 'clear', 'type', 'press', 'pressSequentially', 'check', 'uncheck',
25
+ 'setChecked', 'hover', 'focus', 'blur', 'selectOption', 'selectText', 'setInputFiles', 'dispatchEvent',
26
+ 'scrollIntoViewIfNeeded', 'down', 'up', 'insertText', 'move', 'wheel',
27
+ // reads / waits
28
+ 'textContent', 'innerText', 'innerHTML', 'inputValue', 'getAttribute', 'allTextContents',
29
+ 'allInnerTexts', 'isVisible', 'isHidden', 'isEnabled', 'isDisabled', 'isEditable', 'isChecked',
30
+ 'boundingBox', 'ariaSnapshot', 'waitFor', 'waitForSelector', 'waitForTimeout', 'waitForLoadState',
31
+ 'waitForURL', 'waitForEvent', 'waitForFunction', 'waitForRequest', 'waitForResponse',
32
+ 'waitForNavigation',
33
+ // APIRequestContext
34
+ 'get', 'post', 'put', 'patch', 'delete', 'head', 'fetch', 'dispose',
35
+ ]);
package/dist/profiles.js CHANGED
@@ -52,6 +52,8 @@ export const ESLINT_RULE_MAP = {
52
52
  },
53
53
  // Assertions
54
54
  'playwright/expect-expect': { dimension: 'assertions' },
55
+ // Report-only: see finding-triage.ts. A test that calls into code the scan cannot read.
56
+ 'playwright-score/assertion-unverified': { dimension: 'assertions', severityOverride: 'info', reportOnly: true },
55
57
  // No severityOverride: matches eslint-plugin-playwright's own
56
58
  // recommended severity ('error'), set via eslint-runner.ts's rule config.
57
59
  'playwright/prefer-web-first-assertions': { dimension: 'assertions' },
@@ -175,6 +175,64 @@ function callsResolve(fn, resolveName) {
175
175
  call.callee?.type === 'Identifier' &&
176
176
  call.callee.name === resolveName);
177
177
  }
178
+ const LOOP_TYPES = new Set(['ForStatement', 'ForInStatement', 'ForOfStatement', 'WhileStatement', 'DoWhileStatement']);
179
+ /**
180
+ * A retry/backoff pause inside a plain helper function (an API retry loop,
181
+ * `for (...) { try { await call() } catch { await sleep(500) } }`) is not a
182
+ * test waiting on the UI: it paces calls to a service. Only that shape is
183
+ * exempt: the sleep sits inside a catch clause, or inside a loop whose body
184
+ * contains a try/catch, and the enclosing function is *defined* (function
185
+ * declaration, `const f = () => ...`, class method) and is not itself nested
186
+ * in a callback handed to a call (a test, hook, describe or step body). A
187
+ * hand-rolled polling wait (`while (await x.isVisible()) sleep()`), a sleep
188
+ * directly in a test body or hook, a helper declared inside a test callback
189
+ * and a bare `sleep()` helper are all still flagged. `from` is the ancestor
190
+ * index to start climbing from (the Promise executor's own parent).
191
+ */
192
+ function isRetryBackoffInHelper(ancestors, from) {
193
+ let retryShape = false;
194
+ let helperFound = false;
195
+ for (let i = from; i >= 0; i--) {
196
+ const anc = ancestors[i];
197
+ if (!helperFound) {
198
+ if (anc.type === 'CatchClause')
199
+ retryShape = true;
200
+ if (LOOP_TYPES.has(anc.type) && containsTryCatch(anc.body))
201
+ retryShape = true;
202
+ }
203
+ if (!FUNCTION_TYPES.has(anc.type) && anc.type !== 'FunctionDeclaration')
204
+ continue;
205
+ const parent = ancestors[i - 1];
206
+ if (parent?.type === 'CallExpression' || parent?.type === 'NewExpression') {
207
+ // Callback handed to a call (test body, hook, describe, .then, step):
208
+ // either the sleep is not in a free-standing helper at all, or the
209
+ // helper lives inside such a callback. Not exempt either way.
210
+ return false;
211
+ }
212
+ if (helperFound)
213
+ continue; // a function around the helper: keep checking the callback chain
214
+ if (anc.type === 'FunctionDeclaration' ||
215
+ parent?.type === 'MethodDefinition' ||
216
+ parent?.type === 'PropertyDefinition' ||
217
+ (parent?.type === 'VariableDeclarator' && parent.init === anc)) {
218
+ if (!retryShape)
219
+ return false;
220
+ helperFound = true;
221
+ }
222
+ }
223
+ return helperFound;
224
+ }
225
+ function containsTryCatch(root) {
226
+ let found = false;
227
+ walkNode(root, (n) => {
228
+ if (n.type !== 'TryStatement' || !n.handler)
229
+ return;
230
+ // An empty `try {}` is not a retry shape.
231
+ if ((n.block?.body ?? []).length > 0)
232
+ found = true;
233
+ });
234
+ return found;
235
+ }
178
236
  /**
179
237
  * `await new Promise((resolve) => setTimeout(resolve, ms))` is the same
180
238
  * hard-coded sleep as `page.waitForTimeout(ms)` (already flagged, as an
@@ -289,7 +347,7 @@ const noTimerSleep = {
289
347
  firstArg.name === resolveParam.name;
290
348
  const resolveName = resolveParam?.type === 'Identifier' ? resolveParam.name : undefined;
291
349
  const isWrappedResolve = typeof resolveName === 'string' && callsResolve(firstArg, resolveName);
292
- if (isDirectResolve || isWrappedResolve) {
350
+ if ((isDirectResolve || isWrappedResolve) && !isRetryBackoffInHelper(ancestors, i - 1)) {
293
351
  context.report({ node: node, messageId: 'sleep' });
294
352
  }
295
353
  }
@@ -517,8 +575,119 @@ function collectConstBindings(root) {
517
575
  bindings.set(d.id.name, value);
518
576
  }
519
577
  });
578
+ // A `const` array is still mutable: `const errors = []; page.on('pageerror',
579
+ // (e) => errors.push(e)); expect(errors).toEqual([])` is a real assertion
580
+ // (the listener fills the array), not `expect([]).toEqual([])`. Only keep
581
+ // an array binding when every reference to it is the plain subject of an
582
+ // expect(...) call; any other use (method call, passed along, spread,
583
+ // reassigned element) might mutate it, so it is not a constant.
584
+ for (const [name, value] of [...bindings]) {
585
+ if (value.ok && Array.isArray(value.value) && !isOnlyReadAsExpectSubject(root, name)) {
586
+ bindings.delete(name);
587
+ }
588
+ }
520
589
  return bindings;
521
590
  }
591
+ const MUTATING_METHODS = new Set([
592
+ 'push', 'unshift', 'splice', 'pop', 'shift', 'sort', 'reverse', 'fill', 'copyWithin',
593
+ ]);
594
+ /**
595
+ * True when no reference to `name` under `root` could change the array:
596
+ * every reference is a declaration, the subject of expect(...), a `.length`
597
+ * or index read, or the receiver of a non-mutating method. A reference that
598
+ * is a call argument (it may be handed to code that fills it), the
599
+ * receiver of a mutating method, an assignment/update target, or any other
600
+ * use that aliases the array (returned, assigned on, spread, put in an
601
+ * object) counts as possibly mutating, wherever it sits — including inside
602
+ * a closure such as a listener. Same-named properties (`obj.name`,
603
+ * `{ name: 1 }`) are not references. Shadowing is not resolved: a shadowing
604
+ * binding is read like the outer one, which can only keep a finding quiet.
605
+ */
606
+ function isOnlyReadAsExpectSubject(root, name) {
607
+ let ok = true;
608
+ const visit = (node, stack) => {
609
+ if (!ok || !node || typeof node !== 'object')
610
+ return;
611
+ if (Array.isArray(node)) {
612
+ for (const item of node)
613
+ visit(item, stack);
614
+ return;
615
+ }
616
+ const rec = node;
617
+ if (typeof rec.type !== 'string')
618
+ return;
619
+ const n = node;
620
+ if (n.type === 'Identifier' && n.name === name) {
621
+ if (referenceMayMutate(n, stack))
622
+ ok = false;
623
+ return;
624
+ }
625
+ const next = [...stack, n];
626
+ for (const key of Object.keys(rec)) {
627
+ if (key === 'parent')
628
+ continue;
629
+ const v = rec[key];
630
+ if (v && typeof v === 'object')
631
+ visit(v, next);
632
+ }
633
+ };
634
+ visit(root, []);
635
+ return ok;
636
+ }
637
+ /** Is the member expression `m` itself written, deleted or the receiver of a mutating method? */
638
+ function memberMutates(m, above) {
639
+ if (above.type === 'AssignmentExpression' && above.left === m)
640
+ return true;
641
+ if (above.type === 'UpdateExpression')
642
+ return true;
643
+ if (above.type === 'UnaryExpression' && above.operator === 'delete')
644
+ return true;
645
+ const prop = m.property;
646
+ return (!m.computed &&
647
+ prop?.type === 'Identifier' &&
648
+ !!prop.name &&
649
+ MUTATING_METHODS.has(prop.name) &&
650
+ above.type === 'CallExpression' &&
651
+ above.callee === m);
652
+ }
653
+ function referenceMayMutate(id, stack) {
654
+ const parent = stack[stack.length - 1];
655
+ const grand = stack[stack.length - 2];
656
+ if (!parent)
657
+ return true;
658
+ switch (parent.type) {
659
+ case 'VariableDeclarator':
660
+ return parent.id !== id; // `const b = expected` aliases it
661
+ case 'CallExpression':
662
+ if (parent.arguments?.[0] === id && isExpectCallNode(parent))
663
+ return false;
664
+ return parent.callee !== id || false; // an argument, or the array called
665
+ case 'MemberExpression': {
666
+ if (parent.object !== id)
667
+ return false; // `obj.expected`: a property name, not this binding
668
+ // Mutation of the member itself (`a.x = 1`, `a.push(1)`), then, for a
669
+ // computed access chain (`rows[0].push(9)`, `rows[0][1] = 2`), of
670
+ // the element it reaches: the inner array is part of the asserted one.
671
+ let member = parent;
672
+ for (let up = stack.length - 2; up >= 0; up--) {
673
+ const above = stack[up];
674
+ if (memberMutates(member, above))
675
+ return true;
676
+ if (!member.computed || above.type !== 'MemberExpression' || above.object !== member)
677
+ break;
678
+ member = above;
679
+ }
680
+ return false; // `.length`, `[0]`, `.map(...)`, ... are reads
681
+ }
682
+ case 'Property':
683
+ case 'PropertyDefinition':
684
+ // Non-computed key position is a name, not a reference; a value (or
685
+ // shorthand) puts the array inside an object.
686
+ return !((parent.key === id || false) && !parent.computed && !parent.shorthand);
687
+ default:
688
+ return true;
689
+ }
690
+ }
522
691
  /**
523
692
  * Climbs from `node` to the body of the nearest enclosing function (the
524
693
  * test/hook callback in the common case) — the scope `noTrivialAssertion`
@@ -188,6 +188,7 @@ export function computeScore(input) {
188
188
  nativeLocators: input.nativeLocators,
189
189
  rawLocators: input.rawLocators,
190
190
  unassertedTests: input.findings.filter((f) => f.rule === 'playwright/expect-expect' && !f.reportOnly).length,
191
+ unverifiedTests: input.findings.filter((f) => f.rule === 'playwright-score/assertion-unverified').length,
191
192
  cleanTests,
192
193
  },
193
194
  dimensions: dims,
package/dist/types.d.ts CHANGED
@@ -51,6 +51,10 @@ export interface ScoreSummary {
51
51
  /** Tests with no recognized assertion (uncapped playwright/expect-expect
52
52
  * census, after delegation resolution). */
53
53
  unassertedTests: number;
54
+ /** Tests with no expect(...) that call something this scan cannot read
55
+ * (`playwright-score/assertion-unverified`, report-only). They are NOT in
56
+ * `unassertedTests` and not penalised, but they are not known to assert. */
57
+ unverifiedTests: number;
54
58
  /** Tests with no demerit in any dimension — the headline the score is
55
59
  * built from: the weighted share of tests that are clean. */
56
60
  cleanTests: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qaguardian/playwright-score",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Deterministic, AI-free Playwright spec quality score — the weighted share of your tests that are clean.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",