@qaguardian/playwright-score 2.1.1 → 2.3.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +159 -0
  2. package/METHODOLOGY.md +42 -1
  3. package/README.md +1 -1
  4. package/dist/cli.js +61 -5
  5. package/dist/confidence.d.ts +30 -0
  6. package/dist/confidence.js +67 -0
  7. package/dist/eslint-runner.js +18 -0
  8. package/dist/finding-triage.d.ts +13 -0
  9. package/dist/finding-triage.js +470 -0
  10. package/dist/formatters/json.js +4 -1
  11. package/dist/formatters/markdown.d.ts +2 -0
  12. package/dist/formatters/markdown.js +17 -5
  13. package/dist/formatters/report-css.d.ts +2 -0
  14. package/dist/formatters/report-css.js +68 -0
  15. package/dist/formatters/report.d.ts +20 -0
  16. package/dist/formatters/report.js +279 -0
  17. package/dist/formatters/text.d.ts +6 -1
  18. package/dist/formatters/text.js +73 -27
  19. package/dist/index.js +21 -8
  20. package/dist/metrics.js +33 -2
  21. package/dist/playwright-api-names.d.ts +10 -0
  22. package/dist/playwright-api-names.js +35 -0
  23. package/dist/profiles.js +14 -0
  24. package/dist/report-model.d.ts +52 -0
  25. package/dist/report-model.js +162 -0
  26. package/dist/report-source.d.ts +11 -0
  27. package/dist/report-source.js +96 -0
  28. package/dist/rule-copy.d.ts +14 -0
  29. package/dist/rule-copy.js +215 -0
  30. package/dist/rules/ast-util.d.ts +60 -0
  31. package/dist/rules/ast-util.js +140 -0
  32. package/dist/rules/base-plugin.d.ts +19 -5
  33. package/dist/rules/base-plugin.js +245 -8
  34. package/dist/rules/no-brittle-selector.d.ts +3 -0
  35. package/dist/rules/no-brittle-selector.js +150 -0
  36. package/dist/rules/no-excessive-timeout.d.ts +2 -0
  37. package/dist/rules/no-excessive-timeout.js +87 -0
  38. package/dist/rules/no-fixme-test.d.ts +14 -0
  39. package/dist/rules/no-fixme-test.js +53 -0
  40. package/dist/rules/no-self-fulfilling-fallback.d.ts +29 -0
  41. package/dist/rules/no-self-fulfilling-fallback.js +221 -0
  42. package/dist/rules/no-shared-mutable-state.d.ts +4 -0
  43. package/dist/rules/no-shared-mutable-state.js +186 -0
  44. package/dist/rules/no-swallowed-assertion.d.ts +13 -0
  45. package/dist/rules/no-swallowed-assertion.js +113 -0
  46. package/dist/rules/no-unawaited-action.d.ts +2 -0
  47. package/dist/rules/no-unawaited-action.js +77 -0
  48. package/dist/rules/no-unawaited-expect-value.d.ts +20 -0
  49. package/dist/rules/no-unawaited-expect-value.js +46 -0
  50. package/dist/rules/no-weak-assertion.d.ts +19 -0
  51. package/dist/rules/no-weak-assertion.js +82 -0
  52. package/dist/rules/rule-util.d.ts +56 -0
  53. package/dist/rules/rule-util.js +253 -0
  54. package/dist/score-engine.d.ts +5 -0
  55. package/dist/score-engine.js +10 -9
  56. package/dist/suite-name.d.ts +11 -0
  57. package/dist/suite-name.js +36 -0
  58. package/dist/test-aliases.js +144 -3
  59. package/dist/types.d.ts +18 -0
  60. package/package.json +1 -1
