From 4b725b7e6c6550ac237e079542fc1d9182071f51 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Sun, 26 Sep 2021 11:24:57 +1300 Subject: [PATCH 1/7] feat(valid-title): support custom matcher messages --- src/rules/__tests__/valid-title.test.ts | 91 ++++++++++++++ src/rules/valid-title.ts | 150 ++++++++++++++++++++---- 2 files changed, 217 insertions(+), 24 deletions(-) diff --git a/src/rules/__tests__/valid-title.test.ts b/src/rules/__tests__/valid-title.test.ts index c0e30d2b7..4852fc0b6 100644 --- a/src/rules/__tests__/valid-title.test.ts +++ b/src/rules/__tests__/valid-title.test.ts @@ -230,6 +230,44 @@ ruleTester.run('mustMatch & mustNotMatch options', rule, { }); }); `, + options: [ + { + mustNotMatch: { + describe: [ + /(?:#(?!unit|e2e))\w+/u.source, + 'Please include "#unit" or "#e2e" in describe titles', + ], + }, + mustMatch: { describe: /^[^#]+$|(?:#(?:unit|e2e))/u.source }, + }, + ], + errors: [ + { + messageId: 'mustNotMatchCustom', + data: { + jestFunctionName: 'describe', + pattern: /(?:#(?!unit|e2e))\w+/u, + message: 'Please include "#unit" or "#e2e" in describe titles', + }, + column: 12, + line: 8, + }, + ], + }, + { + code: dedent` + describe('things to test', () => { + describe('unit tests #unit', () => { + it('is true', () => { + expect(true).toBe(true); + }); + }); + + describe('e2e tests #e4e', () => { + it('is another test #e2e #jest4life', () => {}); + }); + }); + `, options: [ { mustNotMatch: { describe: /(?:#(?!unit|e2e))\w+/u.source }, @@ -248,6 +286,59 @@ ruleTester.run('mustMatch & mustNotMatch options', rule, { }, ], }, + { + code: dedent` + describe('things to test', () => { + describe('unit tests #unit', () => { + it('is true #jest4life', () => { + expect(true).toBe(true); + }); + }); + + describe('e2e tests #e4e', () => { + it('is another test #e2e #jest4life', () => {}); + }); + }); + `, + options: [ + { + mustNotMatch: { + describe: [ + /(?:#(?!unit|e2e))\w+/u.source, + 'Please include "#unit" or "#e2e" in describe titles', + ], + }, + mustMatch: { + it: [ + /^[^#]+$|(?:#(?:unit|e2e))/u.source, + 'Please include "#unit" or "#e2e" in it titles', + ], + }, + }, + ], + errors: [ + { + messageId: 'mustMatchCustom', + data: { + jestFunctionName: 'it', + pattern: /^[^#]+$|(?:#(?:unit|e2e))/u, + message: 'Please include "#unit" or "#e2e" in it titles', + }, + column: 8, + line: 3, + }, + { + messageId: 'mustNotMatchCustom', + data: { + jestFunctionName: 'describe', + pattern: /(?:#(?!unit|e2e))\w+/u, + message: 'Please include "#unit" or "#e2e" in describe titles', + }, + column: 12, + line: 8, + }, + ], + }, { code: 'test("the correct way to properly handle all things", () => {});', options: [{ mustMatch: /#(?:unit|integration|e2e)/u.source }], diff --git a/src/rules/valid-title.ts b/src/rules/valid-title.ts index 6902f1262..39e3ce785 100644 --- a/src/rules/valid-title.ts +++ b/src/rules/valid-title.ts @@ -36,33 +36,53 @@ const quoteStringValue = (node: StringNode): string => ? `\`${node.quasis[0].value.raw}\`` : node.raw; +const compileMatcherPattern = ( + matcherMaybeWithMessage: MatcherAndMessage | string, +): CompiledMatcherAndMessage => { + const [matcher, message] = Array.isArray(matcherMaybeWithMessage) + ? matcherMaybeWithMessage + : [matcherMaybeWithMessage]; + + return [new RegExp(matcher, 'u'), message]; +}; + const compileMatcherPatterns = ( - matchers: Partial> | string, -): Record & Record => { + matchers: Partial> | string, +): Record & + Record => { if (typeof matchers === 'string') { - const matcher = new RegExp(matchers, 'u'); + const compiledMatcher = compileMatcherPattern(matchers); return { - describe: matcher, - test: matcher, - it: matcher, + describe: compiledMatcher, + test: compiledMatcher, + it: compiledMatcher, }; } return { - describe: matchers.describe ? new RegExp(matchers.describe, 'u') : null, - test: matchers.test ? new RegExp(matchers.test, 'u') : null, - it: matchers.it ? new RegExp(matchers.it, 'u') : null, + describe: matchers.describe + ? compileMatcherPattern(matchers.describe) + : null, + test: matchers.test ? compileMatcherPattern(matchers.test) : null, + it: matchers.it ? compileMatcherPattern(matchers.it) : null, }; }; +type CompiledMatcherAndMessage = [matcher: RegExp, message?: string]; +type MatcherAndMessage = [matcher: string, message?: string]; + type MatcherGroups = 'describe' | 'test' | 'it'; interface Options { ignoreTypeOfDescribeName?: boolean; disallowedWords?: string[]; - mustNotMatch?: Partial> | string; - mustMatch?: Partial> | string; + mustNotMatch?: + | Partial> + | string; + mustMatch?: + | Partial> + | string; } type MessageIds = @@ -72,7 +92,9 @@ type MessageIds = | 'accidentalSpace' | 'disallowedWord' | 'mustNotMatch' - | 'mustMatch'; + | 'mustMatch' + | 'mustNotMatchCustom' + | 'mustMatchCustom'; export default createRule<[Options], MessageIds>({ name: __filename, @@ -90,6 +112,8 @@ export default createRule<[Options], MessageIds>({ disallowedWord: '"{{ word }}" is not allowed in test titles.', mustNotMatch: '{{ jestFunctionName }} should not match {{ pattern }}', mustMatch: '{{ jestFunctionName }} should match {{ pattern }}', + mustNotMatchCustom: '{{ message }}', + mustMatchCustom: '{{ message }}', }, type: 'suggestion', schema: [ @@ -110,9 +134,42 @@ export default createRule<[Options], MessageIds>({ { type: 'object', properties: { - describe: { type: 'string' }, - test: { type: 'string' }, - it: { type: 'string' }, + describe: { + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { type: 'string' }, + minItems: 1, + maxItems: 2, + additionalItems: false, + }, + ], + }, + test: { + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { type: 'string' }, + minItems: 1, + maxItems: 2, + additionalItems: false, + }, + ], + }, + it: { + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { type: 'string' }, + minItems: 1, + maxItems: 2, + additionalItems: false, + }, + ], + }, }, additionalProperties: false, }, @@ -124,9 +181,42 @@ export default createRule<[Options], MessageIds>({ { type: 'object', properties: { - describe: { type: 'string' }, - test: { type: 'string' }, - it: { type: 'string' }, + describe: { + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { type: 'string' }, + minItems: 1, + maxItems: 2, + additionalItems: false, + }, + ], + }, + test: { + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { type: 'string' }, + minItems: 1, + maxItems: 2, + additionalItems: false, + }, + ], + }, + it: { + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { type: 'string' }, + minItems: 1, + maxItems: 2, + additionalItems: false, + }, + ], + }, }, additionalProperties: false, }, @@ -254,28 +344,40 @@ export default createRule<[Options], MessageIds>({ const [jestFunctionName] = nodeName.split('.'); - const mustNotMatchPattern = mustNotMatchPatterns[jestFunctionName]; + const [mustNotMatchPattern, mustNotMatchMessage] = + mustNotMatchPatterns[jestFunctionName] ?? []; if (mustNotMatchPattern) { if (mustNotMatchPattern.test(title)) { context.report({ - messageId: 'mustNotMatch', + messageId: mustNotMatchMessage + ? 'mustNotMatchCustom' + : 'mustNotMatch', node: argument, - data: { jestFunctionName, pattern: mustNotMatchPattern }, + data: { + jestFunctionName, + pattern: mustNotMatchPattern, + message: mustNotMatchMessage, + }, }); return; } } - const mustMatchPattern = mustMatchPatterns[jestFunctionName]; + const [mustMatchPattern, mustMatchMessage] = + mustMatchPatterns[jestFunctionName] ?? []; if (mustMatchPattern) { if (!mustMatchPattern.test(title)) { context.report({ - messageId: 'mustMatch', + messageId: mustMatchMessage ? 'mustMatchCustom' : 'mustMatch', node: argument, - data: { jestFunctionName, pattern: mustMatchPattern }, + data: { + jestFunctionName, + pattern: mustMatchPattern, + message: mustMatchMessage, + }, }); return; From be2142e02612e64b6ea99b9a74d0b296185163c4 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Sun, 26 Sep 2021 11:31:45 +1300 Subject: [PATCH 2/7] feat(valid-title): support custom matcher messages for single matcher --- src/rules/__tests__/valid-title.test.ts | 49 +++++++++++++++++++++++++ src/rules/valid-title.ts | 23 +++++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/rules/__tests__/valid-title.test.ts b/src/rules/__tests__/valid-title.test.ts index 4852fc0b6..a1db9f505 100644 --- a/src/rules/__tests__/valid-title.test.ts +++ b/src/rules/__tests__/valid-title.test.ts @@ -198,6 +198,55 @@ ruleTester.run('mustMatch & mustNotMatch options', rule, { }); }); `, + options: [ + { + mustNotMatch: [ + /(?:#(?!unit|e2e))\w+/u.source, + 'Please include "#unit" or "#e2e" in titles', + ], + mustMatch: [ + /^[^#]+$|(?:#(?:unit|e2e))/u.source, + 'Please include "#unit" or "#e2e" in titles', + ], + }, + ], + errors: [ + { + messageId: 'mustNotMatchCustom', + data: { + jestFunctionName: 'describe', + pattern: /(?:#(?!unit|e2e))\w+/u, + message: 'Please include "#unit" or "#e2e" in titles', + }, + column: 12, + line: 8, + }, + { + messageId: 'mustNotMatchCustom', + data: { + jestFunctionName: 'it', + pattern: /(?:#(?!unit|e2e))\w+/u, + message: 'Please include "#unit" or "#e2e" in titles', + }, + column: 8, + line: 9, + }, + ], + }, + { + code: dedent` + describe('things to test', () => { + describe('unit tests #unit', () => { + it('is true', () => { + expect(true).toBe(true); + }); + }); + + describe('e2e tests #e4e', () => { + it('is another test #e2e #jest4life', () => {}); + }); + }); + `, options: [ { mustNotMatch: { describe: /(?:#(?!unit|e2e))\w+/u.source }, diff --git a/src/rules/valid-title.ts b/src/rules/valid-title.ts index 39e3ce785..5d27129e3 100644 --- a/src/rules/valid-title.ts +++ b/src/rules/valid-title.ts @@ -47,10 +47,13 @@ const compileMatcherPattern = ( }; const compileMatcherPatterns = ( - matchers: Partial> | string, + matchers: + | Partial> + | MatcherAndMessage + | string, ): Record & Record => { - if (typeof matchers === 'string') { + if (typeof matchers === 'string' || Array.isArray(matchers)) { const compiledMatcher = compileMatcherPattern(matchers); return { @@ -79,9 +82,11 @@ interface Options { disallowedWords?: string[]; mustNotMatch?: | Partial> + | MatcherAndMessage | string; mustMatch?: | Partial> + | MatcherAndMessage | string; } @@ -131,6 +136,13 @@ export default createRule<[Options], MessageIds>({ mustNotMatch: { oneOf: [ { type: 'string' }, + { + type: 'array', + items: { type: 'string' }, + minItems: 1, + maxItems: 2, + additionalItems: false, + }, { type: 'object', properties: { @@ -178,6 +190,13 @@ export default createRule<[Options], MessageIds>({ mustMatch: { oneOf: [ { type: 'string' }, + { + type: 'array', + items: { type: 'string' }, + minItems: 1, + maxItems: 2, + additionalItems: false, + }, { type: 'object', properties: { From 63acba35eff15871bd23bba45ff9ce1d96ec2f72 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Sun, 26 Sep 2021 11:34:02 +1300 Subject: [PATCH 3/7] refactor(valid-title): simplify options schema --- src/rules/valid-title.ts | 104 +++++++++++---------------------------- 1 file changed, 28 insertions(+), 76 deletions(-) diff --git a/src/rules/valid-title.ts b/src/rules/valid-title.ts index 5d27129e3..7436777d9 100644 --- a/src/rules/valid-title.ts +++ b/src/rules/valid-title.ts @@ -145,45 +145,21 @@ export default createRule<[Options], MessageIds>({ }, { type: 'object', - properties: { - describe: { - oneOf: [ - { type: 'string' }, - { - type: 'array', - items: { type: 'string' }, - minItems: 1, - maxItems: 2, - additionalItems: false, - }, - ], - }, - test: { - oneOf: [ - { type: 'string' }, - { - type: 'array', - items: { type: 'string' }, - minItems: 1, - maxItems: 2, - additionalItems: false, - }, - ], - }, - it: { - oneOf: [ - { type: 'string' }, - { - type: 'array', - items: { type: 'string' }, - minItems: 1, - maxItems: 2, - additionalItems: false, - }, - ], - }, + propertyNames: { + enum: ['describe', 'test', 'it'], + }, + additionalProperties: { + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { type: 'string' }, + minItems: 1, + maxItems: 2, + additionalItems: false, + }, + ], }, - additionalProperties: false, }, ], }, @@ -199,45 +175,21 @@ export default createRule<[Options], MessageIds>({ }, { type: 'object', - properties: { - describe: { - oneOf: [ - { type: 'string' }, - { - type: 'array', - items: { type: 'string' }, - minItems: 1, - maxItems: 2, - additionalItems: false, - }, - ], - }, - test: { - oneOf: [ - { type: 'string' }, - { - type: 'array', - items: { type: 'string' }, - minItems: 1, - maxItems: 2, - additionalItems: false, - }, - ], - }, - it: { - oneOf: [ - { type: 'string' }, - { - type: 'array', - items: { type: 'string' }, - minItems: 1, - maxItems: 2, - additionalItems: false, - }, - ], - }, + propertyNames: { + enum: ['describe', 'test', 'it'], + }, + additionalProperties: { + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { type: 'string' }, + minItems: 1, + maxItems: 2, + additionalItems: false, + }, + ], }, - additionalProperties: false, }, ], }, From b1ea9b34bcfa5c8ae089ad233a5b993868d38fc4 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Sun, 26 Sep 2021 11:54:35 +1300 Subject: [PATCH 4/7] refactor(valid-title): simplify options schema even more For some reason using `patternProperties` seems to make `undefined` an invalid value for the two properties, but not too fussed over this because that was only there for coverage in the first place. --- src/rules/__tests__/valid-title.test.ts | 2 +- src/rules/valid-title.ts | 34 +++---------------------- 2 files changed, 4 insertions(+), 32 deletions(-) diff --git a/src/rules/__tests__/valid-title.test.ts b/src/rules/__tests__/valid-title.test.ts index a1db9f505..a57895dd5 100644 --- a/src/rules/__tests__/valid-title.test.ts +++ b/src/rules/__tests__/valid-title.test.ts @@ -107,7 +107,7 @@ ruleTester.run('mustMatch & mustNotMatch options', rule, { 'test("that all is as it should be", () => {});', { code: 'it("correctly sets the value", () => {});', - options: [{ mustMatch: undefined }], + options: [{ mustMatch: {} }], }, { code: 'it("correctly sets the value", () => {});', diff --git a/src/rules/valid-title.ts b/src/rules/valid-title.ts index 7436777d9..2269bedaf 100644 --- a/src/rules/valid-title.ts +++ b/src/rules/valid-title.ts @@ -133,37 +133,9 @@ export default createRule<[Options], MessageIds>({ type: 'array', items: { type: 'string' }, }, - mustNotMatch: { - oneOf: [ - { type: 'string' }, - { - type: 'array', - items: { type: 'string' }, - minItems: 1, - maxItems: 2, - additionalItems: false, - }, - { - type: 'object', - propertyNames: { - enum: ['describe', 'test', 'it'], - }, - additionalProperties: { - oneOf: [ - { type: 'string' }, - { - type: 'array', - items: { type: 'string' }, - minItems: 1, - maxItems: 2, - additionalItems: false, - }, - ], - }, - }, - ], - }, - mustMatch: { + }, + patternProperties: { + [/^must(?:Not)?Match$/u.source]: { oneOf: [ { type: 'string' }, { From 01b915d7ff710c8cccebd95c8c6b1426345dbc69 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Sun, 26 Sep 2021 11:55:33 +1300 Subject: [PATCH 5/7] refactor(valid-title): simplify options schema one last time --- src/rules/valid-title.ts | 32 ++++++++++++-------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/src/rules/valid-title.ts b/src/rules/valid-title.ts index 2269bedaf..de2f00e77 100644 --- a/src/rules/valid-title.ts +++ b/src/rules/valid-title.ts @@ -1,5 +1,6 @@ import { AST_NODE_TYPES, + JSONSchema, TSESTree, } from '@typescript-eslint/experimental-utils'; import { @@ -75,6 +76,14 @@ const compileMatcherPatterns = ( type CompiledMatcherAndMessage = [matcher: RegExp, message?: string]; type MatcherAndMessage = [matcher: string, message?: string]; +const MatcherAndMessageSchema: JSONSchema.JSONSchema7 = { + type: 'array', + items: { type: 'string' }, + minItems: 1, + maxItems: 2, + additionalItems: false, +} as const; + type MatcherGroups = 'describe' | 'test' | 'it'; interface Options { @@ -138,29 +147,12 @@ export default createRule<[Options], MessageIds>({ [/^must(?:Not)?Match$/u.source]: { oneOf: [ { type: 'string' }, - { - type: 'array', - items: { type: 'string' }, - minItems: 1, - maxItems: 2, - additionalItems: false, - }, + MatcherAndMessageSchema, { type: 'object', - propertyNames: { - enum: ['describe', 'test', 'it'], - }, + propertyNames: { enum: ['describe', 'test', 'it'] }, additionalProperties: { - oneOf: [ - { type: 'string' }, - { - type: 'array', - items: { type: 'string' }, - minItems: 1, - maxItems: 2, - additionalItems: false, - }, - ], + oneOf: [{ type: 'string' }, MatcherAndMessageSchema], }, }, ], From 2a78a6d8c3d8855688385ae357a587c1ea39ed03 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Sun, 26 Sep 2021 11:56:03 +1300 Subject: [PATCH 6/7] test(valid-title): make a few adjustments to cover off different configs --- src/rules/__tests__/valid-title.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/rules/__tests__/valid-title.test.ts b/src/rules/__tests__/valid-title.test.ts index a57895dd5..68f9013c4 100644 --- a/src/rules/__tests__/valid-title.test.ts +++ b/src/rules/__tests__/valid-title.test.ts @@ -113,6 +113,10 @@ ruleTester.run('mustMatch & mustNotMatch options', rule, { code: 'it("correctly sets the value", () => {});', options: [{ mustMatch: / /u.source }], }, + { + code: 'it("correctly sets the value", () => {});', + options: [{ mustMatch: [/ /u.source] }], + }, { code: 'it("correctly sets the value #unit", () => {});', options: [{ mustMatch: /#(?:unit|integration|e2e)/u.source }], @@ -249,7 +253,7 @@ ruleTester.run('mustMatch & mustNotMatch options', rule, { `, options: [ { - mustNotMatch: { describe: /(?:#(?!unit|e2e))\w+/u.source }, + mustNotMatch: { describe: [/(?:#(?!unit|e2e))\w+/u.source] }, mustMatch: { describe: /^[^#]+$|(?:#(?:unit|e2e))/u.source }, }, ], From 4b8f70fd32b500e4cd15ed09d43e829242469dec Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Sun, 26 Sep 2021 12:19:10 +1300 Subject: [PATCH 7/7] docs(valid-title): explain how to use optional matcher messages --- docs/rules/valid-title.md | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/docs/rules/valid-title.md b/docs/rules/valid-title.md index a5edda67e..ab1ae76ad 100644 --- a/docs/rules/valid-title.md +++ b/docs/rules/valid-title.md @@ -198,8 +198,9 @@ describe('the proper way to handle things', () => {}); Defaults: `{}` Allows enforcing that titles must match or must not match a given Regular -Expression. An object can be provided to apply different Regular Expressions to -specific Jest test function groups (`describe`, `test`, and `it`). +Expression, with an optional message. An object can be provided to apply +different Regular Expressions (with optional messages) to specific Jest test +function groups (`describe`, `test`, and `it`). Examples of **incorrect** code when using `mustMatch`: @@ -226,3 +227,30 @@ describe('the tests that will be run', () => {}); test('that the stuff works', () => {}); xtest('that errors that thrown have messages', () => {}); ``` + +Optionally you can provide a custom message to show for a particular matcher by +using a tuple at any level where you can provide a matcher: + +```js +const prefixes = ['when', 'with', 'without', 'if', 'unless', 'for']; +const prefixesList = prefixes.join(' - \n'); + +module.exports = { + rules: { + 'jest/valid-title': [ + 'error', + { + mustNotMatch: ['\\.$', 'Titles should not end with a full-stop'], + mustMatch: { + describe: [ + new RegExp(`^(?:[A-Z]|\\b(${prefixes.join('|')})\\b`, 'u').source, + `Describe titles should either start with a capital letter or one of the following prefixes: ${prefixesList}`, + ], + test: [/[^A-Z]/u.source], + it: /[^A-Z]/u.source, + }, + }, + ], + }, +}; +```