@qaguardian/playwright-score 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/METHODOLOGY.md +41 -0
  3. package/README.md +1 -1
  4. package/dist/cli.js +61 -5
  5. package/dist/confidence.d.ts +30 -0
  6. package/dist/confidence.js +67 -0
  7. package/dist/eslint-runner.js +18 -0
  8. package/dist/formatters/json.js +4 -1
  9. package/dist/formatters/markdown.d.ts +2 -0
  10. package/dist/formatters/markdown.js +15 -4
  11. package/dist/formatters/report-css.d.ts +2 -0
  12. package/dist/formatters/report-css.js +68 -0
  13. package/dist/formatters/report.d.ts +20 -0
  14. package/dist/formatters/report.js +279 -0
  15. package/dist/formatters/text.d.ts +6 -1
  16. package/dist/formatters/text.js +73 -28
  17. package/dist/index.js +17 -6
  18. package/dist/profiles.js +12 -0
  19. package/dist/report-model.d.ts +52 -0
  20. package/dist/report-model.js +162 -0
  21. package/dist/report-source.d.ts +11 -0
  22. package/dist/report-source.js +96 -0
  23. package/dist/rule-copy.d.ts +14 -0
  24. package/dist/rule-copy.js +215 -0
  25. package/dist/rules/ast-util.d.ts +60 -0
  26. package/dist/rules/ast-util.js +140 -0
  27. package/dist/rules/base-plugin.d.ts +19 -5
  28. package/dist/rules/base-plugin.js +75 -7
  29. package/dist/rules/no-brittle-selector.d.ts +3 -0
  30. package/dist/rules/no-brittle-selector.js +150 -0
  31. package/dist/rules/no-excessive-timeout.d.ts +2 -0
  32. package/dist/rules/no-excessive-timeout.js +87 -0
  33. package/dist/rules/no-fixme-test.d.ts +14 -0
  34. package/dist/rules/no-fixme-test.js +53 -0
  35. package/dist/rules/no-self-fulfilling-fallback.d.ts +29 -0
  36. package/dist/rules/no-self-fulfilling-fallback.js +221 -0
  37. package/dist/rules/no-shared-mutable-state.d.ts +4 -0
  38. package/dist/rules/no-shared-mutable-state.js +186 -0
  39. package/dist/rules/no-swallowed-assertion.d.ts +13 -0
  40. package/dist/rules/no-swallowed-assertion.js +113 -0
  41. package/dist/rules/no-unawaited-action.d.ts +2 -0
  42. package/dist/rules/no-unawaited-action.js +77 -0
  43. package/dist/rules/no-unawaited-expect-value.d.ts +20 -0
  44. package/dist/rules/no-unawaited-expect-value.js +46 -0
  45. package/dist/rules/no-weak-assertion.d.ts +19 -0
  46. package/dist/rules/no-weak-assertion.js +82 -0
  47. package/dist/rules/rule-util.d.ts +56 -0
  48. package/dist/rules/rule-util.js +253 -0
  49. package/dist/score-engine.d.ts +5 -0
  50. package/dist/score-engine.js +9 -9
  51. package/dist/suite-name.d.ts +11 -0
  52. package/dist/suite-name.js +36 -0
  53. package/dist/test-aliases.js +144 -3
  54. package/dist/types.d.ts +14 -0
  55. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -10,6 +10,92 @@ parsing accuracy, not grading policy — can still move a suite's score
10
10
  without itself being a new model version; 2.1.0 below is exactly that
11
11
  case, and says so explicitly.
12
12
 
13
+ ## 2.3.0 — 2026-11-14
14
+
15
+ Scoring weights and the scoring model (v4) are unchanged, but **scores can
16
+ fall**: this release adds ten rules that catch tests which would miss a real
17
+ bug or flake, and suites that use those patterns now lose points. Nothing
18
+ gets more lenient. Two of the new rules are report-only (shown in the report,
19
+ no effect on the score): `pwscore/no-self-fulfilling-fallback` (info, no real
20
+ suite hit it yet, validated on fixtures only) and `pwscore/no-brittle-selector`
21
+ (its cost is already counted by the raw-locator ratio), plus
22
+ `pwscore/serial-mode-dependency` (info).
23
+
24
+ New rules, flake risk (QAG-287 slice 2b):
25
+
26
+ - `pwscore/no-unawaited-action` (error, hygiene): Playwright action or navigation on a Page/Locator as a bare statement without `await`.
27
+ - `pwscore/no-brittle-selector` (warning, report-only): XPath, `:nth-child`, 4+ combinator CSS chains, generated class names, positional `.first()/.nth()/.last()` on a raw `.locator()`.
28
+ - `pwscore/no-shared-mutable-state` (warning, structure) and report-only `pwscore/serial-mode-dependency` (info): state written in one test and read in another. Not flagged when the writing and the reading test both sit in a serial describe (`test.describe.serial` or `test.describe.configure({ mode: 'serial' })`, at file or describe level), wherever the variable is declared; flagged when either test is outside serial scope. Plus a report-only note for serial describe blocks.
29
+ - `pwscore/no-excessive-timeout` (warning, hygiene): option timeouts over 60s (exactly 60s is not flagged), `test.setTimeout` >= 5 min, unconditional `test.slow()`.
30
+
31
+ New rules, tests that would miss a real bug (QAG-287 slice 2a):
32
+
33
+ - `pwscore/no-fixme-test` (structure, warning): unconditional `test.fixme('t', fn)`, `test.describe.fixme(...)`, in-body `test.fixme()` and `test.fixme(true, ...)`. `test.fixme(condition, reason)` stays clean.
34
+ - `pwscore/no-swallowed-assertion` (assertions, warning): `expect(...)` inside a `try` whose `catch` neither rethrows, asserts, fails, returns a value, records state nor calls `.skip(...)`; and `await expect(...).catch(() => {})` with the result discarded. Retry loops are not flagged. Warning, not error, because some real suites use deliberate soft checks.
35
+ - `pwscore/no-weak-assertion` (assertions, error): `expect(count).toBeGreaterThanOrEqual(0)`, `expect(<Locator>).toBeTruthy()/toBeDefined()/not.toBeNull()`, `expect(await x.isVisible()).toBeDefined()`.
36
+ - `pwscore/no-unawaited-expect-value` (assertions, error): a Promise-returning Playwright call such as `page.title()` or `locator.innerText()` used without `await` inside `expect()` with a non-web-first matcher. Replaces the misleading `playwright/prefer-web-first-assertions` message on the same line.
37
+ - `pwscore/no-self-fulfilling-fallback` (assertions, info, report-only): a negated state check (or a `catch` of a failed expect) whose fallback fills in or selects the value itself (click-only fallbacks are setup and stay clean), followed by an assertion on the same locator.
38
+
39
+ Fix (test-factory recognition): tests declared through a factory function (`const t = testWithConfig({...}); t('x', ...)`, in the same or an imported file) are now recognised as tests, removing false `playwright/no-standalone-expect` errors.
40
+
41
+ New report format (QAG-288), output only:
42
+
43
+ - New `--format report`: a one-page, self-contained HTML suite report
44
+ (headline, dimension bars, top findings with code excerpts, flake risk,
45
+ items needing a human check, next steps). Light/dark, phone width, prints
46
+ to one or two pages. All source and paths are HTML-escaped. `--output` is
47
+ accepted as an alias of `--out`. New `--name <suite name>` sets the report
48
+ headline. Without it the label is the last two path segments (three when the
49
+ folder is generic, e.g. `e2e` or `tests`); it never includes a home directory
50
+ or anything above it, and a home directory itself is labelled "Playwright suite".
51
+ - Findings are ranked with a display-only confidence tier (high / medium /
52
+ needs-check) and every rule has a plain-English title. The text and
53
+ markdown formats list findings by severity, then confidence, instead of
54
+ file order.
55
+ - The report states how many tests need a human check (the report-only
56
+ `playwright-score/assertion-unverified` findings, kept out of the score),
57
+ groups them by file, and reads "why it matters" in business terms. A suite
58
+ hard-failed by an unparseable file says so in the headline.
59
+ - Results gain an optional in-memory `baseDir`, used only by the report to
60
+ read code excerpts. It is never printed and is not part of the JSON, SARIF,
61
+ markdown or HTML output. A rule without plain-English copy renders with a
62
+ generic title instead of failing.
63
+
64
+ Report and terminal polish (QAG-288 slice 3b), output only, scores unchanged:
65
+
66
+ - The HTML report is now a Summary page plus a Details part. The Summary
67
+ (score, score by area, top 3 findings as rows without code, next steps) prints
68
+ on one A4 or Letter page; Details (code excerpts, findings 4-5, the
69
+ human-check list) starts on page 2. The duplicate "Flake risk" table is gone:
70
+ each top finding names the file with the most occurrences, and Details lists
71
+ the most flake-prone files.
72
+ - A line under the score explains it (weighted average of four areas, the
73
+ grade bands, the pass bar; "your bar" when `--threshold` was passed). "clean"
74
+ became "have no issues".
75
+ - Confidence reads Definite / Likely / Needs a human check, with one legend
76
+ line and neutral tags (fixes the 3.95:1 contrast failure of the amber tag).
77
+ The four areas are named Reliability, Checks, Resilience to UI changes and
78
+ Maintainability (the JSON keys are unchanged).
79
+ - A suite with a syntax error or no tests shows "Not scored" instead of
80
+ "0/100 F", with no zero bars and consistent counts.
81
+ - Rule copy says "can" instead of "will" for outcomes static analysis cannot
82
+ prove. The weakest area gets a slot in the top 3 when it scores under 75.
83
+ - A rule that throws while scoring no longer vanishes silently: the run still
84
+ finishes, the CLI prints `warning: rule <id> crashed on <n> node(s); results
85
+ may be incomplete` to stderr, the text, markdown and report formats say "Some
86
+ checks could not run on this suite; results may be incomplete.", and the JSON
87
+ result gains `ruleCrashes: [{ rule, count }]`, only when a rule crashed (JSON
88
+ of a clean run is unchanged).
89
+ - A suite with no tests no longer lists the working directory as an issue, and
90
+ it is "Not scored" rather than "0/100 F".
91
+ - Without `--name`, the suite name falls back to the last two or three path
92
+ segments for a folder (never above the home directory), so
93
+ `e2e-tests/playwright` is no longer just `playwright`.
94
+ - Terminal output (`--format text`) is grouped by rule, worst first, with a
95
+ count per rule, relative paths, at most three locations per rule (`+ N more`)
96
+ and at most ten rules; it opens with the verdict and repeats it on the last
97
+ line. `--format json`, `sarif` and `markdown` are byte-identical to before.
98
+
13
99
  ## 2.2.0 — 2026-11-14
14
100
 
15
101
  False-positive fixes found by hand-checking error-level findings on public
package/METHODOLOGY.md CHANGED
@@ -126,6 +126,20 @@ The exact ESLint rules that produce findings are enabled explicitly in
126
126
  eslint-plugin-playwright's `recommended` set at install time, so identical
127
127
  code scores identically regardless of which plugin version npm resolves.
128
128
 
