From 121f4c0e7252def95d917e4734e933e53e29d501 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Sun, 9 Oct 2022 18:04:38 +1030 Subject: [PATCH] feat(utils): add dependency constraint filtering for `RuleTester` (#5750) * feat(utils): add dependency constraint filtering for eslint plugin RuleTester * address feedback and add more tests * Update packages/utils/src/eslint-utils/rule-tester/dependencyConstraints.ts Co-authored-by: Josh Goldberg * Update packages/utils/src/eslint-utils/rule-tester/RuleTester.ts Co-authored-by: Josh Goldberg Co-authored-by: Josh Goldberg --- .../eslint-plugin-internal/jest.config.js | 1 + packages/eslint-plugin/jest.config.js | 1 + packages/utils/package.json | 4 +- packages/utils/src/eslint-utils/RuleTester.ts | 130 ---- .../eslint-utils/batchedSingleLineTests.ts | 5 +- packages/utils/src/eslint-utils/index.ts | 2 +- .../eslint-utils/rule-tester/RuleTester.ts | 250 ++++++ .../rule-tester/dependencyConstraints.ts | 63 ++ packages/utils/src/ts-eslint/RuleTester.ts | 18 +- .../rule-tester/RuleTester.test.ts | 719 ++++++++++++++++++ 10 files changed, 1058 insertions(+), 135 deletions(-) delete mode 100644 packages/utils/src/eslint-utils/RuleTester.ts create mode 100644 packages/utils/src/eslint-utils/rule-tester/RuleTester.ts create mode 100644 packages/utils/src/eslint-utils/rule-tester/dependencyConstraints.ts create mode 100644 packages/utils/tests/eslint-utils/rule-tester/RuleTester.test.ts diff --git a/packages/eslint-plugin-internal/jest.config.js b/packages/eslint-plugin-internal/jest.config.js index 910991b20cf..72e29aa600b 100644 --- a/packages/eslint-plugin-internal/jest.config.js +++ b/packages/eslint-plugin-internal/jest.config.js @@ -4,4 +4,5 @@ /** @type {import('@jest/types').Config.InitialOptions} */ module.exports = { ...require('../../jest.config.base.js'), + coveragePathIgnorePatterns: ['src/index.ts$', 'src/configs/.*.ts$'], }; diff --git a/packages/eslint-plugin/jest.config.js b/packages/eslint-plugin/jest.config.js index 910991b20cf..72e29aa600b 100644 --- a/packages/eslint-plugin/jest.config.js +++ b/packages/eslint-plugin/jest.config.js @@ -4,4 +4,5 @@ /** @type {import('@jest/types').Config.InitialOptions} */ module.exports = { ...require('../../jest.config.base.js'), + coveragePathIgnorePatterns: ['src/index.ts$', 'src/configs/.*.ts$'], }; diff --git a/packages/utils/package.json b/packages/utils/package.json index 15be0d28738..78452a7fed2 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -44,12 +44,14 @@ "@typescript-eslint/types": "5.39.0", "@typescript-eslint/typescript-estree": "5.39.0", "eslint-scope": "^5.1.1", - "eslint-utils": "^3.0.0" + "eslint-utils": "^3.0.0", + "semver": "^7.3.7" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "devDependencies": { + "@typescript-eslint/parser": "5.39.0", "typescript": "*" }, "funding": { diff --git a/packages/utils/src/eslint-utils/RuleTester.ts b/packages/utils/src/eslint-utils/RuleTester.ts deleted file mode 100644 index d0317837211..00000000000 --- a/packages/utils/src/eslint-utils/RuleTester.ts +++ /dev/null @@ -1,130 +0,0 @@ -import * as path from 'path'; - -import * as TSESLint from '../ts-eslint'; - -const parser = '@typescript-eslint/parser'; - -type RuleTesterConfig = Omit & { - parser: typeof parser; -}; - -class RuleTester extends TSESLint.RuleTester { - readonly #options: RuleTesterConfig; - - // as of eslint 6 you have to provide an absolute path to the parser - // but that's not as clean to type, this saves us trying to manually enforce - // that contributors require.resolve everything - constructor(options: RuleTesterConfig) { - super({ - ...options, - parserOptions: { - ...options.parserOptions, - warnOnUnsupportedTypeScriptVersion: - options.parserOptions?.warnOnUnsupportedTypeScriptVersion ?? false, - }, - parser: require.resolve(options.parser), - }); - - this.#options = options; - - // 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 - if (typeof afterAll !== 'undefined') { - afterAll(() => { - try { - // instead of creating a hard dependency, just use a soft require - // a bit weird, but if they're using this tooling, it'll be installed - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - require(parser).clearCaches(); - } catch { - // ignored - } - }); - } - } - private getFilename(options?: TSESLint.ParserOptions): string { - if (options) { - const filename = `file.ts${options.ecmaFeatures?.jsx ? 'x' : ''}`; - if (options.project) { - return path.join( - options.tsconfigRootDir != null - ? options.tsconfigRootDir - : process.cwd(), - filename, - ); - } - - return filename; - } else if (this.#options.parserOptions) { - return this.getFilename(this.#options.parserOptions); - } - - return 'file.ts'; - } - - // as of eslint 6 you have to provide an absolute path to the parser - // If you don't do that at the test level, the test will fail somewhat cryptically... - // This is a lot more explicit - run>( - name: string, - rule: TSESLint.RuleModule, - testsReadonly: TSESLint.RunTests, - ): void { - const errorMessage = `Do not set the parser at the test level unless you want to use a parser other than ${parser}`; - - const tests = { ...testsReadonly }; - - // standardize the valid tests as objects - tests.valid = tests.valid.map(test => { - if (typeof test === 'string') { - return { - code: test, - }; - } - return test; - }); - - tests.valid = tests.valid.map(test => { - if (typeof test !== 'string') { - if (test.parser === parser) { - throw new Error(errorMessage); - } - if (!test.filename) { - return { - ...test, - filename: this.getFilename(test.parserOptions), - }; - } - } - return test; - }); - tests.invalid = tests.invalid.map(test => { - if (test.parser === parser) { - throw new Error(errorMessage); - } - if (!test.filename) { - return { - ...test, - filename: this.getFilename(test.parserOptions), - }; - } - return test; - }); - - super.run(name, rule, tests); - } -} - -/** - * Simple no-op tag to mark code samples as "should not format with prettier" - * for the internal/plugin-test-formatting lint rule - */ -function noFormat(strings: TemplateStringsArray, ...keys: string[]): string { - const lastIndex = strings.length - 1; - return ( - strings.slice(0, lastIndex).reduce((p, s, i) => p + s + keys[i], '') + - strings[lastIndex] - ); -} - -export { noFormat, RuleTester }; diff --git a/packages/utils/src/eslint-utils/batchedSingleLineTests.ts b/packages/utils/src/eslint-utils/batchedSingleLineTests.ts index 64a29fd5109..fcd15210e48 100644 --- a/packages/utils/src/eslint-utils/batchedSingleLineTests.ts +++ b/packages/utils/src/eslint-utils/batchedSingleLineTests.ts @@ -1,4 +1,7 @@ -import type { InvalidTestCase, ValidTestCase } from '../ts-eslint'; +import type { + InvalidTestCase, + ValidTestCase, +} from '../eslint-utils/rule-tester/RuleTester'; /** * Converts a batch of single line tests into a number of separate test cases. diff --git a/packages/utils/src/eslint-utils/index.ts b/packages/utils/src/eslint-utils/index.ts index fc8e410428e..a3d0cb75245 100644 --- a/packages/utils/src/eslint-utils/index.ts +++ b/packages/utils/src/eslint-utils/index.ts @@ -3,6 +3,6 @@ export * from './batchedSingleLineTests'; export * from './getParserServices'; export * from './InferTypesFromRule'; export * from './RuleCreator'; -export * from './RuleTester'; +export * from './rule-tester/RuleTester'; export * from './deepMerge'; export * from './nullThrows'; diff --git a/packages/utils/src/eslint-utils/rule-tester/RuleTester.ts b/packages/utils/src/eslint-utils/rule-tester/RuleTester.ts new file mode 100644 index 00000000000..e81d23d0206 --- /dev/null +++ b/packages/utils/src/eslint-utils/rule-tester/RuleTester.ts @@ -0,0 +1,250 @@ +import type * as TSESLintParserType from '@typescript-eslint/parser'; +import { version as eslintVersion } from 'eslint/package.json'; +import * as path from 'path'; +import * as semver from 'semver'; + +import * as TSESLint from '../../ts-eslint'; +import { deepMerge } from '../deepMerge'; +import type { DependencyConstraint } from './dependencyConstraints'; +import { satisfiesAllDependencyConstraints } from './dependencyConstraints'; + +const TS_ESLINT_PARSER = '@typescript-eslint/parser'; +const ERROR_MESSAGE = `Do not set the parser at the test level unless you want to use a parser other than ${TS_ESLINT_PARSER}`; + +type RuleTesterConfig = Omit & { + parser: typeof TS_ESLINT_PARSER; +}; + +interface InvalidTestCase< + TMessageIds extends string, + TOptions extends Readonly, +> extends TSESLint.InvalidTestCase { + dependencyConstraints?: DependencyConstraint; +} +interface ValidTestCase> + extends TSESLint.ValidTestCase { + dependencyConstraints?: DependencyConstraint; +} +interface RunTests< + TMessageIds extends string, + TOptions extends Readonly, +> { + // RuleTester.run also accepts strings for valid cases + readonly valid: readonly (ValidTestCase | string)[]; + readonly invalid: readonly InvalidTestCase[]; +} + +type AfterAll = (fn: () => void) => void; + +class RuleTester extends TSESLint.RuleTester { + readonly #baseOptions: RuleTesterConfig; + + static #afterAll: AfterAll; + /** + * If you supply a value to this property, the rule tester will call this instead of using the version defined on + * the global namespace. + */ + static get afterAll(): AfterAll { + return ( + this.#afterAll || + (typeof afterAll === 'function' ? afterAll : (): void => {}) + ); + } + static set afterAll(value) { + this.#afterAll = value; + } + + constructor(baseOptions: RuleTesterConfig) { + super({ + ...baseOptions, + parserOptions: { + ...baseOptions.parserOptions, + warnOnUnsupportedTypeScriptVersion: + baseOptions.parserOptions?.warnOnUnsupportedTypeScriptVersion ?? + false, + }, + // as of eslint 6 you have to provide an absolute path to the parser + // but that's not as clean to type, this saves us trying to manually enforce + // that contributors require.resolve everything + parser: require.resolve(baseOptions.parser), + }); + + this.#baseOptions = baseOptions; + + // 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 + // the cast here is due to https://github.com/microsoft/TypeScript/issues/3841 + (this.constructor as typeof RuleTester).afterAll(() => { + try { + // instead of creating a hard dependency, just use a soft require + // a bit weird, but if they're using this tooling, it'll be installed + const parser = require(TS_ESLINT_PARSER) as typeof TSESLintParserType; + parser.clearCaches(); + } catch { + // ignored on purpose + } + }); + } + private getFilename(testOptions?: TSESLint.ParserOptions): string { + const resolvedOptions = deepMerge( + this.#baseOptions.parserOptions, + testOptions, + ) as TSESLint.ParserOptions; + const filename = `file.ts${resolvedOptions.ecmaFeatures?.jsx ? 'x' : ''}`; + if (resolvedOptions.project) { + return path.join( + resolvedOptions.tsconfigRootDir != null + ? resolvedOptions.tsconfigRootDir + : process.cwd(), + filename, + ); + } + return filename; + } + + // as of eslint 6 you have to provide an absolute path to the parser + // If you don't do that at the test level, the test will fail somewhat cryptically... + // This is a lot more explicit + run>( + name: string, + rule: TSESLint.RuleModule, + testsReadonly: RunTests, + ): void { + const tests = { + // standardize the valid tests as objects + valid: testsReadonly.valid.map(test => { + if (typeof test === 'string') { + return { + code: test, + }; + } + return test; + }), + invalid: testsReadonly.invalid, + }; + + // convenience iterator to make it easy to loop all tests without a concat + const allTestsIterator = { + *[Symbol.iterator](): Generator, void, unknown> { + for (const test of tests.valid) { + yield test; + } + for (const test of tests.invalid) { + yield test; + } + }, + }; + + /* + Automatically add a filename to the tests to enable type-aware tests to "just work". + This saves users having to verbosely and manually add the filename to every + single test case. + Hugely helps with the string-based valid test cases as it means they don't + need to be made objects! + */ + const addFilename = < + T extends + | ValidTestCase + | InvalidTestCase, + >( + test: T, + ): T => { + if (test.parser === TS_ESLINT_PARSER) { + throw new Error(ERROR_MESSAGE); + } + if (!test.filename) { + return { + ...test, + filename: this.getFilename(test.parserOptions), + }; + } + return test; + }; + tests.valid = tests.valid.map(addFilename); + tests.invalid = tests.invalid.map(addFilename); + + const hasOnly = ((): boolean => { + for (const test of allTestsIterator) { + if (test.only) { + return true; + } + } + return false; + })(); + // if there is an `only: true` - don't apply constraints - assume that + // we are in "local development" mode rather than "CI validation" mode + if (!hasOnly) { + /* + Automatically skip tests that don't satisfy the dependency constraints. + */ + const hasConstraints = ((): boolean => { + for (const test of allTestsIterator) { + if ( + test.dependencyConstraints && + Object.keys(test.dependencyConstraints).length > 0 + ) { + return true; + } + } + return false; + })(); + if (hasConstraints) { + // The `only: boolean` test property was only added in ESLint v7.29.0. + if (semver.satisfies(eslintVersion, '>=7.29.0')) { + /* + Mark all satisfactory tests as `only: true`, and all other tests as + `only: false`. + When multiple tests are marked as "only", test frameworks like jest and mocha + will run all of those tests and will just skip the other tests. + + We do this instead of just omitting the tests entirely because it gives the + test framework the opportunity to log the test as skipped rather than the test + just disappearing. + */ + const maybeMarkAsOnly = < + T extends + | ValidTestCase + | InvalidTestCase, + >( + test: T, + ): T => { + return { + ...test, + only: satisfiesAllDependencyConstraints( + test.dependencyConstraints, + ), + }; + }; + + tests.valid = tests.valid.map(maybeMarkAsOnly); + tests.invalid = tests.invalid.map(maybeMarkAsOnly); + } else { + // On older versions we just fallback to raw array filtering like SAVAGES + tests.valid = tests.valid.filter(test => + satisfiesAllDependencyConstraints(test.dependencyConstraints), + ); + tests.invalid = tests.invalid.filter(test => + satisfiesAllDependencyConstraints(test.dependencyConstraints), + ); + } + } + } + + super.run(name, rule, tests); + } +} + +/** + * Simple no-op tag to mark code samples as "should not format with prettier" + * for the internal/plugin-test-formatting lint rule + */ +function noFormat(strings: TemplateStringsArray, ...keys: string[]): string { + const lastIndex = strings.length - 1; + return ( + strings.slice(0, lastIndex).reduce((p, s, i) => p + s + keys[i], '') + + strings[lastIndex] + ); +} + +export { noFormat, RuleTester }; +export type { InvalidTestCase, ValidTestCase, RunTests }; diff --git a/packages/utils/src/eslint-utils/rule-tester/dependencyConstraints.ts b/packages/utils/src/eslint-utils/rule-tester/dependencyConstraints.ts new file mode 100644 index 00000000000..0bc1f5fc5ce --- /dev/null +++ b/packages/utils/src/eslint-utils/rule-tester/dependencyConstraints.ts @@ -0,0 +1,63 @@ +import * as semver from 'semver'; + +interface SemverVersionConstraint { + readonly range: string; + readonly options?: Parameters[2]; +} +type AtLeastVersionConstraint = + | `${number}` + | `${number}.${number}` + | `${number}.${number}.${number}` + | `${number}.${number}.${number}-${string}`; +type VersionConstraint = SemverVersionConstraint | AtLeastVersionConstraint; +interface DependencyConstraint { + /** + * Passing a string for the value is shorthand for a '>=' constraint + */ + readonly [packageName: string]: VersionConstraint; +} + +const BASE_SATISFIES_OPTIONS: semver.RangeOptions = { + includePrerelease: true, +}; + +function satisfiesDependencyConstraint( + packageName: string, + constraintIn: DependencyConstraint[string], +): boolean { + const constraint: SemverVersionConstraint = + typeof constraintIn === 'string' + ? { + range: `>=${constraintIn}`, + } + : constraintIn; + + return semver.satisfies( + (require(`${packageName}/package.json`) as { version: string }).version, + constraint.range, + typeof constraint.options === 'object' + ? { ...BASE_SATISFIES_OPTIONS, ...constraint.options } + : constraint.options, + ); +} + +function satisfiesAllDependencyConstraints( + dependencyConstraints: DependencyConstraint | undefined, +): boolean { + if (dependencyConstraints == null) { + return true; + } + + for (const [packageName, constraint] of Object.entries( + dependencyConstraints, + )) { + if (!satisfiesDependencyConstraint(packageName, constraint)) { + return false; + } + } + + return true; +} + +export { satisfiesAllDependencyConstraints }; +export type { DependencyConstraint }; diff --git a/packages/utils/src/ts-eslint/RuleTester.ts b/packages/utils/src/ts-eslint/RuleTester.ts index 15f3bd7e1f5..7002fc538cd 100644 --- a/packages/utils/src/ts-eslint/RuleTester.ts +++ b/packages/utils/src/ts-eslint/RuleTester.ts @@ -125,6 +125,11 @@ interface TestCaseError { // readonly message?: string | RegExp; } +type RuleTesterTestFrameworkFunction = ( + text: string, + callback: () => void, +) => void; + interface RunTests< TMessageIds extends string, TOptions extends Readonly, @@ -164,7 +169,15 @@ declare class RuleTesterBase { * @param text a string describing the rule * @param callback the test callback */ - static describe?: (text: string, callback: () => void) => void; + static describe?: RuleTesterTestFrameworkFunction; + + /** + * If you supply a value to this property, the rule tester will call this instead of using the version defined on + * the global namespace. + * @param text a string describing the test case + * @param callback the test callback + */ + static it?: RuleTesterTestFrameworkFunction; /** * If you supply a value to this property, the rule tester will call this instead of using the version defined on @@ -172,7 +185,7 @@ declare class RuleTesterBase { * @param text a string describing the test case * @param callback the test callback */ - static it?: (text: string, callback: () => void) => void; + static itOnly?: RuleTesterTestFrameworkFunction; /** * Define a rule for one particular run of tests. @@ -194,6 +207,7 @@ export { SuggestionOutput, RuleTester, RuleTesterConfig, + RuleTesterTestFrameworkFunction, RunTests, TestCaseError, ValidTestCase, diff --git a/packages/utils/tests/eslint-utils/rule-tester/RuleTester.test.ts b/packages/utils/tests/eslint-utils/rule-tester/RuleTester.test.ts new file mode 100644 index 00000000000..cab7666196a --- /dev/null +++ b/packages/utils/tests/eslint-utils/rule-tester/RuleTester.test.ts @@ -0,0 +1,719 @@ +import * as parser from '@typescript-eslint/parser'; +import eslintPackageJson from 'eslint/package.json'; + +import * as dependencyConstraintsModule from '../../../src/eslint-utils/rule-tester/dependencyConstraints'; +import { RuleTester } from '../../../src/eslint-utils/rule-tester/RuleTester'; +import type { RuleModule } from '../../../src/ts-eslint'; +import { RuleTester as BaseRuleTester } from '../../../src/ts-eslint'; + +// we can't spy on the exports of an ES module - so we instead have to mock the entire module +jest.mock('../../../src/eslint-utils/rule-tester/dependencyConstraints', () => { + const dependencyConstraints = jest.requireActual< + typeof dependencyConstraintsModule + >('../../../src/eslint-utils/rule-tester/dependencyConstraints'); + + return { + ...dependencyConstraints, + __esModule: true, + satisfiesAllDependencyConstraints: jest.fn( + dependencyConstraints.satisfiesAllDependencyConstraints, + ), + }; +}); +const satisfiesAllDependencyConstraintsMock = jest.mocked( + dependencyConstraintsModule.satisfiesAllDependencyConstraints, +); + +jest.mock( + 'totally-real-dependency/package.json', + () => ({ + version: '10.0.0', + }), + { + // this is not a real module that will exist + virtual: true, + }, +); +jest.mock( + 'totally-real-dependency-prerelease/package.json', + () => ({ + version: '10.0.0-rc.1', + }), + { + // this is not a real module that will exist + virtual: true, + }, +); + +// mock the eslint package.json so that we can manipulate the version in the test +jest.mock('eslint/package.json', () => { + return { + // make the version a getter so we can spy on it and change the return value + get version(): string { + // fix the version so the test is stable on older ESLint versions + return '8.0.0'; + }, + }; +}); + +jest.mock('@typescript-eslint/parser', () => { + return { + __esModule: true, + clearCaches: jest.fn(), + }; +}); + +/* eslint-disable jest/prefer-spy-on -- + need to specifically assign to the properties or else it will use the + global value */ +RuleTester.afterAll = jest.fn(); +RuleTester.describe = jest.fn(); +RuleTester.it = jest.fn(); +RuleTester.itOnly = jest.fn(); +/* eslint-enable jest/prefer-spy-on */ + +const mockedAfterAll = jest.mocked(RuleTester.afterAll); +const _mockedDescribe = jest.mocked(RuleTester.describe); +const _mockedIt = jest.mocked(RuleTester.it); +const _mockedItOnly = jest.mocked(RuleTester.itOnly); +const runSpy = jest.spyOn(BaseRuleTester.prototype, 'run'); +const mockedParserClearCaches = jest.mocked(parser.clearCaches); + +const eslintVersionSpy = jest.spyOn(eslintPackageJson, 'version', 'get'); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +const NOOP_RULE: RuleModule<'error', []> = { + meta: { + messages: { + error: 'error', + }, + type: 'problem', + schema: {}, + }, + defaultOptions: [], + create() { + return {}; + }, +}; + +describe('RuleTester', () => { + it('automatically sets the filename for tests', () => { + const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: '/some/path/that/totally/exists/', + }, + }); + + ruleTester.run('my-rule', NOOP_RULE, { + invalid: [ + { + code: 'invalid tests should work as well', + errors: [], + }, + ], + valid: [ + 'string based valid test', + { + code: 'object based valid test', + }, + { + code: "explicit filename shouldn't be overwritten", + filename: '/set/in/the/test.ts', + }, + { + code: 'jsx should have the correct filename', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + { + code: 'type-aware parser options should override the constructor config', + parserOptions: { + project: 'tsconfig.test-specific.json', + tsconfigRootDir: '/set/in/the/test/', + }, + }, + ], + }); + + expect(runSpy.mock.lastCall?.[2]).toMatchInlineSnapshot(` + { + "invalid": [ + { + "code": "invalid tests should work as well", + "errors": [], + "filename": "/some/path/that/totally/exists/file.ts", + }, + ], + "valid": [ + { + "code": "string based valid test", + "filename": "/some/path/that/totally/exists/file.ts", + }, + { + "code": "object based valid test", + "filename": "/some/path/that/totally/exists/file.ts", + }, + { + "code": "explicit filename shouldn't be overwritten", + "filename": "/set/in/the/test.ts", + }, + { + "code": "jsx should have the correct filename", + "filename": "/some/path/that/totally/exists/file.tsx", + "parserOptions": { + "ecmaFeatures": { + "jsx": true, + }, + }, + }, + { + "code": "type-aware parser options should override the constructor config", + "filename": "/set/in/the/test/file.ts", + "parserOptions": { + "project": "tsconfig.test-specific.json", + "tsconfigRootDir": "/set/in/the/test/", + }, + }, + ], + } + `); + }); + + it('schedules the parser caches to be cleared afterAll', () => { + // it should schedule the afterAll + expect(mockedAfterAll).toHaveBeenCalledTimes(0); + const _ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: '/some/path/that/totally/exists/', + }, + }); + expect(mockedAfterAll).toHaveBeenCalledTimes(1); + + // the provided callback should clear the caches + const callback = mockedAfterAll.mock.calls[0][0]; + expect(typeof callback).toBe('function'); + expect(mockedParserClearCaches).not.toHaveBeenCalled(); + callback(); + expect(mockedParserClearCaches).toHaveBeenCalledTimes(1); + }); + + it('throws an error if you attempt to set the parser to ts-eslint at the test level', () => { + const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: '/some/path/that/totally/exists/', + }, + }); + + expect(() => + ruleTester.run('my-rule', NOOP_RULE, { + valid: [ + { + code: 'object based valid test', + parser: '@typescript-eslint/parser', + }, + ], + + invalid: [], + }), + ).toThrowErrorMatchingInlineSnapshot( + `"Do not set the parser at the test level unless you want to use a parser other than @typescript-eslint/parser"`, + ); + }); + + describe('checks dependencies as specified', () => { + it('does not check dependencies if there are no dependency constraints', () => { + const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + }); + + ruleTester.run('my-rule', NOOP_RULE, { + valid: [ + 'const x = 1;', + { code: 'const x = 2;' }, + // empty object is ignored + { code: 'const x = 3;', dependencyConstraints: {} }, + ], + invalid: [], + }); + + expect(satisfiesAllDependencyConstraintsMock).not.toHaveBeenCalled(); + }); + + describe('does not check dependencies if is an "only" manually set', () => { + it('in the valid section', () => { + const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + }); + + ruleTester.run('my-rule', NOOP_RULE, { + valid: [ + 'const x = 1;', + { code: 'const x = 2;' }, + { + code: 'const x = 3;', + // eslint-disable-next-line eslint-plugin/no-only-tests -- intentional only for test purposes + only: true, + }, + { + code: 'const x = 4;', + dependencyConstraints: { + 'totally-real-dependency': '999', + }, + }, + ], + invalid: [], + }); + + expect(satisfiesAllDependencyConstraintsMock).not.toHaveBeenCalled(); + }); + + it('in the invalid section', () => { + const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + }); + + ruleTester.run('my-rule', NOOP_RULE, { + valid: [ + 'const x = 1;', + { code: 'const x = 2;' }, + { + code: 'const x = 4;', + dependencyConstraints: { + 'totally-real-dependency': '999', + }, + }, + ], + invalid: [ + { + code: 'const x = 3;', + errors: [], + // eslint-disable-next-line eslint-plugin/no-only-tests -- intentional only for test purposes + only: true, + }, + ], + }); + + expect(satisfiesAllDependencyConstraintsMock).not.toHaveBeenCalled(); + }); + }); + + it('correctly handles string-based at-least', () => { + const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + }); + + ruleTester.run('my-rule', NOOP_RULE, { + invalid: [ + { + code: 'failing - major', + errors: [], + dependencyConstraints: { + 'totally-real-dependency': '999', + }, + }, + { + code: 'failing - major.minor', + errors: [], + dependencyConstraints: { + 'totally-real-dependency': '999.0', + }, + }, + { + code: 'failing - major.minor.patch', + errors: [], + dependencyConstraints: { + 'totally-real-dependency': '999.0.0', + }, + }, + ], + valid: [ + { + code: 'passing - major', + dependencyConstraints: { + 'totally-real-dependency': '10', + }, + }, + { + code: 'passing - major.minor', + dependencyConstraints: { + 'totally-real-dependency': '10.0', + }, + }, + { + code: 'passing - major.minor.patch', + dependencyConstraints: { + 'totally-real-dependency': '10.0.0', + }, + }, + ], + }); + + expect(runSpy.mock.lastCall?.[2]).toMatchInlineSnapshot(` + { + "invalid": [ + { + "code": "failing - major", + "dependencyConstraints": { + "totally-real-dependency": "999", + }, + "errors": [], + "filename": "file.ts", + "only": false, + }, + { + "code": "failing - major.minor", + "dependencyConstraints": { + "totally-real-dependency": "999.0", + }, + "errors": [], + "filename": "file.ts", + "only": false, + }, + { + "code": "failing - major.minor.patch", + "dependencyConstraints": { + "totally-real-dependency": "999.0.0", + }, + "errors": [], + "filename": "file.ts", + "only": false, + }, + ], + "valid": [ + { + "code": "passing - major", + "dependencyConstraints": { + "totally-real-dependency": "10", + }, + "filename": "file.ts", + "only": true, + }, + { + "code": "passing - major.minor", + "dependencyConstraints": { + "totally-real-dependency": "10.0", + }, + "filename": "file.ts", + "only": true, + }, + { + "code": "passing - major.minor.patch", + "dependencyConstraints": { + "totally-real-dependency": "10.0.0", + }, + "filename": "file.ts", + "only": true, + }, + ], + } + `); + }); + + it('correctly handles object-based semver', () => { + const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + }); + + ruleTester.run('my-rule', NOOP_RULE, { + invalid: [ + { + code: 'failing - major', + errors: [], + dependencyConstraints: { + 'totally-real-dependency': { + range: '^999', + }, + }, + }, + { + code: 'failing - major.minor', + errors: [], + dependencyConstraints: { + 'totally-real-dependency': { + range: '>=999.0', + }, + }, + }, + + { + code: 'failing with options', + errors: [], + dependencyConstraints: { + 'totally-real-dependency-prerelease': { + range: '^10', + options: { + includePrerelease: false, + }, + }, + }, + }, + ], + valid: [ + { + code: 'passing - major', + dependencyConstraints: { + 'totally-real-dependency': { + range: '^10', + }, + }, + }, + { + code: 'passing - major.minor', + dependencyConstraints: { + 'totally-real-dependency': { + range: '<999', + }, + }, + }, + ], + }); + + expect(runSpy.mock.lastCall?.[2]).toMatchInlineSnapshot(` + { + "invalid": [ + { + "code": "failing - major", + "dependencyConstraints": { + "totally-real-dependency": { + "range": "^999", + }, + }, + "errors": [], + "filename": "file.ts", + "only": false, + }, + { + "code": "failing - major.minor", + "dependencyConstraints": { + "totally-real-dependency": { + "range": ">=999.0", + }, + }, + "errors": [], + "filename": "file.ts", + "only": false, + }, + { + "code": "failing with options", + "dependencyConstraints": { + "totally-real-dependency-prerelease": { + "options": { + "includePrerelease": false, + }, + "range": "^10", + }, + }, + "errors": [], + "filename": "file.ts", + "only": false, + }, + ], + "valid": [ + { + "code": "passing - major", + "dependencyConstraints": { + "totally-real-dependency": { + "range": "^10", + }, + }, + "filename": "file.ts", + "only": true, + }, + { + "code": "passing - major.minor", + "dependencyConstraints": { + "totally-real-dependency": { + "range": "<999", + }, + }, + "filename": "file.ts", + "only": true, + }, + ], + } + `); + }); + + it('tests without versions should always be run', () => { + const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + }); + + ruleTester.run('my-rule', NOOP_RULE, { + invalid: [ + { + code: 'no constraints is always run', + errors: [], + }, + { + code: 'empty object is always run', + errors: [], + dependencyConstraints: {}, + }, + { + code: 'failing constraint', + errors: [], + dependencyConstraints: { + 'totally-real-dependency': '99999', + }, + }, + ], + valid: [ + 'string based is always run', + { + code: 'no constraints is always run', + }, + { + code: 'empty object is always run', + dependencyConstraints: {}, + }, + { + code: 'passing constraint', + dependencyConstraints: { + 'totally-real-dependency': '10', + }, + }, + ], + }); + + expect(runSpy.mock.lastCall?.[2]).toMatchInlineSnapshot(` + { + "invalid": [ + { + "code": "no constraints is always run", + "errors": [], + "filename": "file.ts", + "only": true, + }, + { + "code": "empty object is always run", + "dependencyConstraints": {}, + "errors": [], + "filename": "file.ts", + "only": true, + }, + { + "code": "failing constraint", + "dependencyConstraints": { + "totally-real-dependency": "99999", + }, + "errors": [], + "filename": "file.ts", + "only": false, + }, + ], + "valid": [ + { + "code": "string based is always run", + "filename": "file.ts", + "only": true, + }, + { + "code": "no constraints is always run", + "filename": "file.ts", + "only": true, + }, + { + "code": "empty object is always run", + "dependencyConstraints": {}, + "filename": "file.ts", + "only": true, + }, + { + "code": "passing constraint", + "dependencyConstraints": { + "totally-real-dependency": "10", + }, + "filename": "file.ts", + "only": true, + }, + ], + } + `); + }); + + it('uses filter instead of "only" for old ESLint versions', () => { + // need it twice because ESLint internally fetches this value once :( + eslintVersionSpy.mockReturnValueOnce('1.0.0'); + eslintVersionSpy.mockReturnValueOnce('1.0.0'); + + const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + }); + + ruleTester.run('my-rule', NOOP_RULE, { + invalid: [ + { + code: 'failing', + errors: [], + dependencyConstraints: { + 'totally-real-dependency': '999', + }, + }, + { + code: 'passing', + errors: [], + dependencyConstraints: { + 'totally-real-dependency': '10', + }, + }, + ], + valid: [ + 'always passing string test', + { + code: 'failing', + dependencyConstraints: { + 'totally-real-dependency': '999', + }, + }, + { + code: 'passing', + dependencyConstraints: { + 'totally-real-dependency': '10', + }, + }, + ], + }); + + expect(runSpy.mock.lastCall?.[2]).toMatchInlineSnapshot(` + { + "invalid": [ + { + "code": "passing", + "dependencyConstraints": { + "totally-real-dependency": "10", + }, + "errors": [], + "filename": "file.ts", + }, + ], + "valid": [ + { + "code": "always passing string test", + "filename": "file.ts", + }, + { + "code": "passing", + "dependencyConstraints": { + "totally-real-dependency": "10", + }, + "filename": "file.ts", + }, + ], + } + `); + }); + }); +});