@@ -0,0 +1,140 @@
1
+ export const FN_TYPES = new Set(['FunctionExpression', 'ArrowFunctionExpression', 'FunctionDeclaration']);
2
+ /** Walk a subtree. `visit` returns false to skip that node's children. Never descends into nested functions unless `intoFunctions`. */
3
+ export function walk(node, visit, intoFunctions = false) {
4
+ if (!node || typeof node !== 'object')
5
+ return;
6
+ if (Array.isArray(node)) {
7
+ for (const item of node)
8
+ walk(item, visit, intoFunctions);
9
+ return;
10
+ }
11
+ const rec = node;
12
+ if (typeof rec.type !== 'string')
13
+ return;
14
+ if (visit(node) === false)
15
+ return;
16
+ for (const key of Object.keys(rec)) {
17
+ if (key === 'parent')
18
+ continue;
19
+ const v = rec[key];
20
+ if (!v || typeof v !== 'object')
21
+ continue;
22
+ const child = v;
23
+ if (!intoFunctions && !Array.isArray(v) && child.type && FN_TYPES.has(child.type))
24
+ continue;
25
+ walk(v, visit, intoFunctions);
26
+ }
27
+ }
28
+ export function unwrap(node) {
29
+ let n = node ?? undefined;
30
+ while (n &&
31
+ (n.type === 'AwaitExpression' ||
32
+ n.type === 'TSNonNullExpression' ||
33
+ n.type === 'ChainExpression' ||
34
+ n.type === 'TSAsExpression' ||
35
+ n.type === 'ParenthesizedExpression')) {
36
+ n = (n.type === 'AwaitExpression' ? n.argument : (n.argument ?? n.expression)) ?? undefined;
37
+ }
38
+ return n;
39
+ }
40
+ export function memberName(callee) {
41
+ if (!callee || callee.type !== 'MemberExpression' || callee.computed)
42
+ return undefined;
43
+ return callee.property?.type === 'Identifier' ? callee.property.name : undefined;
44
+ }
45
+ /** `expect(...)`, `expect.soft(...)`, `expect.poll(...)`. */
46
+ export function isExpectCall(node) {
47
+ if (!node || node.type !== 'CallExpression')
48
+ return false;
49
+ const c = node.callee;
50
+ if (c?.type === 'Identifier' && c.name === 'expect')
51
+ return true;
52
+ return (c?.type === 'MemberExpression' &&
53
+ !c.computed &&
54
+ c.object?.type === 'Identifier' &&
55
+ c.object.name === 'expect' &&
56
+ (c.property?.name === 'soft' || c.property?.name === 'poll'));
57
+ }
58
+ /** If `call` is `expect(x).[not.]matcher(...)`, describe it. */
59
+ export function parseMatcherCall(call) {
60
+ if (call.type !== 'CallExpression')
61
+ return undefined;
62
+ const matcher = memberName(call.callee);
63
+ if (!matcher)
64
+ return undefined;
65
+ const modifiers = [];
66
+ let obj = call.callee?.object;
67
+ while (obj && obj.type === 'MemberExpression' && !obj.computed && obj.property?.type === 'Identifier') {
68
+ modifiers.push(obj.property.name ?? '');
69
+ obj = obj.object;
70
+ }
71
+ if (!obj || !isExpectCall(obj))
72
+ return undefined;
73
+ if (!modifiers.every((m) => m === 'not' || m === 'resolves' || m === 'rejects'))
74
+ return undefined;
75
+ return { expectCall: obj, matcher, modifiers, call };
76
+ }
77
+ /** Scope-resolved `const x = <init>` initialiser for an identifier, if any. */
78
+ export function resolveConstInit(context, id) {
79
+ if (id.type !== 'Identifier' || !id.name)
80
+ return undefined;
81
+ let scope = context.sourceCode.getScope(id);
82
+ while (scope) {
83
+ const v = scope.set.get(id.name);
84
+ if (v) {
85
+ const def = v.defs[0];
86
+ if (!def || def.type !== 'Variable')
87
+ return undefined;
88
+ if (def.parent?.type === 'VariableDeclaration' && def.parent.kind !== 'const')
89
+ return undefined;
90
+ return def.node.id?.type === 'Identifier' ? (def.node.init ?? undefined) : undefined;
91
+ }
92
+ scope = scope.upper;
93
+ }
94
+ return undefined;
95
+ }
96
+ const CHAIN_OK = new Set(['first', 'last', 'nth', 'filter', 'locator', 'and', 'or', 'frameLocator', 'contentFrame']);
97
+ /** Syntactic Locator: `page.locator(..)`, `x.getBy*(..)`, those chained with first/last/nth/filter/..., or a const initialised from one. */
98
+ export function isLocatorExpr(context, node, depth = 0) {
99
+ const n = unwrap(node);
100
+ if (!n || depth > 3)
101
+ return false;
102
+ if (n.type === 'Identifier') {
103
+ const init = resolveConstInit(context, n);
104
+ return init ? isLocatorExpr(context, init, depth + 1) : false;
105
+ }
106
+ if (n.type !== 'CallExpression')
107
+ return false;
108
+ const name = memberName(n.callee);
109
+ if (!name)
110
+ return false;
111
+ if (name === 'locator' || name.startsWith('getBy'))
112
+ return true;
113
+ if (CHAIN_OK.has(name))
114
+ return isLocatorExpr(context, n.callee?.object, depth + 1);
115
+ return false;
116
+ }
117
+ /** Receiver is plausibly a Playwright Page/Locator/Frame (syntactic). */
118
+ export function isPlaywrightReceiver(context, node, depth = 0) {
119
+ const n = unwrap(node);
120
+ if (!n || depth > 3)
121
+ return false;
122
+ if (isLocatorExpr(context, n))
123
+ return true;
124
+ if (n.type === 'Identifier') {
125
+ return n.name === 'page' || n.name === 'frame' || /[a-z]Locator$/.test(n.name ?? '') || /^(locator|page)$/i.test(n.name ?? '');
126
+ }
127
+ if (n.type === 'MemberExpression' && !n.computed) {
128
+ return n.property?.name === 'page' && n.object?.type === 'ThisExpression';
129
+ }
130
+ return false;
131
+ }
132
+ export function nearestFunction(node) {
133
+ let p = node.parent;
134
+ while (p) {
135
+ if (FN_TYPES.has(p.type))
136
+ return p;
137
+ p = p.parent;
138
+ }
139
+ return undefined;
140
+ }
@@ -3,16 +3,30 @@
3
3
  * style — always active.
