@qaguardian/playwright-score 2.1.0 → 2.2.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/index.js CHANGED
@@ -1,12 +1,13 @@
1
+ import { triageFindings } from './finding-triage.js';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
- import { globSync } from 'glob';
4
+ import { globSync, hasMagic, Ignore } from 'glob';
4
5
  import { runEslint } from './eslint-runner.js';
5
6
  import { commonAncestorDir } from './fs-util.js';
6
7
  import { collectLocallyImportedFiles } from './import-graph.js';
7
8
  import { analyzeSource, attributeFindingScopes, countLocators, findLocalAssertionHelperNames, getTestSpans, looksLikeNonPlaywrightTest, } from './metrics.js';
8
9
  import { detectExtendedTestAliases } from './test-aliases.js';
9
- import { findPlaywrightConfig, findRepoBoundary, importsPlaywrightTestTransitively, parsePlaywrightConfig, resolveConfigScopedRoot, testDirEscapesRepo, } from './playwright-config.js';
10
+ import { findPlaywrightConfig, findRepoBoundary, importsPlaywrightTestTransitively, isAncestorOrSame, parsePlaywrightConfig, resolveConfigScopedRoot, safeRealpath, testDirEscapesRepo, } from './playwright-config.js';
10
11
  import { DEFAULT_THRESHOLDS } from './profiles.js';
11
12
  import { computeScore } from './score-engine.js';
12
13
  export { computeScore, MODEL, locatorsScore, gradeFromScore, ratioDimensionScore, } from './score-engine.js';
@@ -57,6 +58,86 @@ const DEFAULT_IGNORE_GLOBS = [
57
58
  '**/playwright-report/**',
58
59
  '**/test-results/**',
59
60
  ];
