fix: strip file extensions from JS/TS import paths in generated tests

LLMs often add .js extensions to TypeScript import paths (e.g.,
`import { func } from '../module.js'`), but TypeScript/Jest module
resolution doesn't require explicit extensions. This causes
"Cannot find module" errors.

This change adds `strip_js_extensions()` function that removes
.js/.ts/.tsx/.jsx/.mjs/.mts extensions from relative import paths
in generated tests. The function handles:
- ES module imports: import { x } from '../path.js'
- CommonJS requires: require('../path.js')
- Jest mocks: jest.mock('../path.js'), jest.doMock(), etc.

External package imports (lodash, react, etc.) are preserved.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Saurabh Misra 2026-01-31 04:22:07 +00:00
parent 6a13aa688a
commit 70360436bd
2 changed files with 122 additions and 0 deletions

View file

@ -122,6 +122,43 @@ def _is_valid_js_identifier(name: str) -> bool:
return bool(JS_IDENTIFIER_PATTERN.match(name)) and name not in reserved_words
# Patterns to strip file extensions from import paths
# LLMs sometimes add .js extensions to TypeScript imports, which breaks module resolution
_JS_EXTENSION_PATTERN = re.compile(
r"""(from\s+['"])(\.{0,2}/[^'"]+?)(\.(?:js|ts|tsx|jsx|mjs|mts))(['"])"""
)
_REQUIRE_EXTENSION_PATTERN = re.compile(
r"""(require\s*\(\s*['"])(\.{0,2}/[^'"]+?)(\.(?:js|ts|tsx|jsx|mjs|mts))(['"]\s*\))"""
)
_JEST_MOCK_EXTENSION_PATTERN = re.compile(
r"""(jest\.(?:mock|doMock|unmock|requireActual|requireMock)\s*\(\s*['"])(\.{0,2}/[^'"]+?)(\.(?:js|ts|tsx|jsx|mjs|mts))(['"])"""
)
def strip_js_extensions(source: str) -> str:
"""Strip .js/.ts/.tsx/.jsx extensions from relative import paths.
TypeScript and Jest's module resolution automatically resolve file extensions,
so adding them explicitly can cause "Cannot find module" errors when the LLM
adds incorrect extensions (e.g., .js to a .ts file).
This function removes extensions from:
- ES module imports: import { x } from '../path/file.js'
- CommonJS requires: require('../path/file.js')
- Jest mocks: jest.mock('../path/file.js')
Args:
source: The test source code.
Returns:
Source code with extensions stripped from relative import paths.
"""
source = _JS_EXTENSION_PATTERN.sub(r"\1\2\4", source)
source = _REQUIRE_EXTENSION_PATTERN.sub(r"\1\2\4", source)
return _JEST_MOCK_EXTENSION_PATTERN.sub(r"\1\2\4", source)
def _generate_import_statement(function_name: str, module_path: str) -> tuple[str, str]:
"""Generate appropriate import statement and accessor for JavaScript/TypeScript.
@ -480,6 +517,9 @@ async def testgen_javascript(
language=data.language,
)
# Strip incorrect file extensions from import paths (LLMs sometimes add .js to .ts imports)
generated_test_source = strip_js_extensions(generated_test_source)
ph(request.user, "aiservice-testgen-tests-generated", properties={"language": language})
if hasattr(request, "should_log_features") and request.should_log_features:

View file

@ -285,3 +285,85 @@ class TestJavaScriptTestGenPromptContent:
system_content = messages[0]["content"]
# Should warn against mocking
assert "mock" in system_content.lower() or "Mock" in system_content
class TestStripJsExtensions:
"""Tests for stripping file extensions from import paths.
These tests copy the regex patterns and function directly to avoid Django dependencies.
"""
# Copy of patterns from aiservice/languages/js_ts/testgen.py
_JS_EXTENSION_PATTERN = re.compile(
r"""(from\s+['"])(\.{0,2}/[^'"]+?)(\.(?:js|ts|tsx|jsx|mjs|mts))(['"])"""
)
_REQUIRE_EXTENSION_PATTERN = re.compile(
r"""(require\s*\(\s*['"])(\.{0,2}/[^'"]+?)(\.(?:js|ts|tsx|jsx|mjs|mts))(['"]\s*\))"""
)
_JEST_MOCK_EXTENSION_PATTERN = re.compile(
r"""(jest\.(?:mock|doMock|unmock|requireActual|requireMock)\s*\(\s*['"])(\.{0,2}/[^'"]+?)(\.(?:js|ts|tsx|jsx|mjs|mts))(['"])"""
)
@staticmethod
def strip_js_extensions(source: str) -> str:
"""Strip .js/.ts/.tsx/.jsx extensions from relative import paths."""
source = TestStripJsExtensions._JS_EXTENSION_PATTERN.sub(r"\1\2\4", source)
source = TestStripJsExtensions._REQUIRE_EXTENSION_PATTERN.sub(r"\1\2\4", source)
return TestStripJsExtensions._JEST_MOCK_EXTENSION_PATTERN.sub(r"\1\2\4", source)
def test_strip_js_extension_from_esm_import(self) -> None:
"""Test stripping .js from ES module imports."""
code = "import { getDifferences } from '../src/utils/DynamicBindingUtils.js';"
expected = "import { getDifferences } from '../src/utils/DynamicBindingUtils';"
result = self.strip_js_extensions(code)
assert result == expected
def test_strip_ts_extension_from_esm_import(self) -> None:
"""Test stripping .ts from ES module imports."""
code = "import { func } from './module.ts';"
expected = "import { func } from './module';"
result = self.strip_js_extensions(code)
assert result == expected
def test_strip_extension_from_require(self) -> None:
"""Test stripping extensions from require() calls."""
code = "const { func } = require('../utils/helper.js');"
expected = "const { func } = require('../utils/helper');"
result = self.strip_js_extensions(code)
assert result == expected
def test_strip_extension_from_jest_mock(self) -> None:
"""Test stripping extensions from jest.mock() calls."""
code = "jest.mock('../src/utils/DynamicBindingUtils.js');"
expected = "jest.mock('../src/utils/DynamicBindingUtils');"
result = self.strip_js_extensions(code)
assert result == expected
def test_preserve_external_package_imports(self) -> None:
"""Test that external package imports are not modified."""
code = "import lodash from 'lodash';"
result = self.strip_js_extensions(code)
assert result == code # Should be unchanged
def test_strip_multiple_extensions_in_file(self) -> None:
"""Test stripping multiple extensions in a single file."""
code = """
import { func1 } from '../utils/helper.js';
import { func2 } from './local.ts';
const { func3 } = require('../lib/util.tsx');
jest.mock('../mocks/mock.jsx');
"""
expected = """
import { func1 } from '../utils/helper';
import { func2 } from './local';
const { func3 } = require('../lib/util');
jest.mock('../mocks/mock');
"""
result = self.strip_js_extensions(code)
assert result == expected