@qaguardian/playwright-score 1.0.0 → 2.1.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.
@@ -24,9 +24,17 @@ import type { DimensionName, Finding, Grade, ProfileName, ScoreResult } from './
24
24
  * scored e.g. a 3-test suite with two hard-wait errors, two assertion-free
25
25
  * tests, and a skipped test at 78/C; model v3 scores it 53/F, which is
26
26
  * what it is.
27
+ *
28
+ * v4 (2.0.0) keeps this exact formula unchanged — the version bump is
29
+ * about which findings feed it, not how they're aggregated: the four
30
+ * QAG-196 gameability rules and the eslint-disable-comment finding are
31
+ * now unconditional demerits (previously report-only under `standard`,
32
+ * scored only under the now-removed `strict` profile), and
33
+ * no-coordinate-click is a warning (0.4), not an error (1.0). See
34
+ * METHODOLOGY.md's "Why v4" and CHANGELOG.md.
27
35
  */
28
36
  export declare const MODEL: {
29
- readonly scoreVersion: "v3";
37
+ readonly scoreVersion: "v4";
30
38
  readonly ERROR_DEMERIT: 1;
31
39
  readonly WARNING_DEMERIT: 0.4;
32
40
  readonly INFO_DEMERIT: 0;
@@ -24,9 +24,17 @@ import { PROFILE_WEIGHTS } from './profiles.js';
24
24
  * scored e.g. a 3-test suite with two hard-wait errors, two assertion-free
25
25
  * tests, and a skipped test at 78/C; model v3 scores it 53/F, which is
26
26
  * what it is.
27
+ *
28
+ * v4 (2.0.0) keeps this exact formula unchanged — the version bump is
29
+ * about which findings feed it, not how they're aggregated: the four
30
+ * QAG-196 gameability rules and the eslint-disable-comment finding are
31
+ * now unconditional demerits (previously report-only under `standard`,
32
+ * scored only under the now-removed `strict` profile), and
33
+ * no-coordinate-click is a warning (0.4), not an error (1.0). See
34
+ * METHODOLOGY.md's "Why v4" and CHANGELOG.md.
27
35
  */
28
36
  export const MODEL = {
29
- scoreVersion: 'v3',
37
+ scoreVersion: 'v4',
30
38
  ERROR_DEMERIT: 1.0,
31
39
  WARNING_DEMERIT: 0.4,
32
40
  INFO_DEMERIT: 0.0,
@@ -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
@@ -1,4 +1,16 @@
1
- export type ScoreVersion = 'v3';
1
+ export type ScoreVersion = 'v3' | 'v4';
2
+ /**
3
+ * `standard` is the only public profile, first published as of 2.0.0
4
+ * (model v4). A separate `strict` profile was planned for 1.1.0 (never
5
+ * published) to count the QAG-196 gameability rules toward the score
6
+ * while `standard` only reported them; dropped before publishing once
7
+ * `standard` started scoring them directly instead — with nothing left
8
+ * to gate, `strict` would have been identical to `standard`. See
9
+ * profiles.ts and CHANGELOG.md's 2.0.0 entry. `ProfileName` keeps room
10
+ * for a future profile; a legacy `'strict'` string degrades gracefully to
11
+ * `standard` weights via scorePaths (same fallback as the removed
12
+ * `guardian` profile), and the CLI gives an explicit migration error.
13
+ */
2
14
  export type ProfileName = 'standard';
3
15
  export type Grade = 'A' | 'B' | 'C' | 'D' | 'F';
4
16
  export type Severity = 'error' | 'warning' | 'info';
@@ -62,6 +74,14 @@ export interface ScoreResult {
62
74
  * if they'd otherwise be skipped.
63
75
  */
64
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[];
65
85
  }
66
86
  export interface ScoreOptions {
67
87
  paths: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qaguardian/playwright-score",
3
- "version": "1.0.0",
3
+ "version": "2.1.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",
@@ -19,10 +19,11 @@
19
19
  "dist",
20
20
  "METHODOLOGY.md",
21
21
  "README.md",
22
+ "CHANGELOG.md",
22
23
  "LICENSE"
23
24
  ],
24
25
  "scripts": {
25
- "build": "tsc && node -e \"require('fs').chmodSync('dist/cli.js',0o755); require('fs').chmodSync('bin/playwright-score.js',0o755)\"",
26
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc && node -e \"require('fs').chmodSync('dist/cli.js',0o755); require('fs').chmodSync('bin/playwright-score.js',0o755)\"",
26
27
  "build:tests": "tsc -p tsconfig.tests.json",
27
28
  "test": "npm run build && npm run build:tests && node --test dist-tests/tests/*.js",
28
29
  "score": "npm run build && node bin/playwright-score.js",