@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/CHANGELOG.md +181 -0
- package/METHODOLOGY.md +1 -1
- package/dist/finding-triage.d.ts +13 -0
- package/dist/finding-triage.js +470 -0
- package/dist/formatters/markdown.js +2 -1
- package/dist/formatters/text.js +2 -1
- package/dist/index.js +188 -9
- package/dist/metrics.js +33 -2
- package/dist/playwright-api-names.d.ts +10 -0
- package/dist/playwright-api-names.js +35 -0
- package/dist/playwright-config.d.ts +26 -0
- package/dist/playwright-config.js +26 -3
- package/dist/profiles.js +2 -0
- package/dist/rules/base-plugin.js +170 -1
- package/dist/score-engine.js +1 -0
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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 === '
|
|
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
|
-
|
|
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/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' },
|