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 25e95fef3ea3..280d3fd67f8e 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,16 +206,18 @@ const test = [ }, { code: wrap`'for (const x of y) {}'`, - output: ` -ruleTester.run({ - valid: [ - { - code: \`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', @@ -225,16 +227,18 @@ ruleTester.run({ { code: wrap`'for (const x of \`asdf\`) {}'`, // make sure it escapes the backticks - output: ` -ruleTester.run({ - valid: [ - { - code: \`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', @@ -254,15 +258,7 @@ ruleTester.run({ }, { code: wrap`\`const a = '1'\``, - output: ` -ruleTester.run({ - valid: [ - { - code: "const a = '1'", - }, - ], -}); - `, + output: [wrap`"const a = '1'"`, wrap`"const a = '1';"`], errors: [ { messageId: 'singleLineQuotes', @@ -271,15 +267,7 @@ ruleTester.run({ }, { code: wrap`\`const a = "1";\``, - output: ` -ruleTester.run({ - valid: [ - { - code: 'const a = "1";', - }, - ], -}); - `, + output: [wrap`'const a = "1";'`, wrap`"const a = '1';"`], errors: [ { messageId: 'singleLineQuotes', @@ -290,17 +278,14 @@ ruleTester.run({ { code: wrap`\`const a = "1"; ${PARENT_INDENT}\``, - output: ` -ruleTester.run({ - valid: [ - { - code: \` + output: [ + wrap`\` const a = "1"; - \`, - }, - ], -}); - `, +${PARENT_INDENT}\``, + wrap`\` +const a = '1'; +${PARENT_INDENT}\``, + ], errors: [ { messageId: 'templateLiteralEmptyEnds', @@ -310,17 +295,17 @@ const a = "1"; { code: wrap`\` ${CODE_INDENT}const a = "1";\``, - output: ` -ruleTester.run({ - valid: [ - { - code: \` - const a = "1"; -\`, - }, - ], -}); - `, + 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', @@ -330,18 +315,20 @@ ruleTester.run({ { code: wrap`\`const a = "1"; ${CODE_INDENT}const b = "2";\``, - output: ` -ruleTester.run({ - valid: [ - { - code: \` + output: [ + wrap`\` const a = "1"; - const b = "2"; -\`, - }, - ], -}); - `, +${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', @@ -353,17 +340,14 @@ const a = "1"; code: wrap`\` ${CODE_INDENT}const a = "1"; \``, - output: ` -ruleTester.run({ - valid: [ - { - code: \` - const a = "1"; - \`, - }, - ], -}); - `, + output: [ + wrap`\` +${CODE_INDENT}const a = "1"; +${PARENT_INDENT}\``, + wrap`\` +${CODE_INDENT}const a = '1'; +${PARENT_INDENT}\``, + ], errors: [ { messageId: 'templateLiteralLastLineIndent', @@ -374,17 +358,14 @@ ruleTester.run({ code: wrap`\` ${CODE_INDENT}const a = "1"; \``, - output: ` -ruleTester.run({ - valid: [ - { - code: \` - const a = "1"; - \`, - }, - ], -}); - `, + output: [ + wrap`\` +${CODE_INDENT}const a = "1"; +${PARENT_INDENT}\``, + wrap`\` +${CODE_INDENT}const a = '1'; +${PARENT_INDENT}\``, + ], errors: [ { messageId: 'templateLiteralLastLineIndent', @@ -555,7 +536,8 @@ ruleTester.run({ ], }); `, - output: ` + output: [ + ` ruleTester.run({ valid: [ { @@ -589,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/rules/no-useless-template-expression.test.ts b/packages/eslint-plugin/tests/rules/no-useless-template-expression.test.ts index 628057ac7d17..2e9c35178a48 100644 --- a/packages/eslint-plugin/tests/rules/no-useless-template-expression.test.ts +++ b/packages/eslint-plugin/tests/rules/no-useless-template-expression.test.ts @@ -409,11 +409,16 @@ declare const nested: string, interpolation: string; \`le\${ \`ss\` }\` }\`; `, - output: ` + output: [ + ` \`use\${ \`less\` }\`; `, + ` +\`useless\`; + `, + ], errors: [ { messageId: 'noUselessTemplateExpression', 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 06eacb1e31e6..4c02f042b986 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: 'noUselessTemplateExpression', diff --git a/packages/rule-tester/src/RuleTester.ts b/packages/rule-tester/src/RuleTester.ts index 7ebe8df5a87b..de19117fcbce 100644 --- a/packages/rule-tester/src/RuleTester.ts +++ b/packages/rule-tester/src/RuleTester.ts @@ -489,7 +489,7 @@ export class RuleTester extends TestFramework { item: InvalidTestCase | ValidTestCase, ): { messages: Linter.LintMessage[]; - output: string; + outputs: string[]; beforeAST: TSESTree.Program; afterAST: TSESTree.Program; config: RuleTesterConfig; @@ -498,7 +498,6 @@ export class RuleTester extends TestFramework { let config: TesterConfigWithDefaults = merge({}, this.#testerConfig); let code; let filename; - let output; let beforeAST: TSESTree.Program; let afterAST: TSESTree.Program; @@ -621,29 +620,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( @@ -652,24 +669,22 @@ 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, + config, + filename, + 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!, // is definitely assigned within the `rule-tester/validate-ast` rule // eslint-disable-next-line @typescript-eslint/no-non-null-assertion afterAST: cloneDeeplyExcludesParent(afterAST!), - config, - filename, }; } @@ -1101,20 +1116,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/tests/RuleTester.test.ts b/packages/rule-tester/tests/RuleTester.test.ts index 2db16e5d4e9b..2550d0239ab6 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,33 +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, - config: { parser: '' }, - }; -}); - -beforeEach(() => { - jest.clearAllMocks(); -}); const NOOP_RULE: RuleModule<'error'> = { meta: { @@ -134,13 +102,45 @@ 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, + config: { parser: '' }, + }; }); -} -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({ @@ -172,7 +172,6 @@ describe('RuleTester', () => { { code: 'type-aware parser options should override the constructor config', parserOptions: { - projectService: false, project: 'tsconfig.test-specific.json', tsconfigRootDir: '/set/in/the/test/', }, @@ -222,7 +221,6 @@ describe('RuleTester', () => { "parserOptions": { "disallowAutomaticSingleRunInference": true, "project": "tsconfig.test-specific.json", - "projectService": false, "tsconfigRootDir": "/set/in/the/test/", }, }, @@ -947,3 +945,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.'); + }); + }); +});