@qaguardian/playwright-score 0.1.10 → 0.1.12

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/dist/metrics.d.ts CHANGED
@@ -10,21 +10,32 @@ import type { Finding, LocatorCounts } from './types.js';
10
10
  export declare function looksLikeNonPlaywrightTest(source: string): boolean;
11
11
  export declare function countLocators(source: string, file?: string): LocatorCounts;
12
12
  /**
13
- * Names of same-file functions (function declarations, or a const bound to
14
- * an arrow/function expression) whose own body contains an expect(...)-
15
- * shaped call anywhere. Fed into playwright/expect-expect's own
16
- * assertFunctionNames option (see eslint-runner.ts) so a test that
17
- * delegates its assertion to a locally-defined helper — `audit(page,
18
- * path)`, `checkFlashMessageVisibility(page, msg)`, `assertDashboardLoaded`
19
- * — isn't flagged as having no assertions, regardless of what the helper
20
- * happens to be named. expect-expect can only see expect() calls written
21
- * directly in the test body, so without this, delegating to a helper at
22
- * all — an extremely common way to dedupe near-identical specs — reads as
23
- * "no assertions"; verified against several real production files using
24
- * several different naming conventions for the exact same shape.
25
- * Deliberately local-only: a helper imported from another file can't be
26
- * resolved without following module specifiers and parsing that file too,
27
- * well beyond what a fast, per-file syntactic check should attempt.
13
+ * Names of same-file assertion helpers — function declarations, a const
14
+ * bound to an arrow/function expression, or a class method (`kind:
15
+ * 'method'`, e.g. a Page Object Model's `async expectNodeVisible(name) {
16
+ * await expect(...) }`) — whose own body contains an expect(...)-shaped
17
+ * call, a `.waitFor(...)` call (see containsWaitForCall), or a call to a
18
+ * name already recognized by an earlier round (so e.g.
19
+ * `waitForNotificationAndClose()` calling `this.waitForNotification()`,
20
+ * itself resolved to a direct `.waitFor()` call, is recognized too —
21
+ * verified against a real Page Object Model, n8n's NotificationsPage,
22
+ * where the assertion sits two calls deep). Fed into
23
+ * playwright/expect-expect's own assertFunctionNames option (see
24
+ * eslint-runner.ts) so a test that delegates its assertion to a helper —
25
+ * `audit(page, path)`, `canvas.expectNodeVisible(name)`,
26
+ * `n8n.notifications.waitForNotificationAndClose(text)` — isn't flagged as
27
+ * having no assertions, regardless of what the helper is named or how
28
+ * deep the delegation chain runs *within this one file*. Matching is by
29
+ * name only, any receiver — the same tradeoff eslint-plugin-playwright's
30
+ * own option already makes, so this doesn't introduce a new risk, just
31
+ * extends an already-accepted one from free functions to methods too; a
32
+ * coincidental name collision between an asserting and non-asserting
33
+ * function is the known, accepted failure mode, same as before.
34
+ * Deliberately single-file only: resolving `this.foo()` across file
35
+ * boundaries needs real type resolution, well beyond what a fast,
36
+ * syntactic per-file check should attempt (cross-*file* delegation is
37
+ * handled separately, at the call site in index.ts, by running this same
38
+ * function against each imported dependency file too).
28
39
  */
29
40
  export declare function findLocalAssertionHelperNames(source: string, file?: string): string[];
30
41
  /** Count test( / test.only( / test.skip( / test.fixme( style declarations (approx). */
package/dist/metrics.js CHANGED
@@ -172,27 +172,88 @@ function containsExpectCall(node) {
172
172
  return found;
173
173
  }
