fix: prevent asset conflicts between React and Grid.js versions

Add coexistence checks to all enqueue methods to prevent loading
both React and Grid.js assets simultaneously.

Changes:
- ReactAdmin.php: Only enqueue React assets when ?react=1
- Init.php: Skip Grid.js when React active on admin pages
- Form.php, Coupon.php, Access.php: Restore classic assets when ?react=0
- Customer.php, Product.php, License.php: Add coexistence checks

Now the toggle between Classic and React versions works correctly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-04-18 17:02:14 +07:00
parent bd9cdac02e
commit e8fbfb14c1
74973 changed files with 6658406 additions and 71 deletions

119
node_modules/eslint-plugin-playwright/lib/index.js generated vendored Normal file
View File

@@ -0,0 +1,119 @@
"use strict";
const expect_expect_1 = require("./rules/expect-expect");
const max_nested_describe_1 = require("./rules/max-nested-describe");
const missing_playwright_await_1 = require("./rules/missing-playwright-await");
const no_conditional_in_test_1 = require("./rules/no-conditional-in-test");
const no_element_handle_1 = require("./rules/no-element-handle");
const no_eval_1 = require("./rules/no-eval");
const no_focused_test_1 = require("./rules/no-focused-test");
const no_force_option_1 = require("./rules/no-force-option");
const no_nested_step_1 = require("./rules/no-nested-step");
const no_networkidle_1 = require("./rules/no-networkidle");
const no_nth_methods_1 = require("./rules/no-nth-methods");
const no_page_pause_1 = require("./rules/no-page-pause");
const no_restricted_matchers_1 = require("./rules/no-restricted-matchers");
const no_skipped_test_1 = require("./rules/no-skipped-test");
const no_useless_await_1 = require("./rules/no-useless-await");
const no_useless_not_1 = require("./rules/no-useless-not");
const no_wait_for_timeout_1 = require("./rules/no-wait-for-timeout");
const prefer_lowercase_title_1 = require("./rules/prefer-lowercase-title");
const prefer_strict_equal_1 = require("./rules/prefer-strict-equal");
const prefer_to_be_1 = require("./rules/prefer-to-be");
const prefer_to_contain_1 = require("./rules/prefer-to-contain");
const prefer_to_have_length_1 = require("./rules/prefer-to-have-length");
const prefer_web_first_assertions_1 = require("./rules/prefer-web-first-assertions");
const require_soft_assertions_1 = require("./rules/require-soft-assertions");
const require_top_level_describe_1 = require("./rules/require-top-level-describe");
const valid_expect_1 = require("./rules/valid-expect");
const recommended = {
env: {
'shared-node-browser': true,
},
plugins: ['playwright'],
rules: {
'no-empty-pattern': 'off',
'playwright/expect-expect': 'warn',
'playwright/max-nested-describe': 'warn',
'playwright/missing-playwright-await': 'error',
'playwright/no-conditional-in-test': 'warn',
'playwright/no-element-handle': 'warn',
'playwright/no-eval': 'warn',
'playwright/no-focused-test': 'error',
'playwright/no-force-option': 'warn',
'playwright/no-nested-step': 'warn',
'playwright/no-networkidle': 'error',
'playwright/no-page-pause': 'warn',
'playwright/no-skipped-test': 'warn',
'playwright/no-useless-await': 'warn',
'playwright/no-useless-not': 'warn',
'playwright/no-wait-for-timeout': 'warn',
'playwright/prefer-web-first-assertions': 'error',
'playwright/valid-expect': 'error',
},
};
module.exports = {
configs: {
'jest-playwright': {
env: {
jest: true,
'shared-node-browser': true,
},
globals: {
browser: true,
browserName: true,
context: true,
deviceName: true,
jestPlaywright: true,
page: true,
},
plugins: ['jest', 'playwright'],
rules: {
'jest/no-standalone-expect': [
'error',
{
additionalTestBlockFunctions: [
'test.jestPlaywrightDebug',
'it.jestPlaywrightDebug',
'test.jestPlaywrightSkip',
'it.jestPlaywrightSkip',
'test.jestPlaywrightConfig',
'it.jestPlaywrightConfig',
],
},
],
'playwright/missing-playwright-await': 'error',
'playwright/no-page-pause': 'warn',
},
},
'playwright-test': recommended,
recommended,
},
rules: {
'expect-expect': expect_expect_1.default,
'max-nested-describe': max_nested_describe_1.default,
'missing-playwright-await': missing_playwright_await_1.default,
'no-conditional-in-test': no_conditional_in_test_1.default,
'no-element-handle': no_element_handle_1.default,
'no-eval': no_eval_1.default,
'no-focused-test': no_focused_test_1.default,
'no-force-option': no_force_option_1.default,
'no-nested-step': no_nested_step_1.default,
'no-networkidle': no_networkidle_1.default,
'no-nth-methods': no_nth_methods_1.default,
'no-page-pause': no_page_pause_1.default,
'no-restricted-matchers': no_restricted_matchers_1.default,
'no-skipped-test': no_skipped_test_1.default,
'no-useless-await': no_useless_await_1.default,
'no-useless-not': no_useless_not_1.default,
'no-wait-for-timeout': no_wait_for_timeout_1.default,
'prefer-lowercase-title': prefer_lowercase_title_1.default,
'prefer-strict-equal': prefer_strict_equal_1.default,
'prefer-to-be': prefer_to_be_1.default,
'prefer-to-contain': prefer_to_contain_1.default,
'prefer-to-have-length': prefer_to_have_length_1.default,
'prefer-web-first-assertions': prefer_web_first_assertions_1.default,
'require-soft-assertions': require_soft_assertions_1.default,
'require-top-level-describe': require_top_level_describe_1.default,
'valid-expect': valid_expect_1.default,
},
};

View File

@@ -0,0 +1,44 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
exports.default = {
create(context) {
const unchecked = [];
function checkExpressions(nodes) {
for (const node of nodes) {
const index = node.type === 'CallExpression' ? unchecked.indexOf(node) : -1;
if (index !== -1) {
unchecked.splice(index, 1);
break;
}
}
}
return {
CallExpression(node) {
if ((0, ast_1.isTest)(node, ['fixme', 'only', 'skip'])) {
unchecked.push(node);
}
else if ((0, ast_1.isExpectCall)(node)) {
checkExpressions(context.getAncestors());
}
},
'Program:exit'() {
unchecked.forEach((node) => {
context.report({ messageId: 'noAssertions', node });
});
},
};
},
meta: {
docs: {
category: 'Best Practices',
description: 'Enforce assertion to be made in a test body',
recommended: true,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/expect-expect.md',
},
messages: {
noAssertions: 'Test has no assertions',
},
type: 'problem',
},
};

View File

