@qaguardian/playwright-score 2.1.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/CHANGELOG.md +108 -0
- package/dist/index.js +184 -7
- package/dist/playwright-config.d.ts +26 -0
- package/dist/playwright-config.js +26 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,114 @@ parsing accuracy, not grading policy — can still move a suite's score
|
|
|
10
10
|
without itself being a new model version; 2.1.0 below is exactly that
|
|
11
11
|
case, and says so explicitly.
|
|
12
12
|
|
|
13
|
+
## 2.1.1 — 2026-09-26
|
|
14
|
+
|
|
15
|
+
Scoring model stays v4. Reviewer follow-ups from the 2.1.0 release review
|
|
16
|
+
(QAG-230) — none of these blocked that release. They close symlink-escape
|
|
17
|
+
gaps a crafted repo could otherwise use, quiet a false warning, and fix a
|
|
18
|
+
false-positive drop the first round of these same fixes introduced. None
|
|
19
|
+
of that changes what an ordinary suite with no symlinked spec files
|
|
20
|
+
scores; the realpath dedupe fix below is the one exception — it can
|
|
21
|
+
change the file/test count for a suite that has a symlinked spec file
|
|
22
|
+
matching discovery alongside its own real target, and says so.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **A match escaping the *repository* through a symlink is now dropped —
|
|
26
|
+
scoped to the repo root, not the narrower directory the caller pointed
|
|
27
|
+
at.** `testDirEscapesRepo` (added in 2.1.0) only clamps a config's
|
|
28
|
+
`testDir` itself before it becomes a glob root — it couldn't see a
|
|
29
|
+
symlinked directory the glob then walked *through* on the way to an
|
|
30
|
+
individual match. A `testMatch` entry that names a symlinked directory
|
|
31
|
+
by a literal path segment (not a `**` wildcard) is still followed even
|
|
32
|
+
with glob's `follow: false` (that option only stops `**` from
|
|
33
|
+
*expanding into* a symlinked directory, not a pattern from naming one
|
|
34
|
+
explicitly), so a crafted repo (`tests/evil -> /`, `testMatch:
|
|
35
|
+
['evil/**/*.ts']`) could make discovery walk arbitrary parts of the
|
|
36
|
+
filesystem while every match still lexically looked like it lived
|
|
37
|
+
under the scanned directory. The same gap let an individually
|
|
38
|
+
symlinked spec file sitting directly in the repo (no `testMatch`
|
|
39
|
+
trickery needed) resolve to, and get scored from, a file outside it.
|
|
40
|
+
`src/index.ts`'s match loop realpath's every candidate match and drops
|
|
41
|
+
it unless it's still contained within the realpath of the **repository
|
|
42
|
+
root** (`safeRealpath(findRepoBoundary(abs))`) — not, as this package
|
|
43
|
+
first shipped it, the narrower directory the caller actually scanned.
|
|
44
|
+
That first version silently dropped a real, common monorepo shape too:
|
|
45
|
+
a spec reachable through an in-repo symlink that resolves to a sibling
|
|
46
|
+
directory *outside* the caller's scanned subpath (one package's specs
|
|
47
|
+
shared with another via a symlink) is legitimate and is kept; only a
|
|
48
|
+
match whose realpath lands outside the repository itself is dropped.
|
|
49
|
+
The same repo-root containment now also applies to a bare glob-pattern
|
|
50
|
+
input (e.g. `tests/*.spec.ts` passed directly to `scorePaths`, not a
|
|
51
|
+
directory it walks itself) — the first version of this fix only
|
|
52
|
+
covered the directory-scan branch, so switching an otherwise-identical
|
|
53
|
+
call from a directory argument to an equivalent glob argument silently
|
|
54
|
+
bypassed the guard entirely.
|
|
55
|
+
- **The walk itself is now pruned at a symlink that escapes the repo
|
|
56
|
+
root, not just filtered after the fact.** The realpath containment
|
|
57
|
+
check above still has to pay to walk all the way through a symlinked
|
|
58
|
+
directory before dropping every match inside it one by one — for a
|
|
59
|
+
large or slow target (`tests/evil -> /`) that's a real cost, not just
|
|
60
|
+
a theoretical one (measured: ~6.4s to fully walk a large symlinked
|
|
61
|
+
directory before this fix). `RepoBoundaryIgnore` (`src/index.ts`) wraps
|
|
62
|
+
glob's own `ignore` matching with a `childrenIgnored` check that stops
|
|
63
|
+
the walk from ever descending into a symlinked directory whose
|
|
64
|
+
realpath lands outside the repo root, only paying the realpath cost at
|
|
65
|
+
an actual symlink (a plain nested directory can't walk outside a root
|
|
66
|
+
its own parent is already inside, so it's never checked).
|
|
67
|
+
- **An in-repo symlinked spec and its own real target are no longer both
|
|
68
|
+
scored, when both independently match discovery under the same scan.**
|
|
69
|
+
Pre-existing, not new to the fixes above: `expandPaths` deduped a
|
|
70
|
+
literal path only against `explicit` inputs, never against another
|
|
71
|
+
`expanded` match that happens to resolve to the same realpath. A
|
|
72
|
+
symlink sitting alongside its target inside the same scanned directory
|
|
73
|
+
(or reachable via two different scan roots in one `scorePaths` call)
|
|
74
|
+
was counted, and scored, twice. Now deduped by realpath, keeping the
|
|
75
|
+
first lexical path in sorted order. **This can change file/test counts
|
|
76
|
+
for a repo that has such a link** — said plainly here rather than
|
|
77
|
+
folded into "doesn't change what a correctly-shaped suite scores",
|
|
78
|
+
since a suite with this exact shape is exactly what changes.
|
|
79
|
+
- **False "testDir resolves outside the repository" warning on a
|
|
80
|
+
nonexistent `testDir` under a symlinked repo path.** `safeRealpath`
|
|
81
|
+
only resolves the portion of a path that exists on disk, so a
|
|
82
|
+
nonexistent `testDir` kept its literal, unresolved form while
|
|
83
|
+
`repoRoot` (which does exist) got fully resolved — on a host where the
|
|
84
|
+
repo itself sits under a symlinked prefix (macOS: `/tmp` ->
|
|
85
|
+
`/private/tmp`), that asymmetry alone made the two sides disagree and
|
|
86
|
+
fired a false escape warning for an ordinary missing `testDir`.
|
|
87
|
+
`testDirEscapesRepo` now checks existence first and returns "does not
|
|
88
|
+
escape" immediately for a `testDir` that isn't on disk — consistent
|
|
89
|
+
with `resolveConfigScopedRoot`'s own existing "nothing to score there"
|
|
90
|
+
handling for the same case.
|
|
91
|
+
- **Escape warning now prints cwd-relative paths**, matching every other
|
|
92
|
+
`configWarning` in this package, instead of the runner's own absolute
|
|
93
|
+
filesystem paths.
|
|
94
|
+
- **A raw glob-pattern input pointing at a different repo than `cwd` no
|
|
95
|
+
longer hard-fails with 0 matches.** The repo-root containment fix above
|
|
96
|
+
used `cwd`'s own repo boundary for a bare glob pattern regardless of
|
|
97
|
+
where the pattern actually pointed — an absolute pattern into a sibling
|
|
98
|
+
repo (`['/abs/repoB/e2e/*.spec.ts']`), or an equivalent `../`-relative
|
|
99
|
+
one, had every match rejected as an "escape" of `cwd`'s repo, where the
|
|
100
|
+
same target passed as a directory already scored fine. `src/index.ts`'s
|
|
101
|
+
glob-input branch now derives its containment boundary from *the
|
|
102
|
+
pattern's own* literal (non-magic) leading-directory prefix, resolved
|
|
103
|
+
against `cwd`, not from `cwd` itself — a literal prefix that lands
|
|
104
|
+
inside another repo entirely is scored against that repo's own
|
|
105
|
+
boundary, while a symlink an in-bounds prefix still reaches that escapes
|
|
106
|
+
*that* repo is still dropped.
|
|
107
|
+
- **An explicit target and a directory scan that reaches it again through
|
|
108
|
+
a symlink are no longer scored twice.** The realpath dedupe added above
|
|
109
|
+
only ever populated its map from `expanded` matches; an `explicit` file
|
|
110
|
+
and a same-realpath `expanded` symlink are two different literal paths,
|
|
111
|
+
so the separate "explicit wins" step (an exact literal-path delete)
|
|
112
|
+
never caught that pairing. The dedupe map is now seeded with
|
|
113
|
+
`explicit`'s own realpaths first, so a same-realpath `expanded` match is
|
|
114
|
+
recognized as a duplicate instead of surviving as a second, independent
|
|
115
|
+
file.
|
|
116
|
+
|
|
117
|
+
Corpus rerun (100 public repos, directory inputs, run on the round-2 build;
|
|
118
|
+
the round-3 fixes only touch glob-pattern inputs): 96/100 repos score
|
|
119
|
+
identically, 4 moved upstream, 0 changed.
|
|
120
|
+
|
|
13
121
|
## 2.1.0 — 2026-09-24
|
|
14
122
|
|
|
15
123
|
Scoring model stays v4 — nothing here changes a rule's weight or
|
package/dist/index.js
CHANGED
|
@@ -1,12 +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
8
|
import { detectExtendedTestAliases } from './test-aliases.js';
|
|
9
|
-
import { findPlaywrightConfig, findRepoBoundary, importsPlaywrightTestTransitively, parsePlaywrightConfig, resolveConfigScopedRoot, testDirEscapesRepo, } from './playwright-config.js';
|
|
9
|
+
import { findPlaywrightConfig, findRepoBoundary, importsPlaywrightTestTransitively, isAncestorOrSame, parsePlaywrightConfig, resolveConfigScopedRoot, safeRealpath, testDirEscapesRepo, } from './playwright-config.js';
|
|
10
10
|
import { DEFAULT_THRESHOLDS } from './profiles.js';
|
|
11
11
|
import { computeScore } from './score-engine.js';
|
|
12
12
|
export { computeScore, MODEL, locatorsScore, gradeFromScore, ratioDimensionScore, } from './score-engine.js';
|
|
@@ -57,6 +57,86 @@ const DEFAULT_IGNORE_GLOBS = [
|
|
|
57
57
|
'**/playwright-report/**',
|
|
58
58
|
'**/test-results/**',
|
|
59
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
|
+
}
|
|
60
140
|
/**
|
|
61
141
|
* `explicit`: paths the caller named directly (a literal existing file) —
|
|
62
142
|
* always scored, regardless of whether they look like a Playwright spec.
|
|
@@ -84,6 +164,19 @@ function expandPaths(inputs, cwd) {
|
|
|
84
164
|
// needs this wider boundary, not the narrower common ancestor of
|
|
85
165
|
// only the matched spec files.
|
|
86
166
|
scanRootDirs.add(abs);
|
|
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);
|
|
87
180
|
// A playwright.config.* at or above this directory, when it can be
|
|
88
181
|
// read statically, is authoritative about what this target itself
|
|
89
182
|
// considers a spec — see playwright-config.ts. Falls back to the
|
|
@@ -120,13 +213,14 @@ function expandPaths(inputs, cwd) {
|
|
|
120
213
|
// same of it), else `abs` itself. Never the filesystem root or
|
|
121
214
|
// 20 levels up — see findRepoBoundary's doc comment.
|
|
122
215
|
const repoRoot = findRepoBoundary(abs, path.dirname(configFile));
|
|
216
|
+
repoRootForContainment = repoRoot;
|
|
123
217
|
if (testDirEscapesRepo(parsedConfig.testDirAbs, repoRoot)) {
|
|
124
218
|
// The config's testDir resolves outside the repo boundary
|
|
125
219
|
// (directly, or via a symlink — see testDirEscapesRepo). Its
|
|
126
220
|
// testMatch/testIgnore were written for that escaped testDir,
|
|
127
221
|
// so applying them to the clamped repoRoot instead would be a
|
|
128
222
|
// 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}.`);
|
|
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}.`);
|
|
130
224
|
}
|
|
131
225
|
else {
|
|
132
226
|
const scopedRoot = resolveConfigScopedRoot(abs, parsedConfig.testDirAbs, repoRoot);
|
|
@@ -143,12 +237,21 @@ function expandPaths(inputs, cwd) {
|
|
|
143
237
|
}
|
|
144
238
|
}
|
|
145
239
|
}
|
|
240
|
+
const realRepoRoot = safeRealpath(repoRootForContainment);
|
|
146
241
|
for (const g of specGlobs) {
|
|
147
242
|
for (const f of globSync(g, {
|
|
148
243
|
cwd: globCwd,
|
|
149
244
|
absolute: true,
|
|
150
245
|
nodir: true,
|
|
151
|
-
|
|
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),
|
|
152
255
|
// glob's own default for `follow` is already false (don't
|
|
153
256
|
// descend into a symlinked directory while expanding `**`), but
|
|
154
257
|
// this is untrusted-repo territory (see testDirEscapesRepo
|
|
@@ -157,6 +260,31 @@ function expandPaths(inputs, cwd) {
|
|
|
157
260
|
follow: false,
|
|
158
261
|
})) {
|
|
159
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;
|
|
160
288
|
// resolveConfigScopedRoot always globs from testDir (Playwright's
|
|
161
289
|
// own testMatch/testIgnore semantics — see its doc comment), which
|
|
162
290
|
// can be wider than the directory the caller actually pointed
|
|
@@ -191,16 +319,65 @@ function expandPaths(inputs, cwd) {
|
|
|
191
319
|
}
|
|
192
320
|
continue;
|
|
193
321
|
}
|
|
194
|
-
// 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));
|
|
195
337
|
for (const f of globSync(input, {
|
|
196
338
|
cwd,
|
|
197
339
|
absolute: true,
|
|
198
340
|
nodir: true,
|
|
199
|
-
ignore: DEFAULT_IGNORE_GLOBS,
|
|
341
|
+
ignore: new RepoBoundaryIgnore(DEFAULT_IGNORE_GLOBS, repoRootForPattern),
|
|
342
|
+
follow: false,
|
|
200
343
|
})) {
|
|
201
|
-
|
|
344
|
+
const resolved = path.resolve(f);
|
|
345
|
+
if (!isAncestorOrSame(repoRootForPattern, safeRealpath(resolved)))
|
|
346
|
+
continue;
|
|
347
|
+
expanded.add(resolved);
|
|
202
348
|
}
|
|
203
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);
|
|
204
381
|
// A file can be reached both explicitly and via expansion (e.g. an
|
|
205
382
|
// explicit path plus an overlapping glob); explicit intent wins.
|
|
206
383
|
for (const f of explicit)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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/package.json
CHANGED