@qaguardian/playwright-score 0.1.0 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -7
- package/dist/cli.js +23 -4
- package/dist/eslint-runner.d.ts +2 -0
- package/dist/eslint-runner.js +109 -8
- package/dist/formatters/markdown.js +10 -0
- package/dist/formatters/sarif.js +1 -1
- package/dist/formatters/text.js +10 -0
- package/dist/fs-util.d.ts +6 -0
- package/dist/fs-util.js +22 -0
- package/dist/index.js +164 -31
- package/dist/metrics.d.ts +28 -7
- package/dist/metrics.js +232 -45
- package/dist/profiles.js +7 -18
- package/dist/rules/base-plugin.d.ts +14 -0
- package/dist/rules/base-plugin.js +77 -0
- package/dist/rules/guardian-plugin.js +145 -44
- package/dist/types.d.ts +9 -0
- package/package.json +1 -1
package/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
|
+
};
|