@qaguardian/playwright-score 0.1.2 → 0.1.10
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 +33 -1
- package/dist/cli.js +1 -1
- package/dist/eslint-runner.d.ts +2 -0
- package/dist/eslint-runner.js +97 -7
- package/dist/formatters/markdown.js +10 -0
- package/dist/formatters/sarif.js +1 -1
- package/dist/formatters/text.js +10 -0
- package/dist/index.js +156 -41
- 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)
|
|
@@ -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
|
-
##
|
|
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
package/dist/eslint-runner.d.ts
CHANGED
package/dist/eslint-runner.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
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) {
|
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,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
|
|
47
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
};
|
|
160
|
+
], {}, skippedFiles);
|
|
84
161
|
}
|
|
85
|
-
|
|
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 =
|
|
93
|
-
const
|
|
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
|
-
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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 (
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 (!
|
|
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
|
|
130
|
-
node.key.name
|
|
131
|
-
node.value.type
|
|
132
|
-
typeof node.value.value
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
320
|
+
if (isBareTestCall(node) || isTestModifierCall(node))
|
|
209
321
|
hasTest = true;
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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
|
|
252
|
-
node.callee.object
|
|
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.
|
|
3
|
+
"version": "0.1.10",
|
|
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",
|