@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.
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
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Walks upward from `startDir` to find this scan's repo boundary — the
3
+ * first ancestor directory that contains a `.git` entry (a directory for a
4
+ * normal clone, a file for a worktree/submodule — either counts as "this is
5
+ * a repo root").
6
+ *
7
+ * When no `.git` turns up within MAX_ANCESTOR_LOOKUP hops or before
8
+ * reaching the filesystem root, the *caller* decides what's safe to fall
9
+ * back to via `fallbackBoundary`:
10
+ *
11
+ * - `findPlaywrightConfig`'s own internal use (below) omits it, keeping
12
+ * this function's legacy behavior of returning whatever directory the
13
+ * climb stopped at. That's fine there — it only ever bounds a loop of
14
+ * cheap `fs.statSync` existence checks for a handful of fixed filenames,
15
+ * never a glob or a file read, so a wide bound doesn't let an untrusted
16
+ * repo make this tool touch anything outside itself.
17
+ * - Every caller that uses the return value as an actual security
18
+ * boundary — most importantly `resolveConfigScopedRoot`'s `repoRoot`,
19
+ * which clamps a config's `testDir` before it becomes a glob root (see
20
+ * its own doc comment) — MUST pass an explicit `fallbackBoundary`, and
21
+ * it must never be the filesystem root or some arbitrary ancestor 20
22
+ * levels up (that would make the "repo boundary" nearly unbounded and
23
+ * defeat the escape checks it exists to support). index.ts passes the
24
+ * scanned directory, or, when a config file was found above it, that
25
+ * config file's own directory — whichever of the two is the ancestor
26
+ * (the config file is always found by walking up from the scan root, so
27
+ * its directory is always at-or-above it).
28
+ */
29
+ export declare function findRepoBoundary(startDir: string, fallbackBoundary?: string): string;
30
+ /**
31
+ * Walks upward from `scanRootAbs` (the directory the caller pointed at, or
32
+ * its own directory tree) looking for a playwright.config.* file "at or
33
+ * above" it — the common real-world shape is a monorepo root config one or
34
+ * more levels above the actual spec directory being scanned. Never searches
35
+ * above the repo boundary (see findRepoBoundary): a config living outside
36
+ * the scanned repo (e.g. a coincidental playwright.config.* sitting in some
37
+ * unrelated ancestor directory on a shared CI runner) must never be picked
38
+ * up and applied to this scan.
39
+ */
40
+ export declare function findPlaywrightConfig(scanRootAbs: string): string | undefined;
41
+ export interface ParsedPlaywrightConfig {
42
+ /** Resolved absolute path (config's `testDir`, default: the config's own directory). */
43
+ testDirAbs: string;
44
+ /** Glob patterns, relative to testDirAbs — undefined when the config didn't set testMatch (index.ts's own default SPEC_GLOBS still apply). */
45
+ testMatch?: string[];
46
+ /** Glob patterns, relative to testDirAbs — undefined when the config didn't set testIgnore. */
47
+ testIgnore?: string[];
48
+ }
49
+ /**
50
+ * Parses `testDir`/`testMatch`/`testIgnore` out of a playwright.config.*
51
+ * file's source, statically (AST-based, never executed — a scored repo's
52
+ * config is untrusted input, same reasoning as never running its tests).
53
+ * Returns undefined when the shape can't be read with confidence (a
54
+ * dynamic value from `process.env`, a spread, an unsupported top-level
55
+ * export shape, ...) — callers fall back to today's filename-suffix
56
+ * discovery for the whole scan, with a warning, rather than acting on a
57
+ * partial or guessed reading.
58
+ */
59
+ export declare function parsePlaywrightConfig(configFile: string): ParsedPlaywrightConfig | undefined;
60
+ /**
61
+ * True when `testDirAbs`, resolved through any symlinks, is NOT inside
62
+ * (or equal to) `repoRoot`, also resolved through symlinks. A scored
63
+ * repo's config is untrusted input, and a lexical ancestor check alone
64
+ * isn't enough — a `testDir` that lexically sits inside the repo can still
65
+ * be a symlink whose real target is elsewhere on disk (e.g. `tests/` ->
66
+ * `/tmp/somewhere-else`), which would otherwise let a crafted repo make
67
+ * this public tool glob outside its own boundary. Both sides are
68
+ * realpath'd (never just testDirAbs) because `repoRoot` itself could in
69
+ * principle be reached through a symlinked path.
70
+ */
71
+ export declare function testDirEscapesRepo(testDirAbs: string, repoRoot: string): boolean;
72
+ /**
73
+ * Chooses which directory to actually glob from, given the directory the
74
+ * caller pointed `scorePaths` at (`scanRootAbs`) and the config's resolved
75
+ * `testDir`. Always testDirAbs, never the caller's narrower `scanRootAbs`
76
+ * — Playwright itself unconditionally resolves testMatch/testIgnore
77
+ * relative to testDir, and a testMatch entry is not always a bare
78
+ * basename pattern; it can embed real path segments anchored at testDir
79
+ * (verified against a real config, PostHog/posthog's `playwright/
80
+ * playwright.config.ts`: `testDir: '..'` — the repo root — with
81
+ * `testMatch: ['playwright/e2e/**\/*.spec.ts', 'products/*\/frontend/e2e/
82
+ * **\/*.spec.ts']`; globbing from the caller's narrower `scanRootAbs`
83
+ * instead of testDirAbs made the `playwright/e2e/` segment never match
84
+ * anything, silently zeroing a real, correctly-discovered config). The
85
+ * caller (index.ts) is responsible for filtering the resulting absolute
86
+ * paths back down to its own requested subtree when `scanRootAbs` is
87
+ * narrower than `testDirAbs`, so scoping to testDir here never widens
88
+ * what actually gets scored beyond what the caller asked for.
89
+ *
90
+ * Returns undefined when testDir doesn't exist on disk, or doesn't
91
+ * overlap the requested scan root at all in either direction — the
92
+ * config doesn't describe this scan, so the caller should ignore it
93
+ * entirely rather than force a mismatch.
94
+ *
95
+ * `repoRoot` (see findRepoBoundary) clamps `testDirAbs` before any of the
96
+ * above, via `testDirEscapesRepo` (realpath'd on both sides, so a
97
+ * symlinked `testDir` can't launder an escape either): a scored repo's own
98
+ * config is untrusted input, and a `testDir` that resolves outside the
99
+ * repo (an absolute `'/'`, a relative `'../../..'` that climbs past the
100
+ * repo root, a symlink pointing elsewhere, ...) must never turn into a
101
+ * glob root — that would let a crafted config make this public tool read
102
+ * or scan arbitrary parts of the filesystem it's running on. When
103
+ * testDirAbs falls outside repoRoot, repoRoot itself is used as the
104
+ * effective testDir instead of rejecting the config outright, so a
105
+ * merely-overshooting `testDir` still gets *some* config-scoped result
106
+ * (bounded to the repo) rather than silently falling all the way back to
107
+ * unscoped filename discovery. index.ts additionally treats this
108
+ * escaped-and-clamped case as "the config doesn't cleanly describe this
109
+ * scan" and falls back to filename-based discovery with a configWarning,
110
+ * rather than applying the config's testMatch/testIgnore (written for the
111
+ * escaped testDir) to the clamped repoRoot instead.
112
+ */
113
+ export declare function resolveConfigScopedRoot(scanRootAbs: string, testDirAbs: string, repoRoot: string): string | undefined;
114
+ export declare function importsPlaywrightTestTransitively(file: string, source: string, boundaryDir: string): boolean;