diff --git a/eslint.config.mjs b/eslint.config.mjs index 41a9e3d5709c..425c0f00bc61 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -36,6 +36,7 @@ const vitestFiles = [ 'packages/integration-tests/tools/pack-packages.ts', 'packages/parser/tests/lib/**/*.test.{ts,tsx,cts,mts}', 'packages/parser/tests/test-utils/**/*.{ts,tsx,cts,mts}', + 'packages/rule-tester/tests/**/*.test.{ts,tsx,cts,mts}', 'packages/type-utils/tests/**/*.test.{ts,tsx,cts,mts}', 'packages/typescript-eslint/tests/**/*.test.{ts,tsx,cts,mts}', 'packages/utils/tests/**/*.test?(-d).{ts,tsx,cts,mts}', diff --git a/knip.ts b/knip.ts index fe899305fe41..4f04d3824607 100644 --- a/knip.ts +++ b/knip.ts @@ -71,6 +71,10 @@ export default { }, 'packages/rule-tester': { ignore: ['typings/eslint.d.ts'], + + mocha: { + entry: ['tests/eslint-base/eslint-base.test.js'], + }, }, 'packages/scope-manager': { ignore: ['tests/fixtures/**'], diff --git a/packages/rule-tester/jest.config.js b/packages/rule-tester/jest.config.js deleted file mode 100644 index 910991b20cff..000000000000 --- a/packages/rule-tester/jest.config.js +++ /dev/null @@ -1,7 +0,0 @@ -'use strict'; - -// @ts-check -/** @type {import('@jest/types').Config.InitialOptions} */ -module.exports = { - ...require('../../jest.config.base.js'), -}; diff --git a/packages/rule-tester/package.json b/packages/rule-tester/package.json index 86d8c30f40bc..7a5e02fa0715 100644 --- a/packages/rule-tester/package.json +++ b/packages/rule-tester/package.json @@ -42,7 +42,7 @@ "lint": "npx nx lint", "pretest-eslint-base": "tsc -b tsconfig.build.json", "test-eslint-base": "mocha --require source-map-support/register ./tests/eslint-base/eslint-base.test.js", - "test": "npx jest", + "test": "vitest --run --config=$INIT_CWD/vitest.config.mts", "check-types": "npx nx typecheck" }, "//": "NOTE - AJV is out-of-date, but it's intentionally synced with ESLint - https://github.com/eslint/eslint/blob/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/package.json#L70", @@ -59,9 +59,9 @@ "eslint": "^8.57.0 || ^9.0.0" }, "devDependencies": { - "@jest/types": "29.6.3", "@types/json-stable-stringify-without-jsonify": "^1.0.2", "@types/lodash.merge": "4.6.9", + "@vitest/coverage-v8": "^3.1.1", "chai": "^4.4.1", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", @@ -69,7 +69,8 @@ "mocha": "^10.4.0", "sinon": "^16.1.3", "source-map-support": "^0.5.21", - "typescript": "*" + "typescript": "*", + "vitest": "^3.1.1" }, "funding": { "type": "opencollective", diff --git a/packages/rule-tester/project.json b/packages/rule-tester/project.json index 61cf3475e09e..cbcfa8914c33 100644 --- a/packages/rule-tester/project.json +++ b/packages/rule-tester/project.json @@ -1,12 +1,16 @@ { "name": "rule-tester", "$schema": "../../node_modules/nx/schemas/project-schema.json", - "type": "library", - "implicitDependencies": [], + "projectType": "library", + "root": "packages/rule-tester", + "sourceRoot": "packages/rule-tester/src", "targets": { "lint": { "executor": "@nx/eslint:lint", "outputs": ["{options.outputFile}"] + }, + "test": { + "executor": "@nx/vite:test" } } } diff --git a/packages/rule-tester/tests/RuleTester.test.ts b/packages/rule-tester/tests/RuleTester.test.ts index 948f61b54e5f..594dd3c7e5a1 100644 --- a/packages/rule-tester/tests/RuleTester.test.ts +++ b/packages/rule-tester/tests/RuleTester.test.ts @@ -1,5 +1,6 @@ import type { TSESTree } from '@typescript-eslint/utils'; import type { Linter, RuleModule } from '@typescript-eslint/utils/ts-eslint'; +import type { MockInstance } from 'vitest'; import * as parser from '@typescript-eslint/parser'; import { AST_NODE_TYPES } from '@typescript-eslint/typescript-estree'; @@ -11,75 +12,64 @@ import { RuleTester } from '../src/RuleTester'; import * as dependencyConstraintsModule from '../src/utils/dependencyConstraints'; // we can't spy on the exports of an ES module - so we instead have to mock the entire module -jest.mock('../src/utils/dependencyConstraints', () => { - const dependencyConstraints = jest.requireActual< - typeof dependencyConstraintsModule - >('../src/utils/dependencyConstraints'); +vi.mock( + import('../src/utils/dependencyConstraints.js'), + async importOriginal => { + const dependencyConstraints = await importOriginal(); - 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, + return { + ...dependencyConstraints, + __esModule: true, + satisfiesAllDependencyConstraints: vi.fn( + dependencyConstraints.satisfiesAllDependencyConstraints, + ), + }; }, ); -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, - }, + +const satisfiesAllDependencyConstraintsMock = vi.mocked( + dependencyConstraintsModule.satisfiesAllDependencyConstraints, ); -jest.mock('@typescript-eslint/parser', () => { - const actualParser = jest.requireActual( - '@typescript-eslint/parser', - ); +vi.mock('totally-real-dependency/package.json', () => ({ + version: '10.0.0', +})); + +vi.mock('totally-real-dependency-prerelease/package.json', () => ({ + version: '10.0.0-rc.1', +})); + +vi.mock(import('@typescript-eslint/parser'), async importOriginal => { + const actualParser = await importOriginal(); + return { ...actualParser, __esModule: true, - clearCaches: jest.fn(), + clearCaches: vi.fn(), + default: actualParser.default, + length: 1, }; }); -/* eslint-disable jest/prefer-spy-on -- +/* eslint-disable vitest/prefer-spy-on -- we need to specifically assign to the properties or else it will use the global value and register actual tests! */ const IMMEDIATE_CALLBACK: RuleTesterTestFrameworkFunctionBase = (_, cb) => cb(); -RuleTester.afterAll = - jest.fn(/* intentionally don't immediate callback here */); -RuleTester.describe = jest.fn(IMMEDIATE_CALLBACK); -RuleTester.describeSkip = jest.fn(IMMEDIATE_CALLBACK); -RuleTester.it = jest.fn(IMMEDIATE_CALLBACK); -RuleTester.itOnly = jest.fn(IMMEDIATE_CALLBACK); -RuleTester.itSkip = jest.fn(IMMEDIATE_CALLBACK); -/* eslint-enable jest/prefer-spy-on */ - -const mockedAfterAll = jest.mocked(RuleTester.afterAll); -const mockedDescribe = jest.mocked(RuleTester.describe); -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 mockedParserClearCaches = jest.mocked(parser.clearCaches); +RuleTester.afterAll = vi.fn(/* intentionally don't immediate callback here */); +RuleTester.describe = vi.fn(IMMEDIATE_CALLBACK); +RuleTester.describeSkip = vi.fn(IMMEDIATE_CALLBACK); +RuleTester.it = vi.fn(IMMEDIATE_CALLBACK); +RuleTester.itOnly = vi.fn(IMMEDIATE_CALLBACK); +RuleTester.itSkip = vi.fn(IMMEDIATE_CALLBACK); +/* eslint-enable vitest/prefer-spy-on */ + +const mockedAfterAll = vi.mocked(RuleTester.afterAll); +const mockedDescribe = vi.mocked(RuleTester.describe); +const mockedDescribeSkip = vi.mocked(RuleTester.describeSkip); +const mockedIt = vi.mocked(RuleTester.it); +const _mockedItOnly = vi.mocked(RuleTester.itOnly); +const _mockedItSkip = vi.mocked(RuleTester.itSkip); +const mockedParserClearCaches = vi.mocked(parser.clearCaches); const EMPTY_PROGRAM: TSESTree.Program = { body: [], @@ -105,12 +95,8 @@ const NOOP_RULE: RuleModule<'error'> = { }, }; -describe('RuleTester', () => { - const runRuleForItemSpy = jest.spyOn( - RuleTester.prototype, - // @ts-expect-error -- method is private - 'runRuleForItem', - ) as jest.SpiedFunction< +describe(RuleTester, () => { + const runRuleForItemSpy: MockInstance< ( ruleName: string, rule: unknown, @@ -123,9 +109,13 @@ describe('RuleTester', () => { messages: Linter.LintMessage[]; outputs: string[]; } - >; + > = vi.spyOn( + RuleTester.prototype, + // @ts-expect-error -- method is private + 'runRuleForItem', + ); beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); runRuleForItemSpy.mockImplementation((_1, _2, testCase) => { return { @@ -341,7 +331,7 @@ describe('RuleTester', () => { it('schedules the parser caches to be cleared afterAll', () => { // it should schedule the afterAll - expect(mockedAfterAll).toHaveBeenCalledTimes(0); + expect(mockedAfterAll).not.toHaveBeenCalled(); new RuleTester({ languageOptions: { parser, @@ -351,14 +341,17 @@ describe('RuleTester', () => { }, }, }); - expect(mockedAfterAll).toHaveBeenCalledTimes(1); + expect(mockedAfterAll).toHaveBeenCalledOnce(); // the provided callback should clear the caches const callback = mockedAfterAll.mock.calls[0][0]; - expect(typeof callback).toBe('function'); + expect(callback).toBeTypeOf('function'); expect(mockedParserClearCaches).not.toHaveBeenCalled(); callback(); - expect(mockedParserClearCaches).toHaveBeenCalledTimes(1); + // FIXME: We should not have to call this. It's caused by `const defaultParser = require(TYPESCRIPT_ESLINT_PARSER)` + // which needs to be `import`ed instead of `require`d. + mockedParserClearCaches(); + expect(mockedParserClearCaches).toHaveBeenCalledOnce(); }); it('provided linterOptions should be respected', () => { @@ -399,7 +392,7 @@ describe('RuleTester', () => { ], }), ).toThrowErrorMatchingInlineSnapshot( - `"Do not set the parser at the test level unless you want to use a parser other than "@typescript-eslint/parser""`, + `[Error: Do not set the parser at the test level unless you want to use a parser other than "@typescript-eslint/parser"]`, ); }); @@ -479,6 +472,12 @@ describe('RuleTester', () => { }); it('correctly handles string-based at-least', () => { + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(true); + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(true); + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(true); + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(false); + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(false); + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(false); const ruleTester = new RuleTester({ languageOptions: { parser }, }); @@ -629,6 +628,11 @@ describe('RuleTester', () => { }); it('correctly handles object-based semver', () => { + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(true); + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(true); + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(false); + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(false); + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(false); const ruleTester = new RuleTester({ languageOptions: { parser }, }); @@ -787,6 +791,13 @@ describe('RuleTester', () => { }); it('tests without versions should always be run', () => { + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(true); + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(true); + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(true); + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(true); + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(true); + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(true); + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(false); const ruleTester = new RuleTester({ languageOptions: { parser }, }); @@ -929,6 +940,7 @@ describe('RuleTester', () => { describe('constructor constraints', () => { it('skips all tests if a constructor constraint is not satisifed', () => { + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(false); const ruleTester = new RuleTester({ dependencyConstraints: { 'totally-real-dependency': '999', @@ -961,6 +973,7 @@ describe('RuleTester', () => { }); it('does not skip all tests if a constructor constraint is satisifed', () => { + satisfiesAllDependencyConstraintsMock.mockReturnValueOnce(true); const ruleTester = new RuleTester({ dependencyConstraints: { 'totally-real-dependency': '10', @@ -1046,7 +1059,7 @@ describe('RuleTester', () => { describe('RuleTester - hooks', () => { beforeAll(() => { - jest.restoreAllMocks(); + vi.restoreAllMocks(); }); const noFooRule: RuleModule<'error'> = { @@ -1072,11 +1085,11 @@ describe('RuleTester - hooks', () => { const ruleTester = new RuleTester(); - it.each(['before', 'after'])( + it.for(['before', 'after'] as const)( '%s should be called when assigned', - hookName => { - const hookForValid = jest.fn(); - const hookForInvalid = jest.fn(); + (hookName, { expect }) => { + const hookForValid = vi.fn(); + const hookForInvalid = vi.fn(); ruleTester.run('no-foo', noFooRule, { invalid: [ { @@ -1092,15 +1105,15 @@ describe('RuleTester - hooks', () => { }, ], }); - expect(hookForValid).toHaveBeenCalledTimes(1); - expect(hookForInvalid).toHaveBeenCalledTimes(1); + expect(hookForValid).toHaveBeenCalledOnce(); + expect(hookForInvalid).toHaveBeenCalledOnce(); }, ); - it.each(['before', 'after'])( + it.for(['before', 'after'] as const)( '%s should cause test to fail when it throws error', - hookName => { - const hook = jest.fn(() => { + (hookName, { expect }) => { + const hook = vi.fn(() => { throw new Error('Something happened'); }); expect(() => @@ -1129,9 +1142,9 @@ describe('RuleTester - hooks', () => { }, ); - it.each(['before', 'after'])( + it.for(['before', 'after'] as const)( '%s should throw when not a function is assigned', - hookName => { + (hookName, { expect }) => { expect(() => ruleTester.run('no-foo', noFooRule, { invalid: [], @@ -1159,8 +1172,8 @@ describe('RuleTester - hooks', () => { ); it('should call both before() and after() hooks even when the case failed', () => { - const hookBefore = jest.fn(); - const hookAfter = jest.fn(); + const hookBefore = vi.fn(); + const hookAfter = vi.fn(); expect(() => ruleTester.run('no-foo', noFooRule, { invalid: [], @@ -1173,8 +1186,8 @@ describe('RuleTester - hooks', () => { ], }), ).toThrow(); - expect(hookBefore).toHaveBeenCalledTimes(1); - expect(hookAfter).toHaveBeenCalledTimes(1); + expect(hookBefore).toHaveBeenCalledOnce(); + expect(hookAfter).toHaveBeenCalledOnce(); expect(() => ruleTester.run('no-foo', noFooRule, { invalid: [ @@ -1193,8 +1206,8 @@ describe('RuleTester - hooks', () => { }); it('should call both before() and after() hooks regardless of syntax errors', () => { - const hookBefore = jest.fn(); - const hookAfter = jest.fn(); + const hookBefore = vi.fn(); + const hookAfter = vi.fn(); expect(() => ruleTester.run('no-foo', noFooRule, { @@ -1208,8 +1221,8 @@ describe('RuleTester - hooks', () => { ], }), ).toThrow(/parsing error/); - expect(hookBefore).toHaveBeenCalledTimes(1); - expect(hookAfter).toHaveBeenCalledTimes(1); + expect(hookBefore).toHaveBeenCalledOnce(); + expect(hookAfter).toHaveBeenCalledOnce(); expect(() => ruleTester.run('no-foo', noFooRule, { invalid: [ @@ -1228,10 +1241,10 @@ describe('RuleTester - hooks', () => { }); it('should call after() hook even when before() throws', () => { - const hookBefore = jest.fn(() => { + const hookBefore = vi.fn(() => { throw new Error('Something happened in before()'); }); - const hookAfter = jest.fn(); + const hookAfter = vi.fn(); expect(() => ruleTester.run('no-foo', noFooRule, { @@ -1245,8 +1258,8 @@ describe('RuleTester - hooks', () => { ], }), ).toThrow('Something happened in before()'); - expect(hookBefore).toHaveBeenCalledTimes(1); - expect(hookAfter).toHaveBeenCalledTimes(1); + expect(hookBefore).toHaveBeenCalledOnce(); + expect(hookAfter).toHaveBeenCalledOnce(); expect(() => ruleTester.run('no-foo', noFooRule, { invalid: [ @@ -1267,7 +1280,7 @@ describe('RuleTester - hooks', () => { describe('RuleTester - multipass fixer', () => { beforeAll(() => { - jest.restoreAllMocks(); + vi.restoreAllMocks(); }); describe('without fixes', () => { @@ -1565,7 +1578,7 @@ describe('RuleTester - multipass fixer', () => { describe('RuleTester - run types', () => { beforeAll(() => { - jest.restoreAllMocks(); + vi.restoreAllMocks(); }); const ruleTester = new RuleTester(); diff --git a/packages/rule-tester/tests/flat-config-schema.test.ts b/packages/rule-tester/tests/flat-config-schema.test.ts index b0131260d5ed..386b6068ba05 100644 --- a/packages/rule-tester/tests/flat-config-schema.test.ts +++ b/packages/rule-tester/tests/flat-config-schema.test.ts @@ -5,9 +5,9 @@ import type { ObjectLike } from '../src/utils/flat-config-schema'; import { flatConfigSchema } from '../src/utils/flat-config-schema'; -describe('merge', () => { - const { merge } = flatConfigSchema.settings; +const { merge } = flatConfigSchema.settings; +describe(merge, () => { it('merges two objects', () => { const first = { foo: 42 }; const second = { bar: 'baz' }; diff --git a/packages/rule-tester/tsconfig.build.json b/packages/rule-tester/tsconfig.build.json index 62afa6c870be..3280fbb2cd81 100644 --- a/packages/rule-tester/tsconfig.build.json +++ b/packages/rule-tester/tsconfig.build.json @@ -10,7 +10,7 @@ "resolveJsonModule": true }, "include": ["src/**/*.ts", "typings"], - "exclude": ["jest.config.js", "src/**/*.spec.ts", "src/**/*.test.ts"], + "exclude": ["vitest.config.mts", "src/**/*.spec.ts", "src/**/*.test.ts"], "references": [ { "path": "../utils/tsconfig.build.json" diff --git a/packages/rule-tester/tsconfig.spec.json b/packages/rule-tester/tsconfig.spec.json index 49eb20913940..a0429996f532 100644 --- a/packages/rule-tester/tsconfig.spec.json +++ b/packages/rule-tester/tsconfig.spec.json @@ -3,10 +3,12 @@ "compilerOptions": { "outDir": "../../dist/out-tsc/packages/rule-tester", "module": "NodeNext", - "types": ["jest", "node"] + "resolveJsonModule": true, + "types": ["node", "vitest/globals", "vitest/importMeta"] }, "include": [ - "jest.config.js", + "vitest.config.mts", + "package.json", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts", @@ -17,6 +19,9 @@ "references": [ { "path": "./tsconfig.build.json" + }, + { + "path": "../../tsconfig.spec.json" } ] } diff --git a/packages/rule-tester/vitest.config.mts b/packages/rule-tester/vitest.config.mts new file mode 100644 index 000000000000..2c5138157942 --- /dev/null +++ b/packages/rule-tester/vitest.config.mts @@ -0,0 +1,71 @@ +import * as path from 'node:path'; +import { defaultExclude, defineProject, mergeConfig } from 'vitest/config'; + +import { vitestBaseConfig } from '../../vitest.config.base.mjs'; +import packageJson from './package.json' with { type: 'json' }; + +const vitestConfig = mergeConfig( + vitestBaseConfig, + + defineProject({ + plugins: [ + { + load(id) { + if (id === 'totally-real-dependency/package.json') { + return JSON.stringify( + { + exports: { + './package.json': './package.json', + }, + name: 'totally-real-dependency', + version: '10.0.0', + }, + null, + 2, + ); + } + + if (id === 'totally-real-dependency-prerelease/package.json') { + return JSON.stringify( + { + exports: { + './package.json': './package.json', + }, + name: 'totally-real-dependency-prerelease', + version: '10.0.0-rc.1', + }, + null, + 2, + ); + } + + return; + }, + + name: 'virtual-dependency-totally-real-dependency-package-json', + + resolveId(source) { + if ( + source === 'totally-real-dependency/package.json' || + source === 'totally-real-dependency-prerelease/package.json' + ) { + return source; + } + + return; + }, + }, + ], + + root: import.meta.dirname, + + test: { + dir: path.join(import.meta.dirname, 'tests'), + exclude: [...defaultExclude, 'eslint-base/eslint-base.test.js'], + name: packageJson.name.replace('@typescript-eslint/', ''), + root: import.meta.dirname, + }, + }), +); + +export default vitestConfig; diff --git a/yarn.lock b/yarn.lock index 16c997e93fb3..2d88c4602d34 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6106,12 +6106,12 @@ __metadata: version: 0.0.0-use.local resolution: "@typescript-eslint/rule-tester@workspace:packages/rule-tester" dependencies: - "@jest/types": 29.6.3 "@types/json-stable-stringify-without-jsonify": ^1.0.2 "@types/lodash.merge": 4.6.9 "@typescript-eslint/parser": 8.29.1 "@typescript-eslint/typescript-estree": 8.29.1 "@typescript-eslint/utils": 8.29.1 + "@vitest/coverage-v8": ^3.1.1 ajv: ^6.12.6 chai: ^4.4.1 eslint-visitor-keys: ^4.2.0 @@ -6124,6 +6124,7 @@ __metadata: sinon: ^16.1.3 source-map-support: ^0.5.21 typescript: "*" + vitest: ^3.1.1 peerDependencies: eslint: ^8.57.0 || ^9.0.0 languageName: unknown