|
| 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