diff --git a/.cspell.json b/.cspell.json index deb654ab3735..68fa3fdfe37c 100644 --- a/.cspell.json +++ b/.cspell.json @@ -135,6 +135,7 @@ "necroing", "Nicolò", "nocheck", + "nodenext", "noninteractive", "Nrwl", "nullish", diff --git a/packages/eslint-plugin/tests/fixtures/tsconfig.json b/packages/eslint-plugin/tests/fixtures/tsconfig.json index a0fc993b1f48..a7bfe41b9856 100644 --- a/packages/eslint-plugin/tests/fixtures/tsconfig.json +++ b/packages/eslint-plugin/tests/fixtures/tsconfig.json @@ -1,12 +1,14 @@ { "compilerOptions": { "jsx": "preserve", - "target": "es5", + "target": "es2020", "module": "commonjs", "strict": true, "esModuleInterop": true, "lib": ["es2015", "es2017", "esnext"], - "experimentalDecorators": true + "types": [], + "experimentalDecorators": true, + "skipLibCheck": true }, "include": [ "file.ts", diff --git a/packages/rule-tester/package.json b/packages/rule-tester/package.json index 87cb5fcfff27..6f64abbcfe17 100644 --- a/packages/rule-tester/package.json +++ b/packages/rule-tester/package.json @@ -72,6 +72,11 @@ "source-map-support": "^0.5.21", "typescript": "*" }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" diff --git a/packages/rule-tester/src/RuleTester.ts b/packages/rule-tester/src/RuleTester.ts index 725e2a9460bc..e23ef43eae30 100644 --- a/packages/rule-tester/src/RuleTester.ts +++ b/packages/rule-tester/src/RuleTester.ts @@ -7,16 +7,21 @@ import type { AnyRuleCreateFunction, AnyRuleModule, ParserOptions, + RuleContext, RuleListener, RuleModule, } from '@typescript-eslint/utils/ts-eslint'; import * as parser from '@typescript-eslint/parser'; -import { deepMerge } from '@typescript-eslint/utils/eslint-utils'; +import { + deepMerge, + getParserServices, +} from '@typescript-eslint/utils/eslint-utils'; import { Linter } from '@typescript-eslint/utils/ts-eslint'; import assert from 'node:assert'; import path from 'node:path'; import util from 'node:util'; +import * as ts from 'typescript'; // we intentionally import from eslint here because we need to use the same class // that ESLint uses, not our custom override typed version import { SourceCode } from 'eslint'; @@ -181,7 +186,10 @@ export class RuleTester extends TestFramework { * configuration and the default configuration. */ this.#testerConfig = merge({}, defaultConfig, testerConfig, { - rules: { [`${RULE_TESTER_PLUGIN_PREFIX}validate-ast`]: 'error' }, + rules: { + [`${RULE_TESTER_PLUGIN_PREFIX}collect-ts-diagnostics`]: 'error', + [`${RULE_TESTER_PLUGIN_PREFIX}validate-ast`]: 'error', + }, }); this.#lintersByBasePath = new Map(); @@ -629,11 +637,37 @@ export class RuleTester extends TestFramework { } { this.defineRule(ruleName, rule); + const shouldCollectTSDiagnostics = + item.runTSC === true || + (item.runTSC !== false && + rule.meta.docs && + 'requiresTypeChecking' in rule.meta.docs && + !!rule.meta.docs.requiresTypeChecking); + let tsDiagnostics = null as readonly ts.Diagnostic[] | null; let config: TesterConfigWithDefaults = merge({}, this.#testerConfig, { files: ['**'], plugins: { [RULE_TESTER_PLUGIN]: { rules: { + 'collect-ts-diagnostics': { + create( + context: Readonly>, + ): RuleListener { + if (!shouldCollectTSDiagnostics) { + return {}; + } + const services = getParserServices(context); + return { + Program(): void { + const diagnostics = + services.program.getSemanticDiagnostics(); + if (diagnostics.length) { + tsDiagnostics ??= diagnostics; + } + }, + }; + }, + }, /** * Setup AST getters. * The goal is to check whether or not AST was modified when @@ -655,7 +689,7 @@ export class RuleTester extends TestFramework { }, }, }, - }); + } satisfies RuleTesterConfig); // Unlike other properties, we don't want to spread props between different parsers. config.languageOptions.parser = @@ -836,6 +870,29 @@ export class RuleTester extends TestFramework { ].join('\n'), ); } while (fixedResult.fixed && passNumber < 10); + if (tsDiagnostics) { + const codesToIgnore = [ + 1375 /* 'await' expressions are only allowed at the top level of a file when that file is a module, but this file has no imports or exports. Consider adding an empty 'export {}' to make this file a module. */, + 1378 /* Top-level 'await' expressions are only allowed when the 'module' option is set to 'es2022', 'esnext', 'system', 'node16', 'nodenext', or 'preserve', and the 'target' option is set to 'es2017' or higher. */, + 6133 /* '{0}' is declared but its value is never read. */, + 6138 /* Property '{0}' is declared but its value is never read. */, + ]; + for (const diagnostic of tsDiagnostics) { + if ( + diagnostic.category !== ts.DiagnosticCategory.Error || + codesToIgnore.includes(diagnostic.code) + ) { + continue; + } + + throw new Error( + `error TS${diagnostic.code}: ${ts.flattenDiagnosticMessageText( + diagnostic.messageText, + ts.sys.newLine, + )}`, + ); + } + } return { config, diff --git a/packages/rule-tester/src/types/ValidTestCase.ts b/packages/rule-tester/src/types/ValidTestCase.ts index 62fe2d1df6fb..9e35ce73c4b3 100644 --- a/packages/rule-tester/src/types/ValidTestCase.ts +++ b/packages/rule-tester/src/types/ValidTestCase.ts @@ -63,6 +63,10 @@ export interface ValidTestCase { * Options for the test case. */ readonly options?: Readonly; + /** + * TODO: add description (or maybe even give a better name?) + */ + readonly runTSC?: boolean; /** * Settings for the test case. */ diff --git a/packages/rule-tester/src/utils/validationHelpers.ts b/packages/rule-tester/src/utils/validationHelpers.ts index 0f3e0f777c95..063e77869f11 100644 --- a/packages/rule-tester/src/utils/validationHelpers.ts +++ b/packages/rule-tester/src/utils/validationHelpers.ts @@ -20,6 +20,7 @@ export const RULE_TESTER_PARAMETERS = [ 'options', 'output', 'skip', + 'runTSC', ] as const; /* diff --git a/packages/rule-tester/tests/RuleTester.test.ts b/packages/rule-tester/tests/RuleTester.test.ts index a5fa577fb1d4..d9440e4b3735 100644 --- a/packages/rule-tester/tests/RuleTester.test.ts +++ b/packages/rule-tester/tests/RuleTester.test.ts @@ -3,6 +3,7 @@ import type { RuleModule } from '@typescript-eslint/utils/ts-eslint'; import * as parser from '@typescript-eslint/parser'; import { AST_NODE_TYPES } from '@typescript-eslint/typescript-estree'; +import path from 'node:path'; import type { RuleTesterTestFrameworkFunctionBase } from '../src/TestFramework'; @@ -1548,3 +1549,300 @@ describe('RuleTester - multipass fixer', () => { }); }); }); + +describe('RuleTester - semantic TS errors', () => { + beforeAll(() => { + jest.restoreAllMocks(); + }); + + const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: path.join(__dirname, 'fixtures'), + }, + }, + }); + const rule: RuleModule<'error'> = { + create() { + return {}; + }, + defaultOptions: [], + meta: { + messages: { + error: 'error', + }, + schema: [], + type: 'problem', + }, + }; + const ruleWithRequiresTypeChecking: RuleModule< + 'error', + [], + { + requiresTypeChecking: boolean; + } + > = { + create() { + return {}; + }, + defaultOptions: [], + meta: { + docs: { + description: 'My Rule', + requiresTypeChecking: true, + }, + messages: { + error: 'error', + }, + schema: [], + type: 'problem', + }, + }; + + it('does not collect diagnostics when runTSC is not passed', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + invalid: [], + valid: [ + { + code: 'const foo: string = 5', + }, + ], + }); + }).not.toThrow(); + }); + + it('does not collect diagnostics when runTSC is false', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + invalid: [], + valid: [ + { + code: 'const foo: string = 5', + runTSC: false, + }, + ], + }); + }).not.toThrow(); + }); + + it('collects diagnostics when runTSC is true', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + invalid: [], + valid: [ + { + code: 'const foo: string = 5', + runTSC: true, + }, + ], + }); + }).toThrow("Type 'number' is not assignable to type 'string'."); + }); + + it('collects diagnostics when meta.docs.requiresTypeChecking is true', () => { + expect(() => { + ruleTester.run('my-rule', ruleWithRequiresTypeChecking, { + invalid: [], + valid: [ + { + code: 'const foo: string = 5', + }, + ], + }); + }).toThrow("Type 'number' is not assignable to type 'string'."); + }); + + it('does not collect diagnostics when meta.docs.requiresTypeChecking is true, but runTSC is false', () => { + expect(() => { + ruleTester.run('my-rule', ruleWithRequiresTypeChecking, { + invalid: [], + valid: [ + { + code: 'const foo: string = 5', + runTSC: false, + }, + ], + }); + }).not.toThrow(); + }); + + it('collects diagnostics when meta.docs.requiresTypeChecking is true, and runTSC is true', () => { + expect(() => { + ruleTester.run('my-rule', ruleWithRequiresTypeChecking, { + invalid: [], + valid: [ + { + code: 'const foo: string = 5', + runTSC: true, + }, + ], + }); + }).toThrow("Type 'number' is not assignable to type 'string'."); + }); + + describe('common errors are ignored', () => { + it('ignores top level await', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + invalid: [], + valid: [ + { + code: 'await Promise.resolve()', + runTSC: true, + }, + ], + }); + }).not.toThrow(); + }); + + it('ignores unused variables', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + invalid: [], + valid: [ + { + code: 'const foo = 5', + runTSC: true, + }, + ], + }); + }).not.toThrow(); + }); + + it('ignores unused properties', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + invalid: [], + valid: [ + { + code: ` + export class Foo { + private bar = 1; + } + `, + runTSC: true, + }, + ], + }); + }).not.toThrow(); + }); + }); + + it('collects diagnostics from imported files (not included in tsconfig.json)', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + invalid: [], + valid: [ + { + code: 'import { foo } from "./fixture-with-semantic-ts-errors"', + runTSC: true, + }, + ], + }); + }).toThrow( + `error TS2322: Type '"actual value"' is not assignable to type '"expected value"'.`, + ); + }); + + it('collects diagnostics from files included in tsconfig.json', () => { + expect(() => { + const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + project: './tsconfig-includes-fixture-with-semantic-ts-errors.json', + tsconfigRootDir: path.join(__dirname, 'fixtures'), + }, + }, + }); + ruleTester.run('my-rule', rule, { + invalid: [], + valid: [ + { + code: 'const foo = 1', + runTSC: true, + }, + ], + }); + }).toThrow( + `error TS2322: Type '"actual value"' is not assignable to type '"expected value"'.`, + ); + }); + + describe('multipass fixer', () => { + const rule: RuleModule<'error'> = { + create(context) { + return { + 'ExpressionStatement > Identifier[name=bar]'(node): void { + context.report({ + fix: fixer => fixer.replaceText(node, 'baz'), + messageId: 'error', + node, + }); + }, + 'ExpressionStatement > Identifier[name=foo]'(node): void { + context.report({ + fix: fixer => fixer.replaceText(node, 'bar'), + messageId: 'error', + node, + }); + }, + }; + }, + defaultOptions: [], + meta: { + fixable: 'code', + messages: { + error: 'error', + }, + schema: [], + type: 'problem', + }, + }; + + it('reports the first diagnostics that comes up', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + invalid: [ + { + code: 'foo', + errors: [{ messageId: 'error' }], + output: ['bar', 'baz'], + runTSC: true, + }, + ], + valid: [], + }); + }).toThrow("Cannot find name 'foo'."); + }); + + it('reports the first diagnostics that comes up (even if TS errors appear after applying fixes)', () => { + expect(() => { + ruleTester.run('my-rule', rule, { + invalid: [ + { + code: ` + declare const foo: string; + foo; + `, + errors: [{ messageId: 'error' }], + output: [ + ` + declare const foo: string; + bar; + `, + ` + declare const foo: string; + baz; + `, + ], + runTSC: true, + }, + ], + valid: [], + }); + }).toThrow("Cannot find name 'bar'."); + }); + }); +}); diff --git a/packages/rule-tester/tests/fixtures/file.ts b/packages/rule-tester/tests/fixtures/file.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/rule-tester/tests/fixtures/fixture-with-semantic-ts-errors.ts b/packages/rule-tester/tests/fixtures/fixture-with-semantic-ts-errors.ts new file mode 100644 index 000000000000..2cfb1678afe7 --- /dev/null +++ b/packages/rule-tester/tests/fixtures/fixture-with-semantic-ts-errors.ts @@ -0,0 +1 @@ +export const foo: 'expected value' = 'actual value'; diff --git a/packages/rule-tester/tests/fixtures/tsconfig-includes-fixture-with-semantic-ts-errors.json b/packages/rule-tester/tests/fixtures/tsconfig-includes-fixture-with-semantic-ts-errors.json new file mode 100644 index 000000000000..0d19a9c6c7c4 --- /dev/null +++ b/packages/rule-tester/tests/fixtures/tsconfig-includes-fixture-with-semantic-ts-errors.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["file.ts", "fixture-with-semantic-ts-errors.ts"] +} diff --git a/packages/rule-tester/tests/fixtures/tsconfig.json b/packages/rule-tester/tests/fixtures/tsconfig.json new file mode 100644 index 000000000000..103896cf643f --- /dev/null +++ b/packages/rule-tester/tests/fixtures/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "jsx": "preserve", + "target": "es2020", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "lib": ["es2015", "es2017", "esnext"], + "types": [], + "experimentalDecorators": true, + "skipLibCheck": true + }, + "include": ["file.ts"] +} diff --git a/packages/rule-tester/tsconfig.build.json b/packages/rule-tester/tsconfig.build.json index 782f14402ae4..0932042a881c 100644 --- a/packages/rule-tester/tsconfig.build.json +++ b/packages/rule-tester/tsconfig.build.json @@ -7,5 +7,6 @@ "resolveJsonModule": true }, "include": ["src", "typings"], + "exclude": ["./tests/fixtures/fixture-with-semantic-ts-errors.ts"], "references": [{ "path": "../utils/tsconfig.build.json" }] } diff --git a/yarn.lock b/yarn.lock index 942816da076c..f67102639c26 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5727,6 +5727,9 @@ __metadata: typescript: "*" peerDependencies: eslint: ^8.57.0 || ^9.0.0 + peerDependenciesMeta: + typescript: + optional: true languageName: unknown linkType: soft pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy