@qaguardian/playwright-score 0.1.0 → 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 +43 -7
- package/dist/cli.js +23 -4
- package/dist/eslint-runner.d.ts +2 -0
- package/dist/eslint-runner.js +109 -8
- package/dist/formatters/markdown.js +10 -0
- package/dist/formatters/sarif.js +1 -1
- package/dist/formatters/text.js +10 -0
- package/dist/fs-util.d.ts +6 -0
- package/dist/fs-util.js +22 -0
- package/dist/index.js +164 -31
- package/dist/metrics.d.ts +28 -7
- package/dist/metrics.js +232 -45
- package/dist/profiles.js +7 -18
- package/dist/rules/base-plugin.d.ts +14 -0
- package/dist/rules/base-plugin.js +77 -0
- package/dist/rules/guardian-plugin.js +145 -44
- package/dist/types.d.ts +9 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# @qaguardian/playwright-score
|
|
2
2
|
|
|
3
|
+
[](https://github.com/qa-guardian/playwright-score/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@qaguardian/playwright-score)
|
|
5
|
+
[](./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)
|
|
@@ -18,15 +22,18 @@ See [METHODOLOGY.md](./METHODOLOGY.md) for the frozen formula, or the full produ
|
|
|
18
22
|
|
|
19
23
|
```bash
|
|
20
24
|
npm install -D @qaguardian/playwright-score
|
|
21
|
-
# or run from this package
|
|
22
|
-
npm install && npm run build
|
|
23
25
|
```
|
|
24
26
|
|
|
27
|
+
Package: [@qaguardian/playwright-score](https://www.npmjs.com/package/@qaguardian/playwright-score) on npm.
|
|
28
|
+
|
|
25
29
|
## CLI
|
|
26
30
|
|
|
27
31
|
```bash
|
|
28
|
-
|
|
29
|
-
npx @qaguardian/playwright-score ./
|
|
32
|
+
# One-shot (scoped package — use -p so the playwright-score binary is resolved)
|
|
33
|
+
npx -p @qaguardian/playwright-score playwright-score ./tests --profile standard --threshold 80
|
|
34
|
+
|
|
35
|
+
# After local install
|
|
36
|
+
npx playwright-score ./flow.spec.ts --profile guardian --format json --out report.json
|
|
30
37
|
```
|
|
31
38
|
|
|
32
39
|
| Flag | Description |
|
|
@@ -35,8 +42,9 @@ npx @qaguardian/playwright-score ./flow.spec.ts --profile guardian --format json
|
|
|
35
42
|
| `--threshold <n>` | Pass bar 0–100 |
|
|
36
43
|
| `--format text\|json\|markdown\|sarif` | Output format |
|
|
37
44
|
| `--out <file>` | Write report to file |
|
|
45
|
+
| `--version` | Print version |
|
|
38
46
|
|
|
39
|
-
**Exit codes:** `0` pass · `1` below threshold · `2` tool error
|
|
47
|
+
**Exit codes:** `0` pass · `1` below threshold **or no files matched** · `2` tool error
|
|
40
48
|
|
|
41
49
|
## Library
|
|
42
50
|
|
|
@@ -52,11 +60,39 @@ const result = await scorePaths({
|
|
|
52
60
|
console.log(result.score, result.grade, result.pass, result.findings);
|
|
53
61
|
```
|
|
54
62
|
|
|
55
|
-
##
|
|
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:
|
|
56
92
|
|
|
57
93
|
```yaml
|
|
58
94
|
- name: Playwright Spec Score
|
|
59
|
-
run: npx playwright-score ./tests --profile standard --threshold 80 --format text
|
|
95
|
+
run: npx -p @qaguardian/playwright-score playwright-score ./tests --profile standard --threshold 80 --format text
|
|
60
96
|
```
|
|
61
97
|
|
|
62
98
|
## QA Guardian integration
|
package/dist/cli.js
CHANGED
|
@@ -10,28 +10,44 @@ import { formatText } from './formatters/text.js';
|
|
|
10
10
|
import { formatMarkdown } from './formatters/markdown.js';
|
|
11
11
|
import { formatSarif } from './formatters/sarif.js';
|
|
12
12
|
function printHelp() {
|
|
13
|
-
console.log(
|
|
13
|
+
console.log(`@qaguardian/playwright-score — deterministic Playwright spec quality score (sqs-v1)
|
|
14
14
|
|
|
15
15
|
Usage:
|
|
16
|
-
playwright-score <paths...> [options]
|
|
16
|
+
npx -p @qaguardian/playwright-score playwright-score <paths...> [options]
|
|
17
|
+
# after npm install -D @qaguardian/playwright-score:
|
|
18
|
+
npx playwright-score <paths...> [options]
|
|
17
19
|
|
|
18
20
|
Options:
|
|
19
21
|
--profile <standard|guardian> Scoring profile (default: standard)
|
|
20
22
|
--threshold <n> Pass threshold 0-100 (default: 80 standard / 75 guardian)
|
|
21
23
|
--format <text|json|markdown|sarif> Output format (default: text)
|
|
22
24
|
--out <file> Write report to file
|
|
25
|
+
--version Print version
|
|
23
26
|
--help Show help
|
|
24
27
|
|
|
25
28
|
Exit codes:
|
|
26
29
|
0 score >= threshold
|
|
27
|
-
1 score < threshold
|
|
30
|
+
1 score < threshold (or no files matched)
|
|
28
31
|
2 tool error
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
Website: https://qaguardian.com/open-source/playwright-score
|
|
34
|
+
Methodology: METHODOLOGY.md (sqs-v1)
|
|
31
35
|
`);
|
|
32
36
|
}
|
|
33
37
|
async function main() {
|
|
34
38
|
const args = process.argv.slice(2);
|
|
39
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
40
|
+
try {
|
|
41
|
+
const { createRequire } = await import('node:module');
|
|
42
|
+
const require = createRequire(import.meta.url);
|
|
43
|
+
const pkg = require('../package.json');
|
|
44
|
+
console.log(pkg.version ?? '0.0.0');
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
console.log('0.1.8');
|
|
48
|
+
}
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
35
51
|
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
36
52
|
printHelp();
|
|
37
53
|
process.exit(args.length === 0 ? 2 : 0);
|
|
@@ -68,6 +84,9 @@ async function main() {
|
|
|
68
84
|
else if (a === '--out') {
|
|
69
85
|
out = args[++i];
|
|
70
86
|
}
|
|
87
|
+
else if (a === '--version' || a === '-v') {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
71
90
|
else if (a.startsWith('-')) {
|
|
72
91
|
console.error(`Unknown option: ${a}`);
|
|
73
92
|
process.exit(2);
|
package/dist/eslint-runner.d.ts
CHANGED
package/dist/eslint-runner.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { ESLint } from 'eslint';
|
|
3
|
+
import { commonAncestorDir } from './fs-util.js';
|
|
3
4
|
import playwright from 'eslint-plugin-playwright';
|
|
4
5
|
import { guardianPlugin, guardianRuleConfigs, } from './rules/guardian-plugin.js';
|
|
6
|
+
import { basePlugin } from './rules/base-plugin.js';
|
|
5
7
|
import { mapRule } from './profiles.js';
|
|
6
|
-
function buildConfig(profile) {
|
|
8
|
+
function buildConfig(profile, assertFunctionNames) {
|
|
7
9
|
const playwrightRecommended = playwright.configs?.['flat/recommended'] ??
|
|
8
10
|
playwright.configs?.recommended;
|
|
9
11
|
const base = [
|
|
@@ -16,21 +18,79 @@ function buildConfig(profile) {
|
|
|
16
18
|
},
|
|
17
19
|
plugins: {
|
|
18
20
|
playwright,
|
|
21
|
+
pwscore: basePlugin,
|
|
19
22
|
...(profile === 'guardian' ? { guardian: guardianPlugin } : {}),
|
|
20
23
|
},
|
|
21
24
|
rules: {
|
|
22
|
-
// Ensure key hygiene rules even if recommended set differs by
|
|
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.
|
|
23
34
|
'playwright/no-wait-for-timeout': 'error',
|
|
24
35
|
'playwright/no-networkidle': 'error',
|
|
25
36
|
'playwright/no-force-option': 'error',
|
|
26
37
|
'playwright/no-wait-for-selector': 'error',
|
|
27
38
|
'playwright/no-wait-for-navigation': 'error',
|
|
28
39
|
'playwright/missing-playwright-await': 'error',
|
|
29
|
-
|
|
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
|
+
],
|
|
30
69
|
'playwright/no-focused-test': 'error',
|
|
31
|
-
|
|
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',
|
|
32
79
|
'playwright/no-raw-locators': 'warn',
|
|
33
|
-
|
|
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',
|
|
34
94
|
...(profile === 'guardian' ? { ...guardianRuleConfigs } : {}),
|
|
35
95
|
},
|
|
36
96
|
},
|
|
@@ -67,8 +127,18 @@ function toSeverity(eslintSeverity, override) {
|
|
|
67
127
|
* Does not load host-repo eslint.config.js.
|
|
68
128
|
*/
|
|
69
129
|
export async function runEslint(options) {
|
|
70
|
-
|
|
71
|
-
|
|
130
|
+
// ESLint 9 flat config resolves a `basePath` from `cwd` and silently
|
|
131
|
+
// drops (as an untracked, ruleId-less message) any file that isn't
|
|
132
|
+
// underneath it — no error, no findings, just a quietly clean score.
|
|
133
|
+
// The caller's cwd (e.g. a runner process's install dir) has no reason
|
|
134
|
+
// to contain the spec files being scored, so it can never be trusted
|
|
135
|
+
// as the ESLint basePath. Derive a basePath that is guaranteed to
|
|
136
|
+
// contain every file instead; fall back to the caller's cwd only when
|
|
137
|
+
// there are no files (nothing to lint anyway).
|
|
138
|
+
const cwd = options.files.length > 0
|
|
139
|
+
? commonAncestorDir(options.files)
|
|
140
|
+
: (options.cwd ?? process.cwd());
|
|
141
|
+
const overrideConfig = buildConfig(options.profile, options.assertFunctionNames ?? []);
|
|
72
142
|
// Try to attach typescript-eslint parser for .ts files
|
|
73
143
|
try {
|
|
74
144
|
const tsParser = await import('@typescript-eslint/parser');
|
|
@@ -99,8 +169,39 @@ export async function runEslint(options) {
|
|
|
99
169
|
const results = await eslint.lintFiles(options.files);
|
|
100
170
|
const findings = [];
|
|
101
171
|
for (const result of results) {
|
|
102
|
-
const
|
|
172
|
+
const absFile = path.resolve(result.filePath);
|
|
173
|
+
const file = path.relative(cwd, absFile) || path.basename(absFile);
|
|
103
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
|
+
}
|
|
104
205
|
if (!msg.ruleId)
|
|
105
206
|
continue;
|
|
106
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
|
}
|
package/dist/formatters/sarif.js
CHANGED
|
@@ -17,7 +17,7 @@ export function formatSarif(result) {
|
|
|
17
17
|
tool: {
|
|
18
18
|
driver: {
|
|
19
19
|
name: 'playwright-score',
|
|
20
|
-
informationUri: 'https://github.com/
|
|
20
|
+
informationUri: 'https://github.com/qa-guardian/playwright-score',
|
|
21
21
|
version: result.scoreVersion,
|
|
22
22
|
rules: [...rulesMap.values()],
|
|
23
23
|
},
|
package/dist/formatters/text.js
CHANGED
|
@@ -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) {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deepest directory that contains every given absolute file path.
|
|
3
|
+
* Used as the ESLint flat-config basePath so linting never silently
|
|
4
|
+
* excludes files that fall outside an unrelated caller cwd.
|
|
5
|
+
*/
|
|
6
|
+
export declare function commonAncestorDir(absoluteFiles: string[]): string;
|
package/dist/fs-util.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* Deepest directory that contains every given absolute file path.
|
|
4
|
+
* Used as the ESLint flat-config basePath so linting never silently
|
|
5
|
+
* excludes files that fall outside an unrelated caller cwd.
|
|
6
|
+
*/
|
|
7
|
+
export function commonAncestorDir(absoluteFiles) {
|
|
8
|
+
const dirs = absoluteFiles.map((f) => path.dirname(path.resolve(f)));
|
|
9
|
+
if (dirs.length === 0)
|
|
10
|
+
return process.cwd();
|
|
11
|
+
let common = dirs[0].split(path.sep);
|
|
12
|
+
for (const dir of dirs.slice(1)) {
|
|
13
|
+
const parts = dir.split(path.sep);
|
|
14
|
+
let i = 0;
|
|
15
|
+
while (i < common.length && i < parts.length && common[i] === parts[i]) {
|
|
16
|
+
i++;
|
|
17
|
+
}
|
|
18
|
+
common = common.slice(0, i);
|
|
19
|
+
}
|
|
20
|
+
const joined = common.join(path.sep);
|
|
21
|
+
return joined || path.sep;
|
|
22
|
+
}
|
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 {
|
|
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
|
|
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
|
-
|
|
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, {
|
|
27
|
-
|
|
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, {
|
|
34
|
-
|
|
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
|
-
|
|
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,36 +119,78 @@ 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
|
|
122
|
+
const { explicit, expanded } = expandPaths(options.paths, cwd);
|
|
123
|
+
if (explicit.length === 0 && expanded.length === 0) {
|
|
124
|
+
// Hard-fail: empty match must never look like a healthy suite (was ~99 PASS).
|
|
125
|
+
return hardFail(profile, threshold, [
|
|
126
|
+
{
|
|
127
|
+
rule: 'playwright-score/no-files',
|
|
128
|
+
severity: 'error',
|
|
129
|
+
message: `No Playwright spec files matched: ${options.paths.join(', ') || '(no paths)'}`,
|
|
130
|
+
file: cwd,
|
|
131
|
+
dimension: 'structure',
|
|
132
|
+
},
|
|
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();
|
|
47
151
|
if (files.length === 0) {
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
{
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
dimension: 'structure',
|
|
58
|
-
},
|
|
59
|
-
],
|
|
60
|
-
sloc: 1,
|
|
61
|
-
files: 0,
|
|
62
|
-
tests: 0,
|
|
63
|
-
nativeLocators: 0,
|
|
64
|
-
rawLocators: 0,
|
|
65
|
-
});
|
|
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',
|
|
159
|
+
},
|
|
160
|
+
], {}, skippedFiles);
|
|
66
161
|
}
|
|
67
|
-
|
|
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
|
+
});
|
|
68
185
|
let totalSloc = 0;
|
|
69
186
|
let totalTests = 0;
|
|
70
187
|
let native = 0;
|
|
71
188
|
let raw = 0;
|
|
72
189
|
const metricFindings = [];
|
|
73
190
|
for (const file of files) {
|
|
74
|
-
const source =
|
|
75
|
-
const
|
|
191
|
+
const source = sources.get(file) ?? '';
|
|
192
|
+
const relFile = path.relative(filesBase, file) || path.basename(file);
|
|
193
|
+
const m = analyzeSource(source, relFile);
|
|
76
194
|
totalSloc += m.sloc;
|
|
77
195
|
totalTests += m.tests;
|
|
78
196
|
native += m.locators.native;
|
|
@@ -82,7 +200,21 @@ export async function scorePaths(options) {
|
|
|
82
200
|
}
|
|
83
201
|
// Dedupe empty-test style: if guardian require-expect already fired, still ok
|
|
84
202
|
const findings = [...eslintFindings, ...metricFindings];
|
|
85
|
-
|
|
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({
|
|
86
218
|
profile,
|
|
87
219
|
threshold,
|
|
88
220
|
findings,
|
|
@@ -92,4 +224,5 @@ export async function scorePaths(options) {
|
|
|
92
224
|
nativeLocators: native,
|
|
93
225
|
rawLocators: raw,
|
|
94
226
|
});
|
|
227
|
+
return skippedFiles.length > 0 ? { ...result, skippedFiles } : result;
|
|
95
228
|
}
|