@qaguardian/playwright-score 1.0.0 → 2.1.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 +817 -0
- package/METHODOLOGY.md +53 -6
- package/README.md +52 -31
- package/dist/cli.js +8 -2
- package/dist/eslint-runner.d.ts +2 -0
- package/dist/eslint-runner.js +57 -3
- package/dist/formatters/markdown.js +8 -0
- package/dist/formatters/text.js +7 -0
- package/dist/import-graph.d.ts +1 -0
- package/dist/import-graph.js +1 -1
- package/dist/index.js +130 -12
- package/dist/metrics.d.ts +18 -4
- package/dist/metrics.js +59 -9
- package/dist/playwright-config.d.ts +114 -0
- package/dist/playwright-config.js +520 -0
- package/dist/profiles.d.ts +15 -0
- package/dist/profiles.js +34 -0
- package/dist/rules/base-plugin.d.ts +4 -0
- package/dist/rules/base-plugin.js +698 -7
- package/dist/score-engine.d.ts +9 -1
- package/dist/score-engine.js +9 -1
- package/dist/test-aliases.d.ts +18 -0
- package/dist/test-aliases.js +218 -0
- package/dist/types.d.ts +21 -1
- package/package.json +3 -2
package/METHODOLOGY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Playwright Spec Score Methodology
|
|
2
2
|
|
|
3
|
-
**Scoring model:**
|
|
3
|
+
**Scoring model:** v4 (2026-09-23)
|
|
4
4
|
**Package:** `playwright-score`
|
|
5
5
|
**Landing page:** https://qaguardian.com/open-source/playwright-score
|
|
6
6
|
**Maintainer:** [QA Guardian](https://qaguardian.com)
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
**AI-free:** no LLM calls in the scoring path
|
|
9
9
|
|
|
10
10
|
Any change that alters what a given suite scores is a new model version
|
|
11
|
-
(
|
|
11
|
+
(v5, …), documented in the CHANGELOG. Model history: v1/v2 (published as
|
|
12
12
|
"sqs-v1"/"sqs-v2" through package 0.4.0) scored finding *density per line
|
|
13
|
-
of code*; v3 replaced that entirely — see "Why v3"
|
|
13
|
+
of code*; v3 replaced that entirely — see "Why v3" below. v4 keeps v3's
|
|
14
|
+
exact per-test ratio formula and changes only which findings feed it —
|
|
15
|
+
see "Why v4" below.
|
|
14
16
|
|
|
15
17
|
---
|
|
16
18
|
|
|
@@ -79,10 +81,10 @@ owns this dimension; counting them as demerits too would double-penalize.)
|
|
|
79
81
|
|
|
80
82
|
| Dimension | Weight | Demerit sources |
|
|
81
83
|
|---|---:|---|
|
|
82
|
-
| playwrightHygiene | 40 | hard waits, networkidle, force, missing await, element handles, eval, conditionals, page.pause, useless await |
|
|
83
|
-
| assertions | 25 | a test with no recognized assertion is fully flawed (`expect-expect`, after all delegation resolution); `valid-expect`, `no-standalone-expect`, `prefer-web-first-assertions` add partial demerits |
|
|
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 |
|
|
84
86
|
| locators | 20 | usage ratio (above) |
|
|
85
|
-
| structure | 15 | focused tests, always-skipped declarations, nested-describe depth, oversized files (module scope) |
|
|
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) |
|
|
86
88
|
|
|
87
89
|
```
|
|
88
90
|
score = round(Σ (weight_d / 100) × dimensionScore_d)
|
|
@@ -126,6 +128,51 @@ code scores identically regardless of which plugin version npm resolves.
|
|
|
126
128
|
|
|
127
129
|
---
|
|
128
130
|
|
|
131
|
+
## Why v4
|
|
132
|
+
|
|
133
|
+
v4 keeps v3's per-test ratio formula unchanged. What changed is which
|
|
134
|
+
findings feed it:
|
|
135
|
+
|
|
136
|
+
1. **Four gameability rules, planned as report-only under `standard`,
|
|
137
|
+
are real demerits there instead.** 1.1.0 (never published) planned
|
|
138
|
+
`pwscore/no-timer-sleep`,
|
|
139
|
+
`pwscore/no-coordinate-click`, `pwscore/no-trivial-assertion` and
|
|
140
|
+
`pwscore/no-soft-assertion-only-test` behind a separate opt-in `strict`
|
|
141
|
+
profile, so the public default (`standard`) wouldn't move anyone's
|
|
142
|
+
score. Owner direction, 2026-09-23: public scores should reflect
|
|
143
|
+
quality, full stop — a spec that sleeps on a timer, clicks a fixed
|
|
144
|
+
pixel coordinate, or asserts on a literal constant should score worse
|
|
145
|
+
under the default profile, not only under an opt-in one nobody has to
|
|
146
|
+
run. `strict` is removed as a result: with nothing left for it to gate
|
|
147
|
+
(every rule now scores the same way in the only remaining profile), a
|
|
148
|
+
second profile identical to the first was dead weight, not a real
|
|
149
|
+
choice. See CHANGELOG.md's 2.0.0 entry for the exact corpus repos this
|
|
150
|
+
moved, including pass→fail flips.
|
|
151
|
+
2. **`pwscore/no-coordinate-click` is a warning (0.4 demerit), not an
|
|
152
|
+
error (1.0).** The 50-repo corpus surfaced a real false-positive
|
|
153
|
+
class: two infinite-canvas editors (tldraw, AFFiNE) accounted for
|
|
154
|
+
428/459 of this rule's hits, and a fixed-pixel `page.mouse.click(x,
|
|
155
|
+
y)` is the *correct*, only way to interact with canvas content that
|
|
156
|
+
has no addressable DOM element — not a brittleness anti-pattern there.
|
|
157
|
+
A full error-level demerit would treat every legitimate canvas
|
|
158
|
+
interaction as a fully-flawed test; a partial one keeps it a real cost
|
|
159
|
+
(still visible, still moves the score) without a brittle "is this a
|
|
160
|
+
canvas app?" heuristic (filename/import sniffing) that would itself
|
|
161
|
+
become a new gameability surface — name your spec file to dodge it.
|
|
162
|
+
3. **Inline `eslint-disable` suppression comments are now a demerit,
|
|
163
|
+
not invisible.** A pre-2.0.0 review found this project's own docs
|
|
164
|
+
recommending `// eslint-disable-next-line pwscore/no-coordinate-click`
|
|
165
|
+
as an escape hatch, while every rule this package ships can in fact be
|
|
166
|
+
silenced the same way — defeating the score for whatever it hides.
|
|
167
|
+
`pwscore/eslint-disable-comment` reports the presence of any
|
|
168
|
+
suppression directive (not, yet, which specific rule it targets — a
|
|
169
|
+
documented known limitation, see VALIDATION.md).
|
|
170
|
+
|
|
171
|
+
Together these move `standard` scores — see CHANGELOG.md's 2.0.0 entry
|
|
172
|
+
for the "scores changed" breakdown, and VALIDATION.md for the full
|
|
173
|
+
before/after table across all 50 corpus repos, including which ones flip
|
|
174
|
+
from pass to fail at the default threshold.
|
|
175
|
+
|
|
129
176
|
## Why v3
|
|
130
177
|
|
|
131
178
|
v1/v2 scored findings per 25 lines of code through an exponential decay
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/qa-guardian/playwright-score/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/@qaguardian/playwright-score)
|
|
5
|
-
[](
|
|
5
|
+
[](https://github.com/qa-guardian/playwright-score/blob/main/LICENSE)
|
|
6
6
|
|
|
7
7
|
**Deterministic, AI-free quality score for Playwright specs** — the
|
|
8
8
|
weighted share of your tests that are clean.
|
|
@@ -19,42 +19,63 @@ The score never calls an LLM. AI may *generate* or *repair* code using findings;
|
|
|
19
19
|
|
|
20
20
|
## Real-world results
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
100 public Playwright suites, scored with the published package against each
|
|
23
23
|
project's actual source — chosen to include both well-known,
|
|
24
|
-
heavily-engineered platforms and smaller, less mature projects
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
heavily-engineered platforms and smaller, less mature projects, plus three
|
|
25
|
+
named testing-infra competitors scored under the exact same rules as
|
|
26
|
+
everyone else. Not curated to look good: 35 of 100 fail the default
|
|
27
|
+
threshold, for real, verifiable reasons (see
|
|
28
|
+
[VALIDATION.md](https://github.com/qa-guardian/playwright-score/blob/main/VALIDATION.md),
|
|
29
|
+
which also has the full before/after against the last published version
|
|
30
|
+
and every one of the 100 entries, not just the 15 shown below).
|
|
27
31
|
|
|
28
|
-
**
|
|
32
|
+
**100 suites · 7,281 files · 39,059 tests · 65/100 pass (80% threshold) ·
|
|
33
|
+
tested on 2026-09-24**
|
|
34
|
+
|
|
35
|
+
Top scorers (everyone at 97+; the next tier down is a 6-way tie at 96 — see
|
|
36
|
+
VALIDATION.md for the full 100):
|
|
37
|
+
|
|
38
|
+
| Repo (source scanned) | Score | Grade | Result |
|
|
39
|
+
|---|---:|:-:|:-:|
|
|
40
|
+
| [pinterest/gestalt](https://github.com/pinterest/gestalt/tree/22874a7522d1/playwright/accessibility) | 100/100 | A | PASS |
|
|
41
|
+
| [TryGhost/Ghost](https://github.com/TryGhost/Ghost/tree/9d7248b56d53/e2e/tests) | 99/100 | A | PASS |
|
|
42
|
+
| [hashicorp/vault](https://github.com/hashicorp/vault/tree/f03da019aba5/ui/e2e) | 98/100 | A | PASS |
|
|
43
|
+
| [microsoft/playwright (TodoMVC example)](https://github.com/microsoft/playwright/tree/7563c5a67eab/examples/todomvc) | 98/100 | A | PASS |
|
|
44
|
+
| [plausible/analytics](https://github.com/plausible/analytics/tree/8f3970104aa7/e2e) | 98/100 | A | PASS |
|
|
45
|
+
| [segmentio/analytics-next](https://github.com/segmentio/analytics-next/tree/2d6d55301804/packages/browser-integration-tests/src) | 98/100 | A | PASS |
|
|
46
|
+
| [cloudflare/templates](https://github.com/cloudflare/templates/tree/a0bb6ef9a990/playwright-tests) | 97/100 | A | PASS |
|
|
47
|
+
| [glpi-project/glpi](https://github.com/glpi-project/glpi/tree/9e8a9570a90b/tests/e2e/specs) | 97/100 | A | PASS |
|
|
48
|
+
| [shopify/hydrogen](https://github.com/shopify/hydrogen/tree/3ef7ec23096a/e2e/specs) | 97/100 | A | PASS |
|
|
49
|
+
|
|
50
|
+
Bottom 5 scorers:
|
|
29
51
|
|
|
30
52
|
| Repo (source scanned) | Score | Grade | Result |
|
|
31
53
|
|---|---:|:-:|:-:|
|
|
32
|
-
| [
|
|
33
|
-
| [
|
|
34
|
-
| [
|
|
35
|
-
| [
|
|
36
|
-
| [
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
yourself live in [VALIDATION.md](./VALIDATION.md). A self-contained visual
|
|
54
|
+
| [Flagsmith/flagsmith](https://github.com/Flagsmith/flagsmith/tree/ea5ad826e85b/frontend/e2e/tests) | 62/100 | D | FAIL |
|
|
55
|
+
| [activepieces/activepieces](https://github.com/activepieces/activepieces/tree/9e493a87f471/packages/tests-e2e) | 58/100 | F | FAIL |
|
|
56
|
+
| [th3cyb3rhub/TheCyberHub](https://github.com/th3cyb3rhub/TheCyberHub/tree/1cf418654f16/e2e) | 56/100 | F | FAIL |
|
|
57
|
+
| [DataDog/documentation](https://github.com/DataDog/documentation/tree/6cd10f40cf46/hugo/e2e) | 50/100 | F | FAIL |
|
|
58
|
+
| [getsentry/spotlight](https://github.com/getsentry/spotlight/tree/3dd5eb5b315f/packages/spotlight/tests) | 33/100 | F | FAIL |
|
|
59
|
+
|
|
60
|
+
Repo names link to the exact commit scanned. Selected by a mix of
|
|
61
|
+
mechanical GitHub search (real `@playwright/test` usage, hand-verified)
|
|
62
|
+
and, for the most recent 16, hand-picked brand recognition — three of the
|
|
63
|
+
100 are named competitors' own example suites (Checkly, Currents,
|
|
64
|
+
LambdaTest), scored with no separate bar. Full methodology, the complete
|
|
65
|
+
100-repo table, findings breakdown, and
|
|
66
|
+
`scripts/validate-corpus.sh` to reproduce every number here yourself live
|
|
67
|
+
in
|
|
68
|
+
[VALIDATION.md](https://github.com/qa-guardian/playwright-score/blob/main/VALIDATION.md).
|
|
69
|
+
A machine-readable
|
|
70
|
+
[`validation.json`](https://github.com/qa-guardian/playwright-score/blob/main/validation.json)
|
|
71
|
+
carries the same per-repo data for tooling. See
|
|
72
|
+
[CHANGELOG.md](https://github.com/qa-guardian/playwright-score/blob/main/CHANGELOG.md)
|
|
73
|
+
for what changed release to release. A self-contained visual
|
|
53
74
|
report of an earlier five-suite audit lives at
|
|
54
|
-
[`docs/scorecard.html`](
|
|
75
|
+
[`docs/scorecard.html`](https://github.com/qa-guardian/playwright-score/blob/main/docs/scorecard.html) — host it wherever
|
|
55
76
|
(GitHub Pages, qaguardian.com, ...).
|
|
56
77
|
|
|
57
|
-
See [METHODOLOGY.md](
|
|
78
|
+
See [METHODOLOGY.md](https://github.com/qa-guardian/playwright-score/blob/main/METHODOLOGY.md) for the full scoring model, or the product write-up on the [landing page](https://qaguardian.com/open-source/playwright-score).
|
|
58
79
|
|
|
59
80
|
## Install
|
|
60
81
|
|
|
@@ -148,8 +169,8 @@ sets:
|
|
|
148
169
|
|
|
149
170
|
- **`warn`**: log score; never fail the run; findings still inject into heal/generate repair prompts
|
|
150
171
|
- **`gate`**: fail the run when score < threshold
|
|
151
|
-
- After AI **generate**,
|
|
152
|
-
- **Heal** prompts always include score findings when the scorer reports issues
|
|
172
|
+
- After AI **generate**, every spec is scored; a spec below threshold is repaired with its score findings and re-scored, up to 3 scored attempts (the initial generation plus up to two repairs). A spec still below threshold, or one that could not be scored, is flagged for Guardian review and never silently accepted.
|
|
173
|
+
- **Heal** and repair prompts always include score findings when the scorer reports issues.
|
|
153
174
|
|
|
154
175
|
```bash
|
|
155
176
|
# Local from monorepo
|
package/dist/cli.js
CHANGED
|
@@ -18,7 +18,8 @@ Usage:
|
|
|
18
18
|
npx playwright-score <paths...> [options]
|
|
19
19
|
|
|
20
20
|
Options:
|
|
21
|
-
--profile <standard>
|
|
21
|
+
--profile <standard> Scoring profile (default, and only
|
|
22
|
+
supported value, as of 2.0.0: standard).
|
|
22
23
|
--threshold <n> Pass threshold 0-100 (default: 80)
|
|
23
24
|
--format <text|json|markdown|sarif> Output format (default: text)
|
|
24
25
|
--out <file> Write report to file
|
|
@@ -31,7 +32,7 @@ Exit codes:
|
|
|
31
32
|
2 tool error
|
|
32
33
|
|
|
33
34
|
Website: https://qaguardian.com/open-source/playwright-score
|
|
34
|
-
Methodology: METHODOLOGY.md (scoring model
|
|
35
|
+
Methodology: METHODOLOGY.md (scoring model v4)
|
|
35
36
|
`);
|
|
36
37
|
}
|
|
37
38
|
// Deliberately never process.exit() right after a console.log/console.error
|
|
@@ -76,6 +77,11 @@ async function main() {
|
|
|
76
77
|
process.exitCode = 2;
|
|
77
78
|
return;
|
|
78
79
|
}
|
|
80
|
+
if (p === 'strict') {
|
|
81
|
+
console.error('Profile "strict" was removed in 2.0.0 — the four gameability checks it used to count on top of "standard" (timer-sleep sleeps, coordinate clicks, trivial assertions, soft-assertion-only tests) are now scored under "standard" directly, so a separate stricter profile has nothing left to gate. See CHANGELOG.md. Use --profile standard.');
|
|
82
|
+
process.exitCode = 2;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
79
85
|
if (p !== 'standard') {
|
|
80
86
|
console.error(`Invalid profile: ${p}`);
|
|
81
87
|
process.exitCode = 2;
|
package/dist/eslint-runner.d.ts
CHANGED
|
@@ -9,4 +9,6 @@ export declare function runEslint(options: {
|
|
|
9
9
|
cwd?: string;
|
|
10
10
|
/** See findLocalAssertionHelperNames in metrics.ts. */
|
|
11
11
|
assertFunctionNames?: string[];
|
|
12
|
+
/** See detectExtendedTestAliases in test-aliases.ts. */
|
|
13
|
+
testAliasNames?: string[];
|
|
12
14
|
}): Promise<Finding[]>;
|
package/dist/eslint-runner.js
CHANGED
|
@@ -4,10 +4,40 @@ import { commonAncestorDir } from './fs-util.js';
|
|
|
4
4
|
import playwright from 'eslint-plugin-playwright';
|
|
5
5
|
import { basePlugin } from './rules/base-plugin.js';
|
|
6
6
|
import { mapRule } from './profiles.js';
|
|
7
|
-
function buildConfig(assertFunctionNames) {
|
|
7
|
+
function buildConfig(assertFunctionNames, testAliasNames) {
|
|
8
8
|
const base = [
|
|
9
9
|
{
|
|
10
10
|
files: ['**/*.{ts,tsx,js,jsx,mjs,cjs}'],
|
|
11
|
+
// Identifiers bound to a `test.extend(...)`-derived fixture under a
|
|
12
|
+
// custom name (test-aliases.ts) — upstream eslint-plugin-playwright
|
|
13
|
+
// already dereferences a *same-file* extend chain internally, but
|
|
14
|
+
// can't follow one across files (its own `dereference` is scoped to
|
|
15
|
+
// the current file); this setting is what makes a cross-file alias
|
|
16
|
+
// (`import { loggedTest } from './fixtures'`) resolve to `test` for
|
|
17
|
+
// every rule that keys off the literal identifier — no-standalone-
|
|
18
|
+
// expect, no-conditional-expect, no-conditional-in-test, valid-expect,
|
|
19
|
+
// no-focused-test, and others built on the same parseFnCall/
|
|
20
|
+
// resolveToPlaywrightFn helper. Verified against two real corpus
|
|
21
|
+
// repos (argos-ci/argos's `loggedTest`, apache/superset's
|
|
22
|
+
// `testWithAssets`) where this exact cross-file shape was the actual
|
|
23
|
+
// false-positive cause.
|
|
24
|
+
settings: {
|
|
25
|
+
playwright: {
|
|
26
|
+
globalAliases: { test: testAliasNames },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
// A pre-2.0.0 review found `/* eslint-disable */` on line 1 of a
|
|
30
|
+
// 10-test file (waitForTimeout + expect(true).toBe(true) in every
|
|
31
|
+
// test) took it from 35/F to 99/A — every rule this package enables
|
|
32
|
+
// is an ordinary ESLint rule, so an ordinary inline disable comment
|
|
33
|
+
// silences it exactly like it would in any host project, defeating
|
|
34
|
+
// every one of these rules at will. noInlineConfig makes every
|
|
35
|
+
// eslint-disable/-line/-next-line comment (and enable/env/global)
|
|
36
|
+
// inert for this run's rule set; pwscore/eslint-disable-comment
|
|
37
|
+
// (metrics.ts) still reports the comment itself as a demerit — see
|
|
38
|
+
// eslint-disable-comment's docs — but it can no longer also silence
|
|
39
|
+
// whatever it's aimed at.
|
|
40
|
+
linterOptions: { noInlineConfig: true },
|
|
11
41
|
languageOptions: {
|
|
12
42
|
ecmaVersion: 2022,
|
|
13
43
|
sourceType: 'module',
|
|
@@ -71,7 +101,7 @@ function buildConfig(assertFunctionNames) {
|
|
|
71
101
|
// with the same severity for the actual anti-pattern (an
|
|
72
102
|
// always-skipped test declaration) only.
|
|
73
103
|
'playwright/no-skipped-test': 'off',
|
|
74
|
-
'pwscore/no-skipped-test-declaration': 'warn',
|
|
104
|
+
'pwscore/no-skipped-test-declaration': ['warn', { testAliasNames }],
|
|
75
105
|
'playwright/no-raw-locators': 'warn',
|
|
76
106
|
// Both were mapped in profiles.ts (report-only, so they don't
|
|
77
107
|
// double-penalize the ratio-based locators dimension) but never
|
|
@@ -112,6 +142,18 @@ function buildConfig(assertFunctionNames) {
|
|
|
112
142
|
'playwright/valid-expect': 'error',
|
|
113
143
|
'playwright/no-standalone-expect': 'error',
|
|
114
144
|
'playwright/max-nested-describe': 'warn',
|
|
145
|
+
// QAG-196 gameability fixes (2026-09-21), scored unconditionally
|
|
146
|
+
// as of 2.0.0/model v4 — see base-plugin.ts for what each one
|
|
147
|
+
// catches and profiles.ts for the severity → demerit mapping.
|
|
148
|
+
// no-coordinate-click is 'warn' here too (not 'error'): profiles.ts
|
|
149
|
+
// overrides its scoring severity to 'warning' regardless (the
|
|
150
|
+
// canvas-app false-positive mitigation), so setting 'error' here
|
|
151
|
+
// would only mislead the raw ESLint severity shown in some
|
|
152
|
+
// formatters without changing what it actually costs.
|
|
153
|
+
'pwscore/no-timer-sleep': 'error',
|
|
154
|
+
'pwscore/no-coordinate-click': 'warn',
|
|
155
|
+
'pwscore/no-trivial-assertion': 'error',
|
|
156
|
+
'pwscore/no-soft-assertion-only-test': ['warn', { testAliasNames }],
|
|
115
157
|
},
|
|
116
158
|
},
|
|
117
159
|
];
|
|
@@ -142,7 +184,7 @@ export async function runEslint(options) {
|
|
|
142
184
|
const cwd = options.files.length > 0
|
|
143
185
|
? commonAncestorDir(options.files)
|
|
144
186
|
: (options.cwd ?? process.cwd());
|
|
145
|
-
const overrideConfig = buildConfig(options.assertFunctionNames ?? []);
|
|
187
|
+
const overrideConfig = buildConfig(options.assertFunctionNames ?? [], options.testAliasNames ?? []);
|
|
146
188
|
// Try to attach typescript-eslint parser for .ts files
|
|
147
189
|
try {
|
|
148
190
|
const tsParser = await import('@typescript-eslint/parser');
|
|
@@ -189,6 +231,18 @@ export async function runEslint(options) {
|
|
|
189
231
|
if (/^Definition for rule '.*' was not found\.$/.test(msg.message)) {
|
|
190
232
|
continue;
|
|
191
233
|
}
|
|
234
|
+
// noInlineConfig (above) reports every inline directive comment
|
|
235
|
+
// itself as a `ruleId: null` message ("'...' has no effect because
|
|
236
|
+
// you have 'noInlineConfig' setting in your config.") — already
|
|
237
|
+
// caught by the generic `!msg.ruleId` skip below, but matched
|
|
238
|
+
// explicitly here too (same treatment as "Definition for rule",
|
|
239
|
+
// above) so this stays true even if that generic skip is ever
|
|
240
|
+
// narrowed: it's a diagnostic about our own noInlineConfig setting,
|
|
241
|
+
// not the spec's quality, and pwscore/eslint-disable-comment already
|
|
242
|
+
// reports the comment itself as a real finding.
|
|
243
|
+
if (/has no effect because you have 'noInlineConfig' setting in your config\.$/.test(msg.message)) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
192
246
|
if (msg.fatal) {
|
|
193
247
|
// A file that doesn't parse can't be meaningfully scored at all —
|
|
194
248
|
// surface it explicitly rather than silently dropping it (it has
|
|
@@ -32,5 +32,13 @@ export function formatMarkdown(result) {
|
|
|
32
32
|
lines.push('');
|
|
33
33
|
lines.push('</details>');
|
|
34
34
|
}
|
|
35
|
+
if (result.configWarnings?.length) {
|
|
36
|
+
lines.push('');
|
|
37
|
+
lines.push('## Config warnings');
|
|
38
|
+
lines.push('');
|
|
39
|
+
for (const w of result.configWarnings) {
|
|
40
|
+
lines.push(`- ${w}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
35
43
|
return lines.join('\n');
|
|
36
44
|
}
|
package/dist/formatters/text.js
CHANGED
|
@@ -36,6 +36,13 @@ export function formatText(result) {
|
|
|
36
36
|
lines.push(` … and ${result.skippedFiles.length - 20} more`);
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
if (result.configWarnings?.length) {
|
|
40
|
+
lines.push('');
|
|
41
|
+
lines.push('Config warnings:');
|
|
42
|
+
for (const w of result.configWarnings) {
|
|
43
|
+
lines.push(` ${w}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
39
46
|
return lines.join('\n');
|
|
40
47
|
}
|
|
41
48
|
function pathBasename(p) {
|
package/dist/import-graph.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export declare function resolveRelativeImport(fromFile: string, specifier: string, boundaryDir: string): string | undefined;
|
|
1
2
|
/**
|
|
2
3
|
* A Page Object Model is common in real Playwright suites: spec files
|
|
3
4
|
* interact with the page entirely through methods on an imported class
|
package/dist/import-graph.js
CHANGED
|
@@ -35,7 +35,7 @@ function extractRelativeImportSpecifiers(source, file) {
|
|
|
35
35
|
}
|
|
36
36
|
return specifiers;
|
|
37
37
|
}
|
|
38
|
-
function resolveRelativeImport(fromFile, specifier, boundaryDir) {
|
|
38
|
+
export function resolveRelativeImport(fromFile, specifier, boundaryDir) {
|
|
39
39
|
const base = path.resolve(path.dirname(fromFile), specifier);
|
|
40
40
|
const candidates = [];
|
|
41
41
|
for (const ext of RESOLVE_EXTENSIONS)
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,8 @@ import { runEslint } from './eslint-runner.js';
|
|
|
5
5
|
import { commonAncestorDir } from './fs-util.js';
|
|
6
6
|
import { collectLocallyImportedFiles } from './import-graph.js';
|
|
7
7
|
import { analyzeSource, attributeFindingScopes, countLocators, findLocalAssertionHelperNames, getTestSpans, looksLikeNonPlaywrightTest, } from './metrics.js';
|
|
8
|
+
import { detectExtendedTestAliases } from './test-aliases.js';
|
|
9
|
+
import { findPlaywrightConfig, findRepoBoundary, importsPlaywrightTestTransitively, parsePlaywrightConfig, resolveConfigScopedRoot, testDirEscapesRepo, } from './playwright-config.js';
|
|
8
10
|
import { DEFAULT_THRESHOLDS } from './profiles.js';
|
|
9
11
|
import { computeScore } from './score-engine.js';
|
|
10
12
|
export { computeScore, MODEL, locatorsScore, gradeFromScore, ratioDimensionScore, } from './score-engine.js';
|
|
@@ -23,8 +25,13 @@ export { formatSarif } from './formatters/sarif.js';
|
|
|
23
25
|
// metrics.ts) is the safety net for a repo that uses `.e2e.` for a
|
|
24
26
|
// *different* framework — Cypress specifically was common enough here to
|
|
25
27
|
// get its own explicit check alongside the existing Jest/Vitest/RTL ones.
|
|
28
|
+
// `.pw.` joined the list from the 50-repo corpus expansion (QAG-196,
|
|
29
|
+
// 2026-09-21) — Flagsmith's entire 20-spec frontend/e2e suite
|
|
30
|
+
// (billing-test.pw.ts, flag-tests.pw.ts, ...) hard-failed with "no files
|
|
31
|
+
// matched" without it, the same failure mode cal.com/Immich already fixed
|
|
32
|
+
// for their own suffixes.
|
|
26
33
|
const SPEC_GLOBS = [
|
|
27
|
-
'**/*.{spec,test,e2e,e2e-spec,e2e-test}.{ts,tsx,js,jsx}',
|
|
34
|
+
'**/*.{spec,test,e2e,e2e-spec,e2e-test,pw}.{ts,tsx,js,jsx}',
|
|
28
35
|
'**/*.spec.ts',
|
|
29
36
|
'**/*.test.ts',
|
|
30
37
|
];
|
|
@@ -62,6 +69,7 @@ function expandPaths(inputs, cwd) {
|
|
|
62
69
|
const explicit = new Set();
|
|
63
70
|
const expanded = new Set();
|
|
64
71
|
const scanRootDirs = new Set();
|
|
72
|
+
const configWarnings = [];
|
|
65
73
|
for (const input of inputs) {
|
|
66
74
|
const abs = path.isAbsolute(input) ? input : path.resolve(cwd, input);
|
|
67
75
|
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
|
|
@@ -76,14 +84,109 @@ function expandPaths(inputs, cwd) {
|
|
|
76
84
|
// needs this wider boundary, not the narrower common ancestor of
|
|
77
85
|
// only the matched spec files.
|
|
78
86
|
scanRootDirs.add(abs);
|
|
79
|
-
|
|
87
|
+
// A playwright.config.* at or above this directory, when it can be
|
|
88
|
+
// read statically, is authoritative about what this target itself
|
|
89
|
+
// considers a spec — see playwright-config.ts. Falls back to the
|
|
90
|
+
// filename-suffix SPEC_GLOBS (today's behavior) whenever a config
|
|
91
|
+
// isn't found, doesn't overlap this scan, or can't be parsed
|
|
92
|
+
// statically (a dynamic/unsupported shape) — the last case surfaces
|
|
93
|
+
// a warning rather than silently guessing.
|
|
94
|
+
let globCwd = abs;
|
|
95
|
+
let specGlobs = SPEC_GLOBS;
|
|
96
|
+
let ignoreGlobs = DEFAULT_IGNORE_GLOBS;
|
|
97
|
+
let configApplied = false;
|
|
98
|
+
// Boundary for the transitive-import safety net below — widened to
|
|
99
|
+
// the config's own directory (not `abs`), for the same reason
|
|
100
|
+
// import-graph.ts already widens its own POM boundary past the
|
|
101
|
+
// matched spec files' common ancestor: a fixtures module a spec
|
|
102
|
+
// imports test/expect from commonly lives in a directory *sibling*
|
|
103
|
+
// to testDir (`playwright/fixtures` next to `playwright/tests`), not
|
|
104
|
+
// inside it. Verified against a real config (appsmithorg/appsmith)
|
|
105
|
+
// where every real spec imports `test`/`expect` from `../../fixtures`
|
|
106
|
+
// — bounding resolution to `abs` (testDir itself) made every file
|
|
107
|
+
// fail the check and silently zeroed the whole suite, the same
|
|
108
|
+
// false-negative failure mode this safety net exists to prevent.
|
|
109
|
+
let importSafetyNetBoundary = abs;
|
|
110
|
+
const configFile = findPlaywrightConfig(abs);
|
|
111
|
+
if (configFile) {
|
|
112
|
+
const parsedConfig = parsePlaywrightConfig(configFile);
|
|
113
|
+
if (!parsedConfig) {
|
|
114
|
+
configWarnings.push(`Found ${path.relative(cwd, configFile) || configFile} but could not statically parse testDir/testMatch/testIgnore (a dynamic value or unsupported shape) — falling back to filename-based spec discovery for ${path.relative(cwd, abs) || abs}.`);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
// Fallback boundary for findRepoBoundary when no .git turns up:
|
|
118
|
+
// the config file's own directory when it sits above `abs`
|
|
119
|
+
// (found by walking up from abs, so it's always an ancestor-or-
|
|
120
|
+
// same of it), else `abs` itself. Never the filesystem root or
|
|
121
|
+
// 20 levels up — see findRepoBoundary's doc comment.
|
|
122
|
+
const repoRoot = findRepoBoundary(abs, path.dirname(configFile));
|
|
123
|
+
if (testDirEscapesRepo(parsedConfig.testDirAbs, repoRoot)) {
|
|
124
|
+
// The config's testDir resolves outside the repo boundary
|
|
125
|
+
// (directly, or via a symlink — see testDirEscapesRepo). Its
|
|
126
|
+
// testMatch/testIgnore were written for that escaped testDir,
|
|
127
|
+
// so applying them to the clamped repoRoot instead would be a
|
|
128
|
+
// silent mismatch; fall back to filename-based discovery.
|
|
129
|
+
configWarnings.push(`Found ${path.relative(cwd, configFile) || configFile} but its testDir (${parsedConfig.testDirAbs}) resolves outside the repository (${repoRoot}) — falling back to filename-based spec discovery for ${path.relative(cwd, abs) || abs}.`);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
const scopedRoot = resolveConfigScopedRoot(abs, parsedConfig.testDirAbs, repoRoot);
|
|
133
|
+
if (scopedRoot) {
|
|
134
|
+
globCwd = scopedRoot;
|
|
135
|
+
if (parsedConfig.testMatch)
|
|
136
|
+
specGlobs = parsedConfig.testMatch;
|
|
137
|
+
if (parsedConfig.testIgnore) {
|
|
138
|
+
ignoreGlobs = [...DEFAULT_IGNORE_GLOBS, ...parsedConfig.testIgnore];
|
|
139
|
+
}
|
|
140
|
+
configApplied = true;
|
|
141
|
+
importSafetyNetBoundary = path.dirname(configFile);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const g of specGlobs) {
|
|
80
147
|
for (const f of globSync(g, {
|
|
81
|
-
cwd:
|
|
148
|
+
cwd: globCwd,
|
|
82
149
|
absolute: true,
|
|
83
150
|
nodir: true,
|
|
84
|
-
ignore:
|
|
151
|
+
ignore: ignoreGlobs,
|
|
152
|
+
// glob's own default for `follow` is already false (don't
|
|
153
|
+
// descend into a symlinked directory while expanding `**`), but
|
|
154
|
+
// this is untrusted-repo territory (see testDirEscapesRepo
|
|
155
|
+
// above), so pin it explicitly rather than relying on a default
|
|
156
|
+
// that could change upstream.
|
|
157
|
+
follow: false,
|
|
85
158
|
})) {
|
|
86
|
-
|
|
159
|
+
const resolved = path.resolve(f);
|
|
160
|
+
// resolveConfigScopedRoot always globs from testDir (Playwright's
|
|
161
|
+
// own testMatch/testIgnore semantics — see its doc comment), which
|
|
162
|
+
// can be wider than the directory the caller actually pointed
|
|
163
|
+
// `scorePaths` at (a narrower `abs`, e.g. one subproject's specs
|
|
164
|
+
// inside a monorepo testDir that also covers others). Filter back
|
|
165
|
+
// down to what the caller asked for so scoping to testDir for
|
|
166
|
+
// correct path matching never silently widens the scored set.
|
|
167
|
+
if (configApplied && globCwd !== abs) {
|
|
168
|
+
const rel = path.relative(abs, resolved);
|
|
169
|
+
if (rel.startsWith('..') || path.isAbsolute(rel))
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
// Extra safety net, only once config-based discovery is actually
|
|
173
|
+
// driving this scan: testMatch can still be broader than intended
|
|
174
|
+
// (e.g. a generic '**/*.ts'); a file that imports neither
|
|
175
|
+
// '@playwright/test' directly nor (transitively, through a local
|
|
176
|
+
// module) re-exports it isn't a Playwright spec regardless of
|
|
177
|
+
// what the config's glob matched.
|
|
178
|
+
if (configApplied) {
|
|
179
|
+
let source;
|
|
180
|
+
try {
|
|
181
|
+
source = fs.readFileSync(resolved, 'utf8');
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (!importsPlaywrightTestTransitively(resolved, source, importSafetyNetBoundary))
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
expanded.add(resolved);
|
|
87
190
|
}
|
|
88
191
|
}
|
|
89
192
|
continue;
|
|
@@ -106,11 +209,12 @@ function expandPaths(inputs, cwd) {
|
|
|
106
209
|
explicit: [...explicit].sort(),
|
|
107
210
|
expanded: [...expanded].sort(),
|
|
108
211
|
scanRootDirs: [...scanRootDirs].sort(),
|
|
212
|
+
configWarnings,
|
|
109
213
|
};
|
|
110
214
|
}
|
|
111
|
-
function hardFail(profile, threshold, findings, extra = {}, skippedFiles) {
|
|
215
|
+
function hardFail(profile, threshold, findings, extra = {}, skippedFiles, configWarnings) {
|
|
112
216
|
return {
|
|
113
|
-
scoreVersion: '
|
|
217
|
+
scoreVersion: 'v4',
|
|
114
218
|
profile,
|
|
115
219
|
score: 0,
|
|
116
220
|
grade: 'F',
|
|
@@ -137,6 +241,7 @@ function hardFail(profile, threshold, findings, extra = {}, skippedFiles) {
|
|
|
137
241
|
},
|
|
138
242
|
findings,
|
|
139
243
|
...(skippedFiles && skippedFiles.length > 0 ? { skippedFiles } : {}),
|
|
244
|
+
...(configWarnings && configWarnings.length > 0 ? { configWarnings } : {}),
|
|
140
245
|
};
|
|
141
246
|
}
|
|
142
247
|
/**
|
|
@@ -146,7 +251,7 @@ export async function scorePaths(options) {
|
|
|
146
251
|
const cwd = options.cwd ?? process.cwd();
|
|
147
252
|
const profile = options.profile ?? 'standard';
|
|
148
253
|
const threshold = options.threshold ?? DEFAULT_THRESHOLDS[profile] ?? 80;
|
|
149
|
-
const { explicit, expanded, scanRootDirs } = expandPaths(options.paths, cwd);
|
|
254
|
+
const { explicit, expanded, scanRootDirs, configWarnings } = expandPaths(options.paths, cwd);
|
|
150
255
|
if (explicit.length === 0 && expanded.length === 0) {
|
|
151
256
|
// Hard-fail: empty match must never look like a healthy suite (was ~99 PASS).
|
|
152
257
|
return hardFail(profile, threshold, [
|
|
@@ -157,7 +262,7 @@ export async function scorePaths(options) {
|
|
|
157
262
|
file: cwd,
|
|
158
263
|
dimension: 'structure',
|
|
159
264
|
},
|
|
160
|
-
]);
|
|
265
|
+
], {}, undefined, configWarnings);
|
|
161
266
|
}
|
|
162
267
|
// Directory/glob matches are excluded only on positive evidence of a
|
|
163
268
|
// non-Playwright test framework — see looksLikeNonPlaywrightTest. Paths
|
|
@@ -224,11 +329,20 @@ export async function scorePaths(options) {
|
|
|
224
329
|
assertFunctionNames.add(name);
|
|
225
330
|
}
|
|
226
331
|
}
|
|
332
|
+
// Identifiers bound to a `test.extend(...)`-derived fixture under a
|
|
333
|
+
// custom name (same-file, or imported from a local fixtures module —
|
|
334
|
+
// see test-aliases.ts), so a suite that always declares its tests via
|
|
335
|
+
// `loggedTest(...)` instead of `test(...)` is recognized correctly by
|
|
336
|
+
// both eslint-plugin-playwright's rules (fed via runEslint's settings
|
|
337
|
+
// below) and this package's own AST checks (getTestSpans/countTests,
|
|
338
|
+
// base-plugin.ts).
|
|
339
|
+
const testAliasNames = detectExtendedTestAliases(sources, importedFiles, importBoundary);
|
|
227
340
|
const eslintFindings = await runEslint({
|
|
228
341
|
files,
|
|
229
342
|
profile,
|
|
230
343
|
cwd,
|
|
231
344
|
assertFunctionNames: [...assertFunctionNames],
|
|
345
|
+
testAliasNames,
|
|
232
346
|
});
|
|
233
347
|
let totalSloc = 0;
|
|
234
348
|
let totalTests = 0;
|
|
@@ -240,12 +354,12 @@ export async function scorePaths(options) {
|
|
|
240
354
|
for (const file of files) {
|
|
241
355
|
const source = sources.get(file) ?? '';
|
|
242
356
|
const relFile = path.relative(filesBase, file) || path.basename(file);
|
|
243
|
-
const m = analyzeSource(source, relFile);
|
|
357
|
+
const m = analyzeSource(source, relFile, testAliasNames);
|
|
244
358
|
totalSloc += m.sloc;
|
|
245
359
|
native += m.locators.native;
|
|
246
360
|
raw += m.locators.raw;
|
|
247
361
|
metricFindings.push(...m.findings);
|
|
248
|
-
const spans = getTestSpans(source, relFile);
|
|
362
|
+
const spans = getTestSpans(source, relFile, testAliasNames);
|
|
249
363
|
fileSpans.set(relFile, spans);
|
|
250
364
|
// AST spans are the authoritative test count (they are also the
|
|
251
365
|
// attribution targets); the regex count is the fallback for a file
|
|
@@ -288,5 +402,9 @@ export async function scorePaths(options) {
|
|
|
288
402
|
nativeLocators: native,
|
|
289
403
|
rawLocators: raw,
|
|
290
404
|
});
|
|
291
|
-
return
|
|
405
|
+
return {
|
|
406
|
+
...result,
|
|
407
|
+
...(skippedFiles.length > 0 ? { skippedFiles } : {}),
|
|
408
|
+
...(configWarnings.length > 0 ? { configWarnings } : {}),
|
|
409
|
+
};
|
|
292
410
|
}
|