@qaguardian/playwright-score 0.1.2 → 0.1.9

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/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @qaguardian/playwright-score
2
2
 
3
+ [![CI](https://github.com/qa-guardian/playwright-score/actions/workflows/ci.yml/badge.svg)](https://github.com/qa-guardian/playwright-score/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/@qaguardian/playwright-score.svg)](https://www.npmjs.com/package/@qaguardian/playwright-score)
5
+ [![license](https://img.shields.io/npm/l/@qaguardian/playwright-score.svg)](./LICENSE)
6
+
3
7
  **Deterministic, AI-free quality score for Playwright specs** (`sqs-v1`).
4
8
 
5
9
  **Website:** [qaguardian.com/open-source/playwright-score](https://qaguardian.com/open-source/playwright-score)
@@ -56,7 +60,35 @@ const result = await scorePaths({
56
60
  console.log(result.score, result.grade, result.pass, result.findings);
57
61
  ```
58
62
 
59
- ## CI example
63
+ ## GitHub Action
64
+
65
+ Drop this into a workflow to gate PRs on the score, post a job summary, and
66
+ keep a sticky PR comment with the full findings up to date:
67
+
68
+ ```yaml
69
+ - uses: qa-guardian/playwright-score@v1
70
+ with:
71
+ paths: tests e2e
72
+ profile: standard # or guardian
73
+ # threshold: 80 # defaults to the profile's default
74
+ mode: gate # gate: fail CI below threshold · warn: report only
75
+ ```
76
+
77
+ | Input | Description | Default |
78
+ |---|---|---|
79
+ | `paths` | Space-separated paths/globs to score | `tests` |
80
+ | `profile` | `standard` \| `guardian` | `standard` |
81
+ | `threshold` | Pass bar 0–100 | profile default (80 / 75) |
82
+ | `mode` | `gate` (fail CI below threshold) \| `warn` (report only) | `gate` |
83
+ | `comment` | Post/update a sticky PR comment | `true` |
84
+ | `version` | `@qaguardian/playwright-score` version to run | `latest` |
85
+ | `github-token` | Token for the PR comment | `${{ github.token }}` |
86
+
87
+ Outputs: `score`, `grade`, `pass` — usable by downstream steps (e.g. a
88
+ custom badge, a Slack notification on regression, etc).
89
+
90
+ Prefer a raw CLI call, or a non-GitHub CI system? See the [CLI](#cli)
91
+ section above — same score, same exit codes:
60
92
 
61
93
  ```yaml
62
94
  - name: Playwright Spec Score
package/dist/cli.js CHANGED
@@ -44,7 +44,7 @@ async function main() {
44
44
  console.log(pkg.version ?? '0.0.0');
45
45
  }
46
46
  catch {
47
- console.log('0.1.1');
47
+ console.log('0.1.8');
48
48
  }
49
49
  process.exit(0);
50
50
  }
@@ -7,4 +7,6 @@ export declare function runEslint(options: {
7
7
  files: string[];
8
8
  profile: ProfileName;
9
9
  cwd?: string;
10
+ /** See findLocalAssertionHelperNames in metrics.ts. */
11
+ assertFunctionNames?: string[];
10
12
  }): Promise<Finding[]>;
@@ -3,8 +3,9 @@ import { ESLint } from 'eslint';
3
3
  import { commonAncestorDir } from './fs-util.js';
4
4
  import playwright from 'eslint-plugin-playwright';
5
5
  import { guardianPlugin, guardianRuleConfigs, } from './rules/guardian-plugin.js';
6
+ import { basePlugin } from './rules/base-plugin.js';
6
7
  import { mapRule } from './profiles.js';
7
- function buildConfig(profile) {
8
+ function buildConfig(profile, assertFunctionNames) {
8
9
  const playwrightRecommended = playwright.configs?.['flat/recommended'] ??
9
10
  playwright.configs?.recommended;
10
11
  const base = [
@@ -17,21 +18,79 @@ function buildConfig(profile) {
17
18
  },
18
19
  plugins: {
19
20
  playwright,
21
+ pwscore: basePlugin,
20
22
  ...(profile === 'guardian' ? { guardian: guardianPlugin } : {}),
21
23
  },
22
24
  rules: {
23
- // Ensure key hygiene rules even if recommended set differs by version
25
+ // Ensure key hygiene rules even if recommended set differs by
26
+ // version. A few (no-wait-for-timeout, no-force-option,
27
+ // no-wait-for-selector, expect-expect) are intentionally stricter
28
+ // than eslint-plugin-playwright's own "recommended" severity —
29
+ // these are patterns Playwright's own docs call out as anti-
30
+ // patterns (see playwright.dev/docs/best-practices), so sqs-v1
31
+ // treats them as errors rather than warnings. no-networkidle and
32
+ // no-raw-locators aren't part of upstream "recommended" at all but
33
+ // are enabled here for the same reason.
24
34
  'playwright/no-wait-for-timeout': 'error',
25
35
  'playwright/no-networkidle': 'error',
26
36
  'playwright/no-force-option': 'error',
27
37
  'playwright/no-wait-for-selector': 'error',
28
38
  'playwright/no-wait-for-navigation': 'error',
29
39
  'playwright/missing-playwright-await': 'error',
30
- 'playwright/expect-expect': 'error',
40
+ // expect-expect can only see expect(...) calls written directly in
41
+ // the test body — it has no way to trace an assertion made inside
42
+ // a helper function the test calls. assertFunctionNames is
43
+ // populated per-run (see index.ts's findLocalAssertionHelperNames)
44
+ // with every same-file helper whose own body actually contains an
45
+ // expect(...)-shaped call, so this is name-agnostic for the common
46
+ // case regardless of what the helper is called. assertFunctionPatterns
47
+ // is a secondary, name-based fallback for helpers imported from
48
+ // another file (which we can't inspect the body of) — patterns are
49
+ // camelCase/exact-anchored so e.g. "checkoutFlow" doesn't collide
50
+ // with "check*"; the suffix pattern catches any name embedding a
51
+ // real Playwright matcher name (toBeVisible, toHaveText, ...).
52
+ // Verified against several real production files using several
53
+ // different naming conventions for the exact same delegation shape
54
+ // (audit, expectRealButtonToBeVisible, alertToBeVisible,
55
+ // checkFlashMessageVisibility) — all previously false-positived as
56
+ // "no assertions" at error severity.
57
+ 'playwright/expect-expect': [
58
+ 'error',
59
+ {
60
+ assertFunctionNames,
61
+ assertFunctionPatterns: [
62
+ '^(assert|verify|validate|audit|expect)([A-Z]|$)',
63
+ '^checkA11y$',
64
+ '^checkAccessibility$',
65
+ '(ToBe|ToHave|ToContain|ToMatch|ToEqual)[A-Z]',
66
+ ],
67
+ },
68
+ ],
31
69
  'playwright/no-focused-test': 'error',
32
- 'playwright/no-skipped-test': 'warn',
70
+ // Upstream flags every test.skip(...) form identically, including
71
+ // Playwright's own documented conditional runtime skip
72
+ // (test.skip(condition, reason)) — a false positive on entirely
73
+ // idiomatic cross-browser test code, verified against real-world
74
+ // suites. pwscore/no-skipped-test-declaration (below) replaces it
75
+ // with the same severity for the actual anti-pattern (an
76
+ // always-skipped test declaration) only.
77
+ 'playwright/no-skipped-test': 'off',
78
+ 'pwscore/no-skipped-test-declaration': 'warn',
33
79
  'playwright/no-raw-locators': 'warn',
34
- 'playwright/prefer-web-first-assertions': 'warn',
80
+ // Both were mapped in profiles.ts (report-only, so they don't
81
+ // double-penalize the ratio-based locators dimension) but never
82
+ // actually enabled anywhere — completely inert. prefer-locator
83
+ // specifically catches Playwright's older direct-action API
84
+ // (page.click('#foo') instead of a locator or native locator),
85
+ // arguably the single most common raw-selector anti-pattern in
86
+ // naive/AI-generated code — verified against a realistic sample
87
+ // that scored a perfect 100 on the locators dimension despite
88
+ // using nothing but page.click(selector)/page.type(selector, ...)
89
+ // throughout, before this fix.
90
+ 'playwright/prefer-locator': 'warn',
91
+ 'playwright/prefer-native-locators': 'warn',
92
+ // Matches eslint-plugin-playwright's own recommended severity.
93
+ 'playwright/prefer-web-first-assertions': 'error',
35
94
  ...(profile === 'guardian' ? { ...guardianRuleConfigs } : {}),
36
95
  },
37
96
  },
@@ -79,7 +138,7 @@ export async function runEslint(options) {
79
138
  const cwd = options.files.length > 0
80
139
  ? commonAncestorDir(options.files)
81
140
  : (options.cwd ?? process.cwd());
82
- const overrideConfig = buildConfig(options.profile);
141
+ const overrideConfig = buildConfig(options.profile, options.assertFunctionNames ?? []);
83
142
  // Try to attach typescript-eslint parser for .ts files
84
143
  try {
85
144
  const tsParser = await import('@typescript-eslint/parser');
@@ -110,8 +169,39 @@ export async function runEslint(options) {
110
169
  const results = await eslint.lintFiles(options.files);
111
170
  const findings = [];
112
171
  for (const result of results) {
113
- const file = path.resolve(result.filePath);
172
+ const absFile = path.resolve(result.filePath);
173
+ const file = path.relative(cwd, absFile) || path.basename(absFile);
114
174
  for (const msg of result.messages) {
175
+ // A `// eslint-disable-next-line some/rule-we-dont-bundle` comment is
176
+ // extremely common in real-world code (any host project that lints
177
+ // with plugins we don't embed, e.g. react-hooks, import, sonarjs, or
178
+ // even @typescript-eslint's own rules — we only bundle its parser,
179
+ // not its rule set). ESLint reports these with a fixed message
180
+ // regardless of reportUnusedDisableDirectives config, since it can't
181
+ // even validate a rule id it has no definition for. This is a
182
+ // diagnostic about *our* rule coverage, not the spec's quality —
183
+ // surfacing it as a finding (even report-only) is pure confusion,
184
+ // not signal.
185
+ if (/^Definition for rule '.*' was not found\.$/.test(msg.message)) {
186
+ continue;
187
+ }
188
+ if (msg.fatal) {
189
+ // A file that doesn't parse can't be meaningfully scored at all —
190
+ // surface it explicitly rather than silently dropping it (it has
191
+ // no ruleId) and letting an unparseable file default to a clean
192
+ // 100. scorePaths hard-fails the whole result when this rule id
193
+ // is present.
194
+ findings.push({
195
+ rule: 'playwright-score/parse-error',
196
+ severity: 'error',
197
+ message: msg.message,
198
+ file,
199
+ line: msg.line,
200
+ column: msg.column,
201
+ dimension: 'structure',
202
+ });
203
+ continue;
204
+ }
115
205
  if (!msg.ruleId)
116
206
  continue;
117
207
  const mapping = mapRule(msg.ruleId);
@@ -22,5 +22,15 @@ export function formatMarkdown(result) {
22
22
  lines.push(`- **${f.severity}** \`${f.rule}\` \`${f.file}${loc}\` — ${f.message}`);
23
23
  }
24
24
  }
25
+ if (result.skippedFiles?.length) {
26
+ lines.push('');
27
+ lines.push(`<details><summary>Skipped — looks like a non-Playwright test (Jest/Vitest/RTL) (${result.skippedFiles.length})</summary>`);
28
+ lines.push('');
29
+ for (const f of result.skippedFiles) {
30
+ lines.push(`- \`${f}\``);
31
+ }
32
+ lines.push('');
33
+ lines.push('</details>');
34
+ }
25
35
  return lines.join('\n');
26
36
  }
@@ -17,7 +17,7 @@ export function formatSarif(result) {
17
17
  tool: {
18
18
  driver: {
19
19
  name: 'playwright-score',
20
- informationUri: 'https://github.com/qaguardian/playwright-score',
20
+ informationUri: 'https://github.com/qa-guardian/playwright-score',
21
21
  version: result.scoreVersion,
22
22
  rules: [...rulesMap.values()],
23
23
  },
@@ -24,6 +24,16 @@ export function formatText(result) {
24
24
  lines.push(` … and ${result.findings.length - 50} more`);
25
25
  }
26
26
  }
27
+ if (result.skippedFiles?.length) {
28
+ lines.push('');
29
+ lines.push(`Skipped (looks like a non-Playwright test — Jest/Vitest/RTL — ${result.skippedFiles.length}):`);
30
+ for (const f of result.skippedFiles.slice(0, 20)) {
31
+ lines.push(` ${f}`);
32
+ }
33
+ if (result.skippedFiles.length > 20) {
34
+ lines.push(` … and ${result.skippedFiles.length - 20} more`);
35
+ }
36
+ }
27
37
  return lines.join('\n');
28
38
  }
29
39
  function pathBasename(p) {
package/dist/index.js CHANGED
@@ -2,7 +2,8 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { globSync } from 'glob';
4
4
  import { runEslint } from './eslint-runner.js';
5
- import { analyzeSource } from './metrics.js';
5
+ import { commonAncestorDir } from './fs-util.js';
6
+ import { analyzeSource, findLocalAssertionHelperNames, looksLikeNonPlaywrightTest, } from './metrics.js';
6
7
  import { DEFAULT_THRESHOLDS } from './profiles.js';
7
8
  import { computeScore } from './score-engine.js';
8
9
  export { computeScore, SQS_V1, locatorsScore, gradeFromScore } from './score-engine.js';
@@ -13,28 +14,103 @@ export { formatText } from './formatters/text.js';
13
14
  export { formatMarkdown } from './formatters/markdown.js';
14
15
  export { formatSarif } from './formatters/sarif.js';
15
16
  const SPEC_GLOBS = ['**/*.{spec,test}.{ts,tsx,js,jsx}', '**/*.spec.ts', '**/*.test.ts'];
17
+ // Directory/glob expansion must never sweep in vendored or generated code —
18
+ // verified as a real bug: scanning a project root with a plain node_modules
19
+ // dependency that ships its own *.spec.ts files silently included them
20
+ // alongside real specs. Every other JS tool in this space (ESLint,
21
+ // Prettier, Jest) default-excludes these directories for the same reason;
22
+ // an explicit file path always bypasses this (see `explicit` in
23
+ // expandPaths), so this only affects auto-discovery.
24
+ const DEFAULT_IGNORE_GLOBS = [
25
+ '**/node_modules/**',
26
+ '**/.git/**',
27
+ '**/dist/**',
28
+ '**/build/**',
29
+ '**/out/**',
30
+ '**/coverage/**',
31
+ '**/.next/**',
32
+ '**/.nuxt/**',
33
+ '**/.turbo/**',
34
+ '**/.cache/**',
35
+ '**/.svelte-kit/**',
36
+ '**/playwright-report/**',
37
+ '**/test-results/**',
38
+ ];
39
+ /**
40
+ * `explicit`: paths the caller named directly (a literal existing file) —
41
+ * always scored, regardless of whether they look like a Playwright spec.
42
+ * `expanded`: everything else (matched via a directory scan or a glob
43
+ * pattern) — subject to the looksLikePlaywrightSpec filter in scorePaths,
44
+ * since a broad `**\/*.test.ts`-style match can just as easily sweep in
45
+ * unrelated Jest/Vitest/RTL unit tests sitting in the same repo.
46
+ */
16
47
  function expandPaths(inputs, cwd) {
17
- const files = new Set();
48
+ const explicit = new Set();
49
+ const expanded = new Set();
18
50
  for (const input of inputs) {
19
51
  const abs = path.isAbsolute(input) ? input : path.resolve(cwd, input);
20
52
  if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
21
- files.add(abs);
53
+ explicit.add(abs);
22
54
  continue;
23
55
  }
24
56
  if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
25
57
  for (const g of SPEC_GLOBS) {
26
- for (const f of globSync(g, { cwd: abs, absolute: true, nodir: true })) {
27
- files.add(path.resolve(f));
58
+ for (const f of globSync(g, {
59
+ cwd: abs,
60
+ absolute: true,
61
+ nodir: true,
62
+ ignore: DEFAULT_IGNORE_GLOBS,
63
+ })) {
64
+ expanded.add(path.resolve(f));
28
65
  }
29
66
  }
30
67
  continue;
31
68
  }
32
69
  // treat as glob
33
- for (const f of globSync(input, { cwd, absolute: true, nodir: true })) {
34
- files.add(path.resolve(f));
70
+ for (const f of globSync(input, {
71
+ cwd,
72
+ absolute: true,
73
+ nodir: true,
74
+ ignore: DEFAULT_IGNORE_GLOBS,
75
+ })) {
76
+ expanded.add(path.resolve(f));
35
77
  }
36
78
  }
37
- return [...files].sort();
79
+ // A file can be reached both explicitly and via expansion (e.g. an
80
+ // explicit path plus an overlapping glob); explicit intent wins.
81
+ for (const f of explicit)
82
+ expanded.delete(f);
83
+ return { explicit: [...explicit].sort(), expanded: [...expanded].sort() };
84
+ }
85
+ function hardFail(profile, threshold, findings, extra = {}, skippedFiles) {
86
+ return {
87
+ scoreVersion: 'sqs-v1',
88
+ profile,
89
+ score: 0,
90
+ grade: 'F',
91
+ pass: false,
92
+ threshold,
93
+ summary: {
94
+ files: 0,
95
+ tests: 0,
96
+ sloc: 0,
97
+ findings: findings.length,
98
+ errors: findings.filter((f) => f.severity === 'error').length,
99
+ warnings: findings.filter((f) => f.severity === 'warning').length,
100
+ nativeLocators: 0,
101
+ rawLocators: 0,
102
+ ...extra,
103
+ },
104
+ dimensions: {
105
+ playwrightHygiene: 0,
106
+ assertions: 0,
107
+ locators: 0,
108
+ structure: 0,
109
+ ...(profile === 'guardian' ? { guardianConventions: 0 } : {}),
110
+ },
111
+ findings,
112
+ ...(skippedFiles && skippedFiles.length > 0 ? { skippedFiles } : {}),
113
+ };
38
114
  }
39
115
  /**
40
116
  * Score Playwright spec files. Deterministic and AI-free (sqs-v1).
@@ -43,10 +119,10 @@ export async function scorePaths(options) {
43
119
  const cwd = options.cwd ?? process.cwd();
44
120
  const profile = options.profile ?? 'standard';
45
121
  const threshold = options.threshold ?? DEFAULT_THRESHOLDS[profile] ?? 80;
46
- const files = expandPaths(options.paths, cwd);
47
- if (files.length === 0) {
122
+ const { explicit, expanded } = expandPaths(options.paths, cwd);
123
+ if (explicit.length === 0 && expanded.length === 0) {
48
124
  // Hard-fail: empty match must never look like a healthy suite (was ~99 PASS).
49
- const findings = [
125
+ return hardFail(profile, threshold, [
50
126
  {
51
127
  rule: 'playwright-score/no-files',
52
128
  severity: 'error',
@@ -54,43 +130,67 @@ export async function scorePaths(options) {
54
130
  file: cwd,
55
131
  dimension: 'structure',
56
132
  },
57
- ];
58
- return {
59
- scoreVersion: 'sqs-v1',
60
- profile,
61
- score: 0,
62
- grade: 'F',
63
- pass: false,
64
- threshold,
65
- summary: {
66
- files: 0,
67
- tests: 0,
68
- sloc: 0,
69
- findings: 1,
70
- errors: 1,
71
- warnings: 0,
72
- nativeLocators: 0,
73
- rawLocators: 0,
74
- },
75
- dimensions: {
76
- playwrightHygiene: 0,
77
- assertions: 0,
78
- locators: 0,
79
- structure: 0,
80
- ...(profile === 'guardian' ? { guardianConventions: 0 } : {}),
133
+ ]);
134
+ }
135
+ // Directory/glob matches are excluded only on positive evidence of a
136
+ // non-Playwright test framework — see looksLikeNonPlaywrightTest. Paths
137
+ // the caller named explicitly are always scored regardless.
138
+ const skippedFiles = [];
139
+ const acceptedExpanded = [];
140
+ const skipBase = commonAncestorDir([...explicit, ...expanded]);
141
+ for (const f of expanded) {
142
+ const source = fs.readFileSync(f, 'utf8');
143
+ if (looksLikeNonPlaywrightTest(source)) {
144
+ skippedFiles.push(path.relative(skipBase, f) || path.basename(f));
145
+ }
146
+ else {
147
+ acceptedExpanded.push(f);
148
+ }
149
+ }
150
+ const files = [...explicit, ...acceptedExpanded].sort();
151
+ if (files.length === 0) {
152
+ return hardFail(profile, threshold, [
153
+ {
154
+ rule: 'playwright-score/no-files',
155
+ severity: 'error',
156
+ message: `${expanded.length} file(s) matched but all look like non-Playwright tests (Jest/Vitest/RTL) — nothing to score.`,
157
+ file: cwd,
158
+ dimension: 'structure',
81
159
  },
82
- findings,
83
- };
160
+ ], {}, skippedFiles);
84
161
  }
85
- const eslintFindings = await runEslint({ files, profile, cwd });
162
+ // Relative to the files' own common ancestor (not the caller's cwd, which
163
+ // may be unrelated) so findings stay portable across machines/CI and
164
+ // formatters/sarif.ts can emit repo-relative artifactLocation URIs.
165
+ const filesBase = commonAncestorDir(files);
166
+ // Read every file once, up front, so (a) we can feed ESLint's
167
+ // expect-expect a per-run list of local assertion-helper names before
168
+ // linting (see findLocalAssertionHelperNames) and (b) the metrics loop
169
+ // below doesn't re-read the same files ESLint already read internally.
170
+ const sources = new Map();
171
+ const assertFunctionNames = new Set();
172
+ for (const file of files) {
173
+ const source = fs.readFileSync(file, 'utf8');
174
+ sources.set(file, source);
175
+ for (const name of findLocalAssertionHelperNames(source, file)) {
176
+ assertFunctionNames.add(name);
177
+ }
178
+ }
179
+ const eslintFindings = await runEslint({
180
+ files,
181
+ profile,
182
+ cwd,
183
+ assertFunctionNames: [...assertFunctionNames],
184
+ });
86
185
  let totalSloc = 0;
87
186
  let totalTests = 0;
88
187
  let native = 0;
89
188
  let raw = 0;
90
189
  const metricFindings = [];
91
190
  for (const file of files) {
92
- const source = fs.readFileSync(file, 'utf8');
93
- const m = analyzeSource(source, file);
191
+ const source = sources.get(file) ?? '';
192
+ const relFile = path.relative(filesBase, file) || path.basename(file);
193
+ const m = analyzeSource(source, relFile);
94
194
  totalSloc += m.sloc;
95
195
  totalTests += m.tests;
96
196
  native += m.locators.native;
@@ -100,7 +200,21 @@ export async function scorePaths(options) {
100
200
  }
101
201
  // Dedupe empty-test style: if guardian require-expect already fired, still ok
102
202
  const findings = [...eslintFindings, ...metricFindings];
103
- return computeScore({
203
+ const parseErrors = findings.filter((f) => f.rule === 'playwright-score/parse-error');
204
+ if (parseErrors.length > 0) {
205
+ // A file that isn't valid JS/TS can't be executed, let alone scored on
206
+ // its merits. Diluting this into the normal per-dimension penalty math
207
+ // (a handful of exp-decay points on an otherwise large, clean batch)
208
+ // would let a broken file hide inside a passing score. Hard-fail
209
+ // instead, same as the no-matching-files case.
210
+ return hardFail(profile, threshold, findings, {
211
+ files: files.length,
212
+ sloc: Math.max(totalSloc, 1),
213
+ nativeLocators: native,
214
+ rawLocators: raw,
215
+ }, skippedFiles);
216
+ }
217
+ const result = computeScore({
104
218
  profile,
105
219
  threshold,
106
220
  findings,
@@ -110,4 +224,5 @@ export async function scorePaths(options) {
110
224
  nativeLocators: native,
111
225
  rawLocators: raw,
112
226
  });
227
+ return skippedFiles.length > 0 ? { ...result, skippedFiles } : result;
113
228
  }
package/dist/metrics.d.ts CHANGED
@@ -1,13 +1,34 @@
1
1
  import type { Finding, LocatorCounts } from './types.js';
2
- /** Count native vs raw locator calls (simple statistical). */
3
- export declare function countLocators(source: string): LocatorCounts;
4
- /** Count test( / test.only( / test.skip( style declarations (approx). */
5
- export declare function countTests(source: string): number;
6
2
  /**
7
- * Detect test callbacks that appear to have zero expect( calls.
8
- * Heuristic: for each test( ... async (...) => { body }, check body for expect.
3
+ * Heuristic: does this file show positive evidence of being a *non*-
4
+ * Playwright test (Jest/Vitest/Mocha/Jasmine/React Testing Library), as
5
+ * opposed to a Playwright spec that a broad `**\/*.test.ts` glob would
6
+ * otherwise sweep in alongside real e2e specs? Used only to decide whether
7
+ * to auto-exclude a file discovered via directory/glob expansion — never
8
+ * applied to a file path the caller named explicitly.
9
+ */
10
+ export declare function looksLikeNonPlaywrightTest(source: string): boolean;
11
+ export declare function countLocators(source: string, file?: string): LocatorCounts;
12
+ /**
13
+ * Names of same-file functions (function declarations, or a const bound to
14
+ * an arrow/function expression) whose own body contains an expect(...)-
15
+ * shaped call anywhere. Fed into playwright/expect-expect's own
16
+ * assertFunctionNames option (see eslint-runner.ts) so a test that
17
+ * delegates its assertion to a locally-defined helper — `audit(page,
18
+ * path)`, `checkFlashMessageVisibility(page, msg)`, `assertDashboardLoaded`
19
+ * — isn't flagged as having no assertions, regardless of what the helper
20
+ * happens to be named. expect-expect can only see expect() calls written
21
+ * directly in the test body, so without this, delegating to a helper at
22
+ * all — an extremely common way to dedupe near-identical specs — reads as
23
+ * "no assertions"; verified against several real production files using
24
+ * several different naming conventions for the exact same shape.
25
+ * Deliberately local-only: a helper imported from another file can't be
26
+ * resolved without following module specifiers and parsing that file too,
27
+ * well beyond what a fast, per-file syntactic check should attempt.
9
28
  */
10
- export declare function findEmptyExpectTests(source: string, file: string): Finding[];
29
+ export declare function findLocalAssertionHelperNames(source: string, file?: string): string[];
30
+ /** Count test( / test.only( / test.skip( / test.fixme( style declarations (approx). */
31
+ export declare function countTests(source: string): number;
11
32
  export declare function findOversizedFile(source: string, file: string, maxSloc?: number): Finding[];
12
33
  export declare function analyzeSource(source: string, file: string): {
13
34
  sloc: number;
package/dist/metrics.js CHANGED
@@ -1,56 +1,237 @@
1
+ import { parseForESLint } from '@typescript-eslint/parser';
1
2
  import { countSloc } from './sloc.js';
2
- const NATIVE_RE = /\.\s*getBy(?:Role|Label|TestId|Text|Placeholder)\s*\(/g;
3
- const RAW_RE = /(?:page|frame)\s*\.\s*locator\s*\(/g;
4
- /** Count native vs raw locator calls (simple statistical). */
5
- export function countLocators(source) {
6
- const native = [...source.matchAll(NATIVE_RE)].length;
7
- const raw = [...source.matchAll(RAW_RE)].length;
3
+ // A positive "this IS a Playwright spec" check turned out to be unsafe:
4
+ // Playwright's own custom-fixtures pattern (test/expect re-exported from a
5
+ // project-local module built with base.extend()) is extremely common in
6
+ // real suites, and the fixtures a test destructures can be named anything
7
+ // (`bomPage`, `authedUser`, ...) — not necessarily the stock page/context/
8
+ // browser/request names. A file that legitimately imports `test`/`expect`
9
+ // from '../fixtures' and destructures `{ bomPage }` has zero syntactic
10
+ // signal distinguishing it from an unrelated framework, so requiring
11
+ // positive Playwright evidence false-negatives on real production specs
12
+ // (verified against real code: it silently skipped 18 of 19 genuine specs
13
+ // in one real suite). Denylisting known non-Playwright frameworks is much
14
+ // safer — a missed rare/unusual file just scores harmlessly, where a
15
+ // falsely-skipped real spec silently stops being scored at all.
16
+ const NON_PLAYWRIGHT_IMPORT_RE = /from\s+['"](?:vitest|jest|@jest\/[a-z-]+|mocha|jasmine|chai|@testing-library\/[a-z-]+)['"]/;
17
+ const RTL_API_RE = /\bscreen\.(?:getBy|queryBy|findBy|getAllBy|queryAllBy|findAllBy)\w*\(|\btoBeInTheDocument\(/;
18
+ /**
19
+ * Heuristic: does this file show positive evidence of being a *non*-
20
+ * Playwright test (Jest/Vitest/Mocha/Jasmine/React Testing Library), as
21
+ * opposed to a Playwright spec that a broad `**\/*.test.ts` glob would
22
+ * otherwise sweep in alongside real e2e specs? Used only to decide whether
23
+ * to auto-exclude a file discovered via directory/glob expansion — never
24
+ * applied to a file path the caller named explicitly.
25
+ */
26
+ export function looksLikeNonPlaywrightTest(source) {
27
+ return NON_PLAYWRIGHT_IMPORT_RE.test(source) || RTL_API_RE.test(source);
28
+ }
29
+ const NATIVE_METHODS = new Set([
30
+ 'getByRole',
31
+ 'getByLabel',
32
+ 'getByTestId',
33
+ 'getByText',
34
+ 'getByPlaceholder',
35
+ ]);
36
+ /**
37
+ * Generic ESTree walk (no visitor-keys dependency). `parent` is excluded so
38
+ * this stays a walk over a DAG even if a parent back-reference is ever
39
+ * attached to the tree upstream.
40
+ */
41
+ function walk(node, visit) {
42
+ if (!node || typeof node !== 'object')
43
+ return;
44
+ if (Array.isArray(node)) {
45
+ for (const item of node)
46
+ walk(item, visit);
47
+ return;
48
+ }
49
+ const obj = node;
50
+ if (typeof obj.type === 'string')
51
+ visit(obj);
52
+ for (const key in obj) {
53
+ if (key === 'parent')
54
+ continue;
55
+ walk(obj[key], visit);
56
+ }
57
+ }
58
+ /**
59
+ * Parses source into an ESTree-compatible AST for our own lightweight
60
+ * AST-walk checks (locator counting, local assertion-helper discovery).
61
+ * Returns undefined on any parse failure — callers degrade to "found
62
+ * nothing" rather than throwing; ESLint's own lint pass over the same
63
+ * file surfaces the real parse error as a finding.
64
+ */
65
+ function parseSource(source, file) {
66
+ try {
67
+ return parseForESLint(source, {
68
+ ecmaVersion: 2022,
69
+ sourceType: 'module',
70
+ // JSX must be extension-driven, not always-on: a bare .ts file can
71
+ // legally use the old-style `<Type>value` cast syntax, which is
72
+ // ambiguous with — and would be misparsed as — a JSX element if JSX
73
+ // were enabled unconditionally.
74
+ ecmaFeatures: { jsx: /\.(tsx|jsx)$/.test(file ?? '') },
75
+ }).ast;
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ }
81
+ /**
82
+ * Count native vs raw locator calls via the same AST the ESLint pass
83
+ * already parses (rather than a regex), so this ratio can't silently
84
+ * disagree with `no-raw-locators` findings. A regex anchored to literal
85
+ * `page`/`frame` receivers misses raw locators on any other receiver —
86
+ * e.g. `this.container.locator(...)` in a Page Object Model, or a second
87
+ * `.locator()` chained off a prior locator call — both common in
88
+ * real-world (and AI-generated) specs.
89
+ */
90
+ // Playwright's older direct-action API takes a raw selector string as the
91
+ // first argument — page.click('#foo') instead of page.locator('#foo').click()
92
+ // or a native locator. This is arguably the single most common raw-selector
93
+ // anti-pattern in naive/AI-generated code (verified against a realistic
94
+ // sample: a file using this exclusively scored a perfect 100 on the
95
+ // locators dimension before this fix). Restricted to methods a Locator
96
+ // itself takes zero required arguments for (click/dblclick/hover/check/
97
+ // uncheck/tap/focus) — real Playwright types never allow a string there on
98
+ // a Locator, so a string first argument unambiguously means the legacy
99
+ // page/frame form regardless of receiver name. fill/type/press/
100
+ // selectOption are deliberately excluded: their first argument is
101
+ // legitimately a string on a Locator too (the value/key, not a selector),
102
+ // so counting them would risk new false positives on correct code.
103
+ const LEGACY_SELECTOR_ACTION_METHODS = new Set([
104
+ 'click',
105
+ 'dblclick',
106
+ 'hover',
107
+ 'check',
108
+ 'uncheck',
109
+ 'tap',
110
+ 'focus',
111
+ ]);
112
+ export function countLocators(source, file) {
113
+ let native = 0;
114
+ let raw = 0;
115
+ const ast = parseSource(source, file);
116
+ if (!ast)
117
+ return { native, raw };
118
+ walk(ast, (node) => {
119
+ if (node.type !== 'CallExpression' ||
120
+ node.callee?.type !== 'MemberExpression') {
121
+ return;
122
+ }
123
+ const callee = node.callee;
124
+ if (callee.computed)
125
+ return;
126
+ const property = callee.property;
127
+ if (property?.type !== 'Identifier')
128
+ return;
129
+ if (NATIVE_METHODS.has(property.name ?? '')) {
130
+ native++;
131
+ }
132
+ else if (property.name === 'locator') {
133
+ raw++;
134
+ }
135
+ else if (LEGACY_SELECTOR_ACTION_METHODS.has(property.name ?? '')) {
136
+ const args = node.arguments;
137
+ const first = args?.[0];
138
+ if (first?.type === 'Literal' && typeof first.value === 'string')
139
+ raw++;
140
+ }
141
+ });
8
142
  return { native, raw };
9
143
  }
10
- /** Count test( / test.only( / test.skip( style declarations (approx). */
144
+ function isExpectCallee(callee) {
145
+ if (!callee)
146
+ return false;
147
+ if (callee.type === 'Identifier')
148
+ return callee.name === 'expect';
149
+ if (callee.type === 'MemberExpression') {
150
+ // Walk down to the root of a member chain, e.g. expect.poll(fn).toBe(x)
151
+ // — the callee of the outer .toBe() call is `expect.poll(fn).toBe`,
152
+ // whose object is the `expect.poll(fn)` CallExpression.
153
+ let obj = callee.object;
154
+ while (obj?.type === 'MemberExpression')
155
+ obj = obj.object;
156
+ if (obj?.type === 'Identifier' && obj.name === 'expect')
157
+ return true;
158
+ if (obj?.type === 'CallExpression') {
159
+ return isExpectCallee(obj.callee);
160
+ }
161
+ }
162
+ return false;
163
+ }
164
+ function containsExpectCall(node) {
165
+ let found = false;
166
+ walk(node, (n) => {
167
+ if (found || n.type !== 'CallExpression')
168
+ return;
169
+ if (isExpectCallee(n.callee))
170
+ found = true;
171
+ });
172
+ return found;
173
+ }
174
+ /**
175
+ * Names of same-file functions (function declarations, or a const bound to
176
+ * an arrow/function expression) whose own body contains an expect(...)-
177
+ * shaped call anywhere. Fed into playwright/expect-expect's own
178
+ * assertFunctionNames option (see eslint-runner.ts) so a test that
179
+ * delegates its assertion to a locally-defined helper — `audit(page,
180
+ * path)`, `checkFlashMessageVisibility(page, msg)`, `assertDashboardLoaded`
181
+ * — isn't flagged as having no assertions, regardless of what the helper
182
+ * happens to be named. expect-expect can only see expect() calls written
183
+ * directly in the test body, so without this, delegating to a helper at
184
+ * all — an extremely common way to dedupe near-identical specs — reads as
185
+ * "no assertions"; verified against several real production files using
186
+ * several different naming conventions for the exact same shape.
187
+ * Deliberately local-only: a helper imported from another file can't be
188
+ * resolved without following module specifiers and parsing that file too,
189
+ * well beyond what a fast, per-file syntactic check should attempt.
190
+ */
191
+ export function findLocalAssertionHelperNames(source, file) {
192
+ const ast = parseSource(source, file);
193
+ if (!ast)
194
+ return [];
195
+ const names = new Set();
196
+ walk(ast, (node) => {
197
+ let name;
198
+ let body;
199
+ if (node.type === 'FunctionDeclaration') {
200
+ const id = node.id;
201
+ if (id?.type === 'Identifier') {
202
+ name = id.name;
203
+ body = node;
204
+ }
205
+ }
206
+ else if (node.type === 'VariableDeclarator') {
207
+ const id = node.id;
208
+ const init = node.init;
209
+ if (id?.type === 'Identifier' &&
210
+ (init?.type === 'ArrowFunctionExpression' || init?.type === 'FunctionExpression')) {
211
+ name = id.name;
212
+ body = init;
213
+ }
214
+ }
215
+ if (name && body && containsExpectCall(body))
216
+ names.add(name);
217
+ });
218
+ return [...names];
219
+ }
220
+ const TEST_NON_DECL_RE = /\btest\.(describe|step|beforeEach|afterEach|beforeAll|afterAll|use|setTimeout|info|expect)\b/;
221
+ /** Count test( / test.only( / test.skip( / test.fixme( style declarations (approx). */
11
222
  export function countTests(source) {
12
- const re = /\btest(?:\.(?:only|skip|fix|slow))?\s*\(/g;
13
- // Exclude test.describe, test.step, test.before*, test.after*, test.use
223
+ // Playwright's real modifier API is test.fixme() — not test.fix().
224
+ const re = /\btest(?:\.(?:only|skip|fixme|slow))?\s*\(/g;
14
225
  let count = 0;
15
226
  let m;
16
- const exclude = /\btest\.(?:describe|step|beforeEach|afterEach|beforeAll|afterAll|use|setTimeout|info|expect)\b/;
17
227
  while ((m = re.exec(source)) !== null) {
18
- const start = Math.max(0, m.index - 0);
19
- const snippet = source.slice(start, start + 40);
20
- if (exclude.test(snippet.split('(')[0] + '(')) {
21
- // more precise: check the matched prefix
22
- }
23
228
  const before = source.slice(Math.max(0, m.index - 1), m.index + 20);
24
- if (/\btest\.(describe|step|beforeEach|afterEach|beforeAll|afterAll|use|setTimeout|info)\b/.test(before)) {
25
- continue;
26
- }
27
- // test.expect is rare as call form
28
- if (/\btest\.expect\b/.test(before))
229
+ if (TEST_NON_DECL_RE.test(before))
29
230
  continue;
30
231
  count++;
31
232
  }
32
233
  return count;
33
234
  }
34
- /**
35
- * Detect test callbacks that appear to have zero expect( calls.
36
- * Heuristic: for each test( ... async (...) => { body }, check body for expect.
37
- */
38
- export function findEmptyExpectTests(source, file) {
39
- const findings = [];
40
- // Rough scan: if file has test( but no expect( at all
41
- const hasTest = /\btest(?:\.(?:only|skip|fix))?\s*\(/.test(source);
42
- const hasExpect = /\bexpect\s*\(/.test(source);
43
- if (hasTest && !hasExpect) {
44
- findings.push({
45
- rule: 'metrics/no-empty-test',
46
- severity: 'error',
47
- message: 'Test file has test() but no expect() assertions',
48
- file,
49
- dimension: 'assertions',
50
- });
51
- }
52
- return findings;
53
- }
54
235
  export function findOversizedFile(source, file, maxSloc = 400) {
55
236
  const sloc = countSloc(source);
56
237
  if (sloc > maxSloc) {
@@ -70,10 +251,16 @@ export function analyzeSource(source, file) {
70
251
  return {
71
252
  sloc: countSloc(source),
72
253
  tests: countTests(source),
73
- locators: countLocators(source),
74
- findings: [
75
- ...findEmptyExpectTests(source, file),
76
- ...findOversizedFile(source, file),
77
- ],
254
+ locators: countLocators(source, file),
255
+ // No homegrown "has no assertions" check here: playwright/expect-expect
256
+ // (a real AST-based community rule, always enabled — see
257
+ // eslint-runner.ts) already covers this correctly, including
258
+ // expect.poll(...)/expect.soft(...) chained forms that a source-text
259
+ // regex would need to special-case. A prior regex-based version of
260
+ // this check (metrics/no-empty-test) only matched a bare `expect(`
261
+ // call and so false-positived at error severity on any file whose only
262
+ // assertions were expect.poll()/expect.soft() — verified against real
263
+ // production code.
264
+ findings: [...findOversizedFile(source, file)],
78
265
  };
79
266
  }
package/dist/profiles.js CHANGED
@@ -45,10 +45,9 @@ export const ESLINT_RULE_MAP = {
45
45
  },
46
46
  // Assertions
47
47
  'playwright/expect-expect': { dimension: 'assertions' },
48
- 'playwright/prefer-web-first-assertions': {
49
- dimension: 'assertions',
50
- severityOverride: 'warning',
51
- },
48
+ // No severityOverride: matches eslint-plugin-playwright's own
49
+ // recommended severity ('error'), set via eslint-runner.ts's rule config.
50
+ 'playwright/prefer-web-first-assertions': { dimension: 'assertions' },
52
51
  'playwright/valid-expect': { dimension: 'assertions' },
53
52
  'playwright/no-standalone-expect': { dimension: 'assertions' },
54
53
  // Locators — findings for repair, reportOnly so ratio owns locators score
@@ -69,10 +68,10 @@ export const ESLINT_RULE_MAP = {
69
68
  },
70
69
  // Structure
71
70
  'playwright/no-focused-test': { dimension: 'structure' },
72
- 'playwright/no-skipped-test': {
73
- dimension: 'structure',
74
- severityOverride: 'warning',
75
- },
71
+ // playwright/no-skipped-test is turned off in eslint-runner.ts in favor
72
+ // of pwscore/no-skipped-test-declaration, which doesn't false-positive
73
+ // on Playwright's documented conditional test.skip(condition, reason).
74
+ 'pwscore/no-skipped-test-declaration': { dimension: 'structure' },
76
75
  'playwright/max-nested-describe': {
77
76
  dimension: 'structure',
78
77
  severityOverride: 'warning',
@@ -101,17 +100,7 @@ export const ESLINT_RULE_MAP = {
101
100
  dimension: 'guardianConventions',
102
101
  severityOverride: 'warning',
103
102
  },
104
- 'guardian/prefer-uuid': {
105
- dimension: 'guardianConventions',
106
- severityOverride: 'warning',
107
- },
108
- 'guardian/fixtures-import': {
109
- dimension: 'guardianConventions',
110
- severityOverride: 'info',
111
- reportOnly: true,
112
- },
113
103
  // Metrics injected as synthetic findings
114
- 'metrics/no-empty-test': { dimension: 'assertions' },
115
104
  'metrics/oversized-file': {
116
105
  dimension: 'structure',
117
106
  severityOverride: 'warning',
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Rules active for both `standard` and `guardian` profiles — corrections
3
+ * to upstream eslint-plugin-playwright behavior, not house style.
4
+ */
5
+ import type { Rule } from 'eslint';
6
+ export declare const basePlugin: {
7
+ meta: {
8
+ name: string;
9
+ version: string;
10
+ };
11
+ rules: {
12
+ 'no-skipped-test-declaration': Rule.RuleModule;
13
+ };
14
+ };
@@ -0,0 +1,77 @@
1
+ function isSkipCallee(callee) {
2
+ if (!callee || callee.type !== 'MemberExpression' || callee.computed)
3
+ return false;
4
+ const obj = callee.object;
5
+ const prop = callee.property;
6
+ if (prop?.type !== 'Identifier' || prop.name !== 'skip')
7
+ return false;
8
+ // test.skip(...)
9
+ if (obj?.type === 'Identifier' && obj.name === 'test')
10
+ return true;
11
+ // test.describe.skip(...)
12
+ if (obj?.type === 'MemberExpression' &&
13
+ !obj.computed &&
14
+ obj.object?.type === 'Identifier' &&
15
+ obj.object.name === 'test' &&
16
+ obj.property?.type === 'Identifier' &&
17
+ obj.property.name === 'describe') {
18
+ return true;
19
+ }
20
+ return false;
21
+ }
22
+ const FUNCTION_TYPES = new Set(['FunctionExpression', 'ArrowFunctionExpression']);
23
+ /**
24
+ * eslint-plugin-playwright's no-skipped-test flags every form of
25
+ * test.skip(...)/test.describe.skip(...) identically. That conflates two
26
+ * very different, both-real patterns:
27
+ *
28
+ * - test.skip('name', fn) / test.describe.skip('name', fn) — a
29
+ * DECLARATION whose test/suite never runs at all. Worth flagging: this
30
+ * is the "test is broken, hide it" anti-pattern.
31
+ * - test.skip(condition, reason) / test.skip() called inside a test/hook
32
+ * body — Playwright's own documented API for conditionally skipping a
33
+ * test at runtime (e.g. per-browser, per-env). This is the correct,
34
+ * recommended way to handle exactly that case, not an anti-pattern —
35
+ * see playwright.dev/docs/test-annotations#conditionally-skip-a-test.
36
+ *
37
+ * Verified against real-world code: eslint-plugin-playwright's rule flags
38
+ * both forms identically, generating warnings on entirely idiomatic
39
+ * cross-browser test suites. This rule replaces it (see eslint-runner.ts,
40
+ * where the upstream rule is turned off) with the same "declaration form"
41
+ * severity but without the false positive on the conditional form — the
42
+ * two are told apart by whether the trailing argument is a function (a
43
+ * declaration always supplies the test body as a function; a conditional
44
+ * skip's second argument, if any, is the string reason).
45
+ */
46
+ const noSkippedTestDeclaration = {
47
+ meta: {
48
+ type: 'suggestion',
49
+ docs: {
50
+ description: 'Disallow test.skip()/test.describe.skip() used to declare an always-skipped test (conditional runtime skips are unaffected)',
51
+ },
52
+ messages: {
53
+ skipped: 'Unexpected use of the .skip() annotation to declare an always-skipped test.',
54
+ },
55
+ schema: [],
56
+ },
57
+ create(context) {
58
+ return {
59
+ CallExpression(node) {
60
+ if (!isSkipCallee(node.callee))
61
+ return;
62
+ const args = node.arguments ?? [];
63
+ const lastArg = args[args.length - 1];
64
+ const isDeclaration = args.length >= 2 && FUNCTION_TYPES.has(lastArg?.type ?? '');
65
+ if (isDeclaration) {
66
+ context.report({ node: node, messageId: 'skipped' });
67
+ }
68
+ },
69
+ };
70
+ },
71
+ };
72
+ export const basePlugin = {
73
+ meta: { name: 'pwscore-base', version: '0.1.0' },
74
+ rules: {
75
+ 'no-skipped-test-declaration': noSkippedTestDeclaration,
76
+ },
77
+ };
@@ -13,6 +13,76 @@ const DUMMY_SECRETS = new Set([
13
13
  'qwerty',
14
14
  '',
15
15
  ].map((s) => s.toLowerCase()));
16
+ // Playwright's actual modifier API is test.fixme() — not test.fix().
17
+ const TEST_MODIFIERS = new Set(['only', 'skip', 'fixme', 'slow']);
18
+ const DESCRIBE_MODIFIERS = new Set(['serial', 'parallel', 'only', 'fixme']);
19
+ /** Matches a bare test(...) call. */
20
+ function isBareTestCall(node) {
21
+ return node.callee?.type === 'Identifier' && node.callee.name === 'test';
22
+ }
23
+ /** Matches test.describe(...) and test.describe.serial/parallel/only/fixme(...). */
24
+ function isDescribeCall(node) {
25
+ const callee = node.callee;
26
+ if (!callee || callee.type !== 'MemberExpression' || callee.computed)
27
+ return false;
28
+ const obj = callee.object;
29
+ const prop = callee.property;
30
+ if (!obj || !prop || prop.type !== 'Identifier')
31
+ return false;
32
+ // test.describe(...)
33
+ if (obj.type === 'Identifier' && obj.name === 'test' && prop.name === 'describe') {
34
+ return true;
35
+ }
36
+ // test.describe.serial/parallel/only/fixme(...)
37
+ if (obj.type === 'MemberExpression' &&
38
+ !obj.computed &&
39
+ obj.object?.type === 'Identifier' &&
40
+ obj.object.name === 'test' &&
41
+ obj.property?.type === 'Identifier' &&
42
+ obj.property.name === 'describe' &&
43
+ DESCRIBE_MODIFIERS.has(prop.name ?? '')) {
44
+ return true;
45
+ }
46
+ return false;
47
+ }
48
+ /** Matches specifically test.describe.serial(...). */
49
+ function isSerialDescribeCall(node) {
50
+ const callee = node.callee;
51
+ if (!callee || callee.type !== 'MemberExpression' || callee.computed)
52
+ return false;
53
+ const obj = callee.object;
54
+ const prop = callee.property;
55
+ return (obj?.type === 'MemberExpression' &&
56
+ !obj.computed &&
57
+ obj.object?.type === 'Identifier' &&
58
+ obj.object.name === 'test' &&
59
+ obj.property?.type === 'Identifier' &&
60
+ obj.property.name === 'describe' &&
61
+ prop?.type === 'Identifier' &&
62
+ prop.name === 'serial');
63
+ }
64
+ /** Matches test.only/skip/fixme/slow(...) (a modifier form of a real test declaration). */
65
+ function isTestModifierCall(node) {
66
+ const callee = node.callee;
67
+ if (!callee || callee.type !== 'MemberExpression' || callee.computed)
68
+ return false;
69
+ const obj = callee.object;
70
+ const prop = callee.property;
71
+ return (obj?.type === 'Identifier' &&
72
+ obj.name === 'test' &&
73
+ prop?.type === 'Identifier' &&
74
+ TEST_MODIFIERS.has(prop.name ?? ''));
75
+ }
76
+ /** Matches test.step(...). */
77
+ function isTestStepCall(node) {
78
+ const callee = node.callee;
79
+ return (callee?.type === 'MemberExpression' &&
80
+ !callee.computed &&
81
+ callee.object?.type === 'Identifier' &&
82
+ callee.object.name === 'test' &&
83
+ callee.property?.type === 'Identifier' &&
84
+ callee.property.name === 'step');
85
+ }
16
86
  function hasDisableNextLine(sourceCode, line, ruleId) {
17
87
  const lines = sourceCode.getText().split(/\r?\n/);
18
88
  if (line < 2)
@@ -69,6 +139,42 @@ const noDateNowId = {
69
139
  };
70
140
  },
71
141
  };
142
+ // Whole words only (after splitting camelCase/snake_case/kebab-case), not a
143
+ // bare substring match — a substring match on "auth" flags "authorName",
144
+ // "isAuthenticated", every "Author"/"authenticate"/"authorize" field in a
145
+ // typical app under test, which is a lot of unrelated real-world test data.
146
+ const SENSITIVE_WORDS = new Set([
147
+ 'password',
148
+ 'passwd',
149
+ 'secret',
150
+ 'secrets',
151
+ 'token',
152
+ 'tokens',
153
+ 'auth',
154
+ 'authorization',
155
+ 'credential',
156
+ 'credentials',
157
+ ]);
158
+ function splitIdentifierWords(identifier) {
159
+ return identifier
160
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
161
+ .replace(/[_-]+/g, ' ')
162
+ .split(/\s+/)
163
+ .filter(Boolean)
164
+ .map((w) => w.toLowerCase());
165
+ }
166
+ function looksLikeSensitiveKey(rawKey) {
167
+ const words = splitIdentifierWords(rawKey);
168
+ if (words.some((w) => SENSITIVE_WORDS.has(w)))
169
+ return true;
170
+ // apiKey / api_key / API-KEY — "key" alone is too generic (primaryKey,
171
+ // sortKey, ...) so only flag it directly adjacent to "api".
172
+ for (let i = 0; i < words.length - 1; i++) {
173
+ if (words[i] === 'api' && words[i + 1] === 'key')
174
+ return true;
175
+ }
176
+ return false;
177
+ }
72
178
  const noHardcodedSecrets = {
73
179
  meta: {
74
180
  type: 'suggestion',
@@ -80,7 +186,6 @@ const noHardcodedSecrets = {
80
186
  },
81
187
  create(context) {
82
188
  const sourceCode = context.sourceCode ?? context.getSourceCode();
83
- const keyRe = /password|passwd|secret|token|api[_-]?key|auth/i;
84
189
  function check(node, rawKey) {
85
190
  if (node.type !== 'Literal' || typeof node.value !== 'string')
86
191
  return;
@@ -89,7 +194,7 @@ const noHardcodedSecrets = {
89
194
  return;
90
195
  if (value.length < 6)
91
196
  return;
92
- if (!keyRe.test(rawKey ?? ''))
197
+ if (!looksLikeSensitiveKey(rawKey ?? ''))
93
198
  return;
94
199
  const line = node.loc?.start.line ?? 1;
95
200
  if (hasDisableNextLine(sourceCode, line, 'guardian/no-hardcoded-secrets'))
@@ -126,13 +231,26 @@ const noGenericLongTimeout = {
126
231
  const allowed = new Set([2000, 20000]);
127
232
  return {
128
233
  Property(node) {
129
- if (node.key.type === 'Identifier' &&
130
- node.key.name === 'timeout' &&
131
- node.value.type === 'Literal' &&
132
- typeof node.value.value === 'number' &&
133
- !allowed.has(node.value.value)) {
134
- context.report({ node, messageId: 'timeout' });
234
+ if (node.key.type !== 'Identifier' ||
235
+ node.key.name !== 'timeout' ||
236
+ node.value.type !== 'Literal' ||
237
+ typeof node.value.value !== 'number' ||
238
+ allowed.has(node.value.value)) {
239
+ return;
135
240
  }
241
+ // Only flag timeout options passed inline to a call, e.g.
242
+ // locator.click({ timeout }), expect(x).toBeVisible({ timeout }),
243
+ // test('x', { timeout }, fn) — not unrelated business data that
244
+ // happens to have a field named "timeout" (session config, a
245
+ // fixture payload, a mocked API response body, ...).
246
+ const objectExpr = node.parent;
247
+ const call = objectExpr?.parent;
248
+ if (objectExpr?.type !== 'ObjectExpression' ||
249
+ call?.type !== 'CallExpression' ||
250
+ !call.arguments?.includes(objectExpr)) {
251
+ return;
252
+ }
253
+ context.report({ node, messageId: 'timeout' });
136
254
  },
137
255
  };
138
256
  },
@@ -150,27 +268,16 @@ const oneDescribeOneTest = {
150
268
  create(context) {
151
269
  let describes = 0;
152
270
  let tests = 0;
271
+ let usesSerialDescribe = false;
153
272
  return {
154
273
  CallExpression(node) {
155
- if (node.callee.type === 'MemberExpression' && !node.callee.computed) {
156
- if (node.callee.object.type === 'Identifier' &&
157
- node.callee.object.name === 'test' &&
158
- node.callee.property.type === 'Identifier' &&
159
- node.callee.property.name === 'describe') {
160
- describes++;
161
- }
162
- }
163
- if (node.callee.type === 'Identifier' && node.callee.name === 'test') {
164
- tests++;
274
+ if (isDescribeCall(node)) {
275
+ describes++;
276
+ if (isSerialDescribeCall(node))
277
+ usesSerialDescribe = true;
165
278
  }
166
- if (node.callee.type === 'MemberExpression' &&
167
- !node.callee.computed &&
168
- node.callee.object.type === 'Identifier' &&
169
- node.callee.object.name === 'test' &&
170
- node.callee.property.type === 'Identifier' &&
171
- ['only', 'skip', 'fix', 'slow'].includes(node.callee.property.name)) {
279
+ if (isBareTestCall(node) || isTestModifierCall(node))
172
280
  tests++;
173
- }
174
281
  },
175
282
  'Program:exit'() {
176
283
  if (describes > 1) {
@@ -180,7 +287,12 @@ const oneDescribeOneTest = {
180
287
  data: { count: String(describes) },
181
288
  });
182
289
  }
183
- if (tests > 1) {
290
+ // test.describe.serial(...) is Playwright's documented mechanism
291
+ // for one user journey split into multiple ordered, dependent
292
+ // test() calls — the same "one scenario per file" intent as this
293
+ // rule, expressed a different way. Don't nag a pattern the
294
+ // framework itself recommends for exactly this case.
295
+ if (tests > 1 && !usesSerialDescribe) {
184
296
  context.report({
185
297
  loc: { line: 1, column: 0 },
186
298
  messageId: 'tests',
@@ -205,20 +317,10 @@ const requireTestStep = {
205
317
  let hasStep = false;
206
318
  return {
207
319
  CallExpression(node) {
208
- if (node.callee.type === 'Identifier' && node.callee.name === 'test') {
320
+ if (isBareTestCall(node) || isTestModifierCall(node))
209
321
  hasTest = true;
210
- }
211
- if (node.callee.type === 'MemberExpression' &&
212
- !node.callee.computed &&
213
- node.callee.object.type === 'Identifier' &&
214
- node.callee.object.name === 'test' &&
215
- node.callee.property.type === 'Identifier') {
216
- if (['only', 'skip', 'fix', 'slow'].includes(node.callee.property.name)) {
217
- hasTest = true;
218
- }
219
- if (node.callee.property.name === 'step')
220
- hasStep = true;
221
- }
322
+ if (isTestStepCall(node))
323
+ hasStep = true;
222
324
  },
223
325
  'Program:exit'() {
224
326
  if (hasTest && !hasStep) {
@@ -242,14 +344,13 @@ const requireExpect = {
242
344
  let hasExpect = false;
243
345
  return {
244
346
  CallExpression(node) {
245
- if (node.callee.type === 'Identifier' && node.callee.name === 'test') {
347
+ if (isBareTestCall(node) || isTestModifierCall(node))
246
348
  hasTest = true;
247
- }
248
- if (node.callee.type === 'Identifier' && node.callee.name === 'expect') {
349
+ if (node.callee?.type === 'Identifier' && node.callee.name === 'expect') {
249
350
  hasExpect = true;
250
351
  }
251
- if (node.callee.type === 'MemberExpression' &&
252
- node.callee.object.type === 'Identifier' &&
352
+ if (node.callee?.type === 'MemberExpression' &&
353
+ node.callee.object?.type === 'Identifier' &&
253
354
  node.callee.object.name === 'expect') {
254
355
  hasExpect = true;
255
356
  }
package/dist/types.d.ts CHANGED
@@ -41,6 +41,15 @@ export interface ScoreResult {
41
41
  summary: ScoreSummary;
42
42
  dimensions: ScoreDimensions;
43
43
  findings: Finding[];
44
+ /**
45
+ * Files matched by a directory/glob input but skipped because they show
46
+ * positive evidence of being a non-Playwright test (an import from
47
+ * vitest/jest/mocha/jasmine/@testing-library, or React Testing Library's
48
+ * screen.getBy/toBeInTheDocument() APIs). Repo-relative paths. Files passed
49
+ * explicitly by the caller are always scored and never appear here, even
50
+ * if they'd otherwise be skipped.
51
+ */
52
+ skippedFiles?: string[];
44
53
  }
45
54
  export interface ScoreOptions {
46
55
  paths: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qaguardian/playwright-score",
3
- "version": "0.1.2",
3
+ "version": "0.1.9",
4
4
  "description": "Deterministic, AI-free Playwright spec quality score (sqs-v1). Lint + score with standard and guardian profiles.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",