diff --git a/README.md b/README.md index 682bbff..87253cf 100644 --- a/README.md +++ b/README.md @@ -166,4 +166,5 @@ CLI option\ | [valid-describe-callback](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/valid-describe-callback.md) | Enforce valid `describe()` callback | ✅ | | | | [valid-expect-in-promise](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/valid-expect-in-promise.md) | Require promises that have expectations in their chain to be valid | ✅ | | | | [valid-expect](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/valid-expect.md) | Enforce valid `expect()` usage | ✅ | | | -| [valid-title](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/valid-title.md) | Enforce valid titles | ✅ | 🔧 | | +| [valid-title](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/valid-title.md) | Enforce valid titles | ✅ | �� | | +| [valid-test-tags](https://github.com/playwright-community/eslint-plugin-playwright/tree/main/docs/rules/valid-test-tags.md) | Enforce valid tag format in test blocks | ✅ | | | diff --git a/docs/rules/valid-test-tags.md b/docs/rules/valid-test-tags.md new file mode 100644 index 0000000..cfa40f7 --- /dev/null +++ b/docs/rules/valid-test-tags.md @@ -0,0 +1,103 @@ +# Valid Test Tags + +This rule ensures that test tags in Playwright test files follow the correct format and meet any configured requirements. + +## Rule Details + +This rule enforces the following: + +1. Tags must start with `@` (e.g., `@e2e`, `@regression`) +2. (Optional, exclusive of 3) Tags must match one of the values in the `allowedTags` property +3. (Optional, exclusive of 2) Tags must not match one of the values in the `disallowedTags` property + +### Examples + +```ts +// Valid +test('my test', { tag: '@e2e' }, async ({ page }) => {}) +test('my test', { tag: ['@e2e', '@login'] }, async ({ page }) => {}) +test.describe('my suite', { tag: '@regression' }, () => {}) +test.step('my step', { tag: '@critical' }, async () => {}) + +// Valid with test.skip, test.fixme, test.only +test.skip('my test', { tag: '@e2e' }, async ({ page }) => {}) +test.fixme('my test', { tag: '@e2e' }, async ({ page }) => {}) +test.only('my test', { tag: '@e2e' }, async ({ page }) => {}) + +// Valid with annotation +test('my test', { + tag: '@e2e', + annotation: { type: 'issue', description: 'BUG-123' } +}, async ({ page }) => {}) + +// Valid with array of annotations +test('my test', { + tag: '@e2e', + annotation: [ + { type: 'issue', description: 'BUG-123' }, + { type: 'flaky' } + ] +}, async ({ page }) => {}) + +// Invalid +test('my test', { tag: 'e2e' }, async ({ page }) => {}) // Missing @ prefix +test('my test', { tag: ['e2e', 'login'] }, async ({ page }) => {}) // Missing @ prefix +``` + +## Options + +This rule accepts an options object with the following properties: + +```ts +type RuleOptions = { + allowedTags?: (string | RegExp)[]; // List of allowed tags or patterns + disallowedTags?: (string | RegExp)[]; // List of disallowed tags or patterns +} +``` + +### `allowedTags` + +When specified, only the listed tags are allowed. You can use either exact strings or regular expressions to match patterns. + +```ts +// Only allow specific tags +{ + "rules": { + "playwright/valid-test-tags": ["error", { "allowedTags": ["@e2e", "@regression"] }] + } +} + +// Allow tags matching a pattern +{ + "rules": { + "playwright/valid-test-tags": ["error", { "allowedTags": ["@e2e", /^@my-tag-\d+$/] }] + } +} +``` + +### `disallowedTags` + +When specified, the listed tags are not allowed. You can use either exact strings or regular expressions to match patterns. + +```ts +// Disallow specific tags +{ + "rules": { + "playwright/valid-test-tags": ["error", { "disallowedTags": ["@skip", "@todo"] }] + } +} + +// Disallow tags matching a pattern +{ + "rules": { + "playwright/valid-test-tags": ["error", { "disallowedTags": ["@skip", /^@temp-/] }] + } +} +``` + +Note: You cannot use both `allowedTags` and `disallowedTags` together. Choose one approach based on your needs. + +## Further Reading + +- [Playwright Test Tags Documentation](https://playwright.dev/docs/test-annotations#tag-tests) +- [Playwright Test Annotations Documentation](https://playwright.dev/docs/test-annotations) \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 26ff8e1..5f4408f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,7 @@ import requireTopLevelDescribe from './rules/require-top-level-describe.js' import validDescribeCallback from './rules/valid-describe-callback.js' import validExpect from './rules/valid-expect.js' import validExpectInPromise from './rules/valid-expect-in-promise.js' +import validTestTags from './rules/valid-test-tags.js' import validTitle from './rules/valid-title.js' const index = { @@ -100,6 +101,7 @@ const index = { 'valid-describe-callback': validDescribeCallback, 'valid-expect': validExpect, 'valid-expect-in-promise': validExpectInPromise, + 'valid-test-tags': validTestTags, 'valid-title': validTitle, }, } diff --git a/src/rules/valid-test-tags.test.ts b/src/rules/valid-test-tags.test.ts new file mode 100644 index 0000000..950112a --- /dev/null +++ b/src/rules/valid-test-tags.test.ts @@ -0,0 +1,141 @@ +import { runTSRuleTester } from '../utils/rule-tester.js' +import validTestTags from './valid-test-tags.js' + +runTSRuleTester('valid-test-tags', validTestTags, { + invalid: [ + // Tag without @ prefix + { + code: "test('my test', { tag: 'e2e' }, async ({ page }) => {})", + errors: [{ messageId: 'invalidTagFormat' }], + }, + // Invalid tag value type + { + code: "test('my test', { tag: 123 }, async ({ page }) => {})", + errors: [{ messageId: 'invalidTagValue' }], + }, + // Array of tags without @ prefix + { + code: "test('my test', { tag: ['e2e', 'login'] }, async ({ page }) => {})", + errors: [ + { messageId: 'invalidTagFormat' }, + { messageId: 'invalidTagFormat' } + ], + }, + // Tag not in allowedTags list + { + code: "test('my test', { tag: '@e2e' }, async ({ page }) => {})", + errors: [{ data: { tag: '@e2e' }, messageId: 'unknownTag' }], + options: [{ allowedTags: ['@regression', '@smoke'] }], + }, + // Tag in disallowedTags list + { + code: "test('my test', { tag: '@skip' }, async ({ page }) => {})", + errors: [{ data: { tag: '@skip' }, messageId: 'disallowedTag' }], + options: [{ disallowedTags: ['@skip', '@todo'] }], + }, + // Tag matching disallowed pattern + { + code: "test('my test', { tag: '@temp-123' }, async ({ page }) => {})", + errors: [{ data: { tag: '@temp-123' }, messageId: 'disallowedTag' }], + options: [{ disallowedTags: ['@skip', /^@temp-/] }], + }, + // Tag not matching allowed pattern + { + code: "test('my test', { tag: '@my-tag-abc' }, async ({ page }) => {})", + errors: [{ data: { tag: '@my-tag-abc' }, messageId: 'unknownTag' }], + options: [{ allowedTags: ['@regression', /^@my-tag-\d+$/] }], + }, + // Invalid tag in test.skip + { + code: "test.skip('my test', { tag: 'e2e' }, async ({ page }) => {})", + errors: [{ messageId: 'invalidTagFormat' }], + }, + // Invalid tag in test.fixme + { + code: "test.fixme('my test', { tag: 'e2e' }, async ({ page }) => {})", + errors: [{ messageId: 'invalidTagFormat' }], + }, + // Invalid tag in test.only + { + code: "test.only('my test', { tag: 'e2e' }, async ({ page }) => {})", + errors: [{ messageId: 'invalidTagFormat' }], + }, + ], + valid: [ + // Basic tag validation + { + code: "test('my test', { tag: '@e2e' }, async ({ page }) => {})", + }, + { + code: "test('my test', { tag: ['@e2e', '@login'] }, async ({ page }) => {})", + }, + { + code: "test.describe('my suite', { tag: '@regression' }, () => {})", + }, + { + code: "test.step('my step', { tag: '@critical' }, async () => {})", + }, + // No tag (valid) + { + code: "test('my test', async ({ page }) => {})", + }, + // Other options without tag (valid) + { + code: "test('my test', { timeout: 5000 }, async ({ page }) => {})", + }, + // Allowed tags + { + code: "test('my test', { tag: '@regression' }, async ({ page }) => {})", + options: [{ allowedTags: ['@regression', '@smoke'] }], + }, + { + code: "test('my test', { tag: '@my-tag-123' }, async ({ page }) => {})", + options: [{ allowedTags: ['@regression', /^@my-tag-\d+$/] }], + }, + // Not in disallowed tags + { + code: "test('my test', { tag: '@e2e' }, async ({ page }) => {})", + options: [{ disallowedTags: ['@skip', '@todo'] }], + }, + { + code: "test('my test', { tag: '@my-tag-123' }, async ({ page }) => {})", + options: [{ disallowedTags: ['@skip', /^@temp-/] }], + }, + // Valid tags with test.skip + { + code: "test.skip('my test', { tag: '@e2e' }, async ({ page }) => {})", + }, + // Valid tags with test.fixme + { + code: "test.fixme('my test', { tag: '@e2e' }, async ({ page }) => {})", + }, + // Valid tags with test.only + { + code: "test.only('my test', { tag: '@e2e' }, async ({ page }) => {})", + }, + // Tag with annotation + { + code: "test('my test', { tag: '@e2e', annotation: { type: 'issue', description: 'BUG-123' } }, async ({ page }) => {})", + }, + // Tag with array of annotations + { + code: "test('my test', { tag: '@e2e', annotation: [{ type: 'issue', description: 'BUG-123' }, { type: 'flaky' }] }, async ({ page }) => {})", + }, + // Array of tags with annotation + { + code: "test('my test', { tag: ['@e2e', '@login'], annotation: { type: 'issue', description: 'BUG-123' } }, async ({ page }) => {})", + }, + // Tag with annotation in test.skip + { + code: "test.skip('my test', { tag: '@e2e', annotation: { type: 'issue', description: 'BUG-123' } }, async ({ page }) => {})", + }, + // Tag with annotation in test.fixme + { + code: "test.fixme('my test', { tag: '@e2e', annotation: { type: 'issue', description: 'BUG-123' } }, async ({ page }) => {})", + }, + // Tag with annotation in test.only + { + code: "test.only('my test', { tag: '@e2e', annotation: { type: 'issue', description: 'BUG-123' } }, async ({ page }) => {})", + }, + ], +}) \ No newline at end of file diff --git a/src/rules/valid-test-tags.ts b/src/rules/valid-test-tags.ts new file mode 100644 index 0000000..a20189a --- /dev/null +++ b/src/rules/valid-test-tags.ts @@ -0,0 +1,157 @@ +import { TSESTree } from '@typescript-eslint/utils' +import { Rule } from 'eslint' +import { createRule } from '../utils/createRule.js' +import { parseFnCall } from '../utils/parseFnCall.js' + +interface RuleOptions { + allowedTags?: (string | RegExp)[] + disallowedTags?: (string | RegExp)[] +} + +export default createRule({ + create(context) { + const options = context.options[0] as RuleOptions || {} + const allowedTags = options.allowedTags || [] + const disallowedTags = options.disallowedTags || [] + + // Validate that the options are not used together + if (allowedTags.length > 0 && disallowedTags.length > 0) { + throw new Error('The allowedTags and disallowedTags options cannot be used together') + } + + // Validate that all configured tags start with @ + for (const tag of [...allowedTags, ...disallowedTags]) { + if (typeof tag === 'string' && !tag.startsWith('@')) { + throw new Error(`Invalid tag "${tag}" in configuration: tags must start with @`) + } + } + + const validateTag = (tag: string, node: Rule.Node) => { + if (!tag.startsWith('@')) { + context.report({ + messageId: 'invalidTagFormat', + node, + }) + return + } + + if (allowedTags.length > 0) { + const isAllowed = allowedTags.some(pattern => + pattern instanceof RegExp ? pattern.test(tag) : pattern === tag + ) + if (!isAllowed) { + context.report({ + data: { tag }, + messageId: 'unknownTag', + node, + }) + return + } + } + + if (disallowedTags.length > 0) { + const isDisallowed = disallowedTags.some(pattern => + pattern instanceof RegExp ? pattern.test(tag) : pattern === tag + ) + if (isDisallowed) { + context.report({ + data: { tag }, + messageId: 'disallowedTag', + node, + }) + } + } + } + + return { + CallExpression(node) { + const call = parseFnCall(context, node) + if (!call) return + + const { type } = call + if (type !== 'test' && type !== 'describe' && type !== 'step') return + + // Check if there's an options object as the second argument + if (node.arguments.length < 2) return + const optionsArg = node.arguments[1] + if (!optionsArg || optionsArg.type !== 'ObjectExpression') return + + // Look for the tag property in the options object + const tagProperty = optionsArg.properties.find( + (prop) => + prop.type === 'Property' && + !('argument' in prop) && // Ensure it's not a spread element + prop.key.type === 'Identifier' && + prop.key.name === 'tag' + ) as TSESTree.Property | undefined + + if (!tagProperty) return + + const tagValue = tagProperty.value + if (tagValue.type === 'Literal') { + // Handle string literal + if (typeof tagValue.value !== 'string') { + context.report({ + messageId: 'invalidTagValue', + node, + }) + return + } + validateTag(tagValue.value, node) + } else if (tagValue.type === 'ArrayExpression') { + // Handle array of strings + for (const element of tagValue.elements) { + if (!element || element.type !== 'Literal' || typeof element.value !== 'string') { + return // Skip invalid elements, TypeScript will handle this + } + validateTag(element.value, node) + } + } else { + context.report({ + messageId: 'invalidTagValue', + node, + }) + } + }, + } + }, + meta: { + docs: { + description: 'Enforce valid tag format in Playwright test blocks', + recommended: true, + }, + messages: { + disallowedTag: 'Tag "{{tag}}" is not allowed', + invalidTagFormat: 'Tag must start with @', + invalidTagValue: 'Tag must be a string or array of strings', + unknownTag: 'Unknown tag "{{tag}}"', + }, + schema: [ + { + additionalProperties: false, + properties: { + allowedTags: { + items: { + oneOf: [ + { type: 'string' }, + { properties: { source: { type: 'string' } }, type: 'object' } + ] + }, + type: 'array', + }, + disallowedTags: { + items: { + oneOf: [ + { type: 'string' }, + { properties: { source: { type: 'string' } }, type: 'object' } + ] + }, + type: 'array', + }, + }, + type: 'object', + }, + ], + type: 'problem', + }, +}) \ No newline at end of file