@qaguardian/playwright-score 0.1.0 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/metrics.d.ts CHANGED
@@ -1,13 +1,34 @@
1
1
  import type { Finding, LocatorCounts } from './types.js';
2
- /** Count native vs raw locator calls (simple statistical). */
3
- export declare function countLocators(source: string): LocatorCounts;
4
- /** Count test( / test.only( / test.skip( style declarations (approx). */
5
- export declare function countTests(source: string): number;
6
2
  /**
7
- * Detect test callbacks that appear to have zero expect( calls.
8
- * Heuristic: for each test( ... async (...) => { body }, check body for expect.
3
+ * Heuristic: does this file show positive evidence of being a *non*-
4
+ * Playwright test (Jest/Vitest/Mocha/Jasmine/React Testing Library), as
5
+ * opposed to a Playwright spec that a broad `**\/*.test.ts` glob would
6
+ * otherwise sweep in alongside real e2e specs? Used only to decide whether
7
+ * to auto-exclude a file discovered via directory/glob expansion — never
8
+ * applied to a file path the caller named explicitly.
9
+ */
10
+ export declare function looksLikeNonPlaywrightTest(source: string): boolean;
11
+ export declare function countLocators(source: string, file?: string): LocatorCounts;
12
+ /**
13
+ * Names of same-file functions (function declarations, or a const bound to
14
+ * an arrow/function expression) whose own body contains an expect(...)-
15
+ * shaped call anywhere. Fed into playwright/expect-expect's own
16
+ * assertFunctionNames option (see eslint-runner.ts) so a test that
17
+ * delegates its assertion to a locally-defined helper — `audit(page,
18
+ * path)`, `checkFlashMessageVisibility(page, msg)`, `assertDashboardLoaded`
19
+ * — isn't flagged as having no assertions, regardless of what the helper
20
+ * happens to be named. expect-expect can only see expect() calls written
21
+ * directly in the test body, so without this, delegating to a helper at
22
+ * all — an extremely common way to dedupe near-identical specs — reads as
23
+ * "no assertions"; verified against several real production files using
24
+ * several different naming conventions for the exact same shape.
25
+ * Deliberately local-only: a helper imported from another file can't be
26
+ * resolved without following module specifiers and parsing that file too,
27
+ * well beyond what a fast, per-file syntactic check should attempt.
9
28
  */
10
- export declare function findEmptyExpectTests(source: string, file: string): Finding[];
29
+ export declare function findLocalAssertionHelperNames(source: string, file?: string): string[];
30
+ /** Count test( / test.only( / test.skip( / test.fixme( style declarations (approx). */
31
+ export declare function countTests(source: string): number;
11
32
  export declare function findOversizedFile(source: string, file: string, maxSloc?: number): Finding[];