61
+ /**
62
+ * Wraps glob's own default `Ignore` (the string-pattern matcher behind
63
+ * DEFAULT_IGNORE_GLOBS/testIgnore) so this package can *also* prune the
64
+ * walk at a symlinked directory whose realpath escapes `realRoot`,
65
+ * without giving up the string-pattern ignores glob's `ignore` option
66
+ * only accepts one or the other of (a plain string[] XOR an IgnoreLike
67
+ * object — see glob's own types).
68
+ *
69
+ * Only `childrenIgnored` does the extra realpath work, and only for an
70
+ * actual symlink: a plain (non-symlinked) directory can't walk outside a
71
+ * root its own parent is already inside, so resolving its realpath would
72
+ * just be wasted syscalls on every directory in a large tree. This is
73
+ * what stops discovery from following a `testDir -> /` (or any other
74
+ * huge, real) symlinked directory arbitrarily far (QAG-230) — the
75
+ * per-match realpath containment check in expandPaths below still
76
+ * catches an escaping *match*, but only after the walk already paid to
77
+ * get there; this stops the walk itself from ever descending.
78
+ */
79
+ class RepoBoundaryIgnore {
80
+ realRoot;
81
+ inner;
82
+ constructor(patterns, realRoot) {
83
+ this.realRoot = realRoot;
84
+ this.inner = new Ignore(patterns, {});
85
+ }
86
+ ignored(p) {
87
+ return this.inner.ignored(p);
88
+ }
89
+ // Forwarded, not required by any globSync call this package makes today
90
+ // (includeChildMatches defaults to true, which never checks for this) —
91
+ // present anyway so relying on that default doesn't quietly become a
92
+ // hard requirement if it's ever overridden.
93
+ add(pattern) {
94
+ this.inner.add(pattern);
95
+ }
96
+ childrenIgnored(p) {
97
+ if (this.inner.childrenIgnored(p))
98
+ return true;
99
+ if (!p.isSymbolicLink())
100
+ return false;
101
+ return !isAncestorOrSame(this.realRoot, safeRealpath(p.fullpath()));
102
+ }
103
+ }
104
+ /**
105
+ * The literal (non-magic) leading-directory prefix of a raw glob pattern
106
+ * string, resolved against `cwd` — e.g. `/abs/repoB/e2e/*.spec.ts` ->
107
+ * `/abs/repoB/e2e`; `../repoB/e2e/*.spec.ts` -> `<cwd>/../repoB/e2e`
108
+ * (collapsed by path.resolve). Used to find *that pattern's own* repo
109
+ * boundary (QAG-230 round 3): a bare glob-pattern input isn't necessarily
110
+ * anywhere near `cwd`'s own repo — it can be an absolute path, or a
111
+ * relative one that climbs out via `../` — into a completely different
112
+ * repo, and computing containment from cwd's repo root regardless made
113
+ * every match there look like an escape (hard-failing a perfectly legit
114
+ * `['/abs/repoB/e2e/*.spec.ts']` down to 0 files, where the equivalent
115
+ * directory input `../repoB/e2e` already scored fine).
116
+ *
117
+ * Segments are checked with glob's own `hasMagic` — with `magicalBraces`
118
+ * on, since an unexpanded `{a,b}` segment isn't a real, literal directory
119
+ * name any more than a `*` is — so this only ever returns a directory
120
+ * that's guaranteed to be a plain path segment, never a piece the glob
121
+ * engine itself still has to interpret.
122
+ */
123
+ function literalPrefixDir(pattern, cwd) {
124
+ const segments = pattern.split('/');
125
+ const prefix = [];
126
+ let sawMagic = false;
127
+ for (const segment of segments) {
128
+ if (hasMagic(segment, { magicalBraces: true })) {
129
+ sawMagic = true;
130
+ break;
131
+ }
132
+ prefix.push(segment);
133
+ }
134
+ // No magic anywhere in the pattern: it's a literal path (only reachable
135
+ // here because it didn't exist on disk — an existing path is handled by
136
+ // the file/directory branches above) — its directory is the parent of
137
+ // the last (literal) segment, not the whole joined string.
138
+ const dirSegments = sawMagic ? prefix : prefix.slice(0, -1);
139
+ return path.resolve(cwd, dirSegments.join('/') || '.');
140
+ }
60
141
  /**
61
142
  * `explicit`: paths the caller named directly (a literal existing file) —
62
143
  * always scored, regardless of whether they look like a Playwright spec.
@@ -84,6 +165,19 @@ function expandPaths(inputs, cwd) {
84
165
  // needs this wider boundary, not the narrower common ancestor of
85
166
  // only the matched spec files.
86
167
  scanRootDirs.add(abs);
168
+ // Repo boundary for this scan's per-match realpath containment check
169
+ // and glob-walk pruning below (see the containment check itself, and
170
+ // RepoBoundaryIgnore, for why this must be the *repo root*, not the
171
+ // narrower `abs`): a scan pointed at one monorepo package's specs
172
+ // must still keep an in-repo symlink that resolves to a sibling
173
+ // directory outside that subpath, while still rejecting anything
174
+ // that resolves outside the repo entirely (QAG-230). Starts with the
175
+ // same conservative fallback-to-`abs` findRepoBoundary uses
176
+ // elsewhere in this file; replaced below with the wider boundary a
177
+ // successfully parsed config already computes for testDirEscapesRepo,
178
+ // so both checks share one repo boundary instead of computing it
179
+ // twice.
180
+ let repoRootForContainment = findRepoBoundary(abs, abs);
87
181
  // A playwright.config.* at or above this directory, when it can be
88
182
  // read statically, is authoritative about what this target itself
89
183
  // considers a spec — see playwright-config.ts. Falls back to the
@@ -120,13 +214,14 @@ function expandPaths(inputs, cwd) {
120
214
  // same of it), else `abs` itself. Never the filesystem root or
121
215
  // 20 levels up — see findRepoBoundary's doc comment.
122
216
  const repoRoot = findRepoBoundary(abs, path.dirname(configFile));
217
+ repoRootForContainment = repoRoot;
123
218
  if (testDirEscapesRepo(parsedConfig.testDirAbs, repoRoot)) {
124
219
  // The config's testDir resolves outside the repo boundary
125
220
  // (directly, or via a symlink — see testDirEscapesRepo). Its
126
221
  // testMatch/testIgnore were written for that escaped testDir,
127
222
  // so applying them to the clamped repoRoot instead would be a
128
223
  // silent mismatch; fall back to filename-based discovery.
129
- configWarnings.push(`Found ${path.relative(cwd, configFile) || configFile} but its testDir (${parsedConfig.testDirAbs}) resolves outside the repository (${repoRoot}) — falling back to filename-based spec discovery for ${path.relative(cwd, abs) || abs}.`);
224
+ 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}.`);
130
225
  }
131
226
  else {
132
227
  const scopedRoot = resolveConfigScopedRoot(abs, parsedConfig.testDirAbs, repoRoot);
@@ -143,12 +238,21 @@ function expandPaths(inputs, cwd) {
143
238
  }
144
239
  }
145
240
  }
241
+ const realRepoRoot = safeRealpath(repoRootForContainment);
146
242
  for (const g of specGlobs) {
147
243
  for (const f of globSync(g, {
148
244
  cwd: globCwd,
149
245
  absolute: true,
150
246
  nodir: true,
151
- ignore: ignoreGlobs,
247
+ // RepoBoundaryIgnore keeps ignoreGlobs' own string-pattern
248
+ // matching (node_modules/, testIgnore, ...) and *additionally*
249
+ // stops the walk from ever descending into a symlinked directory
250
+ // whose realpath escapes realRepoRoot — see its own doc comment.
251
+ // Pruning during the walk (not just filtering matches after the
252
+ // fact, below) is what keeps a `testDir/evil -> /` (or any
253
+ // other huge, real symlinked target) from making discovery
254
+ // actually walk arbitrary parts of the filesystem (QAG-230).
255
+ ignore: new RepoBoundaryIgnore(ignoreGlobs, realRepoRoot),
152
256
  // glob's own default for `follow` is already false (don't
153
257
  // descend into a symlinked directory while expanding `**`), but
154
258
  // this is untrusted-repo territory (see testDirEscapesRepo
@@ -157,6 +261,31 @@ function expandPaths(inputs, cwd) {
157
261
  follow: false,
158
262
  })) {
159
263
  const resolved = path.resolve(f);
264
+ // testDirEscapesRepo (above) only clamps testDir itself before
265
+ // it becomes a glob root — it can't see what the glob then
266
+ // actually walks *through* on the way to a match. A testMatch
267
+ // entry that names a symlinked directory by a literal path
268
+ // segment (not a `**` wildcard) still gets followed even with
269
+ // `follow: false` on the globSync call above, since that option
270
+ // only stops `**` from *expanding into* a symlinked directory —
271
+ // it doesn't stop a pattern from naming one explicitly (e.g.
272
+ // `tests/evil -> /` with `testMatch: ['evil/**/*.ts']` walks
273
+ // `/`, and every match still lexically looks like it's under
274
+ // `abs`). The same gap lets an individually symlinked spec file
275
+ // sitting directly in the repo (no testMatch trickery needed)
276
+ // resolve to a file outside it. A lexical containment check
277
+ // can't catch either case — both matches still "look" like they
278
+ // live under `abs` right up until the symlink is resolved — so
279
+ // this compares realpaths on both sides. Checked against the
280
+ // *repo root* (realRepoRoot), not `abs` itself: a scan pointed
281
+ // at one monorepo package's specs must still keep a match that
282
+ // resolves, in-repo, to a sibling directory outside that
283
+ // subpath — only a true escape of the repo itself is dropped
284
+ // here (QAG-230; the config-scoped re-narrowing to `abs` right
285
+ // below handles the "don't silently widen past what the caller
286
+ // asked for" half of this separately).
287
+ if (!isAncestorOrSame(realRepoRoot, safeRealpath(resolved)))
288
+ continue;
160
289
  // resolveConfigScopedRoot always globs from testDir (Playwright's
