eslint-plugin-playwright
Version:
ESLint plugin for Playwright testing.
1,679 lines (1,658 loc) • 155 kB
JavaScript
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
// src/plugin.ts
var import_globals = __toESM(require("globals"), 1);
// src/utils/parseFnCall.ts
var testHooks = /* @__PURE__ */ new Set(["afterAll", "afterEach", "beforeAll", "beforeEach"]);
var VALID_CHAINS = /* @__PURE__ */ new Set([
// Hooks
"afterAll",
"afterEach",
"beforeAll",
"beforeEach",
"test.afterAll",
"test.afterEach",
"test.beforeAll",
"test.beforeEach",
// Describe
"describe",
"describe.only",
"describe.skip",
"describe.fixme",
"describe.fixme.only",
"describe.configure",
"describe.serial",
"describe.serial.only",
"describe.serial.skip",
"describe.serial.fixme",
"describe.serial.fixme.only",
"describe.parallel",
"describe.parallel.only",
"describe.parallel.skip",
"describe.parallel.fixme",
"describe.parallel.fixme.only",
"test.describe",
"test.describe.only",
"test.describe.skip",
"test.describe.fixme",
"test.describe.fixme.only",
"test.describe.configure",
"test.describe.serial",
"test.describe.serial.only",
"test.describe.serial.skip",
"test.describe.serial.fixme",
"test.describe.serial.fixme.only",
"test.describe.parallel",
"test.describe.parallel.only",
"test.describe.parallel.skip",
"test.describe.parallel.fixme",
"test.describe.parallel.fixme.only",
// Test
"test",
"test.fail",
"test.fail.only",
"test.fixme",
"test.only",
"test.skip",
"test.step",
"test.step.skip",
"test.slow",
"test.use"
]);
var joinChains = (a, b) => a && b ? [...a, ...b] : null;
var isSupportedAccessor = (node, value) => isIdentifier(node, value) || isStringNode(node, value);
var Chain = class {
#nodes = null;
#leaves = /* @__PURE__ */ new WeakSet();
constructor(node) {
this.#nodes = this.#buildChain(node);
}
isLeaf(node) {
return this.#leaves.has(node);
}
get nodes() {
return this.#nodes;
}
#buildChain(node, insideCall = false) {
if (isSupportedAccessor(node)) {
if (insideCall) {
this.#leaves.add(node);
}
return [node];
}
switch (node.type) {
case "TaggedTemplateExpression":
return this.#buildChain(node.tag);
case "MemberExpression":
return joinChains(
this.#buildChain(node.object),
this.#buildChain(node.property, insideCall)
);
case "CallExpression":
return this.#buildChain(node.callee, true);
default:
return null;
}
}
};
function resolvePossibleAliasedGlobal(context, name) {
const settings = context.settings;
const globalAliases = settings?.playwright?.globalAliases ?? {};
const alias = Object.entries(globalAliases).find(([, aliases]) => aliases.includes(name));
return alias?.[0] ?? null;
}
function resolveImportAlias(context, node) {
if (node.type !== "Identifier") {
return null;
}
const scope = context.sourceCode.getScope(node);
const ref = scope.references.find((r) => r.identifier === node);
for (const def of ref?.resolved?.defs ?? []) {
if (def.type === "ImportBinding" && def.node.type === "ImportSpecifier") {
const imported = getStringValue(def.node.imported);
if (imported !== node.name) {
return imported;
}
}
}
return null;
}
var resolveToPlaywrightFn = (context, accessor) => {
const ident = getStringValue(accessor);
const resolved = /(^expect|Expect)$/.test(ident) ? "expect" : ident;
if (resolved === "test" || resolved === "expect") {
return { original: null, local: resolved };
}
return {
original: resolvePossibleAliasedGlobal(context, resolved) ?? resolveImportAlias(context, accessor),
local: resolved
};
};
function determinePlaywrightFnGroup(name) {
if (name === "step") {
return "step";
}
if (name === "expect") {
return "expect";
}
if (name === "describe") {
return "describe";
}
if (name === "test") {
return "test";
}
if (testHooks.has(name)) {
return "hook";
}
return "unknown";
}
var modifiers = /* @__PURE__ */ new Set(["not", "resolves", "rejects"]);
var findModifiersAndMatcher = (chain, members, stage) => {
const modifiers2 = [];
for (const member of members) {
const name = getStringValue(member);
if (name === "soft" || name === "poll") {
if (modifiers2.length > 0) {
return "modifier-unknown";
}
} else if (name === "resolves" || name === "rejects") {
const lastModifier = getStringValue(modifiers2.at(-1));
if (lastModifier && lastModifier !== "soft" && lastModifier !== "poll") {
return "modifier-unknown";
}
} else if (name !== "not") {
if (stage === "modifiers") {
return null;
}
if (member.parent?.type === "MemberExpression" && member.parent.parent?.type === "CallExpression") {
return {
matcher: member,
matcherArgs: member.parent.parent.arguments,
matcherName: name,
modifiers: modifiers2
};
}
return "modifier-unknown";
}
if (chain.isLeaf(member)) {
stage = "matchers";
}
modifiers2.push(member);
}
return "matcher-not-found";
};
function getExpectArguments(call) {
return findParent(call.head.node, "CallExpression")?.arguments ?? [];
}
var parseExpectCall = (chain, call, stage) => {
const modifiersAndMatcher = findModifiersAndMatcher(chain, call.members, stage);
if (!modifiersAndMatcher) {
return null;
}
if (typeof modifiersAndMatcher === "string") {
return modifiersAndMatcher;
}
return {
...call,
args: getExpectArguments(call),
group: "expect",
type: "expect",
...modifiersAndMatcher
};
};
var findTopMostCallExpression = (node) => {
let top = node;
let parent = node.parent;
let child = node;
while (parent) {
if (parent.type === "CallExpression" && parent.callee === child) {
top = parent;
parent = parent.parent;
continue;
}
if (parent.type !== "MemberExpression") {
break;
}
child = parent;
parent = parent.parent;
}
return top;
};
function isTestExtendCall(context, node) {
if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression" || !isPropertyAccessor(node.callee, "extend")) {
return false;
}
const object = node.callee.object;
if (object.type === "Identifier") {
const resolved = resolveToPlaywrightFn(context, object);
if ((resolved?.original ?? resolved?.local) === "test") {
return true;
}
const dereferenced = dereference(context, object);
if (dereferenced) {
return isTestExtendCall(context, dereferenced);
}
}
return false;
}
function parse(context, node) {
const chain = new Chain(node);
if (!chain.nodes?.length) {
return null;
}
const [first, ...rest] = chain.nodes;
let resolved = resolveToPlaywrightFn(context, first);
if (!resolved) {
return null;
}
let name = resolved.original ?? resolved.local;
const links = [name, ...rest.map((link) => getStringValue(link))];
if (determinePlaywrightFnGroup(name) === "unknown") {
const dereferenced = dereference(context, first);
if (dereferenced && isTestExtendCall(context, dereferenced)) {
name = "test";
links[0] = "test";
resolved = { local: resolved.local, original: "test" };
}
}
if (name === "test" && links.length > 1) {
const nextLinkName = links[1];
const nextLinkGroup = determinePlaywrightFnGroup(nextLinkName);
if (nextLinkGroup !== "unknown") {
name = nextLinkName;
}
}
if (name !== "expect" && !VALID_CHAINS.has(links.join("."))) {
return null;
}
const parsedFnCall = {
head: { ...resolved, node: first },
// every member node must have a member expression as their parent
// in order to be part of the call chain we're parsing
members: rest,
name
};
const group = determinePlaywrightFnGroup(name);
if (group === "expect") {
let stage = chain.isLeaf(parsedFnCall.head.node) ? "matchers" : "modifiers";
if (isIdentifier(rest[0], "expect")) {
stage = chain.isLeaf(rest[0]) ? "matchers" : "modifiers";
parsedFnCall.members.shift();
}
const result = parseExpectCall(chain, parsedFnCall, stage);
if (!result) {
return null;
}
if (typeof result === "string" && findTopMostCallExpression(node) !== node) {
return null;
}
if (result === "matcher-not-found") {
if (node.parent?.type === "MemberExpression") {
return "matcher-not-called";
}
}
return result;
}
if (chain.nodes.slice(0, chain.nodes.length - 1).some((n) => n.parent?.type !== "MemberExpression")) {
return null;
}
const parent = node.parent;
if (parent?.type === "CallExpression" || parent?.type === "MemberExpression") {
return null;
}
let type = group;
if ((name === "test" || name === "describe") && (node.arguments.length < 2 || !isFunction(node.arguments.at(-1)))) {
type = "config";
}
return {
...parsedFnCall,
group,
type
};
}
var cache = /* @__PURE__ */ new WeakMap();
function parseFnCallWithReason(context, node) {
if (cache.has(node)) {
return cache.get(node);
}
const call = parse(context, node);
cache.set(node, call);
return call;
}
function parseFnCall(context, node) {
const call = parseFnCallWithReason(context, node);
return typeof call === "string" ? null : call;
}
var isTypeOfFnCall = (context, node, types) => {
const call = parseFnCall(context, node);
return call !== null && types.includes(call.type);
};
// src/utils/ast.ts
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 : "";
}
function getRawValue(node) {
return node.type === "Literal" ? node.raw : void 0;
}
function isIdentifier(node, name) {
return node?.type === "Identifier" && (!name || (typeof name === "string" ? node.name === name : name.test(node.name)));
}
function isLiteral(node, type, value) {
return node.type === "Literal" && (value === void 0 ? typeof node.value === type : node.value === value);
}
var isTemplateLiteral = (node, value) => node.type === "TemplateLiteral" && node.quasis.length === 1 && // bail out if not simple
(value === void 0 || node.quasis[0].value.raw === value);
function isStringLiteral(node, value) {
return isLiteral(node, "string", value);
}
function isBooleanLiteral(node, value) {
return isLiteral(node, "boolean", value);
}
function isStringNode(node, value) {
return node && (isStringLiteral(node, value) || isTemplateLiteral(node, value));
}
function isPropertyAccessor(node, name) {
const value = getStringValue(node.property);
return typeof name === "string" ? value === name : name.test(value);
}
function findParent(node, type) {
const parent = node.parent;
if (!parent) {
return;
}
return parent.type === type ? parent : findParent(parent, type);
}
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;
}
var pageFrameFullPattern = /(^(page|frame)|(Page|Frame)$)/;
var pageFramePrefixPattern = /^(page|frame)/;
function isPageMethod(node, name) {
if (node.callee.type !== "MemberExpression") {
return false;
}
if (!isPropertyAccessor(node.callee, name)) {
return false;
}
const obj = node.callee.object;
if (obj.type === "MemberExpression") {
const pattern = obj.object.type === "ThisExpression" ? pageFrameFullPattern : pageFramePrefixPattern;
return isIdentifier(obj.property, pattern);
}
return dig(obj, pageFrameFullPattern);
}
function isFunction(node) {
return node?.type === "ArrowFunctionExpression" || node?.type === "FunctionExpression";
}
var equalityMatchers = /* @__PURE__ */ new Set(["toBe", "toEqual", "toStrictEqual"]);
var joinNames = (a, b) => a && b ? `${a}.${b}` : null;
function getNodeName(node) {
if (isSupportedAccessor(node)) {
return getStringValue(node);
}
switch (node.type) {
case "TaggedTemplateExpression":
return getNodeName(node.tag);
case "MemberExpression":
return joinNames(getNodeName(node.object), getNodeName(node.property));
case "NewExpression":
case "CallExpression":
return getNodeName(node.callee);
}
return null;
}
var isVariableDeclarator = (node) => node?.type === "VariableDeclarator";
var isAssignmentExpression = (node) => node?.type === "AssignmentExpression";
function isNodeLastAssignment(node, assignment) {
if (node.range && assignment.range && node.range[0] < assignment.range[1]) {
return false;
}
return assignment.left.type === "Identifier" && assignment.left.name === node.name;
}
function dereference(context, node) {
if (node?.type !== "Identifier") {
return node;
}
const scope = context.sourceCode.getScope(node);
const parents = scope.references.map((ref) => ref.identifier).map((ident) => ident.parent);
const decl = parents.filter(isVariableDeclarator).find((p) => p.id.type === "Identifier" && p.id.name === node.name);
const expr = parents.filter(isAssignmentExpression).reverse().find((assignment) => isNodeLastAssignment(node, assignment));
return expr?.right ?? decl?.init;
}
var getActualLastToken = (sourceCode, node) => {
const semiToken = sourceCode.getLastToken(node);
const prevToken = sourceCode.getTokenBefore(semiToken);
const nextToken = sourceCode.getTokenAfter(semiToken);
const isSemicolonLessStyle = !!prevToken && !!nextToken && prevToken.range[0] >= node.range[0] && semiToken.type === "Punctuator" && semiToken.value === ";" && semiToken.loc.start.line !== prevToken.loc.end.line && semiToken.loc.end.line === nextToken.loc.start.line;
return isSemicolonLessStyle ? prevToken : semiToken;
};
var getPaddingLineSequences = (prevNode, nextNode, sourceCode) => {
const pairs = [];
let prevToken = getActualLastToken(sourceCode, prevNode);
if (nextNode.loc.start.line - prevToken.loc.end.line >= 2) {
do {
const token = sourceCode.getTokenAfter(prevToken, {
includeComments: true
});
if (token.loc.start.line - prevToken.loc.end.line >= 2) {
pairs.push([prevToken, token]);
}
prevToken = token;
} while (prevToken.range[0] < nextNode.range[0]);
}
return pairs;
};
var areTokensOnSameLine = (left, right) => left.loc.end.line === right.loc.start.line;
var isPromiseAccessor = (node) => {
return node.type === "MemberExpression" && isIdentifier(node.property, /^(then|catch|finally)$/);
};
// src/utils/createRule.ts
function interpolate(str, data) {
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => data?.[key] ?? "");
}
function createRule(rule) {
return {
create(context) {
const messages = context.settings?.playwright?.messages;
if (!messages) {
return rule.create(context);
}
const report = (options) => {
if (messages && "messageId" in options) {
const { data, messageId: messageId2, ...rest } = options;
const message = messages?.[messageId2];
return context.report(
message ? {
...rest,
message: interpolate(message, data)
} : options
);
}
return context.report(options);
};
const ruleContext = Object.freeze({
...context,
cwd: context.cwd,
filename: context.filename,
id: context.id,
languageOptions: context.languageOptions,
options: context.options,
// @ts-expect-error - Legacy context property
parserOptions: context.parserOptions,
// @ts-expect-error - Legacy context property
parserPath: context.parserPath,
physicalFilename: context.physicalFilename,
report,
settings: context.settings,
sourceCode: context.sourceCode
});
return rule.create(ruleContext);
},
meta: rule.meta
};
}
// src/utils/scope.ts
function createScopeInfo() {
let scope = null;
return {
enter() {
scope = { prevNode: null, upper: scope };
},
exit() {
scope = scope.upper;
},
get prevNode() {
return scope.prevNode;
},
set prevNode(node) {
scope.prevNode = node;
}
};
}
// src/rules/consistent-spacing-between-blocks.ts
var STATEMENT_LIST_PARENTS = /* @__PURE__ */ new Set([
"Program",
"BlockStatement",
"SwitchCase",
"SwitchStatement"
]);
function isValidParent(parentType) {
return STATEMENT_LIST_PARENTS.has(parentType);
}
function fixPadding(prevNode, nextNode, ctx) {
const { ruleContext, sourceCode } = ctx;
const paddingLines = getPaddingLineSequences(prevNode, nextNode, sourceCode);
if (paddingLines.length > 0) {
return;
}
ruleContext.report({
fix(fixer) {
let prevToken = getActualLastToken(sourceCode, prevNode);
const nextToken = sourceCode.getFirstTokenBetween(prevToken, nextNode, {
/**
* Skip the trailing comments of the previous node. This inserts a blank
* line after the last trailing comment.
*
* For example:
*
* foo() // trailing comment.
* // comment.
* bar()
*
* Get fixed to:
*
* foo() // trailing comment.
*
* // comment.
* bar()
*/
filter(token) {
if (areTokensOnSameLine(prevToken, token)) {
prevToken = token;
return false;
}
return true;
},
includeComments: true
}) || nextNode;
const insertText = areTokensOnSameLine(prevToken, nextToken) ? "\n\n" : "\n";
return fixer.insertTextAfter(prevToken, insertText);
},
messageId: "missingWhitespace",
node: nextNode
});
}
function isTestNode(node, ctx) {
let curNode = node;
if (curNode.type === "ExpressionStatement") {
curNode = curNode.expression;
} else if (curNode.type === "VariableDeclaration") {
const decl = curNode.declarations.at(-1);
if (decl.init == null) {
return false;
}
curNode = decl.init;
}
if (curNode.type === "AwaitExpression") {
curNode = curNode.argument;
}
if (curNode.type !== "CallExpression") {
return false;
}
return isTypeOfFnCall(ctx.ruleContext, curNode, ["describe", "test", "step", "hook"]);
}
function testPadding(prevNode, nextNode, ctx) {
while (nextNode.type === "LabeledStatement") {
nextNode = nextNode.body;
}
if (isTestNode(prevNode, ctx) || isTestNode(nextNode, ctx)) {
fixPadding(prevNode, nextNode, ctx);
return;
}
}
function verifyNode(node, ctx) {
const { scopeInfo } = ctx;
if (!isValidParent(node.parent.type)) {
return;
}
if (scopeInfo.prevNode) {
testPadding(scopeInfo.prevNode, node, ctx);
}
scopeInfo.prevNode = node;
}
var consistent_spacing_between_blocks_default = createRule({
create(context) {
const scopeInfo = createScopeInfo();
const ctx = {
ruleContext: context,
scopeInfo,
sourceCode: context.sourceCode
};
return {
":statement": (node) => verifyNode(node, ctx),
"BlockStatement": scopeInfo.enter,
"BlockStatement:exit": scopeInfo.exit,
"Program": scopeInfo.enter,
"Program:exit": scopeInfo.enter,
"SwitchCase"(node) {
verifyNode(node, ctx);
scopeInfo.enter();
},
"SwitchCase:exit": scopeInfo.exit,
"SwitchStatement": scopeInfo.enter,
"SwitchStatement:exit": scopeInfo.exit
};
},
meta: {
docs: {
description: "Enforces a blank line between Playwright test blocks (e.g., test, test.step, test.beforeEach, etc.).",
recommended: true,
url: "https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/consistent-spacing-between-blocks.md"
},
fixable: "whitespace",
messages: {
missingWhitespace: "Expected blank line before this statement."
},
schema: [],
type: "layout"
}
});
// src/rules/expect-expect.ts
var expect_expect_default = createRule({
create(context) {
const options = {
assertFunctionNames: [],
assertFunctionPatterns: [],
...context.options?.[0] ?? {}
};
const patterns = options.assertFunctionPatterns.map((pattern) => new RegExp(pattern));
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;
}
}
}
function matches(node) {
if (options.assertFunctionNames.some((name) => dig(node.callee, name))) {
return true;
}
if (patterns.some((pattern) => dig(node.callee, pattern))) {
return true;
}
return false;
}
return {
"CallExpression"(node) {
const call = parseFnCall(context, node);
if (call?.type === "test") {
unchecked.push(node);
} else if (call?.type === "expect" || matches(node)) {
const ancestors = context.sourceCode.getAncestors(node);
checkExpressions(ancestors);
}
},
"Program:exit"() {
unchecked.forEach((node) => {
context.report({ messageId: "noAssertions", node: node.callee });
});
}
};
},
meta: {
docs: {
description: "Enforce assertion to be made in a test body",
recommended: true,
url: "https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/expect-expect.md"
},
messages: {
noAssertions: "Test has no assertions"
},
schema: [
{
additionalProperties: false,
properties: {
assertFunctionNames: {
items: [{ type: "string" }],
type: "array"
},
assertFunctionPatterns: {
items: [{ type: "string" }],
type: "array"
}
},
type: "object"
}
],
type: "problem"
}
});
// src/rules/max-expects.ts
var max_expects_default = createRule({
create(context) {
const options = {
max: 5,
...context.options?.[0] ?? {}
};
let count = 0;
const maybeResetCount = (node) => {
const parent = node.parent;
const isTestFn = parent?.type !== "CallExpression" || isTypeOfFnCall(context, parent, ["test"]);
if (isTestFn) {
count = 0;
}
};
return {
"ArrowFunctionExpression": maybeResetCount,
"ArrowFunctionExpression:exit": maybeResetCount,
"CallExpression"(node) {
const call = parseFnCall(context, node);
if (call?.type !== "expect") {
return;
}
count += 1;
if (count > options.max) {
context.report({
data: {
count: count.toString(),
max: options.max.toString()
},
messageId: "exceededMaxAssertion",
node
});
}
},
"FunctionExpression": maybeResetCount,
"FunctionExpression:exit": maybeResetCount
};
},
meta: {
docs: {
description: "Enforces a maximum number assertion calls in a test body",
recommended: false,
url: "https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/max-expects.md"
},
messages: {
exceededMaxAssertion: "Too many assertion calls ({{ count }}) - maximum allowed is {{ max }}"
},
schema: [
{
additionalProperties: false,
properties: {
max: {
minimum: 1,
type: "integer"
}
},
type: "object"
}
],
type: "suggestion"
}
});
// src/rules/max-nested-describe.ts
var max_nested_describe_default = createRule({
create(context) {
const { options } = context;
const max = options[0]?.max ?? 5;
const describes = [];
return {
"CallExpression"(node) {
if (isTypeOfFnCall(context, node, ["describe"])) {
describes.unshift(node);
if (describes.length > max) {
context.report({
data: {
depth: describes.length.toString(),
max: max.toString()
},
messageId: "exceededMaxDepth",
node: node.callee
});
}
}
},
"CallExpression:exit"(node) {
if (describes[0] === node) {
describes.shift();
}
}
};
},
meta: {
docs: {
description: "Enforces a maximum depth to nested describe calls",
recommended: true,
url: "https://github.com/mskelton/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"
}
});
// src/rules/missing-playwright-await.ts
var validTypes = /* @__PURE__ */ new Set(["AwaitExpression", "ReturnStatement", "ArrowFunctionExpression"]);
function isArrayLike(node) {
return node.type === "ArrayExpression" || node.type === "NewExpression" && isIdentifier(node.callee, "Array") || node.type === "CallExpression" && node.callee.type === "MemberExpression" && isIdentifier(node.callee.object, "Array");
}
var waitForMethods = [
"waitForConsoleMessage",
"waitForDownload",
"waitForEvent",
"waitForFileChooser",
"waitForFunction",
"waitForPopup",
"waitForRequest",
"waitForResponse",
"waitForWebSocket"
];
var waitForMethodsRegex = new RegExp(`^(${waitForMethods.join("|")})$`);
var pageMethods = /* @__PURE__ */ new Set([
"addInitScript",
"addScriptTag",
"addStyleTag",
"bringToFront",
"check",
"click",
"close",
"dblclick",
"dispatchEvent",
"dragAndDrop",
"emulateMedia",
"evaluate",
"evaluateHandle",
"exposeBinding",
"exposeFunction",
"fill",
"focus",
"getAttribute",
"goBack",
"goForward",
"goto",
"hover",
"innerHTML",
"innerText",
"inputValue",
"isChecked",
"isDisabled",
"isEditable",
"isEnabled",
"isHidden",
"isVisible",
"pdf",
"press",
"reload",
"route",
"routeFromHAR",
"screenshot",
"selectOption",
"setBypassCSP",
"setContent",
"setChecked",
"setExtraHTTPHeaders",
"setInputFiles",
"setViewportSize",
"tap",
"textContent",
"title",
"type",
"uncheck",
"unroute",
"unrouteAll",
"waitForLoadState",
"waitForTimeout",
"waitForURL"
]);
var locatorMethods = /* @__PURE__ */ new Set([
"all",
"allInnerTexts",
"allTextContents",
"blur",
"boundingBox",
"check",
"clear",
"click",
"count",
"dblclick",
"dispatchEvent",
"dragTo",
"evaluate",
"evaluateAll",
"evaluateHandle",
"fill",
"focus",
"getAttribute",
"hover",
"innerHTML",
"innerText",
"inputValue",
"isChecked",
"isDisabled",
"isEditable",
"isEnabled",
"isHidden",
"isVisible",
"press",
"pressSequentially",
"screenshot",
"scrollIntoViewIfNeeded",
"selectOption",
"selectText",
"setChecked",
"setInputFiles",
"tap",
"textContent",
"type",
"uncheck",
"waitFor"
]);
var expectPlaywrightMatchers = [
"toBeChecked",
"toBeDisabled",
"toBeEnabled",
"toEqualText",
// deprecated
"toEqualUrl",
"toEqualValue",
"toHaveFocus",
"toHaveSelector",
"toHaveSelectorCount",
"toHaveText",
// deprecated
"toMatchAttribute",
"toMatchComputedStyle",
"toMatchText",
"toMatchTitle",
"toMatchURL",
"toMatchValue",
"toPass"
];
var playwrightTestMatchers = [
"toBeAttached",
"toBeChecked",
"toBeDisabled",
"toBeEditable",
"toBeEmpty",
"toBeEnabled",
"toBeFocused",
"toBeHidden",
"toBeInViewport",
"toBeOK",
"toBeVisible",
"toContainText",
"toHaveAccessibleErrorMessage",
"toHaveAttribute",
"toHaveCSS",
"toHaveClass",
"toHaveCount",
"toHaveId",
"toHaveJSProperty",
"toHaveScreenshot",
"toHaveText",
"toHaveTitle",
"toHaveURL",
"toHaveValue",
"toHaveValues",
"toContainClass"
];
function getReportNode(node) {
const parent = node.parent;
return parent?.type === "MemberExpression" ? parent : node;
}
function getCallType(call, awaitableMatchers) {
if (call.type === "step") {
return {
data: { name: "test.step" },
messageId: "missingAwait",
node: call.head.node
};
}
if (call.type === "expect") {
const isPoll = call.modifiers.some((m) => getStringValue(m) === "poll");
if (isPoll || awaitableMatchers.has(call.matcherName)) {
return {
data: { name: isPoll ? "expect.poll" : call.matcherName },
messageId: "missingAwait",
node: call.head.node
};
}
}
}
var missing_playwright_await_default = createRule({
create(context) {
const options = context.options[0] || {};
const includePageLocatorMethods = !!options.includePageLocatorMethods;
const awaitableMatchers = /* @__PURE__ */ new Set([
...expectPlaywrightMatchers,
...playwrightTestMatchers,
// Add any custom matchers to the set
...options.customMatchers || []
]);
function isVariableConsumed(declarator, checkValidity2, validTypes2, visited) {
const variables = context.sourceCode.getDeclaredVariables(declarator);
for (const variable of variables) {
for (const ref of variable.references) {
if (!ref.isRead()) {
continue;
}
const refParent = ref.identifier.parent;
if (visited.has(refParent)) {
continue;
}
if (validTypes2.has(refParent.type)) {
return true;
}
if (refParent.type === "VariableDeclarator") {
if (checkValidity2(ref.identifier, visited)) {
return true;
}
continue;
}
if (checkValidity2(refParent, visited)) {
return true;
}
}
}
return false;
}
function checkValidity(node, visited) {
const parent = node.parent;
if (!parent) {
return false;
}
if (visited.has(parent)) {
return false;
}
visited.add(parent);
if (validTypes.has(parent.type)) {
return true;
}
if (isPromiseAccessor(parent) && parent.parent?.type === "CallExpression") {
return checkValidity(parent.parent, visited);
}
if (parent.type === "CallExpression" && parent.callee === node && isPromiseAccessor(node)) {
return checkValidity(parent, visited);
}
if (parent.type === "ArrayExpression") {
return checkValidity(parent, visited);
}
if (parent.type === "ConditionalExpression") {
return checkValidity(parent, visited);
}
if (parent.type === "SpreadElement") {
return checkValidity(parent, visited);
}
if (parent.type === "CallExpression" && parent.callee.type === "MemberExpression" && isIdentifier(parent.callee.object, "Promise") && isIdentifier(parent.callee.property, /^(all|allSettled|race|any)$/)) {
return true;
}
if (parent.type === "MemberExpression" && parent.object === node && getStringValue(parent.property) === "resolves" && node.type === "CallExpression" && isIdentifier(node.callee, "expect")) {
return checkValidity(parent, visited);
}
if (parent.type === "MemberExpression" && parent.object === node) {
return checkValidity(parent, visited);
}
if (parent.type === "CallExpression" && parent.callee === node) {
return checkValidity(parent, visited);
}
if (parent.type === "VariableDeclarator") {
return isVariableConsumed(parent, checkValidity, validTypes, visited);
}
return false;
}
return {
CallExpression(node) {
if (isPageMethod(node, waitForMethodsRegex)) {
if (!checkValidity(node, /* @__PURE__ */ new Set())) {
const methodName = getStringValue(node.callee.property);
context.report({
data: { name: methodName },
messageId: "missingAwait",
node
});
}
return;
}
if (includePageLocatorMethods && node.callee.type === "MemberExpression") {
const methodName = getStringValue(node.callee.property);
const isPlaywrightMethod = !isArrayLike(node.callee.object) && (locatorMethods.has(methodName) || pageMethods.has(methodName) && isPageMethod(node, methodName));
if (isPlaywrightMethod) {
if (!checkValidity(node, /* @__PURE__ */ new Set())) {
context.report({
data: { name: methodName },
messageId: "missingAwait",
node
});
}
return;
}
}
const call = parseFnCall(context, node);
if (call?.type !== "step" && call?.type !== "expect") {
return;
}
const result = getCallType(call, awaitableMatchers);
const isValid = result ? checkValidity(node, /* @__PURE__ */ new Set()) : false;
if (result && !isValid) {
context.report({
data: result.data,
fix: (fixer) => fixer.insertTextBefore(node, "await "),
messageId: result.messageId,
node: getReportNode(result.node)
});
}
}
};
},
meta: {
docs: {
description: `Identify false positives when async Playwright APIs are not properly awaited.`,
recommended: true,
url: "https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/missing-playwright-await.md"
},
fixable: "code",
messages: {
missingAwait: "'{{name}}' must be awaited or returned."
},
schema: [
{
additionalProperties: false,
properties: {
customMatchers: {
items: { type: "string" },
type: "array"
},
includePageLocatorMethods: {
type: "boolean"
}
},
type: "object"
}
],
type: "problem"
}
});
// src/rules/no-commented-out-tests.ts
function getTestNames(context) {
const settings = context.settings;
const aliases = settings?.playwright?.globalAliases?.test ?? [];
return ["test", ...aliases];
}
function hasTests(context, node) {
const testNames = getTestNames(context);
const names = testNames.join("|");
const regex = new RegExp(`^\\s*(${names}|describe)(\\.\\w+|\\[['"]\\w+['"]\\])?\\s*\\(`, "mu");
return regex.test(node.value);
}
var no_commented_out_tests_default = createRule({
create(context) {
function checkNode(node) {
if (!hasTests(context, node)) {
return;
}
context.report({
messageId: "commentedTests",
node
});
}
return {
Program() {
context.sourceCode.getAllComments().forEach(checkNode);
}
};
},
meta: {
docs: {
description: "Disallow commented out tests",
recommended: true,
url: "https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-commented-out-tests.md"
},
messages: {
commentedTests: "Some tests seem to be commented"
},
type: "problem"
}
});
// src/rules/no-conditional-expect.ts
var isCatchCall = (node) => node.callee.type === "MemberExpression" && isPropertyAccessor(node.callee, "catch");
var no_conditional_expect_default = createRule({
create(context) {
let conditionalDepth = 0;
let inTestCase = false;
let inPromiseCatch = false;
const increaseConditionalDepth = () => inTestCase && conditionalDepth++;
const decreaseConditionalDepth = () => inTestCase && conditionalDepth--;
return {
"CallExpression"(node) {
const call = parseFnCall(context, node);
if (call?.type === "test") {
inTestCase = true;
}
if (isCatchCall(node)) {
inPromiseCatch = true;
}
if (inTestCase && call?.type === "expect" && conditionalDepth > 0) {
context.report({
messageId: "conditionalExpect",
node
});
}
if (inPromiseCatch && call?.type === "expect") {
context.report({
messageId: "conditionalExpect",
node
});
}
},
"CallExpression:exit"(node) {
if (isTypeOfFnCall(context, node, ["test"])) {
inTestCase = false;
}
if (isCatchCall(node)) {
inPromiseCatch = false;
}
},
"CatchClause": increaseConditionalDepth,
"CatchClause:exit": decreaseConditionalDepth,
"ConditionalExpression": increaseConditionalDepth,
"ConditionalExpression:exit": decreaseConditionalDepth,
"IfStatement": increaseConditionalDepth,
"IfStatement:exit": decreaseConditionalDepth,
"LogicalExpression": increaseConditionalDepth,
"LogicalExpression:exit": decreaseConditionalDepth,
"SwitchStatement": increaseConditionalDepth,
"SwitchStatement:exit": decreaseConditionalDepth
};
},
meta: {
docs: {
description: "Disallow calling `expect` conditionally",
recommended: true,
url: "https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-conditional-expect.md"
},
messages: {
conditionalExpect: "Avoid calling `expect` conditionally"
},
type: "problem"
}
});
// src/rules/no-conditional-in-test.ts
var no_conditional_in_test_default = createRule({
create(context) {
function checkConditional(node) {
if (node.type === "LogicalExpression" && (node.operator === "??" || node.operator === "||")) {
return;
}
const call = findParent(node, "CallExpression");
if (!call) {
return;
}
if (isTypeOfFnCall(context, call, ["test", "step"])) {
const testFunction = call.arguments[call.arguments.length - 1];
const functionBody = findParent(node, "BlockStatement");
if (!functionBody) {
return;
}
let currentParent = functionBody.parent;
while (currentParent && currentParent !== testFunction) {
currentParent = currentParent.parent;
}
if (currentParent === testFunction) {
context.report({ messageId: "conditionalInTest", node });
}
}
}
return {
ConditionalExpression: checkConditional,
IfStatement: checkConditional,
LogicalExpression: checkConditional,
SwitchStatement: checkConditional
};
},
meta: {
docs: {
description: "Disallow conditional logic in tests",
recommended: true,
url: "https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-conditional-in-test.md"
},
messages: {
conditionalInTest: "Avoid having conditionals in tests"
},
schema: [],
type: "problem"
}
});
// src/rules/no-duplicate-hooks.ts
var no_duplicate_hooks_default = createRule({
create(context) {
const hookContexts = [{}];
return {
"CallExpression"(node) {
const call = parseFnCall(context, node);
if (!call) {
return;
}
if (call.type === "describe") {
hookContexts.push({});
}
if (call.type !== "hook") {
return;
}
const currentLayer = hookContexts[hookContexts.length - 1];
const name = node.callee.type === "MemberExpression" ? getStringValue(node.callee.property) : "";
currentLayer[name] ||= 0;
currentLayer[name] += 1;
if (currentLayer[name] > 1) {
context.report({
data: { hook: name },
messageId: "noDuplicateHook",
node
});
}
},
"CallExpression:exit"(node) {
if (isTypeOfFnCall(context, node, ["describe"])) {
hookContexts.pop();
}
}
};
},
meta: {
docs: {
description: "Disallow duplicate setup and teardown hooks",
recommended: false,
url: "https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-duplicate-hooks.md"
},
messages: {
noDuplicateHook: "Duplicate {{ hook }} in describe block"
},
type: "suggestion"
}
});
// src/rules/no-duplicate-slow.ts
var no_duplicate_slow_default = createRule({
create(context) {
const scopes = [false];
return {
"CallExpression"(node) {
const call = parseFnCall(context, node);
if (!call) {
return;
}
if (call.type === "test" || call.type === "describe") {
scopes.push(scopes[scopes.length - 1]);
}
if (call.group === "test" && call.type === "config") {
const isSlowCall = call.members.some((s) => getStringValue(s) === "slow");
if (isSlowCall) {
const current = scopes.length - 1;
if (scopes[current]) {
context.report({ messageId: "noDuplicateSlow", node });
} else {
scopes[current] = true;
}
}
}
},
"CallExpression:exit"(node) {
if (isTypeOfFnCall(context, node, ["test", "describe"])) {
scopes.pop();
}
}
};
},
meta: {
docs: {
description: "Disallow multiple `test.slow()` calls in the same test",
recommended: true,
url: "https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-duplicate-slow.md"
},
messages: {
noDuplicateSlow: "Multiple `test.slow()` calls will multiply the timeout. Use only one `test.slow()` per test."
},
type: "problem"
}
});
// src/rules/no-element-handle.ts
function getPropertyRange(node) {
return node.type === "Identifier" ? node.range : [node.range[0] + 1, node.range[1] - 1];
}
var no_element_handle_default = createRule({
create(context) {
return {
CallExpression(node) {
if (isPageMethod(node, "$") || isPageMethod(node, "$$")) {
context.report({
messageId: "noElementHandle",
node: node.callee,
suggest: [
{
fix: (fixer) => {
const { property } = node.callee;
const fixes = [fixer.replaceTextRange(getPropertyRange(property), "locator")];
if (node.parent.type === "AwaitExpression") {
fixes.push(fixer.removeRange([node.parent.range[0], node.range[0]]));
}
return fixes;
},
messageId: isPageMethod(node, "$") ? "replaceElementHandleWithLocator" : "replaceElementHandlesWithLocator"
}
]
});
}
}
};
},
meta: {
docs: {
description: "The use of ElementHandle is discouraged, use Locator instead",
recommended: true,
url: "https://github.com/mskelton/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"
}
});
// src/rules/no-eval.ts
var no_eval_default = createRule({
create(context) {
return {
CallExpression(node) {
const isEval = isPageMethod(node, "$eval");
if (isEval || isPageMethod(node, "$$eval")) {
context.report({
messageId: isEval ? "noEval" : "noEvalAll",
node: node.callee
});
}
}
};
},
meta: {
docs: {
description: "The use of `page.$eval` and `page.$$eval` are discouraged, use `locator.evaluate` or `locator.evaluateAll` instead",
recommended: true,
url: "https://github.com/mskelton/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"
}
});
// src/rules/no-focused-test.ts
var no_focused_test_default = createRule({
create(context) {
return {
CallExpression(node) {
const call = parseFnCall(context, node);
if (call?.type !== "test" && call?.type !== "describe") {
return;
}
const onlyNode = call.members.find((s) => getStringValue(s) === "only");
if (!onlyNode) {
return;
}
context.report({
messageId: "noFocusedTest",
node: onlyNode,
suggest: [
{
fix: (fixer) => {
return fixer.removeRange([
onlyNode.range[0] - 1,
onlyNode.range[1] + Number(onlyNode.type !== "Identifier")
]);
},
messageId: "suggestRemoveOnly"
}
]
});
}
};
},
meta: {
docs: {
description: "Prevent usage of `.only()` focus test annotation",
recommended: true,
url: "https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-focused-test.md"
},
hasSuggestions: true,
messages: {
noFocusedTest: "Unexpected focused test.",
suggestRemoveOnly: "Remove .only() annotation."
},
type: "problem"
}
});
// src/rules/no-force-option.ts
function isForceOptionEnabled(node) {
const arg = node.arguments.at(-1);
return arg?.type === "ObjectExpression" && arg.properties.find(
(property) => property.type === "Property" && getStringValue(property.key) === "force" && isBooleanLiteral(property.value, true)
);
}
var methodsWithForceOption = /* @__PURE__ */ new Set([
"check",
"uncheck",
"click",
"dblclick",
"dragTo",
"fill",
"hover",
"selectOption",
"selectText",
"setChecked",
"tap"
]);
var no_force_option_default = createRule({
create(context) {
return {
MemberExpression(node) {
if (methodsWithForceOption.has(getStringValue(node.property)) && node.parent.type === "CallExpression") {
const reportNode = isForceOptionEnabled(node.parent);
if (reportNode) {
context.report({ messageId: "noForceOption", node: reportNode });
}
}
}
};
},
meta: {
docs: {
description: "Prevent usage of `{ force: true }` option.",
recommended: true,
url: "https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-force-option.md"
},
messages: {
noForceOption: "Unexpected use of { force: true } option."
},
type: "suggestion"
}
});
// src/rules/no-get-by-title.ts
var no_get_by_title_default = createRule({
create(context) {
return {
CallExpression(node) {
if (node.callee.type === "MemberExpression" && getStringValue(node.callee.property) === "getByTitle") {
context.report({ messageId: "noGetByTitle", node });
}
}
};
},
meta: {
docs: {
description: "Disallows the usage of getByTitle()",
recommended: false,
url: "https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-get-by-title.md"
},
messages: {
noGetByTitle: "The HTML title attribute is not an accessible name. Prefer getByRole() or getByLabelText() instead."
},
type: "suggestion"
}
});
// src/rules/no-hooks.ts
var no_hooks_default = createRule({
create(context) {
const options = {
allow: [],
...context.options?.[0] ?? {}
};
return {
CallExpression(node) {
const call = parseFnCall(context, node);
if (!call) {
return;
}
if (call.type === "hook" && !options.allow.includes(call.name)) {
context.report({
data: { hookName: call.name },
messageId: "unexpectedHook",
node
});
}
}
};
},
meta: {
docs: {
description: "Disallow setup and teardown hooks",
recommended: false,
url: "https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/no-hooks.md"
},
messages: {
unexpectedHook: "Unexpected '{{ hookName }}' hook"
},
schema: [
{
additionalProperties: false,
properties: {
allow: {
contains: ["beforeAll", "beforeEach", "a