@qaguardian/playwright-score 0.3.0 → 0.5.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/METHODOLOGY.md +94 -123
- package/README.md +20 -19
- package/dist/cli.js +2 -2
- package/dist/eslint-runner.js +2 -2
- package/dist/formatters/text.js +3 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +40 -5
- package/dist/metrics.d.ts +25 -0
- package/dist/metrics.js +69 -0
- package/dist/score-engine.d.ts +44 -13
- package/dist/score-engine.js +117 -39
- package/dist/types.d.ts +14 -1
- package/package.json +2 -2
package/METHODOLOGY.md
CHANGED
|
@@ -1,174 +1,145 @@
|
|
|
1
|
-
# Playwright Spec Score
|
|
1
|
+
# Playwright Spec Score Methodology
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Scoring model:** v3 (2026-08-27)
|
|
4
4
|
**Package:** `playwright-score`
|
|
5
5
|
**Landing page:** https://qaguardian.com/open-source/playwright-score
|
|
6
6
|
**Maintainer:** [QA Guardian](https://qaguardian.com)
|
|
7
7
|
**Deterministic:** same inputs → same score
|
|
8
8
|
**AI-free:** no LLM calls in the scoring path
|
|
9
9
|
|
|
10
|
-
Any change
|
|
10
|
+
Any change that alters what a given suite scores is a new model version
|
|
11
|
+
(v4, …), documented in the CHANGELOG. Model history: v1/v2 (published as
|
|
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" at the bottom.
|
|
11
14
|
|
|
12
15
|
---
|
|
13
16
|
|
|
14
|
-
##
|
|
17
|
+
## The model in one sentence
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
(`eslint-plugin-playwright`) plus suite-level metrics (locator ratio,
|
|
18
|
-
assertion-delegation tracing, Page Object Model import resolution).
|
|
19
|
+
**Your score is the weighted share of your tests that are clean.**
|
|
19
20
|
|
|
20
21
|
---
|
|
21
22
|
|
|
22
|
-
##
|
|
23
|
+
## How it works
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
- **grade:** A (90–100), B (80–89), C (70–79), D (60–69), F (0–59)
|
|
26
|
-
- **pass:** `score >= threshold`
|
|
27
|
-
- **dimensions:** per-dimension 0–100
|
|
28
|
-
- **findings:** full list (uncapped) for humans/agents
|
|
25
|
+
### 1. Findings are attributed to tests
|
|
29
26
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
Every file is parsed and each test declaration's source span is recorded
|
|
28
|
+
(`test(...)`, `test.only/skip/fixme/slow(...)` — any form carrying a
|
|
29
|
+
function body). Each lint/metric finding is then attributed to:
|
|
33
30
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
---
|
|
31
|
+
- **the test it sits in**, when its line falls inside a test span;
|
|
32
|
+
- **every test in the file**, when it sits in a `beforeEach`/`afterEach`/
|
|
33
|
+
`beforeAll`/`afterAll` hook — setup problems affect every test that runs
|
|
34
|
+
through them;
|
|
35
|
+
- **the file, once** (module scope), for anything else — imports,
|
|
36
|
+
`describe` bodies, file-level metrics like oversized-file.
|
|
42
37
|
|
|
43
|
-
|
|
38
|
+
### 2. Tests accumulate demerits
|
|
44
39
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
```
|
|
48
|
-
native = count(getByRole|getByLabel|getByTestId|getByText|getByPlaceholder)
|
|
49
|
-
raw = count(page.locator(|frame.locator()
|
|
50
|
-
total = native + raw
|
|
51
|
-
|
|
52
|
-
locatorsScore = total === 0 ? 100 : round(100 * native / total)
|
|
53
|
-
```
|
|
40
|
+
Within each scored dimension, a test's demerit is the sum of its findings'
|
|
41
|
+
units, capped at 1:
|
|
54
42
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
43
|
+
| Severity | Demerit |
|
|
44
|
+
|---|---|
|
|
45
|
+
| error | 1.0 |
|
|
46
|
+
| warning | 0.4 |
|
|
47
|
+
| info / report-only | 0 |
|
|
58
48
|
|
|
59
|
-
|
|
49
|
+
A test is at worst *fully flawed* (demerit 1) — never more. That cap is
|
|
50
|
+
both the anti-nuke mechanism (one loop-generated repeated finding cannot
|
|
51
|
+
zero a suite) and the anti-gaming mechanism (there is no per-line density
|
|
52
|
+
left to dilute by padding files with clean code). Module-scope findings
|
|
53
|
+
are capped at 1 demerit per file per dimension.
|
|
60
54
|
|
|
61
|
-
|
|
55
|
+
### 3. Dimensions are ratios
|
|
62
56
|
|
|
63
57
|
```
|
|
64
|
-
|
|
65
|
-
- blank / whitespace-only
|
|
66
|
-
- pure comment lines (// or block-comment-only lines)
|
|
67
|
-
sloc = max(sloc, 1)
|
|
58
|
+
dimensionScore = round(100 × (1 − totalDemerit / tests)) // clamped 0–100
|
|
68
59
|
```
|
|
69
60
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
---
|
|
73
|
-
|
|
74
|
-
## Penalty dimensions
|
|
61
|
+
With zero tests the denominator is 1, so a findings-bearing zero-test
|
|
62
|
+
input still cannot score clean.
|
|
75
63
|
|
|
76
|
-
|
|
64
|
+
The **locators** dimension is independent of demerits — it is the usage
|
|
65
|
+
ratio it always was:
|
|
77
66
|
|
|
78
|
-
|
|
67
|
+
```
|
|
68
|
+
native = getByRole|getByLabel|getByTestId|getByText|getByPlaceholder calls
|
|
69
|
+
raw = .locator(...) calls + legacy string-selector actions (page.click('#x'), …)
|
|
79
70
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
| `ERROR_UNIT` | 1.0 |
|
|
83
|
-
| `WARNING_UNIT` | 0.4 |
|
|
84
|
-
| `INFO_UNIT` | 0.0 |
|
|
85
|
-
| `MAX_FINDINGS_PER_RULE_PER_FILE` | 3 (for penalty math only) |
|
|
86
|
-
| `SLOT_DIVISOR` | 25 |
|
|
87
|
-
| `MIN_SLOTS` | 4 |
|
|
88
|
-
| `K` | 0.4 |
|
|
71
|
+
locatorsScore = total === 0 ? 100 : round(100 × native / total)
|
|
72
|
+
```
|
|
89
73
|
|
|
90
|
-
|
|
74
|
+
(`no-raw-locators` / `prefer-native-locators` / `prefer-locator` findings
|
|
75
|
+
appear in `findings[]` for repair guidance but are report-only — the ratio
|
|
76
|
+
owns this dimension; counting them as demerits too would double-penalize.)
|
|
91
77
|
|
|
92
|
-
|
|
93
|
-
2. **Units:** `rawUnits = E * 1.0 + W * 0.4`
|
|
94
|
-
3. **Slots:** `slots = max(sloc / 25, 4)`
|
|
95
|
-
4. **Load:** `load = rawUnits / slots`
|
|
96
|
-
5. **Score:** `dimensionScore = clamp(0, 100, round(100 * exp(-0.4 * load)))`
|
|
78
|
+
### 4. Weighted sum
|
|
97
79
|
|
|
98
|
-
|
|
80
|
+
| Dimension | Weight | Demerit sources |
|
|
81
|
+
|---|---:|---|
|
|
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
|
+
| locators | 20 | usage ratio (above) |
|
|
85
|
+
| structure | 15 | focused tests, always-skipped declarations, nested-describe depth, oversized files (module scope) |
|
|
99
86
|
|
|
100
87
|
```
|
|
101
|
-
score = round(Σ (weight_d / 100)
|
|
102
|
-
pass = score >= threshold
|
|
88
|
+
score = round(Σ (weight_d / 100) × dimensionScore_d)
|
|
89
|
+
pass = score >= threshold // CLI default 80
|
|
90
|
+
grade = A ≥90 · B ≥80 · C ≥70 · D ≥60 · F <60
|
|
103
91
|
```
|
|
104
92
|
|
|
105
93
|
---
|
|
106
94
|
|
|
107
|
-
## Worked
|
|
95
|
+
## Worked example
|
|
108
96
|
|
|
109
|
-
|
|
97
|
+
3 tests. Test 1 has two hard-wait errors and no assertion. Test 2 is an
|
|
98
|
+
always-skipped declaration with a conditional and no assertion. Test 3 is
|
|
99
|
+
clean. Locators: 4 native, 4 raw.
|
|
110
100
|
|
|
111
|
-
|
|
101
|
+
- **hygiene**: test 1 → min(1, 2×1.0) = 1; test 2 → 0.4 → total 1.4/3 → **53**
|
|
102
|
+
- **assertions**: tests 1, 2 unasserted → 2/3 → **33**
|
|
103
|
+
- **locators**: 4/8 → **50**
|
|
104
|
+
- **structure**: skip warning 0.4/3 → **87**
|
|
105
|
+
- **score** = 0.40·53 + 0.25·33 + 0.20·50 + 0.15·87 = **53 → F**
|
|
112
106
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
- **standard score ≈ 100**
|
|
116
|
-
|
|
117
|
-
### 2. Tiny file, 1 hygiene error (~20 SLOC)
|
|
118
|
-
|
|
119
|
-
- slots = max(20/25, 4) = 4
|
|
120
|
-
- rawUnits = 1.0 → load = 0.25 → hygiene = round(100 * e^(-0.1)) ≈ **90**
|
|
121
|
-
- Other dims 100
|
|
122
|
-
- standard ≈ 0.4*90 + 0.25*100 + 0.2*100 + 0.15*100 = **96**
|
|
123
|
-
|
|
124
|
-
### 3. Medium file, 5 distinct hygiene errors (~100 SLOC)
|
|
125
|
-
|
|
126
|
-
- slots = max(100/25, 4) = 4
|
|
127
|
-
- rawUnits = 5 → load = 1.25 → hygiene = round(100 * e^(-0.5)) ≈ **61**
|
|
128
|
-
- standard ≈ 0.4*61 + 60 = **84** if other dims perfect
|
|
129
|
-
|
|
130
|
-
### 4. Large file, 20× same warning (capped to 3)
|
|
131
|
-
|
|
132
|
-
- 20 warnings same rule → cap 3 → rawUnits = 3 * 0.4 = 1.2
|
|
133
|
-
- sloc = 500 → slots = 20 → load = 0.06 → score ≈ **98** on that dim
|
|
134
|
-
- Cap prevents auto-zero
|
|
135
|
-
|
|
136
|
-
### 5. Locator ratio 2 native / 10 total
|
|
137
|
-
|
|
138
|
-
- locatorsScore = round(100 * 2/10) = **20**
|
|
139
|
-
- Independent of caps
|
|
140
|
-
|
|
141
|
-
These examples feel fair: tiny single error stays high; multi-error drops materialy; repeated same-rule does not nuke large files; locator mix is a pure ratio.
|
|
107
|
+
The same suite scored 78/C under the v1 density model. "Two of your three
|
|
108
|
+
tests are broken" should not read as a near-B.
|
|
142
109
|
|
|
143
110
|
---
|
|
144
111
|
|
|
145
|
-
##
|
|
112
|
+
## SLOC
|
|
146
113
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
QA Guardian's own internal codegen pipeline (`playwright_runner`) layers a
|
|
152
|
-
private house-rules gate on top of `standard` — see the README's
|
|
153
|
-
"QA Guardian integration" section. That layer isn't part of this package.
|
|
114
|
+
`summary.sloc` (non-blank, non-comment lines) is reported for context and
|
|
115
|
+
feeds the oversized-file check (>400 SLOC per file → structure warning,
|
|
116
|
+
module scope). It plays no other role in scoring.
|
|
154
117
|
|
|
155
118
|
---
|
|
156
119
|
|
|
157
|
-
##
|
|
158
|
-
|
|
159
|
-
See package rule docs / source `profiles.ts`. High level:
|
|
160
|
-
|
|
161
|
-
- **playwrightHygiene:** waits, force, networkidle, missing await, handles, conditionals
|
|
162
|
-
- **assertions:** expect-expect, empty tests, web-first preference
|
|
163
|
-
- **locators:** ratio metric only (for score); raw-locator ESLint in findings
|
|
164
|
-
- **structure:** focused/skipped, oversized file, describe shape
|
|
120
|
+
## Active rule set (frozen)
|
|
165
121
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
The exact ESLint rules that produce sqs-v1 findings are enabled explicitly
|
|
169
|
-
in `eslint-runner.ts` with fixed severities — the score never inherits
|
|
122
|
+
The exact ESLint rules that produce findings are enabled explicitly in
|
|
123
|
+
`eslint-runner.ts` with fixed severities — the score never inherits
|
|
170
124
|
eslint-plugin-playwright's `recommended` set at install time, so identical
|
|
171
125
|
code scores identically regardless of which plugin version npm resolves.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Why v3
|
|
130
|
+
|
|
131
|
+
v1/v2 scored findings per 25 lines of code through an exponential decay
|
|
132
|
+
with a minimum-slots floor. Three structural problems, each verified with
|
|
133
|
+
real inputs:
|
|
134
|
+
|
|
135
|
+
1. **Density is not how anyone judges a test suite.** "2 of 3 tests have
|
|
136
|
+
hard waits" is what a reviewer says; "0.5 penalty units per slot" is
|
|
137
|
+
not. v2 already fixed this for assertions (coverage ratio); v3 applies
|
|
138
|
+
the same per-test framing everywhere.
|
|
139
|
+
2. **Small suites were structurally under-penalized** — the minimum-slots
|
|
140
|
+
floor meant a 3-test suite with 2 errors, 2 assertion-free tests, and a
|
|
141
|
+
skipped test scored 78/C.
|
|
142
|
+
3. **Density is gameable**: adding clean lines of code (or clean files)
|
|
143
|
+
diluted `findings/SLOC` and raised the score without fixing anything.
|
|
144
|
+
A per-test ratio has no such lever — padding with fake tests costs
|
|
145
|
+
assertions coverage instead.
|
package/README.md
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@qaguardian/playwright-score)
|
|
5
5
|
[](./LICENSE)
|
|
6
6
|
|
|
7
|
-
**Deterministic, AI-free quality score for Playwright specs**
|
|
7
|
+
**Deterministic, AI-free quality score for Playwright specs** — the
|
|
8
|
+
weighted share of your tests that are clean.
|
|
8
9
|
|
|
9
10
|
**Website:** [qaguardian.com/open-source/playwright-score](https://qaguardian.com/open-source/playwright-score)
|
|
10
11
|
**Built by:** [QA Guardian](https://qaguardian.com) — managed Playwright E2E (AI drafts, engineers verify, you own the code)
|
|
@@ -21,30 +22,30 @@ The score never calls an LLM. AI may *generate* or *repair* code using findings;
|
|
|
21
22
|
17 public Playwright suites, scored with the published package against each
|
|
22
23
|
project's actual source — chosen to include both well-known,
|
|
23
24
|
heavily-engineered platforms and smaller, less mature projects. Not curated
|
|
24
|
-
to look good:
|
|
25
|
+
to look good: 5 of 17 fail the default threshold, for real, verifiable
|
|
25
26
|
reasons (see [VALIDATION.md](./VALIDATION.md)).
|
|
26
27
|
|
|
27
|
-
**17 suites · 1,228 files ·
|
|
28
|
+
**17 suites · 1,228 files · 5,664 tests · 12/17 passed (80% threshold)**
|
|
28
29
|
|
|
29
30
|
| Repo (source scanned) | Score | Grade | Result |
|
|
30
31
|
|---|---:|:-:|:-:|
|
|
31
32
|
| [Playwright (own TodoMVC example)](https://github.com/microsoft/playwright/tree/main/examples/todomvc) | 98/100 | A | PASS |
|
|
32
|
-
| [
|
|
33
|
-
| [Documenso](https://github.com/documenso/documenso/tree/main/packages/app-tests) | 96/100 | A | PASS |
|
|
34
|
-
| [Storybook](https://github.com/storybookjs/storybook/tree/next/code/e2e-internal) | 96/100 | A | PASS |
|
|
35
|
-
| [freeCodeCamp](https://github.com/freeCodeCamp/freeCodeCamp/tree/main/e2e) | 96/100 | A | PASS |
|
|
33
|
+
| [Storybook](https://github.com/storybookjs/storybook/tree/next/code/e2e-internal) | 95/100 | A | PASS |
|
|
36
34
|
| [n8n](https://github.com/n8n-io/n8n/tree/master/packages/testing/playwright) | 95/100 | A | PASS |
|
|
37
|
-
| [
|
|
38
|
-
| [
|
|
39
|
-
| [
|
|
40
|
-
| [
|
|
41
|
-
| [
|
|
42
|
-
| [
|
|
43
|
-
| [
|
|
44
|
-
| [
|
|
45
|
-
| [
|
|
46
|
-
| [
|
|
47
|
-
| [
|
|
35
|
+
| [freeCodeCamp](https://github.com/freeCodeCamp/freeCodeCamp/tree/main/e2e) | 94/100 | A | PASS |
|
|
36
|
+
| [dub](https://github.com/dubinc/dub/tree/main/apps/web/playwright) | 94/100 | A | PASS |
|
|
37
|
+
| [Documenso](https://github.com/documenso/documenso/tree/main/packages/app-tests) | 93/100 | A | PASS |
|
|
38
|
+
| [Supabase](https://github.com/supabase/supabase/tree/master/e2e) | 91/100 | A | PASS |
|
|
39
|
+
| [PostHog](https://github.com/PostHog/posthog/tree/master/playwright) | 88/100 | B | PASS |
|
|
40
|
+
| [Grafana](https://github.com/grafana/grafana/tree/main/e2e-playwright) | 87/100 | B | PASS |
|
|
41
|
+
| [sencho](https://github.com/Studio-Saelix/sencho/tree/main/e2e) | 85/100 | B | PASS |
|
|
42
|
+
| [Mattermost](https://github.com/mattermost/mattermost/tree/master/e2e-tests/playwright) | 83/100 | B | PASS |
|
|
43
|
+
| [novu](https://github.com/novuhq/novu/tree/next/apps/dashboard/tests) | 82/100 | B | PASS |
|
|
44
|
+
| [Immich](https://github.com/immich-app/immich/tree/main/e2e/src/specs) | 78/100 | C | FAIL |
|
|
45
|
+
| [cal.com](https://github.com/calcom/cal.com/tree/main/apps/web/playwright) | 73/100 | C | FAIL |
|
|
46
|
+
| [openplayerjs](https://github.com/openplayerjs/openplayerjs/tree/master/e2e) | 73/100 | C | FAIL |
|
|
47
|
+
| [livecodes](https://github.com/live-codes/livecodes/tree/develop/e2e) | 70/100 | C | FAIL |
|
|
48
|
+
| [TheCyberHub](https://github.com/th3cyb3rhub/TheCyberHub/tree/dev/e2e) | 56/100 | F | FAIL |
|
|
48
49
|
|
|
49
50
|
Repo names link to the exact source scanned. Full methodology, findings
|
|
50
51
|
breakdown, and `scripts/validate-corpus.sh` to reproduce every number here
|
|
@@ -53,7 +54,7 @@ report of an earlier five-suite audit lives at
|
|
|
53
54
|
[`docs/scorecard.html`](./docs/scorecard.html) — host it wherever
|
|
54
55
|
(GitHub Pages, qaguardian.com, ...).
|
|
55
56
|
|
|
56
|
-
See [METHODOLOGY.md](./METHODOLOGY.md) for the
|
|
57
|
+
See [METHODOLOGY.md](./METHODOLOGY.md) for the full scoring model, or the product write-up on the [landing page](https://qaguardian.com/open-source/playwright-score).
|
|
57
58
|
|
|
58
59
|
## Install
|
|
59
60
|
|
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,7 @@ import { formatText } from './formatters/text.js';
|
|
|
10
10
|
import { formatMarkdown } from './formatters/markdown.js';
|
|
11
11
|
import { formatSarif } from './formatters/sarif.js';
|
|
12
12
|
function printHelp() {
|
|
13
|
-
console.log(`@qaguardian/playwright-score — deterministic Playwright spec quality score
|
|
13
|
+
console.log(`@qaguardian/playwright-score — deterministic Playwright spec quality score
|
|
14
14
|
|
|
15
15
|
Usage:
|
|
16
16
|
npx -p @qaguardian/playwright-score playwright-score <paths...> [options]
|
|
@@ -31,7 +31,7 @@ Exit codes:
|
|
|
31
31
|
2 tool error
|
|
32
32
|
|
|
33
33
|
Website: https://qaguardian.com/open-source/playwright-score
|
|
34
|
-
Methodology: METHODOLOGY.md (
|
|
34
|
+
Methodology: METHODOLOGY.md (scoring model v3)
|
|
35
35
|
`);
|
|
36
36
|
}
|
|
37
37
|
// Deliberately never process.exit() right after a console.log/console.error
|
package/dist/eslint-runner.js
CHANGED
|
@@ -23,7 +23,7 @@ function buildConfig(assertFunctionNames) {
|
|
|
23
23
|
// no-wait-for-selector, expect-expect) are intentionally stricter
|
|
24
24
|
// than eslint-plugin-playwright's own "recommended" severity —
|
|
25
25
|
// these are patterns Playwright's own docs call out as anti-
|
|
26
|
-
// patterns (see playwright.dev/docs/best-practices), so
|
|
26
|
+
// patterns (see playwright.dev/docs/best-practices), so this scorer
|
|
27
27
|
// treats them as errors rather than warnings. no-networkidle and
|
|
28
28
|
// no-raw-locators aren't part of upstream "recommended" at all but
|
|
29
29
|
// are enabled here for the same reason.
|
|
@@ -87,7 +87,7 @@ function buildConfig(assertFunctionNames) {
|
|
|
87
87
|
'playwright/prefer-native-locators': 'warn',
|
|
88
88
|
// Matches eslint-plugin-playwright's own recommended severity.
|
|
89
89
|
'playwright/prefer-web-first-assertions': 'error',
|
|
90
|
-
// The rules below complete the
|
|
90
|
+
// The rules below complete the intended rule set from
|
|
91
91
|
// profiles.ts's ESLINT_RULE_MAP. Earlier versions (<=0.2.0)
|
|
92
92
|
// expected them to arrive via eslint-plugin-playwright's
|
|
93
93
|
// flat/recommended config, but the spread only handled an array
|
package/dist/formatters/text.js
CHANGED
|
@@ -12,7 +12,9 @@ export function formatText(result) {
|
|
|
12
12
|
lines.push('');
|
|
13
13
|
lines.push(`Summary: files=${result.summary.files} tests=${result.summary.tests} sloc=${result.summary.sloc} ` +
|
|
14
14
|
`findings=${result.summary.findings} (errors=${result.summary.errors} warnings=${result.summary.warnings}) ` +
|
|
15
|
-
`locators native/raw=${result.summary.nativeLocators}/${result.summary.rawLocators}`
|
|
15
|
+
`locators native/raw=${result.summary.nativeLocators}/${result.summary.rawLocators} ` +
|
|
16
|
+
`tests asserting=${result.summary.tests - result.summary.unassertedTests}/${result.summary.tests} ` +
|
|
17
|
+
`clean tests=${result.summary.cleanTests}/${result.summary.tests}`);
|
|
16
18
|
if (result.findings.length) {
|
|
17
19
|
lines.push('');
|
|
18
20
|
lines.push('Findings:');
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ScoreOptions, ScoreResult } from './types.js';
|
|
2
2
|
export type { ScoreResult, ScoreOptions, Finding, ProfileName } from './types.js';
|
|
3
|
-
export { computeScore,
|
|
3
|
+
export { computeScore, MODEL, locatorsScore, gradeFromScore, ratioDimensionScore, } from './score-engine.js';
|
|
4
4
|
export { countSloc } from './sloc.js';
|
|
5
5
|
export { countLocators, analyzeSource } from './metrics.js';
|
|
6
6
|
export { formatJson } from './formatters/json.js';
|
|
@@ -8,6 +8,6 @@ export { formatText } from './formatters/text.js';
|
|
|
8
8
|
export { formatMarkdown } from './formatters/markdown.js';
|
|
9
9
|
export { formatSarif } from './formatters/sarif.js';
|
|
10
10
|
/**
|
|
11
|
-
* Score Playwright spec files. Deterministic and AI-free
|
|
11
|
+
* Score Playwright spec files. Deterministic and AI-free.
|
|
12
12
|
*/
|
|
13
13
|
export declare function scorePaths(options: ScoreOptions): Promise<ScoreResult>;
|
package/dist/index.js
CHANGED
|
@@ -4,10 +4,10 @@ import { globSync } from 'glob';
|
|
|
4
4
|
import { runEslint } from './eslint-runner.js';
|
|
5
5
|
import { commonAncestorDir } from './fs-util.js';
|
|
6
6
|
import { collectLocallyImportedFiles } from './import-graph.js';
|
|
7
|
-
import { analyzeSource, countLocators, findLocalAssertionHelperNames, looksLikeNonPlaywrightTest, } from './metrics.js';
|
|
7
|
+
import { analyzeSource, countLocators, findLocalAssertionHelperNames, getTestSpans, looksLikeNonPlaywrightTest, } from './metrics.js';
|
|
8
8
|
import { DEFAULT_THRESHOLDS } from './profiles.js';
|
|
9
9
|
import { computeScore } from './score-engine.js';
|
|
10
|
-
export { computeScore,
|
|
10
|
+
export { computeScore, MODEL, locatorsScore, gradeFromScore, ratioDimensionScore, } from './score-engine.js';
|
|
11
11
|
export { countSloc } from './sloc.js';
|
|
12
12
|
export { countLocators, analyzeSource } from './metrics.js';
|
|
13
13
|
export { formatJson } from './formatters/json.js';
|
|
@@ -110,7 +110,7 @@ function expandPaths(inputs, cwd) {
|
|
|
110
110
|
}
|
|
111
111
|
function hardFail(profile, threshold, findings, extra = {}, skippedFiles) {
|
|
112
112
|
return {
|
|
113
|
-
scoreVersion: '
|
|
113
|
+
scoreVersion: 'v3',
|
|
114
114
|
profile,
|
|
115
115
|
score: 0,
|
|
116
116
|
grade: 'F',
|
|
@@ -125,6 +125,8 @@ function hardFail(profile, threshold, findings, extra = {}, skippedFiles) {
|
|
|
125
125
|
warnings: findings.filter((f) => f.severity === 'warning').length,
|
|
126
126
|
nativeLocators: 0,
|
|
127
127
|
rawLocators: 0,
|
|
128
|
+
unassertedTests: 0,
|
|
129
|
+
cleanTests: 0,
|
|
128
130
|
...extra,
|
|
129
131
|
},
|
|
130
132
|
dimensions: {
|
|
@@ -138,7 +140,7 @@ function hardFail(profile, threshold, findings, extra = {}, skippedFiles) {
|
|
|
138
140
|
};
|
|
139
141
|
}
|
|
140
142
|
/**
|
|
141
|
-
* Score Playwright spec files. Deterministic and AI-free
|
|
143
|
+
* Score Playwright spec files. Deterministic and AI-free.
|
|
142
144
|
*/
|
|
143
145
|
export async function scorePaths(options) {
|
|
144
146
|
const cwd = options.cwd ?? process.cwd();
|
|
@@ -233,15 +235,24 @@ export async function scorePaths(options) {
|
|
|
233
235
|
let native = 0;
|
|
234
236
|
let raw = 0;
|
|
235
237
|
const metricFindings = [];
|
|
238
|
+
const fileTests = {};
|
|
239
|
+
const fileSpans = new Map();
|
|
236
240
|
for (const file of files) {
|
|
237
241
|
const source = sources.get(file) ?? '';
|
|
238
242
|
const relFile = path.relative(filesBase, file) || path.basename(file);
|
|
239
243
|
const m = analyzeSource(source, relFile);
|
|
240
244
|
totalSloc += m.sloc;
|
|
241
|
-
totalTests += m.tests;
|
|
242
245
|
native += m.locators.native;
|
|
243
246
|
raw += m.locators.raw;
|
|
244
247
|
metricFindings.push(...m.findings);
|
|
248
|
+
const spans = getTestSpans(source, relFile);
|
|
249
|
+
fileSpans.set(relFile, spans);
|
|
250
|
+
// AST spans are the authoritative test count (they are also the
|
|
251
|
+
// attribution targets); the regex count is the fallback for a file
|
|
252
|
+
// the parser can't read — which hard-fails below anyway.
|
|
253
|
+
const testCount = spans.tests.length > 0 ? spans.tests.length : m.tests;
|
|
254
|
+
fileTests[relFile] = testCount;
|
|
255
|
+
totalTests += testCount;
|
|
245
256
|
}
|
|
246
257
|
for (const [file, source] of importedFiles) {
|
|
247
258
|
const counts = countLocators(source, file);
|
|
@@ -249,6 +260,29 @@ export async function scorePaths(options) {
|
|
|
249
260
|
raw += counts.raw;
|
|
250
261
|
}
|
|
251
262
|
const findings = [...eslintFindings, ...metricFindings];
|
|
263
|
+
// Attribute each finding to the test it sits in (model v3): a finding
|
|
264
|
+
// inside a test span belongs to that test; inside a hook span it affects
|
|
265
|
+
// every test in the file; anything else — imports, describe bodies,
|
|
266
|
+
// file-level metrics like oversized-file — is module scope, counted once
|
|
267
|
+
// per file.
|
|
268
|
+
for (const f of findings) {
|
|
269
|
+
const spans = fileSpans.get(f.file);
|
|
270
|
+
if (!spans || f.line === undefined) {
|
|
271
|
+
f.scope = 'module';
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const idx = spans.tests.findIndex((t) => f.line !== undefined && f.line >= t.startLine && f.line <= t.endLine);
|
|
275
|
+
if (idx >= 0) {
|
|
276
|
+
f.scope = 'test';
|
|
277
|
+
f.testKey = `${f.file}#${idx}`;
|
|
278
|
+
}
|
|
279
|
+
else if (spans.hooks.some((h) => f.line !== undefined && f.line >= h.startLine && f.line <= h.endLine)) {
|
|
280
|
+
f.scope = 'hook';
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
f.scope = 'module';
|
|
284
|
+
}
|
|
285
|
+
}
|
|
252
286
|
const parseErrors = findings.filter((f) => f.rule === 'playwright-score/parse-error');
|
|
253
287
|
if (parseErrors.length > 0) {
|
|
254
288
|
// A file that isn't valid JS/TS can't be executed, let alone scored on
|
|
@@ -270,6 +304,7 @@ export async function scorePaths(options) {
|
|
|
270
304
|
sloc: Math.max(totalSloc, 1),
|
|
271
305
|
files: files.length,
|
|
272
306
|
tests: totalTests,
|
|
307
|
+
fileTests,
|
|
273
308
|
nativeLocators: native,
|
|
274
309
|
rawLocators: raw,
|
|
275
310
|
});
|
package/dist/metrics.d.ts
CHANGED
|
@@ -38,6 +38,31 @@ export declare function countLocators(source: string, file?: string): LocatorCou
|
|
|
38
38
|
* function against each imported dependency file too).
|
|
39
39
|
*/
|
|
40
40
|
export declare function findLocalAssertionHelperNames(source: string, file?: string): string[];
|
|
41
|
+
export interface SourceSpan {
|
|
42
|
+
startLine: number;
|
|
43
|
+
endLine: number;
|
|
44
|
+
}
|
|
45
|
+
export interface TestSpans {
|
|
46
|
+
/** Outermost test declaration spans (test/test.only/test.skip/test.fixme/
|
|
47
|
+
* test.slow calls that carry a function body). Order is source order and
|
|
48
|
+
* is the stable per-file test index used for finding attribution. */
|
|
49
|
+
tests: SourceSpan[];
|
|
50
|
+
/** Hook spans (test.beforeEach/afterEach/beforeAll/afterAll and their
|
|
51
|
+
* bare-identifier forms) — findings inside these affect every test in
|
|
52
|
+
* the file, not one. */
|
|
53
|
+
hooks: SourceSpan[];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* AST spans of test declarations and hooks, for attributing findings to
|
|
57
|
+
* the test they actually sit in. Only calls carrying a function argument
|
|
58
|
+
* count — that is what makes `test.skip('t', async () => {...})` (an
|
|
59
|
+
* always-skipped declaration) a span while the documented runtime form
|
|
60
|
+
* `test.skip(condition, reason)` inside a test body is not, and likewise
|
|
61
|
+
* for `test.fixme()` used as a statement. Spans nested inside another test
|
|
62
|
+
* span (rare, but e.g. a helper declaring a test inside a test would be
|
|
63
|
+
* malformed anyway) are dropped so each line maps to at most one test.
|
|
64
|
+
*/
|
|
65
|
+
export declare function getTestSpans(source: string, file?: string): TestSpans;
|
|
41
66
|
/** Count test( / test.only( / test.skip( / test.fixme( style declarations (approx). */
|
|
42
67
|
export declare function countTests(source: string): number;
|
|
43
68
|
export declare function findOversizedFile(source: string, file: string, maxSloc?: number): Finding[];
|
package/dist/metrics.js
CHANGED
|
@@ -313,6 +313,75 @@ export function findLocalAssertionHelperNames(source, file) {
|
|
|
313
313
|
}
|
|
314
314
|
return [...names];
|
|
315
315
|
}
|
|
316
|
+
const TEST_MODIFIERS = new Set(['only', 'skip', 'fixme', 'slow']);
|
|
317
|
+
const HOOK_NAMES = new Set(['beforeEach', 'afterEach', 'beforeAll', 'afterAll']);
|
|
318
|
+
function hasFunctionArg(node) {
|
|
319
|
+
const args = node.arguments;
|
|
320
|
+
return (args ?? []).some((a) => a?.type === 'ArrowFunctionExpression' || a?.type === 'FunctionExpression');
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* AST spans of test declarations and hooks, for attributing findings to
|
|
324
|
+
* the test they actually sit in. Only calls carrying a function argument
|
|
325
|
+
* count — that is what makes `test.skip('t', async () => {...})` (an
|
|
326
|
+
* always-skipped declaration) a span while the documented runtime form
|
|
327
|
+
* `test.skip(condition, reason)` inside a test body is not, and likewise
|
|
328
|
+
* for `test.fixme()` used as a statement. Spans nested inside another test
|
|
329
|
+
* span (rare, but e.g. a helper declaring a test inside a test would be
|
|
330
|
+
* malformed anyway) are dropped so each line maps to at most one test.
|
|
331
|
+
*/
|
|
332
|
+
export function getTestSpans(source, file) {
|
|
333
|
+
const tests = [];
|
|
334
|
+
const hooks = [];
|
|
335
|
+
const ast = parseSource(source, file);
|
|
336
|
+
if (!ast)
|
|
337
|
+
return { tests, hooks };
|
|
338
|
+
walk(ast, (node) => {
|
|
339
|
+
if (node.type !== 'CallExpression')
|
|
340
|
+
return;
|
|
341
|
+
const callee = node.callee;
|
|
342
|
+
const loc = node.loc;
|
|
343
|
+
if (!loc?.start?.line || !loc?.end?.line)
|
|
344
|
+
return;
|
|
345
|
+
const span = { startLine: loc.start.line, endLine: loc.end.line };
|
|
346
|
+
let isTest = false;
|
|
347
|
+
let isHook = false;
|
|
348
|
+
if (callee?.type === 'Identifier') {
|
|
349
|
+
const name = callee.name;
|
|
350
|
+
if (name === 'test' || name === 'it')
|
|
351
|
+
isTest = true;
|
|
352
|
+
else if (name && HOOK_NAMES.has(name))
|
|
353
|
+
isHook = true;
|
|
354
|
+
}
|
|
355
|
+
else if (callee?.type === 'MemberExpression' && !callee.computed) {
|
|
356
|
+
const obj = callee.object;
|
|
357
|
+
const prop = callee.property;
|
|
358
|
+
if (obj?.type === 'Identifier' && (obj.name === 'test' || obj.name === 'it') && prop?.type === 'Identifier') {
|
|
359
|
+
if (TEST_MODIFIERS.has(prop.name ?? ''))
|
|
360
|
+
isTest = true;
|
|
361
|
+
else if (HOOK_NAMES.has(prop.name ?? ''))
|
|
362
|
+
isHook = true;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (!isTest && !isHook)
|
|
366
|
+
return;
|
|
367
|
+
if (!hasFunctionArg(node))
|
|
368
|
+
return;
|
|
369
|
+
if (isTest)
|
|
370
|
+
tests.push(span);
|
|
371
|
+
else
|
|
372
|
+
hooks.push(span);
|
|
373
|
+
});
|
|
374
|
+
// Keep outermost test spans only, in source order.
|
|
375
|
+
tests.sort((a, b) => a.startLine - b.startLine || b.endLine - a.endLine);
|
|
376
|
+
const outer = [];
|
|
377
|
+
for (const s of tests) {
|
|
378
|
+
const last = outer[outer.length - 1];
|
|
379
|
+
if (last && s.startLine >= last.startLine && s.endLine <= last.endLine)
|
|
380
|
+
continue;
|
|
381
|
+
outer.push(s);
|
|
382
|
+
}
|
|
383
|
+
return { tests: outer, hooks };
|
|
384
|
+
}
|
|
316
385
|
const TEST_NON_DECL_RE = /\btest\.(describe|step|beforeEach|afterEach|beforeAll|afterAll|use|setTimeout|info|expect)\b/;
|
|
317
386
|
/** Count test( / test.only( / test.skip( / test.fixme( style declarations (approx). */
|
|
318
387
|
export function countTests(source) {
|
package/dist/score-engine.d.ts
CHANGED
|
@@ -1,21 +1,51 @@
|
|
|
1
1
|
import type { DimensionName, Finding, Grade, ProfileName, ScoreResult } from './types.js';
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Scoring model v3 — "the weighted share of your tests that are clean."
|
|
4
|
+
*
|
|
5
|
+
* Every finding is attributed to the test it actually sits in (AST test
|
|
6
|
+
* spans, resolved in index.ts). Each test accumulates demerit units for a
|
|
7
|
+
* dimension — an error is a full demerit (1.0), a warning 0.4 — capped at
|
|
8
|
+
* 1 per test: a test is at worst fully flawed, never more. Findings in a
|
|
9
|
+
* before/after hook demerit every test in that file (setup problems affect
|
|
10
|
+
* every test that runs through them); module-level findings count once per
|
|
11
|
+
* file. A dimension's score is simply:
|
|
12
|
+
*
|
|
13
|
+
* score = 100 × (1 − totalDemerit / tests)
|
|
14
|
+
*
|
|
15
|
+
* The locators dimension stays the native/(native+raw) usage ratio.
|
|
16
|
+
* The final score is the weighted sum of dimensions.
|
|
17
|
+
*
|
|
18
|
+
* There is deliberately no per-SLOC density, no slot divisor, no minimum
|
|
19
|
+
* slots, no exponential decay, and no per-rule finding caps (the per-test
|
|
20
|
+
* cap of 1 is the anti-nuke mechanism, and it is also the anti-gaming
|
|
21
|
+
* mechanism: padding a suite with clean lines no longer dilutes anything,
|
|
22
|
+
* and padding it with fake clean tests costs assertions coverage). Earlier
|
|
23
|
+
* models (≤0.4.0, published as "sqs-v1"/"sqs-v2") used density math and
|
|
24
|
+
* scored e.g. a 3-test suite with two hard-wait errors, two assertion-free
|
|
25
|
+
* tests, and a skipped test at 78/C; model v3 scores it 53/F, which is
|
|
26
|
+
* what it is.
|
|
27
|
+
*/
|
|
28
|
+
export declare const MODEL: {
|
|
29
|
+
readonly scoreVersion: "v3";
|
|
30
|
+
readonly ERROR_DEMERIT: 1;
|
|
31
|
+
readonly WARNING_DEMERIT: 0.4;
|
|
32
|
+
readonly INFO_DEMERIT: 0;
|
|
33
|
+
/** A single test's demerit ceiling per dimension. */
|
|
34
|
+
readonly MAX_DEMERIT_PER_TEST: 1;
|
|
35
|
+
/** Module-level findings' demerit ceiling per (file, dimension). */
|
|
36
|
+
readonly MAX_DEMERIT_PER_FILE_MODULE: 1;
|
|
12
37
|
};
|
|
13
38
|
declare const PENALTY_DIMENSIONS: DimensionName[];
|
|
14
39
|
export declare function gradeFromScore(score: number): Grade;
|
|
15
40
|
export declare function locatorsScore(native: number, raw: number): number;
|
|
16
|
-
/**
|
|
17
|
-
export
|
|
18
|
-
|
|
41
|
+
/** Tests per file, keyed by the same relative file path findings carry. */
|
|
42
|
+
export type FileTestCounts = Record<string, number>;
|
|
43
|
+
/**
|
|
44
|
+
* 100 × (1 − demerit share). With zero tests there is nothing to take a
|
|
45
|
+
* share of — module/hook demerits then act on a denominator of 1 so a
|
|
46
|
+
* findings-bearing zero-test input still can't score clean.
|
|
47
|
+
*/
|
|
48
|
+
export declare function ratioDimensionScore(findings: Finding[], dimension: DimensionName, fileTests: FileTestCounts, tests: number): number;
|
|
19
49
|
export declare function computeScore(input: {
|
|
20
50
|
profile: ProfileName;
|
|
21
51
|
threshold: number;
|
|
@@ -23,6 +53,7 @@ export declare function computeScore(input: {
|
|
|
23
53
|
sloc: number;
|
|
24
54
|
files: number;
|
|
25
55
|
tests: number;
|
|
56
|
+
fileTests: FileTestCounts;
|
|
26
57
|
nativeLocators: number;
|
|
27
58
|
rawLocators: number;
|
|
28
59
|
}): ScoreResult;
|
package/dist/score-engine.js
CHANGED
|
@@ -1,14 +1,39 @@
|
|
|
1
1
|
import { PROFILE_WEIGHTS } from './profiles.js';
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Scoring model v3 — "the weighted share of your tests that are clean."
|
|
4
|
+
*
|
|
5
|
+
* Every finding is attributed to the test it actually sits in (AST test
|
|
6
|
+
* spans, resolved in index.ts). Each test accumulates demerit units for a
|
|
7
|
+
* dimension — an error is a full demerit (1.0), a warning 0.4 — capped at
|
|
8
|
+
* 1 per test: a test is at worst fully flawed, never more. Findings in a
|
|
9
|
+
* before/after hook demerit every test in that file (setup problems affect
|
|
10
|
+
* every test that runs through them); module-level findings count once per
|
|
11
|
+
* file. A dimension's score is simply:
|
|
12
|
+
*
|
|
13
|
+
* score = 100 × (1 − totalDemerit / tests)
|
|
14
|
+
*
|
|
15
|
+
* The locators dimension stays the native/(native+raw) usage ratio.
|
|
16
|
+
* The final score is the weighted sum of dimensions.
|
|
17
|
+
*
|
|
18
|
+
* There is deliberately no per-SLOC density, no slot divisor, no minimum
|
|
19
|
+
* slots, no exponential decay, and no per-rule finding caps (the per-test
|
|
20
|
+
* cap of 1 is the anti-nuke mechanism, and it is also the anti-gaming
|
|
21
|
+
* mechanism: padding a suite with clean lines no longer dilutes anything,
|
|
22
|
+
* and padding it with fake clean tests costs assertions coverage). Earlier
|
|
23
|
+
* models (≤0.4.0, published as "sqs-v1"/"sqs-v2") used density math and
|
|
24
|
+
* scored e.g. a 3-test suite with two hard-wait errors, two assertion-free
|
|
25
|
+
* tests, and a skipped test at 78/C; model v3 scores it 53/F, which is
|
|
26
|
+
* what it is.
|
|
27
|
+
*/
|
|
28
|
+
export const MODEL = {
|
|
29
|
+
scoreVersion: 'v3',
|
|
30
|
+
ERROR_DEMERIT: 1.0,
|
|
31
|
+
WARNING_DEMERIT: 0.4,
|
|
32
|
+
INFO_DEMERIT: 0.0,
|
|
33
|
+
/** A single test's demerit ceiling per dimension. */
|
|
34
|
+
MAX_DEMERIT_PER_TEST: 1.0,
|
|
35
|
+
/** Module-level findings' demerit ceiling per (file, dimension). */
|
|
36
|
+
MAX_DEMERIT_PER_FILE_MODULE: 1.0,
|
|
12
37
|
};
|
|
13
38
|
const PENALTY_DIMENSIONS = ['playwrightHygiene', 'assertions', 'structure'];
|
|
14
39
|
export function gradeFromScore(score) {
|
|
@@ -28,39 +53,80 @@ export function locatorsScore(native, raw) {
|
|
|
28
53
|
return 100;
|
|
29
54
|
return Math.round((100 * native) / total);
|
|
30
55
|
}
|
|
31
|
-
function
|
|
56
|
+
function severityDemerit(severity) {
|
|
32
57
|
if (severity === 'error')
|
|
33
|
-
return
|
|
58
|
+
return MODEL.ERROR_DEMERIT;
|
|
34
59
|
if (severity === 'warning')
|
|
35
|
-
return
|
|
36
|
-
return
|
|
60
|
+
return MODEL.WARNING_DEMERIT;
|
|
61
|
+
return MODEL.INFO_DEMERIT;
|
|
37
62
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
const
|
|
63
|
+
function demeritsForDimension(findings, dimension, fileTests) {
|
|
64
|
+
const perTest = new Map();
|
|
65
|
+
const perFileHook = new Map();
|
|
66
|
+
const perFileModule = new Map();
|
|
42
67
|
for (const f of findings) {
|
|
43
|
-
if (f.reportOnly)
|
|
68
|
+
if (f.dimension !== dimension || f.reportOnly)
|
|
44
69
|
continue;
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
if (n >= SQS_V1.MAX_FINDINGS_PER_RULE_PER_FILE)
|
|
70
|
+
const d = severityDemerit(f.severity);
|
|
71
|
+
if (d === 0)
|
|
48
72
|
continue;
|
|
49
|
-
|
|
50
|
-
|
|
73
|
+
if (f.scope === 'test' && f.testKey) {
|
|
74
|
+
perTest.set(f.testKey, (perTest.get(f.testKey) ?? 0) + d);
|
|
75
|
+
}
|
|
76
|
+
else if (f.scope === 'hook') {
|
|
77
|
+
perFileHook.set(f.file, (perFileHook.get(f.file) ?? 0) + d);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
perFileModule.set(f.file, (perFileModule.get(f.file) ?? 0) + d);
|
|
81
|
+
}
|
|
51
82
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
83
|
+
let total = 0;
|
|
84
|
+
const flawedTestKeys = new Set();
|
|
85
|
+
// Per-test demerits: each test's own findings plus its file's hook
|
|
86
|
+
// findings, capped at 1 — a test is at worst fully flawed.
|
|
87
|
+
for (const [file, count] of Object.entries(fileTests)) {
|
|
88
|
+
const hookUnits = perFileHook.get(file) ?? 0;
|
|
89
|
+
for (let i = 0; i < count; i++) {
|
|
90
|
+
const key = `${file}#${i}`;
|
|
91
|
+
const units = (perTest.get(key) ?? 0) + hookUnits;
|
|
92
|
+
if (units <= 0)
|
|
93
|
+
continue;
|
|
94
|
+
total += Math.min(MODEL.MAX_DEMERIT_PER_TEST, units);
|
|
95
|
+
flawedTestKeys.add(key);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// A test-scoped finding whose file has no counted tests (should not
|
|
99
|
+
// happen, but attribution and counting are separate passes) still
|
|
100
|
+
// counts, capped the same way.
|
|
101
|
+
for (const [key, units] of perTest) {
|
|
102
|
+
const file = key.slice(0, key.lastIndexOf('#'));
|
|
103
|
+
if (fileTests[file] !== undefined)
|
|
104
|
+
continue;
|
|
105
|
+
total += Math.min(MODEL.MAX_DEMERIT_PER_TEST, units);
|
|
106
|
+
flawedTestKeys.add(key);
|
|
107
|
+
}
|
|
108
|
+
// Hook findings in a file with zero tests (setup-only file swept in):
|
|
109
|
+
// count once per file, like module scope.
|
|
110
|
+
for (const [file, units] of perFileHook) {
|
|
111
|
+
if ((fileTests[file] ?? 0) > 0)
|
|
112
|
+
continue;
|
|
113
|
+
total += Math.min(MODEL.MAX_DEMERIT_PER_FILE_MODULE, units);
|
|
114
|
+
}
|
|
115
|
+
// Module-level findings count once per file.
|
|
116
|
+
for (const units of perFileModule.values()) {
|
|
117
|
+
total += Math.min(MODEL.MAX_DEMERIT_PER_FILE_MODULE, units);
|
|
60
118
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
119
|
+
return { total, flawedTestKeys };
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* 100 × (1 − demerit share). With zero tests there is nothing to take a
|
|
123
|
+
* share of — module/hook demerits then act on a denominator of 1 so a
|
|
124
|
+
* findings-bearing zero-test input still can't score clean.
|
|
125
|
+
*/
|
|
126
|
+
export function ratioDimensionScore(findings, dimension, fileTests, tests) {
|
|
127
|
+
const { total } = demeritsForDimension(findings, dimension, fileTests);
|
|
128
|
+
const denominator = Math.max(tests, 1);
|
|
129
|
+
const score = Math.round(100 * (1 - total / denominator));
|
|
64
130
|
return Math.min(100, Math.max(0, score));
|
|
65
131
|
}
|
|
66
132
|
export function computeScore(input) {
|
|
@@ -71,10 +137,10 @@ export function computeScore(input) {
|
|
|
71
137
|
// score instead of a hard crash.
|
|
72
138
|
const weights = PROFILE_WEIGHTS[input.profile] ?? PROFILE_WEIGHTS.standard;
|
|
73
139
|
const dims = {
|
|
74
|
-
playwrightHygiene:
|
|
75
|
-
assertions:
|
|
140
|
+
playwrightHygiene: ratioDimensionScore(input.findings, 'playwrightHygiene', input.fileTests, input.tests),
|
|
141
|
+
assertions: ratioDimensionScore(input.findings, 'assertions', input.fileTests, input.tests),
|
|
76
142
|
locators: locatorsScore(input.nativeLocators, input.rawLocators),
|
|
77
|
-
structure:
|
|
143
|
+
structure: ratioDimensionScore(input.findings, 'structure', input.fileTests, input.tests),
|
|
78
144
|
};
|
|
79
145
|
let score = 0;
|
|
80
146
|
for (const [dim, weight] of Object.entries(weights)) {
|
|
@@ -87,8 +153,18 @@ export function computeScore(input) {
|
|
|
87
153
|
score = Math.round(score);
|
|
88
154
|
const errors = input.findings.filter((f) => f.severity === 'error').length;
|
|
89
155
|
const warnings = input.findings.filter((f) => f.severity === 'warning').length;
|
|
156
|
+
// Clean tests: no demerit in any penalty dimension. (Locator usage is a
|
|
157
|
+
// suite-level ratio, not a per-test property.)
|
|
158
|
+
const flawed = new Set();
|
|
159
|
+
for (const dim of PENALTY_DIMENSIONS) {
|
|
160
|
+
for (const key of demeritsForDimension(input.findings, dim, input.fileTests)
|
|
161
|
+
.flawedTestKeys) {
|
|
162
|
+
flawed.add(key);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const cleanTests = Math.max(0, input.tests - flawed.size);
|
|
90
166
|
return {
|
|
91
|
-
scoreVersion:
|
|
167
|
+
scoreVersion: MODEL.scoreVersion,
|
|
92
168
|
profile: input.profile,
|
|
93
169
|
score,
|
|
94
170
|
grade: gradeFromScore(score),
|
|
@@ -103,6 +179,8 @@ export function computeScore(input) {
|
|
|
103
179
|
warnings,
|
|
104
180
|
nativeLocators: input.nativeLocators,
|
|
105
181
|
rawLocators: input.rawLocators,
|
|
182
|
+
unassertedTests: input.findings.filter((f) => f.rule === 'playwright/expect-expect' && !f.reportOnly).length,
|
|
183
|
+
cleanTests,
|
|
106
184
|
},
|
|
107
185
|
dimensions: dims,
|
|
108
186
|
findings: input.findings,
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type ScoreVersion = '
|
|
1
|
+
export type ScoreVersion = 'v3';
|
|
2
2
|
export type ProfileName = 'standard';
|
|
3
3
|
export type Grade = 'A' | 'B' | 'C' | 'D' | 'F';
|
|
4
4
|
export type Severity = 'error' | 'warning' | 'info';
|
|
@@ -13,6 +13,13 @@ export interface Finding {
|
|
|
13
13
|
dimension: DimensionName;
|
|
14
14
|
/** When true, finding is listed but does not contribute penalty units */
|
|
15
15
|
reportOnly?: boolean;
|
|
16
|
+
/** Where the finding sits: inside a specific test ('test', with testKey),
|
|
17
|
+
* inside a before/after hook ('hook' — affects every test in the file),
|
|
18
|
+
* or at module level ('module' — counted once per file). Set during
|
|
19
|
+
* attribution in index.ts; findings without it are treated as 'module'. */
|
|
20
|
+
scope?: 'test' | 'hook' | 'module';
|
|
21
|
+
/** `${file}#${testIndex}` when scope === 'test'. */
|
|
22
|
+
testKey?: string;
|
|
16
23
|
}
|
|
17
24
|
export interface ScoreDimensions {
|
|
18
25
|
playwrightHygiene: number;
|
|
@@ -29,6 +36,12 @@ export interface ScoreSummary {
|
|
|
29
36
|
warnings: number;
|
|
30
37
|
nativeLocators: number;
|
|
31
38
|
rawLocators: number;
|
|
39
|
+
/** Tests with no recognized assertion (uncapped playwright/expect-expect
|
|
40
|
+
* census, after delegation resolution). */
|
|
41
|
+
unassertedTests: number;
|
|
42
|
+
/** Tests with no demerit in any dimension — the headline the score is
|
|
43
|
+
* built from: the weighted share of tests that are clean. */
|
|
44
|
+
cleanTests: number;
|
|
32
45
|
}
|
|
33
46
|
export interface ScoreResult {
|
|
34
47
|
scoreVersion: ScoreVersion;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qaguardian/playwright-score",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Deterministic, AI-free Playwright spec quality score (sqs-
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Deterministic, AI-free Playwright spec quality score (sqs-v2).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|