Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions src/rules/prefer-web-first-assertions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ runRuleTester('prefer-web-first-assertions', rule, {
],
output: test('await expect(page.locator(".tweet")).toBeVisible()'),
},
{
code: test('expect(page.locator(".tweet").isVisible()).toBe(true)'),
errors: [
{
column: 28,
data: { matcher: 'toBeVisible', method: 'isVisible' },
endColumn: 70,
line: 1,
messageId: 'useWebFirstAssertion',
},
],
output: test('await expect(page.locator(".tweet")).toBeVisible()'),
},
{
code: test(
'expect(await page.locator(".tweet").isVisible()).toBe(false)',
Expand Down Expand Up @@ -176,6 +189,19 @@ runRuleTester('prefer-web-first-assertions', rule, {
],
output: test('await expect(page.locator(".tweet")).toBeVisible()'),
},
{
code: test('expect(page.locator(".button").isVisible()).toBe(false)'),
errors: [
{
column: 28,
data: { matcher: 'toBeHidden', method: 'isVisible' },
endColumn: 72,
line: 1,
messageId: 'useWebFirstAssertion',
},
],
output: test('await expect(page.locator(".button")).toBeHidden()'),
},

// isHidden
{
Expand Down Expand Up @@ -230,6 +256,19 @@ runRuleTester('prefer-web-first-assertions', rule, {
],
output: test('await expect(foo).toBeHidden()'),
},
{
code: test('expect(page.locator(".link").isHidden()).toBe(true)'),
errors: [
{
column: 28,
data: { matcher: 'toBeHidden', method: 'isHidden' },
endColumn: 69,
line: 1,
messageId: 'useWebFirstAssertion',
},
],
output: test('await expect(page.locator(".link")).toBeHidden()'),
},

// getAttribute
{
Expand Down Expand Up @@ -300,6 +339,23 @@ runRuleTester('prefer-web-first-assertions', rule, {
'await expect.soft(page.locator("foo")).not.toHaveAttribute("aria-label", "bar")',
),
},
{
code: test(
'expect(page.locator(".element").getAttribute("data-testid")).toBe("submit")',
),
errors: [
{
column: 28,
data: { matcher: 'toHaveAttribute', method: 'getAttribute' },
endColumn: 85,
line: 1,
messageId: 'useWebFirstAssertion',
},
],
output: test(
'await expect(page.locator(".element")).toHaveAttribute("data-testid", "submit")',
),
},

// innerText
{
Expand Down Expand Up @@ -328,6 +384,23 @@ runRuleTester('prefer-web-first-assertions', rule, {
],
output: test('await expect.soft(foo).not.toHaveText("bar")'),
},
{
code: test(
'expect(page.locator(".text").innerText()).toBe("Hello World")',
),
errors: [
{
column: 28,
data: { matcher: 'toHaveText', method: 'innerText' },
endColumn: 71,
line: 1,
messageId: 'useWebFirstAssertion',
},
],
output: test(
'await expect(page.locator(".text")).toHaveText("Hello World")',
),
},

// inputValue
{
Expand Down Expand Up @@ -356,6 +429,23 @@ runRuleTester('prefer-web-first-assertions', rule, {
],
output: test('await expect[`soft`](foo).not.toHaveValue("bar")'),
},
{
code: test(
'expect(page.locator(".input").inputValue()).toBe("user input")',
),
errors: [
{
column: 28,
data: { matcher: 'toHaveValue', method: 'inputValue' },
endColumn: 71,
line: 1,
messageId: 'useWebFirstAssertion',
},
],
output: test(
'await expect(page.locator(".input")).toHaveValue("user input")',
),
},

// textContent
{
Expand Down Expand Up @@ -528,6 +618,23 @@ runRuleTester('prefer-web-first-assertions', rule, {
await expect(fooLocatorText).toHaveText('foo');
`),
},
{
code: test(
'expect(page.locator(".content").textContent()).toBe("Some content")',
),
errors: [
{
column: 28,
data: { matcher: 'toHaveText', method: 'textContent' },
endColumn: 75,
line: 1,
messageId: 'useWebFirstAssertion',
},
],
output: test(
'await expect(page.locator(".content")).toHaveText("Some content")',
),
},

// isChecked
{
Expand Down Expand Up @@ -646,6 +753,19 @@ runRuleTester('prefer-web-first-assertions', rule, {
],
output: test('await expect(page.locator("howdy")).toBeChecked()'),
},
{
code: test('expect(page.locator(".checkbox").isChecked()).toBe(true)'),
errors: [
{
column: 28,
data: { matcher: 'toBeChecked', method: 'isChecked' },
endColumn: 72,
line: 1,
messageId: 'useWebFirstAssertion',
},
],
output: test('await expect(page.locator(".checkbox")).toBeChecked()'),
},

// isDisabled
{
Expand Down Expand Up @@ -700,6 +820,19 @@ runRuleTester('prefer-web-first-assertions', rule, {
],
output: test('await expect(foo).toBeDisabled()'),
},
{
code: test('expect(page.locator(".input").isDisabled()).toBe(true)'),
errors: [
{
column: 28,
data: { matcher: 'toBeDisabled', method: 'isDisabled' },
endColumn: 70,
line: 1,
messageId: 'useWebFirstAssertion',
},
],
output: test('await expect(page.locator(".input")).toBeDisabled()'),
},

// isEnabled
{
Expand Down Expand Up @@ -754,6 +887,19 @@ runRuleTester('prefer-web-first-assertions', rule, {
],
output: test('await expect(foo).toBeEnabled()'),
},
{
code: test('expect(page.locator(".field").isEnabled()).toBe(true)'),
errors: [
{
column: 28,
data: { matcher: 'toBeEnabled', method: 'isEnabled' },
endColumn: 69,
line: 1,
messageId: 'useWebFirstAssertion',
},
],
output: test('await expect(page.locator(".field")).toBeEnabled()'),
},

// isEditable
{
Expand Down Expand Up @@ -868,6 +1014,19 @@ runRuleTester('prefer-web-first-assertions', rule, {
],
output: test('await expect(page.locator("howdy")).toBeEditable()'),
},
{
code: test('expect(page.locator(".textarea").isEditable()).toBe(true)'),
errors: [
{
column: 28,
data: { matcher: 'toBeEditable', method: 'isEditable' },
endColumn: 73,
line: 1,
messageId: 'useWebFirstAssertion',
},
],
output: test('await expect(page.locator(".textarea")).toBeEditable()'),
},
// Global aliases
{
code: test('assert(await page.locator(".tweet").isVisible()).toBe(true)'),
Expand Down
48 changes: 22 additions & 26 deletions src/rules/prefer-web-first-assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,40 +62,41 @@ export default createRule({
create(context) {
return {
CallExpression(node) {
const call = parseFnCall(context, node)
if (call?.type !== 'expect') return
const fnCall = parseFnCall(context, node)
if (fnCall?.type !== 'expect') return

const expect = findParent(call.head.node, 'CallExpression')
const expect = findParent(fnCall.head.node, 'CallExpression')
if (!expect) return

const arg = dereference(context, call.args[0])
const arg = dereference(context, fnCall.args[0])
if (!arg) return

const call = arg.type === 'AwaitExpression' ? arg.argument : arg
if (
!arg ||
arg.type !== 'AwaitExpression' ||
arg.argument.type !== 'CallExpression' ||
arg.argument.callee.type !== 'MemberExpression'
call.type !== 'CallExpression' ||
call.callee.type !== 'MemberExpression'
) {
return
}

// Matcher must be supported
if (!supportedMatchers.has(call.matcherName)) return
if (!supportedMatchers.has(fnCall.matcherName)) return

// Playwright method must be supported
const method = getStringValue(arg.argument.callee.property)
const method = getStringValue(call.callee.property)
const methodConfig = methods[method]
if (!methodConfig) return

// Change the matcher
const notModifier = call.modifiers.find(
const notModifier = fnCall.modifiers.find(
(mod) => getStringValue(mod) === 'not',
)

const isFalsy =
methodConfig.type === 'boolean' &&
((!!call.matcherArgs.length &&
isBooleanLiteral(call.matcherArgs[0], false)) ||
call.matcherName === 'toBeFalsy')
((!!fnCall.matcherArgs.length &&
isBooleanLiteral(fnCall.matcherArgs[0], false)) ||
fnCall.matcherName === 'toBeFalsy')

const isInverse = methodConfig.inverse
? notModifier || isFalsy
Expand All @@ -108,17 +109,15 @@ export default createRule({
(+!!notModifier ^ +isFalsy && methodConfig.inverse) ||
methodConfig.matcher

const { callee } = arg.argument
const { callee } = call
context.report({
data: {
matcher: newMatcher,
method,
},
fix: (fixer) => {
const methodArgs =
arg.argument.type === 'CallExpression'
? arg.argument.arguments
: []
call.type === 'CallExpression' ? call.arguments : []

const methodEnd = methodArgs.length
? methodArgs.at(-1)!.range![1] + 1
Expand All @@ -128,10 +127,7 @@ export default createRule({
// Add await to the expect call
fixer.insertTextBefore(expect, 'await '),
// Remove the await keyword
fixer.replaceTextRange(
[arg.range![0], arg.argument.range![0]],
'',
),
fixer.replaceTextRange([arg.range![0], call.range![0]], ''),
// Remove the old Playwright method and any arguments
fixer.replaceTextRange(
[callee.property.range![0] - 1, methodEnd],
Expand All @@ -147,13 +143,13 @@ export default createRule({

// Add not to the matcher chain if no inverse matcher exists
if (!methodConfig.inverse && !notModifier && isFalsy) {
fixes.push(fixer.insertTextBefore(call.matcher, 'not.'))
fixes.push(fixer.insertTextBefore(fnCall.matcher, 'not.'))
}

fixes.push(fixer.replaceText(call.matcher, newMatcher))
fixes.push(fixer.replaceText(fnCall.matcher, newMatcher))

// Remove boolean argument if it exists
const [matcherArg] = call.matcherArgs ?? []
const [matcherArg] = fnCall.matcherArgs ?? []
if (matcherArg && isBooleanLiteral(matcherArg)) {
fixes.push(fixer.remove(matcherArg))
}
Expand All @@ -173,7 +169,7 @@ export default createRule({
).length

if (methodArgs) {
const range = call.matcher.range!
const range = fnCall.matcher.range!
const stringArgs = methodArgs
.map((arg) => getRawValue(arg))
.concat(hasOtherArgs ? '' : [])
Expand Down
Loading