@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.
@@ -175,6 +175,64 @@ function callsResolve(fn, resolveName) {
175
175
  call.callee?.type === 'Identifier' &&
176
176
  call.callee.name === resolveName);
177
177
  }
178
+ const LOOP_TYPES = new Set(['ForStatement', 'ForInStatement', 'ForOfStatement', 'WhileStatement', 'DoWhileStatement']);
179
+ /**
180
+ * A retry/backoff pause inside a plain helper function (an API retry loop,
181
+ * `for (...) { try { await call() } catch { await sleep(500) } }`) is not a
182
+ * test waiting on the UI: it paces calls to a service. Only that shape is
183
+ * exempt: the sleep sits inside a catch clause, or inside a loop whose body
184
+ * contains a try/catch, and the enclosing function is *defined* (function
185
+ * declaration, `const f = () => ...`, class method) and is not itself nested
186
+ * in a callback handed to a call (a test, hook, describe or step body). A
187
+ * hand-rolled polling wait (`while (await x.isVisible()) sleep()`), a sleep
188
+ * directly in a test body or hook, a helper declared inside a test callback
189
+ * and a bare `sleep()` helper are all still flagged. `from` is the ancestor
190
+ * index to start climbing from (the Promise executor's own parent).
191
+ */
192
+ function isRetryBackoffInHelper(ancestors, from) {
193
+ let retryShape = false;
194
+ let helperFound = false;
195
+ for (let i = from; i >= 0; i--) {
196
+ const anc = ancestors[i];
197
+ if (!helperFound) {
198
+ if (anc.type === 'CatchClause')
199
+ retryShape = true;
200
+ if (LOOP_TYPES.has(anc.type) && containsTryCatch(anc.body))
201
+ retryShape = true;
202
+ }
203
+ if (!FUNCTION_TYPES.has(anc.type) && anc.type !== 'FunctionDeclaration')
204
+ continue;
205
+ const parent = ancestors[i - 1];
206
+ if (parent?.type === 'CallExpression' || parent?.type === 'NewExpression') {
207
+ // Callback handed to a call (test body, hook, describe, .then, step):
208
+ // either the sleep is not in a free-standing helper at all, or the
209
+ // helper lives inside such a callback. Not exempt either way.
210
+ return false;
211
+ }
212
+ if (helperFound)
213
+ continue; // a function around the helper: keep checking the callback chain
214
+ if (anc.type === 'FunctionDeclaration' ||
215
+ parent?.type === 'MethodDefinition' ||
216
+ parent?.type === 'PropertyDefinition' ||
217
+ (parent?.type === 'VariableDeclarator' && parent.init === anc)) {
218
+ if (!retryShape)
219
+ return false;
220
+ helperFound = true;
221
+ }
222
+ }
223
+ return helperFound;
224
+ }
225
+ function containsTryCatch(root) {
226
+ let found = false;
227
+ walkNode(root, (n) => {
228
+ if (n.type !== 'TryStatement' || !n.handler)
229
+ return;
230
+ // An empty `try {}` is not a retry shape.
231
+ if ((n.block?.body ?? []).length > 0)
232
+ found = true;
233
+ });
234
+ return found;
235
+ }
178
236
  /**
179
237
  * `await new Promise((resolve) => setTimeout(resolve, ms))` is the same
180
238
  * hard-coded sleep as `page.waitForTimeout(ms)` (already flagged, as an
@@ -289,7 +347,7 @@ const noTimerSleep = {
289
347
  firstArg.name === resolveParam.name;
290
348
  const resolveName = resolveParam?.type === 'Identifier' ? resolveParam.name : undefined;
291
349
  const isWrappedResolve = typeof resolveName === 'string' && callsResolve(firstArg, resolveName);
292
- if (isDirectResolve || isWrappedResolve) {
350
+ if ((isDirectResolve || isWrappedResolve) && !isRetryBackoffInHelper(ancestors, i - 1)) {
293
351
  context.report({ node: node, messageId: 'sleep' });
294
352
  }
295
353
  }
@@ -517,8 +575,119 @@ function collectConstBindings(root) {
517
575
  bindings.set(d.id.name, value);
518
576
  }
519
577
  });
578
+ // A `const` array is still mutable: `const errors = []; page.on('pageerror',
579
+ // (e) => errors.push(e)); expect(errors).toEqual([])` is a real assertion
580
+ // (the listener fills the array), not `expect([]).toEqual([])`. Only keep
581
+ // an array binding when every reference to it is the plain subject of an
582
+ // expect(...) call; any other use (method call, passed along, spread,
583
+ // reassigned element) might mutate it, so it is not a constant.
584
+ for (const [name, value] of [...bindings]) {
585
+ if (value.ok && Array.isArray(value.value) && !isOnlyReadAsExpectSubject(root, name)) {
586
+ bindings.delete(name);
587
+ }
588
+ }
520
589
  return bindings;
521
590
  }
591
+ const MUTATING_METHODS = new Set([
592
+ 'push', 'unshift', 'splice', 'pop', 'shift', 'sort', 'reverse', 'fill', 'copyWithin',
593
+ ]);
594
+ /**
595
+ * True when no reference to `name` under `root` could change the array:
596
+ * every reference is a declaration, the subject of expect(...), a `.length`
597
+ * or index read, or the receiver of a non-mutating method. A reference that
598
+ * is a call argument (it may be handed to code that fills it), the
599
+ * receiver of a mutating method, an assignment/update target, or any other
600
+ * use that aliases the array (returned, assigned on, spread, put in an
601
+ * object) counts as possibly mutating, wherever it sits — including inside
602
+ * a closure such as a listener. Same-named properties (`obj.name`,
603
+ * `{ name: 1 }`) are not references. Shadowing is not resolved: a shadowing
604
+ * binding is read like the outer one, which can only keep a finding quiet.
605
+ */
606
+ function isOnlyReadAsExpectSubject(root, name) {
607
+ let ok = true;
608
+ const visit = (node, stack) => {
609
+ if (!ok || !node || typeof node !== 'object')
610
+ return;
611
+ if (Array.isArray(node)) {
612
+ for (const item of node)
613
+ visit(item, stack);
614
+ return;
615
+ }
616
+ const rec = node;
617
+ if (typeof rec.type !== 'string')
618
+ return;
619
+ const n = node;
620
+ if (n.type === 'Identifier' && n.name === name) {
621
+ if (referenceMayMutate(n, stack))
622
+ ok = false;
623
+ return;
624
+ }
625
+ const next = [...stack, n];
626
+ for (const key of Object.keys(rec)) {
627
+ if (key === 'parent')
628
+ continue;
629
+ const v = rec[key];
630
+ if (v && typeof v === 'object')
631
+ visit(v, next);
632
+ }
633
+ };
634
+ visit(root, []);
635
+ return ok;
636
+ }
637
+ /** Is the member expression `m` itself written, deleted or the receiver of a mutating method? */
638
+ function memberMutates(m, above) {
639
+ if (above.type === 'AssignmentExpression' && above.left === m)
640
+ return true;
641
+ if (above.type === 'UpdateExpression')
642
+ return true;
643
+ if (above.type === 'UnaryExpression' && above.operator === 'delete')
644
+ return true;
645
+ const prop = m.property;
646
+ return (!m.computed &&
647
+ prop?.type === 'Identifier' &&
648
+ !!prop.name &&
649
+ MUTATING_METHODS.has(prop.name) &&
650
+ above.type === 'CallExpression' &&
651
+ above.callee === m);
652
+ }
653
+ function referenceMayMutate(id, stack) {
654
+ const parent = stack[stack.length - 1];
655
+ const grand = stack[stack.length - 2];
656
+ if (!parent)
657
+ return true;
658
+ switch (parent.type) {
659
+ case 'VariableDeclarator':
660
+ return parent.id !== id; // `const b = expected` aliases it
661
+ case 'CallExpression':
662
+ if (parent.arguments?.[0] === id && isExpectCallNode(parent))
663
+ return false;
664
+ return parent.callee !== id || false; // an argument, or the array called
665
+ case 'MemberExpression': {
666
+ if (parent.object !== id)
667
+ return false; // `obj.expected`: a property name, not this binding
668
+ // Mutation of the member itself (`a.x = 1`, `a.push(1)`), then, for a
669
+ // computed access chain (`rows[0].push(9)`, `rows[0][1] = 2`), of
670
+ // the element it reaches: the inner array is part of the asserted one.
671
+ let member = parent;
672
+ for (let up = stack.length - 2; up >= 0; up--) {
673
+ const above = stack[up];
674
+ if (memberMutates(member, above))
675
+ return true;
676
+ if (!member.computed || above.type !== 'MemberExpression' || above.object !== member)
677
+ break;
678
+ member = above;
679
+ }
680
+ return false; // `.length`, `[0]`, `.map(...)`, ... are reads
681
+ }
682
+ case 'Property':
683
+ case 'PropertyDefinition':
684
+ // Non-computed key position is a name, not a reference; a value (or
685
+ // shorthand) puts the array inside an object.
686
+ return !((parent.key === id || false) && !parent.computed && !parent.shorthand);
687
+ default:
688
+ return true;
689
+ }
690
+ }
522
691
  /**
523
692
  * Climbs from `node` to the body of the nearest enclosing function (the
524
693
  * test/hook callback in the common case) — the scope `noTrivialAssertion`
@@ -188,6 +188,7 @@ export function computeScore(input) {
188
188
  nativeLocators: input.nativeLocators,
189
189
  rawLocators: input.rawLocators,
190
190
  unassertedTests: input.findings.filter((f) => f.rule === 'playwright/expect-expect' && !f.reportOnly).length,
191
+ unverifiedTests: input.findings.filter((f) => f.rule === 'playwright-score/assertion-unverified').length,
191
192
  cleanTests,
192
193
  },
193
194
  dimensions: dims,
package/dist/types.d.ts CHANGED
@@ -51,6 +51,10 @@ export interface ScoreSummary {
51
51
  /** Tests with no recognized assertion (uncapped playwright/expect-expect
52
52
  * census, after delegation resolution). */
53
53
  unassertedTests: number;
54
+ /** Tests with no expect(...) that call something this scan cannot read
55
+ * (`playwright-score/assertion-unverified`, report-only). They are NOT in
56
+ * `unassertedTests` and not penalised, but they are not known to assert. */
57
+ unverifiedTests: number;
54
58
  /** Tests with no demerit in any dimension — the headline the score is
55
59
  * built from: the weighted share of tests that are clean. */
56
60
  cleanTests: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qaguardian/playwright-score",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Deterministic, AI-free Playwright spec quality score — the weighted share of your tests that are clean.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",