@@ -0,0 +1,63 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
exports.default = {
create(context) {
const { options } = context;
const max = options[0]?.max ?? 5;
const describeCallbackStack = [];
function pushDescribeCallback(node) {
if (node.parent.type !== 'CallExpression' ||
!(0, ast_1.isDescribeCall)(node.parent)) {
return;
}
describeCallbackStack.push(0);
if (describeCallbackStack.length > max) {
context.report({
data: {
depth: describeCallbackStack.length.toString(),
max: max.toString(),
},
messageId: 'exceededMaxDepth',
node: node.parent.callee,
});
}
}
function popDescribeCallback(node) {
const { parent } = node;
if (parent.type === 'CallExpression' && (0, ast_1.isDescribeCall)(parent)) {
describeCallbackStack.pop();
}
}
return {
ArrowFunctionExpression: pushDescribeCallback,
'ArrowFunctionExpression:exit': popDescribeCallback,
FunctionExpression: pushDescribeCallback,
'FunctionExpression:exit': popDescribeCallback,
};
},
meta: {
docs: {
category: 'Best Practices',
description: 'Enforces a maximum depth to nested describe calls',
recommended: true,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/max-nested-describe.md',
},
messages: {
exceededMaxDepth: 'Maximum describe call depth exceeded ({{ depth }}). Maximum allowed is {{ max }}.',
},
schema: [
{
additionalProperties: false,
properties: {
max: {
minimum: 0,
type: 'integer',
},
},
type: 'object',
},
],
type: 'suggestion',
},
};

View File

@@ -0,0 +1,141 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
const validTypes = new Set([
'AwaitExpression',
'ReturnStatement',
'ArrowFunctionExpression',
]);
const expectPlaywrightMatchers = [
'toBeChecked',
'toBeDisabled',
'toBeEnabled',
'toEqualText',
'toEqualUrl',
'toEqualValue',
'toHaveFocus',
'toHaveSelector',
'toHaveSelectorCount',
'toHaveText',
'toMatchAttribute',
'toMatchComputedStyle',
'toMatchText',
'toMatchTitle',
'toMatchURL',
'toMatchValue',
];
const playwrightTestMatchers = [
'toBeChecked',
'toBeDisabled',
'toBeEditable',
'toBeEmpty',
'toBeEnabled',
'toBeFocused',
'toBeHidden',
'toBeVisible',
'toContainText',
'toHaveAttribute',
'toHaveClass',
'toHaveCount',
'toHaveCSS',
'toHaveId',
'toHaveJSProperty',
'toBeOK',
'toHaveScreenshot',
'toHaveText',
'toHaveTitle',
'toHaveURL',
'toHaveValue',
];
function getCallType(node, awaitableMatchers) {
// test.step
if (node.callee.type === 'MemberExpression' &&
(0, ast_1.isIdentifier)(node.callee.object, 'test') &&
(0, ast_1.isPropertyAccessor)(node.callee, 'step')) {
return { messageId: 'testStep' };
}
const expectType = (0, ast_1.getExpectType)(node);
if (!expectType)
return;
// expect.poll
if (expectType === 'poll') {
return { messageId: 'expectPoll' };
}
// expect with awaitable matcher
const [lastMatcher] = (0, ast_1.getMatchers)(node).slice(-1);
const matcherName = (0, ast_1.getStringValue)(lastMatcher);
if (awaitableMatchers.has(matcherName)) {
return { data: { matcherName }, messageId: 'expect' };
}
}
function isPromiseAll(node) {
return node.type === 'ArrayExpression' &&
node.parent.type === 'CallExpression' &&
node.parent.callee.type === 'MemberExpression' &&
(0, ast_1.isIdentifier)(node.parent.callee.object, 'Promise') &&
(0, ast_1.isIdentifier)(node.parent.callee.property, 'all')
? node.parent
: null;
}
function checkValidity(node) {
if (validTypes.has(node.parent.type))
return;
const promiseAll = isPromiseAll(node.parent);
return promiseAll
? checkValidity(promiseAll)
: node.parent.type === 'MemberExpression' ||
(node.parent.type === 'CallExpression' && node.parent.callee === node)
? checkValidity(node.parent)
: node;
}
exports.default = {
create(context) {
const options = context.options[0] || {};
const awaitableMatchers = new Set([
...expectPlaywrightMatchers,
...playwrightTestMatchers,
// Add any custom matchers to the set
...(options.customMatchers || []),
]);
return {
CallExpression(node) {
const result = getCallType(node, awaitableMatchers);
const reportNode = result ? checkValidity(node) : undefined;
if (result && reportNode) {
context.report({
data: result.data,
fix: (fixer) => fixer.insertTextBefore(node, 'await '),
messageId: result.messageId,
node: node.callee,
});
}
},
};
},
meta: {
docs: {
category: 'Possible Errors',
description: `Identify false positives when async Playwright APIs are not properly awaited.`,
recommended: true,
},
fixable: 'code',
messages: {
expect: "'{{matcherName}}' must be awaited or returned.",
expectPoll: "'expect.poll' matchers must be awaited or returned.",
testStep: "'test.step' must be awaited or returned.",
},
schema: [
{
additionalProperties: false,
properties: {
customMatchers: {
items: { type: 'string' },
type: 'array',
},
},
type: 'object',
},
],
type: 'problem',
},
};

View File

@@ -0,0 +1,32 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
exports.default = {
create(context) {
function checkConditional(node) {
const call = (0, ast_1.findParent)(node, 'CallExpression');
if (call && (0, ast_1.isTest)(call)) {
context.report({ messageId: 'conditionalInTest', node });
}
}
return {
ConditionalExpression: checkConditional,
IfStatement: checkConditional,
LogicalExpression: checkConditional,
SwitchStatement: checkConditional,
};
},
meta: {
docs: {
category: 'Best Practices',
description: 'Disallow conditional logic in tests',
recommended: true,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-conditional-in-test.md',
},
messages: {
conditionalInTest: 'Avoid having conditionals in tests',
},
schema: [],
type: 'problem',
},
};

View File

@@ -0,0 +1,57 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
function getPropertyRange(node) {
return node.type === 'Identifier'
? node.range
: [node.range[0] + 1, node.range[1] - 1];
}
exports.default = {
create(context) {
return {
CallExpression(node) {
if ((0, ast_1.isPageMethod)(node, '$') || (0, ast_1.isPageMethod)(node, '$$')) {
context.report({
messageId: 'noElementHandle',
node: node.callee,
suggest: [
{
fix: (fixer) => {
const { property } = node.callee;
// Replace $/$$ with locator
const fixes = [
fixer.replaceTextRange(getPropertyRange(property), 'locator'),
];
// Remove the await expression if it exists as locators do
// not need to be awaited.
if (node.parent.type === 'AwaitExpression') {
fixes.push(fixer.removeRange([node.parent.range[0], node.range[0]]));
}
return fixes;
},
messageId: (0, ast_1.isPageMethod)(node, '$')
? 'replaceElementHandleWithLocator'
: 'replaceElementHandlesWithLocator',
},
],
});
}
},
};
},
meta: {
docs: {
category: 'Possible Errors',
description: 'The use of ElementHandle is discouraged, use Locator instead',
recommended: true,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-element-handle.md',
},
hasSuggestions: true,
messages: {
noElementHandle: 'Unexpected use of element handles.',
replaceElementHandlesWithLocator: 'Replace `page.$$` with `page.locator`',
replaceElementHandleWithLocator: 'Replace `page.$` with `page.locator`',
},
type: 'suggestion',
},
};