129
+ ### Tests that would miss a real bug
130
+
131
+ Five rules (QAG-287) target tests that pass whatever the app does. Each
132
+ matches one exact syntactic shape and stays silent when unsure.
133
+
134
+ | Rule | Dimension, severity | Exact shape |
135
+ |---|---|---|
136
+ | `pwscore/no-fixme-test` | structure, warning | `test.fixme('t', fn)` / `test.describe.fixme(...)` declarations; in-body `test.fixme()` or `test.fixme(true, ...)`. Conditional `test.fixme(cond, 'why')` is clean. |
137
+ | `pwscore/no-swallowed-assertion` | assertions, warning | `expect(...)` in a `try` whose `catch` has no throw, expect, fail-ish call, returned value or assignment (not inside a loop); or `await expect(...).catch(fn)` as a statement where `fn` does not throw. |
138
+ | `pwscore/no-weak-assertion` | assertions, error | `.count()`/`.length` `toBeGreaterThanOrEqual(0)`; a syntactic Locator (`page.locator`, `getBy*`, or a const from one) asserted `toBeTruthy`/`toBeDefined`/`not.toBeNull`; `isVisible()`-style and `count()` results asserted `toBeDefined`. `toBeTruthy()` on other values is not flagged. |
139
+ | `pwscore/no-unawaited-expect-value` | assertions, error | `expect(<promise call>)` without `await`, receiver syntactically a Page/Locator, matcher not web-first (also an un-awaited promise call as the expected value of `toBe`/`toEqual`). `.resolves`/`.rejects` are exempt. |
140
+ | `pwscore/no-self-fulfilling-fallback` | assertions, info, report-only (no score effect; no real-suite hits, validated on fixtures only) | Inside a test: `if (!check) { actions }`, the `else` of `if (check) {...} else { actions }`, or `try { expect/check } catch { actions }`, where the actions include `fill`/`type`/`pressSequentially`/`selectOption`/`setInputFiles` (click-only and key-press fallbacks are the usual "make sure the menu is open" setup and are not flagged), followed in the same block by an `expect` on a locator shared with the check or the actions. Positive-branch actions (dismiss a banner if visible) are never flagged. |
141
+
142
+
129
143
  ---
130
144
 
131
145
  ## Why v4
@@ -190,3 +204,30 @@ real inputs:
190
204
  diluted `findings/SLOC` and raised the score without fixing anything.
191
205
  A per-test ratio has no such lever — padding with fake tests costs
192
206
  assertions coverage instead.
207
+
208
+ ---
209
+
210
+ ## Flake-risk rules (unreleased)
211
+
212
+ Five static checks for the patterns that make a test pass or fail by luck.
213
+ Each is syntactic and conservative: when the receiver or value cannot be
214
+ read from the source, nothing is reported.
215
+
216
+ | Rule | Exact shape | Severity / dimension |
217
+ |---|---|---|
218
+ | `pwscore/no-unawaited-action` | An expression statement that is exactly `<receiver>.<action>(...)` inside an `async` function, where the receiver is `page`, a `.page` property, a `page.locator/getBy*/first/nth/filter` chain, or a `const` initialised from one, and the action is goto/reload/goBack/goForward/click/dblclick/fill/clear/press/type/check/uncheck/setChecked/selectOption/hover/focus/tap/dragTo/setInputFiles/setContent/waitFor*. Not matched: await, return, void, assignment, argument, `Promise.all`, `.catch()`/`.then()` chains, other receivers. Chosen over eslint-plugin-playwright's `missing-playwright-await` `includePageLocatorMethods` option because that one matches the method name on any receiver. | error / hygiene |
219
+ | `pwscore/no-brittle-selector` | A fully static selector string passed to `.locator()`, `.frameLocator()` or a legacy selector method on a Page/Locator that is XPath (`xpath=`, `//`, `(//`), uses `:nth-child`/`:nth-of-type`, is a plain CSS chain with 4 or more combinators, or contains a generated class (`.css-1a2b3c`, `.sc-bdfBwQ`, `.jss12`, `.MuiBox-root-12`); plus `.nth()/.first()/.last()` directly on a raw `.locator('<string>')` (test-id, `role=` and `text=` strings exempt). | warning, **report-only** / locators |
220
+ | `pwscore/no-shared-mutable-state` | A module- or describe-level `let`/`var` (or `const` object/array/Map/Set) that one `test()` body assigns or mutates and another `test()` body reads without writing it. Not matched: writes only in hooks, any `beforeEach` write, reader that also writes. | warning / structure |
221
+ | `pwscore/serial-mode-dependency` | `test.describe.configure({ mode: 'serial' })` or `test.describe.serial(...)`. | info, **report-only** / structure |
222
+ | `pwscore/no-excessive-timeout` | `{ timeout: N }` with N > 60,000 (strictly more than one minute) on an action, wait or matcher call; `test.setTimeout(N)`/`testInfo.setTimeout(N)` with N >= 300,000; `test.slow()` with no arguments directly at file or describe level. Literals and simple arithmetic only (`2 * 60 * 1000`, `60_000`). Config files are not read. | warning / hygiene |
223
+
224
+ `no-brittle-selector` is report-only on purpose: the locators dimension is a
225
+ native/raw ratio that already counts every raw `.locator()` and legacy
226
+ selector call, so scoring the same call again would charge it twice. The
227
+ finding is named evidence for why that raw call is fragile.
228
+
229
+ **Test factories.** A function whose `return` is `<test>.extend(...)`, a known
230
+ test alias, or a call of another such function (same file or imported through
231
+ relative paths) is a test factory; `const t = factory(...)` makes `t` a test
232
+ alias, so `t('name', ...)` is a test declaration and its `expect` calls are not
233
+ "standalone".
package/README.md CHANGED
@@ -99,7 +99,7 @@ npx playwright-score ./flow.spec.ts --format json --out report.json
99
99
  |---|---|
100
100
  | `--profile standard` | Scoring profile (default, and only, profile) |
101
101
  | `--threshold <n>` | Pass bar 0–100 |
102
- | `--format text\|json\|markdown\|sarif` | Output format |
102
+ | `--format text\|json\|markdown\|sarif\|report` | Output format (`report` is a self-contained one-page HTML suite report) |
103
103
  | `--out <file>` | Write report to file |
104
104
  | `--version` | Print version |
105
105
 
package/dist/cli.js CHANGED
@@ -9,6 +9,9 @@ import { formatJson } from './formatters/json.js';
9
9
  import { formatText } from './formatters/text.js';
10
10
  import { formatMarkdown } from './formatters/markdown.js';
11
11
  import { formatSarif } from './formatters/sarif.js';