4
4
  */
5
5
  import type { Rule } from 'eslint';
6
+ /**
7
+ * Rule crashes are contained: an exception thrown while a rule builds its
8
+ * listeners or runs one on some unusual syntax must not abort the run for
9
+ * the whole suite. The rule simply reports nothing for that node. Crashes are
10
+ * recorded so tests can assert there are none.
11
+ */
12
+ export declare const ruleCrashes: Array<{
13
+ rule: string;
14
+ error: unknown;
15
+ }>;
16
+ /** Clear the crash log; scorePaths calls this at the start of every run. */
17
+ export declare function resetRuleCrashes(): void;
18
+ /** Crashes of the current run, one entry per rule, in first-crash order. */
19
+ export declare function summarizeRuleCrashes(): Array<{
20
+ rule: string;
21
+ count: number;
22
+ }>;
23
+ export declare function contain(name: string, rule: Rule.RuleModule): Rule.RuleModule;
6
24
  export declare const basePlugin: {
7
25
  meta: {
8
26
  name: string;
9
27
  version: string;
10
28
  };
11
29
  rules: {
12
- 'no-skipped-test-declaration': Rule.RuleModule;
13
- 'no-timer-sleep': Rule.RuleModule;
14
- 'no-coordinate-click': Rule.RuleModule;
15
- 'no-trivial-assertion': Rule.RuleModule;
16
- 'no-soft-assertion-only-test': Rule.RuleModule;
30
+ [k: string]: Rule.RuleModule;
17
31
  };
18
32
  };
