Skip to content

Commit 9374973

Browse files
davidenkemskelton
authored andcommitted
feat: Add consistent-spacing-between-blocks rule
1 parent 6b85256 commit 9374973

File tree

6 files changed

+435
-0
lines changed

6 files changed

+435
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ CLI option\
118118

119119
| Rule | Description || 🔧 | 💡 |
120120
| --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | :-: | :-: | :-: |
121+
| [consistent-spacing-between-blocks](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/consistent-spacing-between-blocks.md) | Enforce consistent spacing between test blocks || 🔧 | |
121122
| [expect-expect](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/expect-expect.md) | Enforce assertion to be made in a test body || | |
122123
| [max-expects](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/max-expects.md) | Enforces a maximum number assertion calls in a test body | | | |
123124
| [max-nested-describe](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/max-nested-describe.md) | Enforces a maximum depth to nested describe calls || | |
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Enforce consistent spacing between test blocks (`enforce-consistent-spacing-between-blocks`)
2+
3+
Ensure that there is a consistent spacing between test blocks.
4+
5+
## Rule Details
6+
7+
Examples of **incorrect** code for this rule:
8+
9+
```javascript
10+
test('example 1', () => {
11+
expect(true).toBe(true)
12+
})
13+
test('example 2', () => {
14+
expect(true).toBe(true)
15+
})
16+
```
17+
18+
```javascript
19+
test.beforeEach(() => {})
20+
test('example 3', () => {
21+
await test.step('first', async () => {
22+
expect(true).toBe(true)
23+
})
24+
await test.step('second', async () => {
25+
expect(true).toBe(true)
26+
})
27+
})
28+
```
29+
30+
Examples of **correct** code for this rule:
31+
32+
```javascript
33+
test('example 1', () => {
34+
expect(true).toBe(true)
35+
})
36+
37+
test('example 2', () => {
38+
expect(true).toBe(true)
39+
})
40+
```
41+
42+
```javascript
43+
test.beforeEach(() => {})
44+
45+
test('example 3', () => {
46+
await test.step('first', async () => {
47+
expect(true).toBe(true)
48+
})
49+
50+
await test.step('second', async () => {
51+
expect(true).toBe(true)
52+
})
53+
})
54+
```

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import globals from 'globals'
2+
import consistentSpacingBetweenBlocks from './rules/consistent-spacing-between-blocks.js'
23
import expectExpect from './rules/expect-expect.js'
34
import maxExpects from './rules/max-expects.js'
45
import maxNestedDescribe from './rules/max-nested-describe.js'
@@ -54,6 +55,7 @@ import validTitle from './rules/valid-title.js'
5455
const index = {
5556
configs: {},
5657
rules: {
58+
'consistent-spacing-between-blocks': consistentSpacingBetweenBlocks,
5759
'expect-expect': expectExpect,
5860
'max-expects': maxExpects,
5961
'max-nested-describe': maxNestedDescribe,
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { javascript, runRuleTester } from '../utils/rule-tester.js'
2+
import rule from './consistent-spacing-between-blocks.js'
3+
4+
runRuleTester('consistent-spacing-between-blocks', rule, {
5+
invalid: [
6+
{
7+
code: javascript`
8+
test.beforeEach('Test 1', () => {});
9+
test('Test 2', async () => {
10+
await test.step('Step 1', () => {});
11+
// a comment
12+
test.step('Step 2', () => {});
13+
test.step('Step 3', () => {});
14+
const foo = await test.step('Step 4', () => {});
15+
foo = await test.step('Step 5', () => {});
16+
});
17+
/**
18+
* another comment
19+
*/
20+
test('Test 6', () => {});
21+
`,
22+
errors: [
23+
{ messageId: 'missingWhitespace' },
24+
{ messageId: 'missingWhitespace' },
25+
{ messageId: 'missingWhitespace' },
26+
{ messageId: 'missingWhitespace' },
27+
{ messageId: 'missingWhitespace' },
28+
{ messageId: 'missingWhitespace' },
29+
],
30+
name: 'missing blank lines before test blocks',
31+
output: javascript`
32+
test.beforeEach('Test 1', () => {});
33+
34+
test('Test 2', async () => {
35+
await test.step('Step 1', () => {});
36+
37+
// a comment
38+
test.step('Step 2', () => {});
39+
40+
test.step('Step 3', () => {});
41+
42+
const foo = await test.step('Step 4', () => {});
43+
44+
foo = await test.step('Step 5', () => {});
45+
});
46+
47+
/**
48+
* another comment
49+
*/
50+
test('Test 6', () => {});
51+
`,
52+
},
53+
],
54+
valid: [
55+
{
56+
code: javascript`
57+
test('Test 1', () => {});
58+
59+
test('Test 2', () => {});
60+
`,
61+
name: 'blank line between simple test blocks',
62+
},
63+
{
64+
code: javascript`
65+
test.beforeEach(() => {});
66+
67+
test.skip('Test 2', () => {});
68+
`,
69+
name: 'blank line between test modifiers',
70+
},
71+
{
72+
code: javascript`
73+
test('Test', async () => {
74+
await test.step('Step 1', () => {});
75+
76+
await test.step('Step 2', () => {});
77+
});
78+
`,
79+
name: 'blank line between nested steps in async test',
80+
},
81+
{
82+
code: javascript`
83+
test('Test', async () => {
84+
await test.step('Step 1', () => {});
85+
86+
// some comment
87+
await test.step('Step 2', () => {});
88+
});
89+
`,
90+
name: 'nested steps with a line comment in between',
91+
},
92+
{
93+
code: javascript`
94+
test('Test', async () => {
95+
await test.step('Step 1', () => {});
96+
97+
/**
98+
* another comment
99+
*/
100+
await test.step('Step 2', () => {});
101+
});
102+
`,
103+
name: 'nested steps with a block comment in between',
104+
},
105+
{
106+
code: javascript`
107+
test('assign', async () => {
108+
let foo = await test.step('Step 1', () => {});
109+
110+
foo = await test.step('Step 2', () => {});
111+
});
112+
`,
113+
name: 'assignments initialized by test.step',
114+
},
115+
{
116+
code: javascript`
117+
test('assign', async () => {
118+
let { foo } = await test.step('Step 1', () => {});
119+
120+
({ foo } = await test.step('Step 2', () => {}));
121+
});
122+
`,
123+
name: 'destructuring assignments initialized by test.step',
124+
},
125+
],
126+
})
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import type { AST } from 'eslint'
2+
import type { Comment, Expression, Node } from 'estree'
3+
import { createRule } from '../utils/createRule.js'
4+
import { isTestExpression, unwrapExpression } from '../utils/test-expression.js'
5+
6+
/**
7+
* An ESLint rule that ensures consistent spacing between test blocks (e.g.
8+
* `test`, `test.step`, `test.beforeEach`, etc.). This rule helps improve the
9+
* readability and maintainability of test code by ensuring that test blocks are
10+
* clearly separated from each other.
11+
*/
12+
export default createRule({
13+
create(context) {
14+
/**
15+
* Recursively determines the previous token (if present) and, if necessary,
16+
* a stand-in token to check spacing against. Therefore, the current start
17+
* token can optionally be passed through and used as the comparison token.
18+
*
19+
* Returns the previous token that is not a comment or a grouping expression
20+
* (`previous`), the first token to compare (`start`), and the actual token
21+
* being examined (`origin`).
22+
*
23+
* If there is no previous token for the expression, `null` is returned for
24+
* it. Ideally, the first comparable token is the same as the actual token.
25+
*
26+
* | 1 | test('foo', async () => {
27+
* previous > | 2 | await test.step(...);
28+
* | 3 |
29+
* start > | 4 | // Erster Kommentar
30+
* | 5 | // weiterer Kommentar
31+
* origin > | 6 | await test.step(...);
32+
*/
33+
function getPreviousToken(
34+
node: AST.Token | Node,
35+
start?: AST.Token | Comment | Node,
36+
): {
37+
/** The token actually being checked */
38+
origin: AST.Token | Node
39+
40+
/**
41+
* The previous token that is neither a comment nor a grouping expression,
42+
* if present
43+
*/
44+
previous: AST.Token | null
45+
46+
/**
47+
* The first token used for comparison, e.g. the start of the test
48+
* expression
49+
*/
50+
start: AST.Token | Comment | Node
51+
} {
52+
const current = start ?? node
53+
const previous = context.sourceCode.getTokenBefore(current, {
54+
includeComments: true,
55+
})
56+
57+
// no predecessor present
58+
if (
59+
previous === null ||
60+
previous === undefined ||
61+
previous.value === '{'
62+
) {
63+
return {
64+
origin: node,
65+
previous: null,
66+
start: current,
67+
}
68+
}
69+
70+
// Recursively traverse comments and determine a stand-in
71+
// and unwrap parenthesized expressions
72+
if (
73+
previous.type === 'Line' || // line comment
74+
previous.type === 'Block' || // block comment
75+
previous.value === '(' // grouping operator
76+
) {
77+
return getPreviousToken(node, previous)
78+
}
79+
80+
// Return result
81+
return {
82+
origin: node,
83+
previous: previous as AST.Token,
84+
start: current,
85+
}
86+
}
87+
88+
/**
89+
* Checks whether the spacing before the given test block meets
90+
* expectations. Optionally an offset token can be provided to check
91+
* against, for example in the case of an assignment.
92+
*
93+
* @param node - The node to be checked.
94+
* @param offset - Optional offset token to check spacing against.
95+
*/
96+
function checkSpacing(node: Expression, offset?: AST.Token | Node) {
97+
const { previous, start } = getPreviousToken(node, offset)
98+
99+
// First expression or no previous token
100+
if (previous === null) return
101+
102+
// Ignore when there is one or more blank lines between
103+
if (previous.loc.end.line < start.loc!.start.line - 1) {
104+
return
105+
}
106+
107+
// Since the hint in the IDE may not appear on the affected test expression
108+
// but possibly on the preceding comment, include the test expression in the message
109+
const source = context.sourceCode.getText(unwrapExpression(node))
110+
111+
context.report({
112+
data: { source },
113+
fix(fixer) {
114+
return fixer.insertTextAfter(previous, '\n')
115+
},
116+
loc: {
117+
end: {
118+
column: start.loc!.start.column,
119+
line: start.loc!.start.line,
120+
},
121+
start: {
122+
column: 0,
123+
line: previous.loc.end.line + 1,
124+
},
125+
},
126+
messageId: 'missingWhitespace',
127+
node,
128+
})
129+
}
130+
131+
return {
132+
// Checks call expressions that could be test steps,
133+
// e.g. `test(...)`, `test.step(...)`, or `await test.step(...)`, but also `foo = test(...)`
134+
ExpressionStatement(node) {
135+
if (isTestExpression(context, node.expression)) {
136+
checkSpacing(node.expression)
137+
}
138+
},
139+
// Checks declarations that might be initialized from return values of test steps,
140+
// e.g. `let result = await test(...)` or `const result = await test.step(...)`
141+
VariableDeclaration(node) {
142+
node.declarations.forEach((declaration) => {
143+
if (declaration.init && isTestExpression(context, declaration.init)) {
144+
// When declaring a variable, our examined test expression is used for initialization.
145+
// Therefore, to check spacing we use the keyword token (let, const, var) before it:
146+
// 1 | const foo = test('foo', () => {});
147+
// 2 | ^
148+
const offset = context.sourceCode.getTokenBefore(declaration)
149+
checkSpacing(declaration.init, offset ?? undefined)
150+
}
151+
})
152+
},
153+
}
154+
},
155+
meta: {
156+
docs: {
157+
description:
158+
'Enforces a blank line between Playwright test blocks (e.g., test, test.step, test.beforeEach, etc.).',
159+
recommended: true,
160+
},
161+
fixable: 'whitespace',
162+
messages: {
163+
missingWhitespace:
164+
"A blank line is required before the test block '{{source}}'.",
165+
},
166+
schema: [],
167+
type: 'layout',
168+
},
169+
})

0 commit comments

Comments
 (0)