161
290
  // own testMatch/testIgnore semantics — see its doc comment), which
162
291
  // can be wider than the directory the caller actually pointed
@@ -191,16 +320,65 @@ function expandPaths(inputs, cwd) {
191
320
  }
192
321
  continue;
193
322
  }
194
- // treat as glob
323
+ // treat as glob — a raw pattern the caller passed directly (e.g.
324
+ // `tests/*.spec.ts`), resolved relative to `cwd` rather than a
325
+ // directory this function itself walked. Same realpath containment
326
+ // and walk-pruning as the directory branch above applies here too
327
+ // (QAG-230): a bare glob input is just as capable of naming a
328
+ // symlinked path segment explicitly, and skipping this check here
329
+ // would have made switching from a directory argument to an
330
+ // equivalent glob argument silently bypass it.
331
+ //
332
+ // The boundary itself comes from *this pattern's own* literal prefix
333
+ // directory (literalPrefixDir), not from `cwd` (QAG-230 round 3) — see
334
+ // that function's doc comment for why cwd's repo root is the wrong
335
+ // boundary for a pattern that points somewhere else entirely.
336
+ const prefixDir = literalPrefixDir(input, cwd);
337
+ const repoRootForPattern = safeRealpath(findRepoBoundary(prefixDir, prefixDir));
195
338
  for (const f of globSync(input, {
196
339
  cwd,
197
340
  absolute: true,
198
341
  nodir: true,
199
- ignore: DEFAULT_IGNORE_GLOBS,
342
+ ignore: new RepoBoundaryIgnore(DEFAULT_IGNORE_GLOBS, repoRootForPattern),
343
+ follow: false,
200
344
  })) {
201
- expanded.add(path.resolve(f));
345
+ const resolved = path.resolve(f);
346
+ if (!isAncestorOrSame(repoRootForPattern, safeRealpath(resolved)))
347
+ continue;
348
+ expanded.add(resolved);
202
349
  }