174
174
  /**
175
- * Names of same-file functions (function declarations, or a const bound to
176
- * an arrow/function expression) whose own body contains an expect(...)-
177
- * shaped call anywhere. Fed into playwright/expect-expect's own
178
- * assertFunctionNames option (see eslint-runner.ts) so a test that
179
- * delegates its assertion to a locally-defined helper — `audit(page,
180
- * path)`, `checkFlashMessageVisibility(page, msg)`, `assertDashboardLoaded`
181
- * — isn't flagged as having no assertions, regardless of what the helper
182
- * happens to be named. expect-expect can only see expect() calls written
183
- * directly in the test body, so without this, delegating to a helper at
184
- * all — an extremely common way to dedupe near-identical specs — reads as
185
- * "no assertions"; verified against several real production files using
186
- * several different naming conventions for the exact same shape.
187
- * Deliberately local-only: a helper imported from another file can't be
188
- * resolved without following module specifiers and parsing that file too,
189
- * well beyond what a fast, per-file syntactic check should attempt.
175
+ * `locator.waitFor({ state: ... })` (state defaults to `'visible'` when
176
+ * omitted) has no purpose other than verifying the element reaches a
177
+ * state — unlike an action method (`.click()`, `.fill()`) that also
178
+ * happens to throw on failure as a side effect, this call exists only to
179
+ * check something, functionally equivalent to
180
+ * `expect(locator).toBeVisible()`/`toBeHidden()`. Matched on the exact
181
+ * property name `waitFor` (not a prefix), so it can't be confused with
182
+ * Page's differently-named `waitForEvent`/`waitForURL`/`waitForResponse`/
183
+ * `waitForLoadState`/`waitForTimeout`, none of which are assertions.
184
+ */
185
+ function containsWaitForCall(node) {
186
+ let found = false;
187
+ walk(node, (n) => {
188
+ if (found || n.type !== 'CallExpression')
189
+ return;
190
+ const callee = n.callee;
191
+ if (callee?.type !== 'MemberExpression' || callee.computed)
192
+ return;
193
+ const property = callee.property;
194
+ if (property?.type === 'Identifier' && property.name === 'waitFor')
195
+ found = true;
196
+ });
197
+ return found;
198
+ }
199
+ /** Does `node`'s body contain a call (any receiver, matched by name only —
200
+ * same name-based matching eslint-plugin-playwright's own
201
+ * assertFunctionNames option uses) to one of `names`? */
202
+ function containsCallToAnyName(node, names) {
203
+ if (names.size === 0)
204
+ return false;
205
+ let found = false;
206
+ walk(node, (n) => {
207
+ if (found || n.type !== 'CallExpression')
208
+ return;
209
+ const callee = n.callee;
210
+ let name;
211
+ if (callee?.type === 'Identifier') {
212
+ name = callee.name;
213
+ }
214
+ else if (callee?.type === 'MemberExpression' && !callee.computed) {
215
+ const property = callee.property;
216
+ if (property?.type === 'Identifier')
217
+ name = property.name;
218
+ }
219
+ if (name && names.has(name))
220
+ found = true;
221
+ });
222
+ return found;
223
+ }
224
+ /**
225
+ * Names of same-file assertion helpers — function declarations, a const
226
+ * bound to an arrow/function expression, or a class method (`kind:
227
+ * 'method'`, e.g. a Page Object Model's `async expectNodeVisible(name) {
228
+ * await expect(...) }`) — whose own body contains an expect(...)-shaped
229
+ * call, a `.waitFor(...)` call (see containsWaitForCall), or a call to a
230
+ * name already recognized by an earlier round (so e.g.
231
+ * `waitForNotificationAndClose()` calling `this.waitForNotification()`,
232
+ * itself resolved to a direct `.waitFor()` call, is recognized too —
233
+ * verified against a real Page Object Model, n8n's NotificationsPage,
234
+ * where the assertion sits two calls deep). Fed into
235
+ * playwright/expect-expect's own assertFunctionNames option (see
236
+ * eslint-runner.ts) so a test that delegates its assertion to a helper —
237
+ * `audit(page, path)`, `canvas.expectNodeVisible(name)`,
238
+ * `n8n.notifications.waitForNotificationAndClose(text)` — isn't flagged as
239
+ * having no assertions, regardless of what the helper is named or how
240
+ * deep the delegation chain runs *within this one file*. Matching is by
241
+ * name only, any receiver — the same tradeoff eslint-plugin-playwright's
242
+ * own option already makes, so this doesn't introduce a new risk, just
243
+ * extends an already-accepted one from free functions to methods too; a
244
+ * coincidental name collision between an asserting and non-asserting
245
+ * function is the known, accepted failure mode, same as before.
246
+ * Deliberately single-file only: resolving `this.foo()` across file
247
+ * boundaries needs real type resolution, well beyond what a fast,
248
+ * syntactic per-file check should attempt (cross-*file* delegation is
249
+ * handled separately, at the call site in index.ts, by running this same
250
+ * function against each imported dependency file too).
190
251
  */
191
252
  export function findLocalAssertionHelperNames(source, file) {
192
253
  const ast = parseSource(source, file);
193
254
  if (!ast)
194
255
  return [];
195
- const names = new Set();
256
+ const candidates = [];
196
257
  walk(ast, (node) => {
197
258
  let name;
198
259
  let body;
@@ -212,9 +273,35 @@ export function findLocalAssertionHelperNames(source, file) {
212
273
  body = init;
213
274
  }
214
275
  }
215
- if (name && body && containsExpectCall(body))
216
- names.add(name);
276
+ else if (node.type === 'MethodDefinition') {
277
+ const key = node.key;
278
+ if (!node.computed && key?.type === 'Identifier' && node.kind === 'method') {
279
+ name = key.name;
280
+ body = node.value;
281
+ }
282
+ }
283
+ if (name && body)
284
+ candidates.push({ name, body });
217
285
  });
286
+ const names = new Set();
287
+ for (const c of candidates) {
288
+ if (containsExpectCall(c.body) || containsWaitForCall(c.body))
289
+ names.add(c.name);
290
+ }
291
+ // Fixed-point: naturally bounded by candidates.length rounds, since each
292
+ // round adds at least one name or the loop stops.
293
+ let added = true;
294
+ while (added) {
295
+ added = false;
296
+ for (const c of candidates) {
297
+ if (names.has(c.name))
298
+ continue;
299
+ if (containsCallToAnyName(c.body, names)) {
300
+ names.add(c.name);
301
+ added = true;
302
+ }
303
+ }
304
+ }
218
305
  return [...names];
219
306
  }
220
307
  const TEST_NON_DECL_RE = /\btest\.(describe|step|beforeEach|afterEach|beforeAll|afterAll|use|setTimeout|info|expect)\b/;
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.12",
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",