@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.
- package/CHANGELOG.md +239 -3
- package/README.md +50 -63
- package/dist/eslint-runner.d.ts +2 -0
- package/dist/eslint-runner.js +22 -4
- package/dist/formatters/markdown.js +8 -0
- package/dist/formatters/text.js +7 -0
- package/dist/import-graph.d.ts +1 -0
- package/dist/import-graph.js +1 -1
- package/dist/index.js +304 -14
- package/dist/metrics.d.ts +17 -4
- package/dist/metrics.js +27 -8
- package/dist/playwright-config.d.ts +140 -0
- package/dist/playwright-config.js +543 -0
- package/dist/rules/base-plugin.js +46 -12
- package/dist/test-aliases.d.ts +18 -0
- package/dist/test-aliases.js +218 -0
- package/dist/types.d.ts +8 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,10 +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
|
+
import { detectExtendedTestAliases } from './test-aliases.js';
|
|
9
|
+
import { findPlaywrightConfig, findRepoBoundary, importsPlaywrightTestTransitively, isAncestorOrSame, parsePlaywrightConfig, resolveConfigScopedRoot, safeRealpath, testDirEscapesRepo, } from './playwright-config.js';
|
|
8
10
|
import { DEFAULT_THRESHOLDS } from './profiles.js';
|
|
9
11
|
import { computeScore } from './score-engine.js';
|
|
10
12
|
export { computeScore, MODEL, locatorsScore, gradeFromScore, ratioDimensionScore, } from './score-engine.js';
|
|
@@ -55,6 +57,86 @@ const DEFAULT_IGNORE_GLOBS = [
|
|
|
55
57
|
'**/playwright-report/**',
|
|
56
58
|
'**/test-results/**',
|
|
57
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
|
+
}
|
|
58
140
|
/**
|
|
59
141
|
* `explicit`: paths the caller named directly (a literal existing file) —
|
|
60
142
|
* always scored, regardless of whether they look like a Playwright spec.
|
|
@@ -67,6 +149,7 @@ function expandPaths(inputs, cwd) {
|
|
|
67
149
|
const explicit = new Set();
|
|
68
150
|
const expanded = new Set();
|
|
69
151
|
const scanRootDirs = new Set();
|
|
152
|
+
const configWarnings = [];
|
|
70
153
|
for (const input of inputs) {
|
|
71
154
|
const abs = path.isAbsolute(input) ? input : path.resolve(cwd, input);
|
|
72
155
|
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
|
|
@@ -81,28 +164,220 @@ function expandPaths(inputs, cwd) {
|
|
|
81
164
|
// needs this wider boundary, not the narrower common ancestor of
|
|
82
165
|
// only the matched spec files.
|
|
83
166
|
scanRootDirs.add(abs);
|
|
84
|
-
for
|
|
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);
|
|
180
|
+
// A playwright.config.* at or above this directory, when it can be
|
|
181
|
+
// read statically, is authoritative about what this target itself
|
|
182
|
+
// considers a spec — see playwright-config.ts. Falls back to the
|
|
183
|
+
// filename-suffix SPEC_GLOBS (today's behavior) whenever a config
|
|
184
|
+
// isn't found, doesn't overlap this scan, or can't be parsed
|
|
185
|
+
// statically (a dynamic/unsupported shape) — the last case surfaces
|
|
186
|
+
// a warning rather than silently guessing.
|
|
187
|
+
let globCwd = abs;
|
|
188
|
+
let specGlobs = SPEC_GLOBS;
|
|
189
|
+
let ignoreGlobs = DEFAULT_IGNORE_GLOBS;
|
|
190
|
+
let configApplied = false;
|
|
191
|
+
// Boundary for the transitive-import safety net below — widened to
|
|
192
|
+
// the config's own directory (not `abs`), for the same reason
|
|
193
|
+
// import-graph.ts already widens its own POM boundary past the
|
|
194
|
+
// matched spec files' common ancestor: a fixtures module a spec
|
|
195
|
+
// imports test/expect from commonly lives in a directory *sibling*
|
|
196
|
+
// to testDir (`playwright/fixtures` next to `playwright/tests`), not
|
|
197
|
+
// inside it. Verified against a real config (appsmithorg/appsmith)
|
|
198
|
+
// where every real spec imports `test`/`expect` from `../../fixtures`
|
|
199
|
+
// — bounding resolution to `abs` (testDir itself) made every file
|
|
200
|
+
// fail the check and silently zeroed the whole suite, the same
|
|
201
|
+
// false-negative failure mode this safety net exists to prevent.
|
|
202
|
+
let importSafetyNetBoundary = abs;
|
|
203
|
+
const configFile = findPlaywrightConfig(abs);
|
|
204
|
+
if (configFile) {
|
|
205
|
+
const parsedConfig = parsePlaywrightConfig(configFile);
|
|
206
|
+
if (!parsedConfig) {
|
|
207
|
+
configWarnings.push(`Found ${path.relative(cwd, configFile) || configFile} but could not statically parse testDir/testMatch/testIgnore (a dynamic value or unsupported shape) — falling back to filename-based spec discovery for ${path.relative(cwd, abs) || abs}.`);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
// Fallback boundary for findRepoBoundary when no .git turns up:
|
|
211
|
+
// the config file's own directory when it sits above `abs`
|
|
212
|
+
// (found by walking up from abs, so it's always an ancestor-or-
|
|
213
|
+
// same of it), else `abs` itself. Never the filesystem root or
|
|
214
|
+
// 20 levels up — see findRepoBoundary's doc comment.
|
|
215
|
+
const repoRoot = findRepoBoundary(abs, path.dirname(configFile));
|
|
216
|
+
repoRootForContainment = repoRoot;
|
|
217
|
+
if (testDirEscapesRepo(parsedConfig.testDirAbs, repoRoot)) {
|
|
218
|
+
// The config's testDir resolves outside the repo boundary
|
|
219
|
+
// (directly, or via a symlink — see testDirEscapesRepo). Its
|
|
220
|
+
// testMatch/testIgnore were written for that escaped testDir,
|
|
221
|
+
// so applying them to the clamped repoRoot instead would be a
|
|
222
|
+
// silent mismatch; fall back to filename-based discovery.
|
|
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}.`);
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
const scopedRoot = resolveConfigScopedRoot(abs, parsedConfig.testDirAbs, repoRoot);
|
|
227
|
+
if (scopedRoot) {
|
|
228
|
+
globCwd = scopedRoot;
|
|
229
|
+
if (parsedConfig.testMatch)
|
|
230
|
+
specGlobs = parsedConfig.testMatch;
|
|
231
|
+
if (parsedConfig.testIgnore) {
|
|
232
|
+
ignoreGlobs = [...DEFAULT_IGNORE_GLOBS, ...parsedConfig.testIgnore];
|
|
233
|
+
}
|
|
234
|
+
configApplied = true;
|
|
235
|
+
importSafetyNetBoundary = path.dirname(configFile);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const realRepoRoot = safeRealpath(repoRootForContainment);
|
|
241
|
+
for (const g of specGlobs) {
|
|
85
242
|
for (const f of globSync(g, {
|
|
86
|
-
cwd:
|
|
243
|
+
cwd: globCwd,
|
|
87
244
|
absolute: true,
|
|
88
245
|
nodir: true,
|
|
89
|
-
|
|
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),
|
|
255
|
+
// glob's own default for `follow` is already false (don't
|
|
256
|
+
// descend into a symlinked directory while expanding `**`), but
|
|
257
|
+
// this is untrusted-repo territory (see testDirEscapesRepo
|
|
258
|
+
// above), so pin it explicitly rather than relying on a default
|
|
259
|
+
// that could change upstream.
|
|
260
|
+
follow: false,
|
|
90
261
|
})) {
|
|
91
|
-
|
|
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;
|
|
288
|
+
// resolveConfigScopedRoot always globs from testDir (Playwright's
|
|
289
|
+
// own testMatch/testIgnore semantics — see its doc comment), which
|
|
290
|
+
// can be wider than the directory the caller actually pointed
|
|
291
|
+
// `scorePaths` at (a narrower `abs`, e.g. one subproject's specs
|
|
292
|
+
// inside a monorepo testDir that also covers others). Filter back
|
|
293
|
+
// down to what the caller asked for so scoping to testDir for
|
|
294
|
+
// correct path matching never silently widens the scored set.
|
|
295
|
+
if (configApplied && globCwd !== abs) {
|
|
296
|
+
const rel = path.relative(abs, resolved);
|
|
297
|
+
if (rel.startsWith('..') || path.isAbsolute(rel))
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
// Extra safety net, only once config-based discovery is actually
|
|
301
|
+
// driving this scan: testMatch can still be broader than intended
|
|
302
|
+
// (e.g. a generic '**/*.ts'); a file that imports neither
|
|
303
|
+
// '@playwright/test' directly nor (transitively, through a local
|
|
304
|
+
// module) re-exports it isn't a Playwright spec regardless of
|
|
305
|
+
// what the config's glob matched.
|
|
306
|
+
if (configApplied) {
|
|
307
|
+
let source;
|
|
308
|
+
try {
|
|
309
|
+
source = fs.readFileSync(resolved, 'utf8');
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (!importsPlaywrightTestTransitively(resolved, source, importSafetyNetBoundary))
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
expanded.add(resolved);
|
|
92
318
|
}
|
|
93
319
|
}
|
|
94
320
|
continue;
|
|
95
321
|
}
|
|
96
|
-
// 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));
|
|
97
337
|
for (const f of globSync(input, {
|
|
98
338
|
cwd,
|
|
99
339
|
absolute: true,
|
|
100
340
|
nodir: true,
|
|
101
|
-
ignore: DEFAULT_IGNORE_GLOBS,
|
|
341
|
+
ignore: new RepoBoundaryIgnore(DEFAULT_IGNORE_GLOBS, repoRootForPattern),
|
|
342
|
+
follow: false,
|
|
102
343
|
})) {
|
|
103
|
-
|
|
344
|
+
const resolved = path.resolve(f);
|
|
345
|
+
if (!isAncestorOrSame(repoRootForPattern, safeRealpath(resolved)))
|
|
346
|
+
continue;
|
|
347
|
+
expanded.add(resolved);
|
|
104
348
|
}
|
|
105
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);
|
|
106
381
|
// A file can be reached both explicitly and via expansion (e.g. an
|
|
107
382
|
// explicit path plus an overlapping glob); explicit intent wins.
|
|
108
383
|
for (const f of explicit)
|
|
@@ -111,9 +386,10 @@ function expandPaths(inputs, cwd) {
|
|
|
111
386
|
explicit: [...explicit].sort(),
|
|
112
387
|
expanded: [...expanded].sort(),
|
|
113
388
|
scanRootDirs: [...scanRootDirs].sort(),
|
|
389
|
+
configWarnings,
|
|
114
390
|
};
|
|
115
391
|
}
|
|
116
|
-
function hardFail(profile, threshold, findings, extra = {}, skippedFiles) {
|
|
392
|
+
function hardFail(profile, threshold, findings, extra = {}, skippedFiles, configWarnings) {
|
|
117
393
|
return {
|
|
118
394
|
scoreVersion: 'v4',
|
|
119
395
|
profile,
|
|
@@ -142,6 +418,7 @@ function hardFail(profile, threshold, findings, extra = {}, skippedFiles) {
|
|
|
142
418
|
},
|
|
143
419
|
findings,
|
|
144
420
|
...(skippedFiles && skippedFiles.length > 0 ? { skippedFiles } : {}),
|
|
421
|
+
...(configWarnings && configWarnings.length > 0 ? { configWarnings } : {}),
|
|
145
422
|
};
|
|
146
423
|
}
|
|
147
424
|
/**
|
|
@@ -151,7 +428,7 @@ export async function scorePaths(options) {
|
|
|
151
428
|
const cwd = options.cwd ?? process.cwd();
|
|
152
429
|
const profile = options.profile ?? 'standard';
|
|
153
430
|
const threshold = options.threshold ?? DEFAULT_THRESHOLDS[profile] ?? 80;
|
|
154
|
-
const { explicit, expanded, scanRootDirs } = expandPaths(options.paths, cwd);
|
|
431
|
+
const { explicit, expanded, scanRootDirs, configWarnings } = expandPaths(options.paths, cwd);
|
|
155
432
|
if (explicit.length === 0 && expanded.length === 0) {
|
|
156
433
|
// Hard-fail: empty match must never look like a healthy suite (was ~99 PASS).
|
|
157
434
|
return hardFail(profile, threshold, [
|
|
@@ -162,7 +439,7 @@ export async function scorePaths(options) {
|
|
|
162
439
|
file: cwd,
|
|
163
440
|
dimension: 'structure',
|
|
164
441
|
},
|
|
165
|
-
]);
|
|
442
|
+
], {}, undefined, configWarnings);
|
|
166
443
|
}
|
|
167
444
|
// Directory/glob matches are excluded only on positive evidence of a
|
|
168
445
|
// non-Playwright test framework — see looksLikeNonPlaywrightTest. Paths
|
|
@@ -229,11 +506,20 @@ export async function scorePaths(options) {
|
|
|
229
506
|
assertFunctionNames.add(name);
|
|
230
507
|
}
|
|
231
508
|
}
|
|
509
|
+
// Identifiers bound to a `test.extend(...)`-derived fixture under a
|
|
510
|
+
// custom name (same-file, or imported from a local fixtures module —
|
|
511
|
+
// see test-aliases.ts), so a suite that always declares its tests via
|
|
512
|
+
// `loggedTest(...)` instead of `test(...)` is recognized correctly by
|
|
513
|
+
// both eslint-plugin-playwright's rules (fed via runEslint's settings
|
|
514
|
+
// below) and this package's own AST checks (getTestSpans/countTests,
|
|
515
|
+
// base-plugin.ts).
|
|
516
|
+
const testAliasNames = detectExtendedTestAliases(sources, importedFiles, importBoundary);
|
|
232
517
|
const eslintFindings = await runEslint({
|
|
233
518
|
files,
|
|
234
519
|
profile,
|
|
235
520
|
cwd,
|
|
236
521
|
assertFunctionNames: [...assertFunctionNames],
|
|
522
|
+
testAliasNames,
|
|
237
523
|
});
|
|
238
524
|
let totalSloc = 0;
|
|
239
525
|
let totalTests = 0;
|
|
@@ -245,12 +531,12 @@ export async function scorePaths(options) {
|
|
|
245
531
|
for (const file of files) {
|
|
246
532
|
const source = sources.get(file) ?? '';
|
|
247
533
|
const relFile = path.relative(filesBase, file) || path.basename(file);
|
|
248
|
-
const m = analyzeSource(source, relFile);
|
|
534
|
+
const m = analyzeSource(source, relFile, testAliasNames);
|
|
249
535
|
totalSloc += m.sloc;
|
|
250
536
|
native += m.locators.native;
|
|
251
537
|
raw += m.locators.raw;
|
|
252
538
|
metricFindings.push(...m.findings);
|
|
253
|
-
const spans = getTestSpans(source, relFile);
|
|
539
|
+
const spans = getTestSpans(source, relFile, testAliasNames);
|
|
254
540
|
fileSpans.set(relFile, spans);
|
|
255
541
|
// AST spans are the authoritative test count (they are also the
|
|
256
542
|
// attribution targets); the regex count is the fallback for a file
|
|
@@ -293,5 +579,9 @@ export async function scorePaths(options) {
|
|
|
293
579
|
nativeLocators: native,
|
|
294
580
|
rawLocators: raw,
|
|
295
581
|
});
|
|
296
|
-
return
|
|
582
|
+
return {
|
|
583
|
+
...result,
|
|
584
|
+
...(skippedFiles.length > 0 ? { skippedFiles } : {}),
|
|
585
|
+
...(configWarnings.length > 0 ? { configWarnings } : {}),
|
|
586
|
+
};
|
|
297
587
|
}
|
package/dist/metrics.d.ts
CHANGED
|
@@ -61,8 +61,14 @@ export interface TestSpans {
|
|
|
61
61
|
* for `test.fixme()` used as a statement. Spans nested inside another test
|
|
62
62
|
* span (rare, but e.g. a helper declaring a test inside a test would be
|
|
63
63
|
* malformed anyway) are dropped so each line maps to at most one test.
|
|
64
|
+
*
|
|
65
|
+
* `testAliasNames` (see test-aliases.ts) are identifiers bound to a
|
|
66
|
+
* `test.extend(...)`-derived fixture (`const loggedTest = test.extend(...)`,
|
|
67
|
+
* possibly imported from another file) — treated exactly like the literal
|
|
68
|
+
* `test` identifier here, same as `it`. Without this, a suite that always
|
|
69
|
+
* calls its extended fixture under a custom name would count zero tests.
|
|
64
70
|
*/
|
|
65
|
-
export declare function getTestSpans(source: string, file?: string): TestSpans;
|
|
71
|
+
export declare function getTestSpans(source: string, file?: string, testAliasNames?: readonly string[]): TestSpans;
|
|
66
72
|
/**
|
|
67
73
|
* Model-v3 finding attribution (see score-engine.ts): a finding inside a
|
|
68
74
|
* test span belongs to that test (`scope: 'test'` + a stable
|
|
@@ -75,11 +81,18 @@ export declare function getTestSpans(source: string, file?: string): TestSpans;
|
|
|
75
81
|
* Mutates the findings in place and returns them.
|
|
76
82
|
*/
|
|
77
83
|
export declare function attributeFindingScopes(findings: Finding[], spansByFile: Map<string, TestSpans>): Finding[];
|
|
78
|
-
/**
|
|
79
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Count test( / test.only( / test.skip( / test.fixme( style declarations
|
|
86
|
+
* (approx) — the regex fallback used only when `parseSource` can't parse
|
|
87
|
+
* the file (see `getTestSpans`'s AST path, preferred whenever parsing
|
|
88
|
+
* succeeds). `testAliasNames` extends the matched identifier the same way
|
|
89
|
+
* `getTestSpans` does, so a parse-fallback file using an extended-fixture
|
|
90
|
+
* name isn't undercounted either.
|
|
91
|
+
*/
|
|
92
|
+
export declare function countTests(source: string, testAliasNames?: readonly string[]): number;
|
|
80
93
|
export declare function findOversizedFile(source: string, file: string, maxSloc?: number): Finding[];
|
|
81
94
|
export declare function findEslintDisableComments(source: string, file: string): Finding[];
|
|
82
|
-
export declare function analyzeSource(source: string, file: string): {
|
|
95
|
+
export declare function analyzeSource(source: string, file: string, testAliasNames?: readonly string[]): {
|
|
83
96
|
sloc: number;
|
|
84
97
|
tests: number;
|
|
85
98
|
locators: LocatorCounts;
|
package/dist/metrics.js
CHANGED
|
@@ -334,13 +334,21 @@ function hasFunctionArg(node) {
|
|
|
334
334
|
* for `test.fixme()` used as a statement. Spans nested inside another test
|
|
335
335
|
* span (rare, but e.g. a helper declaring a test inside a test would be
|
|
336
336
|
* malformed anyway) are dropped so each line maps to at most one test.
|
|
337
|
+
*
|
|
338
|
+
* `testAliasNames` (see test-aliases.ts) are identifiers bound to a
|
|
339
|
+
* `test.extend(...)`-derived fixture (`const loggedTest = test.extend(...)`,
|
|
340
|
+
* possibly imported from another file) — treated exactly like the literal
|
|
341
|
+
* `test` identifier here, same as `it`. Without this, a suite that always
|
|
342
|
+
* calls its extended fixture under a custom name would count zero tests.
|
|
337
343
|
*/
|
|
338
|
-
export function getTestSpans(source, file) {
|
|
344
|
+
export function getTestSpans(source, file, testAliasNames = []) {
|
|
339
345
|
const tests = [];
|
|
340
346
|
const hooks = [];
|
|
341
347
|
const ast = parseSource(source, file);
|
|
342
348
|
if (!ast)
|
|
343
349
|
return { tests, hooks };
|
|
350
|
+
const aliasSet = new Set(testAliasNames);
|
|
351
|
+
const isTestName = (name) => name === 'test' || name === 'it' || aliasSet.has(name);
|
|
344
352
|
walk(ast, (node) => {
|
|
345
353
|
if (node.type !== 'CallExpression')
|
|
346
354
|
return;
|
|
@@ -353,7 +361,7 @@ export function getTestSpans(source, file) {
|
|
|
353
361
|
let isHook = false;
|
|
354
362
|
if (callee?.type === 'Identifier') {
|
|
355
363
|
const name = callee.name;
|
|
356
|
-
if (name
|
|
364
|
+
if (name && isTestName(name))
|
|
357
365
|
isTest = true;
|
|
358
366
|
else if (name && HOOK_NAMES.has(name))
|
|
359
367
|
isHook = true;
|
|
@@ -361,7 +369,7 @@ export function getTestSpans(source, file) {
|
|
|
361
369
|
else if (callee?.type === 'MemberExpression' && !callee.computed) {
|
|
362
370
|
const obj = callee.object;
|
|
363
371
|
const prop = callee.property;
|
|
364
|
-
if (obj?.type === 'Identifier' &&
|
|
372
|
+
if (obj?.type === 'Identifier' && obj.name && isTestName(obj.name) && prop?.type === 'Identifier') {
|
|
365
373
|
if (TEST_MODIFIERS.has(prop.name ?? ''))
|
|
366
374
|
isTest = true;
|
|
367
375
|
else if (HOOK_NAMES.has(prop.name ?? ''))
|
|
@@ -422,10 +430,21 @@ export function attributeFindingScopes(findings, spansByFile) {
|
|
|
422
430
|
return findings;
|
|
423
431
|
}
|
|
424
432
|
const TEST_NON_DECL_RE = /\btest\.(describe|step|beforeEach|afterEach|beforeAll|afterAll|use|setTimeout|info|expect)\b/;
|
|
425
|
-
|
|
426
|
-
|
|
433
|
+
function escapeRegExp(value) {
|
|
434
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Count test( / test.only( / test.skip( / test.fixme( style declarations
|
|
438
|
+
* (approx) — the regex fallback used only when `parseSource` can't parse
|
|
439
|
+
* the file (see `getTestSpans`'s AST path, preferred whenever parsing
|
|
440
|
+
* succeeds). `testAliasNames` extends the matched identifier the same way
|
|
441
|
+
* `getTestSpans` does, so a parse-fallback file using an extended-fixture
|
|
442
|
+
* name isn't undercounted either.
|
|
443
|
+
*/
|
|
444
|
+
export function countTests(source, testAliasNames = []) {
|
|
427
445
|
// Playwright's real modifier API is test.fixme() — not test.fix().
|
|
428
|
-
const
|
|
446
|
+
const names = ['test', ...testAliasNames].map(escapeRegExp).join('|');
|
|
447
|
+
const re = new RegExp(`\\b(?:${names})(?:\\.(?:only|skip|fixme|slow))?\\s*\\(`, 'g');
|
|
429
448
|
let count = 0;
|
|
430
449
|
let m;
|
|
431
450
|
while ((m = re.exec(source)) !== null) {
|
|
@@ -482,10 +501,10 @@ export function findEslintDisableComments(source, file) {
|
|
|
482
501
|
}
|
|
483
502
|
return findings;
|
|
484
503
|
}
|
|
485
|
-
export function analyzeSource(source, file) {
|
|
504
|
+
export function analyzeSource(source, file, testAliasNames = []) {
|
|
486
505
|
return {
|
|
487
506
|
sloc: countSloc(source),
|
|
488
|
-
tests: countTests(source),
|
|
507
|
+
tests: countTests(source, testAliasNames),
|
|
489
508
|
locators: countLocators(source, file),
|
|
490
509
|
// No homegrown "has no assertions" check here: playwright/expect-expect
|
|
491
510
|
// (a real AST-based community rule, always enabled — see
|