@qaguardian/playwright-score 2.2.0 → 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 (55) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/METHODOLOGY.md +41 -0
  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/formatters/json.js +4 -1
  9. package/dist/formatters/markdown.d.ts +2 -0
  10. package/dist/formatters/markdown.js +15 -4
  11. package/dist/formatters/report-css.d.ts +2 -0
  12. package/dist/formatters/report-css.js +68 -0
  13. package/dist/formatters/report.d.ts +20 -0
  14. package/dist/formatters/report.js +279 -0
  15. package/dist/formatters/text.d.ts +6 -1
  16. package/dist/formatters/text.js +73 -28
  17. package/dist/index.js +17 -6
  18. package/dist/profiles.js +12 -0
  19. package/dist/report-model.d.ts +52 -0
  20. package/dist/report-model.js +162 -0
  21. package/dist/report-source.d.ts +11 -0
  22. package/dist/report-source.js +96 -0
  23. package/dist/rule-copy.d.ts +14 -0
  24. package/dist/rule-copy.js +215 -0
  25. package/dist/rules/ast-util.d.ts +60 -0
  26. package/dist/rules/ast-util.js +140 -0
  27. package/dist/rules/base-plugin.d.ts +19 -5
  28. package/dist/rules/base-plugin.js +75 -7
  29. package/dist/rules/no-brittle-selector.d.ts +3 -0
  30. package/dist/rules/no-brittle-selector.js +150 -0
  31. package/dist/rules/no-excessive-timeout.d.ts +2 -0
  32. package/dist/rules/no-excessive-timeout.js +87 -0
  33. package/dist/rules/no-fixme-test.d.ts +14 -0
  34. package/dist/rules/no-fixme-test.js +53 -0
  35. package/dist/rules/no-self-fulfilling-fallback.d.ts +29 -0
  36. package/dist/rules/no-self-fulfilling-fallback.js +221 -0
  37. package/dist/rules/no-shared-mutable-state.d.ts +4 -0
  38. package/dist/rules/no-shared-mutable-state.js +186 -0
  39. package/dist/rules/no-swallowed-assertion.d.ts +13 -0
  40. package/dist/rules/no-swallowed-assertion.js +113 -0
  41. package/dist/rules/no-unawaited-action.d.ts +2 -0
  42. package/dist/rules/no-unawaited-action.js +77 -0
  43. package/dist/rules/no-unawaited-expect-value.d.ts +20 -0
  44. package/dist/rules/no-unawaited-expect-value.js +46 -0
  45. package/dist/rules/no-weak-assertion.d.ts +19 -0
  46. package/dist/rules/no-weak-assertion.js +82 -0
  47. package/dist/rules/rule-util.d.ts +56 -0
  48. package/dist/rules/rule-util.js +253 -0
  49. package/dist/score-engine.d.ts +5 -0
  50. package/dist/score-engine.js +9 -9
  51. package/dist/suite-name.d.ts +11 -0
  52. package/dist/suite-name.js +36 -0
  53. package/dist/test-aliases.js +144 -3
  54. package/dist/types.d.ts +14 -0
  55. package/package.json +1 -1
