Skip to content

Commit c83b13a

Browse files
committed
feat: Add require-tags rule
Fixes #401
1 parent 960be8a commit c83b13a

File tree

7 files changed

+284
-6
lines changed

7 files changed

+284
-6
lines changed

AGENTS.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,7 @@ runRuleTester('{rule-name}', rule, {
178178

179179
### Test Utilities
180180

181-
- `javascript` template literal for multi-line code
182-
- `typescript` template literal for TypeScript code
181+
- `dedent` template literal for multi-line code
183182
- `test()` wrapper for common test patterns
184183
- Global alias testing with settings
185184

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ CLI option\
168168
| [prefer-web-first-assertions](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/prefer-web-first-assertions.md) | Suggest using web first assertions || 🔧 | |
169169
| [require-hook](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/require-hook.md) | Require setup and teardown code to be within a hook | | | |
170170
| [require-soft-assertions](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/require-soft-assertions.md) | Require assertions to use `expect.soft()` | | 🔧 | |
171+
| [require-tags](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/require-tags.md) | Require test blocks to have tags | | | |
171172
| [require-to-pass-timeout](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/require-to-pass-timeout.md) | Require a timeout option for `toPass()` | | | |
172173
| [require-to-throw-message](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/require-to-throw-message.md) | Require a message for `toThrow()` | | | |
173174
| [require-top-level-describe](https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/require-top-level-describe.md) | Require test cases and hooks to be inside a `test.describe` block | | | |

docs/rules/missing-playwright-await.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,7 @@ const responsePromise = page.waitForResponse('https://example.com/api')
4040
await page.locator('button').click()
4141
await responsePromise
4242

43-
await Promise.all([
44-
page.locator('button').click(),
45-
page.waitForResponse('https://example.com/api'),
46-
])
43+
await Promise.all([page.locator('button').click(), page.waitForResponse('https://example.com/api')])
4744
```
4845

4946
## Options

docs/rules/require-tags.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Require test blocks to have tags (`require-tags`)
2+
3+
This rule enforces that all test blocks have at least one tag.
4+
5+
Tags can be specified in the test title (e.g., `@e2e my test`) or in the
6+
options object (e.g., `{ tag: '@e2e' }`). Tests also inherit tags from parent
7+
`describe` blocks.
8+
9+
## Rule details
10+
11+
This rule triggers a warning if a test block does not have any tags and none of
12+
its parent `describe` blocks have tags.
13+
14+
The following patterns are considered warnings:
15+
16+
```js
17+
test('my test', async ({ page }) => {})
18+
19+
test('my test', { timeout: 5000 }, async ({ page }) => {})
20+
21+
test.describe('my suite', () => {
22+
test('my test', async ({ page }) => {})
23+
})
24+
```
25+
26+
The following patterns are **not** considered warnings:
27+
28+
```js
29+
test('@e2e my test', async ({ page }) => {})
30+
31+
test('my test', { tag: '@e2e' }, async ({ page }) => {})
32+
33+
test('my test', { tag: ['@e2e', '@smoke'] }, async ({ page }) => {})
34+
35+
// Tests inherit tags from parent describe blocks
36+
test.describe('@suite my suite', () => {
37+
test('my test', async ({ page }) => {})
38+
})
39+
40+
test.describe('my suite', { tag: '@e2e' }, () => {
41+
test('my test', async ({ page }) => {})
42+
})
43+
```

src/plugin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import preferToHaveLength from './rules/prefer-to-have-length.js'
4848
import preferWebFirstAssertions from './rules/prefer-web-first-assertions.js'
4949
import requireHook from './rules/require-hook.js'
5050
import requireSoftAssertions from './rules/require-soft-assertions.js'
51+
import requireTags from './rules/require-tags.js'
5152
import requireToPassTimeout from './rules/require-to-pass-timeout.js'
5253
import requireToThrowMessage from './rules/require-to-throw-message.js'
5354
import requireTopLevelDescribe from './rules/require-top-level-describe.js'
@@ -109,6 +110,7 @@ export const plugin = {
109110
'prefer-web-first-assertions': preferWebFirstAssertions,
110111
'require-hook': requireHook,
111112
'require-soft-assertions': requireSoftAssertions,
113+
'require-tags': requireTags,
112114
'require-to-pass-timeout': requireToPassTimeout,
113115
'require-to-throw-message': requireToThrowMessage,
114116
'require-top-level-describe': requireTopLevelDescribe,

src/rules/require-tags.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import dedent from 'dedent'
2+
import { runRuleTester } from '../utils/rule-tester.js'
3+
import requireTags from './require-tags.js'
4+
5+
runRuleTester('require-tags', requireTags, {
6+
invalid: [
7+
// Test without any tags
8+
{
9+
code: "test('my test', async ({ page }) => {})",
10+
errors: [{ data: { title: 'my test' }, messageId: 'missingTag' }],
11+
},
12+
// Test with options but no tag
13+
{
14+
code: "test('my test', { timeout: 5000 }, async ({ page }) => {})",
15+
errors: [{ data: { title: 'my test' }, messageId: 'missingTag' }],
16+
},
17+
// test.skip without tags
18+
{
19+
code: "test.skip('my test', async ({ page }) => {})",
20+
errors: [{ data: { title: 'my test' }, messageId: 'missingTag' }],
21+
},
22+
// test.fixme without tags
23+
{
24+
code: "test.fixme('my test', async ({ page }) => {})",
25+
errors: [{ data: { title: 'my test' }, messageId: 'missingTag' }],
26+
},
27+
// test.only without tags
28+
{
29+
code: "test.only('my test', async ({ page }) => {})",
30+
errors: [{ data: { title: 'my test' }, messageId: 'missingTag' }],
31+
},
32+
// Test inside describe without tags (describe has no tags either)
33+
{
34+
code: dedent`
35+
test.describe('my suite', () => {
36+
test('my test', async ({ page }) => {})
37+
})
38+
`,
39+
errors: [{ data: { title: 'my test' }, messageId: 'missingTag' }],
40+
},
41+
// Nested describes - test with no tags and no parent tags
42+
{
43+
code: dedent`
44+
test.describe('outer', () => {
45+
test.describe('inner', () => {
46+
test('my test', async ({ page }) => {})
47+
})
48+
})
49+
`,
50+
errors: [{ data: { title: 'my test' }, messageId: 'missingTag' }],
51+
},
52+
],
53+
valid: [
54+
// Test with tag in options
55+
{
56+
code: "test('my test', { tag: '@e2e' }, async ({ page }) => {})",
57+
},
58+
// Test with array of tags
59+
{
60+
code: "test('my test', { tag: ['@e2e', '@login'] }, async ({ page }) => {})",
61+
},
62+
// Test with tag in title
63+
{
64+
code: "test('@e2e my test', async ({ page }) => {})",
65+
},
66+
// Test with multiple tags in title
67+
{
68+
code: "test('@e2e @login my test', async ({ page }) => {})",
69+
},
70+
// Test with tag at end of title
71+
{
72+
code: "test('my test @e2e', async ({ page }) => {})",
73+
},
74+
// test.skip with tag
75+
{
76+
code: "test.skip('@e2e my test', async ({ page }) => {})",
77+
},
78+
{
79+
code: "test.skip('my test', { tag: '@e2e' }, async ({ page }) => {})",
80+
},
81+
// test.fixme with tag
82+
{
83+
code: "test.fixme('@e2e my test', async ({ page }) => {})",
84+
},
85+
{
86+
code: "test.fixme('my test', { tag: '@e2e' }, async ({ page }) => {})",
87+
},
88+
// test.only with tag
89+
{
90+
code: "test.only('@e2e my test', async ({ page }) => {})",
91+
},
92+
{
93+
code: "test.only('my test', { tag: '@e2e' }, async ({ page }) => {})",
94+
},
95+
// Test inheriting tag from parent describe (in title)
96+
{
97+
code: dedent`
98+
test.describe('@suite my suite', () => {
99+
test('my test', async ({ page }) => {})
100+
})
101+
`,
102+
},
103+
// Test inheriting tag from parent describe (in options)
104+
{
105+
code: dedent`
106+
test.describe('my suite', { tag: '@e2e' }, () => {
107+
test('my test', async ({ page }) => {})
108+
})
109+
`,
110+
},
111+
// Test inheriting tag from nested parent describe
112+
{
113+
code: dedent`
114+
test.describe('@suite outer', () => {
115+
test.describe('inner', () => {
116+
test('my test', async ({ page }) => {})
117+
})
118+
})
119+
`,
120+
},
121+
{
122+
code: dedent`
123+
test.describe('outer', { tag: '@e2e' }, () => {
124+
test.describe('inner', () => {
125+
test('my test', async ({ page }) => {})
126+
})
127+
})
128+
`,
129+
},
130+
// Test inheriting tag from inner describe when outer has none
131+
{
132+
code: dedent`
133+
test.describe('outer', () => {
134+
test.describe('@suite inner', () => {
135+
test('my test', async ({ page }) => {})
136+
})
137+
})
138+
`,
139+
},
140+
// Test with its own tag ignores parent inheritance check
141+
{
142+
code: dedent`
143+
test.describe('my suite', () => {
144+
test('@e2e my test', async ({ page }) => {})
145+
})
146+
`,
147+
},
148+
// describe blocks themselves should not be flagged
149+
{
150+
code: "test.describe('my suite', () => {})",
151+
},
152+
// step blocks should not be flagged
153+
{
154+
code: dedent`
155+
test('@e2e my test', async ({ page }) => {
156+
await test.step('my step', async () => {})
157+
})
158+
`,
159+
},
160+
],
161+
})

src/rules/require-tags.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type * as ESTree from 'estree'
2+
import { createRule } from '../utils/createRule.js'
3+
import { isTypeOfFnCall, parseFnCall } from '../utils/parseFnCall.js'
4+
5+
const tagRegex = /@[\S]+/
6+
7+
function hasTagInOptions(node: ESTree.CallExpression): boolean {
8+
const options = node.arguments[1]
9+
if (!options || options.type !== 'ObjectExpression') {
10+
return false
11+
}
12+
13+
return options.properties.some(
14+
(prop) => prop.type === 'Property' && prop.key.type === 'Identifier' && prop.key.name === 'tag',
15+
)
16+
}
17+
18+
function hasTagInTitle(node: ESTree.CallExpression): boolean {
19+
const title = node.arguments[0]
20+
if (!title || title.type !== 'Literal' || typeof title.value !== 'string') {
21+
return false
22+
}
23+
24+
return tagRegex.test(title.value)
25+
}
26+
27+
function hasTags(node: ESTree.CallExpression): boolean {
28+
return hasTagInTitle(node) || hasTagInOptions(node)
29+
}
30+
31+
export default createRule({
32+
create(context) {
33+
const describeStack: boolean[] = []
34+
35+
return {
36+
'CallExpression'(node) {
37+
const call = parseFnCall(context, node)
38+
if (!call) {
39+
return
40+
}
41+
42+
if (call.type === 'describe') {
43+
describeStack.push(hasTags(node) || !!describeStack.at(-1))
44+
return
45+
}
46+
47+
if (call.type === 'test') {
48+
if (hasTags(node) || !!describeStack.at(-1)) {
49+
return
50+
}
51+
52+
context.report({ messageId: 'missingTag', node })
53+
}
54+
},
55+
'CallExpression:exit'(node) {
56+
if (isTypeOfFnCall(context, node, ['describe'])) {
57+
describeStack.pop()
58+
}
59+
},
60+
}
61+
},
62+
meta: {
63+
docs: {
64+
category: 'Best Practices',
65+
description: 'Require test blocks to have tags',
66+
recommended: false,
67+
url: 'https://github.com/mskelton/eslint-plugin-playwright/tree/main/docs/rules/require-tags.md',
68+
},
69+
messages: {
70+
missingTag: 'Test must have at least one tag',
71+
},
72+
schema: [],
73+
type: 'suggestion',
74+
},
75+
})

0 commit comments

Comments
 (0)