@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.
@@ -0,0 +1,140 @@
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
+ /** Exported for index.ts's own realpath containment check on matched spec
61
+ * files (see expandPaths) — a config-scoped `testDir`/`testMatch` can be
62
+ * clamped to the repo boundary here, but the glob it drives can still walk
63
+ * through a symlinked directory *inside* that boundary whose real target
64
+ * is outside it (see testDirEscapesRepo's doc comment for why a lexical
65
+ * check alone isn't enough); index.ts needs the same primitive to catch
66
+ * that per-match, not just per-testDir. */
67
+ export declare function isAncestorOrSame(ancestor: string, descendant: string): boolean;
68
+ /** Resolves symlinks; falls back to the input path unchanged when it
69
+ * doesn't exist on disk (matches the existing "testDir doesn't exist"
70
+ * behavior elsewhere in this file rather than throwing). Exported for the
71
+ * same reason as isAncestorOrSame above. */
72
+ export declare function safeRealpath(p: string): string;
73
+ /**
74
+ * True when `testDirAbs`, resolved through any symlinks, is NOT inside
75
+ * (or equal to) `repoRoot`, also resolved through symlinks. A scored
76
+ * repo's config is untrusted input, and a lexical ancestor check alone
77
+ * isn't enough — a `testDir` that lexically sits inside the repo can still
78
+ * be a symlink whose real target is elsewhere on disk (e.g. `tests/` ->
79
+ * `/tmp/somewhere-else`), which would otherwise let a crafted repo make
80
+ * this public tool glob outside its own boundary. Both sides are
81
+ * realpath'd (never just testDirAbs) because `repoRoot` itself could in
82
+ * principle be reached through a symlinked path.
83
+ *
84
+ * A nonexistent `testDirAbs` (nothing to score there — resolveConfigScopedRoot's
85
+ * own existence check downstream already handles that) short-circuits to
86
+ * "doesn't escape" *before* either side is realpath'd: `safeRealpath` only
87
+ * resolves symlinks up to the point where a path stops existing on disk,
88
+ * so a testDir that doesn't exist keeps its literal (unresolved) form
89
+ * while `repoRoot` — which does exist — gets fully resolved. On a host
90
+ * where the repo itself sits under a symlinked prefix (macOS: `/tmp` ->
91
+ * `/private/tmp`), that asymmetry alone made the two sides disagree and
92
+ * fired a false "escapes the repository" warning for a perfectly ordinary
93
+ * missing testDir (QAG-230). Checked with a plain `fs.existsSync`, not
94
+ * `safeRealpath`'s own try/catch, so this stays a pure existence check
95
+ * with no symlink resolution of its own to get wrong.
96
+ */
97
+ export declare function testDirEscapesRepo(testDirAbs: string, repoRoot: string): boolean;
98
+ /**
99
+ * Chooses which directory to actually glob from, given the directory the
100
+ * caller pointed `scorePaths` at (`scanRootAbs`) and the config's resolved
101
+ * `testDir`. Always testDirAbs, never the caller's narrower `scanRootAbs`
102
+ * — Playwright itself unconditionally resolves testMatch/testIgnore
103
+ * relative to testDir, and a testMatch entry is not always a bare
104
+ * basename pattern; it can embed real path segments anchored at testDir
105
+ * (verified against a real config, PostHog/posthog's `playwright/
106
+ * playwright.config.ts`: `testDir: '..'` — the repo root — with
107
+ * `testMatch: ['playwright/e2e/**\/*.spec.ts', 'products/*\/frontend/e2e/
108
+ * **\/*.spec.ts']`; globbing from the caller's narrower `scanRootAbs`
109
+ * instead of testDirAbs made the `playwright/e2e/` segment never match
110
+ * anything, silently zeroing a real, correctly-discovered config). The
111
+ * caller (index.ts) is responsible for filtering the resulting absolute
112
+ * paths back down to its own requested subtree when `scanRootAbs` is
113
+ * narrower than `testDirAbs`, so scoping to testDir here never widens
114
+ * what actually gets scored beyond what the caller asked for.
115
+ *
116
+ * Returns undefined when testDir doesn't exist on disk, or doesn't
117
+ * overlap the requested scan root at all in either direction — the
118
+ * config doesn't describe this scan, so the caller should ignore it
119
+ * entirely rather than force a mismatch.
120
+ *
121
+ * `repoRoot` (see findRepoBoundary) clamps `testDirAbs` before any of the
122
+ * above, via `testDirEscapesRepo` (realpath'd on both sides, so a
123
+ * symlinked `testDir` can't launder an escape either): a scored repo's own
124
+ * config is untrusted input, and a `testDir` that resolves outside the
125
+ * repo (an absolute `'/'`, a relative `'../../..'` that climbs past the
126
+ * repo root, a symlink pointing elsewhere, ...) must never turn into a
127
+ * glob root — that would let a crafted config make this public tool read
128
+ * or scan arbitrary parts of the filesystem it's running on. When
129
+ * testDirAbs falls outside repoRoot, repoRoot itself is used as the
130
+ * effective testDir instead of rejecting the config outright, so a
131
+ * merely-overshooting `testDir` still gets *some* config-scoped result
132
+ * (bounded to the repo) rather than silently falling all the way back to
133
+ * unscoped filename discovery. index.ts additionally treats this
134
+ * escaped-and-clamped case as "the config doesn't cleanly describe this
135
+ * scan" and falls back to filename-based discovery with a configWarning,
136
+ * rather than applying the config's testMatch/testIgnore (written for the
137
+ * escaped testDir) to the clamped repoRoot instead.
138
+ */
139
+ export declare function resolveConfigScopedRoot(scanRootAbs: string, testDirAbs: string, repoRoot: string): string | undefined;
140
+ export declare function importsPlaywrightTestTransitively(file: string, source: string, boundaryDir: string): boolean;
@@ -0,0 +1,543 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseForESLint } from '@typescript-eslint/parser';
4
+ import { resolveRelativeImport } from './import-graph.js';
5
+ // Spec discovery (index.ts's SPEC_GLOBS) matches by filename suffix only —
6
+ // it never reads the scanned repo's own playwright.config.*, so it can't
7
+ // tell a real Playwright suite's *.e2e.ts apart from an unrelated *.spec.ts
8
+ // file sitting in the same directory under a *different* test runner (Ionic
9
+ // Framework's core/: 419 real *.e2e.ts Playwright specs alongside 60 Jest
10
+ // *.spec.ts unit tests, both matched by the default globs). This module
11
+ // reads testDir/testMatch/testIgnore *statically* — parsed as source, never
12
+ // executed (a scored repo's config is untrusted input) — so index.ts can
13
+ // scope discovery to what the target itself considers its test files.
14
+ const CONFIG_FILENAMES = [
15
+ 'playwright.config.ts',
16
+ 'playwright.config.js',
17
+ 'playwright.config.mjs',
18
+ 'playwright.config.cjs',
19
+ ];
20
+ /** How far up from the scan root to look for a config — generous relative
21
+ * to any real monorepo nesting depth, still a hard bound. */
22
+ const MAX_ANCESTOR_LOOKUP = 20;
23
+ /**
24
+ * Walks upward from `startDir` to find this scan's repo boundary — the
25
+ * first ancestor directory that contains a `.git` entry (a directory for a
26
+ * normal clone, a file for a worktree/submodule — either counts as "this is
27
+ * a repo root").
28
+ *
29
+ * When no `.git` turns up within MAX_ANCESTOR_LOOKUP hops or before
30
+ * reaching the filesystem root, the *caller* decides what's safe to fall
31
+ * back to via `fallbackBoundary`:
32
+ *
33
+ * - `findPlaywrightConfig`'s own internal use (below) omits it, keeping
34
+ * this function's legacy behavior of returning whatever directory the
35
+ * climb stopped at. That's fine there — it only ever bounds a loop of
36
+ * cheap `fs.statSync` existence checks for a handful of fixed filenames,
37
+ * never a glob or a file read, so a wide bound doesn't let an untrusted
38
+ * repo make this tool touch anything outside itself.
39
+ * - Every caller that uses the return value as an actual security
40
+ * boundary — most importantly `resolveConfigScopedRoot`'s `repoRoot`,
41
+ * which clamps a config's `testDir` before it becomes a glob root (see
42
+ * its own doc comment) — MUST pass an explicit `fallbackBoundary`, and
43
+ * it must never be the filesystem root or some arbitrary ancestor 20
44
+ * levels up (that would make the "repo boundary" nearly unbounded and
45
+ * defeat the escape checks it exists to support). index.ts passes the
46
+ * scanned directory, or, when a config file was found above it, that
47
+ * config file's own directory — whichever of the two is the ancestor
48
+ * (the config file is always found by walking up from the scan root, so
49
+ * its directory is always at-or-above it).
50
+ */
51
+ export function findRepoBoundary(startDir, fallbackBoundary) {
52
+ let dir = startDir;
53
+ let last = startDir;
54
+ for (let i = 0; i < MAX_ANCESTOR_LOOKUP; i++) {
55
+ last = dir;
56
+ if (fs.existsSync(path.join(dir, '.git')))
57
+ return dir;
58
+ const parent = path.dirname(dir);
59
+ if (parent === dir)
60
+ break; // reached the filesystem root — no .git found
61
+ dir = parent;
62
+ }
63
+ return fallbackBoundary ?? last;
64
+ }
65
+ /**
66
+ * Walks upward from `scanRootAbs` (the directory the caller pointed at, or
67
+ * its own directory tree) looking for a playwright.config.* file "at or
68
+ * above" it — the common real-world shape is a monorepo root config one or
69
+ * more levels above the actual spec directory being scanned. Never searches
70
+ * above the repo boundary (see findRepoBoundary): a config living outside
71
+ * the scanned repo (e.g. a coincidental playwright.config.* sitting in some
72
+ * unrelated ancestor directory on a shared CI runner) must never be picked
73
+ * up and applied to this scan.
74
+ */
75
+ export function findPlaywrightConfig(scanRootAbs) {
76
+ const boundary = findRepoBoundary(scanRootAbs);
77
+ let dir = scanRootAbs;
78
+ for (let i = 0; i < MAX_ANCESTOR_LOOKUP; i++) {
79
+ for (const name of CONFIG_FILENAMES) {
80
+ const candidate = path.join(dir, name);
81
+ try {
82
+ if (fs.statSync(candidate).isFile())
83
+ return candidate;
84
+ }
85
+ catch {
86
+ // doesn't exist — try the next filename/directory
87
+ }
88
+ }
89
+ if (dir === boundary)
90
+ break; // never search above the repo root
91
+ const parent = path.dirname(dir);
92
+ if (parent === dir)
93
+ break;
94
+ dir = parent;
95
+ }
96
+ return undefined;
97
+ }
98
+ function isStringLiteral(node) {
99
+ return !!node && node.type === 'Literal' && typeof node.value === 'string';
100
+ }
101
+ /**
102
+ * A string literal, or an array of only string literals — Playwright's own
103
+ * `testMatch`/`testIgnore` also accept a `RegExp`, which this deliberately
104
+ * does not support (a regex can embed arbitrary logic no static reader
105
+ * should try to evaluate): a config using one is treated as unparseable as
106
+ * a whole (see parsePlaywrightConfig) rather than silently ignoring just
107
+ * that field, so the fallback is obvious rather than a partial, confusing
108
+ * hybrid.
109
+ */
110
+ /**
111
+ * Playwright's own `createFileMatcher` (packages/playwright/src/util.ts)
112
+ * prepends `**\/` to any string pattern that doesn't already start with
113
+ * it, before handing it to minimatch — i.e. a pattern with no leading
114
+ * `**\/` still matches at any depth, not just files directly under
115
+ * testDir. A plain glob library (this package uses `glob`) does NOT do
116
+ * this on its own: `*e2e.spec.ts` only matches a file sitting directly in
117
+ * the glob's cwd, since a single `*` never crosses a `/`. Verified against
118
+ * a real config (payloadcms/payload's `test/playwright.config.ts`:
119
+ * `testMatch: ['*e2e.spec.ts', '*perf.spec.ts']`, every real spec several
120
+ * directories below testDir) that silently matched zero files without
121
+ * this — the exact same failure mode this feature exists to fix, caused
122
+ * by the feature itself. Applied to testIgnore too, since Playwright
123
+ * matches both through the same `createFileMatcher`.
124
+ */
125
+ function toRecursiveGlob(pattern) {
126
+ return pattern.startsWith('**/') ? pattern : `**/${pattern}`;
127
+ }
128
+ function extractStringOrStringArray(node) {
129
+ if (!node)
130
+ return undefined;
131
+ if (isStringLiteral(node))
132
+ return [toRecursiveGlob(node.value)];
133
+ if (node.type === 'ArrayExpression') {
134
+ const elements = node.elements ?? [];
135
+ const values = [];
136
+ for (const el of elements) {
137
+ const candidate = el ?? undefined;
138
+ if (!isStringLiteral(candidate))
139
+ return undefined;
140
+ values.push(toRecursiveGlob(candidate.value));
141
+ }
142
+ return values;
143
+ }
144
+ return undefined;
145
+ }
146
+ /** Finds the plain object literal passed to defineConfig(...), or used
147
+ * directly as `export default {...}` / `module.exports = {...}`. Returns
148
+ * undefined for anything else (a spread, a conditional, a function call
149
+ * result other than defineConfig, a variable reference, ...) — deliberately
150
+ * conservative: a config shape we can't confidently read statically must
151
+ * fall back, never guess. */
152
+ function findConfigObjectLiteral(body) {
153
+ const unwrapToObjectLiteral = (node) => {
154
+ if (!node)
155
+ return undefined;
156
+ if (node.type === 'ObjectExpression')
157
+ return node;
158
+ if (node.type === 'CallExpression') {
159
+ // defineConfig({...}) (any callee shape — named import, namespace
160
+ // access, etc.) — only the single object-literal argument shape is
161
+ // supported; anything else (spread args, no args, a non-object arg)
162
+ // is unparseable.
163
+ const args = node.arguments ?? [];
164
+ if (args.length === 1 && args[0]?.type === 'ObjectExpression')
165
+ return args[0];
166
+ return undefined;
167
+ }
168
+ return undefined;
169
+ };
170
+ for (const node of body) {
171
+ if (node.type === 'ExportDefaultDeclaration') {
172
+ const decl = node.declaration;
173
+ const obj = unwrapToObjectLiteral(decl);
174
+ if (obj)
175
+ return obj;
176
+ }
177
+ if (node.type === 'ExpressionStatement') {
178
+ const expr = node.expression;
179
+ if (expr?.type === 'AssignmentExpression' &&
180
+ expr.left?.type === 'MemberExpression') {
181
+ const left = expr.left;
182
+ const obj = left.object;
183
+ const prop = left.property;
184
+ // module.exports = {...} / module.exports = defineConfig({...})
185
+ if (obj?.type === 'Identifier' && obj.name === 'module' && prop?.type === 'Identifier' && prop.name === 'exports') {
186
+ const right = expr.right;
187
+ const resolved = unwrapToObjectLiteral(right);
188
+ if (resolved)
189
+ return resolved;
190
+ }
191
+ }
192
+ }
193
+ }
194
+ return undefined;
195
+ }
196
+ /**
197
+ * Parses `testDir`/`testMatch`/`testIgnore` out of a playwright.config.*
198
+ * file's source, statically (AST-based, never executed — a scored repo's
199
+ * config is untrusted input, same reasoning as never running its tests).
200
+ * Returns undefined when the shape can't be read with confidence (a
201
+ * dynamic value from `process.env`, a spread, an unsupported top-level
202
+ * export shape, ...) — callers fall back to today's filename-suffix
203
+ * discovery for the whole scan, with a warning, rather than acting on a
204
+ * partial or guessed reading.
205
+ */
206
+ export function parsePlaywrightConfig(configFile) {
207
+ let source;
208
+ try {
209
+ source = fs.readFileSync(configFile, 'utf8');
210
+ }
211
+ catch {
212
+ return undefined;
213
+ }
214
+ let ast;
215
+ try {
216
+ ast = parseForESLint(source, {
217
+ ecmaVersion: 2022,
218
+ sourceType: 'module',
219
+ }).ast;
220
+ }
221
+ catch {
222
+ return undefined;
223
+ }
224
+ const configObject = findConfigObjectLiteral(ast.body ?? []);
225
+ if (!configObject)
226
+ return undefined;
227
+ const properties = configObject.properties ?? [];
228
+ let testDir;
229
+ let testMatch;
230
+ let testIgnore;
231
+ let sawTestMatchKey = false;
232
+ let sawTestIgnoreKey = false;
233
+ for (const prop of properties) {
234
+ // A spread (`...shared`) or computed key makes the object's real shape
235
+ // impossible to read statically — bail on the whole config rather than
236
+ // silently ignoring part of it.
237
+ if (prop.type === 'SpreadElement')
238
+ return undefined;
239
+ if (prop.type !== 'Property')
240
+ continue;
241
+ const computed = prop.computed;
242
+ const key = prop.key;
243
+ if (computed)
244
+ return undefined;
245
+ const keyName = key?.type === 'Identifier'
246
+ ? key.name
247
+ : isStringLiteral(key)
248
+ ? key.value
249
+ : undefined;
250
+ if (!keyName)
251
+ continue;
252
+ const value = prop.value;
253
+ if (keyName === 'testDir') {
254
+ if (!isStringLiteral(value))
255
+ return undefined;
256
+ testDir = value.value;
257
+ }
258
+ else if (keyName === 'testMatch') {
259
+ sawTestMatchKey = true;
260
+ testMatch = extractStringOrStringArray(value);
261
+ if (testMatch === undefined)
262
+ return undefined;
263
+ }
264
+ else if (keyName === 'testIgnore') {
265
+ sawTestIgnoreKey = true;
266
+ testIgnore = extractStringOrStringArray(value);
267
+ if (testIgnore === undefined)
268
+ return undefined;
269
+ }
270
+ }
271
+ const configDir = path.dirname(configFile);
272
+ const testDirAbs = testDir ? path.resolve(configDir, testDir) : configDir;
273
+ return {
274
+ testDirAbs,
275
+ testMatch: sawTestMatchKey ? testMatch : undefined,
276
+ testIgnore: sawTestIgnoreKey ? testIgnore : undefined,
277
+ };
278
+ }
279
+ /** Exported for index.ts's own realpath containment check on matched spec
280
+ * files (see expandPaths) — a config-scoped `testDir`/`testMatch` can be
281
+ * clamped to the repo boundary here, but the glob it drives can still walk
282
+ * through a symlinked directory *inside* that boundary whose real target
283
+ * is outside it (see testDirEscapesRepo's doc comment for why a lexical
284
+ * check alone isn't enough); index.ts needs the same primitive to catch
285
+ * that per-match, not just per-testDir. */
286
+ export function isAncestorOrSame(ancestor, descendant) {
287
+ const rel = path.relative(ancestor, descendant);
288
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
289
+ }
290
+ /** Resolves symlinks; falls back to the input path unchanged when it
291
+ * doesn't exist on disk (matches the existing "testDir doesn't exist"
292
+ * behavior elsewhere in this file rather than throwing). Exported for the
293
+ * same reason as isAncestorOrSame above. */
294
+ export function safeRealpath(p) {
295
+ try {
296
+ return fs.realpathSync(p);
297
+ }
298
+ catch {
299
+ return p;
300
+ }
301
+ }
302
+ /**
303
+ * True when `testDirAbs`, resolved through any symlinks, is NOT inside
304
+ * (or equal to) `repoRoot`, also resolved through symlinks. A scored
305
+ * repo's config is untrusted input, and a lexical ancestor check alone
306
+ * isn't enough — a `testDir` that lexically sits inside the repo can still
307
+ * be a symlink whose real target is elsewhere on disk (e.g. `tests/` ->
308
+ * `/tmp/somewhere-else`), which would otherwise let a crafted repo make
309
+ * this public tool glob outside its own boundary. Both sides are
310
+ * realpath'd (never just testDirAbs) because `repoRoot` itself could in
311
+ * principle be reached through a symlinked path.
312
+ *
313
+ * A nonexistent `testDirAbs` (nothing to score there — resolveConfigScopedRoot's
314
+ * own existence check downstream already handles that) short-circuits to
315
+ * "doesn't escape" *before* either side is realpath'd: `safeRealpath` only
316
+ * resolves symlinks up to the point where a path stops existing on disk,
317
+ * so a testDir that doesn't exist keeps its literal (unresolved) form
318
+ * while `repoRoot` — which does exist — gets fully resolved. On a host
319
+ * where the repo itself sits under a symlinked prefix (macOS: `/tmp` ->
320
+ * `/private/tmp`), that asymmetry alone made the two sides disagree and
321
+ * fired a false "escapes the repository" warning for a perfectly ordinary
322
+ * missing testDir (QAG-230). Checked with a plain `fs.existsSync`, not
323
+ * `safeRealpath`'s own try/catch, so this stays a pure existence check
324
+ * with no symlink resolution of its own to get wrong.
325
+ */
326
+ export function testDirEscapesRepo(testDirAbs, repoRoot) {
327
+ if (!fs.existsSync(testDirAbs))
328
+ return false;
329
+ const realRepoRoot = safeRealpath(repoRoot);
330
+ const realTestDirAbs = safeRealpath(testDirAbs);
331
+ return !isAncestorOrSame(realRepoRoot, realTestDirAbs);
332
+ }
333
+ /**
334
+ * Chooses which directory to actually glob from, given the directory the
335
+ * caller pointed `scorePaths` at (`scanRootAbs`) and the config's resolved
336
+ * `testDir`. Always testDirAbs, never the caller's narrower `scanRootAbs`
337
+ * — Playwright itself unconditionally resolves testMatch/testIgnore
338
+ * relative to testDir, and a testMatch entry is not always a bare
339
+ * basename pattern; it can embed real path segments anchored at testDir
340
+ * (verified against a real config, PostHog/posthog's `playwright/
341
+ * playwright.config.ts`: `testDir: '..'` — the repo root — with
342
+ * `testMatch: ['playwright/e2e/**\/*.spec.ts', 'products/*\/frontend/e2e/
343
+ * **\/*.spec.ts']`; globbing from the caller's narrower `scanRootAbs`
344
+ * instead of testDirAbs made the `playwright/e2e/` segment never match
345
+ * anything, silently zeroing a real, correctly-discovered config). The
346
+ * caller (index.ts) is responsible for filtering the resulting absolute
347
+ * paths back down to its own requested subtree when `scanRootAbs` is
348
+ * narrower than `testDirAbs`, so scoping to testDir here never widens
349
+ * what actually gets scored beyond what the caller asked for.
350
+ *
351
+ * Returns undefined when testDir doesn't exist on disk, or doesn't
352
+ * overlap the requested scan root at all in either direction — the
353
+ * config doesn't describe this scan, so the caller should ignore it
354
+ * entirely rather than force a mismatch.
355
+ *
356
+ * `repoRoot` (see findRepoBoundary) clamps `testDirAbs` before any of the
357
+ * above, via `testDirEscapesRepo` (realpath'd on both sides, so a
358
+ * symlinked `testDir` can't launder an escape either): a scored repo's own
359
+ * config is untrusted input, and a `testDir` that resolves outside the
360
+ * repo (an absolute `'/'`, a relative `'../../..'` that climbs past the
361
+ * repo root, a symlink pointing elsewhere, ...) must never turn into a
362
+ * glob root — that would let a crafted config make this public tool read
363
+ * or scan arbitrary parts of the filesystem it's running on. When
364
+ * testDirAbs falls outside repoRoot, repoRoot itself is used as the
365
+ * effective testDir instead of rejecting the config outright, so a
366
+ * merely-overshooting `testDir` still gets *some* config-scoped result
367
+ * (bounded to the repo) rather than silently falling all the way back to
368
+ * unscoped filename discovery. index.ts additionally treats this
369
+ * escaped-and-clamped case as "the config doesn't cleanly describe this
370
+ * scan" and falls back to filename-based discovery with a configWarning,
371
+ * rather than applying the config's testMatch/testIgnore (written for the
372
+ * escaped testDir) to the clamped repoRoot instead.
373
+ */
374
+ export function resolveConfigScopedRoot(scanRootAbs, testDirAbs, repoRoot) {
375
+ const clampedTestDirAbs = testDirEscapesRepo(testDirAbs, repoRoot) ? repoRoot : testDirAbs;
376
+ if (!isAncestorOrSame(clampedTestDirAbs, scanRootAbs) && !isAncestorOrSame(scanRootAbs, clampedTestDirAbs)) {
377
+ return undefined;
378
+ }
379
+ try {
380
+ if (fs.statSync(clampedTestDirAbs).isDirectory())
381
+ return clampedTestDirAbs;
382
+ }
383
+ catch {
384
+ // testDir doesn't exist on disk — nothing to scope to.
385
+ }
386
+ return undefined;
387
+ }
388
+ const RESOLVE_DEPTH = 4;
389
+ const RESOLVE_FILE_CAP = 100;
390
+ /**
391
+ * True unless `file` has *definitive negative evidence* of not being a
392
+ * Playwright spec: no direct `@playwright/test` import, no relative
393
+ * import at all, or every relative import resolves (on disk) to a file
394
+ * that itself, transitively, doesn't import `@playwright/test` either.
395
+ * Used only as an extra safety net once config-based discovery is already
396
+ * active (see index.ts) — testMatch/testIgnore can still be broader than
397
+ * intended (e.g. a generic `**\/*.ts`), and this catches a plain non-test
398
+ * source file that pattern would otherwise sweep in.
399
+ *
400
+ * Deliberately fails OPEN (keeps the file) whenever a relative import
401
+ * can't be resolved on disk, rather than treating that as negative
402
+ * evidence — same "denylist, not allowlist" philosophy metrics.ts's
403
+ * `looksLikeNonPlaywrightTest` already documents ("a missed rare/unusual
404
+ * file just scores harmlessly, where a falsely-skipped real spec silently
405
+ * stops being scored at all"). This matters in practice, not just in
406
+ * theory: verified against a real config (appsmithorg/appsmith) whose
407
+ * every spec imports `test`/`expect` from a sibling `../../fixtures`
408
+ * module — under `scripts/validate-corpus.sh`'s sparse checkout (only the
409
+ * declared subpath is fetched), that fixtures file genuinely doesn't
410
+ * exist on disk, so resolution legitimately fails even though the import
411
+ * is completely real; a full (non-sparse) checkout, which is what a real
412
+ * customer's CI actually has, would resolve it fine. Treating an
413
+ * unresolvable import as "not a test" turned a correctly-configured real
414
+ * suite into a hard 0-files fail.
415
+ */
416
+ /**
417
+ * Pulls the string-literal specifier out of a CommonJS `require('x')` call
418
+ * expression (any position — a bare `require(...)`, not just a call whose
419
+ * result is assigned to something).
420
+ */
421
+ function requireCallSpecifier(node) {
422
+ if (!node || node.type !== 'CallExpression')
423
+ return undefined;
424
+ const callee = node.callee;
425
+ if (callee?.type !== 'Identifier' || callee.name !== 'require')
426
+ return undefined;
427
+ const args = node.arguments ?? [];
428
+ const arg = args[0];
429
+ if (arg?.type === 'Literal' && typeof arg.value === 'string') {
430
+ return arg.value;
431
+ }
432
+ return undefined;
433
+ }
434
+ /**
435
+ * Top-level-only `require(...)` specifiers in a CommonJS file — mirrors the
436
+ * ES `import`/`export ... from` sources this function already walks at the
437
+ * same (non-nested) depth. Covers the two shapes real fixtures/spec files
438
+ * actually use: `const { test } = require('./x')` and a bare
439
+ * `require('./x')` statement. Verified against a real repo
440
+ * (wekan/wekan's `tests/playwright/`, plain CommonJS `.js` specs/fixtures,
441
+ * no `import` anywhere) that this function silently zeroed to "not a
442
+ * Playwright spec" for every file before this fix — `hasAnyRelativeImport`
443
+ * never became true (no ES import syntax present at all), so the "no
444
+ * @playwright/test found + nothing to resolve" fallback read as definitive
445
+ * negative evidence instead of the true reason: this loop just wasn't
446
+ * looking at CommonJS's own import syntax.
447
+ */
448
+ function requireSpecifiersInBody(body) {
449
+ const specifiers = [];
450
+ for (const node of body) {
451
+ if (node.type === 'ExpressionStatement') {
452
+ const spec = requireCallSpecifier(node.expression);
453
+ if (spec)
454
+ specifiers.push(spec);
455
+ continue;
456
+ }
457
+ if (node.type === 'VariableDeclaration') {
458
+ for (const decl of node.declarations ?? []) {
459
+ const spec = requireCallSpecifier(decl.init);
460
+ if (spec)
461
+ specifiers.push(spec);
462
+ }
463
+ }
464
+ }
465
+ return specifiers;
466
+ }
467
+ export function importsPlaywrightTestTransitively(file, source, boundaryDir) {
468
+ const visited = new Set([file]);
469
+ const queue = [{ file, source, depth: 0 }];
470
+ let checked = 0;
471
+ let hasAnyRelativeImport = false;
472
+ let everyRelativeImportResolved = true;
473
+ while (queue.length > 0 && checked < RESOLVE_FILE_CAP) {
474
+ const next = queue.shift();
475
+ if (!next)
476
+ break;
477
+ checked++;
478
+ let ast;
479
+ try {
480
+ ast = parseForESLint(next.source, {
481
+ ecmaVersion: 2022,
482
+ sourceType: 'module',
483
+ ecmaFeatures: { jsx: /\.(tsx|jsx)$/.test(next.file) },
484
+ }).ast;
485
+ }
486
+ catch {
487
+ // Can't parse this hop — inconclusive, not negative evidence.
488
+ everyRelativeImportResolved = false;
489
+ continue;
490
+ }
491
+ const body = ast.body ?? [];
492
+ const requireSrcs = requireSpecifiersInBody(body);
493
+ for (const node of body) {
494
+ if (node.type !== 'ImportDeclaration' && node.type !== 'ExportNamedDeclaration' && node.type !== 'ExportAllDeclaration') {
495
+ continue;
496
+ }
497
+ const src = node.source?.value;
498
+ if (typeof src !== 'string')
499
+ continue;
500
+ requireSrcs.push(src);
501
+ }
502
+ for (const src of requireSrcs) {
503
+ if (src === '@playwright/test')
504
+ return true;
505
+ if (!src.startsWith('.'))
506
+ continue;
507
+ hasAnyRelativeImport = true;
508
+ if (next.depth >= RESOLVE_DEPTH) {
509
+ everyRelativeImportResolved = false;
510
+ continue;
511
+ }
512
+ const resolved = resolveRelativeImport(next.file, src, boundaryDir);
513
+ if (!resolved) {
514
+ // Doesn't exist on disk (or climbs outside boundaryDir) — could be
515
+ // a real fixtures module missing only because of how this scan was
516
+ // set up (sparse checkout, a narrower boundary than the real
517
+ // project root, ...). Inconclusive, not negative evidence.
518
+ everyRelativeImportResolved = false;
519
+ continue;
520
+ }
521
+ if (visited.has(resolved))
522
+ continue;
523
+ visited.add(resolved);
524
+ let depSource;
525
+ try {
526
+ depSource = fs.readFileSync(resolved, 'utf8');
527
+ }
528
+ catch {
529
+ everyRelativeImportResolved = false;
530
+ continue;
531
+ }
532
+ queue.push({ file: resolved, source: depSource, depth: next.depth + 1 });
533
+ }
534
+ }
535
+ if (checked >= RESOLVE_FILE_CAP && queue.length > 0)
536
+ everyRelativeImportResolved = false;
537
+ // No @playwright/test found anywhere reachable: keep the file unless
538
+ // every relative import chain fully resolved and still came up empty
539
+ // (definitive negative evidence) — a file with no relative imports at
540
+ // all and no @playwright/test import is also definitive negative
541
+ // evidence (nothing left to resolve).
542
+ return hasAnyRelativeImport && !everyRelativeImportResolved;
543
+ }