@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.
- package/CHANGELOG.md +181 -0
- package/METHODOLOGY.md +1 -1
- package/dist/finding-triage.d.ts +13 -0
- package/dist/finding-triage.js +470 -0
- package/dist/formatters/markdown.js +2 -1
- package/dist/formatters/text.js +2 -1
- package/dist/index.js +188 -9
- package/dist/metrics.js +33 -2
- package/dist/playwright-api-names.d.ts +10 -0
- package/dist/playwright-api-names.js +35 -0
- package/dist/playwright-config.d.ts +26 -0
- package/dist/playwright-config.js +26 -3
- package/dist/profiles.js +2 -0
- package/dist/rules/base-plugin.js +170 -1
- package/dist/score-engine.js +1 -0
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
|
@@ -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`
|
package/dist/score-engine.js
CHANGED
|
@@ -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