@qaguardian/playwright-score 2.2.0 → 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 (55) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/METHODOLOGY.md +41 -0
  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/formatters/json.js +4 -1
  9. package/dist/formatters/markdown.d.ts +2 -0
  10. package/dist/formatters/markdown.js +15 -4
  11. package/dist/formatters/report-css.d.ts +2 -0
  12. package/dist/formatters/report-css.js +68 -0
  13. package/dist/formatters/report.d.ts +20 -0
  14. package/dist/formatters/report.js +279 -0
  15. package/dist/formatters/text.d.ts +6 -1
  16. package/dist/formatters/text.js +73 -28
  17. package/dist/index.js +17 -6
  18. package/dist/profiles.js +12 -0
  19. package/dist/report-model.d.ts +52 -0
  20. package/dist/report-model.js +162 -0
  21. package/dist/report-source.d.ts +11 -0
  22. package/dist/report-source.js +96 -0
  23. package/dist/rule-copy.d.ts +14 -0
  24. package/dist/rule-copy.js +215 -0
  25. package/dist/rules/ast-util.d.ts +60 -0
  26. package/dist/rules/ast-util.js +140 -0
  27. package/dist/rules/base-plugin.d.ts +19 -5
  28. package/dist/rules/base-plugin.js +75 -7
  29. package/dist/rules/no-brittle-selector.d.ts +3 -0
  30. package/dist/rules/no-brittle-selector.js +150 -0
  31. package/dist/rules/no-excessive-timeout.d.ts +2 -0
  32. package/dist/rules/no-excessive-timeout.js +87 -0
  33. package/dist/rules/no-fixme-test.d.ts +14 -0
  34. package/dist/rules/no-fixme-test.js +53 -0
  35. package/dist/rules/no-self-fulfilling-fallback.d.ts +29 -0
  36. package/dist/rules/no-self-fulfilling-fallback.js +221 -0
  37. package/dist/rules/no-shared-mutable-state.d.ts +4 -0
  38. package/dist/rules/no-shared-mutable-state.js +186 -0
  39. package/dist/rules/no-swallowed-assertion.d.ts +13 -0
  40. package/dist/rules/no-swallowed-assertion.js +113 -0
  41. package/dist/rules/no-unawaited-action.d.ts +2 -0
  42. package/dist/rules/no-unawaited-action.js +77 -0
  43. package/dist/rules/no-unawaited-expect-value.d.ts +20 -0
  44. package/dist/rules/no-unawaited-expect-value.js +46 -0
  45. package/dist/rules/no-weak-assertion.d.ts +19 -0
  46. package/dist/rules/no-weak-assertion.js +82 -0
  47. package/dist/rules/rule-util.d.ts +56 -0
  48. package/dist/rules/rule-util.js +253 -0
  49. package/dist/score-engine.d.ts +5 -0
  50. package/dist/score-engine.js +9 -9
  51. package/dist/suite-name.d.ts +11 -0
  52. package/dist/suite-name.js +36 -0
  53. package/dist/test-aliases.js +144 -3
  54. package/dist/types.d.ts +14 -0
  55. package/package.json +1 -1
@@ -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
@@ -69,6 +69,12 @@ export interface ScoreResult {
69
69
  summary: ScoreSummary;
70
70
  dimensions: ScoreDimensions;
71
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;
72
78
  /**
73
79
  * Files matched by a directory/glob input but skipped because they show
74
80
  * positive evidence of being a non-Playwright test (an import from
@@ -86,6 +92,14 @@ export interface ScoreResult {
86
92
  * on a partial or guessed reading. See playwright-config.ts.
87
93
  */
88
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
+ }>;
89
103
  }
90
104
  export interface ScoreOptions {
91
105
  paths: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qaguardian/playwright-score",
3
- "version": "2.2.0",
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",