From 1d32972ae4f03dce8f7ba5603b27a11a48859423 Mon Sep 17 00:00:00 2001 From: hainenber Date: Sat, 8 Nov 2025 16:46:36 +0700 Subject: [PATCH 1/8] feat(rule): add new rule to validate `jest.mock` path existence Signed-off-by: hainenber --- README.md | 1 + docs/rules/valid-mocked-module-path.md | 43 ++++++++++ src/rules/__tests__/fixtures/module/foo.js | 1 + src/rules/__tests__/fixtures/module/foo.ts | 1 + src/rules/__tests__/fixtures/module/index.ts | 1 + .../valid-mocked-module-path.test.ts | 60 +++++++++++++ src/rules/no-untyped-mock-factory.ts | 11 +-- src/rules/utils/misc.ts | 10 +++ src/rules/valid-mocked-module-path.ts | 85 +++++++++++++++++++ 9 files changed, 203 insertions(+), 10 deletions(-) create mode 100644 docs/rules/valid-mocked-module-path.md create mode 100644 src/rules/__tests__/fixtures/module/foo.js create mode 100644 src/rules/__tests__/fixtures/module/foo.ts create mode 100644 src/rules/__tests__/fixtures/module/index.ts create mode 100644 src/rules/__tests__/valid-mocked-module-path.test.ts create mode 100644 src/rules/valid-mocked-module-path.ts diff --git a/README.md b/README.md index 79064c319..765a5ff95 100644 --- a/README.md +++ b/README.md @@ -386,6 +386,7 @@ Manually fixable by | [valid-describe-callback](docs/rules/valid-describe-callback.md) | Enforce valid `describe()` callback | ✅ | | | | | [valid-expect](docs/rules/valid-expect.md) | Enforce valid `expect()` usage | ✅ | | 🔧 | | | [valid-expect-in-promise](docs/rules/valid-expect-in-promise.md) | Require promises that have expectations in their chain to be valid | ✅ | | | | +| [valid-mocked-module-path](docs/rules/valid-mocked-module-path.md) | Disallow mocking of non-existing module path | | | | | | [valid-title](docs/rules/valid-title.md) | Enforce valid titles | ✅ | | 🔧 | | ### Requires Type Checking diff --git a/docs/rules/valid-mocked-module-path.md b/docs/rules/valid-mocked-module-path.md new file mode 100644 index 000000000..8e19fdcb3 --- /dev/null +++ b/docs/rules/valid-mocked-module-path.md @@ -0,0 +1,43 @@ +# Disallow mocking of non-existing module path (`valid-mocked-module-path`) + + + +This rule raises an error when using `jest.mock` and `jest.doMock` and the first +argument for mocked object (module/local file) do not exist. + +## Rule details + +This rule checks existence of the supplied path for `jest.mock` or `jest.doMock` +in the first argument. + +The following patterns are considered errors: + +```js +// Module(s) that cannot be found +jest.mock('@org/some-module-not-in-package-json'); +jest.mock('some-module-not-in-package-json'); + +// Local module (directory) that cannot be found +jest.mock('../../this/module/does/not/exist'); + +// Local file that cannot be found +jest.mock('../../this/path/does/not/exist.js'); +``` + +The following patterns are **not** considered errors: + +```js +// Module(s) that can be found +jest.mock('@org/some-module-in-package-json'); +jest.mock('some-module-in-package-json'); + +// Local module that cannot be found +jest.mock('../../this/module/really/does/exist'); + +// Local file that cannot be found +jest.mock('../../this/path/really/does/exist.js'); +``` + +## When Not To Use It + +Don't use this rule on non-jest test files. diff --git a/src/rules/__tests__/fixtures/module/foo.js b/src/rules/__tests__/fixtures/module/foo.js new file mode 100644 index 000000000..b9627ca7e --- /dev/null +++ b/src/rules/__tests__/fixtures/module/foo.js @@ -0,0 +1 @@ +export const foo = 'foo_js'; \ No newline at end of file diff --git a/src/rules/__tests__/fixtures/module/foo.ts b/src/rules/__tests__/fixtures/module/foo.ts new file mode 100644 index 000000000..4221ba044 --- /dev/null +++ b/src/rules/__tests__/fixtures/module/foo.ts @@ -0,0 +1 @@ +export const foo = 'foo_ts'; \ No newline at end of file diff --git a/src/rules/__tests__/fixtures/module/index.ts b/src/rules/__tests__/fixtures/module/index.ts new file mode 100644 index 000000000..d6c3be183 --- /dev/null +++ b/src/rules/__tests__/fixtures/module/index.ts @@ -0,0 +1 @@ +export * from './foo'; diff --git a/src/rules/__tests__/valid-mocked-module-path.test.ts b/src/rules/__tests__/valid-mocked-module-path.test.ts new file mode 100644 index 000000000..40e23ce0f --- /dev/null +++ b/src/rules/__tests__/valid-mocked-module-path.test.ts @@ -0,0 +1,60 @@ +import rule from '../valid-mocked-module-path'; +import { FlatCompatRuleTester as RuleTester, espreeParser } from './test-utils'; + +const ruleTester = new RuleTester({ + parser: espreeParser, + parserOptions: { + ecmaVersion: 2015, + }, +}); + +ruleTester.run('valid-mocked-module-path', rule, { + valid: [ + { filename: __filename, code: 'jest.mock("./fixtures/module")' }, + { filename: __filename, code: 'jest.doMock("./fixtures/module")' }, + { filename: __filename, code: 'jest.mock("./fixtures/module/foo.ts")' }, + { filename: __filename, code: 'jest.doMock("./fixtures/module/foo.ts")' }, + { filename: __filename, code: 'jest.mock("./fixtures/module/foo.js")' }, + { filename: __filename, code: 'jest.doMock("./fixtures/module/foo.js")' }, + 'jest.mock("dedent")', + 'jest.doMock("dedent")', + ], + invalid: [ + { + filename: __filename, + code: "jest.mock('../module/does/not/exist')", + errors: [ + { + messageId: 'invalidMockModulePath', + data: { moduleName: "'../module/does/not/exist'" }, + column: 1, + line: 1, + }, + ], + }, + { + filename: __filename, + code: 'jest.mock("../file/does/not/exist.ts")', + errors: [ + { + messageId: 'invalidMockModulePath', + data: { moduleName: '"../file/does/not/exist.ts"' }, + column: 1, + line: 1, + }, + ], + }, + { + filename: __filename, + code: 'jest.mock("@doesnotexist/module")', + errors: [ + { + messageId: 'invalidMockModulePath', + data: { moduleName: '"@doesnotexist/module"' }, + column: 1, + line: 1, + }, + ], + }, + ], +}); diff --git a/src/rules/no-untyped-mock-factory.ts b/src/rules/no-untyped-mock-factory.ts index 4f0448696..cebc26f33 100644 --- a/src/rules/no-untyped-mock-factory.ts +++ b/src/rules/no-untyped-mock-factory.ts @@ -1,22 +1,13 @@ import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils'; import { createRule, + findModuleName, getAccessorValue, isFunction, isSupportedAccessor, isTypeOfJestFnCall, } from './utils'; -const findModuleName = ( - node: TSESTree.Literal | TSESTree.Node, -): TSESTree.StringLiteral | null => { - if (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string') { - return node; - } - - return null; -}; - export default createRule({ name: __filename, meta: { diff --git a/src/rules/utils/misc.ts b/src/rules/utils/misc.ts index 71c8c91e1..fb56c85c8 100644 --- a/src/rules/utils/misc.ts +++ b/src/rules/utils/misc.ts @@ -225,3 +225,13 @@ export const getFirstMatcherArg = ( return followTypeAssertionChain(firstArg); }; + +export const findModuleName = ( + node: TSESTree.Literal | TSESTree.Node, +): TSESTree.StringLiteral | null => { + if (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string') { + return node; + } + + return null; +}; diff --git a/src/rules/valid-mocked-module-path.ts b/src/rules/valid-mocked-module-path.ts new file mode 100644 index 000000000..7a1404f9c --- /dev/null +++ b/src/rules/valid-mocked-module-path.ts @@ -0,0 +1,85 @@ +import { statSync } from 'fs'; +import path from 'path'; +import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils'; +import { + createRule, + findModuleName, + getAccessorValue, + isSupportedAccessor, + isTypeOfJestFnCall, +} from './utils'; + +export default createRule({ + name: __filename, + meta: { + type: 'problem', + docs: { + description: 'Disallow mocking of non-existing module path', + }, + messages: { + invalidMockModulePath: 'Mocked module path {{moduleName}} does not exist', + }, + schema: [], + }, + defaultOptions: [], + create(context) { + return { + CallExpression(node: TSESTree.CallExpression): void { + const { callee } = node; + + if (callee.type !== AST_NODE_TYPES.MemberExpression) { + return; + } + + const { property } = callee; + + if ( + node.arguments.length >= 1 && + isTypeOfJestFnCall(node, context, ['jest']) && + isSupportedAccessor(property) && + ['mock', 'doMock'].includes(getAccessorValue(property)) + ) { + const [nameNode] = node.arguments; + const moduleName = findModuleName(nameNode); + + try { + if (moduleName) { + if (moduleName.value.startsWith('.')) { + const resolvedModulePath = path.resolve( + path.dirname(context.filename), + moduleName.value, + ); + + const hasPossiblyModulePaths = ['', '.js', '.ts'] + .map(ext => `${resolvedModulePath}${ext}`) + .some(modPath => { + try { + statSync(modPath); + + return true; + } catch { + return false; + } + }); + + if (!hasPossiblyModulePaths) { + throw { code: 'MODULE_NOT_FOUND' }; + } + } else { + require.resolve(moduleName.value); + } + } + } catch (err: any) { + if (err?.code === 'MODULE_NOT_FOUND' || err?.code === 'ENOENT') { + context.report({ + messageId: 'invalidMockModulePath', + data: { moduleName: moduleName?.raw ?? './module-name' }, + node, + }); + } + } + } + }, + }; + }, +}); From 9ce0a40fe90accce8215e91b9292cfe2aa3cb5cc Mon Sep 17 00:00:00 2001 From: hainenber Date: Sat, 8 Nov 2025 17:01:03 +0700 Subject: [PATCH 2/8] chore: update rules.test.ts due to newly added rule Signed-off-by: hainenber --- src/__tests__/__snapshots__/rules.test.ts.snap | 2 ++ src/__tests__/rules.test.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/__tests__/__snapshots__/rules.test.ts.snap b/src/__tests__/__snapshots__/rules.test.ts.snap index cbd7a4236..3333b477d 100644 --- a/src/__tests__/__snapshots__/rules.test.ts.snap +++ b/src/__tests__/__snapshots__/rules.test.ts.snap @@ -72,6 +72,7 @@ exports[`rules should export configs that refer to actual rules 1`] = ` "jest/valid-describe-callback": "error", "jest/valid-expect": "error", "jest/valid-expect-in-promise": "error", + "jest/valid-mocked-module-path": "error", "jest/valid-title": "error", }, }, @@ -164,6 +165,7 @@ exports[`rules should export configs that refer to actual rules 1`] = ` "jest/valid-describe-callback": "error", "jest/valid-expect": "error", "jest/valid-expect-in-promise": "error", + "jest/valid-mocked-module-path": "error", "jest/valid-title": "error", }, }, diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index 857f65a41..a1a9af556 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -2,7 +2,7 @@ import { existsSync } from 'fs'; import { resolve } from 'path'; import plugin from '../'; -const numberOfRules = 63; +const numberOfRules = 64; const ruleNames = Object.keys(plugin.rules); const deprecatedRules = Object.entries(plugin.rules) .filter(([, rule]) => rule.meta.deprecated) From 621b8da1a505a908196093f427c72f2a24f7c71a Mon Sep 17 00:00:00 2001 From: hainenber Date: Sat, 8 Nov 2025 20:52:54 +0700 Subject: [PATCH 3/8] chore: rewrite implementation to return early when linting over unwanted LoCs Signed-off-by: hainenber --- .../valid-mocked-module-path.test.ts | 18 +++- src/rules/valid-mocked-module-path.ts | 84 +++++++++++-------- 2 files changed, 65 insertions(+), 37 deletions(-) diff --git a/src/rules/__tests__/valid-mocked-module-path.test.ts b/src/rules/__tests__/valid-mocked-module-path.test.ts index 40e23ce0f..509f3960a 100644 --- a/src/rules/__tests__/valid-mocked-module-path.test.ts +++ b/src/rules/__tests__/valid-mocked-module-path.test.ts @@ -1,3 +1,4 @@ +import dedent from 'dedent'; import rule from '../valid-mocked-module-path'; import { FlatCompatRuleTester as RuleTester, espreeParser } from './test-utils'; @@ -11,13 +12,26 @@ const ruleTester = new RuleTester({ ruleTester.run('valid-mocked-module-path', rule, { valid: [ { filename: __filename, code: 'jest.mock("./fixtures/module")' }, + { filename: __filename, code: 'jest.mock("./fixtures/module", () => {})' }, + { filename: __filename, code: 'jest.mock()' }, + { + filename: __filename, + code: 'jest.doMock("./fixtures/module", () => {})', + }, + { + filename: __filename, + code: dedent` + describe("foo", () => {}); + `, + }, { filename: __filename, code: 'jest.doMock("./fixtures/module")' }, { filename: __filename, code: 'jest.mock("./fixtures/module/foo.ts")' }, { filename: __filename, code: 'jest.doMock("./fixtures/module/foo.ts")' }, { filename: __filename, code: 'jest.mock("./fixtures/module/foo.js")' }, { filename: __filename, code: 'jest.doMock("./fixtures/module/foo.js")' }, - 'jest.mock("dedent")', - 'jest.doMock("dedent")', + 'jest.mock("eslint")', + 'jest.doMock("eslint")', + 'jest.mock("child_process")', ], invalid: [ { diff --git a/src/rules/valid-mocked-module-path.ts b/src/rules/valid-mocked-module-path.ts index 7a1404f9c..5acf0d56a 100644 --- a/src/rules/valid-mocked-module-path.ts +++ b/src/rules/valid-mocked-module-path.ts @@ -34,50 +34,64 @@ export default createRule({ const { property } = callee; if ( - node.arguments.length >= 1 && - isTypeOfJestFnCall(node, context, ['jest']) && - isSupportedAccessor(property) && - ['mock', 'doMock'].includes(getAccessorValue(property)) + !node.arguments.length || + !isTypeOfJestFnCall(node, context, ['jest']) || + !( + isSupportedAccessor(property) && + ['mock', 'doMock'].includes(getAccessorValue(property)) + ) ) { - const [nameNode] = node.arguments; - const moduleName = findModuleName(nameNode); + return; + } - try { - if (moduleName) { - if (moduleName.value.startsWith('.')) { - const resolvedModulePath = path.resolve( - path.dirname(context.filename), - moduleName.value, - ); + const [nameNode] = node.arguments; + const moduleName = findModuleName(nameNode); + + /* istanbul ignore if */ + if (!moduleName) { + throw new Error( + 'Cannot parse mocked module name from `jest.mock` - - please file a github issue at https://github.com/jest-community/eslint-plugin-jest`', + ); + } - const hasPossiblyModulePaths = ['', '.js', '.ts'] - .map(ext => `${resolvedModulePath}${ext}`) - .some(modPath => { - try { - statSync(modPath); + try { + if (moduleName.value.startsWith('.')) { + const resolvedModulePath = path.resolve( + path.dirname(context.filename), + moduleName.value, + ); - return true; - } catch { - return false; - } - }); + const hasPossiblyModulePaths = ['', '.js', '.ts'] + .map(ext => `${resolvedModulePath}${ext}`) + .some(modPath => { + try { + statSync(modPath); - if (!hasPossiblyModulePaths) { - throw { code: 'MODULE_NOT_FOUND' }; + return true; + } catch { + return false; } - } else { - require.resolve(moduleName.value); - } - } - } catch (err: any) { - if (err?.code === 'MODULE_NOT_FOUND' || err?.code === 'ENOENT') { - context.report({ - messageId: 'invalidMockModulePath', - data: { moduleName: moduleName?.raw ?? './module-name' }, - node, }); + + if (!hasPossiblyModulePaths) { + throw { code: 'MODULE_NOT_FOUND' }; } + } else { + require.resolve(moduleName.value); } + } catch (err: any) { + // Skip over any unexpected issues when attempt to verify mocked module path. + // The list of possible errors is non-exhaustive. + /* istanbul ignore if */ + if (!['MODULE_NOT_FOUND', 'ENOENT'].includes(err.code)) { + return; + } + + context.report({ + messageId: 'invalidMockModulePath', + data: { moduleName: moduleName.raw }, + node, + }); } }, }; From c7d19016b3ade0afad901bd1b677d332b0162f90 Mon Sep 17 00:00:00 2001 From: hainenber Date: Sat, 8 Nov 2025 21:16:36 +0700 Subject: [PATCH 4/8] fix(ci): verify broken npmjs link in MD file by checking its `registry.npmjs.org` URI instead Signed-off-by: hainenber --- markdown_link_check_config.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/markdown_link_check_config.json b/markdown_link_check_config.json index bb5e90455..2b15c894f 100644 --- a/markdown_link_check_config.json +++ b/markdown_link_check_config.json @@ -6,5 +6,11 @@ "Accept-Encoding": "br, gzip, deflate" } } + ], + "replacementPatterns": [ + { + "pattern": "^https:\\/\\/www\\.npmjs\\.com\\/package", + "replacement": "https://registry.npmjs.org/" + } ] } From fee7e7af6bebecd66ee8522b648ffbf950f83ea0 Mon Sep 17 00:00:00 2001 From: hainenber Date: Sat, 8 Nov 2025 21:23:36 +0700 Subject: [PATCH 5/8] build(dev-deps): update `markdown-link-check` Signed-off-by: hainenber --- package.json | 2 +- yarn.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index bf8f844bb..68d46fca8 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,7 @@ "jest": "^30.0.0", "jest-runner-eslint": "^2.0.0", "lint-staged": "^16.0.0", - "markdown-link-check": "^3.13.7", + "markdown-link-check": "^3.14.1", "pinst": "^3.0.0", "prettier": "^3.0.0", "rimraf": "^6.0.0", diff --git a/yarn.lock b/yarn.lock index ff5077cc8..e80f446a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5463,7 +5463,7 @@ __metadata: jest: "npm:^30.0.0" jest-runner-eslint: "npm:^2.0.0" lint-staged: "npm:^16.0.0" - markdown-link-check: "npm:^3.13.7" + markdown-link-check: "npm:^3.14.1" pinst: "npm:^3.0.0" prettier: "npm:^3.0.0" rimraf: "npm:^6.0.0" @@ -8442,7 +8442,7 @@ __metadata: languageName: node linkType: hard -"markdown-link-check@npm:^3.13.7": +"markdown-link-check@npm:^3.14.1": version: 3.14.1 resolution: "markdown-link-check@npm:3.14.1" dependencies: From bf6127f993ebd8f5979bdb34ab56f13f85317283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BB=97=20Tr=E1=BB=8Dng=20H=E1=BA=A3i?= <41283691+hainenber@users.noreply.github.com> Date: Mon, 10 Nov 2025 21:10:33 +0700 Subject: [PATCH 6/8] feat: apply suggestions from @G-Rath code review Co-authored-by: Gareth Jones <3151613+G-Rath@users.noreply.github.com> --- src/rules/valid-mocked-module-path.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/rules/valid-mocked-module-path.ts b/src/rules/valid-mocked-module-path.ts index 5acf0d56a..c1cde56e4 100644 --- a/src/rules/valid-mocked-module-path.ts +++ b/src/rules/valid-mocked-module-path.ts @@ -17,7 +17,7 @@ export default createRule({ description: 'Disallow mocking of non-existing module path', }, messages: { - invalidMockModulePath: 'Mocked module path {{moduleName}} does not exist', + invalidMockModulePath: 'Module path {{ moduleName }} does not exist', }, schema: [], }, @@ -44,8 +44,7 @@ export default createRule({ return; } - const [nameNode] = node.arguments; - const moduleName = findModuleName(nameNode); + const moduleName = findModuleName(node.arguments[0]); /* istanbul ignore if */ if (!moduleName) { From 172594cab4f3cd66fbc0e9aea15006a82e1be2ef Mon Sep 17 00:00:00 2001 From: hainenber Date: Mon, 10 Nov 2025 21:11:56 +0700 Subject: [PATCH 7/8] Revert "fix(ci): verify broken npmjs link in MD file by checking its `registry.npmjs.org` URI instead" This reverts commit c7d19016b3ade0afad901bd1b677d332b0162f90. --- markdown_link_check_config.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/markdown_link_check_config.json b/markdown_link_check_config.json index 2b15c894f..bb5e90455 100644 --- a/markdown_link_check_config.json +++ b/markdown_link_check_config.json @@ -6,11 +6,5 @@ "Accept-Encoding": "br, gzip, deflate" } } - ], - "replacementPatterns": [ - { - "pattern": "^https:\\/\\/www\\.npmjs\\.com\\/package", - "replacement": "https://registry.npmjs.org/" - } ] } From 4fb268122ac17df24626da148a7bea2afb6d865e Mon Sep 17 00:00:00 2001 From: hainenber Date: Mon, 10 Nov 2025 21:12:14 +0700 Subject: [PATCH 8/8] Revert "build(dev-deps): update `markdown-link-check`" This reverts commit fee7e7af6bebecd66ee8522b648ffbf950f83ea0. --- package.json | 2 +- yarn.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 68d46fca8..bf8f844bb 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,7 @@ "jest": "^30.0.0", "jest-runner-eslint": "^2.0.0", "lint-staged": "^16.0.0", - "markdown-link-check": "^3.14.1", + "markdown-link-check": "^3.13.7", "pinst": "^3.0.0", "prettier": "^3.0.0", "rimraf": "^6.0.0", diff --git a/yarn.lock b/yarn.lock index e80f446a5..ff5077cc8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5463,7 +5463,7 @@ __metadata: jest: "npm:^30.0.0" jest-runner-eslint: "npm:^2.0.0" lint-staged: "npm:^16.0.0" - markdown-link-check: "npm:^3.14.1" + markdown-link-check: "npm:^3.13.7" pinst: "npm:^3.0.0" prettier: "npm:^3.0.0" rimraf: "npm:^6.0.0" @@ -8442,7 +8442,7 @@ __metadata: languageName: node linkType: hard -"markdown-link-check@npm:^3.14.1": +"markdown-link-check@npm:^3.13.7": version: 3.14.1 resolution: "markdown-link-check@npm:3.14.1" dependencies: