@qaguardian/playwright-score 2.0.0 → 2.1.1

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/index.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { globSync } from 'glob';
3
+ import { globSync, hasMagic, Ignore } from 'glob';
4
4
  import { runEslint } from './eslint-runner.js';
5
5
  import { commonAncestorDir } from './fs-util.js';
6
6
  import { collectLocallyImportedFiles } from './import-graph.js';
7
7
  import { analyzeSource, attributeFindingScopes, countLocators, findLocalAssertionHelperNames, getTestSpans, looksLikeNonPlaywrightTest, } from './metrics.js';
8
+ import { detectExtendedTestAliases } from './test-aliases.js';
9
+ import { findPlaywrightConfig, findRepoBoundary, importsPlaywrightTestTransitively, isAncestorOrSame, parsePlaywrightConfig, resolveConfigScopedRoot, safeRealpath, testDirEscapesRepo, } from './playwright-config.js';
8
10
  import { DEFAULT_THRESHOLDS } from './profiles.js';
9
11
  import { computeScore } from './score-engine.js';
10
12
  export { computeScore, MODEL, locatorsScore, gradeFromScore, ratioDimensionScore, } from './score-engine.js';
@@ -55,6 +57,86 @@ const DEFAULT_IGNORE_GLOBS = [
55
57
  '**/playwright-report/**',
56
58
  '**/test-results/**',
57
59
  ];
60
+ /**
61
+ * Wraps glob's own default `Ignore` (the string-pattern matcher behind
62
+ * DEFAULT_IGNORE_GLOBS/testIgnore) so this package can *also* prune the
63
+ * walk at a symlinked directory whose realpath escapes `realRoot`,
64
+ * without giving up the string-pattern ignores glob's `ignore` option
65
+ * only accepts one or the other of (a plain string[] XOR an IgnoreLike
66
+ * object — see glob's own types).
67
+ *
68
+ * Only `childrenIgnored` does the extra realpath work, and only for an
69
+ * actual symlink: a plain (non-symlinked) directory can't walk outside a
70
+ * root its own parent is already inside, so resolving its realpath would
71
+ * just be wasted syscalls on every directory in a large tree. This is
72
+ * what stops discovery from following a `testDir -> /` (or any other
73
+ * huge, real) symlinked directory arbitrarily far (QAG-230) — the
74
+ * per-match realpath containment check in expandPaths below still
75
+ * catches an escaping *match*, but only after the walk already paid to
76
+ * get there; this stops the walk itself from ever descending.
77
+ */
78
+ class RepoBoundaryIgnore {
79
+ realRoot;
80
+ inner;
81
+ constructor(patterns, realRoot) {
82
+ this.realRoot = realRoot;
83
+ this.inner = new Ignore(patterns, {});
84
+ }
85
+ ignored(p) {
86
+ return this.inner.ignored(p);
87
+ }
88
+ // Forwarded, not required by any globSync call this package makes today
89
+ // (includeChildMatches defaults to true, which never checks for this) —
90
+ // present anyway so relying on that default doesn't quietly become a
91
+ // hard requirement if it's ever overridden.
92
+ add(pattern) {
93
+ this.inner.add(pattern);
94
+ }
95
+ childrenIgnored(p) {
96
+ if (this.inner.childrenIgnored(p))
97
+ return true;
98
+ if (!p.isSymbolicLink())
99
+ return false;
100
+ return !isAncestorOrSame(this.realRoot, safeRealpath(p.fullpath()));
101
+ }
102
+ }
103
+ /**
104
+ * The literal (non-magic) leading-directory prefix of a raw glob pattern
105
+ * string, resolved against `cwd` — e.g. `/abs/repoB/e2e/*.spec.ts` ->
106
+ * `/abs/repoB/e2e`; `../repoB/e2e/*.spec.ts` -> `<cwd>/../repoB/e2e`
107
+ * (collapsed by path.resolve). Used to find *that pattern's own* repo
108
+ * boundary (QAG-230 round 3): a bare glob-pattern input isn't necessarily
109
+ * anywhere near `cwd`'s own repo — it can be an absolute path, or a
110
+ * relative one that climbs out via `../` — into a completely different
111
+ * repo, and computing containment from cwd's repo root regardless made
112
+ * every match there look like an escape (hard-failing a perfectly legit
113
+ * `['/abs/repoB/e2e/*.spec.ts']` down to 0 files, where the equivalent
114
+ * directory input `../repoB/e2e` already scored fine).
115
+ *
116
+ * Segments are checked with glob's own `hasMagic` — with `magicalBraces`
117
+ * on, since an unexpanded `{a,b}` segment isn't a real, literal directory
118
+ * name any more than a `*` is — so this only ever returns a directory
119
+ * that's guaranteed to be a plain path segment, never a piece the glob
120
+ * engine itself still has to interpret.
121
+ */
122
+ function literalPrefixDir(pattern, cwd) {
123
+ const segments = pattern.split('/');
124
+ const prefix = [];
125
+ let sawMagic = false;
126
+ for (const segment of segments) {
127
+ if (hasMagic(segment, { magicalBraces: true })) {
128
+ sawMagic = true;
129
+ break;
130
+ }
131
+ prefix.push(segment);
132
+ }
133
+ // No magic anywhere in the pattern: it's a literal path (only reachable
134
+ // here because it didn't exist on disk — an existing path is handled by
135
+ // the file/directory branches above) — its directory is the parent of
136
+ // the last (literal) segment, not the whole joined string.
137
+ const dirSegments = sawMagic ? prefix : prefix.slice(0, -1);
138
+ return path.resolve(cwd, dirSegments.join('/') || '.');
139
+ }
58
140
  /**
59
141
  * `explicit`: paths the caller named directly (a literal existing file) —
60
142
  * always scored, regardless of whether they look like a Playwright spec.
@@ -67,6 +149,7 @@ function expandPaths(inputs, cwd) {
67
149
  const explicit = new Set();
68
150
  const expanded = new Set();
69
151
  const scanRootDirs = new Set();
152
+ const configWarnings = [];
70
153
  for (const input of inputs) {
71
154
  const abs = path.isAbsolute(input) ? input : path.resolve(cwd, input);
72
155
  if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
@@ -81,28 +164,220 @@ function expandPaths(inputs, cwd) {
81
164
  // needs this wider boundary, not the narrower common ancestor of
82
165
  // only the matched spec files.
83
166
  scanRootDirs.add(abs);
84
- for (const g of SPEC_GLOBS) {
167
+ // Repo boundary for this scan's per-match realpath containment check
168
+ // and glob-walk pruning below (see the containment check itself, and
169
+ // RepoBoundaryIgnore, for why this must be the *repo root*, not the
170
+ // narrower `abs`): a scan pointed at one monorepo package's specs
171
+ // must still keep an in-repo symlink that resolves to a sibling
172
+ // directory outside that subpath, while still rejecting anything
173
+ // that resolves outside the repo entirely (QAG-230). Starts with the
174
+ // same conservative fallback-to-`abs` findRepoBoundary uses
175
+ // elsewhere in this file; replaced below with the wider boundary a
176
+ // successfully parsed config already computes for testDirEscapesRepo,
177
+ // so both checks share one repo boundary instead of computing it
178
+ // twice.
179
+ let repoRootForContainment = findRepoBoundary(abs, abs);
180
+ // A playwright.config.* at or above this directory, when it can be
181
+ // read statically, is authoritative about what this target itself
182
+ // considers a spec — see playwright-config.ts. Falls back to the
183
+ // filename-suffix SPEC_GLOBS (today's behavior) whenever a config
184
+ // isn't found, doesn't overlap this scan, or can't be parsed
185
+ // statically (a dynamic/unsupported shape) — the last case surfaces
186
+ // a warning rather than silently guessing.
187
+ let globCwd = abs;
188
+ let specGlobs = SPEC_GLOBS;
189
+ let ignoreGlobs = DEFAULT_IGNORE_GLOBS;
190
+ let configApplied = false;
191
+ // Boundary for the transitive-import safety net below — widened to
192
+ // the config's own directory (not `abs`), for the same reason
193
+ // import-graph.ts already widens its own POM boundary past the
194
+ // matched spec files' common ancestor: a fixtures module a spec
195
+ // imports test/expect from commonly lives in a directory *sibling*
196
+ // to testDir (`playwright/fixtures` next to `playwright/tests`), not
197
+ // inside it. Verified against a real config (appsmithorg/appsmith)
198
+ // where every real spec imports `test`/`expect` from `../../fixtures`
199
+ // — bounding resolution to `abs` (testDir itself) made every file
200
+ // fail the check and silently zeroed the whole suite, the same
201
+ // false-negative failure mode this safety net exists to prevent.
202
+ let importSafetyNetBoundary = abs;
203
+ const configFile = findPlaywrightConfig(abs);
204
+ if (configFile) {
205
+ const parsedConfig = parsePlaywrightConfig(configFile);
206
+ if (!parsedConfig) {
207
+ configWarnings.push(`Found ${path.relative(cwd, configFile) || configFile} but could not statically parse testDir/testMatch/testIgnore (a dynamic value or unsupported shape) — falling back to filename-based spec discovery for ${path.relative(cwd, abs) || abs}.`);
208
+ }
209
+ else {
210
+ // Fallback boundary for findRepoBoundary when no .git turns up:
211
+ // the config file's own directory when it sits above `abs`
212
+ // (found by walking up from abs, so it's always an ancestor-or-
213
+ // same of it), else `abs` itself. Never the filesystem root or
214
+ // 20 levels up — see findRepoBoundary's doc comment.
215
+ const repoRoot = findRepoBoundary(abs, path.dirname(configFile));
216
+ repoRootForContainment = repoRoot;
217
+ if (testDirEscapesRepo(parsedConfig.testDirAbs, repoRoot)) {
218
+ // The config's testDir resolves outside the repo boundary
219
+ // (directly, or via a symlink — see testDirEscapesRepo). Its
220
+ // testMatch/testIgnore were written for that escaped testDir,
221
+ // so applying them to the clamped repoRoot instead would be a
222
+ // silent mismatch; fall back to filename-based discovery.
223
+ configWarnings.push(`Found ${path.relative(cwd, configFile) || configFile} but its testDir (${path.relative(cwd, parsedConfig.testDirAbs) || parsedConfig.testDirAbs}) resolves outside the repository (${path.relative(cwd, repoRoot) || repoRoot}) — falling back to filename-based spec discovery for ${path.relative(cwd, abs) || abs}.`);
224
+ }
225
+ else {
226
+ const scopedRoot = resolveConfigScopedRoot(abs, parsedConfig.testDirAbs, repoRoot);
227
+ if (scopedRoot) {
228
+ globCwd = scopedRoot;
229
+ if (parsedConfig.testMatch)
230
+ specGlobs = parsedConfig.testMatch;
231
+ if (parsedConfig.testIgnore) {
232
+ ignoreGlobs = [...DEFAULT_IGNORE_GLOBS, ...parsedConfig.testIgnore];
233
+ }
234
+ configApplied = true;
235
+ importSafetyNetBoundary = path.dirname(configFile);
236
+ }
237
+ }
238
+ }
239
+ }
240
+ const realRepoRoot = safeRealpath(repoRootForContainment);
241
+ for (const g of specGlobs) {
85
242
  for (const f of globSync(g, {
86
- cwd: abs,
243
+ cwd: globCwd,
87
244
  absolute: true,
88
245
  nodir: true,
89
- ignore: DEFAULT_IGNORE_GLOBS,
246
+ // RepoBoundaryIgnore keeps ignoreGlobs' own string-pattern
247
+ // matching (node_modules/, testIgnore, ...) and *additionally*
248
+ // stops the walk from ever descending into a symlinked directory
249
+ // whose realpath escapes realRepoRoot — see its own doc comment.
250
+ // Pruning during the walk (not just filtering matches after the
251
+ // fact, below) is what keeps a `testDir/evil -> /` (or any
252
+ // other huge, real symlinked target) from making discovery
253
+ // actually walk arbitrary parts of the filesystem (QAG-230).
254
+ ignore: new RepoBoundaryIgnore(ignoreGlobs, realRepoRoot),
255
+ // glob's own default for `follow` is already false (don't
256
+ // descend into a symlinked directory while expanding `**`), but
257
+ // this is untrusted-repo territory (see testDirEscapesRepo
258
+ // above), so pin it explicitly rather than relying on a default
259
+ // that could change upstream.
260
+ follow: false,
90
261
  })) {
91
- expanded.add(path.resolve(f));
262
+ const resolved = path.resolve(f);
263
+ // testDirEscapesRepo (above) only clamps testDir itself before
264
+ // it becomes a glob root — it can't see what the glob then
265
+ // actually walks *through* on the way to a match. A testMatch
266
+ // entry that names a symlinked directory by a literal path
267
+ // segment (not a `**` wildcard) still gets followed even with
268
+ // `follow: false` on the globSync call above, since that option
269
+ // only stops `**` from *expanding into* a symlinked directory —
270
+ // it doesn't stop a pattern from naming one explicitly (e.g.
271
+ // `tests/evil -> /` with `testMatch: ['evil/**/*.ts']` walks
272
+ // `/`, and every match still lexically looks like it's under
273
+ // `abs`). The same gap lets an individually symlinked spec file
274
+ // sitting directly in the repo (no testMatch trickery needed)
275
+ // resolve to a file outside it. A lexical containment check
276
+ // can't catch either case — both matches still "look" like they
277
+ // live under `abs` right up until the symlink is resolved — so
278
+ // this compares realpaths on both sides. Checked against the
279
+ // *repo root* (realRepoRoot), not `abs` itself: a scan pointed
280
+ // at one monorepo package's specs must still keep a match that
281
+ // resolves, in-repo, to a sibling directory outside that
282
+ // subpath — only a true escape of the repo itself is dropped
283
+ // here (QAG-230; the config-scoped re-narrowing to `abs` right
284
+ // below handles the "don't silently widen past what the caller
285
+ // asked for" half of this separately).
286
+ if (!isAncestorOrSame(realRepoRoot, safeRealpath(resolved)))
287
+ continue;
288
+ // resolveConfigScopedRoot always globs from testDir (Playwright's
289
+ // own testMatch/testIgnore semantics — see its doc comment), which
290
+ // can be wider than the directory the caller actually pointed
291
+ // `scorePaths` at (a narrower `abs`, e.g. one subproject's specs
292
+ // inside a monorepo testDir that also covers others). Filter back
293
+ // down to what the caller asked for so scoping to testDir for
294
+ // correct path matching never silently widens the scored set.
295
+ if (configApplied && globCwd !== abs) {
296
+ const rel = path.relative(abs, resolved);
297
+ if (rel.startsWith('..') || path.isAbsolute(rel))
298
+ continue;
299
+ }
300
+ // Extra safety net, only once config-based discovery is actually
301
+ // driving this scan: testMatch can still be broader than intended
302
+ // (e.g. a generic '**/*.ts'); a file that imports neither
303
+ // '@playwright/test' directly nor (transitively, through a local
304
+ // module) re-exports it isn't a Playwright spec regardless of
305
+ // what the config's glob matched.
306
+ if (configApplied) {
307
+ let source;
308
+ try {
309
+ source = fs.readFileSync(resolved, 'utf8');
310
+ }
311
+ catch {
312
+ continue;
313
+ }
314
+ if (!importsPlaywrightTestTransitively(resolved, source, importSafetyNetBoundary))
315
+ continue;
316
+ }
317
+ expanded.add(resolved);
92
318
  }
93
319
  }
94
320
  continue;
95
321
  }
96
- // treat as glob
322
+ // treat as glob — a raw pattern the caller passed directly (e.g.
323
+ // `tests/*.spec.ts`), resolved relative to `cwd` rather than a
324
+ // directory this function itself walked. Same realpath containment
325
+ // and walk-pruning as the directory branch above applies here too
326
+ // (QAG-230): a bare glob input is just as capable of naming a
327
+ // symlinked path segment explicitly, and skipping this check here
328
+ // would have made switching from a directory argument to an
329
+ // equivalent glob argument silently bypass it.
330
+ //
331
+ // The boundary itself comes from *this pattern's own* literal prefix
332
+ // directory (literalPrefixDir), not from `cwd` (QAG-230 round 3) — see
333
+ // that function's doc comment for why cwd's repo root is the wrong
334
+ // boundary for a pattern that points somewhere else entirely.
335
+ const prefixDir = literalPrefixDir(input, cwd);
336
+ const repoRootForPattern = safeRealpath(findRepoBoundary(prefixDir, prefixDir));
97
337
  for (const f of globSync(input, {
98
338
  cwd,
99
339
  absolute: true,
100
340
  nodir: true,
101
- ignore: DEFAULT_IGNORE_GLOBS,
341
+ ignore: new RepoBoundaryIgnore(DEFAULT_IGNORE_GLOBS, repoRootForPattern),
342
+ follow: false,
102
343
  })) {
103
- expanded.add(path.resolve(f));
344
+ const resolved = path.resolve(f);
345
+ if (!isAncestorOrSame(repoRootForPattern, safeRealpath(resolved)))
346
+ continue;
347
+ expanded.add(resolved);
104
348
  }