203
350
  }
351
+ // De-dupe by realpath: an in-repo symlinked spec and its real target can
352
+ // both independently match discovery (the symlink itself sitting under
353
+ // one scanned root, its target sitting somewhere else under the same
354
+ // repo) and would otherwise be scored twice, as if they were two
355
+ // different files. Keeps the first lexical path per realpath, in sorted
356
+ // order, so which one "wins" is deterministic rather than depending on
357
+ // Set insertion order. Pre-existing gap, not new to the escape fixes
358
+ // above — those only ever drop a true escape, never a legitimate
359
+ // in-repo duplicate (see CHANGELOG: this can change file counts for a
360
+ // repo that happens to have such a link).
361
+ const firstPathByRealpath = new Map();
362
+ // Seeded with `explicit`'s own realpaths first: an explicit target and a
363
+ // directory scan containing a symlink to that same file are two
364
+ // different literal paths for the same file, so the plain "explicit
365
+ // wins" delete below (which only matches identical literal paths) can't
366
+ // catch that pairing — without this seeding, the symlinked match survives
367
+ // dedup (its realpath hadn't been seen yet) and gets scored a second
368
+ // time alongside the explicit path.
369
+ for (const f of [...explicit].sort()) {
370
+ const real = safeRealpath(f);
371
+ if (!firstPathByRealpath.has(real))
372
+ firstPathByRealpath.set(real, f);
373
+ }
374
+ for (const f of [...expanded].sort()) {
375
+ const real = safeRealpath(f);
376
+ if (!firstPathByRealpath.has(real))
377
+ firstPathByRealpath.set(real, f);
378
+ }
379
+ expanded.clear();
380
+ for (const f of firstPathByRealpath.values())
381
+ expanded.add(f);
204
382
  // A file can be reached both explicitly and via expansion (e.g. an
205
383
  // explicit path plus an overlapping glob); explicit intent wins.
206
384
  for (const f of explicit)
@@ -230,6 +408,7 @@ function hardFail(profile, threshold, findings, extra = {}, skippedFiles, config
230
408
  nativeLocators: 0,
231
409
  rawLocators: 0,
232
410
  unassertedTests: 0,
411
+ unverifiedTests: 0,
233
412
  cleanTests: 0,
234
413
  ...extra,
235
414
  },
@@ -337,13 +516,13 @@ export async function scorePaths(options) {
337
516
  // below) and this package's own AST checks (getTestSpans/countTests,
338
517
  // base-plugin.ts).
339
518
  const testAliasNames = detectExtendedTestAliases(sources, importedFiles, importBoundary);
340
- const eslintFindings = await runEslint({
519
+ const eslintFindings = triageFindings(await runEslint({
341
520
  files,
342
521
  profile,
343
522
  cwd,
344
523
  assertFunctionNames: [...assertFunctionNames],
345
524
  testAliasNames,
346
- });
525
+ }), { filesBase, sources, importedFiles, importBoundary });
347
526
  let totalSloc = 0;
348
527
  let totalTests = 0;
349
528
  let native = 0;
package/dist/metrics.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parseForESLint } from '@typescript-eslint/parser';
2
2
  import { countSloc } from './sloc.js';
3
+ import { PLAYWRIGHT_API_METHODS } from './playwright-api-names.js';
3
4
  // A positive "this IS a Playwright spec" check turned out to be unsafe:
4
5
  // Playwright's own custom-fixtures pattern (test/expect re-exported from a
5
6
  // project-local module built with base.extend()) is extremely common in
@@ -269,10 +270,25 @@ export function findLocalAssertionHelperNames(source, file) {
269
270
  if (!ast)
270
271
  return [];
271
272
  const candidates = [];
273
+ // `test.extend({ login: async ({page}, use) => {...} })` entries are
274
+ // fixtures, not callable helpers: an expect in their setup says nothing
275
+ // about whoever uses the name, so they never register as helper names
276
+ // (src/finding-triage.ts resolves a fixture that hands `use()` an
277
+ // asserting callable). Parents are visited before children, so marking
278
+ // the entries when the extend call is seen is early enough.
279
+ const fixtureEntries = new Set();
272
280
  walk(ast, (node) => {
273
281
  let name;
274
282
  let body;
275
- if (node.type === 'FunctionDeclaration') {
283
+ if (node.type === 'CallExpression') {
284
+ const callee = node.callee;
285
+ const arg0 = node.arguments?.[0];
286
+ if (callee?.type === 'MemberExpression' && callee.property?.name === 'extend' && arg0?.type === 'ObjectExpression') {
287
+ for (const p of arg0.properties ?? [])
288
+ fixtureEntries.add(p);
289
+ }
290
+ }
291
+ else if (node.type === 'FunctionDeclaration') {
276
292
  const id = node.id;
277
293
  if (id?.type === 'Identifier') {
278
294
  name = id.name;
@@ -288,6 +304,21 @@ export function findLocalAssertionHelperNames(source, file) {
288
304
  body = init;
289
305
  }
290
306
  }
307
+ else if (
308
+ // `{ checkTab: async () => { await expect(...) } }` — an object-literal
309
+ // method (a fixture factory's returned object, a test.extend entry) or
310
+ // a class property holding an arrow function (`check = async () => {}`).
311
+ (node.type === 'Property' || node.type === 'PropertyDefinition') &&
312
+ !node.computed &&
313
+ !fixtureEntries.has(node)) {
314
+ const key = node.key;
315
+ const value = node.value;
316
+ if (key?.type === 'Identifier' &&
317
+ (value?.type === 'ArrowFunctionExpression' || value?.type === 'FunctionExpression')) {
318
+ name = key.name;
319
+ body = value;
320
+ }
321
+ }
291
322
  else if (node.type === 'MethodDefinition') {
292
323
  const key = node.key;
293
324
  if (!node.computed && key?.type === 'Identifier' && node.kind === 'method') {
@@ -295,7 +326,7 @@ export function findLocalAssertionHelperNames(source, file) {
295
326
  body = node.value;
296
327
  }
297
328
  }
298
- if (name && body)
329
+ if (name && body && !PLAYWRIGHT_API_METHODS.has(name))
299
330
  candidates.push({ name, body });
300
331
  });
301
332
  const names = new Set();
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Method names of the Playwright objects a spec calls directly (Page,
3
+ * Locator, FrameLocator, Frame, APIRequestContext, Keyboard, Mouse,
4
+ * Touchscreen, BrowserContext). A user-defined helper that shares one of
5
+ * these names must never be registered as an "assertion helper": upstream
6
+ * `expect-expect` matches the LAST property of any call chain, so a helper
7
+ * `nav.goto` that asserts would make every `page.goto()` count as an
8
+ * assertion. One exported set, used by metrics.ts and finding-triage.ts.
9
+ */
10
+ export declare const PLAYWRIGHT_API_METHODS: ReadonlySet<string>;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Method names of the Playwright objects a spec calls directly (Page,
3
+ * Locator, FrameLocator, Frame, APIRequestContext, Keyboard, Mouse,
4
+ * Touchscreen, BrowserContext). A user-defined helper that shares one of
5
+ * these names must never be registered as an "assertion helper": upstream
6
+ * `expect-expect` matches the LAST property of any call chain, so a helper
7
+ * `nav.goto` that asserts would make every `page.goto()` count as an
8
+ * assertion. One exported set, used by metrics.ts and finding-triage.ts.
9
+ */
10
+ export const PLAYWRIGHT_API_METHODS = new Set([
11
+ // navigation / page
12
+ 'goto', 'goBack', 'goForward', 'reload', 'close', 'bringToFront', 'evaluate', 'evaluateHandle',
13
+ 'evaluateAll', 'setContent', 'content', 'title', 'url', 'screenshot', 'pdf', 'route', 'unroute',
14
+ 'routeFromHAR', 'on', 'once', 'off', 'addInitScript', 'addScriptTag', 'addStyleTag', 'exposeFunction',
15
+ 'exposeBinding', 'pause', 'frame', 'frames', 'mainFrame', 'opener', 'setViewportSize',
16
+ 'setExtraHTTPHeaders', 'emulateMedia', 'dragAndDrop', 'setDefaultTimeout',
17
+ 'setDefaultNavigationTimeout', 'storageState', 'newPage', 'cookies', 'addCookies', 'clearCookies',
18
+ 'grantPermissions', 'clearPermissions', 'setGeolocation', 'setOffline', 'newContext', 'newCDPSession',
19
+ // locators
20
+ 'locator', 'getByRole', 'getByText', 'getByLabel', 'getByPlaceholder', 'getByAltText', 'getByTitle',
21
+ 'getByTestId', 'frameLocator', 'filter', 'and', 'or', 'first', 'last', 'nth', 'all', 'count',
22
+ 'contentFrame', 'elementHandle', 'elementHandles', 'highlight', 'describe',
23
+ // actions
24
+ 'click', 'dblclick', 'tap', 'fill', 'clear', 'type', 'press', 'pressSequentially', 'check', 'uncheck',
25
+ 'setChecked', 'hover', 'focus', 'blur', 'selectOption', 'selectText', 'setInputFiles', 'dispatchEvent',
26
+ 'scrollIntoViewIfNeeded', 'down', 'up', 'insertText', 'move', 'wheel',
27
+ // reads / waits
28
+ 'textContent', 'innerText', 'innerHTML', 'inputValue', 'getAttribute', 'allTextContents',
29
+ 'allInnerTexts', 'isVisible', 'isHidden', 'isEnabled', 'isDisabled', 'isEditable', 'isChecked',
30
+ 'boundingBox', 'ariaSnapshot', 'waitFor', 'waitForSelector', 'waitForTimeout', 'waitForLoadState',
31
+ 'waitForURL', 'waitForEvent', 'waitForFunction', 'waitForRequest', 'waitForResponse',
32
+ 'waitForNavigation',
33
+ // APIRequestContext
34
+ 'get', 'post', 'put', 'patch', 'delete', 'head', 'fetch', 'dispose',
35
+ ]);
@@ -57,6 +57,19 @@ export interface ParsedPlaywrightConfig {
57
57
  * partial or guessed reading.
58
58
  */
59
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;
60
73
  /**
61
74
  * True when `testDirAbs`, resolved through any symlinks, is NOT inside
62
75
  * (or equal to) `repoRoot`, also resolved through symlinks. A scored
@@ -67,6 +80,19 @@ export declare function parsePlaywrightConfig(configFile: string): ParsedPlaywri
67
80
  * this public tool glob outside its own boundary. Both sides are
68
81
  * realpath'd (never just testDirAbs) because `repoRoot` itself could in
69
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.
70
96
  */
71
97
  export declare function testDirEscapesRepo(testDirAbs: string, repoRoot: string): boolean;
72
98
  /**
@@ -276,14 +276,22 @@ export function parsePlaywrightConfig(configFile) {
276
276
  testIgnore: sawTestIgnoreKey ? testIgnore : undefined,
277
277
  };
278
278
  }
279
- function isAncestorOrSame(ancestor, descendant) {
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) {
280
287
  const rel = path.relative(ancestor, descendant);
281
288
  return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
282
289
  }
283
290
  /** Resolves symlinks; falls back to the input path unchanged when it
284
291
  * doesn't exist on disk (matches the existing "testDir doesn't exist"
285
- * behavior elsewhere in this file rather than throwing). */
286
- function safeRealpath(p) {
292
+ * behavior elsewhere in this file rather than throwing). Exported for the
293
+ * same reason as isAncestorOrSame above. */
294
+ export function safeRealpath(p) {
287
295
  try {
288
296
  return fs.realpathSync(p);
289
297
  }
@@ -301,8 +309,23 @@ function safeRealpath(p) {
301
309
  * this public tool glob outside its own boundary. Both sides are
302
310
  * realpath'd (never just testDirAbs) because `repoRoot` itself could in
303
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.
304
325
  */
305
326
  export function testDirEscapesRepo(testDirAbs, repoRoot) {
327
+ if (!fs.existsSync(testDirAbs))
328
+ return false;
306
329
  const realRepoRoot = safeRealpath(repoRoot);
307
330
  const realTestDirAbs = safeRealpath(testDirAbs);
308
331
  return !isAncestorOrSame(realRepoRoot, realTestDirAbs);
package/dist/profiles.js CHANGED
@@ -52,6 +52,8 @@ export const ESLINT_RULE_MAP = {
52
52
  },
53
53
  // Assertions
54
54
  'playwright/expect-expect': { dimension: 'assertions' },
55
+ // Report-only: see finding-triage.ts. A test that calls into code the scan cannot read.
56
+ 'playwright-score/assertion-unverified': { dimension: 'assertions', severityOverride: 'info', reportOnly: true },
55
57
  // No severityOverride: matches eslint-plugin-playwright's own
56
58
  // recommended severity ('error'), set via eslint-runner.ts's rule config.
57
59
  'playwright/prefer-web-first-assertions': { dimension: 'assertions' },