From 69d258fded087f2521aff382ca2b91cf401fd3e0 Mon Sep 17 00:00:00 2001 From: ysknsid25 Date: Mon, 10 Aug 2026 00:43:34 +0900 Subject: [PATCH] feat(vitest): [titleValidity] add rule --- .changeset/many-needles-brake.md | 6 + packages/rule-data/src/data.json | 3 +- .../docs/rules/vitest/titleValidity.mdx | 217 ++++++ packages/vitest/package.json | 3 +- packages/vitest/src/plugin.ts | 2 + .../vitest/src/rules/titleValidity.test.ts | 734 ++++++++++++++++++ packages/vitest/src/rules/titleValidity.ts | 344 ++++++++ pnpm-lock.yaml | 3 + 8 files changed, 1310 insertions(+), 2 deletions(-) create mode 100644 .changeset/many-needles-brake.md create mode 100644 packages/site/src/content/docs/rules/vitest/titleValidity.mdx create mode 100644 packages/vitest/src/rules/titleValidity.test.ts create mode 100644 packages/vitest/src/rules/titleValidity.ts diff --git a/.changeset/many-needles-brake.md b/.changeset/many-needles-brake.md new file mode 100644 index 0000000000..b1867d51bc --- /dev/null +++ b/.changeset/many-needles-brake.md @@ -0,0 +1,6 @@ +--- +"@flint.fyi/rule-data": patch +"@flint.fyi/vitest": patch +--- + +[titleValidity] add rule. diff --git a/packages/rule-data/src/data.json b/packages/rule-data/src/data.json index c83f28f16e..963f502565 100644 --- a/packages/rule-data/src/data.json +++ b/packages/rule-data/src/data.json @@ -31557,7 +31557,8 @@ "flint": { "name": "titleValidity", "plugin": "vitest", - "preset": "logical" + "preset": "logical", + "status": "implemented" }, "oxlint": [ { diff --git a/packages/site/src/content/docs/rules/vitest/titleValidity.mdx b/packages/site/src/content/docs/rules/vitest/titleValidity.mdx new file mode 100644 index 0000000000..ae13ab89f9 --- /dev/null +++ b/packages/site/src/content/docs/rules/vitest/titleValidity.mdx @@ -0,0 +1,217 @@ +--- +description: "This rule aims to enforce valid titles for `describe()`, `it()` and `test()` titles." +title: "titleValidity" +topic: "rules" +--- + +import { TabItem, Tabs } from "@astrojs/starlight/components"; + +import { RuleEquivalents } from "~/components/RuleEquivalents"; +import RuleSummary from "~/components/RuleSummary.astro"; + + + +Reports `describe()`, `it()`, and `test()` calls with invalid titles. + +## Examples + + + + +```ts +describe(1, () => { + // ... +}); +``` + +```ts +it("", () => { + // ... +}); +``` + +```ts +it("it returns a number", () => { + // ... +}); +``` + +```ts +it(" returns a number ", () => { + // ... +}); +``` + + + + +```ts +describe("1", () => { + // ... +}); +``` + +```ts +it("returns a number", () => { + // ... +}); +``` + +```ts +declare function getValue(): number; + +describe(getValue, () => { + // ... +}); +``` + + + + +## Options + +### `allowArguments` + +Whether to allow identifiers as titles. +Defaults to `false`. + +Only identifiers are skipped by this option. +Function calls such as `getTitle()` and property accesses such as `config.title` are not skipped, and are still reported. + +Examples of **incorrect** code for this rule with the `{ "allowArguments": true }` option: + +```ts +declare function getTitle(): unknown; + +it(getTitle(), () => { + // ... +}); +``` + +```ts +declare const config: { title: unknown }; + +it(config.title, () => { + // ... +}); +``` + +Examples of **correct** code for this rule with the `{ "allowArguments": true }` option: + +```ts +declare const title: unknown; + +it(title, () => { + // ... +}); +``` + +### `disallowedWords` + +Words that are not allowed to appear in titles. +Defaults to `[]`. + +Words are matched case-insensitively on word boundaries, against the title string only. +Because of the word boundaries, `"skip"` does not match a title containing `skipped`. + +Examples of **incorrect** code for this rule with the `{ "disallowedWords": ["skips"] }` option: + +```ts +it("skips the empty case", () => { + // ... +}); +``` + +Examples of **correct** code for this rule with the `{ "disallowedWords": ["skips"] }` option: + +```ts +it("ignores the empty case", () => { + // ... +}); +``` + +### `ignoreTypeOfDescribeName` + +Whether to skip checking the type of `describe()` titles. +Defaults to `false`. + +Examples of **correct** code for this rule with the `{ "ignoreTypeOfDescribeName": true }` option: + +```ts +declare const value: unknown; + +describe(typeof value, () => { + // ... +}); +``` + +### `mustMatch` + +Regular expressions that titles must match, optionally with a custom message. +Not set by default. + +A string or a `[pattern, message]` pair applies to `describe`, `it`, and `test` alike. +An object applies a separate pattern per function: + +```json +{ + "mustMatch": { + "it": ["^should ", "Start test titles with \"should\"."] + } +} +``` + +Aliases map onto those three keys: `xdescribe` uses `describe`, `fit` and `xit` use `it`, and `xtest` uses `test`. + +Examples of **incorrect** code for this rule with the `{ "mustMatch": "^should " }` option: + +```ts +it("returns a number", () => { + // ... +}); +``` + +Examples of **correct** code for this rule with the `{ "mustMatch": "^should " }` option: + +```ts +it("should return a number", () => { + // ... +}); +``` + +### `mustNotMatch` + +Regular expressions that titles must not match, optionally with a custom message. +Not set by default. + +It takes the same shapes as `mustMatch`. +When a title matches `mustNotMatch`, that is reported and `mustMatch` is not checked. + +Examples of **incorrect** code for this rule with the `{ "mustNotMatch": "^should " }` option: + +```ts +it("should return a number", () => { + // ... +}); +``` + +Examples of **correct** code for this rule with the `{ "mustNotMatch": "^should " }` option: + +```ts +it("returns a number", () => { + // ... +}); +``` + +## When Not To Use It + +Projects that lean on generated titles, or that have a large existing suite whose titles would be disruptive to rename, might prefer to enable only the options they need rather than the whole rule. + +## Further Reading + +- [Vitest API: Describe](https://vitest.dev/api/describe) +- [Vitest API: Test](https://vitest.dev/api/test) + +## Equivalents in Other Linters + + diff --git a/packages/vitest/package.json b/packages/vitest/package.json index e224174bfd..2ab2535588 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -27,7 +27,8 @@ "dependencies": { "@flint.fyi/core": "workspace:^", "@flint.fyi/typescript-language": "workspace:^", - "typescript": "^6.0.0" + "typescript": "^6.0.0", + "zod": "^4.3.6" }, "devDependencies": { "@flint.fyi/build": "workspace:^", diff --git a/packages/vitest/src/plugin.ts b/packages/vitest/src/plugin.ts index ff79d3bb6c..466bcc8bad 100644 --- a/packages/vitest/src/plugin.ts +++ b/packages/vitest/src/plugin.ts @@ -12,6 +12,7 @@ import expectGroupPaddingLines from "./rules/expectGroupPaddingLines.ts"; import nodeTestImports from "./rules/nodeTestImports.ts"; import testCasePaddingLines from "./rules/testCasePaddingLines.ts"; import testCasesWithinDescribes from "./rules/testCasesWithinDescribes.ts"; +import titleValidity from "./rules/titleValidity.ts"; export const vitest = createPlugin({ files: { @@ -29,5 +30,6 @@ export const vitest = createPlugin({ nodeTestImports, testCasePaddingLines, testCasesWithinDescribes, + titleValidity, ], }); diff --git a/packages/vitest/src/rules/titleValidity.test.ts b/packages/vitest/src/rules/titleValidity.test.ts new file mode 100644 index 0000000000..18a441913c --- /dev/null +++ b/packages/vitest/src/rules/titleValidity.test.ts @@ -0,0 +1,734 @@ +import { ruleTester } from "../ruleTester.ts"; +import rule from "./titleValidity.ts"; + +ruleTester.describe(rule, { + invalid: [ + { + code: ` +test("the correct way to properly handle all things", () => {}); +`, + options: { disallowedWords: ["correct", "properly", "all"] }, + snapshot: ` +test("the correct way to properly handle all things", () => {}); + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + \`correct\` is not allowed in test title. +`, + }, + { + code: ` +describe("the correct way to do things", function () {}) +`, + options: { disallowedWords: ["correct"] }, + snapshot: ` +describe("the correct way to do things", function () {}) + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + \`correct\` is not allowed in test title. +`, + }, + { + code: ` +it("has ALL the things", () => {}) +`, + options: { disallowedWords: ["all"] }, + snapshot: ` +it("has ALL the things", () => {}) + ~~~~~~~~~~~~~~~~~~~~ + \`ALL\` is not allowed in test title. +`, + }, + { + code: ` +xdescribe("every single one of them", function () {}) +`, + options: { disallowedWords: ["every"] }, + snapshot: ` +xdescribe("every single one of them", function () {}) + ~~~~~~~~~~~~~~~~~~~~~~~~~~ + \`every\` is not allowed in test title. +`, + }, + { + code: ` +describe('Very Descriptive Title Goes Here', function () {}) +`, + options: { disallowedWords: ["descriptive"] }, + snapshot: ` +describe('Very Descriptive Title Goes Here', function () {}) + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + \`Descriptive\` is not allowed in test title. +`, + }, + { + code: ` +test(\`that the value is set properly\`, function () {}) +`, + options: { disallowedWords: ["properly"] }, + snapshot: ` +test(\`that the value is set properly\`, function () {}) + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + \`properly\` is not allowed in test title. +`, + }, + { + code: ` +import { test } from './test-extend' +test('the correct way to properly handle all things', () => {}) +`, + options: { disallowedWords: ["correct"] }, + snapshot: ` +import { test } from './test-extend' +test('the correct way to properly handle all things', () => {}) + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + \`correct\` is not allowed in test title. +`, + }, + { + code: ` +import { test } from '@/tests/fixtures' +test('the correct way to properly handle all things', () => {}) +`, + options: { disallowedWords: ["correct"] }, + snapshot: ` +import { test } from '@/tests/fixtures' +test('the correct way to properly handle all things', () => {}) + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + \`correct\` is not allowed in test title. +`, + }, + ], + valid: [ + `describe("the correct way to properly handle all the things", () => {});`, + `test("that all is as it should be", () => {});`, + { + code: `it("correctly sets the value", () => {});`, + options: { + disallowedWords: ["incorrectly"], + ignoreTypeOfDescribeName: false, + }, + }, + { + code: `it("correctly sets the value", () => {});`, + options: { disallowedWords: undefined }, + }, + ` + function foo(){} + describe(foo, () => { + test('item', () => { + expect(0).toBe(0) + }) + }) + `, + ` + declare const outerName: string; + describe(outerName, () => { + test('item', () => { + expect(0).toBe(0) + }) + }) + `, + ` + declare const outerName: 'a'; + describe(outerName, () => { + test('item', () => { + expect(0).toBe(0) + }) + }) + `, + ` + declare const outerName: \`\${'a'}\`; + describe(outerName, () => { + test('item', () => { + expect(0).toBe(0) + }) + }) + `, + ` + class foo{} + describe(foo, () => { + test('item', () => { + expect(0).toBe(0) + }) + }) + `, + ` + type Func = (params: object) => void + const func: Func = (params) => console.log(params) + describe(func, () => { + test('item', () => { + expect(0).toBe(0) + }) + });`, + ` + interface Func { + (params: object): void + } + const func: Func = (params) => console.log(params) + describe(func, () => { + test('item', () => { + expect(0).toBe(0) + }) + });`, + { + code: ` + import { validatorFunction } from "./myFunction" + describe(validatorFunction, () => { + test('item', () => { + expect(0).toBe(0) + }) + }) + `, + fileName: "myFunction.test.ts", + files: { "myFunction.ts": `export function validatorFunction() {}` }, + }, + ], +}); + +ruleTester.describe(rule, { + invalid: [ + { + code: ` +test(bar, () => {}); +`, + options: { allowArguments: false }, + snapshot: ` +test(bar, () => {}); + ~~~ + Test title must be a string, a function or class name. +`, + }, + ], + valid: [ + { + code: `it(foo, () => {});`, + options: { allowArguments: true }, + }, + { + code: `describe(bar, () => {});`, + options: { allowArguments: true }, + }, + { + code: `test(baz, () => {});`, + options: { allowArguments: true }, + }, + ], +}); + +ruleTester.describe(rule, { + invalid: [ + { + code: ` + describe('things to test', () => { + describe('unit tests #unit', () => { + it('is true', () => { + expect(true).toBe(true); + }); + }); + + describe('e2e tests #e4e', () => { + it('is another test #e2e #vitest4life', () => {}); + }); + }); +`, + options: { + mustMatch: "^[^#]+$|(?:#(?:unit|e2e))", + mustNotMatch: "(?:#(?!unit|e2e))\\w+", + }, + snapshot: ` + describe('things to test', () => { + describe('unit tests #unit', () => { + it('is true', () => { + expect(true).toBe(true); + }); + }); + + describe('e2e tests #e4e', () => { + ~~~~~~~~~~~~~~~~ + \`describe\` should not match /(?:#(?!unit|e2e))\\w+/u. + it('is another test #e2e #vitest4life', () => {}); + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + \`it\` should not match /(?:#(?!unit|e2e))\\w+/u. + }); + }); +`, + }, + { + code: ` + describe('things to test', () => { + describe('unit tests #unit', () => { + it('is true', () => { + expect(true).toBe(true); + }); + }); + + describe('e2e tests #e4e', () => { + it('is another test #e2e #vitest4life', () => {}); + }); + }); +`, + options: { + mustMatch: [ + "^[^#]+$|(?:#(?:unit|e2e))", + 'Please include "#unit" or "#e2e" in titles', + ], + mustNotMatch: [ + "(?:#(?!unit|e2e))\\w+", + 'Please include "#unit" or "#e2e" in titles', + ], + }, + snapshot: ` + describe('things to test', () => { + describe('unit tests #unit', () => { + it('is true', () => { + expect(true).toBe(true); + }); + }); + + describe('e2e tests #e4e', () => { + ~~~~~~~~~~~~~~~~ + \`describe\` should not match /(?:#(?!unit|e2e))\\w+/u. Please include "#unit" or "#e2e" in titles + it('is another test #e2e #vitest4life', () => {}); + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + \`it\` should not match /(?:#(?!unit|e2e))\\w+/u. Please include "#unit" or "#e2e" in titles + }); + }); +`, + }, + { + code: ` +test("the correct way to properly handle all things", () => {}); +`, + options: { mustMatch: "#(?:unit|integration|e2e)" }, + snapshot: ` +test("the correct way to properly handle all things", () => {}); + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + \`test\` should match /#(?:unit|integration|e2e)/u. +`, + }, + { + code: ` +describe.skip("the test", () => {}); +`, + options: { mustMatch: { describe: "#(?:unit|integration|e2e)" } }, + snapshot: ` +describe.skip("the test", () => {}); + ~~~~~~~~~~ + \`describe\` should match /#(?:unit|integration|e2e)/u. +`, + }, + ], + valid: [ + `describe("the correct way to properly handle all the things", () => {});`, + `test("that all is as it should be", () => {});`, + { + code: `it("correctly sets the value", () => {});`, + options: { mustMatch: {} }, + }, + { + code: `it("correctly sets the value", () => {});`, + options: { mustMatch: " " }, + }, + { + code: `it("correctly sets the value", () => {});`, + options: { mustMatch: [" "] }, + }, + { + code: `it("correctly sets the value #unit", () => {});`, + options: { mustMatch: "#(?:unit|integration|e2e)" }, + }, + { + code: `it("correctly sets the value", () => {});`, + options: { mustMatch: "^[^#]+$|(?:#(?:unit|e2e))" }, + }, + { + code: `it("correctly sets the value", () => {});`, + options: { mustMatch: { test: "#(?:unit|integration|e2e)" } }, + }, + { + code: `describe('things to test', () => { + describe('unit tests #unit', () => { + it('is true', () => { + expect(true).toBe(true); + }); + }); + + describe('e2e tests #e2e', () => { + it('is another test #jest4life', () => {}); + }); + });`, + options: { mustMatch: { test: "^[^#]+$|(?:#(?:unit|e2e))" } }, + }, + ], +}); + +ruleTester.describe(rule, { + invalid: [ + { + code: ` +it.each([])(1, () => {}); +`, + snapshot: ` +it.each([])(1, () => {}); + ~ + Test title must be a string, a function or class name. +`, + }, + { + code: ` +it.skip.each([])(1, () => {}); +`, + snapshot: ` +it.skip.each([])(1, () => {}); + ~ + Test title must be a string, a function or class name. +`, + }, + { + code: ` +it.skip.each\`\`(1, () => {}); +`, + snapshot: ` +it.skip.each\`\`(1, () => {}); + ~ + Test title must be a string, a function or class name. +`, + }, + { + code: ` +it(123, () => {}); +`, + snapshot: ` +it(123, () => {}); + ~~~ + Test title must be a string, a function or class name. +`, + }, + { + code: ` +it.concurrent(123, () => {}); +`, + snapshot: ` +it.concurrent(123, () => {}); + ~~~ + Test title must be a string, a function or class name. +`, + }, + { + code: ` +it(1 + 2 + 3, () => {}); +`, + snapshot: ` +it(1 + 2 + 3, () => {}); + ~~~~~~~~~ + Test title must be a string, a function or class name. +`, + }, + ], + valid: [ + `it("is a string", () => {});`, + `it("is" + " a " + " string", () => {});`, + `it(1 + " + " + 1, () => {});`, + `test("is a string", () => {});`, + `xtest("is a string", () => {});`, + `xtest(\`\${myFunc} is a string\`, () => {});`, + `describe("is a string", () => {});`, + `describe.skip("is a string", () => {});`, + `describe.skip(\`\${myFunc} is a string\`, () => {});`, + `fdescribe("is a string", () => {});`, + { + code: `describe(String(/.+/), () => {});`, + options: { ignoreTypeOfDescribeName: true }, + }, + { + code: `describe(myFunction, () => {});`, + options: { ignoreTypeOfDescribeName: true }, + }, + { + code: `xdescribe(skipFunction, () => {});`, + options: { disallowedWords: [], ignoreTypeOfDescribeName: true }, + }, + ], +}); + +ruleTester.describe(rule, { + invalid: [ + { + code: ` +describe("", function () {}) +`, + snapshot: ` +describe("", function () {}) + ~~ + \`describe\` should not have an empty title. +`, + }, + { + code: ` + describe('foo', () => { + it('', () => {}); + }); +`, + snapshot: ` + describe('foo', () => { + it('', () => {}); + ~~ + \`it\` should not have an empty title. + }); +`, + }, + { + code: ` + describe('foo', () => { + test('', () => {}); + }); +`, + snapshot: ` + describe('foo', () => { + test('', () => {}); + ~~ + \`test\` should not have an empty title. + }); +`, + }, + { + code: ` +it("", function () {}) +`, + snapshot: ` +it("", function () {}) + ~~ + \`it\` should not have an empty title. +`, + }, + { + code: ` +it.concurrent("", function () {}) +`, + snapshot: ` +it.concurrent("", function () {}) + ~~ + \`it\` should not have an empty title. +`, + }, + { + code: ` +test("", function () {}) +`, + snapshot: ` +test("", function () {}) + ~~ + \`test\` should not have an empty title. +`, + }, + { + code: ` +test.concurrent("", function () {}) +`, + snapshot: ` +test.concurrent("", function () {}) + ~~ + \`test\` should not have an empty title. +`, + }, + { + code: ` +test.concurrent(\`\`, function () {}) +`, + snapshot: ` +test.concurrent(\`\`, function () {}) + ~~ + \`test\` should not have an empty title. +`, + }, + { + code: ` +xdescribe('', () => {}) +`, + snapshot: ` +xdescribe('', () => {}) + ~~ + \`describe\` should not have an empty title. +`, + }, + ], + valid: [ + `describe()`, + `someFn("", function () {})`, + `describe("foo", function () {})`, + `describe("foo", function () { it("bar", function () {}) })`, + `test("foo", function () {})`, + `test.concurrent("foo", function () {})`, + `test(\`foo\`, function () {})`, + `test.concurrent(\`foo\`, function () {})`, + `test(\`\${foo}\`, function () {})`, + `test.concurrent(\`\${foo}\`, function () {})`, + `test.scoped({})`, + `it.scoped({})`, + `it('foo', function () {})`, + `it.each([])()`, + `it.concurrent('foo', function () {})`, + `xdescribe('foo', function () {})`, + `xit('foo', function () {})`, + `xtest('foo', function () {})`, + ], +}); + +ruleTester.describe(rule, { + invalid: [ + { + code: ` +describe(" foo", function () {}) +`, + output: ` +describe("foo", function () {}) +`, + snapshot: ` +describe(" foo", function () {}) + ~~~~~~ + Should not have leading or trailing spaces +`, + }, + { + code: ` +describe.each()(" foo", function () {}) +`, + output: ` +describe.each()("foo", function () {}) +`, + snapshot: ` +describe.each()(" foo", function () {}) + ~~~~~~ + Should not have leading or trailing spaces +`, + }, + { + code: ` +describe.only.each()(" foo", function () {}) +`, + output: ` +describe.only.each()("foo", function () {}) +`, + snapshot: ` +describe.only.each()(" foo", function () {}) + ~~~~~~ + Should not have leading or trailing spaces +`, + }, + { + code: ` +describe(" foo foe fum", function () {}) +`, + output: ` +describe("foo foe fum", function () {}) +`, + snapshot: ` +describe(" foo foe fum", function () {}) + ~~~~~~~~~~~~~~ + Should not have leading or trailing spaces +`, + }, + { + code: ` +describe("foo foe fum ", function () {}) +`, + output: ` +describe("foo foe fum", function () {}) +`, + snapshot: ` +describe("foo foe fum ", function () {}) + ~~~~~~~~~~~~~~ + Should not have leading or trailing spaces +`, + }, + { + code: ` +it.skip(" foo", function () {}) +`, + output: ` +it.skip("foo", function () {}) +`, + snapshot: ` +it.skip(" foo", function () {}) + ~~~~~~ + Should not have leading or trailing spaces +`, + }, + { + code: ` +fit("foo ", function () {}) +`, + output: ` +fit("foo", function () {}) +`, + snapshot: ` +fit("foo ", function () {}) + ~~~~~~ + Should not have leading or trailing spaces +`, + }, + { + code: ` +it.skip("foo ", function () {}) +`, + output: ` +it.skip("foo", function () {}) +`, + snapshot: ` +it.skip("foo ", function () {}) + ~~~~~~ + Should not have leading or trailing spaces +`, + }, + ], + valid: [ + `it()`, + `it.concurrent()`, + `describe()`, + `it.each()()`, + `describe("foo", function () {})`, + `fdescribe("foo", function () {})`, + `xdescribe("foo", function () {})`, + `it("foo", function () {})`, + `it.concurrent("foo", function () {})`, + `fit("foo", function () {})`, + `fit.concurrent("foo", function () {})`, + `xit("foo", function () {})`, + `test("foo", function () {})`, + `test.concurrent("foo", function () {})`, + `xtest("foo", function () {})`, + `xtest(\`foo\`, function () {})`, + `someFn("foo", function () {})`, + ` + import { test } from 'vitest'; + + export const myTest = test.extend({ + archive: [] + })`, + { + code: ` + import { test } from 'vitest'; + + const it = test.extend({}) + it('passes', () => {}) + `, + name: "does not error when using test.extend", + }, + { + code: `import { it } from 'vitest' + + const test = it.extend({ + fixture: [ + async ({}, use) => { + setup() + await use() + teardown() + }, + { auto: true } + ], + }) + + test('passes', () => {}) + `, + name: "does not error when using it.extend", + }, + ], +}); diff --git a/packages/vitest/src/rules/titleValidity.ts b/packages/vitest/src/rules/titleValidity.ts new file mode 100644 index 0000000000..30435e3eba --- /dev/null +++ b/packages/vitest/src/rules/titleValidity.ts @@ -0,0 +1,344 @@ +import type ts from "typescript"; +import { SyntaxKind, TypeFlags } from "typescript"; +import { z } from "zod/v4"; + +import { + getTSNodeRange, + isStaticString, + typescriptLanguage, + type AST, + type StaticString, +} from "@flint.fyi/typescript-language"; + +import { ruleCreator } from "../ruleCreator.ts"; +import { parseVitestFunctionCall } from "../utils/parseVitestFunctionCall.ts"; + +const matcherSchema = z.union([ + z.string(), + z.tuple([z.string()]), + z.tuple([z.string(), z.string()]), +]); + +const matchersSchema = z.union([ + matcherSchema, + z.object({ + describe: matcherSchema.optional(), + it: matcherSchema.optional(), + test: matcherSchema.optional(), + }), +]); + +const options = { + allowArguments: z + .boolean() + .default(false) + .describe( + "Whether to allow identifiers and other dynamic values as title.", + ), + disallowedWords: z + .array(z.string()) + .default([]) + .describe("Words that are not allowed to appear in title."), + ignoreTypeOfDescribeName: z + .boolean() + .default(false) + .describe("Whether to skip checking the type of `describe()` title."), + mustMatch: matchersSchema + .optional() + .describe( + "Regular expressions that title must match, optionally with a custom message.", + ), + mustNotMatch: matchersSchema + .optional() + .describe( + "Regular expressions that title must not match, optionally with a custom message.", + ), +}; + +type Matchers = z.infer; +type Options = z.infer>; +type TitleGroup = "describe" | "it" | "test"; + +function containsStaticString(node: AST.BinaryExpression): boolean { + if (isStaticString(node.right)) { + return true; + } + + if (node.left.kind === SyntaxKind.BinaryExpression) { + return containsStaticString(node.left); + } + + return isStaticString(node.left); +} + +function findMatcher(matchers: Matchers | undefined, group: TitleGroup) { + if (matchers === undefined) { + return undefined; + } + + const matcher = + typeof matchers === "string" || Array.isArray(matchers) + ? matchers + : matchers[group]; + if (matcher === undefined) { + return undefined; + } + + const [pattern, message] = + typeof matcher === "string" ? ([matcher] as const) : matcher; + + return { message, pattern: new RegExp(pattern, "u") }; +} + +function getTitleGroup(name: string | undefined) { + switch (name) { + case "describe": + case "xdescribe": + return "describe"; + + case "fit": + case "it": + case "xit": + return "it"; + + case "test": + case "xtest": + return "test"; + } +} + +function isClassOrFunctionType(type: ts.Type) { + return ( + !!type.getCallSignatures().length || !!type.getConstructSignatures().length + ); +} + +export default ruleCreator.createRule(typescriptLanguage, { + about: { + description: + "Reports `describe()`, `it()`, and `test()` calls with invalid titles.", + id: "titleValidity", + presets: ["logical", "logicalStrict"], + }, + messages: { + accidentalSpace: { + primary: "Should not have leading or trailing spaces", + secondary: [ + "Vitest joins nested titles with spaces and matches `--testNamePattern` against the joined name.", + "Whitespace at the edges of a title is invisible in reports but still changes what those patterns match.", + ], + suggestions: [ + "Remove the whitespace at the start and end of this title.", + ], + }, + disallowedWord: { + primary: "`{{ word }}` is not allowed in test title.", + secondary: [ + "This word is listed in the `disallowedWords` option for this rule.", + ], + suggestions: ["Rewrite this title without the word `{{ word }}`."], + }, + duplicatePrefix: { + primary: "Should not have duplicate prefix", + secondary: [ + "The `{{ functionName }}()` call already indicates what kind of block this is.", + "Repeating it at the start of the title makes reported names longer without adding information.", + ], + suggestions: ["Remove the `{{ functionName }}` prefix from this title."], + }, + emptyTitle: { + primary: "`{{ functionName }}` should not have an empty title.", + secondary: [ + "Vitest identifies blocks by their titles in reports and in `--testNamePattern` matching.", + "An empty title leaves this block identifiable only by its position in the file.", + ], + suggestions: [ + "Add a title describing what this `{{ functionName }}()` covers.", + ], + }, + mustMatch: { + primary: "`{{ functionName }}` should match {{ pattern }}.", + secondary: [ + "Titles for `{{ functionName }}` are required to match the `mustMatch` option for this rule.", + ], + suggestions: ["Rewrite this title so it matches {{ pattern }}."], + }, + mustMatchCustom: { + primary: "`{{ functionName }}` should match {{ pattern }}. {{ message }}", + secondary: [ + "Titles for `{{ functionName }}` are required to match the `mustMatch` option for this rule.", + ], + suggestions: ["Rewrite this title so it matches {{ pattern }}."], + }, + mustNotMatch: { + primary: "`{{ functionName }}` should not match {{ pattern }}.", + secondary: [ + "Titles for `{{ functionName }}` are required not to match the `mustNotMatch` option for this rule.", + ], + suggestions: ["Rewrite this title so it does not match {{ pattern }}."], + }, + mustNotMatchCustom: { + primary: + "`{{ functionName }}` should not match {{ pattern }}. {{ message }}", + secondary: [ + "Titles for `{{ functionName }}` are required not to match the `mustNotMatch` option for this rule.", + ], + suggestions: ["Rewrite this title so it does not match {{ pattern }}."], + }, + titleMustBeString: { + primary: "Test title must be a string, a function or class name.", + secondary: [ + "Vitest converts other values to strings, turning objects into titles such as `[object Object]`.", + "Titles that don't read as text make reports and `--testNamePattern` filters harder to use.", + ], + suggestions: [ + "Change this title to a string.", + "Pass a function or class whose name describes what's under test.", + ], + }, + }, + options, + setup(context) { + function checkMatchers( + options: Options, + group: TitleGroup, + title: string, + range: ReturnType, + ) { + const mustNotMatch = findMatcher(options.mustNotMatch, group); + if (mustNotMatch?.pattern.test(title)) { + context.report({ + data: { + functionName: group, + message: mustNotMatch.message ?? "", + pattern: String(mustNotMatch.pattern), + }, + message: mustNotMatch.message ? "mustNotMatchCustom" : "mustNotMatch", + range, + }); + return; + } + + const mustMatch = findMatcher(options.mustMatch, group); + if (mustMatch && !mustMatch.pattern.test(title)) { + context.report({ + data: { + functionName: group, + message: mustMatch.message ?? "", + pattern: String(mustMatch.pattern), + }, + message: mustMatch.message ? "mustMatchCustom" : "mustMatch", + range, + }); + } + } + + function checkTitle( + argument: StaticString, + group: TitleGroup, + options: Options, + sourceFile: AST.SourceFile, + ) { + const range = getTSNodeRange(argument, sourceFile); + const title = argument.text; + + if (!title) { + context.report({ + data: { functionName: group }, + message: "emptyTitle", + range, + }); + return; + } + + if (options.disallowedWords.length) { + const disallowedMatch = new RegExp( + `\\b(${options.disallowedWords.join("|")})\\b`, + "iu", + ).exec(title); + + if (disallowedMatch) { + context.report({ + data: { word: disallowedMatch[0] }, + message: "disallowedWord", + range, + }); + return; + } + } + + const text = argument.getText(sourceFile); + + if (title.trim() !== title) { + const trimmed = text + .replace(/^(['"`])\s+/u, "$1") + .replace(/\s+(['"`])$/u, "$1"); + + context.report({ + fix: trimmed === text ? undefined : [{ range, text: trimmed }], + message: "accidentalSpace", + range, + }); + } + + if (title.split(" ")[0]?.toLowerCase() === group) { + context.report({ + data: { functionName: group }, + fix: [{ range, text: text.replace(/^(['"`]).+? /u, "$1") }], + message: "duplicatePrefix", + range, + }); + } + + checkMatchers(options, group, title, range); + } + + return { + visitors: { + CallExpression: (node, { options, sourceFile, typeChecker }) => { + const group = getTitleGroup(parseVitestFunctionCall(node)?.name); + if (!group) { + return; + } + + const [argument] = node.arguments; + if (!argument) { + return; + } + + const type = typeChecker.getTypeAtLocation(argument); + if (isClassOrFunctionType(type)) { + return; + } + + if ( + options.allowArguments && + argument.kind === SyntaxKind.Identifier + ) { + return; + } + + if (isStaticString(argument)) { + checkTitle(argument, group, options, sourceFile); + return; + } + + if ( + (argument.kind === SyntaxKind.BinaryExpression && + containsStaticString(argument)) || + (type.flags & TypeFlags.StringLike) !== 0 + ) { + return; + } + + if (!(options.ignoreTypeOfDescribeName && group === "describe")) { + context.report({ + message: "titleMustBeString", + range: getTSNodeRange(argument, sourceFile), + }); + } + }, + }, + }; + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 391eb99e59..6e75be0470 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1735,6 +1735,9 @@ importers: typescript: specifier: ^6.0.0 version: 6.0.3 + zod: + specifier: ^4.3.6 + version: 4.4.3 devDependencies: '@flint.fyi/build': specifier: workspace:^