@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.
@@ -0,0 +1,470 @@
1
+ /**
2
+ * Post-lint triage of two upstream rules whose own checks cannot see across
3
+ * files or tell a guard from real branching (QAG-287):
4
+ *
5
+ * - `playwright/expect-expect` only sees `expect(...)` written in the test
6
+ * body. A test that asserts through a page-object method, an injected
7
+ * fixture or an imported helper was reported as "no assertion" at error
8
+ * severity even though the assertion runs. Each such finding is resolved
9
+ * here: dropped when a fixture the test uses is known to assert, turned
10
+ * into a report-only "needs a human check" finding when the test calls
11
+ * something this static scan cannot read, and kept as the error it is
12
+ * when every call resolves to code we could read (or is plain Playwright
13
+ * API on `page`, `request`, ...).
14
+ * - `playwright/valid-expect` fires on a helper whose name ends in
15
+ * "Expect" (`clickSortableHeaderAndExpect(...)`) as if it were expect().
16
+ * - `playwright/no-conditional-in-test` fires on a guard whose only body is
17
+ * `throw`. That is an assertion in disguise, not branching test logic.
18
+ *
19
+ * Purely syntactic and name-based, like the rest of this package: a
20
+ * resolved-by-name match is the same trade-off `assertFunctionNames` makes.
21
+ */
22
+ import path from 'node:path';
23
+ import { parseForESLint } from '@typescript-eslint/parser';
24
+ import { resolveRelativeImport } from './import-graph.js';
25
+ import { PLAYWRIGHT_API_METHODS } from './playwright-api-names.js';
26
+ export const ASSERTION_UNVERIFIED_RULE = 'playwright-score/assertion-unverified';
27
+ /** Identifiers whose calls are Playwright API or the language runtime, never a hidden assertion helper. */
28
+ const NEUTRAL_ROOTS = new Set([
29
+ 'page', 'context', 'request', 'browser', 'browserName', 'baseURL', 'testInfo', 'playwright',
30
+ 'expect', 'test', 'it', 'describe', 'console', 'Promise', 'JSON', 'Math', 'Date', 'Object',
31
+ 'Array', 'String', 'Number', 'Boolean', 'process', 'setTimeout', 'setInterval', 'require',
32
+ 'URL', 'Buffer', 'fetch', 'undefined', 'globalThis', 'window', 'document',
33
+ ]);
34
+ // Word-boundary, case-sensitive (same idea as the assertFunctionPatterns in
35
+ // eslint-runner.ts): `checkout`, `toISOString`, `toLowerCase` or `shop.checkout()`
36
+ // are not assertions, `checkFoo`, `assert_ok` and `toBeVisible` are.
37
+ const ASSERTIONISH_PREFIX = /^(assert|verify|check|validate|expect|ensure|should|confirm)([A-Z_0-9]|$)/;
38
+ const ASSERTIONISH_TO = /^to(Be|Have|Contain|Match|Equal|Throw)/;
39
+ /** JS / Node built-ins: `new Date().toISOString()` is never a page-object call. */
40
+ const BUILTIN_CLASSES = new Set([
41
+ 'Date', 'Map', 'Set', 'WeakMap', 'WeakSet', 'URL', 'URLSearchParams', 'Array', 'Error', 'TypeError',
42
+ 'RangeError', 'RegExp', 'Promise', 'Object', 'String', 'Number', 'Boolean', 'Buffer', 'Uint8Array',
43
+ 'ArrayBuffer', 'Intl', 'AbortController', 'TextEncoder', 'TextDecoder', 'FormData', 'Headers', 'Blob',
44
+ ]);
45
+ /**
46
+ * Only a method whose NAME reads like an assertion may be a hidden one:
47
+ * `app.waitForLoad()` or `users.list()` on an unreadable object is just an
48
+ * unverified call, and the test stays an expect-expect error. A name that is
49
+ * a Playwright API verb (`check`, `fill`, `goto`) is an action whatever the
50
+ * receiver: `loginPage.rememberMe.check()` is a locator held in a property.
51
+ */
52
+ function isAssertionLikeCall(name) {
53
+ if (!ASSERTIONISH_PREFIX.test(name) && !ASSERTIONISH_TO.test(name))
54
+ return false;
55
+ return !PLAYWRIGHT_API_METHODS.has(name);
56
+ }
57
+ function walk(node, visit, parent) {
58
+ if (!node || typeof node !== 'object')
59
+ return;
60
+ if (Array.isArray(node)) {
61
+ for (const item of node)
62
+ walk(item, visit, parent);
63
+ return;
64
+ }
65
+ if (typeof node.type !== 'string')
66
+ return;
67
+ visit(node, parent);
68
+ for (const key of Object.keys(node)) {
69
+ if (key === 'parent')
70
+ continue;
71
+ const v = node[key];
72
+ if (v && typeof v === 'object')
73
+ walk(v, visit, node);
74
+ }
75
+ }
76
+ function parse(astCache, source, file) {
77
+ const hit = astCache.get(file);
78
+ if (hit && hit.source === source)
79
+ return hit.ast;
80
+ let ast;
81
+ try {
82
+ ast = parseForESLint(source, {
83
+ ecmaVersion: 2022,
84
+ sourceType: 'module',
85
+ ecmaFeatures: { jsx: /\.(tsx|jsx)$/.test(file) },
86
+ }).ast;
87
+ }
88
+ catch {
89
+ ast = undefined;
90
+ }
91
+ astCache.set(file, { source, ast });
92
+ return ast;
93
+ }
94
+ function isExpectCall(n) {
95
+ if (n?.type !== 'CallExpression')
96
+ return false;
97
+ const c = n.callee;
98
+ if (c?.type === 'Identifier' && c.name === 'expect')
99
+ return true;
100
+ return c?.type === 'MemberExpression' && c.object?.type === 'Identifier' && c.object.name === 'expect';
101
+ }
102
+ function containsExpect(node) {
103
+ let found = false;
104
+ walk(node, (n) => {
105
+ if (!found && isExpectCall(n))
106
+ found = true;
107
+ });
108
+ return found;
109
+ }
110
+ function isFn(n) {
111
+ return n?.type === 'ArrowFunctionExpression' || n?.type === 'FunctionExpression';
112
+ }
113
+ function propKeyName(p) {
114
+ if (p?.type !== 'Property' && p?.type !== 'ObjectProperty')
115
+ return undefined;
116
+ if (p.key?.type === 'Identifier' && !p.computed)
117
+ return p.key.name;
118
+ if (p.key?.type === 'Literal' && typeof p.key.value === 'string')
119
+ return p.key.value;
120
+ return undefined;
121
+ }
122
+ /** Does the fixture function hand a function containing expect(...) to use(...)? */
123
+ function usesAssertingCallable(fixtureFn) {
124
+ const p1 = fixtureFn.params?.[1];
125
+ const useName = p1?.type === 'Identifier' ? p1.name : 'use';
126
+ let found = false;
127
+ walk(fixtureFn.body, (n) => {
128
+ if (found || n.type !== 'CallExpression' || n.callee?.type !== 'Identifier' || n.callee.name !== useName)
129
+ return;
130
+ if ((n.arguments ?? []).some((a) => isFn(a) && containsExpect(a)))
131
+ found = true;
132
+ });
133
+ return found;
134
+ }
135
+ function gather(ctx, cache) {
136
+ const defined = new Set();
137
+ const assertingFixtures = new Map();
138
+ const fixtureKeys = new Map();
139
+ const all = [...ctx.sources, ...ctx.importedFiles];
140
+ for (const [file, source] of all) {
141
+ const ast = parse(cache, source, file);
142
+ if (!ast)
143
+ continue;
144
+ walk(ast, (n) => {
145
+ if (n.type === 'FunctionDeclaration' && n.id?.name)
146
+ defined.add(n.id.name);
147
+ else if (n.type === 'ClassDeclaration' && n.id?.name)
148
+ defined.add(n.id.name);
149
+ else if (n.type === 'VariableDeclarator' && n.id?.type === 'Identifier' && isFn(n.init)) {
150
+ defined.add(n.id.name);
151
+ }
152
+ else if (n.type === 'MethodDefinition' && !n.computed && n.key?.type === 'Identifier') {
153
+ defined.add(n.key.name);
154
+ }
155
+ else if (n.type === 'PropertyDefinition' && !n.computed && n.key?.type === 'Identifier' && isFn(n.value)) {
156
+ defined.add(n.key.name);
157
+ }
158
+ else if (n.type === 'Property' && isFn(n.value)) {
159
+ const k = propKeyName(n);
160
+ if (k)
161
+ defined.add(k);
162
+ }
163
+ else if (n.type === 'CallExpression' &&
164
+ n.callee?.type === 'MemberExpression' &&
165
+ n.callee.property?.name === 'extend' &&
166
+ n.arguments?.[0]?.type === 'ObjectExpression') {
167
+ for (const p of n.arguments[0].properties ?? []) {
168
+ const k = propKeyName(p);
169
+ if (k) {
170
+ if (!fixtureKeys.has(file))
171
+ fixtureKeys.set(file, new Set());
172
+ fixtureKeys.get(file).add(k);
173
+ }
174
+ if (!k || NEUTRAL_ROOTS.has(k) || !isFn(p.value) || !usesAssertingCallable(p.value))
175
+ continue;
176
+ if (!assertingFixtures.has(file))
177
+ assertingFixtures.set(file, new Set());
178
+ assertingFixtures.get(file).add(k);
179
+ }
180
+ }
181
+ });
182
+ }
183
+ return { defined, assertingFixtures, fixtureKeys, cache };
184
+ }
185
+ /** Names bound by an identifier, object or array pattern. */
186
+ function boundNames(pat, out = []) {
187
+ if (!pat)
188
+ return out;
189
+ if (pat.type === 'Identifier')
190
+ out.push(pat.name);
191
+ else if (pat.type === 'ObjectPattern')
192
+ for (const p of pat.properties ?? [])
193
+ boundNames(p.type === 'RestElement' ? p.argument : p.value, out);
194
+ else if (pat.type === 'ArrayPattern')
195
+ for (const e of pat.elements ?? [])
196
+ boundNames(e, out);
197
+ else if (pat.type === 'AssignmentPattern')
198
+ boundNames(pat.left, out);
199
+ else if (pat.type === 'RestElement')
200
+ boundNames(pat.argument, out);
201
+ return out;
202
+ }
203
+ /** Root identifier / expression of a call or member chain. */
204
+ function chainRoot(node) {
205
+ let cur = node;
206
+ for (let i = 0; i < 64 && cur; i++) {
207
+ if (cur.type === 'AwaitExpression')
208
+ cur = cur.argument;
209
+ else if (cur.type === 'TSNonNullExpression' || cur.type === 'ChainExpression')
210
+ cur = cur.expression;
211
+ else if (cur.type === 'TSAsExpression' || cur.type === 'TSTypeAssertion')
212
+ cur = cur.expression;
213
+ else if (cur.type === 'CallExpression')
214
+ cur = cur.callee;
215
+ else if (cur.type === 'MemberExpression')
216
+ cur = cur.object;
217
+ else
218
+ break;
219
+ }
220
+ return cur;
221
+ }
222
+ function importKind(specifier) {
223
+ if (specifier.startsWith('.') || /^(@\/|~\/|#|src\/|tests?\/|e2e\/|pages\/|fixtures\/|utils?\/|lib\/)/.test(specifier)) {
224
+ return 'external';
225
+ }
226
+ return 'lib';
227
+ }
228
+ /**
229
+ * Calls inside a test callback whose receiver lives somewhere this scan
230
+ * cannot read: names of those calls (empty when every call is plain
231
+ * Playwright API, a same-file/readable helper, or a utility library).
232
+ */
233
+ function unresolvedCalls(testCall, ast, know) {
234
+ const fn = [...(testCall.arguments ?? [])].reverse().find(isFn);
235
+ if (!fn)
236
+ return [];
237
+ const imports = new Map();
238
+ for (const stmt of ast.body ?? []) {
239
+ if (stmt.type !== 'ImportDeclaration' || typeof stmt.source?.value !== 'string')
240
+ continue;
241
+ for (const s of stmt.specifiers ?? []) {
242
+ if (s.local?.name)
243
+ imports.set(s.local.name, importKind(stmt.source.value));
244
+ }
245
+ }
246
+ const kinds = new Map();
247
+ const first = fn.params?.[0];
248
+ if (first?.type === 'ObjectPattern') {
249
+ for (const p of first.properties ?? []) {
250
+ const local = p.value?.type === 'AssignmentPattern' ? p.value.left : p.value;
251
+ const name = local?.type === 'Identifier' ? local.name : undefined;
252
+ if (name)
253
+ kinds.set(name, NEUTRAL_ROOTS.has(propKeyName(p) ?? name) ? 'neutral' : 'external');
254
+ }
255
+ }
256
+ const rootKind = (root) => {
257
+ if (root?.type === 'NewExpression') {
258
+ const cls = root.callee?.name;
259
+ if (cls && (BUILTIN_CLASSES.has(cls) || NEUTRAL_ROOTS.has(cls)))
260
+ return 'neutral';
261
+ return cls && know.defined.has(cls) ? 'neutral' : 'external';
262
+ }
263
+ if (root?.type !== 'Identifier')
264
+ return 'neutral';
265
+ const local = kinds.get(root.name);
266
+ if (local)
267
+ return local;
268
+ if (NEUTRAL_ROOTS.has(root.name))
269
+ return 'neutral';
270
+ return imports.get(root.name) ?? 'neutral';
271
+ };
272
+ const unresolved = [];
273
+ walk(fn.body, (n) => {
274
+ if (n.type === 'VariableDeclarator' && n.init) {
275
+ // `const x = ...` and destructuring (`const { a, b: [c] } = await ...`):
276
+ // every bound name takes the kind of the initializer's root.
277
+ const kind = rootKind(chainRoot(n.init));
278
+ for (const bound of boundNames(n.id))
279
+ kinds.set(bound, kind);
280
+ return;
281
+ }
282
+ if (n.type !== 'CallExpression')
283
+ return;
284
+ const c = n.callee;
285
+ let name;
286
+ if (c?.type === 'Identifier')
287
+ name = c.name;
288
+ else if (c?.type === 'MemberExpression' && !c.computed && c.property?.type === 'Identifier')
289
+ name = c.property.name;
290
+ if (!name || know.defined.has(name))
291
+ return;
292
+ const kind = rootKind(chainRoot(c));
293
+ if ((kind === 'external' || kind === 'lib') && isAssertionLikeCall(name))
294
+ unresolved.push(name);
295
+ });
296
+ return unresolved;
297
+ }
298
+ /** The spec file plus every file reachable from it through relative imports / re-exports. */
299
+ function reachableFiles(spec, ctx, know) {
300
+ const boundary = ctx.importBoundary ?? ctx.filesBase;
301
+ const seen = new Set([spec]);
302
+ const queue = [spec];
303
+ while (queue.length) {
304
+ const file = queue.pop();
305
+ const source = ctx.sources.get(file) ?? ctx.importedFiles.get(file);
306
+ const ast = source === undefined ? undefined : parse(know.cache, source, file);
307
+ for (const stmt of ast?.body ?? []) {
308
+ const spec2 = stmt.source?.value;
309
+ if (typeof spec2 !== 'string' || !spec2.startsWith('.'))
310
+ continue;
311
+ const target = resolveRelativeImport(file, spec2, boundary);
312
+ if (target && !seen.has(target)) {
313
+ seen.add(target);
314
+ queue.push(target);
315
+ }
316
+ }
317
+ }
318
+ return seen;
319
+ }
320
+ /** Local names of destructured test args that are asserting fixtures reachable from this spec. */
321
+ function assertingFixtureLocals(testFn, reachable, know) {
322
+ const locals = new Set();
323
+ for (const p of testFn?.params?.[0]?.properties ?? []) {
324
+ const key = propKeyName(p);
325
+ if (!key || NEUTRAL_ROOTS.has(key))
326
+ continue;
327
+ if (![...reachable].some((f) => know.assertingFixtures.get(f)?.has(key)))
328
+ continue;
329
+ const local = p.value?.type === 'AssignmentPattern' ? p.value.left : p.value;
330
+ if (local?.type === 'Identifier')
331
+ locals.add(local.name);
332
+ }
333
+ return locals;
334
+ }
335
+ /**
336
+ * Fixtures the test destructures AND calls (`a11y()`) that no reachable file
337
+ * defines, while its `test` comes from a non-relative module this scan does
338
+ * not resolve (a tsconfig `paths` alias such as `@fx/base`, a workspace
339
+ * package). The fixture may well assert there, so it is "unverified", not
340
+ * "no assertion". Relative imports are followed by reachableFiles instead.
341
+ */
342
+ function unresolvedFixtureCalls(call, ast, fn, reachable, know) {
343
+ const testRoot = chainRoot(call.callee);
344
+ if (testRoot?.type !== 'Identifier')
345
+ return [];
346
+ const fromUnresolvedModule = (ast.body ?? []).some((stmt) => stmt.type === 'ImportDeclaration' &&
347
+ typeof stmt.source?.value === 'string' &&
348
+ !stmt.source.value.startsWith('.') &&
349
+ !/^(@playwright\/|playwright|node:)/.test(stmt.source.value) &&
350
+ (stmt.specifiers ?? []).some((sp) => sp.local?.name === testRoot.name));
351
+ if (!fromUnresolvedModule)
352
+ return [];
353
+ const out = [];
354
+ for (const p of fn?.params?.[0]?.properties ?? []) {
355
+ const key = propKeyName(p);
356
+ if (!key || NEUTRAL_ROOTS.has(key))
357
+ continue;
358
+ if ([...reachable].some((f) => know.fixtureKeys.get(f)?.has(key)))
359
+ continue;
360
+ const local = p.value?.type === 'AssignmentPattern' ? p.value.left : p.value;
361
+ if (local?.type === 'Identifier' && callsAny(fn.body, new Set([local.name])))
362
+ out.push(key);
363
+ }
364
+ return out;
365
+ }
366
+ /** Does the body call `name(...)` directly (the fixture is invoked, not merely destructured)? */
367
+ function callsAny(body, names) {
368
+ let found = false;
369
+ walk(body, (n) => {
370
+ if (!found && n.type === 'CallExpression' && n.callee?.type === 'Identifier' && names.has(n.callee.name))
371
+ found = true;
372
+ });
373
+ return found;
374
+ }
375
+ function findCall(ast, line, col0) {
376
+ let hit;
377
+ walk(ast, (n) => {
378
+ if (!hit && n.type === 'CallExpression' && n.loc?.start.line === line && n.loc.start.column === col0)
379
+ hit = n;
380
+ });
381
+ return hit;
382
+ }
383
+ function findIf(ast, line, col0) {
384
+ let hit;
385
+ walk(ast, (n) => {
386
+ if (!hit && n.type === 'IfStatement' && n.loc?.start.line === line && n.loc.start.column === col0)
387
+ hit = n;
388
+ });
389
+ return hit;
390
+ }
391
+ function isThrowOnly(stmt) {
392
+ if (stmt?.type === 'ThrowStatement')
393
+ return true;
394
+ return stmt?.type === 'BlockStatement' && stmt.body?.length === 1 && stmt.body[0].type === 'ThrowStatement';
395
+ }
396
+ export function triageFindings(findings, ctx) {
397
+ const wants = findings.some((f) => f.rule === 'playwright/expect-expect' ||
398
+ f.rule === 'playwright/no-conditional-in-test' ||
399
+ f.rule === 'playwright/valid-expect');
400
+ if (!wants)
401
+ return findings;
402
+ const cache = new Map(); // per call: nothing leaks between scorePaths runs
403
+ const know = gather(ctx, cache);
404
+ const out = [];
405
+ for (const f of findings) {
406
+ if ((f.rule !== 'playwright/expect-expect' &&
407
+ f.rule !== 'playwright/no-conditional-in-test' &&
408
+ f.rule !== 'playwright/valid-expect') ||
409
+ f.line === undefined ||
410
+ f.column === undefined) {
411
+ out.push(f);
412
+ continue;
413
+ }
414
+ const abs = path.resolve(ctx.filesBase, f.file);
415
+ const source = ctx.sources.get(abs);
416
+ const ast = source === undefined ? undefined : parse(cache, source, abs);
417
+ if (!ast) {
418
+ out.push(f);
419
+ continue;
420
+ }
421
+ if (f.rule === 'playwright/valid-expect') {
422
+ // Upstream treats any callee whose name merely ends in "Expect"
423
+ // (`clickSortableHeaderAndExpect(header, 'ascending')`) as an expect()
424
+ // call missing its matcher. Only the real `expect` is one.
425
+ const node = findCall(ast, f.line, f.column - 1);
426
+ const callee = node?.callee;
427
+ const isRealExpect = callee?.type === 'Identifier'
428
+ ? callee.name === 'expect'
429
+ : callee?.type === 'MemberExpression' && callee.object?.name === 'expect';
430
+ if (node && !isRealExpect)
431
+ continue;
432
+ out.push(f);
433
+ continue;
434
+ }
435
+ if (f.rule === 'playwright/no-conditional-in-test') {
436
+ const node = findIf(ast, f.line, f.column - 1);
437
+ if (node && !node.alternate && isThrowOnly(node.consequent))
438
+ continue; // a guard, not branching
439
+ out.push(f);
440
+ continue;
441
+ }
442
+ const call = findCall(ast, f.line, f.column - 1);
443
+ if (!call) {
444
+ out.push(f);
445
+ continue;
446
+ }
447
+ const fn = [...(call.arguments ?? [])].reverse().find(isFn);
448
+ const reachable = reachableFiles(abs, ctx, know);
449
+ const fixtureLocals = assertingFixtureLocals(fn, reachable, know);
450
+ if (fixtureLocals.size > 0 && callsAny(fn.body, fixtureLocals))
451
+ continue; // a callable fixture that asserts is invoked
452
+ const calls = [...new Set([...unresolvedCalls(call, ast, know), ...unresolvedFixtureCalls(call, ast, fn, reachable, know)])];
453
+ if (calls.length === 0) {
454
+ out.push(f);
455
+ continue;
456
+ }
457
+ out.push({
458
+ ...f,
459
+ rule: ASSERTION_UNVERIFIED_RULE,
460
+ severity: 'info',
461
+ reportOnly: true,
462
+ message: `Needs a human check: no expect(...) in this test body, but it calls ${calls
463
+ .slice(0, 3)
464
+ .map((c) => `${c}()`)
465
+ .join(', ')}${calls.length > 3 ? ', ...' : ''} on an object defined elsewhere (page object, injected fixture or ` +
466
+ 'imported helper) that this scan cannot read, so the assertion may live there. Not counted against the score.',
467
+ });
468
+ }
469
+ return out;
470
+ }
@@ -12,7 +12,8 @@ export function formatMarkdown(result) {
12
12
  lines.push(`| ${k} | ${v} |`);
13
13
  }
14
14
  lines.push('');
15
- lines.push(`Files: ${result.summary.files} · Tests: ${result.summary.tests} · SLOC: ${result.summary.sloc} · Findings: ${result.summary.findings}`);
15
+ lines.push(`Files: ${result.summary.files} · Tests: ${result.summary.tests} · SLOC: ${result.summary.sloc} · Findings: ${result.summary.findings}` +
16
+ (result.summary.unverifiedTests ? ` · Unverified assertions: ${result.summary.unverifiedTests}` : ''));
16
17
  if (result.findings.length) {
17
18
  lines.push('');
18
19
  lines.push('## Findings');
@@ -13,7 +13,8 @@ export function formatText(result) {
13
13
  lines.push(`Summary: files=${result.summary.files} tests=${result.summary.tests} sloc=${result.summary.sloc} ` +
14
14
  `findings=${result.summary.findings} (errors=${result.summary.errors} warnings=${result.summary.warnings}) ` +
15
15
  `locators native/raw=${result.summary.nativeLocators}/${result.summary.rawLocators} ` +
16
- `tests asserting=${result.summary.tests - result.summary.unassertedTests}/${result.summary.tests} ` +
16
+ `tests asserting=${result.summary.tests - result.summary.unassertedTests - result.summary.unverifiedTests}/${result.summary.tests}` +
17
+ `${result.summary.unverifiedTests ? ` (unverified=${result.summary.unverifiedTests})` : ''} ` +
17
18
  `clean tests=${result.summary.cleanTests}/${result.summary.tests}`);
18
19
  if (result.findings.length) {
19
20
  lines.push('');