105
349
  }
350
+ // De-dupe by realpath: an in-repo symlinked spec and its real target can
351
+ // both independently match discovery (the symlink itself sitting under
352
+ // one scanned root, its target sitting somewhere else under the same
353
+ // repo) and would otherwise be scored twice, as if they were two
354
+ // different files. Keeps the first lexical path per realpath, in sorted
355
+ // order, so which one "wins" is deterministic rather than depending on
356
+ // Set insertion order. Pre-existing gap, not new to the escape fixes
357
+ // above — those only ever drop a true escape, never a legitimate
358
+ // in-repo duplicate (see CHANGELOG: this can change file counts for a
359
+ // repo that happens to have such a link).
360
+ const firstPathByRealpath = new Map();
361
+ // Seeded with `explicit`'s own realpaths first: an explicit target and a
362
+ // directory scan containing a symlink to that same file are two
363
+ // different literal paths for the same file, so the plain "explicit
364
+ // wins" delete below (which only matches identical literal paths) can't
365
+ // catch that pairing — without this seeding, the symlinked match survives
366
+ // dedup (its realpath hadn't been seen yet) and gets scored a second
367
+ // time alongside the explicit path.
368
+ for (const f of [...explicit].sort()) {
369
+ const real = safeRealpath(f);
370
+ if (!firstPathByRealpath.has(real))
371
+ firstPathByRealpath.set(real, f);
372
+ }
373
+ for (const f of [...expanded].sort()) {
374
+ const real = safeRealpath(f);
375
+ if (!firstPathByRealpath.has(real))
376
+ firstPathByRealpath.set(real, f);
377
+ }
378
+ expanded.clear();
379
+ for (const f of firstPathByRealpath.values())
380
+ expanded.add(f);
106
381
  // A file can be reached both explicitly and via expansion (e.g. an
