From 6aa9edc8f2eda37e966cfb8ad9ce1c7da4f9a93c Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Tue, 28 Mar 2023 23:32:01 +1030 Subject: [PATCH] add our current updated features --- .vscode/launch.json | 36 + packages/rule-tester/package.json | 1 + packages/rule-tester/src/RuleTester.ts | 1317 ++++++++++------- packages/rule-tester/src/TestFramework.ts | 136 +- packages/rule-tester/src/index.ts | 9 + packages/rule-tester/src/types.ts | 176 ++- .../rule-tester/src/utils/config-schema.ts | 21 +- .../src/utils/validationHelpers.ts | 24 +- packages/rule-tester/tests/RuleTester.test.ts | 822 ++++++++++ 9 files changed, 1945 insertions(+), 597 deletions(-) create mode 100644 packages/rule-tester/tests/RuleTester.test.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 48cba7571e8..8b649895028 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -72,6 +72,42 @@ "${workspaceFolder}/packages/scope-manager/dist/index.js", ], }, + { + "type": "node", + "request": "launch", + "name": "Run currently opened rule-tester test", + "cwd": "${workspaceFolder}/packages/rule-tester/", + "program": "${workspaceFolder}/node_modules/jest/bin/jest.js", + "args": [ + "--runInBand", + "--no-cache", + "--no-coverage", + "${fileBasename}" + ], + "sourceMaps": true, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": [ + "${workspaceFolder}/packages/utils/src/index.ts", + "${workspaceFolder}/packages/utils/dist/index.js", + "${workspaceFolder}/packages/utils/src/ts-estree.ts", + "${workspaceFolder}/packages/utils/dist/ts-estree.js", + "${workspaceFolder}/packages/type-utils/src/ts-estree.ts", + "${workspaceFolder}/packages/type-utils/dist/ts-estree.js", + "${workspaceFolder}/packages/parser/src/index.ts", + "${workspaceFolder}/packages/parser/dist/index.js", + "${workspaceFolder}/packages/rule-tester/src/index.ts", + "${workspaceFolder}/packages/rule-tester/dist/index.js", + "${workspaceFolder}/packages/typescript-estree/src/index.ts", + "${workspaceFolder}/packages/typescript-estree/dist/index.js", + "${workspaceFolder}/packages/types/src/index.ts", + "${workspaceFolder}/packages/types/dist/index.js", + "${workspaceFolder}/packages/visitor-keys/src/index.ts", + "${workspaceFolder}/packages/visitor-keys/dist/index.js", + "${workspaceFolder}/packages/scope-manager/dist/index.js", + "${workspaceFolder}/packages/scope-manager/dist/index.js", + ], + }, { "type": "node", "request": "launch", diff --git a/packages/rule-tester/package.json b/packages/rule-tester/package.json index df7367f0e50..eea8c38cfa6 100644 --- a/packages/rule-tester/package.json +++ b/packages/rule-tester/package.json @@ -58,6 +58,7 @@ "eslint": ">=8" }, "devDependencies": { + "@typescript-eslint/parser": "5.56.0", "@types/lodash.merge": "4.6.7", "chai": "^4.0.1", "mocha": "^8.3.2", diff --git a/packages/rule-tester/src/RuleTester.ts b/packages/rule-tester/src/RuleTester.ts index b6e642cd956..4f1f4727b4e 100644 --- a/packages/rule-tester/src/RuleTester.ts +++ b/packages/rule-tester/src/RuleTester.ts @@ -4,14 +4,14 @@ import assert from 'node:assert'; import path from 'node:path'; import util from 'node:util'; +import type * as ParserType from '@typescript-eslint/parser'; import type { TSESTree } from '@typescript-eslint/utils'; +import { deepMerge } from '@typescript-eslint/utils/eslint-utils'; import type { AnyRuleModule, - InvalidTestCase, + ParserOptions, RuleContext, RuleModule, - RunTests, - ValidTestCase, } from '@typescript-eslint/utils/ts-eslint'; import { Linter } from '@typescript-eslint/utils/ts-eslint'; // we intentionally import from eslint here because we need to use the same class @@ -19,8 +19,16 @@ import { Linter } from '@typescript-eslint/utils/ts-eslint'; import { SourceCode } from 'eslint'; import merge from 'lodash.merge'; +import { satisfiesAllDependencyConstraints } from './dependencyConstraints'; import { TestFramework } from './TestFramework'; -import type { RuleTesterConfig, TesterConfigWithDefaults } from './types'; +import type { + InvalidTestCase, + NormalizedRunTests, + RuleTesterConfig, + RunTests, + TesterConfigWithDefaults, + ValidTestCase, +} from './types'; import { ajvBuilder } from './utils/ajv'; import { cloneDeeplyExcludesParent } from './utils/cloneDeeplyExcludesParent'; import { validate } from './utils/config-validator'; @@ -38,31 +46,34 @@ import { FRIENDLY_SUGGESTION_OBJECT_PARAMETER_LIST, getCommentsDeprecation, REQUIRED_SCENARIOS, - RuleTesterParameters, + RULE_TESTER_PARAMETERS, sanitize, SUGGESTION_OBJECT_PARAMETERS, wrapParser, } from './utils/validationHelpers'; const ajv = ajvBuilder({ strictDefaults: true }); -const TYPESCRIPT_ESLINT_PARSER_PATH = require.resolve( - '@typescript-eslint/parser', -); +const TYPESCRIPT_ESLINT_PARSER = '@typescript-eslint/parser'; +const DUPLICATE_PARSER_ERROR_MESSAGE = `Do not set the parser at the test level unless you want to use a parser other than "${TYPESCRIPT_ESLINT_PARSER}"`; /* * testerDefaultConfig must not be modified as it allows to reset the tester to * the initial default configuration */ const testerDefaultConfig: Readonly = { - parser: TYPESCRIPT_ESLINT_PARSER_PATH, + parser: TYPESCRIPT_ESLINT_PARSER, rules: {}, + defaultFilenames: { ts: 'file.ts', tsx: 'react.tsx' }, }; -let defaultConfig: TesterConfigWithDefaults = { ...testerDefaultConfig }; +let defaultConfig = deepMerge( + {}, + testerDefaultConfig, +) as TesterConfigWithDefaults; export class RuleTester extends TestFramework { - private testerConfig: TesterConfigWithDefaults; - private rules: Record; - private linter: Linter; + readonly #testerConfig: TesterConfigWithDefaults; + readonly #rules: Record = {}; + readonly #linter: Linter = new Linter(); /** * Creates a new instance of RuleTester. @@ -74,15 +85,27 @@ export class RuleTester extends TestFramework { * The configuration to use for this tester. Combination of the tester * configuration and the default configuration. */ - this.testerConfig = merge({}, defaultConfig, testerConfig, { + this.#testerConfig = merge({}, defaultConfig, testerConfig, { rules: { 'rule-tester/validate-ast': 'error' }, + // 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((testerConfig ?? defaultConfig).parser), }); - /** - * Rule definitions to define before tests. - */ - this.rules = {}; - this.linter = new Linter(); + // 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 + const constructor = this.constructor as typeof RuleTester; + constructor.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(TYPESCRIPT_ESLINT_PARSER) as typeof ParserType; + parser.clearCaches(); + } catch { + // ignored on purpose + } + }); } /** @@ -95,7 +118,11 @@ export class RuleTester extends TestFramework { ); } // Make sure the rules object exists since it is assumed to exist later - defaultConfig = { rules: {}, ...config }; + defaultConfig = deepMerge( + defaultConfig, + // @ts-expect-error -- no index signature + config, + ) as TesterConfigWithDefaults; } /** @@ -142,7 +169,136 @@ export class RuleTester extends TestFramework { * Define a rule for one particular run of tests. */ defineRule(name: string, rule: AnyRuleModule): void { - this.rules[name] = rule; + this.#rules[name] = rule; + } + + #normalizeTests< + TMessageIds extends string, + TOptions extends readonly unknown[], + >( + rawTests: RunTests, + ): NormalizedRunTests { + /* + 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 getFilename = (testOptions?: ParserOptions): string => { + const resolvedOptions = deepMerge( + this.#testerConfig.parserOptions, + testOptions, + ) as ParserOptions; + const filename = resolvedOptions.ecmaFeatures?.jsx + ? this.#testerConfig.defaultFilenames.tsx + : this.#testerConfig.defaultFilenames.ts; + if (resolvedOptions.project) { + return path.join( + resolvedOptions.tsconfigRootDir != null + ? resolvedOptions.tsconfigRootDir + : process.cwd(), + filename, + ); + } + return filename; + }; + const normalizeTest = < + TMessageIds extends string, + TOptions extends readonly unknown[], + T extends + | ValidTestCase + | InvalidTestCase, + >( + test: T, + ): T => { + if (test.parser === TYPESCRIPT_ESLINT_PARSER) { + throw new Error(DUPLICATE_PARSER_ERROR_MESSAGE); + } + if (!test.filename) { + return { + ...test, + filename: getFilename(test.parserOptions), + }; + } + return test; + }; + + const normalizedTests = { + valid: rawTests.valid + .map(test => { + if (typeof test === 'string') { + return { code: test }; + } + return test; + }) + .map(normalizeTest), + invalid: rawTests.invalid.map(normalizeTest), + }; + + // convenience iterator to make it easy to loop all tests without a concat + const allTestsIterator = { + *[Symbol.iterator](): Generator, void, unknown> { + for (const testCase of normalizedTests.valid) { + yield testCase; + } + for (const testCase of normalizedTests.invalid) { + yield testCase; + } + }, + }; + + const hasOnly = ((): boolean => { + for (const test of allTestsIterator) { + if (test.only) { + return true; + } + } + return false; + })(); + if (hasOnly) { + // if there is an `only: true` - don't try apply constraints - assume that + // we are in "local development" mode rather than "CI validation" mode + return normalizedTests; + } + + const hasConstraints = ((): boolean => { + for (const test of allTestsIterator) { + if ( + test.dependencyConstraints && + Object.keys(test.dependencyConstraints).length > 0 + ) { + return true; + } + } + return false; + })(); + if (!hasConstraints) { + return normalizedTests; + } + + /* + Mark all unsatisfactory tests as `skip: true`. + 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 without a trace. + */ + const maybeMarkAsOnly = < + T extends + | ValidTestCase + | InvalidTestCase, + >( + test: T, + ): T => { + return { + ...test, + skip: !satisfiesAllDependencyConstraints(test.dependencyConstraints), + }; + }; + normalizedTests.valid = normalizedTests.valid.map(maybeMarkAsOnly); + normalizedTests.invalid = normalizedTests.invalid.map(maybeMarkAsOnly); + + return normalizedTests; } /** @@ -153,8 +309,29 @@ export class RuleTester extends TestFramework { rule: RuleModule, test: RunTests, ): void { - const testerConfig = this.testerConfig; - const linter = this.linter; + const constructor = this.constructor as typeof RuleTester; + + if ( + this.#testerConfig.dependencyConstraints && + !satisfiesAllDependencyConstraints( + this.#testerConfig.dependencyConstraints, + ) + ) { + // for frameworks like mocha or jest that have a "skip" version of their function + // we can provide a nice skipped test! + constructor.describeSkip(ruleName, () => { + constructor.it( + 'All tests skipped due to unsatisfied constructor dependency constraints', + () => { + // some frameworks error if there are no assertions + assert.equal(true, true); + }, + ); + }); + + // don't run any tests because we don't match the base constraint + return; + } if (!test || typeof test !== 'object') { throw new TypeError( @@ -184,7 +361,7 @@ export class RuleTester extends TestFramework { emitLegacyRuleAPIWarning(ruleName); } - linter.defineRule( + this.#linter.defineRule( ruleName, Object.assign({}, rule, { // Create a wrapper rule that freezes the `context` properties. @@ -198,637 +375,653 @@ export class RuleTester extends TestFramework { }), ); - linter.defineRules(this.rules); + this.#linter.defineRules(this.#rules); - /** - * Run the rule for the given item - * @throws {Error} If an invalid schema. - */ - function runRuleForItem( - item: ValidTestCase | InvalidTestCase, - ): { - messages: Linter.LintMessage[]; - output: string; - beforeAST: TSESTree.Program; - afterAST: TSESTree.Program; - } { - let config: TesterConfigWithDefaults = merge({}, testerConfig); - let code; - let filename; - let output; - let beforeAST: TSESTree.Program; - let afterAST: TSESTree.Program; - - if (typeof item === 'string') { - code = item; - } else { - code = item.code; - - /* - * Assumes everything on the item is a config except for the - * parameters used by this tester - */ - const itemConfig: Record = { ...item }; - - for (const parameter of RuleTesterParameters) { - delete itemConfig[parameter]; - } + const normalizedTests = this.#normalizeTests(test); - /* - * Create the config object from the tester config and this item - * specific configurations. - */ - config = merge(config, itemConfig); + function getTestMethod( + test: ValidTestCase, + ): 'it' | 'itOnly' | 'itSkip' { + if (test.skip) { + return 'itSkip'; } - - if (item.filename) { - filename = item.filename; - } - - if (hasOwnProperty(item, 'options')) { - assert(Array.isArray(item.options), 'options must be an array'); - if ( - item.options.length > 0 && - typeof rule === 'object' && - (!rule.meta || (rule.meta && rule.meta.schema == null)) - ) { - emitMissingSchemaWarning(ruleName); - } - config.rules[ruleName] = ['error', ...item.options]; - } else { - config.rules[ruleName] = 'error'; + if (test.only) { + return 'itOnly'; } + return 'it'; + } - const schema = getRuleOptionsSchema(rule); + /* + * This creates a test suite and pipes all supplied info through + * one of the templates above. + */ + constructor.describe(ruleName, () => { + constructor.describe('valid', () => { + normalizedTests.valid.forEach(valid => { + const testName = ((): string => { + if (valid.name == null || valid.name.length === 0) { + return valid.code; + } + return valid.name; + })(); + constructor[getTestMethod(valid)](sanitize(testName), () => { + this.#testValidTemplate(ruleName, rule, valid); + }); + }); + }); - /* - * Setup AST getters. - * The goal is to check whether or not AST was modified when - * running the rule under test. - */ - linter.defineRule('rule-tester/validate-ast', { - create() { - return { - Program(node): void { - beforeAST = cloneDeeplyExcludesParent(node); - }, - 'Program:exit'(node): void { - afterAST = node; - }, - }; - }, + constructor.describe('invalid', () => { + normalizedTests.invalid.forEach(invalid => { + const name = ((): string => { + if (invalid.name == null || invalid.name.length === 0) { + return invalid.code; + } + return invalid.name; + })(); + constructor[getTestMethod(invalid)](sanitize(name), () => { + this.#testInvalidTemplate(ruleName, rule, invalid); + }); + }); }); + }); + } - if (typeof config.parser === 'string') { - assert( - path.isAbsolute(config.parser), - 'Parsers provided as strings to RuleTester must be absolute paths', - ); - } else { - config.parser = TYPESCRIPT_ESLINT_PARSER_PATH; - } + /** + * Run the rule for the given item + * @throws {Error} If an invalid schema. + * Use @private instead of #private to expose it for testing purposes + */ + private runRuleForItem< + TMessageIds extends string, + TOptions extends readonly unknown[], + >( + ruleName: string, + rule: RuleModule, + item: ValidTestCase | InvalidTestCase, + ): { + messages: Linter.LintMessage[]; + output: string; + beforeAST: TSESTree.Program; + afterAST: TSESTree.Program; + } { + let config: TesterConfigWithDefaults = merge({}, this.#testerConfig); + let code; + let filename; + let output; + let beforeAST: TSESTree.Program; + let afterAST: TSESTree.Program; - linter.defineParser( - config.parser, - wrapParser(require(config.parser) as Linter.ParserModule), - ); + if (typeof item === 'string') { + code = item; + } else { + code = item.code; - if (schema) { - ajv.validateSchema(schema); - - if (ajv.errors) { - const errors = ajv.errors - .map(error => { - const field = - error.dataPath[0] === '.' - ? error.dataPath.slice(1) - : error.dataPath; - - return `\t${field}: ${error.message}`; - }) - .join('\n'); - - throw new Error( - [`Schema for rule ${ruleName} is invalid:`, errors].join( - // no space after comma to match eslint core - ',', - ), - ); - } + /* + * Assumes everything on the item is a config except for the + * parameters used by this tester + */ + const itemConfig: Record = { ...item }; - /* - * `ajv.validateSchema` checks for errors in the structure of the schema (by comparing the schema against a "meta-schema"), - * and it reports those errors individually. However, there are other types of schema errors that only occur when compiling - * the schema (e.g. using invalid defaults in a schema), and only one of these errors can be reported at a time. As a result, - * the schema is compiled here separately from checking for `validateSchema` errors. - */ - try { - ajv.compile(schema); - } catch (err) { - throw new Error( - `Schema for rule ${ruleName} is invalid: ${(err as Error).message}`, - ); - } + for (const parameter of RULE_TESTER_PARAMETERS) { + delete itemConfig[parameter]; } - validate(config, 'rule-tester', id => (id === ruleName ? rule : null)); + /* + * Create the config object from the tester config and this item + * specific configurations. + */ + config = merge(config, itemConfig); + } - // Verify the code. - // @ts-expect-error -- we don't define deprecated members on our types - const { getComments } = SourceCode.prototype as { getComments: unknown }; - let messages; + if (item.filename) { + filename = item.filename; + } - try { - // @ts-expect-error -- we don't define deprecated members on our types - SourceCode.prototype.getComments = getCommentsDeprecation; - messages = linter.verify(code, config, filename); - } finally { - // @ts-expect-error -- we don't define deprecated members on our types - SourceCode.prototype.getComments = getComments; + if (hasOwnProperty(item, 'options')) { + assert(Array.isArray(item.options), 'options must be an array'); + if ( + item.options.length > 0 && + typeof rule === 'object' && + (!rule.meta || (rule.meta && rule.meta.schema == null)) + ) { + emitMissingSchemaWarning(ruleName); } + config.rules[ruleName] = ['error', ...item.options]; + } else { + config.rules[ruleName] = 'error'; + } - const fatalErrorMessage = messages.find(m => m.fatal); + const schema = getRuleOptionsSchema(rule); + /* + * Setup AST getters. + * The goal is to check whether or not AST was modified when + * running the rule under test. + */ + this.#linter.defineRule('rule-tester/validate-ast', { + create() { + return { + Program(node): void { + beforeAST = cloneDeeplyExcludesParent(node); + }, + 'Program:exit'(node): void { + afterAST = node; + }, + }; + }, + }); + + if (typeof config.parser === 'string') { assert( - !fatalErrorMessage, - `A fatal parsing error occurred: ${fatalErrorMessage?.message}`, + path.isAbsolute(config.parser), + 'Parsers provided as strings to RuleTester must be absolute paths', ); + } else { + config.parser = require.resolve(TYPESCRIPT_ESLINT_PARSER); + } - // Verify if autofix makes a syntax error or not. - if (messages.some(m => m.fix)) { - output = SourceCodeFixer.applyFixes(code, messages).output; - const errorMessageInFix = linter - .verify(output, config, filename) - .find(m => m.fatal); + this.#linter.defineParser( + config.parser, + wrapParser(require(config.parser) as Linter.ParserModule), + ); - assert( - !errorMessageInFix, - [ - 'A fatal parsing error occurred in autofix.', - `Error: ${errorMessageInFix?.message}`, - 'Autofix output:', - output, - ].join('\n'), + if (schema) { + ajv.validateSchema(schema); + + if (ajv.errors) { + const errors = ajv.errors + .map(error => { + const field = + error.dataPath[0] === '.' + ? error.dataPath.slice(1) + : error.dataPath; + + return `\t${field}: ${error.message}`; + }) + .join('\n'); + + throw new Error( + [`Schema for rule ${ruleName} is invalid:`, errors].join( + // no space after comma to match eslint core + ',', + ), ); - } else { - output = code; } - return { - messages, - output, - // is definitely assigned within the `rule-tester/validate-ast` rule - beforeAST: beforeAST!, - // is definitely assigned within the `rule-tester/validate-ast` rule - afterAST: cloneDeeplyExcludesParent(afterAST!), - }; + /* + * `ajv.validateSchema` checks for errors in the structure of the schema (by comparing the schema against a "meta-schema"), + * and it reports those errors individually. However, there are other types of schema errors that only occur when compiling + * the schema (e.g. using invalid defaults in a schema), and only one of these errors can be reported at a time. As a result, + * the schema is compiled here separately from checking for `validateSchema` errors. + */ + try { + ajv.compile(schema); + } catch (err) { + throw new Error( + `Schema for rule ${ruleName} is invalid: ${(err as Error).message}`, + ); + } } - /** - * Check if the AST was changed - */ - function assertASTDidntChange(beforeAST: unknown, afterAST: unknown): void { - assert.deepStrictEqual( - beforeAST, - afterAST, - 'Rule should not modify AST.', - ); + validate(config, 'rule-tester', id => (id === ruleName ? rule : null)); + + // 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; } - /** - * Check if the template is valid or not - * all valid cases go through this - */ - function testValidTemplate(itemIn: string | ValidTestCase): void { - const item: ValidTestCase = - typeof itemIn === 'object' ? itemIn : { code: itemIn }; + const fatalErrorMessage = messages.find(m => m.fatal); - assert.ok( - typeof item.code === 'string', - "Test case must specify a string value for 'code'", - ); - if (item.name) { - assert.ok( - typeof item.name === 'string', - "Optional test case property 'name' must be a string", - ); - } + assert( + !fatalErrorMessage, + `A fatal parsing error occurred: ${fatalErrorMessage?.message}`, + ); - const result = runRuleForItem(item); - const messages = result.messages; + // Verify if autofix makes a syntax error or not. + if (messages.some(m => m.fix)) { + output = SourceCodeFixer.applyFixes(code, messages).output; + const errorMessageInFix = this.#linter + .verify(output, config, filename) + .find(m => m.fatal); - assert.strictEqual( - messages.length, - 0, - util.format( - 'Should have no errors but had %d: %s', - messages.length, - util.inspect(messages), - ), + assert( + !errorMessageInFix, + [ + 'A fatal parsing error occurred in autofix.', + `Error: ${errorMessageInFix?.message}`, + 'Autofix output:', + output, + ].join('\n'), ); - - assertASTDidntChange(result.beforeAST, result.afterAST); + } else { + output = code; } - /** - * Asserts that the message matches its expected value. If the expected - * value is a regular expression, it is checked against the actual - * value. - */ - function assertMessageMatches( - actual: string, - expected: string | RegExp, - ): void { - if (expected instanceof RegExp) { - // assert.js doesn't have a built-in RegExp match function - assert.ok( - expected.test(actual), - `Expected '${actual}' to match ${expected}`, - ); - } else { - assert.strictEqual(actual, expected); - } - } + return { + messages, + output, + // is definitely assigned within the `rule-tester/validate-ast` rule + beforeAST: beforeAST!, + // is definitely assigned within the `rule-tester/validate-ast` rule + afterAST: cloneDeeplyExcludesParent(afterAST!), + }; + } - /** - * Check if the template is invalid or not - * all invalid cases go through this. - */ - function testInvalidTemplate( - item: InvalidTestCase, - ): void { + /** + * Check if the template is valid or not + * all valid cases go through this + */ + #testValidTemplate< + TMessageIds extends string, + TOptions extends readonly unknown[], + >( + ruleName: string, + rule: RuleModule, + itemIn: string | ValidTestCase, + ): void { + const item: ValidTestCase = + typeof itemIn === 'object' ? itemIn : { code: itemIn }; + + assert.ok( + typeof item.code === 'string', + "Test case must specify a string value for 'code'", + ); + if (item.name) { assert.ok( - typeof item.code === 'string', - "Test case must specify a string value for 'code'", + typeof item.name === 'string', + "Optional test case property 'name' must be a string", ); - if (item.name) { - assert.ok( - typeof item.name === 'string', - "Optional test case property 'name' must be a string", - ); - } + } + + const result = this.runRuleForItem(ruleName, rule, item); + const messages = result.messages; + + assert.strictEqual( + messages.length, + 0, + util.format( + 'Should have no errors but had %d: %s', + messages.length, + util.inspect(messages), + ), + ); + + assertASTDidntChange(result.beforeAST, result.afterAST); + } + + /** + * Check if the template is invalid or not + * all invalid cases go through this. + */ + #testInvalidTemplate< + TMessageIds extends string, + TOptions extends readonly unknown[], + >( + ruleName: string, + rule: RuleModule, + item: InvalidTestCase, + ): void { + assert.ok( + typeof item.code === 'string', + "Test case must specify a string value for 'code'", + ); + if (item.name) { assert.ok( - item.errors || item.errors === 0, - `Did not specify errors for an invalid test of ${ruleName}`, + typeof item.name === 'string', + "Optional test case property 'name' must be a string", ); + } + assert.ok( + item.errors || item.errors === 0, + `Did not specify errors for an invalid test of ${ruleName}`, + ); - if (Array.isArray(item.errors) && item.errors.length === 0) { - assert.fail('Invalid cases must have at least one error'); - } + if (Array.isArray(item.errors) && item.errors.length === 0) { + assert.fail('Invalid cases must have at least one error'); + } - const ruleHasMetaMessages = - hasOwnProperty(rule, 'meta') && hasOwnProperty(rule.meta, 'messages'); - const friendlyIDList = ruleHasMetaMessages - ? `[${Object.keys(rule.meta.messages) - .map(key => `'${key}'`) - .join(', ')}]` - : null; + const ruleHasMetaMessages = + hasOwnProperty(rule, 'meta') && hasOwnProperty(rule.meta, 'messages'); + const friendlyIDList = ruleHasMetaMessages + ? `[${Object.keys(rule.meta.messages) + .map(key => `'${key}'`) + .join(', ')}]` + : null; - const result = runRuleForItem(item); - const messages = result.messages; + const result = this.runRuleForItem(ruleName, rule, item); + const messages = result.messages; - if (typeof item.errors === 'number') { - if (item.errors === 0) { - assert.fail("Invalid cases must have 'error' value greater than 0"); - } + if (typeof item.errors === 'number') { + if (item.errors === 0) { + assert.fail("Invalid cases must have 'error' value greater than 0"); + } - assert.strictEqual( - messages.length, + assert.strictEqual( + messages.length, + item.errors, + util.format( + 'Should have %d error%s but had %d: %s', item.errors, - util.format( - 'Should have %d error%s but had %d: %s', - item.errors, - item.errors === 1 ? '' : 's', - messages.length, - util.inspect(messages), - ), - ); - } else { - assert.strictEqual( + item.errors === 1 ? '' : 's', messages.length, + util.inspect(messages), + ), + ); + } else { + assert.strictEqual( + messages.length, + item.errors.length, + util.format( + 'Should have %d error%s but had %d: %s', item.errors.length, - util.format( - 'Should have %d error%s but had %d: %s', - item.errors.length, - item.errors.length === 1 ? '' : 's', - messages.length, - util.inspect(messages), - ), - ); - - const hasMessageOfThisRule = messages.some(m => m.ruleId === ruleName); - - for (let i = 0, l = item.errors.length; i < l; i++) { - const error = item.errors[i]; - const message = messages[i]; + item.errors.length === 1 ? '' : 's', + messages.length, + util.inspect(messages), + ), + ); - assert( - hasMessageOfThisRule, - 'Error rule name should be the same as the name of the rule being tested', - ); + const hasMessageOfThisRule = messages.some(m => m.ruleId === ruleName); - if (typeof error === 'string' || error instanceof RegExp) { - // Just an error message. - assertMessageMatches(message.message, error); - } else if (typeof error === 'object' && error != null) { - /* - * Error object. - * This may have a message, messageId, data, node type, line, and/or - * column. - */ - - Object.keys(error).forEach(propertyName => { - assert.ok( - ERROR_OBJECT_PARAMETERS.has(propertyName), - `Invalid error property name '${propertyName}'. Expected one of ${FRIENDLY_ERROR_OBJECT_PARAMETER_LIST}.`, - ); - }); + for (let i = 0, l = item.errors.length; i < l; i++) { + const error = item.errors[i]; + const message = messages[i]; - // @ts-expect-error -- we purposely don't define `message` on our types as the current standard is `messageId` - if (hasOwnProperty(error, 'message')) { - assert.ok( - !hasOwnProperty(error, 'messageId'), - "Error should not specify both 'message' and a 'messageId'.", - ); - assert.ok( - !hasOwnProperty(error, 'data'), - "Error should not specify both 'data' and 'message'.", - ); - assertMessageMatches( - message.message, - // @ts-expect-error -- we purposely don't define `message` on our types as the current standard is `messageId` - error.message as unknown, - ); - } else if (hasOwnProperty(error, 'messageId')) { - assert.ok( - ruleHasMetaMessages, - "Error can not use 'messageId' if rule under test doesn't define 'meta.messages'.", - ); - if (!hasOwnProperty(rule.meta.messages, error.messageId)) { - assert( - false, - `Invalid messageId '${error.messageId}'. Expected one of ${friendlyIDList}.`, - ); - } - assert.strictEqual( - message.messageId, - error.messageId, - `messageId '${message.messageId}' does not match expected messageId '${error.messageId}'.`, - ); - if (hasOwnProperty(error, 'data')) { - /* - * if data was provided, then directly compare the returned message to a synthetic - * interpolated message using the same message ID and data provided in the test. - * See https://github.com/eslint/eslint/issues/9890 for context. - */ - const unformattedOriginalMessage = - rule.meta.messages[error.messageId]; - const rehydratedMessage = interpolate( - unformattedOriginalMessage, - error.data, - ); - - assert.strictEqual( - message.message, - rehydratedMessage, - `Hydrated message "${rehydratedMessage}" does not match "${message.message}"`, - ); - } - } + assert( + hasMessageOfThisRule, + 'Error rule name should be the same as the name of the rule being tested', + ); + if (typeof error === 'string' || error instanceof RegExp) { + // Just an error message. + assertMessageMatches(message.message, error); + } else if (typeof error === 'object' && error != null) { + /* + * Error object. + * This may have a message, messageId, data, node type, line, and/or + * column. + */ + + Object.keys(error).forEach(propertyName => { assert.ok( - hasOwnProperty(error, 'data') - ? hasOwnProperty(error, 'messageId') - : true, - "Error must specify 'messageId' if 'data' is used.", + ERROR_OBJECT_PARAMETERS.has(propertyName), + `Invalid error property name '${propertyName}'. Expected one of ${FRIENDLY_ERROR_OBJECT_PARAMETER_LIST}.`, ); + }); - if (error.type) { - assert.strictEqual( - message.nodeType, - error.type, - `Error type should be ${error.type}, found ${message.nodeType}`, + // @ts-expect-error -- we purposely don't define `message` on our types as the current standard is `messageId` + if (hasOwnProperty(error, 'message')) { + assert.ok( + !hasOwnProperty(error, 'messageId'), + "Error should not specify both 'message' and a 'messageId'.", + ); + assert.ok( + !hasOwnProperty(error, 'data'), + "Error should not specify both 'data' and 'message'.", + ); + assertMessageMatches( + message.message, + // @ts-expect-error -- we purposely don't define `message` on our types as the current standard is `messageId` + error.message as unknown, + ); + } else if (hasOwnProperty(error, 'messageId')) { + assert.ok( + ruleHasMetaMessages, + "Error can not use 'messageId' if rule under test doesn't define 'meta.messages'.", + ); + if (!hasOwnProperty(rule.meta.messages, error.messageId)) { + assert( + false, + `Invalid messageId '${error.messageId}'. Expected one of ${friendlyIDList}.`, ); } - - if (hasOwnProperty(error, 'line')) { - assert.strictEqual( - message.line, - error.line, - `Error line should be ${error.line}`, + assert.strictEqual( + message.messageId, + error.messageId, + `messageId '${message.messageId}' does not match expected messageId '${error.messageId}'.`, + ); + if (hasOwnProperty(error, 'data')) { + /* + * if data was provided, then directly compare the returned message to a synthetic + * interpolated message using the same message ID and data provided in the test. + * See https://github.com/eslint/eslint/issues/9890 for context. + */ + const unformattedOriginalMessage = + rule.meta.messages[error.messageId]; + const rehydratedMessage = interpolate( + unformattedOriginalMessage, + error.data, ); - } - if (hasOwnProperty(error, 'column')) { assert.strictEqual( - message.column, - error.column, - `Error column should be ${error.column}`, + message.message, + rehydratedMessage, + `Hydrated message "${rehydratedMessage}" does not match "${message.message}"`, ); } + } - if (hasOwnProperty(error, 'endLine')) { - assert.strictEqual( - message.endLine, - error.endLine, - `Error endLine should be ${error.endLine}`, - ); - } + assert.ok( + hasOwnProperty(error, 'data') + ? hasOwnProperty(error, 'messageId') + : true, + "Error must specify 'messageId' if 'data' is used.", + ); - if (hasOwnProperty(error, 'endColumn')) { - assert.strictEqual( - message.endColumn, - error.endColumn, - `Error endColumn should be ${error.endColumn}`, - ); - } + if (error.type) { + assert.strictEqual( + message.nodeType, + error.type, + `Error type should be ${error.type}, found ${message.nodeType}`, + ); + } - if (hasOwnProperty(error, 'suggestions')) { - // Support asserting there are no suggestions + if (hasOwnProperty(error, 'line')) { + assert.strictEqual( + message.line, + error.line, + `Error line should be ${error.line}`, + ); + } + + if (hasOwnProperty(error, 'column')) { + assert.strictEqual( + message.column, + error.column, + `Error column should be ${error.column}`, + ); + } + + if (hasOwnProperty(error, 'endLine')) { + assert.strictEqual( + message.endLine, + error.endLine, + `Error endLine should be ${error.endLine}`, + ); + } + + if (hasOwnProperty(error, 'endColumn')) { + assert.strictEqual( + message.endColumn, + error.endColumn, + `Error endColumn should be ${error.endColumn}`, + ); + } + + if (hasOwnProperty(error, 'suggestions')) { + // Support asserting there are no suggestions + if ( + !error.suggestions || + (isReadonlyArray(error.suggestions) && + error.suggestions.length === 0) + ) { if ( - !error.suggestions || - (isReadonlyArray(error.suggestions) && - error.suggestions.length === 0) + Array.isArray(message.suggestions) && + message.suggestions.length > 0 ) { - if ( - Array.isArray(message.suggestions) && - message.suggestions.length > 0 - ) { - assert.fail( - `Error should have no suggestions on error with message: "${message.message}"`, - ); - } - } else { - assert( - Array.isArray(message.suggestions), - `Error should have an array of suggestions. Instead received "${String( - message.suggestions, - )}" on error with message: "${message.message}"`, - ); - const messageSuggestions = message.suggestions; - assert.strictEqual( - messageSuggestions.length, - error.suggestions.length, - `Error should have ${error.suggestions.length} suggestions. Instead found ${messageSuggestions.length} suggestions`, + assert.fail( + `Error should have no suggestions on error with message: "${message.message}"`, ); + } + } else { + assert( + Array.isArray(message.suggestions), + `Error should have an array of suggestions. Instead received "${String( + message.suggestions, + )}" on error with message: "${message.message}"`, + ); + const messageSuggestions = message.suggestions; + assert.strictEqual( + messageSuggestions.length, + error.suggestions.length, + `Error should have ${error.suggestions.length} suggestions. Instead found ${messageSuggestions.length} suggestions`, + ); - error.suggestions.forEach((expectedSuggestion, index) => { + error.suggestions.forEach((expectedSuggestion, index) => { + assert.ok( + typeof expectedSuggestion === 'object' && + expectedSuggestion != null, + "Test suggestion in 'suggestions' array must be an object.", + ); + Object.keys(expectedSuggestion).forEach(propertyName => { assert.ok( - typeof expectedSuggestion === 'object' && - expectedSuggestion != null, - "Test suggestion in 'suggestions' array must be an object.", + SUGGESTION_OBJECT_PARAMETERS.has(propertyName), + `Invalid suggestion property name '${propertyName}'. Expected one of ${FRIENDLY_SUGGESTION_OBJECT_PARAMETER_LIST}.`, ); - Object.keys(expectedSuggestion).forEach(propertyName => { - assert.ok( - SUGGESTION_OBJECT_PARAMETERS.has(propertyName), - `Invalid suggestion property name '${propertyName}'. Expected one of ${FRIENDLY_SUGGESTION_OBJECT_PARAMETER_LIST}.`, - ); - }); + }); - const actualSuggestion = messageSuggestions[index]; - const suggestionPrefix = `Error Suggestion at index ${index} :`; + const actualSuggestion = messageSuggestions[index]; + const suggestionPrefix = `Error Suggestion at index ${index} :`; + // @ts-expect-error -- we purposely don't define `desc` on our types as the current standard is `messageId` + if (hasOwnProperty(expectedSuggestion, 'desc')) { + assert.ok( + !hasOwnProperty(expectedSuggestion, 'data'), + `${suggestionPrefix} Test should not specify both 'desc' and 'data'.`, + ); // @ts-expect-error -- we purposely don't define `desc` on our types as the current standard is `messageId` - if (hasOwnProperty(expectedSuggestion, 'desc')) { - assert.ok( - !hasOwnProperty(expectedSuggestion, 'data'), - `${suggestionPrefix} Test should not specify both 'desc' and 'data'.`, - ); - // @ts-expect-error -- we purposely don't define `desc` on our types as the current standard is `messageId` - const expectedDesc = expectedSuggestion.desc as string; - assert.strictEqual( - actualSuggestion.desc, - expectedDesc, - `${suggestionPrefix} desc should be "${expectedDesc}" but got "${actualSuggestion.desc}" instead.`, - ); - } + const expectedDesc = expectedSuggestion.desc as string; + assert.strictEqual( + actualSuggestion.desc, + expectedDesc, + `${suggestionPrefix} desc should be "${expectedDesc}" but got "${actualSuggestion.desc}" instead.`, + ); + } - if (hasOwnProperty(expectedSuggestion, 'messageId')) { - assert.ok( - ruleHasMetaMessages, - `${suggestionPrefix} Test can not use 'messageId' if rule under test doesn't define 'meta.messages'.`, - ); - assert.ok( - hasOwnProperty( - rule.meta.messages, - expectedSuggestion.messageId, - ), - `${suggestionPrefix} Test has invalid messageId '${expectedSuggestion.messageId}', the rule under test allows only one of ${friendlyIDList}.`, - ); - assert.strictEqual( - actualSuggestion.messageId, + if (hasOwnProperty(expectedSuggestion, 'messageId')) { + assert.ok( + ruleHasMetaMessages, + `${suggestionPrefix} Test can not use 'messageId' if rule under test doesn't define 'meta.messages'.`, + ); + assert.ok( + hasOwnProperty( + rule.meta.messages, expectedSuggestion.messageId, - `${suggestionPrefix} messageId should be '${expectedSuggestion.messageId}' but got '${actualSuggestion.messageId}' instead.`, - ); - if (hasOwnProperty(expectedSuggestion, 'data')) { - const unformattedMetaMessage = - rule.meta.messages[expectedSuggestion.messageId]; - const rehydratedDesc = interpolate( - unformattedMetaMessage, - expectedSuggestion.data, - ); - - assert.strictEqual( - actualSuggestion.desc, - rehydratedDesc, - `${suggestionPrefix} Hydrated test desc "${rehydratedDesc}" does not match received desc "${actualSuggestion.desc}".`, - ); - } - } else { - assert.ok( - !hasOwnProperty(expectedSuggestion, 'data'), - `${suggestionPrefix} Test must specify 'messageId' if 'data' is used.`, + ), + `${suggestionPrefix} Test has invalid messageId '${expectedSuggestion.messageId}', the rule under test allows only one of ${friendlyIDList}.`, + ); + assert.strictEqual( + actualSuggestion.messageId, + expectedSuggestion.messageId, + `${suggestionPrefix} messageId should be '${expectedSuggestion.messageId}' but got '${actualSuggestion.messageId}' instead.`, + ); + if (hasOwnProperty(expectedSuggestion, 'data')) { + const unformattedMetaMessage = + rule.meta.messages[expectedSuggestion.messageId]; + const rehydratedDesc = interpolate( + unformattedMetaMessage, + expectedSuggestion.data, ); - } - - if (hasOwnProperty(expectedSuggestion, 'output')) { - const codeWithAppliedSuggestion = - SourceCodeFixer.applyFixes(item.code, [ - actualSuggestion, - ]).output; assert.strictEqual( - codeWithAppliedSuggestion, - expectedSuggestion.output, - `Expected the applied suggestion fix to match the test suggestion output for suggestion at index: ${index} on error with message: "${message.message}"`, + actualSuggestion.desc, + rehydratedDesc, + `${suggestionPrefix} Hydrated test desc "${rehydratedDesc}" does not match received desc "${actualSuggestion.desc}".`, ); } - }); - } + } else { + assert.ok( + !hasOwnProperty(expectedSuggestion, 'data'), + `${suggestionPrefix} Test must specify 'messageId' if 'data' is used.`, + ); + } + + if (hasOwnProperty(expectedSuggestion, 'output')) { + const codeWithAppliedSuggestion = SourceCodeFixer.applyFixes( + item.code, + [actualSuggestion], + ).output; + + assert.strictEqual( + codeWithAppliedSuggestion, + expectedSuggestion.output, + `Expected the applied suggestion fix to match the test suggestion output for suggestion at index: ${index} on error with message: "${message.message}"`, + ); + } + }); } - } else { - // Message was an unexpected type - assert.fail( - `Error should be a string, object, or RegExp, but found (${util.inspect( - message, - )})`, - ); } - } - } - - if (hasOwnProperty(item, 'output')) { - if (item.output == null) { - assert.strictEqual( - result.output, - item.code, - 'Expected no autofixes to be suggested', - ); } else { - assert.strictEqual( - result.output, - item.output, - 'Output is incorrect.', + // Message was an unexpected type + assert.fail( + `Error should be a string, object, or RegExp, but found (${util.inspect( + message, + )})`, ); } - } else { + } + } + + if (hasOwnProperty(item, 'output')) { + if (item.output == null) { assert.strictEqual( result.output, item.code, - "The rule fixed the code. Please add 'output' property.", + 'Expected no autofixes to be suggested', ); + } else { + assert.strictEqual(result.output, item.output, 'Output is incorrect.'); } - - assertASTDidntChange(result.beforeAST, result.afterAST); + } else { + assert.strictEqual( + result.output, + item.code, + "The rule fixed the code. Please add 'output' property.", + ); } - /* - * This creates a mocha test suite and pipes all supplied info through - * one of the templates above. - */ - const constructor = this.constructor as typeof RuleTester; - constructor.describe(ruleName, () => { - constructor.describe('valid', () => { - test.valid.forEach(valid => { - const isOnly = typeof valid === 'object' && valid.only; - const testName = ((): string => { - if (typeof valid === 'object') { - if (valid.name == null || valid.name.length === 0) { - return valid.code; - } - return valid.name; - } - return valid; - })(); - constructor[isOnly ? 'itOnly' : 'it'](sanitize(testName), () => { - testValidTemplate(valid); - }); - }); - }); + assertASTDidntChange(result.beforeAST, result.afterAST); + } +} - constructor.describe('invalid', () => { - test.invalid.forEach(invalid => { - const name = ((): string => { - if (invalid.name == null || invalid.name.length === 0) { - return invalid.code; - } - return invalid.name; - })(); - constructor[invalid.only ? 'itOnly' : 'it'](sanitize(name), () => { - testInvalidTemplate(invalid); - }); - }); - }); - }); +/** + * Check if the AST was changed + */ +function assertASTDidntChange(beforeAST: unknown, afterAST: unknown): void { + assert.deepStrictEqual(beforeAST, afterAST, 'Rule should not modify AST.'); +} + +/** + * Asserts that the message matches its expected value. If the expected + * value is a regular expression, it is checked against the actual + * value. + */ +function assertMessageMatches(actual: string, expected: string | RegExp): void { + if (expected instanceof RegExp) { + // assert.js doesn't have a built-in RegExp match function + assert.ok( + expected.test(actual), + `Expected '${actual}' to match ${expected}`, + ); + } else { + assert.strictEqual(actual, expected); } } diff --git a/packages/rule-tester/src/TestFramework.ts b/packages/rule-tester/src/TestFramework.ts index 70e2d085c0c..dea77d74624 100644 --- a/packages/rule-tester/src/TestFramework.ts +++ b/packages/rule-tester/src/TestFramework.ts @@ -2,26 +2,42 @@ * @param text a string describing the rule * @param callback the test callback */ -type RuleTesterTestFrameworkFunction = ( +export type RuleTesterTestFrameworkFunctionBase = ( text: string, callback: () => void, ) => void; -type RuleTesterTestFrameworkItFunction = RuleTesterTestFrameworkFunction & { - only?: RuleTesterTestFrameworkFunction; -}; +export type RuleTesterTestFrameworkFunction = + RuleTesterTestFrameworkFunctionBase & { + /** + * Skips running the tests inside this `describe` for the current file + */ + skip?: RuleTesterTestFrameworkFunctionBase; + }; +export type RuleTesterTestFrameworkItFunction = + RuleTesterTestFrameworkFunctionBase & { + /** + * Only runs this test in the current file. + */ + only?: RuleTesterTestFrameworkFunctionBase; + /** + * Skips running this test in the current file. + */ + skip?: RuleTesterTestFrameworkFunctionBase; + }; + +type Maybe = T | null | undefined; /** * @param fn a callback called after all the tests are done */ type AfterAll = (fn: () => void) => void; -let OVERRIDE_AFTER_ALL: AfterAll | null | undefined = undefined; -let OVERRIDE_DESCRIBE: RuleTesterTestFrameworkFunction | null | undefined = - undefined; -let OVERRIDE_IT: RuleTesterTestFrameworkItFunction | null | undefined = - undefined; -let OVERRIDE_IT_ONLY: RuleTesterTestFrameworkFunction | null | undefined = - undefined; +let OVERRIDE_AFTER_ALL: Maybe = null; +let OVERRIDE_DESCRIBE: Maybe = null; +let OVERRIDE_DESCRIBE_SKIP: Maybe = null; +let OVERRIDE_IT: Maybe = null; +let OVERRIDE_IT_ONLY: Maybe = null; +let OVERRIDE_IT_SKIP: Maybe = null; /* * NOTE - If people use `mocha test.js --watch` command, the test function @@ -36,6 +52,9 @@ let OVERRIDE_IT_ONLY: RuleTesterTestFrameworkFunction | null | undefined = * own tooling */ export abstract class TestFramework { + /** + * Runs a function after all the tests in this file have completed. + */ static get afterAll(): AfterAll { if (OVERRIDE_AFTER_ALL != null) { return OVERRIDE_AFTER_ALL; @@ -47,10 +66,13 @@ export abstract class TestFramework { 'Missing definition for `afterAll` - you must set one using `RuleTester.afterAll` or there must be one defined globally as `afterAll`.', ); } - static set afterAll(value: AfterAll | null | undefined) { + static set afterAll(value: Maybe) { OVERRIDE_AFTER_ALL = value; } + /** + * Creates a test grouping + */ static get describe(): RuleTesterTestFrameworkFunction { if (OVERRIDE_DESCRIBE != null) { return OVERRIDE_DESCRIBE; @@ -62,12 +84,50 @@ export abstract class TestFramework { 'Missing definition for `describe` - you must set one using `RuleTester.describe` or there must be one defined globally as `describe`.', ); } - static set describe( - value: RuleTesterTestFrameworkFunction | null | undefined, - ) { + static set describe(value: Maybe) { OVERRIDE_DESCRIBE = value; } + /** + * Skips running the tests inside this `describe` for the current file + */ + static get describeSkip(): RuleTesterTestFrameworkFunctionBase { + if (OVERRIDE_DESCRIBE_SKIP != null) { + return OVERRIDE_DESCRIBE_SKIP; + } + if ( + typeof OVERRIDE_DESCRIBE === 'function' && + typeof OVERRIDE_DESCRIBE.skip === 'function' + ) { + return OVERRIDE_DESCRIBE.skip.bind(OVERRIDE_DESCRIBE); + } + if (typeof describe === 'function' && typeof describe.skip === 'function') { + return describe.skip.bind(describe); + } + if ( + typeof OVERRIDE_DESCRIBE === 'function' || + typeof OVERRIDE_IT === 'function' + ) { + throw new Error( + 'Set `RuleTester.describeSkip` to use `dependencyConstraints` with a custom test framework.', + ); + } + if (typeof describe === 'function') { + throw new Error( + 'The current test framework does not support skipping tests tests with `dependencyConstraints`.', + ); + } + throw new Error( + 'Missing definition for `describeSkip` - you must set one using `RuleTester.describeSkip` or there must be one defined globally as `describe.skip`.', + ); + } + static set describeSkip(value: Maybe) { + OVERRIDE_DESCRIBE_SKIP = value; + } + + /** + * Creates a test closure + */ static get it(): RuleTesterTestFrameworkItFunction { if (OVERRIDE_IT != null) { return OVERRIDE_IT; @@ -79,11 +139,14 @@ export abstract class TestFramework { 'Missing definition for `it` - you must set one using `RuleTester.it` or there must be one defined globally as `it`.', ); } - static set it(value: RuleTesterTestFrameworkItFunction | null | undefined) { + static set it(value: Maybe) { OVERRIDE_IT = value; } - static get itOnly(): RuleTesterTestFrameworkFunction { + /** + * Only runs this test in the current file. + */ + static get itOnly(): RuleTesterTestFrameworkFunctionBase { if (OVERRIDE_IT_ONLY != null) { return OVERRIDE_IT_ONLY; } @@ -114,7 +177,44 @@ export abstract class TestFramework { 'Missing definition for `itOnly` - you must set one using `RuleTester.itOnly` or there must be one defined globally as `it.only`.', ); } - static set itOnly(value: RuleTesterTestFrameworkFunction | null | undefined) { + static set itOnly(value: Maybe) { OVERRIDE_IT_ONLY = value; } + + /** + * Skips running this test in the current file. + */ + static get itSkip(): RuleTesterTestFrameworkFunctionBase { + if (OVERRIDE_IT_SKIP != null) { + return OVERRIDE_IT_SKIP; + } + if ( + typeof OVERRIDE_IT === 'function' && + typeof OVERRIDE_IT.skip === 'function' + ) { + return OVERRIDE_IT.skip.bind(OVERRIDE_IT); + } + if (typeof it === 'function' && typeof it.skip === 'function') { + return it.skip.bind(it); + } + if ( + typeof OVERRIDE_DESCRIBE === 'function' || + typeof OVERRIDE_IT === 'function' + ) { + throw new Error( + 'Set `RuleTester.itSkip` to use `only` with a custom test framework.', + ); + } + if (typeof it === 'function') { + throw new Error( + 'The current test framework does not support exclusive tests with `only`.', + ); + } + throw new Error( + 'Missing definition for `itSkip` - you must set one using `RuleTester.itSkip` or there must be one defined globally as `it.only`.', + ); + } + static set itSkip(value: Maybe) { + OVERRIDE_IT_SKIP = value; + } } diff --git a/packages/rule-tester/src/index.ts b/packages/rule-tester/src/index.ts index e69de29bb2d..fc8d04007b7 100644 --- a/packages/rule-tester/src/index.ts +++ b/packages/rule-tester/src/index.ts @@ -0,0 +1,9 @@ +export { RuleTester } from './RuleTester'; +export type { + InvalidTestCase, + RuleTesterConfig, + RunTests, + SuggestionOutput, + TestCaseError, + ValidTestCase, +} from './types'; diff --git a/packages/rule-tester/src/types.ts b/packages/rule-tester/src/types.ts index a26e7ffe562..c00b8cda200 100644 --- a/packages/rule-tester/src/types.ts +++ b/packages/rule-tester/src/types.ts @@ -1,14 +1,184 @@ -import type { Linter, ParserOptions } from '@typescript-eslint/utils/ts-eslint'; +import type { AST_NODE_TYPES, AST_TOKEN_TYPES } from '@typescript-eslint/utils'; +import type { + Linter, + ParserOptions, + ReportDescriptorMessageData, + SharedConfigurationSettings, +} from '@typescript-eslint/utils/ts-eslint'; + +import type { DependencyConstraint } from './dependencyConstraints'; export interface RuleTesterConfig extends Linter.Config { - // should be require.resolve(parserPackageName) + /** + * The default parser to use for tests. + * @default '@typescript-eslint/parser' + */ readonly parser: string; + /** + * The default parser options to use for tests. + */ readonly parserOptions?: Readonly; + /** + * Constraints that must pass in the current environment for any tests to run. + */ + readonly dependencyConstraints?: DependencyConstraint; + /** + * The default filenames to use for type-aware tests. + * @default { ts: 'file.ts', tsx: 'react.tsx' } + */ + readonly defaultFilenames?: Readonly<{ + ts: string; + tsx: string; + }>; } type Mutable = { -readonly [P in keyof T]: T[P]; }; export type TesterConfigWithDefaults = Mutable< - RuleTesterConfig & Required> + RuleTesterConfig & + Required> >; + +export interface ValidTestCase> { + /** + * Name for the test case. + */ + readonly name?: string; + /** + * Code for the test case. + */ + readonly code: string; + /** + * Environments for the test case. + */ + readonly env?: Readonly>; + /** + * The fake filename for the test case. Useful for rules that make assertion about filenames. + */ + readonly filename?: string; + /** + * The additional global variables. + */ + readonly globals?: Record; + /** + * Options for the test case. + */ + readonly options?: Readonly; + /** + * The absolute path for the parser. + */ + readonly parser?: string; + /** + * Options for the parser. + */ + readonly parserOptions?: Readonly; + /** + * Settings for the test case. + */ + readonly settings?: Readonly; + /** + * Run this case exclusively for debugging in supported test frameworks. + */ + readonly only?: boolean; + /** + * Skip this case in supported test frameworks. + */ + readonly skip?: boolean; + /** + * Constraints that must pass in the current environment for the test to run + */ + readonly dependencyConstraints?: DependencyConstraint; +} + +export interface SuggestionOutput { + /** + * Reported message ID. + */ + readonly messageId: TMessageIds; + /** + * The data used to fill the message template. + */ + readonly data?: ReportDescriptorMessageData; + /** + * NOTE: Suggestions will be applied as a stand-alone change, without triggering multi-pass fixes. + * Each individual error has its own suggestion, so you have to show the correct, _isolated_ output for each suggestion. + */ + readonly output: string; + + // we disallow this because it's much better to use messageIds for reusable errors that are easily testable + // readonly desc?: string; +} + +export interface TestCaseError { + /** + * The 1-based column number of the reported start location. + */ + readonly column?: number; + /** + * The data used to fill the message template. + */ + readonly data?: ReportDescriptorMessageData; + /** + * The 1-based column number of the reported end location. + */ + readonly endColumn?: number; + /** + * The 1-based line number of the reported end location. + */ + readonly endLine?: number; + /** + * The 1-based line number of the reported start location. + */ + readonly line?: number; + /** + * Reported message ID. + */ + readonly messageId: TMessageIds; + /** + * Reported suggestions. + */ + readonly suggestions?: readonly SuggestionOutput[] | null; + /** + * The type of the reported AST node. + */ + readonly type?: AST_NODE_TYPES | AST_TOKEN_TYPES; + + // we disallow this because it's much better to use messageIds for reusable errors that are easily testable + // readonly message?: string | RegExp; +} + +export interface InvalidTestCase< + TMessageIds extends string, + TOptions extends Readonly, +> extends ValidTestCase { + /** + * Expected errors. + */ + readonly errors: readonly TestCaseError[]; + /** + * The expected code after autofixes are applied. If set to `null`, the test runner will assert that no autofix is suggested. + */ + readonly output?: string | null; + /** + * Constraints that must pass in the current environment for the test to run + */ + readonly dependencyConstraints?: DependencyConstraint; +} + +export 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[]; +} + +export interface NormalizedRunTests< + TMessageIds extends string, + TOptions extends Readonly, +> { + readonly valid: readonly ValidTestCase[]; + readonly invalid: readonly InvalidTestCase[]; +} diff --git a/packages/rule-tester/src/utils/config-schema.ts b/packages/rule-tester/src/utils/config-schema.ts index 8620ed5c1a0..8261ac8749c 100644 --- a/packages/rule-tester/src/utils/config-schema.ts +++ b/packages/rule-tester/src/utils/config-schema.ts @@ -2,11 +2,27 @@ import type { JSONSchema } from '@typescript-eslint/utils'; -const baseConfigProperties: JSONSchema.JSONSchema4Object = { +const baseConfigProperties: JSONSchema.JSONSchema4['properties'] = { $schema: { type: 'string' }, + defaultFilenames: { + type: 'object', + properties: { + ts: { type: 'string' }, + tsx: { type: 'string' }, + }, + required: ['ts', 'tsx'], + additionalProperties: false, + }, + dependencyConstraints: { + type: 'object', + additionalProperties: { + type: 'string', + }, + }, env: { type: 'object' }, extends: { $ref: '#/definitions/stringOrStrings' }, globals: { type: 'object' }, + noInlineConfig: { type: 'boolean' }, overrides: { type: 'array', items: { $ref: '#/definitions/overrideConfig' }, @@ -16,10 +32,9 @@ const baseConfigProperties: JSONSchema.JSONSchema4Object = { parserOptions: { type: 'object' }, plugins: { type: 'array' }, processor: { type: 'string' }, + reportUnusedDisableDirectives: { type: 'boolean' }, rules: { type: 'object' }, settings: { type: 'object' }, - noInlineConfig: { type: 'boolean' }, - reportUnusedDisableDirectives: { type: 'boolean' }, ecmaFeatures: { type: 'object' }, // deprecated; logs a warning when used }; diff --git a/packages/rule-tester/src/utils/validationHelpers.ts b/packages/rule-tester/src/utils/validationHelpers.ts index f5d4613075b..3619df6bf25 100644 --- a/packages/rule-tester/src/utils/validationHelpers.ts +++ b/packages/rule-tester/src/utils/validationHelpers.ts @@ -6,29 +6,31 @@ import type { Linter, SourceCode } from '@typescript-eslint/utils/ts-eslint'; * List every parameters possible on a test case that are not related to eslint * configuration */ -export const RuleTesterParameters = [ - 'name', +export const RULE_TESTER_PARAMETERS = [ 'code', + 'defaultFilenames', + 'dependencyConstraints', + 'errors', 'filename', + 'name', + 'only', 'options', - 'errors', 'output', - 'only', ] as const; /* * All allowed property names in error objects. */ export const ERROR_OBJECT_PARAMETERS: ReadonlySet = new Set([ - 'message', - 'messageId', - 'data', - 'type', - 'line', 'column', - 'endLine', + 'data', 'endColumn', + 'endLine', + 'line', + 'message', + 'messageId', 'suggestions', + 'type', ]); export const FRIENDLY_ERROR_OBJECT_PARAMETER_LIST = `[${[ ...ERROR_OBJECT_PARAMETERS, @@ -40,9 +42,9 @@ export const FRIENDLY_ERROR_OBJECT_PARAMETER_LIST = `[${[ * All allowed property names in suggestion objects. */ export const SUGGESTION_OBJECT_PARAMETERS: ReadonlySet = new Set([ + 'data', 'desc', 'messageId', - 'data', 'output', ]); export const FRIENDLY_SUGGESTION_OBJECT_PARAMETER_LIST = `[${[ diff --git a/packages/rule-tester/tests/RuleTester.test.ts b/packages/rule-tester/tests/RuleTester.test.ts new file mode 100644 index 00000000000..752ab636cee --- /dev/null +++ b/packages/rule-tester/tests/RuleTester.test.ts @@ -0,0 +1,822 @@ +import * as parser from '@typescript-eslint/parser'; +import { AST_NODE_TYPES } from '@typescript-eslint/typescript-estree'; +import type { TSESTree } from '@typescript-eslint/utils'; +import type { RuleModule } from '@typescript-eslint/utils/ts-eslint'; + +import * as dependencyConstraintsModule from '../src/dependencyConstraints'; +import { RuleTester } from '../src/RuleTester'; +import type { RuleTesterTestFrameworkFunctionBase } from '../src/TestFramework'; + +// we can't spy on the exports of an ES module - so we instead have to mock the entire module +jest.mock('../src/dependencyConstraints', () => { + const dependencyConstraints = jest.requireActual< + typeof dependencyConstraintsModule + >('../src/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, + }, +); + +jest.mock('@typescript-eslint/parser', () => { + const actualParser = jest.requireActual( + '@typescript-eslint/parser', + ); + return { + ...actualParser, + __esModule: true, + clearCaches: jest.fn(), + }; +}); + +/* eslint-disable jest/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 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 = { + type: AST_NODE_TYPES.Program, + body: [], + comments: [], + loc: { end: { column: 0, line: 0 }, start: { column: 0, line: 0 } }, + sourceType: 'module', + 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, + }; +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +const NOOP_RULE: RuleModule<'error', []> = { + meta: { + messages: { + error: 'error', + }, + type: 'problem', + schema: {}, + }, + defaultOptions: [], + create() { + return {}; + }, +}; + +function getTestConfigFromCall(): unknown[] { + return runRuleForItemSpy.mock.calls.map(c => c[2]); +} + +describe('RuleTester', () => { + describe('filenames', () => { + 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, { + 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/', + }, + }, + ], + invalid: [ + { + code: 'invalid tests should work as well', + errors: [{ messageId: 'error' }], + }, + ], + }); + + expect(getTestConfigFromCall()).toMatchInlineSnapshot(` + [ + { + "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/react.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/", + }, + }, + { + "code": "invalid tests should work as well", + "errors": [ + { + "messageId": "error", + }, + ], + "filename": "/some/path/that/totally/exists/file.ts", + }, + ] + `); + }); + + it('allows the automated filenames to be overridden in the constructor', () => { + const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: '/some/path/that/totally/exists/', + }, + defaultFilenames: { + ts: 'set-in-constructor.ts', + tsx: 'react-set-in-constructor.tsx', + }, + }); + + ruleTester.run('my-rule', NOOP_RULE, { + valid: [ + { + code: 'normal', + }, + { + code: 'jsx', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + ], + invalid: [], + }); + + expect(getTestConfigFromCall()).toMatchInlineSnapshot(` + [ + { + "code": "normal", + "filename": "/some/path/that/totally/exists/set-in-constructor.ts", + }, + { + "code": "jsx", + "filename": "/some/path/that/totally/exists/react-set-in-constructor.tsx", + "parserOptions": { + "ecmaFeatures": { + "jsx": true, + }, + }, + }, + ] + `); + }); + }); + + 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: [{ messageId: 'error' }], + // 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, { + 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', + }, + }, + ], + invalid: [ + { + code: 'failing - major', + errors: [{ messageId: 'error' }], + dependencyConstraints: { + 'totally-real-dependency': '999', + }, + }, + { + code: 'failing - major.minor', + errors: [{ messageId: 'error' }], + dependencyConstraints: { + 'totally-real-dependency': '999.0', + }, + }, + { + code: 'failing - major.minor.patch', + errors: [{ messageId: 'error' }], + dependencyConstraints: { + 'totally-real-dependency': '999.0.0', + }, + }, + ], + }); + + expect(getTestConfigFromCall()).toMatchInlineSnapshot(` + [ + { + "code": "passing - major", + "dependencyConstraints": { + "totally-real-dependency": "10", + }, + "filename": "file.ts", + "skip": false, + }, + { + "code": "passing - major.minor", + "dependencyConstraints": { + "totally-real-dependency": "10.0", + }, + "filename": "file.ts", + "skip": false, + }, + { + "code": "passing - major.minor.patch", + "dependencyConstraints": { + "totally-real-dependency": "10.0.0", + }, + "filename": "file.ts", + "skip": false, + }, + { + "code": "failing - major", + "dependencyConstraints": { + "totally-real-dependency": "999", + }, + "errors": [ + { + "messageId": "error", + }, + ], + "filename": "file.ts", + "skip": true, + }, + { + "code": "failing - major.minor", + "dependencyConstraints": { + "totally-real-dependency": "999.0", + }, + "errors": [ + { + "messageId": "error", + }, + ], + "filename": "file.ts", + "skip": true, + }, + { + "code": "failing - major.minor.patch", + "dependencyConstraints": { + "totally-real-dependency": "999.0.0", + }, + "errors": [ + { + "messageId": "error", + }, + ], + "filename": "file.ts", + "skip": true, + }, + ] + `); + }); + + it('correctly handles object-based semver', () => { + const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + }); + + ruleTester.run('my-rule', NOOP_RULE, { + valid: [ + { + code: 'passing - major', + dependencyConstraints: { + 'totally-real-dependency': { + range: '^10', + }, + }, + }, + { + code: 'passing - major.minor', + dependencyConstraints: { + 'totally-real-dependency': { + range: '<999', + }, + }, + }, + ], + invalid: [ + { + code: 'failing - major', + errors: [{ messageId: 'error' }], + dependencyConstraints: { + 'totally-real-dependency': { + range: '^999', + }, + }, + }, + { + code: 'failing - major.minor', + errors: [{ messageId: 'error' }], + dependencyConstraints: { + 'totally-real-dependency': { + range: '>=999.0', + }, + }, + }, + + { + code: 'failing with options', + errors: [{ messageId: 'error' }], + dependencyConstraints: { + 'totally-real-dependency-prerelease': { + range: '^10', + options: { + includePrerelease: false, + }, + }, + }, + }, + ], + }); + + expect(getTestConfigFromCall()).toMatchInlineSnapshot(` + [ + { + "code": "passing - major", + "dependencyConstraints": { + "totally-real-dependency": { + "range": "^10", + }, + }, + "filename": "file.ts", + "skip": false, + }, + { + "code": "passing - major.minor", + "dependencyConstraints": { + "totally-real-dependency": { + "range": "<999", + }, + }, + "filename": "file.ts", + "skip": false, + }, + { + "code": "failing - major", + "dependencyConstraints": { + "totally-real-dependency": { + "range": "^999", + }, + }, + "errors": [ + { + "messageId": "error", + }, + ], + "filename": "file.ts", + "skip": true, + }, + { + "code": "failing - major.minor", + "dependencyConstraints": { + "totally-real-dependency": { + "range": ">=999.0", + }, + }, + "errors": [ + { + "messageId": "error", + }, + ], + "filename": "file.ts", + "skip": true, + }, + { + "code": "failing with options", + "dependencyConstraints": { + "totally-real-dependency-prerelease": { + "options": { + "includePrerelease": false, + }, + "range": "^10", + }, + }, + "errors": [ + { + "messageId": "error", + }, + ], + "filename": "file.ts", + "skip": true, + }, + ] + `); + }); + + it('tests without versions should always be run', () => { + const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + }); + + ruleTester.run('my-rule', NOOP_RULE, { + 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', + }, + }, + ], + invalid: [ + { + code: 'no constraints is always run', + errors: [{ messageId: 'error' }], + }, + { + code: 'empty object is always run', + errors: [{ messageId: 'error' }], + dependencyConstraints: {}, + }, + { + code: 'failing constraint', + errors: [{ messageId: 'error' }], + dependencyConstraints: { + 'totally-real-dependency': '99999', + }, + }, + ], + }); + + expect(getTestConfigFromCall()).toMatchInlineSnapshot(` + [ + { + "code": "string based is always run", + "filename": "file.ts", + "skip": false, + }, + { + "code": "no constraints is always run", + "filename": "file.ts", + "skip": false, + }, + { + "code": "empty object is always run", + "dependencyConstraints": {}, + "filename": "file.ts", + "skip": false, + }, + { + "code": "passing constraint", + "dependencyConstraints": { + "totally-real-dependency": "10", + }, + "filename": "file.ts", + "skip": false, + }, + { + "code": "no constraints is always run", + "errors": [ + { + "messageId": "error", + }, + ], + "filename": "file.ts", + "skip": false, + }, + { + "code": "empty object is always run", + "dependencyConstraints": {}, + "errors": [ + { + "messageId": "error", + }, + ], + "filename": "file.ts", + "skip": false, + }, + { + "code": "failing constraint", + "dependencyConstraints": { + "totally-real-dependency": "99999", + }, + "errors": [ + { + "messageId": "error", + }, + ], + "filename": "file.ts", + "skip": true, + }, + ] + `); + }); + + describe('constructor constraints', () => { + it('skips all tests if a constructor constraint is not satisifed', () => { + const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + dependencyConstraints: { + 'totally-real-dependency': '999', + }, + }); + + ruleTester.run('my-rule', NOOP_RULE, { + valid: [ + { + code: 'passing - major', + }, + ], + invalid: [ + { + code: 'failing - major', + errors: [{ messageId: 'error' }], + }, + ], + }); + + // trigger the describe block + expect(mockedDescribeSkip.mock.calls).toHaveLength(1); + expect(mockedIt.mock.lastCall).toMatchInlineSnapshot(` + [ + "All tests skipped due to unsatisfied constructor dependency constraints", + [Function], + ] + `); + }); + + it('does not skip all tests if a constructor constraint is satisifed', () => { + const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + dependencyConstraints: { + 'totally-real-dependency': '10', + }, + }); + + ruleTester.run('my-rule', NOOP_RULE, { + valid: [ + { + code: 'valid', + }, + ], + invalid: [ + { + code: 'invalid', + errors: [{ messageId: 'error' }], + }, + ], + }); + + // trigger the describe block + expect(mockedDescribe.mock.calls).toHaveLength(3); + expect(mockedDescribeSkip.mock.calls).toHaveLength(0); + // expect(mockedIt.mock.lastCall).toMatchInlineSnapshot(`undefined`); + }); + }); + }); +});