12
+ import { formatReport } from './formatters/report.js';
13
+ import { makeSourceReader } from './report-source.js';
14
+ import { suiteLabel } from './suite-name.js';
12
15
  function printHelp() {
13
16
  console.log(`@qaguardian/playwright-score — deterministic Playwright spec quality score
14
17
 
@@ -21,8 +24,12 @@ Options:
21
24
  --profile <standard> Scoring profile (default, and only
22
25
  supported value, as of 2.0.0: standard).
23
26
  --threshold <n> Pass threshold 0-100 (default: 80)
24
- --format <text|json|markdown|sarif> Output format (default: text)
25
- --out <file> Write report to file
27
+ --format <text|json|markdown|sarif|report>
28
+ Output format (default: text). report is a
29
+ self-contained one-page HTML suite report.
30
+ --out, --output <file> Write report to file
31
+ --name <suite name> Suite name for the report headline
32
+ (default: the directory basename)
26
33
  --version Print version
27
34
  --help Show help
28
35
 
@@ -43,6 +50,30 @@ Methodology: METHODOLOGY.md (scoring model v4)
43
50
  // size (64KB on macOS/Linux). Setting process.exitCode and returning lets
44
51
  // Node exit naturally once pending I/O actually completes, which is the
45
52
  // standard fix for this class of bug.
53
+ async function packageVersion() {
54
+ try {
55
+ const { createRequire } = await import('node:module');
56
+ const require = createRequire(import.meta.url);
57
+ return require('../package.json').version ?? 'unknown';
58
+ }
59
+ catch {
60
+ return 'unknown';
61
+ }
62
+ }
63
+ // Never an absolute local path: see suite-name.ts.
64
+ function suiteName(paths) {
65
+ const names = paths.slice(0, 2).map((p) => {
66
+ let isDir = false;
67
+ try {
68
+ isDir = fs.statSync(p).isDirectory();
69
+ }
70
+ catch {
71
+ /* missing path: scorePaths reports it */
72
+ }
73
+ return suiteLabel(p, isDir);
74
+ });
75
+ return names.join(', ') + (paths.length > 2 ? ` and ${paths.length - 2} more` : '');
76
+ }
46
77
  async function main() {
47
78
  const args = process.argv.slice(2);
48
79
  if (args.includes('--version') || args.includes('-v')) {
@@ -67,6 +98,7 @@ async function main() {
67
98
  let threshold;
68
99
  let format = 'text';
69
100
  let out;
101
+ let name;
70
102
  const paths = [];
71
103
  for (let i = 0; i < args.length; i++) {
72
104
  const a = args[i];
@@ -99,16 +131,24 @@ async function main() {
99
131
  }
100
132
  else if (a === '--format') {
101
133
  const f = args[++i];
102
- if (f !== 'text' && f !== 'json' && f !== 'markdown' && f !== 'sarif') {
134
+ if (f !== 'text' && f !== 'json' && f !== 'markdown' && f !== 'sarif' && f !== 'report') {
103
135
  console.error(`Invalid format: ${f}`);
104
136
  process.exitCode = 2;
105
137
  return;
106
138
  }
107
139
  format = f;
108
140
  }
109
- else if (a === '--out') {
141
+ else if (a === '--out' || a === '--output') {
110
142
  out = args[++i];
111
143
  }
144
+ else if (a === '--name') {
145
+ name = args[++i];
146
+ if (!name) {
147
+ console.error('--name requires a value');
148
+ process.exitCode = 2;
149
+ return;
150
+ }
151
+ }
112
152
  else if (a === '--version' || a === '-v') {
113
153
  continue;
114
154
  }
@@ -128,6 +168,9 @@ async function main() {
128
168
  }
129
169
  try {
130
170
  const result = await scorePaths({ paths, profile, threshold });
171
+ for (const c of result.ruleCrashes ?? []) {
172
+ console.error(`warning: rule ${c.rule} crashed on ${c.count} node(s); results may be incomplete`);
173
+ }
131
174
  let body;
132
175
  switch (format) {
133
176
  case 'json':
@@ -139,8 +182,21 @@ async function main() {
139
182
  case 'sarif':
140
183
  body = formatSarif(result);
141
184
  break;
185
+ case 'report':
186
+ body = formatReport(result, {
187
+ date: new Date().toISOString().slice(0, 10),
188
+ version: await packageVersion(),
189
+ suiteName: name ?? suiteName(paths),
190
+ customThreshold: threshold !== undefined,
191
+ readSource: makeSourceReader(paths, process.cwd(), result.baseDir),
192
+ });
193
+ break;
142
194
  default:
143
- body = formatText(result);
195
+ body = formatText(result, {
196
+ suiteName: name ?? suiteName(paths),
197
+ version: await packageVersion(),
198
+ customThreshold: threshold !== undefined,
199
+ });
144
200
  }
145
201
  if (out) {
146
202
  fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
@@ -0,0 +1,30 @@
1
+ import type { Finding, Severity } from './types.js';
2
+ /**
3
+ * How sure we are that a finding is a real problem a reader would agree
4
+ * with. Derived from a hand check of 20 findings (QAG-288). Display and
5
+ * ranking only: it never changes the score.
6
+ *
7
+ * - high: mechanical, almost never a false positive.
8
+ * - medium: usually right, but there are legitimate uses.
9
+ * - needs-check: the scorer could not resolve something; a human should look.
10
+ */
11
+ export type Confidence = 'high' | 'medium' | 'needs-check';
12
+ export declare const CONFIDENCE_BY_RULE: Record<string, Confidence>;
13
+ /** Display names (the Confidence values themselves never change). */
14
+ export declare const CONFIDENCE_LABEL: Record<Confidence, string>;
15
+ /** Rules not listed above (and not needs-check) are medium. */
16
+ export declare const DEFAULT_CONFIDENCE: Confidence;
17
+ /**
18
+ * A report-only finding normally means "ratio-scored" (the locator rules:
19
+ * they feed the locators dimension through native/raw counts, not penalty
20
+ * units) and is an ordinary finding. Any OTHER report-only finding is one
21
+ * the scorer deliberately left out of the score (e.g. an assertion helper it
22
+ * could not resolve), so it is a "needs a human check".
23
+ */
24
+ export declare function isNeedsCheck(f: Pick<Finding, 'reportOnly' | 'dimension'>): boolean;
25
+ export declare function confidenceOf(f: Pick<Finding, 'rule' | 'reportOnly' | 'dimension'>): Confidence;
26
+ export declare function severityRank(s: Severity): number;
27
+ export declare function confidenceRank(c: Confidence): number;
28
+ /** Severity, then confidence, then file, line, rule: total and stable. */
29
+ export declare function compareFindings(a: Finding, b: Finding): number;
30
+ export declare function sortFindings(findings: readonly Finding[]): Finding[];
@@ -0,0 +1,67 @@
1
+ export const CONFIDENCE_BY_RULE = {
2
+ 'playwright/missing-playwright-await': 'high',
3
+ 'playwright/no-networkidle': 'high',
4
+ 'playwright/no-wait-for-timeout': 'high',
5
+ 'playwright/no-focused-test': 'high',
6
+ 'playwright/no-page-pause': 'high',
7
+ 'playwright-score/parse-error': 'high',
8
+ 'pwscore/no-unawaited-action': 'high',
9
+ 'pwscore/no-unawaited-expect-value': 'high',
10
+ 'pwscore/no-weak-assertion': 'high',
11
+ 'pwscore/no-fixme-test': 'high',
12
+ 'pwscore/no-excessive-timeout': 'high',
13
+ 'pwscore/no-swallowed-assertion': 'medium',
14
+ 'pwscore/no-shared-mutable-state': 'medium',
15
+ 'pwscore/no-self-fulfilling-fallback': 'needs-check',
16
+ 'pwscore/no-brittle-selector': 'needs-check',
17
+ 'pwscore/serial-mode-dependency': 'needs-check',
18
+ 'playwright-score/assertion-unverified': 'needs-check',
19
+ 'playwright/no-force-option': 'medium',
20
+ 'playwright/no-wait-for-selector': 'medium',
21
+ 'pwscore/no-timer-sleep': 'medium',
22
+ 'pwscore/no-trivial-assertion': 'medium',
23
+ 'playwright/no-conditional-in-test': 'medium',
24
+ 'playwright/no-conditional-expect': 'medium',
25
+ };
26
+ /** Display names (the Confidence values themselves never change). */
27
+ export const CONFIDENCE_LABEL = {
28
+ high: 'Definite',
29
+ medium: 'Likely',
30
+ 'needs-check': 'Needs a human check',
31
+ };
32
+ /** Rules not listed above (and not needs-check) are medium. */
33
+ export const DEFAULT_CONFIDENCE = 'medium';
34
+ const CONFIDENCE_RANK = { high: 0, medium: 1, 'needs-check': 2 };
35
+ const SEVERITY_RANK = { error: 0, warning: 1, info: 2 };
36
+ /**
37
+ * A report-only finding normally means "ratio-scored" (the locator rules:
38
+ * they feed the locators dimension through native/raw counts, not penalty
39
+ * units) and is an ordinary finding. Any OTHER report-only finding is one
40
+ * the scorer deliberately left out of the score (e.g. an assertion helper it
41
+ * could not resolve), so it is a "needs a human check".
42
+ */
43
+ export function isNeedsCheck(f) {
44
+ return f.reportOnly === true && f.dimension !== 'locators';
45
+ }
46
+ export function confidenceOf(f) {
47
+ if (isNeedsCheck(f))
48
+ return 'needs-check';
49
+ return CONFIDENCE_BY_RULE[f.rule] ?? DEFAULT_CONFIDENCE;
50
+ }
51
+ export function severityRank(s) {
52
+ return SEVERITY_RANK[s] ?? 3;
53
+ }
54
+ export function confidenceRank(c) {
55
+ return CONFIDENCE_RANK[c];
56
+ }
57
+ /** Severity, then confidence, then file, line, rule: total and stable. */
58
+ export function compareFindings(a, b) {
59
+ return (severityRank(a.severity) - severityRank(b.severity) ||
60
+ confidenceRank(confidenceOf(a)) - confidenceRank(confidenceOf(b)) ||
61
+ a.file.localeCompare(b.file) ||
62
+ (a.line ?? 0) - (b.line ?? 0) ||
63
+ a.rule.localeCompare(b.rule));
64
+ }
65
+ export function sortFindings(findings) {
66
+ return [...findings].sort(compareFindings);
67
+ }
@@ -154,6 +154,18 @@ function buildConfig(assertFunctionNames, testAliasNames) {
154
154
  'pwscore/no-coordinate-click': 'warn',
155
155
  'pwscore/no-trivial-assertion': 'error',
156
156
  'pwscore/no-soft-assertion-only-test': ['warn', { testAliasNames }],
157
+ // QAG-287 slice 2b flake-risk rules, one file each under src/rules/.
158
+ 'pwscore/no-unawaited-action': 'error',
159
+ 'pwscore/no-brittle-selector': 'warn',
160
+ 'pwscore/no-shared-mutable-state': ['warn', { testAliasNames }],
161
+ 'pwscore/serial-mode-dependency': ['warn', { testAliasNames }],
162
+ 'pwscore/no-excessive-timeout': ['warn', { testAliasNames }],
163
+ // QAG-287 slice 2a: tests that would miss a real bug.
164
+ 'pwscore/no-fixme-test': ['warn', { testAliasNames }],
165
+ 'pwscore/no-swallowed-assertion': 'warn',
166
+ 'pwscore/no-weak-assertion': 'error',
167
+ 'pwscore/no-unawaited-expect-value': 'error',
168
+ 'pwscore/no-self-fulfilling-fallback': ['warn', { testAliasNames }],
157
169
  },
158
170
  },
159
171
  ];
@@ -215,6 +227,10 @@ export async function runEslint(options) {
215
227
  const results = await eslint.lintFiles(options.files);
216
228
  const findings = [];
217
229
  for (const result of results) {
230
+ // pwscore/no-unawaited-expect-value says "missing await" for the same
231
+ // expect(); upstream prefer-web-first-assertions says something
232
+ // misleading about it. Keep ours (same line).
233
+ const unawaitedLines = new Set(result.messages.filter((m) => m.ruleId === 'pwscore/no-unawaited-expect-value').map((m) => m.line));
218
234
  const absFile = path.resolve(result.filePath);
219
235
  const file = path.relative(cwd, absFile) || path.basename(absFile);
220
236
  for (const msg of result.messages) {
@@ -262,6 +278,8 @@ export async function runEslint(options) {
262
278
  }
263
279
  if (!msg.ruleId)
264
280
  continue;
281
+ if (msg.ruleId === 'playwright/prefer-web-first-assertions' && unawaitedLines.has(msg.line))
282
+ continue;
265
283
  const mapping = mapRule(msg.ruleId);
266
284
  findings.push({
267
285
  rule: msg.ruleId,
@@ -1,3 +1,6 @@
1
1
  export function formatJson(result) {
2
- return JSON.stringify(result, null, 2);
2
+ // baseDir is an absolute local path used only by the report's excerpt
3
+ // reader; it must never reach a shareable artifact.
4
+ const { baseDir: _baseDir, ...shareable } = result;
5
+ return JSON.stringify(shareable, null, 2);
3
6
  }
@@ -1,2 +1,4 @@
1
1
  import type { ScoreResult } from '../types.js';
2
+ /** Make a value safe inside a Markdown table cell: no pipe or newline can end the cell or row. */
3
+ export declare function escapeCell(v: unknown): string;
2
4
  export declare function formatMarkdown(result: ScoreResult): string;
@@ -1,3 +1,9 @@
1
+ import { INCOMPLETE_NOTE, notScoredState } from '../report-model.js';
2
+ import { sortFindings } from '../confidence.js';
3
+ /** Make a value safe inside a Markdown table cell: no pipe or newline can end the cell or row. */
4
+ export function escapeCell(v) {
5
+ return String(v).replace(/\|/g, '\\|').replace(/\r?\n|\r/g, ' ');
6
+ }
1
7
  export function formatMarkdown(result) {
2
8
  const lines = [];
3
9
  lines.push(`# Playwright Spec Score (\`${result.scoreVersion}\`)`);
@@ -9,18 +15,23 @@ export function formatMarkdown(result) {
9
15
  for (const [k, v] of Object.entries(result.dimensions)) {
10
16
  if (v === undefined)
11
17
  continue;
12
- lines.push(`| ${k} | ${v} |`);
18
+ lines.push(`| ${escapeCell(k)} | ${escapeCell(v)} |`);
13
19
  }
14
20
  lines.push('');
15
21
  lines.push(`Files: ${result.summary.files} · Tests: ${result.summary.tests} · SLOC: ${result.summary.sloc} · Findings: ${result.summary.findings}` +
16
22
  (result.summary.unverifiedTests ? ` · Unverified assertions: ${result.summary.unverifiedTests}` : ''));
17
- if (result.findings.length) {
23
+ if (result.ruleCrashes?.length) {
24
+ lines.push('');
25
+ lines.push(INCOMPLETE_NOTE);
26
+ }
27
+ // 'no-tests' hard-fail findings carry the absolute working directory as their file.
28
+ if (result.findings.length && notScoredState(result) !== 'no-tests') {
18
29
  lines.push('');
19
30
  lines.push('## Findings');
20
31
  lines.push('');
21
- for (const f of result.findings.slice(0, 40)) {
32
+ for (const f of sortFindings(result.findings).slice(0, 40)) {
22
33
  const loc = f.line ? `:${f.line}` : '';
23
- lines.push(`- **${f.severity}** \`${f.rule}\` \`${f.file}${loc}\` — ${f.message}`);
34
+ lines.push(`- **${f.severity}** \`${f.rule}\` \`${escapeCell(f.file)}${loc}\` — ${escapeCell(f.message)}`);
24
35
  }
25
36
  }
26
37
  if (result.skippedFiles?.length) {
@@ -0,0 +1,2 @@
1
+ /** Inline stylesheet for the suite report: light/dark, phone width, Letter/A4 print. */
2
+ export declare const REPORT_CSS = "\n:root{--bg:#fff;--fg:#1a1d23;--muted:#5b6472;--line:#e3e6eb;--card:#f6f7f9;--accent:#2f5bea;--hit:#fff1c2;--hitfg:#1a1d23;--good:#1a7f4b;--warn:#b26a00;--bad:#c62828;--track:#e8ebf0}\n@media (prefers-color-scheme:dark){:root{--bg:#14161a;--fg:#e8eaee;--muted:#9aa3b2;--line:#2a2e36;--card:#1c1f25;--accent:#7c9bff;--hit:#4a3d10;--hitfg:#fff;--good:#4cc38a;--warn:#f0a63a;--bad:#ff6b6b;--track:#2a2e36}}\n*{box-sizing:border-box}\nbody{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,-apple-system,\"Segoe UI\",Roboto,sans-serif}\nmain{max-width:880px;margin:0 auto;padding:20px 20px 40px}\nheader{border-bottom:1px solid var(--line);padding-bottom:14px}\n.hd{display:flex;gap:24px;align-items:center;flex-wrap:wrap}\n.eyebrow{margin:0;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}\n.suite{margin:2px 0 10px;font-size:15px;font-weight:600;overflow-wrap:anywhere}\n.explain,.check{font-size:14px;margin:4px 0}\n.check a{color:var(--fg)}\n.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}\nh1{font-size:22px;margin:0 0 4px}\nh2{font-size:17px;margin:20px 0 8px;padding-bottom:6px;border-bottom:1px solid var(--line)}\nh3{font-size:15px;margin:0 0 4px}\np{margin:4px 0}\ncode,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px}\ncode{overflow-wrap:anywhere}\n.muted{color:var(--muted);font-size:13px}\n.meta{color:var(--muted);font-size:13px;margin-top:8px}\n.sub{font-weight:600;margin:12px 0 4px}\n.score{display:flex;align-items:baseline;gap:2px;padding:10px 18px;border-radius:12px;background:var(--card);border:1px solid var(--line);border-left:4px solid var(--muted)}\n.score .big{font-size:46px;font-weight:700;line-height:1}\n.score .of{color:var(--muted);font-size:16px}\n.score .grade{margin-left:12px;font-size:28px;font-weight:700}\n.g-A,.g-B{border-left-color:var(--good)}.g-A .grade,.g-B .grade{color:var(--good)}\n.g-C{border-left-color:var(--warn)}.g-C .grade{color:var(--warn)}\n.g-D,.g-F{border-left-color:var(--bad)}.g-D .grade,.g-F .grade{color:var(--bad)}\n.notscored{border-left-color:var(--bad)}.notscored .grade{margin-left:12px;font-size:16px;color:var(--fg)}\n@media (min-width:720px){.dims{display:grid;grid-template-columns:1fr 1fr;column-gap:28px}}\n.dim{margin:8px 0}.dimhead{display:flex;justify-content:space-between}\n.bar{height:10px;background:var(--track);border-radius:5px;overflow:hidden;margin:4px 0}\n.bar span{display:block;height:100%;background:var(--accent);-webkit-print-color-adjust:exact;print-color-adjust:exact}\n.finding{background:var(--card);border:1px solid var(--line);border-left:4px solid var(--warn);border-radius:8px;padding:12px 14px;margin:12px 0;break-inside:avoid}\n.finding.sev-error{border-left-color:var(--bad)}\n.rank{display:inline-block;width:22px;height:22px;line-height:22px;text-align:center;border-radius:50%;background:var(--fg);color:var(--bg);font-size:12px;margin-right:8px}\n.tag{font-size:11px;font-weight:600;border:1px solid var(--line);border-radius:10px;padding:1px 8px;margin-left:6px;color:var(--fg);border-color:var(--muted);white-space:nowrap}\n.where{margin:2px 0 6px}\nol.top3{list-style:none;padding:0;margin:8px 0}\nli.top{display:flex;gap:10px;align-items:flex-start;margin:8px 0;break-inside:avoid}\nli.top .rank{flex:none;margin-top:1px}\nli.top h3{margin:0 0 2px}li.top p{margin:2px 0}li.top .why{font-size:14px}\n.legend{margin:4px 0 8px}\npre.code{margin:6px 0;padding:8px 0;background:var(--bg);border:1px solid var(--line);border-radius:6px;overflow-x:auto}\n.ln{display:block;white-space:pre;padding:0 10px}\n.ln i{display:inline-block;width:3.2em;color:var(--muted);font-style:normal;user-select:none}\n.ln.hit{box-shadow:inset 3px 0 0 var(--bad);background:var(--hit);color:var(--hitfg);-webkit-print-color-adjust:exact;print-color-adjust:exact}\n.why{margin:6px 0}\nul.plain{list-style:none;padding:0;margin:6px 0}ul.plain li{padding:5px 0;border-bottom:1px solid var(--line);overflow-wrap:anywhere}\n.note{background:var(--card);border-radius:6px;padding:8px 12px}\nol{padding-left:22px}ol li{margin:6px 0}\nfooter{margin-top:32px;padding-top:12px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}\n@media (max-width:520px){main{padding:16px 12px}.score .big{font-size:44px}.tag{display:inline-block;margin:4px 0 0}pre.code{font-size:11.5px}}\n@media print{\n@page{margin:14mm}\nbody{font-size:11.5px}main{max-width:none;padding:0}\nh2{margin:12px 0 6px;break-after:avoid}\n.summary .dims{display:grid;grid-template-columns:1fr 1fr;column-gap:16px}\n.summary pre.code{display:none}\n.finding{padding:6px 10px;margin:6px 0;break-inside:avoid}\nli.top{margin:6px 0}li.top .why{font-size:inherit}.explain,.check{font-size:inherit}.suite{margin-bottom:8px}p{margin:2px 0}ol li{margin:3px 0}.legend{font-size:11px;margin:2px 0 4px}\n.score .big{font-size:40px}header{padding-bottom:10px}\n.details{break-before:page}\npre.code{overflow:visible;white-space:pre-wrap}\n}\n";
@@ -0,0 +1,68 @@
1
+ /** Inline stylesheet for the suite report: light/dark, phone width, Letter/A4 print. */
2
+ export const REPORT_CSS = `
3
+ :root{--bg:#fff;--fg:#1a1d23;--muted:#5b6472;--line:#e3e6eb;--card:#f6f7f9;--accent:#2f5bea;--hit:#fff1c2;--hitfg:#1a1d23;--good:#1a7f4b;--warn:#b26a00;--bad:#c62828;--track:#e8ebf0}
4
+ @media (prefers-color-scheme:dark){:root{--bg:#14161a;--fg:#e8eaee;--muted:#9aa3b2;--line:#2a2e36;--card:#1c1f25;--accent:#7c9bff;--hit:#4a3d10;--hitfg:#fff;--good:#4cc38a;--warn:#f0a63a;--bad:#ff6b6b;--track:#2a2e36}}
5
+ *{box-sizing:border-box}
6
+ body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
7
+ main{max-width:880px;margin:0 auto;padding:20px 20px 40px}
8
+ header{border-bottom:1px solid var(--line);padding-bottom:14px}
9
+ .hd{display:flex;gap:24px;align-items:center;flex-wrap:wrap}
10
+ .eyebrow{margin:0;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
11
+ .suite{margin:2px 0 10px;font-size:15px;font-weight:600;overflow-wrap:anywhere}
12
+ .explain,.check{font-size:14px;margin:4px 0}
13
+ .check a{color:var(--fg)}
14
+ .sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
15
+ h1{font-size:22px;margin:0 0 4px}
16
+ h2{font-size:17px;margin:20px 0 8px;padding-bottom:6px;border-bottom:1px solid var(--line)}
17
+ h3{font-size:15px;margin:0 0 4px}
18
+ p{margin:4px 0}
19
+ code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px}
20
+ code{overflow-wrap:anywhere}
21
+ .muted{color:var(--muted);font-size:13px}
22
+ .meta{color:var(--muted);font-size:13px;margin-top:8px}
23
+ .sub{font-weight:600;margin:12px 0 4px}
24
+ .score{display:flex;align-items:baseline;gap:2px;padding:10px 18px;border-radius:12px;background:var(--card);border:1px solid var(--line);border-left:4px solid var(--muted)}
25
+ .score .big{font-size:46px;font-weight:700;line-height:1}
26
+ .score .of{color:var(--muted);font-size:16px}
27
+ .score .grade{margin-left:12px;font-size:28px;font-weight:700}
28
+ .g-A,.g-B{border-left-color:var(--good)}.g-A .grade,.g-B .grade{color:var(--good)}
29
+ .g-C{border-left-color:var(--warn)}.g-C .grade{color:var(--warn)}
30
+ .g-D,.g-F{border-left-color:var(--bad)}.g-D .grade,.g-F .grade{color:var(--bad)}
31
+ .notscored{border-left-color:var(--bad)}.notscored .grade{margin-left:12px;font-size:16px;color:var(--fg)}
32
+ @media (min-width:720px){.dims{display:grid;grid-template-columns:1fr 1fr;column-gap:28px}}
33
+ .dim{margin:8px 0}.dimhead{display:flex;justify-content:space-between}
34
+ .bar{height:10px;background:var(--track);border-radius:5px;overflow:hidden;margin:4px 0}
35
+ .bar span{display:block;height:100%;background:var(--accent);-webkit-print-color-adjust:exact;print-color-adjust:exact}
36
+ .finding{background:var(--card);border:1px solid var(--line);border-left:4px solid var(--warn);border-radius:8px;padding:12px 14px;margin:12px 0;break-inside:avoid}
37
+ .finding.sev-error{border-left-color:var(--bad)}
38
+ .rank{display:inline-block;width:22px;height:22px;line-height:22px;text-align:center;border-radius:50%;background:var(--fg);color:var(--bg);font-size:12px;margin-right:8px}
39
+ .tag{font-size:11px;font-weight:600;border:1px solid var(--line);border-radius:10px;padding:1px 8px;margin-left:6px;color:var(--fg);border-color:var(--muted);white-space:nowrap}
40
+ .where{margin:2px 0 6px}
41
+ ol.top3{list-style:none;padding:0;margin:8px 0}
42
+ li.top{display:flex;gap:10px;align-items:flex-start;margin:8px 0;break-inside:avoid}
43
+ li.top .rank{flex:none;margin-top:1px}
44
+ li.top h3{margin:0 0 2px}li.top p{margin:2px 0}li.top .why{font-size:14px}
45
+ .legend{margin:4px 0 8px}
46
+ pre.code{margin:6px 0;padding:8px 0;background:var(--bg);border:1px solid var(--line);border-radius:6px;overflow-x:auto}
47
+ .ln{display:block;white-space:pre;padding:0 10px}
48
+ .ln i{display:inline-block;width:3.2em;color:var(--muted);font-style:normal;user-select:none}
49
+ .ln.hit{box-shadow:inset 3px 0 0 var(--bad);background:var(--hit);color:var(--hitfg);-webkit-print-color-adjust:exact;print-color-adjust:exact}
50
+ .why{margin:6px 0}
51
+ ul.plain{list-style:none;padding:0;margin:6px 0}ul.plain li{padding:5px 0;border-bottom:1px solid var(--line);overflow-wrap:anywhere}
52
+ .note{background:var(--card);border-radius:6px;padding:8px 12px}
53
+ ol{padding-left:22px}ol li{margin:6px 0}
54
+ footer{margin-top:32px;padding-top:12px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}
55
+ @media (max-width:520px){main{padding:16px 12px}.score .big{font-size:44px}.tag{display:inline-block;margin:4px 0 0}pre.code{font-size:11.5px}}
56
+ @media print{
57
+ @page{margin:14mm}
58
+ body{font-size:11.5px}main{max-width:none;padding:0}
59
+ h2{margin:12px 0 6px;break-after:avoid}
60
+ .summary .dims{display:grid;grid-template-columns:1fr 1fr;column-gap:16px}
61
+ .summary pre.code{display:none}
62
+ .finding{padding:6px 10px;margin:6px 0;break-inside:avoid}
63
+ li.top{margin:6px 0}li.top .why{font-size:inherit}.explain,.check{font-size:inherit}.suite{margin-bottom:8px}p{margin:2px 0}ol li{margin:3px 0}.legend{font-size:11px;margin:2px 0 4px}
64
+ .score .big{font-size:40px}header{padding-bottom:10px}
65
+ .details{break-before:page}
66
+ pre.code{overflow:visible;white-space:pre-wrap}
67
+ }
68
+ `;
@@ -0,0 +1,20 @@
1
+ import type { ScoreResult } from '../types.js';
2
+ export { rankTopFindings, compareGroups, type RuleGroup } from '../report-model.js';
3
+ /** Returns the source text of a finding's file (path as in Finding.file), or undefined. */
4
+ export type SourceReader = (file: string) => string | undefined;
5
+ export interface ReportOptions {
6
+ /** Shown in the header, e.g. "2026-11-14". Injected so output is reproducible. */
7
+ date: string;
8
+ /** Scorer package version. */
9
+ version: string;
10
+ /** Suite name or path shown under the headline. */
11
+ suiteName: string;
12
+ /** True when --threshold was passed, so the bar reads "your bar" instead of "the default bar". */
13
+ customThreshold?: boolean;
14
+ /** Source lookup for code excerpts; without it the excerpts are omitted. */
15
+ readSource?: SourceReader;
16
+ }
17
+ /** Rules that make a test flaky rather than merely untidy. */
18
+ export declare const FLAKE_RULES: readonly string[];
19
+ export declare function escapeHtml(s: string): string;
20
+ export declare function formatReport(result: ScoreResult, opts: ReportOptions): string;