107
382
  // explicit path plus an overlapping glob); explicit intent wins.
108
383
  for (const f of explicit)
@@ -111,9 +386,10 @@ function expandPaths(inputs, cwd) {
111
386
  explicit: [...explicit].sort(),
112
387
  expanded: [...expanded].sort(),
113
388
  scanRootDirs: [...scanRootDirs].sort(),
389
+ configWarnings,
114
390
  };
115
391
  }
116
- function hardFail(profile, threshold, findings, extra = {}, skippedFiles) {
392
+ function hardFail(profile, threshold, findings, extra = {}, skippedFiles, configWarnings) {
117
393
  return {
118
394
  scoreVersion: 'v4',
119
395
  profile,
@@ -142,6 +418,7 @@ function hardFail(profile, threshold, findings, extra = {}, skippedFiles) {
142
418
  },
143
419
  findings,
144
420
  ...(skippedFiles && skippedFiles.length > 0 ? { skippedFiles } : {}),
421
+ ...(configWarnings && configWarnings.length > 0 ? { configWarnings } : {}),
145
422
  };
146
423
  }
147
424
  /**
@@ -151,7 +428,7 @@ export async function scorePaths(options) {
151
428
  const cwd = options.cwd ?? process.cwd();
152
429
  const profile = options.profile ?? 'standard';
153
430
  const threshold = options.threshold ?? DEFAULT_THRESHOLDS[profile] ?? 80;
154
- const { explicit, expanded, scanRootDirs } = expandPaths(options.paths, cwd);
431
+ const { explicit, expanded, scanRootDirs, configWarnings } = expandPaths(options.paths, cwd);
155
432
  if (explicit.length === 0 && expanded.length === 0) {
156
433
  // Hard-fail: empty match must never look like a healthy suite (was ~99 PASS).
157
434
  return hardFail(profile, threshold, [
@@ -162,7 +439,7 @@ export async function scorePaths(options) {
162
439
  file: cwd,
163
440
  dimension: 'structure',
164
441
  },
165
- ]);
442
+ ], {}, undefined, configWarnings);
166
443
  }
167
444
  // Directory/glob matches are excluded only on positive evidence of a
168
445
  // non-Playwright test framework — see looksLikeNonPlaywrightTest. Paths
@@ -229,11 +506,20 @@ export async function scorePaths(options) {
229
506
  assertFunctionNames.add(name);
230
507
  }
231
508
  }
509
+ // Identifiers bound to a `test.extend(...)`-derived fixture under a
510
+ // custom name (same-file, or imported from a local fixtures module —
511
+ // see test-aliases.ts), so a suite that always declares its tests via
512
+ // `loggedTest(...)` instead of `test(...)` is recognized correctly by
513
+ // both eslint-plugin-playwright's rules (fed via runEslint's settings
514
+ // below) and this package's own AST checks (getTestSpans/countTests,
515
+ // base-plugin.ts).
516
+ const testAliasNames = detectExtendedTestAliases(sources, importedFiles, importBoundary);
232
517
  const eslintFindings = await runEslint({
233
518
  files,
234
519
  profile,
235
520
  cwd,
236
521
  assertFunctionNames: [...assertFunctionNames],
522
+ testAliasNames,
237
523
  });
238
524
  let totalSloc = 0;
239
525
  let totalTests = 0;
@@ -245,12 +531,12 @@ export async function scorePaths(options) {
245
531
  for (const file of files) {
246
532
  const source = sources.get(file) ?? '';
247
533
  const relFile = path.relative(filesBase, file) || path.basename(file);
248
- const m = analyzeSource(source, relFile);
534
+ const m = analyzeSource(source, relFile, testAliasNames);
249
535
  totalSloc += m.sloc;
250
536
  native += m.locators.native;
251
537
  raw += m.locators.raw;
252
538
  metricFindings.push(...m.findings);
253
- const spans = getTestSpans(source, relFile);
539
+ const spans = getTestSpans(source, relFile, testAliasNames);
254
540
  fileSpans.set(relFile, spans);
255
541
  // AST spans are the authoritative test count (they are also the
256
542
  // attribution targets); the regex count is the fallback for a file
@@ -293,5 +579,9 @@ export async function scorePaths(options) {
293
579
  nativeLocators: native,
294
580
  rawLocators: raw,
295
581
  });
296
- return skippedFiles.length > 0 ? { ...result, skippedFiles } : result;
582
+ return {
583
+ ...result,
584
+ ...(skippedFiles.length > 0 ? { skippedFiles } : {}),
585
+ ...(configWarnings.length > 0 ? { configWarnings } : {}),
586
+ };
297
587
  }
package/dist/metrics.d.ts CHANGED
@@ -61,8 +61,14 @@ export interface TestSpans {
61
61
  * for `test.fixme()` used as a statement. Spans nested inside another test
62
62
  * span (rare, but e.g. a helper declaring a test inside a test would be
63
63
  * malformed anyway) are dropped so each line maps to at most one test.
64
+ *
65
+ * `testAliasNames` (see test-aliases.ts) are identifiers bound to a
66
+ * `test.extend(...)`-derived fixture (`const loggedTest = test.extend(...)`,
67
+ * possibly imported from another file) — treated exactly like the literal
68
+ * `test` identifier here, same as `it`. Without this, a suite that always
69
+ * calls its extended fixture under a custom name would count zero tests.
64
70
  */
65
- export declare function getTestSpans(source: string, file?: string): TestSpans;
71
+ export declare function getTestSpans(source: string, file?: string, testAliasNames?: readonly string[]): TestSpans;
66
72
  /**
67
73
  * Model-v3 finding attribution (see score-engine.ts): a finding inside a
68
74
  * test span belongs to that test (`scope: 'test'` + a stable
@@ -75,11 +81,18 @@ export declare function getTestSpans(source: string, file?: string): TestSpans;
75
81
  * Mutates the findings in place and returns them.
76
82
  */
77
83
  export declare function attributeFindingScopes(findings: Finding[], spansByFile: Map<string, TestSpans>): Finding[];
78
- /** Count test( / test.only( / test.skip( / test.fixme( style declarations (approx). */
79
- export declare function countTests(source: string): number;
84
+ /**
85
+ * Count test( / test.only( / test.skip( / test.fixme( style declarations
86
+ * (approx) — the regex fallback used only when `parseSource` can't parse
87
+ * the file (see `getTestSpans`'s AST path, preferred whenever parsing
88
+ * succeeds). `testAliasNames` extends the matched identifier the same way
89
+ * `getTestSpans` does, so a parse-fallback file using an extended-fixture
90
+ * name isn't undercounted either.
91
+ */
92
+ export declare function countTests(source: string, testAliasNames?: readonly string[]): number;
80
93
  export declare function findOversizedFile(source: string, file: string, maxSloc?: number): Finding[];
81
94
  export declare function findEslintDisableComments(source: string, file: string): Finding[];
82
- export declare function analyzeSource(source: string, file: string): {
95
+ export declare function analyzeSource(source: string, file: string, testAliasNames?: readonly string[]): {
83
96
  sloc: number;
84
97
  tests: number;
85
98
  locators: LocatorCounts;
package/dist/metrics.js CHANGED
@@ -334,13 +334,21 @@ function hasFunctionArg(node) {
334
334
  * for `test.fixme()` used as a statement. Spans nested inside another test
335
335
  * span (rare, but e.g. a helper declaring a test inside a test would be
336
336
  * malformed anyway) are dropped so each line maps to at most one test.
337
+ *
338
+ * `testAliasNames` (see test-aliases.ts) are identifiers bound to a
339
+ * `test.extend(...)`-derived fixture (`const loggedTest = test.extend(...)`,
340
+ * possibly imported from another file) — treated exactly like the literal
341
+ * `test` identifier here, same as `it`. Without this, a suite that always
342
+ * calls its extended fixture under a custom name would count zero tests.
337
343
  */
338
- export function getTestSpans(source, file) {
344
+ export function getTestSpans(source, file, testAliasNames = []) {
339
345
  const tests = [];
340
346
  const hooks = [];
341
347
  const ast = parseSource(source, file);
342
348
  if (!ast)
343
349
  return { tests, hooks };
350
+ const aliasSet = new Set(testAliasNames);
351
+ const isTestName = (name) => name === 'test' || name === 'it' || aliasSet.has(name);
344
352
  walk(ast, (node) => {
345
353
  if (node.type !== 'CallExpression')
346
354
  return;
@@ -353,7 +361,7 @@ export function getTestSpans(source, file) {
353
361
  let isHook = false;
354
362
  if (callee?.type === 'Identifier') {
355
363
  const name = callee.name;
356
- if (name === 'test' || name === 'it')
364
+ if (name && isTestName(name))
357
365
  isTest = true;
358
366
  else if (name && HOOK_NAMES.has(name))
359
367
  isHook = true;
@@ -361,7 +369,7 @@ export function getTestSpans(source, file) {
361
369
  else if (callee?.type === 'MemberExpression' && !callee.computed) {
362
370
  const obj = callee.object;
363
371
  const prop = callee.property;
364
- if (obj?.type === 'Identifier' && (obj.name === 'test' || obj.name === 'it') && prop?.type === 'Identifier') {
372
+ if (obj?.type === 'Identifier' && obj.name && isTestName(obj.name) && prop?.type === 'Identifier') {
365
373
  if (TEST_MODIFIERS.has(prop.name ?? ''))
366
374
  isTest = true;
367
375
  else if (HOOK_NAMES.has(prop.name ?? ''))
@@ -422,10 +430,21 @@ export function attributeFindingScopes(findings, spansByFile) {
422
430
  return findings;
423
431
  }
424
432
  const TEST_NON_DECL_RE = /\btest\.(describe|step|beforeEach|afterEach|beforeAll|afterAll|use|setTimeout|info|expect)\b/;
425
- /** Count test( / test.only( / test.skip( / test.fixme( style declarations (approx). */
426
- export function countTests(source) {
433
+ function escapeRegExp(value) {
434
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
435
+ }
436
+ /**
437
+ * Count test( / test.only( / test.skip( / test.fixme( style declarations
438
+ * (approx) — the regex fallback used only when `parseSource` can't parse
439
+ * the file (see `getTestSpans`'s AST path, preferred whenever parsing
440
+ * succeeds). `testAliasNames` extends the matched identifier the same way
441
+ * `getTestSpans` does, so a parse-fallback file using an extended-fixture
442
+ * name isn't undercounted either.
443
+ */
444
+ export function countTests(source, testAliasNames = []) {
427
445
  // Playwright's real modifier API is test.fixme() — not test.fix().
428
- const re = /\btest(?:\.(?:only|skip|fixme|slow))?\s*\(/g;
446
+ const names = ['test', ...testAliasNames].map(escapeRegExp).join('|');
447
+ const re = new RegExp(`\\b(?:${names})(?:\\.(?:only|skip|fixme|slow))?\\s*\\(`, 'g');
429
448
  let count = 0;
430
449
  let m;
431
450
  while ((m = re.exec(source)) !== null) {
@@ -482,10 +501,10 @@ export function findEslintDisableComments(source, file) {
482
501
  }
483
502
  return findings;
484
503
  }
485
- export function analyzeSource(source, file) {
504
+ export function analyzeSource(source, file, testAliasNames = []) {
486
505
  return {
487
506
  sloc: countSloc(source),
488
- tests: countTests(source),
507
+ tests: countTests(source, testAliasNames),
489
508
  locators: countLocators(source, file),
490
509
  // No homegrown "has no assertions" check here: playwright/expect-expect
491
510
  // (a real AST-based community rule, always enabled — see