Skip to content

Prefer lowercase title #87

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Aug 22, 2022
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,5 @@ command line option.\
| ✔ | | 💡 | [no-skipped-test](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-skipped-test.md) | Disallow usage of the `.skip` annotation |
| ✔ | | | [no-useless-not](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-useless-not.md) | Disallow usage of `not` matchers when a specific matcher exists |
| ✔ | | 💡 | [no-wait-for-timeout](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/no-wait-for-timeout.md) | Disallow usage of `page.waitForTimeout` |
| | 🔧 | | [prefer-lowercase-title](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-lowercase-title.md) | Enforce lowercase test names |
| ✔ | | | [valid-expect](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/valid-expect.md) | Enforce valid `expect()` usage |
90 changes: 90 additions & 0 deletions docs/rules/prefer-lowercase-title.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Enforce lowercase test names (`prefer-lowercase-title`)

## Rule details

Enforce `test` and `test.describe` to have descriptions that begin with a
lowercase letter. This provides more readable test failures. This rule is not
enabled by default.

The following pattern is considered a warning:

```javascript
test('Adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});
```

The following pattern is **not** considered a warning:

```javascript
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});
```

## Options

```json
{
"playwright/prefer-lowercase-title": [
"error",
{
"allowedPrefixes": ["GET", "POST"],
"ignore": ["test.describe", "test"],
"ignoreTopLevelDescribe": true
}
]
}
```

### `ignore`

This array option controls which Playwright functions are checked by this rule.
There are two possible values:

- `"test.describe"`
- `"test"`

By default, none of these options are enabled (the equivalent of
`{ "ignore": [] }`).

Example of **correct** code for the `{ "ignore": ["test.describe"] }` option:

```javascript
test.describe('Uppercase description');
```

Example of **correct** code for the `{ "ignore": ["test"] }` option:

```javascript
test('Uppercase description');
```

### `allowedPrefixes`

This array option allows specifying prefixes, which contain capitals that titles
can start with. This can be useful when writing tests for API endpoints, where
you'd like to prefix with the HTTP method.

By default, nothing is allowed (the equivalent of `{ "allowedPrefixes": [] }`).

Example of **correct** code for the `{ "allowedPrefixes": ["GET"] }` option:

```javascript
test.describe('GET /live');
```

### `ignoreTopLevelDescribe`

This option can be set to allow only the top-level `test.describe` blocks to
have a title starting with an upper-case letter.

Example of **correct** code for the `{ "ignoreTopLevelDescribe": true }` option:

