@qaguardian/playwright-score 0.1.9 → 0.1.11
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/dist/import-graph.d.ts +29 -0
- package/dist/import-graph.js +124 -0
- package/dist/index.js +42 -3
- package/package.json +1 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Page Object Model is common in real Playwright suites: spec files
|
|
3
|
+
* interact with the page entirely through methods on an imported class
|
|
4
|
+
* (`n8n.canvas.clickZoomToFitButton()`), and the actual `getByRole()` /
|
|
5
|
+
* `.locator()` calls live in a separate file the spec never touches
|
|
6
|
+
* directly. Verified against a real, large suite (n8n's own e2e tests,
|
|
7
|
+
* 256 spec files, 0 direct locator calls across all of them) where every
|
|
8
|
+
* interaction routes through exactly this pattern — the locator-ratio
|
|
9
|
+
* metric read a flat zero for a suite that, underneath, uses native
|
|
10
|
+
* locators almost exclusively (confirmed by hand in its page-object
|
|
11
|
+
* files).
|
|
12
|
+
*
|
|
13
|
+
* This follows only *relative* import specifiers (`./`, `../`) — bare
|
|
14
|
+
* specifiers (npm packages, workspace packages, tsconfig path aliases)
|
|
15
|
+
* are always left alone, since resolving them correctly needs a real
|
|
16
|
+
* module resolver and getting it wrong risks pulling in unrelated code.
|
|
17
|
+
* Under-counting by skipping an alias is a safe failure mode; guessing
|
|
18
|
+
* wrong isn't. Traversal is also confined to `boundaryDir` (the scored
|
|
19
|
+
* files' own common ancestor) and depth/file-count capped — see MAX_DEPTH
|
|
20
|
+
* / MAX_FILES above — so a large or adversarial import graph can't blow
|
|
21
|
+
* up cost.
|
|
22
|
+
*
|
|
23
|
+
* Returns dependency files only (never a file already present in
|
|
24
|
+
* `specSources`), keyed by absolute path, so callers can fold their
|
|
25
|
+
* locator counts (and, optionally, assertion-helper names) into the
|
|
26
|
+
* suite-level totals without treating them as scored test files —
|
|
27
|
+
* they contribute no SLOC/tests/findings of their own.
|
|
28
|
+
*/
|
|
29
|
+
export declare function collectLocallyImportedFiles(specSources: Map<string, string>, boundaryDir: string): Map<string, string>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseForESLint } from '@typescript-eslint/parser';
|
|
4
|
+
const RESOLVE_EXTENSIONS = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
|
|
5
|
+
const INDEX_FILES = ['index.ts', 'index.tsx', 'index.js', 'index.jsx'];
|
|
6
|
+
// Bounds on the traversal so a large, deep, or circular import graph can't
|
|
7
|
+
// blow up cost. Chosen generously relative to real page-object directory
|
|
8
|
+
// sizes (verified against n8n's own packages/testing/playwright/pages,
|
|
9
|
+
// ~90 files, deepest chain 3) while still being a hard ceiling.
|
|
10
|
+
const MAX_DEPTH = 6;
|
|
11
|
+
const MAX_FILES = 300;
|
|
12
|
+
function extractRelativeImportSpecifiers(source, file) {
|
|
13
|
+
let ast;
|
|
14
|
+
try {
|
|
15
|
+
ast = parseForESLint(source, {
|
|
16
|
+
ecmaVersion: 2022,
|
|
17
|
+
sourceType: 'module',
|
|
18
|
+
ecmaFeatures: { jsx: /\.(tsx|jsx)$/.test(file) },
|
|
19
|
+
}).ast;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const specifiers = [];
|
|
25
|
+
for (const node of ast.body ?? []) {
|
|
26
|
+
if (node.type !== 'ImportDeclaration' &&
|
|
27
|
+
node.type !== 'ExportNamedDeclaration' &&
|
|
28
|
+
node.type !== 'ExportAllDeclaration') {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const source_ = node.source;
|
|
32
|
+
if (typeof source_?.value === 'string' && source_.value.startsWith('.')) {
|
|
33
|
+
specifiers.push(source_.value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return specifiers;
|
|
37
|
+
}
|
|
38
|
+
function resolveRelativeImport(fromFile, specifier, boundaryDir) {
|
|
39
|
+
const base = path.resolve(path.dirname(fromFile), specifier);
|
|
40
|
+
const candidates = [];
|
|
41
|
+
for (const ext of RESOLVE_EXTENSIONS)
|
|
42
|
+
candidates.push(base + ext);
|
|
43
|
+
for (const idx of INDEX_FILES)
|
|
44
|
+
candidates.push(path.join(base, idx));
|
|
45
|
+
for (const candidate of candidates) {
|
|
46
|
+
// Never resolve outside the scanned suite's own directory tree — keeps
|
|
47
|
+
// the traversal bounded to the code actually being scored, and refuses
|
|
48
|
+
// to follow a relative import that climbs out to a sibling package or
|
|
49
|
+
// node_modules via a longer `../../..` chain.
|
|
50
|
+
const rel = path.relative(boundaryDir, candidate);
|
|
51
|
+
if (rel.startsWith('..') || path.isAbsolute(rel))
|
|
52
|
+
continue;
|
|
53
|
+
try {
|
|
54
|
+
if (fs.statSync(candidate).isFile())
|
|
55
|
+
return candidate;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Candidate doesn't exist — try the next extension/index form.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A Page Object Model is common in real Playwright suites: spec files
|
|
65
|
+
* interact with the page entirely through methods on an imported class
|
|
66
|
+
* (`n8n.canvas.clickZoomToFitButton()`), and the actual `getByRole()` /
|
|
67
|
+
* `.locator()` calls live in a separate file the spec never touches
|
|
68
|
+
* directly. Verified against a real, large suite (n8n's own e2e tests,
|
|
69
|
+
* 256 spec files, 0 direct locator calls across all of them) where every
|
|
70
|
+
* interaction routes through exactly this pattern — the locator-ratio
|
|
71
|
+
* metric read a flat zero for a suite that, underneath, uses native
|
|
72
|
+
* locators almost exclusively (confirmed by hand in its page-object
|
|
73
|
+
* files).
|
|
74
|
+
*
|
|
75
|
+
* This follows only *relative* import specifiers (`./`, `../`) — bare
|
|
76
|
+
* specifiers (npm packages, workspace packages, tsconfig path aliases)
|
|
77
|
+
* are always left alone, since resolving them correctly needs a real
|
|
78
|
+
* module resolver and getting it wrong risks pulling in unrelated code.
|
|
79
|
+
* Under-counting by skipping an alias is a safe failure mode; guessing
|
|
80
|
+
* wrong isn't. Traversal is also confined to `boundaryDir` (the scored
|
|
81
|
+
* files' own common ancestor) and depth/file-count capped — see MAX_DEPTH
|
|
82
|
+
* / MAX_FILES above — so a large or adversarial import graph can't blow
|
|
83
|
+
* up cost.
|
|
84
|
+
*
|
|
85
|
+
* Returns dependency files only (never a file already present in
|
|
86
|
+
* `specSources`), keyed by absolute path, so callers can fold their
|
|
87
|
+
* locator counts (and, optionally, assertion-helper names) into the
|
|
88
|
+
* suite-level totals without treating them as scored test files —
|
|
89
|
+
* they contribute no SLOC/tests/findings of their own.
|
|
90
|
+
*/
|
|
91
|
+
export function collectLocallyImportedFiles(specSources, boundaryDir) {
|
|
92
|
+
const collected = new Map();
|
|
93
|
+
const visited = new Set(specSources.keys());
|
|
94
|
+
const queue = [];
|
|
95
|
+
for (const [file, source] of specSources) {
|
|
96
|
+
queue.push({ file, source, depth: 0 });
|
|
97
|
+
}
|
|
98
|
+
while (queue.length > 0 && collected.size < MAX_FILES) {
|
|
99
|
+
const next = queue.shift();
|
|
100
|
+
if (!next)
|
|
101
|
+
break;
|
|
102
|
+
const { file, source, depth } = next;
|
|
103
|
+
if (depth >= MAX_DEPTH)
|
|
104
|
+
continue;
|
|
105
|
+
for (const specifier of extractRelativeImportSpecifiers(source, file)) {
|
|
106
|
+
if (collected.size >= MAX_FILES)
|
|
107
|
+
break;
|
|
108
|
+
const resolved = resolveRelativeImport(file, specifier, boundaryDir);
|
|
109
|
+
if (!resolved || visited.has(resolved))
|
|
110
|
+
continue;
|
|
111
|
+
visited.add(resolved);
|
|
112
|
+
let depSource;
|
|
113
|
+
try {
|
|
114
|
+
depSource = fs.readFileSync(resolved, 'utf8');
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
collected.set(resolved, depSource);
|
|
120
|
+
queue.push({ file: resolved, source: depSource, depth: depth + 1 });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return collected;
|
|
124
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,8 @@ import path from 'node:path';
|
|
|
3
3
|
import { globSync } from 'glob';
|
|
4
4
|
import { runEslint } from './eslint-runner.js';
|
|
5
5
|
import { commonAncestorDir } from './fs-util.js';
|
|
6
|
-
import {
|
|
6
|
+
import { collectLocallyImportedFiles } from './import-graph.js';
|
|
7
|
+
import { analyzeSource, countLocators, findLocalAssertionHelperNames, looksLikeNonPlaywrightTest, } from './metrics.js';
|
|
7
8
|
import { DEFAULT_THRESHOLDS } from './profiles.js';
|
|
8
9
|
import { computeScore } from './score-engine.js';
|
|
9
10
|
export { computeScore, SQS_V1, locatorsScore, gradeFromScore } from './score-engine.js';
|
|
@@ -47,6 +48,7 @@ const DEFAULT_IGNORE_GLOBS = [
|
|
|
47
48
|
function expandPaths(inputs, cwd) {
|
|
48
49
|
const explicit = new Set();
|
|
49
50
|
const expanded = new Set();
|
|
51
|
+
const scanRootDirs = new Set();
|
|
50
52
|
for (const input of inputs) {
|
|
51
53
|
const abs = path.isAbsolute(input) ? input : path.resolve(cwd, input);
|
|
52
54
|
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
|
|
@@ -54,6 +56,13 @@ function expandPaths(inputs, cwd) {
|
|
|
54
56
|
continue;
|
|
55
57
|
}
|
|
56
58
|
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
|
59
|
+
// The directory the caller actually pointed at — not just wherever a
|
|
60
|
+
// spec glob happened to match inside it. A Page Object Model's
|
|
61
|
+
// classes typically live in a sibling directory to the specs
|
|
62
|
+
// themselves (tests/ next to pages/, fixtures/), so import-graph.ts
|
|
63
|
+
// needs this wider boundary, not the narrower common ancestor of
|
|
64
|
+
// only the matched spec files.
|
|
65
|
+
scanRootDirs.add(abs);
|
|
57
66
|
for (const g of SPEC_GLOBS) {
|
|
58
67
|
for (const f of globSync(g, {
|
|
59
68
|
cwd: abs,
|
|
@@ -80,7 +89,11 @@ function expandPaths(inputs, cwd) {
|
|
|
80
89
|
// explicit path plus an overlapping glob); explicit intent wins.
|
|
81
90
|
for (const f of explicit)
|
|
82
91
|
expanded.delete(f);
|
|
83
|
-
return {
|
|
92
|
+
return {
|
|
93
|
+
explicit: [...explicit].sort(),
|
|
94
|
+
expanded: [...expanded].sort(),
|
|
95
|
+
scanRootDirs: [...scanRootDirs].sort(),
|
|
96
|
+
};
|
|
84
97
|
}
|
|
85
98
|
function hardFail(profile, threshold, findings, extra = {}, skippedFiles) {
|
|
86
99
|
return {
|
|
@@ -119,7 +132,7 @@ export async function scorePaths(options) {
|
|
|
119
132
|
const cwd = options.cwd ?? process.cwd();
|
|
120
133
|
const profile = options.profile ?? 'standard';
|
|
121
134
|
const threshold = options.threshold ?? DEFAULT_THRESHOLDS[profile] ?? 80;
|
|
122
|
-
const { explicit, expanded } = expandPaths(options.paths, cwd);
|
|
135
|
+
const { explicit, expanded, scanRootDirs } = expandPaths(options.paths, cwd);
|
|
123
136
|
if (explicit.length === 0 && expanded.length === 0) {
|
|
124
137
|
// Hard-fail: empty match must never look like a healthy suite (was ~99 PASS).
|
|
125
138
|
return hardFail(profile, threshold, [
|
|
@@ -163,6 +176,15 @@ export async function scorePaths(options) {
|
|
|
163
176
|
// may be unrelated) so findings stay portable across machines/CI and
|
|
164
177
|
// formatters/sarif.ts can emit repo-relative artifactLocation URIs.
|
|
165
178
|
const filesBase = commonAncestorDir(files);
|
|
179
|
+
// Wider than filesBase on purpose: a Page Object Model's classes
|
|
180
|
+
// typically live in a directory *sibling* to the specs themselves
|
|
181
|
+
// (tests/ next to pages/, fixtures/), so bounding import-graph.ts's
|
|
182
|
+
// traversal to just the matched spec files' own common ancestor would
|
|
183
|
+
// reject every import that climbs back out of tests/ — verified against
|
|
184
|
+
// a real suite (n8n) where this was the exact reason locator counts from
|
|
185
|
+
// its page objects were being discarded. Falls back to filesBase itself
|
|
186
|
+
// when every input was an explicit file (no directory to widen to).
|
|
187
|
+
const importBoundary = commonAncestorDir([...files, ...scanRootDirs]);
|
|
166
188
|
// Read every file once, up front, so (a) we can feed ESLint's
|
|
167
189
|
// expect-expect a per-run list of local assertion-helper names before
|
|
168
190
|
// linting (see findLocalAssertionHelperNames) and (b) the metrics loop
|
|
@@ -176,6 +198,18 @@ export async function scorePaths(options) {
|
|
|
176
198
|
assertFunctionNames.add(name);
|
|
177
199
|
}
|
|
178
200
|
}
|
|
201
|
+
// Page Object Model suites route locator calls through imported classes
|
|
202
|
+
// (`n8n.canvas.click(...)`) rather than calling `page.locator()`/
|
|
203
|
+
// `getByRole()` directly in the spec file — see import-graph.ts. These
|
|
204
|
+
// dependency files are never linted or counted toward sloc/tests/
|
|
205
|
+
// findings; they only contribute locator counts (and, the same way,
|
|
206
|
+
// any assertion-helper names) to the suite-level totals below.
|
|
207
|
+
const importedFiles = collectLocallyImportedFiles(sources, importBoundary);
|
|
208
|
+
for (const [file, source] of importedFiles) {
|
|
209
|
+
for (const name of findLocalAssertionHelperNames(source, file)) {
|
|
210
|
+
assertFunctionNames.add(name);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
179
213
|
const eslintFindings = await runEslint({
|
|
180
214
|
files,
|
|
181
215
|
profile,
|
|
@@ -198,6 +232,11 @@ export async function scorePaths(options) {
|
|
|
198
232
|
// metrics/no-empty-test duplicates guardian/require-expect under guardian — keep both mild
|
|
199
233
|
metricFindings.push(...m.findings);
|
|
200
234
|
}
|
|
235
|
+
for (const [file, source] of importedFiles) {
|
|
236
|
+
const counts = countLocators(source, file);
|
|
237
|
+
native += counts.native;
|
|
238
|
+
raw += counts.raw;
|
|
239
|
+
}
|
|
201
240
|
// Dedupe empty-test style: if guardian require-expect already fired, still ok
|
|
202
241
|
const findings = [...eslintFindings, ...metricFindings];
|
|
203
242
|
const parseErrors = findings.filter((f) => f.rule === 'playwright-score/parse-error');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qaguardian/playwright-score",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "Deterministic, AI-free Playwright spec quality score (sqs-v1). Lint + score with standard and guardian profiles.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|