@qaguardian/playwright-score 0.1.13 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/METHODOLOGY.md +12 -26
- package/README.md +18 -29
- package/dist/cli.js +35 -14
- package/dist/eslint-runner.js +2 -16
- package/dist/index.d.ts +1 -1
- package/dist/index.js +11 -14
- package/dist/profiles.js +0 -31
- package/dist/rules/base-plugin.d.ts +2 -2
- package/dist/score-engine.js +7 -10
- package/dist/types.d.ts +2 -3
- package/package.json +2 -3
- package/dist/rules/guardian-plugin.d.ts +0 -29
- package/dist/rules/guardian-plugin.js +0 -386
package/METHODOLOGY.md
CHANGED
|
@@ -11,14 +11,11 @@ Any change to formulas, weights, SLOC definition, caps, or constants requires a
|
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
-
##
|
|
14
|
+
## Profile
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
| `guardian` | `standard` + QA Guardian house conventions (optional; clearly labeled) |
|
|
20
|
-
|
|
21
|
-
House rules are **not** claimed to be official Playwright doctrine.
|
|
16
|
+
`standard` (the only profile): community Playwright best practices
|
|
17
|
+
(`eslint-plugin-playwright`) plus suite-level metrics (locator ratio,
|
|
18
|
+
assertion-delegation tracing, Page Object Model import resolution).
|
|
22
19
|
|
|
23
20
|
---
|
|
24
21
|
|
|
@@ -34,8 +31,6 @@ House rules are **not** claimed to be official Playwright doctrine.
|
|
|
34
31
|
|
|
35
32
|
## Dimension weights
|
|
36
33
|
|
|
37
|
-
### `standard`
|
|
38
|
-
|
|
39
34
|
| Dimension | Weight |
|
|
40
35
|
|---|---|
|
|
41
36
|
| playwrightHygiene | 40 |
|
|
@@ -43,16 +38,6 @@ House rules are **not** claimed to be official Playwright doctrine.
|
|
|
43
38
|
| locators | 20 |
|
|
44
39
|
| structure | 15 |
|
|
45
40
|
|
|
46
|
-
### `guardian`
|
|
47
|
-
|
|
48
|
-
| Dimension | Weight |
|
|
49
|
-
|---|---|
|
|
50
|
-
| playwrightHygiene | 30 |
|
|
51
|
-
| assertions | 15 |
|
|
52
|
-
| locators | 15 |
|
|
53
|
-
| structure | 15 |
|
|
54
|
-
| guardianConventions | 25 |
|
|
55
|
-
|
|
56
41
|
---
|
|
57
42
|
|
|
58
43
|
## Locators dimension (ratio)
|
|
@@ -88,7 +73,7 @@ Used for penalty density. JSDoc / decorative comments do not inflate slots.
|
|
|
88
73
|
|
|
89
74
|
## Penalty dimensions
|
|
90
75
|
|
|
91
|
-
Applies to: `playwrightHygiene`, `assertions`, `structure
|
|
76
|
+
Applies to: `playwrightHygiene`, `assertions`, `structure`.
|
|
92
77
|
|
|
93
78
|
### Constants (frozen sqs-v1)
|
|
94
79
|
|
|
@@ -159,11 +144,13 @@ These examples feel fair: tiny single error stays high; multi-error drops materi
|
|
|
159
144
|
|
|
160
145
|
## Default thresholds
|
|
161
146
|
|
|
162
|
-
| Context |
|
|
163
|
-
|
|
164
|
-
| CLI default |
|
|
165
|
-
|
|
166
|
-
|
|
147
|
+
| Context | Threshold |
|
|
148
|
+
|---|---|
|
|
149
|
+
| CLI default | 80 |
|
|
150
|
+
|
|
151
|
+
QA Guardian's own internal codegen pipeline (`playwright_runner`) layers a
|
|
152
|
+
private house-rules gate on top of `standard` — see the README's
|
|
153
|
+
"QA Guardian integration" section. That layer isn't part of this package.
|
|
167
154
|
|
|
168
155
|
---
|
|
169
156
|
|
|
@@ -175,4 +162,3 @@ See package rule docs / source `profiles.ts`. High level:
|
|
|
175
162
|
- **assertions:** expect-expect, empty tests, web-first preference
|
|
176
163
|
- **locators:** ratio metric only (for score); raw-locator ESLint in findings
|
|
177
164
|
- **structure:** focused/skipped, oversized file, describe shape
|
|
178
|
-
- **guardianConventions:** load-state ban, Date.now ban, secrets, test.step, one-describe-one-test, etc.
|
package/README.md
CHANGED
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
**Website:** [qaguardian.com/open-source/playwright-score](https://qaguardian.com/open-source/playwright-score)
|
|
10
10
|
**Built by:** [QA Guardian](https://qaguardian.com) — managed Playwright E2E (AI drafts, engineers verify, you own the code)
|
|
11
11
|
|
|
12
|
-
Lint + score Playwright tests
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
|
|
12
|
+
Lint + score Playwright tests against community best practices via
|
|
13
|
+
[`eslint-plugin-playwright`](https://github.com/playwright-community/eslint-plugin-playwright)
|
|
14
|
+
plus suite-level metrics (locator ratio, assertion-delegation tracing,
|
|
15
|
+
Page Object Model import resolution, ...).
|
|
16
16
|
|
|
17
17
|
The score never calls an LLM. AI may *generate* or *repair* code using findings; **rules grade code**.
|
|
18
18
|
|
|
19
|
-
See [METHODOLOGY.md](./METHODOLOGY.md) for the frozen formula, or the full product write-up on the [landing page](https://qaguardian.com/open-source/playwright-score).
|
|
19
|
+
See [METHODOLOGY.md](./METHODOLOGY.md) for the frozen formula, [VALIDATION.md](./VALIDATION.md) for real-world results against public repos (cal.com, Grafana, Mattermost, ...) and a re-runnable script to reproduce them, or the full product write-up on the [landing page](https://qaguardian.com/open-source/playwright-score).
|
|
20
20
|
|
|
21
21
|
## Install
|
|
22
22
|
|
|
@@ -33,12 +33,12 @@ Package: [@qaguardian/playwright-score](https://www.npmjs.com/package/@qaguardia
|
|
|
33
33
|
npx -p @qaguardian/playwright-score playwright-score ./tests --profile standard --threshold 80
|
|
34
34
|
|
|
35
35
|
# After local install
|
|
36
|
-
npx playwright-score ./flow.spec.ts --
|
|
36
|
+
npx playwright-score ./flow.spec.ts --format json --out report.json
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
| Flag | Description |
|
|
40
40
|
|---|---|
|
|
41
|
-
| `--profile standard
|
|
41
|
+
| `--profile standard` | Scoring profile (default, and only, profile) |
|
|
42
42
|
| `--threshold <n>` | Pass bar 0–100 |
|
|
43
43
|
| `--format text\|json\|markdown\|sarif` | Output format |
|
|
44
44
|
| `--out <file>` | Write report to file |
|
|
@@ -69,16 +69,15 @@ keep a sticky PR comment with the full findings up to date:
|
|
|
69
69
|
- uses: qa-guardian/playwright-score@v1
|
|
70
70
|
with:
|
|
71
71
|
paths: tests e2e
|
|
72
|
-
|
|
73
|
-
# threshold: 80 # defaults to the profile's default
|
|
72
|
+
# threshold: 80 # defaults to 80
|
|
74
73
|
mode: gate # gate: fail CI below threshold · warn: report only
|
|
75
74
|
```
|
|
76
75
|
|
|
77
76
|
| Input | Description | Default |
|
|
78
77
|
|---|---|---|
|
|
79
78
|
| `paths` | Space-separated paths/globs to score | `tests` |
|
|
80
|
-
| `profile` | `standard`
|
|
81
|
-
| `threshold` | Pass bar 0–100 |
|
|
79
|
+
| `profile` | `standard` (default, and only, profile) | `standard` |
|
|
80
|
+
| `threshold` | Pass bar 0–100 | `80` |
|
|
82
81
|
| `mode` | `gate` (fail CI below threshold) \| `warn` (report only) | `gate` |
|
|
83
82
|
| `comment` | Post/update a sticky PR comment | `true` |
|
|
84
83
|
| `version` | `@qaguardian/playwright-score` version to run | `latest` |
|
|
@@ -97,13 +96,17 @@ section above — same score, same exit codes:
|
|
|
97
96
|
|
|
98
97
|
## QA Guardian integration
|
|
99
98
|
|
|
100
|
-
|
|
99
|
+
QA Guardian's own codegen pipeline (`playwright_runner`) dogfoods this
|
|
100
|
+
package for the `standard` profile, layering its own private house-rules
|
|
101
|
+
gate on top internally — that layer isn't part of this package (it's
|
|
102
|
+
product-specific, e.g. "timeouts must be exactly 2000 or 20000ms", not
|
|
103
|
+
Playwright best practice) and isn't published here. `playwright_runner`
|
|
104
|
+
sets:
|
|
101
105
|
|
|
102
106
|
| Env | Values | Default |
|
|
103
107
|
|---|---|---|
|
|
104
108
|
| `SPEC_SCORE_MODE` | `off` \| `warn` \| `gate` | `warn` |
|
|
105
|
-
| `
|
|
106
|
-
| `SPEC_SCORE_THRESHOLD` | `0`–`100` | `75` (guardian) / `80` (standard) |
|
|
109
|
+
| `SPEC_SCORE_THRESHOLD` | `0`–`100` | `80` |
|
|
107
110
|
|
|
108
111
|
- **`warn`**: log score; never fail the run; findings still inject into heal/generate repair prompts
|
|
109
112
|
- **`gate`**: fail the run when score < threshold
|
|
@@ -114,21 +117,7 @@ When used from `playwright_runner`, set:
|
|
|
114
117
|
# Local from monorepo
|
|
115
118
|
cd playwright-score && npm run build
|
|
116
119
|
cd ../playwright_runner && npm install
|
|
117
|
-
SPEC_SCORE_MODE=warn
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
## Profiles
|
|
121
|
-
|
|
122
|
-
| Profile | Use when |
|
|
123
|
-
|---|---|
|
|
124
|
-
| `standard` | Any Playwright repo — best practices + locator mix + empty-expect detection |
|
|
125
|
-
| `guardian` | QA Guardian (or similar) codegen quality gate |
|
|
126
|
-
|
|
127
|
-
Disable a guardian secrets false positive:
|
|
128
|
-
|
|
129
|
-
```ts
|
|
130
|
-
// spec-score-disable-next-line guardian/no-hardcoded-secrets
|
|
131
|
-
const password = 'intentional-fixture-value-xyz';
|
|
120
|
+
SPEC_SCORE_MODE=warn node ...
|
|
132
121
|
```
|
|
133
122
|
|
|
134
123
|
## Development
|
package/dist/cli.js
CHANGED
|
@@ -18,8 +18,8 @@ Usage:
|
|
|
18
18
|
npx playwright-score <paths...> [options]
|
|
19
19
|
|
|
20
20
|
Options:
|
|
21
|
-
--profile <standard
|
|
22
|
-
--threshold <n> Pass threshold 0-100 (default: 80
|
|
21
|
+
--profile <standard> Scoring profile (default, and only: standard)
|
|
22
|
+
--threshold <n> Pass threshold 0-100 (default: 80)
|
|
23
23
|
--format <text|json|markdown|sarif> Output format (default: text)
|
|
24
24
|
--out <file> Write report to file
|
|
25
25
|
--version Print version
|
|
@@ -34,6 +34,14 @@ Website: https://qaguardian.com/open-source/playwright-score
|
|
|
34
34
|
Methodology: METHODOLOGY.md (sqs-v1)
|
|
35
35
|
`);
|
|
36
36
|
}
|
|
37
|
+
// Deliberately never process.exit() right after a console.log/console.error
|
|
38
|
+
// — for a large enough write (verified against a real suite: a JSON report
|
|
39
|
+
// with 1000+ findings, ~370KB) stdout to a pipe (not a TTY) is asynchronous,
|
|
40
|
+
// and process.exit() can terminate the process before the OS has drained
|
|
41
|
+
// the write, silently truncating the output exactly at the pipe buffer
|
|
42
|
+
// size (64KB on macOS/Linux). Setting process.exitCode and returning lets
|
|
43
|
+
// Node exit naturally once pending I/O actually completes, which is the
|
|
44
|
+
// standard fix for this class of bug.
|
|
37
45
|
async function main() {
|
|
38
46
|
const args = process.argv.slice(2);
|
|
39
47
|
if (args.includes('--version') || args.includes('-v')) {
|
|
@@ -46,11 +54,13 @@ async function main() {
|
|
|
46
54
|
catch {
|
|
47
55
|
console.log('0.1.8');
|
|
48
56
|
}
|
|
49
|
-
process.
|
|
57
|
+
process.exitCode = 0;
|
|
58
|
+
return;
|
|
50
59
|
}
|
|
51
60
|
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
52
61
|
printHelp();
|
|
53
|
-
process.
|
|
62
|
+
process.exitCode = args.length === 0 ? 2 : 0;
|
|
63
|
+
return;
|
|
54
64
|
}
|
|
55
65
|
let profile = 'standard';
|
|
56
66
|
let threshold;
|
|
@@ -60,24 +70,33 @@ async function main() {
|
|
|
60
70
|
for (let i = 0; i < args.length; i++) {
|
|
61
71
|
const a = args[i];
|
|
62
72
|
if (a === '--profile') {
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
console.error(
|
|
66
|
-
process.
|
|
73
|
+
const p = args[++i];
|
|
74
|
+
if (p === 'guardian') {
|
|
75
|
+
console.error('Profile "guardian" is no longer public — it was QA Guardian\'s internal house-rules layer, never official Playwright doctrine, and doesn\'t belong in a general-purpose tool. See CHANGELOG.md. Use --profile standard.');
|
|
76
|
+
process.exitCode = 2;
|
|
77
|
+
return;
|
|
67
78
|
}
|
|
79
|
+
if (p !== 'standard') {
|
|
80
|
+
console.error(`Invalid profile: ${p}`);
|
|
81
|
+
process.exitCode = 2;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
profile = p;
|
|
68
85
|
}
|
|
69
86
|
else if (a === '--threshold') {
|
|
70
87
|
threshold = Number(args[++i]);
|
|
71
88
|
if (Number.isNaN(threshold) || threshold < 0 || threshold > 100) {
|
|
72
89
|
console.error('threshold must be 0-100');
|
|
73
|
-
process.
|
|
90
|
+
process.exitCode = 2;
|
|
91
|
+
return;
|
|
74
92
|
}
|
|
75
93
|
}
|
|
76
94
|
else if (a === '--format') {
|
|
77
95
|
const f = args[++i];
|
|
78
96
|
if (f !== 'text' && f !== 'json' && f !== 'markdown' && f !== 'sarif') {
|
|
79
97
|
console.error(`Invalid format: ${f}`);
|
|
80
|
-
process.
|
|
98
|
+
process.exitCode = 2;
|
|
99
|
+
return;
|
|
81
100
|
}
|
|
82
101
|
format = f;
|
|
83
102
|
}
|
|
@@ -89,7 +108,8 @@ async function main() {
|
|
|
89
108
|
}
|
|
90
109
|
else if (a.startsWith('-')) {
|
|
91
110
|
console.error(`Unknown option: ${a}`);
|
|
92
|
-
process.
|
|
111
|
+
process.exitCode = 2;
|
|
112
|
+
return;
|
|
93
113
|
}
|
|
94
114
|
else {
|
|
95
115
|
paths.push(a);
|
|
@@ -97,7 +117,8 @@ async function main() {
|
|
|
97
117
|
}
|
|
98
118
|
if (paths.length === 0) {
|
|
99
119
|
console.error('At least one path is required');
|
|
100
|
-
process.
|
|
120
|
+
process.exitCode = 2;
|
|
121
|
+
return;
|
|
101
122
|
}
|
|
102
123
|
try {
|
|
103
124
|
const result = await scorePaths({ paths, profile, threshold });
|
|
@@ -122,11 +143,11 @@ async function main() {
|
|
|
122
143
|
else {
|
|
123
144
|
console.log(body);
|
|
124
145
|
}
|
|
125
|
-
process.
|
|
146
|
+
process.exitCode = result.pass ? 0 : 1;
|
|
126
147
|
}
|
|
127
148
|
catch (err) {
|
|
128
149
|
console.error(err instanceof Error ? err.message : err);
|
|
129
|
-
process.
|
|
150
|
+
process.exitCode = 2;
|
|
130
151
|
}
|
|
131
152
|
}
|
|
132
153
|
main();
|
package/dist/eslint-runner.js
CHANGED
|
@@ -2,10 +2,9 @@ import path from 'node:path';
|
|
|
2
2
|
import { ESLint } from 'eslint';
|
|
3
3
|
import { commonAncestorDir } from './fs-util.js';
|
|
4
4
|
import playwright from 'eslint-plugin-playwright';
|
|
5
|
-
import { guardianPlugin, guardianRuleConfigs, } from './rules/guardian-plugin.js';
|
|
6
5
|
import { basePlugin } from './rules/base-plugin.js';
|
|
7
6
|
import { mapRule } from './profiles.js';
|
|
8
|
-
function buildConfig(
|
|
7
|
+
function buildConfig(assertFunctionNames) {
|
|
9
8
|
const playwrightRecommended = playwright.configs?.['flat/recommended'] ??
|
|
10
9
|
playwright.configs?.recommended;
|
|
11
10
|
const base = [
|
|
@@ -19,7 +18,6 @@ function buildConfig(profile, assertFunctionNames) {
|
|
|
19
18
|
plugins: {
|
|
20
19
|
playwright,
|
|
21
20
|
pwscore: basePlugin,
|
|
22
|
-
...(profile === 'guardian' ? { guardian: guardianPlugin } : {}),
|
|
23
21
|
},
|
|
24
22
|
rules: {
|
|
25
23
|
// Ensure key hygiene rules even if recommended set differs by
|
|
@@ -91,24 +89,12 @@ function buildConfig(profile, assertFunctionNames) {
|
|
|
91
89
|
'playwright/prefer-native-locators': 'warn',
|
|
92
90
|
// Matches eslint-plugin-playwright's own recommended severity.
|
|
93
91
|
'playwright/prefer-web-first-assertions': 'error',
|
|
94
|
-
...(profile === 'guardian' ? { ...guardianRuleConfigs } : {}),
|
|
95
92
|
},
|
|
96
93
|
},
|
|
97
94
|
];
|
|
98
95
|
// Prefer spreading official recommended flat config when available
|
|
99
96
|
if (Array.isArray(playwrightRecommended)) {
|
|
100
97
|
const configs = playwrightRecommended;
|
|
101
|
-
if (profile === 'guardian') {
|
|
102
|
-
return [
|
|
103
|
-
...configs,
|
|
104
|
-
{
|
|
105
|
-
plugins: { guardian: guardianPlugin },
|
|
106
|
-
rules: { ...guardianRuleConfigs },
|
|
107
|
-
},
|
|
108
|
-
// our severity tweaks
|
|
109
|
-
base[0],
|
|
110
|
-
];
|
|
111
|
-
}
|
|
112
98
|
return [...configs, base[0]];
|
|
113
99
|
}
|
|
114
100
|
return base;
|
|
@@ -138,7 +124,7 @@ export async function runEslint(options) {
|
|
|
138
124
|
const cwd = options.files.length > 0
|
|
139
125
|
? commonAncestorDir(options.files)
|
|
140
126
|
: (options.cwd ?? process.cwd());
|
|
141
|
-
const overrideConfig = buildConfig(options.
|
|
127
|
+
const overrideConfig = buildConfig(options.assertFunctionNames ?? []);
|
|
142
128
|
// Try to attach typescript-eslint parser for .ts files
|
|
143
129
|
try {
|
|
144
130
|
const tsParser = await import('@typescript-eslint/parser');
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ScoreOptions, ScoreResult } from './types.js';
|
|
2
2
|
export type { ScoreResult, ScoreOptions, Finding, ProfileName } from './types.js';
|
|
3
|
-
export { computeScore, SQS_V1, locatorsScore, gradeFromScore } from './score-engine.js';
|
|
3
|
+
export { computeScore, SQS_V1, locatorsScore, gradeFromScore, penaltyDimensionScore, applyPerRuleCap, } from './score-engine.js';
|
|
4
4
|
export { countSloc } from './sloc.js';
|
|
5
5
|
export { countLocators, analyzeSource } from './metrics.js';
|
|
6
6
|
export { formatJson } from './formatters/json.js';
|
package/dist/index.js
CHANGED
|
@@ -7,24 +7,24 @@ import { collectLocallyImportedFiles } from './import-graph.js';
|
|
|
7
7
|
import { analyzeSource, countLocators, findLocalAssertionHelperNames, looksLikeNonPlaywrightTest, } from './metrics.js';
|
|
8
8
|
import { DEFAULT_THRESHOLDS } from './profiles.js';
|
|
9
9
|
import { computeScore } from './score-engine.js';
|
|
10
|
-
export { computeScore, SQS_V1, locatorsScore, gradeFromScore } from './score-engine.js';
|
|
10
|
+
export { computeScore, SQS_V1, locatorsScore, gradeFromScore, penaltyDimensionScore, applyPerRuleCap, } from './score-engine.js';
|
|
11
11
|
export { countSloc } from './sloc.js';
|
|
12
12
|
export { countLocators, analyzeSource } from './metrics.js';
|
|
13
13
|
export { formatJson } from './formatters/json.js';
|
|
14
14
|
export { formatText } from './formatters/text.js';
|
|
15
15
|
export { formatMarkdown } from './formatters/markdown.js';
|
|
16
16
|
export { formatSarif } from './formatters/sarif.js';
|
|
17
|
-
// `.e2e.`
|
|
18
|
-
// from unit tests living in the same tree
|
|
19
|
-
// well-known, large real-world
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// safety net for a repo that uses `.e2e.` for a
|
|
24
|
-
// Cypress specifically was common enough here to
|
|
25
|
-
// check alongside the existing Jest/Vitest/RTL ones.
|
|
17
|
+
// `.e2e.`/`.e2e-spec.`/`.e2e-test.` are real, common conventions for
|
|
18
|
+
// disambiguating end-to-end specs from unit tests living in the same tree
|
|
19
|
+
// — verified against two well-known, large real-world repos where a
|
|
20
|
+
// directory scan previously hard-failed with "no files matched" despite a
|
|
21
|
+
// fully healthy, populated suite: cal.com (53 specs, all *.e2e.ts) and
|
|
22
|
+
// Immich (52 specs, all *.e2e-spec.ts). looksLikeNonPlaywrightTest (see
|
|
23
|
+
// metrics.ts) is the safety net for a repo that uses `.e2e.` for a
|
|
24
|
+
// *different* framework — Cypress specifically was common enough here to
|
|
25
|
+
// get its own explicit check alongside the existing Jest/Vitest/RTL ones.
|
|
26
26
|
const SPEC_GLOBS = [
|
|
27
|
-
'**/*.{spec,test,e2e}.{ts,tsx,js,jsx}',
|
|
27
|
+
'**/*.{spec,test,e2e,e2e-spec,e2e-test}.{ts,tsx,js,jsx}',
|
|
28
28
|
'**/*.spec.ts',
|
|
29
29
|
'**/*.test.ts',
|
|
30
30
|
];
|
|
@@ -132,7 +132,6 @@ function hardFail(profile, threshold, findings, extra = {}, skippedFiles) {
|
|
|
132
132
|
assertions: 0,
|
|
133
133
|
locators: 0,
|
|
134
134
|
structure: 0,
|
|
135
|
-
...(profile === 'guardian' ? { guardianConventions: 0 } : {}),
|
|
136
135
|
},
|
|
137
136
|
findings,
|
|
138
137
|
...(skippedFiles && skippedFiles.length > 0 ? { skippedFiles } : {}),
|
|
@@ -242,7 +241,6 @@ export async function scorePaths(options) {
|
|
|
242
241
|
totalTests += m.tests;
|
|
243
242
|
native += m.locators.native;
|
|
244
243
|
raw += m.locators.raw;
|
|
245
|
-
// metrics/no-empty-test duplicates guardian/require-expect under guardian — keep both mild
|
|
246
244
|
metricFindings.push(...m.findings);
|
|
247
245
|
}
|
|
248
246
|
for (const [file, source] of importedFiles) {
|
|
@@ -250,7 +248,6 @@ export async function scorePaths(options) {
|
|
|
250
248
|
native += counts.native;
|
|
251
249
|
raw += counts.raw;
|
|
252
250
|
}
|
|
253
|
-
// Dedupe empty-test style: if guardian require-expect already fired, still ok
|
|
254
251
|
const findings = [...eslintFindings, ...metricFindings];
|
|
255
252
|
const parseErrors = findings.filter((f) => f.rule === 'playwright-score/parse-error');
|
|
256
253
|
if (parseErrors.length > 0) {
|
package/dist/profiles.js
CHANGED
|
@@ -5,17 +5,9 @@ export const PROFILE_WEIGHTS = {
|
|
|
5
5
|
locators: 20,
|
|
6
6
|
structure: 15,
|
|
7
7
|
},
|
|
8
|
-
guardian: {
|
|
9
|
-
playwrightHygiene: 30,
|
|
10
|
-
assertions: 15,
|
|
11
|
-
locators: 15,
|
|
12
|
-
structure: 15,
|
|
13
|
-
guardianConventions: 25,
|
|
14
|
-
},
|
|
15
8
|
};
|
|
16
9
|
export const DEFAULT_THRESHOLDS = {
|
|
17
10
|
standard: 80,
|
|
18
|
-
guardian: 75,
|
|
19
11
|
};
|
|
20
12
|
/**
|
|
21
13
|
* ESLint rule → dimension map (plugin rule ids without prefix handled in runner).
|
|
@@ -80,26 +72,6 @@ export const ESLINT_RULE_MAP = {
|
|
|
80
72
|
dimension: 'structure',
|
|
81
73
|
severityOverride: 'warning',
|
|
82
74
|
},
|
|
83
|
-
// Guardian custom rules
|
|
84
|
-
'guardian/no-wait-for-load-state': { dimension: 'guardianConventions' },
|
|
85
|
-
'guardian/no-date-now-id': { dimension: 'guardianConventions' },
|
|
86
|
-
'guardian/require-expect': { dimension: 'guardianConventions' },
|
|
87
|
-
'guardian/no-hardcoded-secrets': {
|
|
88
|
-
dimension: 'guardianConventions',
|
|
89
|
-
severityOverride: 'warning',
|
|
90
|
-
},
|
|
91
|
-
'guardian/require-test-step': {
|
|
92
|
-
dimension: 'guardianConventions',
|
|
93
|
-
severityOverride: 'warning',
|
|
94
|
-
},
|
|
95
|
-
'guardian/one-describe-one-test': {
|
|
96
|
-
dimension: 'guardianConventions',
|
|
97
|
-
severityOverride: 'warning',
|
|
98
|
-
},
|
|
99
|
-
'guardian/no-generic-long-timeout': {
|
|
100
|
-
dimension: 'guardianConventions',
|
|
101
|
-
severityOverride: 'warning',
|
|
102
|
-
},
|
|
103
75
|
// Metrics injected as synthetic findings
|
|
104
76
|
'metrics/oversized-file': {
|
|
105
77
|
dimension: 'structure',
|
|
@@ -109,9 +81,6 @@ export const ESLINT_RULE_MAP = {
|
|
|
109
81
|
export function mapRule(ruleId) {
|
|
110
82
|
if (ESLINT_RULE_MAP[ruleId])
|
|
111
83
|
return ESLINT_RULE_MAP[ruleId];
|
|
112
|
-
if (ruleId.startsWith('guardian/')) {
|
|
113
|
-
return { dimension: 'guardianConventions', severityOverride: 'warning' };
|
|
114
|
-
}
|
|
115
84
|
if (ruleId.startsWith('playwright/')) {
|
|
116
85
|
return { dimension: 'playwrightHygiene', severityOverride: 'warning' };
|
|
117
86
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Corrections to upstream eslint-plugin-playwright behavior, not house
|
|
3
|
+
* style — always active.
|
|
4
4
|
*/
|
|
5
5
|
import type { Rule } from 'eslint';
|
|
6
6
|
export declare const basePlugin: {
|
package/dist/score-engine.js
CHANGED
|
@@ -10,12 +10,7 @@ export const SQS_V1 = {
|
|
|
10
10
|
MIN_SLOTS: 4,
|
|
11
11
|
K: 0.4,
|
|
12
12
|
};
|
|
13
|
-
const PENALTY_DIMENSIONS = [
|
|
14
|
-
'playwrightHygiene',
|
|
15
|
-
'assertions',
|
|
16
|
-
'structure',
|
|
17
|
-
'guardianConventions',
|
|
18
|
-
];
|
|
13
|
+
const PENALTY_DIMENSIONS = ['playwrightHygiene', 'assertions', 'structure'];
|
|
19
14
|
export function gradeFromScore(score) {
|
|
20
15
|
if (score >= 90)
|
|
21
16
|
return 'A';
|
|
@@ -69,16 +64,18 @@ export function penaltyDimensionScore(findings, dimension, sloc) {
|
|
|
69
64
|
return Math.min(100, Math.max(0, score));
|
|
70
65
|
}
|
|
71
66
|
export function computeScore(input) {
|
|
72
|
-
|
|
67
|
+
// Falls back to `standard`'s weights for any unrecognized profile string
|
|
68
|
+
// rather than throwing — a defensive guard against exactly the failure
|
|
69
|
+
// mode a profile removal creates: a caller pinned to an old contract
|
|
70
|
+
// (e.g. `profile: 'guardian'`, removed in 0.2.0) degrades to a working
|
|
71
|
+
// score instead of a hard crash.
|
|
72
|
+
const weights = PROFILE_WEIGHTS[input.profile] ?? PROFILE_WEIGHTS.standard;
|
|
73
73
|
const dims = {
|
|
74
74
|
playwrightHygiene: penaltyDimensionScore(input.findings, 'playwrightHygiene', input.sloc),
|
|
75
75
|
assertions: penaltyDimensionScore(input.findings, 'assertions', input.sloc),
|
|
76
76
|
locators: locatorsScore(input.nativeLocators, input.rawLocators),
|
|
77
77
|
structure: penaltyDimensionScore(input.findings, 'structure', input.sloc),
|
|
78
78
|
};
|
|
79
|
-
if (input.profile === 'guardian') {
|
|
80
|
-
dims.guardianConventions = penaltyDimensionScore(input.findings, 'guardianConventions', input.sloc);
|
|
81
|
-
}
|
|
82
79
|
let score = 0;
|
|
83
80
|
for (const [dim, weight] of Object.entries(weights)) {
|
|
84
81
|
const key = dim;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export type ScoreVersion = 'sqs-v1';
|
|
2
|
-
export type ProfileName = 'standard'
|
|
2
|
+
export type ProfileName = 'standard';
|
|
3
3
|
export type Grade = 'A' | 'B' | 'C' | 'D' | 'F';
|
|
4
4
|
export type Severity = 'error' | 'warning' | 'info';
|
|
5
|
-
export type DimensionName = 'playwrightHygiene' | 'assertions' | 'locators' | 'structure'
|
|
5
|
+
export type DimensionName = 'playwrightHygiene' | 'assertions' | 'locators' | 'structure';
|
|
6
6
|
export interface Finding {
|
|
7
7
|
rule: string;
|
|
8
8
|
severity: Severity;
|
|
@@ -19,7 +19,6 @@ export interface ScoreDimensions {
|
|
|
19
19
|
assertions: number;
|
|
20
20
|
locators: number;
|
|
21
21
|
structure: number;
|
|
22
|
-
guardianConventions?: number;
|
|
23
22
|
}
|
|
24
23
|
export interface ScoreSummary {
|
|
25
24
|
files: number;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qaguardian/playwright-score",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Deterministic, AI-free Playwright spec quality score (sqs-v1).
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Deterministic, AI-free Playwright spec quality score (sqs-v1).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -26,7 +26,6 @@
|
|
|
26
26
|
"build:tests": "tsc -p tsconfig.tests.json",
|
|
27
27
|
"test": "npm run build && npm run build:tests && node --test dist-tests/tests/*.js",
|
|
28
28
|
"score": "npm run build && node bin/playwright-score.js",
|
|
29
|
-
"dogfood": "npm run build && bash scripts/dogfood.sh",
|
|
30
29
|
"prepublishOnly": "npm run build"
|
|
31
30
|
},
|
|
32
31
|
"keywords": [
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Guardian house-style ESLint rules for profile=guardian.
|
|
3
|
-
* Embedded in playwright-score — not claimed as official Playwright doctrine.
|
|
4
|
-
*/
|
|
5
|
-
import type { Rule } from 'eslint';
|
|
6
|
-
export declare const guardianPlugin: {
|
|
7
|
-
meta: {
|
|
8
|
-
name: string;
|
|
9
|
-
version: string;
|
|
10
|
-
};
|
|
11
|
-
rules: {
|
|
12
|
-
'no-wait-for-load-state': Rule.RuleModule;
|
|
13
|
-
'no-date-now-id': Rule.RuleModule;
|
|
14
|
-
'no-hardcoded-secrets': Rule.RuleModule;
|
|
15
|
-
'no-generic-long-timeout': Rule.RuleModule;
|
|
16
|
-
'one-describe-one-test': Rule.RuleModule;
|
|
17
|
-
'require-test-step': Rule.RuleModule;
|
|
18
|
-
'require-expect': Rule.RuleModule;
|
|
19
|
-
};
|
|
20
|
-
};
|
|
21
|
-
export declare const guardianRuleConfigs: {
|
|
22
|
-
readonly 'guardian/no-wait-for-load-state': "error";
|
|
23
|
-
readonly 'guardian/no-date-now-id': "error";
|
|
24
|
-
readonly 'guardian/no-hardcoded-secrets': "warn";
|
|
25
|
-
readonly 'guardian/no-generic-long-timeout': "warn";
|
|
26
|
-
readonly 'guardian/one-describe-one-test': "warn";
|
|
27
|
-
readonly 'guardian/require-test-step': "warn";
|
|
28
|
-
readonly 'guardian/require-expect': "error";
|
|
29
|
-
};
|
|
@@ -1,386 +0,0 @@
|
|
|
1
|
-
const DUMMY_SECRETS = new Set([
|
|
2
|
-
'password',
|
|
3
|
-
'password123',
|
|
4
|
-
'test',
|
|
5
|
-
'test123',
|
|
6
|
-
'secret',
|
|
7
|
-
'changeme',
|
|
8
|
-
'admin',
|
|
9
|
-
'admin123',
|
|
10
|
-
'pass',
|
|
11
|
-
'pwd',
|
|
12
|
-
'123456',
|
|
13
|
-
'qwerty',
|
|
14
|
-
'',
|
|
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
|
-
}
|
|
86
|
-
function hasDisableNextLine(sourceCode, line, ruleId) {
|
|
87
|
-
const lines = sourceCode.getText().split(/\r?\n/);
|
|
88
|
-
if (line < 2)
|
|
89
|
-
return false;
|
|
90
|
-
const prev = lines[line - 2] ?? '';
|
|
91
|
-
return (prev.includes('spec-score-disable-next-line') &&
|
|
92
|
-
(prev.includes(ruleId) || prev.includes('guardian/')));
|
|
93
|
-
}
|
|
94
|
-
const noWaitForLoadState = {
|
|
95
|
-
meta: {
|
|
96
|
-
type: 'problem',
|
|
97
|
-
docs: { description: 'Disallow page.waitForLoadState()' },
|
|
98
|
-
messages: {
|
|
99
|
-
banned: 'waitForLoadState is banned under guardian profile. Use web-first assertions.',
|
|
100
|
-
},
|
|
101
|
-
schema: [],
|
|
102
|
-
},
|
|
103
|
-
create(context) {
|
|
104
|
-
return {
|
|
105
|
-
CallExpression(node) {
|
|
106
|
-
const callee = node.callee;
|
|
107
|
-
if (callee.type === 'MemberExpression' &&
|
|
108
|
-
!callee.computed &&
|
|
109
|
-
callee.property.type === 'Identifier' &&
|
|
110
|
-
callee.property.name === 'waitForLoadState') {
|
|
111
|
-
context.report({ node, messageId: 'banned' });
|
|
112
|
-
}
|
|
113
|
-
},
|
|
114
|
-
};
|
|
115
|
-
},
|
|
116
|
-
};
|
|
117
|
-
const noDateNowId = {
|
|
118
|
-
meta: {
|
|
119
|
-
type: 'problem',
|
|
120
|
-
docs: { description: 'Disallow Date.now() for unique ids' },
|
|
121
|
-
messages: {
|
|
122
|
-
banned: 'Date.now() is banned for unique ids. Use crypto.randomUUID().split("-")[0].',
|
|
123
|
-
},
|
|
124
|
-
schema: [],
|
|
125
|
-
},
|
|
126
|
-
create(context) {
|
|
127
|
-
return {
|
|
128
|
-
CallExpression(node) {
|
|
129
|
-
const callee = node.callee;
|
|
130
|
-
if (callee.type === 'MemberExpression' &&
|
|
131
|
-
!callee.computed &&
|
|
132
|
-
callee.object.type === 'Identifier' &&
|
|
133
|
-
callee.object.name === 'Date' &&
|
|
134
|
-
callee.property.type === 'Identifier' &&
|
|
135
|
-
callee.property.name === 'now') {
|
|
136
|
-
context.report({ node, messageId: 'banned' });
|
|
137
|
-
}
|
|
138
|
-
},
|
|
139
|
-
};
|
|
140
|
-
},
|
|
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
|
-
}
|
|
178
|
-
const noHardcodedSecrets = {
|
|
179
|
-
meta: {
|
|
180
|
-
type: 'suggestion',
|
|
181
|
-
docs: { description: 'Flag likely hardcoded secrets (with allowlist)' },
|
|
182
|
-
messages: {
|
|
183
|
-
secret: 'Possible hardcoded secret value. Use env/fixtures or allowlist dummy data.',
|
|
184
|
-
},
|
|
185
|
-
schema: [],
|
|
186
|
-
},
|
|
187
|
-
create(context) {
|
|
188
|
-
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
189
|
-
function check(node, rawKey) {
|
|
190
|
-
if (node.type !== 'Literal' || typeof node.value !== 'string')
|
|
191
|
-
return;
|
|
192
|
-
const value = String(node.value);
|
|
193
|
-
if (DUMMY_SECRETS.has(value.toLowerCase()))
|
|
194
|
-
return;
|
|
195
|
-
if (value.length < 6)
|
|
196
|
-
return;
|
|
197
|
-
if (!looksLikeSensitiveKey(rawKey ?? ''))
|
|
198
|
-
return;
|
|
199
|
-
const line = node.loc?.start.line ?? 1;
|
|
200
|
-
if (hasDisableNextLine(sourceCode, line, 'guardian/no-hardcoded-secrets'))
|
|
201
|
-
return;
|
|
202
|
-
context.report({ node: node, messageId: 'secret' });
|
|
203
|
-
}
|
|
204
|
-
return {
|
|
205
|
-
Property(node) {
|
|
206
|
-
if (node.key.type === 'Identifier') {
|
|
207
|
-
check(node.value, node.key.name);
|
|
208
|
-
}
|
|
209
|
-
else if (node.key.type === 'Literal') {
|
|
210
|
-
check(node.value, String(node.key.value));
|
|
211
|
-
}
|
|
212
|
-
},
|
|
213
|
-
VariableDeclarator(node) {
|
|
214
|
-
if (node.id.type === 'Identifier' && node.init) {
|
|
215
|
-
check(node.init, node.id.name);
|
|
216
|
-
}
|
|
217
|
-
},
|
|
218
|
-
};
|
|
219
|
-
},
|
|
220
|
-
};
|
|
221
|
-
const noGenericLongTimeout = {
|
|
222
|
-
meta: {
|
|
223
|
-
type: 'suggestion',
|
|
224
|
-
docs: { description: 'Only allow timeout 2000 or 20000' },
|
|
225
|
-
messages: {
|
|
226
|
-
timeout: 'Avoid generic timeouts; allowed values under guardian: 2000 (audit) or 20000 (backend).',
|
|
227
|
-
},
|
|
228
|
-
schema: [],
|
|
229
|
-
},
|
|
230
|
-
create(context) {
|
|
231
|
-
const allowed = new Set([2000, 20000]);
|
|
232
|
-
return {
|
|
233
|
-
Property(node) {
|
|
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;
|
|
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' });
|
|
254
|
-
},
|
|
255
|
-
};
|
|
256
|
-
},
|
|
257
|
-
};
|
|
258
|
-
const oneDescribeOneTest = {
|
|
259
|
-
meta: {
|
|
260
|
-
type: 'suggestion',
|
|
261
|
-
docs: { description: 'Prefer one test.describe and one test per file' },
|
|
262
|
-
messages: {
|
|
263
|
-
describes: 'Guardian profile prefers exactly one test.describe() per file (found {{count}}).',
|
|
264
|
-
tests: 'Guardian profile prefers exactly one test() per file (found {{count}}).',
|
|
265
|
-
},
|
|
266
|
-
schema: [],
|
|
267
|
-
},
|
|
268
|
-
create(context) {
|
|
269
|
-
let describes = 0;
|
|
270
|
-
let tests = 0;
|
|
271
|
-
let usesSerialDescribe = false;
|
|
272
|
-
return {
|
|
273
|
-
CallExpression(node) {
|
|
274
|
-
if (isDescribeCall(node)) {
|
|
275
|
-
describes++;
|
|
276
|
-
if (isSerialDescribeCall(node))
|
|
277
|
-
usesSerialDescribe = true;
|
|
278
|
-
}
|
|
279
|
-
if (isBareTestCall(node) || isTestModifierCall(node))
|
|
280
|
-
tests++;
|
|
281
|
-
},
|
|
282
|
-
'Program:exit'() {
|
|
283
|
-
if (describes > 1) {
|
|
284
|
-
context.report({
|
|
285
|
-
loc: { line: 1, column: 0 },
|
|
286
|
-
messageId: 'describes',
|
|
287
|
-
data: { count: String(describes) },
|
|
288
|
-
});
|
|
289
|
-
}
|
|
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) {
|
|
296
|
-
context.report({
|
|
297
|
-
loc: { line: 1, column: 0 },
|
|
298
|
-
messageId: 'tests',
|
|
299
|
-
data: { count: String(tests) },
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
},
|
|
303
|
-
};
|
|
304
|
-
},
|
|
305
|
-
};
|
|
306
|
-
const requireTestStep = {
|
|
307
|
-
meta: {
|
|
308
|
-
type: 'suggestion',
|
|
309
|
-
docs: { description: 'Warn when file uses test() but no test.step()' },
|
|
310
|
-
messages: {
|
|
311
|
-
missing: 'Consider wrapping actions in test.step() for clearer structure.',
|
|
312
|
-
},
|
|
313
|
-
schema: [],
|
|
314
|
-
},
|
|
315
|
-
create(context) {
|
|
316
|
-
let hasTest = false;
|
|
317
|
-
let hasStep = false;
|
|
318
|
-
return {
|
|
319
|
-
CallExpression(node) {
|
|
320
|
-
if (isBareTestCall(node) || isTestModifierCall(node))
|
|
321
|
-
hasTest = true;
|
|
322
|
-
if (isTestStepCall(node))
|
|
323
|
-
hasStep = true;
|
|
324
|
-
},
|
|
325
|
-
'Program:exit'() {
|
|
326
|
-
if (hasTest && !hasStep) {
|
|
327
|
-
context.report({ loc: { line: 1, column: 0 }, messageId: 'missing' });
|
|
328
|
-
}
|
|
329
|
-
},
|
|
330
|
-
};
|
|
331
|
-
},
|
|
332
|
-
};
|
|
333
|
-
const requireExpect = {
|
|
334
|
-
meta: {
|
|
335
|
-
type: 'problem',
|
|
336
|
-
docs: { description: 'Require expect() in files with test()' },
|
|
337
|
-
messages: {
|
|
338
|
-
missing: 'Guardian profile requires at least one expect() in the spec.',
|
|
339
|
-
},
|
|
340
|
-
schema: [],
|
|
341
|
-
},
|
|
342
|
-
create(context) {
|
|
343
|
-
let hasTest = false;
|
|
344
|
-
let hasExpect = false;
|
|
345
|
-
return {
|
|
346
|
-
CallExpression(node) {
|
|
347
|
-
if (isBareTestCall(node) || isTestModifierCall(node))
|
|
348
|
-
hasTest = true;
|
|
349
|
-
if (node.callee?.type === 'Identifier' && node.callee.name === 'expect') {
|
|
350
|
-
hasExpect = true;
|
|
351
|
-
}
|
|
352
|
-
if (node.callee?.type === 'MemberExpression' &&
|
|
353
|
-
node.callee.object?.type === 'Identifier' &&
|
|
354
|
-
node.callee.object.name === 'expect') {
|
|
355
|
-
hasExpect = true;
|
|
356
|
-
}
|
|
357
|
-
},
|
|
358
|
-
'Program:exit'() {
|
|
359
|
-
if (hasTest && !hasExpect) {
|
|
360
|
-
context.report({ loc: { line: 1, column: 0 }, messageId: 'missing' });
|
|
361
|
-
}
|
|
362
|
-
},
|
|
363
|
-
};
|
|
364
|
-
},
|
|
365
|
-
};
|
|
366
|
-
export const guardianPlugin = {
|
|
367
|
-
meta: { name: 'guardian', version: '0.1.0' },
|
|
368
|
-
rules: {
|
|
369
|
-
'no-wait-for-load-state': noWaitForLoadState,
|
|
370
|
-
'no-date-now-id': noDateNowId,
|
|
371
|
-
'no-hardcoded-secrets': noHardcodedSecrets,
|
|
372
|
-
'no-generic-long-timeout': noGenericLongTimeout,
|
|
373
|
-
'one-describe-one-test': oneDescribeOneTest,
|
|
374
|
-
'require-test-step': requireTestStep,
|
|
375
|
-
'require-expect': requireExpect,
|
|
376
|
-
},
|
|
377
|
-
};
|
|
378
|
-
export const guardianRuleConfigs = {
|
|
379
|
-
'guardian/no-wait-for-load-state': 'error',
|
|
380
|
-
'guardian/no-date-now-id': 'error',
|
|
381
|
-
'guardian/no-hardcoded-secrets': 'warn',
|
|
382
|
-
'guardian/no-generic-long-timeout': 'warn',
|
|
383
|
-
'guardian/one-describe-one-test': 'warn',
|
|
384
|
-
'guardian/require-test-step': 'warn',
|
|
385
|
-
'guardian/require-expect': 'error',
|
|
386
|
-
};
|