@qaguardian/playwright-score 2.0.0 → 2.1.1
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/CHANGELOG.md +239 -3
- package/README.md +50 -63
- package/dist/eslint-runner.d.ts +2 -0
- package/dist/eslint-runner.js +22 -4
- package/dist/formatters/markdown.js +8 -0
- package/dist/formatters/text.js +7 -0
- package/dist/import-graph.d.ts +1 -0
- package/dist/import-graph.js +1 -1
- package/dist/index.js +304 -14
- package/dist/metrics.d.ts +17 -4
- package/dist/metrics.js +27 -8
- package/dist/playwright-config.d.ts +140 -0
- package/dist/playwright-config.js +543 -0
- package/dist/rules/base-plugin.js +46 -12
- package/dist/test-aliases.d.ts +18 -0
- package/dist/test-aliases.js +218 -0
- package/dist/types.d.ts +8 -0
- package/package.json +1 -1
|
@@ -1,18 +1,28 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* `testNames` is `'test'` plus any `test.extend(...)`-derived fixture
|
|
3
|
+
* aliases detected by test-aliases.ts (see eslint-runner.ts, which feeds
|
|
4
|
+
* this rule's `testAliasNames` option) — a suite that always declares its
|
|
5
|
+
* tests via `loggedTest.skip(...)` instead of `test.skip(...)` still needs
|
|
6
|
+
* the always-skipped-declaration check below, same as upstream
|
|
7
|
+
* eslint-plugin-playwright's own rules need the alias for no-standalone-
|
|
8
|
+
* expect (see METHODOLOGY.md/test-aliases.ts).
|
|
9
|
+
*/
|
|
10
|
+
function isSkipCallee(callee, testNames) {
|
|
2
11
|
if (!callee || callee.type !== 'MemberExpression' || callee.computed)
|
|
3
12
|
return false;
|
|
4
13
|
const obj = callee.object;
|
|
5
14
|
const prop = callee.property;
|
|
6
15
|
if (prop?.type !== 'Identifier' || prop.name !== 'skip')
|
|
7
16
|
return false;
|
|
8
|
-
// test.skip(...)
|
|
9
|
-
if (obj?.type === 'Identifier' && obj.name
|
|
17
|
+
// test.skip(...) / loggedTest.skip(...)
|
|
18
|
+
if (obj?.type === 'Identifier' && obj.name && testNames.has(obj.name))
|
|
10
19
|
return true;
|
|
11
|
-
// test.describe.skip(...)
|
|
20
|
+
// test.describe.skip(...) / loggedTest.describe.skip(...)
|
|
12
21
|
if (obj?.type === 'MemberExpression' &&
|
|
13
22
|
!obj.computed &&
|
|
14
23
|
obj.object?.type === 'Identifier' &&
|
|
15
|
-
obj.object.name
|
|
24
|
+
obj.object.name &&
|
|
25
|
+
testNames.has(obj.object.name) &&
|
|
16
26
|
obj.property?.type === 'Identifier' &&
|
|
17
27
|
obj.property.name === 'describe') {
|
|
18
28
|
return true;
|
|
@@ -52,12 +62,23 @@ const noSkippedTestDeclaration = {
|
|
|
52
62
|
messages: {
|
|
53
63
|
skipped: 'Unexpected use of the .skip() annotation to declare an always-skipped test.',
|
|
54
64
|
},
|
|
55
|
-
schema: [
|
|
65
|
+
schema: [
|
|
66
|
+
{
|
|
67
|
+
additionalProperties: false,
|
|
68
|
+
properties: {
|
|
69
|
+
testAliasNames: { items: { type: 'string' }, type: 'array' },
|
|
70
|
+
},
|
|
71
|
+
type: 'object',
|
|
72
|
+
},
|
|
73
|
+
],
|
|
56
74
|
},
|
|
57
75
|
create(context) {
|
|
76
|
+
const testAliasNames = context.options[0]
|
|
77
|
+
?.testAliasNames ?? [];
|
|
78
|
+
const testNames = new Set(['test', ...testAliasNames]);
|
|
58
79
|
return {
|
|
59
80
|
CallExpression(node) {
|
|
60
|
-
if (!isSkipCallee(node.callee))
|
|
81
|
+
if (!isSkipCallee(node.callee, testNames))
|
|
61
82
|
return;
|
|
62
83
|
const args = node.arguments ?? [];
|
|
63
84
|
const lastArg = args[args.length - 1];
|
|
@@ -645,15 +666,17 @@ const noTrivialAssertion = {
|
|
|
645
666
|
};
|
|
646
667
|
},
|
|
647
668
|
};
|
|
648
|
-
|
|
669
|
+
/** `testNames` — see isSkipCallee's doc comment above; same alias treatment. */
|
|
670
|
+
function isTestDeclarationCallee(callee, testNames) {
|
|
649
671
|
if (!callee)
|
|
650
672
|
return false;
|
|
651
|
-
if (callee.type === 'Identifier' && callee.name
|
|
673
|
+
if (callee.type === 'Identifier' && callee.name && testNames.has(callee.name))
|
|
652
674
|
return true;
|
|
653
675
|
if (callee.type === 'MemberExpression' &&
|
|
654
676
|
!callee.computed &&
|
|
655
677
|
callee.object?.type === 'Identifier' &&
|
|
656
|
-
callee.object.name
|
|
678
|
+
callee.object.name &&
|
|
679
|
+
testNames.has(callee.object.name) &&
|
|
657
680
|
callee.property?.type === 'Identifier' &&
|
|
658
681
|
(callee.property.name === 'only' || callee.property.name === 'fixme')) {
|
|
659
682
|
return true;
|
|
@@ -683,12 +706,23 @@ const noSoftAssertionOnlyTest = {
|
|
|
683
706
|
messages: {
|
|
684
707
|
softOnly: 'This test only uses expect.soft(...) assertions — a failed check is recorded but never fails the test. Use a hard expect() for anything that should stop the test.',
|
|
685
708
|
},
|
|
686
|
-
schema: [
|
|
709
|
+
schema: [
|
|
710
|
+
{
|
|
711
|
+
additionalProperties: false,
|
|
712
|
+
properties: {
|
|
713
|
+
testAliasNames: { items: { type: 'string' }, type: 'array' },
|
|
714
|
+
},
|
|
715
|
+
type: 'object',
|
|
716
|
+
},
|
|
717
|
+
],
|
|
687
718
|
},
|
|
688
719
|
create(context) {
|
|
720
|
+
const testAliasNames = context.options[0]
|
|
721
|
+
?.testAliasNames ?? [];
|
|
722
|
+
const testNames = new Set(['test', ...testAliasNames]);
|
|
689
723
|
return {
|
|
690
724
|
CallExpression(node) {
|
|
691
|
-
if (!isTestDeclarationCallee(node.callee))
|
|
725
|
+
if (!isTestDeclarationCallee(node.callee, testNames))
|
|
692
726
|
return;
|
|
693
727
|
const args = node.arguments ?? [];
|
|
694
728
|
const body = args[args.length - 1];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects identifiers bound (directly, or via a local import/re-export
|
|
3
|
+
* chain of any depth) to `<base>.extend(...)` where `<base>` is `test`
|
|
4
|
+
* imported from `@playwright/test`, or is itself such an extended test.
|
|
5
|
+
* Returns the flat set of alias name strings (e.g. `loggedTest`,
|
|
6
|
+
* `testWithAssets`) usable anywhere they're called across the scored
|
|
7
|
+
* files — callers feed this into `eslint-runner.ts`'s
|
|
8
|
+
* `settings.playwright.globalAliases.test` and into `metrics.ts`'s
|
|
9
|
+
* `getTestSpans`/`countTests` and `base-plugin.ts`'s test-name checks.
|
|
10
|
+
*
|
|
11
|
+
* `sources`/`importedFiles` are the same maps `index.ts` already builds
|
|
12
|
+
* (scored spec files, and their transitively-collected local relative
|
|
13
|
+
* imports — see `collectLocallyImportedFiles`); this function does not
|
|
14
|
+
* walk the filesystem itself beyond resolving relative specifiers already
|
|
15
|
+
* present in those files' own import/export statements, and never
|
|
16
|
+
* escapes `boundaryDir` (same safety rule as `import-graph.ts`).
|
|
17
|
+
*/
|
|
18
|
+
export declare function detectExtendedTestAliases(sources: Map<string, string>, importedFiles: Map<string, string>, boundaryDir: string): string[];
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { parseForESLint } from '@typescript-eslint/parser';
|
|
2
|
+
import { resolveRelativeImport } from './import-graph.js';
|
|
3
|
+
function isRelativeSpecifier(value) {
|
|
4
|
+
return typeof value === 'string' && value.startsWith('.');
|
|
5
|
+
}
|
|
6
|
+
function parseFileInfo(source, file) {
|
|
7
|
+
let ast;
|
|
8
|
+
try {
|
|
9
|
+
ast = parseForESLint(source, {
|
|
10
|
+
ecmaVersion: 2022,
|
|
11
|
+
sourceType: 'module',
|
|
12
|
+
ecmaFeatures: { jsx: /\.(tsx|jsx)$/.test(file) },
|
|
13
|
+
}).ast;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
const playwrightTestLocalNames = new Set();
|
|
19
|
+
const extendDeclarations = [];
|
|
20
|
+
const directExportNames = new Set();
|
|
21
|
+
const imports = [];
|
|
22
|
+
const reExports = [];
|
|
23
|
+
function collectExtendFromVariableDeclaration(decl) {
|
|
24
|
+
const declarations = (decl.declarations ?? []);
|
|
25
|
+
for (const d of declarations) {
|
|
26
|
+
if (d.id?.type !== 'Identifier' || !d.id.name || !d.init)
|
|
27
|
+
continue;
|
|
28
|
+
const init = d.init;
|
|
29
|
+
if (init.type === 'CallExpression' &&
|
|
30
|
+
init.callee?.type === 'MemberExpression') {
|
|
31
|
+
const callee = init.callee;
|
|
32
|
+
const property = callee.property;
|
|
33
|
+
const object = callee.object;
|
|
34
|
+
if (!callee.computed &&
|
|
35
|
+
property?.type === 'Identifier' &&
|
|
36
|
+
property.name === 'extend' &&
|
|
37
|
+
object?.type === 'Identifier' &&
|
|
38
|
+
object.name) {
|
|
39
|
+
extendDeclarations.push({ declaredName: d.id.name, baseName: object.name });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Generic subtree walk to find `const X = Y.extend(...)` anywhere (not
|
|
45
|
+
// just top-level) — a fixture module may nest these inside a helper.
|
|
46
|
+
function walk(node) {
|
|
47
|
+
if (!node || typeof node !== 'object')
|
|
48
|
+
return;
|
|
49
|
+
if (Array.isArray(node)) {
|
|
50
|
+
for (const item of node)
|
|
51
|
+
walk(item);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const obj = node;
|
|
55
|
+
if (obj.type === 'VariableDeclaration')
|
|
56
|
+
collectExtendFromVariableDeclaration(obj);
|
|
57
|
+
for (const key in obj) {
|
|
58
|
+
if (key === 'parent')
|
|
59
|
+
continue;
|
|
60
|
+
const value = obj[key];
|
|
61
|
+
if (value && typeof value === 'object')
|
|
62
|
+
walk(value);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const node of ast.body ?? []) {
|
|
66
|
+
if (node.type === 'ImportDeclaration') {
|
|
67
|
+
const src = node.source?.value;
|
|
68
|
+
if (!isRelativeSpecifier(src)) {
|
|
69
|
+
if (src === '@playwright/test') {
|
|
70
|
+
for (const spec of (node.specifiers ?? [])) {
|
|
71
|
+
if (spec.type !== 'ImportSpecifier')
|
|
72
|
+
continue;
|
|
73
|
+
const importedName = spec.imported?.name ?? spec.imported?.value;
|
|
74
|
+
if (importedName === 'test' && spec.local?.name) {
|
|
75
|
+
playwrightTestLocalNames.add(spec.local.name);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
for (const spec of (node.specifiers ?? [])) {
|
|
82
|
+
if (spec.type !== 'ImportSpecifier')
|
|
83
|
+
continue;
|
|
84
|
+
const importedName = spec.imported?.name ?? spec.imported?.value;
|
|
85
|
+
if (importedName && spec.local?.name) {
|
|
86
|
+
imports.push({ localName: spec.local.name, importedName, source: src });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else if (node.type === 'ExportNamedDeclaration') {
|
|
91
|
+
const declaration = node.declaration;
|
|
92
|
+
const src = node.source?.value;
|
|
93
|
+
if (declaration?.type === 'VariableDeclaration') {
|
|
94
|
+
collectExtendFromVariableDeclaration(declaration);
|
|
95
|
+
for (const d of (declaration.declarations ?? [])) {
|
|
96
|
+
if (d.id?.type === 'Identifier' && d.id.name)
|
|
97
|
+
directExportNames.add(d.id.name);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
for (const spec of (node.specifiers ?? [])) {
|
|
102
|
+
const localName = spec.local?.name ?? spec.local?.value;
|
|
103
|
+
const exportedName = spec.exported?.name ?? spec.exported?.value;
|
|
104
|
+
if (!localName || !exportedName)
|
|
105
|
+
continue;
|
|
106
|
+
reExports.push({
|
|
107
|
+
exportedName,
|
|
108
|
+
localName,
|
|
109
|
+
source: isRelativeSpecifier(src) ? src : undefined,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Fixtures modules commonly declare the extend() call inside a nested
|
|
116
|
+
// scope too (e.g. wrapped in a `(() => {...})()` IIFE is rare, but
|
|
117
|
+
// multiple top-level `const`s are common) — the top-level pass above
|
|
118
|
+
// already covers the overwhelming majority; this catches the rest
|
|
119
|
+
// without a second parse.
|
|
120
|
+
walk(ast.body ?? []);
|
|
121
|
+
return {
|
|
122
|
+
playwrightTestLocalNames,
|
|
123
|
+
extendDeclarations,
|
|
124
|
+
directExportNames,
|
|
125
|
+
imports,
|
|
126
|
+
reExports,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Detects identifiers bound (directly, or via a local import/re-export
|
|
131
|
+
* chain of any depth) to `<base>.extend(...)` where `<base>` is `test`
|
|
132
|
+
* imported from `@playwright/test`, or is itself such an extended test.
|
|
133
|
+
* Returns the flat set of alias name strings (e.g. `loggedTest`,
|
|
134
|
+
* `testWithAssets`) usable anywhere they're called across the scored
|
|
135
|
+
* files — callers feed this into `eslint-runner.ts`'s
|
|
136
|
+
* `settings.playwright.globalAliases.test` and into `metrics.ts`'s
|
|
137
|
+
* `getTestSpans`/`countTests` and `base-plugin.ts`'s test-name checks.
|
|
138
|
+
*
|
|
139
|
+
* `sources`/`importedFiles` are the same maps `index.ts` already builds
|
|
140
|
+
* (scored spec files, and their transitively-collected local relative
|
|
141
|
+
* imports — see `collectLocallyImportedFiles`); this function does not
|
|
142
|
+
* walk the filesystem itself beyond resolving relative specifiers already
|
|
143
|
+
* present in those files' own import/export statements, and never
|
|
144
|
+
* escapes `boundaryDir` (same safety rule as `import-graph.ts`).
|
|
145
|
+
*/
|
|
146
|
+
export function detectExtendedTestAliases(sources, importedFiles, boundaryDir) {
|
|
147
|
+
const allFiles = new Map([...sources, ...importedFiles]);
|
|
148
|
+
const parsed = new Map();
|
|
149
|
+
for (const [file, source] of allFiles) {
|
|
150
|
+
const info = parseFileInfo(source, file);
|
|
151
|
+
if (info)
|
|
152
|
+
parsed.set(file, info);
|
|
153
|
+
}
|
|
154
|
+
const localAlias = new Map();
|
|
155
|
+
const exportedAlias = new Map();
|
|
156
|
+
for (const file of parsed.keys()) {
|
|
157
|
+
localAlias.set(file, new Set());
|
|
158
|
+
exportedAlias.set(file, new Set());
|
|
159
|
+
}
|
|
160
|
+
const resolve = (fromFile, specifier) => resolveRelativeImport(fromFile, specifier, boundaryDir);
|
|
161
|
+
// Bounded fixed point: each round can only add names (monotonic), and
|
|
162
|
+
// there are at most parsed.size * (max names per file) additions total —
|
|
163
|
+
// capping rounds at file count + a small constant is generous relative
|
|
164
|
+
// to any real import-chain depth (mirrors import-graph.ts's own
|
|
165
|
+
// MAX_DEPTH-style bound).
|
|
166
|
+
const maxRounds = parsed.size + 5;
|
|
167
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
168
|
+
let changed = false;
|
|
169
|
+
for (const [file, info] of parsed) {
|
|
170
|
+
const known = localAlias.get(file);
|
|
171
|
+
const exported = exportedAlias.get(file);
|
|
172
|
+
for (const { declaredName, baseName } of info.extendDeclarations) {
|
|
173
|
+
if (known.has(declaredName))
|
|
174
|
+
continue;
|
|
175
|
+
if (info.playwrightTestLocalNames.has(baseName) || known.has(baseName)) {
|
|
176
|
+
known.add(declaredName);
|
|
177
|
+
changed = true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const { localName, importedName, source } of info.imports) {
|
|
181
|
+
if (known.has(localName))
|
|
182
|
+
continue;
|
|
183
|
+
const resolved = resolve(file, source);
|
|
184
|
+
if (!resolved)
|
|
185
|
+
continue;
|
|
186
|
+
if (exportedAlias.get(resolved)?.has(importedName)) {
|
|
187
|
+
known.add(localName);
|
|
188
|
+
changed = true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (const name of known) {
|
|
192
|
+
if (info.directExportNames.has(name) && !exported.has(name)) {
|
|
193
|
+
exported.add(name);
|
|
194
|
+
changed = true;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const { exportedName, localName, source } of info.reExports) {
|
|
198
|
+
if (exported.has(exportedName))
|
|
199
|
+
continue;
|
|
200
|
+
const isAlias = source
|
|
201
|
+
? !!resolve(file, source) && exportedAlias.get(resolve(file, source))?.has(localName)
|
|
202
|
+
: known.has(localName);
|
|
203
|
+
if (isAlias) {
|
|
204
|
+
exported.add(exportedName);
|
|
205
|
+
changed = true;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (!changed)
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
const result = new Set();
|
|
213
|
+
for (const names of localAlias.values()) {
|
|
214
|
+
for (const name of names)
|
|
215
|
+
result.add(name);
|
|
216
|
+
}
|
|
217
|
+
return [...result].sort();
|
|
218
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -74,6 +74,14 @@ export interface ScoreResult {
|
|
|
74
74
|
* if they'd otherwise be skipped.
|
|
75
75
|
*/
|
|
76
76
|
skippedFiles?: string[];
|
|
77
|
+
/**
|
|
78
|
+
* A playwright.config.* was found at or above a scanned directory but
|
|
79
|
+
* could not be read statically (a dynamic value, e.g. `process.env`, a
|
|
80
|
+
* spread, or an unsupported export shape) — discovery fell back to
|
|
81
|
+
* today's filename-suffix behavior for that directory instead of acting
|
|
82
|
+
* on a partial or guessed reading. See playwright-config.ts.
|
|
83
|
+
*/
|
|
84
|
+
configWarnings?: string[];
|
|
77
85
|
}
|
|
78
86
|
export interface ScoreOptions {
|
|
79
87
|
paths: string[];
|
package/package.json
CHANGED