12
33
  export declare function analyzeSource(source: string, file: string): {
13
34
  sloc: number;
package/dist/metrics.js CHANGED
@@ -1,56 +1,237 @@
1
+ import { parseForESLint } from '@typescript-eslint/parser';
1
2
  import { countSloc } from './sloc.js';
2
- const NATIVE_RE = /\.\s*getBy(?:Role|Label|TestId|Text|Placeholder)\s*\(/g;
3
- const RAW_RE = /(?:page|frame)\s*\.\s*locator\s*\(/g;
4
- /** Count native vs raw locator calls (simple statistical). */
5
- export function countLocators(source) {
6
- const native = [...source.matchAll(NATIVE_RE)].length;
7
- const raw = [...source.matchAll(RAW_RE)].length;
3
+ // A positive "this IS a Playwright spec" check turned out to be unsafe:
4
+ // Playwright's own custom-fixtures pattern (test/expect re-exported from a
5
+ // project-local module built with base.extend()) is extremely common in
6
+ // real suites, and the fixtures a test destructures can be named anything
7
+ // (`bomPage`, `authedUser`, ...) — not necessarily the stock page/context/
8
+ // browser/request names. A file that legitimately imports `test`/`expect`
9
+ // from '../fixtures' and destructures `{ bomPage }` has zero syntactic
10
+ // signal distinguishing it from an unrelated framework, so requiring
11
+ // positive Playwright evidence false-negatives on real production specs
12
+ // (verified against real code: it silently skipped 18 of 19 genuine specs
13
+ // in one real suite). Denylisting known non-Playwright frameworks is much
14
+ // safer — a missed rare/unusual file just scores harmlessly, where a
15
+ // falsely-skipped real spec silently stops being scored at all.
16
+ const NON_PLAYWRIGHT_IMPORT_RE = /from\s+['"](?:vitest|jest|@jest\/[a-z-]+|mocha|jasmine|chai|@testing-library\/[a-z-]+)['"]/;
17
+ const RTL_API_RE = /\bscreen\.(?:getBy|queryBy|findBy|getAllBy|queryAllBy|findAllBy)\w*\(|\btoBeInTheDocument\(/;
18
+ /**
19
+ * Heuristic: does this file show positive evidence of being a *non*-
20
+ * Playwright test (Jest/Vitest/Mocha/Jasmine/React Testing Library), as
21
+ * opposed to a Playwright spec that a broad `**\/*.test.ts` glob would
22
+ * otherwise sweep in alongside real e2e specs? Used only to decide whether
23
+ * to auto-exclude a file discovered via directory/glob expansion — never
24
+ * applied to a file path the caller named explicitly.
25
+ */
26
+ export function looksLikeNonPlaywrightTest(source) {
27
+ return NON_PLAYWRIGHT_IMPORT_RE.test(source) || RTL_API_RE.test(source);
28
+ }
29
+ const NATIVE_METHODS = new Set([
30
+ 'getByRole',
31
+ 'getByLabel',
32
+ 'getByTestId',
33
+ 'getByText',
34
+ 'getByPlaceholder',
35
+ ]);
36
+ /**
37
+ * Generic ESTree walk (no visitor-keys dependency). `parent` is excluded so
38
+ * this stays a walk over a DAG even if a parent back-reference is ever
39
+ * attached to the tree upstream.
40
+ */
41
+ function walk(node, visit) {
42
+ if (!node || typeof node !== 'object')
43
+ return;
44
+ if (Array.isArray(node)) {
45
+ for (const item of node)
46
+ walk(item, visit);
47
+ return;
48
+ }
49
+ const obj = node;
50
+ if (typeof obj.type === 'string')
51
+ visit(obj);
52
+ for (const key in obj) {
53
+ if (key === 'parent')
54
+ continue;
55
+ walk(obj[key], visit);
56
+ }
57
+ }
58
+ /**
59
+ * Parses source into an ESTree-compatible AST for our own lightweight
60
+ * AST-walk checks (locator counting, local assertion-helper discovery).
61
+ * Returns undefined on any parse failure — callers degrade to "found
62
+ * nothing" rather than throwing; ESLint's own lint pass over the same
63
+ * file surfaces the real parse error as a finding.
64
+ */
65
+ function parseSource(source, file) {
66
+ try {
67
+ return parseForESLint(source, {
68
+ ecmaVersion: 2022,
69
+ sourceType: 'module',
70
+ // JSX must be extension-driven, not always-on: a bare .ts file can
71
+ // legally use the old-style `<Type>value` cast syntax, which is
72
+ // ambiguous with — and would be misparsed as — a JSX element if JSX
73
+ // were enabled unconditionally.
74
+ ecmaFeatures: { jsx: /\.(tsx|jsx)$/.test(file ?? '') },
75
+ }).ast;
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ }
81
+ /**
82
+ * Count native vs raw locator calls via the same AST the ESLint pass
83
+ * already parses (rather than a regex), so this ratio can't silently
84
+ * disagree with `no-raw-locators` findings. A regex anchored to literal
85
+ * `page`/`frame` receivers misses raw locators on any other receiver —
86
+ * e.g. `this.container.locator(...)` in a Page Object Model, or a second
87
+ * `.locator()` chained off a prior locator call — both common in
88
+ * real-world (and AI-generated) specs.
89
+ */
90
+ // Playwright's older direct-action API takes a raw selector string as the
91
+ // first argument — page.click('#foo') instead of page.locator('#foo').click()
92
+ // or a native locator. This is arguably the single most common raw-selector
93
+ // anti-pattern in naive/AI-generated code (verified against a realistic
94
+ // sample: a file using this exclusively scored a perfect 100 on the
95
+ // locators dimension before this fix). Restricted to methods a Locator
96
+ // itself takes zero required arguments for (click/dblclick/hover/check/
97
+ // uncheck/tap/focus) — real Playwright types never allow a string there on
98
+ // a Locator, so a string first argument unambiguously means the legacy
99
+ // page/frame form regardless of receiver name. fill/type/press/
100
+ // selectOption are deliberately excluded: their first argument is
101
+ // legitimately a string on a Locator too (the value/key, not a selector),
102
+ // so counting them would risk new false positives on correct code.
103
+ const LEGACY_SELECTOR_ACTION_METHODS = new Set([
104
+ 'click',
105
+ 'dblclick',
106
+ 'hover',
107
+ 'check',
108
+ 'uncheck',
109
+ 'tap',
110
+ 'focus',
111
+ ]);
112
+ export function countLocators(source, file) {
113
+ let native = 0;
114
+ let raw = 0;
115
+ const ast = parseSource(source, file);
116
+ if (!ast)
117
+ return { native, raw };
118
+ walk(ast, (node) => {
119
+ if (node.type !== 'CallExpression' ||
120
+ node.callee?.type !== 'MemberExpression') {
121
+ return;
122
+ }
123
+ const callee = node.callee;
124
+ if (callee.computed)
125
+ return;
126
+ const property = callee.property;
127
+ if (property?.type !== 'Identifier')
128
+ return;
129
+ if (NATIVE_METHODS.has(property.name ?? '')) {
130
+ native++;
131
+ }
132
+ else if (property.name === 'locator') {
133
+ raw++;
134
+ }
135
+ else if (LEGACY_SELECTOR_ACTION_METHODS.has(property.name ?? '')) {
136
+ const args = node.arguments;
137
+ const first = args?.[0];
138
+ if (first?.type === 'Literal' && typeof first.value === 'string')
139
+ raw++;
140
+ }
141
+ });
8
142
  return { native, raw };
9
143
  }
10
- /** Count test( / test.only( / test.skip( style declarations (approx). */
144
+ function isExpectCallee(callee) {
145
+ if (!callee)
146
+ return false;
147
+ if (callee.type === 'Identifier')
148
+ return callee.name === 'expect';
149
+ if (callee.type === 'MemberExpression') {
150
+ // Walk down to the root of a member chain, e.g. expect.poll(fn).toBe(x)
151
+ // — the callee of the outer .toBe() call is `expect.poll(fn).toBe`,
152
+ // whose object is the `expect.poll(fn)` CallExpression.
153
+ let obj = callee.object;
154
+ while (obj?.type === 'MemberExpression')
155
+ obj = obj.object;
156
+ if (obj?.type === 'Identifier' && obj.name === 'expect')
157
+ return true;
158
+ if (obj?.type === 'CallExpression') {
159
+ return isExpectCallee(obj.callee);
160
+ }
161
+ }
162
+ return false;
163
+ }
164
+ function containsExpectCall(node) {
165
+ let found = false;
166
+ walk(node, (n) => {
167
+ if (found || n.type !== 'CallExpression')
168
+ return;
169
+ if (isExpectCallee(n.callee))
170
+ found = true;
171
+ });
172
+ return found;
173
+ }
174
+ /**
175
+ * Names of same-file functions (function declarations, or a const bound to
176
+ * an arrow/function expression) whose own body contains an expect(...)-
177
+ * shaped call anywhere. Fed into playwright/expect-expect's own
178
+ * assertFunctionNames option (see eslint-runner.ts) so a test that
179
+ * delegates its assertion to a locally-defined helper — `audit(page,
180
+ * path)`, `checkFlashMessageVisibility(page, msg)`, `assertDashboardLoaded`
181
+ * — isn't flagged as having no assertions, regardless of what the helper
182
+ * happens to be named. expect-expect can only see expect() calls written
183
+ * directly in the test body, so without this, delegating to a helper at
184
+ * all — an extremely common way to dedupe near-identical specs — reads as
185
+ * "no assertions"; verified against several real production files using
186
+ * several different naming conventions for the exact same shape.
187
+ * Deliberately local-only: a helper imported from another file can't be
188
+ * resolved without following module specifiers and parsing that file too,
189
+ * well beyond what a fast, per-file syntactic check should attempt.
190
+ */
191
+ export function findLocalAssertionHelperNames(source, file) {
192
+ const ast = parseSource(source, file);
193
+ if (!ast)
194
+ return [];
195
+ const names = new Set();
196
+ walk(ast, (node) => {
197
+ let name;
198
+ let body;
199
+ if (node.type === 'FunctionDeclaration') {
200
+ const id = node.id;
201
+ if (id?.type === 'Identifier') {
202
+ name = id.name;
203
+ body = node;
204
+ }
205
+ }
206
+ else if (node.type === 'VariableDeclarator') {
207
+ const id = node.id;
208
+ const init = node.init;
209
+ if (id?.type === 'Identifier' &&
210
+ (init?.type === 'ArrowFunctionExpression' || init?.type === 'FunctionExpression')) {
211
+ name = id.name;
212
+ body = init;
213
+ }
214
+ }
215
+ if (name && body && containsExpectCall(body))
216
+ names.add(name);
217
+ });
218
+ return [...names];
219
+ }
220
+ const TEST_NON_DECL_RE = /\btest\.(describe|step|beforeEach|afterEach|beforeAll|afterAll|use|setTimeout|info|expect)\b/;
221
+ /** Count test( / test.only( / test.skip( / test.fixme( style declarations (approx). */
11
222
  export function countTests(source) {
12
- const re = /\btest(?:\.(?:only|skip|fix|slow))?\s*\(/g;
13
- // Exclude test.describe, test.step, test.before*, test.after*, test.use
223
+ // Playwright's real modifier API is test.fixme() — not test.fix().
224
+ const re = /\btest(?:\.(?:only|skip|fixme|slow))?\s*\(/g;
14
225
  let count = 0;
15
226
  let m;
16
- const exclude = /\btest\.(?:describe|step|beforeEach|afterEach|beforeAll|afterAll|use|setTimeout|info|expect)\b/;
17
227
  while ((m = re.exec(source)) !== null) {
18
- const start = Math.max(0, m.index - 0);
19
- const snippet = source.slice(start, start + 40);
20
- if (exclude.test(snippet.split('(')[0] + '(')) {
21
- // more precise: check the matched prefix
22
- }
23
228
  const before = source.slice(Math.max(0, m.index - 1), m.index + 20);
24
- if (/\btest\.(describe|step|beforeEach|afterEach|beforeAll|afterAll|use|setTimeout|info)\b/.test(before)) {
25
- continue;
26
- }
27
- // test.expect is rare as call form
28
- if (/\btest\.expect\b/.test(before))
229
+ if (TEST_NON_DECL_RE.test(before))
29
230
  continue;
30
231
  count++;
31
232
  }
32
233
  return count;
33
234
  }
34
- /**
35
- * Detect test callbacks that appear to have zero expect( calls.
36
- * Heuristic: for each test( ... async (...) => { body }, check body for expect.
37
- */
38
- export function findEmptyExpectTests(source, file) {
39
- const findings = [];
40
- // Rough scan: if file has test( but no expect( at all
41
- const hasTest = /\btest(?:\.(?:only|skip|fix))?\s*\(/.test(source);
42
- const hasExpect = /\bexpect\s*\(/.test(source);
43
- if (hasTest && !hasExpect) {
44
- findings.push({
45
- rule: 'metrics/no-empty-test',
46
- severity: 'error',
47
- message: 'Test file has test() but no expect() assertions',
48
- file,
49
- dimension: 'assertions',
50
- });
51
- }
52
- return findings;
53
- }
54
235
  export function findOversizedFile(source, file, maxSloc = 400) {
55
236
  const sloc = countSloc(source);
56
237
  if (sloc > maxSloc) {
@@ -70,10 +251,16 @@ export function analyzeSource(source, file) {
70
251
  return {
71
252
  sloc: countSloc(source),
72
253
  tests: countTests(source),
73
- locators: countLocators(source),
74
- findings: [
75
- ...findEmptyExpectTests(source, file),
76
- ...findOversizedFile(source, file),
77
- ],
254
+ locators: countLocators(source, file),
255
+ // No homegrown "has no assertions" check here: playwright/expect-expect
256
+ // (a real AST-based community rule, always enabled — see
257
+ // eslint-runner.ts) already covers this correctly, including
258
+ // expect.poll(...)/expect.soft(...) chained forms that a source-text
259
+ // regex would need to special-case. A prior regex-based version of
260
+ // this check (metrics/no-empty-test) only matched a bare `expect(`
261
+ // call and so false-positived at error severity on any file whose only
262
+ // assertions were expect.poll()/expect.soft() — verified against real
263
+ // production code.
264
+ findings: [...findOversizedFile(source, file)],
78
265
  };
79
266
  }
package/dist/profiles.js CHANGED
@@ -45,10 +45,9 @@ export const ESLINT_RULE_MAP = {
45
45
  },
46
46
  // Assertions
47
47
  'playwright/expect-expect': { dimension: 'assertions' },
48
- 'playwright/prefer-web-first-assertions': {
49
- dimension: 'assertions',
50
- severityOverride: 'warning',
51
- },
48
+ // No severityOverride: matches eslint-plugin-playwright's own
49
+ // recommended severity ('error'), set via eslint-runner.ts's rule config.
50
+ 'playwright/prefer-web-first-assertions': { dimension: 'assertions' },
52
51
  'playwright/valid-expect': { dimension: 'assertions' },
53
52
  'playwright/no-standalone-expect': { dimension: 'assertions' },
54
53
  // Locators — findings for repair, reportOnly so ratio owns locators score
@@ -69,10 +68,10 @@ export const ESLINT_RULE_MAP = {
69
68
  },
70
69
  // Structure
71
70
  'playwright/no-focused-test': { dimension: 'structure' },
72
- 'playwright/no-skipped-test': {
73
- dimension: 'structure',
74
- severityOverride: 'warning',
75
- },
71
+ // playwright/no-skipped-test is turned off in eslint-runner.ts in favor
72
+ // of pwscore/no-skipped-test-declaration, which doesn't false-positive
73
+ // on Playwright's documented conditional test.skip(condition, reason).
74
+ 'pwscore/no-skipped-test-declaration': { dimension: 'structure' },
76
75
  'playwright/max-nested-describe': {
77
76
  dimension: 'structure',
78
77
  severityOverride: 'warning',
@@ -101,17 +100,7 @@ export const ESLINT_RULE_MAP = {
101
100
  dimension: 'guardianConventions',
102
101
  severityOverride: 'warning',
103
102
  },
104
- 'guardian/prefer-uuid': {
105
- dimension: 'guardianConventions',
106
- severityOverride: 'warning',
107
- },
108
- 'guardian/fixtures-import': {
109
- dimension: 'guardianConventions',
110
- severityOverride: 'info',
111
- reportOnly: true,
112
- },
113
103
  // Metrics injected as synthetic findings
114
- 'metrics/no-empty-test': { dimension: 'assertions' },
115
104
  'metrics/oversized-file': {
116
105
  dimension: 'structure',
117
106
  severityOverride: 'warning',
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Rules active for both `standard` and `guardian` profiles — corrections
3
+ * to upstream eslint-plugin-playwright behavior, not house style.
4
+ */
5
+ import type { Rule } from 'eslint';
6
+ export declare const basePlugin: {
7
+ meta: {
8
+ name: string;
9
+ version: string;
10
+ };
11
+ rules: {
12
+ 'no-skipped-test-declaration': Rule.RuleModule;
13
+ };
14
+ };
@@ -0,0 +1,77 @@
1
+ function isSkipCallee(callee) {
2
+ if (!callee || callee.type !== 'MemberExpression' || callee.computed)
3
+ return false;
4
+ const obj = callee.object;
5
+ const prop = callee.property;
6
+ if (prop?.type !== 'Identifier' || prop.name !== 'skip')
7
+ return false;
8
+ // test.skip(...)
9
+ if (obj?.type === 'Identifier' && obj.name === 'test')
10
+ return true;
11
+ // test.describe.skip(...)
12
+ if (obj?.type === 'MemberExpression' &&
13
+ !obj.computed &&
14
+ obj.object?.type === 'Identifier' &&
15
+ obj.object.name === 'test' &&
16
+ obj.property?.type === 'Identifier' &&
17
+ obj.property.name === 'describe') {
18
+ return true;
19
+ }
20
+ return false;
21
+ }
22
+ const FUNCTION_TYPES = new Set(['FunctionExpression', 'ArrowFunctionExpression']);
23
+ /**
24
+ * eslint-plugin-playwright's no-skipped-test flags every form of
25
+ * test.skip(...)/test.describe.skip(...) identically. That conflates two
26
+ * very different, both-real patterns:
27
+ *
28
+ * - test.skip('name', fn) / test.describe.skip('name', fn) — a
29
+ * DECLARATION whose test/suite never runs at all. Worth flagging: this
30
+ * is the "test is broken, hide it" anti-pattern.
31
+ * - test.skip(condition, reason) / test.skip() called inside a test/hook
32
+ * body — Playwright's own documented API for conditionally skipping a
33
+ * test at runtime (e.g. per-browser, per-env). This is the correct,
34
+ * recommended way to handle exactly that case, not an anti-pattern —
35
+ * see playwright.dev/docs/test-annotations#conditionally-skip-a-test.
36
+ *
37
+ * Verified against real-world code: eslint-plugin-playwright's rule flags
38
+ * both forms identically, generating warnings on entirely idiomatic
39
+ * cross-browser test suites. This rule replaces it (see eslint-runner.ts,
40
+ * where the upstream rule is turned off) with the same "declaration form"
41
+ * severity but without the false positive on the conditional form — the
42
+ * two are told apart by whether the trailing argument is a function (a
43
+ * declaration always supplies the test body as a function; a conditional
44
+ * skip's second argument, if any, is the string reason).
45
+ */
46
+ const noSkippedTestDeclaration = {
47
+ meta: {
48
+ type: 'suggestion',
49
+ docs: {
50
+ description: 'Disallow test.skip()/test.describe.skip() used to declare an always-skipped test (conditional runtime skips are unaffected)',
51
+ },
52
+ messages: {
53
+ skipped: 'Unexpected use of the .skip() annotation to declare an always-skipped test.',
54
+ },
55
+ schema: [],
56
+ },
57
+ create(context) {
58
+ return {
59
+ CallExpression(node) {
60
+ if (!isSkipCallee(node.callee))
61
+ return;
62
+ const args = node.arguments ?? [];
63
+ const lastArg = args[args.length - 1];
64
+ const isDeclaration = args.length >= 2 && FUNCTION_TYPES.has(lastArg?.type ?? '');
65
+ if (isDeclaration) {
66
+ context.report({ node: node, messageId: 'skipped' });
67
+ }
68
+ },
69
+ };
70
+ },
71
+ };
72
+ export const basePlugin = {
73
+ meta: { name: 'pwscore-base', version: '0.1.0' },
74
+ rules: {
75
+ 'no-skipped-test-declaration': noSkippedTestDeclaration,
76
+ },
77
+ };