@qaguardian/playwright-score 2.1.1 → 2.3.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 +159 -0
- package/METHODOLOGY.md +42 -1
- package/README.md +1 -1
- package/dist/cli.js +61 -5
- package/dist/confidence.d.ts +30 -0
- package/dist/confidence.js +67 -0
- package/dist/eslint-runner.js +18 -0
- package/dist/finding-triage.d.ts +13 -0
- package/dist/finding-triage.js +470 -0
- package/dist/formatters/json.js +4 -1
- package/dist/formatters/markdown.d.ts +2 -0
- package/dist/formatters/markdown.js +17 -5
- package/dist/formatters/report-css.d.ts +2 -0
- package/dist/formatters/report-css.js +68 -0
- package/dist/formatters/report.d.ts +20 -0
- package/dist/formatters/report.js +279 -0
- package/dist/formatters/text.d.ts +6 -1
- package/dist/formatters/text.js +73 -27
- package/dist/index.js +21 -8
- 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 +14 -0
- package/dist/report-model.d.ts +52 -0
- package/dist/report-model.js +162 -0
- package/dist/report-source.d.ts +11 -0
- package/dist/report-source.js +96 -0
- package/dist/rule-copy.d.ts +14 -0
- package/dist/rule-copy.js +215 -0
- package/dist/rules/ast-util.d.ts +60 -0
- package/dist/rules/ast-util.js +140 -0
- package/dist/rules/base-plugin.d.ts +19 -5
- package/dist/rules/base-plugin.js +245 -8
- package/dist/rules/no-brittle-selector.d.ts +3 -0
- package/dist/rules/no-brittle-selector.js +150 -0
- package/dist/rules/no-excessive-timeout.d.ts +2 -0
- package/dist/rules/no-excessive-timeout.js +87 -0
- package/dist/rules/no-fixme-test.d.ts +14 -0
- package/dist/rules/no-fixme-test.js +53 -0
- package/dist/rules/no-self-fulfilling-fallback.d.ts +29 -0
- package/dist/rules/no-self-fulfilling-fallback.js +221 -0
- package/dist/rules/no-shared-mutable-state.d.ts +4 -0
- package/dist/rules/no-shared-mutable-state.js +186 -0
- package/dist/rules/no-swallowed-assertion.d.ts +13 -0
- package/dist/rules/no-swallowed-assertion.js +113 -0
- package/dist/rules/no-unawaited-action.d.ts +2 -0
- package/dist/rules/no-unawaited-action.js +77 -0
- package/dist/rules/no-unawaited-expect-value.d.ts +20 -0
- package/dist/rules/no-unawaited-expect-value.js +46 -0
- package/dist/rules/no-weak-assertion.d.ts +19 -0
- package/dist/rules/no-weak-assertion.js +82 -0
- package/dist/rules/rule-util.d.ts +56 -0
- package/dist/rules/rule-util.js +253 -0
- package/dist/score-engine.d.ts +5 -0
- package/dist/score-engine.js +10 -9
- package/dist/suite-name.d.ts +11 -0
- package/dist/suite-name.js +36 -0
- package/dist/test-aliases.js +144 -3
- package/dist/types.d.ts +18 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,165 @@ 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.3.0 — 2026-11-14
|
|
14
|
+
|
|
15
|
+
Scoring weights and the scoring model (v4) are unchanged, but **scores can
|
|
16
|
+
fall**: this release adds ten rules that catch tests which would miss a real
|
|
17
|
+
bug or flake, and suites that use those patterns now lose points. Nothing
|
|
18
|
+
gets more lenient. Two of the new rules are report-only (shown in the report,
|
|
19
|
+
no effect on the score): `pwscore/no-self-fulfilling-fallback` (info, no real
|
|
20
|
+
suite hit it yet, validated on fixtures only) and `pwscore/no-brittle-selector`
|
|
21
|
+
(its cost is already counted by the raw-locator ratio), plus
|
|
22
|
+
`pwscore/serial-mode-dependency` (info).
|
|
23
|
+
|
|
24
|
+
New rules, flake risk (QAG-287 slice 2b):
|
|
25
|
+
|
|
26
|
+
- `pwscore/no-unawaited-action` (error, hygiene): Playwright action or navigation on a Page/Locator as a bare statement without `await`.
|
|
27
|
+
- `pwscore/no-brittle-selector` (warning, report-only): XPath, `:nth-child`, 4+ combinator CSS chains, generated class names, positional `.first()/.nth()/.last()` on a raw `.locator()`.
|
|
28
|
+
- `pwscore/no-shared-mutable-state` (warning, structure) and report-only `pwscore/serial-mode-dependency` (info): state written in one test and read in another. Not flagged when the writing and the reading test both sit in a serial describe (`test.describe.serial` or `test.describe.configure({ mode: 'serial' })`, at file or describe level), wherever the variable is declared; flagged when either test is outside serial scope. Plus a report-only note for serial describe blocks.
|
|
29
|
+
- `pwscore/no-excessive-timeout` (warning, hygiene): option timeouts over 60s (exactly 60s is not flagged), `test.setTimeout` >= 5 min, unconditional `test.slow()`.
|
|
30
|
+
|
|
31
|
+
New rules, tests that would miss a real bug (QAG-287 slice 2a):
|
|
32
|
+
|
|
33
|
+
- `pwscore/no-fixme-test` (structure, warning): unconditional `test.fixme('t', fn)`, `test.describe.fixme(...)`, in-body `test.fixme()` and `test.fixme(true, ...)`. `test.fixme(condition, reason)` stays clean.
|
|
34
|
+
- `pwscore/no-swallowed-assertion` (assertions, warning): `expect(...)` inside a `try` whose `catch` neither rethrows, asserts, fails, returns a value, records state nor calls `.skip(...)`; and `await expect(...).catch(() => {})` with the result discarded. Retry loops are not flagged. Warning, not error, because some real suites use deliberate soft checks.
|
|
35
|
+
- `pwscore/no-weak-assertion` (assertions, error): `expect(count).toBeGreaterThanOrEqual(0)`, `expect(<Locator>).toBeTruthy()/toBeDefined()/not.toBeNull()`, `expect(await x.isVisible()).toBeDefined()`.
|
|
36
|
+
- `pwscore/no-unawaited-expect-value` (assertions, error): a Promise-returning Playwright call such as `page.title()` or `locator.innerText()` used without `await` inside `expect()` with a non-web-first matcher. Replaces the misleading `playwright/prefer-web-first-assertions` message on the same line.
|
|
37
|
+
- `pwscore/no-self-fulfilling-fallback` (assertions, info, report-only): a negated state check (or a `catch` of a failed expect) whose fallback fills in or selects the value itself (click-only fallbacks are setup and stay clean), followed by an assertion on the same locator.
|
|
38
|
+
|
|
39
|
+
Fix (test-factory recognition): tests declared through a factory function (`const t = testWithConfig({...}); t('x', ...)`, in the same or an imported file) are now recognised as tests, removing false `playwright/no-standalone-expect` errors.
|
|
40
|
+
|
|
41
|
+
New report format (QAG-288), output only:
|
|
42
|
+
|
|
43
|
+
- New `--format report`: a one-page, self-contained HTML suite report
|
|
44
|
+
(headline, dimension bars, top findings with code excerpts, flake risk,
|
|
45
|
+
items needing a human check, next steps). Light/dark, phone width, prints
|
|
46
|
+
to one or two pages. All source and paths are HTML-escaped. `--output` is
|
|
47
|
+
accepted as an alias of `--out`. New `--name <suite name>` sets the report
|
|
48
|
+
headline. Without it the label is the last two path segments (three when the
|
|
49
|
+
folder is generic, e.g. `e2e` or `tests`); it never includes a home directory
|
|
50
|
+
or anything above it, and a home directory itself is labelled "Playwright suite".
|
|
51
|
+
- Findings are ranked with a display-only confidence tier (high / medium /
|
|
52
|
+
needs-check) and every rule has a plain-English title. The text and
|
|
53
|
+
markdown formats list findings by severity, then confidence, instead of
|
|
54
|
+
file order.
|
|
55
|
+
- The report states how many tests need a human check (the report-only
|
|
56
|
+
`playwright-score/assertion-unverified` findings, kept out of the score),
|
|
57
|
+
groups them by file, and reads "why it matters" in business terms. A suite
|
|
58
|
+
hard-failed by an unparseable file says so in the headline.
|
|
59
|
+
- Results gain an optional in-memory `baseDir`, used only by the report to
|
|
60
|
+
read code excerpts. It is never printed and is not part of the JSON, SARIF,
|
|
61
|
+
markdown or HTML output. A rule without plain-English copy renders with a
|
|
62
|
+
generic title instead of failing.
|
|
63
|
+
|
|
64
|
+
Report and terminal polish (QAG-288 slice 3b), output only, scores unchanged:
|
|
65
|
+
|
|
66
|
+
- The HTML report is now a Summary page plus a Details part. The Summary
|
|
67
|
+
(score, score by area, top 3 findings as rows without code, next steps) prints
|
|
68
|
+
on one A4 or Letter page; Details (code excerpts, findings 4-5, the
|
|
69
|
+
human-check list) starts on page 2. The duplicate "Flake risk" table is gone:
|
|
70
|
+
each top finding names the file with the most occurrences, and Details lists
|
|
71
|
+
the most flake-prone files.
|
|
72
|
+
- A line under the score explains it (weighted average of four areas, the
|
|
73
|
+
grade bands, the pass bar; "your bar" when `--threshold` was passed). "clean"
|
|
74
|
+
became "have no issues".
|
|
75
|
+
- Confidence reads Definite / Likely / Needs a human check, with one legend
|
|
76
|
+
line and neutral tags (fixes the 3.95:1 contrast failure of the amber tag).
|
|
77
|
+
The four areas are named Reliability, Checks, Resilience to UI changes and
|
|
78
|
+
Maintainability (the JSON keys are unchanged).
|
|
79
|
+
- A suite with a syntax error or no tests shows "Not scored" instead of
|
|
80
|
+
"0/100 F", with no zero bars and consistent counts.
|
|
81
|
+
- Rule copy says "can" instead of "will" for outcomes static analysis cannot
|
|
82
|
+
prove. The weakest area gets a slot in the top 3 when it scores under 75.
|
|
83
|
+
- A rule that throws while scoring no longer vanishes silently: the run still
|
|
84
|
+
finishes, the CLI prints `warning: rule <id> crashed on <n> node(s); results
|
|
85
|
+
may be incomplete` to stderr, the text, markdown and report formats say "Some
|
|
86
|
+
checks could not run on this suite; results may be incomplete.", and the JSON
|
|
87
|
+
result gains `ruleCrashes: [{ rule, count }]`, only when a rule crashed (JSON
|
|
88
|
+
of a clean run is unchanged).
|
|
89
|
+
- A suite with no tests no longer lists the working directory as an issue, and
|
|
90
|
+
it is "Not scored" rather than "0/100 F".
|
|
91
|
+
- Without `--name`, the suite name falls back to the last two or three path
|
|
92
|
+
segments for a folder (never above the home directory), so
|
|
93
|
+
`e2e-tests/playwright` is no longer just `playwright`.
|
|
94
|
+
- Terminal output (`--format text`) is grouped by rule, worst first, with a
|
|
95
|
+
count per rule, relative paths, at most three locations per rule (`+ N more`)
|
|
96
|
+
and at most ten rules; it opens with the verdict and repeats it on the last
|
|
97
|
+
line. `--format json`, `sarif` and `markdown` are byte-identical to before.
|
|
98
|
+
|
|
99
|
+
## 2.2.0 — 2026-11-14
|
|
100
|
+
|
|
101
|
+
False-positive fixes found by hand-checking error-level findings on public
|
|
102
|
+
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
|
|
103
|
+
is not a new scoring model (still v4), but **scores can rise**: findings
|
|
104
|
+
that were wrong are no longer reported. Nothing gets stricter.
|
|
105
|
+
|
|
106
|
+
- `playwright/expect-expect`: a test that asserts through a fixture
|
|
107
|
+
function injected via the test args is no longer reported as having no
|
|
108
|
+
assertion, but only when the fixture is a callable that really asserts:
|
|
109
|
+
a `test.extend({ check: async ({ page }, use) => { await use(async () =>
|
|
110
|
+
{ await expect(...) }) } })` entry whose function handed to `use(...)`
|
|
111
|
+
runs `expect`, the test destructures it AND calls it, and the fixtures
|
|
112
|
+
module is reachable from that spec through relative imports. An `expect`
|
|
113
|
+
in a fixture's setup or teardown does not count, nor does any fixture
|
|
114
|
+
named like a built-in one (`page`, `context`, `request`, ...). A test
|
|
115
|
+
also counts as asserting through an object-literal method / class-property
|
|
116
|
+
arrow function in a file it imports (a fixture factory's returned object,
|
|
117
|
+
a page object), except that a helper whose name collides with a Playwright
|
|
118
|
+
method (`goto`, `click`, `fill`, `check`, `get`, `post`, ...) is never
|
|
119
|
+
treated as an assertion helper, so it cannot turn every `page.goto()` into
|
|
120
|
+
an assertion. When a test has no `expect` of its own but calls an
|
|
121
|
+
assertion-named method (`assert`, `verify`, `check`, `validate`, `expect`,
|
|
122
|
+
`ensure`, `should`, `confirm` followed by an upper-case letter, `_`, a digit
|
|
123
|
+
or nothing, such as `checkTotals` but not `checkout`; or `toBe*`, `toHave*`,
|
|
124
|
+
`toContain*`, `toMatch*`, `toEqual*`, `toThrow*`) on an object this scan
|
|
125
|
+
cannot read (an injected fixture from another package, an unresolvable
|
|
126
|
+
import, a third-party package), it is no longer an error: it is reported
|
|
127
|
+
as the report-only finding `playwright-score/assertion-unverified`
|
|
128
|
+
("needs a human check"), which does not lower the score and is not
|
|
129
|
+
counted in `summary.unassertedTests`. Those tests are counted separately
|
|
130
|
+
in `summary.unverifiedTests`, a new field that is always present in the
|
|
131
|
+
JSON output (`0` when nothing is unverified; the text and Markdown reports
|
|
132
|
+
show those tests as unverified, never as asserting): they are not known to
|
|
133
|
+
assert. A fixture a test calls, whose `test` comes from a module the scan
|
|
134
|
+
does not resolve (a tsconfig `paths` alias, a workspace package), is
|
|
135
|
+
unverified the same way. Names bound by destructuring
|
|
136
|
+
(`const { channelsPage } = await pw.login(user)`) take the kind of the
|
|
137
|
+
expression they come from. Query-style names (`is...`, `has...`,
|
|
138
|
+
`wait...`), helper methods named like Playwright actions (`fill`, `check`,
|
|
139
|
+
`goto`, ...), built-in calls (`new Date().toISOString()`,
|
|
140
|
+
`user.email.toLowerCase()`, `x.toString()`), `shop.checkout()` and
|
|
141
|
+
`loginPage.rememberMe.check()` are not treated as assertions: a call whose
|
|
142
|
+
name is not assertion-like, a test that only calls Playwright API on
|
|
143
|
+
`page`/`request`/..., and helpers whose body we can read and which do not
|
|
144
|
+
assert are still `expect-expect` errors.
|
|
145
|
+
- `pwscore/no-trivial-assertion`: `expect(pageErrors).toEqual([])` where
|
|
146
|
+
`pageErrors` is a `const` array that may be mutated (passed to a call as
|
|
147
|
+
an argument, the receiver of `push`/`pop`/`splice`/`sort`/..., an
|
|
148
|
+
assignment target, aliased, or captured by a closure that does one of
|
|
149
|
+
those, such as a listener, or an inner array reached through an index:
|
|
150
|
+
`rows[0].push(9)`) is no longer flagged. A `.length` or index
|
|
151
|
+
read, a non-mutating method such as `.map()`, and an unrelated property
|
|
152
|
+
with the same name (`obj.expected`) do not count as mutation; literals
|
|
153
|
+
and a `const` array that is only read are still flagged.
|
|
154
|
+
- `pwscore/no-timer-sleep`: a retry/backoff pause inside a plain helper
|
|
155
|
+
function (declaration, `const f = () =>`, class method) is no longer
|
|
156
|
+
flagged, but only when the sleep sits in a `catch` clause or in a loop
|
|
157
|
+
whose body contains a non-empty `try` with a `catch`, and the helper is not declared inside
|
|
158
|
+
a test, hook, describe or step callback. Sleeps directly in a test body or
|
|
159
|
+
hook, any loop without `try`/`catch` (including a hand-rolled
|
|
160
|
+
`while (await x.isVisible()) sleep()` polling wait), helpers declared
|
|
161
|
+
inside a test callback, and bare `sleep()` helpers are still flagged.
|
|
162
|
+
- `playwright/no-conditional-in-test`: a guard whose only body is `throw`
|
|
163
|
+
(`if (!id) throw new Error(...)`, no `else`) is no longer flagged.
|
|
164
|
+
- `playwright/valid-expect`: a helper whose name merely ends in `Expect`
|
|
165
|
+
(`clickSortableHeaderAndExpect(...)`) is no longer reported as an
|
|
166
|
+
`expect()` without a matcher.
|
|
167
|
+
- Known and not fixed: `playwright/no-standalone-expect` on tests declared
|
|
168
|
+
through a factory function that returns a `test.extend(...)` result
|
|
169
|
+
(`const t = testWithConfig({...}); t('name', ...)`). The alias detection
|
|
170
|
+
only follows direct `test.extend` chains.
|
|
171
|
+
|
|
13
172
|
## 2.1.1 — 2026-09-26
|
|
14
173
|
|
|
15
174
|
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
|
|
|
@@ -126,6 +126,20 @@ The exact ESLint rules that produce findings are enabled explicitly in
|
|
|
126
126
|
eslint-plugin-playwright's `recommended` set at install time, so identical
|
|
127
127
|
code scores identically regardless of which plugin version npm resolves.
|
|
128
128
|
|
|
129
|
+
### Tests that would miss a real bug
|
|
130
|
+
|
|
131
|
+
Five rules (QAG-287) target tests that pass whatever the app does. Each
|
|
132
|
+
matches one exact syntactic shape and stays silent when unsure.
|
|
133
|
+
|
|
134
|
+
| Rule | Dimension, severity | Exact shape |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `pwscore/no-fixme-test` | structure, warning | `test.fixme('t', fn)` / `test.describe.fixme(...)` declarations; in-body `test.fixme()` or `test.fixme(true, ...)`. Conditional `test.fixme(cond, 'why')` is clean. |
|
|
137
|
+
| `pwscore/no-swallowed-assertion` | assertions, warning | `expect(...)` in a `try` whose `catch` has no throw, expect, fail-ish call, returned value or assignment (not inside a loop); or `await expect(...).catch(fn)` as a statement where `fn` does not throw. |
|
|
138
|
+
| `pwscore/no-weak-assertion` | assertions, error | `.count()`/`.length` `toBeGreaterThanOrEqual(0)`; a syntactic Locator (`page.locator`, `getBy*`, or a const from one) asserted `toBeTruthy`/`toBeDefined`/`not.toBeNull`; `isVisible()`-style and `count()` results asserted `toBeDefined`. `toBeTruthy()` on other values is not flagged. |
|
|
139
|
+
| `pwscore/no-unawaited-expect-value` | assertions, error | `expect(<promise call>)` without `await`, receiver syntactically a Page/Locator, matcher not web-first (also an un-awaited promise call as the expected value of `toBe`/`toEqual`). `.resolves`/`.rejects` are exempt. |
|
|
140
|
+
| `pwscore/no-self-fulfilling-fallback` | assertions, info, report-only (no score effect; no real-suite hits, validated on fixtures only) | Inside a test: `if (!check) { actions }`, the `else` of `if (check) {...} else { actions }`, or `try { expect/check } catch { actions }`, where the actions include `fill`/`type`/`pressSequentially`/`selectOption`/`setInputFiles` (click-only and key-press fallbacks are the usual "make sure the menu is open" setup and are not flagged), followed in the same block by an `expect` on a locator shared with the check or the actions. Positive-branch actions (dismiss a banner if visible) are never flagged. |
|
|
141
|
+
|
|
142
|
+
|
|
129
143
|
---
|
|
130
144
|
|
|
131
145
|
## Why v4
|
|
@@ -190,3 +204,30 @@ real inputs:
|
|
|
190
204
|
diluted `findings/SLOC` and raised the score without fixing anything.
|
|
191
205
|
A per-test ratio has no such lever — padding with fake tests costs
|
|
192
206
|
assertions coverage instead.
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Flake-risk rules (unreleased)
|
|
211
|
+
|
|
212
|
+
Five static checks for the patterns that make a test pass or fail by luck.
|
|
213
|
+
Each is syntactic and conservative: when the receiver or value cannot be
|
|
214
|
+
read from the source, nothing is reported.
|
|
215
|
+
|
|
216
|
+
| Rule | Exact shape | Severity / dimension |
|
|
217
|
+
|---|---|---|
|
|
218
|
+
| `pwscore/no-unawaited-action` | An expression statement that is exactly `<receiver>.<action>(...)` inside an `async` function, where the receiver is `page`, a `.page` property, a `page.locator/getBy*/first/nth/filter` chain, or a `const` initialised from one, and the action is goto/reload/goBack/goForward/click/dblclick/fill/clear/press/type/check/uncheck/setChecked/selectOption/hover/focus/tap/dragTo/setInputFiles/setContent/waitFor*. Not matched: await, return, void, assignment, argument, `Promise.all`, `.catch()`/`.then()` chains, other receivers. Chosen over eslint-plugin-playwright's `missing-playwright-await` `includePageLocatorMethods` option because that one matches the method name on any receiver. | error / hygiene |
|
|
219
|
+
| `pwscore/no-brittle-selector` | A fully static selector string passed to `.locator()`, `.frameLocator()` or a legacy selector method on a Page/Locator that is XPath (`xpath=`, `//`, `(//`), uses `:nth-child`/`:nth-of-type`, is a plain CSS chain with 4 or more combinators, or contains a generated class (`.css-1a2b3c`, `.sc-bdfBwQ`, `.jss12`, `.MuiBox-root-12`); plus `.nth()/.first()/.last()` directly on a raw `.locator('<string>')` (test-id, `role=` and `text=` strings exempt). | warning, **report-only** / locators |
|
|
220
|
+
| `pwscore/no-shared-mutable-state` | A module- or describe-level `let`/`var` (or `const` object/array/Map/Set) that one `test()` body assigns or mutates and another `test()` body reads without writing it. Not matched: writes only in hooks, any `beforeEach` write, reader that also writes. | warning / structure |
|
|
221
|
+
| `pwscore/serial-mode-dependency` | `test.describe.configure({ mode: 'serial' })` or `test.describe.serial(...)`. | info, **report-only** / structure |
|
|
222
|
+
| `pwscore/no-excessive-timeout` | `{ timeout: N }` with N > 60,000 (strictly more than one minute) on an action, wait or matcher call; `test.setTimeout(N)`/`testInfo.setTimeout(N)` with N >= 300,000; `test.slow()` with no arguments directly at file or describe level. Literals and simple arithmetic only (`2 * 60 * 1000`, `60_000`). Config files are not read. | warning / hygiene |
|
|
223
|
+
|
|
224
|
+
`no-brittle-selector` is report-only on purpose: the locators dimension is a
|
|
225
|
+
native/raw ratio that already counts every raw `.locator()` and legacy
|
|
226
|
+
selector call, so scoring the same call again would charge it twice. The
|
|
227
|
+
finding is named evidence for why that raw call is fragile.
|
|
228
|
+
|
|
229
|
+
**Test factories.** A function whose `return` is `<test>.extend(...)`, a known
|
|
230
|
+
test alias, or a call of another such function (same file or imported through
|
|
231
|
+
relative paths) is a test factory; `const t = factory(...)` makes `t` a test
|
|
232
|
+
alias, so `t('name', ...)` is a test declaration and its `expect` calls are not
|
|
233
|
+
"standalone".
|
package/README.md
CHANGED
|
@@ -99,7 +99,7 @@ npx playwright-score ./flow.spec.ts --format json --out report.json
|
|
|
99
99
|
|---|---|
|
|
100
100
|
| `--profile standard` | Scoring profile (default, and only, profile) |
|
|
101
101
|
| `--threshold <n>` | Pass bar 0–100 |
|
|
102
|
-
| `--format text\|json\|markdown\|sarif` | Output format |
|
|
102
|
+
| `--format text\|json\|markdown\|sarif\|report` | Output format (`report` is a self-contained one-page HTML suite report) |
|
|
103
103
|
| `--out <file>` | Write report to file |
|
|
104
104
|
| `--version` | Print version |
|
|
105
105
|
|
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,9 @@ import { formatJson } from './formatters/json.js';
|
|
|
9
9
|
import { formatText } from './formatters/text.js';
|
|
10
10
|
import { formatMarkdown } from './formatters/markdown.js';
|
|
11
11
|
import { formatSarif } from './formatters/sarif.js';
|
|
12
|
+
import { formatReport } from './formatters/report.js';
|
|
13
|
+
import { makeSourceReader } from './report-source.js';
|
|
14
|
+
import { suiteLabel } from './suite-name.js';
|
|
12
15
|
function printHelp() {
|
|
13
16
|
console.log(`@qaguardian/playwright-score — deterministic Playwright spec quality score
|
|
14
17
|
|
|
@@ -21,8 +24,12 @@ Options:
|
|
|
21
24
|
--profile <standard> Scoring profile (default, and only
|
|
22
25
|
supported value, as of 2.0.0: standard).
|
|
23
26
|
--threshold <n> Pass threshold 0-100 (default: 80)
|
|
24
|
-
--format <text|json|markdown|sarif>
|
|
25
|
-
|
|
27
|
+
--format <text|json|markdown|sarif|report>
|
|
28
|
+
Output format (default: text). report is a
|
|
29
|
+
self-contained one-page HTML suite report.
|
|
30
|
+
--out, --output <file> Write report to file
|
|
31
|
+
--name <suite name> Suite name for the report headline
|
|
32
|
+
(default: the directory basename)
|
|
26
33
|
--version Print version
|
|
27
34
|
--help Show help
|
|
28
35
|
|
|
@@ -43,6 +50,30 @@ Methodology: METHODOLOGY.md (scoring model v4)
|
|
|
43
50
|
// size (64KB on macOS/Linux). Setting process.exitCode and returning lets
|
|
44
51
|
// Node exit naturally once pending I/O actually completes, which is the
|
|
45
52
|
// standard fix for this class of bug.
|
|
53
|
+
async function packageVersion() {
|
|
54
|
+
try {
|
|
55
|
+
const { createRequire } = await import('node:module');
|
|
56
|
+
const require = createRequire(import.meta.url);
|
|
57
|
+
return require('../package.json').version ?? 'unknown';
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return 'unknown';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Never an absolute local path: see suite-name.ts.
|
|
64
|
+
function suiteName(paths) {
|
|
65
|
+
const names = paths.slice(0, 2).map((p) => {
|
|
66
|
+
let isDir = false;
|
|
67
|
+
try {
|
|
68
|
+
isDir = fs.statSync(p).isDirectory();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* missing path: scorePaths reports it */
|
|
72
|
+
}
|
|
73
|
+
return suiteLabel(p, isDir);
|
|
74
|
+
});
|
|
75
|
+
return names.join(', ') + (paths.length > 2 ? ` and ${paths.length - 2} more` : '');
|
|
76
|
+
}
|
|
46
77
|
async function main() {
|
|
47
78
|
const args = process.argv.slice(2);
|
|
48
79
|
if (args.includes('--version') || args.includes('-v')) {
|
|
@@ -67,6 +98,7 @@ async function main() {
|
|
|
67
98
|
let threshold;
|
|
68
99
|
let format = 'text';
|
|
69
100
|
let out;
|
|
101
|
+
let name;
|
|
70
102
|
const paths = [];
|
|
71
103
|
for (let i = 0; i < args.length; i++) {
|
|
72
104
|
const a = args[i];
|
|
@@ -99,16 +131,24 @@ async function main() {
|
|
|
99
131
|
}
|
|
100
132
|
else if (a === '--format') {
|
|
101
133
|
const f = args[++i];
|
|
102
|
-
if (f !== 'text' && f !== 'json' && f !== 'markdown' && f !== 'sarif') {
|
|
134
|
+
if (f !== 'text' && f !== 'json' && f !== 'markdown' && f !== 'sarif' && f !== 'report') {
|
|
103
135
|
console.error(`Invalid format: ${f}`);
|
|
104
136
|
process.exitCode = 2;
|
|
105
137
|
return;
|
|
106
138
|
}
|
|
107
139
|
format = f;
|
|
108
140
|
}
|
|
109
|
-
else if (a === '--out') {
|
|
141
|
+
else if (a === '--out' || a === '--output') {
|
|
110
142
|
out = args[++i];
|
|
111
143
|
}
|
|
144
|
+
else if (a === '--name') {
|
|
145
|
+
name = args[++i];
|
|
146
|
+
if (!name) {
|
|
147
|
+
console.error('--name requires a value');
|
|
148
|
+
process.exitCode = 2;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
112
152
|
else if (a === '--version' || a === '-v') {
|
|
113
153
|
continue;
|
|
114
154
|
}
|
|
@@ -128,6 +168,9 @@ async function main() {
|
|
|
128
168
|
}
|
|
129
169
|
try {
|
|
130
170
|
const result = await scorePaths({ paths, profile, threshold });
|
|
171
|
+
for (const c of result.ruleCrashes ?? []) {
|
|
172
|
+
console.error(`warning: rule ${c.rule} crashed on ${c.count} node(s); results may be incomplete`);
|
|
173
|
+
}
|
|
131
174
|
let body;
|
|
132
175
|
switch (format) {
|
|
133
176
|
case 'json':
|
|
@@ -139,8 +182,21 @@ async function main() {
|
|
|
139
182
|
case 'sarif':
|
|
140
183
|
body = formatSarif(result);
|
|
141
184
|
break;
|
|
185
|
+
case 'report':
|
|
186
|
+
body = formatReport(result, {
|
|
187
|
+
date: new Date().toISOString().slice(0, 10),
|
|
188
|
+
version: await packageVersion(),
|
|
189
|
+
suiteName: name ?? suiteName(paths),
|
|
190
|
+
customThreshold: threshold !== undefined,
|
|
191
|
+
readSource: makeSourceReader(paths, process.cwd(), result.baseDir),
|
|
192
|
+
});
|
|
193
|
+
break;
|
|
142
194
|
default:
|
|
143
|
-
body = formatText(result
|
|
195
|
+
body = formatText(result, {
|
|
196
|
+
suiteName: name ?? suiteName(paths),
|
|
197
|
+
version: await packageVersion(),
|
|
198
|
+
customThreshold: threshold !== undefined,
|
|
199
|
+
});
|
|
144
200
|
}
|
|
145
201
|
if (out) {
|
|
146
202
|
fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Finding, Severity } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* How sure we are that a finding is a real problem a reader would agree
|
|
4
|
+
* with. Derived from a hand check of 20 findings (QAG-288). Display and
|
|
5
|
+
* ranking only: it never changes the score.
|
|
6
|
+
*
|
|
7
|
+
* - high: mechanical, almost never a false positive.
|
|
8
|
+
* - medium: usually right, but there are legitimate uses.
|
|
9
|
+
* - needs-check: the scorer could not resolve something; a human should look.
|
|
10
|
+
*/
|
|
11
|
+
export type Confidence = 'high' | 'medium' | 'needs-check';
|
|
12
|
+
export declare const CONFIDENCE_BY_RULE: Record<string, Confidence>;
|
|
13
|
+
/** Display names (the Confidence values themselves never change). */
|
|
14
|
+
export declare const CONFIDENCE_LABEL: Record<Confidence, string>;
|
|
15
|
+
/** Rules not listed above (and not needs-check) are medium. */
|
|
16
|
+
export declare const DEFAULT_CONFIDENCE: Confidence;
|
|
17
|
+
/**
|
|
18
|
+
* A report-only finding normally means "ratio-scored" (the locator rules:
|
|
19
|
+
* they feed the locators dimension through native/raw counts, not penalty
|
|
20
|
+
* units) and is an ordinary finding. Any OTHER report-only finding is one
|
|
21
|
+
* the scorer deliberately left out of the score (e.g. an assertion helper it
|
|
22
|
+
* could not resolve), so it is a "needs a human check".
|
|
23
|
+
*/
|
|
24
|
+
export declare function isNeedsCheck(f: Pick<Finding, 'reportOnly' | 'dimension'>): boolean;
|
|
25
|
+
export declare function confidenceOf(f: Pick<Finding, 'rule' | 'reportOnly' | 'dimension'>): Confidence;
|
|
26
|
+
export declare function severityRank(s: Severity): number;
|
|
27
|
+
export declare function confidenceRank(c: Confidence): number;
|
|
28
|
+
/** Severity, then confidence, then file, line, rule: total and stable. */
|
|
29
|
+
export declare function compareFindings(a: Finding, b: Finding): number;
|
|
30
|
+
export declare function sortFindings(findings: readonly Finding[]): Finding[];
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export const CONFIDENCE_BY_RULE = {
|
|
2
|
+
'playwright/missing-playwright-await': 'high',
|
|
3
|
+
'playwright/no-networkidle': 'high',
|
|
4
|
+
'playwright/no-wait-for-timeout': 'high',
|
|
5
|
+
'playwright/no-focused-test': 'high',
|
|
6
|
+
'playwright/no-page-pause': 'high',
|
|
7
|
+
'playwright-score/parse-error': 'high',
|
|
8
|
+
'pwscore/no-unawaited-action': 'high',
|
|
9
|
+
'pwscore/no-unawaited-expect-value': 'high',
|
|
10
|
+
'pwscore/no-weak-assertion': 'high',
|
|
11
|
+
'pwscore/no-fixme-test': 'high',
|
|
12
|
+
'pwscore/no-excessive-timeout': 'high',
|
|
13
|
+
'pwscore/no-swallowed-assertion': 'medium',
|
|
14
|
+
'pwscore/no-shared-mutable-state': 'medium',
|
|
15
|
+
'pwscore/no-self-fulfilling-fallback': 'needs-check',
|
|
16
|
+
'pwscore/no-brittle-selector': 'needs-check',
|
|
17
|
+
'pwscore/serial-mode-dependency': 'needs-check',
|
|
18
|
+
'playwright-score/assertion-unverified': 'needs-check',
|
|
19
|
+
'playwright/no-force-option': 'medium',
|
|
20
|
+
'playwright/no-wait-for-selector': 'medium',
|
|
21
|
+
'pwscore/no-timer-sleep': 'medium',
|
|
22
|
+
'pwscore/no-trivial-assertion': 'medium',
|
|
23
|
+
'playwright/no-conditional-in-test': 'medium',
|
|
24
|
+
'playwright/no-conditional-expect': 'medium',
|
|
25
|
+
};
|
|
26
|
+
/** Display names (the Confidence values themselves never change). */
|
|
27
|
+
export const CONFIDENCE_LABEL = {
|
|
28
|
+
high: 'Definite',
|
|
29
|
+
medium: 'Likely',
|
|
30
|
+
'needs-check': 'Needs a human check',
|
|
31
|
+
};
|
|
32
|
+
/** Rules not listed above (and not needs-check) are medium. */
|
|
33
|
+
export const DEFAULT_CONFIDENCE = 'medium';
|
|
34
|
+
const CONFIDENCE_RANK = { high: 0, medium: 1, 'needs-check': 2 };
|
|
35
|
+
const SEVERITY_RANK = { error: 0, warning: 1, info: 2 };
|
|
36
|
+
/**
|
|
37
|
+
* A report-only finding normally means "ratio-scored" (the locator rules:
|
|
38
|
+
* they feed the locators dimension through native/raw counts, not penalty
|
|
39
|
+
* units) and is an ordinary finding. Any OTHER report-only finding is one
|
|
40
|
+
* the scorer deliberately left out of the score (e.g. an assertion helper it
|
|
41
|
+
* could not resolve), so it is a "needs a human check".
|
|
42
|
+
*/
|
|
43
|
+
export function isNeedsCheck(f) {
|
|
44
|
+
return f.reportOnly === true && f.dimension !== 'locators';
|
|
45
|
+
}
|
|
46
|
+
export function confidenceOf(f) {
|
|
47
|
+
if (isNeedsCheck(f))
|
|
48
|
+
return 'needs-check';
|
|
49
|
+
return CONFIDENCE_BY_RULE[f.rule] ?? DEFAULT_CONFIDENCE;
|
|
50
|
+
}
|
|
51
|
+
export function severityRank(s) {
|
|
52
|
+
return SEVERITY_RANK[s] ?? 3;
|
|
53
|
+
}
|
|
54
|
+
export function confidenceRank(c) {
|
|
55
|
+
return CONFIDENCE_RANK[c];
|
|
56
|
+
}
|
|
57
|
+
/** Severity, then confidence, then file, line, rule: total and stable. */
|
|
58
|
+
export function compareFindings(a, b) {
|
|
59
|
+
return (severityRank(a.severity) - severityRank(b.severity) ||
|
|
60
|
+
confidenceRank(confidenceOf(a)) - confidenceRank(confidenceOf(b)) ||
|
|
61
|
+
a.file.localeCompare(b.file) ||
|
|
62
|
+
(a.line ?? 0) - (b.line ?? 0) ||
|
|
63
|
+
a.rule.localeCompare(b.rule));
|
|
64
|
+
}
|
|
65
|
+
export function sortFindings(findings) {
|
|
66
|
+
return [...findings].sort(compareFindings);
|
|
67
|
+
}
|
package/dist/eslint-runner.js
CHANGED
|
@@ -154,6 +154,18 @@ function buildConfig(assertFunctionNames, testAliasNames) {
|
|
|
154
154
|
'pwscore/no-coordinate-click': 'warn',
|
|
155
155
|
'pwscore/no-trivial-assertion': 'error',
|
|
156
156
|
'pwscore/no-soft-assertion-only-test': ['warn', { testAliasNames }],
|
|
157
|
+
// QAG-287 slice 2b flake-risk rules, one file each under src/rules/.
|
|
158
|
+
'pwscore/no-unawaited-action': 'error',
|
|
159
|
+
'pwscore/no-brittle-selector': 'warn',
|
|
160
|
+
'pwscore/no-shared-mutable-state': ['warn', { testAliasNames }],
|
|
161
|
+
'pwscore/serial-mode-dependency': ['warn', { testAliasNames }],
|
|
162
|
+
'pwscore/no-excessive-timeout': ['warn', { testAliasNames }],
|
|
163
|
+
// QAG-287 slice 2a: tests that would miss a real bug.
|
|
164
|
+
'pwscore/no-fixme-test': ['warn', { testAliasNames }],
|
|
165
|
+
'pwscore/no-swallowed-assertion': 'warn',
|
|
166
|
+
'pwscore/no-weak-assertion': 'error',
|
|
167
|
+
'pwscore/no-unawaited-expect-value': 'error',
|
|
168
|
+
'pwscore/no-self-fulfilling-fallback': ['warn', { testAliasNames }],
|
|
157
169
|
},
|
|
158
170
|
},
|
|
159
171
|
];
|
|
@@ -215,6 +227,10 @@ export async function runEslint(options) {
|
|
|
215
227
|
const results = await eslint.lintFiles(options.files);
|
|
216
228
|
const findings = [];
|
|
217
229
|
for (const result of results) {
|
|
230
|
+
// pwscore/no-unawaited-expect-value says "missing await" for the same
|
|
231
|
+
// expect(); upstream prefer-web-first-assertions says something
|
|
232
|
+
// misleading about it. Keep ours (same line).
|
|
233
|
+
const unawaitedLines = new Set(result.messages.filter((m) => m.ruleId === 'pwscore/no-unawaited-expect-value').map((m) => m.line));
|
|
218
234
|
const absFile = path.resolve(result.filePath);
|
|
219
235
|
const file = path.relative(cwd, absFile) || path.basename(absFile);
|
|
220
236
|
for (const msg of result.messages) {
|
|
@@ -262,6 +278,8 @@ export async function runEslint(options) {
|
|
|
262
278
|
}
|
|
263
279
|
if (!msg.ruleId)
|
|
264
280
|
continue;
|
|
281
|
+
if (msg.ruleId === 'playwright/prefer-web-first-assertions' && unawaitedLines.has(msg.line))
|
|
282
|
+
continue;
|
|
265
283
|
const mapping = mapRule(msg.ruleId);
|
|
266
284
|
findings.push({
|
|
267
285
|
rule: msg.ruleId,
|
|
@@ -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[];
|