@qaguardian/playwright-score 2.1.1 → 2.3.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/CHANGELOG.md +159 -0
- package/METHODOLOGY.md +42 -1
- package/README.md +1 -1
- package/dist/cli.js +61 -5
- package/dist/confidence.d.ts +30 -0
- package/dist/confidence.js +67 -0
- package/dist/eslint-runner.js +18 -0
- package/dist/finding-triage.d.ts +13 -0
- package/dist/finding-triage.js +470 -0
- package/dist/formatters/json.js +4 -1
- package/dist/formatters/markdown.d.ts +2 -0
- package/dist/formatters/markdown.js +17 -5
- package/dist/formatters/report-css.d.ts +2 -0
- package/dist/formatters/report-css.js +68 -0
- package/dist/formatters/report.d.ts +20 -0
- package/dist/formatters/report.js +279 -0
- package/dist/formatters/text.d.ts +6 -1
- package/dist/formatters/text.js +73 -27
- package/dist/index.js +21 -8
- package/dist/metrics.js +33 -2
- package/dist/playwright-api-names.d.ts +10 -0
- package/dist/playwright-api-names.js +35 -0
- package/dist/profiles.js +14 -0
- package/dist/report-model.d.ts +52 -0
- package/dist/report-model.js +162 -0
- package/dist/report-source.d.ts +11 -0
- package/dist/report-source.js +96 -0
- package/dist/rule-copy.d.ts +14 -0
- package/dist/rule-copy.js +215 -0
- package/dist/rules/ast-util.d.ts +60 -0
- package/dist/rules/ast-util.js +140 -0
- package/dist/rules/base-plugin.d.ts +19 -5
- package/dist/rules/base-plugin.js +245 -8
- package/dist/rules/no-brittle-selector.d.ts +3 -0
- package/dist/rules/no-brittle-selector.js +150 -0
- package/dist/rules/no-excessive-timeout.d.ts +2 -0
- package/dist/rules/no-excessive-timeout.js +87 -0
- package/dist/rules/no-fixme-test.d.ts +14 -0
- package/dist/rules/no-fixme-test.js +53 -0
- package/dist/rules/no-self-fulfilling-fallback.d.ts +29 -0
- package/dist/rules/no-self-fulfilling-fallback.js +221 -0
- package/dist/rules/no-shared-mutable-state.d.ts +4 -0
- package/dist/rules/no-shared-mutable-state.js +186 -0
- package/dist/rules/no-swallowed-assertion.d.ts +13 -0
- package/dist/rules/no-swallowed-assertion.js +113 -0
- package/dist/rules/no-unawaited-action.d.ts +2 -0
- package/dist/rules/no-unawaited-action.js +77 -0
- package/dist/rules/no-unawaited-expect-value.d.ts +20 -0
- package/dist/rules/no-unawaited-expect-value.js +46 -0
- package/dist/rules/no-weak-assertion.d.ts +19 -0
- package/dist/rules/no-weak-assertion.js +82 -0
- package/dist/rules/rule-util.d.ts +56 -0
- package/dist/rules/rule-util.js +253 -0
- package/dist/score-engine.d.ts +5 -0
- package/dist/score-engine.js +10 -9
- package/dist/suite-name.d.ts +11 -0
- package/dist/suite-name.js +36 -0
- package/dist/test-aliases.js +144 -3
- package/dist/types.d.ts +18 -0
- package/package.json +1 -1
package/dist/score-engine.d.ts
CHANGED
|
@@ -44,6 +44,11 @@ export declare const MODEL: {
|
|
|
44
44
|
readonly MAX_DEMERIT_PER_FILE_MODULE: 1;
|
|
45
45
|
};
|
|
46
46
|
declare const PENALTY_DIMENSIONS: DimensionName[];
|
|
47
|
+
/** Lowest score of each grade, best first. Shared with the report's score explanation. */
|
|
48
|
+
export declare const GRADE_BANDS: ReadonlyArray<{
|
|
49
|
+
grade: Grade;
|
|
50
|
+
min: number;
|
|
51
|
+
}>;
|
|
47
52
|
export declare function gradeFromScore(score: number): Grade;
|
|
48
53
|
export declare function locatorsScore(native: number, raw: number): number;
|
|
49
54
|
/** Tests per file, keyed by the same relative file path findings carry. */
|
package/dist/score-engine.js
CHANGED
|
@@ -44,16 +44,16 @@ export const MODEL = {
|
|
|
44
44
|
MAX_DEMERIT_PER_FILE_MODULE: 1.0,
|
|
45
45
|
};
|
|
46
46
|
const PENALTY_DIMENSIONS = ['playwrightHygiene', 'assertions', 'structure'];
|
|
47
|
+
/** Lowest score of each grade, best first. Shared with the report's score explanation. */
|
|
48
|
+
export const GRADE_BANDS = [
|
|
49
|
+
{ grade: 'A', min: 90 },
|
|
50
|
+
{ grade: 'B', min: 80 },
|
|
51
|
+
{ grade: 'C', min: 70 },
|
|
52
|
+
{ grade: 'D', min: 60 },
|
|
53
|
+
{ grade: 'F', min: 0 },
|
|
54
|
+
];
|
|
47
55
|
export function gradeFromScore(score) {
|
|
48
|
-
|
|
49
|
-
return 'A';
|
|
50
|
-
if (score >= 80)
|
|
51
|
-
return 'B';
|
|
52
|
-
if (score >= 70)
|
|
53
|
-
return 'C';
|
|
54
|
-
if (score >= 60)
|
|
55
|
-
return 'D';
|
|
56
|
-
return 'F';
|
|
56
|
+
return (GRADE_BANDS.find((b) => score >= b.min) ?? GRADE_BANDS[GRADE_BANDS.length - 1]).grade;
|
|
57
57
|
}
|
|
58
58
|
export function locatorsScore(native, raw) {
|
|
59
59
|
const total = native + raw;
|
|
@@ -188,6 +188,7 @@ export function computeScore(input) {
|
|
|
188
188
|
nativeLocators: input.nativeLocators,
|
|
189
189
|
rawLocators: input.rawLocators,
|
|
190
190
|
unassertedTests: input.findings.filter((f) => f.rule === 'playwright/expect-expect' && !f.reportOnly).length,
|
|
191
|
+
unverifiedTests: input.findings.filter((f) => f.rule === 'playwright-score/assertion-unverified').length,
|
|
191
192
|
cleanTests,
|
|
192
193
|
},
|
|
193
194
|
dimensions: dims,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Label for one scored path, used when --name is absent. A file keeps its
|
|
3
|
+
* basename. A folder gets its own name plus parents: three segments when the
|
|
4
|
+
* name is generic ("mattermost/e2e-tests/playwright"), two otherwise. Never
|
|
5
|
+
* reaches the user-name segment of a home directory or anything above it:
|
|
6
|
+
* the walk stops under any `home`/`Users` segment (wherever it sits, so
|
|
7
|
+
* `/tmp/home/alice/e2e` is safe even if $HOME says otherwise) and under the
|
|
8
|
+
* current home directory. A folder that is itself a home directory gets a
|
|
9
|
+
* neutral label.
|
|
10
|
+
*/
|
|
11
|
+
export declare function suiteLabel(p: string, isDir: boolean, home?: string): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/** Folder names that say nothing about whose suite this is. */
|
|
4
|
+
const GENERIC_NAMES = new Set(['playwright', 'e2e', 'e2e-tests', 'tests', 'test', 'specs', 'spec', 'src']);
|
|
5
|
+
const NEUTRAL = 'Playwright suite';
|
|
6
|
+
const HOME_PARENTS = new Set(['home', 'users']);
|
|
7
|
+
/**
|
|
8
|
+
* Label for one scored path, used when --name is absent. A file keeps its
|
|
9
|
+
* basename. A folder gets its own name plus parents: three segments when the
|
|
10
|
+
* name is generic ("mattermost/e2e-tests/playwright"), two otherwise. Never
|
|
11
|
+
* reaches the user-name segment of a home directory or anything above it:
|
|
12
|
+
* the walk stops under any `home`/`Users` segment (wherever it sits, so
|
|
13
|
+
* `/tmp/home/alice/e2e` is safe even if $HOME says otherwise) and under the
|
|
14
|
+
* current home directory. A folder that is itself a home directory gets a
|
|
15
|
+
* neutral label.
|
|
16
|
+
*/
|
|
17
|
+
export function suiteLabel(p, isDir, home = os.homedir()) {
|
|
18
|
+
const abs = path.resolve(p);
|
|
19
|
+
const base = path.basename(abs) || p;
|
|
20
|
+
if (!isDir)
|
|
21
|
+
return base;
|
|
22
|
+
const segs = abs.split(path.sep).filter(Boolean);
|
|
23
|
+
// First segment index the label may use.
|
|
24
|
+
let min = 0;
|
|
25
|
+
segs.forEach((seg, k) => {
|
|
26
|
+
if (HOME_PARENTS.has(seg.toLowerCase()))
|
|
27
|
+
min = Math.max(min, k + 2);
|
|
28
|
+
});
|
|
29
|
+
const homeSegs = path.resolve(home || path.sep).split(path.sep).filter(Boolean);
|
|
30
|
+
if (homeSegs.length > 0 && homeSegs.every((seg, k) => segs[k] === seg))
|
|
31
|
+
min = Math.max(min, homeSegs.length);
|
|
32
|
+
if (segs.length <= min)
|
|
33
|
+
return NEUTRAL;
|
|
34
|
+
const want = GENERIC_NAMES.has(base.toLowerCase()) ? 3 : 2;
|
|
35
|
+
return segs.slice(Math.max(min, segs.length - want)).join('/');
|
|
36
|
+
}
|
package/dist/test-aliases.js
CHANGED
|
@@ -1,5 +1,69 @@
|
|
|
1
1
|
import { parseForESLint } from '@typescript-eslint/parser';
|
|
2
2
|
import { resolveRelativeImport } from './import-graph.js';
|
|
3
|
+
function classifyReturn(expr) {
|
|
4
|
+
if (!expr)
|
|
5
|
+
return undefined;
|
|
6
|
+
if (expr.type === 'Identifier' && typeof expr.name === 'string') {
|
|
7
|
+
return { kind: 'ident', name: expr.name };
|
|
8
|
+
}
|
|
9
|
+
if (expr.type !== 'CallExpression')
|
|
10
|
+
return undefined;
|
|
11
|
+
const callee = expr.callee;
|
|
12
|
+
if (callee?.type === 'Identifier' && typeof callee.name === 'string') {
|
|
13
|
+
return { kind: 'call', calleeName: callee.name };
|
|
14
|
+
}
|
|
15
|
+
if (callee?.type === 'MemberExpression' && !callee.computed) {
|
|
16
|
+
const prop = callee.property;
|
|
17
|
+
const obj = callee.object;
|
|
18
|
+
if (prop?.type === 'Identifier' && prop.name === 'extend' && obj?.type === 'Identifier' && obj.name) {
|
|
19
|
+
return { kind: 'extend', baseName: obj.name };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
/** `return <expr>` statements of one function body, not descending into nested functions. */
|
|
25
|
+
function collectFunctionReturns(fn) {
|
|
26
|
+
const out = [];
|
|
27
|
+
const body = fn.body;
|
|
28
|
+
if (!body)
|
|
29
|
+
return out;
|
|
30
|
+
if (body.type !== 'BlockStatement') {
|
|
31
|
+
const r = classifyReturn(body);
|
|
32
|
+
if (r)
|
|
33
|
+
out.push(r);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
const visit = (node) => {
|
|
37
|
+
if (!node || typeof node !== 'object')
|
|
38
|
+
return;
|
|
39
|
+
if (Array.isArray(node)) {
|
|
40
|
+
for (const n of node)
|
|
41
|
+
visit(n);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const obj = node;
|
|
45
|
+
if (obj.type === 'FunctionDeclaration' ||
|
|
46
|
+
obj.type === 'FunctionExpression' ||
|
|
47
|
+
obj.type === 'ArrowFunctionExpression') {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (obj.type === 'ReturnStatement') {
|
|
51
|
+
const r = classifyReturn(obj.argument);
|
|
52
|
+
if (r)
|
|
53
|
+
out.push(r);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
for (const key in obj) {
|
|
57
|
+
if (key === 'parent')
|
|
58
|
+
continue;
|
|
59
|
+
const v = obj[key];
|
|
60
|
+
if (v && typeof v === 'object')
|
|
61
|
+
visit(v);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
visit(body.body);
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
3
67
|
function isRelativeSpecifier(value) {
|
|
4
68
|
return typeof value === 'string' && value.startsWith('.');
|
|
5
69
|
}
|
|
@@ -20,12 +84,38 @@ function parseFileInfo(source, file) {
|
|
|
20
84
|
const directExportNames = new Set();
|
|
21
85
|
const imports = [];
|
|
22
86
|
const reExports = [];
|
|
87
|
+
const functions = [];
|
|
88
|
+
const callDeclarations = [];
|
|
89
|
+
function collectFunction(node, exported) {
|
|
90
|
+
if (node.type === 'FunctionDeclaration') {
|
|
91
|
+
const name = node.id?.name;
|
|
92
|
+
if (name)
|
|
93
|
+
functions.push({ name, returns: collectFunctionReturns(node), exported });
|
|
94
|
+
}
|
|
95
|
+
else if (node.type === 'VariableDeclaration') {
|
|
96
|
+
for (const d of (node.declarations ?? [])) {
|
|
97
|
+
const init = d.init;
|
|
98
|
+
if (d.id?.type === 'Identifier' &&
|
|
99
|
+
d.id.name &&
|
|
100
|
+
init &&
|
|
101
|
+
(init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression')) {
|
|
102
|
+
functions.push({ name: d.id.name, returns: collectFunctionReturns(init), exported });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
23
107
|
function collectExtendFromVariableDeclaration(decl) {
|
|
24
108
|
const declarations = (decl.declarations ?? []);
|
|
25
109
|
for (const d of declarations) {
|
|
26
110
|
if (d.id?.type !== 'Identifier' || !d.id.name || !d.init)
|
|
27
111
|
continue;
|
|
28
112
|
const init = d.init;
|
|
113
|
+
if (init.type === 'CallExpression' &&
|
|
114
|
+
init.callee?.type === 'Identifier') {
|
|
115
|
+
const calleeName = (init.callee).name;
|
|
116
|
+
if (calleeName)
|
|
117
|
+
callDeclarations.push({ declaredName: d.id.name, calleeName });
|
|
118
|
+
}
|
|
29
119
|
if (init.type === 'CallExpression' &&
|
|
30
120
|
init.callee?.type === 'MemberExpression') {
|
|
31
121
|
const callee = init.callee;
|
|
@@ -63,6 +153,9 @@ function parseFileInfo(source, file) {
|
|
|
63
153
|
}
|
|
64
154
|
}
|
|
65
155
|
for (const node of ast.body ?? []) {
|
|
156
|
+
if (node.type === 'FunctionDeclaration' || node.type === 'VariableDeclaration') {
|
|
157
|
+
collectFunction(node, false);
|
|
158
|
+
}
|
|
66
159
|
if (node.type === 'ImportDeclaration') {
|
|
67
160
|
const src = node.source?.value;
|
|
68
161
|
if (!isRelativeSpecifier(src)) {
|
|
@@ -90,6 +183,8 @@ function parseFileInfo(source, file) {
|
|
|
90
183
|
else if (node.type === 'ExportNamedDeclaration') {
|
|
91
184
|
const declaration = node.declaration;
|
|
92
185
|
const src = node.source?.value;
|
|
186
|
+
if (declaration)
|
|
187
|
+
collectFunction(declaration, true);
|
|
93
188
|
if (declaration?.type === 'VariableDeclaration') {
|
|
94
189
|
collectExtendFromVariableDeclaration(declaration);
|
|
95
190
|
for (const d of (declaration.declarations ?? [])) {
|
|
@@ -124,6 +219,8 @@ function parseFileInfo(source, file) {
|
|
|
124
219
|
directExportNames,
|
|
125
220
|
imports,
|
|
126
221
|
reExports,
|
|
222
|
+
functions,
|
|
223
|
+
callDeclarations,
|
|
127
224
|
};
|
|
128
225
|
}
|
|
129
226
|
/**
|
|
@@ -153,9 +250,15 @@ export function detectExtendedTestAliases(sources, importedFiles, boundaryDir) {
|
|
|
153
250
|
}
|
|
154
251
|
const localAlias = new Map();
|
|
155
252
|
const exportedAlias = new Map();
|
|
253
|
+
// Test-factory functions (QAG-287): `function make(cfg) { return test.extend(...) }`.
|
|
254
|
+
// `const t = make(cfg)` then makes `t` a test alias.
|
|
255
|
+
const localFactory = new Map();
|
|
256
|
+
const exportedFactory = new Map();
|
|
156
257
|
for (const file of parsed.keys()) {
|
|
157
258
|
localAlias.set(file, new Set());
|
|
158
259
|
exportedAlias.set(file, new Set());
|
|
260
|
+
localFactory.set(file, new Set());
|
|
261
|
+
exportedFactory.set(file, new Set());
|
|
159
262
|
}
|
|
160
263
|
const resolve = (fromFile, specifier) => resolveRelativeImport(fromFile, specifier, boundaryDir);
|
|
161
264
|
// Bounded fixed point: each round can only add names (monotonic), and
|
|
@@ -177,16 +280,54 @@ export function detectExtendedTestAliases(sources, importedFiles, boundaryDir) {
|
|
|
177
280
|
changed = true;
|
|
178
281
|
}
|
|
179
282
|
}
|
|
283
|
+
const factories = localFactory.get(file);
|
|
284
|
+
const exportedFactories = exportedFactory.get(file);
|
|
180
285
|
for (const { localName, importedName, source } of info.imports) {
|
|
181
|
-
if (known.has(localName))
|
|
182
|
-
continue;
|
|
183
286
|
const resolved = resolve(file, source);
|
|
184
287
|
if (!resolved)
|
|
185
288
|
continue;
|
|
186
|
-
if (exportedAlias.get(resolved)?.has(importedName)) {
|
|
289
|
+
if (!known.has(localName) && exportedAlias.get(resolved)?.has(importedName)) {
|
|
187
290
|
known.add(localName);
|
|
188
291
|
changed = true;
|
|
189
292
|
}
|
|
293
|
+
if (!factories.has(localName) && exportedFactory.get(resolved)?.has(importedName)) {
|
|
294
|
+
factories.add(localName);
|
|
295
|
+
changed = true;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for (const fn of info.functions) {
|
|
299
|
+
if (!factories.has(fn.name)) {
|
|
300
|
+
const returnsAlias = fn.returns.some((r) => r.kind === 'extend'
|
|
301
|
+
? info.playwrightTestLocalNames.has(r.baseName) || known.has(r.baseName)
|
|
302
|
+
: r.kind === 'ident'
|
|
303
|
+
? known.has(r.name)
|
|
304
|
+
: factories.has(r.calleeName));
|
|
305
|
+
if (returnsAlias) {
|
|
306
|
+
factories.add(fn.name);
|
|
307
|
+
changed = true;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (fn.exported && factories.has(fn.name) && !exportedFactories.has(fn.name)) {
|
|
311
|
+
exportedFactories.add(fn.name);
|
|
312
|
+
changed = true;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
for (const { declaredName, calleeName } of info.callDeclarations) {
|
|
316
|
+
if (known.has(declaredName) || !factories.has(calleeName))
|
|
317
|
+
continue;
|
|
318
|
+
known.add(declaredName);
|
|
319
|
+
changed = true;
|
|
320
|
+
}
|
|
321
|
+
for (const { exportedName, localName, source } of info.reExports) {
|
|
322
|
+
if (exportedFactories.has(exportedName))
|
|
323
|
+
continue;
|
|
324
|
+
const isFactory = source
|
|
325
|
+
? !!resolve(file, source) && exportedFactory.get(resolve(file, source))?.has(localName)
|
|
326
|
+
: factories.has(localName);
|
|
327
|
+
if (isFactory) {
|
|
328
|
+
exportedFactories.add(exportedName);
|
|
329
|
+
changed = true;
|
|
330
|
+
}
|
|
190
331
|
}
|
|
191
332
|
for (const name of known) {
|
|
192
333
|
if (info.directExportNames.has(name) && !exported.has(name)) {
|
package/dist/types.d.ts
CHANGED
|
@@ -51,6 +51,10 @@ export interface ScoreSummary {
|
|
|
51
51
|
/** Tests with no recognized assertion (uncapped playwright/expect-expect
|
|
52
52
|
* census, after delegation resolution). */
|
|
53
53
|
unassertedTests: number;
|
|
54
|
+
/** Tests with no expect(...) that call something this scan cannot read
|
|
55
|
+
* (`playwright-score/assertion-unverified`, report-only). They are NOT in
|
|
56
|
+
* `unassertedTests` and not penalised, but they are not known to assert. */
|
|
57
|
+
unverifiedTests: number;
|
|
54
58
|
/** Tests with no demerit in any dimension — the headline the score is
|
|
55
59
|
* built from: the weighted share of tests that are clean. */
|
|
56
60
|
cleanTests: number;
|
|
@@ -65,6 +69,12 @@ export interface ScoreResult {
|
|
|
65
69
|
summary: ScoreSummary;
|
|
66
70
|
dimensions: ScoreDimensions;
|
|
67
71
|
findings: Finding[];
|
|
72
|
+
/**
|
|
73
|
+
* Absolute directory that every `Finding.file` is relative to (the scored
|
|
74
|
+
* files' common ancestor). Lets a consumer locate source for excerpts. Local
|
|
75
|
+
* to the machine that ran the scan: never put it in a shareable artifact.
|
|
76
|
+
*/
|
|
77
|
+
baseDir?: string;
|
|
68
78
|
/**
|
|
69
79
|
* Files matched by a directory/glob input but skipped because they show
|
|
70
80
|
* positive evidence of being a non-Playwright test (an import from
|
|
@@ -82,6 +92,14 @@ export interface ScoreResult {
|
|
|
82
92
|
* on a partial or guessed reading. See playwright-config.ts.
|
|
83
93
|
*/
|
|
84
94
|
configWarnings?: string[];
|
|
95
|
+
/**
|
|
96
|
+
* Rules that threw while scoring this run (contained, so the run finished
|
|
97
|
+
* but their findings may be missing). Present only when non-empty.
|
|
98
|
+
*/
|
|
99
|
+
ruleCrashes?: Array<{
|
|
100
|
+
rule: string;
|
|
101
|
+
count: number;
|
|
102
|
+
}>;
|
|
85
103
|
}
|
|
86
104
|
export interface ScoreOptions {
|
|
87
105
|
paths: string[];
|
package/package.json
CHANGED