@qaguardian/playwright-score 2.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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qaguardian/playwright-score",
3
- "version": "2.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",