From 5c80828664f19e3f8892cce042be79360ffc3701 Mon Sep 17 00:00:00 2001 From: auvred <61150013+auvred@users.noreply.github.com> Date: Thu, 16 May 2024 03:20:23 +0300 Subject: [PATCH] feat(rule-tester): support multipass fixes (#8883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(rule-tester): support multipass fixes * docs: mention multi-pass fixes in rule tester docs * Update docs/packages/Rule_Tester.mdx Co-authored-by: Josh Goldberg ✨ * Update packages/rule-tester/src/RuleTester.ts Co-authored-by: Kirk Waiblinger * refactor: break if fixer doesn't change the code --------- Co-authored-by: Josh Goldberg ✨ Co-authored-by: Kirk Waiblinger --- docs/packages/Rule_Tester.mdx | 9 + .../rules/plugin-test-formatting.test.ts | 143 ++++++- packages/eslint-plugin/tests/RuleTester.ts | 6 +- .../no-useless-template-literals.test.ts | 7 +- .../tests/util/getWrappingFixer.test.ts | 66 ++-- packages/rule-tester/src/RuleTester.ts | 110 ++++-- .../rule-tester/src/types/InvalidTestCase.ts | 2 +- .../rule-tester/src/utils/SourceCodeFixer.ts | 12 +- packages/rule-tester/tests/RuleTester.test.ts | 370 ++++++++++++++++-- packages/utils/src/ts-eslint/RuleTester.ts | 2 +- 10 files changed, 603 insertions(+), 124 deletions(-) diff --git a/docs/packages/Rule_Tester.mdx b/docs/packages/Rule_Tester.mdx index 4c62d391c7e..25deac0e38b 100644 --- a/docs/packages/Rule_Tester.mdx +++ b/docs/packages/Rule_Tester.mdx @@ -75,6 +75,15 @@ ruleTester.run('my-rule', rule, { /* ... */ ], }, + // Multi-pass fixes can be tested using the array form of output. + // Note: this is unique to typescript-eslint, and doesn't exist in ESLint core. + { + code: 'const d = 1;', + output: ['const e = 1;', 'const f = 1;'], + errors: [ + /* ... */ + ], + }, // suggestions can be tested via errors { diff --git a/packages/eslint-plugin-internal/tests/rules/plugin-test-formatting.test.ts b/packages/eslint-plugin-internal/tests/rules/plugin-test-formatting.test.ts index 28e567d052b..280d3fd67f8 100644 --- a/packages/eslint-plugin-internal/tests/rules/plugin-test-formatting.test.ts +++ b/packages/eslint-plugin-internal/tests/rules/plugin-test-formatting.test.ts @@ -206,8 +206,18 @@ const test = [ }, { code: wrap`'for (const x of y) {}'`, - output: wrap`\`for (const x of y) { + output: [ + wrap`\`for (const x of y) { }\``, + wrap`\` +for (const x of y) { +} +\``, + wrap`\` +for (const x of y) { +} +${PARENT_INDENT}\``, + ], errors: [ { messageId: 'invalidFormatting', @@ -217,8 +227,18 @@ const test = [ { code: wrap`'for (const x of \`asdf\`) {}'`, // make sure it escapes the backticks - output: wrap`\`for (const x of \\\`asdf\\\`) { + output: [ + wrap`\`for (const x of \\\`asdf\\\`) { }\``, + wrap`\` +for (const x of \\\`asdf\\\`) { +} +\``, + wrap`\` +for (const x of \\\`asdf\\\`) { +} +${PARENT_INDENT}\``, + ], errors: [ { messageId: 'invalidFormatting', @@ -238,7 +258,7 @@ const test = [ }, { code: wrap`\`const a = '1'\``, - output: wrap`"const a = '1'"`, + output: [wrap`"const a = '1'"`, wrap`"const a = '1';"`], errors: [ { messageId: 'singleLineQuotes', @@ -247,7 +267,7 @@ const test = [ }, { code: wrap`\`const a = "1";\``, - output: wrap`'const a = "1";'`, + output: [wrap`'const a = "1";'`, wrap`"const a = '1';"`], errors: [ { messageId: 'singleLineQuotes', @@ -258,9 +278,14 @@ const test = [ { code: wrap`\`const a = "1"; ${PARENT_INDENT}\``, - output: wrap`\` + output: [ + wrap`\` const a = "1"; ${PARENT_INDENT}\``, + wrap`\` +const a = '1'; +${PARENT_INDENT}\``, + ], errors: [ { messageId: 'templateLiteralEmptyEnds', @@ -270,9 +295,17 @@ ${PARENT_INDENT}\``, { code: wrap`\` ${CODE_INDENT}const a = "1";\``, - output: wrap`\` + output: [ + wrap`\` ${CODE_INDENT}const a = "1"; \``, + wrap`\` +${CODE_INDENT}const a = "1"; +${PARENT_INDENT}\``, + wrap`\` +${CODE_INDENT}const a = '1'; +${PARENT_INDENT}\``, + ], errors: [ { messageId: 'templateLiteralEmptyEnds', @@ -282,10 +315,20 @@ ${CODE_INDENT}const a = "1"; { code: wrap`\`const a = "1"; ${CODE_INDENT}const b = "2";\``, - output: wrap`\` + output: [ + wrap`\` const a = "1"; ${CODE_INDENT}const b = "2"; \``, + wrap`\` +const a = "1"; +${CODE_INDENT}const b = "2"; +${PARENT_INDENT}\``, + wrap`\` +const a = '1'; +const b = '2'; +${PARENT_INDENT}\``, + ], errors: [ { messageId: 'templateLiteralEmptyEnds', @@ -297,9 +340,14 @@ ${CODE_INDENT}const b = "2"; code: wrap`\` ${CODE_INDENT}const a = "1"; \``, - output: wrap`\` + output: [ + wrap`\` ${CODE_INDENT}const a = "1"; ${PARENT_INDENT}\``, + wrap`\` +${CODE_INDENT}const a = '1'; +${PARENT_INDENT}\``, + ], errors: [ { messageId: 'templateLiteralLastLineIndent', @@ -310,9 +358,14 @@ ${PARENT_INDENT}\``, code: wrap`\` ${CODE_INDENT}const a = "1"; \``, - output: wrap`\` + output: [ + wrap`\` ${CODE_INDENT}const a = "1"; ${PARENT_INDENT}\``, + wrap`\` +${CODE_INDENT}const a = '1'; +${PARENT_INDENT}\``, + ], errors: [ { messageId: 'templateLiteralLastLineIndent', @@ -483,7 +536,8 @@ ruleTester.run({ ], }); `, - output: ` + output: [ + ` ruleTester.run({ valid: [ { @@ -517,6 +571,75 @@ foo ], }); `, + ` +ruleTester.run({ + valid: [ + { + code: 'foo;', + }, + { + code: \` +foo + \`, + }, + { + code: \` + foo + \`, + }, + ], + invalid: [ + { + code: 'foo;', + }, + { + code: \` +foo + \`, + }, + { + code: \` + foo + \`, + }, + ], +}); + `, + ` +ruleTester.run({ + valid: [ + { + code: 'foo;', + }, + { + code: \` +foo; + \`, + }, + { + code: \` + foo + \`, + }, + ], + invalid: [ + { + code: 'foo;', + }, + { + code: \` +foo; + \`, + }, + { + code: \` + foo + \`, + }, + ], +}); + `, + ], errors: [ { messageId: 'singleLineQuotes', diff --git a/packages/eslint-plugin/tests/RuleTester.ts b/packages/eslint-plugin/tests/RuleTester.ts index 1ad34648a8b..0fce4ae0439 100644 --- a/packages/eslint-plugin/tests/RuleTester.ts +++ b/packages/eslint-plugin/tests/RuleTester.ts @@ -44,7 +44,11 @@ export function batchedSingleLineTests< MessageIds extends string, Options extends readonly unknown[], >( - options: InvalidTestCase | ValidTestCase, + options: + | (Omit, 'output'> & { + output?: string | null; + }) + | ValidTestCase, ): (InvalidTestCase | ValidTestCase)[] { // -- eslint counts lines from 1 const lineOffset = options.code.startsWith('\n') ? 2 : 1; diff --git a/packages/eslint-plugin/tests/rules/no-useless-template-literals.test.ts b/packages/eslint-plugin/tests/rules/no-useless-template-literals.test.ts index d443c4ff729..a0874aae795 100644 --- a/packages/eslint-plugin/tests/rules/no-useless-template-literals.test.ts +++ b/packages/eslint-plugin/tests/rules/no-useless-template-literals.test.ts @@ -409,11 +409,16 @@ declare const nested: string, interpolation: string; \`le\${ \`ss\` }\` }\`; `, - output: ` + output: [ + ` \`use\${ \`less\` }\`; `, + ` +\`useless\`; + `, + ], errors: [ { messageId: 'noUselessTemplateLiteral', diff --git a/packages/eslint-plugin/tests/util/getWrappingFixer.test.ts b/packages/eslint-plugin/tests/util/getWrappingFixer.test.ts index 2e8cc730113..621e8e3b4d5 100644 --- a/packages/eslint-plugin/tests/util/getWrappingFixer.test.ts +++ b/packages/eslint-plugin/tests/util/getWrappingFixer.test.ts @@ -36,7 +36,7 @@ const voidEverythingRule = createRule({ fix: getWrappingFixer({ sourceCode: context.sourceCode, node, - wrap: code => `void ${code}`, + wrap: code => `void ${code.replaceAll('wrap', 'wrapped')}`, }), }); }; @@ -59,103 +59,103 @@ ruleTester.run('getWrappingFixer - voidEverythingRule', voidEverythingRule, { { code: '(function wrapFunction() {})', errors: [{ messageId: 'addVoid' }], - output: '(void (function wrapFunction() {}))', + output: '(void (function wrappedFunction() {}))', }, { code: '(class wrapClass {})', errors: [{ messageId: 'addVoid' }], - output: '(void (class wrapClass {}))', + output: '(void (class wrappedClass {}))', }, // shouldn't add inner parens when not necessary { code: 'wrapMe', errors: [{ messageId: 'addVoid' }], - output: 'void wrapMe', + output: 'void wrappedMe', }, { code: '"wrapMe"', errors: [{ messageId: 'addVoid' }], - output: 'void "wrapMe"', + output: 'void "wrappedMe"', }, { code: '["wrapArray"]', errors: [{ messageId: 'addVoid' }], - output: 'void ["wrapArray"]', + output: 'void ["wrappedArray"]', }, { code: '({ x: "wrapObject" })', errors: [{ messageId: 'addVoid' }], - output: '(void { x: "wrapObject" })', + output: '(void { x: "wrappedObject" })', }, // should add parens when the outer expression might need them { code: '!wrapMe', errors: [{ messageId: 'addVoid' }], - output: '!(void wrapMe)', + output: '!(void wrappedMe)', }, { code: '"wrapMe" + "dontWrap"', errors: [{ messageId: 'addVoid' }], - output: '(void "wrapMe") + "dontWrap"', + output: '(void "wrappedMe") + "dontWrap"', }, { code: 'async () => await wrapMe', errors: [{ messageId: 'addVoid' }], - output: 'async () => await (void wrapMe)', + output: 'async () => await (void wrappedMe)', }, { code: 'wrapMe(arg)', errors: [{ messageId: 'addVoid' }], - output: '(void wrapMe)(arg)', + output: '(void wrappedMe)(arg)', }, { code: 'new wrapMe(arg)', errors: [{ messageId: 'addVoid' }], - output: 'new (void wrapMe)(arg)', + output: 'new (void wrappedMe)(arg)', }, { code: 'wrapMe`arg`', errors: [{ messageId: 'addVoid' }], - output: '(void wrapMe)`arg`', + output: '(void wrappedMe)`arg`', }, { code: 'wrapMe.prop', errors: [{ messageId: 'addVoid' }], - output: '(void wrapMe).prop', + output: '(void wrappedMe).prop', }, // shouldn't add outer parens when not necessary { code: 'obj["wrapMe"]', errors: [{ messageId: 'addVoid' }], - output: 'obj[void "wrapMe"]', + output: 'obj[void "wrappedMe"]', }, { code: 'fn(wrapMe)', errors: [{ messageId: 'addVoid' }], - output: 'fn(void wrapMe)', + output: 'fn(void wrappedMe)', }, { code: 'new Cls(wrapMe)', errors: [{ messageId: 'addVoid' }], - output: 'new Cls(void wrapMe)', + output: 'new Cls(void wrappedMe)', }, { code: '[wrapMe, ...wrapMe]', errors: [{ messageId: 'addVoid' }, { messageId: 'addVoid' }], - output: '[void wrapMe, ...void wrapMe]', + output: '[void wrappedMe, ...void wrappedMe]', }, { code: '`${wrapMe}`', errors: [{ messageId: 'addVoid' }], - output: '`${void wrapMe}`', + output: '`${void wrappedMe}`', }, { code: 'tpl`${wrapMe}`', errors: [{ messageId: 'addVoid' }], - output: 'tpl`${void wrapMe}`', + output: 'tpl`${void wrappedMe}`', }, { code: '({ ["wrapMe"]: wrapMe, ...wrapMe })', @@ -164,22 +164,22 @@ ruleTester.run('getWrappingFixer - voidEverythingRule', voidEverythingRule, { { messageId: 'addVoid' }, { messageId: 'addVoid' }, ], - output: '({ [void "wrapMe"]: void wrapMe, ...void wrapMe })', + output: '({ [void "wrappedMe"]: void wrappedMe, ...void wrappedMe })', }, { code: 'function fn() { return wrapMe }', errors: [{ messageId: 'addVoid' }], - output: 'function fn() { return void wrapMe }', + output: 'function fn() { return void wrappedMe }', }, { code: 'function* fn() { yield wrapMe }', errors: [{ messageId: 'addVoid' }], - output: 'function* fn() { yield void wrapMe }', + output: 'function* fn() { yield void wrappedMe }', }, { code: 'if (wrapMe) {}', errors: [{ messageId: 'addVoid' }], - output: 'if (void wrapMe) {}', + output: 'if (void wrappedMe) {}', }, // should detect parens at the beginning of a line and add a semi @@ -191,7 +191,7 @@ ruleTester.run('getWrappingFixer - voidEverythingRule', voidEverythingRule, { errors: [{ messageId: 'addVoid' }], output: ` "dontWrap" - ;(void "wrapMe") + "!" + ;(void "wrappedMe") + "!" `, }, { @@ -202,7 +202,7 @@ ruleTester.run('getWrappingFixer - voidEverythingRule', voidEverythingRule, { errors: [{ messageId: 'addVoid' }], output: ` dontWrap() - ;(void wrapMe)() + ;(void wrappedMe)() `, }, { @@ -213,7 +213,7 @@ ruleTester.run('getWrappingFixer - voidEverythingRule', voidEverythingRule, { errors: [{ messageId: 'addVoid' }], output: ` dontWrap() - ;(void wrapMe)\`\` + ;(void wrappedMe)\`\` `, }, @@ -226,7 +226,7 @@ ruleTester.run('getWrappingFixer - voidEverythingRule', voidEverythingRule, { errors: [{ messageId: 'addVoid' }], output: ` "dontWrap" - test() ? (void "wrapMe") : "dontWrap" + test() ? (void "wrappedMe") : "dontWrap" `, }, { @@ -237,7 +237,7 @@ ruleTester.run('getWrappingFixer - voidEverythingRule', voidEverythingRule, { errors: [{ messageId: 'addVoid' }], output: ` "dontWrap"; - (void wrapMe) && f() + (void wrappedMe) && f() `, }, { @@ -248,7 +248,7 @@ ruleTester.run('getWrappingFixer - voidEverythingRule', voidEverythingRule, { errors: [{ messageId: 'addVoid' }], output: ` new dontWrap - new (void wrapMe) + new (void wrappedMe) `, }, { @@ -257,7 +257,7 @@ ruleTester.run('getWrappingFixer - voidEverythingRule', voidEverythingRule, { `, errors: [{ messageId: 'addVoid' }], output: ` - (void wrapMe) || f() + (void wrappedMe) || f() `, }, { @@ -266,7 +266,7 @@ ruleTester.run('getWrappingFixer - voidEverythingRule', voidEverythingRule, { `, errors: [{ messageId: 'addVoid' }], output: ` - if (true) (void wrapMe) && f() + if (true) (void wrappedMe) && f() `, }, { @@ -280,7 +280,7 @@ ruleTester.run('getWrappingFixer - voidEverythingRule', voidEverythingRule, { output: ` dontWrap if (true) { - (void wrapMe) ?? f() + (void wrappedMe) ?? f() } `, }, diff --git a/packages/rule-tester/src/RuleTester.ts b/packages/rule-tester/src/RuleTester.ts index d657f47a66e..2e0d834665a 100644 --- a/packages/rule-tester/src/RuleTester.ts +++ b/packages/rule-tester/src/RuleTester.ts @@ -483,14 +483,13 @@ export class RuleTester extends TestFramework { item: InvalidTestCase | ValidTestCase, ): { messages: Linter.LintMessage[]; - output: string; + outputs: string[]; beforeAST: TSESTree.Program; afterAST: TSESTree.Program; } { let config: TesterConfigWithDefaults = merge({}, this.#testerConfig); let code; let filename; - let output; let beforeAST: TSESTree.Program; let afterAST: TSESTree.Program; @@ -612,29 +611,47 @@ export class RuleTester extends TestFramework { // Verify the code. // @ts-expect-error -- we don't define deprecated members on our types const { getComments } = SourceCode.prototype as { getComments: unknown }; - let messages; - - try { - // @ts-expect-error -- we don't define deprecated members on our types - SourceCode.prototype.getComments = getCommentsDeprecation; - messages = this.#linter.verify(code, config, filename); - } finally { - // @ts-expect-error -- we don't define deprecated members on our types - SourceCode.prototype.getComments = getComments; - } - const fatalErrorMessage = messages.find(m => m.fatal); + let initialMessages: Linter.LintMessage[] | null = null; + let messages: Linter.LintMessage[] | null = null; + let fixedResult: SourceCodeFixer.AppliedFixes | null = null; + let passNumber = 0; + const outputs: string[] = []; - assert( - !fatalErrorMessage, - `A fatal parsing error occurred: ${fatalErrorMessage?.message}`, - ); + do { + passNumber++; + + try { + // @ts-expect-error -- we don't define deprecated members on our types + SourceCode.prototype.getComments = getCommentsDeprecation; + messages = this.#linter.verify(code, config, filename); + if (!initialMessages) { + initialMessages = messages; + } + } finally { + // @ts-expect-error -- we don't define deprecated members on our types + SourceCode.prototype.getComments = getComments; + } + if (messages.length === 0) { + break; + } - // Verify if autofix makes a syntax error or not. - if (messages.some(m => m.fix)) { - output = SourceCodeFixer.applyFixes(code, messages).output; + const fatalErrorMessage = messages.find(m => m.fatal); + assert( + !fatalErrorMessage, + `A fatal parsing error occurred: ${fatalErrorMessage?.message}`, + ); + + fixedResult = SourceCodeFixer.applyFixes(code, messages); + if (fixedResult.output === code) { + break; + } + code = fixedResult.output; + outputs.push(code); + + // Verify if autofix makes a syntax error or not. const errorMessageInFix = this.#linter - .verify(output, config, filename) + .verify(fixedResult.output, config, filename) .find(m => m.fatal); assert( @@ -643,16 +660,14 @@ export class RuleTester extends TestFramework { 'A fatal parsing error occurred in autofix.', `Error: ${errorMessageInFix?.message}`, 'Autofix output:', - output, + fixedResult.output, ].join('\n'), ); - } else { - output = code; - } + } while (fixedResult.fixed && passNumber < 10); return { - messages, - output, + messages: initialMessages, + outputs, // is definitely assigned within the `rule-tester/validate-ast` rule // eslint-disable-next-line @typescript-eslint/no-non-null-assertion beforeAST: beforeAST!, @@ -1071,20 +1086,43 @@ export class RuleTester extends TestFramework { if (hasOwnProperty(item, 'output')) { if (item.output == null) { + if (result.outputs.length) { + assert.strictEqual( + result.outputs[0], + item.code, + 'Expected no autofixes to be suggested.', + ); + } + } else if (typeof item.output === 'string') { + assert(result.outputs.length > 0, 'Expected autofix to be suggested.'); assert.strictEqual( - result.output, - item.code, - 'Expected no autofixes to be suggested', + result.outputs[0], + item.output, + 'Output is incorrect.', ); + if (result.outputs.length) { + assert.deepStrictEqual( + result.outputs, + [item.output], + 'Multiple autofixes are required due to overlapping fix ranges - please use the array form of output to declare all of the expected autofix passes.', + ); + } } else { - assert.strictEqual(result.output, item.output, 'Output is incorrect.'); + assert(result.outputs.length > 0, 'Expected autofix to be suggested.'); + assert.deepStrictEqual( + result.outputs, + item.output, + 'Outputs do not match.', + ); } } else { - assert.strictEqual( - result.output, - item.code, - "The rule fixed the code. Please add 'output' property.", - ); + if (result.outputs.length) { + assert.strictEqual( + result.outputs[0], + item.code, + "The rule fixed the code. Please add 'output' property.", + ); + } } assertASTDidntChange(result.beforeAST, result.afterAST); diff --git a/packages/rule-tester/src/types/InvalidTestCase.ts b/packages/rule-tester/src/types/InvalidTestCase.ts index 96754682cc4..c117d9674aa 100644 --- a/packages/rule-tester/src/types/InvalidTestCase.ts +++ b/packages/rule-tester/src/types/InvalidTestCase.ts @@ -72,7 +72,7 @@ export interface InvalidTestCase< /** * The expected code after autofixes are applied. If set to `null`, the test runner will assert that no autofix is suggested. */ - readonly output?: string | null; + readonly output?: string | string[] | null; /** * Constraints that must pass in the current environment for the test to run */ diff --git a/packages/rule-tester/src/utils/SourceCodeFixer.ts b/packages/rule-tester/src/utils/SourceCodeFixer.ts index a1f8fd3cb89..6a108c2e21e 100644 --- a/packages/rule-tester/src/utils/SourceCodeFixer.ts +++ b/packages/rule-tester/src/utils/SourceCodeFixer.ts @@ -29,6 +29,12 @@ function compareMessagesByLocation(a: LintMessage, b: LintMessage): number { return a.line - b.line || a.column - b.column; } +export interface AppliedFixes { + fixed: boolean; + messages: readonly LintMessage[]; + output: string; +} + /** * Applies the fixes specified by the messages to the given text. Tries to be * smart about the fixes and won't apply fixes over the same area in the text. @@ -39,11 +45,7 @@ function compareMessagesByLocation(a: LintMessage, b: LintMessage): number { export function applyFixes( sourceText: string, messages: readonly LintMessage[], -): { - fixed: boolean; - messages: readonly LintMessage[]; - output: string; -} { +): AppliedFixes { // clone the array const remainingMessages: LintMessage[] = []; const fixes: LintMessageWithFix[] = []; diff --git a/packages/rule-tester/tests/RuleTester.test.ts b/packages/rule-tester/tests/RuleTester.test.ts index 5df3c4e68f9..e163508cacb 100644 --- a/packages/rule-tester/tests/RuleTester.test.ts +++ b/packages/rule-tester/tests/RuleTester.test.ts @@ -76,11 +76,6 @@ const mockedDescribeSkip = jest.mocked(RuleTester.describeSkip); const mockedIt = jest.mocked(RuleTester.it); const _mockedItOnly = jest.mocked(RuleTester.itOnly); const _mockedItSkip = jest.mocked(RuleTester.itSkip); -const runRuleForItemSpy = jest.spyOn( - RuleTester.prototype, - // @ts-expect-error -- method is private - 'runRuleForItem', -) as jest.SpiedFunction; const mockedParserClearCaches = jest.mocked(parser.clearCaches); const EMPTY_PROGRAM: TSESTree.Program = { @@ -92,32 +87,6 @@ const EMPTY_PROGRAM: TSESTree.Program = { tokens: [], range: [0, 0], }; -runRuleForItemSpy.mockImplementation((_1, _2, testCase) => { - return { - messages: - 'errors' in testCase - ? [ - { - column: 0, - line: 0, - message: 'error', - messageId: 'error', - nodeType: AST_NODE_TYPES.Program, - ruleId: 'my-rule', - severity: 2, - source: null, - }, - ] - : [], - output: testCase.code, - afterAST: EMPTY_PROGRAM, - beforeAST: EMPTY_PROGRAM, - }; -}); - -beforeEach(() => { - jest.clearAllMocks(); -}); const NOOP_RULE: RuleModule<'error'> = { meta: { @@ -133,13 +102,44 @@ const NOOP_RULE: RuleModule<'error'> = { }, }; -function getTestConfigFromCall(): unknown[] { - return runRuleForItemSpy.mock.calls.map(c => { - return { ...c[2], filename: c[2].filename?.replaceAll('\\', '/') }; +describe('RuleTester', () => { + const runRuleForItemSpy = jest.spyOn( + RuleTester.prototype, + // @ts-expect-error -- method is private + 'runRuleForItem', + ) as jest.SpiedFunction; + beforeEach(() => { + jest.clearAllMocks(); + }); + runRuleForItemSpy.mockImplementation((_1, _2, testCase) => { + return { + messages: + 'errors' in testCase + ? [ + { + column: 0, + line: 0, + message: 'error', + messageId: 'error', + nodeType: AST_NODE_TYPES.Program, + ruleId: 'my-rule', + severity: 2, + source: null, + }, + ] + : [], + outputs: [testCase.code], + afterAST: EMPTY_PROGRAM, + beforeAST: EMPTY_PROGRAM, + }; }); -} -describe('RuleTester', () => { + function getTestConfigFromCall(): unknown[] { + return runRuleForItemSpy.mock.calls.map(c => { + return { ...c[2], filename: c[2].filename?.replaceAll('\\', '/') }; + }); + } + describe('filenames', () => { it('automatically sets the filename for tests', () => { const ruleTester = new RuleTester({ @@ -949,3 +949,301 @@ describe('RuleTester', () => { }); }); }); + +describe('RuleTester - multipass fixer', () => { + beforeAll(() => { + jest.restoreAllMocks(); + }); + + describe('without fixes', () => { + const ruleTester = new RuleTester(); + const rule: RuleModule<'error'> = { + meta: { + messages: { + error: 'error', + }, + type: 'problem', + schema: [], + }, + defaultOptions: [], + create(context) { + return { + 'Identifier[name=foo]'(node): void { + context.report({ + node, + messageId: 'error', + }); + }, + }; + }, + }; + + it('passes with no output', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + valid: [], + invalid: [ + { + code: 'foo', + errors: [{ messageId: 'error' }], + }, + ], + }); + }).not.toThrow(); + }); + + it('passes with null output', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + valid: [], + invalid: [ + { + code: 'foo', + errors: [{ messageId: 'error' }], + }, + ], + }); + }).not.toThrow(); + }); + + it('throws with string output', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + valid: [], + invalid: [ + { + code: 'foo', + output: 'bar', + errors: [{ messageId: 'error' }], + }, + ], + }); + }).toThrow('Expected autofix to be suggested.'); + }); + + it('throws with array output', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + valid: [], + invalid: [ + { + code: 'foo', + output: ['bar', 'baz'], + errors: [{ messageId: 'error' }], + }, + ], + }); + }).toThrow('Expected autofix to be suggested.'); + }); + }); + + describe('with single fix', () => { + const ruleTester = new RuleTester(); + const rule: RuleModule<'error'> = { + meta: { + messages: { + error: 'error', + }, + type: 'problem', + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + return { + 'Identifier[name=foo]'(node): void { + context.report({ + node, + messageId: 'error', + fix: fixer => fixer.replaceText(node, 'bar'), + }); + }, + }; + }, + }; + + it('passes with correct string output', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + valid: [], + invalid: [ + { + code: 'foo', + output: 'bar', + errors: [{ messageId: 'error' }], + }, + ], + }); + }).not.toThrow(); + }); + + it('passes with correct array output', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + valid: [], + invalid: [ + { + code: 'foo', + output: ['bar'], + errors: [{ messageId: 'error' }], + }, + ], + }); + }).not.toThrow(); + }); + + it('throws with no output', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + valid: [], + invalid: [ + { + code: 'foo', + errors: [{ messageId: 'error' }], + }, + ], + }); + }).toThrow("The rule fixed the code. Please add 'output' property."); + }); + + it('throws with null output', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + valid: [], + invalid: [ + { + code: 'foo', + output: null, + errors: [{ messageId: 'error' }], + }, + ], + }); + }).toThrow('Expected no autofixes to be suggested.'); + }); + + it('throws with incorrect array output', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + valid: [], + invalid: [ + { + code: 'foo', + output: ['bar', 'baz'], + errors: [{ messageId: 'error' }], + }, + ], + }); + }).toThrow('Outputs do not match.'); + }); + + it('throws with incorrect string output', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + valid: [], + invalid: [ + { + code: 'foo', + output: 'baz', + errors: [{ messageId: 'error' }], + }, + ], + }); + }).toThrow('Output is incorrect.'); + }); + }); + + describe('with multiple fixes', () => { + const ruleTester = new RuleTester(); + const rule: RuleModule<'error'> = { + meta: { + messages: { + error: 'error', + }, + type: 'problem', + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + return { + 'Identifier[name=foo]'(node): void { + context.report({ + node, + messageId: 'error', + fix: fixer => fixer.replaceText(node, 'bar'), + }); + }, + 'Identifier[name=bar]'(node): void { + context.report({ + node, + messageId: 'error', + fix: fixer => fixer.replaceText(node, 'baz'), + }); + }, + }; + }, + }; + + it('passes with correct array output', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + valid: [], + invalid: [ + { + code: 'foo', + output: ['bar', 'baz'], + errors: [{ messageId: 'error' }], + }, + ], + }); + }).not.toThrow(); + }); + + it('throws with string output', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + valid: [], + invalid: [ + { + code: 'foo', + output: 'bar', + errors: [{ messageId: 'error' }], + }, + ], + }); + }).toThrow( + 'Multiple autofixes are required due to overlapping fix ranges - please use the array form of output to declare all of the expected autofix passes.', + ); + }); + + it('throws with incorrect array output', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + valid: [], + invalid: [ + { + code: 'foo', + output: ['bar'], + errors: [{ messageId: 'error' }], + }, + ], + }); + }).toThrow('Outputs do not match.'); + }); + + it('throws with incorrectly ordered array output', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + valid: [], + invalid: [ + { + code: 'foo', + output: ['baz', 'bar'], + errors: [{ messageId: 'error' }], + }, + ], + }); + }).toThrow('Outputs do not match.'); + }); + }); +}); diff --git a/packages/utils/src/ts-eslint/RuleTester.ts b/packages/utils/src/ts-eslint/RuleTester.ts index 11696716222..0b41f30c357 100644 --- a/packages/utils/src/ts-eslint/RuleTester.ts +++ b/packages/utils/src/ts-eslint/RuleTester.ts @@ -84,7 +84,7 @@ interface InvalidTestCase< /** * The expected code after autofixes are applied. If set to `null`, the test runner will assert that no autofix is suggested. */ - readonly output?: string | null; + readonly output?: string | string[] | null; } interface TestCaseError {