@qaguardian/playwright-score 0.1.10 → 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.
@@ -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 { analyzeSource, findLocalAssertionHelperNames, looksLikeNonPlaywrightTest, } from './metrics.js';
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 { explicit: [...explicit].sort(), expanded: [...expanded].sort() };
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.10",
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",