@@ -0,0 +1,46 @@
1
+ import { isPlaywrightReceiver, memberName, parseMatcherCall, resolveConstInit } from './ast-util.js';
2
+ export const PROMISE_METHODS = new Set([
3
+ 'innerText', 'innerHTML', 'textContent', 'inputValue', 'getAttribute', 'isVisible', 'isHidden',
4
+ 'isEnabled', 'isDisabled', 'isChecked', 'isEditable', 'count', 'title', 'allInnerTexts',
5
+ 'allTextContents', 'boundingBox', 'content',
6
+ ]);
7
+ const WEB_FIRST = /^(toHave|toBe(Visible|Hidden|Enabled|Disabled|Checked|Editable|Empty|Focused|Attached|InViewport|OK)$|toContainText$|toMatchAriaSnapshot$|toPass$|toMatchSnapshot$|toHaveScreenshot$)/;
8
+ const VALUE_ARG_MATCHERS = new Set(['toBe', 'toEqual', 'toStrictEqual']);
9
+ export const noUnawaitedExpectValue = {
10
+ meta: {
11
+ type: 'problem',
12
+ docs: { description: 'Disallow un-awaited Promise-returning Playwright calls inside expect()' },
13
+ messages: {
14
+ missingAwait: 'Missing await: {{call}}() returns a Promise, and this matcher compares the Promise itself, not the value (so it always passes or always fails). Add await, or use a web-first assertion on the locator.',
15
+ },
16
+ schema: [],
17
+ },
18
+ create(context) {
19
+ function bareCall(n0) {
20
+ // `const t = page.title(); expect(t)...`: a const initialised from an un-awaited call.
21
+ const n = n0?.type === 'Identifier' ? resolveConstInit(context, n0) : n0;
22
+ if (!n || n.type !== 'CallExpression')
23
+ return undefined; // AwaitExpression etc. are not bare
24
+ const name = memberName(n.callee);
25
+ if (!name || !PROMISE_METHODS.has(name))
26
+ return undefined;
27
+ return isPlaywrightReceiver(context, n.callee?.object) ? name : undefined;
28
+ }
29
+ return {
30
+ CallExpression(raw) {
31
+ const node = raw;
32
+ const m = parseMatcherCall(node);
33
+ if (!m || m.modifiers.some((x) => x === 'resolves' || x === 'rejects'))
34
+ return;
35
+ if (WEB_FIRST.test(m.matcher))
36
+ return;
37
+ const isPoll = m.expectCall.callee?.type === 'MemberExpression' && m.expectCall.callee.property?.name === 'poll';
38
+ const subject = (m.expectCall.arguments ?? [])[0];
39
+ const hit = (!isPoll && bareCall(subject)) ||
40
+ (VALUE_ARG_MATCHERS.has(m.matcher) ? bareCall((node.arguments ?? [])[0]) : undefined);
41
+ if (hit)
42
+ context.report({ node: raw, messageId: 'missingAwait', data: { call: hit } });
43
+ },
44
+ };
45
+ },
46
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Assertions that cannot fail, or assert almost nothing. Exact shapes:
3
+ *
4
+ * 1. `expect(<count>).toBeGreaterThanOrEqual(0)` / `.toBeGreaterThan(-1)`
5
+ * (no `.not`), where <count> is `.length`, `[await] x.count()`, a const
6
+ * initialised from one of those, or `expect.poll(() => x.count())`.
7
+ * 2. `expect(<Locator>).toBeTruthy()` / `.toBeDefined()` / `.not.toBeNull()`
8
+ * / `.not.toBeUndefined()`; <Locator> is syntactic only: `page.locator()`,
9
+ * `x.getBy*()`, those chained with first/last/nth/filter/locator, or a
10
+ * const initialised from one. A Locator object is always truthy.
11
+ * 3. `expect(await x.isVisible()).toBeDefined()` (also isHidden, isEnabled,
12
+ * isDisabled, isChecked, isEditable, count, innerText, innerHTML,
13
+ * inputValue, title; textContent/getAttribute only for toBeDefined):
14
+ * these always return a value, so "defined" proves nothing.
15
+ *
16
+ * `toBeTruthy()` on any other value is NOT flagged.
17
+ */
18
+ import type { Rule } from 'eslint';
19
+ export declare const noWeakAssertion: Rule.RuleModule;
@@ -0,0 +1,82 @@
1
+ import { FN_TYPES, isLocatorExpr, memberName, parseMatcherCall, resolveConstInit, unwrap } from './ast-util.js';
2
+ const ALWAYS_VALUE = new Set([
3
+ 'isVisible', 'isHidden', 'isEnabled', 'isDisabled', 'isChecked', 'isEditable',
4
+ 'count', 'innerText', 'innerHTML', 'inputValue', 'title',
5
+ ]);
6
+ const MAYBE_NULL = new Set(['textContent', 'getAttribute']); // null is "defined"
7
+ function isCountish(context, node, depth = 0) {
8
+ const n = unwrap(node);
9
+ if (!n || depth > 2)
10
+ return false;
11
+ if (n.type === 'MemberExpression' && !n.computed && n.property?.name === 'length')
12
+ return true;
13
+ if (n.type === 'CallExpression' && memberName(n.callee) === 'count')
14
+ return true;
15
+ if (n.type === 'Identifier') {
16
+ const init = resolveConstInit(context, n);
17
+ return init ? isCountish(context, init, depth + 1) : false;
18
+ }
19
+ return false;
20
+ }
21
+ function negativeLiteral(arg, want) {
22
+ if (!arg)
23
+ return false;
24
+ if (arg.type === 'Literal')
25
+ return arg.value === want;
26
+ return arg.type === 'UnaryExpression' && arg.operator === '-' && arg.argument?.type === 'Literal' && -arg.argument.value === want;
27
+ }
28
+ export const noWeakAssertion = {
29
+ meta: {
30
+ type: 'problem',
31
+ docs: { description: 'Disallow assertions that cannot fail or assert almost nothing' },
32
+ messages: {
33
+ count: 'A count is never negative, so this assertion cannot fail. Assert the expected count, or use expect(locator).toHaveCount(n).',
34
+ locator: 'A Locator object is always truthy/defined, so this assertion cannot fail. Use a web-first assertion such as expect(locator).toBeVisible().',
35
+ defined: 'This call always returns a value, so asserting it is defined cannot fail. Assert the value itself, or use a web-first assertion.',
36
+ },
37
+ schema: [],
38
+ },
39
+ create(context) {
40
+ return {
41
+ CallExpression(raw) {
42
+ const node = raw;
43
+ const m = parseMatcherCall(node);
44
+ if (!m || m.modifiers.some((x) => x === 'resolves' || x === 'rejects'))
45
+ return;
46
+ const not = m.modifiers.includes('not');
47
+ const arg0 = (m.expectCall.arguments ?? [])[0];
48
+ const isPoll = m.expectCall.callee?.type === 'MemberExpression' && m.expectCall.callee.property?.name === 'poll';
49
+ // 1. count >= 0
50
+ if (!not && ((m.matcher === 'toBeGreaterThanOrEqual' && negativeLiteral((node.arguments ?? [])[0], 0)) ||
51
+ (m.matcher === 'toBeGreaterThan' && negativeLiteral((node.arguments ?? [])[0], -1)))) {
52
+ let subject = arg0;
53
+ if (isPoll && subject && FN_TYPES.has(subject.type) && subject.type === 'ArrowFunctionExpression' && subject.body.type !== 'BlockStatement') {
54
+ subject = subject.body;
55
+ }
56
+ if (isCountish(context, subject))
57
+ context.report({ node: raw, messageId: 'count' });
58
+ return;
59
+ }
60
+ // 2. Locator truthy
61
+ const truthy = (!not && (m.matcher === 'toBeTruthy' || m.matcher === 'toBeDefined')) ||
62
+ (not && (m.matcher === 'toBeNull' || m.matcher === 'toBeUndefined'));
63
+ if (truthy && !isPoll) {
64
+ if (isLocatorExpr(context, arg0)) {
65
+ context.report({ node: raw, messageId: 'locator' });
66
+ return;
67
+ }
68
+ // 3. value-returning call asserted "defined"
69
+ const subj = unwrap(arg0);
70
+ const name = subj?.type === 'CallExpression' ? memberName(subj.callee) : undefined;
71
+ if (name) {
72
+ const definedCheck = (!not && m.matcher === 'toBeDefined') || (not && m.matcher === 'toBeUndefined');
73
+ const nullCheck = not && m.matcher === 'toBeNull';
74
+ if ((definedCheck && (ALWAYS_VALUE.has(name) || MAYBE_NULL.has(name))) || (nullCheck && ALWAYS_VALUE.has(name))) {
75
+ context.report({ node: raw, messageId: 'defined' });
76
+ }
77
+ }
78
+ }
79
+ },
80
+ };
81
+ },
82
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Small AST helpers shared by the QAG-287 flake-risk rules (one rule per
3
+ * file). Nodes are typed loosely on purpose: the parser may produce
4
+ * TypeScript-ESTree node types that the plain ESTree typings do not model.
5
+ */
6
+ export type AnyNode = any;
7
+ /** Strips `x!`, `x as T` and optional-chain wrappers. */
8
+ export declare function unwrap(node: AnyNode): AnyNode;
9
+ /** Name of a non-computed member property (or string-literal computed one). */
10
+ export declare function propName(member: AnyNode): string | undefined;
11
+ /**
12
+ * True when the expression is *syntactically* a Playwright Page or Locator:
13
+ * the identifier `page`, a `.page` property (`this.page`, `fixtures.page`),
14
+ * a locator-producing call chain on one of those (`page.getByRole(..)`,
15
+ * `page.locator(..).first()`), or an identifier bound once by `const` to
16
+ * such an expression. Anything else (a parameter called `el`, a jQuery
17
+ * wrapper, an array) is not a receiver, so a method that happens to share a
18
+ * Playwright name on another object is never matched.
19
+ */
20
+ export declare function isPwReceiver(context: {
21
+ sourceCode: AnyNode;
22
+ }, expr: AnyNode, depth?: number): boolean;
23
+ /** Nearest enclosing function node, or undefined at module level. */
24
+ export declare function enclosingFunction(node: AnyNode): AnyNode;
25
+ /** Evaluates a numeric literal / simple arithmetic expression, else undefined. */
26
+ export declare function evalNumber(node: AnyNode): number | undefined;
27
+ /** Static string value of a literal or an expression-free template literal. */
28
+ export declare function staticString(node: AnyNode): string | undefined;
29
+ /** `test(...)`, `test.only(...)`, `it(...)` — a test declaration call (not describe/hook). */
30
+ export declare function isTestDeclaration(call: AnyNode, testNames: ReadonlySet<string>): boolean;
31
+ export declare function hookName(call: AnyNode, testNames: ReadonlySet<string>): string | undefined;
32
+ /** `test.describe(...)`, `describe(...)`, `test.describe.serial/parallel/only(...)`. */
33
+ export declare function isDescribeCall(call: AnyNode, testNames: ReadonlySet<string>): boolean;
34
+ /** `test.describe.serial(...)` call. */
35
+ export declare function isSerialDescribeCall(call: AnyNode, testNames: ReadonlySet<string>): boolean;
36
+ /** `test.describe.configure({ mode: 'serial' })` call. */
37
+ export declare function isSerialConfigureCall(call: AnyNode, testNames: ReadonlySet<string>): boolean;
38
+ /**
39
+ * Is `node` in a scope declared serial: the file has a top-level
40
+ * `test.describe.configure({ mode: 'serial' })`, or any enclosing describe is
41
+ * `test.describe.serial(...)` or configures serial mode in its own body.
42
+ */
43
+ export declare function isInSerialScope(node: AnyNode, testNames: ReadonlySet<string>): boolean;
44
+ export declare function testNamesFromOptions(options: unknown[]): Set<string>;
45
+ export declare const TEST_ALIAS_SCHEMA: {
46
+ type: "object";
47
+ properties: {
48
+ testAliasNames: {
49
+ items: {
50
+ type: "string";
51
+ };
52
+ type: "array";
53
+ };
54
+ };
55
+ additionalProperties: boolean;
56
+ }[];
@@ -0,0 +1,253 @@
1
+ /** Strips `x!`, `x as T` and optional-chain wrappers. */
2
+ export function unwrap(node) {
3
+ let n = node;
4
+ while (n &&
5
+ (n.type === 'TSNonNullExpression' ||
6
+ n.type === 'ChainExpression' ||
7
+ n.type === 'TSAsExpression' ||
8
+ n.type === 'TSSatisfiesExpression' ||
9
+ n.type === 'TSTypeAssertion')) {
10
+ n = n.expression;
11
+ }
12
+ return n;
13
+ }
14
+ /** Name of a non-computed member property (or string-literal computed one). */
15
+ export function propName(member) {
16
+ if (!member || member.type !== 'MemberExpression')
17
+ return undefined;
18
+ const p = member.property;
19
+ if (!member.computed && p?.type === 'Identifier')
20
+ return p.name;
21
+ if (member.computed && p?.type === 'Literal' && typeof p.value === 'string')
22
+ return p.value;
23
+ return undefined;
24
+ }
25
+ const LOCATOR_PRODUCERS = new Set([
26
+ 'locator',
27
+ 'getByRole',
28
+ 'getByText',
29
+ 'getByLabel',
30
+ 'getByPlaceholder',
31
+ 'getByAltText',
32
+ 'getByTitle',
33
+ 'getByTestId',
34
+ 'first',
35
+ 'last',
36
+ 'nth',
37
+ 'filter',
38
+ 'and',
39
+ 'or',
40
+ 'frameLocator',
41
+ 'contentFrame',
42
+ ]);
43
+ function findConstInit(context, node, name) {
44
+ let scope = context.sourceCode.getScope(node);
45
+ while (scope) {
46
+ const v = scope.set?.get(name);
47
+ if (v) {
48
+ const defs = v.defs ?? [];
49
+ if (defs.length !== 1)
50
+ return undefined;
51
+ const def = defs[0];
52
+ if (def.type !== 'Variable' || def.parent?.kind !== 'const')
53
+ return undefined;
54
+ return def.node?.init;
55
+ }
56
+ scope = scope.upper;
57
+ }
58
+ return undefined;
59
+ }
60
+ /**
61
+ * True when the expression is *syntactically* a Playwright Page or Locator:
62
+ * the identifier `page`, a `.page` property (`this.page`, `fixtures.page`),
63
+ * a locator-producing call chain on one of those (`page.getByRole(..)`,
64
+ * `page.locator(..).first()`), or an identifier bound once by `const` to
65
+ * such an expression. Anything else (a parameter called `el`, a jQuery
66
+ * wrapper, an array) is not a receiver, so a method that happens to share a
67
+ * Playwright name on another object is never matched.
68
+ */
69
+ export function isPwReceiver(context, expr, depth = 0) {
70
+ const n = unwrap(expr);
71
+ if (!n || depth > 4)
72
+ return false;
73
+ if (n.type === 'Identifier') {
74
+ if (n.name === 'page')
75
+ return true;
76
+ const init = findConstInit(context, n, n.name);
77
+ return init ? isPwReceiver(context, init, depth + 1) : false;
78
+ }
79
+ if (n.type === 'MemberExpression') {
80
+ return propName(n) === 'page';
81
+ }
82
+ if (n.type === 'CallExpression' && n.callee?.type === 'MemberExpression') {
83
+ const name = propName(n.callee);
84
+ return !!name && LOCATOR_PRODUCERS.has(name) && isPwReceiver(context, n.callee.object, depth + 1);
85
+ }
86
+ return false;
87
+ }
88
+ /** Nearest enclosing function node, or undefined at module level. */
89
+ export function enclosingFunction(node) {
90
+ let p = node?.parent;
91
+ while (p) {
92
+ if (p.type === 'FunctionDeclaration' ||
93
+ p.type === 'FunctionExpression' ||
94
+ p.type === 'ArrowFunctionExpression') {
95
+ return p;
96
+ }
97
+ p = p.parent;
98
+ }
99
+ return undefined;
100
+ }
101
+ /** Evaluates a numeric literal / simple arithmetic expression, else undefined. */
102
+ export function evalNumber(node) {
103
+ const n = unwrap(node);
104
+ if (!n)
105
+ return undefined;
106
+ if (n.type === 'Literal' && typeof n.value === 'number')
107
+ return n.value;
108
+ if (n.type === 'UnaryExpression' && (n.operator === '-' || n.operator === '+')) {
109
+ const v = evalNumber(n.argument);
110
+ return v === undefined ? undefined : n.operator === '-' ? -v : v;
111
+ }
112
+ if (n.type === 'BinaryExpression' && ['+', '-', '*', '/', '**'].includes(n.operator)) {
113
+ const a = evalNumber(n.left);
114
+ const b = evalNumber(n.right);
115
+ if (a === undefined || b === undefined)
116
+ return undefined;
117
+ switch (n.operator) {
118
+ case '+':
119
+ return a + b;
120
+ case '-':
121
+ return a - b;
122
+ case '*':
123
+ return a * b;
124
+ case '/':
125
+ return b === 0 ? undefined : a / b;
126
+ default:
127
+ return a ** b;
128
+ }
129
+ }
130
+ return undefined;
131
+ }
132
+ /** Static string value of a literal or an expression-free template literal. */
133
+ export function staticString(node) {
134
+ const n = unwrap(node);
135
+ if (!n)
136
+ return undefined;
137
+ if (n.type === 'Literal' && typeof n.value === 'string')
138
+ return n.value;
139
+ if (n.type === 'TemplateLiteral' && n.expressions.length === 0 && n.quasis.length === 1) {
140
+ return n.quasis[0].value.cooked ?? undefined;
141
+ }
142
+ return undefined;
143
+ }
144
+ /** `test(...)`, `test.only(...)`, `it(...)` — a test declaration call (not describe/hook). */
145
+ export function isTestDeclaration(call, testNames) {
146
+ if (call?.type !== 'CallExpression')
147
+ return false;
148
+ const c = call.callee;
149
+ if (c?.type === 'Identifier')
150
+ return testNames.has(c.name) || c.name === 'it';
151
+ if (c?.type === 'MemberExpression' && c.object?.type === 'Identifier') {
152
+ const m = propName(c);
153
+ return (testNames.has(c.object.name) || c.object.name === 'it') && (m === 'only' || m === 'fail' || m === 'fixme');
154
+ }
155
+ return false;
156
+ }
157
+ const HOOKS = new Set(['beforeEach', 'afterEach', 'beforeAll', 'afterAll']);
158
+ export function hookName(call, testNames) {
159
+ const c = call?.type === 'CallExpression' ? call.callee : undefined;
160
+ if (c?.type === 'MemberExpression' && c.object?.type === 'Identifier' && testNames.has(c.object.name)) {
161
+ const m = propName(c);
162
+ if (m && HOOKS.has(m))
163
+ return m;
164
+ }
165
+ if (c?.type === 'Identifier' && HOOKS.has(c.name))
166
+ return c.name;
167
+ return undefined;
168
+ }
169
+ /** `test.describe(...)`, `describe(...)`, `test.describe.serial/parallel/only(...)`. */
170
+ export function isDescribeCall(call, testNames) {
171
+ if (call?.type !== 'CallExpression')
172
+ return false;
173
+ const c = call.callee;
174
+ if (c?.type === 'Identifier')
175
+ return c.name === 'describe';
176
+ if (c?.type !== 'MemberExpression')
177
+ return false;
178
+ const m = propName(c);
179
+ const o = unwrap(c.object);
180
+ if (m === 'describe')
181
+ return o?.type === 'Identifier' && (testNames.has(o.name) || o.name === 'it');
182
+ if (m === 'serial' || m === 'parallel' || m === 'only' || m === 'skip' || m === 'fixme') {
183
+ return (o?.type === 'MemberExpression' &&
184
+ propName(o) === 'describe' &&
185
+ o.object?.type === 'Identifier' &&
186
+ testNames.has(o.object.name));
187
+ }
188
+ return false;
189
+ }
190
+ /** `test.describe.serial(...)` call. */
191
+ export function isSerialDescribeCall(call, testNames) {
192
+ if (call?.type !== 'CallExpression')
193
+ return false;
194
+ const c = unwrap(call.callee);
195
+ if (c?.type !== 'MemberExpression' || propName(c) !== 'serial')
196
+ return false;
197
+ const o = unwrap(c.object);
198
+ return o?.type === 'MemberExpression' && propName(o) === 'describe' && o.object?.type === 'Identifier' && testNames.has(o.object.name);
199
+ }
200
+ /** `test.describe.configure({ mode: 'serial' })` call. */
201
+ export function isSerialConfigureCall(call, testNames) {
202
+ if (call?.type !== 'CallExpression')
203
+ return false;
204
+ const c = unwrap(call.callee);
205
+ if (c?.type !== 'MemberExpression' || propName(c) !== 'configure')
206
+ return false;
207
+ const o = unwrap(c.object);
208
+ if (!(o?.type === 'MemberExpression' && propName(o) === 'describe' && o.object?.type === 'Identifier' && testNames.has(o.object.name)))
209
+ return false;
210
+ const arg = unwrap(call.arguments?.[0]);
211
+ if (arg?.type !== 'ObjectExpression')
212
+ return false;
213
+ return arg.properties.some((p) => p.type === 'Property' && (p.key?.name ?? p.key?.value) === 'mode' && p.value?.type === 'Literal' && p.value.value === 'serial');
214
+ }
215
+ /** Does this statement list directly contain `test.describe.configure({ mode: 'serial' })`? */
216
+ function bodyHasSerialConfigure(body, testNames) {
217
+ return (body ?? []).some((st) => {
218
+ if (st?.type !== 'ExpressionStatement')
219
+ return false;
220
+ const e = unwrap(st.expression);
221
+ return isSerialConfigureCall(e?.type === 'AwaitExpression' ? unwrap(e.argument) : e, testNames);
222
+ });
223
+ }
224
+ /**
225
+ * Is `node` in a scope declared serial: the file has a top-level
226
+ * `test.describe.configure({ mode: 'serial' })`, or any enclosing describe is
227
+ * `test.describe.serial(...)` or configures serial mode in its own body.
228
+ */
229
+ export function isInSerialScope(node, testNames) {
230
+ for (let p = node?.parent; p; p = p.parent) {
231
+ if (p.type === 'Program')
232
+ return bodyHasSerialConfigure(p.body, testNames);
233
+ if ((p.type === 'ArrowFunctionExpression' || p.type === 'FunctionExpression') && p.parent?.type === 'CallExpression' && p.parent.arguments.includes(p)) {
234
+ const call = p.parent;
235
+ if (isSerialDescribeCall(call, testNames))
236
+ return true;
237
+ if (isDescribeCall(call, testNames) && p.body?.type === 'BlockStatement' && bodyHasSerialConfigure(p.body.body, testNames))
238
+ return true;
239
+ }
240
+ }
241
+ return false;
242
+ }
243
+ export function testNamesFromOptions(options) {
244
+ const names = options[0]?.testAliasNames ?? [];
245
+ return new Set(['test', ...names]);
246
+ }
247
+ export const TEST_ALIAS_SCHEMA = [
248
+ {
249
+ type: 'object',
250
+ properties: { testAliasNames: { items: { type: 'string' }, type: 'array' } },
251
+ additionalProperties: false,
252
+ },
253
+ ];
@@ -44,6 +44,11 @@ export declare const MODEL: {
44
44
  readonly MAX_DEMERIT_PER_FILE_MODULE: 1;
45
45
  };
46
46
  declare const PENALTY_DIMENSIONS: DimensionName[];
47
+ /** Lowest score of each grade, best first. Shared with the report's score explanation. */
48
+ export declare const GRADE_BANDS: ReadonlyArray<{
49
+ grade: Grade;
50
+ min: number;
51
+ }>;
47
52
  export declare function gradeFromScore(score: number): Grade;
48
53
  export declare function locatorsScore(native: number, raw: number): number;
49
54
  /** Tests per file, keyed by the same relative file path findings carry. */
@@ -44,16 +44,16 @@ export const MODEL = {
44
44
  MAX_DEMERIT_PER_FILE_MODULE: 1.0,
45
45
  };
46
46
  const PENALTY_DIMENSIONS = ['playwrightHygiene', 'assertions', 'structure'];
47
+ /** Lowest score of each grade, best first. Shared with the report's score explanation. */
48
+ export const GRADE_BANDS = [
49
+ { grade: 'A', min: 90 },
50
+ { grade: 'B', min: 80 },
51
+ { grade: 'C', min: 70 },
52
+ { grade: 'D', min: 60 },
53
+ { grade: 'F', min: 0 },
54
+ ];
47
55
  export function gradeFromScore(score) {
48
- if (score >= 90)
49
- return 'A';
50
- if (score >= 80)
51
- return 'B';
52
- if (score >= 70)
53
- return 'C';
54
- if (score >= 60)
55
- return 'D';
56
- return 'F';
56
+ return (GRADE_BANDS.find((b) => score >= b.min) ?? GRADE_BANDS[GRADE_BANDS.length - 1]).grade;
57
57
  }
58
58
  export function locatorsScore(native, raw) {
59
59
  const total = native + raw;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Label for one scored path, used when --name is absent. A file keeps its
3
+ * basename. A folder gets its own name plus parents: three segments when the
4
+ * name is generic ("mattermost/e2e-tests/playwright"), two otherwise. Never
5
+ * reaches the user-name segment of a home directory or anything above it:
6
+ * the walk stops under any `home`/`Users` segment (wherever it sits, so
7
+ * `/tmp/home/alice/e2e` is safe even if $HOME says otherwise) and under the
8
+ * current home directory. A folder that is itself a home directory gets a
9
+ * neutral label.
10
+ */
11
+ export declare function suiteLabel(p: string, isDir: boolean, home?: string): string;
@@ -0,0 +1,36 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ /** Folder names that say nothing about whose suite this is. */
4
+ const GENERIC_NAMES = new Set(['playwright', 'e2e', 'e2e-tests', 'tests', 'test', 'specs', 'spec', 'src']);
5
+ const NEUTRAL = 'Playwright suite';
6
+ const HOME_PARENTS = new Set(['home', 'users']);
7
+ /**
8
+ * Label for one scored path, used when --name is absent. A file keeps its
9
+ * basename. A folder gets its own name plus parents: three segments when the
10
+ * name is generic ("mattermost/e2e-tests/playwright"), two otherwise. Never
11
+ * reaches the user-name segment of a home directory or anything above it:
12
+ * the walk stops under any `home`/`Users` segment (wherever it sits, so
13
+ * `/tmp/home/alice/e2e` is safe even if $HOME says otherwise) and under the
14
+ * current home directory. A folder that is itself a home directory gets a
15
+ * neutral label.
16
+ */
17
+ export function suiteLabel(p, isDir, home = os.homedir()) {
18
+ const abs = path.resolve(p);
19
+ const base = path.basename(abs) || p;
20
+ if (!isDir)
21
+ return base;
22
+ const segs = abs.split(path.sep).filter(Boolean);
23
+ // First segment index the label may use.
24
+ let min = 0;
25
+ segs.forEach((seg, k) => {
26
+ if (HOME_PARENTS.has(seg.toLowerCase()))
27
+ min = Math.max(min, k + 2);
28
+ });
29
+ const homeSegs = path.resolve(home || path.sep).split(path.sep).filter(Boolean);
30
+ if (homeSegs.length > 0 && homeSegs.every((seg, k) => segs[k] === seg))
31
+ min = Math.max(min, homeSegs.length);
32
+ if (segs.length <= min)
33
+ return NEUTRAL;
34
+ const want = GENERIC_NAMES.has(base.toLowerCase()) ? 3 : 2;
35
+ return segs.slice(Math.max(min, segs.length - want)).join('/');
36
+ }