@@ -1,3 +1,12 @@
1
+ import { noUnawaitedAction } from './no-unawaited-action.js';
2
+ import { noBrittleSelector } from './no-brittle-selector.js';
3
+ import { noSharedMutableState, serialModeDependency } from './no-shared-mutable-state.js';
4
+ import { noExcessiveTimeout } from './no-excessive-timeout.js';
5
+ import { noFixmeTest } from './no-fixme-test.js';
6
+ import { noSwallowedAssertion } from './no-swallowed-assertion.js';
7
+ import { noWeakAssertion } from './no-weak-assertion.js';
8
+ import { noUnawaitedExpectValue } from './no-unawaited-expect-value.js';
9
+ import { noSelfFulfillingFallback } from './no-self-fulfilling-fallback.js';
1
10
  /**
2
11
  * `testNames` is `'test'` plus any `test.extend(...)`-derived fixture
3
12
  * aliases detected by test-aliases.ts (see eslint-runner.ts, which feeds
@@ -175,6 +184,64 @@ function callsResolve(fn, resolveName) {
175
184
  call.callee?.type === 'Identifier' &&
176
185
  call.callee.name === resolveName);
177
186
  }
187
+ const LOOP_TYPES = new Set(['ForStatement', 'ForInStatement', 'ForOfStatement', 'WhileStatement', 'DoWhileStatement']);
188
+ /**
189
+ * A retry/backoff pause inside a plain helper function (an API retry loop,
190
+ * `for (...) { try { await call() } catch { await sleep(500) } }`) is not a
191
+ * test waiting on the UI: it paces calls to a service. Only that shape is
192
+ * exempt: the sleep sits inside a catch clause, or inside a loop whose body
193
+ * contains a try/catch, and the enclosing function is *defined* (function
194
+ * declaration, `const f = () => ...`, class method) and is not itself nested
195
+ * in a callback handed to a call (a test, hook, describe or step body). A
196
+ * hand-rolled polling wait (`while (await x.isVisible()) sleep()`), a sleep
197
+ * directly in a test body or hook, a helper declared inside a test callback
198
+ * and a bare `sleep()` helper are all still flagged. `from` is the ancestor
199
+ * index to start climbing from (the Promise executor's own parent).
200
+ */
201
+ function isRetryBackoffInHelper(ancestors, from) {
202
+ let retryShape = false;
203
+ let helperFound = false;
204
+ for (let i = from; i >= 0; i--) {
205
+ const anc = ancestors[i];
206
+ if (!helperFound) {
207
+ if (anc.type === 'CatchClause')
208
+ retryShape = true;
209
+ if (LOOP_TYPES.has(anc.type) && containsTryCatch(anc.body))
210
+ retryShape = true;
211
+ }
212
+ if (!FUNCTION_TYPES.has(anc.type) && anc.type !== 'FunctionDeclaration')
213
+ continue;
214
+ const parent = ancestors[i - 1];
215
+ if (parent?.type === 'CallExpression' || parent?.type === 'NewExpression') {
216
+ // Callback handed to a call (test body, hook, describe, .then, step):
217
+ // either the sleep is not in a free-standing helper at all, or the
218
+ // helper lives inside such a callback. Not exempt either way.
219
+ return false;
220
+ }
221
+ if (helperFound)
222
+ continue; // a function around the helper: keep checking the callback chain
223
+ if (anc.type === 'FunctionDeclaration' ||
224
+ parent?.type === 'MethodDefinition' ||
225
+ parent?.type === 'PropertyDefinition' ||
226
+ (parent?.type === 'VariableDeclarator' && parent.init === anc)) {
227
+ if (!retryShape)
228
+ return false;
229
+ helperFound = true;
230
+ }
231
+ }
232
+ return helperFound;
233
+ }
234
+ function containsTryCatch(root) {
235
+ let found = false;
236
+ walkNode(root, (n) => {
237
+ if (n.type !== 'TryStatement' || !n.handler)
238
+ return;
239
+ // An empty `try {}` is not a retry shape.
240
+ if ((n.block?.body ?? []).length > 0)
241
+ found = true;
242
+ });
243
+ return found;
244
+ }
178
245
  /**
179
246
  * `await new Promise((resolve) => setTimeout(resolve, ms))` is the same
180
247
  * hard-coded sleep as `page.waitForTimeout(ms)` (already flagged, as an
@@ -289,7 +356,7 @@ const noTimerSleep = {
289
356
  firstArg.name === resolveParam.name;
290
357
  const resolveName = resolveParam?.type === 'Identifier' ? resolveParam.name : undefined;
291
358
  const isWrappedResolve = typeof resolveName === 'string' && callsResolve(firstArg, resolveName);
292
- if (isDirectResolve || isWrappedResolve) {
359
+ if ((isDirectResolve || isWrappedResolve) && !isRetryBackoffInHelper(ancestors, i - 1)) {
293
360
  context.report({ node: node, messageId: 'sleep' });
294
361
  }
295
362
  }
@@ -517,8 +584,119 @@ function collectConstBindings(root) {
517
584
  bindings.set(d.id.name, value);
518
585
  }
519
586
  });
587
+ // A `const` array is still mutable: `const errors = []; page.on('pageerror',
588
+ // (e) => errors.push(e)); expect(errors).toEqual([])` is a real assertion
589
+ // (the listener fills the array), not `expect([]).toEqual([])`. Only keep
590
+ // an array binding when every reference to it is the plain subject of an
591
+ // expect(...) call; any other use (method call, passed along, spread,
592
+ // reassigned element) might mutate it, so it is not a constant.
593
+ for (const [name, value] of [...bindings]) {
594
+ if (value.ok && Array.isArray(value.value) && !isOnlyReadAsExpectSubject(root, name)) {
595
+ bindings.delete(name);
596
+ }
597
+ }
520
598
  return bindings;
521
599
  }
600
+ const MUTATING_METHODS = new Set([
601
+ 'push', 'unshift', 'splice', 'pop', 'shift', 'sort', 'reverse', 'fill', 'copyWithin',
602
+ ]);
603
+ /**
604
+ * True when no reference to `name` under `root` could change the array:
605
+ * every reference is a declaration, the subject of expect(...), a `.length`
606
+ * or index read, or the receiver of a non-mutating method. A reference that
607
+ * is a call argument (it may be handed to code that fills it), the
608
+ * receiver of a mutating method, an assignment/update target, or any other
609
+ * use that aliases the array (returned, assigned on, spread, put in an
610
+ * object) counts as possibly mutating, wherever it sits — including inside
611
+ * a closure such as a listener. Same-named properties (`obj.name`,
612
+ * `{ name: 1 }`) are not references. Shadowing is not resolved: a shadowing
613
+ * binding is read like the outer one, which can only keep a finding quiet.
614
+ */
615
+ function isOnlyReadAsExpectSubject(root, name) {
616
+ let ok = true;
617
+ const visit = (node, stack) => {
618
+ if (!ok || !node || typeof node !== 'object')
619
+ return;
620
+ if (Array.isArray(node)) {
621
+ for (const item of node)
622
+ visit(item, stack);
623
+ return;
624
+ }
625
+ const rec = node;
626
+ if (typeof rec.type !== 'string')
627
+ return;
628
+ const n = node;
629
+ if (n.type === 'Identifier' && n.name === name) {
630
+ if (referenceMayMutate(n, stack))
631
+ ok = false;
632
+ return;
633
+ }
634
+ const next = [...stack, n];
635
+ for (const key of Object.keys(rec)) {
636
+ if (key === 'parent')
637
+ continue;
638
+ const v = rec[key];
639
+ if (v && typeof v === 'object')
640
+ visit(v, next);
641
+ }
642
+ };
643
+ visit(root, []);
644
+ return ok;
645
+ }
646
+ /** Is the member expression `m` itself written, deleted or the receiver of a mutating method? */
647
+ function memberMutates(m, above) {
648
+ if (above.type === 'AssignmentExpression' && above.left === m)
649
+ return true;
650
+ if (above.type === 'UpdateExpression')
651
+ return true;
652
+ if (above.type === 'UnaryExpression' && above.operator === 'delete')
653
+ return true;
654
+ const prop = m.property;
655
+ return (!m.computed &&
656
+ prop?.type === 'Identifier' &&
657
+ !!prop.name &&
658
+ MUTATING_METHODS.has(prop.name) &&
659
+ above.type === 'CallExpression' &&
660
+ above.callee === m);
661
+ }
662
+ function referenceMayMutate(id, stack) {
663
+ const parent = stack[stack.length - 1];
664
+ const grand = stack[stack.length - 2];
665
+ if (!parent)
666
+ return true;
667
+ switch (parent.type) {
668
+ case 'VariableDeclarator':
669
+ return parent.id !== id; // `const b = expected` aliases it
670
+ case 'CallExpression':
671
+ if (parent.arguments?.[0] === id && isExpectCallNode(parent))
672
+ return false;
673
+ return parent.callee !== id || false; // an argument, or the array called
674
+ case 'MemberExpression': {
675
+ if (parent.object !== id)
676
+ return false; // `obj.expected`: a property name, not this binding
677
+ // Mutation of the member itself (`a.x = 1`, `a.push(1)`), then, for a
678
+ // computed access chain (`rows[0].push(9)`, `rows[0][1] = 2`), of
679
+ // the element it reaches: the inner array is part of the asserted one.
680
+ let member = parent;
681
+ for (let up = stack.length - 2; up >= 0; up--) {
682
+ const above = stack[up];
683
+ if (memberMutates(member, above))
684
+ return true;
685
+ if (!member.computed || above.type !== 'MemberExpression' || above.object !== member)
686
+ break;
687
+ member = above;
688
+ }
689
+ return false; // `.length`, `[0]`, `.map(...)`, ... are reads
690
+ }
691
+ case 'Property':
692
+ case 'PropertyDefinition':
693
+ // Non-computed key position is a name, not a reference; a value (or
694
+ // shorthand) puts the array inside an object.
695
+ return !((parent.key === id || false) && !parent.computed && !parent.shorthand);
696
+ default:
697
+ return true;
698
+ }
699
+ }
522
700
  /**
523
701
  * Climbs from `node` to the body of the nearest enclosing function (the
524
702
  * test/hook callback in the common case) — the scope `noTrivialAssertion`
@@ -756,13 +934,72 @@ const noSoftAssertionOnlyTest = {
756
934
  };
757
935
  },
758
936
  };
937
+ const rawRules = {
938
+ 'no-skipped-test-declaration': noSkippedTestDeclaration,
939
+ 'no-timer-sleep': noTimerSleep,
940
+ 'no-coordinate-click': noCoordinateClick,
941
+ 'no-trivial-assertion': noTrivialAssertion,
942
+ 'no-soft-assertion-only-test': noSoftAssertionOnlyTest,
943
+ 'no-unawaited-action': noUnawaitedAction,
944
+ 'no-brittle-selector': noBrittleSelector,
945
+ 'no-shared-mutable-state': noSharedMutableState,
946
+ 'serial-mode-dependency': serialModeDependency,
947
+ 'no-excessive-timeout': noExcessiveTimeout,
948
+ 'no-fixme-test': noFixmeTest,
949
+ 'no-swallowed-assertion': noSwallowedAssertion,
950
+ 'no-weak-assertion': noWeakAssertion,
951
+ 'no-unawaited-expect-value': noUnawaitedExpectValue,
952
+ 'no-self-fulfilling-fallback': noSelfFulfillingFallback,
953
+ };
954
+ /**
955
+ * Rule crashes are contained: an exception thrown while a rule builds its
956
+ * listeners or runs one on some unusual syntax must not abort the run for
957
+ * the whole suite. The rule simply reports nothing for that node. Crashes are
958
+ * recorded so tests can assert there are none.
959
+ */
960
+ export const ruleCrashes = [];
961
+ /** Clear the crash log; scorePaths calls this at the start of every run. */
962
+ export function resetRuleCrashes() {
963
+ ruleCrashes.length = 0;
964
+ }
965
+ /** Crashes of the current run, one entry per rule, in first-crash order. */
966
+ export function summarizeRuleCrashes() {
967
+ const counts = new Map();
968
+ for (const c of ruleCrashes)
969
+ counts.set(c.rule, (counts.get(c.rule) ?? 0) + 1);
970
+ return [...counts].map(([rule, count]) => ({ rule, count }));
971
+ }
972
+ export function contain(name, rule) {
973
+ return {
974
+ ...rule,
975
+ create(context) {
976
+ let listeners;
977
+ try {
978
+ listeners = rule.create(context);
979
+ }
980
+ catch (error) {
981
+ ruleCrashes.push({ rule: name, error });
982
+ return {};
983
+ }
984
+ const safe = {};
985
+ for (const [key, fn] of Object.entries(listeners)) {
986
+ if (typeof fn !== 'function')
987
+ continue;
988
+ safe[key] = (...args) => {
989
+ try {
990
+ return fn(...args);
991
+ }
992
+ catch (error) {
993
+ ruleCrashes.push({ rule: name, error });
994
+ return undefined;
995
+ }
996
+ };
997
+ }
998
+ return safe;
999
+ },
1000
+ };
1001
+ }
759
1002
  export const basePlugin = {
760
1003
  meta: { name: 'pwscore-base', version: '0.1.0' },
761
- rules: {
762
- 'no-skipped-test-declaration': noSkippedTestDeclaration,
763
- 'no-timer-sleep': noTimerSleep,
764
- 'no-coordinate-click': noCoordinateClick,
765
- 'no-trivial-assertion': noTrivialAssertion,
766
- 'no-soft-assertion-only-test': noSoftAssertionOnlyTest,
767
- },
1004
+ rules: Object.fromEntries(Object.entries(rawRules).map(([name, rule]) => [name, contain(name, rule)])),
768
1005
  };
@@ -0,0 +1,3 @@
1
+ import type { Rule } from 'eslint';
2
+ export declare function brittleReason(sel: string): string | undefined;
3
+ export declare const noBrittleSelector: Rule.RuleModule;
@@ -0,0 +1,150 @@
1
+ import { isPwReceiver, propName, staticString, unwrap } from './rule-util.js';
2
+ /**
3
+ * `pwscore/no-brittle-selector` (QAG-287). Selector strings that encode
4
+ * page structure instead of what the user sees. Matches, on the first
5
+ * argument of `.locator(...)`, `.frameLocator(...)` and the legacy
6
+ * selector-taking page methods (`page.click('..')`, `page.$('..')`, ...)
7
+ * when it is a fully static string:
8
+ * - XPath: `xpath=...`, or a string starting with `//` or `(//`
9
+ * - `:nth-child(` / `:nth-of-type(`
10
+ * - a CSS chain with 4 or more combinators (`>`, `+`, `~`, descendant
11
+ * space) outside brackets/parentheses/quotes
12
+ * - generated class names: `.css-1a2b3c`, `.sc-bdfBwQ`, `.jss123`,
13
+ * `.MuiBox-root-12`, `.makeStyles-root-12`
14
+ * and `.nth(n)` / `.first()` / `.last()` applied directly to a raw
15
+ * `.locator('<string>')` (a `data-testid`/`role=`/`text=` selector string
16
+ * is exempt: it names something stable).
17
+ *
18
+ * Reported as warning but report-only (evidence, not score): the locators
19
+ * dimension already counts every raw `.locator()`/legacy selector call in
20
+ * its native/raw ratio; scoring these as well would charge the same call
21
+ * twice. The finding says why the raw call is fragile.
22
+ */
23
+ const SELECTOR_METHODS = new Set(['locator', 'frameLocator']);
24
+ const LEGACY = new Set([
25
+ 'click', 'dblclick', 'fill', 'type', 'press', 'check', 'uncheck', 'hover', 'focus',
26
+ 'selectOption', 'textContent', 'innerText', 'innerHTML', 'getAttribute', 'inputValue',
27
+ 'isVisible', 'isChecked', 'isEnabled', 'isDisabled', 'isHidden', 'waitForSelector', '$', '$$',
28
+ 'setInputFiles', 'dispatchEvent', 'tap',
29
+ ]);
30
+ function countCombinators(css) {
31
+ let depthParen = 0;
32
+ let depthBracket = 0;
33
+ let quote = '';
34
+ let count = 0;
35
+ let pendingSpace = false;
36
+ let sawCompound = false;
37
+ for (let i = 0; i < css.length; i++) {
38
+ const ch = css[i];
39
+ if (quote) {
40
+ if (ch === '\\')
41
+ i++;
42
+ else if (ch === quote)
43
+ quote = '';
44
+ continue;
45
+ }
46
+ if (ch === '"' || ch === "'") {
47
+ quote = ch;
48
+ sawCompound = true;
49
+ continue;
50
+ }
51
+ if (ch === '(') {
52
+ depthParen++;
53
+ continue;
54
+ }
55
+ if (ch === ')') {
56
+ depthParen = Math.max(0, depthParen - 1);
57
+ continue;
58
+ }
59
+ if (ch === '[') {
60
+ depthBracket++;
61
+ continue;
62
+ }
63
+ if (ch === ']') {
64
+ depthBracket = Math.max(0, depthBracket - 1);
65
+ continue;
66
+ }
67
+ if (depthParen || depthBracket)
68
+ continue;
69
+ if (ch === ',')
70
+ return 0; // selector list: do not guess
71
+ if (ch === '>' || ch === '+' || ch === '~') {
72
+ if (sawCompound)
73
+ count++;
74
+ sawCompound = false;
75
+ pendingSpace = false;
76
+ continue;
77
+ }
78
+ if (/\s/.test(ch)) {
79
+ if (sawCompound)
80
+ pendingSpace = true;
81
+ continue;
82
+ }
83
+ if (pendingSpace) {
84
+ count++;
85
+ pendingSpace = false;
86
+ }
87
+ sawCompound = true;
88
+ }
89
+ return count;
90
+ }
91
+ export function brittleReason(sel) {
92
+ const s = sel.trim();
93
+ if (/^xpath=/i.test(s) || s.startsWith('//') || s.startsWith('(//'))
94
+ return 'an XPath selector';
95
+ if (/:nth-(child|of-type|last-child|last-of-type)\(/.test(s))
96
+ return 'a positional :nth-child/:nth-of-type selector';
97
+ if (/\.css-(?=[a-z0-9]*\d)[a-z0-9]{5,}\b/.test(s) || /\.sc-(?=[A-Za-z0-9]*[A-Z0-9])[A-Za-z0-9]{5,}/.test(s) ||
98
+ /\.jss\d+/.test(s) || /\.Mui[A-Za-z]+-[A-Za-z]+-\d+/.test(s) || /\.makeStyles-[A-Za-z]+-\d+/.test(s)) {
99
+ return 'a generated class name that changes between builds';
100
+ }
101
+ // Only plain CSS gets the combinator count (not text=/role=/>> chains).
102
+ if (!/^[a-z-]+=/i.test(s) || /^css=/i.test(s)) {
103
+ const css = s.replace(/^css=/i, '');
104
+ if (!css.includes('>>') && countCombinators(css) >= 4)
105
+ return 'a long CSS chain (4+ combinators) tied to the page structure';
106
+ }
107
+ return undefined;
108
+ }
109
+ export const noBrittleSelector = {
110
+ meta: {
111
+ type: 'suggestion',
112
+ docs: { description: 'Disallow selectors that encode page structure' },
113
+ messages: {
114
+ brittle: 'Brittle selector: {{reason}}. It breaks when markup is reorganised; prefer getByRole/getByLabel/getByTestId.',
115
+ positional: '`.{{method}}()` on a raw `.locator(...)` picks an element by its position, which changes with the data or layout. Narrow the locator instead (role, label, test id, filter).',
116
+ },
117
+ schema: [],
118
+ },
119
+ create(context) {
120
+ return {
121
+ CallExpression(node) {
122
+ const callee = unwrap(node.callee);
123
+ if (callee?.type !== 'MemberExpression')
124
+ return;
125
+ const name = propName(callee);
126
+ if (!name)
127
+ return;
128
+ if (SELECTOR_METHODS.has(name) || (LEGACY.has(name) && isPwReceiver(context, callee.object))) {
129
+ const sel = staticString(node.arguments?.[0]);
130
+ if (sel !== undefined) {
131
+ const reason = brittleReason(sel);
132
+ if (reason)
133
+ context.report({ node: node, messageId: 'brittle', data: { reason } });
134
+ }
135
+ }
136
+ if (name === 'first' || name === 'last' || name === 'nth') {
137
+ const inner = unwrap(callee.object);
138
+ if (inner?.type === 'CallExpression' && inner.callee?.type === 'MemberExpression' && propName(inner.callee) === 'locator') {
139
+ const sel = staticString(inner.arguments?.[0]);
140
+ if (sel === undefined)
141
+ return;
142
+ if (/data-(testid|test-id|test|cy|qa|pw)|^role=|^text=|^internal:|^id=|^testid=/i.test(sel.trim()))
143
+ return;
144
+ context.report({ node: node, messageId: 'positional', data: { method: name } });
145
+ }
146
+ }
147
+ },
148
+ };
149
+ },
150
+ };
@@ -0,0 +1,2 @@
1
+ import type { Rule } from 'eslint';
2
+ export declare const noExcessiveTimeout: Rule.RuleModule;