diff --git a/codeflash/languages/javascript/module_system.py b/codeflash/languages/javascript/module_system.py index 1f7b57c8a..c6ff73e54 100644 --- a/codeflash/languages/javascript/module_system.py +++ b/codeflash/languages/javascript/module_system.py @@ -46,8 +46,10 @@ def detect_module_system(project_root: Path, file_path: Path | None = None) -> s """Detect the module system used by a JavaScript/TypeScript project. Detection strategy: - 1. Check file extension for explicit module type (.mjs, .cjs, .ts, .tsx, .mts) - - TypeScript files always use ESM syntax regardless of package.json + 1. Check file extension for explicit module type (.mjs, .cjs, .mts, .cts) + - .mjs and .mts always use ES Modules + - .cjs and .cts always use CommonJS + - .ts and .tsx defer to package.json "type" field 2. Check package.json for explicit "type" field (only if explicitly set) 3. Analyze import/export statements in the file content 4. Default to CommonJS if uncertain @@ -61,33 +63,41 @@ def detect_module_system(project_root: Path, file_path: Path | None = None) -> s """ # Strategy 1: Check file extension first for explicit module type indicators - # TypeScript files always use ESM syntax (import/export) if file_path: suffix = file_path.suffix.lower() + # Explicit JavaScript module system extensions if suffix == ".mjs": logger.debug("Detected ES Module from .mjs extension") return ModuleSystem.ES_MODULE if suffix == ".cjs": logger.debug("Detected CommonJS from .cjs extension") return ModuleSystem.COMMONJS - if suffix in (".ts", ".tsx", ".mts"): - # TypeScript always uses ESM syntax (import/export) - # even if package.json doesn't have "type": "module" - logger.debug("Detected ES Module from TypeScript file extension") + + # Explicit TypeScript module system extensions + if suffix == ".mts": + logger.debug("Detected ES Module from .mts extension") return ModuleSystem.ES_MODULE + if suffix == ".cts": + logger.debug("Detected CommonJS from .cts extension") + return ModuleSystem.COMMONJS + + # For .ts/.tsx files, defer to package.json "type" field + # TypeScript source uses ESM syntax (import/export), but the module system + # at runtime depends on package.json and tsconfig compilation settings # Strategy 2: Check package.json for explicit type field package_json = project_root / "package.json" + pkg_type_from_json = None if package_json.exists(): try: with package_json.open("r") as f: pkg = json.load(f) - pkg_type = pkg.get("type") # Don't default - only use if explicitly set + pkg_type_from_json = pkg.get("type") # Don't default - only use if explicitly set - if pkg_type == "module": + if pkg_type_from_json == "module": logger.debug("Detected ES Module from package.json type field") return ModuleSystem.ES_MODULE - if pkg_type == "commonjs": + if pkg_type_from_json == "commonjs": logger.debug("Detected CommonJS from package.json type field") return ModuleSystem.COMMONJS # If type is not explicitly set, continue to file content analysis @@ -95,6 +105,16 @@ def detect_module_system(project_root: Path, file_path: Path | None = None) -> s except Exception as e: logger.warning("Failed to parse package.json: %s", e) + # For TypeScript files (.ts, .tsx), if package.json doesn't specify a type, + # default to CommonJS since that's the Node.js default. + # We skip file content analysis for TypeScript because TypeScript source + # always uses ESM syntax (import/export), but the actual module system + # depends on how TypeScript compiles and how Node.js loads the files. + if file_path and file_path.suffix.lower() in (".ts", ".tsx"): + if pkg_type_from_json is None: + logger.debug("TypeScript file without explicit package.json type field - defaulting to CommonJS") + return ModuleSystem.COMMONJS + # Strategy 3: Analyze file content for import/export patterns if file_path and file_path.exists(): try: @@ -399,22 +419,47 @@ def uses_ts_jest(project_root: Path) -> bool: return False -def ensure_module_system_compatibility(code: str, target_module_system: str, project_root: Path | None = None) -> str: +def ensure_module_system_compatibility( + code: str, target_module_system: str, project_root: Path | None = None, file_path: Path | None = None +) -> str: """Ensure code uses the correct module system syntax. If the project uses ts-jest, no conversion is performed because ts-jest handles module interoperability internally. Otherwise, converts between CommonJS and ES Modules as needed. + IMPORTANT: TypeScript test files (.test.ts, .spec.ts) ALWAYS keep ESM import + syntax, even in CommonJS projects. This is because TypeScript test runners + (@swc/jest, ts-jest) expect ESM syntax in .ts files. Converting ESM → CommonJS + causes SyntaxError when Jest tries to parse TypeScript source files via require(). + Args: code: JavaScript code to check and potentially convert. target_module_system: Target ModuleSystem (COMMONJS or ES_MODULE). project_root: Project root directory for ts-jest detection. + file_path: Path to the file being converted (used to detect TypeScript test files). Returns: - Converted code, or unchanged if ts-jest handles interop. + Converted code, or unchanged if ts-jest handles interop or file is TypeScript test. """ + # TypeScript test files must preserve ESM imports regardless of project module system + # See Issue #15: https://github.com/codeflash-ai/codeflash/issues/XXXX + if file_path is not None: + is_typescript_test = file_path.suffix in (".ts", ".tsx") and ( + ".test." in file_path.name + or ".spec." in file_path.name + or "/tests/" in str(file_path) + or "/__tests__/" in str(file_path) + ) + if is_typescript_test and target_module_system == ModuleSystem.COMMONJS: + logger.debug( + "Preserving ESM imports for TypeScript test file: %s. " + "TypeScript test runners expect ESM syntax even in CommonJS projects.", + file_path, + ) + return code + # If ts-jest is installed, skip conversion - it handles interop natively if is_typescript() and project_root and uses_ts_jest(project_root): logger.debug( diff --git a/codeflash/languages/javascript/support.py b/codeflash/languages/javascript/support.py index 500c02839..5dd457d55 100644 --- a/codeflash/languages/javascript/support.py +++ b/codeflash/languages/javascript/support.py @@ -2064,13 +2064,26 @@ def process_generated_test_strings( ) # Convert module system if needed (e.g., CommonJS -> ESM for ESM projects) + # Pass test_path so TypeScript test files can preserve ESM imports (Issue #15) generated_test_source = ensure_module_system_compatibility( - generated_test_source, project_module_system, test_cfg.tests_project_rootdir + generated_test_source, project_module_system, test_cfg.tests_project_rootdir, file_path=test_path ) # Add .js extensions to relative imports for ESM projects - # TypeScript + ESM requires explicit .js extensions even for .ts source files - if project_module_system == ModuleSystem.ES_MODULE: + # IMPORTANT: Only for JavaScript source files, NOT TypeScript! + # + # When tests run on TypeScript source with ts-jest/tsx: + # - Imports should NOT have .js extensions (file is .ts, not .js) + # - ts-jest transpiles TypeScript at runtime + # - Adding .js causes "Cannot find module './foo.js'" when file is foo.ts + # + # When tests run on compiled JavaScript output: + # - Imports SHOULD have .js extensions (TypeScript convention for ESM) + # - But we're testing source files, not compiled output + # + # Solution: Skip .js extension for TypeScript test files + is_typescript_test = test_path.suffix in (".ts", ".tsx") + if project_module_system == ModuleSystem.ES_MODULE and not is_typescript_test: from codeflash.languages.javascript.module_system import add_js_extensions_to_relative_imports generated_test_source = add_js_extensions_to_relative_imports(generated_test_source) diff --git a/tests/test_languages/test_typescript_test_esm_preservation.py b/tests/test_languages/test_typescript_test_esm_preservation.py new file mode 100644 index 000000000..d30409bf5 --- /dev/null +++ b/tests/test_languages/test_typescript_test_esm_preservation.py @@ -0,0 +1,127 @@ +"""Test that TypeScript test files preserve ESM imports in CommonJS projects. + +Regression test for Issue #15: TypeScript tests converted to CommonJS cause SyntaxError. + +When the AI service generates tests for TypeScript files in CommonJS projects: +1. AI service generates ESM import syntax (correct per Issue #12 fix) +2. CLI should NOT convert these imports to CommonJS require() +3. TypeScript test runners (@swc/jest, ts-jest) expect ESM syntax in .ts files + +If imports are converted to require(), Jest fails with SyntaxError when trying +to load TypeScript source files via require(). + +Trace ID: 024aacf1-42c9-4e06-a27b-870660035d3e +""" + +from pathlib import Path + +import pytest + +from codeflash.languages.javascript.module_system import ensure_module_system_compatibility + + +class TestTypeScriptTestESMPreservation: + """Tests for preserving ESM imports in TypeScript test files.""" + + def test_typescript_test_preserves_esm_in_commonjs_project(self): + """TypeScript test files should keep ESM imports even in CommonJS projects.""" + # TypeScript test with ESM imports (what AI service generates) + typescript_test = """import { destroy } from '../../internal'; +import sdk from '../../../sdk'; + +test('should work', () => { + expect(destroy).toBeDefined(); +}); +""" + + # Convert to CommonJS (simulating CommonJS project) + # For TypeScript tests, this should be a NO-OP + result = ensure_module_system_compatibility( + typescript_test, + target_module_system="commonjs", + project_root=None, + file_path=Path("test_destroy__unit_test_0.test.ts"), # TypeScript test file + ) + + # This test will FAIL until the fix is implemented + # Should preserve ESM syntax for TypeScript tests + assert "import { destroy } from" in result, "Named import should be preserved" + assert "import sdk from" in result, "Default import should be preserved" + assert "require(" not in result, "Should NOT convert to require() for TypeScript tests" + + def test_javascript_test_converts_esm_in_commonjs_project(self): + """JavaScript test files should still convert ESM to CommonJS in CommonJS projects.""" + # JavaScript test with ESM imports + javascript_test = """import { destroy } from '../../internal'; +import sdk from '../../../sdk'; + +test('should work', () => { + expect(destroy).toBeDefined(); +}); +""" + + # Convert to CommonJS (for JavaScript test, this SHOULD convert) + # This behavior is CORRECT and should remain unchanged + result = ensure_module_system_compatibility( + javascript_test, + target_module_system="commonjs", + project_root=None, + ) + + # Should convert to CommonJS for JavaScript tests + assert "const { destroy } = require(" in result, "Named import should convert to require" + assert "const sdk = require(" in result, "Default import should convert to require" + assert "import " not in result, "Should NOT have ESM imports for JavaScript tests" + + @pytest.mark.skip(reason="Test demonstrates intended behavior, but we can't distinguish source vs test files yet") + def test_typescript_source_converts_esm_in_commonjs_project(self): + """TypeScript SOURCE files (not tests) should still convert in CommonJS projects.""" + # This test ensures we only special-case TypeScript TEST files + # NOTE: Currently we can't distinguish source files from test files without additional context + # This test is skipped because it would require API changes + typescript_source = """import { foo } from './bar'; +export const result = foo(); +""" + + # Convert to CommonJS (for source files, should still convert) + result = ensure_module_system_compatibility( + typescript_source, + target_module_system="commonjs", + project_root=None, + ) + + # Source files should convert normally + assert "const { foo } = require(" in result, "Source file should convert to require" + assert "import " not in result, "Source file should not have ESM imports" + + def test_typescript_test_with_multiple_import_styles(self): + """Test all import styles are preserved for TypeScript tests.""" + typescript_test = """import { destroy, create } from '../../internal'; +import * as utils from '../../../utils'; +import sdk from '../../../sdk'; +import type { Table } from '@types'; + +describe('tests', () => { + test('should work', () => { + expect(destroy).toBeDefined(); + }); +}); +""" + + result = ensure_module_system_compatibility( + typescript_test, + target_module_system="commonjs", + project_root=None, + file_path=Path("test.spec.ts"), # TypeScript test file + ) + + # All import styles should be preserved for TypeScript tests + assert "import { destroy, create } from" in result + assert "import * as utils from" in result + assert "import sdk from" in result + assert "import type { Table } from" in result + assert "require(" not in result + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])