@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 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[];