@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 CHANGED
@@ -1,13 +1,18 @@
1
1
  # Playwright Spec Score (SQS) Methodology
2
2
 
3
- **Score version:** `sqs-v1`
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-v2`, …).
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`, `assertions`, `structure`.
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, empty tests, web-first preference
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
  [![npm version](https://img.shields.io/npm/v/@qaguardian/playwright-score.svg)](https://www.npmjs.com/package/@qaguardian/playwright-score)
5
5
  [![license](https://img.shields.io/npm/l/@qaguardian/playwright-score.svg)](./LICENSE)
6
6
 
7
- **Deterministic, AI-free quality score for Playwright specs** (`sqs-v1`).
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
- See [METHODOLOGY.md](./METHODOLOGY.md) for the frozen formula, [VALIDATION.md](./VALIDATION.md) for real-world results against public repos (cal.com, Grafana, Mattermost, ...) and a re-runnable script to reproduce them, or the full product write-up on the [landing page](https://qaguardian.com/open-source/playwright-score).
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
 
@@ -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) {
@@ -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-v1',
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: {
@@ -1,7 +1,19 @@
1
1
  import type { DimensionName, Finding, Grade, ProfileName, ScoreResult } from './types.js';
2
- /** sqs-v1 frozen constants — change requires sqs-v2 */
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-v1";
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;
@@ -1,7 +1,7 @@
1
1
  import { PROFILE_WEIGHTS } from './profiles.js';
2
- /** sqs-v1 frozen constants — change requires sqs-v2 */
3
- export const SQS_V1 = {
4
- scoreVersion: 'sqs-v1',
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 SQS_V1.ERROR_UNIT;
79
+ return SQS_V2.ERROR_UNIT;
34
80
  if (severity === 'warning')
35
- return SQS_V1.WARNING_UNIT;
36
- return SQS_V1.INFO_UNIT;
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 >= SQS_V1.MAX_FINDINGS_PER_RULE_PER_FILE)
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 / SQS_V1.SLOT_DIVISOR, SQS_V1.MIN_SLOTS);
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(-SQS_V1.K * load));
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: penaltyDimensionScore(input.findings, 'assertions', input.sloc),
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: SQS_V1.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-v1';
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.2.0",
4
- "description": "Deterministic, AI-free Playwright spec quality score (sqs-v1).",
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",