@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 +73 -0
- package/METHODOLOGY.md +1 -1
- package/dist/finding-triage.d.ts +13 -0
- package/dist/finding-triage.js +470 -0
- package/dist/formatters/markdown.js +2 -1
- package/dist/formatters/text.js +2 -1
- package/dist/index.js +4 -2
- package/dist/metrics.js +33 -2
- package/dist/playwright-api-names.d.ts +10 -0
- package/dist/playwright-api-names.js +35 -0
- package/dist/profiles.js +2 -0
- package/dist/rules/base-plugin.js +170 -1
- package/dist/score-engine.js +1 -0
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
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');
|
package/dist/formatters/text.js
CHANGED
|
@@ -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 === '
|
|
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`
|
package/dist/score-engine.js
CHANGED
|
@@ -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