From 2ce1c1d22c799a1ca027674fcb9b3a7ab0107428 Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Thu, 27 Apr 2023 12:42:21 +0930 Subject: [PATCH] feat: add new package `rule-tester` (#6777) --- .cspell.json | 10 +- .eslintignore | 3 + .github/renovate.json5 | 2 + .prettierignore | 3 + .vscode/launch.json | 36 + docs/Architecture.mdx | 3 +- docs/Custom_Rules.mdx | 16 +- docs/architecture/Rule_Tester.mdx | 218 ++ ...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/LICENSE | 21 + packages/rule-tester/README.md | 10 + packages/rule-tester/jest.config.js | 7 + packages/rule-tester/package.json | 79 + packages/rule-tester/project.json | 15 + packages/rule-tester/src/RuleTester.ts | 1028 ++++++ packages/rule-tester/src/TestFramework.ts | 220 ++ packages/rule-tester/src/index.ts | 16 + packages/rule-tester/src/noFormat.ts | 7 + .../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 + .../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 | 91 + .../rule-tester/src/utils/config-validator.ts | 288 ++ .../src/utils/dependencyConstraints.ts | 48 + .../src/utils/deprecation-warnings.ts | 40 + .../rule-tester/src/utils/freezeDeeply.ts | 17 + .../src/utils/getRuleOptionsSchema.ts | 35 + .../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 | 182 ++ packages/rule-tester/tests/RuleTester.test.ts | 822 +++++ .../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 + packages/scope-manager/README.md | 2 + .../get-declared-variables.test.ts | 24 +- .../tests/util/getSpecificNode.ts | 16 +- packages/type-utils/README.md | 10 +- packages/typescript-estree/README.md | 4 + .../typescript-estree/src/simple-traverse.ts | 27 +- packages/utils/README.md | 2 + packages/utils/package.json | 1 - packages/utils/src/json-schema.ts | 24 +- packages/utils/src/ts-eslint/Linter.ts | 27 +- packages/utils/src/ts-eslint/Rule.ts | 25 +- packages/utils/src/ts-eslint/RuleTester.ts | 5 +- packages/website/package.json | 1 + packages/website/sidebars/sidebar.base.js | 1 + patches/ajv+6.12.6.patch | 13 + yarn.lock | 367 ++- 77 files changed, 7501 insertions(+), 107 deletions(-) create mode 100644 docs/architecture/Rule_Tester.mdx rename docs/architecture/{TypeScript-ESTree.mdx => TypeScript_ESTree.mdx} (100%) 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/index.ts create mode 100644 packages/rule-tester/src/noFormat.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 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/dependencyConstraints.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/RuleTester.test.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 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/.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/.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/.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/.vscode/launch.json b/.vscode/launch.json index 27b3fb97740..4cee04bec4a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -105,6 +105,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/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/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 new file mode 100644 index 00000000000..8caec346d77 --- /dev/null +++ b/docs/architecture/Rule_Tester.mdx @@ -0,0 +1,218 @@ +--- +id: rule-tester +sidebar_label: rule-tester +--- + +import CodeBlock from '@theme/CodeBlock'; + +# `@typescript-eslint/rule-tester` + +> 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: + +```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/LICENSE b/packages/rule-tester/LICENSE new file mode 100644 index 00000000000..d68c4a4557e --- /dev/null +++ b/packages/rule-tester/LICENSE @@ -0,0 +1,21 @@ +MIT License + +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 +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..2464e5ac626 --- /dev/null +++ b/packages/rule-tester/README.md @@ -0,0 +1,10 @@ +# `@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..5b38b4895e4 --- /dev/null +++ b/packages/rule-tester/package.json @@ -0,0 +1,79 @@ +{ + "name": "@typescript-eslint/rule-tester", + "version": "5.59.1", + "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.59.1", + "@typescript-eslint/utils": "5.59.1", + "lodash.merge": "4.6.2", + "semver": "^7.3.7", + "ajv": "^6.10.0" + }, + "peerDependencies": { + "@eslint/eslintrc": ">=2", + "eslint": ">=8" + }, + "devDependencies": { + "@typescript-eslint/parser": "5.59.1", + "@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..0ff658adfbd --- /dev/null +++ b/packages/rule-tester/src/RuleTester.ts @@ -0,0 +1,1028 @@ +// 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 * 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, + RuleModule, +} 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 { + InvalidTestCase, + NormalizedRunTests, + RuleTesterConfig, + RunTests, + TesterConfigWithDefaults, + ValidTestCase, +} from './types'; +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'; +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, + RULE_TESTER_PARAMETERS, + sanitize, + SUGGESTION_OBJECT_PARAMETERS, + wrapParser, +} from './utils/validationHelpers'; + +const ajv = ajvBuilder({ strictDefaults: true }); +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, + rules: {}, + defaultFilenames: { ts: 'file.ts', tsx: 'react.tsx' }, +}; +let defaultConfig = deepMerge( + {}, + testerDefaultConfig, +) as TesterConfigWithDefaults; + +export class RuleTester extends TestFramework { + readonly #testerConfig: TesterConfigWithDefaults; + readonly #rules: Record = {}; + readonly #linter: Linter = new 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' }, + // 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), + }); + + // 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 + } + }); + } + + /** + * 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 = deepMerge( + defaultConfig, + // @ts-expect-error -- no index signature + config, + ) as TesterConfigWithDefaults; + } + + /** + * 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 | AnyRuleCreateFunction): void { + 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; + } + + /** + * Adds a new rule test to execute. + */ + run( + ruleName: string, + rule: RuleModule, + test: RunTests, + ): void { + 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( + `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); + } + + this.#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); + }, + }), + ); + + this.#linter.defineRules(this.#rules); + + const normalizedTests = this.#normalizeTests(test); + + function getTestMethod( + test: ValidTestCase, + ): 'it' | 'itOnly' | 'itSkip' { + if (test.skip) { + return 'itSkip'; + } + if (test.only) { + return 'itOnly'; + } + return 'it'; + } + + /* + * 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); + }); + }); + }); + + 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); + }); + }); + }); + }); + } + + /** + * 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; + + 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 RULE_TESTER_PARAMETERS) { + 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. + */ + 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( + path.isAbsolute(config.parser), + 'Parsers provided as strings to RuleTester must be absolute paths', + ); + } else { + config.parser = require.resolve(TYPESCRIPT_ESLINT_PARSER); + } + + this.#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 = this.#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 = this.#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 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.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( + 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 = 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"); + } + + 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); + } +} + +/** + * 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 new file mode 100644 index 00000000000..dea77d74624 --- /dev/null +++ b/packages/rule-tester/src/TestFramework.ts @@ -0,0 +1,220 @@ +/** + * @param text a string describing the rule + * @param callback the test callback + */ +export type RuleTesterTestFrameworkFunctionBase = ( + text: string, + callback: () => void, +) => void; +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: 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 + * 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 { + /** + * 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; + } + 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: Maybe) { + OVERRIDE_AFTER_ALL = value; + } + + /** + * Creates a test grouping + */ + 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: 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; + } + 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: Maybe) { + OVERRIDE_IT = value; + } + + /** + * Only runs this test in the current file. + */ + static get itOnly(): RuleTesterTestFrameworkFunctionBase { + 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: 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 new file mode 100644 index 00000000000..6ea08fc5add --- /dev/null +++ b/packages/rule-tester/src/index.ts @@ -0,0 +1,16 @@ +export { RuleTester } from './RuleTester'; +export { noFormat } from './noFormat'; +export type { + InvalidTestCase, + RuleTesterConfig, + RunTests, + SuggestionOutput, + TestCaseError, + ValidTestCase, +} from './types'; +export type { + AtLeastVersionConstraint, + DependencyConstraint, + SemverVersionConstraint, + VersionConstraint, +} from './types/DependencyConstraint'; 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/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/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..8261ac8749c --- /dev/null +++ b/packages/rule-tester/src/utils/config-schema.ts @@ -0,0 +1,91 @@ +// Forked from https://github.com/eslint/eslint/blob/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/conf/config-schema.js + +import type { JSONSchema } from '@typescript-eslint/utils'; + +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' }, + additionalItems: false, + }, + parser: { type: ['string', 'null'] }, + parserOptions: { type: 'object' }, + plugins: { type: 'array' }, + processor: { type: 'string' }, + reportUnusedDisableDirectives: { type: 'boolean' }, + rules: { type: 'object' }, + settings: { type: 'object' }, + + 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/dependencyConstraints.ts b/packages/rule-tester/src/utils/dependencyConstraints.ts new file mode 100644 index 00000000000..e651356587a --- /dev/null +++ b/packages/rule-tester/src/utils/dependencyConstraints.ts @@ -0,0 +1,48 @@ +import * as semver from 'semver'; + +import type { + DependencyConstraint, + SemverVersionConstraint, +} from '../types/DependencyConstraint'; + +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; +} 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..51f94711f70 --- /dev/null +++ b/packages/rule-tester/src/utils/getRuleOptionsSchema.ts @@ -0,0 +1,35 @@ +// Forked from https://github.com/eslint/eslint/blob/ad9dd6a933fd098a0d99c6a9aa059850535c23ee/lib/shared/config-validator.js#LL50-L82C2 + +import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema'; +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): 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 as JSONSchema4[], + 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..33fd0c234de --- /dev/null +++ b/packages/rule-tester/src/utils/validationHelpers.ts @@ -0,0 +1,182 @@ +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 RULE_TESTER_PARAMETERS = [ + 'code', + 'defaultFilenames', + 'dependencyConstraints', + 'errors', + 'filename', + 'name', + 'only', + 'options', + 'output', + 'skip', +] as const; + +/* + * All allowed property names in error objects. + */ +export const ERROR_OBJECT_PARAMETERS: ReadonlySet = new Set([ + 'column', + 'data', + 'endColumn', + 'endLine', + 'line', + 'message', + 'messageId', + 'suggestions', + 'type', +]); +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([ + 'data', + 'desc', + 'messageId', + '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: 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/RuleTester.test.ts b/packages/rule-tester/tests/RuleTester.test.ts new file mode 100644 index 00000000000..6ed3edc23f3 --- /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 { 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', () => { + 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`); + }); + }); + }); +}); 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/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/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/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/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/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/utils/package.json b/packages/utils/package.json index a852cc6e026..ed063710ad9 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -67,7 +67,6 @@ "dependencies": { "@eslint-community/eslint-utils": "^4.3.0", "@types/json-schema": "^7.0.9", - "@types/semver": "^7.3.12", "@typescript-eslint/scope-manager": "5.59.1", "@typescript-eslint/types": "5.59.1", "@typescript-eslint/typescript-estree": "5.59.1", 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..171335c5dd8 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,8 @@ declare class LinterBase { defineRules( rulesToDefine: Record< string, - RuleModule | RuleCreateFunction + | MinimalRuleModule + | RuleCreateFunction >, ): void; @@ -52,7 +59,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 +127,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 +143,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 +151,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..f798e6e8cce 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,18 @@ 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 +interface RuleListenerExtension {} + +type RuleListener = RuleListenerBaseSelectors & + RuleListenerExitSelectors & + RuleListenerCatchAllBaseCase; interface RuleModule< TMessageIds extends string, @@ -447,14 +460,19 @@ interface RuleModule< */ create(context: Readonly>): TRuleListener; } +type AnyRuleModule = RuleModule; type RuleCreateFunction< TMessageIds extends string = never, TOptions extends readonly unknown[] = unknown[], > = (context: Readonly>) => RuleListener; +type AnyRuleCreateFunction = RuleCreateFunction; export { + AnyRuleCreateFunction, + AnyRuleModule, ReportDescriptor, + ReportDescriptorMessageData, ReportFixFunction, ReportSuggestionArray, RuleContext, @@ -463,6 +481,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/packages/website/package.json b/packages/website/package.json index abe0c02eb8d..01ea34e9cd5 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -59,6 +59,7 @@ "globby": "^11.1.0", "make-dir": "*", "monaco-editor": "^0.37.0", + "raw-loader": "^4.0.2", "rimraf": "*", "stylelint": "^15.3.0", "stylelint-config-recommended": "^11.0.0", 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', 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/yarn.lock b/yarn.lock index 8b3c889b137..667000b4fcb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2202,6 +2202,11 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.36.0.tgz#9837f768c03a1e4a30bd304a64fb8844f0e72efe" integrity sha512-lxJ9R5ygVm8ZWgYdUweoq5ownDlJ4upvoWmO4eLxBYHdMo+vZ/Rx0EN6MbKWDJOSUGrqJy2Gt+Dyv/VKml0fjg== +"@eslint/js@8.38.0": + version "8.38.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.38.0.tgz#73a8a0d8aa8a8e6fe270431c5e72ae91b5337892" + integrity sha512-IoD2MfUnOV58ghIHCiil01PcohxjbYR/qCxsoC+xNgUwh1EY8jOOrYmu3d3a71+tJJ23uscEV4X2HJWMsPJu4g== + "@eslint/js@8.39.0": version "8.39.0" resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.39.0.tgz#58b536bcc843f4cd1e02a7e6171da5c040f4d44b" @@ -3325,6 +3330,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" @@ -3339,6 +3351,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" @@ -3823,6 +3856,18 @@ 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@*": + version "4.14.192" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.192.tgz#5790406361a2852d332d41635d927f1600811285" + integrity sha512-km+Vyn3BYm5ytMO13k9KTp27O75rbQ0NFw+U//g+PX7VZyjCioXaRFisqSIJRECljcTv73G3i6BpglNGHgUQ5A== + "@types/marked@*", "@types/marked@^4.0.3": version "4.0.8" resolved "https://registry.yarnpkg.com/@types/marked/-/marked-4.0.8.tgz#b316887ab3499d0a8f4c70b7bd8508f92d477955" @@ -3986,7 +4031,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== @@ -4059,6 +4104,11 @@ dependencies: "@types/yargs-parser" "*" +"@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.5", "@webassemblyjs/ast@^1.11.5": version "1.11.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.5.tgz#6e818036b94548c1fb53b754b5cae3c9b208281c" @@ -4370,7 +4420,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== @@ -4387,6 +4437,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" @@ -4421,7 +4476,7 @@ ansi-styles@^6.0.0, ansi-styles@^6.1.0: resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== -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== @@ -4574,6 +4629,11 @@ 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" @@ -4929,6 +4989,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" @@ -5100,7 +5165,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== @@ -5125,6 +5190,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" @@ -5180,6 +5258,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" @@ -5205,6 +5288,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" @@ -6154,6 +6252,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" @@ -6174,6 +6279,11 @@ decamelize@^1.1.0, decamelize@^1.2.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" @@ -6186,6 +6296,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" @@ -6343,11 +6460,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" @@ -6740,6 +6867,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" @@ -6750,11 +6882,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" @@ -6907,7 +7034,7 @@ eslint-scope@5.1.1, eslint-scope@^5.1.1: esrecurse "^4.3.0" estraverse "^4.1.1" -eslint-scope@^7.2.0: +eslint-scope@^7.1.1, eslint-scope@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.0.tgz#f21ebdafda02352f103634b96dd47d9f81ca117b" integrity sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw== @@ -7583,7 +7710,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== @@ -7651,6 +7778,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" @@ -7786,7 +7918,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== @@ -7827,6 +7959,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" @@ -7987,6 +8131,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" @@ -8150,7 +8299,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== @@ -8717,6 +8866,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" @@ -8819,7 +8973,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== @@ -9466,6 +9620,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" @@ -9607,6 +9768,11 @@ just-diff@^6.0.0: resolved "https://registry.yarnpkg.com/just-diff/-/just-diff-6.0.2.tgz#03b65908543ac0521caf6d8eb85035f7d27ea285" integrity sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA== +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" @@ -9948,7 +10114,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== @@ -9973,6 +10139,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" @@ -9998,6 +10171,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" @@ -10538,6 +10718,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" @@ -10587,6 +10798,11 @@ mute-stream@0.0.8, mute-stream@~0.0.4: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== +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" @@ -10617,6 +10833,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" @@ -11549,6 +11776,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" @@ -12170,6 +12402,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" @@ -12470,6 +12710,13 @@ readable-stream@^4.1.0: events "^3.3.0" process "^0.11.10" +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" @@ -13032,6 +13279,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" @@ -13176,6 +13430,18 @@ sigstore@^1.0.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" + 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" @@ -13475,6 +13741,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" @@ -13557,6 +13831,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" @@ -13608,7 +13889,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== @@ -13715,6 +13996,13 @@ stylelint@^15.3.0: v8-compile-cache "^2.3.0" write-file-atomic "^5.0.0" +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" @@ -13722,20 +14010,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-hyperlinks@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-3.0.0.tgz#c711352a5c89070779b4dad54c05a2f14b15c94b" @@ -14148,7 +14429,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== @@ -14864,6 +15145,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" @@ -14871,13 +15159,6 @@ 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== - dependencies: - isexe "^2.0.0" - which@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/which/-/which-3.0.0.tgz#a9efd016db59728758a390d23f1687b6e8f59f8e" @@ -14885,6 +15166,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" @@ -15079,6 +15367,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" @@ -15242,6 +15535,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"