@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +159 -0
  2. package/METHODOLOGY.md +42 -1
  3. package/README.md +1 -1
  4. package/dist/cli.js +61 -5
  5. package/dist/confidence.d.ts +30 -0
  6. package/dist/confidence.js +67 -0
  7. package/dist/eslint-runner.js +18 -0
  8. package/dist/finding-triage.d.ts +13 -0
  9. package/dist/finding-triage.js +470 -0
  10. package/dist/formatters/json.js +4 -1
  11. package/dist/formatters/markdown.d.ts +2 -0
  12. package/dist/formatters/markdown.js +17 -5
  13. package/dist/formatters/report-css.d.ts +2 -0
  14. package/dist/formatters/report-css.js +68 -0
  15. package/dist/formatters/report.d.ts +20 -0
  16. package/dist/formatters/report.js +279 -0
  17. package/dist/formatters/text.d.ts +6 -1
  18. package/dist/formatters/text.js +73 -27
  19. package/dist/index.js +21 -8
  20. package/dist/metrics.js +33 -2
  21. package/dist/playwright-api-names.d.ts +10 -0
  22. package/dist/playwright-api-names.js +35 -0
  23. package/dist/profiles.js +14 -0
  24. package/dist/report-model.d.ts +52 -0
  25. package/dist/report-model.js +162 -0
  26. package/dist/report-source.d.ts +11 -0
  27. package/dist/report-source.js +96 -0
  28. package/dist/rule-copy.d.ts +14 -0
  29. package/dist/rule-copy.js +215 -0
  30. package/dist/rules/ast-util.d.ts +60 -0
  31. package/dist/rules/ast-util.js +140 -0
  32. package/dist/rules/base-plugin.d.ts +19 -5
  33. package/dist/rules/base-plugin.js +245 -8
  34. package/dist/rules/no-brittle-selector.d.ts +3 -0
  35. package/dist/rules/no-brittle-selector.js +150 -0
  36. package/dist/rules/no-excessive-timeout.d.ts +2 -0
  37. package/dist/rules/no-excessive-timeout.js +87 -0
  38. package/dist/rules/no-fixme-test.d.ts +14 -0
  39. package/dist/rules/no-fixme-test.js +53 -0
  40. package/dist/rules/no-self-fulfilling-fallback.d.ts +29 -0
  41. package/dist/rules/no-self-fulfilling-fallback.js +221 -0
  42. package/dist/rules/no-shared-mutable-state.d.ts +4 -0
  43. package/dist/rules/no-shared-mutable-state.js +186 -0
  44. package/dist/rules/no-swallowed-assertion.d.ts +13 -0
  45. package/dist/rules/no-swallowed-assertion.js +113 -0
  46. package/dist/rules/no-unawaited-action.d.ts +2 -0
  47. package/dist/rules/no-unawaited-action.js +77 -0
  48. package/dist/rules/no-unawaited-expect-value.d.ts +20 -0
  49. package/dist/rules/no-unawaited-expect-value.js +46 -0
  50. package/dist/rules/no-weak-assertion.d.ts +19 -0
  51. package/dist/rules/no-weak-assertion.js +82 -0
  52. package/dist/rules/rule-util.d.ts +56 -0
  53. package/dist/rules/rule-util.js +253 -0
  54. package/dist/score-engine.d.ts +5 -0
  55. package/dist/score-engine.js +10 -9
  56. package/dist/suite-name.d.ts +11 -0
  57. package/dist/suite-name.js +36 -0
  58. package/dist/test-aliases.js +144 -3
  59. package/dist/types.d.ts +18 -0
  60. package/package.json +1 -1
@@ -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. */
@@ -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
- if (score >= 90)
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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qaguardian/playwright-score",
3
- "version": "2.1.1",
3
+ "version": "2.3.0",
4
4
  "description": "Deterministic, AI-free Playwright spec quality score — the weighted share of your tests that are clean.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",