@qaguardian/playwright-score 2.1.0 → 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 +181 -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 +188 -9
- 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/playwright-config.d.ts +26 -0
- package/dist/playwright-config.js +26 -3
- 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,187 @@ 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
|
+
|
|
86
|
+
## 2.1.1 — 2026-09-26
|
|
87
|
+
|
|
88
|
+
Scoring model stays v4. Reviewer follow-ups from the 2.1.0 release review
|
|
89
|
+
(QAG-230) — none of these blocked that release. They close symlink-escape
|
|
90
|
+
gaps a crafted repo could otherwise use, quiet a false warning, and fix a
|
|
91
|
+
false-positive drop the first round of these same fixes introduced. None
|
|
92
|
+
of that changes what an ordinary suite with no symlinked spec files
|
|
93
|
+
scores; the realpath dedupe fix below is the one exception — it can
|
|
94
|
+
change the file/test count for a suite that has a symlinked spec file
|
|
95
|
+
matching discovery alongside its own real target, and says so.
|
|
96
|
+
|
|
97
|
+
### Fixed
|
|
98
|
+
- **A match escaping the *repository* through a symlink is now dropped —
|
|
99
|
+
scoped to the repo root, not the narrower directory the caller pointed
|
|
100
|
+
at.** `testDirEscapesRepo` (added in 2.1.0) only clamps a config's
|
|
101
|
+
`testDir` itself before it becomes a glob root — it couldn't see a
|
|
102
|
+
symlinked directory the glob then walked *through* on the way to an
|
|
103
|
+
individual match. A `testMatch` entry that names a symlinked directory
|
|
104
|
+
by a literal path segment (not a `**` wildcard) is still followed even
|
|
105
|
+
with glob's `follow: false` (that option only stops `**` from
|
|
106
|
+
*expanding into* a symlinked directory, not a pattern from naming one
|
|
107
|
+
explicitly), so a crafted repo (`tests/evil -> /`, `testMatch:
|
|
108
|
+
['evil/**/*.ts']`) could make discovery walk arbitrary parts of the
|
|
109
|
+
filesystem while every match still lexically looked like it lived
|
|
110
|
+
under the scanned directory. The same gap let an individually
|
|
111
|
+
symlinked spec file sitting directly in the repo (no `testMatch`
|
|
112
|
+
trickery needed) resolve to, and get scored from, a file outside it.
|
|
113
|
+
`src/index.ts`'s match loop realpath's every candidate match and drops
|
|
114
|
+
it unless it's still contained within the realpath of the **repository
|
|
115
|
+
root** (`safeRealpath(findRepoBoundary(abs))`) — not, as this package
|
|
116
|
+
first shipped it, the narrower directory the caller actually scanned.
|
|
117
|
+
That first version silently dropped a real, common monorepo shape too:
|
|
118
|
+
a spec reachable through an in-repo symlink that resolves to a sibling
|
|
119
|
+
directory *outside* the caller's scanned subpath (one package's specs
|
|
120
|
+
shared with another via a symlink) is legitimate and is kept; only a
|
|
121
|
+
match whose realpath lands outside the repository itself is dropped.
|
|
122
|
+
The same repo-root containment now also applies to a bare glob-pattern
|
|
123
|
+
input (e.g. `tests/*.spec.ts` passed directly to `scorePaths`, not a
|
|
124
|
+
directory it walks itself) — the first version of this fix only
|
|
125
|
+
covered the directory-scan branch, so switching an otherwise-identical
|
|
126
|
+
call from a directory argument to an equivalent glob argument silently
|
|
127
|
+
bypassed the guard entirely.
|
|
128
|
+
- **The walk itself is now pruned at a symlink that escapes the repo
|
|
129
|
+
root, not just filtered after the fact.** The realpath containment
|
|
130
|
+
check above still has to pay to walk all the way through a symlinked
|
|
131
|
+
directory before dropping every match inside it one by one — for a
|
|
132
|
+
large or slow target (`tests/evil -> /`) that's a real cost, not just
|
|
133
|
+
a theoretical one (measured: ~6.4s to fully walk a large symlinked
|
|
134
|
+
directory before this fix). `RepoBoundaryIgnore` (`src/index.ts`) wraps
|
|
135
|
+
glob's own `ignore` matching with a `childrenIgnored` check that stops
|
|
136
|
+
the walk from ever descending into a symlinked directory whose
|
|
137
|
+
realpath lands outside the repo root, only paying the realpath cost at
|
|
138
|
+
an actual symlink (a plain nested directory can't walk outside a root
|
|
139
|
+
its own parent is already inside, so it's never checked).
|
|
140
|
+
- **An in-repo symlinked spec and its own real target are no longer both
|
|
141
|
+
scored, when both independently match discovery under the same scan.**
|
|
142
|
+
Pre-existing, not new to the fixes above: `expandPaths` deduped a
|
|
143
|
+
literal path only against `explicit` inputs, never against another
|
|
144
|
+
`expanded` match that happens to resolve to the same realpath. A
|
|
145
|
+
symlink sitting alongside its target inside the same scanned directory
|
|
146
|
+
(or reachable via two different scan roots in one `scorePaths` call)
|
|
147
|
+
was counted, and scored, twice. Now deduped by realpath, keeping the
|
|
148
|
+
first lexical path in sorted order. **This can change file/test counts
|
|
149
|
+
for a repo that has such a link** — said plainly here rather than
|
|
150
|
+
folded into "doesn't change what a correctly-shaped suite scores",
|
|
151
|
+
since a suite with this exact shape is exactly what changes.
|
|
152
|
+
- **False "testDir resolves outside the repository" warning on a
|
|
153
|
+
nonexistent `testDir` under a symlinked repo path.** `safeRealpath`
|
|
154
|
+
only resolves the portion of a path that exists on disk, so a
|
|
155
|
+
nonexistent `testDir` kept its literal, unresolved form while
|
|
156
|
+
`repoRoot` (which does exist) got fully resolved — on a host where the
|
|
157
|
+
repo itself sits under a symlinked prefix (macOS: `/tmp` ->
|
|
158
|
+
`/private/tmp`), that asymmetry alone made the two sides disagree and
|
|
159
|
+
fired a false escape warning for an ordinary missing `testDir`.
|
|
160
|
+
`testDirEscapesRepo` now checks existence first and returns "does not
|
|
161
|
+
escape" immediately for a `testDir` that isn't on disk — consistent
|
|
162
|
+
with `resolveConfigScopedRoot`'s own existing "nothing to score there"
|
|
163
|
+
handling for the same case.
|
|
164
|
+
- **Escape warning now prints cwd-relative paths**, matching every other
|
|
165
|
+
`configWarning` in this package, instead of the runner's own absolute
|
|
166
|
+
filesystem paths.
|
|
167
|
+
- **A raw glob-pattern input pointing at a different repo than `cwd` no
|
|
168
|
+
longer hard-fails with 0 matches.** The repo-root containment fix above
|
|
169
|
+
used `cwd`'s own repo boundary for a bare glob pattern regardless of
|
|
170
|
+
where the pattern actually pointed — an absolute pattern into a sibling
|
|
171
|
+
repo (`['/abs/repoB/e2e/*.spec.ts']`), or an equivalent `../`-relative
|
|
172
|
+
one, had every match rejected as an "escape" of `cwd`'s repo, where the
|
|
173
|
+
same target passed as a directory already scored fine. `src/index.ts`'s
|
|
174
|
+
glob-input branch now derives its containment boundary from *the
|
|
175
|
+
pattern's own* literal (non-magic) leading-directory prefix, resolved
|
|
176
|
+
against `cwd`, not from `cwd` itself — a literal prefix that lands
|
|
177
|
+
inside another repo entirely is scored against that repo's own
|
|
178
|
+
boundary, while a symlink an in-bounds prefix still reaches that escapes
|
|
179
|
+
*that* repo is still dropped.
|
|
180
|
+
- **An explicit target and a directory scan that reaches it again through
|
|
181
|
+
a symlink are no longer scored twice.** The realpath dedupe added above
|
|
182
|
+
only ever populated its map from `expanded` matches; an `explicit` file
|
|
183
|
+
and a same-realpath `expanded` symlink are two different literal paths,
|
|
184
|
+
so the separate "explicit wins" step (an exact literal-path delete)
|
|
185
|
+
never caught that pairing. The dedupe map is now seeded with
|
|
186
|
+
`explicit`'s own realpaths first, so a same-realpath `expanded` match is
|
|
187
|
+
recognized as a duplicate instead of surviving as a second, independent
|
|
188
|
+
file.
|
|
189
|
+
|
|
190
|
+
Corpus rerun (100 public repos, directory inputs, run on the round-2 build;
|
|
191
|
+
the round-3 fixes only touch glob-pattern inputs): 96/100 repos score
|
|
192
|
+
identically, 4 moved upstream, 0 changed.
|
|
193
|
+
|
|
13
194
|
## 2.1.0 — 2026-09-24
|
|
14
195
|
|
|
15
196
|
Scoring model stays v4 — nothing here changes a rule's weight or
|
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[];
|