@qaguardian/playwright-score 0.2.0 → 0.4.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 +60 -7
- package/README.md +39 -2
- package/dist/eslint-runner.js +25 -7
- package/dist/formatters/text.js +2 -1
- package/dist/index.js +2 -1
- package/dist/score-engine.d.ts +36 -2
- package/dist/score-engine.js +58 -11
- package/dist/types.d.ts +5 -1
- package/package.json +2 -2
package/METHODOLOGY.md
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
# Playwright Spec Score (SQS) Methodology
|
|
2
2
|
|
|
3
|
-
**Score version:** `sqs-
|
|
3
|
+
**Score version:** `sqs-v2`
|
|
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 to formulas, weights, SLOC definition, caps, or constants requires a new version (`sqs-
|
|
10
|
+
Any change to formulas, weights, SLOC definition, caps, or constants requires a new version (`sqs-v3`, …).
|
|
11
|
+
|
|
12
|
+
**sqs-v2 (2026-08-27)** changed exactly one thing versus sqs-v1: the
|
|
13
|
+
assertions dimension is now a per-test coverage ratio instead of a
|
|
14
|
+
finding-density penalty (see the Assertions dimension section). All other
|
|
15
|
+
formulas, weights, and constants are unchanged from sqs-v1.
|
|
11
16
|
|
|
12
17
|
---
|
|
13
18
|
|
|
@@ -40,6 +45,43 @@ assertion-delegation tracing, Page Object Model import resolution).
|
|
|
40
45
|
|
|
41
46
|
---
|
|
42
47
|
|
|
48
|
+
## Assertions dimension (coverage ratio × quality decay)
|
|
49
|
+
|
|
50
|
+
sqs-v1 fed "test has no assertions" through the same density math as
|
|
51
|
+
hygiene smells. That misread coverage badly on small suites: 2 of 3 tests
|
|
52
|
+
asserting nothing scored 82/100 on this dimension, because two findings in
|
|
53
|
+
a tiny file barely register per-SLOC. Whether a test asserts anything is a
|
|
54
|
+
fraction of tests, not a findings-per-SLOC rate — so, like the locators
|
|
55
|
+
dimension, the primary signal is a ratio:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
unassertedTests = count(playwright/expect-expect findings) // UNCAPPED census
|
|
59
|
+
coverage = tests === 0 ? 1 : clamp01((tests - unassertedTests) / tests)
|
|
60
|
+
|
|
61
|
+
auxLoad = penalty load (same units/caps/slots as below) of the
|
|
62
|
+
OTHER assertion rules only: valid-expect,
|
|
63
|
+
no-standalone-expect, prefer-web-first-assertions
|
|
64
|
+
|
|
65
|
+
assertionsScore = clamp(0, 100, round(100 * coverage * e^(-K * auxLoad)))
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Notes:
|
|
69
|
+
|
|
70
|
+
- `expect-expect` findings are excluded from the density term — they ARE
|
|
71
|
+
the coverage term; counting them twice would double-penalize.
|
|
72
|
+
- The census is deliberately uncapped: the per-(file, rule) cap exists to
|
|
73
|
+
stop one repeated smell from nuking a density score, but hiding 2 of 5
|
|
74
|
+
assertion-free tests would misstate coverage.
|
|
75
|
+
- All of expect-expect's delegation machinery (same-file helper discovery,
|
|
76
|
+
imported-helper tracing, `assertFunctionPatterns`) applies before the
|
|
77
|
+
census is taken, so delegated assertions do not count as unasserted.
|
|
78
|
+
- `tests === 0` (nothing matched but helpers) keeps coverage neutral at 1.
|
|
79
|
+
|
|
80
|
+
Worked example (the motivating case): 3 tests, 2 without assertions, no
|
|
81
|
+
aux findings → coverage = 1/3 → **assertions = 33** (sqs-v1 said 82).
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
43
85
|
## Locators dimension (ratio)
|
|
44
86
|
|
|
45
87
|
Independent of finding-penalty math:
|
|
@@ -54,7 +96,7 @@ locatorsScore = total === 0 ? 100 : round(100 * native / total)
|
|
|
54
96
|
|
|
55
97
|
ESLint `no-raw-locators` / `prefer-native-locators` still appear in `findings[]` for repair guidance.
|
|
56
98
|
They are **not** double-counted into the locators dimension via exponential penalties.
|
|
57
|
-
For sqs-v1 they map to **report-only** for scoring (severity still shown in findings; units = 0 for penalty dimensions).
|
|
99
|
+
For sqs-v2 (as in sqs-v1) they map to **report-only** for scoring (severity still shown in findings; units = 0 for penalty dimensions).
|
|
58
100
|
|
|
59
101
|
---
|
|
60
102
|
|
|
@@ -73,9 +115,10 @@ Used for penalty density. JSDoc / decorative comments do not inflate slots.
|
|
|
73
115
|
|
|
74
116
|
## Penalty dimensions
|
|
75
117
|
|
|
76
|
-
Applies to: `playwrightHygiene`, `
|
|
118
|
+
Applies to: `playwrightHygiene`, `structure`, and the *aux* term of
|
|
119
|
+
`assertions` (see above; the assertions primary term is a coverage ratio).
|
|
77
120
|
|
|
78
|
-
### Constants (frozen sqs-v1)
|
|
121
|
+
### Constants (frozen sqs-v2; identical values to sqs-v1)
|
|
79
122
|
|
|
80
123
|
| Constant | Value |
|
|
81
124
|
|---|---|
|
|
@@ -159,6 +202,16 @@ private house-rules gate on top of `standard` — see the README's
|
|
|
159
202
|
See package rule docs / source `profiles.ts`. High level:
|
|
160
203
|
|
|
161
204
|
- **playwrightHygiene:** waits, force, networkidle, missing await, handles, conditionals
|
|
162
|
-
- **assertions:** expect-expect
|
|
205
|
+
- **assertions:** per-test coverage census (expect-expect) + quality decay (valid-expect, standalone, web-first)
|
|
163
206
|
- **locators:** ratio metric only (for score); raw-locator ESLint in findings
|
|
164
|
-
- **structure:** focused/skipped, oversized file, describe shape
|
|
207
|
+
- **structure:** focused/skipped, oversized file, describe shape
|
|
208
|
+
|
|
209
|
+
### Active rule set (frozen)
|
|
210
|
+
|
|
211
|
+
The exact ESLint rules that produce findings are enabled explicitly
|
|
212
|
+
in `eslint-runner.ts` with fixed severities — the score never inherits
|
|
213
|
+
eslint-plugin-playwright's `recommended` set at install time, so identical
|
|
214
|
+
code scores identically regardless of which plugin version npm resolves.
|
|
215
|
+
(Versions ≤0.2.0 intended to inherit `recommended` but a config-shape bug
|
|
216
|
+
dropped it entirely; 0.3.0 froze the intended set explicitly. See
|
|
217
|
+
CHANGELOG.)
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@qaguardian/playwright-score)
|
|
5
5
|
[](./LICENSE)
|
|
6
6
|
|
|
7
|
-
**Deterministic, AI-free quality score for Playwright specs** (`sqs-
|
|
7
|
+
**Deterministic, AI-free quality score for Playwright specs** (`sqs-v2`).
|
|
8
8
|
|
|
9
9
|
**Website:** [qaguardian.com/open-source/playwright-score](https://qaguardian.com/open-source/playwright-score)
|
|
10
10
|
**Built by:** [QA Guardian](https://qaguardian.com) — managed Playwright E2E (AI drafts, engineers verify, you own the code)
|
|
@@ -16,7 +16,44 @@ Page Object Model import resolution, ...).
|
|
|
16
16
|
|
|
17
17
|
The score never calls an LLM. AI may *generate* or *repair* code using findings; **rules grade code**.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
## Real-world results
|
|
20
|
+
|
|
21
|
+
17 public Playwright suites, scored with the published package against each
|
|
22
|
+
project's actual source — chosen to include both well-known,
|
|
23
|
+
heavily-engineered platforms and smaller, less mature projects. Not curated
|
|
24
|
+
to look good: 3 of 17 fail the default threshold, for real, verifiable
|
|
25
|
+
reasons (see [VALIDATION.md](./VALIDATION.md)).
|
|
26
|
+
|
|
27
|
+
**17 suites · 1,228 files · 6,037 tests · 14/17 passed (80% threshold)**
|
|
28
|
+
|
|
29
|
+
| Repo (source scanned) | Score | Grade | Result |
|
|
30
|
+
|---|---:|:-:|:-:|
|
|
31
|
+
| [Playwright (own TodoMVC example)](https://github.com/microsoft/playwright/tree/main/examples/todomvc) | 98/100 | A | PASS |
|
|
32
|
+
| [Supabase](https://github.com/supabase/supabase/tree/master/e2e) | 97/100 | A | PASS |
|
|
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 |
|
|
36
|
+
| [n8n](https://github.com/n8n-io/n8n/tree/master/packages/testing/playwright) | 95/100 | A | PASS |
|
|
37
|
+
| [dub](https://github.com/dubinc/dub/tree/main/apps/web/playwright) | 95/100 | A | PASS |
|
|
38
|
+
| [novu](https://github.com/novuhq/novu/tree/next/apps/dashboard/tests) | 93/100 | A | PASS |
|
|
39
|
+
| [Grafana](https://github.com/grafana/grafana/tree/main/e2e-playwright) | 91/100 | A | PASS |
|
|
40
|
+
| [PostHog](https://github.com/PostHog/posthog/tree/master/playwright) | 90/100 | A | PASS |
|
|
41
|
+
| [sencho](https://github.com/Studio-Saelix/sencho/tree/main/e2e) | 89/100 | B | PASS |
|
|
42
|
+
| [Mattermost](https://github.com/mattermost/mattermost/tree/master/e2e-tests/playwright) | 88/100 | B | PASS |
|
|
43
|
+
| [Immich](https://github.com/immich-app/immich/tree/main/e2e/src/specs) | 85/100 | B | PASS |
|
|
44
|
+
| [cal.com](https://github.com/calcom/cal.com/tree/main/apps/web/playwright) | 84/100 | B | PASS |
|
|
45
|
+
| [livecodes](https://github.com/live-codes/livecodes/tree/develop/e2e) | 78/100 | C | FAIL |
|
|
46
|
+
| [openplayerjs](https://github.com/openplayerjs/openplayerjs/tree/master/e2e) | 77/100 | C | FAIL |
|
|
47
|
+
| [TheCyberHub](https://github.com/th3cyb3rhub/TheCyberHub/tree/dev/e2e) | 68/100 | D | FAIL |
|
|
48
|
+
|
|
49
|
+
Repo names link to the exact source scanned. Full methodology, findings
|
|
50
|
+
breakdown, and `scripts/validate-corpus.sh` to reproduce every number here
|
|
51
|
+
yourself live in [VALIDATION.md](./VALIDATION.md). A self-contained visual
|
|
52
|
+
report of an earlier five-suite audit lives at
|
|
53
|
+
[`docs/scorecard.html`](./docs/scorecard.html) — host it wherever
|
|
54
|
+
(GitHub Pages, qaguardian.com, ...).
|
|
55
|
+
|
|
56
|
+
See [METHODOLOGY.md](./METHODOLOGY.md) for the frozen formula, or the full product write-up on the [landing page](https://qaguardian.com/open-source/playwright-score).
|
|
20
57
|
|
|
21
58
|
## Install
|
|
22
59
|
|
package/dist/eslint-runner.js
CHANGED
|
@@ -5,8 +5,6 @@ import playwright from 'eslint-plugin-playwright';
|
|
|
5
5
|
import { basePlugin } from './rules/base-plugin.js';
|
|
6
6
|
import { mapRule } from './profiles.js';
|
|
7
7
|
function buildConfig(assertFunctionNames) {
|
|
8
|
-
const playwrightRecommended = playwright.configs?.['flat/recommended'] ??
|
|
9
|
-
playwright.configs?.recommended;
|
|
10
8
|
const base = [
|
|
11
9
|
{
|
|
12
10
|
files: ['**/*.{ts,tsx,js,jsx,mjs,cjs}'],
|
|
@@ -89,14 +87,34 @@ function buildConfig(assertFunctionNames) {
|
|
|
89
87
|
'playwright/prefer-native-locators': 'warn',
|
|
90
88
|
// Matches eslint-plugin-playwright's own recommended severity.
|
|
91
89
|
'playwright/prefer-web-first-assertions': 'error',
|
|
90
|
+
// The rules below complete the sqs-v1 rule set from
|
|
91
|
+
// profiles.ts's ESLINT_RULE_MAP. Earlier versions (<=0.2.0)
|
|
92
|
+
// expected them to arrive via eslint-plugin-playwright's
|
|
93
|
+
// flat/recommended config, but the spread only handled an array
|
|
94
|
+
// shape while the plugin exports a single config object — so the
|
|
95
|
+
// entire recommended set was silently dropped and these rules
|
|
96
|
+
// never ran (METHODOLOGY.md's hygiene dimension lists
|
|
97
|
+
// "conditionals" and "handles", yet neither could ever fire).
|
|
98
|
+
// They are enabled explicitly and frozen here on purpose:
|
|
99
|
+
// eslint-plugin-playwright is a ^range dependency, and inheriting
|
|
100
|
+
// whatever its recommended set says at install time would make
|
|
101
|
+
// identical code score differently across installs. Severities
|
|
102
|
+
// mirror upstream flat/recommended as of eslint-plugin-playwright
|
|
103
|
+
// 2.x. require-top-level-describe stays disabled deliberately —
|
|
104
|
+
// it is stylistic, not in upstream recommended, and would
|
|
105
|
+
// penalize idiomatic describe-less suites.
|
|
106
|
+
'playwright/no-conditional-in-test': 'warn',
|
|
107
|
+
'playwright/no-conditional-expect': 'warn',
|
|
108
|
+
'playwright/no-element-handle': 'warn',
|
|
109
|
+
'playwright/no-eval': 'warn',
|
|
110
|
+
'playwright/no-page-pause': 'warn',
|
|
111
|
+
'playwright/no-useless-await': 'warn',
|
|
112
|
+
'playwright/valid-expect': 'error',
|
|
113
|
+
'playwright/no-standalone-expect': 'error',
|
|
114
|
+
'playwright/max-nested-describe': 'warn',
|
|
92
115
|
},
|
|
93
116
|
},
|
|
94
117
|
];
|
|
95
|
-
// Prefer spreading official recommended flat config when available
|
|
96
|
-
if (Array.isArray(playwrightRecommended)) {
|
|
97
|
-
const configs = playwrightRecommended;
|
|
98
|
-
return [...configs, base[0]];
|
|
99
|
-
}
|
|
100
118
|
return base;
|
|
101
119
|
}
|
|
102
120
|
function toSeverity(eslintSeverity, override) {
|
package/dist/formatters/text.js
CHANGED
|
@@ -12,7 +12,8 @@ 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}`);
|
|
16
17
|
if (result.findings.length) {
|
|
17
18
|
lines.push('');
|
|
18
19
|
lines.push('Findings:');
|
package/dist/index.js
CHANGED
|
@@ -110,7 +110,7 @@ function expandPaths(inputs, cwd) {
|
|
|
110
110
|
}
|
|
111
111
|
function hardFail(profile, threshold, findings, extra = {}, skippedFiles) {
|
|
112
112
|
return {
|
|
113
|
-
scoreVersion: 'sqs-
|
|
113
|
+
scoreVersion: 'sqs-v2',
|
|
114
114
|
profile,
|
|
115
115
|
score: 0,
|
|
116
116
|
grade: 'F',
|
|
@@ -125,6 +125,7 @@ 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,
|
|
128
129
|
...extra,
|
|
129
130
|
},
|
|
130
131
|
dimensions: {
|
package/dist/score-engine.d.ts
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import type { DimensionName, Finding, Grade, ProfileName, ScoreResult } from './types.js';
|
|
2
|
-
/** sqs-
|
|
2
|
+
/** sqs-v2 frozen constants — change requires sqs-v3 */
|
|
3
|
+
export declare const SQS_V2: {
|
|
4
|
+
readonly scoreVersion: "sqs-v2";
|
|
5
|
+
readonly ERROR_UNIT: 1;
|
|
6
|
+
readonly WARNING_UNIT: 0.4;
|
|
7
|
+
readonly INFO_UNIT: 0;
|
|
8
|
+
readonly MAX_FINDINGS_PER_RULE_PER_FILE: 3;
|
|
9
|
+
readonly SLOT_DIVISOR: 25;
|
|
10
|
+
readonly MIN_SLOTS: 4;
|
|
11
|
+
readonly K: 0.4;
|
|
12
|
+
};
|
|
13
|
+
/** @deprecated sqs-v1 alias kept for one release so pinned callers get a
|
|
14
|
+
* type-level nudge instead of a hard break; constants are identical. */
|
|
3
15
|
export declare const SQS_V1: {
|
|
4
|
-
readonly scoreVersion: "sqs-
|
|
16
|
+
readonly scoreVersion: "sqs-v2";
|
|
5
17
|
readonly ERROR_UNIT: 1;
|
|
6
18
|
readonly WARNING_UNIT: 0.4;
|
|
7
19
|
readonly INFO_UNIT: 0;
|
|
@@ -13,6 +25,28 @@ export declare const SQS_V1: {
|
|
|
13
25
|
declare const PENALTY_DIMENSIONS: DimensionName[];
|
|
14
26
|
export declare function gradeFromScore(score: number): Grade;
|
|
15
27
|
export declare function locatorsScore(native: number, raw: number): number;
|
|
28
|
+
/**
|
|
29
|
+
* sqs-v2 assertions dimension: a per-test coverage ratio, decayed by the
|
|
30
|
+
* density of the remaining assertion-quality findings.
|
|
31
|
+
*
|
|
32
|
+
* sqs-v1 fed "test has no assertions" through the same density math as
|
|
33
|
+
* hygiene smells, which misread coverage badly on small suites: 2 of 3
|
|
34
|
+
* tests asserting nothing scored 82/100 on this dimension. Whether a test
|
|
35
|
+
* asserts anything is a fraction of tests, not a findings-per-SLOC rate —
|
|
36
|
+
* so, like the locators dimension, the primary signal is now a ratio:
|
|
37
|
+
*
|
|
38
|
+
* coverage = (tests - unassertedTests) / tests // uncapped census
|
|
39
|
+
* aux = density load of the OTHER assertion rules
|
|
40
|
+
* (valid-expect, no-standalone-expect,
|
|
41
|
+
* prefer-web-first-assertions), capped as usual
|
|
42
|
+
* score = round(100 * coverage * e^(-K * aux))
|
|
43
|
+
*
|
|
44
|
+
* expect-expect findings are excluded from the density term — they ARE the
|
|
45
|
+
* coverage term, and counting them twice would double-penalize.
|
|
46
|
+
* tests === 0 (nothing but helpers matched) keeps coverage at 1 and lets
|
|
47
|
+
* the aux findings, if any, carry the dimension.
|
|
48
|
+
*/
|
|
49
|
+
export declare function assertionsScore(findings: Finding[], tests: number, sloc: number): number;
|
|
16
50
|
/** Cap findings per (file, rule) for penalty math only */
|
|
17
51
|
export declare function applyPerRuleCap(findings: Finding[]): Finding[];
|
|
18
52
|
export declare function penaltyDimensionScore(findings: Finding[], dimension: DimensionName, sloc: number): number;
|
package/dist/score-engine.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { PROFILE_WEIGHTS } from './profiles.js';
|
|
2
|
-
/** sqs-
|
|
3
|
-
export const
|
|
4
|
-
scoreVersion: 'sqs-
|
|
2
|
+
/** sqs-v2 frozen constants — change requires sqs-v3 */
|
|
3
|
+
export const SQS_V2 = {
|
|
4
|
+
scoreVersion: 'sqs-v2',
|
|
5
5
|
ERROR_UNIT: 1.0,
|
|
6
6
|
WARNING_UNIT: 0.4,
|
|
7
7
|
INFO_UNIT: 0.0,
|
|
@@ -10,6 +10,18 @@ export const SQS_V1 = {
|
|
|
10
10
|
MIN_SLOTS: 4,
|
|
11
11
|
K: 0.4,
|
|
12
12
|
};
|
|
13
|
+
/** @deprecated sqs-v1 alias kept for one release so pinned callers get a
|
|
14
|
+
* type-level nudge instead of a hard break; constants are identical. */
|
|
15
|
+
export const SQS_V1 = SQS_V2;
|
|
16
|
+
/**
|
|
17
|
+
* The rule whose findings are, by construction, a census of "this test
|
|
18
|
+
* declaration contains no recognized assertion" — one finding per test,
|
|
19
|
+
* with delegation to same-file/imported helpers already resolved upstream
|
|
20
|
+
* (see eslint-runner.ts's assertFunctionNames plumbing). sqs-v2 counts
|
|
21
|
+
* these uncapped as the assertions-coverage numerator instead of feeding
|
|
22
|
+
* them through density math.
|
|
23
|
+
*/
|
|
24
|
+
const UNASSERTED_TEST_RULE = 'playwright/expect-expect';
|
|
13
25
|
const PENALTY_DIMENSIONS = ['playwrightHygiene', 'assertions', 'structure'];
|
|
14
26
|
export function gradeFromScore(score) {
|
|
15
27
|
if (score >= 90)
|
|
@@ -28,12 +40,46 @@ export function locatorsScore(native, raw) {
|
|
|
28
40
|
return 100;
|
|
29
41
|
return Math.round((100 * native) / total);
|
|
30
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* sqs-v2 assertions dimension: a per-test coverage ratio, decayed by the
|
|
45
|
+
* density of the remaining assertion-quality findings.
|
|
46
|
+
*
|
|
47
|
+
* sqs-v1 fed "test has no assertions" through the same density math as
|
|
48
|
+
* hygiene smells, which misread coverage badly on small suites: 2 of 3
|
|
49
|
+
* tests asserting nothing scored 82/100 on this dimension. Whether a test
|
|
50
|
+
* asserts anything is a fraction of tests, not a findings-per-SLOC rate —
|
|
51
|
+
* so, like the locators dimension, the primary signal is now a ratio:
|
|
52
|
+
*
|
|
53
|
+
* coverage = (tests - unassertedTests) / tests // uncapped census
|
|
54
|
+
* aux = density load of the OTHER assertion rules
|
|
55
|
+
* (valid-expect, no-standalone-expect,
|
|
56
|
+
* prefer-web-first-assertions), capped as usual
|
|
57
|
+
* score = round(100 * coverage * e^(-K * aux))
|
|
58
|
+
*
|
|
59
|
+
* expect-expect findings are excluded from the density term — they ARE the
|
|
60
|
+
* coverage term, and counting them twice would double-penalize.
|
|
61
|
+
* tests === 0 (nothing but helpers matched) keeps coverage at 1 and lets
|
|
62
|
+
* the aux findings, if any, carry the dimension.
|
|
63
|
+
*/
|
|
64
|
+
export function assertionsScore(findings, tests, sloc) {
|
|
65
|
+
const unasserted = findings.filter((f) => f.rule === UNASSERTED_TEST_RULE && !f.reportOnly).length;
|
|
66
|
+
const coverage = tests <= 0 ? 1 : Math.min(1, Math.max(0, (tests - unasserted) / tests));
|
|
67
|
+
const aux = findings.filter((f) => f.dimension === 'assertions' && f.rule !== UNASSERTED_TEST_RULE);
|
|
68
|
+
const capped = applyPerRuleCap(aux);
|
|
69
|
+
let rawUnits = 0;
|
|
70
|
+
for (const f of capped)
|
|
71
|
+
rawUnits += severityUnit(f.severity);
|
|
72
|
+
const slots = Math.max(sloc / SQS_V2.SLOT_DIVISOR, SQS_V2.MIN_SLOTS);
|
|
73
|
+
const load = rawUnits / slots;
|
|
74
|
+
const score = Math.round(100 * coverage * Math.exp(-SQS_V2.K * load));
|
|
75
|
+
return Math.min(100, Math.max(0, score));
|
|
76
|
+
}
|
|
31
77
|
function severityUnit(severity) {
|
|
32
78
|
if (severity === 'error')
|
|
33
|
-
return
|
|
79
|
+
return SQS_V2.ERROR_UNIT;
|
|
34
80
|
if (severity === 'warning')
|
|
35
|
-
return
|
|
36
|
-
return
|
|
81
|
+
return SQS_V2.WARNING_UNIT;
|
|
82
|
+
return SQS_V2.INFO_UNIT;
|
|
37
83
|
}
|
|
38
84
|
/** Cap findings per (file, rule) for penalty math only */
|
|
39
85
|
export function applyPerRuleCap(findings) {
|
|
@@ -44,7 +90,7 @@ export function applyPerRuleCap(findings) {
|
|
|
44
90
|
continue;
|
|
45
91
|
const key = `${f.file}::${f.rule}`;
|
|
46
92
|
const n = counts.get(key) ?? 0;
|
|
47
|
-
if (n >=
|
|
93
|
+
if (n >= SQS_V2.MAX_FINDINGS_PER_RULE_PER_FILE)
|
|
48
94
|
continue;
|
|
49
95
|
counts.set(key, n + 1);
|
|
50
96
|
out.push(f);
|
|
@@ -58,9 +104,9 @@ export function penaltyDimensionScore(findings, dimension, sloc) {
|
|
|
58
104
|
for (const f of capped) {
|
|
59
105
|
rawUnits += severityUnit(f.severity);
|
|
60
106
|
}
|
|
61
|
-
const slots = Math.max(sloc /
|
|
107
|
+
const slots = Math.max(sloc / SQS_V2.SLOT_DIVISOR, SQS_V2.MIN_SLOTS);
|
|
62
108
|
const load = rawUnits / slots;
|
|
63
|
-
const score = Math.round(100 * Math.exp(-
|
|
109
|
+
const score = Math.round(100 * Math.exp(-SQS_V2.K * load));
|
|
64
110
|
return Math.min(100, Math.max(0, score));
|
|
65
111
|
}
|
|
66
112
|
export function computeScore(input) {
|
|
@@ -72,7 +118,7 @@ export function computeScore(input) {
|
|
|
72
118
|
const weights = PROFILE_WEIGHTS[input.profile] ?? PROFILE_WEIGHTS.standard;
|
|
73
119
|
const dims = {
|
|
74
120
|
playwrightHygiene: penaltyDimensionScore(input.findings, 'playwrightHygiene', input.sloc),
|
|
75
|
-
assertions:
|
|
121
|
+
assertions: assertionsScore(input.findings, input.tests, input.sloc),
|
|
76
122
|
locators: locatorsScore(input.nativeLocators, input.rawLocators),
|
|
77
123
|
structure: penaltyDimensionScore(input.findings, 'structure', input.sloc),
|
|
78
124
|
};
|
|
@@ -88,7 +134,7 @@ export function computeScore(input) {
|
|
|
88
134
|
const errors = input.findings.filter((f) => f.severity === 'error').length;
|
|
89
135
|
const warnings = input.findings.filter((f) => f.severity === 'warning').length;
|
|
90
136
|
return {
|
|
91
|
-
scoreVersion:
|
|
137
|
+
scoreVersion: SQS_V2.scoreVersion,
|
|
92
138
|
profile: input.profile,
|
|
93
139
|
score,
|
|
94
140
|
grade: gradeFromScore(score),
|
|
@@ -103,6 +149,7 @@ export function computeScore(input) {
|
|
|
103
149
|
warnings,
|
|
104
150
|
nativeLocators: input.nativeLocators,
|
|
105
151
|
rawLocators: input.rawLocators,
|
|
152
|
+
unassertedTests: input.findings.filter((f) => f.rule === UNASSERTED_TEST_RULE && !f.reportOnly).length,
|
|
106
153
|
},
|
|
107
154
|
dimensions: dims,
|
|
108
155
|
findings: input.findings,
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type ScoreVersion = 'sqs-
|
|
1
|
+
export type ScoreVersion = 'sqs-v2';
|
|
2
2
|
export type ProfileName = 'standard';
|
|
3
3
|
export type Grade = 'A' | 'B' | 'C' | 'D' | 'F';
|
|
4
4
|
export type Severity = 'error' | 'warning' | 'info';
|
|
@@ -29,6 +29,10 @@ export interface ScoreSummary {
|
|
|
29
29
|
warnings: number;
|
|
30
30
|
nativeLocators: number;
|
|
31
31
|
rawLocators: number;
|
|
32
|
+
/** Count of test declarations with no recognized assertion (sqs-v2
|
|
33
|
+
* assertions-coverage numerator input; equals the uncapped
|
|
34
|
+
* playwright/expect-expect finding count). */
|
|
35
|
+
unassertedTests: number;
|
|
32
36
|
}
|
|
33
37
|
export interface ScoreResult {
|
|
34
38
|
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.4.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",
|