View File

@@ -0,0 +1,31 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
exports.default = {
create(context) {
return {
CallExpression(node) {
const isEval = (0, ast_1.isPageMethod)(node, '$eval');
if (isEval || (0, ast_1.isPageMethod)(node, '$$eval')) {
context.report({
messageId: isEval ? 'noEval' : 'noEvalAll',
node: node.callee,
});
}
},
};
},
meta: {
docs: {
category: 'Possible Errors',
description: 'The use of `page.$eval` and `page.$$eval` are discouraged, use `locator.evaluate` or `locator.evaluateAll` instead',
recommended: true,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-eval.md',
},
messages: {
noEval: 'Unexpected use of page.$eval().',
noEvalAll: 'Unexpected use of page.$$eval().',
},
type: 'problem',
},
};

View File

@@ -0,0 +1,44 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
exports.default = {
create(context) {
return {
CallExpression(node) {
if (((0, ast_1.isTest)(node) || (0, ast_1.isDescribeCall)(node)) &&
node.callee.type === 'MemberExpression' &&
(0, ast_1.isPropertyAccessor)(node.callee, 'only')) {
const { callee } = node;
context.report({
messageId: 'noFocusedTest',
node: node.callee.property,
suggest: [
{
// - 1 to remove the `.only` annotation with dot notation
fix: (fixer) => fixer.removeRange([
callee.property.range[0] - 1,
callee.range[1],
]),
messageId: 'suggestRemoveOnly',
},
],
});
}
},
};
},
meta: {
docs: {
category: 'Possible Errors',
description: 'Prevent usage of `.only()` focus test annotation',
recommended: true,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-focused-test.md',
},
hasSuggestions: true,
messages: {
noFocusedTest: 'Unexpected focused test.',
suggestRemoveOnly: 'Remove .only() annotation.',
},
type: 'problem',
},
};

View File

@@ -0,0 +1,51 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
function isForceOptionEnabled(node) {
const arg = node.arguments[node.arguments.length - 1];
return (arg?.type === 'ObjectExpression' &&
arg.properties.find((property) => property.type === 'Property' &&
(0, ast_1.getStringValue)(property.key) === 'force' &&
(0, ast_1.isBooleanLiteral)(property.value, true)));
}
// https://playwright.dev/docs/api/class-locator
const methodsWithForceOption = new Set([
'check',
'uncheck',
'click',
'dblclick',
'dragTo',
'fill',
'hover',
'selectOption',
'selectText',
'setChecked',
'tap',
]);
exports.default = {
create(context) {
return {
MemberExpression(node) {
if (methodsWithForceOption.has((0, ast_1.getStringValue)(node.property)) &&
node.parent.type === 'CallExpression') {
const reportNode = isForceOptionEnabled(node.parent);
if (reportNode) {
context.report({ messageId: 'noForceOption', node: reportNode });
}
}
},
};
},
meta: {
docs: {
category: 'Best Practices',
description: 'Prevent usage of `{ force: true }` option.',
recommended: true,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-force-option.md',
},
messages: {
noForceOption: 'Unexpected use of { force: true } option.',
},
type: 'suggestion',
},
};

View File

@@ -0,0 +1,52 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
function isStepCall(node) {
const inner = node.type === 'CallExpression' ? node.callee : node;
if (inner.type !== 'MemberExpression') {
return false;
}
return (0, ast_1.isPropertyAccessor)(inner, 'step');
}
exports.default = {
create(context) {
const stack = [];
function pushStepCallback(node) {
if (node.parent.type !== 'CallExpression' || !isStepCall(node.parent)) {
return;
}
stack.push(0);
if (stack.length > 1) {
context.report({
messageId: 'noNestedStep',
node: node.parent.callee,
});
}
}
function popStepCallback(node) {
const { parent } = node;
if (parent.type === 'CallExpression' && isStepCall(parent)) {
stack.pop();
}
}
return {
ArrowFunctionExpression: pushStepCallback,
'ArrowFunctionExpression:exit': popStepCallback,
FunctionExpression: pushStepCallback,
'FunctionExpression:exit': popStepCallback,
};
},
meta: {
docs: {
category: 'Best Practices',
description: 'Disallow nested `test.step()` methods',
recommended: true,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-nested-step.md',
},
messages: {
noNestedStep: 'Do not nest `test.step()` methods.',
},
schema: [],
type: 'problem',
},
};

View File

@@ -0,0 +1,57 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
const messageId = 'noNetworkidle';
const methods = new Set([
'goBack',
'goForward',
'goto',
'reload',
'setContent',
'waitForLoadState',
'waitForURL',
]);
exports.default = {
create(context) {
return {
CallExpression(node) {
if (node.callee.type !== 'MemberExpression')
return;
const methodName = (0, ast_1.getStringValue)(node.callee.property);
if (!methods.has(methodName))
return;
// waitForLoadState has a single string argument
if (methodName === 'waitForLoadState') {
const arg = node.arguments[0];
if (arg && (0, ast_1.isStringLiteral)(arg, 'networkidle')) {
context.report({ messageId, node: arg });
}
return;
}
// All other methods have an options object
if (node.arguments.length >= 2) {
const [_, arg] = node.arguments;
if (arg.type !== 'ObjectExpression')
return;
const property = arg.properties
.filter((p) => p.type === 'Property')
.find((p) => (0, ast_1.isStringLiteral)(p.value, 'networkidle'));
if (property) {
context.report({ messageId, node: property.value });
}
}
},
};
},
meta: {
docs: {
category: 'Possible Errors',
description: 'Prevent usage of the networkidle option',
recommended: true,
},
messages: {
noNetworkidle: 'Unexpected use of networkidle.',
},
type: 'problem',
},
};

View File

@@ -0,0 +1,37 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
const methods = new Set(['first', 'last', 'nth']);
exports.default = {
create(context) {
return {
CallExpression(node) {
if (node.callee.type !== 'MemberExpression')
return;
const method = (0, ast_1.getStringValue)(node.callee.property);
if (!methods.has(method))
return;
context.report({
data: { method },
loc: {
end: node.loc.end,
start: node.callee.property.loc.start,
},
messageId: 'noNthMethod',
});
},
};
},
meta: {
docs: {
category: 'Best Practices',
description: 'Disallow usage of nth methods',
recommended: true,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-nth-methods.md',
},
messages: {
noNthMethod: 'Unexpected use of {{method}}()',
},
type: 'problem',
},
};

View File

