Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 188 additions & 20 deletions codeflash/languages/javascript/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,151 @@ def _has_ts_jest_dependency(project_root: Path) -> bool:
return False


def _ensure_babel_preset_typescript(project_root: Path) -> bool:
"""Ensure @babel/preset-typescript is installed if @babel/core is present.

Args:
project_root: Root of the project.

Returns:
True if @babel/preset-typescript is available (already installed or just installed),
False if installation failed or @babel/core is not present.

"""
package_json = project_root / "package.json"
if not package_json.exists():
return False

try:
content = json.loads(package_json.read_text())
deps = {**content.get("dependencies", {}), **content.get("devDependencies", {})}

# Only proceed if @babel/core is installed
if "@babel/core" not in deps:
return False

# Check if already available
if "@babel/preset-typescript" in deps:
return True

# Check if actually resolvable (might be transitively installed)
check_cmd = [
"node",
"-e",
"try { require.resolve('@babel/preset-typescript'); process.exit(0); } catch { process.exit(1); }"
]
result = subprocess.run(check_cmd, cwd=project_root, capture_output=True, timeout=5)
if result.returncode == 0:
logger.debug("@babel/preset-typescript available transitively")
return True

# Not available - install it
logger.info("Installing @babel/preset-typescript for TypeScript transformation...")
install_cmd = get_package_install_command(project_root, "@babel/preset-typescript", dev=True)
result = subprocess.run(install_cmd, check=False, cwd=project_root, capture_output=True, text=True, timeout=120)

if result.returncode == 0:
logger.debug(f"Installed @babel/preset-typescript using {install_cmd[0]}")
return True

logger.warning(f"Failed to install @babel/preset-typescript: {result.stderr}")
return False

except Exception as e:
logger.warning(f"Error ensuring @babel/preset-typescript: {e}")
return False


def _detect_typescript_transformer(project_root: Path) -> tuple[str | None, str]:
"""Detect the TypeScript transformer configured in the project.

Checks package.json for common TypeScript transformers and returns
the transformer name and its configuration string for Jest config.

If no transformer is found but @babel/core is installed, attempts to
install @babel/preset-typescript and returns a babel-jest config.

Args:
project_root: Root of the project.

Returns:
Tuple of (transformer_name, config_string) where:
- transformer_name is the package name (e.g., "@swc/jest", "ts-jest")
- config_string is the Jest transform config snippet to inject
Returns (None, "") if no TypeScript transformer is found.

"""
package_json = project_root / "package.json"
if not package_json.exists():
return (None, "")

try:
content = json.loads(package_json.read_text())
deps = {**content.get("dependencies", {}), **content.get("devDependencies", {})}

# Check for various TypeScript transformers in order of preference
if "ts-jest" in deps:
config = """
// Ensure TypeScript files are transformed using ts-jest
transform: {
'^.+\\\\.(ts|tsx)$': ['ts-jest', { isolatedModules: true }],
// Use ts-jest for JS files in ESM packages too
'^.+\\\\.js$': ['ts-jest', { isolatedModules: true }],
},"""
return ("ts-jest", config)

if "@swc/jest" in deps:
config = """
// Ensure TypeScript files are transformed using @swc/jest
transform: {
'^.+\\\\.(ts|tsx)$': '@swc/jest',
},"""
return ("@swc/jest", config)

if "babel-jest" in deps and "@babel/preset-typescript" in deps:
config = """
// Ensure TypeScript files are transformed using babel-jest
transform: {
'^.+\\\\.(ts|tsx)$': 'babel-jest',
},"""
return ("babel-jest", config)

if "esbuild-jest" in deps:
config = """
// Ensure TypeScript files are transformed using esbuild-jest
transform: {
'^.+\\\\.(ts|tsx)$': 'esbuild-jest',
},"""
return ("esbuild-jest", config)

# Fallback: If @babel/core is installed but no TypeScript transformer found,
# try to ensure @babel/preset-typescript is available and use babel-jest.
# This handles projects that have Babel but no TypeScript-specific setup.
if "@babel/core" in deps:
# Ensure preset-typescript is available (install if needed)
if _ensure_babel_preset_typescript(project_root):
config = """
// Fallback: Use babel-jest with TypeScript preset
// @babel/preset-typescript was installed by codeflash for TypeScript transformation
transform: {
'^.+\\\\.(ts|tsx)$': ['babel-jest', {
presets: [
['@babel/preset-typescript', { allowDeclareFields: true }]
]
}],
},"""
return ("babel-jest (fallback)", config)
else:
logger.warning(
"@babel/core is installed but @babel/preset-typescript could not be installed. "
"TypeScript files may fail to transform. Consider installing ts-jest or @swc/jest."
)

return (None, "")
except (json.JSONDecodeError, OSError):
return (None, "")


def _create_codeflash_jest_config(
project_root: Path, original_jest_config: Path | None, *, for_esm: bool = False
) -> Path | None:
Expand Down Expand Up @@ -278,21 +423,13 @@ def _create_codeflash_jest_config(
]
esm_pattern = "|".join(esm_packages)

# Check if ts-jest is available in the project
has_ts_jest = _has_ts_jest_dependency(project_root)
# Detect TypeScript transformer in the project
transformer_name, transform_config = _detect_typescript_transformer(project_root)

# Build transform config only if ts-jest is available
if has_ts_jest:
transform_config = """
// Ensure TypeScript files are transformed using ts-jest
transform: {
'^.+\\\\.(ts|tsx)$': ['ts-jest', { isolatedModules: true }],
// Use ts-jest for JS files in ESM packages too
'^.+\\\\.js$': ['ts-jest', { isolatedModules: true }],
},"""
if transformer_name:
logger.debug(f"Detected TypeScript transformer: {transformer_name}")
else:
transform_config = ""
logger.debug("ts-jest not found in project dependencies, skipping transform config")
logger.debug("No TypeScript transformer found in project dependencies")

# Create a wrapper Jest config
if original_jest_config:
Expand All @@ -310,6 +447,10 @@ def _create_codeflash_jest_config(
transformIgnorePatterns: [
'node_modules/(?!(\\\\.pnpm/)?({esm_pattern}))',
],{transform_config}
// Disable globalSetup/globalTeardown - these often require infrastructure (Docker, databases)
// that isn't available when running Codeflash-generated unit tests
globalSetup: undefined,
globalTeardown: undefined,
}};
"""
else:
Expand All @@ -326,6 +467,9 @@ def _create_codeflash_jest_config(
'node_modules/(?!(\\\\.pnpm/)?({esm_pattern}))',
],{transform_config}
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
// Disable globalSetup/globalTeardown - not needed for unit tests
globalSetup: undefined,
globalTeardown: undefined,
}};
"""

Expand Down Expand Up @@ -369,20 +513,35 @@ def _create_runtime_jest_config(base_config_path: Path | None, project_root: Pat

runtime_config_path = config_dir / f"jest.codeflash.runtime.config{config_ext}"

test_dirs_js = ", ".join(f"'{d}'" for d in sorted(test_dirs))
# SECURITY FIX (Issue #17): Use json.dumps() to properly escape paths
# Before: f"'{d}'" - vulnerable to code injection if path contains single quote
# After: json.dumps(d) - properly escapes quotes and special characters
test_dirs_js = ", ".join(json.dumps(d) for d in sorted(test_dirs))

# In monorepos, add the root node_modules to moduleDirectories so Jest
# can resolve workspace packages that are hoisted to the monorepo root.
monorepo_root = _find_monorepo_root(project_root)
module_dirs_line = ""
if monorepo_root and monorepo_root != project_root:
monorepo_node_modules = (monorepo_root / "node_modules").as_posix()
module_dirs_line = f" moduleDirectories: [...(baseConfig.moduleDirectories || ['node_modules']), '{monorepo_node_modules}'],\n"
module_dirs_line_no_base = f" moduleDirectories: ['node_modules', '{monorepo_node_modules}'],\n"
# SECURITY FIX (Issue #17): Use json.dumps() to escape path
monorepo_node_modules_escaped = json.dumps(monorepo_node_modules)
module_dirs_line = f" moduleDirectories: [...(baseConfig.moduleDirectories || ['node_modules']), {monorepo_node_modules_escaped}],\n"
module_dirs_line_no_base = f" moduleDirectories: ['node_modules', {monorepo_node_modules_escaped}],\n"
else:
module_dirs_line_no_base = ""

if base_config_path:
# TypeScript config files cannot be directly required by Node.js without a loader.
# If the base config is a .ts file, skip it and create a standalone config instead.
can_require_base_config = base_config_path and base_config_path.suffix != ".ts"

if base_config_path and not can_require_base_config:
logger.debug(
f"Skipping TypeScript Jest config {base_config_path.name} "
"(cannot be directly required by Node.js)"
)

if can_require_base_config:
require_path = f"./{base_config_path.name}"
config_content = f"""// Auto-generated by codeflash - runtime config with test roots
const baseConfig = require('{require_path}');
Expand All @@ -394,14 +553,23 @@ def _create_runtime_jest_config(base_config_path: Path | None, project_root: Pat
],
testMatch: ['**/*.test.ts', '**/*.test.js', '**/*.test.tsx', '**/*.test.jsx'],
testRegex: undefined, // Clear testRegex from baseConfig to avoid conflict with testMatch
{module_dirs_line}}};
{module_dirs_line} // Disable globalSetup/globalTeardown - these often require infrastructure (Docker, databases)
// that isn't available when running Codeflash-generated unit tests
globalSetup: undefined,
globalTeardown: undefined,
}};
"""
else:
# SECURITY FIX (Issue #17): Escape project_root too
project_root_escaped = json.dumps(str(project_root))
config_content = f"""// Auto-generated by codeflash - runtime config with test roots
module.exports = {{
roots: ['{project_root}', {test_dirs_js}],
roots: [{project_root_escaped}, {test_dirs_js}],
testMatch: ['**/*.test.ts', '**/*.test.js', '**/*.test.tsx', '**/*.test.jsx'],
{module_dirs_line_no_base}}};
{module_dirs_line_no_base} // Disable globalSetup/globalTeardown - not needed for unit tests
globalSetup: undefined,
globalTeardown: undefined,
}};
"""

try:
Expand Down
Loading