```javascript
test.describe('MyClass', () => {
test.describe('#myMethod', () => {
test('does things', () => {});
});
});
```
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
"ts": "tsc"
},
"devDependencies": {
"@types/dedent": "^0.7.0",
"@types/eslint": "^8.4.5",
"@types/estree": "^1.0.0",
"dedent": "^0.7.0",
"eslint": "^8.4.1",
"jest": "^28.1.3",
"prettier": "^2.7.1",
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import maxNestedDescribe from './rules/max-nested-describe';
import noConditionalInTest from './rules/no-conditional-in-test';
import noUselessNot from './rules/no-useless-not';
import validExpect from './rules/valid-expect';
import preferLowercaseTitle from './rules/prefer-lowercase-title';

export = {
configs: {
Expand Down Expand Up @@ -80,5 +81,6 @@ export = {
'no-conditional-in-test': noConditionalInTest,
'no-useless-not': noUselessNot,
'valid-expect': validExpect,
'prefer-lowercase-title': preferLowercaseTitle,
},
};
131 changes: 131 additions & 0 deletions src/rules/prefer-lowercase-title.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { Rule, AST } from 'eslint';
import {
getStringValue,
isDescribeCall,
isStringLiteral,
isTest,
} from '../utils/ast';
import * as ESTree from 'estree';

type Method = 'test' | 'test.describe';

function isString(
node: ESTree.Expression | ESTree.SpreadElement
): node is ESTree.Literal | ESTree.TemplateLiteral {
return node && (isStringLiteral(node) || node.type === 'TemplateLiteral');
}

export default {
create(context) {
const { ignoreTopLevelDescribe, allowedPrefixes, ignore } = {
ignore: [] as Method[],
allowedPrefixes: [] as string[],
ignoreTopLevelDescribe: false,
...((context.options?.[0] as {}) ?? {}),
};

let describeCount = 0;

return {
CallExpression(node) {
const method = isDescribeCall(node)
? 'test.describe'
: isTest(node)
? 'test'
: null;

if (method === 'test.describe') {
describeCount++;

if (ignoreTopLevelDescribe && describeCount === 1) {
return;
}
} else if (!method) {
return;
}

const [title] = node.arguments;
if (!isString(title)) {
return;
}

const description = getStringValue(title);
if (
!description ||
allowedPrefixes.some((name) => description.startsWith(name))
) {
return;
}

const firstCharacter = description.charAt(0);
if (
!firstCharacter ||
firstCharacter === firstCharacter.toLowerCase() ||
ignore.includes(method)
) {
return;
}

context.report({
messageId: 'unexpectedLowercase',
node: node.arguments[0],
data: { method },
fix(fixer) {
const rangeIgnoringQuotes: AST.Range = [
title.range![0] + 1,
title.range![1] - 1,
];

const newDescription =
description.substring(0, 1).toLowerCase() +
description.substring(1);

return fixer.replaceTextRange(rangeIgnoringQuotes, newDescription);
},
});
},
'CallExpression:exit'(node: ESTree.CallExpression) {
if (isDescribeCall(node)) {
describeCount--;
}
},
};
},
meta: {
docs: {
category: 'Best Practices',
description: 'Enforce lowercase test names',
recommended: true,
url: 'https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/prefer-lowercase-title.md',
},
messages: {
unexpectedLowercase: '`{{method}}`s should begin with lowercase',
},
type: 'suggestion',
fixable: 'code',
schema: [
{
type: 'object',
properties: {
ignore: {
type: 'array',
items: {
enum: ['test.describe', 'test'],
},
additionalItems: false,
},
allowedPrefixes: {
type: 'array',
items: { type: 'string' },
additionalItems: false,
},
ignoreTopLevelDescribe: {
type: 'boolean',
default: false,
},
},
additionalProperties: false,
},
],
},
} as Rule.RuleModule;
54 changes: 27 additions & 27 deletions src/utils/ast.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { Rule } from 'eslint';
import * as ESTree from 'estree';

export function getStringValue(node: ESTree.Node) {
return node.type === 'TemplateLiteral'
? node.quasis[0].value.raw
: node.type === 'Literal' && typeof node.value === 'string'
? node.value
: '';
}

export function getNodeName(node: ESTree.Node) {
return node.type === 'Identifier' ? node.name : undefined;
}
Expand Down Expand Up @@ -54,43 +62,35 @@ export function isBooleanLiteral(node: ESTree.Node, value?: boolean) {
return isLiteral(node, 'boolean', value);
}

function isDescribeAlias(node: ESTree.Node) {
return isIdentifier(node, 'describe');
}
const describeProperties = new Set([
'parallel',
'serial',
'only',
'skip',
'fixme',
]);

function isDescribeProperty(node: ESTree.Node) {
const describeProperties = ['parallel', 'serial', 'only', 'skip'];
return describeProperties.some((prop) => isIdentifier(node, prop));
return describeProperties.has(getNodeName(node) ?? '');
}

export function isDescribeCall(node: ESTree.CallExpression) {
if (isIdentifier(node.callee, 'describe')) {
return true;
}

const callee =
node.callee.type === 'TaggedTemplateExpression'
? node.callee.tag
: node.callee.type === 'CallExpression'
? node.callee.callee
: node.callee;
export function isDescribeCall(node: ESTree.Node): boolean {
const inner = node.type === 'CallExpression' ? node.callee : node;

if (callee.type === 'MemberExpression' && isDescribeAlias(callee.property)) {
// Allow describe without test prefix
if (isIdentifier(inner, 'describe')) {
return true;
}

if (
callee.type === 'MemberExpression' &&
isDescribeProperty(callee.property)
) {
return callee.object.type === 'MemberExpression'
? callee.object.object.type === 'MemberExpression'
? isDescribeAlias(callee.object.object.property)
: isDescribeAlias(callee.object.property)
: isDescribeAlias(callee.property) || isDescribeAlias(callee.object);
if (inner.type !== 'MemberExpression') {
return false;
}

return false;
return isIdentifier(inner.property, 'describe')
? true
: isDescribeProperty(inner.property)
? isDescribeCall(inner.object)
: false;
}

type NodeWithParent<T extends ESTree.Node['type']> = Extract<
Expand Down
2 changes: 2 additions & 0 deletions test/spec/max-nested-describe.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ runRuleTester('max-nested-describe', rule, {
'test.describe("describe tests", () => {});',
'test.describe.only("describe focus tests", () => {});',
'test.describe.serial.only("describe serial focus tests", () => {});',
'test.describe.serial.skip("describe serial focus tests", () => {});',
'test.describe.parallel.fixme("describe serial focus tests", () => {});',
valid(
`
test('foo', function () {
Expand Down
Loading