@@ -0,0 +1,25 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
exports.default = {
create(context) {
return {
CallExpression(node) {
if ((0, ast_1.isPageMethod)(node, 'pause')) {
context.report({ messageId: 'noPagePause', node });
}
},
};
},
meta: {
docs: {
category: 'Possible Errors',
description: 'Prevent usage of page.pause()',
recommended: true,
},
messages: {
noPagePause: 'Unexpected use of page.pause().',
},
type: 'problem',
},
};

View File

@@ -0,0 +1,72 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
const parseExpectCall_1 = require("../utils/parseExpectCall");
exports.default = {
create(context) {
const restrictedChains = (context.options?.[0] ?? {});
return {
CallExpression(node) {
const expectCall = (0, parseExpectCall_1.parseExpectCall)(node);
if (!expectCall)
return;
Object.entries(restrictedChains)
.map(([restriction, message]) => {
const chain = expectCall.members;
const restrictionLinks = restriction.split('.').length;
// Find in the full chain, where the restriction chain starts
const startIndex = chain.findIndex((_, i) => {
// Construct the partial chain to compare against the restriction
// chain string.
const partial = chain
.slice(i, i + restrictionLinks)
.map(ast_1.getStringValue)
.join('.');
return partial === restriction;
});
return {
// If the restriction chain was found, return the portion of the
// chain that matches the restriction chain.
chain: startIndex !== -1
? chain.slice(startIndex, startIndex + restrictionLinks)
: [],
message,
restriction,
};
})
.filter(({ chain }) => chain.length)
.forEach(({ chain, message, restriction }) => {
context.report({
data: { message: message ?? '', restriction },
loc: {
end: chain[chain.length - 1].loc.end,
start: chain[0].loc.start,
},
messageId: message ? 'restrictedWithMessage' : 'restricted',
});
});
},
};
},
meta: {
docs: {
category: 'Best Practices',
description: 'Disallow specific matchers & modifiers',
recommended: false,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-restricted-matchers.md',
},
messages: {
restricted: 'Use of `{{restriction}}` is disallowed',
restrictedWithMessage: '{{message}}',
},
schema: [
{
additionalProperties: {
type: ['string', 'null'],
},
type: 'object',
},
],
type: 'suggestion',
},
};

View File

@@ -0,0 +1,48 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
exports.default = {
create(context) {
return {
CallExpression(node) {
const { callee } = node;
if (((0, ast_1.isTestIdentifier)(callee) || (0, ast_1.isDescribeCall)(node)) &&
callee.type === 'MemberExpression' &&
(0, ast_1.isPropertyAccessor)(callee, 'skip')) {
const isHook = (0, ast_1.isTest)(node) || (0, ast_1.isDescribeCall)(node);
context.report({
messageId: 'noSkippedTest',
node: isHook ? callee.property : node,
suggest: [
{
fix: (fixer) => {
return isHook
? fixer.removeRange([
callee.property.range[0] - 1,
callee.range[1],
])
: fixer.remove(node.parent);
},
messageId: 'removeSkippedTestAnnotation',
},
],
});
}
},
};
},
meta: {
docs: {
category: 'Best Practices',
description: 'Prevent usage of the `.skip()` skip test annotation.',
recommended: true,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-skipped-test.md',
},
hasSuggestions: true,
messages: {
noSkippedTest: 'Unexpected use of the `.skip()` annotation.',
removeSkippedTestAnnotation: 'Remove the `.skip()` annotation.',
},
type: 'suggestion',
},
};

View File

@@ -0,0 +1,87 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
const locatorMethods = new Set([
'and',
'first',
'getByAltText',
'getByLabel',
'getByPlaceholder',
'getByRole',
'getByTestId',
'getByText',
'getByTitle',
'last',
'locator',
'nth',
'or',
]);
const pageMethods = new Set([
'childFrames',
'frame',
'frameLocator',
'frames',
'isClosed',
'isDetached',
'mainFrame',
'name',
'on',
'page',
'parentFrame',
'setDefaultNavigationTimeout',
'setDefaultTimeout',
'url',
'video',
'viewportSize',
'workers',
]);
function isSupportedMethod(node) {
if (node.callee.type !== 'MemberExpression')
return false;
const name = (0, ast_1.getStringValue)(node.callee.property);
return (locatorMethods.has(name) ||
(pageMethods.has(name) && (0, ast_1.isPageMethod)(node, name)));
}
exports.default = {
create(context) {
return {
AwaitExpression(node) {
// Must be a call expression
if (node.argument.type !== 'CallExpression')
return;
// Must be a foo.bar() call, bare calls are ignored
const { callee } = node.argument;
if (callee.type !== 'MemberExpression')
return;
// Must be a method we care about
if (!isSupportedMethod(node.argument))
return;
const start = node.loc.start;
const range = node.range;
context.report({
fix: (fixer) => fixer.removeRange([range[0], range[0] + 6]),
loc: {
end: {
column: start.column + 5,
line: start.line,
},
start,
},
messageId: 'noUselessAwait',
});
},
};
},
meta: {
docs: {
category: 'Possible Errors',
description: 'Disallow unnecessary awaits for Playwright methods',
recommended: true,
},
fixable: 'code',
messages: {
noUselessAwait: 'Unnecessary await expression. This method does not return a Promise.',
},
type: 'problem',
},
};

View File

@@ -0,0 +1,59 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
const fixer_1 = require("../utils/fixer");
const parseExpectCall_1 = require("../utils/parseExpectCall");
const matcherMap = {
toBeDisabled: 'toBeEnabled',
toBeEnabled: 'toBeDisabled',
toBeHidden: 'toBeVisible',
toBeVisible: 'toBeHidden',
};
exports.default = {
create(context) {
return {
CallExpression(node) {
const expectCall = (0, parseExpectCall_1.parseExpectCall)(node);
if (!expectCall)
return;
// As the name implies, this rule only implies if the not modifier is
// part of the matcher chain
const notModifier = expectCall.modifiers.find((mod) => (0, ast_1.getStringValue)(mod) === 'not');
if (!notModifier)
return;
// This rule only applies to specific matchers that have opposites
if (expectCall.matcherName in matcherMap) {
const newMatcher = matcherMap[expectCall.matcherName];
context.report({
data: { new: newMatcher, old: expectCall.matcherName },
fix: (fixer) => [
fixer.removeRange([
notModifier.range[0] - (0, fixer_1.getRangeOffset)(notModifier),
notModifier.range[1] + 1,
]),
(0, fixer_1.replaceAccessorFixer)(fixer, expectCall.matcher, newMatcher),
],
loc: {
end: expectCall.matcher.loc.end,
start: notModifier.loc.start,
},
messageId: 'noUselessNot',
});
}
},
};
},
meta: {
docs: {
category: 'Best Practices',
description: `Disallow usage of 'not' matchers when a more specific matcher exists`,
recommended: true,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-useless-not.md',
},
fixable: 'code',
messages: {
noUselessNot: 'Unexpected usage of not.{{old}}(). Use {{new}}() instead.',
},
type: 'problem',
},
};

View File

