diff --git a/packages/rule-tester/src/RuleTester.ts b/packages/rule-tester/src/RuleTester.ts index 1cbd5e2cd226..725e2a9460bc 100644 --- a/packages/rule-tester/src/RuleTester.ts +++ b/packages/rule-tester/src/RuleTester.ts @@ -166,7 +166,7 @@ function getUnsubstitutedMessagePlaceholders( } export class RuleTester extends TestFramework { - readonly #linter: Linter; + readonly #lintersByBasePath: Map; readonly #rules: Record = {}; readonly #testerConfig: TesterConfigWithDefaults; @@ -184,31 +184,7 @@ export class RuleTester extends TestFramework { rules: { [`${RULE_TESTER_PLUGIN_PREFIX}validate-ast`]: 'error' }, }); - this.#linter = (() => { - const linter = new Linter({ - configType: 'flat', - cwd: this.#testerConfig.languageOptions.parserOptions?.tsconfigRootDir, - }); - - // This nonsense is a workaround for https://github.com/jestjs/jest/issues/14840 - // see also https://github.com/typescript-eslint/typescript-eslint/issues/8942 - // - // For some reason rethrowing exceptions skirts around the circular JSON error. - const oldVerify = linter.verify.bind(linter); - linter.verify = ( - ...args: Parameters - ): ReturnType => { - try { - return oldVerify(...args); - } catch (error) { - throw new Error('Caught an error while linting', { - cause: error, - }); - } - }; - - return linter; - })(); + this.#lintersByBasePath = new Map(); // make sure that the parser doesn't hold onto file handles between tests // on linux (i.e. our CI env), there can be very a limited number of watch handles available @@ -222,6 +198,57 @@ export class RuleTester extends TestFramework { }); } + #getLinterForFilename(filename: string | undefined): Linter { + let basePath: string | undefined = + this.#testerConfig.languageOptions.parserOptions?.tsconfigRootDir; + // For an absolute path (`/foo.ts`), or a path that steps + // up (`../foo.ts`), resolve the path relative to the base + // path (using the current working directory if the parser + // options did not specify a base path) and use the file's + // root as the base path so that the file is under the base + // path. For any other path, which would just be a plain + // file name (`foo.ts`), don't change the base path. + if ( + filename !== undefined && + (filename.startsWith('/') || filename.startsWith('..')) + ) { + basePath = path.parse( + path.resolve(basePath ?? process.cwd(), filename), + ).root; + } + + let linterForBasePath = this.#lintersByBasePath.get(basePath); + if (!linterForBasePath) { + linterForBasePath = (() => { + const linter = new Linter({ + configType: 'flat', + cwd: basePath, + }); + + // This nonsense is a workaround for https://github.com/jestjs/jest/issues/14840 + // see also https://github.com/typescript-eslint/typescript-eslint/issues/8942 + // + // For some reason rethrowing exceptions skirts around the circular JSON error. + const oldVerify = linter.verify.bind(linter); + linter.verify = ( + ...args: Parameters + ): ReturnType => { + try { + return oldVerify(...args); + } catch (error) { + throw new Error('Caught an error while linting', { + cause: error, + }); + } + }; + + return linter; + })(); + this.#lintersByBasePath.set(basePath, linterForBasePath); + } + return linterForBasePath; + } + /** * Set the configuration to use for all future tests */ @@ -738,6 +765,7 @@ export class RuleTester extends TestFramework { let passNumber = 0; const outputs: string[] = []; const configWithoutCustomKeys = omitCustomConfigProperties(config); + const linter = this.#getLinterForFilename(filename); do { passNumber++; @@ -767,15 +795,7 @@ export class RuleTester extends TestFramework { ...configWithoutCustomKeys.linterOptions, }, }); - messages = this.#linter.verify( - code, - // ESLint uses an internal FlatConfigArray that extends @humanwhocodes/config-array. - Object.assign([], { - basePath: filename ? path.parse(filename).root : '', - getConfig: () => actualConfig, - }), - filename, - ); + messages = linter.verify(code, actualConfig, filename); } finally { SourceCode.prototype.applyInlineConfig = applyInlineConfig; SourceCode.prototype.applyLanguageOptions = applyLanguageOptions; @@ -802,7 +822,7 @@ export class RuleTester extends TestFramework { outputs.push(code); // Verify if autofix makes a syntax error or not. - const errorMessageInFix = this.#linter + const errorMessageInFix = linter .verify(fixedResult.output, configWithoutCustomKeys, filename) .find(m => m.fatal); @@ -1255,7 +1275,9 @@ export class RuleTester extends TestFramework { ]).output; // Verify if suggestion fix makes a syntax error or not. - const errorMessageInSuggestion = this.#linter + const errorMessageInSuggestion = this.#getLinterForFilename( + item.filename, + ) .verify( codeWithAppliedSuggestion, omitCustomConfigProperties(result.config), diff --git a/packages/rule-tester/tests/filename.test.ts b/packages/rule-tester/tests/filename.test.ts index 1c68f4ba196d..7068aa7fd3b5 100644 --- a/packages/rule-tester/tests/filename.test.ts +++ b/packages/rule-tester/tests/filename.test.ts @@ -1,8 +1,9 @@ /* eslint-disable perfectionist/sort-objects */ -import { RuleTester } from '@typescript-eslint/rule-tester'; -import { ESLintUtils } from '@typescript-eslint/utils'; +import type { TSESLint } from '@typescript-eslint/utils'; -const ruleTester = new RuleTester(); +import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'; + +import { RuleTester } from '../src/RuleTester'; const rule = ESLintUtils.RuleCreator.withoutDocs({ meta: { @@ -11,33 +12,68 @@ const rule = ESLintUtils.RuleCreator.withoutDocs({ }, messages: { foo: 'It works', + createError: 'Create error', }, schema: [], type: 'problem', + hasSuggestions: true, }, defaultOptions: [], create: context => ({ Program(node): void { - context.report({ node, messageId: 'foo' }); + context.report({ + node, + messageId: 'foo', + suggest: + node.body.length === 1 && + node.body[0].type === AST_NODE_TYPES.EmptyStatement + ? [ + { + messageId: 'createError', + fix(fixer): TSESLint.RuleFix { + return fixer.replaceText(node, '//'); + }, + }, + ] + : [], + }); }, }), }); describe('rule tester filename', () => { - ruleTester.run('absolute path', rule, { + new RuleTester().run('without tsconfigRootDir', rule, { invalid: [ { + name: 'absolute path', code: '_', errors: [{ messageId: 'foo' }], filename: '/an-absolute-path/foo.js', }, + { + name: 'relative path above project', + code: '_', + errors: [{ messageId: 'foo' }], + filename: '../foo.js', + }, ], valid: [], }); - ruleTester.run('relative path', rule, { + new RuleTester({ + languageOptions: { + parserOptions: { tsconfigRootDir: '/some/path/that/totally/exists/' }, + }, + }).run('with tsconfigRootDir', rule, { invalid: [ { + name: 'absolute path', + code: '_', + errors: [{ messageId: 'foo' }], + filename: '/an-absolute-path/foo.js', + }, + { + name: 'relative path above project', code: '_', errors: [{ messageId: 'foo' }], filename: '../foo.js', @@ -46,3 +82,52 @@ describe('rule tester filename', () => { valid: [], }); }); + +describe('rule tester suggestion syntax error checks', () => { + new RuleTester().run('verifies suggestion with absolute path', rule, { + invalid: [ + { + code: ';', + errors: [ + { + messageId: 'foo', + suggestions: [{ messageId: 'createError', output: '//' }], + }, + ], + filename: '/an-absolute-path/foo.js', + }, + ], + valid: [], + }); + + new RuleTester().run('verifies suggestion with relative path', rule, { + invalid: [ + { + code: ';', + errors: [ + { + messageId: 'foo', + suggestions: [{ messageId: 'createError', output: '//' }], + }, + ], + filename: '../foo.js', + }, + ], + valid: [], + }); + + new RuleTester().run('verifies suggestion with no path', rule, { + invalid: [ + { + code: ';', + errors: [ + { + messageId: 'foo', + suggestions: [{ messageId: 'createError', output: '//' }], + }, + ], + }, + ], + valid: [], + }); +});