├── .codecov.yml
├── .eslint-doc-generatorrc.js
├── .eslintignore
├── .eslintrc.js
├── .github
├── renovate.json
└── workflows
│ ├── lint.yml
│ ├── markdown-link-check.yml
│ ├── nodejs.yml
│ ├── prepare-cache.yml
│ ├── smoke-test.yml
│ └── test.yml
├── .gitignore
├── .husky
├── commit-msg
└── pre-commit
├── .npmignore
├── .prettierignore
├── .yarn
├── plugins
│ └── @yarnpkg
│ │ └── plugin-interactive-tools.cjs
└── releases
│ └── yarn-3.8.7.cjs
├── .yarnrc.yml
├── CHANGELOG.md
├── LICENSE
├── README.md
├── babel.config.js
├── dangerfile.ts
├── docs
└── rules
│ ├── prefer-to-be-array.md
│ ├── prefer-to-be-false.md
│ ├── prefer-to-be-object.md
│ ├── prefer-to-be-true.md
│ └── prefer-to-have-been-called-once.md
├── eslint-remote-tester.config.ts
├── jest.config.ts
├── markdown_link_check_config.json
├── package.json
├── src
├── __tests__
│ ├── __snapshots__
│ │ └── rules.test.ts.snap
│ └── rules.test.ts
├── index.ts
└── rules
│ ├── __tests__
│ ├── prefer-to-be-array.test.ts
│ ├── prefer-to-be-false.test.ts
│ ├── prefer-to-be-object.test.ts
│ ├── prefer-to-be-true.test.ts
│ ├── prefer-to-have-been-called-once.test.ts
│ └── test-utils.ts
│ ├── prefer-to-be-array.ts
│ ├── prefer-to-be-false.ts
│ ├── prefer-to-be-object.ts
│ ├── prefer-to-be-true.ts
│ ├── prefer-to-have-been-called-once.ts
│ └── utils
│ ├── __tests__
│ └── parseJestFnCall.test.ts
│ ├── accessors.ts
│ ├── followTypeAssertionChain.ts
│ ├── index.ts
│ ├── misc.ts
│ └── parseJestFnCall.ts
├── tsconfig.json
└── yarn.lock
/.codecov.yml:
--------------------------------------------------------------------------------
1 | comment: off
2 |
3 | coverage:
4 | status:
5 | patch:
6 | default:
7 | target: 100%
8 | project:
9 | default:
10 | target: 100%
11 |
--------------------------------------------------------------------------------
/.eslint-doc-generatorrc.js:
--------------------------------------------------------------------------------
1 | const { format } = require('prettier');
2 | const { prettier: prettierRC } = require('./package.json');
3 |
4 | /** @type {import('eslint-doc-generator').GenerateOptions} */
5 | const config = {
6 | ignoreConfig: [
7 | 'all',
8 | 'flat/all',
9 | 'flat/recommended',
10 | 'flat/style',
11 | 'flat/snapshots',
12 | ],
13 | ruleDocTitleFormat: 'desc-parens-name',
14 | ruleDocSectionInclude: ['Rule details'],
15 | ruleListColumns: [
16 | 'name',
17 | 'description',
18 | 'configsError',
19 | 'configsWarn',
20 | 'configsOff',
21 | 'fixable',
22 | 'hasSuggestions',
23 | 'deprecated',
24 | ],
25 | urlConfigs: `https://github.com/jest-community/eslint-plugin-jest-extended/blob/main/README.md#shareable-configurations`,
26 | postprocess: doc => format(doc, { ...prettierRC, parser: 'markdown' }),
27 | };
28 |
29 | module.exports = config;
30 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | coverage/
2 | lib/
3 | !.eslintrc.js
4 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const {
4 | version: typescriptESLintPluginVersion,
5 | } = require('@typescript-eslint/eslint-plugin/package.json');
6 | const semver = require('semver');
7 |
8 | const typescriptBanTypesRules = () => {
9 | if (semver.major(typescriptESLintPluginVersion) === 8) {
10 | return {
11 | '@typescript-eslint/no-empty-object-type': 'error',
12 | '@typescript-eslint/no-unsafe-function-type': 'error',
13 | '@typescript-eslint/no-wrapper-object-types': 'error',
14 | };
15 | }
16 |
17 | return {
18 | '@typescript-eslint/ban-types': 'error',
19 | };
20 | };
21 |
22 | module.exports = {
23 | parser: require.resolve('@typescript-eslint/parser'),
24 | extends: [
25 | 'plugin:eslint-plugin/recommended',
26 | 'plugin:@eslint-community/eslint-comments/recommended',
27 | 'plugin:n/recommended',
28 | 'plugin:@typescript-eslint/eslint-recommended',
29 | 'plugin:prettier/recommended',
30 | ],
31 | plugins: [
32 | 'eslint-plugin',
33 | '@eslint-community/eslint-comments',
34 | 'n',
35 | 'import',
36 | '@typescript-eslint',
37 | ],
38 | parserOptions: {
39 | ecmaVersion: 2018,
40 | warnOnUnsupportedTypeScriptVersion: false,
41 | },
42 | env: {
43 | node: true,
44 | es6: true,
45 | },
46 | rules: {
47 | '@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
48 | '@typescript-eslint/no-require-imports': 'error',
49 | '@typescript-eslint/ban-ts-comment': 'error',
50 | ...typescriptBanTypesRules(),
51 | '@typescript-eslint/consistent-type-imports': [
52 | 'error',
53 | { disallowTypeAnnotations: false, fixStyle: 'inline-type-imports' },
54 | ],
55 | '@typescript-eslint/no-import-type-side-effects': 'error',
56 | '@typescript-eslint/no-unused-vars': 'error',
57 | '@eslint-community/eslint-comments/no-unused-disable': 'error',
58 | 'eslint-plugin/require-meta-docs-description': [
59 | 'error',
60 | { pattern: '^(Enforce|Require|Disallow|Suggest|Prefer)' },
61 | ],
62 | 'eslint-plugin/test-case-property-ordering': 'error',
63 | 'no-else-return': 'error',
64 | 'no-negated-condition': 'error',
65 | eqeqeq: ['error', 'smart'],
66 | strict: 'error',
67 | 'prefer-template': 'error',
68 | 'object-shorthand': [
69 | 'error',
70 | 'always',
71 | { avoidExplicitReturnArrows: true },
72 | ],
73 | 'prefer-destructuring': [
74 | 'error',
75 | { VariableDeclarator: { array: true, object: true } },
76 | ],
77 | 'sort-imports': ['error', { ignoreDeclarationSort: true }],
78 | 'require-unicode-regexp': 'error',
79 | // TS covers these 2
80 | 'n/no-missing-import': 'off',
81 | 'n/no-missing-require': 'off',
82 | 'n/no-unsupported-features/es-syntax': 'off',
83 | 'n/no-unsupported-features/es-builtins': 'error',
84 | 'import/no-commonjs': 'error',
85 | 'import/no-duplicates': 'error',
86 | 'import/no-extraneous-dependencies': 'error',
87 | 'import/no-unused-modules': 'error',
88 | 'import/order': [
89 | 'error',
90 | { alphabetize: { order: 'asc' }, 'newlines-between': 'never' },
91 | ],
92 | 'padding-line-between-statements': [
93 | 'error',
94 | { blankLine: 'always', prev: '*', next: 'return' },
95 | { blankLine: 'always', prev: ['const', 'let', 'var'], next: '*' },
96 | {
97 | blankLine: 'any',
98 | prev: ['const', 'let', 'var'],
99 | next: ['const', 'let', 'var'],
100 | },
101 | { blankLine: 'always', prev: 'directive', next: '*' },
102 | { blankLine: 'any', prev: 'directive', next: 'directive' },
103 | ],
104 |
105 | 'prefer-spread': 'error',
106 | 'prefer-rest-params': 'error',
107 | 'prefer-const': ['error', { destructuring: 'all' }],
108 | 'no-var': 'error',
109 | curly: 'error',
110 | },
111 | overrides: [
112 | {
113 | files: ['*.js'],
114 | rules: {
115 | '@typescript-eslint/no-require-imports': 'off',
116 | },
117 | },
118 | {
119 | files: ['src/**/*', 'dangerfile.ts', './jest.config.ts'],
120 | parserOptions: {
121 | sourceType: 'module',
122 | },
123 | },
124 | {
125 | files: ['tools/*'],
126 | rules: {
127 | 'n/shebang': 'off',
128 | },
129 | },
130 | {
131 | files: ['.eslintrc.js', 'babel.config.js'],
132 | rules: {
133 | '@typescript-eslint/no-require-imports': 'off',
134 | 'import/no-commonjs': 'off',
135 | },
136 | },
137 | ],
138 | };
139 |
--------------------------------------------------------------------------------
/.github/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["config:base"],
3 | "lockFileMaintenance": { "enabled": true, "automerge": true },
4 | "rangeStrategy": "replace",
5 | "postUpdateOptions": ["yarnDedupeHighest"]
6 | }
7 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Lint
2 | on: [pull_request]
3 |
4 | permissions:
5 | contents: read # to fetch code (actions/checkout)
6 |
7 | jobs:
8 | commitlint:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v4
12 | with:
13 | fetch-depth: 0
14 | persist-credentials: false
15 | - uses: wagoid/commitlint-github-action@v6
16 | with:
17 | configFile: './package.json'
18 | env:
19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
20 | danger:
21 | permissions:
22 | contents: read # to fetch code (actions/checkout)
23 | issues: write # to create comment
24 | pull-requests: write # to create comment
25 | runs-on: ubuntu-latest
26 | steps:
27 | - uses: actions/checkout@v4
28 | with:
29 | persist-credentials: false
30 | - name: Danger
31 | uses: danger/danger-js@12.3.4
32 | env:
33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
34 |
--------------------------------------------------------------------------------
/.github/workflows/markdown-link-check.yml:
--------------------------------------------------------------------------------
1 | name: Check Markdown links
2 |
3 | on:
4 | workflow_dispatch:
5 | schedule:
6 | - cron: '0 12 * * *'
7 | pull_request:
8 | paths:
9 | - '**/*.md'
10 | - 'markdown_link_check_config.json'
11 |
12 | permissions:
13 | contents: read
14 |
15 | jobs:
16 | prepare-yarn-cache-ubuntu:
17 | uses: ./.github/workflows/prepare-cache.yml
18 | with:
19 | os: ubuntu-latest
20 |
21 | markdown-link-check:
22 | needs: prepare-yarn-cache-ubuntu
23 | runs-on: ubuntu-latest
24 | steps:
25 | - uses: actions/checkout@v4
26 | with:
27 | persist-credentials: false
28 | - uses: actions/setup-node@v4
29 | with:
30 | node-version: lts/*
31 | cache: yarn
32 | - name: install
33 | run: yarn
34 | - name: Run markdown-link-check on MD files
35 | run:
36 | find . -name "*.md" | grep -v node_modules | grep -v CHANGELOG.md |
37 | xargs -n 1 yarn markdown-link-check -c markdown_link_check_config.json
38 |
--------------------------------------------------------------------------------
/.github/workflows/nodejs.yml:
--------------------------------------------------------------------------------
1 | name: Unit tests & Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | - next
8 | pull_request:
9 | branches:
10 | - main
11 | - next
12 |
13 | concurrency:
14 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
15 | cancel-in-progress: true
16 |
17 | permissions:
18 | contents: read # to fetch code (actions/checkout)
19 |
20 | jobs:
21 | prepare-yarn-cache-ubuntu:
22 | uses: ./.github/workflows/prepare-cache.yml
23 | with:
24 | os: ubuntu-latest
25 | prepare-yarn-cache-macos:
26 | uses: ./.github/workflows/prepare-cache.yml
27 | with:
28 | os: macos-latest
29 | prepare-yarn-cache-windows:
30 | uses: ./.github/workflows/prepare-cache.yml
31 | with:
32 | os: windows-latest
33 |
34 | prettier:
35 | needs: prepare-yarn-cache-ubuntu
36 | runs-on: ubuntu-latest
37 | steps:
38 | - uses: actions/checkout@v4
39 | with:
40 | persist-credentials: false
41 | - uses: actions/setup-node@v4
42 | with:
43 | node-version: lts/*
44 | cache: yarn
45 | - name: install
46 | run: yarn
47 | - name: run prettier
48 | run: yarn prettier:check
49 |
50 | typecheck:
51 | needs: prepare-yarn-cache-ubuntu
52 | runs-on: ubuntu-latest
53 | steps:
54 | - uses: actions/checkout@v4
55 | with:
56 | persist-credentials: false
57 | - uses: actions/setup-node@v4
58 | with:
59 | node-version: lts/*
60 | cache: yarn
61 | - name: install
62 | run: yarn
63 | - name: run typecheck
64 | run: yarn typecheck
65 |
66 | test-node:
67 | name:
68 | # prettier-ignore
69 | Test on Node.js v${{ matrix.node-version }}, eslint v${{ matrix.eslint-version }}, and ts-eslint v${{ matrix.ts-eslint-plugin-version }}
70 | needs: prepare-yarn-cache-ubuntu
71 | strategy:
72 | fail-fast: false
73 | matrix:
74 | node-version: [16.x, 18.x, 19.x, 20.x, 21.x]
75 | eslint-version: [7, 8, 9]
76 | ts-eslint-plugin-version: [6, 7, 8]
77 | exclude:
78 | # ts-eslint/plugin@7 doesn't support node@16
79 | - node-version: 16.x
80 | ts-eslint-plugin-version: 7
81 | # ts-eslint/plugin@8 doesn't support node@16
82 | - node-version: 16.x
83 | ts-eslint-plugin-version: 8
84 | # eslint@9 doesn't support node@16
85 | - node-version: 16.x
86 | eslint-version: 9
87 | # ts-eslint/plugin@7 doesn't support eslint@7
88 | - eslint-version: 7
89 | ts-eslint-plugin-version: 7
90 | # ts-eslint/plugin@8 doesn't support eslint@7
91 | - eslint-version: 7
92 | ts-eslint-plugin-version: 8
93 | runs-on: ubuntu-latest
94 |
95 | steps:
96 | - uses: actions/checkout@v4
97 | with:
98 | persist-credentials: false
99 | - name: Use Node.js ${{ matrix.node-version }}
100 | uses: actions/setup-node@v4
101 | with:
102 | node-version: ${{ matrix.node-version }}
103 | cache: yarn
104 | - name:
105 | # prettier-ignore
106 | install with eslint v${{ matrix.eslint-version }}
107 | run: |
108 | yarn
109 | yarn add @typescript-eslint/utils@${{ matrix.ts-eslint-plugin-version }}
110 | # prettier-ignore
111 | yarn add --dev eslint@${{ matrix.eslint-version }} @typescript-eslint/eslint-plugin@${{ matrix.ts-eslint-plugin-version }} @typescript-eslint/parser@${{ matrix.ts-eslint-plugin-version }}
112 | - name: run tests
113 | # only collect coverage on eslint versions that support dynamic import
114 | run: yarn test --coverage ${{ matrix.eslint-version == 8 }}
115 | env:
116 | CI: true
117 | - uses: codecov/codecov-action@v3
118 | if: ${{ matrix.eslint-version == 8 }}
119 | test-ubuntu:
120 | uses: ./.github/workflows/test.yml
121 | needs: prepare-yarn-cache-ubuntu
122 | with:
123 | os: ubuntu-latest
124 | test-macos:
125 | uses: ./.github/workflows/test.yml
126 | needs: prepare-yarn-cache-macos
127 | with:
128 | os: macos-latest
129 | test-windows:
130 | uses: ./.github/workflows/test.yml
131 | needs: prepare-yarn-cache-windows
132 | with:
133 | os: windows-latest
134 |
135 | docs:
136 | if: ${{ github.event_name == 'pull_request' }}
137 | needs: prepare-yarn-cache-ubuntu
138 | runs-on: ubuntu-latest
139 | steps:
140 | - uses: actions/checkout@v4
141 | - uses: actions/setup-node@v4
142 | with:
143 | node-version: lts/*
144 | cache: yarn
145 | - name: install
146 | run: yarn
147 | - name: regenerate docs
148 | run: yarn tools:regenerate-docs
149 | - name: report regenerated docs
150 | run: |
151 | git diff --name-only \
152 | | xargs -I '{}' bash -c \
153 | 'echo "::error file={}::This needs to be regenerated by running \`tools:regenerate-docs\`" && false'
154 |
155 | release:
156 | permissions:
157 | contents: write # for semantic-release
158 | issues: write # to create comment
159 | pull-requests: write # to create comment
160 |
161 | if:
162 | # prettier-ignore
163 | ${{ github.event_name == 'push' && (github.event.ref == 'refs/heads/main' || github.event.ref == 'refs/heads/next') }}
164 | name: Release new version
165 | needs:
166 | [prettier, typecheck, test-node, test-ubuntu, test-macos, test-windows]
167 | runs-on: ubuntu-latest
168 | steps:
169 | - uses: actions/checkout@v4
170 | with:
171 | persist-credentials: false
172 | - uses: actions/setup-node@v4
173 | with:
174 | node-version: lts/*
175 | cache: yarn
176 | - name: install
177 | run: yarn
178 | - run: yarn semantic-release
179 | env:
180 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
181 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
182 |
--------------------------------------------------------------------------------
/.github/workflows/prepare-cache.yml:
--------------------------------------------------------------------------------
1 | name: Prepare CI cache
2 |
3 | on:
4 | workflow_call:
5 | inputs:
6 | os:
7 | required: true
8 | type: string
9 |
10 | jobs:
11 | prepare-yarn-cache:
12 | name: Prepare yarn cache for ${{ inputs.os }}
13 | runs-on: ${{ inputs.os }}
14 |
15 | steps:
16 | - uses: actions/checkout@v4
17 | with:
18 | persist-credentials: false
19 |
20 | - uses: actions/setup-node@v4
21 | with:
22 | node-version: lts/*
23 | cache: yarn
24 |
25 | - name: Validate cache
26 | env:
27 | # Use PnP and disable postinstall scripts as this just needs to
28 | # populate the cache for the other jobs
29 | YARN_NODE_LINKER: pnp
30 | YARN_ENABLE_SCRIPTS: false
31 | run: yarn --immutable
32 |
--------------------------------------------------------------------------------
/.github/workflows/smoke-test.yml:
--------------------------------------------------------------------------------
1 | name: Smoke test
2 |
3 | on:
4 | schedule:
5 | - cron: '0 0 * * SUN'
6 | workflow_dispatch:
7 |
8 | permissions:
9 | contents: read # to fetch code (actions/checkout)
10 |
11 | jobs:
12 | test:
13 | permissions:
14 | contents: read # to fetch code (actions/checkout)
15 | issues: write # to create comment
16 | pull-requests: read # for searching pull requests
17 |
18 | runs-on: ubuntu-latest
19 | steps:
20 | - uses: actions/checkout@v4
21 | with:
22 | persist-credentials: false
23 | - uses: actions/setup-node@v4
24 | with:
25 | node-version: 16
26 | - run: |
27 | npm install --legacy-peer-deps
28 | npm run build
29 | npm link
30 | npm link eslint-plugin-jest-extended
31 | - uses: AriPerkkio/eslint-remote-tester-run-action@v4
32 | with:
33 | issue-title: 'Results of weekly scheduled smoke test'
34 | eslint-remote-tester-config: eslint-remote-tester.config.ts
35 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | workflow_call:
5 | inputs:
6 | os:
7 | required: true
8 | type: string
9 |
10 | jobs:
11 | test:
12 | name: Test on ${{ inputs.os }} using Node.js LTS
13 | runs-on: ${{ inputs.os }}
14 |
15 | steps:
16 | - uses: actions/checkout@v4
17 | with:
18 | persist-credentials: false
19 | - uses: actions/setup-node@v4
20 | with:
21 | node-version: lts/*
22 | cache: yarn
23 | - name: install
24 | run: yarn
25 | - name: run tests
26 | run: yarn test
27 | env:
28 | CI: true
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | node_modules/
3 | coverage/
4 | lib/
5 | *.log
6 | .yarn/*
7 | !.yarn/releases
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/sdks
11 | !.yarn/versions
12 | .pnp.*
13 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | yarn commitlint --edit $1
4 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | yarn lint-staged
4 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | **/__tests__/**
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .yarn/*
2 | lib/*
3 | coverage/*
4 | CHANGELOG.md
5 |
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | enableGlobalCache: true
2 |
3 | nodeLinker: node-modules
4 |
5 | plugins:
6 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
7 | spec: '@yarnpkg/plugin-interactive-tools'
8 |
9 | yarnPath: .yarn/releases/yarn-3.8.7.cjs
10 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # [3.0.0](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v2.4.0...v3.0.0) (2025-01-17)
2 |
3 |
4 | ### Features
5 |
6 | * adjust Node constraints to match `eslint-plugin-jest` ([#235](https://github.com/jest-community/eslint-plugin-jest-extended/issues/235)) ([6d2414a](https://github.com/jest-community/eslint-plugin-jest-extended/commit/6d2414a5bbb16988e7b3e0e88eca66ae0dab76c9))
7 | * drop support for Node v14 ([#232](https://github.com/jest-community/eslint-plugin-jest-extended/issues/232)) ([fef65e0](https://github.com/jest-community/eslint-plugin-jest-extended/commit/fef65e0fb61adca91598bbf4f3018273fb7b752e))
8 | * support `@typescript-eslint/utils` v7 & v8 ([#241](https://github.com/jest-community/eslint-plugin-jest-extended/issues/241)) ([40766b0](https://github.com/jest-community/eslint-plugin-jest-extended/commit/40766b019eb70271837e5eeb45d44dd21a999ca6))
9 | * upgrade `@typescript-eslint/utils` to v6 ([#238](https://github.com/jest-community/eslint-plugin-jest-extended/issues/238)) ([657bc42](https://github.com/jest-community/eslint-plugin-jest-extended/commit/657bc428599e4b5794603c9b308dedb5029bf2c2))
10 |
11 |
12 | ### BREAKING CHANGES
13 |
14 | * Versions of Node v18 up to 18.11.x are no longer supported
15 | * drop support for Node v14
16 |
17 | # [3.0.0-next.4](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v3.0.0-next.3...v3.0.0-next.4) (2025-01-17)
18 |
19 |
20 | ### Features
21 |
22 | * support `@typescript-eslint/utils` v7 & v8 ([#241](https://github.com/jest-community/eslint-plugin-jest-extended/issues/241)) ([414dc8f](https://github.com/jest-community/eslint-plugin-jest-extended/commit/414dc8f778d761c6f0f3eb3b621406cbe74b1afa))
23 |
24 | # [3.0.0-next.3](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v3.0.0-next.2...v3.0.0-next.3) (2025-01-16)
25 |
26 |
27 | ### Features
28 |
29 | * upgrade `@typescript-eslint/utils` to v6 ([#238](https://github.com/jest-community/eslint-plugin-jest-extended/issues/238)) ([3815685](https://github.com/jest-community/eslint-plugin-jest-extended/commit/381568546db6b94fc2a92386556b737f1551e262))
30 |
31 | # [3.0.0-next.2](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v3.0.0-next.1...v3.0.0-next.2) (2025-01-16)
32 |
33 |
34 | ### Features
35 |
36 | * adjust Node constraints to match `eslint-plugin-jest` ([#235](https://github.com/jest-community/eslint-plugin-jest-extended/issues/235)) ([c9fb39c](https://github.com/jest-community/eslint-plugin-jest-extended/commit/c9fb39caa43e074e64fb9fdb4dbcbdb2822fc57a))
37 |
38 |
39 | ### BREAKING CHANGES
40 |
41 | * Versions of Node v18 up to 18.11.x are no longer supported
42 |
43 | # [3.0.0-next.1](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v2.4.0...v3.0.0-next.1) (2025-01-16)
44 |
45 |
46 | ### Features
47 |
48 | * drop support for Node v14 ([#232](https://github.com/jest-community/eslint-plugin-jest-extended/issues/232)) ([a3e953f](https://github.com/jest-community/eslint-plugin-jest-extended/commit/a3e953f06572f03777148f903f3e6486034468eb))
49 |
50 |
51 | ### BREAKING CHANGES
52 |
53 | * drop support for Node v14
54 |
55 | # [2.4.0](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v2.3.0...v2.4.0) (2024-04-20)
56 |
57 |
58 | ### Features
59 |
60 | * support ESLint v9 ([#185](https://github.com/jest-community/eslint-plugin-jest-extended/issues/185)) ([42d36b1](https://github.com/jest-community/eslint-plugin-jest-extended/commit/42d36b198d0c6b489636843475e8ebd9ea4e837d))
61 |
62 | # [2.3.0](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v2.2.0...v2.3.0) (2024-04-20)
63 |
64 |
65 | ### Features
66 |
67 | * add should-be-fine support for flat configs ([#181](https://github.com/jest-community/eslint-plugin-jest-extended/issues/181)) ([7d106b0](https://github.com/jest-community/eslint-plugin-jest-extended/commit/7d106b0fc8eb99946ba760bd9f4feccc8fb6e18e))
68 |
69 | # [2.2.0](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v2.1.0...v2.2.0) (2024-04-19)
70 |
71 |
72 | ### Features
73 |
74 | * support providing aliases for `@jest/globals` package ([#180](https://github.com/jest-community/eslint-plugin-jest-extended/issues/180)) ([d070ca7](https://github.com/jest-community/eslint-plugin-jest-extended/commit/d070ca79caf41e0974c8b048a741f0db8104e4d1))
75 |
76 | # [2.1.0](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v2.0.3...v2.1.0) (2024-04-19)
77 |
78 |
79 | ### Features
80 |
81 | * support `failing.each` ([#179](https://github.com/jest-community/eslint-plugin-jest-extended/issues/179)) ([b2adda4](https://github.com/jest-community/eslint-plugin-jest-extended/commit/b2adda4cacf1616ce18bed4d655a8a5b533c6664))
82 |
83 | ## [2.0.3](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v2.0.2...v2.0.3) (2024-04-19)
84 |
85 |
86 | ### Bug Fixes
87 |
88 | * replace use of deprecated methods ([#178](https://github.com/jest-community/eslint-plugin-jest-extended/issues/178)) ([31c01ea](https://github.com/jest-community/eslint-plugin-jest-extended/commit/31c01ea02f4b8dbdf7e103efbde5aa9bd03fbfb2))
89 |
90 | ## [2.0.2](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v2.0.1...v2.0.2) (2024-04-19)
91 |
92 |
93 | ### Performance Improvements
94 |
95 | * use `Set` instead of iterating, and deduplicate a function ([#175](https://github.com/jest-community/eslint-plugin-jest-extended/issues/175)) ([d0652cd](https://github.com/jest-community/eslint-plugin-jest-extended/commit/d0652cdb82b692cdee0f2ef4616b96e89c6d4ddf))
96 |
97 | ## [2.0.1](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v2.0.0...v2.0.1) (2024-04-19)
98 |
99 |
100 | ### Performance Improvements
101 |
102 | * don't collect more info than needed when resolving jest functions ([#172](https://github.com/jest-community/eslint-plugin-jest-extended/issues/172)) ([08e130c](https://github.com/jest-community/eslint-plugin-jest-extended/commit/08e130c79df9e81e6b4c9c0e0f8b52885ee20ada))
103 |
104 | # [2.0.0](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v1.2.0...v2.0.0) (2022-08-25)
105 |
106 |
107 | ### Features
108 |
109 | * drop support for `eslint@6` ([#26](https://github.com/jest-community/eslint-plugin-jest-extended/issues/26)) ([d3d40f3](https://github.com/jest-community/eslint-plugin-jest-extended/commit/d3d40f30266b00bf7182adcd90f52f3ccd1859ba))
110 | * drop support for Node versions 12 and 17 ([#25](https://github.com/jest-community/eslint-plugin-jest-extended/issues/25)) ([14c90ed](https://github.com/jest-community/eslint-plugin-jest-extended/commit/14c90edffc359db59b1492fa9a94e361b6959f28))
111 |
112 |
113 | ### BREAKING CHANGES
114 |
115 | * Support for ESLint version 6 is removed
116 | * Node versions 12 and 17 are no longer supported
117 |
118 | # [1.2.0](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v1.1.0...v1.2.0) (2022-08-20)
119 |
120 |
121 | ### Features
122 |
123 | * switch to new scope-based jest fn call parser to support `@jest/globals` ([#20](https://github.com/jest-community/eslint-plugin-jest-extended/issues/20)) ([35ddfed](https://github.com/jest-community/eslint-plugin-jest-extended/commit/35ddfedd58de975ca2c235a5295dfa28aab11ac5))
124 |
125 | # [1.1.0](https://github.com/jest-community/eslint-plugin-jest-extended/compare/v1.0.0...v1.1.0) (2022-08-20)
126 |
127 |
128 | ### Features
129 |
130 | * create `prefer-to-have-been-called-once` rule ([#19](https://github.com/jest-community/eslint-plugin-jest-extended/issues/19)) ([12e6372](https://github.com/jest-community/eslint-plugin-jest-extended/commit/12e6372ec54df6d768254bd528a970163a9fbc63))
131 |
132 | # 1.0.0 (2022-08-15)
133 |
134 |
135 | ### Features
136 |
137 | * bunch of updates ([#5](https://github.com/jest-community/eslint-plugin-jest-extended/issues/5)) ([8e45c68](https://github.com/jest-community/eslint-plugin-jest-extended/commit/8e45c682b7c287f2180b03c4e903288a69c32711))
138 | * create `prefer-to-be-array` rule ([9bd067c](https://github.com/jest-community/eslint-plugin-jest-extended/commit/9bd067ccc37d7651f812782bde785868c9cadfb4))
139 | * create `prefer-to-be-false` rule ([b35e45c](https://github.com/jest-community/eslint-plugin-jest-extended/commit/b35e45c23cb2aa660034ac49b5082c61f34de758))
140 | * create `prefer-to-be-object` rule ([676de1d](https://github.com/jest-community/eslint-plugin-jest-extended/commit/676de1d77ba19430d96f5df93ce8f3a548c6acfe))
141 | * create `prefer-to-be-true` rule ([22f8093](https://github.com/jest-community/eslint-plugin-jest-extended/commit/22f8093136c30212498b8a347891f1cff995003b))
142 | * initial commit ([3ed88c4](https://github.com/jest-community/eslint-plugin-jest-extended/commit/3ed88c4699d2ef82780183bae00560282a3f1916))
143 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Jonathan Kim
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
11 |
12 | [](https://github.com/jest-community/eslint-plugin-jest-extended/actions)
13 |
14 | ## Installation
15 |
16 | ```
17 | $ yarn add --dev eslint eslint-plugin-jest-extended
18 | ```
19 |
20 | **Note:** If you installed ESLint globally then you must also install
21 | `eslint-plugin-jest-extended` globally.
22 |
23 | ## Usage
24 |
25 | > [!NOTE]
26 | >
27 | > `eslint.config.js` is supported, though most of the plugin documentation still
28 | > currently uses `.eslintrc` syntax.
29 | >
30 | > Refer to the
31 | > [ESLint documentation on the new configuration file format](https://eslint.org/docs/latest/use/configure/configuration-files-new)
32 | > for more.
33 |
34 | Add `jest-extended` to the plugins section of your `.eslintrc` configuration
35 | file. You can omit the `eslint-plugin-` prefix:
36 |
37 | ```json
38 | {
39 | "plugins": ["jest-extended"]
40 | }
41 | ```
42 |
43 | Then configure the rules you want to use under the rules section.
44 |
45 | ```json
46 | {
47 | "rules": {
48 | "jest-extended/prefer-to-be-true": "warn",
49 | "jest-extended/prefer-to-be-false": "error"
50 | }
51 | }
52 | ```
53 |
54 | ## Shareable configurations
55 |
56 | ### Recommended
57 |
58 | This plugin does not export a recommended configuration, as the rules provided
59 | by this plugin are about enforcing usage of preferred matchers for particular
60 | patterns, rather than helping to prevent bugs & commonly overlooked traps.
61 |
62 | ### All
63 |
64 | If you want to enable all rules instead of only some you can do so by adding the
65 | `all` configuration to your `.eslintrc` config file:
66 |
67 | ```json
68 | {
69 | "extends": ["plugin:jest-extended/all"]
70 | }
71 | ```
72 |
73 | To enable this configuration with `eslint.config.js`, use
74 | `jestExtended.configs['flat/all']`:
75 |
76 | ```js
77 | const jestExtended = require('eslint-plugin-jest-extended');
78 |
79 | module.exports = [
80 | {
81 | files: [
82 | /* glob matching your test files */
83 | ],
84 | ...jestExtended.configs['flat/all'],
85 | },
86 | ];
87 | ```
88 |
89 | Note that the `all` configuration may change in any release and is thus unsuited
90 | for installations requiring long-term consistency.
91 |
92 | ## Rules
93 |
94 |
95 |
96 | 🔧 Automatically fixable by the
97 | [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).
98 |
99 | | Name | Description | 🔧 |
100 | | :------------------------------------------------------------------------------- | :------------------------------------- | :-- |
101 | | [prefer-to-be-array](docs/rules/prefer-to-be-array.md) | Suggest using `toBeArray()` | 🔧 |
102 | | [prefer-to-be-false](docs/rules/prefer-to-be-false.md) | Suggest using `toBeFalse()` | 🔧 |
103 | | [prefer-to-be-object](docs/rules/prefer-to-be-object.md) | Suggest using `toBeObject()` | 🔧 |
104 | | [prefer-to-be-true](docs/rules/prefer-to-be-true.md) | Suggest using `toBeTrue()` | 🔧 |
105 | | [prefer-to-have-been-called-once](docs/rules/prefer-to-have-been-called-once.md) | Suggest using `toHaveBeenCalledOnce()` | 🔧 |
106 |
107 |
108 |
109 | ## Credit
110 |
111 | - [eslint-plugin-jest](https://github.com/jest-community/eslint-plugin-jest)
112 |
113 | ## Related Projects
114 |
115 | ### eslint-plugin-jest
116 |
117 | This project aims to provide linting rules to aid in writing tests using jest.
118 |
119 | https://github.com/jest-community/eslint-plugin-jest
120 |
121 | ### eslint-plugin-jest-formatting
122 |
123 | This project aims to provide formatting rules (auto-fixable where possible) to
124 | ensure consistency and readability in jest test suites.
125 |
126 | https://github.com/dangreenisrael/eslint-plugin-jest-formatting
127 |
128 | [fixable]: https://img.shields.io/badge/-fixable-green.svg
129 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const semver = require('semver');
4 | const pkg = require('./package.json');
5 |
6 | const supportedNodeVersion = semver.minVersion(pkg.engines.node).version;
7 |
8 | // todo: https://github.com/babel/babel/issues/8529 :'(
9 | module.exports = {
10 | plugins: ['replace-ts-export-assignment'],
11 | presets: [
12 | '@babel/preset-typescript',
13 | ['@babel/preset-env', { targets: { node: supportedNodeVersion } }],
14 | ],
15 | };
16 |
--------------------------------------------------------------------------------
/dangerfile.ts:
--------------------------------------------------------------------------------
1 | import { parse } from 'path';
2 | import { danger, warn } from 'danger';
3 |
4 | // Ensure that people include a description on their PRs
5 | if (danger.github.pr.body.length === 0) {
6 | fail('Please include a body for your PR');
7 | }
8 |
9 | const createOrAddLabelSafely = async (name: string, color: string): boolean => {
10 | try {
11 | await danger.github.utils.createOrAddLabel({
12 | name,
13 | color: color.replace('#', ''),
14 | description: '',
15 | });
16 |
17 | return true;
18 | } catch (error) {
19 | console.warn(error);
20 | warn(`Was unable to create or add label "${name}"`);
21 |
22 | return false;
23 | }
24 | };
25 |
26 | const labelBasedOnRules = async () => {
27 | const affectedRules = [
28 | ...danger.git.created_files,
29 | ...danger.git.modified_files,
30 | ...danger.git.deleted_files,
31 | ]
32 | .filter(filename => {
33 | const { dir, ext } = parse(filename);
34 |
35 | return dir === 'src/rules' && ext === '.ts';
36 | })
37 | .map(filename => parse(filename).name);
38 |
39 | await Promise.all(
40 | affectedRules.map(rule =>
41 | createOrAddLabelSafely(`rule: ${rule}`, '#7d3abc'),
42 | ),
43 | );
44 | };
45 |
46 | const labelBasedOnTitle = async (): Promise => {
47 | if (danger.github.pr.title.startsWith('feat')) {
48 | return createOrAddLabelSafely('enhancement', '#84b6eb');
49 | }
50 |
51 | if (danger.github.pr.title.startsWith('fix')) {
52 | return createOrAddLabelSafely('bug', '#ee0701');
53 | }
54 |
55 | return false;
56 | };
57 |
58 | const labelBasedOnCommits = async () => {
59 | const commits = danger.github.commits.map(commits => commits.commit.message);
60 |
61 | if (commits.some(commit => commit.startsWith('fix'))) {
62 | await createOrAddLabelSafely('bug', '#ee0701');
63 | }
64 |
65 | if (commits.some(commit => commit.startsWith('feat'))) {
66 | await createOrAddLabelSafely('enhancement', '#84b6eb');
67 | }
68 | };
69 |
70 | const labelBasedOnTitleOrCommits = async () => {
71 | // prioritize labeling based on the title since most pull requests will get
72 | // squashed into a single commit with the title as the subject, but fallback
73 | // to looking through the commits if we can't determine a label from the title
74 | if (await labelBasedOnTitle()) {
75 | return;
76 | }
77 |
78 | await labelBasedOnCommits();
79 | };
80 |
81 | Promise.all([labelBasedOnRules(), labelBasedOnTitleOrCommits()]).catch(
82 | error => {
83 | console.error(error);
84 | fail(`Something went very wrong: ${error}`);
85 | },
86 | );
87 |
--------------------------------------------------------------------------------
/docs/rules/prefer-to-be-array.md:
--------------------------------------------------------------------------------
1 | # Suggest using `toBeArray()` (`prefer-to-be-array`)
2 |
3 | 🔧 This rule is automatically fixable by the
4 | [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
5 |
6 |
7 |
8 | For expecting a value to be an array, `jest-extended` provides the `toBeArray`
9 | matcher.
10 |
11 | ## Rule details
12 |
13 | This rule triggers a warning if an `expect` assertion is found asserting that a
14 | value is an array using one of the following methods:
15 |
16 | - Comparing the result of `Array.isArary()` to a boolean value,
17 | - Comparing the result of ` instanceof Array` to a boolean value,
18 | - Calling the `toBeInstanceOf` matcher with the `Array` class.
19 |
20 | The following patterns are considered warnings:
21 |
22 | ```js
23 | expect(Array.isArray([])).toBe(true);
24 |
25 | expect(Array.isArray(myValue)).toStrictEqual(false);
26 |
27 | expect(Array.isArray(theResults())).not.toBeFalse();
28 |
29 | expect([] instanceof Array).toBe(true);
30 |
31 | expect(myValue instanceof Array).toStrictEqual(false);
32 |
33 | expect(theResults() instanceof Array).not.toBeFalse();
34 |
35 | expect([]).toBeInstanceOf(true);
36 |
37 | expect(myValue).resolves.toBeInstanceOf(Array);
38 |
39 | expect(theResults()).not.toBeInstanceOf(Array);
40 | ```
41 |
42 | The following patterns are _not_ considered warnings:
43 |
44 | ```js
45 | expect(Array.isArray([])).toBeArray();
46 |
47 | expect(Array.isArray(myValue)).not.toBeArray();
48 |
49 | expect(myValue).resolves.toBeArray();
50 |
51 | expect(Array.isArray(theResults())).toBeArray();
52 | ```
53 |
54 | ## Further Reading
55 |
56 | - [`jest-extended#toBeArray` matcher](https://github.com/jest-community/jest-extended#tobearray)
57 |
--------------------------------------------------------------------------------
/docs/rules/prefer-to-be-false.md:
--------------------------------------------------------------------------------
1 | # Suggest using `toBeFalse()` (`prefer-to-be-false`)
2 |
3 | 🔧 This rule is automatically fixable by the
4 | [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
5 |
6 |
7 |
8 | For expecting a value to be `false`, `jest-extended` provides the `toBeFalse`
9 | matcher.
10 |
11 | ## Rule details
12 |
13 | This rule triggers a warning if `toBe()`, `toEqual()` or `toStrictEqual()` is
14 | used to assert against the `false` literal.
15 |
16 | The following patterns are considered warnings:
17 |
18 | ```js
19 | expect(true).toBe(false);
20 |
21 | expect(wasSuccessful).toEqual(false);
22 |
23 | expect(fs.existsSync('/path/to/file')).toStrictEqual(false);
24 | ```
25 |
26 | The following patterns are _not_ considered warnings:
27 |
28 | ```js
29 | expect(true).toBeFalse();
30 |
31 | expect(wasSuccessful).toBeFalse();
32 |
33 | expect(fs.existsSync('/path/to/file')).toBeFalse();
34 |
35 | test('returns false', () => {
36 | expect(areWeThereYet()).toBeFalse();
37 | expect(true).not.toBeFalse();
38 | });
39 | ```
40 |
41 | ## Further Reading
42 |
43 | - [`jest-extended#toBeFalse` matcher](https://github.com/jest-community/jest-extended#tobefalse)
44 |
--------------------------------------------------------------------------------
/docs/rules/prefer-to-be-object.md:
--------------------------------------------------------------------------------
1 | # Suggest using `toBeObject()` (`prefer-to-be-object`)
2 |
3 | 🔧 This rule is automatically fixable by the
4 | [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
5 |
6 |
7 |
8 | For expecting a value to be an object, `jest-extended` provides the `toBeObject`
9 | matcher.
10 |
11 | ## Rule details
12 |
13 | This rule triggers a warning if an `expect` assertion is found asserting that a
14 | value is an object using one of the following methods:
15 |
16 | - Comparing the result of ` instanceof Object` to a boolean value,
17 | - Calling the `toBeInstanceOf` matcher with the `Object` class.
18 |
19 | The following patterns are considered warnings:
20 |
21 | ```js
22 | expect([] instanceof Object).toBe(true);
23 |
24 | expect(myValue instanceof Object).toStrictEqual(false);
25 |
26 | expect(theResults() instanceof Object).not.toBeFalse();
27 |
28 | expect([]).toBeInstanceOf(true);
29 |
30 | expect(myValue).resolves.toBeInstanceOf(Object);
31 |
32 | expect(theResults()).not.toBeInstanceOf(Object);
33 | ```
34 |
35 | The following patterns are _not_ considered warnings:
36 |
37 | ```js
38 | expect({}).toBeObject();
39 |
40 | expect(myValue).not.toBeObject();
41 |
42 | expect(queryApi()).resolves.toBeObject();
43 |
44 | expect(theResults()).toBeObject();
45 | ```
46 |
47 | ## Further Reading
48 |
49 | - [`jest-extended#toBeObject` matcher](https://github.com/jest-community/jest-extended#tobeobject)
50 |
--------------------------------------------------------------------------------
/docs/rules/prefer-to-be-true.md:
--------------------------------------------------------------------------------
1 | # Suggest using `toBeTrue()` (`prefer-to-be-true`)
2 |
3 | 🔧 This rule is automatically fixable by the
4 | [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
5 |
6 |
7 |
8 | For expecting a value to be `true`, `jest-extended` provides the `toBeTrue`
9 | matcher.
10 |
11 | ## Rule details
12 |
13 | This rule triggers a warning if `toBe()`, `toEqual()` or `toStrictEqual()` is
14 | used to assert against the `true` literal.
15 |
16 | The following patterns are considered warnings:
17 |
18 | ```js
19 | expect(false).toBe(true);
20 |
21 | expect(wasSuccessful).toEqual(true);
22 |
23 | expect(fs.existsSync('/path/to/file')).toStrictEqual(true);
24 | ```
25 |
26 | The following patterns are _not_ considered warnings:
27 |
28 | ```js
29 | expect(false).toBeTrue();
30 |
31 | expect(wasSuccessful).toBeTrue();
32 |
33 | expect(fs.existsSync('/path/to/file')).toBeTrue();
34 |
35 | test('is jest cool', () => {
36 | expect(isJestCool()).toBeTrue();
37 | expect(false).not.toBeTrue();
38 | });
39 | ```
40 |
41 | ## Further Reading
42 |
43 | - [`jest-extended#toBeTrue` matcher](https://github.com/jest-community/jest-extended#tobetrue)
44 |
--------------------------------------------------------------------------------
/docs/rules/prefer-to-have-been-called-once.md:
--------------------------------------------------------------------------------
1 | # Suggest using `toHaveBeenCalledOnce()` (`prefer-to-have-been-called-once`)
2 |
3 | 🔧 This rule is automatically fixable by the
4 | [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
5 |
6 |
7 |
8 | For expecting a mock or spy to have been called once, `jest-extended` provides
9 | the `toHaveBeenCalledOnce` matcher.
10 |
11 | ## Rule details
12 |
13 | This rule triggers a warning if an `expect` assertion is found asserting that a
14 | mock or spy is called once using `toHaveBeenCalledTimes(1)`.
15 |
16 | The following patterns are considered warnings:
17 |
18 | ```js
19 | expect(myMock).toHaveBeenCalledTimes(1);
20 | expect(mySpy).not.toHaveBeenCalledTimes(1);
21 | ```
22 |
23 | The following patterns are _not_ considered warnings:
24 |
25 | ```js
26 | expect(myMock).toHaveBeenCalledOnce();
27 | expect(mySpy).not.toHaveBeenCalledOnce();
28 | ```
29 |
30 | ## Further Reading
31 |
32 | - [`jest-extended#toHaveBeenCalledOnce` matcher](https://github.com/jest-community/jest-extended#tohavebeencalledonce)
33 |
--------------------------------------------------------------------------------
/eslint-remote-tester.config.ts:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import type { Config } from 'eslint-remote-tester';
4 | import {
5 | getPathIgnorePattern,
6 | getRepositories,
7 | } from 'eslint-remote-tester-repositories';
8 |
9 | const config: Config = {
10 | repositories: getRepositories({ randomize: true }),
11 | pathIgnorePattern: getPathIgnorePattern(),
12 | extensions: ['js', 'jsx', 'ts', 'tsx'],
13 | concurrentTasks: 3,
14 | cache: false,
15 | logLevel: 'info',
16 | eslintrc: {
17 | root: true,
18 | env: {
19 | es6: true,
20 | },
21 | parser: '@typescript-eslint/parser',
22 | parserOptions: {
23 | ecmaVersion: 2020,
24 | sourceType: 'module',
25 | ecmaFeatures: {
26 | jsx: true,
27 | },
28 | },
29 | extends: ['plugin:jest-extended/all'],
30 | rules: {},
31 | },
32 | };
33 |
34 | export default config;
35 |
--------------------------------------------------------------------------------
/jest.config.ts:
--------------------------------------------------------------------------------
1 | import { version as eslintVersion } from 'eslint/package.json';
2 | import type { Config } from 'jest';
3 | import * as semver from 'semver';
4 |
5 | const config = {
6 | clearMocks: true,
7 | restoreMocks: true,
8 | resetMocks: true,
9 |
10 | coverageThreshold: {
11 | global: {
12 | branches: 100,
13 | functions: 100,
14 | lines: 100,
15 | statements: 100,
16 | },
17 | },
18 |
19 | projects: [
20 | {
21 | displayName: 'test',
22 | testPathIgnorePatterns: [
23 | '/lib/.*',
24 | '/src/rules/__tests__/test-utils.ts',
25 | ],
26 | coveragePathIgnorePatterns: ['/node_modules/'],
27 | },
28 | {
29 | displayName: 'lint',
30 | runner: 'jest-runner-eslint',
31 | testMatch: ['/**/*.{js,ts}'],
32 | testPathIgnorePatterns: ['/lib/.*'],
33 | coveragePathIgnorePatterns: ['/node_modules/'],
34 | },
35 | ],
36 | } satisfies Config;
37 |
38 | if (semver.major(eslintVersion) >= 9) {
39 | config.projects = config.projects.filter(
40 | ({ displayName }) => displayName !== 'lint',
41 | );
42 | }
43 |
44 | export default config;
45 |
--------------------------------------------------------------------------------
/markdown_link_check_config.json:
--------------------------------------------------------------------------------
1 | {
2 | "httpHeaders": [
3 | {
4 | "urls": ["https://docs.github.com"],
5 | "headers": {
6 | "Accept-Encoding": "br, gzip, deflate"
7 | }
8 | }
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "eslint-plugin-jest-extended",
3 | "version": "3.0.0",
4 | "description": "Eslint rules for Jest Extended",
5 | "keywords": [
6 | "jest",
7 | "eslint",
8 | "eslintplugin",
9 | "eslint-plugin",
10 | "eslint-plugin-jest"
11 | ],
12 | "homepage": "https://github.com/jest-community/eslint-plugin-jest-extended#readme",
13 | "bugs": {
14 | "url": "https://github.com/jest-community/eslint-plugin-jest-extended/issues"
15 | },
16 | "repository": "https://github.com/jest-community/eslint-plugin-jest-extended",
17 | "license": "MIT",
18 | "main": "lib/",
19 | "directories": {
20 | "lib": "lib",
21 | "doc": "docs"
22 | },
23 | "files": [
24 | "docs/",
25 | "lib/"
26 | ],
27 | "scripts": {
28 | "build": "babel --extensions .js,.ts src --out-dir lib --copy-files && rimraf --glob lib/__tests__ 'lib/**/__tests__'",
29 | "postinstall": "is-ci || husky",
30 | "lint": "eslint . --ignore-pattern '!.eslintrc.js' --ext js,ts",
31 | "prepack": "rimraf --glob lib && yarn build",
32 | "prepublishOnly": "pinst --disable",
33 | "prettier:check": "prettier --check .",
34 | "prettier:write": "prettier --write .",
35 | "postpublish": "pinst --enable",
36 | "test": "jest",
37 | "tools:regenerate-docs": "yarn prepack && eslint-doc-generator",
38 | "typecheck": "tsc -p ."
39 | },
40 | "commitlint": {
41 | "extends": [
42 | "@commitlint/config-conventional"
43 | ]
44 | },
45 | "lint-staged": {
46 | "*.{js,ts}": "eslint --fix",
47 | "*.{md,json,yml}": "prettier --write"
48 | },
49 | "prettier": {
50 | "arrowParens": "avoid",
51 | "endOfLine": "auto",
52 | "proseWrap": "always",
53 | "singleQuote": true
54 | },
55 | "release": {
56 | "branches": [
57 | "main",
58 | {
59 | "name": "next",
60 | "prerelease": true
61 | }
62 | ],
63 | "plugins": [
64 | "@semantic-release/commit-analyzer",
65 | "@semantic-release/release-notes-generator",
66 | "@semantic-release/changelog",
67 | "@semantic-release/npm",
68 | "@semantic-release/git",
69 | "@semantic-release/github"
70 | ]
71 | },
72 | "dependencies": {
73 | "@typescript-eslint/utils": "^6.0.0 || ^7.0.0 || ^8.0.0"
74 | },
75 | "devDependencies": {
76 | "@babel/cli": "^7.4.4",
77 | "@babel/core": "^7.4.4",
78 | "@babel/preset-env": "^7.4.4",
79 | "@babel/preset-typescript": "^7.3.3",
80 | "@commitlint/cli": "^19.0.0",
81 | "@commitlint/config-conventional": "^19.0.0",
82 | "@eslint-community/eslint-plugin-eslint-comments": "^4.4.1",
83 | "@schemastore/package": "^0.0.10",
84 | "@semantic-release/changelog": "^6.0.0",
85 | "@semantic-release/git": "^10.0.0",
86 | "@tsconfig/node16": "^16.0.0",
87 | "@types/dedent": "^0.7.0",
88 | "@types/jest": "^29.0.0",
89 | "@types/node": "^16.0.0",
90 | "@types/semver": "^7.5.8",
91 | "@typescript-eslint/eslint-plugin": "^6.0.0",
92 | "@typescript-eslint/parser": "^6.0.0",
93 | "@typescript-eslint/utils": "^6.0.0",
94 | "babel-jest": "^29.0.0",
95 | "babel-plugin-replace-ts-export-assignment": "^0.0.2",
96 | "dedent": "^1.0.0",
97 | "eslint": "^7.0.0 || ^8.0.0",
98 | "eslint-config-prettier": "^10.0.0",
99 | "eslint-doc-generator": "^1.0.0",
100 | "eslint-plugin-eslint-plugin": "^6.0.0",
101 | "eslint-plugin-import": "^2.18.0",
102 | "eslint-plugin-n": "^17.0.0",
103 | "eslint-plugin-prettier": "^5.0.0",
104 | "eslint-remote-tester": "^3.0.0",
105 | "eslint-remote-tester-repositories": "^1.0.0",
106 | "husky": "^9.0.0",
107 | "is-ci": "^4.0.0",
108 | "jest": "^29.0.0",
109 | "jest-runner-eslint": "^2.0.0",
110 | "lint-staged": "^13.0.3",
111 | "markdown-link-check": "~3.12.0",
112 | "pinst": "^3.0.0",
113 | "prettier": "^3.0.0",
114 | "rimraf": "^5.0.0",
115 | "semantic-release": "^24.0.0",
116 | "semver": "^7.6.0",
117 | "ts-node": "^10.9.1",
118 | "typescript": "^5.0.0"
119 | },
120 | "peerDependencies": {
121 | "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0"
122 | },
123 | "packageManager": "yarn@3.8.7",
124 | "engines": {
125 | "node": "^16.10.0 || ^18.12.0 || >=20.0.0"
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/src/__tests__/__snapshots__/rules.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`rules should export configs that refer to actual rules 1`] = `
4 | {
5 | "all": {
6 | "plugins": [
7 | "jest-extended",
8 | ],
9 | "rules": {
10 | "jest-extended/prefer-to-be-array": "error",
11 | "jest-extended/prefer-to-be-false": "error",
12 | "jest-extended/prefer-to-be-object": "error",
13 | "jest-extended/prefer-to-be-true": "error",
14 | "jest-extended/prefer-to-have-been-called-once": "error",
15 | },
16 | },
17 | "flat/all": {
18 | "plugins": {
19 | "jest-extended": ObjectContaining {
20 | "meta": {
21 | "name": "eslint-plugin-jest-extended",
22 | "version": Any,
23 | },
24 | },
25 | },
26 | "rules": {
27 | "jest-extended/prefer-to-be-array": "error",
28 | "jest-extended/prefer-to-be-false": "error",
29 | "jest-extended/prefer-to-be-object": "error",
30 | "jest-extended/prefer-to-be-true": "error",
31 | "jest-extended/prefer-to-have-been-called-once": "error",
32 | },
33 | },
34 | "flat/recommended": {
35 | "plugins": {
36 | "jest-extended": ObjectContaining {
37 | "meta": {
38 | "name": "eslint-plugin-jest-extended",
39 | "version": Any,
40 | },
41 | },
42 | },
43 | "rules": {},
44 | },
45 | "recommended": {
46 | "plugins": [
47 | "jest-extended",
48 | ],
49 | "rules": {},
50 | },
51 | }
52 | `;
53 |
--------------------------------------------------------------------------------
/src/__tests__/rules.test.ts:
--------------------------------------------------------------------------------
1 | import { existsSync } from 'fs';
2 | import { resolve } from 'path';
3 | import plugin from '../';
4 |
5 | const ruleNames = Object.keys(plugin.rules);
6 | const numberOfRules = 5;
7 |
8 | describe('rules', () => {
9 | it('should have a corresponding doc for each rule', () => {
10 | ruleNames.forEach(rule => {
11 | const docPath = resolve(__dirname, '../../docs/rules', `${rule}.md`);
12 |
13 | if (!existsSync(docPath)) {
14 | throw new Error(
15 | `Could not find documentation file for rule "${rule}" in path "${docPath}"`,
16 | );
17 | }
18 | });
19 | });
20 |
21 | it('should have the correct amount of rules', () => {
22 | const { length } = ruleNames;
23 |
24 | if (length !== numberOfRules) {
25 | throw new Error(
26 | `There should be exactly ${numberOfRules} rules, but there are ${length}. If you've added a new rule, please update this number.`,
27 | );
28 | }
29 | });
30 |
31 | it('should export configs that refer to actual rules', () => {
32 | const expectJestExtendedPlugin = expect.objectContaining({
33 | meta: {
34 | name: 'eslint-plugin-jest-extended',
35 | version: expect.any(String),
36 | },
37 | });
38 |
39 | const recommendedConfigs = plugin.configs;
40 |
41 | expect(recommendedConfigs).toMatchSnapshot({
42 | 'flat/recommended': {
43 | plugins: { 'jest-extended': expectJestExtendedPlugin },
44 | },
45 | 'flat/all': {
46 | plugins: { 'jest-extended': expectJestExtendedPlugin },
47 | },
48 | });
49 | expect(Object.keys(recommendedConfigs)).toEqual([
50 | 'all',
51 | 'recommended',
52 | 'flat/all',
53 | 'flat/recommended',
54 | ]);
55 | expect(Object.keys(recommendedConfigs.all.rules)).toHaveLength(
56 | ruleNames.length,
57 | );
58 | expect(Object.keys(recommendedConfigs['flat/all'].rules)).toHaveLength(
59 | ruleNames.length,
60 | );
61 | const allConfigRules = Object.values(recommendedConfigs)
62 | .map(config => Object.keys(config.rules ?? {}))
63 | .reduce((previousValue, currentValue) => [
64 | ...previousValue,
65 | ...currentValue,
66 | ]);
67 |
68 | allConfigRules.forEach(rule => {
69 | const ruleNamePrefix = 'jest-extended/';
70 | const ruleName = rule.slice(ruleNamePrefix.length);
71 |
72 | expect(rule.startsWith(ruleNamePrefix)).toBe(true);
73 | expect(ruleNames).toContain(ruleName);
74 | // eslint-disable-next-line @typescript-eslint/no-require-imports
75 | expect(() => require(`../rules/${ruleName}`)).not.toThrow();
76 | });
77 | });
78 | });
79 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { readdirSync } from 'fs';
2 | import { join, parse } from 'path';
3 | import type { TSESLint } from '@typescript-eslint/utils';
4 | import {
5 | name as packageName,
6 | version as packageVersion,
7 | } from '../package.json';
8 |
9 | type RuleModule = TSESLint.RuleModule & {
10 | meta: Required, 'docs'>>;
11 | };
12 |
13 | // copied from https://github.com/babel/babel/blob/d8da63c929f2d28c401571e2a43166678c555bc4/packages/babel-helpers/src/helpers.js#L602-L606
14 | /* istanbul ignore next */
15 | const interopRequireDefault = (obj: any): { default: any } =>
16 | obj && obj.__esModule ? obj : { default: obj };
17 |
18 | const importDefault = (moduleName: string) =>
19 | // eslint-disable-next-line @typescript-eslint/no-require-imports
20 | interopRequireDefault(require(moduleName)).default;
21 |
22 | const rulesDir = join(__dirname, 'rules');
23 | const excludedFiles = ['__tests__', 'utils'];
24 |
25 | const rules = Object.fromEntries(
26 | readdirSync(rulesDir)
27 | .map(rule => parse(rule).name)
28 | .filter(rule => !excludedFiles.includes(rule))
29 | .map(rule => [rule, importDefault(join(rulesDir, rule)) as RuleModule]),
30 | );
31 |
32 | const recommendedRules = {} satisfies Record;
33 |
34 | const allRules = Object.fromEntries(
35 | Object.entries(rules)
36 | .filter(([, rule]) => !rule.meta.deprecated)
37 | .map(([name]) => [`jest-extended/${name}`, 'error']),
38 | );
39 |
40 | const plugin = {
41 | meta: { name: packageName, version: packageVersion },
42 | // ugly cast for now to keep TypeScript happy since
43 | // we don't have types for flat config yet
44 | configs: {} as Record<
45 | 'all' | 'recommended' | 'flat/all' | 'flat/recommended',
46 | Pick, 'rules'>
47 | >,
48 | rules,
49 | };
50 |
51 | const createRCConfig = (rules: Record) => ({
52 | plugins: ['jest-extended'],
53 | rules,
54 | });
55 |
56 | const createFlatConfig = (
57 | rules: Record,
58 | ) => ({
59 | plugins: { 'jest-extended': plugin },
60 | rules,
61 | });
62 |
63 | plugin.configs = {
64 | all: createRCConfig(allRules),
65 | recommended: createRCConfig(recommendedRules),
66 | 'flat/all': createFlatConfig(allRules),
67 | 'flat/recommended': createFlatConfig(recommendedRules),
68 | };
69 |
70 | export = plugin;
71 |
--------------------------------------------------------------------------------
/src/rules/__tests__/prefer-to-be-array.test.ts:
--------------------------------------------------------------------------------
1 | import type { TSESLint } from '@typescript-eslint/utils';
2 | import rule, { type MessageIds, type Options } from '../prefer-to-be-array';
3 | import { FlatCompatRuleTester as RuleTester, espreeParser } from './test-utils';
4 |
5 | const ruleTester = new RuleTester({
6 | parser: espreeParser,
7 | parserOptions: {
8 | ecmaVersion: 2017,
9 | },
10 | });
11 |
12 | // makes ts happy about the dynamic test generation
13 | const messageId = 'preferToBeArray' as const;
14 |
15 | const expectInAndOutValues = [
16 | ['[] instanceof Array', '[]'],
17 | ['Array.isArray([])', '([])'],
18 | ];
19 |
20 | const createTestsForEqualityMatchers = (): Array<
21 | TSESLint.InvalidTestCase
22 | > =>
23 | ['toBe', 'toEqual', 'toStrictEqual']
24 | .map(matcher =>
25 | expectInAndOutValues.map(([inValue, outValue]) => [
26 | {
27 | code: `expect(${inValue}).${matcher}(true);`,
28 | errors: [{ messageId, column: 10 + inValue.length, line: 1 }],
29 | output: `expect(${outValue}).toBeArray();`,
30 | },
31 | {
32 | code: `expect(${inValue}).not.${matcher}(true);`,
33 | errors: [{ messageId, column: 14 + inValue.length, line: 1 }],
34 | output: `expect(${outValue}).not.toBeArray();`,
35 | },
36 | {
37 | code: `expect(${inValue}).${matcher}(false);`,
38 | errors: [{ messageId, column: 10 + inValue.length, line: 1 }],
39 | output: `expect(${outValue}).not.toBeArray();`,
40 | },
41 | {
42 | code: `expect(${inValue}).not.${matcher}(false);`,
43 | errors: [{ messageId, column: 14 + inValue.length, line: 1 }],
44 | output: `expect(${outValue}).toBeArray();`,
45 | },
46 | ]),
47 | )
48 | .reduce((arr, cur) => arr.concat(cur), [])
49 | .reduce((arr, cur) => arr.concat(cur), []);
50 |
51 | ruleTester.run('prefer-to-be-array', rule, {
52 | valid: [
53 | 'expect.hasAssertions',
54 | 'expect.hasAssertions()',
55 | 'expect',
56 | 'expect()',
57 | 'expect().toBe(true)',
58 | 'expect([]).toBe(true)',
59 | 'expect([])["toBe"](true)',
60 | 'expect([]).toBeArray()',
61 | 'expect([]).not.toBeArray()',
62 | 'expect([] instanceof Object).not.toBeArray()',
63 | 'expect([]).not.toBeInstanceOf(Object)',
64 | ],
65 | invalid: [
66 | ...createTestsForEqualityMatchers(),
67 | ...expectInAndOutValues
68 | .map(([inValue, outValue]) => [
69 | {
70 | code: `expect(${inValue}).toBeTrue();`,
71 | errors: [{ messageId, column: 10 + inValue.length, line: 1 }],
72 | output: `expect(${outValue}).toBeArray();`,
73 | },
74 | {
75 | code: `expect(${inValue}).not.toBeTrue();`,
76 | errors: [{ messageId, column: 14 + inValue.length, line: 1 }],
77 | output: `expect(${outValue}).not.toBeArray();`,
78 | },
79 | {
80 | code: `expect(${inValue}).toBeFalse();`,
81 | errors: [{ messageId, column: 10 + inValue.length, line: 1 }],
82 | output: `expect(${outValue}).not.toBeArray();`,
83 | },
84 | {
85 | code: `expect(${inValue}).not.toBeFalse();`,
86 | errors: [{ messageId, column: 14 + inValue.length, line: 1 }],
87 | output: `expect(${outValue}).toBeArray();`,
88 | },
89 | ])
90 | .reduce((arr, cur) => arr.concat(cur), []),
91 |
92 | {
93 | code: 'expect(Array["isArray"]([])).toBe(true);',
94 | output: 'expect(([])).toBeArray();',
95 | errors: [{ messageId, column: 30, line: 1 }],
96 | },
97 | {
98 | code: 'expect(Array[`isArray`]([])).toBe(true);',
99 | output: 'expect(([])).toBeArray();',
100 | errors: [{ messageId, column: 30, line: 1 }],
101 | },
102 | {
103 | code: 'expect([]).toBeInstanceOf(Array);',
104 | output: 'expect([]).toBeArray();',
105 | errors: [{ messageId, column: 12, line: 1 }],
106 | },
107 | {
108 | code: 'expect([]).not.toBeInstanceOf(Array);',
109 | output: 'expect([]).not.toBeArray();',
110 | errors: [{ messageId, column: 16, line: 1 }],
111 | },
112 | {
113 | code: 'expect(requestValues()).resolves.toBeInstanceOf(Array);',
114 | output: 'expect(requestValues()).resolves.toBeArray();',
115 | errors: [{ messageId, column: 34, line: 1 }],
116 | },
117 | {
118 | code: 'expect(queryApi()).rejects.not.toBeInstanceOf(Array);',
119 | output: 'expect(queryApi()).rejects.not.toBeArray();',
120 | errors: [{ messageId, column: 32, line: 1 }],
121 | },
122 | ],
123 | });
124 |
--------------------------------------------------------------------------------
/src/rules/__tests__/prefer-to-be-false.test.ts:
--------------------------------------------------------------------------------
1 | import rule from '../prefer-to-be-false';
2 | import { FlatCompatRuleTester as RuleTester } from './test-utils';
3 |
4 | const ruleTester = new RuleTester();
5 |
6 | ruleTester.run('prefer-to-be-false', rule, {
7 | valid: [
8 | '[].push(false)',
9 | 'expect("something");',
10 | 'expect(true).toBeTrue();',
11 | 'expect(false).toBeTrue();',
12 | 'expect(false).toBeFalse();',
13 | 'expect(true).toBeFalse();',
14 | 'expect(value).toEqual();',
15 | 'expect(value).not.toBeFalse();',
16 | 'expect(value).not.toEqual();',
17 | 'expect(value).toBe(undefined);',
18 | 'expect(value).not.toBe(undefined);',
19 | 'expect(false).toBe(true)',
20 | 'expect(value).toBe();',
21 | 'expect(true).toMatchSnapshot();',
22 | 'expect("a string").toMatchSnapshot(false);',
23 | 'expect("a string").not.toMatchSnapshot();',
24 | "expect(something).toEqual('a string');",
25 | 'expect(false).toBe',
26 | ],
27 | invalid: [
28 | {
29 | code: 'expect(true).toBe(false);',
30 | output: 'expect(true).toBeFalse();',
31 | errors: [{ messageId: 'preferToBeFalse', column: 14, line: 1 }],
32 | },
33 | {
34 | code: 'expect(wasSuccessful).toEqual(false);',
35 | output: 'expect(wasSuccessful).toBeFalse();',
36 | errors: [{ messageId: 'preferToBeFalse', column: 23, line: 1 }],
37 | },
38 | {
39 | code: "expect(fs.existsSync('/path/to/file')).toStrictEqual(false);",
40 | output: "expect(fs.existsSync('/path/to/file')).toBeFalse();",
41 | errors: [{ messageId: 'preferToBeFalse', column: 40, line: 1 }],
42 | },
43 | {
44 | code: 'expect("a string").not.toBe(false);',
45 | output: 'expect("a string").not.toBeFalse();',
46 | errors: [{ messageId: 'preferToBeFalse', column: 24, line: 1 }],
47 | },
48 | {
49 | code: 'expect("a string").not.toEqual(false);',
50 | output: 'expect("a string").not.toBeFalse();',
51 | errors: [{ messageId: 'preferToBeFalse', column: 24, line: 1 }],
52 | },
53 | {
54 | code: 'expect("a string").not.toStrictEqual(false);',
55 | output: 'expect("a string").not.toBeFalse();',
56 | errors: [{ messageId: 'preferToBeFalse', column: 24, line: 1 }],
57 | },
58 | ],
59 | });
60 |
61 | new RuleTester({
62 | parser: require.resolve('@typescript-eslint/parser'),
63 | }).run('prefer-to-be-false: typescript edition', rule, {
64 | valid: [
65 | "(expect('Model must be bound to an array if the multiple property is true') as any).toHaveBeenTipped()",
66 | ],
67 | invalid: [
68 | {
69 | code: 'expect(true).toBe(false as unknown as string as unknown as any);',
70 | output: 'expect(true).toBeFalse();',
71 | errors: [{ messageId: 'preferToBeFalse', column: 14, line: 1 }],
72 | },
73 | {
74 | code: 'expect("a string").not.toEqual(false as number);',
75 | output: 'expect("a string").not.toBeFalse();',
76 | errors: [{ messageId: 'preferToBeFalse', column: 24, line: 1 }],
77 | },
78 | ],
79 | });
80 |
--------------------------------------------------------------------------------
/src/rules/__tests__/prefer-to-be-object.test.ts:
--------------------------------------------------------------------------------
1 | import type { TSESLint } from '@typescript-eslint/utils';
2 | import rule, { type MessageIds, type Options } from '../prefer-to-be-object';
3 | import { FlatCompatRuleTester as RuleTester } from './test-utils';
4 |
5 | const ruleTester = new RuleTester();
6 |
7 | // makes ts happy about the dynamic test generation
8 | const messageId = 'preferToBeObject' as const;
9 |
10 | const createTestsForEqualityMatchers = (): Array<
11 | TSESLint.InvalidTestCase
12 | > =>
13 | ['toBe', 'toEqual', 'toStrictEqual']
14 | .map(matcher => [
15 | {
16 | code: `expect({} instanceof Object).${matcher}(true);`,
17 | errors: [{ messageId, column: 30, line: 1 }],
18 | output: `expect({}).toBeObject();`,
19 | },
20 | {
21 | code: `expect({} instanceof Object).not.${matcher}(true);`,
22 | errors: [{ messageId, column: 34, line: 1 }],
23 | output: `expect({}).not.toBeObject();`,
24 | },
25 | {
26 | code: `expect({} instanceof Object).${matcher}(false);`,
27 | errors: [{ messageId, column: 30, line: 1 }],
28 | output: `expect({}).not.toBeObject();`,
29 | },
30 | {
31 | code: `expect({} instanceof Object).not.${matcher}(false);`,
32 | errors: [{ messageId, column: 34, line: 1 }],
33 | output: `expect({}).toBeObject();`,
34 | },
35 | ])
36 | .reduce((arr, cur) => arr.concat(cur), []);
37 |
38 | ruleTester.run('prefer-to-be-object', rule, {
39 | valid: [
40 | 'expect.hasAssertions',
41 | 'expect.hasAssertions()',
42 | 'expect',
43 | 'expect().not',
44 | 'expect().toBe',
45 | 'expect().toBe(true)',
46 | 'expect({}).toBe(true)',
47 | 'expect({}).toBeObject()',
48 | 'expect({}).not.toBeObject()',
49 | 'expect([] instanceof Array).not.toBeObject()',
50 | 'expect({}).not.toBeInstanceOf(Array)',
51 | ],
52 | invalid: [
53 | ...createTestsForEqualityMatchers(),
54 | {
55 | code: 'expect(({} instanceof Object)).toBeTrue();',
56 | output: 'expect(({})).toBeObject();',
57 | errors: [{ messageId, column: 32, line: 1 }],
58 | },
59 | {
60 | code: 'expect({} instanceof Object).toBeTrue();',
61 | output: 'expect({}).toBeObject();',
62 | errors: [{ messageId, column: 30, line: 1 }],
63 | },
64 | {
65 | code: 'expect({} instanceof Object).not.toBeTrue();',
66 | output: 'expect({}).not.toBeObject();',
67 | errors: [{ messageId, column: 34, line: 1 }],
68 | },
69 | {
70 | code: 'expect({} instanceof Object).toBeFalse();',
71 | output: 'expect({}).not.toBeObject();',
72 | errors: [{ messageId, column: 30, line: 1 }],
73 | },
74 | {
75 | code: 'expect({} instanceof Object).not.toBeFalse();',
76 | output: 'expect({}).toBeObject();',
77 | errors: [{ messageId, column: 34, line: 1 }],
78 | },
79 | {
80 | code: 'expect({}).toBeInstanceOf(Object);',
81 | output: 'expect({}).toBeObject();',
82 | errors: [{ messageId, column: 12, line: 1 }],
83 | },
84 | {
85 | code: 'expect({}).not.toBeInstanceOf(Object);',
86 | output: 'expect({}).not.toBeObject();',
87 | errors: [{ messageId, column: 16, line: 1 }],
88 | },
89 | {
90 | code: 'expect(requestValues()).resolves.toBeInstanceOf(Object);',
91 | output: 'expect(requestValues()).resolves.toBeObject();',
92 | errors: [{ messageId, column: 34, line: 1 }],
93 | },
94 | {
95 | code: 'expect(queryApi()).resolves.not.toBeInstanceOf(Object);',
96 | output: 'expect(queryApi()).resolves.not.toBeObject();',
97 | errors: [{ messageId, column: 33, line: 1 }],
98 | },
99 | ],
100 | });
101 |
--------------------------------------------------------------------------------
/src/rules/__tests__/prefer-to-be-true.test.ts:
--------------------------------------------------------------------------------
1 | import rule from '../prefer-to-be-true';
2 | import { FlatCompatRuleTester as RuleTester } from './test-utils';
3 |
4 | const ruleTester = new RuleTester();
5 |
6 | ruleTester.run('prefer-to-be-true', rule, {
7 | valid: [
8 | '[].push(true)',
9 | 'expect("something");',
10 | 'expect(true).toBeTrue();',
11 | 'expect(false).toBeTrue();',
12 | 'expect(false).toBeFalse();',
13 | 'expect(true).toBeFalse();',
14 | 'expect(value).toEqual();',
15 | 'expect(value).not.toBeTrue();',
16 | 'expect(value).not.toEqual();',
17 | 'expect(value).toBe(undefined);',
18 | 'expect(value).not.toBe(undefined);',
19 | 'expect(true).toBe(false)',
20 | 'expect(value).toBe();',
21 | 'expect(true).toMatchSnapshot();',
22 | 'expect("a string").toMatchSnapshot(true);',
23 | 'expect("a string").not.toMatchSnapshot();',
24 | "expect(something).toEqual('a string');",
25 | 'expect(true).toBe',
26 | ],
27 | invalid: [
28 | {
29 | code: 'expect(false).toBe(true);',
30 | output: 'expect(false).toBeTrue();',
31 | errors: [{ messageId: 'preferToBeTrue', column: 15, line: 1 }],
32 | },
33 | {
34 | code: 'expect(wasSuccessful).toEqual(true);',
35 | output: 'expect(wasSuccessful).toBeTrue();',
36 | errors: [{ messageId: 'preferToBeTrue', column: 23, line: 1 }],
37 | },
38 | {
39 | code: "expect(fs.existsSync('/path/to/file')).toStrictEqual(true);",
40 | output: "expect(fs.existsSync('/path/to/file')).toBeTrue();",
41 | errors: [{ messageId: 'preferToBeTrue', column: 40, line: 1 }],
42 | },
43 | {
44 | code: 'expect("a string").not.toBe(true);',
45 | output: 'expect("a string").not.toBeTrue();',
46 | errors: [{ messageId: 'preferToBeTrue', column: 24, line: 1 }],
47 | },
48 | {
49 | code: 'expect("a string").not.toEqual(true);',
50 | output: 'expect("a string").not.toBeTrue();',
51 | errors: [{ messageId: 'preferToBeTrue', column: 24, line: 1 }],
52 | },
53 | {
54 | code: 'expect("a string").not.toStrictEqual(true);',
55 | output: 'expect("a string").not.toBeTrue();',
56 | errors: [{ messageId: 'preferToBeTrue', column: 24, line: 1 }],
57 | },
58 | ],
59 | });
60 |
61 | new RuleTester({
62 | parser: require.resolve('@typescript-eslint/parser'),
63 | }).run('prefer-to-be-true: typescript edition', rule, {
64 | valid: [
65 | "(expect('Model must be bound to an array if the multiple property is true') as any).toHaveBeenTipped()",
66 | ],
67 | invalid: [
68 | {
69 | code: 'expect(true).toBe(true as unknown as string as unknown as any);',
70 | output: 'expect(true).toBeTrue();',
71 | errors: [{ messageId: 'preferToBeTrue', column: 14, line: 1 }],
72 | },
73 | {
74 | code: 'expect("a string").not.toEqual(true as number);',
75 | output: 'expect("a string").not.toBeTrue();',
76 | errors: [{ messageId: 'preferToBeTrue', column: 24, line: 1 }],
77 | },
78 | ],
79 | });
80 |
--------------------------------------------------------------------------------
/src/rules/__tests__/prefer-to-have-been-called-once.test.ts:
--------------------------------------------------------------------------------
1 | import rule from '../prefer-to-have-been-called-once';
2 | import { FlatCompatRuleTester as RuleTester } from './test-utils';
3 |
4 | const ruleTester = new RuleTester();
5 |
6 | ruleTester.run('prefer-to-have-been-called-once', rule, {
7 | valid: [
8 | '[].push(true)',
9 | 'expect("something");',
10 | 'expect(xyz).toBe',
11 | 'expect(xyz).toBe(true)',
12 | 'expect(xyz).toHaveBeenCalled(true)',
13 | 'expect(xyz).not.toHaveBeenCalled(true)',
14 | 'expect(xyz).toHaveBeenCalled(1)',
15 | 'expect(xyz).not.toHaveBeenCalled(1)',
16 | 'expect(xyz).toHaveBeenCalledWith(1)',
17 | 'expect(xyz).not.toHaveBeenCalledWith(1)',
18 | 'expect(xyz).toHaveBeenCalledTimes(0)',
19 | 'expect(xyz).toHaveBeenCalledTimes(2)',
20 | {
21 | code: 'expect(xyz).toHaveBeenCalledTimes(...1)',
22 | parserOptions: {
23 | ecmaVersion: 2015,
24 | },
25 | },
26 | ],
27 | invalid: [
28 | {
29 | code: 'expect(xyz).toHaveBeenCalledTimes(1)',
30 | output: 'expect(xyz).toHaveBeenCalledOnce()',
31 | errors: [{ messageId: 'preferCalledOnce', column: 13, line: 1 }],
32 | },
33 | {
34 | code: 'expect(xyz).not.toHaveBeenCalledTimes(1)',
35 | output: 'expect(xyz).not.toHaveBeenCalledOnce()',
36 | errors: [{ messageId: 'preferCalledOnce', column: 17, line: 1 }],
37 | },
38 | ],
39 | });
40 |
41 | new RuleTester({
42 | parser: require.resolve('@typescript-eslint/parser'),
43 | }).run('prefer-to-have-been-called-once: typescript edition', rule, {
44 | valid: [
45 | "(expect('Model must be bound to an array if the multiple property is true') as any).toHaveBeenCalledTimes(2 as string)",
46 | ],
47 | invalid: [
48 | {
49 | code: 'expect(xyz).toHaveBeenCalledTimes(1 as unknown as string as unknown as any);',
50 | output: 'expect(xyz).toHaveBeenCalledOnce();',
51 | errors: [{ messageId: 'preferCalledOnce', column: 13, line: 1 }],
52 | },
53 | {
54 | code: 'expect(xyz).not.toHaveBeenCalledTimes(1 as number);',
55 | output: 'expect(xyz).not.toHaveBeenCalledOnce();',
56 | errors: [{ messageId: 'preferCalledOnce', column: 17, line: 1 }],
57 | },
58 | ],
59 | });
60 |
--------------------------------------------------------------------------------
/src/rules/__tests__/test-utils.ts:
--------------------------------------------------------------------------------
1 | import { createRequire } from 'module';
2 | import { TSESLint } from '@typescript-eslint/utils';
3 | import { version as eslintVersion } from 'eslint/package.json';
4 | import * as semver from 'semver';
5 |
6 | const require = createRequire(__filename);
7 | const eslintRequire = createRequire(require.resolve('eslint'));
8 |
9 | export const espreeParser = eslintRequire.resolve('espree');
10 |
11 | export const usingFlatConfig = semver.major(eslintVersion) >= 9;
12 |
13 | export class FlatCompatRuleTester extends TSESLint.RuleTester {
14 | public constructor(testerConfig?: TSESLint.RuleTesterConfig) {
15 | super(FlatCompatRuleTester._flatCompat(testerConfig));
16 | }
17 |
18 | public override run<
19 | TMessageIds extends string,
20 | TOptions extends readonly unknown[],
21 | >(
22 | ruleName: string,
23 | rule: TSESLint.RuleModule,
24 | tests: TSESLint.RunTests,
25 | ) {
26 | super.run(ruleName, rule, {
27 | valid: tests.valid.map(t => FlatCompatRuleTester._flatCompat(t)),
28 | invalid: tests.invalid.map(t => FlatCompatRuleTester._flatCompat(t)),
29 | });
30 | }
31 |
32 | /* istanbul ignore next */
33 | private static _flatCompat<
34 | T extends
35 | | undefined
36 | | RuleTesterConfig
37 | | string
38 | | TSESLint.ValidTestCase
39 | | TSESLint.InvalidTestCase,
40 | >(config: T): T {
41 | if (!config || !usingFlatConfig || typeof config === 'string') {
42 | return config;
43 | }
44 |
45 | const obj: FlatConfig.Config & {
46 | languageOptions: FlatConfig.LanguageOptions & {
47 | parserOptions: FlatConfig.ParserOptions;
48 | };
49 | } = {
50 | languageOptions: { parserOptions: {} },
51 | };
52 |
53 | for (const [key, value] of Object.entries(config)) {
54 | if (key === 'parser') {
55 | obj.languageOptions.parser = require(value);
56 |
57 | continue;
58 | }
59 |
60 | if (key === 'parserOptions') {
61 | for (const [option, val] of Object.entries(value)) {
62 | if (option === 'ecmaVersion' || option === 'sourceType') {
63 | // @ts-expect-error: TS thinks the value could the opposite type of whatever option is
64 | obj.languageOptions[option] = val as FlatConfig.LanguageOptions[
65 | | 'ecmaVersion'
66 | | 'sourceType'];
67 |
68 | continue;
69 | }
70 |
71 | obj.languageOptions.parserOptions[option] = val;
72 | }
73 |
74 | continue;
75 | }
76 |
77 | obj[key as keyof typeof obj] = value;
78 | }
79 |
80 | return obj as unknown as T;
81 | }
82 | }
83 |
84 | type RuleTesterConfig = TSESLint.RuleTesterConfig | FlatConfig.Config;
85 |
86 | export declare namespace FlatConfig {
87 | type EcmaVersion = TSESLint.EcmaVersion;
88 | type ParserOptions = TSESLint.ParserOptions;
89 | type SourceType = TSESLint.SourceType | 'commonjs';
90 | interface LanguageOptions {
91 | /**
92 | * The version of ECMAScript to support.
93 | * May be any year (i.e., `2022`) or version (i.e., `5`).
94 | * Set to `"latest"` for the most recent supported version.
95 | * @default "latest"
96 | */
97 | ecmaVersion?: EcmaVersion;
98 | /**
99 | * An object specifying additional objects that should be added to the global scope during linting.
100 | */
101 | globals?: unknown;
102 | /**
103 | * An object containing a `parse()` method or a `parseForESLint()` method.
104 | * @default
105 | * ```
106 | * // https://github.com/eslint/espree
107 | * require('espree')
108 | * ```
109 | */
110 | parser?: unknown;
111 | /**
112 | * An object specifying additional options that are passed directly to the parser.
113 | * The available options are parser-dependent.
114 | */
115 | parserOptions?: ParserOptions;
116 | /**
117 | * The type of JavaScript source code.
118 | * Possible values are `"script"` for traditional script files, `"module"` for ECMAScript modules (ESM), and `"commonjs"` for CommonJS files.
119 | * @default
120 | * ```
121 | * // for `.js` and `.mjs` files
122 | * "module"
123 | * // for `.cjs` files
124 | * "commonjs"
125 | * ```
126 | */
127 | sourceType?: SourceType;
128 | }
129 | interface Config {
130 | /**
131 | * An array of glob patterns indicating the files that the configuration object should apply to.
132 | * If not specified, the configuration object applies to all files matched by any other configuration object.
133 | */
134 | files?: string[];
135 | /**
136 | * An array of glob patterns indicating the files that the configuration object should not apply to.
137 | * If not specified, the configuration object applies to all files matched by files.
138 | */
139 | ignores?: string[];
140 | /**
141 | * An object containing settings related to how JavaScript is configured for linting.
142 | */
143 | languageOptions?: LanguageOptions;
144 | /**
145 | * An object containing settings related to the linting process.
146 | */
147 | linterOptions?: unknown;
148 | /**
149 | * An object containing a name-value mapping of plugin names to plugin objects.
150 | * When `files` is specified, these plugins are only available to the matching files.
151 | */
152 | plugins?: unknown;
153 | /**
154 | * An object containing the configured rules.
155 | * When `files` or `ignores` are specified, these rule configurations are only available to the matching files.
156 | */
157 | rules?: unknown;
158 | /**
159 | * An object containing name-value pairs of information that should be available to all rules.
160 | */
161 | settings?: unknown;
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/src/rules/prefer-to-be-array.ts:
--------------------------------------------------------------------------------
1 | import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
2 | import {
3 | createRule,
4 | followTypeAssertionChain,
5 | getAccessorValue,
6 | isBooleanEqualityMatcher,
7 | isInstanceOfBinaryExpression,
8 | isParsedInstanceOfMatcherCall,
9 | isSupportedAccessor,
10 | parseJestFnCall,
11 | } from './utils';
12 |
13 | const isArrayIsArrayCall = (
14 | node: TSESTree.Node,
15 | ): node is TSESTree.CallExpression =>
16 | node.type === AST_NODE_TYPES.CallExpression &&
17 | node.callee.type === AST_NODE_TYPES.MemberExpression &&
18 | isSupportedAccessor(node.callee.object, 'Array') &&
19 | isSupportedAccessor(node.callee.property, 'isArray');
20 |
21 | export type MessageIds = 'preferToBeArray';
22 | export type Options = [];
23 |
24 | export default createRule({
25 | name: __filename,
26 | meta: {
27 | docs: {
28 | description: 'Suggest using `toBeArray()`',
29 | },
30 | messages: {
31 | preferToBeArray:
32 | 'Prefer using `toBeArray()` to test if a value is an array.',
33 | },
34 | fixable: 'code',
35 | type: 'suggestion',
36 | schema: [],
37 | },
38 | defaultOptions: [],
39 | create(context) {
40 | return {
41 | CallExpression(node) {
42 | const jestFnCall = parseJestFnCall(node, context);
43 |
44 | if (jestFnCall?.type !== 'expect') {
45 | return;
46 | }
47 |
48 | if (isParsedInstanceOfMatcherCall(jestFnCall, 'Array')) {
49 | context.report({
50 | node: jestFnCall.matcher,
51 | messageId: 'preferToBeArray',
52 | fix: fixer => [
53 | fixer.replaceTextRange(
54 | [
55 | jestFnCall.matcher.range[0],
56 | jestFnCall.matcher.range[1] + '(Array)'.length,
57 | ],
58 | 'toBeArray()',
59 | ),
60 | ],
61 | });
62 |
63 | return;
64 | }
65 |
66 | const { parent: expect } = jestFnCall.head.node;
67 |
68 | if (expect?.type !== AST_NODE_TYPES.CallExpression) {
69 | return;
70 | }
71 |
72 | const [expectArg] = expect.arguments;
73 |
74 | if (
75 | !expectArg ||
76 | !isBooleanEqualityMatcher(jestFnCall) ||
77 | !(
78 | isArrayIsArrayCall(expectArg) ||
79 | isInstanceOfBinaryExpression(expectArg, 'Array')
80 | )
81 | ) {
82 | return;
83 | }
84 |
85 | context.report({
86 | node: jestFnCall.matcher,
87 | messageId: 'preferToBeArray',
88 | fix(fixer) {
89 | const fixes = [
90 | fixer.replaceText(jestFnCall.matcher, 'toBeArray'),
91 | expectArg.type === AST_NODE_TYPES.CallExpression
92 | ? fixer.remove(expectArg.callee)
93 | : fixer.removeRange([
94 | expectArg.left.range[1],
95 | expectArg.range[1],
96 | ]),
97 | ];
98 |
99 | let invertCondition =
100 | getAccessorValue(jestFnCall.matcher) === 'toBeFalse';
101 |
102 | if (jestFnCall.args.length) {
103 | const [matcherArg] = jestFnCall.args;
104 |
105 | fixes.push(fixer.remove(matcherArg));
106 |
107 | // toBeFalse can't have arguments, so this won't be true beforehand
108 | invertCondition =
109 | matcherArg.type === AST_NODE_TYPES.Literal &&
110 | followTypeAssertionChain(matcherArg).value === false;
111 | }
112 |
113 | if (invertCondition) {
114 | const notModifier = jestFnCall.modifiers.find(
115 | nod => getAccessorValue(nod) === 'not',
116 | );
117 |
118 | fixes.push(
119 | notModifier
120 | ? fixer.removeRange([
121 | notModifier.range[0] - 1,
122 | notModifier.range[1],
123 | ])
124 | : fixer.insertTextBefore(jestFnCall.matcher, 'not.'),
125 | );
126 | }
127 |
128 | return fixes;
129 | },
130 | });
131 | },
132 | };
133 | },
134 | });
135 |
--------------------------------------------------------------------------------
/src/rules/prefer-to-be-false.ts:
--------------------------------------------------------------------------------
1 | import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
2 | import {
3 | EqualityMatcher,
4 | createRule,
5 | getAccessorValue,
6 | getFirstMatcherArg,
7 | parseJestFnCall,
8 | } from './utils';
9 |
10 | interface FalseLiteral extends TSESTree.BooleanLiteral {
11 | value: false;
12 | }
13 |
14 | const isFalseLiteral = (node: TSESTree.Node): node is FalseLiteral =>
15 | node.type === AST_NODE_TYPES.Literal && node.value === false;
16 |
17 | export default createRule({
18 | name: __filename,
19 | meta: {
20 | docs: {
21 | description: 'Suggest using `toBeFalse()`',
22 | },
23 | messages: {
24 | preferToBeFalse: 'Prefer using `toBeFalse()` to test value is `false`.',
25 | },
26 | fixable: 'code',
27 | type: 'suggestion',
28 | schema: [],
29 | },
30 | defaultOptions: [],
31 | create(context) {
32 | return {
33 | CallExpression(node) {
34 | const jestFnCall = parseJestFnCall(node, context);
35 |
36 | if (jestFnCall?.type !== 'expect') {
37 | return;
38 | }
39 |
40 | if (
41 | jestFnCall.args.length === 1 &&
42 | isFalseLiteral(getFirstMatcherArg(jestFnCall)) &&
43 | EqualityMatcher.hasOwnProperty(getAccessorValue(jestFnCall.matcher))
44 | ) {
45 | context.report({
46 | node: jestFnCall.matcher,
47 | messageId: 'preferToBeFalse',
48 | fix: fixer => [
49 | fixer.replaceText(jestFnCall.matcher, 'toBeFalse'),
50 | fixer.remove(jestFnCall.args[0]),
51 | ],
52 | });
53 | }
54 | },
55 | };
56 | },
57 | });
58 |
--------------------------------------------------------------------------------
/src/rules/prefer-to-be-object.ts:
--------------------------------------------------------------------------------
1 | import { AST_NODE_TYPES } from '@typescript-eslint/utils';
2 | import {
3 | createRule,
4 | followTypeAssertionChain,
5 | getAccessorValue,
6 | isBooleanEqualityMatcher,
7 | isInstanceOfBinaryExpression,
8 | isParsedInstanceOfMatcherCall,
9 | parseJestFnCall,
10 | } from './utils';
11 |
12 | export type MessageIds = 'preferToBeObject';
13 | export type Options = [];
14 |
15 | export default createRule({
16 | name: __filename,
17 | meta: {
18 | docs: {
19 | description: 'Suggest using `toBeObject()`',
20 | },
21 | messages: {
22 | preferToBeObject:
23 | 'Prefer using `toBeObject()` to test if a value is an Object.',
24 | },
25 | fixable: 'code',
26 | type: 'suggestion',
27 | schema: [],
28 | },
29 | defaultOptions: [],
30 | create(context) {
31 | return {
32 | CallExpression(node) {
33 | const jestFnCall = parseJestFnCall(node, context);
34 |
35 | if (jestFnCall?.type !== 'expect') {
36 | return;
37 | }
38 |
39 | if (isParsedInstanceOfMatcherCall(jestFnCall, 'Object')) {
40 | context.report({
41 | node: jestFnCall.matcher,
42 | messageId: 'preferToBeObject',
43 | fix: fixer => [
44 | fixer.replaceTextRange(
45 | [
46 | jestFnCall.matcher.range[0],
47 | jestFnCall.matcher.range[1] + '(Object)'.length,
48 | ],
49 | 'toBeObject()',
50 | ),
51 | ],
52 | });
53 |
54 | return;
55 | }
56 |
57 | const { parent: expect } = jestFnCall.head.node;
58 |
59 | if (expect?.type !== AST_NODE_TYPES.CallExpression) {
60 | return;
61 | }
62 |
63 | const [expectArg] = expect.arguments;
64 |
65 | if (
66 | !expectArg ||
67 | !isBooleanEqualityMatcher(jestFnCall) ||
68 | !isInstanceOfBinaryExpression(expectArg, 'Object')
69 | ) {
70 | return;
71 | }
72 |
73 | context.report({
74 | node: jestFnCall.matcher,
75 | messageId: 'preferToBeObject',
76 | fix(fixer) {
77 | const fixes = [
78 | fixer.replaceText(jestFnCall.matcher, 'toBeObject'),
79 | fixer.removeRange([expectArg.left.range[1], expectArg.range[1]]),
80 | ];
81 |
82 | let invertCondition =
83 | getAccessorValue(jestFnCall.matcher) === 'toBeFalse';
84 |
85 | if (jestFnCall.args?.length) {
86 | const [matcherArg] = jestFnCall.args;
87 |
88 | fixes.push(fixer.remove(matcherArg));
89 |
90 | // toBeFalse can't have arguments, so this won't be true beforehand
91 | invertCondition =
92 | matcherArg.type === AST_NODE_TYPES.Literal &&
93 | followTypeAssertionChain(matcherArg).value === false;
94 | }
95 |
96 | if (invertCondition) {
97 | const notModifier = jestFnCall.modifiers.find(
98 | nod => getAccessorValue(nod) === 'not',
99 | );
100 |
101 | fixes.push(
102 | notModifier
103 | ? fixer.removeRange([
104 | notModifier.range[0] - 1,
105 | notModifier.range[1],
106 | ])
107 | : fixer.insertTextBefore(jestFnCall.matcher, 'not.'),
108 | );
109 | }
110 |
111 | return fixes;
112 | },
113 | });
114 | },
115 | };
116 | },
117 | });
118 |
--------------------------------------------------------------------------------
/src/rules/prefer-to-be-true.ts:
--------------------------------------------------------------------------------
1 | import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
2 | import {
3 | EqualityMatcher,
4 | createRule,
5 | getAccessorValue,
6 | getFirstMatcherArg,
7 | parseJestFnCall,
8 | } from './utils';
9 |
10 | interface TrueLiteral extends TSESTree.BooleanLiteral {
11 | value: true;
12 | }
13 |
14 | const isTrueLiteral = (node: TSESTree.Node): node is TrueLiteral =>
15 | node.type === AST_NODE_TYPES.Literal && node.value === true;
16 |
17 | export default createRule({
18 | name: __filename,
19 | meta: {
20 | docs: {
21 | description: 'Suggest using `toBeTrue()`',
22 | },
23 | messages: {
24 | preferToBeTrue: 'Prefer using `toBeTrue()` to test value is `true`.',
25 | },
26 | fixable: 'code',
27 | type: 'suggestion',
28 | schema: [],
29 | },
30 | defaultOptions: [],
31 | create(context) {
32 | return {
33 | CallExpression(node) {
34 | const jestFnCall = parseJestFnCall(node, context);
35 |
36 | if (jestFnCall?.type !== 'expect') {
37 | return;
38 | }
39 |
40 | if (
41 | jestFnCall.args.length === 1 &&
42 | isTrueLiteral(getFirstMatcherArg(jestFnCall)) &&
43 | EqualityMatcher.hasOwnProperty(getAccessorValue(jestFnCall.matcher))
44 | ) {
45 | context.report({
46 | node: jestFnCall.matcher,
47 | messageId: 'preferToBeTrue',
48 | fix: fixer => [
49 | fixer.replaceText(jestFnCall.matcher, 'toBeTrue'),
50 | fixer.remove(jestFnCall.args[0]),
51 | ],
52 | });
53 | }
54 | },
55 | };
56 | },
57 | });
58 |
--------------------------------------------------------------------------------
/src/rules/prefer-to-have-been-called-once.ts:
--------------------------------------------------------------------------------
1 | import { AST_NODE_TYPES } from '@typescript-eslint/utils';
2 | import {
3 | createRule,
4 | getAccessorValue,
5 | getFirstMatcherArg,
6 | parseJestFnCall,
7 | } from './utils';
8 |
9 | export default createRule({
10 | name: __filename,
11 | meta: {
12 | docs: {
13 | description: 'Suggest using `toHaveBeenCalledOnce()`',
14 | },
15 | messages: {
16 | preferCalledOnce: 'Prefer `toHaveBeenCalledOnce()`',
17 | },
18 | fixable: 'code',
19 | type: 'suggestion',
20 | schema: [],
21 | },
22 | defaultOptions: [],
23 | create(context) {
24 | return {
25 | CallExpression(node) {
26 | const jestFnCall = parseJestFnCall(node, context);
27 |
28 | if (jestFnCall?.type !== 'expect') {
29 | return;
30 | }
31 |
32 | if (
33 | getAccessorValue(jestFnCall.matcher) === 'toHaveBeenCalledTimes' &&
34 | jestFnCall.args.length === 1
35 | ) {
36 | const arg = getFirstMatcherArg(jestFnCall);
37 |
38 | if (arg.type !== AST_NODE_TYPES.Literal || arg.value !== 1) {
39 | return;
40 | }
41 |
42 | context.report({
43 | node: jestFnCall.matcher,
44 | messageId: 'preferCalledOnce',
45 | fix: fixer => [
46 | fixer.replaceText(jestFnCall.matcher, 'toHaveBeenCalledOnce'),
47 | fixer.remove(jestFnCall.args[0]),
48 | ],
49 | });
50 | }
51 | },
52 | };
53 | },
54 | });
55 |
--------------------------------------------------------------------------------
/src/rules/utils/__tests__/parseJestFnCall.test.ts:
--------------------------------------------------------------------------------
1 | import type { JSONSchemaForNPMPackageJsonFiles } from '@schemastore/package';
2 | import type { TSESTree } from '@typescript-eslint/utils';
3 | import dedent from 'dedent';
4 | import {
5 | FlatCompatRuleTester as RuleTester,
6 | espreeParser,
7 | } from '../../__tests__/test-utils';
8 | import {
9 | type ParsedJestFnCall,
10 | type ResolvedJestFnWithNode,
11 | createRule,
12 | getAccessorValue,
13 | isSupportedAccessor,
14 | parseJestFnCall,
15 | } from '../../utils';
16 |
17 | const findESLintVersion = (): number => {
18 | const eslintPath = require.resolve('eslint/package.json');
19 |
20 | const eslintPackageJson =
21 | // eslint-disable-next-line @typescript-eslint/no-require-imports
22 | require(eslintPath) as JSONSchemaForNPMPackageJsonFiles;
23 |
24 | if (!eslintPackageJson.version) {
25 | throw new Error('eslint package.json does not have a version!');
26 | }
27 |
28 | const [majorVersion] = eslintPackageJson.version.split('.');
29 |
30 | return parseInt(majorVersion, 10);
31 | };
32 |
33 | const eslintVersion = findESLintVersion();
34 |
35 | const ruleTester = new RuleTester({
36 | parser: espreeParser,
37 | parserOptions: {
38 | ecmaVersion: 2015,
39 | },
40 | });
41 |
42 | const isNode = (obj: unknown): obj is TSESTree.Node => {
43 | if (typeof obj === 'object' && obj !== null) {
44 | return ['type', 'loc', 'range', 'parent'].every(p => p in obj);
45 | }
46 |
47 | return false;
48 | };
49 |
50 | const rule = createRule({
51 | name: __filename,
52 | meta: {
53 | docs: {
54 | description: 'Fake rule for testing parseJestFnCall',
55 | },
56 | messages: {
57 | details: '{{ data }}',
58 | },
59 | schema: [],
60 | type: 'problem',
61 | },
62 | defaultOptions: [],
63 | create: context => ({
64 | CallExpression(node) {
65 | const jestFnCall = parseJestFnCall(node, context);
66 |
67 | if (jestFnCall) {
68 | const sorted = {
69 | // ...jestFnCall,
70 | name: jestFnCall.name,
71 | type: jestFnCall.type,
72 | head: jestFnCall.head,
73 | members: jestFnCall.members,
74 | };
75 |
76 | context.report({
77 | messageId: 'details',
78 | node,
79 | data: {
80 | data: JSON.stringify(sorted, (_key, value) => {
81 | if (isNode(value)) {
82 | if (isSupportedAccessor(value)) {
83 | return getAccessorValue(value);
84 | }
85 |
86 | return undefined;
87 | }
88 |
89 | return value;
90 | }),
91 | },
92 | });
93 | }
94 | },
95 | }),
96 | });
97 |
98 | interface TestResolvedJestFnWithNode
99 | extends Omit {
100 | node: string;
101 | }
102 |
103 | interface TestParsedJestFnCall
104 | extends Omit {
105 | head: TestResolvedJestFnWithNode;
106 | members: string[];
107 | }
108 |
109 | // const sortParsedJestFnCallResults = ()
110 |
111 | const expectedParsedJestFnCallResultData = (result: TestParsedJestFnCall) => ({
112 | data: JSON.stringify({
113 | name: result.name,
114 | type: result.type,
115 | head: result.head,
116 | members: result.members,
117 | }),
118 | });
119 |
120 | ruleTester.run('nonexistent methods', rule, {
121 | valid: [
122 | 'describe.something()',
123 | 'describe.me()',
124 | 'test.me()',
125 | 'it.fails()',
126 | 'context()',
127 | 'context.each``()',
128 | 'context.each()',
129 | 'describe.context()',
130 | 'describe.concurrent()()',
131 | 'describe.concurrent``()',
132 | 'describe.every``()',
133 | '/regex/.test()',
134 | '"something".describe()',
135 | '[].describe()',
136 | 'new describe().only()',
137 | '``.test()',
138 | 'test.only``()',
139 | 'test``.only()',
140 | ],
141 | invalid: [],
142 | });
143 |
144 | ruleTester.run('expect', rule, {
145 | valid: [
146 | {
147 | code: dedent`
148 | import { expect } from './test-utils';
149 |
150 | expect(x).toBe(y);
151 | `,
152 | parserOptions: { sourceType: 'module' },
153 | },
154 | {
155 | code: dedent`
156 | import { expect } from '@jest/globals';
157 |
158 | expect(x).not.resolves.toBe(x);
159 | `,
160 | parserOptions: { sourceType: 'module' },
161 | },
162 | // {
163 | // code: dedent`
164 | // import { expect } from '@jest/globals';
165 | //
166 | // expect(x).not().toBe(x);
167 | // `,
168 | // parserOptions: { sourceType: 'module' },
169 | // },
170 | {
171 | code: dedent`
172 | import { expect } from '@jest/globals';
173 |
174 | expect(x).is.toBe(x);
175 | `,
176 | parserOptions: { sourceType: 'module' },
177 | },
178 | {
179 | code: dedent`
180 | import { expect } from '@jest/globals';
181 |
182 | expect;
183 | expect(x);
184 | expect(x).toBe;
185 | expect(x).not.toBe;
186 | //expect(x).toBe(x).not();
187 | `,
188 | parserOptions: { sourceType: 'module' },
189 | },
190 | ],
191 | invalid: [
192 | {
193 | code: 'expect(x).toBe(y);',
194 | parserOptions: { sourceType: 'script' },
195 | errors: [
196 | {
197 | messageId: 'details' as const,
198 | data: expectedParsedJestFnCallResultData({
199 | name: 'expect',
200 | type: 'expect',
201 | head: {
202 | original: null,
203 | local: 'expect',
204 | type: 'global',
205 | node: 'expect',
206 | },
207 | members: ['toBe'],
208 | }),
209 | column: 1,
210 | line: 1,
211 | },
212 | ],
213 | },
214 | {
215 | code: dedent`
216 | import { expect } from '@jest/globals';
217 |
218 | expect.assertions();
219 | `,
220 | parserOptions: { sourceType: 'module' },
221 | errors: [
222 | {
223 | messageId: 'details' as const,
224 | data: expectedParsedJestFnCallResultData({
225 | name: 'expect',
226 | type: 'expect',
227 | head: {
228 | original: 'expect',
229 | local: 'expect',
230 | type: 'import',
231 | node: 'expect',
232 | },
233 | members: ['assertions'],
234 | }),
235 | column: 1,
236 | line: 3,
237 | },
238 | ],
239 | },
240 | {
241 | code: dedent`
242 | import { expect } from '@jest/globals';
243 |
244 | expect(x).toBe(y);
245 | `,
246 | parserOptions: { sourceType: 'module' },
247 | errors: [
248 | {
249 | messageId: 'details' as const,
250 | data: expectedParsedJestFnCallResultData({
251 | name: 'expect',
252 | type: 'expect',
253 | head: {
254 | original: 'expect',
255 | local: 'expect',
256 | type: 'import',
257 | node: 'expect',
258 | },
259 | members: ['toBe'],
260 | }),
261 | column: 1,
262 | line: 3,
263 | },
264 | ],
265 | },
266 | {
267 | code: dedent`
268 | import { expect } from '@jest/globals';
269 |
270 | expect(x).not(y);
271 | `,
272 | parserOptions: { sourceType: 'module' },
273 | errors: [
274 | {
275 | messageId: 'details' as const,
276 | data: expectedParsedJestFnCallResultData({
277 | name: 'expect',
278 | type: 'expect',
279 | head: {
280 | original: 'expect',
281 | local: 'expect',
282 | type: 'import',
283 | node: 'expect',
284 | },
285 | members: ['not'],
286 | }),
287 | column: 1,
288 | line: 3,
289 | },
290 | ],
291 | },
292 | {
293 | code: dedent`
294 | import { expect } from '@jest/globals';
295 |
296 | expect(x).not.toBe(y);
297 | `,
298 | parserOptions: { sourceType: 'module' },
299 | errors: [
300 | {
301 | messageId: 'details' as const,
302 | data: expectedParsedJestFnCallResultData({
303 | name: 'expect',
304 | type: 'expect',
305 | head: {
306 | original: 'expect',
307 | local: 'expect',
308 | type: 'import',
309 | node: 'expect',
310 | },
311 | members: ['not', 'toBe'],
312 | }),
313 | column: 1,
314 | line: 3,
315 | },
316 | ],
317 | },
318 | {
319 | code: dedent`
320 | import { expect } from '@jest/globals';
321 |
322 | expect.assertions();
323 | expect.hasAssertions();
324 | expect.anything();
325 | expect.not.arrayContaining();
326 | `,
327 | parserOptions: { sourceType: 'module' },
328 | errors: [
329 | {
330 | messageId: 'details' as const,
331 | data: expectedParsedJestFnCallResultData({
332 | name: 'expect',
333 | type: 'expect',
334 | head: {
335 | original: 'expect',
336 | local: 'expect',
337 | type: 'import',
338 | node: 'expect',
339 | },
340 | members: ['assertions'],
341 | }),
342 | column: 1,
343 | line: 3,
344 | },
345 | {
346 | messageId: 'details' as const,
347 | data: expectedParsedJestFnCallResultData({
348 | name: 'expect',
349 | type: 'expect',
350 | head: {
351 | original: 'expect',
352 | local: 'expect',
353 | type: 'import',
354 | node: 'expect',
355 | },
356 | members: ['hasAssertions'],
357 | }),
358 | column: 1,
359 | line: 4,
360 | },
361 | {
362 | messageId: 'details' as const,
363 | data: expectedParsedJestFnCallResultData({
364 | name: 'expect',
365 | type: 'expect',
366 | head: {
367 | original: 'expect',
368 | local: 'expect',
369 | type: 'import',
370 | node: 'expect',
371 | },
372 | members: ['anything'],
373 | }),
374 | column: 1,
375 | line: 5,
376 | },
377 | {
378 | messageId: 'details' as const,
379 | data: expectedParsedJestFnCallResultData({
380 | name: 'expect',
381 | type: 'expect',
382 | head: {
383 | original: 'expect',
384 | local: 'expect',
385 | type: 'import',
386 | node: 'expect',
387 | },
388 | members: ['not', 'arrayContaining'],
389 | }),
390 | column: 1,
391 | line: 6,
392 | },
393 | ],
394 | },
395 | ],
396 | });
397 |
398 | ruleTester.run('esm', rule, {
399 | valid: [
400 | {
401 | code: dedent`
402 | import { it } from './test-utils';
403 |
404 | it('is not a jest function', () => {});
405 | `,
406 | parserOptions: { sourceType: 'module' },
407 | },
408 | {
409 | code: dedent`
410 | import { defineFeature, loadFeature } from "jest-cucumber";
411 |
412 | const feature = loadFeature("some/feature");
413 |
414 | defineFeature(feature, (test) => {
415 | test("A scenario", ({ given, when, then }) => {});
416 | });
417 | `,
418 | parserOptions: { sourceType: 'module' },
419 | },
420 | {
421 | code: dedent`
422 | import { describe } from './test-utils';
423 |
424 | describe('a function that is not from jest', () => {});
425 | `,
426 | parserOptions: { sourceType: 'module' },
427 | },
428 | {
429 | code: dedent`
430 | import { fn as it } from './test-utils';
431 |
432 | it('is not a jest function', () => {});
433 | `,
434 | parserOptions: { sourceType: 'module' },
435 | },
436 | {
437 | code: dedent`
438 | import * as jest from '@jest/globals';
439 | const { it } = jest;
440 |
441 | it('is not supported', () => {});
442 | `,
443 | parserOptions: { sourceType: 'module' },
444 | },
445 | {
446 | code: dedent`
447 | import ByDefault from './myfile';
448 |
449 | ByDefault.sayHello();
450 | `,
451 | parserOptions: { sourceType: 'module' },
452 | },
453 | {
454 | code: dedent`
455 | async function doSomething() {
456 | const build = await rollup(config);
457 | build.generate();
458 | }
459 | `,
460 | parserOptions: { sourceType: 'module', ecmaVersion: 2017 },
461 | },
462 | ],
463 | invalid: [],
464 | });
465 |
466 | if (eslintVersion >= 8) {
467 | ruleTester.run('esm (dynamic)', rule, {
468 | valid: [
469 | {
470 | code: dedent`
471 | const { it } = await import('./test-utils');
472 |
473 | it('is not a jest function', () => {});
474 | `,
475 | parserOptions: { sourceType: 'module', ecmaVersion: 2022 },
476 | },
477 | {
478 | code: dedent`
479 | const { it } = await import(\`./test-utils\`);
480 |
481 | it('is not a jest function', () => {});
482 | `,
483 | parserOptions: { sourceType: 'module', ecmaVersion: 2022 },
484 | },
485 | ],
486 | invalid: [
487 | {
488 | code: dedent`
489 | const { it } = await import("@jest/globals");
490 |
491 | it('is a jest function', () => {});
492 | `,
493 | parserOptions: { sourceType: 'module', ecmaVersion: 2022 },
494 | errors: [
495 | {
496 | messageId: 'details' as const,
497 | data: expectedParsedJestFnCallResultData({
498 | name: 'it',
499 | type: 'test',
500 | head: {
501 | original: 'it',
502 | local: 'it',
503 | type: 'import',
504 | node: 'it',
505 | },
506 | members: [],
507 | }),
508 | column: 1,
509 | line: 3,
510 | },
511 | ],
512 | },
513 | {
514 | code: dedent`
515 | const { it } = await import(\`@jest/globals\`);
516 |
517 | it('is a jest function', () => {});
518 | `,
519 | parserOptions: { sourceType: 'module', ecmaVersion: 2022 },
520 | errors: [
521 | {
522 | messageId: 'details' as const,
523 | data: expectedParsedJestFnCallResultData({
524 | name: 'it',
525 | type: 'test',
526 | head: {
527 | original: 'it',
528 | local: 'it',
529 | type: 'import',
530 | node: 'it',
531 | },
532 | members: [],
533 | }),
534 | column: 1,
535 | line: 3,
536 | },
537 | ],
538 | },
539 | ],
540 | });
541 | }
542 |
543 | ruleTester.run('cjs', rule, {
544 | valid: [
545 | {
546 | code: dedent`
547 | const { it } = require('./test-utils');
548 |
549 | it('is not a jest function', () => {});
550 | `,
551 | parserOptions: { sourceType: 'script' },
552 | },
553 | {
554 | code: dedent`
555 | const { it } = require(\`./test-utils\`);
556 |
557 | it('is not a jest function', () => {});
558 | `,
559 | parserOptions: { sourceType: 'script' },
560 | },
561 | {
562 | code: dedent`
563 | const { describe } = require('./test-utils');
564 |
565 | describe('a function that is not from jest', () => {});
566 | `,
567 | parserOptions: { sourceType: 'script' },
568 | },
569 | {
570 | code: dedent`
571 | const { fn: it } = require('./test-utils');
572 |
573 | it('is not a jest function', () => {});
574 | `,
575 | parserOptions: { sourceType: 'script' },
576 | },
577 | {
578 | code: dedent`
579 | const { fn: it } = require('@jest/globals');
580 |
581 | it('is not considered a test function', () => {});
582 | `,
583 | parserOptions: { sourceType: 'script' },
584 | },
585 | {
586 | code: dedent`
587 | const { it } = aliasedRequire('@jest/globals');
588 |
589 | it('is not considered a jest function', () => {});
590 | `,
591 | parserOptions: { sourceType: 'script' },
592 | },
593 | {
594 | code: dedent`
595 | const { it } = require();
596 |
597 | it('is not a jest function', () => {});
598 | `,
599 | parserOptions: { sourceType: 'script' },
600 | },
601 | {
602 | code: dedent`
603 | const { it } = require(pathToMyPackage);
604 |
605 | it('is not a jest function', () => {});
606 | `,
607 | parserOptions: { sourceType: 'script' },
608 | },
609 | {
610 | code: dedent`
611 | const { [() => {}]: it } = require('@jest/globals');
612 |
613 | it('is not a jest function', () => {});
614 | `,
615 | parserOptions: { sourceType: 'script' },
616 | },
617 | ],
618 | invalid: [],
619 | });
620 |
621 | ruleTester.run('global aliases', rule, {
622 | valid: [
623 | {
624 | code: 'xcontext("skip this please", () => {});',
625 | settings: { jest: { globalAliases: { describe: ['context'] } } },
626 | },
627 | ],
628 | invalid: [
629 | {
630 | code: 'context("when there is an error", () => {})',
631 | errors: [
632 | {
633 | messageId: 'details' as const,
634 | data: expectedParsedJestFnCallResultData({
635 | name: 'describe',
636 | type: 'describe',
637 | head: {
638 | original: 'describe',
639 | local: 'context',
640 | type: 'global',
641 | node: 'context',
642 | },
643 | members: [],
644 | }),
645 | column: 1,
646 | line: 1,
647 | },
648 | ],
649 | settings: { jest: { globalAliases: { describe: ['context'] } } },
650 | },
651 | {
652 | code: 'context.skip("when there is an error", () => {})',
653 | errors: [
654 | {
655 | messageId: 'details' as const,
656 | data: expectedParsedJestFnCallResultData({
657 | name: 'describe',
658 | type: 'describe',
659 | head: {
660 | original: 'describe',
661 | local: 'context',
662 | type: 'global',
663 | node: 'context',
664 | },
665 | members: ['skip'],
666 | }),
667 | column: 1,
668 | line: 1,
669 | },
670 | ],
671 | settings: { jest: { globalAliases: { describe: ['context'] } } },
672 | },
673 | {
674 | code: dedent`
675 | context("when there is an error", () => {})
676 | xcontext("skip this please", () => {});
677 | `,
678 | errors: [
679 | {
680 | messageId: 'details' as const,
681 | data: expectedParsedJestFnCallResultData({
682 | name: 'xdescribe',
683 | type: 'describe',
684 | head: {
685 | original: 'xdescribe',
686 | local: 'xcontext',
687 | type: 'global',
688 | node: 'xcontext',
689 | },
690 | members: [],
691 | }),
692 | column: 1,
693 | line: 2,
694 | },
695 | ],
696 | settings: { jest: { globalAliases: { xdescribe: ['xcontext'] } } },
697 | },
698 | {
699 | code: dedent`
700 | context("when there is an error", () => {})
701 | describe("when there is an error", () => {})
702 | xcontext("skip this please", () => {});
703 | `,
704 | errors: [
705 | {
706 | messageId: 'details' as const,
707 | data: expectedParsedJestFnCallResultData({
708 | name: 'describe',
709 | type: 'describe',
710 | head: {
711 | original: 'describe',
712 | local: 'context',
713 | type: 'global',
714 | node: 'context',
715 | },
716 | members: [],
717 | }),
718 | column: 1,
719 | line: 1,
720 | },
721 | {
722 | messageId: 'details' as const,
723 | data: expectedParsedJestFnCallResultData({
724 | name: 'describe',
725 | type: 'describe',
726 | head: {
727 | original: null,
728 | local: 'describe',
729 | type: 'global',
730 | node: 'describe',
731 | },
732 | members: [],
733 | }),
734 | column: 1,
735 | line: 2,
736 | },
737 | ],
738 | settings: { jest: { globalAliases: { describe: ['context'] } } },
739 | },
740 | ],
741 | });
742 |
743 | ruleTester.run('global package source', rule, {
744 | valid: [
745 | {
746 | code: dedent`
747 | import { expect } from 'bun:test'
748 |
749 | expect(x).toBe(y);
750 | `,
751 | parserOptions: { sourceType: 'module' },
752 | settings: { jest: { globalPackage: '@jest/globals' } },
753 | },
754 | {
755 | code: dedent`
756 | const { it } = require('@jest/globals');
757 |
758 | it('is not considered a test function', () => {});
759 | `,
760 | parserOptions: { sourceType: 'script' },
761 | settings: { jest: { globalPackage: 'bun:test' } },
762 | },
763 | {
764 | code: dedent`
765 | const { fn: it } = require('bun:test');
766 |
767 | it('is not considered a test function', () => {});
768 | `,
769 | parserOptions: { sourceType: 'script' },
770 | settings: { jest: { globalPackage: 'bun:test' } },
771 | },
772 | {
773 | code: dedent`
774 | import { it } from '@jest/globals';
775 |
776 | it('is not considered a test function', () => {});
777 | `,
778 | parserOptions: { sourceType: 'module' },
779 | settings: { jest: { globalPackage: 'bun:test' } },
780 | },
781 | {
782 | code: dedent`
783 | import { fn as it } from 'bun:test';
784 |
785 | it('is not considered a test function', () => {});
786 | `,
787 | parserOptions: { sourceType: 'module' },
788 | settings: { jest: { globalPackage: 'bun:test' } },
789 | },
790 | ],
791 | invalid: [
792 | {
793 | code: 'expect(x).toBe(y);',
794 | parserOptions: { sourceType: 'script' },
795 | errors: [
796 | {
797 | messageId: 'details' as const,
798 | data: expectedParsedJestFnCallResultData({
799 | name: 'expect',
800 | type: 'expect',
801 | head: {
802 | original: null,
803 | local: 'expect',
804 | type: 'global',
805 | node: 'expect',
806 | },
807 | members: ['toBe'],
808 | }),
809 | column: 1,
810 | line: 1,
811 | },
812 | ],
813 | settings: { jest: { globalPackage: 'bun:test' } },
814 | },
815 | {
816 | code: dedent`
817 | import { describe, expect, it } from 'bun:test'
818 |
819 | describe('some tests', () => {
820 | it('ensures something', () => {
821 | expect.assertions();
822 | });
823 | });
824 | `,
825 | parserOptions: { sourceType: 'module' },
826 | errors: [
827 | {
828 | messageId: 'details' as const,
829 | data: expectedParsedJestFnCallResultData({
830 | name: 'describe',
831 | type: 'describe',
832 | head: {
833 | original: 'describe',
834 | local: 'describe',
835 | type: 'import',
836 | node: 'describe',
837 | },
838 | members: [],
839 | }),
840 | column: 1,
841 | line: 3,
842 | },
843 | {
844 | messageId: 'details' as const,
845 | data: expectedParsedJestFnCallResultData({
846 | name: 'it',
847 | type: 'test',
848 | head: {
849 | original: 'it',
850 | local: 'it',
851 | type: 'import',
852 | node: 'it',
853 | },
854 | members: [],
855 | }),
856 | column: 3,
857 | line: 4,
858 | },
859 | {
860 | messageId: 'details' as const,
861 | data: expectedParsedJestFnCallResultData({
862 | name: 'expect',
863 | type: 'expect',
864 | head: {
865 | original: 'expect',
866 | local: 'expect',
867 | type: 'import',
868 | node: 'expect',
869 | },
870 | members: ['assertions'],
871 | }),
872 | column: 5,
873 | line: 5,
874 | },
875 | ],
876 | settings: { jest: { globalPackage: 'bun:test' } },
877 | },
878 | {
879 | code: dedent`
880 | import { expect } from 'bun:test'
881 |
882 | expect(x).not.toBe(y);
883 | `,
884 | parserOptions: { sourceType: 'module' },
885 | errors: [
886 | {
887 | messageId: 'details' as const,
888 | data: expectedParsedJestFnCallResultData({
889 | name: 'expect',
890 | type: 'expect',
891 | head: {
892 | original: 'expect',
893 | local: 'expect',
894 | type: 'import',
895 | node: 'expect',
896 | },
897 | members: ['not', 'toBe'],
898 | }),
899 | column: 1,
900 | line: 3,
901 | },
902 | ],
903 | settings: { jest: { globalPackage: 'bun:test' } },
904 | },
905 | {
906 | code: 'context("when there is an error", () => {})',
907 | errors: [
908 | {
909 | messageId: 'details' as const,
910 | data: expectedParsedJestFnCallResultData({
911 | name: 'describe',
912 | type: 'describe',
913 | head: {
914 | original: 'describe',
915 | local: 'context',
916 | type: 'global',
917 | node: 'context',
918 | },
919 | members: [],
920 | }),
921 | column: 1,
922 | line: 1,
923 | },
924 | ],
925 | settings: {
926 | jest: {
927 | globalPackage: 'bun:test',
928 | globalAliases: { describe: ['context'] },
929 | },
930 | },
931 | },
932 | ],
933 | });
934 |
935 | ruleTester.run('typescript', rule, {
936 | valid: [
937 | {
938 | code: dedent`
939 | const { test };
940 |
941 | test('is not a jest function', () => {});
942 | `,
943 | parser: require.resolve('@typescript-eslint/parser'),
944 | },
945 | {
946 | code: dedent`
947 | import type { it } from '@jest/globals';
948 |
949 | it('is not a jest function', () => {});
950 | `,
951 | parser: require.resolve('@typescript-eslint/parser'),
952 | parserOptions: { sourceType: 'module' },
953 | },
954 | {
955 | code: dedent`
956 | import jest = require('@jest/globals');
957 | const { it } = jest;
958 |
959 | it('is not a jest function', () => {});
960 | `,
961 | parser: require.resolve('@typescript-eslint/parser'),
962 | parserOptions: { sourceType: 'module' },
963 | },
964 | {
965 | code: dedent`
966 | function it(message: string, fn: () => void): void;
967 | function it(cases: unknown[], message: string, fn: () => void): void;
968 | function it(...all: any[]): void {}
969 |
970 | it('is not a jest function', () => {});
971 | `,
972 | parser: require.resolve('@typescript-eslint/parser'),
973 | parserOptions: { sourceType: 'module' },
974 | },
975 | {
976 | code: dedent`
977 | interface it {}
978 | function it(...all: any[]): void {}
979 |
980 | it('is not a jest function', () => {});
981 | `,
982 | parser: require.resolve('@typescript-eslint/parser'),
983 | parserOptions: { sourceType: 'module' },
984 | },
985 | {
986 | code: dedent`
987 | import { it } from '@jest/globals';
988 | import { it } from '../it-utils';
989 |
990 | it('is not a jest function', () => {});
991 | `,
992 | parser: require.resolve('@typescript-eslint/parser'),
993 | parserOptions: { sourceType: 'module' },
994 | },
995 | {
996 | code: dedent`
997 | import dedent = require('dedent');
998 |
999 | dedent();
1000 | `,
1001 | parser: require.resolve('@typescript-eslint/parser'),
1002 | },
1003 | ],
1004 | invalid: [
1005 | {
1006 | code: dedent`
1007 | import { it } from '../it-utils';
1008 | import { it } from '@jest/globals';
1009 |
1010 | it('is a jest function', () => {});
1011 | `,
1012 | parser: require.resolve('@typescript-eslint/parser'),
1013 | parserOptions: { sourceType: 'module' },
1014 | errors: [
1015 | {
1016 | messageId: 'details' as const,
1017 | data: expectedParsedJestFnCallResultData({
1018 | name: 'it',
1019 | type: 'test',
1020 | head: {
1021 | original: 'it',
1022 | local: 'it',
1023 | type: 'import',
1024 | node: 'it',
1025 | },
1026 | members: [],
1027 | }),
1028 | column: 1,
1029 | line: 4,
1030 | },
1031 | ],
1032 | },
1033 | ],
1034 | });
1035 |
1036 | ruleTester.run('misc', rule, {
1037 | valid: [
1038 | 'var spyOn = require("actions"); spyOn("foo")',
1039 | 'test().finally()',
1040 | 'expect(true).not.not.toBeDefined();',
1041 | 'expect(true).resolves.not.exactly.toBeDefined();',
1042 | ],
1043 | invalid: [
1044 | {
1045 | code: 'beforeEach(() => {});',
1046 | errors: [
1047 | {
1048 | messageId: 'details' as const,
1049 | data: expectedParsedJestFnCallResultData({
1050 | name: 'beforeEach',
1051 | type: 'hook',
1052 | head: {
1053 | original: null,
1054 | local: 'beforeEach',
1055 | type: 'global',
1056 | node: 'beforeEach',
1057 | },
1058 | members: [],
1059 | }),
1060 | column: 1,
1061 | line: 1,
1062 | },
1063 | ],
1064 | },
1065 | {
1066 | code: 'jest.spyOn(console, "log");',
1067 | errors: [
1068 | {
1069 | messageId: 'details' as const,
1070 | data: expectedParsedJestFnCallResultData({
1071 | name: 'jest',
1072 | type: 'jest',
1073 | head: {
1074 | original: null,
1075 | local: 'jest',
1076 | type: 'global',
1077 | node: 'jest',
1078 | },
1079 | members: ['spyOn'],
1080 | }),
1081 | column: 1,
1082 | line: 1,
1083 | },
1084 | ],
1085 | },
1086 | {
1087 | code: dedent`
1088 | test('valid-expect-in-promise', async () => {
1089 | const text = await fetch('url')
1090 | .then(res => res.text())
1091 | .then(text => text);
1092 |
1093 | expect(text).toBe('text');
1094 | });
1095 | `,
1096 | parserOptions: { ecmaVersion: 2017 },
1097 | errors: [
1098 | {
1099 | messageId: 'details' as const,
1100 | data: expectedParsedJestFnCallResultData({
1101 | name: 'test',
1102 | type: 'test',
1103 | head: {
1104 | original: null,
1105 | local: 'test',
1106 | type: 'global',
1107 | node: 'test',
1108 | },
1109 | members: [],
1110 | }),
1111 | column: 1,
1112 | line: 1,
1113 | },
1114 | {
1115 | messageId: 'details' as const,
1116 | data: expectedParsedJestFnCallResultData({
1117 | name: 'expect',
1118 | type: 'expect',
1119 | head: {
1120 | original: null,
1121 | local: 'expect',
1122 | type: 'global',
1123 | node: 'expect',
1124 | },
1125 | members: ['toBe'],
1126 | }),
1127 | column: 3,
1128 | line: 6,
1129 | },
1130 | ],
1131 | },
1132 | ],
1133 | });
1134 |
--------------------------------------------------------------------------------
/src/rules/utils/accessors.ts:
--------------------------------------------------------------------------------
1 | import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
2 |
3 | /**
4 | * A `Literal` with a `value` of type `string`.
5 | */
6 | interface StringLiteral
7 | extends TSESTree.StringLiteral {
8 | value: Value;
9 | }
10 |
11 | /**
12 | * Checks if the given `node` is a `StringLiteral`.
13 | *
14 | * If a `value` is provided & the `node` is a `StringLiteral`,
15 | * the `value` will be compared to that of the `StringLiteral`.
16 | *
17 | * @param {Node} node
18 | * @param {V} [value]
19 | *
20 | * @return {node is StringLiteral}
21 | *
22 | * @template V
23 | */
24 | const isStringLiteral = (
25 | node: TSESTree.Node,
26 | value?: V,
27 | ): node is StringLiteral =>
28 | node.type === AST_NODE_TYPES.Literal &&
29 | typeof node.value === 'string' &&
30 | (value === undefined || node.value === value);
31 |
32 | interface TemplateLiteral
33 | extends TSESTree.TemplateLiteral {
34 | quasis: [TSESTree.TemplateElement & { value: { raw: Value; cooked: Value } }];
35 | }
36 |
37 | /**
38 | * Checks if the given `node` is a `TemplateLiteral`.
39 | *
40 | * Complex `TemplateLiteral`s are not considered specific, and so will return `false`.
41 | *
42 | * If a `value` is provided & the `node` is a `TemplateLiteral`,
43 | * the `value` will be compared to that of the `TemplateLiteral`.
44 | *
45 | * @param {Node} node
46 | * @param {V} [value]
47 | *
48 | * @return {node is TemplateLiteral}
49 | *
50 | * @template V
51 | */
52 | const isTemplateLiteral = (
53 | node: TSESTree.Node,
54 | value?: V,
55 | ): node is TemplateLiteral =>
56 | node.type === AST_NODE_TYPES.TemplateLiteral &&
57 | node.quasis.length === 1 && // bail out if not simple
58 | (value === undefined || node.quasis[0].value.raw === value);
59 |
60 | export type StringNode =
61 | | StringLiteral
62 | | TemplateLiteral;
63 |
64 | /**
65 | * Checks if the given `node` is a {@link StringNode}.
66 | *
67 | * @param {Node} node
68 | * @param {V} [specifics]
69 | *
70 | * @return {node is StringNode}
71 | *
72 | * @template V
73 | */
74 | export const isStringNode = (
75 | node: TSESTree.Node,
76 | specifics?: V,
77 | ): node is StringNode =>
78 | isStringLiteral(node, specifics) || isTemplateLiteral(node, specifics);
79 |
80 | /**
81 | * Gets the value of the given `StringNode`.
82 | *
83 | * If the `node` is a `TemplateLiteral`, the `raw` value is used;
84 | * otherwise, `value` is returned instead.
85 | *
86 | * @param {StringNode} node
87 | *
88 | * @return {S}
89 | *
90 | * @template S
91 | */
92 | export const getStringValue = (node: StringNode): S =>
93 | isTemplateLiteral(node) ? node.quasis[0].value.raw : node.value;
94 |
95 | /**
96 | * An `Identifier` with a known `name` value - i.e `expect`.
97 | */
98 | interface KnownIdentifier extends TSESTree.Identifier {
99 | name: Name;
100 | }
101 |
102 | /**
103 | * Checks if the given `node` is an `Identifier`.
104 | *
105 | * If a `name` is provided, & the `node` is an `Identifier`,
106 | * the `name` will be compared to that of the `identifier`.
107 | *
108 | * @param {Node} node
109 | * @param {V} [name]
110 | *
111 | * @return {node is KnownIdentifier}
112 | *
113 | * @template V
114 | */
115 | export const isIdentifier = (
116 | node: TSESTree.Node,
117 | name?: V,
118 | ): node is KnownIdentifier =>
119 | node.type === AST_NODE_TYPES.Identifier &&
120 | (name === undefined || node.name === name);
121 |
122 | /**
123 | * Checks if the given `node` is a "supported accessor".
124 | *
125 | * This means that it's a node can be used to access properties,
126 | * and who's "value" can be statically determined.
127 | *
128 | * `MemberExpression` nodes most commonly contain accessors,
129 | * but it's possible for other nodes to contain them.
130 | *
131 | * If a `value` is provided & the `node` is an `AccessorNode`,
132 | * the `value` will be compared to that of the `AccessorNode`.
133 | *
134 | * Note that `value` here refers to the normalised value.
135 | * The property that holds the value is not always called `name`.
136 | *
137 | * @param {Node} node
138 | * @param {V} [value]
139 | *
140 | * @return {node is AccessorNode}
141 | *
142 | * @template V
143 | */
144 | export const isSupportedAccessor = (
145 | node: TSESTree.Node,
146 | value?: V,
147 | ): node is AccessorNode =>
148 | isIdentifier(node, value) || isStringNode(node, value);
149 |
150 | /**
151 | * Gets the value of the given `AccessorNode`,
152 | * account for the different node types.
153 | *
154 | * @param {AccessorNode} accessor
155 | *
156 | * @return {S}
157 | *
158 | * @template S
159 | */
160 | export const getAccessorValue = (
161 | accessor: AccessorNode,
162 | ): S =>
163 | accessor.type === AST_NODE_TYPES.Identifier
164 | ? accessor.name
165 | : getStringValue(accessor);
166 |
167 | export type AccessorNode =
168 | | StringNode
169 | | KnownIdentifier;
170 |
--------------------------------------------------------------------------------
/src/rules/utils/followTypeAssertionChain.ts:
--------------------------------------------------------------------------------
1 | import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
2 |
3 | export type MaybeTypeCast =
4 | | TSTypeCastExpression
5 | | Expression;
6 |
7 | type TSTypeCastExpression<
8 | Expression extends TSESTree.Expression = TSESTree.Expression,
9 | > = AsExpressionChain | TypeAssertionChain;
10 |
11 | interface AsExpressionChain<
12 | Expression extends TSESTree.Expression = TSESTree.Expression,
13 | > extends TSESTree.TSAsExpression {
14 | expression: AsExpressionChain | Expression;
15 | }
16 |
17 | interface TypeAssertionChain<
18 | Expression extends TSESTree.Expression = TSESTree.Expression,
19 | > extends TSESTree.TSTypeAssertion {
20 | expression: TypeAssertionChain | Expression;
21 | }
22 |
23 | const isTypeCastExpression = (
24 | node: MaybeTypeCast,
25 | ): node is TSTypeCastExpression =>
26 | node.type === AST_NODE_TYPES.TSAsExpression ||
27 | node.type === AST_NODE_TYPES.TSTypeAssertion;
28 |
29 | export const followTypeAssertionChain = <
30 | Expression extends TSESTree.Expression,
31 | >(
32 | expression: MaybeTypeCast,
33 | ): Expression =>
34 | isTypeCastExpression(expression)
35 | ? followTypeAssertionChain(expression.expression)
36 | : expression;
37 |
--------------------------------------------------------------------------------
/src/rules/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './accessors';
2 | export * from './followTypeAssertionChain';
3 | export * from './misc';
4 | export * from './parseJestFnCall';
5 |
--------------------------------------------------------------------------------
/src/rules/utils/misc.ts:
--------------------------------------------------------------------------------
1 | import { parse as parsePath } from 'path';
2 | import {
3 | AST_NODE_TYPES,
4 | ESLintUtils,
5 | type TSESTree,
6 | } from '@typescript-eslint/utils';
7 | import { repository, version } from '../../../package.json';
8 | import {
9 | type AccessorNode,
10 | getAccessorValue,
11 | isSupportedAccessor,
12 | } from './accessors';
13 | import { followTypeAssertionChain } from './followTypeAssertionChain';
14 | import type { ParsedExpectFnCall } from './parseJestFnCall';
15 |
16 | export const createRule = ESLintUtils.RuleCreator(name => {
17 | const ruleName = parsePath(name).name;
18 |
19 | return `${repository}/blob/v${version}/docs/rules/${ruleName}.md`;
20 | });
21 |
22 | /**
23 | * Represents a `MemberExpression` with a "known" `property`.
24 | */
25 | export interface KnownMemberExpression
26 | extends TSESTree.MemberExpressionComputedName {
27 | property: AccessorNode;
28 | }
29 |
30 | /**
31 | * Represents a `CallExpression` with a "known" `property` accessor.
32 | *
33 | * i.e `KnownCallExpression<'includes'>` represents `.includes()`.
34 | */
35 | export interface KnownCallExpression
36 | extends TSESTree.CallExpression {
37 | callee: CalledKnownMemberExpression;
38 | }
39 |
40 | /**
41 | * Represents a `MemberExpression` with a "known" `property`, that is called.
42 | *
43 | * This is `KnownCallExpression` from the perspective of the `MemberExpression` node.
44 | */
45 | interface CalledKnownMemberExpression
46 | extends KnownMemberExpression {
47 | parent: KnownCallExpression;
48 | }
49 |
50 | export enum DescribeAlias {
51 | 'describe' = 'describe',
52 | 'fdescribe' = 'fdescribe',
53 | 'xdescribe' = 'xdescribe',
54 | }
55 |
56 | export enum TestCaseName {
57 | 'fit' = 'fit',
58 | 'it' = 'it',
59 | 'test' = 'test',
60 | 'xit' = 'xit',
61 | 'xtest' = 'xtest',
62 | }
63 |
64 | export enum HookName {
65 | 'beforeAll' = 'beforeAll',
66 | 'beforeEach' = 'beforeEach',
67 | 'afterAll' = 'afterAll',
68 | 'afterEach' = 'afterEach',
69 | }
70 |
71 | export enum ModifierName {
72 | not = 'not',
73 | rejects = 'rejects',
74 | resolves = 'resolves',
75 | }
76 |
77 | export enum EqualityMatcher {
78 | toBe = 'toBe',
79 | toEqual = 'toEqual',
80 | toStrictEqual = 'toStrictEqual',
81 | }
82 |
83 | export const findTopMostCallExpression = (
84 | node: TSESTree.CallExpression,
85 | ): TSESTree.CallExpression => {
86 | let topMostCallExpression = node;
87 | let { parent } = node;
88 |
89 | while (parent) {
90 | if (parent.type === AST_NODE_TYPES.CallExpression) {
91 | topMostCallExpression = parent;
92 |
93 | parent = parent.parent;
94 |
95 | continue;
96 | }
97 |
98 | if (parent.type !== AST_NODE_TYPES.MemberExpression) {
99 | break;
100 | }
101 |
102 | parent = parent.parent;
103 | }
104 |
105 | return topMostCallExpression;
106 | };
107 |
108 | export const isBooleanLiteral = (
109 | node: TSESTree.Node,
110 | ): node is TSESTree.BooleanLiteral =>
111 | node.type === AST_NODE_TYPES.Literal && typeof node.value === 'boolean';
112 |
113 | export const getFirstMatcherArg = (
114 | expectFnCall: ParsedExpectFnCall,
115 | ): TSESTree.SpreadElement | TSESTree.Expression => {
116 | const [firstArg] = expectFnCall.args;
117 |
118 | if (firstArg.type === AST_NODE_TYPES.SpreadElement) {
119 | return firstArg;
120 | }
121 |
122 | return followTypeAssertionChain(firstArg);
123 | };
124 |
125 | export const isInstanceOfBinaryExpression = (
126 | node: TSESTree.Node,
127 | className: string,
128 | ): node is TSESTree.BinaryExpression =>
129 | node.type === AST_NODE_TYPES.BinaryExpression &&
130 | node.operator === 'instanceof' &&
131 | isSupportedAccessor(node.right, className);
132 |
133 | export const isParsedInstanceOfMatcherCall = (
134 | expectFnCall: ParsedExpectFnCall,
135 | classArg?: string,
136 | ): boolean => {
137 | return (
138 | getAccessorValue(expectFnCall.matcher) === 'toBeInstanceOf' &&
139 | expectFnCall.args.length === 1 &&
140 | isSupportedAccessor(expectFnCall.args[0], classArg)
141 | );
142 | };
143 |
144 | /**
145 | * Checks if the given `ParsedExpectMatcher` is either a call to one of the equality matchers,
146 | * with a boolean` literal as the sole argument, *or* is a call to `toBeTrue` or `toBeFalse`.
147 | */
148 | export const isBooleanEqualityMatcher = (
149 | expectFnCall: ParsedExpectFnCall,
150 | ): boolean => {
151 | const matcherName = getAccessorValue(expectFnCall.matcher);
152 |
153 | if (['toBeTrue', 'toBeFalse'].includes(matcherName)) {
154 | return true;
155 | }
156 |
157 | if (expectFnCall.args.length !== 1) {
158 | return false;
159 | }
160 |
161 | const arg = getFirstMatcherArg(expectFnCall);
162 |
163 | return EqualityMatcher.hasOwnProperty(matcherName) && isBooleanLiteral(arg);
164 | };
165 |
--------------------------------------------------------------------------------
/src/rules/utils/parseJestFnCall.ts:
--------------------------------------------------------------------------------
1 | import {
2 | AST_NODE_TYPES,
3 | type TSESLint,
4 | type TSESTree,
5 | } from '@typescript-eslint/utils';
6 | import {
7 | type AccessorNode,
8 | DescribeAlias,
9 | HookName,
10 | type KnownMemberExpression,
11 | ModifierName,
12 | TestCaseName,
13 | findTopMostCallExpression,
14 | getAccessorValue,
15 | getStringValue,
16 | isIdentifier,
17 | isStringNode,
18 | isSupportedAccessor,
19 | } from '../utils';
20 |
21 | const joinChains = (
22 | a: AccessorNode[] | null,
23 | b: AccessorNode[] | null,
24 | ): AccessorNode[] | null => (a && b ? [...a, ...b] : null);
25 |
26 | export function getNodeChain(node: TSESTree.Node): AccessorNode[] | null {
27 | if (isSupportedAccessor(node)) {
28 | return [node];
29 | }
30 |
31 | switch (node.type) {
32 | case AST_NODE_TYPES.TaggedTemplateExpression:
33 | return getNodeChain(node.tag);
34 | case AST_NODE_TYPES.MemberExpression:
35 | return joinChains(getNodeChain(node.object), getNodeChain(node.property));
36 | case AST_NODE_TYPES.CallExpression:
37 | return getNodeChain(node.callee);
38 | }
39 |
40 | return null;
41 | }
42 |
43 | export interface ResolvedJestFnWithNode extends ResolvedJestFn {
44 | node: AccessorNode;
45 | }
46 |
47 | type JestFnType = 'hook' | 'describe' | 'test' | 'expect' | 'jest' | 'unknown';
48 |
49 | const determineJestFnType = (name: string): JestFnType => {
50 | if (name === 'expect') {
51 | return 'expect';
52 | }
53 |
54 | if (name === 'jest') {
55 | return 'jest';
56 | }
57 |
58 | if (DescribeAlias.hasOwnProperty(name)) {
59 | return 'describe';
60 | }
61 |
62 | if (TestCaseName.hasOwnProperty(name)) {
63 | return 'test';
64 | }
65 |
66 | /* istanbul ignore else */
67 | if (HookName.hasOwnProperty(name)) {
68 | return 'hook';
69 | }
70 |
71 | /* istanbul ignore next */
72 | return 'unknown';
73 | };
74 |
75 | interface BaseParsedJestFnCall {
76 | /**
77 | * The name of the underlying Jest function that is being called.
78 | * This is the result of `(head.original ?? head.local)`.
79 | */
80 | name: string;
81 | type: JestFnType;
82 | head: ResolvedJestFnWithNode;
83 | members: KnownMemberExpressionProperty[];
84 | }
85 |
86 | interface ParsedGeneralJestFnCall extends BaseParsedJestFnCall {
87 | type: Exclude;
88 | }
89 |
90 | export interface ParsedExpectFnCall
91 | extends BaseParsedJestFnCall,
92 | ModifiersAndMatcher {
93 | type: 'expect';
94 | }
95 |
96 | export type ParsedJestFnCall = ParsedGeneralJestFnCall | ParsedExpectFnCall;
97 |
98 | const ValidJestFnCallChains = [
99 | 'afterAll',
100 | 'afterEach',
101 | 'beforeAll',
102 | 'beforeEach',
103 | 'describe',
104 | 'describe.each',
105 | 'describe.only',
106 | 'describe.only.each',
107 | 'describe.skip',
108 | 'describe.skip.each',
109 | 'fdescribe',
110 | 'fdescribe.each',
111 | 'xdescribe',
112 | 'xdescribe.each',
113 | 'it',
114 | 'it.concurrent',
115 | 'it.concurrent.failing',
116 | 'it.concurrent.each',
117 | 'it.concurrent.failing.each',
118 | 'it.concurrent.failing.only.each',
119 | 'it.concurrent.failing.skip.each',
120 | 'it.concurrent.only.each',
121 | 'it.concurrent.skip.each',
122 | 'it.each',
123 | 'it.failing',
124 | 'it.failing.each',
125 | 'it.only',
126 | 'it.only.each',
127 | 'it.only.failing',
128 | 'it.only.failing.each',
129 | 'it.skip',
130 | 'it.skip.each',
131 | 'it.skip.failing',
132 | 'it.skip.failing.each',
133 | 'it.todo',
134 | 'fit',
135 | 'fit.each',
136 | 'fit.failing',
137 | 'fit.failing.each',
138 | 'xit',
139 | 'xit.each',
140 | 'xit.failing',
141 | 'xit.failing.each',
142 | 'test',
143 | 'test.concurrent',
144 | 'test.concurrent.failing',
145 | 'test.concurrent.each',
146 | 'test.concurrent.failing.each',
147 | 'test.concurrent.failing.only.each',
148 | 'test.concurrent.failing.skip.each',
149 | 'test.concurrent.only.each',
150 | 'test.concurrent.skip.each',
151 | 'test.each',
152 | 'test.failing',
153 | 'test.failing.each',
154 | 'test.only',
155 | 'test.only.each',
156 | 'test.only.failing',
157 | 'test.only.failing.each',
158 | 'test.skip',
159 | 'test.skip.each',
160 | 'test.skip.failing',
161 | 'test.skip.failing.each',
162 | 'test.todo',
163 | 'xtest',
164 | 'xtest.each',
165 | 'xtest.failing',
166 | 'xtest.failing.each',
167 | ];
168 |
169 | // todo: switch back to using declaration merging once https://github.com/typescript-eslint/typescript-eslint/pull/8485
170 | // is landed
171 | interface SharedConfigurationSettings {
172 | jest?: {
173 | globalAliases?: Record;
174 | globalPackage?: string;
175 | version?: number | string;
176 | };
177 | }
178 |
179 | const resolvePossibleAliasedGlobal = (
180 | global: string,
181 | context: TSESLint.RuleContext,
182 | ) => {
183 | const globalAliases =
184 | (context.settings as SharedConfigurationSettings).jest?.globalAliases ?? {};
185 |
186 | const alias = Object.entries(globalAliases).find(([, aliases]) =>
187 | aliases.includes(global),
188 | );
189 |
190 | if (alias) {
191 | return alias[0];
192 | }
193 |
194 | return null;
195 | };
196 |
197 | const parseJestFnCallCache = new WeakMap<
198 | TSESTree.CallExpression,
199 | ParsedJestFnCall | string | null
200 | >();
201 |
202 | export const parseJestFnCall = (
203 | node: TSESTree.CallExpression,
204 | context: TSESLint.RuleContext,
205 | ): ParsedJestFnCall | null => {
206 | const jestFnCall = parseJestFnCallWithReason(node, context);
207 |
208 | if (typeof jestFnCall === 'string') {
209 | return null;
210 | }
211 |
212 | return jestFnCall;
213 | };
214 |
215 | export const parseJestFnCallWithReason = (
216 | node: TSESTree.CallExpression,
217 | context: TSESLint.RuleContext,
218 | ): ParsedJestFnCall | string | null => {
219 | let parsedJestFnCall = parseJestFnCallCache.get(node);
220 |
221 | /* istanbul ignore next */
222 | if (parsedJestFnCall) {
223 | return parsedJestFnCall;
224 | }
225 |
226 | parsedJestFnCall = parseJestFnCallWithReasonInner(node, context);
227 |
228 | parseJestFnCallCache.set(node, parsedJestFnCall);
229 |
230 | return parsedJestFnCall;
231 | };
232 |
233 | const parseJestFnCallWithReasonInner = (
234 | node: TSESTree.CallExpression,
235 | context: TSESLint.RuleContext,
236 | ): ParsedJestFnCall | string | null => {
237 | const chain = getNodeChain(node);
238 |
239 | if (!chain?.length) {
240 | return null;
241 | }
242 |
243 | const [first, ...rest] = chain;
244 |
245 | const lastLink = getAccessorValue(chain[chain.length - 1]);
246 |
247 | // if we're an `each()`, ensure we're the outer CallExpression (i.e `.each()()`)
248 | if (lastLink === 'each') {
249 | if (
250 | node.callee.type !== AST_NODE_TYPES.CallExpression &&
251 | node.callee.type !== AST_NODE_TYPES.TaggedTemplateExpression
252 | ) {
253 | return null;
254 | }
255 | }
256 |
257 | if (
258 | node.callee.type === AST_NODE_TYPES.TaggedTemplateExpression &&
259 | lastLink !== 'each'
260 | ) {
261 | return null;
262 | }
263 |
264 | const resolved = resolveToJestFn(context, first);
265 |
266 | // we're not a jest function
267 | if (!resolved) {
268 | return null;
269 | }
270 |
271 | const name = resolved.original ?? resolved.local;
272 |
273 | const links = [name, ...rest.map(link => getAccessorValue(link))];
274 |
275 | if (
276 | name !== 'jest' &&
277 | name !== 'expect' &&
278 | !ValidJestFnCallChains.includes(links.join('.'))
279 | ) {
280 | return null;
281 | }
282 |
283 | const parsedJestFnCall: Omit = {
284 | name,
285 | head: { ...resolved, node: first },
286 | // every member node must have a member expression as their parent
287 | // in order to be part of the call chain we're parsing
288 | members: rest as KnownMemberExpressionProperty[],
289 | };
290 |
291 | const type = determineJestFnType(name);
292 |
293 | if (type === 'expect') {
294 | const result = parseJestExpectCall(parsedJestFnCall);
295 |
296 | // if the `expect` call chain is not valid, only report on the topmost node
297 | // since all members in the chain are likely to get flagged for some reason
298 | if (
299 | typeof result === 'string' &&
300 | findTopMostCallExpression(node) !== node
301 | ) {
302 | return null;
303 | }
304 |
305 | if (result === 'matcher-not-found') {
306 | if (node.parent?.type === AST_NODE_TYPES.MemberExpression) {
307 | return 'matcher-not-called';
308 | }
309 | }
310 |
311 | return result;
312 | }
313 |
314 | // check that every link in the chain except the last is a member expression
315 | if (
316 | chain
317 | .slice(0, chain.length - 1)
318 | .some(nod => nod.parent?.type !== AST_NODE_TYPES.MemberExpression)
319 | ) {
320 | return null;
321 | }
322 |
323 | // ensure that we're at the "top" of the function call chain otherwise when
324 | // parsing e.g. x().y.z(), we'll incorrectly find & parse "x()" even though
325 | // the full chain is not a valid jest function call chain
326 | if (
327 | node.parent?.type === AST_NODE_TYPES.CallExpression ||
328 | node.parent?.type === AST_NODE_TYPES.MemberExpression
329 | ) {
330 | return null;
331 | }
332 |
333 | return { ...parsedJestFnCall, type };
334 | };
335 |
336 | type KnownMemberExpressionProperty =
337 | AccessorNode & { parent: KnownMemberExpression };
338 |
339 | interface ModifiersAndMatcher {
340 | modifiers: KnownMemberExpressionProperty[];
341 | matcher: KnownMemberExpressionProperty;
342 | /** The arguments that are being passed to the `matcher` */
343 | args: TSESTree.CallExpression['arguments'];
344 | }
345 |
346 | const findModifiersAndMatcher = (
347 | members: KnownMemberExpressionProperty[],
348 | ): ModifiersAndMatcher | string => {
349 | const modifiers: KnownMemberExpressionProperty[] = [];
350 |
351 | for (const member of members) {
352 | // check if the member is being called, which means it is the matcher
353 | // (and also the end of the entire "expect" call chain)
354 | if (
355 | member.parent?.type === AST_NODE_TYPES.MemberExpression &&
356 | member.parent.parent?.type === AST_NODE_TYPES.CallExpression
357 | ) {
358 | return {
359 | matcher: member,
360 | args: member.parent.parent.arguments,
361 | modifiers,
362 | };
363 | }
364 |
365 | // otherwise, it should be a modifier
366 | const name = getAccessorValue(member);
367 |
368 | if (modifiers.length === 0) {
369 | // the first modifier can be any of the three modifiers
370 | if (!ModifierName.hasOwnProperty(name)) {
371 | return 'modifier-unknown';
372 | }
373 | } else if (modifiers.length === 1) {
374 | // the second modifier can only be "not"
375 | if (name !== ModifierName.not) {
376 | return 'modifier-unknown';
377 | }
378 |
379 | const firstModifier = getAccessorValue(modifiers[0]);
380 |
381 | // and the first modifier has to be either "resolves" or "rejects"
382 | if (
383 | firstModifier !== ModifierName.resolves &&
384 | firstModifier !== ModifierName.rejects
385 | ) {
386 | return 'modifier-unknown';
387 | }
388 | } else {
389 | return 'modifier-unknown';
390 | }
391 |
392 | modifiers.push(member);
393 | }
394 |
395 | // this will only really happen if there are no members
396 | return 'matcher-not-found';
397 | };
398 |
399 | const parseJestExpectCall = (
400 | typelessParsedJestFnCall: Omit,
401 | ): ParsedExpectFnCall | string => {
402 | const modifiersAndMatcher = findModifiersAndMatcher(
403 | typelessParsedJestFnCall.members,
404 | );
405 |
406 | if (typeof modifiersAndMatcher === 'string') {
407 | return modifiersAndMatcher;
408 | }
409 |
410 | return {
411 | ...typelessParsedJestFnCall,
412 | type: 'expect',
413 | ...modifiersAndMatcher,
414 | };
415 | };
416 |
417 | interface ImportDetails {
418 | source: string;
419 | local: string;
420 | imported: string;
421 | }
422 |
423 | const describeImportDefAsImport = (
424 | def: TSESLint.Scope.Definitions.ImportBindingDefinition,
425 | ): ImportDetails | null => {
426 | if (def.parent.type === AST_NODE_TYPES.TSImportEqualsDeclaration) {
427 | return null;
428 | }
429 |
430 | if (def.node.type !== AST_NODE_TYPES.ImportSpecifier) {
431 | return null;
432 | }
433 |
434 | // we only care about value imports
435 | if (def.parent.importKind === 'type') {
436 | return null;
437 | }
438 |
439 | return {
440 | source: def.parent.source.value,
441 | imported: def.node.imported.name,
442 | local: def.node.local.name,
443 | };
444 | };
445 |
446 | /**
447 | * Attempts to find the node that represents the import source for the
448 | * given expression node, if it looks like it's an import.
449 | *
450 | * If no such node can be found (e.g. because the expression doesn't look
451 | * like an import), then `null` is returned instead.
452 | */
453 | const findImportSourceNode = (
454 | node: TSESTree.Expression,
455 | ): TSESTree.Node | null => {
456 | if (node.type === AST_NODE_TYPES.AwaitExpression) {
457 | if (node.argument.type === AST_NODE_TYPES.ImportExpression) {
458 | return node.argument.source;
459 | }
460 |
461 | return null;
462 | }
463 |
464 | if (
465 | node.type === AST_NODE_TYPES.CallExpression &&
466 | isIdentifier(node.callee, 'require')
467 | ) {
468 | return node.arguments[0] ?? null;
469 | }
470 |
471 | return null;
472 | };
473 |
474 | const describeVariableDefAsImport = (
475 | def: TSESLint.Scope.Definitions.VariableDefinition,
476 | ): ImportDetails | null => {
477 | // make sure that we've actually being assigned a value
478 | if (!def.node.init) {
479 | return null;
480 | }
481 |
482 | const sourceNode = findImportSourceNode(def.node.init);
483 |
484 | if (!sourceNode || !isStringNode(sourceNode)) {
485 | return null;
486 | }
487 |
488 | if (def.name.parent?.type !== AST_NODE_TYPES.Property) {
489 | return null;
490 | }
491 |
492 | if (!isSupportedAccessor(def.name.parent.key)) {
493 | return null;
494 | }
495 |
496 | return {
497 | source: getStringValue(sourceNode),
498 | imported: getAccessorValue(def.name.parent.key),
499 | local: def.name.name,
500 | };
501 | };
502 |
503 | /**
504 | * Attempts to describe a definition as an import if possible.
505 | *
506 | * If the definition is an import binding, it's described as you'd expect.
507 | * If the definition is a variable, then we try and determine if it's either
508 | * a dynamic `import()` or otherwise a call to `require()`.
509 | *
510 | * If it's neither of these, `null` is returned to indicate that the definition
511 | * is not describable as an import of any kind.
512 | */
513 | const describePossibleImportDef = (def: TSESLint.Scope.Definition) => {
514 | if (def.type === 'Variable') {
515 | return describeVariableDefAsImport(def);
516 | }
517 |
518 | if (def.type === 'ImportBinding') {
519 | return describeImportDefAsImport(def);
520 | }
521 |
522 | return null;
523 | };
524 |
525 | const resolveScope = (
526 | scope: TSESLint.Scope.Scope,
527 | identifier: string,
528 | ): ImportDetails | 'local' | null => {
529 | let currentScope: TSESLint.Scope.Scope | null = scope;
530 |
531 | while (currentScope !== null) {
532 | const ref = currentScope.set.get(identifier);
533 |
534 | if (ref && ref.defs.length > 0) {
535 | const def = ref.defs[ref.defs.length - 1];
536 |
537 | const importDetails = describePossibleImportDef(def);
538 |
539 | if (importDetails?.local === identifier) {
540 | return importDetails;
541 | }
542 |
543 | return 'local';
544 | }
545 |
546 | currentScope = currentScope.upper;
547 | }
548 |
549 | return null;
550 | };
551 |
552 | interface ResolvedJestFn {
553 | original: string | null;
554 | local: string;
555 | type: 'import' | 'global';
556 | }
557 |
558 | const resolveToJestFn = (
559 | context: TSESLint.RuleContext,
560 | accessor: AccessorNode,
561 | ): ResolvedJestFn | null => {
562 | const identifier = getAccessorValue(accessor);
563 | const maybeImport = resolveScope(getScope(context, accessor), identifier);
564 |
565 | // the identifier was found as a local variable or function declaration
566 | // meaning it's not a function from jest
567 | if (maybeImport === 'local') {
568 | return null;
569 | }
570 |
571 | if (maybeImport) {
572 | const globalPackage =
573 | (context.settings as SharedConfigurationSettings).jest?.globalPackage ??
574 | '@jest/globals';
575 |
576 | // the identifier is imported from our global package so return the original import name
577 | if (maybeImport.source === globalPackage) {
578 | return {
579 | original: maybeImport.imported,
580 | local: maybeImport.local,
581 | type: 'import',
582 | };
583 | }
584 |
585 | return null;
586 | }
587 |
588 | return {
589 | original: resolvePossibleAliasedGlobal(identifier, context),
590 | local: identifier,
591 | type: 'global',
592 | };
593 | };
594 |
595 | /* istanbul ignore next */
596 | const getScope = (
597 | context: TSESLint.RuleContext,
598 | node: TSESTree.Node,
599 | ) => {
600 | const sourceCode = context.sourceCode ?? context.getSourceCode();
601 |
602 | return sourceCode.getScope?.(node) ?? context.getScope();
603 | };
604 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/node16/tsconfig.json",
3 | "compilerOptions": {
4 | "noEmit": true,
5 | "stripInternal": true,
6 |
7 | /* Additional Checks */
8 | "noUnusedLocals": true,
9 | "noUnusedParameters": true,
10 | "noImplicitOverride": true,
11 | "noImplicitReturns": true,
12 | "noFallthroughCasesInSwitch": true,
13 |
14 | "strict": true,
15 | "esModuleInterop": true,
16 | "resolveJsonModule": true,
17 | "isolatedModules": true,
18 | "skipLibCheck": true, // until we can upgrade @typescript-eslint
19 | "forceConsistentCasingInFileNames": true
20 | },
21 | "include": ["src/**/*"]
22 | }
23 |
--------------------------------------------------------------------------------