@qaguardian/playwright-score 1.0.0 → 2.1.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 +817 -0
- package/METHODOLOGY.md +53 -6
- package/README.md +52 -31
- package/dist/cli.js +8 -2
- package/dist/eslint-runner.d.ts +2 -0
- package/dist/eslint-runner.js +57 -3
- 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 +130 -12
- package/dist/metrics.d.ts +18 -4
- package/dist/metrics.js +59 -9
- package/dist/playwright-config.d.ts +114 -0
- package/dist/playwright-config.js +520 -0
- package/dist/profiles.d.ts +15 -0
- package/dist/profiles.js +34 -0
- package/dist/rules/base-plugin.d.ts +4 -0
- package/dist/rules/base-plugin.js +698 -7
- package/dist/score-engine.d.ts +9 -1
- package/dist/score-engine.js +9 -1
- package/dist/test-aliases.d.ts +18 -0
- package/dist/test-aliases.js +218 -0
- package/dist/types.d.ts +21 -1
- package/package.json +3 -2
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,10 +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
|
-
export declare function
|
|
94
|
+
export declare function findEslintDisableComments(source: string, file: string): Finding[];
|
|
95
|
+
export declare function analyzeSource(source: string, file: string, testAliasNames?: readonly string[]): {
|
|
82
96
|
sloc: number;
|
|
83
97
|
tests: number;
|
|
84
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) {
|
|
@@ -451,10 +470,41 @@ export function findOversizedFile(source, file, maxSloc = 400) {
|
|
|
451
470
|
}
|
|
452
471
|
return [];
|
|
453
472
|
}
|
|
454
|
-
|
|
473
|
+
/**
|
|
474
|
+
* Matches every ESLint suppression-directive comment: `// eslint-disable`,
|
|
475
|
+
* `// eslint-disable-line`, `// eslint-disable-next-line`, and their
|
|
476
|
+
* `/* ... *\/` block-comment equivalents. Deliberately does NOT parse which
|
|
477
|
+
* rule(s) it names or narrow to only our own rule ids — a pre-2.0.0 review
|
|
478
|
+
* found this project's own docs (VALIDATION.md) recommending
|
|
479
|
+
* `eslint-disable-next-line pwscore/no-coordinate-click` as "the correct
|
|
480
|
+
* escape hatch", while every one of this project's rules can in fact be
|
|
481
|
+
* silenced this way, defeating the score entirely for whatever it hides.
|
|
482
|
+
* Reporting the comment itself, without attempting to resolve exactly what
|
|
483
|
+
* it suppresses, is a known-coarse but honest signal — see VALIDATION.md's
|
|
484
|
+
* "Known limitations" for the plan to make it precise. Scored (a real
|
|
485
|
+
* demerit, not report-only) as of 2.0.0/model v4 — see CHANGELOG.md.
|
|
486
|
+
*/
|
|
487
|
+
const ESLINT_DISABLE_COMMENT_RE = /\/[/*]\s*eslint-disable(?:-next-line|-line)?\b/g;
|
|
488
|
+
export function findEslintDisableComments(source, file) {
|
|
489
|
+
const findings = [];
|
|
490
|
+
ESLINT_DISABLE_COMMENT_RE.lastIndex = 0;
|
|
491
|
+
let m;
|
|
492
|
+
while ((m = ESLINT_DISABLE_COMMENT_RE.exec(source)) !== null) {
|
|
493
|
+
findings.push({
|
|
494
|
+
rule: 'pwscore/eslint-disable-comment',
|
|
495
|
+
severity: 'warning',
|
|
496
|
+
message: 'Inline ESLint suppression comment. Disabling a rule hides whatever finding it silences from every tool that runs it, including this scorer — fix the underlying issue, or if the finding is a genuine false positive, report it as a bug in this project rather than relying on a permanent local workaround.',
|
|
497
|
+
file,
|
|
498
|
+
line: source.slice(0, m.index).split('\n').length,
|
|
499
|
+
dimension: 'structure',
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
return findings;
|
|
503
|
+
}
|
|
504
|
+
export function analyzeSource(source, file, testAliasNames = []) {
|
|
455
505
|
return {
|
|
456
506
|
sloc: countSloc(source),
|
|
457
|
-
tests: countTests(source),
|
|
507
|
+
tests: countTests(source, testAliasNames),
|
|
458
508
|
locators: countLocators(source, file),
|
|
459
509
|
// No homegrown "has no assertions" check here: playwright/expect-expect
|
|
460
510
|
// (a real AST-based community rule, always enabled — see
|
|
@@ -465,6 +515,6 @@ export function analyzeSource(source, file) {
|
|
|
465
515
|
// call and so false-positived at error severity on any file whose only
|
|
466
516
|
// assertions were expect.poll()/expect.soft() — verified against real
|
|
467
517
|
// production code.
|
|
468
|
-
findings: [...findOversizedFile(source, file)],
|
|
518
|
+
findings: [...findOversizedFile(source, file), ...findEslintDisableComments(source, file)],
|
|
469
519
|
};
|
|
470
520
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walks upward from `startDir` to find this scan's repo boundary — the
|
|
3
|
+
* first ancestor directory that contains a `.git` entry (a directory for a
|
|
4
|
+
* normal clone, a file for a worktree/submodule — either counts as "this is
|
|
5
|
+
* a repo root").
|
|
6
|
+
*
|
|
7
|
+
* When no `.git` turns up within MAX_ANCESTOR_LOOKUP hops or before
|
|
8
|
+
* reaching the filesystem root, the *caller* decides what's safe to fall
|
|
9
|
+
* back to via `fallbackBoundary`:
|
|
10
|
+
*
|
|
11
|
+
* - `findPlaywrightConfig`'s own internal use (below) omits it, keeping
|
|
12
|
+
* this function's legacy behavior of returning whatever directory the
|
|
13
|
+
* climb stopped at. That's fine there — it only ever bounds a loop of
|
|
14
|
+
* cheap `fs.statSync` existence checks for a handful of fixed filenames,
|
|
15
|
+
* never a glob or a file read, so a wide bound doesn't let an untrusted
|
|
16
|
+
* repo make this tool touch anything outside itself.
|
|
17
|
+
* - Every caller that uses the return value as an actual security
|
|
18
|
+
* boundary — most importantly `resolveConfigScopedRoot`'s `repoRoot`,
|
|
19
|
+
* which clamps a config's `testDir` before it becomes a glob root (see
|
|
20
|
+
* its own doc comment) — MUST pass an explicit `fallbackBoundary`, and
|
|
21
|
+
* it must never be the filesystem root or some arbitrary ancestor 20
|
|
22
|
+
* levels up (that would make the "repo boundary" nearly unbounded and
|
|
23
|
+
* defeat the escape checks it exists to support). index.ts passes the
|
|
24
|
+
* scanned directory, or, when a config file was found above it, that
|
|
25
|
+
* config file's own directory — whichever of the two is the ancestor
|
|
26
|
+
* (the config file is always found by walking up from the scan root, so
|
|
27
|
+
* its directory is always at-or-above it).
|
|
28
|
+
*/
|
|
29
|
+
export declare function findRepoBoundary(startDir: string, fallbackBoundary?: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* Walks upward from `scanRootAbs` (the directory the caller pointed at, or
|
|
32
|
+
* its own directory tree) looking for a playwright.config.* file "at or
|
|
33
|
+
* above" it — the common real-world shape is a monorepo root config one or
|
|
34
|
+
* more levels above the actual spec directory being scanned. Never searches
|
|
35
|
+
* above the repo boundary (see findRepoBoundary): a config living outside
|
|
36
|
+
* the scanned repo (e.g. a coincidental playwright.config.* sitting in some
|
|
37
|
+
* unrelated ancestor directory on a shared CI runner) must never be picked
|
|
38
|
+
* up and applied to this scan.
|
|
39
|
+
*/
|
|
40
|
+
export declare function findPlaywrightConfig(scanRootAbs: string): string | undefined;
|
|
41
|
+
export interface ParsedPlaywrightConfig {
|
|
42
|
+
/** Resolved absolute path (config's `testDir`, default: the config's own directory). */
|
|
43
|
+
testDirAbs: string;
|
|
44
|
+
/** Glob patterns, relative to testDirAbs — undefined when the config didn't set testMatch (index.ts's own default SPEC_GLOBS still apply). */
|
|
45
|
+
testMatch?: string[];
|
|
46
|
+
/** Glob patterns, relative to testDirAbs — undefined when the config didn't set testIgnore. */
|
|
47
|
+
testIgnore?: string[];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Parses `testDir`/`testMatch`/`testIgnore` out of a playwright.config.*
|
|
51
|
+
* file's source, statically (AST-based, never executed — a scored repo's
|
|
52
|
+
* config is untrusted input, same reasoning as never running its tests).
|
|
53
|
+
* Returns undefined when the shape can't be read with confidence (a
|
|
54
|
+
* dynamic value from `process.env`, a spread, an unsupported top-level
|
|
55
|
+
* export shape, ...) — callers fall back to today's filename-suffix
|
|
56
|
+
* discovery for the whole scan, with a warning, rather than acting on a
|
|
57
|
+
* partial or guessed reading.
|
|
58
|
+
*/
|
|
59
|
+
export declare function parsePlaywrightConfig(configFile: string): ParsedPlaywrightConfig | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* True when `testDirAbs`, resolved through any symlinks, is NOT inside
|
|
62
|
+
* (or equal to) `repoRoot`, also resolved through symlinks. A scored
|
|
63
|
+
* repo's config is untrusted input, and a lexical ancestor check alone
|
|
64
|
+
* isn't enough — a `testDir` that lexically sits inside the repo can still
|
|
65
|
+
* be a symlink whose real target is elsewhere on disk (e.g. `tests/` ->
|
|
66
|
+
* `/tmp/somewhere-else`), which would otherwise let a crafted repo make
|
|
67
|
+
* this public tool glob outside its own boundary. Both sides are
|
|
68
|
+
* realpath'd (never just testDirAbs) because `repoRoot` itself could in
|
|
69
|
+
* principle be reached through a symlinked path.
|
|
70
|
+
*/
|
|
71
|
+
export declare function testDirEscapesRepo(testDirAbs: string, repoRoot: string): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Chooses which directory to actually glob from, given the directory the
|
|
74
|
+
* caller pointed `scorePaths` at (`scanRootAbs`) and the config's resolved
|
|
75
|
+
* `testDir`. Always testDirAbs, never the caller's narrower `scanRootAbs`
|
|
76
|
+
* — Playwright itself unconditionally resolves testMatch/testIgnore
|
|
77
|
+
* relative to testDir, and a testMatch entry is not always a bare
|
|
78
|
+
* basename pattern; it can embed real path segments anchored at testDir
|
|
79
|
+
* (verified against a real config, PostHog/posthog's `playwright/
|
|
80
|
+
* playwright.config.ts`: `testDir: '..'` — the repo root — with
|
|
81
|
+
* `testMatch: ['playwright/e2e/**\/*.spec.ts', 'products/*\/frontend/e2e/
|
|
82
|
+
* **\/*.spec.ts']`; globbing from the caller's narrower `scanRootAbs`
|
|
83
|
+
* instead of testDirAbs made the `playwright/e2e/` segment never match
|
|
84
|
+
* anything, silently zeroing a real, correctly-discovered config). The
|
|
85
|
+
* caller (index.ts) is responsible for filtering the resulting absolute
|
|
86
|
+
* paths back down to its own requested subtree when `scanRootAbs` is
|
|
87
|
+
* narrower than `testDirAbs`, so scoping to testDir here never widens
|
|
88
|
+
* what actually gets scored beyond what the caller asked for.
|
|
89
|
+
*
|
|
90
|
+
* Returns undefined when testDir doesn't exist on disk, or doesn't
|
|
91
|
+
* overlap the requested scan root at all in either direction — the
|
|
92
|
+
* config doesn't describe this scan, so the caller should ignore it
|
|
93
|
+
* entirely rather than force a mismatch.
|
|
94
|
+
*
|
|
95
|
+
* `repoRoot` (see findRepoBoundary) clamps `testDirAbs` before any of the
|
|
96
|
+
* above, via `testDirEscapesRepo` (realpath'd on both sides, so a
|
|
97
|
+
* symlinked `testDir` can't launder an escape either): a scored repo's own
|
|
98
|
+
* config is untrusted input, and a `testDir` that resolves outside the
|
|
99
|
+
* repo (an absolute `'/'`, a relative `'../../..'` that climbs past the
|
|
100
|
+
* repo root, a symlink pointing elsewhere, ...) must never turn into a
|
|
101
|
+
* glob root — that would let a crafted config make this public tool read
|
|
102
|
+
* or scan arbitrary parts of the filesystem it's running on. When
|
|
103
|
+
* testDirAbs falls outside repoRoot, repoRoot itself is used as the
|
|
104
|
+
* effective testDir instead of rejecting the config outright, so a
|
|
105
|
+
* merely-overshooting `testDir` still gets *some* config-scoped result
|
|
106
|
+
* (bounded to the repo) rather than silently falling all the way back to
|
|
107
|
+
* unscoped filename discovery. index.ts additionally treats this
|
|
108
|
+
* escaped-and-clamped case as "the config doesn't cleanly describe this
|
|
109
|
+
* scan" and falls back to filename-based discovery with a configWarning,
|
|
110
|
+
* rather than applying the config's testMatch/testIgnore (written for the
|
|
111
|
+
* escaped testDir) to the clamped repoRoot instead.
|
|
112
|
+
*/
|
|
113
|
+
export declare function resolveConfigScopedRoot(scanRootAbs: string, testDirAbs: string, repoRoot: string): string | undefined;
|
|
114
|
+
export declare function importsPlaywrightTestTransitively(file: string, source: string, boundaryDir: string): boolean;
|