From d2e7f1512a79821cf77ddcc2e1e0219eea91c0ac Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Tue, 21 Mar 2023 13:46:45 +1030 Subject: [PATCH 01/11] add a new package and fork the base class --- .eslintignore | 3 + .prettierignore | 3 + packages/eslint-plugin-tslint/package.json | 3 +- .../eslint-plugin-tslint/src/rules/config.ts | 19 +- packages/rule-tester/LICENSE | 21 + packages/rule-tester/README.md | 8 + packages/rule-tester/jest.config.js | 7 + packages/rule-tester/package.json | 78 + packages/rule-tester/project.json | 15 + packages/rule-tester/src/RuleTester.ts | 834 +++++ packages/rule-tester/src/TestFramework.ts | 120 + .../rule-tester/src/dependencyConstraints.ts | 63 + packages/rule-tester/src/index.ts | 0 packages/rule-tester/src/types.ts | 14 + .../rule-tester/src/utils/SourceCodeFixer.ts | 117 + packages/rule-tester/src/utils/ajv.ts | 23 + .../src/utils/cloneDeeplyExcludesParent.ts | 23 + .../rule-tester/src/utils/config-schema.ts | 76 + .../rule-tester/src/utils/config-validator.ts | 288 ++ .../src/utils/deprecation-warnings.ts | 40 + .../rule-tester/src/utils/freezeDeeply.ts | 17 + .../src/utils/getRuleOptionsSchema.ts | 37 + .../rule-tester/src/utils/hasOwnProperty.ts | 8 + packages/rule-tester/src/utils/interpolate.ts | 27 + .../rule-tester/src/utils/isReadonlyArray.ts | 4 + .../src/utils/validationHelpers.ts | 179 + .../tests/eslint-base/eslint-base.test.js | 2882 +++++++++++++++++ .../fixtures/empty-program-parser.js | 29 + .../eslint-base/fixtures/enhanced-parser.js | 20 + .../eslint-base/fixtures/enhanced-parser2.js | 25 + .../eslint-base/fixtures/fixes-one-problem.js | 25 + .../tests/eslint-base/fixtures/messageId.js | 39 + .../fixtures/modify-ast-at-first.js | 37 + .../fixtures/modify-ast-at-last.js | 37 + .../tests/eslint-base/fixtures/modify-ast.js | 21 + .../tests/eslint-base/fixtures/no-eval.js | 19 + .../eslint-base/fixtures/no-invalid-args.js | 23 + .../eslint-base/fixtures/no-invalid-schema.js | 21 + .../fixtures/no-schema-violation.js | 22 + .../eslint-base/fixtures/no-test-filename | 19 + .../eslint-base/fixtures/no-test-global.js | 26 + .../eslint-base/fixtures/no-test-settings.js | 22 + .../tests/eslint-base/fixtures/no-var.js | 28 + .../tests/eslint-base/fixtures/suggestions.js | 76 + packages/rule-tester/tsconfig.build.json | 11 + packages/rule-tester/tsconfig.json | 8 + packages/rule-tester/typings/eslint.d.ts | 24 + .../typescript-estree/src/simple-traverse.ts | 27 +- packages/utils/package.json | 1 - packages/utils/src/json-schema.ts | 24 +- packages/utils/src/ts-eslint/Linter.ts | 26 +- packages/utils/src/ts-eslint/Rule.ts | 24 +- packages/utils/src/ts-eslint/RuleTester.ts | 5 +- patches/ajv+6.12.6.patch | 13 + ...slint+8.34.0.patch => eslint+8.36.0.patch} | 0 yarn.lock | 359 +- 56 files changed, 5836 insertions(+), 84 deletions(-) create mode 100644 packages/rule-tester/LICENSE create mode 100644 packages/rule-tester/README.md create mode 100644 packages/rule-tester/jest.config.js create mode 100644 packages/rule-tester/package.json create mode 100644 packages/rule-tester/project.json create mode 100644 packages/rule-tester/src/RuleTester.ts create mode 100644 packages/rule-tester/src/TestFramework.ts create mode 100644 packages/rule-tester/src/dependencyConstraints.ts create mode 100644 packages/rule-tester/src/index.ts create mode 100644 packages/rule-tester/src/types.ts create mode 100644 packages/rule-tester/src/utils/SourceCodeFixer.ts create mode 100644 packages/rule-tester/src/utils/ajv.ts create mode 100644 packages/rule-tester/src/utils/cloneDeeplyExcludesParent.ts create mode 100644 packages/rule-tester/src/utils/config-schema.ts create mode 100644 packages/rule-tester/src/utils/config-validator.ts create mode 100644 packages/rule-tester/src/utils/deprecation-warnings.ts create mode 100644 packages/rule-tester/src/utils/freezeDeeply.ts create mode 100644 packages/rule-tester/src/utils/getRuleOptionsSchema.ts create mode 100644 packages/rule-tester/src/utils/hasOwnProperty.ts create mode 100644 packages/rule-tester/src/utils/interpolate.ts create mode 100644 packages/rule-tester/src/utils/isReadonlyArray.ts create mode 100644 packages/rule-tester/src/utils/validationHelpers.ts create mode 100644 packages/rule-tester/tests/eslint-base/eslint-base.test.js create mode 100644 packages/rule-tester/tests/eslint-base/fixtures/empty-program-parser.js create mode 100644 packages/rule-tester/tests/eslint-base/fixtures/enhanced-parser.js create mode 100644 packages/rule-tester/tests/eslint-base/fixtures/enhanced-parser2.js create mode 100644 packages/rule-tester/tests/eslint-base/fixtures/fixes-one-problem.js create mode 100644 packages/rule-tester/tests/eslint-base/fixtures/messageId.js create mode 100644 packages/rule-tester/tests/eslint-base/fixtures/modify-ast-at-first.js create mode 100644 packages/rule-tester/tests/eslint-base/fixtures/modify-ast-at-last.js create mode 100644 packages/rule-tester/tests/eslint-base/fixtures/modify-ast.js create mode 100644 packages/rule-tester/tests/eslint-base/fixtures/no-eval.js create mode 100644 packages/rule-tester/tests/eslint-base/fixtures/no-invalid-args.js create mode 100644 packages/rule-tester/tests/eslint-base/fixtures/no-invalid-schema.js create mode 100644 packages/rule-tester/tests/eslint-base/fixtures/no-schema-violation.js create mode 100644 packages/rule-tester/tests/eslint-base/fixtures/no-test-filename create mode 100644 packages/rule-tester/tests/eslint-base/fixtures/no-test-global.js create mode 100644 packages/rule-tester/tests/eslint-base/fixtures/no-test-settings.js create mode 100644 packages/rule-tester/tests/eslint-base/fixtures/no-var.js create mode 100644 packages/rule-tester/tests/eslint-base/fixtures/suggestions.js create mode 100644 packages/rule-tester/tsconfig.build.json create mode 100644 packages/rule-tester/tsconfig.json create mode 100644 packages/rule-tester/typings/eslint.d.ts create mode 100644 patches/ajv+6.12.6.patch rename patches/{eslint+8.34.0.patch => eslint+8.36.0.patch} (100%) diff --git a/.eslintignore b/.eslintignore index d372e0ba7c1..431bc803029 100644 --- a/.eslintignore +++ b/.eslintignore @@ -12,3 +12,6 @@ packages/types/src/generated/**/*.ts # Playground types downloaded from the web packages/website/src/vendor + +# see the file header in eslint-base.test.js for more info +packages/rule-tester/tests/eslint-base diff --git a/.prettierignore b/.prettierignore index dd9551fb811..b807ea259fe 100644 --- a/.prettierignore +++ b/.prettierignore @@ -18,3 +18,6 @@ CHANGELOG.md packages/website/.docusaurus packages/website/build + +# see the file header in eslint-base.test.js for more info +packages/rule-tester/tests/eslint-base diff --git a/packages/eslint-plugin-tslint/package.json b/packages/eslint-plugin-tslint/package.json index 720bc5e5e63..2541fb80924 100644 --- a/packages/eslint-plugin-tslint/package.json +++ b/packages/eslint-plugin-tslint/package.json @@ -46,8 +46,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@typescript-eslint/utils": "5.56.0", - "lodash": "^4.17.21" + "@typescript-eslint/utils": "5.56.0" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0", diff --git a/packages/eslint-plugin-tslint/src/rules/config.ts b/packages/eslint-plugin-tslint/src/rules/config.ts index 15bd341aa28..847b6846ea9 100644 --- a/packages/eslint-plugin-tslint/src/rules/config.ts +++ b/packages/eslint-plugin-tslint/src/rules/config.ts @@ -1,10 +1,27 @@ import { ESLintUtils } from '@typescript-eslint/utils'; -import memoize from 'lodash/memoize'; import type { RuleSeverity } from 'tslint'; import { Configuration } from 'tslint'; import { CustomLinter } from '../custom-linter'; +function memoize unknown>( + func: T, + resolver: (...args: Parameters) => string, +): T { + const cache = new Map>(); + const memoized = function (...args) { + const key = resolver(...(args as Parameters)); + + if (cache.has(key)) { + return cache.get(key)!; + } + const result = func(...args); + cache.set(key, result as ReturnType); + return result; + } as T; + return memoized; +} + // note - cannot migrate this to an import statement because it will make TSC copy the package.json to the dist folder // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const version: string = require('../../package.json'); diff --git a/packages/rule-tester/LICENSE b/packages/rule-tester/LICENSE new file mode 100644 index 00000000000..a1164108d4d --- /dev/null +++ b/packages/rule-tester/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 typescript-eslint and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/rule-tester/README.md b/packages/rule-tester/README.md new file mode 100644 index 00000000000..8f67529142c --- /dev/null +++ b/packages/rule-tester/README.md @@ -0,0 +1,8 @@ +# `@typescript-eslint/rule-tester` + +> Tooling to test ESLint rules + +[![NPM Version](https://img.shields.io/npm/v/@typescript-eslint/rule-tester.svg?style=flat-square)](https://www.npmjs.com/package/@typescript-eslint/rule-tester) +[![NPM Downloads](https://img.shields.io/npm/dm/@typescript-eslint/rule-tester.svg?style=flat-square)](https://www.npmjs.com/package/@typescript-eslint/rule-tester) + +👉 See **https://typescript-eslint.io/architecture/rule-tester** for documentation on this package. diff --git a/packages/rule-tester/jest.config.js b/packages/rule-tester/jest.config.js new file mode 100644 index 00000000000..910991b20cf --- /dev/null +++ b/packages/rule-tester/jest.config.js @@ -0,0 +1,7 @@ +'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 new file mode 100644 index 00000000000..df7367f0e50 --- /dev/null +++ b/packages/rule-tester/package.json @@ -0,0 +1,78 @@ +{ + "name": "@typescript-eslint/rule-tester", + "version": "5.56.0", + "description": "Tooling to test ESLint rules", + "files": [ + "dist", + "_ts4.2", + "README.md", + "LICENSE" + ], + "type": "commonjs", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "engines": { + "node": "^14.18.0 || ^16.0.0 || >=18.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/typescript-eslint/typescript-eslint.git", + "directory": "packages/rule-tester" + }, + "bugs": { + "url": "https://github.com/typescript-eslint/typescript-eslint/issues" + }, + "license": "MIT", + "keywords": [ + "eslint", + "typescript", + "estree" + ], + "scripts": { + "build": "tsc -b tsconfig.build.json", + "postbuild": "downlevel-dts dist _ts4.2/dist --to=4.2", + "clean": "tsc -b tsconfig.build.json --clean", + "postclean": "rimraf dist && rimraf _ts3.4 && rimraf coverage", + "format": "prettier --write \"./**/*.{ts,mts,cts,tsx,js,mjs,cjs,jsx,json,md,css}\" --ignore-path ../../.prettierignore", + "lint": "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": "jest --coverage", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "//": "NOTE - AJV is out-of-date, but it's intentionally synced with ESLint - https://github.com/eslint/eslint/blob/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/package.json#L70", + "dependencies": { + "@typescript-eslint/typescript-estree": "5.56.0", + "@typescript-eslint/utils": "5.56.0", + "lodash.merge": "4.6.2", + "semver": "^7.3.7", + "ajv": "^6.10.0" + }, + "peerDependencies": { + "@eslint/eslintrc": ">=2", + "eslint": ">=8" + }, + "devDependencies": { + "@types/lodash.merge": "4.6.7", + "chai": "^4.0.1", + "mocha": "^8.3.2", + "sinon": "^11.0.0", + "source-map-support": "^0.5.21" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "typesVersions": { + "<3.8": { + "*": [ + "_ts3.4/*" + ] + } + } +} diff --git a/packages/rule-tester/project.json b/packages/rule-tester/project.json new file mode 100644 index 00000000000..148a81c84c8 --- /dev/null +++ b/packages/rule-tester/project.json @@ -0,0 +1,15 @@ +{ + "name": "rule-tester", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "type": "library", + "implicitDependencies": [], + "targets": { + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/rule-tester/**/*.ts"] + } + } + } +} diff --git a/packages/rule-tester/src/RuleTester.ts b/packages/rule-tester/src/RuleTester.ts new file mode 100644 index 00000000000..b6e642cd956 --- /dev/null +++ b/packages/rule-tester/src/RuleTester.ts @@ -0,0 +1,834 @@ +// Forked from https://github.com/eslint/eslint/blob/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/lib/rule-tester/rule-tester.js + +import assert from 'node:assert'; +import path from 'node:path'; +import util from 'node:util'; + +import type { TSESTree } from '@typescript-eslint/utils'; +import type { + AnyRuleModule, + InvalidTestCase, + 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 +// that ESLint uses, not our custom override typed version +import { SourceCode } from 'eslint'; +import merge from 'lodash.merge'; + +import { TestFramework } from './TestFramework'; +import type { RuleTesterConfig, TesterConfigWithDefaults } from './types'; +import { ajvBuilder } from './utils/ajv'; +import { cloneDeeplyExcludesParent } from './utils/cloneDeeplyExcludesParent'; +import { validate } from './utils/config-validator'; +import { freezeDeeply } from './utils/freezeDeeply'; +import { getRuleOptionsSchema } from './utils/getRuleOptionsSchema'; +import { hasOwnProperty } from './utils/hasOwnProperty'; +import { interpolate } from './utils/interpolate'; +import { isReadonlyArray } from './utils/isReadonlyArray'; +import * as SourceCodeFixer from './utils/SourceCodeFixer'; +import { + emitLegacyRuleAPIWarning, + emitMissingSchemaWarning, + ERROR_OBJECT_PARAMETERS, + FRIENDLY_ERROR_OBJECT_PARAMETER_LIST, + FRIENDLY_SUGGESTION_OBJECT_PARAMETER_LIST, + getCommentsDeprecation, + REQUIRED_SCENARIOS, + RuleTesterParameters, + sanitize, + SUGGESTION_OBJECT_PARAMETERS, + wrapParser, +} from './utils/validationHelpers'; + +const ajv = ajvBuilder({ strictDefaults: true }); +const TYPESCRIPT_ESLINT_PARSER_PATH = require.resolve( + '@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, + rules: {}, +}; +let defaultConfig: TesterConfigWithDefaults = { ...testerDefaultConfig }; + +export class RuleTester extends TestFramework { + private testerConfig: TesterConfigWithDefaults; + private rules: Record; + private linter: Linter; + + /** + * Creates a new instance of RuleTester. + */ + constructor(testerConfig?: RuleTesterConfig) { + super(); + + /** + * The configuration to use for this tester. Combination of the tester + * configuration and the default configuration. + */ + this.testerConfig = merge({}, defaultConfig, testerConfig, { + rules: { 'rule-tester/validate-ast': 'error' }, + }); + + /** + * Rule definitions to define before tests. + */ + this.rules = {}; + this.linter = new Linter(); + } + + /** + * Set the configuration to use for all future tests + */ + static setDefaultConfig(config: RuleTesterConfig): void { + if (typeof config !== 'object' || config == null) { + throw new TypeError( + 'RuleTester.setDefaultConfig: config must be an object', + ); + } + // Make sure the rules object exists since it is assumed to exist later + defaultConfig = { rules: {}, ...config }; + } + + /** + * Get the current configuration used for all tests + */ + static getDefaultConfig(): Readonly { + return defaultConfig; + } + + /** + * Reset the configuration to the initial configuration of the tester removing + * any changes made until now. + */ + static resetDefaultConfig(): void { + defaultConfig = merge({}, testerDefaultConfig); + } + + /** + * Adds the `only` property to a test to run it in isolation. + */ + static only>( + item: string | ValidTestCase, + ): ValidTestCase; + /** + * Adds the `only` property to a test to run it in isolation. + */ + static only>( + item: InvalidTestCase, + ): InvalidTestCase; + static only>( + item: + | string + | ValidTestCase + | InvalidTestCase, + ): ValidTestCase | InvalidTestCase { + if (typeof item === 'string') { + return { code: item, only: true }; + } + + return { ...item, only: true }; + } + + /** + * Define a rule for one particular run of tests. + */ + defineRule(name: string, rule: AnyRuleModule): void { + this.rules[name] = rule; + } + + /** + * Adds a new rule test to execute. + */ + run( + ruleName: string, + rule: RuleModule, + test: RunTests, + ): void { + const testerConfig = this.testerConfig; + const linter = this.linter; + + if (!test || typeof test !== 'object') { + throw new TypeError( + `Test Scenarios for rule ${ruleName} : Could not find test scenario object`, + ); + } + + const scenarioErrors: string[] = []; + REQUIRED_SCENARIOS.forEach(scenarioType => { + if (!test[scenarioType]) { + scenarioErrors.push( + `Could not find any ${scenarioType} test scenarios`, + ); + } + }); + + if (scenarioErrors.length > 0) { + throw new Error( + [ + `Test Scenarios for rule ${ruleName} is invalid:`, + ...scenarioErrors, + ].join('\n'), + ); + } + + if (typeof rule === 'function') { + emitLegacyRuleAPIWarning(ruleName); + } + + linter.defineRule( + ruleName, + Object.assign({}, rule, { + // Create a wrapper rule that freezes the `context` properties. + create(context: RuleContext) { + freezeDeeply(context.options); + freezeDeeply(context.settings); + freezeDeeply(context.parserOptions); + + return (typeof rule === 'function' ? rule : rule.create)(context); + }, + }), + ); + + 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]; + } + + /* + * Create the config object from the tester config and this item + * specific configurations. + */ + config = merge(config, itemConfig); + } + + 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'; + } + + const schema = getRuleOptionsSchema(rule); + + /* + * 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; + }, + }; + }, + }); + + 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; + } + + linter.defineParser( + config.parser, + wrapParser(require(config.parser) as Linter.ParserModule), + ); + + 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 + ',', + ), + ); + } + + /* + * `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}`, + ); + } + } + + 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 = linter.verify(code, config, filename); + } finally { + // @ts-expect-error -- we don't define deprecated members on our types + SourceCode.prototype.getComments = getComments; + } + + const fatalErrorMessage = messages.find(m => m.fatal); + + assert( + !fatalErrorMessage, + `A fatal parsing error occurred: ${fatalErrorMessage?.message}`, + ); + + // 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); + + assert( + !errorMessageInFix, + [ + 'A fatal parsing error occurred in autofix.', + `Error: ${errorMessageInFix?.message}`, + 'Autofix output:', + output, + ].join('\n'), + ); + } 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!), + }; + } + + /** + * Check if the AST was changed + */ + function assertASTDidntChange(beforeAST: unknown, afterAST: unknown): void { + assert.deepStrictEqual( + beforeAST, + afterAST, + 'Rule should not modify AST.', + ); + } + + /** + * 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 }; + + 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", + ); + } + + const result = runRuleForItem(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); + } + + /** + * 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); + } + } + + /** + * Check if the template is invalid or not + * all invalid cases go through this. + */ + function testInvalidTemplate( + item: InvalidTestCase, + ): void { + 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.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'); + } + + 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; + + if (typeof item.errors === 'number') { + if (item.errors === 0) { + assert.fail("Invalid cases must have 'error' value greater than 0"); + } + + assert.strictEqual( + messages.length, + 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( + messages.length, + 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]; + + 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( + ERROR_OBJECT_PARAMETERS.has(propertyName), + `Invalid error property name '${propertyName}'. Expected one of ${FRIENDLY_ERROR_OBJECT_PARAMETER_LIST}.`, + ); + }); + + // @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.ok( + hasOwnProperty(error, 'data') + ? hasOwnProperty(error, 'messageId') + : true, + "Error must specify 'messageId' if 'data' is used.", + ); + + if (error.type) { + assert.strictEqual( + message.nodeType, + error.type, + `Error type should be ${error.type}, found ${message.nodeType}`, + ); + } + + 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 ( + 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`, + ); + + 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( + 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} :`; + + // @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.`, + ); + } + + 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, + 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.`, + ); + } + + 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.', + ); + } + } else { + assert.strictEqual( + result.output, + item.code, + "The rule fixed the code. Please add 'output' property.", + ); + } + + assertASTDidntChange(result.beforeAST, result.afterAST); + } + + /* + * 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); + }); + }); + }); + + 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); + }); + }); + }); + }); + } +} diff --git a/packages/rule-tester/src/TestFramework.ts b/packages/rule-tester/src/TestFramework.ts new file mode 100644 index 00000000000..70e2d085c0c --- /dev/null +++ b/packages/rule-tester/src/TestFramework.ts @@ -0,0 +1,120 @@ +/** + * @param text a string describing the rule + * @param callback the test callback + */ +type RuleTesterTestFrameworkFunction = ( + text: string, + callback: () => void, +) => void; +type RuleTesterTestFrameworkItFunction = RuleTesterTestFrameworkFunction & { + only?: RuleTesterTestFrameworkFunction; +}; + +/** + * @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; + +/* + * NOTE - If people use `mocha test.js --watch` command, the test function + * instances are different for each execution. + * This is why the getters get fresh instance always. + */ + +/** + * Defines a test framework used by the rule tester + * This class defaults to using functions defined on the global scope, but also + * allows the user to manually supply functions in case they want to roll their + * own tooling + */ +export abstract class TestFramework { + static get afterAll(): AfterAll { + if (OVERRIDE_AFTER_ALL != null) { + return OVERRIDE_AFTER_ALL; + } + if (typeof afterAll === 'function') { + return afterAll; + } + throw new Error( + '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) { + OVERRIDE_AFTER_ALL = value; + } + + static get describe(): RuleTesterTestFrameworkFunction { + if (OVERRIDE_DESCRIBE != null) { + return OVERRIDE_DESCRIBE; + } + if (typeof describe === 'function') { + return describe; + } + throw new Error( + '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, + ) { + OVERRIDE_DESCRIBE = value; + } + + static get it(): RuleTesterTestFrameworkItFunction { + if (OVERRIDE_IT != null) { + return OVERRIDE_IT; + } + if (typeof it === 'function') { + return it; + } + throw new Error( + '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) { + OVERRIDE_IT = value; + } + + static get itOnly(): RuleTesterTestFrameworkFunction { + if (OVERRIDE_IT_ONLY != null) { + return OVERRIDE_IT_ONLY; + } + if ( + typeof OVERRIDE_IT === 'function' && + typeof OVERRIDE_IT.only === 'function' + ) { + return OVERRIDE_IT.only.bind(OVERRIDE_IT); + } + if (typeof it === 'function' && typeof it.only === 'function') { + return it.only.bind(it); + } + if ( + typeof OVERRIDE_DESCRIBE === 'function' || + typeof OVERRIDE_IT === 'function' + ) { + throw new Error( + 'Set `RuleTester.itOnly` to use `only` with a custom test framework.\n' + + 'See https://eslint.org/docs/latest/integrate/nodejs-api#customizing-ruletester for more.', + ); + } + if (typeof it === 'function') { + throw new Error( + 'The current test framework does not support exclusive tests with `only`.', + ); + } + throw new Error( + '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) { + OVERRIDE_IT_ONLY = value; + } +} diff --git a/packages/rule-tester/src/dependencyConstraints.ts b/packages/rule-tester/src/dependencyConstraints.ts new file mode 100644 index 00000000000..0bc1f5fc5ce --- /dev/null +++ b/packages/rule-tester/src/dependencyConstraints.ts @@ -0,0 +1,63 @@ +import * as semver from 'semver'; + +interface SemverVersionConstraint { + readonly range: string; + readonly options?: Parameters[2]; +} +type AtLeastVersionConstraint = + | `${number}` + | `${number}.${number}` + | `${number}.${number}.${number}` + | `${number}.${number}.${number}-${string}`; +type VersionConstraint = SemverVersionConstraint | AtLeastVersionConstraint; +interface DependencyConstraint { + /** + * Passing a string for the value is shorthand for a '>=' constraint + */ + readonly [packageName: string]: VersionConstraint; +} + +const BASE_SATISFIES_OPTIONS: semver.RangeOptions = { + includePrerelease: true, +}; + +function satisfiesDependencyConstraint( + packageName: string, + constraintIn: DependencyConstraint[string], +): boolean { + const constraint: SemverVersionConstraint = + typeof constraintIn === 'string' + ? { + range: `>=${constraintIn}`, + } + : constraintIn; + + return semver.satisfies( + (require(`${packageName}/package.json`) as { version: string }).version, + constraint.range, + typeof constraint.options === 'object' + ? { ...BASE_SATISFIES_OPTIONS, ...constraint.options } + : constraint.options, + ); +} + +function satisfiesAllDependencyConstraints( + dependencyConstraints: DependencyConstraint | undefined, +): boolean { + if (dependencyConstraints == null) { + return true; + } + + for (const [packageName, constraint] of Object.entries( + dependencyConstraints, + )) { + if (!satisfiesDependencyConstraint(packageName, constraint)) { + return false; + } + } + + return true; +} + +export { satisfiesAllDependencyConstraints }; +export type { DependencyConstraint }; diff --git a/packages/rule-tester/src/index.ts b/packages/rule-tester/src/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/rule-tester/src/types.ts b/packages/rule-tester/src/types.ts new file mode 100644 index 00000000000..a26e7ffe562 --- /dev/null +++ b/packages/rule-tester/src/types.ts @@ -0,0 +1,14 @@ +import type { Linter, ParserOptions } from '@typescript-eslint/utils/ts-eslint'; + +export interface RuleTesterConfig extends Linter.Config { + // should be require.resolve(parserPackageName) + readonly parser: string; + readonly parserOptions?: Readonly; +} + +type Mutable = { + -readonly [P in keyof T]: T[P]; +}; +export type TesterConfigWithDefaults = Mutable< + RuleTesterConfig & Required> +>; diff --git a/packages/rule-tester/src/utils/SourceCodeFixer.ts b/packages/rule-tester/src/utils/SourceCodeFixer.ts new file mode 100644 index 00000000000..9346b42b080 --- /dev/null +++ b/packages/rule-tester/src/utils/SourceCodeFixer.ts @@ -0,0 +1,117 @@ +// Forked from https://github.com/eslint/eslint/blob/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/lib/linter/source-code-fixer.js + +import type { Linter } from '@typescript-eslint/utils/ts-eslint'; + +import { hasOwnProperty } from './hasOwnProperty'; + +type LintMessage = Linter.LintMessage | Linter.LintSuggestion; +type LintMessageWithFix = LintMessage & Required>; + +const BOM = '\uFEFF'; + +/** + * Compares items in a messages array by range. + * @returns -1 if a comes before b, 1 if a comes after b, 0 if equal. + */ +function compareMessagesByFixRange( + a: LintMessageWithFix, + b: LintMessageWithFix, +): number { + return a.fix.range[0] - b.fix.range[0] || a.fix.range[1] - b.fix.range[1]; +} + +/** + * Compares items in a messages array by line and column. + * @returns -1 if a comes before b, 1 if a comes after b, 0 if equal. + */ +function compareMessagesByLocation(a: LintMessage, b: LintMessage): number { + // @ts-expect-error -- it's not possible for suggestions to reach this location + return a.line - b.line || a.column - b.column; +} + +/** + * Applies the fixes specified by the messages to the given text. Tries to be + * smart about the fixes and won't apply fixes over the same area in the text. + * @param sourceText The text to apply the changes to. + * @param messages The array of messages reported by ESLint. + * @returns {Object} An object containing the fixed text and any unfixed messages. + */ +export function applyFixes( + sourceText: string, + messages: readonly LintMessage[], +): { + fixed: boolean; + messages: readonly LintMessage[]; + output: string; +} { + // clone the array + const remainingMessages: LintMessage[] = []; + const fixes: LintMessageWithFix[] = []; + const bom = sourceText.startsWith(BOM) ? BOM : ''; + const text = bom ? sourceText.slice(1) : sourceText; + let lastPos = Number.NEGATIVE_INFINITY; + let output = bom; + + /** + * Try to use the 'fix' from a problem. + * @param {Message} problem The message object to apply fixes from + * @returns {boolean} Whether fix was successfully applied + */ + function attemptFix(problem: LintMessageWithFix): boolean { + const fix = problem.fix; + const start = fix.range[0]; + const end = fix.range[1]; + + // Remain it as a problem if it's overlapped or it's a negative range + if (lastPos >= start || start > end) { + remainingMessages.push(problem); + return false; + } + + // Remove BOM. + if ((start < 0 && end >= 0) || (start === 0 && fix.text.startsWith(BOM))) { + output = ''; + } + + // Make output to this fix. + output += text.slice(Math.max(0, lastPos), Math.max(0, start)); + output += fix.text; + lastPos = end; + return true; + } + + messages.forEach(problem => { + if (hasOwnProperty(problem, 'fix')) { + fixes.push(problem); + } else { + remainingMessages.push(problem); + } + }); + + if (fixes.length) { + let fixesWereApplied = false; + + for (const problem of fixes.sort(compareMessagesByFixRange)) { + attemptFix(problem); + + /* + * The only time attemptFix will fail is if a previous fix was + * applied which conflicts with it. So we can mark this as true. + */ + fixesWereApplied = true; + } + output += text.slice(Math.max(0, lastPos)); + + return { + fixed: fixesWereApplied, + messages: remainingMessages.sort(compareMessagesByLocation), + output, + }; + } + + return { + fixed: false, + messages, + output: bom + text, + }; +} diff --git a/packages/rule-tester/src/utils/ajv.ts b/packages/rule-tester/src/utils/ajv.ts new file mode 100644 index 00000000000..f3dcacc641d --- /dev/null +++ b/packages/rule-tester/src/utils/ajv.ts @@ -0,0 +1,23 @@ +// Forked from https://github.com/eslint/eslint/blob/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/lib/shared/ajv.js + +import Ajv from 'ajv'; +import metaSchema from 'ajv/lib/refs/json-schema-draft-04.json'; + +export function ajvBuilder(additionalOptions = {}): Ajv.Ajv { + const ajv = new Ajv({ + meta: false, + useDefaults: true, + validateSchema: false, + missingRefs: 'ignore', + verbose: true, + schemaId: 'auto', + ...additionalOptions, + }); + + ajv.addMetaSchema(metaSchema); + + // @ts-expect-error -- this is an untyped part of the ajv API + ajv._opts.defaultMeta = metaSchema.id; + + return ajv; +} diff --git a/packages/rule-tester/src/utils/cloneDeeplyExcludesParent.ts b/packages/rule-tester/src/utils/cloneDeeplyExcludesParent.ts new file mode 100644 index 00000000000..b3bb23869a7 --- /dev/null +++ b/packages/rule-tester/src/utils/cloneDeeplyExcludesParent.ts @@ -0,0 +1,23 @@ +/** + * Clones a given value deeply. + * Note: This ignores `parent` property. + */ +export function cloneDeeplyExcludesParent(x: T): T { + if (typeof x === 'object' && x != null) { + if (Array.isArray(x)) { + return x.map(cloneDeeplyExcludesParent) as T; + } + + const retv = {} as typeof x; + + for (const key in x) { + if (key !== 'parent' && Object.prototype.hasOwnProperty.call(x, key)) { + retv[key] = cloneDeeplyExcludesParent(x[key]); + } + } + + return retv; + } + + return x; +} diff --git a/packages/rule-tester/src/utils/config-schema.ts b/packages/rule-tester/src/utils/config-schema.ts new file mode 100644 index 00000000000..8620ed5c1a0 --- /dev/null +++ b/packages/rule-tester/src/utils/config-schema.ts @@ -0,0 +1,76 @@ +// Forked from https://github.com/eslint/eslint/blob/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/conf/config-schema.js + +import type { JSONSchema } from '@typescript-eslint/utils'; + +const baseConfigProperties: JSONSchema.JSONSchema4Object = { + $schema: { type: 'string' }, + env: { type: 'object' }, + extends: { $ref: '#/definitions/stringOrStrings' }, + globals: { type: 'object' }, + overrides: { + type: 'array', + items: { $ref: '#/definitions/overrideConfig' }, + additionalItems: false, + }, + parser: { type: ['string', 'null'] }, + parserOptions: { type: 'object' }, + plugins: { type: 'array' }, + processor: { type: 'string' }, + rules: { type: 'object' }, + settings: { type: 'object' }, + noInlineConfig: { type: 'boolean' }, + reportUnusedDisableDirectives: { type: 'boolean' }, + + ecmaFeatures: { type: 'object' }, // deprecated; logs a warning when used +}; + +export const configSchema: JSONSchema.JSONSchema4 = { + definitions: { + stringOrStrings: { + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { type: 'string' }, + additionalItems: false, + }, + ], + }, + stringOrStringsRequired: { + oneOf: [ + { type: 'string' }, + { + type: 'array', + items: { type: 'string' }, + additionalItems: false, + minItems: 1, + }, + ], + }, + + // Config at top-level. + objectConfig: { + type: 'object', + properties: { + root: { type: 'boolean' }, + ignorePatterns: { $ref: '#/definitions/stringOrStrings' }, + ...baseConfigProperties, + }, + additionalProperties: false, + }, + + // Config in `overrides`. + overrideConfig: { + type: 'object', + properties: { + excludedFiles: { $ref: '#/definitions/stringOrStrings' }, + files: { $ref: '#/definitions/stringOrStringsRequired' }, + ...baseConfigProperties, + }, + required: ['files'], + additionalProperties: false, + }, + }, + + $ref: '#/definitions/objectConfig', +}; diff --git a/packages/rule-tester/src/utils/config-validator.ts b/packages/rule-tester/src/utils/config-validator.ts new file mode 100644 index 00000000000..ef88f7e664e --- /dev/null +++ b/packages/rule-tester/src/utils/config-validator.ts @@ -0,0 +1,288 @@ +// Forked from https://github.com/eslint/eslint/blob/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/lib/shared/config-validator.js + +import util from 'node:util'; + +import { Legacy } from '@eslint/eslintrc'; +import type { AnyRuleModule, Linter } from '@typescript-eslint/utils/ts-eslint'; +import type { + AdditionalPropertiesParams, + ErrorObject as AjvErrorObject, + ValidateFunction, +} from 'ajv'; +import { builtinRules } from 'eslint/use-at-your-own-risk'; + +import type { TesterConfigWithDefaults } from '../types'; +import { ajvBuilder } from './ajv'; +import { configSchema } from './config-schema'; +import { emitDeprecationWarning } from './deprecation-warnings'; +import { getRuleOptionsSchema } from './getRuleOptionsSchema'; +import { hasOwnProperty } from './hasOwnProperty'; + +type GetAdditionalRule = (ruleId: string) => AnyRuleModule | null; + +const { ConfigOps, environments: BuiltInEnvironments } = Legacy; +const ajv = ajvBuilder(); +const ruleValidators = new WeakMap(); + +let validateSchema: ValidateFunction; +const severityMap = { + error: 2, + warn: 1, + off: 0, +} as const; + +/** + * Validates a rule's severity and returns the severity value. Throws an error if the severity is invalid. + * @param options The given options for the rule. + * @throws {Error} Wrong severity value. + */ +function validateRuleSeverity(options: Linter.RuleEntry): number | string { + const severity = Array.isArray(options) ? options[0] : options; + const normSeverity = + typeof severity === 'string' + ? severityMap[severity.toLowerCase() as Linter.SeverityString] + : severity; + + if (normSeverity === 0 || normSeverity === 1 || normSeverity === 2) { + return normSeverity; + } + + throw new Error( + `\tSeverity should be one of the following: 0 = off, 1 = warn, 2 = error (you passed '${util + .inspect(severity) + .replace(/'/gu, '"') + .replace(/\n/gu, '')}').\n`, + ); +} + +/** + * Validates the non-severity options passed to a rule, based on its schema. + * @param rule The rule to validate + * @param localOptions The options for the rule, excluding severity + * @throws {Error} Any rule validation errors. + */ +function validateRuleSchema( + rule: AnyRuleModule, + localOptions: unknown[], +): void { + if (!ruleValidators.has(rule)) { + const schema = getRuleOptionsSchema(rule); + + if (schema) { + ruleValidators.set(rule, ajv.compile(schema)); + } + } + + const validateRule = ruleValidators.get(rule); + + if (validateRule) { + validateRule(localOptions); + if (validateRule.errors) { + throw new Error( + validateRule.errors + .map( + error => + `\tValue ${JSON.stringify(error.data)} ${error.message}.\n`, + ) + .join(''), + ); + } + } +} + +/** + * Validates a rule's options against its schema. + * @param rule The rule that the config is being validated for + * @param ruleId The rule's unique name. + * @param {Array|number} options The given options for the rule. + * @param source The name of the configuration source to report in any errors. If null or undefined, + * no source is prepended to the message. + * @throws {Error} Upon any bad rule configuration. + */ +function validateRuleOptions( + rule: AnyRuleModule, + ruleId: string, + options: Linter.RuleEntry, + source: string | null = null, +): void { + try { + const severity = validateRuleSeverity(options); + + if (severity !== 0) { + validateRuleSchema(rule, Array.isArray(options) ? options.slice(1) : []); + } + } catch (err) { + const enhancedMessage = `Configuration for rule "${ruleId}" is invalid:\n${ + (err as Error).message + }`; + + if (typeof source === 'string') { + throw new Error(`${source}:\n\t${enhancedMessage}`); + } else { + throw new Error(enhancedMessage); + } + } +} + +/** + * Validates an environment object + * @param environment The environment config object to validate. + * @param source The name of the configuration source to report in any errors. + */ +function validateEnvironment( + environment: Linter.EnvironmentConfig | undefined, + source: string, +): void { + // not having an environment is ok + if (!environment) { + return; + } + + Object.keys(environment).forEach(id => { + const env = BuiltInEnvironments.get(id) ?? null; + + if (!env) { + const message = `${source}:\n\tEnvironment key "${id}" is unknown\n`; + + throw new Error(message); + } + }); +} + +/** + * Validates a rules config object + * @param rulesConfig The rules config object to validate. + * @param source The name of the configuration source to report in any errors. + * @param getAdditionalRule A map from strings to loaded rules + */ +function validateRules( + rulesConfig: Linter.RulesRecord | undefined, + source: string, + getAdditionalRule: GetAdditionalRule, +): void { + if (!rulesConfig) { + return; + } + + Object.keys(rulesConfig).forEach(id => { + const rule = getAdditionalRule(id) ?? builtinRules.get(id) ?? null; + if (rule == null) { + return; + } + + validateRuleOptions(rule, id, rulesConfig[id]!, source); + }); +} + +/** + * Validates a `globals` section of a config file + * @param globalsConfig The `globals` section + * @param source The name of the configuration source to report in the event of an error. + */ +function validateGlobals( + globalsConfig: Linter.GlobalsConfig | undefined, + source: string | null = null, +): void { + if (!globalsConfig) { + return; + } + + Object.entries(globalsConfig).forEach( + ([configuredGlobal, configuredValue]) => { + try { + ConfigOps.normalizeConfigGlobal(configuredValue); + } catch (err) { + throw new Error( + `ESLint configuration of global '${configuredGlobal}' in ${source} is invalid:\n${ + (err as Error).message + }`, + ); + } + }, + ); +} + +/** + * Formats an array of schema validation errors. + */ +function formatErrors(errors: AjvErrorObject[]): string { + return errors + .map(error => { + if (error.keyword === 'additionalProperties') { + const params = error.params as AdditionalPropertiesParams; + const formattedPropertyPath = error.dataPath.length + ? `${error.dataPath.slice(1)}.${params.additionalProperty}` + : params.additionalProperty; + + return `Unexpected top-level property "${formattedPropertyPath}"`; + } + if (error.keyword === 'type') { + const formattedField = error.dataPath.slice(1); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const formattedExpectedType = Array.isArray(error.schema) + ? error.schema.join('/') + : error.schema; + const formattedValue = JSON.stringify(error.data); + + return `Property "${formattedField}" is the wrong type (expected ${formattedExpectedType} but got \`${formattedValue}\`)`; + } + + const field = + error.dataPath[0] === '.' ? error.dataPath.slice(1) : error.dataPath; + + return `"${field}" ${error.message}. Value: ${JSON.stringify( + error.data, + )}`; + }) + .map(message => `\t- ${message}.\n`) + .join(''); +} + +/** + * Validates the top level properties of the config object. + * @param config The config object to validate. + * @param source The name of the configuration source to report in any errors. + * @throws {Error} For any config invalid per the schema. + */ +function validateConfigSchema( + config: TesterConfigWithDefaults, + source: string, +): void { + validateSchema = validateSchema || ajv.compile(configSchema); + + if (!validateSchema(config)) { + throw new Error( + `ESLint configuration in ${source} is invalid:\n${formatErrors( + validateSchema.errors!, + )}`, + ); + } + + // @ts-expect-error -- intentional deprecated check + if (hasOwnProperty(config, 'ecmaFeatures')) { + emitDeprecationWarning(source, 'ESLINT_LEGACY_ECMAFEATURES'); + } +} + +/** + * Validates an entire config object. + * @param config The config object to validate. + * @param source The name of the configuration source to report in any errors. + * @param getAdditionalRule A map from strings to loaded rules. + */ +export function validate( + config: TesterConfigWithDefaults, + source: string, + getAdditionalRule: GetAdditionalRule, +): void { + validateConfigSchema(config, source); + validateRules(config.rules, source, getAdditionalRule); + validateEnvironment(config.env, source); + validateGlobals(config.globals, source); + + for (const override of config.overrides ?? []) { + validateRules(override.rules, source, getAdditionalRule); + validateEnvironment(override.env, source); + validateGlobals(config.globals, source); + } +} diff --git a/packages/rule-tester/src/utils/deprecation-warnings.ts b/packages/rule-tester/src/utils/deprecation-warnings.ts new file mode 100644 index 00000000000..9f264a41296 --- /dev/null +++ b/packages/rule-tester/src/utils/deprecation-warnings.ts @@ -0,0 +1,40 @@ +// Forked from https://github.com/eslint/eslint/blob/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/lib/shared/deprecation-warnings.js + +import path from 'node:path'; + +// Definitions for deprecation warnings. +const deprecationWarningMessages = { + ESLINT_LEGACY_ECMAFEATURES: + "The 'ecmaFeatures' config file property is deprecated and has no effect.", +} as const; + +const sourceFileErrorCache = new Set(); + +/** + * Emits a deprecation warning containing a given filepath. A new deprecation warning is emitted + * for each unique file path, but repeated invocations with the same file path have no effect. + * No warnings are emitted if the `--no-deprecation` or `--no-warnings` Node runtime flags are active. + * @param source The name of the configuration source to report the warning for. + * @param errorCode The warning message to show. + */ +export function emitDeprecationWarning( + source: string, + errorCode: keyof typeof deprecationWarningMessages, +): void { + const cacheKey = JSON.stringify({ source, errorCode }); + + if (sourceFileErrorCache.has(cacheKey)) { + return; + } + + sourceFileErrorCache.add(cacheKey); + + const rel = path.relative(process.cwd(), source); + const message = deprecationWarningMessages[errorCode]; + + process.emitWarning( + `${message} (found in "${rel}")`, + 'DeprecationWarning', + errorCode, + ); +} diff --git a/packages/rule-tester/src/utils/freezeDeeply.ts b/packages/rule-tester/src/utils/freezeDeeply.ts new file mode 100644 index 00000000000..36c375063ae --- /dev/null +++ b/packages/rule-tester/src/utils/freezeDeeply.ts @@ -0,0 +1,17 @@ +/** + * Freezes a given value deeply. + */ +export function freezeDeeply(x: unknown): void { + if (typeof x === 'object' && x != null) { + if (Array.isArray(x)) { + x.forEach(freezeDeeply); + } else { + for (const key in x) { + if (key !== 'parent' && Object.prototype.hasOwnProperty.call(x, key)) { + freezeDeeply((x as Record)[key]); + } + } + } + Object.freeze(x); + } +} diff --git a/packages/rule-tester/src/utils/getRuleOptionsSchema.ts b/packages/rule-tester/src/utils/getRuleOptionsSchema.ts new file mode 100644 index 00000000000..efbab8d469b --- /dev/null +++ b/packages/rule-tester/src/utils/getRuleOptionsSchema.ts @@ -0,0 +1,37 @@ +// Forked from https://github.com/eslint/eslint/blob/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/lib/shared/config-validator.js#LL50-L82C2 + +import type { JSONSchema } from '@typescript-eslint/utils'; +import type { AnyRuleModule } from '@typescript-eslint/utils/ts-eslint'; + +import { isReadonlyArray } from './isReadonlyArray'; + +/** + * Gets a complete options schema for a rule. + * @param rule A new-style rule object + * @returns JSON Schema for the rule's options. + */ +export function getRuleOptionsSchema( + rule: AnyRuleModule, +): JSONSchema.JSONSchema4 | null { + const schema = rule.meta?.schema; + + // Given a tuple of schemas, insert warning level at the beginning + if (isReadonlyArray(schema)) { + if (schema.length) { + return { + type: 'array', + items: schema, + minItems: 0, + maxItems: schema.length, + }; + } + return { + type: 'array', + minItems: 0, + maxItems: 0, + }; + } + + // Given a full schema, leave it alone + return schema || null; +} diff --git a/packages/rule-tester/src/utils/hasOwnProperty.ts b/packages/rule-tester/src/utils/hasOwnProperty.ts new file mode 100644 index 00000000000..a8816f89fe8 --- /dev/null +++ b/packages/rule-tester/src/utils/hasOwnProperty.ts @@ -0,0 +1,8 @@ +// typed so that TS can remove optionality +export const hasOwnProperty = Function.call.bind(Object.hasOwnProperty) as < + TObj extends object, + TK extends keyof TObj, +>( + obj: TObj, + key: TK, +) => obj is TObj & { [key in TK]-?: TObj[key] }; diff --git a/packages/rule-tester/src/utils/interpolate.ts b/packages/rule-tester/src/utils/interpolate.ts new file mode 100644 index 00000000000..0b3266fefb9 --- /dev/null +++ b/packages/rule-tester/src/utils/interpolate.ts @@ -0,0 +1,27 @@ +// Forked from https://github.com/eslint/eslint/blob/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/lib/linter/interpolate.js + +import type { ReportDescriptorMessageData } from '@typescript-eslint/utils/ts-eslint'; + +export function interpolate( + text: string, + data: ReportDescriptorMessageData, +): string { + if (!data) { + return text; + } + + // Substitution content for any {{ }} markers. + return text.replace( + /\{\{([^{}]+?)\}\}/gu, + (fullMatch, termWithWhitespace: string) => { + const term = termWithWhitespace.trim(); + + if (term in data) { + return String(data[term]); + } + + // Preserve old behavior: If parameter name not provided, don't replace it. + return fullMatch; + }, + ); +} diff --git a/packages/rule-tester/src/utils/isReadonlyArray.ts b/packages/rule-tester/src/utils/isReadonlyArray.ts new file mode 100644 index 00000000000..b16a9f31f01 --- /dev/null +++ b/packages/rule-tester/src/utils/isReadonlyArray.ts @@ -0,0 +1,4 @@ +// working around https://github.com/microsoft/TypeScript/issues/17002 +export function isReadonlyArray(arg: unknown): arg is readonly unknown[] { + return Array.isArray(arg); +} diff --git a/packages/rule-tester/src/utils/validationHelpers.ts b/packages/rule-tester/src/utils/validationHelpers.ts new file mode 100644 index 00000000000..f5d4613075b --- /dev/null +++ b/packages/rule-tester/src/utils/validationHelpers.ts @@ -0,0 +1,179 @@ +import { simpleTraverse } from '@typescript-eslint/typescript-estree'; +import type { TSESTree } from '@typescript-eslint/utils'; +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', + 'code', + 'filename', + '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', + 'endColumn', + 'suggestions', +]); +export const FRIENDLY_ERROR_OBJECT_PARAMETER_LIST = `[${[ + ...ERROR_OBJECT_PARAMETERS, +] + .map(key => `'${key}'`) + .join(', ')}]`; + +/* + * All allowed property names in suggestion objects. + */ +export const SUGGESTION_OBJECT_PARAMETERS: ReadonlySet = new Set([ + 'desc', + 'messageId', + 'data', + 'output', +]); +export const FRIENDLY_SUGGESTION_OBJECT_PARAMETER_LIST = `[${[ + ...SUGGESTION_OBJECT_PARAMETERS, +] + .map(key => `'${key}'`) + .join(', ')}]`; + +/** + * Replace control characters by `\u00xx` form. + */ +export function sanitize(text: string): string { + if (typeof text !== 'string') { + return ''; + } + return text.replace( + // eslint-disable-next-line no-control-regex + /[\u0000-\u0009\u000b-\u001a]/gu, + c => `\\u${c.codePointAt(0)!.toString(16).padStart(4, '0')}`, + ); +} + +// this symbol is used internally by ESLint to unwrap the wrapped parser +// https://github.com/eslint/eslint/blob/129e252132c7c476d7de17f40b54a333ddb2e6bb/lib/linter/linter.js#L139-L146 +const parserSymbol = Symbol.for('eslint.RuleTester.parser'); +/** + * Wraps the given parser in order to intercept and modify return values from the `parse` and `parseForESLint` methods, for test purposes. + * In particular, to modify ast nodes, tokens and comments to throw on access to their `start` and `end` properties. + */ +export function wrapParser(parser: Linter.ParserModule): Linter.ParserModule { + /** + * Define `start`/`end` properties of all nodes of the given AST as throwing error. + */ + function defineStartEndAsErrorInTree( + ast: TSESTree.Program, + visitorKeys?: Readonly, + ): void { + /** + * Define `start`/`end` properties as throwing error. + */ + function defineStartEndAsError(objName: string, node: unknown): void { + Object.defineProperties(node, { + start: { + get() { + throw new Error( + `Use ${objName}.range[0] instead of ${objName}.start`, + ); + }, + configurable: true, + enumerable: false, + }, + end: { + get() { + throw new Error( + `Use ${objName}.range[1] instead of ${objName}.end`, + ); + }, + configurable: true, + enumerable: false, + }, + }); + } + + simpleTraverse(ast, { + visitorKeys, + enter: node => defineStartEndAsError('node', node), + }); + ast.tokens?.forEach(token => defineStartEndAsError('token', token)); + ast.comments?.forEach(comment => defineStartEndAsError('token', comment)); + } + + if ('parseForESLint' in parser) { + return { + // @ts-expect-error -- see above + [parserSymbol]: parser, + parseForESLint(...args): Linter.ESLintParseResult { + const ret = parser.parseForESLint(...args); + + defineStartEndAsErrorInTree(ret.ast, ret.visitorKeys); + return ret; + }, + }; + } + + return { + // @ts-expect-error -- see above + [parserSymbol]: parser, + parse(...args): TSESTree.Program { + const ast = parser.parse(...args); + + defineStartEndAsErrorInTree(ast); + return ast; + }, + }; +} + +/** + * Function to replace `SourceCode.prototype.getComments`. + */ +export function getCommentsDeprecation(): never { + throw new Error( + '`SourceCode#getComments()` is deprecated and will be removed in a future major version. Use `getCommentsBefore()`, `getCommentsAfter()`, and `getCommentsInside()` instead.', + ); +} + +const EMIT_LEGACY_RULE_API_WARNING: Record = {}; +/** + * Emit a deprecation warning if function-style format is being used. + */ +export function emitLegacyRuleAPIWarning(ruleName: string): void { + if (!EMIT_LEGACY_RULE_API_WARNING[`warned-${ruleName}`]) { + EMIT_LEGACY_RULE_API_WARNING[`warned-${ruleName}`] = true; + process.emitWarning( + `"${ruleName}" rule is using the deprecated function-style format and will stop working in ESLint v9. Please use object-style format: https://eslint.org/docs/latest/extend/custom-rules`, + 'DeprecationWarning', + ); + } +} + +const EMIT_MISSING_SCHEMA_WARNING: Record = {}; +/** + * Emit a deprecation warning if rule has options but is missing the "meta.schema" property + */ +export function emitMissingSchemaWarning(ruleName: string): void { + if (!EMIT_MISSING_SCHEMA_WARNING[`warned-${ruleName}`]) { + EMIT_MISSING_SCHEMA_WARNING[`warned-${ruleName}`] = true; + process.emitWarning( + `"${ruleName}" rule has options but is missing the "meta.schema" property and will stop working in ESLint v9. Please add a schema: https://eslint.org/docs/latest/extend/custom-rules#options-schemas`, + 'DeprecationWarning', + ); + } +} + +export const REQUIRED_SCENARIOS = ['valid', 'invalid'] as const; diff --git a/packages/rule-tester/tests/eslint-base/eslint-base.test.js b/packages/rule-tester/tests/eslint-base/eslint-base.test.js new file mode 100644 index 00000000000..03d5f1d8f8c --- /dev/null +++ b/packages/rule-tester/tests/eslint-base/eslint-base.test.js @@ -0,0 +1,2882 @@ +/** + * This file intentionally does not match the standards in the rest of our codebase. + * It's intended to exactly match the test in ESLint core so we can ensure we + * have compatibility. + * It's tempting to switch this to be strictly typed in TS and to use jest - but + * it's too easy to introduce subtle changes into the test by doing that. It also + * makes it much harder to merge upstream changes into this test. + * + * The only edits we have made are to update the paths for our rep + * + * Forked from https://github.com/eslint/eslint/blob/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/tests/lib/rule-tester/rule-tester.js + * + * @noformat + */ +/* eslint-disable */ + +"use strict"; + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ +const sinon = require("sinon"), + EventEmitter = require("events"), + { RuleTester } = require("../../dist/RuleTester"), + assert = require("chai").assert, + nodeAssert = require("assert"), + espree = require("espree"); + +const NODE_ASSERT_STRICT_EQUAL_OPERATOR = (() => { + try { + nodeAssert.strictEqual(1, 2); + } catch (err) { + return err.operator; + } + throw new Error("unexpected successful assertion"); +})(); + +/** + * Do nothing. + * @returns {void} + */ +function noop() { + + // do nothing. +} + +//------------------------------------------------------------------------------ +// Rewire Things +//------------------------------------------------------------------------------ + +/* + * So here's the situation. Because RuleTester uses it() and describe() from + * Mocha, any failures would show up in the output of this test file. That means + * when we tested that a failure is thrown, that would also count as a failure + * in the testing for RuleTester. In order to remove those results from the + * results of this file, we need to overwrite it() and describe() just in + * RuleTester to do nothing but run code. Effectively, it() and describe() + * just become regular functions inside of index.js, not at all related to Mocha. + * That allows the results of this file to be untainted and therefore accurate. + * + * To assert that the right arguments are passed to RuleTester.describe/it, an + * event emitter is used which emits the arguments. + */ + +const ruleTesterTestEmitter = new EventEmitter(); + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +describe("RuleTester", () => { + + // Stub `describe()` and `it()` while this test suite. + before(() => { + RuleTester.describe = function(text, method) { + ruleTesterTestEmitter.emit("describe", text, method); + return method.call(this); + }; + RuleTester.it = function(text, method) { + ruleTesterTestEmitter.emit("it", text, method); + return method.call(this); + }; + }); + after(() => { + RuleTester.describe = null; + RuleTester.it = null; + }); + + let ruleTester; + + /** + * A helper function to verify Node.js core error messages. + * @param {string} actual The actual input + * @param {string} expected The expected input + * @returns {Function} Error callback to verify that the message is correct + * for the actual and expected input. + */ + function assertErrorMatches(actual, expected) { + const err = new nodeAssert.AssertionError({ + actual, + expected, + operator: NODE_ASSERT_STRICT_EQUAL_OPERATOR + }); + + return err.message; + } + + beforeEach(() => { + RuleTester.resetDefaultConfig(); + ruleTester = new RuleTester(); + }); + + describe("only", () => { + describe("`itOnly` accessor", () => { + describe("when `itOnly` is set", () => { + before(() => { + RuleTester.itOnly = sinon.spy(); + }); + after(() => { + RuleTester.itOnly = void 0; + }); + beforeEach(() => { + RuleTester.itOnly.resetHistory(); + ruleTester = new RuleTester(); + }); + + it("is called by exclusive tests", () => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [{ + code: "const notVar = 42;", + only: true + }], + invalid: [] + }); + + sinon.assert.calledWith(RuleTester.itOnly, "const notVar = 42;"); + }); + }); + + describe("when `it` is set and has an `only()` method", () => { + before(() => { + RuleTester.it.only = () => {}; + sinon.spy(RuleTester.it, "only"); + }); + after(() => { + RuleTester.it.only = void 0; + }); + beforeEach(() => { + RuleTester.it.only.resetHistory(); + ruleTester = new RuleTester(); + }); + + it("is called by tests with `only` set", () => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [{ + code: "const notVar = 42;", + only: true + }], + invalid: [] + }); + + sinon.assert.calledWith(RuleTester.it.only, "const notVar = 42;"); + }); + }); + + describe("when global `it` is a function that has an `only()` method", () => { + let originalGlobalItOnly; + + before(() => { + + /* + * We run tests with `--forbid-only`, so we have to override + * `it.only` to prevent the real one from being called. + */ + originalGlobalItOnly = it.only; + it.only = () => {}; + sinon.spy(it, "only"); + }); + after(() => { + it.only = originalGlobalItOnly; + }); + beforeEach(() => { + it.only.resetHistory(); + ruleTester = new RuleTester(); + }); + + it("is called by tests with `only` set", () => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [{ + code: "const notVar = 42;", + only: true + }], + invalid: [] + }); + + sinon.assert.calledWith(it.only, "const notVar = 42;"); + }); + }); + + describe("when `describe` and `it` are overridden without `itOnly`", () => { + let originalGlobalItOnly; + + before(() => { + + /* + * These tests override `describe` and `it` already, so we + * don't need to override them here. We do, however, need to + * remove `only` from the global `it` to prevent it from + * being used instead. + */ + originalGlobalItOnly = it.only; + it.only = void 0; + }); + after(() => { + it.only = originalGlobalItOnly; + }); + beforeEach(() => { + ruleTester = new RuleTester(); + }); + + it("throws an error recommending overriding `itOnly`", () => { + assert.throws(() => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [{ + code: "const notVar = 42;", + only: true + }], + invalid: [] + }); + }, "Set `RuleTester.itOnly` to use `only` with a custom test framework."); + }); + }); + + describe("when global `it` is a function that does not have an `only()` method", () => { + let originalGlobalIt; + let originalRuleTesterDescribe; + let originalRuleTesterIt; + + before(() => { + originalGlobalIt = global.it; + + // eslint-disable-next-line no-global-assign -- Temporarily override Mocha global + it = () => {}; + + /* + * These tests override `describe` and `it`, so we need to + * un-override them here so they won't interfere. + */ + originalRuleTesterDescribe = RuleTester.describe; + RuleTester.describe = void 0; + originalRuleTesterIt = RuleTester.it; + RuleTester.it = void 0; + }); + after(() => { + + // eslint-disable-next-line no-global-assign -- Restore Mocha global + it = originalGlobalIt; + RuleTester.describe = originalRuleTesterDescribe; + RuleTester.it = originalRuleTesterIt; + }); + beforeEach(() => { + ruleTester = new RuleTester(); + }); + + it("throws an error explaining that the current test framework does not support `only`", () => { + assert.throws(() => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [{ + code: "const notVar = 42;", + only: true + }], + invalid: [] + }); + }, "The current test framework does not support exclusive tests with `only`."); + }); + }); + }); + + describe("test cases", () => { + const ruleName = "no-var"; + const rule = require("./fixtures/no-var"); + + let originalRuleTesterIt; + let spyRuleTesterIt; + let originalRuleTesterItOnly; + let spyRuleTesterItOnly; + + before(() => { + originalRuleTesterIt = RuleTester.it; + spyRuleTesterIt = sinon.spy(); + RuleTester.it = spyRuleTesterIt; + originalRuleTesterItOnly = RuleTester.itOnly; + spyRuleTesterItOnly = sinon.spy(); + RuleTester.itOnly = spyRuleTesterItOnly; + }); + after(() => { + RuleTester.it = originalRuleTesterIt; + RuleTester.itOnly = originalRuleTesterItOnly; + }); + beforeEach(() => { + spyRuleTesterIt.resetHistory(); + spyRuleTesterItOnly.resetHistory(); + ruleTester = new RuleTester(); + }); + + it("isn't called for normal tests", () => { + ruleTester.run(ruleName, rule, { + valid: ["const notVar = 42;"], + invalid: [] + }); + sinon.assert.calledWith(spyRuleTesterIt, "const notVar = 42;"); + sinon.assert.notCalled(spyRuleTesterItOnly); + }); + + it("calls it or itOnly for every test case", () => { + + /* + * `RuleTester` doesn't implement test case exclusivity itself. + * Setting `only: true` just causes `RuleTester` to call + * whatever `only()` function is provided by the test framework + * instead of the regular `it()` function. + */ + + ruleTester.run(ruleName, rule, { + valid: [ + "const valid = 42;", + { + code: "const onlyValid = 42;", + only: true + } + ], + invalid: [ + { + code: "var invalid = 42;", + errors: [/^Bad var/u] + }, + { + code: "var onlyInvalid = 42;", + errors: [/^Bad var/u], + only: true + } + ] + }); + + sinon.assert.calledWith(spyRuleTesterIt, "const valid = 42;"); + sinon.assert.calledWith(spyRuleTesterItOnly, "const onlyValid = 42;"); + sinon.assert.calledWith(spyRuleTesterIt, "var invalid = 42;"); + sinon.assert.calledWith(spyRuleTesterItOnly, "var onlyInvalid = 42;"); + }); + }); + + describe("static helper wrapper", () => { + it("adds `only` to string test cases", () => { + const test = RuleTester.only("const valid = 42;"); + + assert.deepStrictEqual(test, { + code: "const valid = 42;", + only: true + }); + }); + + it("adds `only` to object test cases", () => { + const test = RuleTester.only({ code: "const valid = 42;" }); + + assert.deepStrictEqual(test, { + code: "const valid = 42;", + only: true + }); + }); + }); + }); + + it("should not throw an error when everything passes", () => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: [ + "Eval(foo)" + ], + invalid: [ + { code: "eval(foo)", errors: [{ message: "eval sucks.", type: "CallExpression" }] } + ] + }); + }); + + it("should throw an error when valid code is invalid", () => { + + assert.throws(() => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: [ + "eval(foo)" + ], + invalid: [ + { code: "eval(foo)", errors: [{ message: "eval sucks.", type: "CallExpression" }] } + ] + }); + }, /Should have no errors but had 1/u); + }); + + it("should throw an error when valid code is invalid", () => { + + assert.throws(() => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: [ + { code: "eval(foo)" } + ], + invalid: [ + { code: "eval(foo)", errors: [{ message: "eval sucks.", type: "CallExpression" }] } + ] + }); + }, /Should have no errors but had 1/u); + }); + + it("should throw an error if invalid code is valid", () => { + + assert.throws(() => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: [ + "Eval(foo)" + ], + invalid: [ + { code: "Eval(foo)", errors: [{ message: "eval sucks.", type: "CallExpression" }] } + ] + }); + }, /Should have 1 error but had 0/u); + }); + + it("should throw an error when the error message is wrong", () => { + assert.throws(() => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + + // Only the invalid test matters here + valid: [ + "bar = baz;" + ], + invalid: [ + { code: "var foo = bar;", errors: [{ message: "Bad error message." }] } + ] + }); + }, assertErrorMatches("Bad var.", "Bad error message.")); + }); + + it("should throw an error when the error message regex does not match", () => { + assert.throws(() => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [], + invalid: [ + { code: "var foo = bar;", errors: [{ message: /Bad error message/u }] } + ] + }); + }, /Expected 'Bad var.' to match \/Bad error message\//u); + }); + + it("should throw an error when the error is not a supported type", () => { + assert.throws(() => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + + // Only the invalid test matters here + valid: [ + "bar = baz;" + ], + invalid: [ + { code: "var foo = bar;", errors: [42] } + ] + }); + }, /Error should be a string, object, or RegExp/u); + }); + + it("should throw an error when any of the errors is not a supported type", () => { + assert.throws(() => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + + // Only the invalid test matters here + valid: [ + "bar = baz;" + ], + invalid: [ + { code: "var foo = bar; var baz = quux", errors: [{ type: "VariableDeclaration" }, null] } + ] + }); + }, /Error should be a string, object, or RegExp/u); + }); + + it("should throw an error when the error is a string and it does not match error message", () => { + assert.throws(() => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + + // Only the invalid test matters here + valid: [ + "bar = baz;" + ], + invalid: [ + { code: "var foo = bar;", errors: ["Bad error message."] } + ] + }); + }, assertErrorMatches("Bad var.", "Bad error message.")); + }); + + it("should throw an error when the error is a string and it does not match error message", () => { + assert.throws(() => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + + valid: [ + ], + invalid: [ + { code: "var foo = bar;", errors: [/Bad error message/u] } + ] + }); + }, /Expected 'Bad var.' to match \/Bad error message\//u); + }); + + it("should not throw an error when the error is a string and it matches error message", () => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + + // Only the invalid test matters here + valid: [ + "bar = baz;" + ], + invalid: [ + { code: "var foo = bar;", output: " foo = bar;", errors: ["Bad var."] } + ] + }); + }); + + it("should not throw an error when the error is a regex and it matches error message", () => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [], + invalid: [ + { code: "var foo = bar;", output: " foo = bar;", errors: [/^Bad var/u] } + ] + }); + }); + + it("should throw an error when the error is an object with an unknown property name", () => { + assert.throws(() => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [ + "bar = baz;" + ], + invalid: [ + { code: "var foo = bar;", errors: [{ Message: "Bad var." }] } + ] + }); + }, /Invalid error property name 'Message'/u); + }); + + it("should throw an error when any of the errors is an object with an unknown property name", () => { + assert.throws(() => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [ + "bar = baz;" + ], + invalid: [ + { + code: "var foo = bar; var baz = quux", + errors: [ + { message: "Bad var.", type: "VariableDeclaration" }, + { message: "Bad var.", typo: "VariableDeclaration" } + ] + } + ] + }); + }, /Invalid error property name 'typo'/u); + }); + + it("should not throw an error when the error is a regex in an object and it matches error message", () => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [], + invalid: [ + { code: "var foo = bar;", output: " foo = bar;", errors: [{ message: /^Bad var/u }] } + ] + }); + }); + + it("should throw an error when the expected output doesn't match", () => { + assert.throws(() => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [ + "bar = baz;" + ], + invalid: [ + { code: "var foo = bar;", output: "foo = bar", errors: [{ message: "Bad var.", type: "VariableDeclaration" }] } + ] + }); + }, /Output is incorrect/u); + }); + + it("should use strict equality to compare output", () => { + const replaceProgramWith5Rule = { + meta: { + fixable: "code" + }, + + create: context => ({ + Program(node) { + context.report({ node, message: "bad", fix: fixer => fixer.replaceText(node, "5") }); + } + }) + }; + + // Should not throw. + ruleTester.run("foo", replaceProgramWith5Rule, { + valid: [], + invalid: [ + { code: "var foo = bar;", output: "5", errors: 1 } + ] + }); + + assert.throws(() => { + ruleTester.run("foo", replaceProgramWith5Rule, { + valid: [], + invalid: [ + { code: "var foo = bar;", output: 5, errors: 1 } + ] + }); + }, /Output is incorrect/u); + }); + + it("should throw an error when the expected output doesn't match and errors is just a number", () => { + assert.throws(() => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [ + "bar = baz;" + ], + invalid: [ + { code: "var foo = bar;", output: "foo = bar", errors: 1 } + ] + }); + }, /Output is incorrect/u); + }); + + it("should not throw an error when the expected output is null and no errors produce output", () => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: [ + "bar = baz;" + ], + invalid: [ + { code: "eval(x)", errors: 1, output: null }, + { code: "eval(x); eval(y);", errors: 2, output: null } + ] + }); + }); + + it("should throw an error when the expected output is null and problems produce output", () => { + assert.throws(() => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [ + "bar = baz;" + ], + invalid: [ + { code: "var foo = bar;", output: null, errors: 1 } + ] + }); + }, /Expected no autofixes to be suggested/u); + + assert.throws(() => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [ + "bar = baz;" + ], + invalid: [ + { + code: "var foo = bar; var qux = boop;", + output: null, + errors: 2 + } + ] + }); + }, /Expected no autofixes to be suggested/u); + }); + + it("should throw an error when the expected output is null and only some problems produce output", () => { + assert.throws(() => { + ruleTester.run("fixes-one-problem", require("./fixtures/fixes-one-problem"), { + valid: [], + invalid: [ + { code: "foo", output: null, errors: 2 } + ] + }); + }, /Expected no autofixes to be suggested/u); + }); + + it("should throw an error when the expected output isn't specified and problems produce output", () => { + assert.throws(() => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [ + "bar = baz;" + ], + invalid: [ + { code: "var foo = bar;", errors: 1 } + ] + }); + }, "The rule fixed the code. Please add 'output' property."); + }); + + it("should throw an error if invalid code specifies wrong type", () => { + assert.throws(() => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: [ + "Eval(foo)" + ], + invalid: [ + { code: "eval(foo)", errors: [{ message: "eval sucks.", type: "CallExpression2" }] } + ] + }); + }, /Error type should be CallExpression2, found CallExpression/u); + }); + + it("should throw an error if invalid code specifies wrong line", () => { + assert.throws(() => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: [ + "Eval(foo)" + ], + invalid: [ + { code: "eval(foo)", errors: [{ message: "eval sucks.", type: "CallExpression", line: 5 }] } + ] + }); + }, /Error line should be 5/u); + }); + + it("should not skip line assertion if line is a falsy value", () => { + assert.throws(() => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: [ + "Eval(foo)" + ], + invalid: [ + { code: "\neval(foo)", errors: [{ message: "eval sucks.", type: "CallExpression", line: 0 }] } + ] + }); + }, /Error line should be 0/u); + }); + + it("should throw an error if invalid code specifies wrong column", () => { + const wrongColumn = 10, + expectedErrorMessage = "Error column should be 1"; + + assert.throws(() => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: ["Eval(foo)"], + invalid: [{ + code: "eval(foo)", + errors: [{ + message: "eval sucks.", + column: wrongColumn + }] + }] + }); + }, expectedErrorMessage); + }); + + it("should throw error for empty error array", () => { + assert.throws(() => { + ruleTester.run("suggestions-messageIds", require("./fixtures/suggestions").withMessageIds, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [] + }] + }); + }, /Invalid cases must have at least one error/u); + }); + + it("should throw error for errors : 0", () => { + assert.throws(() => { + ruleTester.run( + "suggestions-messageIds", + require("./fixtures/suggestions") + .withMessageIds, + { + valid: [], + invalid: [ + { + code: "var foo;", + errors: 0 + } + ] + } + ); + }, /Invalid cases must have 'error' value greater than 0/u); + }); + + it("should not skip column assertion if column is a falsy value", () => { + assert.throws(() => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: ["Eval(foo)"], + invalid: [{ + code: "var foo; eval(foo)", + errors: [{ message: "eval sucks.", column: 0 }] + }] + }); + }, /Error column should be 0/u); + }); + + it("should throw an error if invalid code specifies wrong endLine", () => { + assert.throws(() => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [ + "bar = baz;" + ], + invalid: [ + { code: "var foo = bar;", output: "foo = bar", errors: [{ message: "Bad var.", type: "VariableDeclaration", endLine: 10 }] } + ] + }); + }, "Error endLine should be 10"); + }); + + it("should throw an error if invalid code specifies wrong endColumn", () => { + assert.throws(() => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [ + "bar = baz;" + ], + invalid: [ + { code: "var foo = bar;", output: "foo = bar", errors: [{ message: "Bad var.", type: "VariableDeclaration", endColumn: 10 }] } + ] + }); + }, "Error endColumn should be 10"); + }); + + it("should throw an error if invalid code has the wrong number of errors", () => { + assert.throws(() => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: [ + "Eval(foo)" + ], + invalid: [ + { + code: "eval(foo)", + errors: [ + { message: "eval sucks.", type: "CallExpression" }, + { message: "eval sucks.", type: "CallExpression" } + ] + } + ] + }); + }, /Should have 2 errors but had 1/u); + }); + + it("should throw an error if invalid code does not have errors", () => { + assert.throws(() => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: [ + "Eval(foo)" + ], + invalid: [ + { code: "eval(foo)" } + ] + }); + }, /Did not specify errors for an invalid test of no-eval/u); + }); + + it("should throw an error if invalid code has the wrong explicit number of errors", () => { + assert.throws(() => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: [ + "Eval(foo)" + ], + invalid: [ + { code: "eval(foo)", errors: 2 } + ] + }); + }, /Should have 2 errors but had 1/u); + }); + + it("should throw an error if there's a parsing error in a valid test", () => { + assert.throws(() => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: [ + "1eval('foo')" + ], + invalid: [ + { code: "eval('foo')", errors: [{}] } + ] + }); + }, /fatal parsing error/iu); + }); + + it("should throw an error if there's a parsing error in an invalid test", () => { + assert.throws(() => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: [ + "noeval('foo')" + ], + invalid: [ + { code: "1eval('foo')", errors: [{}] } + ] + }); + }, /fatal parsing error/iu); + }); + + it("should throw an error if there's a parsing error in an invalid test and errors is just a number", () => { + assert.throws(() => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: [ + "noeval('foo')" + ], + invalid: [ + { code: "1eval('foo')", errors: 1 } + ] + }); + }, /fatal parsing error/iu); + }); + + // https://github.com/eslint/eslint/issues/4779 + it("should throw an error if there's a parsing error and output doesn't match", () => { + assert.throws(() => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: [], + invalid: [ + { code: "obvious parser error", output: "string that doesnt match", errors: [{}] } + ] + }); + }, /fatal parsing error/iu); + }); + + it("should not throw an error if invalid code has at least an expected empty error object", () => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: ["Eval(foo)"], + invalid: [{ + code: "eval(foo)", + errors: [{}] + }] + }); + }); + + it("should pass-through the globals config of valid tests to the to rule", () => { + ruleTester.run("no-test-global", require("./fixtures/no-test-global"), { + valid: [ + "var test = 'foo'", + { + code: "var test2 = 'bar'", + globals: { test: true } + } + ], + invalid: [{ code: "bar", errors: 1 }] + }); + }); + + it("should pass-through the globals config of invalid tests to the to rule", () => { + ruleTester.run("no-test-global", require("./fixtures/no-test-global"), { + valid: ["var test = 'foo'"], + invalid: [ + { + code: "var test = 'foo'; var foo = 'bar'", + errors: 1 + }, + { + code: "var test = 'foo'", + globals: { foo: true }, + errors: [{ message: "Global variable foo should not be used." }] + } + ] + }); + }); + + it("should pass-through the settings config to rules", () => { + ruleTester.run("no-test-settings", require("./fixtures/no-test-settings"), { + valid: [ + { + code: "var test = 'bar'", settings: { test: 1 } + } + ], + invalid: [ + { + code: "var test = 'bar'", settings: { "no-test": 22 }, errors: 1 + } + ] + }); + }); + + it("should pass-through the filename to the rule", () => { + (function() { + ruleTester.run("", require("./fixtures/no-test-filename"), { + valid: [ + { + code: "var foo = 'bar'", + filename: "somefile.js" + } + ], + invalid: [ + { + code: "var foo = 'bar'", + errors: [ + { message: "Filename test was not defined." } + ] + } + ] + }); + }()); + }); + + it("should pass-through the options to the rule", () => { + ruleTester.run("no-invalid-args", require("./fixtures/no-invalid-args"), { + valid: [ + { + code: "var foo = 'bar'", + options: [false] + } + ], + invalid: [ + { + code: "var foo = 'bar'", + options: [true], + errors: [{ message: "Invalid args" }] + } + ] + }); + }); + + it("should throw an error if the options are an object", () => { + assert.throws(() => { + ruleTester.run("no-invalid-args", require("./fixtures/no-invalid-args"), { + valid: [ + { + code: "foo", + options: { ok: true } + } + ], + invalid: [] + }); + }, /options must be an array/u); + }); + + it("should throw an error if the options are a number", () => { + assert.throws(() => { + ruleTester.run("no-invalid-args", require("./fixtures/no-invalid-args"), { + valid: [ + { + code: "foo", + options: 0 + } + ], + invalid: [] + }); + }, /options must be an array/u); + }); + + it("should pass-through the parser to the rule", () => { + const spy = sinon.spy(ruleTester.linter, "verify"); + + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: [ + { + code: "Eval(foo)" + } + ], + invalid: [ + { + code: "eval(foo)", + parser: require.resolve("esprima"), + errors: [{ line: 1 }] + } + ] + }); + assert.strictEqual(spy.args[1][1].parser, require.resolve("esprima")); + }); + + // skipping because it's not something our parser cares about + it.skip("should pass normalized ecmaVersion to the rule", () => { + const reportEcmaVersionRule = { + meta: { + messages: { + ecmaVersionMessage: "context.parserOptions.ecmaVersion is {{type}} {{ecmaVersion}}." + } + }, + create: context => ({ + Program(node) { + const { ecmaVersion } = context.parserOptions; + + context.report({ + node, + messageId: "ecmaVersionMessage", + data: { type: typeof ecmaVersion, ecmaVersion } + }); + } + }) + }; + + const notEspree = require.resolve("./fixtures/empty-program-parser"); + + ruleTester.run("report-ecma-version", reportEcmaVersionRule, { + valid: [], + invalid: [ + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "undefined", ecmaVersion: "undefined" } }] + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "undefined", ecmaVersion: "undefined" } }], + parserOptions: {} + }, + { + code: "
", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "undefined", ecmaVersion: "undefined" } }], + parserOptions: { ecmaFeatures: { jsx: true } } + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "undefined", ecmaVersion: "undefined" } }], + parser: require.resolve("espree") + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "number", ecmaVersion: "6" } }], + parserOptions: { ecmaVersion: 6 } + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "number", ecmaVersion: "6" } }], + parserOptions: { ecmaVersion: 2015 } + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "undefined", ecmaVersion: "undefined" } }], + env: { browser: true } + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "undefined", ecmaVersion: "undefined" } }], + env: { es6: false } + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "number", ecmaVersion: "6" } }], + env: { es6: true } + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "number", ecmaVersion: "8" } }], + env: { es6: false, es2017: true } + }, + { + code: "let x", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "number", ecmaVersion: "6" } }], + env: { es6: "truthy" } + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "number", ecmaVersion: "8" } }], + env: { es2017: true } + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "number", ecmaVersion: "11" } }], + env: { es2020: true } + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "number", ecmaVersion: "12" } }], + env: { es2021: true } + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "number", ecmaVersion: String(espree.latestEcmaVersion) } }], + parserOptions: { ecmaVersion: "latest" } + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "number", ecmaVersion: String(espree.latestEcmaVersion) } }], + parser: require.resolve("espree"), + parserOptions: { ecmaVersion: "latest" } + }, + { + code: "
", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "number", ecmaVersion: String(espree.latestEcmaVersion) } }], + parserOptions: { ecmaVersion: "latest", ecmaFeatures: { jsx: true } } + }, + { + code: "import 'foo'", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "number", ecmaVersion: String(espree.latestEcmaVersion) } }], + parserOptions: { ecmaVersion: "latest", sourceType: "module" } + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "number", ecmaVersion: String(espree.latestEcmaVersion) } }], + parserOptions: { ecmaVersion: "latest" }, + env: { es6: true } + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "number", ecmaVersion: String(espree.latestEcmaVersion) } }], + parserOptions: { ecmaVersion: "latest" }, + env: { es2020: true } + }, + + // Non-Espree parsers normalize ecmaVersion if it's not "latest" + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "undefined", ecmaVersion: "undefined" } }], + parser: notEspree + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "undefined", ecmaVersion: "undefined" } }], + parser: notEspree, + parserOptions: {} + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "number", ecmaVersion: "5" } }], + parser: notEspree, + parserOptions: { ecmaVersion: 5 } + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "number", ecmaVersion: "6" } }], + parser: notEspree, + parserOptions: { ecmaVersion: 6 } + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "number", ecmaVersion: 6 } }], + parser: notEspree, + parserOptions: { ecmaVersion: 2015 } + }, + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "string", ecmaVersion: "latest" } }], + parser: notEspree, + parserOptions: { ecmaVersion: "latest" } + } + ] + }); + + [{ parserOptions: { ecmaVersion: 6 } }, { env: { es6: true } }].forEach(options => { + new RuleTester(options).run("report-ecma-version", reportEcmaVersionRule, { + valid: [], + invalid: [ + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "number", ecmaVersion: "6" } }] + }, + { + code: "", + parserOptions: {}, + errors: [{ messageId: "ecmaVersionMessage", data: { type: "number", ecmaVersion: "6" } }] + } + ] + }); + }); + + new RuleTester({ parser: notEspree }).run("report-ecma-version", reportEcmaVersionRule, { + valid: [], + invalid: [ + { + code: "", + errors: [{ messageId: "ecmaVersionMessage", data: { type: "undefined", ecmaVersion: "undefined" } }] + }, + { + code: "", + parserOptions: { ecmaVersion: "latest" }, + errors: [{ messageId: "ecmaVersionMessage", data: { type: "string", ecmaVersion: "latest" } }] + } + ] + }); + }); + + it("should pass-through services from parseForESLint to the rule", () => { + const enhancedParserPath = require.resolve("./fixtures/enhanced-parser"); + const disallowHiRule = { + create: context => ({ + Literal(node) { + const disallowed = context.parserServices.test.getMessage(); // returns "Hi!" + + if (node.value === disallowed) { + context.report({ node, message: `Don't use '${disallowed}'` }); + } + } + }) + }; + + ruleTester.run("no-hi", disallowHiRule, { + valid: [ + { + code: "'Hello!'", + parser: enhancedParserPath + } + ], + invalid: [ + { + code: "'Hi!'", + parser: enhancedParserPath, + errors: [{ message: "Don't use 'Hi!'" }] + } + ] + }); + }); + + it("should prevent invalid options schemas", () => { + assert.throws(() => { + ruleTester.run("no-invalid-schema", require("./fixtures/no-invalid-schema"), { + valid: [ + "var answer = 6 * 7;", + { code: "var answer = 6 * 7;", options: [] } + ], + invalid: [ + { code: "var answer = 6 * 7;", options: ["bar"], errors: [{ message: "Expected nothing." }] } + ] + }); + }, "Schema for rule no-invalid-schema is invalid:,\titems: should be object\n\titems[0].enum: should NOT have fewer than 1 items\n\titems: should match some schema in anyOf"); + + }); + + it("should prevent schema violations in options", () => { + assert.throws(() => { + ruleTester.run("no-schema-violation", require("./fixtures/no-schema-violation"), { + valid: [ + "var answer = 6 * 7;", + { code: "var answer = 6 * 7;", options: ["foo"] } + ], + invalid: [ + { code: "var answer = 6 * 7;", options: ["bar"], errors: [{ message: "Expected foo." }] } + ] + }); + }, /Value "bar" should be equal to one of the allowed values./u); + + }); + + it("should disallow invalid defaults in rules", () => { + const ruleWithInvalidDefaults = { + meta: { + schema: [ + { + oneOf: [ + { enum: ["foo"] }, + { + type: "object", + properties: { + foo: { + enum: ["foo", "bar"], + default: "foo" + } + }, + additionalProperties: false + } + ] + } + ] + }, + create: () => ({}) + }; + + assert.throws(() => { + ruleTester.run("invalid-defaults", ruleWithInvalidDefaults, { + valid: [ + { + code: "foo", + options: [{}] + } + ], + invalid: [] + }); + }, /Schema for rule invalid-defaults is invalid: default is ignored for: data1\.foo/u); + }); + + it("throw an error when an unknown config option is included", () => { + assert.throws(() => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: [ + { code: "Eval(foo)", foo: "bar" } + ], + invalid: [] + }); + }, /ESLint configuration in rule-tester is invalid./u); + }); + + it("throw an error when an invalid config value is included", () => { + assert.throws(() => { + ruleTester.run("no-eval", require("./fixtures/no-eval"), { + valid: [ + { code: "Eval(foo)", env: ["es6"] } + ], + invalid: [] + }); + }, /Property "env" is the wrong type./u); + }); + + it("should pass-through the tester config to the rule", () => { + ruleTester = new RuleTester({ + globals: { test: true } + }); + + ruleTester.run("no-test-global", require("./fixtures/no-test-global"), { + valid: [ + "var test = 'foo'", + "var test2 = test" + ], + invalid: [{ code: "bar", errors: 1, globals: { foo: true } }] + }); + }); + + it("should correctly set the globals configuration", () => { + const config = { globals: { test: true } }; + + RuleTester.setDefaultConfig(config); + assert( + RuleTester.getDefaultConfig().globals.test, + "The default config object is incorrect" + ); + }); + + it("should correctly reset the global configuration", () => { + const config = { globals: { test: true } }; + + RuleTester.setDefaultConfig(config); + RuleTester.resetDefaultConfig(); + assert.deepStrictEqual( + RuleTester.getDefaultConfig(), + { parser: require.resolve('@typescript-eslint/parser'), rules: {} }, + "The default configuration has not reset correctly" + ); + }); + + it("should enforce the global configuration to be an object", () => { + + /** + * Set the default config for the rules tester + * @param {Object} config configuration object + * @returns {Function} Function to be executed + * @private + */ + function setConfig(config) { + return function() { + RuleTester.setDefaultConfig(config); + }; + } + assert.throw(setConfig()); + assert.throw(setConfig(1)); + assert.throw(setConfig(3.14)); + assert.throw(setConfig("foo")); + assert.throw(setConfig(null)); + assert.throw(setConfig(true)); + }); + + it("should pass-through the globals config to the tester then to the to rule", () => { + const config = { globals: { test: true } }; + + RuleTester.setDefaultConfig(config); + ruleTester = new RuleTester(); + + ruleTester.run("no-test-global", require("./fixtures/no-test-global"), { + valid: [ + "var test = 'foo'", + "var test2 = test" + ], + invalid: [{ code: "bar", errors: 1, globals: { foo: true } }] + }); + }); + + it("should throw an error if AST was modified", () => { + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/modify-ast"), { + valid: [ + "var foo = 0;" + ], + invalid: [] + }); + }, "Rule should not modify AST."); + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/modify-ast"), { + valid: [], + invalid: [ + { code: "var bar = 0;", errors: ["error"] } + ] + }); + }, "Rule should not modify AST."); + }); + + it("should throw an error if AST was modified (at Program)", () => { + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/modify-ast-at-first"), { + valid: [ + "var foo = 0;" + ], + invalid: [] + }); + }, "Rule should not modify AST."); + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/modify-ast-at-first"), { + valid: [], + invalid: [ + { code: "var bar = 0;", errors: ["error"] } + ] + }); + }, "Rule should not modify AST."); + }); + + it("should throw an error if AST was modified (at Program:exit)", () => { + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/modify-ast-at-last"), { + valid: [ + "var foo = 0;" + ], + invalid: [] + }); + }, "Rule should not modify AST."); + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/modify-ast-at-last"), { + valid: [], + invalid: [ + { code: "var bar = 0;", errors: ["error"] } + ] + }); + }, "Rule should not modify AST."); + }); + + it("should throw an error if rule uses start and end properties on nodes, tokens or comments", () => { + const usesStartEndRule = { + create(context) { + + const sourceCode = context.getSourceCode(); + + return { + CallExpression(node) { + noop(node.arguments[1].start); + }, + "BinaryExpression[operator='+']"(node) { + noop(node.end); + }, + "UnaryExpression[operator='-']"(node) { + noop(sourceCode.getFirstToken(node).start); + }, + ConditionalExpression(node) { + noop(sourceCode.getFirstToken(node).end); + }, + BlockStatement(node) { + noop(sourceCode.getCommentsInside(node)[0].start); + }, + ObjectExpression(node) { + noop(sourceCode.getCommentsInside(node)[0].end); + }, + Decorator(node) { + noop(node.start); + } + }; + } + }; + + assert.throws(() => { + ruleTester.run("uses-start-end", usesStartEndRule, { + valid: ["foo(a, b)"], + invalid: [] + }); + }, "Use node.range[0] instead of node.start"); + assert.throws(() => { + ruleTester.run("uses-start-end", usesStartEndRule, { + valid: [], + invalid: [{ code: "var a = b * (c + d) / e;", errors: 1 }] + }); + }, "Use node.range[1] instead of node.end"); + assert.throws(() => { + ruleTester.run("uses-start-end", usesStartEndRule, { + valid: [], + invalid: [{ code: "var a = -b * c;", errors: 1 }] + }); + }, "Use token.range[0] instead of token.start"); + assert.throws(() => { + ruleTester.run("uses-start-end", usesStartEndRule, { + valid: ["var a = b ? c : d;"], + invalid: [] + }); + }, "Use token.range[1] instead of token.end"); + assert.throws(() => { + ruleTester.run("uses-start-end", usesStartEndRule, { + valid: ["function f() { /* comment */ }"], + invalid: [] + }); + }, "Use token.range[0] instead of token.start"); + assert.throws(() => { + ruleTester.run("uses-start-end", usesStartEndRule, { + valid: [], + invalid: [{ code: "var x = //\n {\n //comment\n //\n}", errors: 1 }] + }); + }, "Use token.range[1] instead of token.end"); + + const enhancedParserPath = require.resolve("./fixtures/enhanced-parser"); + + assert.throws(() => { + ruleTester.run("uses-start-end", usesStartEndRule, { + valid: [{ code: "foo(a, b)", parser: enhancedParserPath }], + invalid: [] + }); + }, "Use node.range[0] instead of node.start"); + assert.throws(() => { + ruleTester.run("uses-start-end", usesStartEndRule, { + valid: [], + invalid: [{ code: "var a = b * (c + d) / e;", parser: enhancedParserPath, errors: 1 }] + }); + }, "Use node.range[1] instead of node.end"); + assert.throws(() => { + ruleTester.run("uses-start-end", usesStartEndRule, { + valid: [], + invalid: [{ code: "var a = -b * c;", parser: enhancedParserPath, errors: 1 }] + }); + }, "Use token.range[0] instead of token.start"); + assert.throws(() => { + ruleTester.run("uses-start-end", usesStartEndRule, { + valid: [{ code: "var a = b ? c : d;", parser: enhancedParserPath }], + invalid: [] + }); + }, "Use token.range[1] instead of token.end"); + assert.throws(() => { + ruleTester.run("uses-start-end", usesStartEndRule, { + valid: [{ code: "function f() { /* comment */ }", parser: enhancedParserPath }], + invalid: [] + }); + }, "Use token.range[0] instead of token.start"); + assert.throws(() => { + ruleTester.run("uses-start-end", usesStartEndRule, { + valid: [], + invalid: [{ code: "var x = //\n {\n //comment\n //\n}", parser: enhancedParserPath, errors: 1 }] + }); + }, "Use token.range[1] instead of token.end"); + + assert.throws(() => { + ruleTester.run("uses-start-end", usesStartEndRule, { + valid: [{ code: "@foo class A {}", parser: require.resolve("./fixtures/enhanced-parser2") }], + invalid: [] + }); + }, "Use node.range[0] instead of node.start"); + }); + + it("should throw an error if no test scenarios given", () => { + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/modify-ast-at-last")); + }, "Test Scenarios for rule foo : Could not find test scenario object"); + }); + + it("should throw an error if no acceptable test scenario object is given", () => { + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/modify-ast-at-last"), []); + }, "Test Scenarios for rule foo is invalid:\nCould not find any valid test scenarios\nCould not find any invalid test scenarios"); + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/modify-ast-at-last"), ""); + }, "Test Scenarios for rule foo : Could not find test scenario object"); + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/modify-ast-at-last"), 2); + }, "Test Scenarios for rule foo : Could not find test scenario object"); + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/modify-ast-at-last"), {}); + }, "Test Scenarios for rule foo is invalid:\nCould not find any valid test scenarios\nCould not find any invalid test scenarios"); + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/modify-ast-at-last"), { + valid: [] + }); + }, "Test Scenarios for rule foo is invalid:\nCould not find any invalid test scenarios"); + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/modify-ast-at-last"), { + invalid: [] + }); + }, "Test Scenarios for rule foo is invalid:\nCould not find any valid test scenarios"); + }); + + // Nominal message/messageId use cases + it("should assert match if message provided in both test and result.", () => { + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/messageId").withMessageOnly, { + valid: [], + invalid: [{ code: "foo", errors: [{ message: "something" }] }] + }); + }, /Avoid using variables named/u); + + ruleTester.run("foo", require("./fixtures/messageId").withMessageOnly, { + valid: [], + invalid: [{ code: "foo", errors: [{ message: "Avoid using variables named 'foo'." }] }] + }); + }); + + it("should assert match between messageId if provided in both test and result.", () => { + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/messageId").withMetaWithData, { + valid: [], + invalid: [{ code: "foo", errors: [{ messageId: "unused" }] }] + }); + }, "messageId 'avoidFoo' does not match expected messageId 'unused'."); + + ruleTester.run("foo", require("./fixtures/messageId").withMetaWithData, { + valid: [], + invalid: [{ code: "foo", errors: [{ messageId: "avoidFoo" }] }] + }); + }); + it("should assert match between resulting message output if messageId and data provided in both test and result", () => { + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/messageId").withMetaWithData, { + valid: [], + invalid: [{ code: "foo", errors: [{ messageId: "avoidFoo", data: { name: "notFoo" } }] }] + }); + }, "Hydrated message \"Avoid using variables named 'notFoo'.\" does not match \"Avoid using variables named 'foo'.\""); + }); + + // messageId/message misconfiguration cases + it("should throw if user tests for both message and messageId", () => { + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/messageId").withMetaWithData, { + valid: [], + invalid: [{ code: "foo", errors: [{ message: "something", messageId: "avoidFoo" }] }] + }); + }, "Error should not specify both 'message' and a 'messageId'."); + }); + it("should throw if user tests for messageId but the rule doesn't use the messageId meta syntax.", () => { + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/messageId").withMessageOnly, { + valid: [], + invalid: [{ code: "foo", errors: [{ messageId: "avoidFoo" }] }] + }); + }, "Error can not use 'messageId' if rule under test doesn't define 'meta.messages'"); + }); + it("should throw if user tests for messageId not listed in the rule's meta syntax.", () => { + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/messageId").withMetaWithData, { + valid: [], + invalid: [{ code: "foo", errors: [{ messageId: "useFoo" }] }] + }); + }, /Invalid messageId 'useFoo'/u); + }); + it("should throw if data provided without messageId.", () => { + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/messageId").withMetaWithData, { + valid: [], + invalid: [{ code: "foo", errors: [{ data: "something" }] }] + }); + }, "Error must specify 'messageId' if 'data' is used."); + }); + + describe("suggestions", () => { + it("should pass with valid suggestions (tested using desc)", () => { + ruleTester.run("suggestions-basic", require("./fixtures/suggestions").basic, { + valid: [ + "var boo;" + ], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + desc: "Rename identifier 'foo' to 'bar'", + output: "var bar;" + }] + }] + }] + }); + }); + + it("should pass with suggestions on multiple lines", () => { + ruleTester.run("suggestions-basic", require("./fixtures/suggestions").basic, { + valid: [], + invalid: [ + { + code: "function foo() {\n var foo = 1;\n}", + errors: [{ + suggestions: [{ + desc: "Rename identifier 'foo' to 'bar'", + output: "function bar() {\n var foo = 1;\n}" + }] + }, { + suggestions: [{ + desc: "Rename identifier 'foo' to 'bar'", + output: "function foo() {\n var bar = 1;\n}" + }] + }] + } + ] + }); + }); + + it("should pass with valid suggestions (tested using messageIds)", () => { + ruleTester.run("suggestions-messageIds", require("./fixtures/suggestions").withMessageIds, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + messageId: "renameFoo", + output: "var bar;" + }, { + messageId: "renameFoo", + output: "var baz;" + }] + }] + }] + }); + }); + + it("should pass with valid suggestions (one tested using messageIds, the other using desc)", () => { + ruleTester.run("suggestions-messageIds", require("./fixtures/suggestions").withMessageIds, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + messageId: "renameFoo", + output: "var bar;" + }, { + desc: "Rename identifier 'foo' to 'baz'", + output: "var baz;" + }] + }] + }] + }); + }); + + it("should pass with valid suggestions (tested using both desc and messageIds for the same suggestion)", () => { + ruleTester.run("suggestions-messageIds", require("./fixtures/suggestions").withMessageIds, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + desc: "Rename identifier 'foo' to 'bar'", + messageId: "renameFoo", + output: "var bar;" + }, { + desc: "Rename identifier 'foo' to 'baz'", + messageId: "renameFoo", + output: "var baz;" + }] + }] + }] + }); + }); + + it("should pass with valid suggestions (tested using only desc on a rule that utilizes meta.messages)", () => { + ruleTester.run("suggestions-messageIds", require("./fixtures/suggestions").withMessageIds, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + desc: "Rename identifier 'foo' to 'bar'", + output: "var bar;" + }, { + desc: "Rename identifier 'foo' to 'baz'", + output: "var baz;" + }] + }] + }] + }); + }); + + it("should pass with valid suggestions (tested using messageIds and data)", () => { + ruleTester.run("suggestions-messageIds", require("./fixtures/suggestions").withMessageIds, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + messageId: "renameFoo", + data: { newName: "bar" }, + output: "var bar;" + }, { + messageId: "renameFoo", + data: { newName: "baz" }, + output: "var baz;" + }] + }] + }] + }); + }); + + + it("should pass when tested using empty suggestion test objects if the array length is correct", () => { + ruleTester.run("suggestions-messageIds", require("./fixtures/suggestions").withMessageIds, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{}, {}] + }] + }] + }); + }); + + it("should support explicitly expecting no suggestions", () => { + [void 0, null, false, []].forEach(suggestions => { + ruleTester.run("suggestions-basic", require("./fixtures/no-eval"), { + valid: [], + invalid: [{ + code: "eval('var foo');", + errors: [{ + suggestions + }] + }] + }); + }); + }); + + it("should fail when expecting no suggestions and there are suggestions", () => { + [void 0, null, false, []].forEach(suggestions => { + assert.throws(() => { + ruleTester.run("suggestions-basic", require("./fixtures/suggestions").basic, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions + }] + }] + }); + }, "Error should have no suggestions on error with message: \"Avoid using identifiers named 'foo'.\""); + }); + }); + + it("should fail when testing for suggestions that don't exist", () => { + assert.throws(() => { + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + messageId: "this-does-not-exist" + }] + }] + }] + }); + }, "Error should have an array of suggestions. Instead received \"undefined\" on error with message: \"Bad var.\""); + }); + + it("should fail when there are a different number of suggestions", () => { + assert.throws(() => { + ruleTester.run("suggestions-basic", require("./fixtures/suggestions").basic, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + desc: "Rename identifier 'foo' to 'bar'", + output: "var bar;" + }, { + desc: "Rename identifier 'foo' to 'baz'", + output: "var baz;" + }] + }] + }] + }); + }, "Error should have 2 suggestions. Instead found 1 suggestions"); + }); + + it("should throw if the suggestion description doesn't match", () => { + assert.throws(() => { + ruleTester.run("suggestions-basic", require("./fixtures/suggestions").basic, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + desc: "not right", + output: "var baz;" + }] + }] + }] + }); + }, "Error Suggestion at index 0 : desc should be \"not right\" but got \"Rename identifier 'foo' to 'bar'\" instead."); + }); + + it("should throw if the suggestion description doesn't match (although messageIds match)", () => { + assert.throws(() => { + ruleTester.run("suggestions-messageIds", require("./fixtures/suggestions").withMessageIds, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + desc: "Rename identifier 'foo' to 'bar'", + messageId: "renameFoo", + output: "var bar;" + }, { + desc: "Rename id 'foo' to 'baz'", + messageId: "renameFoo", + output: "var baz;" + }] + }] + }] + }); + }, "Error Suggestion at index 1 : desc should be \"Rename id 'foo' to 'baz'\" but got \"Rename identifier 'foo' to 'baz'\" instead."); + }); + + it("should throw if the suggestion messageId doesn't match", () => { + assert.throws(() => { + ruleTester.run("suggestions-messageIds", require("./fixtures/suggestions").withMessageIds, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + messageId: "unused", + output: "var bar;" + }, { + messageId: "renameFoo", + output: "var baz;" + }] + }] + }] + }); + }, "Error Suggestion at index 0 : messageId should be 'unused' but got 'renameFoo' instead."); + }); + + it("should throw if the suggestion messageId doesn't match (although descriptions match)", () => { + assert.throws(() => { + ruleTester.run("suggestions-messageIds", require("./fixtures/suggestions").withMessageIds, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + desc: "Rename identifier 'foo' to 'bar'", + messageId: "renameFoo", + output: "var bar;" + }, { + desc: "Rename identifier 'foo' to 'baz'", + messageId: "avoidFoo", + output: "var baz;" + }] + }] + }] + }); + }, "Error Suggestion at index 1 : messageId should be 'avoidFoo' but got 'renameFoo' instead."); + }); + + it("should throw if test specifies messageId for a rule that doesn't have meta.messages", () => { + assert.throws(() => { + ruleTester.run("suggestions-basic", require("./fixtures/suggestions").basic, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + messageId: "renameFoo", + output: "var bar;" + }] + }] + }] + }); + }, "Error Suggestion at index 0 : Test can not use 'messageId' if rule under test doesn't define 'meta.messages'."); + }); + + it("should throw if test specifies messageId that doesn't exist in the rule's meta.messages", () => { + assert.throws(() => { + ruleTester.run("suggestions-messageIds", require("./fixtures/suggestions").withMessageIds, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + messageId: "renameFoo", + output: "var bar;" + }, { + messageId: "removeFoo", + output: "var baz;" + }] + }] + }] + }); + }, "Error Suggestion at index 1 : Test has invalid messageId 'removeFoo', the rule under test allows only one of ['avoidFoo', 'unused', 'renameFoo']."); + }); + + it("should throw if hydrated desc doesn't match (wrong data value)", () => { + assert.throws(() => { + ruleTester.run("suggestions-messageIds", require("./fixtures/suggestions").withMessageIds, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + messageId: "renameFoo", + data: { newName: "car" }, + output: "var bar;" + }, { + messageId: "renameFoo", + data: { newName: "baz" }, + output: "var baz;" + }] + }] + }] + }); + }, "Error Suggestion at index 0 : Hydrated test desc \"Rename identifier 'foo' to 'car'\" does not match received desc \"Rename identifier 'foo' to 'bar'\"."); + }); + + it("should throw if hydrated desc doesn't match (wrong data key)", () => { + assert.throws(() => { + ruleTester.run("suggestions-messageIds", require("./fixtures/suggestions").withMessageIds, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + messageId: "renameFoo", + data: { newName: "bar" }, + output: "var bar;" + }, { + messageId: "renameFoo", + data: { name: "baz" }, + output: "var baz;" + }] + }] + }] + }); + }, "Error Suggestion at index 1 : Hydrated test desc \"Rename identifier 'foo' to '{{ newName }}'\" does not match received desc \"Rename identifier 'foo' to 'baz'\"."); + }); + + it("should throw if test specifies both desc and data", () => { + assert.throws(() => { + ruleTester.run("suggestions-messageIds", require("./fixtures/suggestions").withMessageIds, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + desc: "Rename identifier 'foo' to 'bar'", + messageId: "renameFoo", + data: { newName: "bar" }, + output: "var bar;" + }, { + messageId: "renameFoo", + data: { newName: "baz" }, + output: "var baz;" + }] + }] + }] + }); + }, "Error Suggestion at index 0 : Test should not specify both 'desc' and 'data'."); + }); + + it("should throw if test uses data but doesn't specify messageId", () => { + assert.throws(() => { + ruleTester.run("suggestions-messageIds", require("./fixtures/suggestions").withMessageIds, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + messageId: "renameFoo", + data: { newName: "bar" }, + output: "var bar;" + }, { + data: { newName: "baz" }, + output: "var baz;" + }] + }] + }] + }); + }, "Error Suggestion at index 1 : Test must specify 'messageId' if 'data' is used."); + }); + + it("should throw if the resulting suggestion output doesn't match", () => { + assert.throws(() => { + ruleTester.run("suggestions-basic", require("./fixtures/suggestions").basic, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + desc: "Rename identifier 'foo' to 'bar'", + output: "var baz;" + }] + }] + }] + }); + }, "Expected the applied suggestion fix to match the test suggestion output"); + }); + + it("should fail when specified suggestion isn't an object", () => { + assert.throws(() => { + ruleTester.run("suggestions-basic", require("./fixtures/suggestions").basic, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [null] + }] + }] + }); + }, "Test suggestion in 'suggestions' array must be an object."); + + assert.throws(() => { + ruleTester.run("suggestions-messageIds", require("./fixtures/suggestions").withMessageIds, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [ + { + messageId: "renameFoo", + output: "var bar;" + }, + "Rename identifier 'foo' to 'baz'" + ] + }] + }] + }); + }, "Test suggestion in 'suggestions' array must be an object."); + }); + + it("should fail when the suggestion is an object with an unknown property name", () => { + assert.throws(() => { + ruleTester.run("suggestions-basic", require("./fixtures/suggestions").basic, { + valid: [ + "var boo;" + ], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + message: "Rename identifier 'foo' to 'bar'" + }] + }] + }] + }); + }, /Invalid suggestion property name 'message'/u); + }); + + it("should fail when any of the suggestions is an object with an unknown property name", () => { + assert.throws(() => { + ruleTester.run("suggestions-messageIds", require("./fixtures/suggestions").withMessageIds, { + valid: [], + invalid: [{ + code: "var foo;", + errors: [{ + suggestions: [{ + messageId: "renameFoo", + output: "var bar;" + }, { + messageId: "renameFoo", + outpt: "var baz;" + }] + }] + }] + }); + }, /Invalid suggestion property name 'outpt'/u); + }); + + it("should throw an error if a rule that doesn't have `meta.hasSuggestions` enabled produces suggestions", () => { + assert.throws(() => { + ruleTester.run("suggestions-missing-hasSuggestions-property", require("./fixtures/suggestions").withoutHasSuggestionsProperty, { + valid: [], + invalid: [ + { code: "var foo = bar;", output: "5", errors: 1 } + ] + }); + }, "Rules with suggestions must set the `meta.hasSuggestions` property to `true`."); + }); + }); + + describe("deprecations", () => { + let processStub; + const ruleWithNoSchema = { + meta: { + type: "suggestion" + }, + create(context) { + return { + Program(node) { + context.report({ node, message: "bad" }); + } + }; + } + }; + const ruleWithNoMeta = { + create(context) { + return { + Program(node) { + context.report({ node, message: "bad" }); + } + }; + } + }; + + beforeEach(() => { + processStub = sinon.stub(process, "emitWarning"); + }); + + afterEach(() => { + processStub.restore(); + }); + + it("should log a deprecation warning when using the legacy function-style API for rule", () => { + + /** + * Legacy-format rule (a function instead of an object with `create` method). + * @param {RuleContext} context The ESLint rule context object. + * @returns {Object} Listeners. + */ + function functionStyleRule(context) { + return { + Program(node) { + context.report({ node, message: "bad" }); + } + }; + } + + ruleTester.run("function-style-rule", functionStyleRule, { + valid: [], + invalid: [ + { code: "var foo = bar;", errors: 1 } + ] + }); + + assert.strictEqual(processStub.callCount, 1, "calls `process.emitWarning()` once"); + assert.deepStrictEqual( + processStub.getCall(0).args, + [ + "\"function-style-rule\" rule is using the deprecated function-style format and will stop working in ESLint v9. Please use object-style format: https://eslint.org/docs/latest/extend/custom-rules", + "DeprecationWarning" + ] + ); + }); + + it("should log a deprecation warning when meta is not defined for the rule", () => { + ruleTester.run("rule-with-no-meta-1", ruleWithNoMeta, { + valid: [], + invalid: [ + { code: "var foo = bar;", options: [{ foo: true }], errors: 1 } + ] + }); + + assert.strictEqual(processStub.callCount, 1, "calls `process.emitWarning()` once"); + assert.deepStrictEqual( + processStub.getCall(0).args, + [ + "\"rule-with-no-meta-1\" rule has options but is missing the \"meta.schema\" property and will stop working in ESLint v9. Please add a schema: https://eslint.org/docs/latest/extend/custom-rules#options-schemas", + "DeprecationWarning" + ] + ); + }); + + it("should log a deprecation warning when schema is not defined for the rule", () => { + ruleTester.run("rule-with-no-schema-1", ruleWithNoSchema, { + valid: [], + invalid: [ + { code: "var foo = bar;", options: [{ foo: true }], errors: 1 } + ] + }); + + assert.strictEqual(processStub.callCount, 1, "calls `process.emitWarning()` once"); + assert.deepStrictEqual( + processStub.getCall(0).args, + [ + "\"rule-with-no-schema-1\" rule has options but is missing the \"meta.schema\" property and will stop working in ESLint v9. Please add a schema: https://eslint.org/docs/latest/extend/custom-rules#options-schemas", + "DeprecationWarning" + ] + ); + }); + + it("should log a deprecation warning when schema is `undefined`", () => { + const ruleWithUndefinedSchema = { + meta: { + type: "problem", + // eslint-disable-next-line no-undefined -- intentionally added for test case + schema: undefined + }, + create(context) { + return { + Program(node) { + context.report({ node, message: "bad" }); + } + }; + } + }; + + ruleTester.run("rule-with-undefined-schema", ruleWithUndefinedSchema, { + valid: [], + invalid: [ + { code: "var foo = bar;", options: [{ foo: true }], errors: 1 } + ] + }); + + assert.strictEqual(processStub.callCount, 1, "calls `process.emitWarning()` once"); + assert.deepStrictEqual( + processStub.getCall(0).args, + [ + "\"rule-with-undefined-schema\" rule has options but is missing the \"meta.schema\" property and will stop working in ESLint v9. Please add a schema: https://eslint.org/docs/latest/extend/custom-rules#options-schemas", + "DeprecationWarning" + ] + ); + }); + + it("should log a deprecation warning when schema is `null`", () => { + const ruleWithNullSchema = { + meta: { + type: "problem", + schema: null + }, + create(context) { + return { + Program(node) { + context.report({ node, message: "bad" }); + } + }; + } + }; + + ruleTester.run("rule-with-null-schema", ruleWithNullSchema, { + valid: [], + invalid: [ + { code: "var foo = bar;", options: [{ foo: true }], errors: 1 } + ] + }); + + assert.strictEqual(processStub.callCount, 1, "calls `process.emitWarning()` once"); + assert.deepStrictEqual( + processStub.getCall(0).args, + [ + "\"rule-with-null-schema\" rule has options but is missing the \"meta.schema\" property and will stop working in ESLint v9. Please add a schema: https://eslint.org/docs/latest/extend/custom-rules#options-schemas", + "DeprecationWarning" + ] + ); + }); + + it("should not log a deprecation warning when schema is an empty array", () => { + const ruleWithEmptySchema = { + meta: { + type: "suggestion", + schema: [] + }, + create(context) { + return { + Program(node) { + context.report({ node, message: "bad" }); + } + }; + } + }; + + ruleTester.run("rule-with-no-options", ruleWithEmptySchema, { + valid: [], + invalid: [{ code: "var foo = bar;", errors: 1 }] + }); + + assert.strictEqual(processStub.callCount, 0, "never calls `process.emitWarning()`"); + }); + + it("When the rule is an object-style rule, the legacy rule API warning is not emitted", () => { + ruleTester.run("rule-with-no-schema-2", ruleWithNoSchema, { + valid: [], + invalid: [ + { code: "var foo = bar;", errors: 1 } + ] + }); + + assert.strictEqual(processStub.callCount, 0, "never calls `process.emitWarning()`"); + }); + + it("When the rule has meta.schema and there are test cases with options, the missing schema warning is not emitted", () => { + const ruleWithSchema = { + meta: { + type: "suggestion", + schema: [{ + type: "boolean" + }] + }, + create(context) { + return { + Program(node) { + context.report({ node, message: "bad" }); + } + }; + } + }; + + ruleTester.run("rule-with-schema", ruleWithSchema, { + valid: [], + invalid: [ + { code: "var foo = bar;", options: [true], errors: 1 } + ] + }); + + assert.strictEqual(processStub.callCount, 0, "never calls `process.emitWarning()`"); + }); + + it("When the rule does not have meta, but there are no test cases with options, the missing schema warning is not emitted", () => { + ruleTester.run("rule-with-no-meta-2", ruleWithNoMeta, { + valid: [], + invalid: [ + { code: "var foo = bar;", errors: 1 } + ] + }); + + assert.strictEqual(processStub.callCount, 0, "never calls `process.emitWarning()`"); + }); + + it("When the rule has meta without meta.schema, but there are no test cases with options, the missing schema warning is not emitted", () => { + ruleTester.run("rule-with-no-schema-3", ruleWithNoSchema, { + valid: [], + invalid: [ + { code: "var foo = bar;", errors: 1 } + ] + }); + + assert.strictEqual(processStub.callCount, 0, "never calls `process.emitWarning()`"); + }); + it("When the rule has meta without meta.schema, and some test cases have options property but it's an empty array, the missing schema warning is not emitted", () => { + ruleTester.run("rule-with-no-schema-4", ruleWithNoSchema, { + valid: [], + invalid: [ + { code: "var foo = bar;", options: [], errors: 1 } + ] + }); + + assert.strictEqual(processStub.callCount, 0, "never calls `process.emitWarning()`"); + }); + }); + + /** + * Asserts that a particular value will be emitted from an EventEmitter. + * @param {EventEmitter} emitter The emitter that should emit a value + * @param {string} emitType The type of emission to listen for + * @param {any} expectedValue The value that should be emitted + * @returns {Promise} A Promise that fulfills if the value is emitted, and rejects if something else is emitted. + * The Promise will be indefinitely pending if no value is emitted. + */ + function assertEmitted(emitter, emitType, expectedValue) { + return new Promise((resolve, reject) => { + emitter.once(emitType, emittedValue => { + if (emittedValue === expectedValue) { + resolve(); + } else { + reject(new Error(`Expected ${expectedValue} to be emitted but ${emittedValue} was emitted instead.`)); + } + }); + }); + } + + describe("naming test cases", () => { + + it("should use the first argument as the name of the test suite", () => { + const assertion = assertEmitted(ruleTesterTestEmitter, "describe", "this-is-a-rule-name"); + + ruleTester.run("this-is-a-rule-name", require("./fixtures/no-var"), { + valid: [], + invalid: [] + }); + + return assertion; + }); + + it("should use the test code as the name of the tests for valid code (string form)", () => { + const assertion = assertEmitted(ruleTesterTestEmitter, "it", "valid(code);"); + + ruleTester.run("foo", require("./fixtures/no-var"), { + valid: [ + "valid(code);" + ], + invalid: [] + }); + + return assertion; + }); + + it("should use the test code as the name of the tests for valid code (object form)", () => { + const assertion = assertEmitted(ruleTesterTestEmitter, "it", "valid(code);"); + + ruleTester.run("foo", require("./fixtures/no-var"), { + valid: [ + { + code: "valid(code);" + } + ], + invalid: [] + }); + + return assertion; + }); + + it("should use the test code as the name of the tests for invalid code", () => { + const assertion = assertEmitted(ruleTesterTestEmitter, "it", "var x = invalid(code);"); + + ruleTester.run("foo", require("./fixtures/no-var"), { + valid: [], + invalid: [ + { + code: "var x = invalid(code);", + output: " x = invalid(code);", + errors: 1 + } + ] + }); + + return assertion; + }); + + // https://github.com/eslint/eslint/issues/8142 + it("should use the empty string as the name of the test if the test case is an empty string", () => { + const assertion = assertEmitted(ruleTesterTestEmitter, "it", ""); + + ruleTester.run("foo", require("./fixtures/no-var"), { + valid: [ + { + code: "" + } + ], + invalid: [] + }); + + return assertion; + }); + + it('should use the "name" property if set to a non-empty string', () => { + const assertion = assertEmitted(ruleTesterTestEmitter, "it", "my test"); + + ruleTester.run("foo", require("./fixtures/no-var"), { + valid: [], + invalid: [ + { + name: "my test", + code: "var x = invalid(code);", + output: " x = invalid(code);", + errors: 1 + } + ] + }); + + return assertion; + }); + + it('should use the "name" property if set to a non-empty string for valid cases too', () => { + const assertion = assertEmitted(ruleTesterTestEmitter, "it", "my test"); + + ruleTester.run("foo", require("./fixtures/no-var"), { + valid: [ + { + name: "my test", + code: "valid(code);" + } + ], + invalid: [] + }); + + return assertion; + }); + + + it('should use the test code as the name if the "name" property is set to an empty string', () => { + const assertion = assertEmitted(ruleTesterTestEmitter, "it", "var x = invalid(code);"); + + ruleTester.run("foo", require("./fixtures/no-var"), { + valid: [], + invalid: [ + { + name: "", + code: "var x = invalid(code);", + output: " x = invalid(code);", + errors: 1 + } + ] + }); + + return assertion; + }); + + it('should throw if "name" property is not a string', () => { + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/no-var"), { + valid: [{ code: "foo", name: 123 }], + invalid: [{ code: "foo" }] + + }); + }, /Optional test case property 'name' must be a string/u); + + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/no-var"), { + valid: ["foo"], + invalid: [{ code: "foo", name: 123 }] + }); + }, /Optional test case property 'name' must be a string/u); + }); + + it('should throw if "code" property is not a string', () => { + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/no-var"), { + valid: [{ code: 123 }], + invalid: [{ code: "foo" }] + + }); + }, /Test case must specify a string value for 'code'/u); + + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/no-var"), { + valid: [123], + invalid: [{ code: "foo" }] + + }); + }, /Test case must specify a string value for 'code'/u); + + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/no-var"), { + valid: ["foo"], + invalid: [{ code: 123 }] + }); + }, /Test case must specify a string value for 'code'/u); + }); + + it('should throw if "code" property is missing', () => { + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/no-var"), { + valid: [{ }], + invalid: [{ code: "foo" }] + + }); + }, /Test case must specify a string value for 'code'/u); + + assert.throws(() => { + ruleTester.run("foo", require("./fixtures/no-var"), { + valid: ["foo"], + invalid: [{ }] + }); + }, /Test case must specify a string value for 'code'/u); + }); + }); + + // https://github.com/eslint/eslint/issues/11615 + it("should fail the case if autofix made a syntax error.", () => { + assert.throw(() => { + ruleTester.run( + "foo", + { + meta: { + fixable: "code" + }, + create(context) { + return { + Identifier(node) { + context.report({ + node, + message: "make a syntax error", + fix(fixer) { + return fixer.replaceText(node, "one two"); + } + }); + } + }; + } + }, + { + valid: ["one()"], + invalid: [] + } + ); + }, /A fatal parsing error occurred in autofix.\nError: .+\nAutofix output:\n.+/u); + }); + + describe("sanitize test cases", () => { + let originalRuleTesterIt; + let spyRuleTesterIt; + + before(() => { + originalRuleTesterIt = RuleTester.it; + spyRuleTesterIt = sinon.spy(); + RuleTester.it = spyRuleTesterIt; + }); + after(() => { + RuleTester.it = originalRuleTesterIt; + }); + beforeEach(() => { + spyRuleTesterIt.resetHistory(); + ruleTester = new RuleTester(); + }); + it("should present newline when using back-tick as new line", () => { + const code = ` + var foo = bar;`; + + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [], + invalid: [ + { + code, + errors: [/^Bad var/u] + } + ] + }); + sinon.assert.calledWith(spyRuleTesterIt, code); + }); + it("should present \\u0000 as a string", () => { + const code = "\u0000"; + + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [], + invalid: [ + { + code, + errors: [/^Bad var/u] + } + ] + }); + sinon.assert.calledWith(spyRuleTesterIt, "\\u0000"); + }); + it("should present the pipe character correctly", () => { + const code = "var foo = bar || baz;"; + + ruleTester.run("no-var", require("./fixtures/no-var"), { + valid: [], + invalid: [ + { + code, + errors: [/^Bad var/u] + } + ] + }); + sinon.assert.calledWith(spyRuleTesterIt, code); + }); + + }); + + describe("SourceCode#getComments()", () => { + const useGetCommentsRule = { + create: context => ({ + Program(node) { + const sourceCode = context.getSourceCode(); + + sourceCode.getComments(node); + } + }) + }; + + it("should throw if called from a valid test case", () => { + assert.throws(() => { + ruleTester.run("use-get-comments", useGetCommentsRule, { + valid: [""], + invalid: [] + }); + }, /`SourceCode#getComments\(\)` is deprecated/u); + }); + + it("should throw if called from an invalid test case", () => { + assert.throws(() => { + ruleTester.run("use-get-comments", useGetCommentsRule, { + valid: [], + invalid: [{ + code: "", + errors: [{}] + }] + }); + }, /`SourceCode#getComments\(\)` is deprecated/u); + }); + }); + + describe("Subclassing", () => { + + it("should allow subclasses to set the describe/it/itOnly statics and should correctly use those values", () => { + const assertionDescribe = assertEmitted(ruleTesterTestEmitter, "custom describe", "this-is-a-rule-name"); + const assertionIt = assertEmitted(ruleTesterTestEmitter, "custom it", "valid(code);"); + const assertionItOnly = assertEmitted(ruleTesterTestEmitter, "custom itOnly", "validOnly(code);"); + + /** + * Subclass for testing + */ + class RuleTesterSubclass extends RuleTester { } + RuleTesterSubclass.describe = function(text, method) { + ruleTesterTestEmitter.emit("custom describe", text, method); + return method.call(this); + }; + RuleTesterSubclass.it = function(text, method) { + ruleTesterTestEmitter.emit("custom it", text, method); + return method.call(this); + }; + RuleTesterSubclass.itOnly = function(text, method) { + ruleTesterTestEmitter.emit("custom itOnly", text, method); + return method.call(this); + }; + + const ruleTesterSubclass = new RuleTesterSubclass(); + + ruleTesterSubclass.run("this-is-a-rule-name", require("./fixtures/no-var"), { + valid: [ + "valid(code);", + { + code: "validOnly(code);", + only: true + } + ], + invalid: [] + }); + + return Promise.all([ + assertionDescribe, + assertionIt, + assertionItOnly + ]); + }); + + }); + +}); diff --git a/packages/rule-tester/tests/eslint-base/fixtures/empty-program-parser.js b/packages/rule-tester/tests/eslint-base/fixtures/empty-program-parser.js new file mode 100644 index 00000000000..06a87c90dcd --- /dev/null +++ b/packages/rule-tester/tests/eslint-base/fixtures/empty-program-parser.js @@ -0,0 +1,29 @@ +// Forked from https://github.com/eslint/eslint/blob/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/tests/fixtures/parsers/empty-program-parser.js + +"use strict"; + +exports.parse = function (text, parserOptions) { + return { + "type": "Program", + "start": 0, + "end": 0, + "loc": { + "start": { + "line": 1, + "column": 0 + }, + "end": { + "line": 1, + "column": 0 + } + }, + "range": [ + 0, + 0 + ], + "body": [], + "sourceType": "script", + "comments": [], + "tokens": [] + }; +}; diff --git a/packages/rule-tester/tests/eslint-base/fixtures/enhanced-parser.js b/packages/rule-tester/tests/eslint-base/fixtures/enhanced-parser.js new file mode 100644 index 00000000000..9aef71554d3 --- /dev/null +++ b/packages/rule-tester/tests/eslint-base/fixtures/enhanced-parser.js @@ -0,0 +1,20 @@ +// Forked from https://github.com/eslint/eslint/blob/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/tests/fixtures/parsers/enhanced-parser.js + +var espree = require("espree"); + +exports.parseForESLint = function(code, options) { + return { + ast: espree.parse(code, options), + services: { + test: { + getMessage() { + return "Hi!"; + } + } + } + }; +}; + +exports.parse = function() { + throw new Error("Use parseForESLint() instead."); +}; diff --git a/packages/rule-tester/tests/eslint-base/fixtures/enhanced-parser2.js b/packages/rule-tester/tests/eslint-base/fixtures/enhanced-parser2.js new file mode 100644 index 00000000000..bd91282a38c --- /dev/null +++ b/packages/rule-tester/tests/eslint-base/fixtures/enhanced-parser2.js @@ -0,0 +1,25 @@ +// Forked from https://github.com/eslint/eslint/blob/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/tests/fixtures/parsers/enhanced-parser2.js + +"use strict"; + +const assert = require("assert"); +const vk = require("eslint-visitor-keys"); +const KEYS = vk.unionWith({ + ClassDeclaration: ["experimentalDecorators"], + ClassExpression: ["experimentalDecorators"] +}) + +exports.parseForESLint = (code, options) => { + assert(code === "@foo class A {}"); + assert(options.eslintVisitorKeys === true); + assert(options.eslintScopeManager === true); + + return { + ast: { type: "Program", start: 0, end: 15, loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 15 } }, comments: [], tokens: [{ type: "Punctuator", value: "@", start: 0, end: 1, loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 1 } }, range: [0, 1] }, { type: "Identifier", value: "foo", start: 1, end: 4, loc: { start: { line: 1, column: 1 }, end: { line: 1, column: 4 } }, range: [1, 4] }, { type: "Keyword", value: "class", start: 5, end: 10, loc: { start: { line: 1, column: 5 }, end: { line: 1, column: 10 } }, range: [5, 10] }, { type: "Identifier", value: "A", start: 11, end: 12, loc: { start: { line: 1, column: 11 }, end: { line: 1, column: 12 } }, range: [11, 12] }, { type: "Punctuator", value: "{", start: 13, end: 14, loc: { start: { line: 1, column: 13 }, end: { line: 1, column: 14 } }, range: [13, 14] }, { type: "Punctuator", value: "}", start: 14, end: 15, loc: { start: { line: 1, column: 14 }, end: { line: 1, column: 15 } }, range: [14, 15] }], range: [5, 15], sourceType: "module", body: [{ type: "ClassDeclaration", start: 5, end: 15, loc: { start: { line: 1, column: 5 }, end: { line: 1, column: 15 } }, experimentalDecorators: [{ type: "Decorator", start: 0, end: 4, loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 4 } }, expression: { type: "Identifier", start: 1, end: 4, loc: { start: { line: 1, column: 1 }, end: { line: 1, column: 4 }, identifierName: "foo" }, name: "foo", range: [1, 4], _babelType: "Identifier" }, range: [0, 4], _babelType: "Decorator" }], id: { type: "Identifier", start: 11, end: 12, loc: { start: { line: 1, column: 11 }, end: { line: 1, column: 12 }, identifierName: "A" }, name: "A", range: [11, 12], _babelType: "Identifier" }, superClass: null, body: { type: "ClassBody", start: 13, end: 15, loc: { start: { line: 1, column: 13 }, end: { line: 1, column: 15 } }, body: [], range: [13, 15], _babelType: "ClassBody" }, range: [5, 15], _babelType: "ClassDeclaration" }] }, + visitorKeys: KEYS + }; +}; + +exports.parse = function () { + throw new Error("Use parseForESLint() instead."); +}; diff --git a/packages/rule-tester/tests/eslint-base/fixtures/fixes-one-problem.js b/packages/rule-tester/tests/eslint-base/fixtures/fixes-one-problem.js new file mode 100644 index 00000000000..adde04196c9 --- /dev/null +++ b/packages/rule-tester/tests/eslint-base/fixtures/fixes-one-problem.js @@ -0,0 +1,25 @@ +// Forked from https://github.com/eslint/eslint/tree/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/tests/fixtures/testers/rule-tester/fixes-one-problem.js + +"use strict"; + +module.exports = { + meta: { + fixable: "code" + }, + create(context) { + return { + Program(node) { + context.report({ + node, + message: "No programs allowed." + }); + + context.report({ + node, + message: "Seriously, no programs allowed.", + fix: fixer => fixer.remove(node) + }); + } + } + } +}; diff --git a/packages/rule-tester/tests/eslint-base/fixtures/messageId.js b/packages/rule-tester/tests/eslint-base/fixtures/messageId.js new file mode 100644 index 00000000000..8f2bb2a246f --- /dev/null +++ b/packages/rule-tester/tests/eslint-base/fixtures/messageId.js @@ -0,0 +1,39 @@ +// Forked from https://github.com/eslint/eslint/tree/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/tests/fixtures/testers/rule-tester/messageId.js + +"use strict"; + +module.exports.withMetaWithData = { + meta: { + messages: { + avoidFoo: "Avoid using variables named '{{ name }}'.", + unused: "An unused key" + } + }, + create(context) { + return { + Identifier(node) { + if (node.name === "foo") { + context.report({ + node, + messageId: "avoidFoo", + data: { + name: "foo" + } + }); + } + } + }; + } +}; + +module.exports.withMessageOnly = { + create(context) { + return { + Identifier(node) { + if (node.name === "foo") { + context.report({ node, message: "Avoid using variables named 'foo'."}); + } + } + }; + } +}; diff --git a/packages/rule-tester/tests/eslint-base/fixtures/modify-ast-at-first.js b/packages/rule-tester/tests/eslint-base/fixtures/modify-ast-at-first.js new file mode 100644 index 00000000000..53ddcd28847 --- /dev/null +++ b/packages/rule-tester/tests/eslint-base/fixtures/modify-ast-at-first.js @@ -0,0 +1,37 @@ +// Forked from https://github.com/eslint/eslint/tree/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/tests/fixtures/testers/rule-tester/modify-ast-at-first.js + +"use strict"; + +module.exports = { + meta: { + type: "problem", + schema: [] + }, + create(context) { + return { + "Program": function(node) { + node.body.push({ + "type": "Identifier", + "name": "modified", + "range": [0, 8], + "loc": { + "start": { + "line": 1, + "column": 0 + }, + "end": { + "line": 1, + "column": 8 + } + } + }); + }, + + "Identifier": function(node) { + if (node.name === "bar") { + context.report({message: "error", node: node}); + } + } + }; + }, +}; diff --git a/packages/rule-tester/tests/eslint-base/fixtures/modify-ast-at-last.js b/packages/rule-tester/tests/eslint-base/fixtures/modify-ast-at-last.js new file mode 100644 index 00000000000..9fda56d0260 --- /dev/null +++ b/packages/rule-tester/tests/eslint-base/fixtures/modify-ast-at-last.js @@ -0,0 +1,37 @@ +// Forked from https://github.com/eslint/eslint/tree/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/tests/fixtures/testers/rule-tester/modify-ast-at-last.js + +"use strict"; + +module.exports = { + meta: { + type: "problem", + schema: [] + }, + create(context) { + return { + "Program:exit": function(node) { + node.body.push({ + "type": "Identifier", + "name": "modified", + "range": [0, 8], + "loc": { + "start": { + "line": 1, + "column": 0 + }, + "end": { + "line": 1, + "column": 8 + } + } + }); + }, + + "Identifier": function(node) { + if (node.name === "bar") { + context.report({message: "error", node: node}); + } + } + }; + }, +}; diff --git a/packages/rule-tester/tests/eslint-base/fixtures/modify-ast.js b/packages/rule-tester/tests/eslint-base/fixtures/modify-ast.js new file mode 100644 index 00000000000..3b8a879920a --- /dev/null +++ b/packages/rule-tester/tests/eslint-base/fixtures/modify-ast.js @@ -0,0 +1,21 @@ +// Forked from https://github.com/eslint/eslint/tree/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/tests/fixtures/testers/rule-tester/modify-ast.js + +"use strict"; + +module.exports = { + meta: { + type: "problem", + schema: [] + }, + create(context) { + return { + "Identifier": function(node) { + node.name += "!"; + + if (node.name === "bar!") { + context.report({message: "error", node: node}); + } + } + }; + }, +}; diff --git a/packages/rule-tester/tests/eslint-base/fixtures/no-eval.js b/packages/rule-tester/tests/eslint-base/fixtures/no-eval.js new file mode 100644 index 00000000000..a7cba23f612 --- /dev/null +++ b/packages/rule-tester/tests/eslint-base/fixtures/no-eval.js @@ -0,0 +1,19 @@ +// Forked from https://github.com/eslint/eslint/tree/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/tests/fixtures/testers/rule-tester/no-eval.js + +"use strict"; + +module.exports = { + meta: { + type: "problem", + schema: [], + }, + create(context) { + return { + CallExpression: function (node) { + if (node.callee.name === "eval") { + context.report(node, "eval sucks."); + } + }, + }; + }, +}; diff --git a/packages/rule-tester/tests/eslint-base/fixtures/no-invalid-args.js b/packages/rule-tester/tests/eslint-base/fixtures/no-invalid-args.js new file mode 100644 index 00000000000..7f9a1683aa0 --- /dev/null +++ b/packages/rule-tester/tests/eslint-base/fixtures/no-invalid-args.js @@ -0,0 +1,23 @@ +// Forked from https://github.com/eslint/eslint/tree/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/tests/fixtures/testers/rule-tester/no-invalid-args.js + +"use strict"; + +module.exports = { + meta: { + type: "problem", + schema: [{ + type: "boolean" + }] + }, + create(context) { + var config = context.options[0]; + + return { + "Program": function(node) { + if (config === true) { + context.report(node, "Invalid args"); + } + } + }; + } +}; diff --git a/packages/rule-tester/tests/eslint-base/fixtures/no-invalid-schema.js b/packages/rule-tester/tests/eslint-base/fixtures/no-invalid-schema.js new file mode 100644 index 00000000000..fd691f11a96 --- /dev/null +++ b/packages/rule-tester/tests/eslint-base/fixtures/no-invalid-schema.js @@ -0,0 +1,21 @@ +// Forked from https://github.com/eslint/eslint/tree/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/tests/fixtures/testers/rule-tester/no-invalid-schema.js + +"use strict"; + +module.exports = { + meta: { + type: "problem", + schema: [{ + "enum": [] + }] + }, + create(context) { + return { + "Program": function(node) { + if (config) { + context.report(node, "Expected nothing."); + } + } + }; + }, +}; diff --git a/packages/rule-tester/tests/eslint-base/fixtures/no-schema-violation.js b/packages/rule-tester/tests/eslint-base/fixtures/no-schema-violation.js new file mode 100644 index 00000000000..1e12913c228 --- /dev/null +++ b/packages/rule-tester/tests/eslint-base/fixtures/no-schema-violation.js @@ -0,0 +1,22 @@ +// Forked from https://github.com/eslint/eslint/tree/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/tests/fixtures/testers/rule-tester/no-schema-violation.js + +"use strict"; + +module.exports = { + meta: { + type: "problem", + schema: [{ + "enum": ["foo"] + }] + }, + create(context) { + const config = context.options[0]; + return { + "Program": function(node) { + if (config && config !== "foo") { + context.report(node, "Expected foo."); + } + } + }; + }, +}; diff --git a/packages/rule-tester/tests/eslint-base/fixtures/no-test-filename b/packages/rule-tester/tests/eslint-base/fixtures/no-test-filename new file mode 100644 index 00000000000..795bd0ac7af --- /dev/null +++ b/packages/rule-tester/tests/eslint-base/fixtures/no-test-filename @@ -0,0 +1,19 @@ +// Forked from https://github.com/eslint/eslint/tree/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/tests/fixtures/testers/rule-tester/no-test-filename + +"use strict"; + +module.exports = { + meta: { + type: "problem", + schema: [] + }, + create(context) { + return { + "Program": function(node) { + if (context.getFilename() === '') { + context.report(node, "Filename test was not defined."); + } + } + }; + } +}; diff --git a/packages/rule-tester/tests/eslint-base/fixtures/no-test-global.js b/packages/rule-tester/tests/eslint-base/fixtures/no-test-global.js new file mode 100644 index 00000000000..94834189376 --- /dev/null +++ b/packages/rule-tester/tests/eslint-base/fixtures/no-test-global.js @@ -0,0 +1,26 @@ +// Forked from https://github.com/eslint/eslint/tree/1665c029acb92bf8812267f1647ad1a7054cbcb4/tests/fixtures/testers/rule-tester/no-test-global.js + +"use strict"; + +module.exports = { + meta: { + type: "problem", + schema: [], + }, + create(context) { + return { + "Program": function(node) { + var globals = context.getScope().variables.map(function (variable) { + return variable.name; + }); + + if (globals.indexOf("test") === -1) { + context.report(node, "Global variable test was not defined."); + } + if (globals.indexOf("foo") !== -1) { + context.report(node, "Global variable foo should not be used."); + } + } + }; + }, +}; diff --git a/packages/rule-tester/tests/eslint-base/fixtures/no-test-settings.js b/packages/rule-tester/tests/eslint-base/fixtures/no-test-settings.js new file mode 100644 index 00000000000..291b81b105f --- /dev/null +++ b/packages/rule-tester/tests/eslint-base/fixtures/no-test-settings.js @@ -0,0 +1,22 @@ +// Forked from https://github.com/eslint/eslint/tree/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/tests/fixtures/testers/rule-tester/no-test-settings.js + +"use strict"; + +module.exports = { + meta: { + type: "problem", + schema: [], + }, + create(context) { + return { + Program: function (node) { + if (!context.settings || !context.settings.test) { + context.report( + node, + "Global settings test was not defined." + ); + } + }, + }; + }, +}; diff --git a/packages/rule-tester/tests/eslint-base/fixtures/no-var.js b/packages/rule-tester/tests/eslint-base/fixtures/no-var.js new file mode 100644 index 00000000000..26f0382536d --- /dev/null +++ b/packages/rule-tester/tests/eslint-base/fixtures/no-var.js @@ -0,0 +1,28 @@ +// Forked from https://github.com/eslint/eslint/tree/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/tests/fixtures/testers/rule-tester/no-var.js + +"use strict"; + +module.exports = { + meta: { + fixable: "code", + schema: [] + }, + create(context) { + var sourceCode = context.getSourceCode(); + + return { + "VariableDeclaration": function(node) { + if (node.kind === "var") { + context.report({ + node: node, + loc: sourceCode.getFirstToken(node).loc, + message: "Bad var.", + fix: function(fixer) { + return fixer.remove(sourceCode.getFirstToken(node)); + } + }) + } + } + }; + } +}; diff --git a/packages/rule-tester/tests/eslint-base/fixtures/suggestions.js b/packages/rule-tester/tests/eslint-base/fixtures/suggestions.js new file mode 100644 index 00000000000..4638ac2cacb --- /dev/null +++ b/packages/rule-tester/tests/eslint-base/fixtures/suggestions.js @@ -0,0 +1,76 @@ +// Forked from https://github.com/eslint/eslint/tree/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/tests/fixtures/testers/rule-tester/suggestions.js + +"use strict"; + +module.exports.basic = { + meta: { hasSuggestions: true }, + create(context) { + return { + Identifier(node) { + if (node.name === "foo") { + context.report({ + node, + message: "Avoid using identifiers named 'foo'.", + suggest: [{ + desc: "Rename identifier 'foo' to 'bar'", + fix: fixer => fixer.replaceText(node, 'bar') + }] + }); + } + } + }; + } +}; + +module.exports.withMessageIds = { + meta: { + messages: { + avoidFoo: "Avoid using identifiers named '{{ name }}'.", + unused: "An unused key", + renameFoo: "Rename identifier 'foo' to '{{ newName }}'" + }, + hasSuggestions: true + }, + create(context) { + return { + Identifier(node) { + if (node.name === "foo") { + context.report({ + node, + messageId: "avoidFoo", + data: { + name: "foo" + }, + suggest: [{ + messageId: "renameFoo", + data: { + newName: "bar" + }, + fix: fixer => fixer.replaceText(node, "bar") + }, { + messageId: "renameFoo", + data: { + newName: "baz" + }, + fix: fixer => fixer.replaceText(node, "baz") + }] + }); + } + } + }; + } +}; + +module.exports.withoutHasSuggestionsProperty = { + create(context) { + return { + Identifier(node) { + context.report({ + node, + message: "some message", + suggest: [{ desc: "some suggestion", fix: fixer => fixer.replaceText(node, 'bar') }] + }); + } + }; + } +}; diff --git a/packages/rule-tester/tsconfig.build.json b/packages/rule-tester/tsconfig.build.json new file mode 100644 index 00000000000..782f14402ae --- /dev/null +++ b/packages/rule-tester/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src", + "resolveJsonModule": true + }, + "include": ["src", "typings"], + "references": [{ "path": "../utils/tsconfig.build.json" }] +} diff --git a/packages/rule-tester/tsconfig.json b/packages/rule-tester/tsconfig.json new file mode 100644 index 00000000000..9cea515ba6b --- /dev/null +++ b/packages/rule-tester/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "composite": false, + "rootDir": "." + }, + "include": ["src", "typings", "tests", "tools"] +} diff --git a/packages/rule-tester/typings/eslint.d.ts b/packages/rule-tester/typings/eslint.d.ts new file mode 100644 index 00000000000..6341a84533a --- /dev/null +++ b/packages/rule-tester/typings/eslint.d.ts @@ -0,0 +1,24 @@ +declare module 'eslint/use-at-your-own-risk' { + import type { AnyRuleModule } from '@typescript-eslint/utils/ts-eslint'; + + export const builtinRules: ReadonlyMap; +} + +declare module '@eslint/eslintrc' { + import type { Linter } from '@typescript-eslint/utils/ts-eslint'; + + export const Legacy: { + ConfigOps: { + normalizeConfigGlobal: ( + configuredValue: boolean | string | null, + ) => Linter.GlobalVariableOptionBase; + // ... + }; + environments: Map; + // ... + }; +} + +declare module 'eslint' { + export { SourceCode } from '@typescript-eslint/utils/ts-eslint'; +} diff --git a/packages/typescript-estree/src/simple-traverse.ts b/packages/typescript-estree/src/simple-traverse.ts index 2d51cdbe4fa..67b56d02c38 100644 --- a/packages/typescript-estree/src/simple-traverse.ts +++ b/packages/typescript-estree/src/simple-traverse.ts @@ -1,3 +1,4 @@ +import type { VisitorKeys } from '@typescript-eslint/visitor-keys'; import { visitorKeys } from '@typescript-eslint/visitor-keys'; import type { TSESTree } from './ts-estree'; @@ -16,25 +17,33 @@ function getVisitorKeysForNode( return (keys ?? []) as never; } -type SimpleTraverseOptions = +type SimpleTraverseOptions = Readonly< | { + visitorKeys?: Readonly; enter: (node: TSESTree.Node, parent: TSESTree.Node | undefined) => void; } | { - [key: string]: ( - node: TSESTree.Node, - parent: TSESTree.Node | undefined, - ) => void; - }; + visitorKeys?: Readonly; + visitors: { + [key: string]: ( + node: TSESTree.Node, + parent: TSESTree.Node | undefined, + ) => void; + }; + } +>; class SimpleTraverser { - private readonly allVisitorKeys = visitorKeys; + private readonly allVisitorKeys: Readonly = visitorKeys; private readonly selectors: SimpleTraverseOptions; private readonly setParentPointers: boolean; constructor(selectors: SimpleTraverseOptions, setParentPointers = false) { this.selectors = selectors; this.setParentPointers = setParentPointers; + if (selectors.visitorKeys) { + this.allVisitorKeys = selectors.visitorKeys; + } } traverse(node: unknown, parent: TSESTree.Node | undefined): void { @@ -48,8 +57,8 @@ class SimpleTraverser { if ('enter' in this.selectors) { this.selectors.enter(node, parent); - } else if (node.type in this.selectors) { - this.selectors[node.type](node, parent); + } else if (node.type in this.selectors.visitors) { + this.selectors.visitors[node.type](node, parent); } const keys = getVisitorKeysForNode(this.allVisitorKeys, node); diff --git a/packages/utils/package.json b/packages/utils/package.json index 65ffdc1c680..658ec9ae36e 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -67,7 +67,6 @@ "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@types/json-schema": "^7.0.9", - "@types/semver": "^7.3.12", "@typescript-eslint/scope-manager": "5.56.0", "@typescript-eslint/types": "5.56.0", "@typescript-eslint/typescript-estree": "5.56.0", diff --git a/packages/utils/src/json-schema.ts b/packages/utils/src/json-schema.ts index 8e11b8b3caa..c6b1ccacd71 100644 --- a/packages/utils/src/json-schema.ts +++ b/packages/utils/src/json-schema.ts @@ -1,24 +1,2 @@ -// Note - @types/json-schema@7.0.4 added some function declarations to the type package -// If we do export *, then it will also export these function declarations. -// This will cause typescript to not scrub the require from the build, breaking anyone who doesn't have it as a dependency - // eslint-disable-next-line import/no-extraneous-dependencies -export { - JSONSchema4, - JSONSchema4Type, - JSONSchema4TypeName, - JSONSchema4Version, - JSONSchema6, - JSONSchema6Definition, - JSONSchema6Type, - JSONSchema6TypeName, - JSONSchema6Version, - JSONSchema7, - JSONSchema7Array, - JSONSchema7Definition, - JSONSchema7Type, - JSONSchema7TypeName, - JSONSchema7Version, - ValidationError, - ValidationResult, -} from 'json-schema'; +export type * from 'json-schema'; diff --git a/packages/utils/src/ts-eslint/Linter.ts b/packages/utils/src/ts-eslint/Linter.ts index 20ec02893a5..dd5045f37bd 100644 --- a/packages/utils/src/ts-eslint/Linter.ts +++ b/packages/utils/src/ts-eslint/Linter.ts @@ -13,6 +13,12 @@ import type { import type { Scope } from './Scope'; import type { SourceCode } from './SourceCode'; +export type MinimalRuleModule< + TMessageIds extends string = string, + TOptions extends readonly unknown[] = [], +> = Pick, 'create'> & + Partial, 'create'>>; + declare class LinterBase { /** * Initialize the Linter. @@ -34,7 +40,7 @@ declare class LinterBase { */ defineRule( ruleId: string, - ruleModule: RuleModule | RuleCreateFunction, + ruleModule: MinimalRuleModule | RuleCreateFunction, ): void; /** @@ -44,7 +50,7 @@ declare class LinterBase { defineRules( rulesToDefine: Record< string, - RuleModule | RuleCreateFunction + MinimalRuleModule | RuleCreateFunction >, ): void; @@ -52,7 +58,7 @@ declare class LinterBase { * Gets an object with all loaded rules. * @returns All loaded rules */ - getRules(): Map>; + getRules(): Map>; /** * Gets the `SourceCode` object representing the parsed source. @@ -120,7 +126,15 @@ namespace Linter { export type RuleEntry = RuleLevel | RuleLevelAndOptions; export type RulesRecord = Partial>; - export type GlobalVariableOption = 'readonly' | 'writable' | 'off' | boolean; + export type GlobalVariableOptionBase = 'readonly' | 'writable' | 'off'; + export type GlobalVariableOption = GlobalVariableOptionBase | boolean; + + export interface GlobalsConfig { + [name: string]: GlobalVariableOption; + } + export interface EnvironmentConfig { + [name: string]: boolean; + } // https://github.com/eslint/eslint/blob/v6.8.0/conf/config-schema.js interface BaseConfig { @@ -128,7 +142,7 @@ namespace Linter { /** * The environment settings. */ - env?: { [name: string]: boolean }; + env?: EnvironmentConfig; /** * The path to other config files or the package name of shareable configs. */ @@ -136,7 +150,7 @@ namespace Linter { /** * The global variable settings. */ - globals?: { [name: string]: GlobalVariableOption }; + globals?: GlobalsConfig; /** * The flag that disables directive comments. */ diff --git a/packages/utils/src/ts-eslint/Rule.ts b/packages/utils/src/ts-eslint/Rule.ts index b0705cc5c47..57f353ccaac 100644 --- a/packages/utils/src/ts-eslint/Rule.ts +++ b/packages/utils/src/ts-eslint/Rule.ts @@ -117,11 +117,13 @@ type ReportFixFunction = ( type ReportSuggestionArray = SuggestionReportDescriptor[]; +type ReportDescriptorMessageData = Readonly>; + interface ReportDescriptorBase { /** * The parameters for the message string associated with `messageId`. */ - readonly data?: Readonly>; + readonly data?: ReportDescriptorMessageData; /** * The fixer function. */ @@ -264,8 +266,7 @@ type RuleFunction = ( node: T, ) => void; -interface RuleListener { - [nodeSelector: string]: RuleFunction | undefined; +interface RuleListenerBaseSelectors { ArrayExpression?: RuleFunction; ArrayPattern?: RuleFunction; ArrowFunctionExpression?: RuleFunction; @@ -424,6 +425,19 @@ interface RuleListener { WithStatement?: RuleFunction; YieldExpression?: RuleFunction; } +type RuleListenerExitSelectors = { + [K in keyof RuleListenerBaseSelectors as `${K}:exit`]: RuleListenerBaseSelectors[K]; +}; +interface RuleListenerCatchAllBaseCase { + [nodeSelector: string]: RuleFunction | undefined; +} +// Interface to merge into for anyone that wants to add more selectors +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface RuleListenerExtension {} + +type RuleListener = RuleListenerBaseSelectors & + RuleListenerExitSelectors & + RuleListenerCatchAllBaseCase; interface RuleModule< TMessageIds extends string, @@ -447,6 +461,7 @@ interface RuleModule< */ create(context: Readonly>): TRuleListener; } +type AnyRuleModule = RuleModule; type RuleCreateFunction< TMessageIds extends string = never, @@ -454,7 +469,9 @@ type RuleCreateFunction< > = (context: Readonly>) => RuleListener; export { + AnyRuleModule, ReportDescriptor, + ReportDescriptorMessageData, ReportFixFunction, ReportSuggestionArray, RuleContext, @@ -463,6 +480,7 @@ export { RuleFixer, RuleFunction, RuleListener, + RuleListenerExtension, RuleMetaData, RuleMetaDataDocs, RuleModule, diff --git a/packages/utils/src/ts-eslint/RuleTester.ts b/packages/utils/src/ts-eslint/RuleTester.ts index 6c0b98b795f..51f7840fc36 100644 --- a/packages/utils/src/ts-eslint/RuleTester.ts +++ b/packages/utils/src/ts-eslint/RuleTester.ts @@ -4,6 +4,7 @@ import type { AST_NODE_TYPES, AST_TOKEN_TYPES } from '../ts-estree'; import type { Linter } from './Linter'; import type { ParserOptions } from './ParserOptions'; import type { + ReportDescriptorMessageData, RuleCreateFunction, RuleModule, SharedConfigurationSettings, @@ -62,7 +63,7 @@ interface SuggestionOutput { /** * The data used to fill the message template. */ - readonly data?: Readonly>; + 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. @@ -95,7 +96,7 @@ interface TestCaseError { /** * The data used to fill the message template. */ - readonly data?: Readonly>; + readonly data?: ReportDescriptorMessageData; /** * The 1-based column number of the reported end location. */ diff --git a/patches/ajv+6.12.6.patch b/patches/ajv+6.12.6.patch new file mode 100644 index 00000000000..43b89b3f40a --- /dev/null +++ b/patches/ajv+6.12.6.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/ajv/lib/ajv.d.ts b/node_modules/ajv/lib/ajv.d.ts +index 078364d..21cf7bf 100644 +--- a/node_modules/ajv/lib/ajv.d.ts ++++ b/node_modules/ajv/lib/ajv.d.ts +@@ -153,7 +153,7 @@ declare namespace ajv { + parentData?: object | Array, + parentDataProperty?: string | number, + rootData?: object | Array +- ): boolean | PromiseLike; ++ ): boolean; + schema?: object | boolean; + errors?: null | Array; + refs?: object; diff --git a/patches/eslint+8.34.0.patch b/patches/eslint+8.36.0.patch similarity index 100% rename from patches/eslint+8.34.0.patch rename to patches/eslint+8.36.0.patch diff --git a/yarn.lock b/yarn.lock index 874526e6de7..56e33413cf3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3219,6 +3219,13 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== +"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.3": + version "1.8.6" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.6.tgz#80c516a4dc264c2a69115e7578d62581ff455ed9" + integrity sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ== + dependencies: + type-detect "4.0.8" + "@sinonjs/commons@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-2.0.0.tgz#fd4ca5b063554307e8327b4564bd56d3b73924a3" @@ -3233,6 +3240,27 @@ dependencies: "@sinonjs/commons" "^2.0.0" +"@sinonjs/fake-timers@^7.1.2": + version "7.1.2" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-7.1.2.tgz#2524eae70c4910edccf99b2f4e6efc5894aff7b5" + integrity sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg== + dependencies: + "@sinonjs/commons" "^1.7.0" + +"@sinonjs/samsam@^6.0.2": + version "6.1.3" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.1.3.tgz#4e30bcd4700336363302a7d72cbec9b9ab87b104" + integrity sha512-nhOb2dWPeb1sd3IQXL/dVPnKHDOAFfvichtBf4xV00/rU1QbPCQqKMbvIheIjqwVjh7qIgf2AHTHi391yMOMpQ== + dependencies: + "@sinonjs/commons" "^1.6.0" + lodash.get "^4.4.2" + type-detect "^4.0.8" + +"@sinonjs/text-encoding@^0.7.1": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz#5981a8db18b56ba38ef0efb7d995b12aa7b51918" + integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ== + "@slorber/static-site-generator-webpack-plugin@^4.0.7": version "4.0.7" resolved "https://registry.yarnpkg.com/@slorber/static-site-generator-webpack-plugin/-/static-site-generator-webpack-plugin-4.0.7.tgz#fc1678bddefab014e2145cbe25b3ce4e1cfc36f3" @@ -3726,6 +3754,13 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/lodash.merge@4.6.7": + version "4.6.7" + resolved "https://registry.yarnpkg.com/@types/lodash.merge/-/lodash.merge-4.6.7.tgz#0af6555dd8bc6568ef73e5e0d820a027362946b1" + integrity sha512-OwxUJ9E50gw3LnAefSHJPHaBLGEKmQBQ7CZe/xflHkyy/wH2zVyEIAKReHvVrrn7zKdF58p16We9kMfh7v0RRQ== + dependencies: + "@types/lodash" "*" + "@types/lodash@*", "@types/lodash@^4.14.182": version "4.14.191" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.191.tgz#09511e7f7cba275acd8b419ddac8da9a6a79e2fa" @@ -3899,7 +3934,7 @@ resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== -"@types/semver@*", "@types/semver@^7.3.12", "@types/semver@^7.3.9": +"@types/semver@*", "@types/semver@^7.3.9": version "7.3.13" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91" integrity sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw== @@ -3979,6 +4014,11 @@ dependencies: "@typescript-eslint/utils" "5.56.0" +"@ungap/promise-all-settled@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" + integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q== + "@webassemblyjs/ast@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7" @@ -4278,7 +4318,7 @@ ansi-align@^3.0.0, ansi-align@^3.0.1: dependencies: string-width "^4.1.0" -ansi-colors@^4.1.1: +ansi-colors@4.1.1, ansi-colors@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== @@ -4295,6 +4335,11 @@ ansi-html-community@^0.0.8: resolved "https://registry.yarnpkg.com/ansi-html-community/-/ansi-html-community-0.0.8.tgz#69fbc4d6ccbe383f9736934ae34c3f8290f1bf41" integrity sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw== +ansi-regex@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.1.tgz#123d6479e92ad45ad897d4054e3c7ca7db4944e1" + integrity sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw== + ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" @@ -4334,7 +4379,7 @@ any-promise@^1.0.0: resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== -anymatch@^3.0.3, anymatch@~3.1.2: +anymatch@^3.0.3, anymatch@~3.1.1, anymatch@~3.1.2: version "3.1.3" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== @@ -4479,6 +4524,11 @@ asap@^2.0.0, asap@~2.0.3: resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= +assertion-error@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" + integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== + ast-types-flow@^0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad" @@ -4831,6 +4881,11 @@ braces@^3.0.2, braces@~3.0.2: dependencies: fill-range "^7.0.1" +browser-stdout@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" + integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + browserslist@^4.0.0, browserslist@^4.14.5, browserslist@^4.16.6, browserslist@^4.18.1, browserslist@^4.20.3, browserslist@^4.21.3, browserslist@^4.21.4: version "4.21.5" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.5.tgz#75c5dae60063ee641f977e00edd3cfb2fb7af6a7" @@ -4980,7 +5035,7 @@ camelcase@^5.3.1: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -camelcase@^6.2.0: +camelcase@^6.0.0, camelcase@^6.2.0: version "6.3.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== @@ -5005,6 +5060,19 @@ ccount@^1.0.0: resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.1.0.tgz#246687debb6014735131be8abab2d93898f8d043" integrity sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg== +chai@^4.0.1: + version "4.3.7" + resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.7.tgz#ec63f6df01829088e8bf55fca839bcd464a8ec51" + integrity sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A== + dependencies: + assertion-error "^1.1.0" + check-error "^1.0.2" + deep-eql "^4.1.2" + get-func-name "^2.0.0" + loupe "^2.3.1" + pathval "^1.1.1" + type-detect "^4.0.5" + chalk@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" @@ -5060,6 +5128,11 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== +check-error@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" + integrity sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA== + cheerio-select@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4" @@ -5085,6 +5158,21 @@ cheerio@^1.0.0-rc.12: parse5 "^7.0.0" parse5-htmlparser2-tree-adapter "^7.0.0" +chokidar@3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a" + integrity sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.5.0" + optionalDependencies: + fsevents "~2.3.1" + chokidar@^3.4.2, chokidar@^3.5.1, chokidar@^3.5.3: version "3.5.3" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" @@ -6025,6 +6113,13 @@ debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: dependencies: ms "2.1.2" +debug@4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" + integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== + dependencies: + ms "2.1.2" + debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -6050,6 +6145,11 @@ decamelize@^1.1.0: resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= +decamelize@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" + integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== + decompress-response@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3" @@ -6062,6 +6162,13 @@ dedent@0.7.0, dedent@^0.7.0: resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" integrity sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA== +deep-eql@^4.1.2: + version "4.1.3" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.3.tgz#7c7775513092f7df98d8df9996dd085eb668cc6d" + integrity sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw== + dependencies: + type-detect "^4.0.0" + deep-equal@^2.0.5: version "2.2.0" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.0.tgz#5caeace9c781028b9ff459f33b779346637c43e6" @@ -6227,11 +6334,21 @@ diff-sequences@^29.4.3: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.4.3.tgz#9314bc1fabe09267ffeca9cbafc457d8499a13f2" integrity sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA== +diff@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" + integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== + diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +diff@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40" + integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -6660,6 +6777,11 @@ escape-html@^1.0.3, escape-html@~1.0.3: resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= +escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" @@ -6670,11 +6792,6 @@ escape-string-regexp@^2.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== -escape-string-regexp@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" - integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== - eslint-import-resolver-node@^0.3.7: version "0.3.7" resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.7.tgz#83b375187d412324a1963d84fa664377a23eb4d7" @@ -7278,6 +7395,14 @@ find-cache-dir@^3.3.1: make-dir "^3.0.2" pkg-dir "^4.1.0" +find-up@5.0.0, find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + find-up@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" @@ -7300,14 +7425,6 @@ find-up@^4.0.0, find-up@^4.1.0: locate-path "^5.0.0" path-exists "^4.0.0" -find-up@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" - integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== - dependencies: - locate-path "^6.0.0" - path-exists "^4.0.0" - find-yarn-workspace-root@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz#f47fb8d239c900eb78179aa81b66673eac88f7bd" @@ -7455,7 +7572,7 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@2.3.2, fsevents@^2.3.2, fsevents@~2.3.2: +fsevents@2.3.2, fsevents@^2.3.2, fsevents@~2.3.1, fsevents@~2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== @@ -7509,6 +7626,11 @@ get-caller-file@^2.0.5: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== +get-func-name@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41" + integrity sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig== + get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.0.tgz#7ad1dc0535f3a2904bba075772763e5051f6d05f" @@ -7644,7 +7766,7 @@ github-slugger@^1.4.0: resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-1.4.0.tgz#206eb96cdb22ee56fdc53a28d5a302338463444e" integrity sha512-w0dzqw/nt51xMVmlaV1+JRzN+oCa1KfcgGEWhxUG16wbdA+Xnt/yoFO8Z8x/V82ZcZ0wy6ln9QDup5avbhiDhQ== -glob-parent@5.1.2, glob-parent@^5.1.2, glob-parent@~5.1.2: +glob-parent@5.1.2, glob-parent@^5.1.2, glob-parent@~5.1.0, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== @@ -7693,6 +7815,18 @@ glob@7.1.4: once "^1.3.0" path-is-absolute "^1.0.0" +glob@7.1.6: + version "7.1.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + glob@^7.0.0, glob@^7.1.1, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" @@ -7837,6 +7971,11 @@ gray-matter@^4.0.3: section-matter "^1.0.0" strip-bom-string "^1.0.0" +growl@1.10.5: + version "1.10.5" + resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" + integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA== + gzip-size@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462" @@ -8000,7 +8139,7 @@ hastscript@^6.0.0: property-information "^5.0.0" space-separated-tokens "^1.0.0" -he@^1.2.0: +he@1.2.0, he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== @@ -8553,6 +8692,11 @@ is-extglob@^2.1.1: resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w== + is-fullwidth-code-point@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" @@ -8655,7 +8799,7 @@ is-plain-obj@^1.0.0, is-plain-obj@^1.1.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4= -is-plain-obj@^2.0.0: +is-plain-obj@^2.0.0, is-plain-obj@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== @@ -9314,6 +9458,13 @@ js-sdsl@^4.1.4: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== +js-yaml@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.0.0.tgz#f426bc0ff4b4051926cd588c71113183409a121f" + integrity sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q== + dependencies: + argparse "^2.0.1" + js-yaml@4.1.0, js-yaml@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" @@ -9470,6 +9621,11 @@ just-diff@^5.0.1: resolved "https://registry.yarnpkg.com/just-diff/-/just-diff-5.0.2.tgz#68854c94280c37d28cb266d8f29bdd2cd29f003e" integrity sha512-uGd6F+eIZ4T95EinP8ubINGkbEy3jrgBym+6LjW+ja1UG1WQIcEcQ6FLeyXtVJZglk+bj7fvEn+Cu2LBxkgiYQ== +just-extend@^4.0.2: + version "4.2.1" + resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744" + integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg== + keyv@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" @@ -9806,7 +9962,7 @@ lodash.memoize@^4.1.2: resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= -lodash.merge@^4.6.2: +lodash.merge@4.6.2, lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== @@ -9826,6 +9982,13 @@ lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@~4.17 resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +log-symbols@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.0.0.tgz#69b3cc46d20f448eccdb75ea1fa733d9e821c920" + integrity sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA== + dependencies: + chalk "^4.0.0" + log-symbols@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" @@ -9851,6 +10014,13 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3 dependencies: js-tokens "^3.0.0 || ^4.0.0" +loupe@^2.3.1: + version "2.3.6" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.6.tgz#76e4af498103c532d1ecc9be102036a21f787b53" + integrity sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA== + dependencies: + get-func-name "^2.0.0" + lower-case@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" @@ -10333,6 +10503,37 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +mocha@^8.3.2: + version "8.4.0" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-8.4.0.tgz#677be88bf15980a3cae03a73e10a0fc3997f0cff" + integrity sha512-hJaO0mwDXmZS4ghXsvPVriOhsxQ7ofcpQdm8dE+jISUOKopitvnXFQmpRR7jd2K6VBG6E26gU3IAbXXGIbu4sQ== + dependencies: + "@ungap/promise-all-settled" "1.1.2" + ansi-colors "4.1.1" + browser-stdout "1.3.1" + chokidar "3.5.1" + debug "4.3.1" + diff "5.0.0" + escape-string-regexp "4.0.0" + find-up "5.0.0" + glob "7.1.6" + growl "1.10.5" + he "1.2.0" + js-yaml "4.0.0" + log-symbols "4.0.0" + minimatch "3.0.4" + ms "2.1.3" + nanoid "3.1.20" + serialize-javascript "5.0.1" + strip-json-comments "3.1.1" + supports-color "8.1.1" + which "2.0.2" + wide-align "1.1.3" + workerpool "6.1.0" + yargs "16.2.0" + yargs-parser "20.2.4" + yargs-unparser "2.0.0" + modify-values@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" @@ -10391,6 +10592,11 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" +nanoid@3.1.20: + version "3.1.20" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.20.tgz#badc263c6b1dcf14b71efaa85f6ab4c1d6cfc788" + integrity sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw== + nanoid@^3.3.4: version "3.3.4" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" @@ -10431,6 +10637,17 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +nise@^5.1.0: + version "5.1.4" + resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.4.tgz#491ce7e7307d4ec546f5a659b2efe94a18b4bbc0" + integrity sha512-8+Ib8rRJ4L0o3kfmyVCL7gzrohyDe0cMFTBa2d364yIrEGMEoetznKJx899YxjybU6bL9SQkYPSBBs1gyYs8Xg== + dependencies: + "@sinonjs/commons" "^2.0.0" + "@sinonjs/fake-timers" "^10.0.2" + "@sinonjs/text-encoding" "^0.7.1" + just-extend "^4.0.2" + path-to-regexp "^1.7.0" + no-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" @@ -11245,6 +11462,11 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +pathval@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" + integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== + picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" @@ -12113,6 +12335,13 @@ readdir-scoped-modules@^1.1.0: graceful-fs "^4.1.2" once "^1.3.0" +readdirp@~3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e" + integrity sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ== + dependencies: + picomatch "^2.2.1" + readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -12673,6 +12902,13 @@ send@0.18.0: range-parser "~1.2.1" statuses "2.0.1" +serialize-javascript@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-5.0.1.tgz#7886ec848049a462467a97d3d918ebb2aaf934f4" + integrity sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA== + dependencies: + randombytes "^2.1.0" + serialize-javascript@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa" @@ -12808,6 +13044,18 @@ signal-exit@3.0.7, signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== +sinon@^11.0.0: + version "11.1.2" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-11.1.2.tgz#9e78850c747241d5c59d1614d8f9cbe8840e8674" + integrity sha512-59237HChms4kg7/sXhiRcUzdSkKuydDeTiamT/jesUVHshBgL8XAmhgFo0GfK6RruMDM/iRSij1EybmMog9cJw== + dependencies: + "@sinonjs/commons" "^1.8.3" + "@sinonjs/fake-timers" "^7.1.2" + "@sinonjs/samsam" "^6.0.2" + diff "^5.0.0" + nise "^5.1.0" + supports-color "^7.2.0" + sirv@^1.0.7: version "1.0.18" resolved "https://registry.yarnpkg.com/sirv/-/sirv-1.0.18.tgz#105fab52fb656ce8a2bebbf36b11052005952899" @@ -13096,6 +13344,14 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" +"string-width@^1.0.2 || 2": + version "2.1.1" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" + "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -13178,6 +13434,13 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + integrity sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow== + dependencies: + ansi-regex "^3.0.0" + strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -13229,7 +13492,7 @@ strip-indent@^3.0.0: dependencies: min-indent "^1.0.0" -strip-json-comments@^3.1.0, strip-json-comments@^3.1.1, strip-json-comments@~3.1.1: +strip-json-comments@3.1.1, strip-json-comments@^3.1.0, strip-json-comments@^3.1.1, strip-json-comments@~3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== @@ -13263,6 +13526,13 @@ stylehacks@^5.1.0: browserslist "^4.16.6" postcss-selector-parser "^6.0.4" +supports-color@8.1.1, supports-color@^8.0.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -13270,20 +13540,13 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" -supports-color@^7.0.0, supports-color@^7.1.0: +supports-color@^7.0.0, supports-color@^7.1.0, supports-color@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== dependencies: has-flag "^4.0.0" -supports-color@^8.0.0: - version "8.1.1" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" @@ -13680,7 +13943,7 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" -type-detect@4.0.8: +type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== @@ -14380,6 +14643,13 @@ which-typed-array@^1.1.9: has-tostringtag "^1.0.0" is-typed-array "^1.1.10" +which@2.0.2, which@^2.0.1, which@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + which@^1.2.9, which@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" @@ -14387,12 +14657,12 @@ which@^1.2.9, which@^1.3.1: dependencies: isexe "^2.0.0" -which@^2.0.1, which@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" - integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== +wide-align@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" + integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== dependencies: - isexe "^2.0.0" + string-width "^1.0.2 || 2" wide-align@^1.1.5: version "1.1.5" @@ -14588,6 +14858,11 @@ workbox-window@6.5.4, workbox-window@^6.5.3: "@types/trusted-types" "^2.0.2" workbox-core "6.5.4" +workerpool@6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.1.0.tgz#a8e038b4c94569596852de7a8ea4228eefdeb37b" + integrity sha512-toV7q9rWNYha963Pl/qyeZ6wG+3nnsyvolaNUS8+R5Wtw6qJPTxIlOP1ZSvcGhEJw+l3HMMmtiNo9Gl61G4GVg== + wrap-ansi@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" @@ -14743,6 +15018,16 @@ yargs-parser@^20.2.2, yargs-parser@^20.2.3: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== +yargs-unparser@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" + integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== + dependencies: + camelcase "^6.0.0" + decamelize "^4.0.0" + flat "^5.0.2" + is-plain-obj "^2.1.0" + yargs@16.2.0, yargs@^16.2.0: version "16.2.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" From 6aa9edc8f2eda37e966cfb8ad9ce1c7da4f9a93c Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Tue, 28 Mar 2023 23:32:01 +1030 Subject: [PATCH 02/11] 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`); + }); + }); + }); +}); From 38d80191df39ff247d45eb05d39e6d28f851ba54 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Tue, 28 Mar 2023 23:47:59 +1030 Subject: [PATCH 03/11] tweak types --- packages/rule-tester/src/RuleTester.ts | 2 +- .../rule-tester/src/dependencyConstraints.ts | 63 ------------------- packages/rule-tester/src/index.ts | 6 ++ packages/rule-tester/src/types.ts | 2 +- packages/rule-tester/tests/RuleTester.test.ts | 2 +- 5 files changed, 9 insertions(+), 66 deletions(-) delete mode 100644 packages/rule-tester/src/dependencyConstraints.ts diff --git a/packages/rule-tester/src/RuleTester.ts b/packages/rule-tester/src/RuleTester.ts index 4f1f4727b4e..49e7a0a5f18 100644 --- a/packages/rule-tester/src/RuleTester.ts +++ b/packages/rule-tester/src/RuleTester.ts @@ -19,7 +19,7 @@ import { Linter } from '@typescript-eslint/utils/ts-eslint'; import { SourceCode } from 'eslint'; import merge from 'lodash.merge'; -import { satisfiesAllDependencyConstraints } from './dependencyConstraints'; +import { satisfiesAllDependencyConstraints } from './utils/dependencyConstraints'; import { TestFramework } from './TestFramework'; import type { InvalidTestCase, diff --git a/packages/rule-tester/src/dependencyConstraints.ts b/packages/rule-tester/src/dependencyConstraints.ts deleted file mode 100644 index 0bc1f5fc5ce..00000000000 --- a/packages/rule-tester/src/dependencyConstraints.ts +++ /dev/null @@ -1,63 +0,0 @@ -import * as semver from 'semver'; - -interface SemverVersionConstraint { - readonly range: string; - readonly options?: Parameters[2]; -} -type AtLeastVersionConstraint = - | `${number}` - | `${number}.${number}` - | `${number}.${number}.${number}` - | `${number}.${number}.${number}-${string}`; -type VersionConstraint = SemverVersionConstraint | AtLeastVersionConstraint; -interface DependencyConstraint { - /** - * Passing a string for the value is shorthand for a '>=' constraint - */ - readonly [packageName: string]: VersionConstraint; -} - -const BASE_SATISFIES_OPTIONS: semver.RangeOptions = { - includePrerelease: true, -}; - -function satisfiesDependencyConstraint( - packageName: string, - constraintIn: DependencyConstraint[string], -): boolean { - const constraint: SemverVersionConstraint = - typeof constraintIn === 'string' - ? { - range: `>=${constraintIn}`, - } - : constraintIn; - - return semver.satisfies( - (require(`${packageName}/package.json`) as { version: string }).version, - constraint.range, - typeof constraint.options === 'object' - ? { ...BASE_SATISFIES_OPTIONS, ...constraint.options } - : constraint.options, - ); -} - -function satisfiesAllDependencyConstraints( - dependencyConstraints: DependencyConstraint | undefined, -): boolean { - if (dependencyConstraints == null) { - return true; - } - - for (const [packageName, constraint] of Object.entries( - dependencyConstraints, - )) { - if (!satisfiesDependencyConstraint(packageName, constraint)) { - return false; - } - } - - return true; -} - -export { satisfiesAllDependencyConstraints }; -export type { DependencyConstraint }; diff --git a/packages/rule-tester/src/index.ts b/packages/rule-tester/src/index.ts index fc8d04007b7..a5e0a45dfca 100644 --- a/packages/rule-tester/src/index.ts +++ b/packages/rule-tester/src/index.ts @@ -7,3 +7,9 @@ export type { TestCaseError, ValidTestCase, } from './types'; +export type { + AtLeastVersionConstraint, + DependencyConstraint, + SemverVersionConstraint, + VersionConstraint, +} from './utils/dependencyConstraints'; diff --git a/packages/rule-tester/src/types.ts b/packages/rule-tester/src/types.ts index c00b8cda200..2cff2236ddd 100644 --- a/packages/rule-tester/src/types.ts +++ b/packages/rule-tester/src/types.ts @@ -6,7 +6,7 @@ import type { SharedConfigurationSettings, } from '@typescript-eslint/utils/ts-eslint'; -import type { DependencyConstraint } from './dependencyConstraints'; +import type { DependencyConstraint } from './utils/dependencyConstraints'; export interface RuleTesterConfig extends Linter.Config { /** diff --git a/packages/rule-tester/tests/RuleTester.test.ts b/packages/rule-tester/tests/RuleTester.test.ts index 752ab636cee..62d41384449 100644 --- a/packages/rule-tester/tests/RuleTester.test.ts +++ b/packages/rule-tester/tests/RuleTester.test.ts @@ -3,7 +3,7 @@ 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 * as dependencyConstraintsModule from '../src/utils/dependencyConstraints'; import { RuleTester } from '../src/RuleTester'; import type { RuleTesterTestFrameworkFunctionBase } from '../src/TestFramework'; From 3f9fa9a3057162803b251bc6f1663eee11d526ea Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Sat, 1 Apr 2023 09:44:44 +1030 Subject: [PATCH 04/11] move util --- .../src/utils/dependencyConstraints.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 packages/rule-tester/src/utils/dependencyConstraints.ts diff --git a/packages/rule-tester/src/utils/dependencyConstraints.ts b/packages/rule-tester/src/utils/dependencyConstraints.ts new file mode 100644 index 00000000000..1a98d20bd4c --- /dev/null +++ b/packages/rule-tester/src/utils/dependencyConstraints.ts @@ -0,0 +1,64 @@ +import * as semver from 'semver'; + +export interface SemverVersionConstraint { + readonly range: string; + readonly options?: Parameters[2]; +} +export type AtLeastVersionConstraint = + | `${number}` + | `${number}.${number}` + | `${number}.${number}.${number}` + | `${number}.${number}.${number}-${string}`; +export type VersionConstraint = + | SemverVersionConstraint + | AtLeastVersionConstraint; +export interface DependencyConstraint { + readonly typescript: VersionConstraint; + readonly eslint: VersionConstraint; + /** + * Passing a string for the value is shorthand for a '>=' constraint + */ + readonly [packageName: string]: VersionConstraint; +} + +const BASE_SATISFIES_OPTIONS: semver.RangeOptions = { + includePrerelease: true, +}; + +function satisfiesDependencyConstraint( + packageName: string, + constraintIn: DependencyConstraint[string], +): boolean { + const constraint: SemverVersionConstraint = + typeof constraintIn === 'string' + ? { + range: `>=${constraintIn}`, + } + : constraintIn; + + return semver.satisfies( + (require(`${packageName}/package.json`) as { version: string }).version, + constraint.range, + typeof constraint.options === 'object' + ? { ...BASE_SATISFIES_OPTIONS, ...constraint.options } + : constraint.options, + ); +} + +export function satisfiesAllDependencyConstraints( + dependencyConstraints: DependencyConstraint | undefined, +): boolean { + if (dependencyConstraints == null) { + return true; + } + + for (const [packageName, constraint] of Object.entries( + dependencyConstraints, + )) { + if (!satisfiesDependencyConstraint(packageName, constraint)) { + return false; + } + } + + return true; +} From 35610acc289ecdfdf05c148e0d77042df5053a4e Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Sat, 1 Apr 2023 10:27:01 +1030 Subject: [PATCH 05/11] revert changes to tslint plugin --- packages/eslint-plugin-tslint/package.json | 3 +- .../eslint-plugin-tslint/src/rules/config.ts | 19 +-- yarn.lock | 120 ++++++++++++------ 3 files changed, 85 insertions(+), 57 deletions(-) diff --git a/packages/eslint-plugin-tslint/package.json b/packages/eslint-plugin-tslint/package.json index 04c0eec0bd7..e47b7830d97 100644 --- a/packages/eslint-plugin-tslint/package.json +++ b/packages/eslint-plugin-tslint/package.json @@ -46,7 +46,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@typescript-eslint/utils": "5.57.0" + "@typescript-eslint/utils": "5.57.0", + "lodash": "^4.17.21" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0", diff --git a/packages/eslint-plugin-tslint/src/rules/config.ts b/packages/eslint-plugin-tslint/src/rules/config.ts index 847b6846ea9..3d721c62165 100644 --- a/packages/eslint-plugin-tslint/src/rules/config.ts +++ b/packages/eslint-plugin-tslint/src/rules/config.ts @@ -1,27 +1,10 @@ import { ESLintUtils } from '@typescript-eslint/utils'; +import { memoize } from 'lodash'; import type { RuleSeverity } from 'tslint'; import { Configuration } from 'tslint'; import { CustomLinter } from '../custom-linter'; -function memoize unknown>( - func: T, - resolver: (...args: Parameters) => string, -): T { - const cache = new Map>(); - const memoized = function (...args) { - const key = resolver(...(args as Parameters)); - - if (cache.has(key)) { - return cache.get(key)!; - } - const result = func(...args); - cache.set(key, result as ReturnType); - return result; - } as T; - return memoized; -} - // note - cannot migrate this to an import statement because it will make TSC copy the package.json to the dist folder // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const version: string = require('../../package.json'); diff --git a/yarn.lock b/yarn.lock index 81c8b5a8dad..54bdc3e3b48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4044,7 +4044,7 @@ resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== -"@types/semver@*", "@types/semver@^7.3.9": +"@types/semver@*", "@types/semver@^7.3.12", "@types/semver@^7.3.9": version "7.3.13" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91" integrity sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw== @@ -4124,6 +4124,64 @@ dependencies: "@typescript-eslint/utils" "5.57.0" +"@typescript-eslint/parser@5.56.0": + version "5.56.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.56.0.tgz#42eafb44b639ef1dbd54a3dbe628c446ca753ea6" + integrity sha512-sn1OZmBxUsgxMmR8a8U5QM/Wl+tyqlH//jTqCg8daTAmhAk26L2PFhcqPLlYBhYUJMZJK276qLXlHN3a83o2cg== + dependencies: + "@typescript-eslint/scope-manager" "5.56.0" + "@typescript-eslint/types" "5.56.0" + "@typescript-eslint/typescript-estree" "5.56.0" + debug "^4.3.4" + +"@typescript-eslint/scope-manager@5.56.0": + version "5.56.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.56.0.tgz#62b4055088903b5254fa20403010e1c16d6ab725" + integrity sha512-jGYKyt+iBakD0SA5Ww8vFqGpoV2asSjwt60Gl6YcO8ksQ8s2HlUEyHBMSa38bdLopYqGf7EYQMUIGdT/Luw+sw== + dependencies: + "@typescript-eslint/types" "5.56.0" + "@typescript-eslint/visitor-keys" "5.56.0" + +"@typescript-eslint/types@5.56.0": + version "5.56.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.56.0.tgz#b03f0bfd6fa2afff4e67c5795930aff398cbd834" + integrity sha512-JyAzbTJcIyhuUhogmiu+t79AkdnqgPUEsxMTMc/dCZczGMJQh1MK2wgrju++yMN6AWroVAy2jxyPcPr3SWCq5w== + +"@typescript-eslint/typescript-estree@5.56.0": + version "5.56.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.56.0.tgz#48342aa2344649a03321e74cab9ccecb9af086c3" + integrity sha512-41CH/GncsLXOJi0jb74SnC7jVPWeVJ0pxQj8bOjH1h2O26jXN3YHKDT1ejkVz5YeTEQPeLCCRY0U2r68tfNOcg== + dependencies: + "@typescript-eslint/types" "5.56.0" + "@typescript-eslint/visitor-keys" "5.56.0" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + semver "^7.3.7" + tsutils "^3.21.0" + +"@typescript-eslint/utils@5.56.0": + version "5.56.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.56.0.tgz#db64705409b9a15546053fb4deb2888b37df1f41" + integrity sha512-XhZDVdLnUJNtbzaJeDSCIYaM+Tgr59gZGbFuELgF7m0IY03PlciidS7UQNKLE0+WpUTn1GlycEr6Ivb/afjbhA== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@types/json-schema" "^7.0.9" + "@types/semver" "^7.3.12" + "@typescript-eslint/scope-manager" "5.56.0" + "@typescript-eslint/types" "5.56.0" + "@typescript-eslint/typescript-estree" "5.56.0" + eslint-scope "^5.1.1" + semver "^7.3.7" + +"@typescript-eslint/visitor-keys@5.56.0": + version "5.56.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.56.0.tgz#f19eb297d972417eb13cb69b35b3213e13cc214f" + integrity sha512-1mFdED7u5bZpX6Xxf5N9U2c18sb+8EvU3tyOIj6LQZ5OOvnmj8BVeNNP603OFPm5KkS1a7IvCIcwrdHXaEMG/Q== + dependencies: + "@typescript-eslint/types" "5.56.0" + eslint-visitor-keys "^3.3.0" + "@ungap/promise-all-settled@1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" @@ -13546,7 +13604,15 @@ signal-exit@3.0.7, signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== -<<<<<<< HEAD +sigstore@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/sigstore/-/sigstore-1.2.0.tgz#ae5b31dac75c2d31e7873897e2862f0d0b205bce" + integrity sha512-Fr9+W1nkBSIZCkJQR7jDn/zI0UXNsVpp+7mDQkCnZOIxG9p6yNXBx9xntHsfUyYHE55XDkkVV3+rYbrkzAeesA== + dependencies: + "@sigstore/protobuf-specs" "^0.1.0" + make-fetch-happen "^11.0.1" + tuf-js "^1.0.0" + sinon@^11.0.0: version "11.1.2" resolved "https://registry.yarnpkg.com/sinon/-/sinon-11.1.2.tgz#9e78850c747241d5c59d1614d8f9cbe8840e8674" @@ -13558,16 +13624,6 @@ sinon@^11.0.0: diff "^5.0.0" nise "^5.1.0" supports-color "^7.2.0" -======= -sigstore@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/sigstore/-/sigstore-1.2.0.tgz#ae5b31dac75c2d31e7873897e2862f0d0b205bce" - integrity sha512-Fr9+W1nkBSIZCkJQR7jDn/zI0UXNsVpp+7mDQkCnZOIxG9p6yNXBx9xntHsfUyYHE55XDkkVV3+rYbrkzAeesA== - dependencies: - "@sigstore/protobuf-specs" "^0.1.0" - make-fetch-happen "^11.0.1" - tuf-js "^1.0.0" ->>>>>>> v6 sirv@^1.0.7: version "1.0.18" @@ -14055,14 +14111,6 @@ stylehacks@^5.1.0: browserslist "^4.16.6" postcss-selector-parser "^6.0.4" -<<<<<<< HEAD -supports-color@8.1.1, supports-color@^8.0.0: - version "8.1.1" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" -======= stylelint-config-recommended@^11.0.0: version "11.0.0" resolved "https://registry.yarnpkg.com/stylelint-config-recommended/-/stylelint-config-recommended-11.0.0.tgz#b1cb7d71bd92f9b8593f93c2ca6df16ed7d61522" @@ -14130,7 +14178,13 @@ stylelint@^15.3.0: table "^6.8.1" v8-compile-cache "^2.3.0" write-file-atomic "^5.0.0" ->>>>>>> v6 + +supports-color@8.1.1, supports-color@^8.0.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" supports-color@^5.3.0: version "5.5.0" @@ -14146,15 +14200,6 @@ supports-color@^7.0.0, supports-color@^7.1.0, supports-color@^7.2.0: dependencies: has-flag "^4.0.0" -<<<<<<< HEAD -======= -supports-color@^8.0.0: - version "8.1.1" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - supports-hyperlinks@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-3.0.0.tgz#c711352a5c89070779b4dad54c05a2f14b15c94b" @@ -14163,7 +14208,6 @@ supports-hyperlinks@^3.0.0: has-flag "^4.0.0" supports-color "^7.0.0" ->>>>>>> v6 supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" @@ -15335,13 +15379,6 @@ which@^1.2.9, which@^1.3.1: dependencies: isexe "^2.0.0" -wide-align@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" - integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== - dependencies: - string-width "^1.0.2 || 2" - which@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/which/-/which-3.0.0.tgz#a9efd016db59728758a390d23f1687b6e8f59f8e" @@ -15349,6 +15386,13 @@ which@^3.0.0: dependencies: isexe "^2.0.0" +wide-align@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" + integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== + dependencies: + string-width "^1.0.2 || 2" + wide-align@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" From ea1bf0a4460c0a127a5775e13669b0de33ef831c Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Sat, 1 Apr 2023 10:27:40 +1030 Subject: [PATCH 06/11] yarn deduplicate --- yarn.lock | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/yarn.lock b/yarn.lock index 54bdc3e3b48..9dabe364884 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5626,12 +5626,7 @@ color-support@^1.1.3: resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== -colord@^2.9.1: - version "2.9.1" - resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.1.tgz#c961ea0efeb57c9f0f4834458f26cb9cc4a3f90e" - integrity sha512-4LBMSt09vR0uLnPVkOUBnmxgoaeN4ewRbx801wY/bXcltXfpR/G46OdWn96XpYmCWuYvO46aBZP4NgX8HpNAcw== - -colord@^2.9.3: +colord@^2.9.1, colord@^2.9.3: version "2.9.3" resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43" integrity sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw== @@ -12328,16 +12323,7 @@ postcss-zindex@^5.1.0: resolved "https://registry.yarnpkg.com/postcss-zindex/-/postcss-zindex-5.1.0.tgz#4a5c7e5ff1050bd4c01d95b1847dfdcc58a496ff" integrity sha512-fgFMf0OtVSBR1va1JNHYgMxYk73yhn/qb4uQDq1DLGYolz8gHCyr/sesEuGUaYs58E3ZJRcpoGuPVoB7Meiq9A== -postcss@^8.3.11, postcss@^8.4.13, postcss@^8.4.14, postcss@^8.4.7: - version "8.4.14" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.14.tgz#ee9274d5622b4858c1007a74d76e42e56fd21caf" - integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig== - dependencies: - nanoid "^3.3.4" - picocolors "^1.0.0" - source-map-js "^1.0.2" - -postcss@^8.4.21: +postcss@^8.3.11, postcss@^8.4.13, postcss@^8.4.14, postcss@^8.4.21, postcss@^8.4.7: version "8.4.21" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.21.tgz#c639b719a57efc3187b13a1d765675485f4134f4" integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg== From 6655d196113f53873ce4196da68efbc772bec419 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Sat, 1 Apr 2023 10:55:49 +1030 Subject: [PATCH 07/11] small fixes --- packages/rule-tester/src/RuleTester.ts | 7 +++--- packages/rule-tester/src/index.ts | 1 + packages/rule-tester/src/noFormat.ts | 7 ++++++ .../src/utils/dependencyConstraints.ts | 2 -- packages/rule-tester/tests/RuleTester.test.ts | 2 +- .../get-declared-variables.test.ts | 24 ++++++++++--------- .../tests/util/getSpecificNode.ts | 16 +++++++------ packages/utils/src/ts-eslint/Linter.ts | 3 ++- packages/utils/src/ts-eslint/Rule.ts | 3 ++- 9 files changed, 39 insertions(+), 26 deletions(-) create mode 100644 packages/rule-tester/src/noFormat.ts diff --git a/packages/rule-tester/src/RuleTester.ts b/packages/rule-tester/src/RuleTester.ts index 49e7a0a5f18..0ff658adfbd 100644 --- a/packages/rule-tester/src/RuleTester.ts +++ b/packages/rule-tester/src/RuleTester.ts @@ -8,6 +8,7 @@ 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 { + AnyRuleCreateFunction, AnyRuleModule, ParserOptions, RuleContext, @@ -19,7 +20,6 @@ import { Linter } from '@typescript-eslint/utils/ts-eslint'; import { SourceCode } from 'eslint'; import merge from 'lodash.merge'; -import { satisfiesAllDependencyConstraints } from './utils/dependencyConstraints'; import { TestFramework } from './TestFramework'; import type { InvalidTestCase, @@ -32,6 +32,7 @@ import type { import { ajvBuilder } from './utils/ajv'; import { cloneDeeplyExcludesParent } from './utils/cloneDeeplyExcludesParent'; import { validate } from './utils/config-validator'; +import { satisfiesAllDependencyConstraints } from './utils/dependencyConstraints'; import { freezeDeeply } from './utils/freezeDeeply'; import { getRuleOptionsSchema } from './utils/getRuleOptionsSchema'; import { hasOwnProperty } from './utils/hasOwnProperty'; @@ -72,7 +73,7 @@ let defaultConfig = deepMerge( export class RuleTester extends TestFramework { readonly #testerConfig: TesterConfigWithDefaults; - readonly #rules: Record = {}; + readonly #rules: Record = {}; readonly #linter: Linter = new Linter(); /** @@ -168,7 +169,7 @@ export class RuleTester extends TestFramework { /** * Define a rule for one particular run of tests. */ - defineRule(name: string, rule: AnyRuleModule): void { + defineRule(name: string, rule: AnyRuleModule | AnyRuleCreateFunction): void { this.#rules[name] = rule; } diff --git a/packages/rule-tester/src/index.ts b/packages/rule-tester/src/index.ts index a5e0a45dfca..c12db2b39c9 100644 --- a/packages/rule-tester/src/index.ts +++ b/packages/rule-tester/src/index.ts @@ -1,4 +1,5 @@ export { RuleTester } from './RuleTester'; +export { noFormat } from './noFormat'; export type { InvalidTestCase, RuleTesterConfig, diff --git a/packages/rule-tester/src/noFormat.ts b/packages/rule-tester/src/noFormat.ts new file mode 100644 index 00000000000..e52001cdb66 --- /dev/null +++ b/packages/rule-tester/src/noFormat.ts @@ -0,0 +1,7 @@ +/** + * Simple no-op tag to mark code samples as "should not format with prettier" + * for the plugin-test-formatting lint rule + */ +export function noFormat(raw: TemplateStringsArray, ...keys: string[]): string { + return String.raw({ raw }, ...keys); +} diff --git a/packages/rule-tester/src/utils/dependencyConstraints.ts b/packages/rule-tester/src/utils/dependencyConstraints.ts index 1a98d20bd4c..58bab6e9800 100644 --- a/packages/rule-tester/src/utils/dependencyConstraints.ts +++ b/packages/rule-tester/src/utils/dependencyConstraints.ts @@ -13,8 +13,6 @@ export type VersionConstraint = | SemverVersionConstraint | AtLeastVersionConstraint; export interface DependencyConstraint { - readonly typescript: VersionConstraint; - readonly eslint: VersionConstraint; /** * Passing a string for the value is shorthand for a '>=' constraint */ diff --git a/packages/rule-tester/tests/RuleTester.test.ts b/packages/rule-tester/tests/RuleTester.test.ts index 62d41384449..6ed3edc23f3 100644 --- a/packages/rule-tester/tests/RuleTester.test.ts +++ b/packages/rule-tester/tests/RuleTester.test.ts @@ -3,9 +3,9 @@ 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/utils/dependencyConstraints'; import { RuleTester } from '../src/RuleTester'; import type { RuleTesterTestFrameworkFunctionBase } from '../src/TestFramework'; +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/dependencyConstraints', () => { diff --git a/packages/scope-manager/tests/eslint-scope/get-declared-variables.test.ts b/packages/scope-manager/tests/eslint-scope/get-declared-variables.test.ts index 82611eb5147..5a19f01debf 100644 --- a/packages/scope-manager/tests/eslint-scope/get-declared-variables.test.ts +++ b/packages/scope-manager/tests/eslint-scope/get-declared-variables.test.ts @@ -16,18 +16,20 @@ describe('ScopeManager.prototype.getDeclaredVariables', () => { }); simpleTraverse(ast, { - [type](node) { - const expected = expectedNamesList.shift()!; - const actual = scopeManager.getDeclaredVariables(node); - - expect(actual).toHaveLength(expected.length); - if (actual.length > 0) { - const end = actual.length - 1; - - for (let i = 0; i <= end; i++) { - expect(actual[i].name).toBe(expected[i]); + visitors: { + [type](node) { + const expected = expectedNamesList.shift()!; + const actual = scopeManager.getDeclaredVariables(node); + + expect(actual).toHaveLength(expected.length); + if (actual.length > 0) { + const end = actual.length - 1; + + for (let i = 0; i <= end; i++) { + expect(actual[i].name).toBe(expected[i]); + } } - } + }, }, }); diff --git a/packages/scope-manager/tests/util/getSpecificNode.ts b/packages/scope-manager/tests/util/getSpecificNode.ts index a6d35615612..e9d23cf90c9 100644 --- a/packages/scope-manager/tests/util/getSpecificNode.ts +++ b/packages/scope-manager/tests/util/getSpecificNode.ts @@ -32,13 +32,15 @@ function getSpecificNode( simpleTraverse( ast, { - [selector](n) { - const res = cb ? cb(n) : n; - if (res) { - // the callback shouldn't match multiple nodes or else tests may behave weirdly - expect(node).toBeFalsy(); - node = typeof res === 'boolean' ? n : res; - } + visitors: { + [selector](n) { + const res = cb ? cb(n) : n; + if (res) { + // the callback shouldn't match multiple nodes or else tests may behave weirdly + expect(node).toBeFalsy(); + node = typeof res === 'boolean' ? n : res; + } + }, }, }, true, diff --git a/packages/utils/src/ts-eslint/Linter.ts b/packages/utils/src/ts-eslint/Linter.ts index dd5045f37bd..171335c5dd8 100644 --- a/packages/utils/src/ts-eslint/Linter.ts +++ b/packages/utils/src/ts-eslint/Linter.ts @@ -50,7 +50,8 @@ declare class LinterBase { defineRules( rulesToDefine: Record< string, - MinimalRuleModule | RuleCreateFunction + | MinimalRuleModule + | RuleCreateFunction >, ): void; diff --git a/packages/utils/src/ts-eslint/Rule.ts b/packages/utils/src/ts-eslint/Rule.ts index 57f353ccaac..f798e6e8cce 100644 --- a/packages/utils/src/ts-eslint/Rule.ts +++ b/packages/utils/src/ts-eslint/Rule.ts @@ -432,7 +432,6 @@ interface RuleListenerCatchAllBaseCase { [nodeSelector: string]: RuleFunction | undefined; } // Interface to merge into for anyone that wants to add more selectors -// eslint-disable-next-line @typescript-eslint/no-empty-interface interface RuleListenerExtension {} type RuleListener = RuleListenerBaseSelectors & @@ -467,8 +466,10 @@ type RuleCreateFunction< TMessageIds extends string = never, TOptions extends readonly unknown[] = unknown[], > = (context: Readonly>) => RuleListener; +type AnyRuleCreateFunction = RuleCreateFunction; export { + AnyRuleCreateFunction, AnyRuleModule, ReportDescriptor, ReportDescriptorMessageData, From df9da2c7ef85037c9c8b8293f16994b707952a42 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Sat, 1 Apr 2023 13:34:10 +1030 Subject: [PATCH 08/11] fix missing 'skip' in allowlist --- packages/rule-tester/src/utils/validationHelpers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/rule-tester/src/utils/validationHelpers.ts b/packages/rule-tester/src/utils/validationHelpers.ts index 3619df6bf25..27fb7f7631b 100644 --- a/packages/rule-tester/src/utils/validationHelpers.ts +++ b/packages/rule-tester/src/utils/validationHelpers.ts @@ -16,6 +16,7 @@ export const RULE_TESTER_PARAMETERS = [ 'only', 'options', 'output', + 'skip', ] as const; /* From 0af3ea9dfb95f3858c8e61a9a806e25c2b132f6d Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Sun, 2 Apr 2023 10:11:03 +0930 Subject: [PATCH 09/11] add docs --- .cspell.json | 10 +- docs/Architecture.mdx | 3 +- docs/architecture/Rule_Tester.mdx | 216 ++++++++++++++++++ ...cript-ESTree.mdx => TypeScript_ESTree.mdx} | 0 packages/eslint-plugin-tslint/README.md | 2 + packages/eslint-plugin/README.md | 2 + packages/parser/README.md | 2 + packages/rule-tester/README.md | 2 + packages/rule-tester/package.json | 8 +- packages/rule-tester/src/index.ts | 2 +- packages/rule-tester/src/types.ts | 184 --------------- .../src/types/DependencyConstraint.ts | 20 ++ .../rule-tester/src/types/InvalidTestCase.ts | 80 +++++++ .../rule-tester/src/types/RuleTesterConfig.ts | 27 +++ .../rule-tester/src/types/ValidTestCase.ts | 57 +++++ packages/rule-tester/src/types/index.ts | 36 +++ .../src/utils/dependencyConstraints.ts | 22 +- .../src/utils/validationHelpers.ts | 2 +- packages/scope-manager/README.md | 2 + packages/type-utils/README.md | 10 +- packages/typescript-estree/README.md | 4 + packages/utils/README.md | 2 + packages/website/package.json | 1 + yarn.lock | 68 +----- 24 files changed, 489 insertions(+), 273 deletions(-) create mode 100644 docs/architecture/Rule_Tester.mdx rename docs/architecture/{TypeScript-ESTree.mdx => TypeScript_ESTree.mdx} (100%) delete mode 100644 packages/rule-tester/src/types.ts create mode 100644 packages/rule-tester/src/types/DependencyConstraint.ts create mode 100644 packages/rule-tester/src/types/InvalidTestCase.ts create mode 100644 packages/rule-tester/src/types/RuleTesterConfig.ts create mode 100644 packages/rule-tester/src/types/ValidTestCase.ts create mode 100644 packages/rule-tester/src/types/index.ts diff --git a/.cspell.json b/.cspell.json index 62dd3ad1bfc..044fb2b4342 100644 --- a/.cspell.json +++ b/.cspell.json @@ -1,6 +1,14 @@ { - "version": "0.1", + "version": "0.2", "language": "en", + "enableFiletypes": [ + "markdown", + "mdx", + "typescript", + "typescriptreact", + "javascript", + "javascriptreact" + ], "ignorePaths": [ ".cspell.json", ".github/workflows/**", diff --git a/docs/Architecture.mdx b/docs/Architecture.mdx index ee224c1e90a..847473a659d 100644 --- a/docs/Architecture.mdx +++ b/docs/Architecture.mdx @@ -12,6 +12,7 @@ They are: - [`@typescript-eslint/eslint-plugin`](./architecture/ESLint_Plugin.mdx): An ESLint plugin which provides lint rules for TypeScript codebases. - [`@typescript-eslint/eslint-plugin-tslint`](./architecture/ESLint_Plugin_TSLint.mdx): ESLint plugin that allows running TSLint rules within ESLint to help you migrate from TSLint to ESLint. - [`@typescript-eslint/parser`](./architecture/Parser.mdx): An ESLint parser which allows for ESLint to lint TypeScript source code. +- [`@typescript-eslint/rule-tester`](./architecture/Rule_Tester.mdx): A utility for testing ESLint rules. - [`@typescript-eslint/scope-manager`](./architecture/Scope_Manager.mdx): A fork of [`eslint-scope`](https://github.com/eslint/eslint-scope), enhanced to support TypeScript functionality. -- [`@typescript-eslint/typescript-estree`](./architecture/TypeScript-ESTree.mdx): The underlying code used by [`@typescript-eslint/parser`](./architecture/Parser.mdx) that converts TypeScript source code into an ESTree-compatible form. +- [`@typescript-eslint/typescript-estree`](./architecture/TypeScript_ESTree.mdx): The underlying code used by [`@typescript-eslint/parser`](./architecture/Parser.mdx) that converts TypeScript source code into an ESTree-compatible form. - [`@typescript-eslint/utils`](./architecture/Utils.mdx): Utilities for working with TypeScript + ESLint together. diff --git a/docs/architecture/Rule_Tester.mdx b/docs/architecture/Rule_Tester.mdx new file mode 100644 index 00000000000..7160a2dc17c --- /dev/null +++ b/docs/architecture/Rule_Tester.mdx @@ -0,0 +1,216 @@ +--- +id: rule-tester +sidebar_label: rule-tester +--- + +import CodeBlock from '@theme/CodeBlock'; + +# `@typescript-eslint/rule-tester` + +> A utility for testing ESLint rules + +## Usage + +For non-type-aware rules you can test them as follows: + +```ts +import { RuleTester } from '@typescript-eslint/rule-tester'; +import rule from '../src/rules/my-rule.ts'; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('my-rule', rule, { + valid: [ + // valid tests can be a raw string, + 'const x = 1;', + // or they can be an object + { + code: 'const y = 2;', + options: [{ ruleOption: true }], + }, + + // you can enable JSX parsing by passing parserOptions.ecmaFeatures.jsx = true + { + code: 'const z =
;', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + ], + invalid: [ + // invalid tests must always be an object + { + code: 'const a = 1;', + // invalid tests must always specify the expected errors + errors: [ + { + messageId: 'ruleMessage', + // If applicable - it's recommended that you also assert the data in + // addition to the messageId so that you can ensure the correct message + // is generated + data: { + placeholder1: 'a', + }, + }, + ], + }, + + // fixers can be tested using the output parameter + { + code: 'const b = 1;', + output: 'const c = 1;', + errors: [ + /* ... */ + ], + }, + // passing `output = null` will enforce the code is NOT changed + { + code: 'const c = 1;', + output: null, + errors: [ + /* ... */ + ], + }, + + // suggestions can be tested via errors + { + code: 'const d = 1;', + output: null, + errors: [ + { + messageId: 'suggestionError', + suggestions: [ + { + messageId: 'suggestionOne', + output: 'const e = 1;', + }, + ], + }, + ], + }, + // passing `suggestions = null` will enforce there are NO suggestions + { + code: 'const d = 1;', + output: null, + errors: [ + { + messageId: 'noSuggestionError', + suggestions: null, + }, + ], + }, + ], +}); +``` + +### Type-Aware Testing + +Type-aware rules can be tested in almost exactly the same way, except you need to create some files on disk. +We require files on disk due to a limitation with TypeScript in that it requires physical files on disk to initialize the project. +We suggest creating a `fixture` folder nearby that contains three files: + +1. `file.ts` - this should be an empty file. +2. `react.tsx` - this should be an empty file. +3. `tsconfig.json` - this should be the config to use for your test, for example: + ```json + { + "compilerOptions": { + "strict": true + }, + "include": ["file.ts", "react.tsx"] + } + ``` + +:::caution +It's important to note that both `file.ts` and `react.tsx` must both be empty files! +The rule tester will automatically use the string content from your tests - the empty files are just there for initialization. +::: + +You can then test your rule by providing the type-aware config: + +```ts +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + // Added lines start + parserOptions: { + tsconfigRootDir: './path/to/your/folder/fixture', + project: './tsconfig.json', + }, + // Added lines end +}); +``` + +With that config the parser will automatically run in type-aware mode and you can write tests just like before. + +### Test Dependency Constraints + +Sometimes it's desirable to test your rule against multiple versions of a dependency to ensure backwards and forwards compatibility. +With backwards-compatibility testing there comes a complication in that some tests may not be compatible with an older version of a dependency. +For example - if you're testing against an older version of TypeScript, certain features might cause a parser error! + +import DependencyConstraint from '!!raw-loader!../../packages/rule-tester/src/types/DependencyConstraint.ts'; + +{DependencyConstraint} + +The `RuleTester` allows you to apply dependency constraints at either an individual test or constructor level. + +```ts +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + // Added lines start + dependencyConstraints: { + // none of the tests will run unless `my-dependency` matches the semver range `>=1.2.3` + 'my-dependency': '1.2.3', + // you can also provide granular semver ranges + 'my-granular-dep': { + // none of the tests will run unless `my-granular-dep` matches the semver range `~3.2.1` + range: '~3.2.1', + }, + }, + // Added lines end +}); + +ruleTester.run('my-rule', rule, { + valid: [ + { + code: 'const y = 2;', + // Added lines start + dependencyConstraints: { + // this test won't run unless BOTH dependencies match the given ranges + first: '1.2.3', + second: '3.2.1', + }, + // Added lines end + }, + ], + invalid: [ + /* ... */ + ], +}); +``` + +All dependencies provided in the `dependencyConstraints` object must match their given ranges in order for a test to not be skipped. + +## Options + +### `RuleTester` constructor options + +import RuleTesterConfig from '!!raw-loader!../../packages/rule-tester/src/types/RuleTesterConfig.ts'; + +{RuleTesterConfig} + +### Valid test case options + +import ValidTestCase from '!!raw-loader!../../packages/rule-tester/src/types/ValidTestCase.ts'; + +{ValidTestCase} + +### Invalid test case options + +import InvalidTestCase from '!!raw-loader!../../packages/rule-tester/src/types/InvalidTestCase.ts'; + +{InvalidTestCase} diff --git a/docs/architecture/TypeScript-ESTree.mdx b/docs/architecture/TypeScript_ESTree.mdx similarity index 100% rename from docs/architecture/TypeScript-ESTree.mdx rename to docs/architecture/TypeScript_ESTree.mdx diff --git a/packages/eslint-plugin-tslint/README.md b/packages/eslint-plugin-tslint/README.md index 57c414230ec..dfe8ffecb36 100644 --- a/packages/eslint-plugin-tslint/README.md +++ b/packages/eslint-plugin-tslint/README.md @@ -8,3 +8,5 @@ 👉 See **https://typescript-eslint.io/architecture/eslint-plugin-tslint** for documentation on this package. > See https://typescript-eslint.io for general documentation on typescript-eslint, the tooling that allows you to run ESLint and Prettier on TypeScript code. + + diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 9c98f8c7d4b..e8e09e03eb6 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -8,3 +8,5 @@ An ESLint plugin which provides lint rules for TypeScript codebases. 👉 See **https://typescript-eslint.io/getting-started** for our Getting Started docs. > See https://typescript-eslint.io for general documentation on typescript-eslint, the tooling that allows you to run ESLint and Prettier on TypeScript code. + + diff --git a/packages/parser/README.md b/packages/parser/README.md index f057b97f868..5f92225c382 100644 --- a/packages/parser/README.md +++ b/packages/parser/README.md @@ -8,3 +8,5 @@ 👉 See **https://typescript-eslint.io/architecture/parser** for documentation on this package. > See https://typescript-eslint.io for general documentation on typescript-eslint, the tooling that allows you to run ESLint and Prettier on TypeScript code. + + diff --git a/packages/rule-tester/README.md b/packages/rule-tester/README.md index 8f67529142c..2464e5ac626 100644 --- a/packages/rule-tester/README.md +++ b/packages/rule-tester/README.md @@ -6,3 +6,5 @@ [![NPM Downloads](https://img.shields.io/npm/dm/@typescript-eslint/rule-tester.svg?style=flat-square)](https://www.npmjs.com/package/@typescript-eslint/rule-tester) 👉 See **https://typescript-eslint.io/architecture/rule-tester** for documentation on this package. + + diff --git a/packages/rule-tester/package.json b/packages/rule-tester/package.json index eea8c38cfa6..4b2d2370917 100644 --- a/packages/rule-tester/package.json +++ b/packages/rule-tester/package.json @@ -1,6 +1,6 @@ { "name": "@typescript-eslint/rule-tester", - "version": "5.56.0", + "version": "5.57.0", "description": "Tooling to test ESLint rules", "files": [ "dist", @@ -47,8 +47,8 @@ }, "//": "NOTE - AJV is out-of-date, but it's intentionally synced with ESLint - https://github.com/eslint/eslint/blob/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/package.json#L70", "dependencies": { - "@typescript-eslint/typescript-estree": "5.56.0", - "@typescript-eslint/utils": "5.56.0", + "@typescript-eslint/typescript-estree": "5.57.0", + "@typescript-eslint/utils": "5.57.0", "lodash.merge": "4.6.2", "semver": "^7.3.7", "ajv": "^6.10.0" @@ -58,7 +58,7 @@ "eslint": ">=8" }, "devDependencies": { - "@typescript-eslint/parser": "5.56.0", + "@typescript-eslint/parser": "5.57.0", "@types/lodash.merge": "4.6.7", "chai": "^4.0.1", "mocha": "^8.3.2", diff --git a/packages/rule-tester/src/index.ts b/packages/rule-tester/src/index.ts index c12db2b39c9..6ea08fc5add 100644 --- a/packages/rule-tester/src/index.ts +++ b/packages/rule-tester/src/index.ts @@ -13,4 +13,4 @@ export type { DependencyConstraint, SemverVersionConstraint, VersionConstraint, -} from './utils/dependencyConstraints'; +} from './types/DependencyConstraint'; diff --git a/packages/rule-tester/src/types.ts b/packages/rule-tester/src/types.ts deleted file mode 100644 index 2cff2236ddd..00000000000 --- a/packages/rule-tester/src/types.ts +++ /dev/null @@ -1,184 +0,0 @@ -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 './utils/dependencyConstraints'; - -export interface RuleTesterConfig extends Linter.Config { - /** - * 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> ->; - -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/types/DependencyConstraint.ts b/packages/rule-tester/src/types/DependencyConstraint.ts new file mode 100644 index 00000000000..37cb3d5a180 --- /dev/null +++ b/packages/rule-tester/src/types/DependencyConstraint.ts @@ -0,0 +1,20 @@ +import type { RangeOptions } from 'semver'; + +export interface SemverVersionConstraint { + readonly range: string; + readonly options?: boolean | RangeOptions; +} +export type AtLeastVersionConstraint = + | `${number}` + | `${number}.${number}` + | `${number}.${number}.${number}` + | `${number}.${number}.${number}-${string}`; +export type VersionConstraint = + | SemverVersionConstraint + | AtLeastVersionConstraint; +export interface DependencyConstraint { + /** + * Passing a string for the value is shorthand for a '>=' constraint + */ + readonly [packageName: string]: VersionConstraint; +} diff --git a/packages/rule-tester/src/types/InvalidTestCase.ts b/packages/rule-tester/src/types/InvalidTestCase.ts new file mode 100644 index 00000000000..1bef9e2b89f --- /dev/null +++ b/packages/rule-tester/src/types/InvalidTestCase.ts @@ -0,0 +1,80 @@ +import type { AST_NODE_TYPES, AST_TOKEN_TYPES } from '@typescript-eslint/utils'; +import type { ReportDescriptorMessageData } from '@typescript-eslint/utils/ts-eslint'; + +import type { DependencyConstraint } from './DependencyConstraint'; +import type { ValidTestCase } from './ValidTestCase'; + +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; +} diff --git a/packages/rule-tester/src/types/RuleTesterConfig.ts b/packages/rule-tester/src/types/RuleTesterConfig.ts new file mode 100644 index 00000000000..c722c5be074 --- /dev/null +++ b/packages/rule-tester/src/types/RuleTesterConfig.ts @@ -0,0 +1,27 @@ +import type { Linter, ParserOptions } from '@typescript-eslint/utils/ts-eslint'; + +import type { DependencyConstraint } from './DependencyConstraint'; + +export interface RuleTesterConfig extends Linter.Config { + /** + * 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; + }>; +} diff --git a/packages/rule-tester/src/types/ValidTestCase.ts b/packages/rule-tester/src/types/ValidTestCase.ts new file mode 100644 index 00000000000..4aa2ef0aa6c --- /dev/null +++ b/packages/rule-tester/src/types/ValidTestCase.ts @@ -0,0 +1,57 @@ +import type { + ParserOptions, + SharedConfigurationSettings, +} from '@typescript-eslint/utils/ts-eslint'; + +import type { DependencyConstraint } from './DependencyConstraint'; + +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; +} diff --git a/packages/rule-tester/src/types/index.ts b/packages/rule-tester/src/types/index.ts new file mode 100644 index 00000000000..bebb6786af8 --- /dev/null +++ b/packages/rule-tester/src/types/index.ts @@ -0,0 +1,36 @@ +import type { InvalidTestCase } from './InvalidTestCase'; +import type { RuleTesterConfig } from './RuleTesterConfig'; +import type { ValidTestCase } from './ValidTestCase'; + +type Mutable = { + -readonly [P in keyof T]: T[P]; +}; +export type TesterConfigWithDefaults = Mutable< + RuleTesterConfig & + Required> +>; + +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[]; +} + +export type { ValidTestCase } from './ValidTestCase'; +export type { + InvalidTestCase, + SuggestionOutput, + TestCaseError, +} from './InvalidTestCase'; +export type { RuleTesterConfig } from './RuleTesterConfig'; diff --git a/packages/rule-tester/src/utils/dependencyConstraints.ts b/packages/rule-tester/src/utils/dependencyConstraints.ts index 58bab6e9800..e651356587a 100644 --- a/packages/rule-tester/src/utils/dependencyConstraints.ts +++ b/packages/rule-tester/src/utils/dependencyConstraints.ts @@ -1,23 +1,9 @@ import * as semver from 'semver'; -export interface SemverVersionConstraint { - readonly range: string; - readonly options?: Parameters[2]; -} -export type AtLeastVersionConstraint = - | `${number}` - | `${number}.${number}` - | `${number}.${number}.${number}` - | `${number}.${number}.${number}-${string}`; -export type VersionConstraint = - | SemverVersionConstraint - | AtLeastVersionConstraint; -export interface DependencyConstraint { - /** - * Passing a string for the value is shorthand for a '>=' constraint - */ - readonly [packageName: string]: VersionConstraint; -} +import type { + DependencyConstraint, + SemverVersionConstraint, +} from '../types/DependencyConstraint'; const BASE_SATISFIES_OPTIONS: semver.RangeOptions = { includePrerelease: true, diff --git a/packages/rule-tester/src/utils/validationHelpers.ts b/packages/rule-tester/src/utils/validationHelpers.ts index 27fb7f7631b..33fd0c234de 100644 --- a/packages/rule-tester/src/utils/validationHelpers.ts +++ b/packages/rule-tester/src/utils/validationHelpers.ts @@ -110,7 +110,7 @@ export function wrapParser(parser: Linter.ParserModule): Linter.ParserModule { } simpleTraverse(ast, { - visitorKeys, + visitorKeys: visitorKeys, enter: node => defineStartEndAsError('node', node), }); ast.tokens?.forEach(token => defineStartEndAsError('token', token)); diff --git a/packages/scope-manager/README.md b/packages/scope-manager/README.md index 0258932e390..233997bcbb6 100644 --- a/packages/scope-manager/README.md +++ b/packages/scope-manager/README.md @@ -6,3 +6,5 @@ 👉 See **https://typescript-eslint.io/architecture/scope-manager** for documentation on this package. > See https://typescript-eslint.io for general documentation on typescript-eslint, the tooling that allows you to run ESLint and Prettier on TypeScript code. + + diff --git a/packages/type-utils/README.md b/packages/type-utils/README.md index 2f842e803cc..09a28aea18c 100644 --- a/packages/type-utils/README.md +++ b/packages/type-utils/README.md @@ -2,11 +2,13 @@ > Type utilities for working with TypeScript within ESLint rules. +[![NPM Version](https://img.shields.io/npm/v/@typescript-eslint/utils.svg?style=flat-square)](https://www.npmjs.com/package/@typescript-eslint/utils) +[![NPM Downloads](https://img.shields.io/npm/dm/@typescript-eslint/utils.svg?style=flat-square)](https://www.npmjs.com/package/@typescript-eslint/utils) + The utilities in this package are separated from `@typescript-eslint/utils` so that that package does not require a dependency on `typescript`. -## ✋ Internal Package +👉 See **https://typescript-eslint.io/architecture/type-utils** for documentation on this package. -This is an _internal package_ to the [typescript-eslint monorepo](https://github.com/typescript-eslint/typescript-eslint). -You likely don't want to use it directly. +> See https://typescript-eslint.io for general documentation on typescript-eslint, the tooling that allows you to run ESLint and Prettier on TypeScript code. -👉 See **https://typescript-eslint.io** for docs on typescript-eslint. + diff --git a/packages/typescript-estree/README.md b/packages/typescript-estree/README.md index 4ce7f1e7cf4..9d7ec247ebf 100644 --- a/packages/typescript-estree/README.md +++ b/packages/typescript-estree/README.md @@ -1,5 +1,7 @@ # `@typescript-eslint/typescript-estree` +> A parser that produces an ESTree-compatible AST for TypeScript code. + [![NPM Version](https://img.shields.io/npm/v/@typescript-eslint/typescript-estree.svg?style=flat-square)](https://www.npmjs.com/package/@typescript-eslint/utils) [![NPM Downloads](https://img.shields.io/npm/dm/@typescript-eslint/typescript-estree.svg?style=flat-square)](https://www.npmjs.com/package/@typescript-eslint/utils) @@ -8,3 +10,5 @@ 👉 See **https://typescript-eslint.io/architecture/typescript-estree** for documentation on this package. > See https://typescript-eslint.io for general documentation on typescript-eslint, the tooling that allows you to run ESLint and Prettier on TypeScript code. + + diff --git a/packages/utils/README.md b/packages/utils/README.md index 8013675d962..171393b51ca 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -8,3 +8,5 @@ 👉 See **https://typescript-eslint.io/architecture/utils** for documentation on this package. > See https://typescript-eslint.io for general documentation on typescript-eslint, the tooling that allows you to run ESLint and Prettier on TypeScript code. + + diff --git a/packages/website/package.json b/packages/website/package.json index de772bc68b4..e3f84533d01 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -59,6 +59,7 @@ "globby": "^11.1.0", "make-dir": "*", "monaco-editor": "^0.33.0", + "raw-loader": "^4.0.2", "rimraf": "*", "stylelint": "^15.3.0", "stylelint-config-recommended": "^11.0.0", diff --git a/yarn.lock b/yarn.lock index 9dabe364884..1b857128c52 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4044,7 +4044,7 @@ resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== -"@types/semver@*", "@types/semver@^7.3.12", "@types/semver@^7.3.9": +"@types/semver@*", "@types/semver@^7.3.9": version "7.3.13" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91" integrity sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw== @@ -4124,64 +4124,6 @@ dependencies: "@typescript-eslint/utils" "5.57.0" -"@typescript-eslint/parser@5.56.0": - version "5.56.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.56.0.tgz#42eafb44b639ef1dbd54a3dbe628c446ca753ea6" - integrity sha512-sn1OZmBxUsgxMmR8a8U5QM/Wl+tyqlH//jTqCg8daTAmhAk26L2PFhcqPLlYBhYUJMZJK276qLXlHN3a83o2cg== - dependencies: - "@typescript-eslint/scope-manager" "5.56.0" - "@typescript-eslint/types" "5.56.0" - "@typescript-eslint/typescript-estree" "5.56.0" - debug "^4.3.4" - -"@typescript-eslint/scope-manager@5.56.0": - version "5.56.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.56.0.tgz#62b4055088903b5254fa20403010e1c16d6ab725" - integrity sha512-jGYKyt+iBakD0SA5Ww8vFqGpoV2asSjwt60Gl6YcO8ksQ8s2HlUEyHBMSa38bdLopYqGf7EYQMUIGdT/Luw+sw== - dependencies: - "@typescript-eslint/types" "5.56.0" - "@typescript-eslint/visitor-keys" "5.56.0" - -"@typescript-eslint/types@5.56.0": - version "5.56.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.56.0.tgz#b03f0bfd6fa2afff4e67c5795930aff398cbd834" - integrity sha512-JyAzbTJcIyhuUhogmiu+t79AkdnqgPUEsxMTMc/dCZczGMJQh1MK2wgrju++yMN6AWroVAy2jxyPcPr3SWCq5w== - -"@typescript-eslint/typescript-estree@5.56.0": - version "5.56.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.56.0.tgz#48342aa2344649a03321e74cab9ccecb9af086c3" - integrity sha512-41CH/GncsLXOJi0jb74SnC7jVPWeVJ0pxQj8bOjH1h2O26jXN3YHKDT1ejkVz5YeTEQPeLCCRY0U2r68tfNOcg== - dependencies: - "@typescript-eslint/types" "5.56.0" - "@typescript-eslint/visitor-keys" "5.56.0" - debug "^4.3.4" - globby "^11.1.0" - is-glob "^4.0.3" - semver "^7.3.7" - tsutils "^3.21.0" - -"@typescript-eslint/utils@5.56.0": - version "5.56.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.56.0.tgz#db64705409b9a15546053fb4deb2888b37df1f41" - integrity sha512-XhZDVdLnUJNtbzaJeDSCIYaM+Tgr59gZGbFuELgF7m0IY03PlciidS7UQNKLE0+WpUTn1GlycEr6Ivb/afjbhA== - dependencies: - "@eslint-community/eslint-utils" "^4.2.0" - "@types/json-schema" "^7.0.9" - "@types/semver" "^7.3.12" - "@typescript-eslint/scope-manager" "5.56.0" - "@typescript-eslint/types" "5.56.0" - "@typescript-eslint/typescript-estree" "5.56.0" - eslint-scope "^5.1.1" - semver "^7.3.7" - -"@typescript-eslint/visitor-keys@5.56.0": - version "5.56.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.56.0.tgz#f19eb297d972417eb13cb69b35b3213e13cc214f" - integrity sha512-1mFdED7u5bZpX6Xxf5N9U2c18sb+8EvU3tyOIj6LQZ5OOvnmj8BVeNNP603OFPm5KkS1a7IvCIcwrdHXaEMG/Q== - dependencies: - "@typescript-eslint/types" "5.56.0" - eslint-visitor-keys "^3.3.0" - "@ungap/promise-all-settled@1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" @@ -12579,6 +12521,14 @@ raw-body@2.5.1: iconv-lite "0.4.24" unpipe "1.0.0" +raw-loader@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-4.0.2.tgz#1aac6b7d1ad1501e66efdac1522c73e59a584eb6" + integrity sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA== + dependencies: + loader-utils "^2.0.0" + schema-utils "^3.0.0" + rc@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" From af11858a1b0ba14dc13fe2ee5de59695994e5d16 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Sun, 2 Apr 2023 10:19:23 +0930 Subject: [PATCH 10/11] add missing sidebar link --- packages/website/sidebars/sidebar.base.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/website/sidebars/sidebar.base.js b/packages/website/sidebars/sidebar.base.js index 2a097b02a70..c1485c6007a 100644 --- a/packages/website/sidebars/sidebar.base.js +++ b/packages/website/sidebars/sidebar.base.js @@ -42,6 +42,7 @@ module.exports = { 'architecture/eslint-plugin', 'architecture/eslint-plugin-tslint', 'architecture/parser', + 'architecture/rule-tester', 'architecture/scope-manager', 'architecture/typescript-estree', 'architecture/utils', From d5a1839d24e6d8642cd0d1cf0c5fa92462f753d1 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Thu, 27 Apr 2023 11:28:43 +0930 Subject: [PATCH 11/11] review --- .github/renovate.json5 | 2 ++ docs/Custom_Rules.mdx | 16 +++++++++------- docs/architecture/Rule_Tester.mdx | 2 ++ packages/rule-tester/LICENSE | 2 +- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index b8d7426f2cf..feda2165fe2 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -1,6 +1,8 @@ { enabledManagers: ['github-actions', 'npm'], ignoreDeps: [ + // AJV is out-of-date, but it's intentionally synced with ESLint - https://github.com/eslint/eslint/blob/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/package.json#L70 + 'ajv', // globby is ESM so we can't go any higher right now 'globby', // this dep now uses package.json exports - we will be removing it next major diff --git a/docs/Custom_Rules.mdx b/docs/Custom_Rules.mdx index d636a993112..e245e2fb058 100644 --- a/docs/Custom_Rules.mdx +++ b/docs/Custom_Rules.mdx @@ -274,18 +274,20 @@ This can be necessary for TypeScript APIs not wrapped by the parser services. ## Testing -`@typescript-eslint/utils` exports a `RuleTester` with a similar API to the built-in [ESLint `RuleTester`](https://eslint.org/docs/developer-guide/nodejs-api#ruletester). +`@typescript-eslint/rule-tester` exports a `RuleTester` with a similar API to the built-in ESLint `RuleTester`. It should be provided with the same `parser` and `parserOptions` you would use in your ESLint configuration. +Below is a quick-start guide. For more in-depth docs and examples [see the `@typescript-eslint/rule-tester` package documentation](./architecture/Rule_Tester.mdx). + ### Testing Untyped Rules For rules that don't need type information, passing just the `parser` will do: ```ts -import { ESLintUtils } from '@typescript-eslint/utils'; +import { RuleTester } from '@typescript-eslint/rule-tester'; import rule from './my-rule'; -const ruleTester = new ESLintUtils.RuleTester({ +const ruleTester = new RuleTester({ parser: '@typescript-eslint/parser', }); @@ -305,10 +307,10 @@ For rules that do need type information, `parserOptions` must be passed in as we Tests must have at least an absolute `tsconfigRootDir` path provided as well as a relative `project` path from that directory: ```ts -import { ESLintUtils } from '@typescript-eslint/utils'; +import { RuleTester } from '@typescript-eslint/rule-tester'; import rule from './my-typed-rule'; -const ruleTester = new ESLintUtils.RuleTester({ +const ruleTester = new RuleTester({ parser: '@typescript-eslint/parser', parserOptions: { project: './tsconfig.json', @@ -327,11 +329,11 @@ ruleTester.run('my-typed-rule', rule, { ``` :::note -For now, `ESLintUtils.RuleTester` requires the following physical files be present on disk for typed rules: +For now, `RuleTester` requires the following physical files be present on disk for typed rules: - `tsconfig.json`: tsconfig used as the test "project" - One of the following two files: - `file.ts`: blank test file used for normal TS tests - - `file.tsx`: blank test file used for tests with `parserOptions: { ecmaFeatures: { jsx: true } }` + - `react.tsx`: blank test file used for tests with `parserOptions: { ecmaFeatures: { jsx: true } }` ::: diff --git a/docs/architecture/Rule_Tester.mdx b/docs/architecture/Rule_Tester.mdx index 7160a2dc17c..8caec346d77 100644 --- a/docs/architecture/Rule_Tester.mdx +++ b/docs/architecture/Rule_Tester.mdx @@ -9,6 +9,8 @@ import CodeBlock from '@theme/CodeBlock'; > A utility for testing ESLint rules +This is a fork of ESLint's built-in `RuleTester` to provide some better types and additional features for testing TypeScript rules. + ## Usage For non-type-aware rules you can test them as follows: diff --git a/packages/rule-tester/LICENSE b/packages/rule-tester/LICENSE index a1164108d4d..d68c4a4557e 100644 --- a/packages/rule-tester/LICENSE +++ b/packages/rule-tester/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 typescript-eslint and other contributors +Copyright (c) 2023 typescript-eslint and other contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal