eslint-plugin-no-mistakes 0.53.2 → 0.54.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/package.json +9 -9
- package/src/index.js +2 -0
- package/src/rules/playwright-no-hoisted-unique-token-events.js +75 -0
- package/src/rules/playwright-no-hoisted-unique-token.js +193 -0
- package/src/rules/playwright-no-raw-scroll-pagination.js +236 -0
- package/src/rules/test-no-shared-state-aliases.js +11 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-no-mistakes",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.0",
|
|
4
4
|
"description": "ESLint and Oxlint rules for deterministic no-mistakes code analysis",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -17,20 +17,20 @@
|
|
|
17
17
|
"publishConfig": {
|
|
18
18
|
"access": "public"
|
|
19
19
|
},
|
|
20
|
-
"scripts": {
|
|
21
|
-
"test": "vitest run --coverage"
|
|
22
|
-
},
|
|
23
20
|
"devDependencies": {
|
|
24
21
|
"@typescript-eslint/parser": "^8.67.0",
|
|
25
|
-
"@vitest/coverage-v8": "^4.1.
|
|
26
|
-
"eslint": "^10.
|
|
27
|
-
"oxlint": "^1.
|
|
28
|
-
"vitest": "^4.1.
|
|
22
|
+
"@vitest/coverage-v8": "^4.1.11",
|
|
23
|
+
"eslint": "^10.9.0",
|
|
24
|
+
"oxlint": "^1.80.0",
|
|
25
|
+
"vitest": "^4.1.11"
|
|
29
26
|
},
|
|
30
27
|
"peerDependencies": {
|
|
31
28
|
"eslint": ">=9"
|
|
32
29
|
},
|
|
33
30
|
"engines": {
|
|
34
31
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "vitest run --coverage"
|
|
35
35
|
}
|
|
36
|
-
}
|
|
36
|
+
}
|
package/src/index.js
CHANGED
|
@@ -22,6 +22,8 @@ const rules = {
|
|
|
22
22
|
"playwright-literals": require("./rules/playwright-literals"),
|
|
23
23
|
"playwright-naming-convention": require("./rules/playwright-naming-convention"),
|
|
24
24
|
"playwright-no-empty": require("./rules/playwright-no-empty"),
|
|
25
|
+
"playwright-no-hoisted-unique-token": require("./rules/playwright-no-hoisted-unique-token"),
|
|
26
|
+
"playwright-no-raw-scroll-pagination": require("./rules/playwright-no-raw-scroll-pagination"),
|
|
25
27
|
"playwright-no-set-timeout": require("./rules/playwright-no-set-timeout"),
|
|
26
28
|
"playwright-prefer-get-by-test-id": require("./rules/playwright-prefer-get-by-test-id"),
|
|
27
29
|
"playwright-require-exported-component-attribute": require("./rules/playwright-require-exported-component-attribute"),
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { childNodes } = require("./test-no-shared-state-helpers");
|
|
4
|
+
const { resolveVariable } = require("./test-no-shared-state-aliases");
|
|
5
|
+
|
|
6
|
+
// The TypeScript node types that wrap a runtime value expression rather than being purely
|
|
7
|
+
// type-positioned; every other "TS"-prefixed node type is skipped entirely by `collectEvents`.
|
|
8
|
+
const TS_VALUE_WRAPPER_TYPES = new Set([
|
|
9
|
+
"TSAsExpression",
|
|
10
|
+
"TSSatisfiesExpression",
|
|
11
|
+
"TSNonNullExpression",
|
|
12
|
+
"TSInstantiationExpression",
|
|
13
|
+
"TSTypeAssertion",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
// An Identifier assigned to (not read) by a plain `=` assignment is a write, not a hoisted-value
|
|
17
|
+
// reference: a hook that reassigns the tracked variable before reading it is no longer reusing the
|
|
18
|
+
// hoisted value.
|
|
19
|
+
function isWriteTarget(node) {
|
|
20
|
+
const parent = node.parent;
|
|
21
|
+
return parent?.type === "AssignmentExpression" && parent.operator === "=" && parent.left === node;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const KEYED_MEMBER_TYPES = new Set(["MethodDefinition", "PropertyDefinition"]);
|
|
25
|
+
|
|
26
|
+
// Skip Identifier positions that are names, not value references: the non-computed `.property`
|
|
27
|
+
// of a member access, a non-computed, non-shorthand object-literal `.key`, a non-computed class
|
|
28
|
+
// method/field `.key` (a class declared inside the hook can legally name a member after the
|
|
29
|
+
// tracked variable without reading it), and a plain-assignment write target.
|
|
30
|
+
function isReferenceIdentifier(node) {
|
|
31
|
+
const parent = node.parent;
|
|
32
|
+
if (!parent) return true;
|
|
33
|
+
if (parent.type === "MemberExpression" && !parent.computed && parent.property === node) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (parent.type === "Property" && !parent.computed && !parent.shorthand && parent.key === node) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
if (KEYED_MEMBER_TYPES.has(parent.type) && !parent.computed && parent.key === node) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return !isWriteTarget(node);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Collects every read and write of an Identifier reachable from `node`, in source order.
|
|
46
|
+
// TypeScript type-only positions (`type X = typeof suffix`, `: typeof suffix` annotations, a
|
|
47
|
+
// `TSInterfaceDeclaration` body, ...) are skipped entirely — the value-wrapper expressions above
|
|
48
|
+
// recurse into their runtime `.expression` only, never a type operand.
|
|
49
|
+
function collectEvents(node, results) {
|
|
50
|
+
if (!node || typeof node.type !== "string") return;
|
|
51
|
+
if (node.type.startsWith("TS")) {
|
|
52
|
+
if (TS_VALUE_WRAPPER_TYPES.has(node.type)) collectEvents(node.expression, results);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (node.type === "Identifier") {
|
|
56
|
+
if (isWriteTarget(node)) results.push({ node, isWrite: true });
|
|
57
|
+
else if (isReferenceIdentifier(node)) results.push({ node, isWrite: false });
|
|
58
|
+
}
|
|
59
|
+
for (const child of childNodes(node)) collectEvents(child, results);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A write's own right-hand side evaluates in full before the assignment takes effect, so a
|
|
63
|
+
// self-referential write (`suffix = suffix.trim()`) still observes the pre-write, potentially
|
|
64
|
+
// stale value — the result carries the same collision hazard as the value it derives from and
|
|
65
|
+
// must not be treated as a fresh refresh of the tracked declarator.
|
|
66
|
+
function writeReusesTrackedValue(node, declarator, context) {
|
|
67
|
+
const rhsEvents = [];
|
|
68
|
+
collectEvents(node.parent.right, rhsEvents);
|
|
69
|
+
return rhsEvents.some((rhsEvent) => {
|
|
70
|
+
const variable = resolveVariable(rhsEvent.node, context);
|
|
71
|
+
return variable?.defs?.some((def) => def.node === declarator) ?? false;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { collectEvents, writeReusesTrackedValue };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { rule } = require("../helpers");
|
|
4
|
+
const { isFunctionNode } = require("./test-no-shared-state-helpers");
|
|
5
|
+
const { hasProperty, resolveVariable } = require("./test-no-shared-state-aliases");
|
|
6
|
+
const {
|
|
7
|
+
calleeName,
|
|
8
|
+
importSpecifierName,
|
|
9
|
+
setupCallbackKind,
|
|
10
|
+
} = require("./test-no-shared-state-callees");
|
|
11
|
+
const {
|
|
12
|
+
collectEvents,
|
|
13
|
+
writeReusesTrackedValue,
|
|
14
|
+
} = require("./playwright-no-hoisted-unique-token-events");
|
|
15
|
+
|
|
16
|
+
const PLAYWRIGHT_PATH_PATTERN =
|
|
17
|
+
/(?:^|[/\\])(?:e2e|playwright)(?:[/\\]|$)|(?:^|[/\\])e2e\.(?:spec|test)\.[cm]?[jt]sx?$|\.pw\.(?:spec|test)\.[cm]?[jt]sx?$/;
|
|
18
|
+
|
|
19
|
+
const TEST_BODY_NAMES = new Set(["test", "it"]);
|
|
20
|
+
|
|
21
|
+
function isPlaywrightPath(filename) {
|
|
22
|
+
return PLAYWRIGHT_PATH_PATTERN.test(filename.replace(/\\/g, "/"));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function nearestOwnFunction(node) {
|
|
26
|
+
for (let current = node.parent; current && current.type !== "Program"; current = current.parent) {
|
|
27
|
+
if (isFunctionNode(current)) return current;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A declaration is already safely scoped to its own re-entry unit when the function it lives in
|
|
33
|
+
// is passed straight into a setup hook (`beforeAll`/`beforeEach`/`afterEach`/`afterAll`, bare or
|
|
34
|
+
// `test.`-qualified) or a test body (`test`/`it`, including `.only`/`.skip`-style modifiers) — but
|
|
35
|
+
// NOT `describe` anywhere in the callee chain (e.g. `test.describe.only`), which registers once
|
|
36
|
+
// and is exactly as re-entry-hazardous as module scope. `testCalleeNames` carries every local alias
|
|
37
|
+
// of the imported `test`/`it`/`describe` bindings, so an aliased `import { test as pw } from
|
|
38
|
+
// "@playwright/test"` is recognized the same as the bare names.
|
|
39
|
+
function isSharedScopeShield(fn, testCalleeNames) {
|
|
40
|
+
const parent = fn.parent;
|
|
41
|
+
if (!parent || parent.type !== "CallExpression") return false;
|
|
42
|
+
if (setupCallbackKind(parent, testCalleeNames)) return true;
|
|
43
|
+
const callee = parent.callee;
|
|
44
|
+
if (callee.type === "Identifier") return TEST_BODY_NAMES.has(callee.name);
|
|
45
|
+
if (callee.type === "MemberExpression" && !callee.computed) {
|
|
46
|
+
return TEST_BODY_NAMES.has(calleeName(callee)) && !hasProperty(callee, "describe");
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A declaration nested arbitrarily deep inside a `beforeAll` callback — even inside a local helper
|
|
52
|
+
// function the hook itself defines and invokes — is minted fresh on every hook re-entry, so it is
|
|
53
|
+
// never a hoisting hazard regardless of how many function boundaries separate it from the hook.
|
|
54
|
+
function isWithinBeforeAllCallback(node, testCalleeNames) {
|
|
55
|
+
for (let fn = nearestOwnFunction(node); fn; fn = nearestOwnFunction(fn)) {
|
|
56
|
+
const parent = fn.parent;
|
|
57
|
+
if (
|
|
58
|
+
parent?.type === "CallExpression" &&
|
|
59
|
+
setupCallbackKind(parent, testCalleeNames) === "before-once"
|
|
60
|
+
) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const BRANCHING_ANCESTOR_TYPES = new Set([
|
|
68
|
+
"IfStatement",
|
|
69
|
+
"ConditionalExpression",
|
|
70
|
+
"SwitchStatement",
|
|
71
|
+
"SwitchCase",
|
|
72
|
+
"TryStatement",
|
|
73
|
+
"CatchClause",
|
|
74
|
+
"LogicalExpression",
|
|
75
|
+
"ForStatement",
|
|
76
|
+
"ForInStatement",
|
|
77
|
+
"ForOfStatement",
|
|
78
|
+
"WhileStatement",
|
|
79
|
+
"DoWhileStatement",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
// `collectEvents` walks the callback body in source order, not control-flow order, so a write
|
|
83
|
+
// nested inside a branch, loop, or logical short-circuit may never execute on a given re-entry —
|
|
84
|
+
// it cannot "refresh" the tracked value just because it was visited earlier in the traversal. Only
|
|
85
|
+
// a write with no branching ancestor between it and the callback body is unconditional.
|
|
86
|
+
function isUnconditionalWrite(node, callback) {
|
|
87
|
+
for (let current = node.parent; current && current !== callback; current = current.parent) {
|
|
88
|
+
if (BRANCHING_ANCESTOR_TYPES.has(current.type)) return false;
|
|
89
|
+
// A write inside a function nested in the callback (a locally-defined helper) only runs if
|
|
90
|
+
// that function is itself called — never assume the enclosing callback executes it unconditionally.
|
|
91
|
+
if (isFunctionNode(current) && current !== callback) return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = rule(
|
|
97
|
+
{
|
|
98
|
+
type: "problem",
|
|
99
|
+
docs: {
|
|
100
|
+
description:
|
|
101
|
+
"disallow reading a module/describe-scope unique-token call inside a re-entrant beforeAll",
|
|
102
|
+
recommended: false,
|
|
103
|
+
},
|
|
104
|
+
schema: [
|
|
105
|
+
{
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: { tokenFactories: { type: "array", items: { type: "string" } } },
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
messages: {
|
|
111
|
+
hoisted:
|
|
112
|
+
"`{{name}}` is generated once by `{{factory}}()` at module/describe scope, but `beforeAll` can re-run in the same worker process with module state preserved — the hoisted value is reused unchanged on re-entry and collides with itself. Move the `{{factory}}()` call to the first statement inside this `beforeAll` instead.",
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
(context) => {
|
|
116
|
+
let isPlaywrightFile = isPlaywrightPath(context.filename);
|
|
117
|
+
const tokenFactories = context.options?.[0]?.tokenFactories;
|
|
118
|
+
if (!tokenFactories || tokenFactories.length === 0) return {};
|
|
119
|
+
const factoryNames = new Set(tokenFactories);
|
|
120
|
+
|
|
121
|
+
// declarator node -> factory name, for declarations not already shielded inside their own hook/test.
|
|
122
|
+
const candidates = new Map();
|
|
123
|
+
const beforeAllCallbacks = [];
|
|
124
|
+
// Local aliases of the imported `test`/`it`/`describe` bindings (e.g. `import { test as pw }`),
|
|
125
|
+
// so `pw.beforeAll(...)` is recognized the same as `test.beforeAll(...)`.
|
|
126
|
+
const testCalleeNames = new Set(["test", "it", "describe"]);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
ImportDeclaration(node) {
|
|
130
|
+
if (node.source.value !== "@playwright/test") return;
|
|
131
|
+
isPlaywrightFile = true;
|
|
132
|
+
for (const specifier of node.specifiers) {
|
|
133
|
+
if (specifier.type !== "ImportSpecifier") continue;
|
|
134
|
+
const imported = importSpecifierName(specifier);
|
|
135
|
+
if (["describe", "it", "test"].includes(imported) && specifier.local?.name) {
|
|
136
|
+
testCalleeNames.add(specifier.local.name);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
VariableDeclarator(node) {
|
|
141
|
+
if (
|
|
142
|
+
node.id.type !== "Identifier" ||
|
|
143
|
+
node.init?.type !== "CallExpression" ||
|
|
144
|
+
node.init.callee.type !== "Identifier" ||
|
|
145
|
+
!factoryNames.has(node.init.callee.name)
|
|
146
|
+
) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const fn = nearestOwnFunction(node);
|
|
150
|
+
if (
|
|
151
|
+
(!fn || !isSharedScopeShield(fn, testCalleeNames)) &&
|
|
152
|
+
!isWithinBeforeAllCallback(node, testCalleeNames)
|
|
153
|
+
) {
|
|
154
|
+
candidates.set(node, node.init.callee.name);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
CallExpression(node) {
|
|
158
|
+
if (setupCallbackKind(node, testCalleeNames) !== "before-once") return;
|
|
159
|
+
const callback = node.arguments.find((argument) => isFunctionNode(argument));
|
|
160
|
+
if (callback) beforeAllCallbacks.push(callback);
|
|
161
|
+
},
|
|
162
|
+
"Program:exit"() {
|
|
163
|
+
if (!isPlaywrightFile || candidates.size === 0) return;
|
|
164
|
+
for (const callback of beforeAllCallbacks) {
|
|
165
|
+
const events = [];
|
|
166
|
+
collectEvents(callback.body, events);
|
|
167
|
+
const refreshed = new Set();
|
|
168
|
+
for (const event of events) {
|
|
169
|
+
// A `var` redeclared in the same scope (`var suffix; var suffix = randomSuffix();`)
|
|
170
|
+
// is one Variable with multiple defs — the factory-call declarator may be any of
|
|
171
|
+
// them, not necessarily defs[0], so every def must be checked against `candidates`.
|
|
172
|
+
const variable = resolveVariable(event.node, context);
|
|
173
|
+
const declarator = variable?.defs?.find((def) => candidates.has(def.node))?.node;
|
|
174
|
+
if (!declarator) continue;
|
|
175
|
+
if (event.isWrite) {
|
|
176
|
+
const refreshes =
|
|
177
|
+
isUnconditionalWrite(event.node, callback) &&
|
|
178
|
+
!writeReusesTrackedValue(event.node, declarator, context);
|
|
179
|
+
if (refreshes) refreshed.add(declarator);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (refreshed.has(declarator)) continue;
|
|
183
|
+
context.report({
|
|
184
|
+
node: event.node,
|
|
185
|
+
messageId: "hoisted",
|
|
186
|
+
data: { name: event.node.name, factory: candidates.get(declarator) },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
);
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { rule } = require("../helpers");
|
|
4
|
+
const { childNodes } = require("./test-no-shared-state-helpers");
|
|
5
|
+
const { resolveVariable } = require("./test-no-shared-state-aliases");
|
|
6
|
+
|
|
7
|
+
const PLAYWRIGHT_PATH_PATTERN =
|
|
8
|
+
/(?:^|[/\\])(?:e2e|playwright)(?:[/\\]|$)|(?:^|[/\\])e2e\.(?:spec|test)\.[cm]?[jt]sx?$|\.pw\.(?:spec|test)\.[cm]?[jt]sx?$/;
|
|
9
|
+
|
|
10
|
+
const CURSOR_WAIT_PROPERTIES = new Set(["waitForRequest", "waitForResponse"]);
|
|
11
|
+
const RAW_SCROLL_NAMES = new Set(["scrollTo", "scrollBy", "scroll"]);
|
|
12
|
+
const SEARCH_PARAMS_ACCESSORS = new Set(["has", "get", "getAll"]);
|
|
13
|
+
|
|
14
|
+
// The TypeScript node types that wrap a runtime value expression rather than being purely
|
|
15
|
+
// type-positioned; every other "TS"-prefixed node type is skipped by `collectLiteralStrings` — a
|
|
16
|
+
// string literal appearing only inside a type alias or annotation is never evaluated at runtime
|
|
17
|
+
// and must not be mistaken for an actual cursor-param check.
|
|
18
|
+
const TS_VALUE_WRAPPER_TYPES = new Set([
|
|
19
|
+
"TSAsExpression",
|
|
20
|
+
"TSSatisfiesExpression",
|
|
21
|
+
"TSNonNullExpression",
|
|
22
|
+
"TSInstantiationExpression",
|
|
23
|
+
"TSTypeAssertion",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function isPlaywrightPath(filename) {
|
|
27
|
+
return PLAYWRIGHT_PATH_PATTERN.test(filename.replace(/\\/g, "/"));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function propertyName(node) {
|
|
31
|
+
if (!node) return null;
|
|
32
|
+
return node.type === "Literal" ? String(node.value) : node.name;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// `page.waitForRequest(...)` / `page.waitForResponse(...)`, matched by property name only — the
|
|
36
|
+
// object (page, frame, a locator, ...) doesn't matter, mirroring how `playwright-no-set-timeout`
|
|
37
|
+
// matches `.waitForTimeout` regardless of receiver. A statically computed access
|
|
38
|
+
// (`page["waitForRequest"]`) is just as unambiguous as dot access and is matched the same way.
|
|
39
|
+
function isCursorWaitCall(node) {
|
|
40
|
+
return (
|
|
41
|
+
node.callee.type === "MemberExpression" &&
|
|
42
|
+
(!node.callee.computed || node.callee.property.type === "Literal") &&
|
|
43
|
+
CURSOR_WAIT_PROPERTIES.has(propertyName(node.callee.property))
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// `window.scrollTo(...)`/`window.scroll(...)`, bare `scrollTo(...)`/`scroll(...)`, and the
|
|
48
|
+
// `scrollBy` equivalents — the only imperative, position-based browser scroll APIs. A same-named
|
|
49
|
+
// method on any other receiver (`map.scrollTo()`, an editor or page-object helper) is not a
|
|
50
|
+
// browser scroll and is never matched. `scrollIntoView` is element-relative, not a pagination
|
|
51
|
+
// driver, and is intentionally not matched. Both the bare call and the `window`-qualified call
|
|
52
|
+
// only count when the receiver resolves to the global — a project's own locally-declared or
|
|
53
|
+
// imported `scrollTo`/`scroll`/`scrollBy` helper, or a shadowing `window` parameter (as in
|
|
54
|
+
// `page.evaluate((window) => window.scrollTo(...), safeScroller)`), is a different function and
|
|
55
|
+
// must not be misflagged.
|
|
56
|
+
function isRawScrollCall(node, context) {
|
|
57
|
+
const callee = node.callee;
|
|
58
|
+
if (callee.type === "Identifier") {
|
|
59
|
+
if (!RAW_SCROLL_NAMES.has(callee.name)) return false;
|
|
60
|
+
return !(resolveVariable(callee, context)?.defs?.length > 0);
|
|
61
|
+
}
|
|
62
|
+
if (
|
|
63
|
+
callee.type !== "MemberExpression" ||
|
|
64
|
+
(callee.computed && callee.property.type !== "Literal") ||
|
|
65
|
+
callee.object.type !== "Identifier" ||
|
|
66
|
+
callee.object.name !== "window" ||
|
|
67
|
+
!RAW_SCROLL_NAMES.has(propertyName(callee.property))
|
|
68
|
+
) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
return !(resolveVariable(callee.object, context)?.defs?.length > 0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// `<something>.searchParams.has("after")` / `.get("after")` / `.getAll("after")` — the cursor
|
|
75
|
+
// param name is passed as a bare accessor argument, never appearing with a trailing `=` the way
|
|
76
|
+
// it does in a raw query string, so it needs its own literal-collection path rather than the
|
|
77
|
+
// boundary-matched one below.
|
|
78
|
+
function isSearchParamsAccessorCall(node) {
|
|
79
|
+
return Boolean(
|
|
80
|
+
node.callee.type === "MemberExpression" &&
|
|
81
|
+
!node.callee.computed &&
|
|
82
|
+
SEARCH_PARAMS_ACCESSORS.has(propertyName(node.callee.property)) &&
|
|
83
|
+
node.callee.object.type === "MemberExpression" &&
|
|
84
|
+
!node.callee.object.computed &&
|
|
85
|
+
propertyName(node.callee.object.property) === "searchParams",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function escapeRegExp(value) {
|
|
90
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Regex-derived literals are matched as pattern *source text*, not executed, so a regex author's
|
|
94
|
+
// own boundary syntax (`[?&]after=`, `(?:^|[?&])after=`) leaves a `]` or `)` immediately before
|
|
95
|
+
// the param name instead of a literal `?`/`&`. Only these exact, known boundary constructs count
|
|
96
|
+
// as a separator for a regex-derived literal — accepting an arbitrary `)`/`]` before the param
|
|
97
|
+
// would also match an unrelated alternation like `(?:next|prev)cursor=`, which closes a group
|
|
98
|
+
// that has nothing to do with a query-string separator. A bare `&`, an escaped `\?`, or a `^`
|
|
99
|
+
// start anchor written directly (`/&after=/`, `/\?after=/`, `/^after=/`) is unambiguous — unlike
|
|
100
|
+
// `)`/`]`, none of these three characters ever closes an unrelated construct — so they count too.
|
|
101
|
+
const REGEX_BOUNDARY_SUFFIXES = [
|
|
102
|
+
"(?:^|[?&])",
|
|
103
|
+
"(?:^|[&?])",
|
|
104
|
+
"(?:^|\\?|&)",
|
|
105
|
+
"(?:^|&|\\?)",
|
|
106
|
+
"[?&]",
|
|
107
|
+
"[&?]",
|
|
108
|
+
"&",
|
|
109
|
+
"\\?",
|
|
110
|
+
"^",
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
// Requires the cursor param to appear as an actual query-key boundary (`?after=`, `&after=`, or
|
|
114
|
+
// `after=` at the very start of the literal) rather than an unconstrained substring match, which
|
|
115
|
+
// would false-positive on e.g. `category=after-hours` for a configured param of `after`.
|
|
116
|
+
function hasQueryParamBoundary(literal, param, isRegex) {
|
|
117
|
+
const target = new RegExp(`${escapeRegExp(param)}=`, "g");
|
|
118
|
+
for (const match of literal.matchAll(target)) {
|
|
119
|
+
const prefix = literal.slice(0, match.index);
|
|
120
|
+
if (prefix.length === 0) return true;
|
|
121
|
+
if (isRegex) {
|
|
122
|
+
if (REGEX_BOUNDARY_SUFFIXES.some((suffix) => prefix.endsWith(suffix))) return true;
|
|
123
|
+
} else if (/[?&]$/.test(prefix)) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// TypeScript type-only positions (a type alias, an interface body, a `: typeof x` annotation, ...)
|
|
131
|
+
// are skipped entirely — a string literal type or template literal type is never evaluated at
|
|
132
|
+
// runtime and must not be mistaken for an actual cursor-param check. The value-wrapper expressions
|
|
133
|
+
// above recurse into their runtime `.expression` only, never a type operand.
|
|
134
|
+
function collectLiteralStrings(node, results) {
|
|
135
|
+
if (!node) return;
|
|
136
|
+
if (node.type.startsWith("TS")) {
|
|
137
|
+
if (TS_VALUE_WRAPPER_TYPES.has(node.type)) collectLiteralStrings(node.expression, results);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (node.type === "Literal") {
|
|
141
|
+
if (typeof node.value === "string") results.push({ value: node.value, isRegex: false });
|
|
142
|
+
else if (node.regex) results.push({ value: node.regex.pattern, isRegex: true });
|
|
143
|
+
} else if (node.type === "TemplateElement") {
|
|
144
|
+
results.push({ value: node.value?.raw ?? "", isRegex: false });
|
|
145
|
+
}
|
|
146
|
+
for (const child of childNodes(node)) collectLiteralStrings(child, results);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// A `.searchParams.has()`/`.get()` argument is never boundary-matched (see above), so an
|
|
150
|
+
// interpolation-free template literal (`` `cursor` ``) is just as statically knowable as a plain
|
|
151
|
+
// string literal — only a template with actual `${...}` interpolation is genuinely dynamic.
|
|
152
|
+
function staticStringValue(node) {
|
|
153
|
+
if (node?.type === "Literal" && typeof node.value === "string") return node.value;
|
|
154
|
+
if (node?.type === "TemplateLiteral" && node.expressions.length === 0) {
|
|
155
|
+
return node.quasis[0].value.raw;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function collectSearchParamsAccessorArgs(node, results) {
|
|
161
|
+
if (!node) return;
|
|
162
|
+
if (node.type === "CallExpression" && isSearchParamsAccessorCall(node)) {
|
|
163
|
+
const value = staticStringValue(node.arguments[0]);
|
|
164
|
+
if (value !== null) results.push(value);
|
|
165
|
+
}
|
|
166
|
+
for (const child of childNodes(node)) collectSearchParamsAccessorArgs(child, results);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function mentionsCursorParam(node, cursorParams) {
|
|
170
|
+
if (!node) return false;
|
|
171
|
+
const literals = [];
|
|
172
|
+
collectLiteralStrings(node, literals);
|
|
173
|
+
if (
|
|
174
|
+
literals.some(({ value, isRegex }) =>
|
|
175
|
+
cursorParams.some((param) => hasQueryParamBoundary(value, param, isRegex)),
|
|
176
|
+
)
|
|
177
|
+
) {
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
const accessorArgs = [];
|
|
181
|
+
collectSearchParamsAccessorArgs(node, accessorArgs);
|
|
182
|
+
return accessorArgs.some((value) => cursorParams.includes(value));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = rule(
|
|
186
|
+
{
|
|
187
|
+
type: "problem",
|
|
188
|
+
docs: {
|
|
189
|
+
description:
|
|
190
|
+
"disallow driving cursor-paginated infinite scroll with a raw window.scrollTo/scroll/scrollBy",
|
|
191
|
+
recommended: false,
|
|
192
|
+
},
|
|
193
|
+
schema: [
|
|
194
|
+
{
|
|
195
|
+
type: "object",
|
|
196
|
+
properties: {
|
|
197
|
+
cursorParams: { type: "array", items: { type: "string" } },
|
|
198
|
+
scrollHelper: { type: "string" },
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
messages: {
|
|
203
|
+
rawScroll:
|
|
204
|
+
"This file awaits a cursor-paginated request/response but drives scrolling with a raw scrollTo/scroll/scrollBy call — a single synthetic scroll can land before a deferred IntersectionObserver mounts and be lost forever, stalling the wait for its full timeout.{{helperHint}}",
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
(context) => {
|
|
208
|
+
let isPlaywrightFile = isPlaywrightPath(context.filename);
|
|
209
|
+
const options = context.options?.[0] ?? {};
|
|
210
|
+
const cursorParams = options.cursorParams ?? ["after", "cursor"];
|
|
211
|
+
const scrollHelper = options.scrollHelper ?? "";
|
|
212
|
+
const helperHint = scrollHelper
|
|
213
|
+
? ` Use ${scrollHelper}, which scrolls repeatedly until the request fires, instead.`
|
|
214
|
+
: " Use a helper that scrolls repeatedly until the request fires instead of a single raw call.";
|
|
215
|
+
let hasCursorWait = false;
|
|
216
|
+
const scrollCandidates = [];
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
ImportDeclaration(node) {
|
|
220
|
+
if (node.source.value === "@playwright/test") isPlaywrightFile = true;
|
|
221
|
+
},
|
|
222
|
+
CallExpression(node) {
|
|
223
|
+
if (isCursorWaitCall(node) && mentionsCursorParam(node.arguments[0], cursorParams)) {
|
|
224
|
+
hasCursorWait = true;
|
|
225
|
+
}
|
|
226
|
+
if (isRawScrollCall(node, context)) scrollCandidates.push(node);
|
|
227
|
+
},
|
|
228
|
+
"Program:exit"() {
|
|
229
|
+
if (!isPlaywrightFile || !hasCursorWait) return;
|
|
230
|
+
for (const node of scrollCandidates) {
|
|
231
|
+
context.report({ node, messageId: "rawScroll", data: { helperHint } });
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
},
|
|
236
|
+
);
|
|
@@ -37,10 +37,7 @@ function createImportedTestAliases(context) {
|
|
|
37
37
|
return Boolean(imported && resolveVariable(base, context) !== imported);
|
|
38
38
|
},
|
|
39
39
|
hasProperty(callee, name) {
|
|
40
|
-
|
|
41
|
-
if (callee?.type === "MemberExpression") return this.hasProperty(callee.object, name);
|
|
42
|
-
if (callee?.type === "CallExpression") return this.hasProperty(callee.callee, name);
|
|
43
|
-
return false;
|
|
40
|
+
return hasProperty(callee, name);
|
|
44
41
|
},
|
|
45
42
|
};
|
|
46
43
|
}
|
|
@@ -49,8 +46,18 @@ function hasOwnProperty(callee, name) {
|
|
|
49
46
|
return callee?.type === "MemberExpression" && !callee.computed && callee.property?.name === name;
|
|
50
47
|
}
|
|
51
48
|
|
|
49
|
+
// Recursively checks the whole callee chain (member accesses and call results) for a
|
|
50
|
+
// non-computed property named `name`, e.g. `hasProperty(test.describe.only, "describe")`.
|
|
51
|
+
function hasProperty(callee, name) {
|
|
52
|
+
if (hasOwnProperty(callee, name)) return true;
|
|
53
|
+
if (callee?.type === "MemberExpression") return hasProperty(callee.object, name);
|
|
54
|
+
if (callee?.type === "CallExpression") return hasProperty(callee.callee, name);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
52
58
|
module.exports = {
|
|
53
59
|
createImportedTestAliases,
|
|
54
60
|
hasOwnProperty,
|
|
61
|
+
hasProperty,
|
|
55
62
|
resolveVariable,
|
|
56
63
|
};
|