@@ -0,0 +1,39 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
exports.default = {
create(context) {
return {
CallExpression(node) {
if ((0, ast_1.isPageMethod)(node, 'waitForTimeout')) {
context.report({
messageId: 'noWaitForTimeout',
node,
suggest: [
{
fix: (fixer) => fixer.remove(node.parent && node.parent.type !== 'AwaitExpression'
? node.parent
: node.parent.parent),
messageId: 'removeWaitForTimeout',
},
],
});
}
},
};
},
meta: {
docs: {
category: 'Best Practices',
description: 'Prevent usage of page.waitForTimeout()',
recommended: true,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-wait-for-timeout.md',
},
hasSuggestions: true,
messages: {
noWaitForTimeout: 'Unexpected use of page.waitForTimeout().',
removeWaitForTimeout: 'Remove the page.waitForTimeout() method.',
},
type: 'suggestion',
},
};

View File

@@ -0,0 +1,106 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
function isString(node) {
return node && ((0, ast_1.isStringLiteral)(node) || node.type === 'TemplateLiteral');
}
exports.default = {
create(context) {
const { allowedPrefixes, ignore, ignoreTopLevelDescribe } = {
allowedPrefixes: [],
ignore: [],
ignoreTopLevelDescribe: false,
...(context.options?.[0] ?? {}),
};
let describeCount = 0;
return {
CallExpression(node) {
const method = (0, ast_1.isDescribeCall)(node)
? 'test.describe'
: (0, ast_1.isTest)(node)
? 'test'
: null;
if (method === 'test.describe') {
describeCount++;
if (ignoreTopLevelDescribe && describeCount === 1) {
return;
}
}
else if (!method) {
return;
}
const [title] = node.arguments;
if (!isString(title)) {
return;
}
const description = (0, ast_1.getStringValue)(title);
if (!description ||
allowedPrefixes.some((name) => description.startsWith(name))) {
return;
}
const firstCharacter = description.charAt(0);
if (!firstCharacter ||
firstCharacter === firstCharacter.toLowerCase() ||
ignore.includes(method)) {
return;
}
context.report({
data: { method },
fix(fixer) {
const rangeIgnoringQuotes = [
title.range[0] + 1,
title.range[1] - 1,
];
const newDescription = description.substring(0, 1).toLowerCase() +
description.substring(1);
return fixer.replaceTextRange(rangeIgnoringQuotes, newDescription);
},
messageId: 'unexpectedLowercase',
node: node.arguments[0],
});
},
'CallExpression:exit'(node) {
if ((0, ast_1.isDescribeCall)(node)) {
describeCount--;
}
},
};
},
meta: {
docs: {
category: 'Best Practices',
description: 'Enforce lowercase test names',
recommended: false,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-lowercase-title.md',
},
fixable: 'code',
messages: {
unexpectedLowercase: '`{{method}}`s should begin with lowercase',
},
schema: [
{
additionalProperties: false,
properties: {
allowedPrefixes: {
additionalItems: false,
items: { type: 'string' },
type: 'array',
},
ignore: {
additionalItems: false,
items: {
enum: ['test.describe', 'test'],
},
type: 'array',
},
ignoreTopLevelDescribe: {
default: false,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
};

View File

@@ -0,0 +1,43 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const fixer_1 = require("../utils/fixer");
const parseExpectCall_1 = require("../utils/parseExpectCall");
exports.default = {
create(context) {
return {
CallExpression(node) {
const expectCall = (0, parseExpectCall_1.parseExpectCall)(node);
if (expectCall?.matcherName === 'toEqual') {
context.report({
messageId: 'useToStrictEqual',
node: expectCall.matcher,
suggest: [
{
fix: (fixer) => {
return (0, fixer_1.replaceAccessorFixer)(fixer, expectCall.matcher, 'toStrictEqual');
},
messageId: 'suggestReplaceWithStrictEqual',
},
],
});
}
},
};
},
meta: {
docs: {
category: 'Best Practices',
description: 'Suggest using `toStrictEqual()`',
recommended: false,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-strict-equal.md',
},
fixable: 'code',
hasSuggestions: true,
messages: {
suggestReplaceWithStrictEqual: 'Replace with `toStrictEqual()`',
useToStrictEqual: 'Use toStrictEqual() instead',
},
schema: [],
type: 'suggestion',
},
};

View File

@@ -0,0 +1,88 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
const fixer_1 = require("../utils/fixer");
const parseExpectCall_1 = require("../utils/parseExpectCall");
function shouldUseToBe(expectCall) {
let arg = expectCall.args[0];
if (arg.type === 'UnaryExpression' && arg.operator === '-') {
arg = arg.argument;
}
if (arg.type === 'Literal') {
// regex literals are classed as literals, but they're actually objects
// which means "toBe" will give different results than other matchers
return !('regex' in arg);
}
return arg.type === 'TemplateLiteral';
}
function reportPreferToBe(context, expectCall, whatToBe, notModifier) {
context.report({
fix(fixer) {
const fixes = [
(0, fixer_1.replaceAccessorFixer)(fixer, expectCall.matcher, `toBe${whatToBe}`),
];
if (expectCall.args?.length && whatToBe !== '') {
fixes.push(fixer.remove(expectCall.args[0]));
}
if (notModifier) {
const [start, end] = notModifier.range;
fixes.push(fixer.removeRange([start - 1, end]));
}
return fixes;
},
messageId: `useToBe${whatToBe}`,
node: expectCall.matcher,
});
}
exports.default = {
create(context) {
return {
CallExpression(node) {
const expectCall = (0, parseExpectCall_1.parseExpectCall)(node);
if (!expectCall)
return;
const notMatchers = ['toBeUndefined', 'toBeDefined'];
const notModifier = expectCall.modifiers.find((node) => (0, ast_1.getStringValue)(node) === 'not');
if (notModifier && notMatchers.includes(expectCall.matcherName)) {
return reportPreferToBe(context, expectCall, expectCall.matcherName === 'toBeDefined' ? 'Undefined' : 'Defined', notModifier);
}
const argumentMatchers = ['toBe', 'toEqual', 'toStrictEqual'];
const firstArg = expectCall.args[0];
if (!argumentMatchers.includes(expectCall.matcherName) || !firstArg) {
return;
}
if (firstArg.type === 'Literal' && firstArg.value === null) {
return reportPreferToBe(context, expectCall, 'Null');
}
if ((0, ast_1.isIdentifier)(firstArg, 'undefined')) {
const name = notModifier ? 'Defined' : 'Undefined';
return reportPreferToBe(context, expectCall, name, notModifier);
}
if ((0, ast_1.isIdentifier)(firstArg, 'NaN')) {
return reportPreferToBe(context, expectCall, 'NaN');
}
if (shouldUseToBe(expectCall) && expectCall.matcherName !== 'toBe') {
reportPreferToBe(context, expectCall, '');
}
},
};
},
meta: {
docs: {
category: 'Best Practices',
description: 'Suggest using `toBe()` for primitive literals',
recommended: false,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-be.md',
},
fixable: 'code',
messages: {
useToBe: 'Use `toBe` when expecting primitive literals',
useToBeDefined: 'Use `toBeDefined` instead',
useToBeNaN: 'Use `toBeNaN` instead',
useToBeNull: 'Use `toBeNull` instead',
useToBeUndefined: 'Use `toBeUndefined` instead',
},
schema: [],
type: 'suggestion',
},
};

View File

@@ -0,0 +1,75 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
const parseExpectCall_1 = require("../utils/parseExpectCall");
const matchers = new Set(['toBe', 'toEqual', 'toStrictEqual']);
const isFixableIncludesCallExpression = (node) => node.type === 'CallExpression' &&
node.callee.type === 'MemberExpression' &&
(0, ast_1.isPropertyAccessor)(node.callee, 'includes') &&
node.arguments.length === 1 &&
node.arguments[0].type !== 'SpreadElement';
exports.default = {
create(context) {
return {
CallExpression(node) {
const expectCall = (0, parseExpectCall_1.parseExpectCall)(node);
if (!expectCall || expectCall.args.length === 0)
return;
const { args, matcher, matcherName } = expectCall;
const [includesCall] = node.arguments;
const [matcherArg] = args;
if (!includesCall ||
matcherArg.type === 'SpreadElement' ||
!matchers.has(matcherName) ||
!(0, ast_1.isBooleanLiteral)(matcherArg) ||
!isFixableIncludesCallExpression(includesCall)) {
return;
}
const notModifier = expectCall.modifiers.find((node) => (0, ast_1.getStringValue)(node) === 'not');
context.report({
fix(fixer) {
const sourceCode = context.getSourceCode();
// We need to negate the expectation if the current expected
// value is itself negated by the "not" modifier
const addNotModifier = matcherArg.type === 'Literal' &&
matcherArg.value === !!notModifier;
const fixes = [
// remove the "includes" call entirely
fixer.removeRange([
includesCall.callee.property.range[0] - 1,
includesCall.range[1],
]),
// replace the current matcher with "toContain", adding "not" if needed
fixer.replaceText(matcher, addNotModifier ? 'not.toContain' : 'toContain'),
// replace the matcher argument with the value from the "includes"
fixer.replaceText(expectCall.args[0], sourceCode.getText(includesCall.arguments[0])),
];
// Remove the "not" modifier if needed
if (notModifier) {
fixes.push(fixer.removeRange([
notModifier.range[0],
notModifier.range[1] + 1,
]));
}
return fixes;
},
messageId: 'useToContain',
node: matcher,
});
},
};
},
meta: {
docs: {
category: 'Best Practices',
description: 'Suggest using toContain()',
recommended: false,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-contain.md',
},
fixable: 'code',
messages: {
useToContain: 'Use toContain() instead',
},
type: 'suggestion',
},
};

View File

@@ -0,0 +1,52 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
const fixer_1 = require("../utils/fixer");
const parseExpectCall_1 = require("../utils/parseExpectCall");
const lengthMatchers = new Set(['toBe', 'toEqual', 'toStrictEqual']);
exports.default = {
create(context) {
return {
CallExpression(node) {
const expectCall = (0, parseExpectCall_1.parseExpectCall)(node);
if (!expectCall || !lengthMatchers.has(expectCall.matcherName)) {
return;
}
const [argument] = node.arguments;
if (argument?.type !== 'MemberExpression' ||
!(0, ast_1.isPropertyAccessor)(argument, 'length')) {
return;
}
context.report({
fix(fixer) {
return [
// remove the "length" property accessor
fixer.removeRange([
argument.property.range[0] - 1,
argument.range[1],
]),
// replace the current matcher with "toHaveLength"
(0, fixer_1.replaceAccessorFixer)(fixer, expectCall.matcher, 'toHaveLength'),
];
},
messageId: 'useToHaveLength',
node: expectCall.matcher,
});
},
};
},
meta: {
docs: {
category: 'Best Practices',
description: 'Suggest using `toHaveLength()`',
recommended: false,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-to-have-length.md',
},
fixable: 'code',
messages: {
useToHaveLength: 'Use toHaveLength() instead',
},
schema: [],
type: 'suggestion',
},
};

View File

@@ -0,0 +1,150 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getMatcherCall = void 0;
const ast_1 = require("../utils/ast");
const parseExpectCall_1 = require("../utils/parseExpectCall");
const methods = {
getAttribute: {
matcher: 'toHaveAttribute',
type: 'string',
},
innerText: { matcher: 'toHaveText', type: 'string' },
inputValue: { matcher: 'toHaveValue', type: 'string' },
isChecked: { matcher: 'toBeChecked', type: 'boolean' },
isDisabled: {
inverse: 'toBeEnabled',
matcher: 'toBeDisabled',
type: 'boolean',
},
isEditable: { matcher: 'toBeEditable', type: 'boolean' },
isEnabled: {
inverse: 'toBeDisabled',
matcher: 'toBeEnabled',
type: 'boolean',
},
isHidden: {
inverse: 'toBeVisible',
matcher: 'toBeHidden',
type: 'boolean',
},
isVisible: {
inverse: 'toBeHidden',
matcher: 'toBeVisible',
type: 'boolean',
},
textContent: { matcher: 'toHaveText', type: 'string' },
};
const supportedMatchers = new Set([
'toBe',
'toEqual',
'toBeTruthy',
'toBeFalsy',
]);
function getMatcherCall(node) {
const grandparent = node.parent?.parent;
return grandparent.type === 'CallExpression' ? grandparent : undefined;
}
exports.getMatcherCall = getMatcherCall;
exports.default = {
create(context) {
return {
CallExpression(node) {
const expectCall = (0, parseExpectCall_1.parseExpectCall)(node);
if (!expectCall)
return;
const [arg] = node.arguments;
if (arg.type !== 'AwaitExpression' ||
arg.argument.type !== 'CallExpression' ||
arg.argument.callee.type !== 'MemberExpression') {
return;
}
// Matcher must be supported
if (!supportedMatchers.has(expectCall.matcherName))
return;
// Playwright method must be supported
const method = (0, ast_1.getStringValue)(arg.argument.callee.property);
const methodConfig = methods[method];
if (!methodConfig)
return;
// Change the matcher
const { args, matcher } = expectCall;
const notModifier = expectCall.modifiers.find((mod) => (0, ast_1.getStringValue)(mod) === 'not');
const isFalsy = methodConfig.type === 'boolean' &&
((!!args.length && (0, ast_1.isBooleanLiteral)(args[0], false)) ||
expectCall.matcherName === 'toBeFalsy');
const isInverse = methodConfig.inverse
? notModifier || isFalsy
: notModifier && isFalsy;
// Replace the old matcher with the new matcher. The inverse
// matcher should only be used if the old statement was not a
// double negation.
const newMatcher = (+!!notModifier ^ +isFalsy && methodConfig.inverse) ||
methodConfig.matcher;
const { callee } = arg.argument;
context.report({
data: {
matcher: newMatcher,
method,
},
fix: (fixer) => {
const methodArgs = arg.argument.type === 'CallExpression'
? arg.argument.arguments
: [];
const methodEnd = methodArgs.length
? methodArgs[methodArgs.length - 1].range[1] + 1
: callee.property.range[1] + 2;
const fixes = [
// Add await to the expect call
fixer.insertTextBefore(node, 'await '),
// Remove the await keyword
fixer.replaceTextRange([arg.range[0], arg.argument.range[0]], ''),
// Remove the old Playwright method and any arguments
fixer.replaceTextRange([callee.property.range[0] - 1, methodEnd], ''),
];
// Remove not from matcher chain if no longer needed
if (isInverse && notModifier) {
const notRange = notModifier.range;
fixes.push(fixer.removeRange([notRange[0], notRange[1] + 1]));
}
// Add not to the matcher chain if no inverse matcher exists
if (!methodConfig.inverse && !notModifier && isFalsy) {
fixes.push(fixer.insertTextBefore(matcher, 'not.'));
}
fixes.push(fixer.replaceText(matcher, newMatcher));
// Remove boolean argument if it exists
const [matcherArg] = args ?? [];
if (matcherArg && (0, ast_1.isBooleanLiteral)(matcherArg)) {
fixes.push(fixer.remove(matcherArg));
}
// Add the new matcher arguments if needed
const hasOtherArgs = !!methodArgs.filter((arg) => !(0, ast_1.isBooleanLiteral)(arg)).length;
if (methodArgs) {
const range = matcher.range;
const stringArgs = methodArgs
.map((arg) => (0, ast_1.getRawValue)(arg))
.concat(hasOtherArgs ? '' : [])
.join(', ');
fixes.push(fixer.insertTextAfterRange([range[0], range[1] + 1], stringArgs));
}
return fixes;
},
messageId: 'useWebFirstAssertion',
node,
});
},
};
},
meta: {
docs: {
category: 'Best Practices',
description: 'Prefer web first assertions',
recommended: true,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-web-first-assertions.md',
},
fixable: 'code',
messages: {
useWebFirstAssertion: 'Replace {{method}}() with {{matcher}}().',
},
type: 'suggestion',
},
};

View File

@@ -0,0 +1,31 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
exports.default = {
create(context) {
return {
CallExpression(node) {
if ((0, ast_1.getExpectType)(node) === 'standalone') {
context.report({
fix: (fixer) => fixer.insertTextAfter(node.callee, '.soft'),
messageId: 'requireSoft',
node: node.callee,
});
}
},
};
},
meta: {
docs: {
description: 'Require all assertions to use `expect.soft`',
recommended: false,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/require-soft-assertions.md',
},
fixable: 'code',
messages: {
requireSoft: 'Unexpected non-soft assertion',
},
schema: [],
type: 'suggestion',
},
};

View File

@@ -0,0 +1,70 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
const misc_1 = require("../utils/misc");
exports.default = {
create(context) {
const { maxTopLevelDescribes } = {
maxTopLevelDescribes: Infinity,
...(context.options?.[0] ?? {}),
};
let topLevelDescribeCount = 0;
let describeCount = 0;
return {
CallExpression(node) {
if ((0, ast_1.isDescribeCall)(node)) {
describeCount++;
if (describeCount === 1) {
topLevelDescribeCount++;
if (topLevelDescribeCount > maxTopLevelDescribes) {
context.report({
data: (0, misc_1.getAmountData)(maxTopLevelDescribes),
messageId: 'tooManyDescribes',
node: node.callee,
});
}
}
}
else if (!describeCount) {
if ((0, ast_1.isTest)(node)) {
context.report({ messageId: 'unexpectedTest', node: node.callee });
}
else if ((0, ast_1.isTestHook)(node)) {
context.report({ messageId: 'unexpectedHook', node: node.callee });
}
}
},
'CallExpression:exit'(node) {
if ((0, ast_1.isDescribeCall)(node)) {
describeCount--;
}
},
};
},
meta: {
docs: {
category: 'Best Practices',
description: 'Require test cases and hooks to be inside a `test.describe` block',
recommended: false,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/require-top-level-describe.md',
},
messages: {
tooManyDescribes: 'There should not be more than {{max}} describe{{s}} at the top level',
unexpectedHook: 'All hooks must be wrapped in a describe block.',
unexpectedTest: 'All test cases must be wrapped in a describe block.',
},
schema: [
{
additionalProperties: false,
properties: {
maxTopLevelDescribes: {
minimum: 1,
type: 'number',
},
},
type: 'object',
},
],
type: 'suggestion',
},
};

View File

@@ -0,0 +1,97 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const ast_1 = require("../utils/ast");
const misc_1 = require("../utils/misc");
const parseExpectCall_1 = require("../utils/parseExpectCall");
function isMatcherCalled(node) {
if (node.parent.type !== 'MemberExpression') {
// Just asserting that the parent is a call expression is not enough as
// the node could be an argument of a call expression which doesn't
// determine if it is called. To determine if it is called, we verify
// that the parent call expression callee is the same as the node.
return {
called: node.parent.type === 'CallExpression' && node.parent.callee === node,
node,
};
}
// If the parent is a member expression, we continue traversing upward to
// handle matcher chains of unknown length. e.g. expect().not.something.
return isMatcherCalled(node.parent);
}
exports.default = {
create(context) {
const options = {
maxArgs: 2,
minArgs: 1,
...(context.options?.[0] ?? {}),
};
const minArgs = Math.min(options.minArgs, options.maxArgs);
const maxArgs = Math.max(options.minArgs, options.maxArgs);
return {
CallExpression(node) {
if (!(0, ast_1.isExpectCall)(node))
return;
const expectCall = (0, parseExpectCall_1.parseExpectCall)(node);
if (!expectCall) {
context.report({ messageId: 'matcherNotFound', node });
}
else {
const result = isMatcherCalled(node);
if (!result.called) {
context.report({
messageId: 'matcherNotCalled',
node: result.node.type === 'MemberExpression'
? result.node.property
: result.node,
});
}
}
if (node.arguments.length < minArgs) {
context.report({
data: (0, misc_1.getAmountData)(minArgs),
messageId: 'notEnoughArgs',
node,
});
}
if (node.arguments.length > maxArgs) {
context.report({
data: (0, misc_1.getAmountData)(maxArgs),
messageId: 'tooManyArgs',
node,
});
}
},
};
},
meta: {
docs: {
category: 'Possible Errors',
description: 'Enforce valid `expect()` usage',
recommended: true,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/valid-expect.md',
},
messages: {
matcherNotCalled: 'Matchers must be called to assert.',
matcherNotFound: 'Expect must have a corresponding matcher call.',
notEnoughArgs: 'Expect requires at least {{amount}} argument{{s}}.',
tooManyArgs: 'Expect takes at most {{amount}} argument{{s}}.',
},
schema: [
{
additionalProperties: false,
properties: {
maxArgs: {
minimum: 1,
type: 'number',
},
minArgs: {
minimum: 1,
type: 'number',
},
},
type: 'object',
},
],
type: 'problem',
},
};

140
node_modules/eslint-plugin-playwright/lib/utils/ast.js generated vendored Normal file
View File

@@ -0,0 +1,140 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.isPageMethod = exports.getMatchers = exports.isExpectCall = exports.getExpectType = exports.isTestHook = exports.isTest = exports.findParent = exports.isDescribeCall = exports.isTestIdentifier = exports.isPropertyAccessor = exports.isBooleanLiteral = exports.isStringLiteral = exports.isIdentifier = exports.getRawValue = exports.getStringValue = void 0;
function getStringValue(node) {
if (!node)
return '';
return node.type === 'Identifier'
? node.name
: node.type === 'TemplateLiteral'
? node.quasis[0].value.raw
: node.type === 'Literal' && typeof node.value === 'string'
? node.value
: '';
}
exports.getStringValue = getStringValue;
function getRawValue(node) {
return node.type === 'Literal' ? node.raw : undefined;
}
exports.getRawValue = getRawValue;
function isIdentifier(node, name) {
return (node.type === 'Identifier' &&
(typeof name === 'string' ? node.name === name : name.test(node.name)));
}
exports.isIdentifier = isIdentifier;
function isLiteral(node, type, value) {
return (node.type === 'Literal' &&
(value === undefined
? typeof node.value === type
: node.value === value));
}
function isStringLiteral(node, value) {
return isLiteral(node, 'string', value);
}
exports.isStringLiteral = isStringLiteral;
function isBooleanLiteral(node, value) {
return isLiteral(node, 'boolean', value);
}
exports.isBooleanLiteral = isBooleanLiteral;
function isPropertyAccessor(node, name) {
return getStringValue(node.property) === name;
}
exports.isPropertyAccessor = isPropertyAccessor;
function isTestIdentifier(node) {
return (isIdentifier(node, 'test') ||
(node.type === 'MemberExpression' && isIdentifier(node.object, 'test')));
}
exports.isTestIdentifier = isTestIdentifier;
const describeProperties = new Set([
'parallel',
'serial',
'only',
'skip',
'fixme',
]);
function isDescribeCall(node) {
const inner = node.type === 'CallExpression' ? node.callee : node;
// Allow describe without test prefix
if (isIdentifier(inner, 'describe')) {
return true;
}
if (inner.type !== 'MemberExpression') {
return false;
}
return isPropertyAccessor(inner, 'describe')
? true
: describeProperties.has(getStringValue(inner.property))
? isDescribeCall(inner.object)
: false;
}
exports.isDescribeCall = isDescribeCall;
function findParent(node, type) {
if (!node.parent)
return;
return node.parent.type === type
? node.parent
: findParent(node.parent, type);
}
exports.findParent = findParent;
function isTest(node, modifiers) {
return (isTestIdentifier(node.callee) &&
!isDescribeCall(node) &&
(node.callee.type !== 'MemberExpression' ||
!modifiers ||
modifiers?.includes(getStringValue(node.callee.property))) &&
node.arguments.length === 2 &&
['ArrowFunctionExpression', 'FunctionExpression'].includes(node.arguments[1].type));
}
exports.isTest = isTest;
const testHooks = new Set(['afterAll', 'afterEach', 'beforeAll', 'beforeEach']);
function isTestHook(node) {
return (node.callee.type === 'MemberExpression' &&
isIdentifier(node.callee.object, 'test') &&
testHooks.has(getStringValue(node.callee.property)));
}
exports.isTestHook = isTestHook;
const expectSubCommands = new Set(['soft', 'poll']);
function getExpectType(node) {
if (isIdentifier(node.callee, /(^expect|Expect)$/)) {
return 'standalone';
}
if (node.callee.type === 'MemberExpression' &&
isIdentifier(node.callee.object, 'expect')) {
const type = getStringValue(node.callee.property);
return expectSubCommands.has(type) ? type : undefined;
}
}
exports.getExpectType = getExpectType;
function isExpectCall(node) {
return !!getExpectType(node);
}
exports.isExpectCall = isExpectCall;
function getMatchers(node, chain = []) {
if (node.parent.type === 'MemberExpression' && node.parent.object === node) {
return getMatchers(node.parent, [
...chain,
node.parent.property,
]);
}
return chain;
}
exports.getMatchers = getMatchers;
/**
* Digs through a series of MemberExpressions and CallExpressions to find an
* Identifier with the given name.
*/
function dig(node, identifier) {
return node.type === 'MemberExpression'
? dig(node.property, identifier)
: node.type === 'CallExpression'
? dig(node.callee, identifier)
: node.type === 'Identifier'
? isIdentifier(node, identifier)
: false;
}
function isPageMethod(node, name) {
return (node.callee.type === 'MemberExpression' &&
dig(node.callee.object, /(^(page|frame)|(Page|Frame)$)/) &&
isPropertyAccessor(node.callee, name));
}
exports.isPageMethod = isPageMethod;

View File

@@ -0,0 +1,16 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.replaceAccessorFixer = exports.getRangeOffset = void 0;
const getRangeOffset = (node) => node.type === 'Identifier' ? 0 : 1;
exports.getRangeOffset = getRangeOffset;
/**
* Replaces an accessor node with the given `text`.
*
* This ensures that fixes produce valid code when replacing both dot-based and
* bracket-based property accessors.
*/
function replaceAccessorFixer(fixer, node, text) {
const [start, end] = node.range;
return fixer.replaceTextRange([start + (0, exports.getRangeOffset)(node), end - (0, exports.getRangeOffset)(node)], text);
}
exports.replaceAccessorFixer = replaceAccessorFixer;

View File

@@ -0,0 +1,8 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getAmountData = void 0;
const getAmountData = (amount) => ({
amount: amount.toString(),
s: amount === 1 ? '' : 's',
});
exports.getAmountData = getAmountData;

View File

@@ -0,0 +1,38 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseExpectCall = void 0;
const ast_1 = require("./ast");
const MODIFIER_NAMES = new Set(['not', 'resolves', 'rejects']);
function getExpectArguments(node) {
const grandparent = node.parent.parent;
return grandparent.type === 'CallExpression' ? grandparent.arguments : [];
}
function parseExpectCall(node) {
if (!(0, ast_1.isExpectCall)(node)) {
return;
}
const members = (0, ast_1.getMatchers)(node);
const modifiers = [];
let matcher;
// Separate the matchers (e.g. toBe) from modifiers (e.g. not)
members.forEach((item) => {
if (MODIFIER_NAMES.has((0, ast_1.getStringValue)(item))) {
modifiers.push(item);
}
else {
matcher = item;
}
});
// Rules only run against full expect calls with matchers
if (!matcher) {
return;
}
return {
args: getExpectArguments(matcher),
matcher,
matcherName: (0, ast_1.getStringValue)(matcher),
members,
modifiers,
};
}
exports.parseExpectCall = parseExpectCall;

View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });