From 7f7cc62d4eacb257bb2807d2a3b70c18fa9538d5 Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Fri, 30 Jan 2026 19:45:13 +0530 Subject: [PATCH 1/3] Setup with auto detection process --- codeflash/cli_cmds/cli.py | 107 +++ codeflash/cli_cmds/cmd_init.py | 96 ++- codeflash/cli_cmds/init_javascript.py | 27 +- codeflash/main.py | 54 +- codeflash/setup/__init__.py | 22 + codeflash/setup/config_schema.py | 197 ++++++ codeflash/setup/config_writer.py | 246 +++++++ codeflash/setup/detector.py | 692 +++++++++++++++++++ codeflash/setup/first_run.py | 294 +++++++++ tests/test_setup/__init__.py | 1 + tests/test_setup/test_config.py | 397 +++++++++++ tests/test_setup/test_detector.py | 389 +++++++++++ tests/test_setup/test_e2e_setup.py | 914 ++++++++++++++++++++++++++ tests/test_setup/test_first_run.py | 290 ++++++++ 14 files changed, 3689 insertions(+), 37 deletions(-) create mode 100644 codeflash/setup/__init__.py create mode 100644 codeflash/setup/config_schema.py create mode 100644 codeflash/setup/config_writer.py create mode 100644 codeflash/setup/detector.py create mode 100644 codeflash/setup/first_run.py create mode 100644 tests/test_setup/__init__.py create mode 100644 tests/test_setup/test_config.py create mode 100644 tests/test_setup/test_detector.py create mode 100644 tests/test_setup/test_e2e_setup.py create mode 100644 tests/test_setup/test_first_run.py diff --git a/codeflash/cli_cmds/cli.py b/codeflash/cli_cmds/cli.py index 9dca009fd..e821a3a85 100644 --- a/codeflash/cli_cmds/cli.py +++ b/codeflash/cli_cmds/cli.py @@ -121,6 +121,15 @@ def parse_args() -> Namespace: "--effort", type=str, help="Effort level for optimization", choices=["low", "medium", "high"], default="medium" ) + # Config management flags + parser.add_argument( + "--show-config", action="store_true", help="Show current or auto-detected configuration and exit." + ) + parser.add_argument( + "--reset-config", action="store_true", help="Remove codeflash configuration from project config file." + ) + parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompts (useful for CI/scripts).") + args, unknown_args = parser.parse_known_args() sys.argv[:] = [sys.argv[0], *unknown_args] return process_and_validate_cmd_args(args) @@ -147,6 +156,16 @@ def process_and_validate_cmd_args(args: Namespace) -> Namespace: logger.info(f"Codeflash version {version}") sys.exit() + # Handle --show-config + if getattr(args, "show_config", False): + _handle_show_config() + sys.exit() + + # Handle --reset-config + if getattr(args, "reset_config", False): + _handle_reset_config(confirm=not getattr(args, "yes", False)) + sys.exit() + if args.command == "vscode-install": install_vscode_extension() sys.exit() @@ -309,3 +328,91 @@ def handle_optimize_all_arg_parsing(args: Namespace) -> Namespace: else: args.all = Path(args.all).resolve() return args + + +def _handle_show_config() -> None: + """Show current or auto-detected Codeflash configuration.""" + from rich.table import Table + + from codeflash.cli_cmds.console import console + from codeflash.setup.detector import detect_project, has_existing_config + + project_root = Path.cwd() + detected = detect_project(project_root) + + # Check if config exists or is auto-detected + config_exists = has_existing_config(project_root) + status = "Saved config" if config_exists else "Auto-detected (not saved)" + + console.print() + console.print(f"[bold]Codeflash Configuration[/bold] ({status})") + console.print() + + table = Table(show_header=True, header_style="bold cyan") + table.add_column("Setting", style="dim") + table.add_column("Value") + + table.add_row("Language", detected.language) + table.add_row("Project root", str(detected.project_root)) + table.add_row("Module root", str(detected.module_root)) + table.add_row("Tests root", str(detected.tests_root) if detected.tests_root else "(not detected)") + table.add_row("Test runner", detected.test_runner or "(not detected)") + table.add_row("Formatter", ", ".join(detected.formatter_cmds) if detected.formatter_cmds else "(not detected)") + table.add_row( + "Ignore paths", ", ".join(str(p) for p in detected.ignore_paths) if detected.ignore_paths else "(none)" + ) + table.add_row("Confidence", f"{detected.confidence:.0%}") + + console.print(table) + console.print() + + if not config_exists: + console.print("[dim]Run [bold]codeflash --file [/bold] to auto-save this config.[/dim]") + + +def _handle_reset_config(confirm: bool = True) -> None: + """Remove Codeflash configuration from project config file. + + Args: + confirm: If True, prompt for confirmation before removing. + + """ + from codeflash.cli_cmds.console import console + from codeflash.setup.config_writer import remove_config + from codeflash.setup.detector import detect_project, has_existing_config + + project_root = Path.cwd() + + if not has_existing_config(project_root): + console.print("[yellow]No Codeflash configuration found to remove.[/yellow]") + return + + detected = detect_project(project_root) + + if confirm: + console.print("[bold]This will remove Codeflash configuration from your project.[/bold]") + console.print() + + config_file = "pyproject.toml" if detected.language == "python" else "package.json" + console.print(f" Config file: {project_root / config_file}") + console.print() + + try: + response = console.input("[bold]Are you sure you want to remove the config? [y/N][/bold] ") + except (EOFError, KeyboardInterrupt): + console.print("\n[yellow]Cancelled.[/yellow]") + return + + if response.lower() not in ("y", "yes"): + console.print("[yellow]Cancelled.[/yellow]") + return + + success, message = remove_config(project_root, detected.language) + + # Escape brackets in message to prevent Rich markup interpretation + escaped_message = message.replace("[", "\\[") + + if success: + console.print(f"[green]āœ“[/green] {escaped_message}") + else: + console.print(f"[red]āœ—[/red] {escaped_message}") diff --git a/codeflash/cli_cmds/cmd_init.py b/codeflash/cli_cmds/cmd_init.py index 51ca1a4f2..c4e45cc0a 100644 --- a/codeflash/cli_cmds/cmd_init.py +++ b/codeflash/cli_cmds/cmd_init.py @@ -607,7 +607,33 @@ def check_for_toml_or_setup_file() -> str | None: curdir = Path.cwd() pyproject_toml_path = curdir / "pyproject.toml" setup_py_path = curdir / "setup.py" + package_json_path = curdir / "package.json" project_name = None + + # Check if this might be a JavaScript/TypeScript project that wasn't detected + if package_json_path.exists() and not pyproject_toml_path.exists() and not setup_py_path.exists(): + js_redirect_panel = Panel( + Text( + f"šŸ“¦ I found a package.json in {curdir}.\n\n" + "This looks like a JavaScript/TypeScript project!\n" + "Redirecting to JavaScript setup...", + style="cyan", + ), + title="🟨 JavaScript Project Detected", + border_style="bright_yellow", + ) + console.print(js_redirect_panel) + console.print() + ph("cli-js-project-redirect") + + # Redirect to JS init + from codeflash.cli_cmds.init_javascript import ProjectLanguage, detect_project_language, init_js_project + + project_language = detect_project_language() + if project_language in (ProjectLanguage.JAVASCRIPT, ProjectLanguage.TYPESCRIPT): + init_js_project(project_language) + sys.exit(0) # init_js_project handles its own exit, but ensure we don't continue + if pyproject_toml_path.exists(): try: pyproject_toml_content = pyproject_toml_path.read_text(encoding="utf8") @@ -617,28 +643,44 @@ def check_for_toml_or_setup_file() -> str | None: except Exception: click.echo("āœ… I found a pyproject.toml for your project.") ph("cli-pyproject-toml-found") + elif setup_py_path.exists(): + setup_py_content = setup_py_path.read_text(encoding="utf8") + project_name_match = re.search(r"setup\s*\([^)]*?name\s*=\s*['\"](.*?)['\"]", setup_py_content, re.DOTALL) + if project_name_match: + project_name = project_name_match.group(1) + click.echo(f"āœ… Found setup.py for your project {project_name}") + ph("cli-setup-py-found-name") + else: + click.echo("āœ… Found setup.py.") + ph("cli-setup-py-found") else: - if setup_py_path.exists(): - setup_py_content = setup_py_path.read_text(encoding="utf8") - project_name_match = re.search(r"setup\s*\([^)]*?name\s*=\s*['\"](.*?)['\"]", setup_py_content, re.DOTALL) - if project_name_match: - project_name = project_name_match.group(1) - click.echo(f"āœ… Found setup.py for your project {project_name}") - ph("cli-setup-py-found-name") - else: - click.echo("āœ… Found setup.py.") - ph("cli-setup-py-found") - toml_info_panel = Panel( - Text( - f"šŸ’” No pyproject.toml found in {curdir}.\n\n" - "This file is essential for Codeflash to store its configuration.\n" - "Please ensure you are running `codeflash init` from your project's root directory.", - style="yellow", - ), - title="šŸ“‹ pyproject.toml Required", - border_style="bright_yellow", - ) - console.print(toml_info_panel) + # No Python config files found - show appropriate message + # Check again if this might be a JS project + if package_json_path.exists(): + js_hint_panel = Panel( + Text( + f"šŸ“¦ I found a package.json but no pyproject.toml in {curdir}.\n\n" + "If this is a JavaScript/TypeScript project, please run:\n" + " codeflash init\n\n" + "from the project root directory.", + style="yellow", + ), + title="šŸ¤” Mixed Project?", + border_style="bright_yellow", + ) + console.print(js_hint_panel) + else: + toml_info_panel = Panel( + Text( + f"šŸ’” No pyproject.toml found in {curdir}.\n\n" + "This file is essential for Codeflash to store its configuration.\n" + "Please ensure you are running `codeflash init` from your project's root directory.", + style="yellow", + ), + title="šŸ“‹ pyproject.toml Required", + border_style="bright_yellow", + ) + console.print(toml_info_panel) console.print() ph("cli-no-pyproject-toml-or-setup-py") @@ -1474,11 +1516,7 @@ def customize_codeflash_yaml_content( return _customize_python_workflow_content(optimize_yml_content, git_root, benchmark_mode) -def _customize_python_workflow_content( - optimize_yml_content: str, - git_root: Path, - benchmark_mode: bool = False, # noqa: FBT001, FBT002 -) -> str: +def _customize_python_workflow_content(optimize_yml_content: str, git_root: Path, benchmark_mode: bool = False) -> str: """Customize workflow content for Python projects.""" # Get dependency installation commands toml_path = Path.cwd() / "pyproject.toml" @@ -1513,11 +1551,7 @@ def _customize_python_workflow_content( # TODO:{claude} Refactor and move to support for language specific -def _customize_js_workflow_content( - optimize_yml_content: str, - git_root: Path, - benchmark_mode: bool = False, # noqa: FBT001, FBT002 -) -> str: +def _customize_js_workflow_content(optimize_yml_content: str, git_root: Path, benchmark_mode: bool = False) -> str: """Customize workflow content for JavaScript/TypeScript projects.""" from codeflash.cli_cmds.init_javascript import ( get_js_codeflash_install_step, diff --git a/codeflash/cli_cmds/init_javascript.py b/codeflash/cli_cmds/init_javascript.py index 578b56ca5..bf863f292 100644 --- a/codeflash/cli_cmds/init_javascript.py +++ b/codeflash/cli_cmds/init_javascript.py @@ -66,7 +66,7 @@ class JSSetupInfo: # Import theme from cmd_init to avoid duplication -def _get_theme(): # noqa: ANN202 +def _get_theme(): """Get the CodeflashTheme - imported lazily to avoid circular imports.""" from codeflash.cli_cmds.cmd_init import CodeflashTheme @@ -90,13 +90,30 @@ def detect_project_language(project_root: Path | None = None) -> ProjectLanguage has_package_json = (root / "package.json").exists() has_tsconfig = (root / "tsconfig.json").exists() - # TypeScript project + # TypeScript project (tsconfig.json is definitive) if has_tsconfig: return ProjectLanguage.TYPESCRIPT - # Pure JS project (has package.json but no Python files) - if has_package_json and not has_pyproject and not has_setup_py: - return ProjectLanguage.JAVASCRIPT + # JavaScript project - package.json without Python-specific files takes priority + # Note: If both package.json and pyproject.toml exist, check for typical JS project indicators + if has_package_json: + # If no Python config files, it's definitely JavaScript + if not has_pyproject and not has_setup_py: + return ProjectLanguage.JAVASCRIPT + + # If package.json exists with Python files, check for JS-specific indicators + # Common React/Node patterns indicate a JS project + js_indicators = [ + (root / "node_modules").exists(), + (root / ".npmrc").exists(), + (root / "yarn.lock").exists(), + (root / "package-lock.json").exists(), + (root / "pnpm-lock.yaml").exists(), + (root / "bun.lockb").exists(), + (root / "bun.lock").exists(), + ] + if any(js_indicators): + return ProjectLanguage.JAVASCRIPT # Python project (default) return ProjectLanguage.PYTHON diff --git a/codeflash/main.py b/codeflash/main.py index f01d8e737..2454b51ce 100644 --- a/codeflash/main.py +++ b/codeflash/main.py @@ -4,6 +4,7 @@ If you might want to work with us on finally making performance a solved problem, please reach out to us at careers@codeflash.ai. We're hiring! """ +import sys from pathlib import Path from codeflash.cli_cmds.cli import parse_args, process_pyproject_config @@ -39,7 +40,11 @@ def main() -> None: posthog_cf.initialize_posthog(enabled=not args.disable_telemetry) ask_run_end_to_end_test(args) else: - args = process_pyproject_config(args) + # Check for first-run experience (no config exists) + args = _handle_config_loading(args) + if args is None: + sys.exit(0) + if not env_utils.check_formatter_installed(args.formatter_cmds): return args.previous_checkpoint_functions = ask_should_use_checkpoint_get_functions(args) @@ -51,6 +56,53 @@ def main() -> None: optimizer.run_with_args(args) +def _handle_config_loading(args): + """Handle config loading with first-run experience support. + + If no config exists and not in CI, triggers the first-run experience. + Otherwise, loads config normally. + + Args: + args: CLI args namespace. + + Returns: + Updated args with config loaded, or None if user cancelled first-run. + + """ + from codeflash.setup.first_run import handle_first_run, is_first_run + + # Check if we're in CI environment + is_ci = any( + var in ("true", "1", "True") + for var in [env_utils.os.environ.get("CI", ""), env_utils.os.environ.get("GITHUB_ACTIONS", "")] + ) + + # Check if first run (no config exists) + if is_first_run() and not is_ci: + # Skip API key check if already set + skip_api_key = bool(env_utils.os.environ.get("CODEFLASH_API_KEY")) + + # Handle first-run experience + result = handle_first_run(args=args, skip_confirm=getattr(args, "yes", False), skip_api_key=skip_api_key) + + if result is None: + return None + + # Merge first-run results with any CLI overrides + args = result + # Still need to process some config values + # Config might not exist yet if first run just saved it - that's OK + import contextlib + + with contextlib.suppress(ValueError): + args = process_pyproject_config(args) + + return args + + # Normal config loading + return process_pyproject_config(args) + + def print_codeflash_banner() -> None: paneled_text( CODEFLASH_LOGO, panel_args={"title": "https://codeflash.ai", "expand": False}, text_args={"style": "bold gold3"} diff --git a/codeflash/setup/__init__.py b/codeflash/setup/__init__.py new file mode 100644 index 000000000..d8b39ecae --- /dev/null +++ b/codeflash/setup/__init__.py @@ -0,0 +1,22 @@ +"""Setup module for Codeflash auto-detection and first-run experience. + +This module provides: +- Universal project detection across all supported languages +- First-run experience with auto-detection and quick confirm +- Config writing to native config files (pyproject.toml, package.json) +""" + +from codeflash.setup.config_schema import CodeflashConfig +from codeflash.setup.config_writer import write_config +from codeflash.setup.detector import DetectedProject, detect_project, has_existing_config +from codeflash.setup.first_run import handle_first_run, is_first_run + +__all__ = [ + "CodeflashConfig", + "DetectedProject", + "detect_project", + "handle_first_run", + "has_existing_config", + "is_first_run", + "write_config", +] diff --git a/codeflash/setup/config_schema.py b/codeflash/setup/config_schema.py new file mode 100644 index 000000000..dd12dbb4a --- /dev/null +++ b/codeflash/setup/config_schema.py @@ -0,0 +1,197 @@ +"""Codeflash configuration schema using Pydantic. + +This module provides a language-agnostic internal representation of Codeflash +configuration that can be serialized to different formats (TOML, JSON). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel, ConfigDict, Field + +if TYPE_CHECKING: + from pathlib import Path + + +class CodeflashConfig(BaseModel): + """Internal representation of Codeflash configuration. + + This is the canonical config format used internally. It can be converted + to/from pyproject.toml (Python) or package.json (JS/TS) formats. + + Note: All paths are stored as strings (relative to project root). + """ + + # Core settings (always present after detection) + language: str = Field(description="Project language: python, javascript, typescript") + module_root: str = Field(default=".", description="Root directory containing source code") + tests_root: str | None = Field(default=None, description="Root directory containing tests") + + # Tooling settings (auto-detected, can be overridden) + test_runner: str | None = Field(default=None, description="Test runner: pytest, jest, vitest, mocha") + formatter_cmds: list[str] = Field(default_factory=list, description="Formatter commands") + + # Optional settings + ignore_paths: list[str] = Field(default_factory=list, description="Paths to ignore") + benchmarks_root: str | None = Field(default=None, description="Benchmarks directory") + + # Git settings + git_remote: str = Field(default="origin", description="Git remote for PRs") + + # Privacy settings + disable_telemetry: bool = Field(default=False, description="Disable telemetry") + + # Python-specific settings + pytest_cmd: str = Field(default="pytest", description="Pytest command (Python only)") + disable_imports_sorting: bool = Field(default=False, description="Disable import sorting (Python only)") + override_fixtures: bool = Field(default=False, description="Override test fixtures (Python only)") + + model_config = ConfigDict(extra="allow") # Allow extra fields for forward compatibility + + def to_pyproject_dict(self) -> dict[str, Any]: + """Convert to pyproject.toml [tool.codeflash] format. + + Uses kebab-case keys as per TOML conventions. + Only includes non-default values to keep config minimal. + """ + config: dict[str, Any] = {} + + # Always include required fields + config["module-root"] = self.module_root + if self.tests_root: + config["tests-root"] = self.tests_root + + # Include non-default optional fields + if self.ignore_paths: + config["ignore-paths"] = self.ignore_paths + + if self.formatter_cmds and self.formatter_cmds != ["black $file"]: + config["formatter-cmds"] = self.formatter_cmds + elif not self.formatter_cmds: + config["formatter-cmds"] = ["disabled"] + + if self.benchmarks_root: + config["benchmarks-root"] = self.benchmarks_root + + if self.git_remote and self.git_remote != "origin": + config["git-remote"] = self.git_remote + + if self.disable_telemetry: + config["disable-telemetry"] = True + + if self.pytest_cmd and self.pytest_cmd != "pytest": + config["pytest-cmd"] = self.pytest_cmd + + if self.disable_imports_sorting: + config["disable-imports-sorting"] = True + + if self.override_fixtures: + config["override-fixtures"] = True + + return config + + def to_package_json_dict(self) -> dict[str, Any]: + """Convert to package.json codeflash section format. + + Uses camelCase keys as per JSON/JS conventions. + Only includes values that override auto-detection. + """ + config: dict[str, Any] = {} + + # Module root (only if not auto-detected default) + if self.module_root and self.module_root not in (".", "src"): + config["moduleRoot"] = self.module_root + + # Formatter (only if explicitly set) + if self.formatter_cmds: + config["formatterCmds"] = self.formatter_cmds + + # Ignore paths (only if set) + if self.ignore_paths: + config["ignorePaths"] = self.ignore_paths + + # Benchmarks root + if self.benchmarks_root: + config["benchmarksRoot"] = self.benchmarks_root + + # Git remote (only if not default) + if self.git_remote and self.git_remote != "origin": + config["gitRemote"] = self.git_remote + + # Telemetry + if self.disable_telemetry: + config["disableTelemetry"] = True + + return config + + @classmethod + def from_detected_project(cls, detected: Any) -> CodeflashConfig: + """Create config from DetectedProject. + + Args: + detected: DetectedProject instance from detector. + + Returns: + CodeflashConfig instance. + + """ + return cls( + language=detected.language, + module_root=str(detected.module_root.relative_to(detected.project_root)) + if detected.module_root != detected.project_root + else ".", + tests_root=str(detected.tests_root.relative_to(detected.project_root)) if detected.tests_root else None, + test_runner=detected.test_runner, + formatter_cmds=detected.formatter_cmds, + ignore_paths=[ + str(p.relative_to(detected.project_root)) for p in detected.ignore_paths if p != detected.project_root + ], + pytest_cmd=detected.test_runner if detected.language == "python" else "pytest", + ) + + @classmethod + def from_pyproject_dict(cls, data: dict[str, Any], project_root: Path | None = None) -> CodeflashConfig: + """Create config from pyproject.toml [tool.codeflash] section. + + Args: + data: Dict from [tool.codeflash] section. + project_root: Project root path (reserved for future path resolution). + + Returns: + CodeflashConfig instance. + + """ + _ = project_root # Reserved for future path resolution + + def convert_key(key: str) -> str: + """Convert kebab-case to snake_case.""" + return key.replace("-", "_") + + converted = {convert_key(k): v for k, v in data.items()} + converted.setdefault("language", "python") + return cls(**converted) + + @classmethod + def from_package_json_dict(cls, data: dict[str, Any], project_root: Path | None = None) -> CodeflashConfig: + """Create config from package.json codeflash section. + + Args: + data: Dict from package.json "codeflash" key. + project_root: Project root path (reserved for future path resolution). + + Returns: + CodeflashConfig instance. + + """ + _ = project_root # Reserved for future path resolution + + def convert_key(key: str) -> str: + """Convert camelCase to snake_case.""" + import re + + return re.sub(r"(? tuple[bool, str]: + """Write Codeflash config to the appropriate native config file. + + Args: + detected: DetectedProject with project information. + config: Optional CodeflashConfig to write. If None, creates from detected. + + Returns: + Tuple of (success, message). + + """ + from codeflash.setup.config_schema import CodeflashConfig + + if config is None: + config = CodeflashConfig.from_detected_project(detected) + + if detected.language == "python": + return _write_pyproject_toml(detected.project_root, config) + return _write_package_json(detected.project_root, config) + + +def _write_pyproject_toml(project_root: Path, config: CodeflashConfig) -> tuple[bool, str]: + """Write config to pyproject.toml [tool.codeflash] section. + + Creates pyproject.toml if it doesn't exist. + Preserves existing content and formatting. + + Args: + project_root: Project root directory. + config: CodeflashConfig to write. + + Returns: + Tuple of (success, message). + + """ + pyproject_path = project_root / "pyproject.toml" + + try: + # Load existing or create new + if pyproject_path.exists(): + with pyproject_path.open("rb") as f: + doc = tomlkit.parse(f.read()) + else: + doc = tomlkit.document() + + # Ensure [tool] section exists + if "tool" not in doc: + doc["tool"] = tomlkit.table() + + # Create codeflash section + codeflash_table = tomlkit.table() + codeflash_table.add(tomlkit.comment("Codeflash configuration - https://docs.codeflash.ai")) + + # Add config values + config_dict = config.to_pyproject_dict() + for key, value in config_dict.items(): + codeflash_table[key] = value + + # Update the document + doc["tool"]["codeflash"] = codeflash_table + + # Write back + with pyproject_path.open("w", encoding="utf8") as f: + f.write(tomlkit.dumps(doc)) + + return True, f"Config saved to {pyproject_path}" + + except Exception as e: + return False, f"Failed to write pyproject.toml: {e}" + + +def _write_package_json(project_root: Path, config: CodeflashConfig) -> tuple[bool, str]: + """Write config to package.json codeflash section. + + Preserves existing content and formatting. + Creates minimal config (only non-default values). + + Args: + project_root: Project root directory. + config: CodeflashConfig to write. + + Returns: + Tuple of (success, message). + + """ + package_json_path = project_root / "package.json" + + if not package_json_path.exists(): + return False, f"No package.json found at {project_root}" + + try: + # Load existing + with package_json_path.open(encoding="utf8") as f: + doc = json.load(f) + + # Get config dict (only non-default values) + config_dict = config.to_package_json_dict() + + # Update or remove codeflash section + if config_dict: + doc["codeflash"] = config_dict + action = "Updated" + else: + # Remove codeflash section if empty (all defaults) + doc.pop("codeflash", None) + action = "Using auto-detected defaults (no config needed)" + + # Write back with nice formatting + with package_json_path.open("w", encoding="utf8") as f: + json.dump(doc, f, indent=2) + f.write("\n") # Trailing newline + + if config_dict: + return True, f"{action} config in {package_json_path}" + return True, action + + except json.JSONDecodeError as e: + return False, f"Invalid JSON in package.json: {e}" + except Exception as e: + return False, f"Failed to write package.json: {e}" + + +def create_pyproject_toml(project_root: Path) -> tuple[bool, str]: + """Create a minimal pyproject.toml file. + + Used when no pyproject.toml exists for a Python project. + + Args: + project_root: Project root directory. + + Returns: + Tuple of (success, message). + + """ + pyproject_path = project_root / "pyproject.toml" + + if pyproject_path.exists(): + return False, f"pyproject.toml already exists at {pyproject_path}" + + try: + doc = tomlkit.document() + doc.add(tomlkit.comment("Created by Codeflash")) + doc.add(tomlkit.nl()) + + # Add minimal [tool.codeflash] section + tool_table = tomlkit.table() + codeflash_table = tomlkit.table() + codeflash_table.add(tomlkit.comment("Codeflash configuration - https://docs.codeflash.ai")) + tool_table["codeflash"] = codeflash_table + doc["tool"] = tool_table + + with pyproject_path.open("w", encoding="utf8") as f: + f.write(tomlkit.dumps(doc)) + + return True, f"Created {pyproject_path}" + + except Exception as e: + return False, f"Failed to create pyproject.toml: {e}" + + +def remove_config(project_root: Path, language: str) -> tuple[bool, str]: + """Remove Codeflash config from native config file. + + Args: + project_root: Project root directory. + language: Project language ("python", "javascript", "typescript"). + + Returns: + Tuple of (success, message). + + """ + if language == "python": + return _remove_from_pyproject(project_root) + return _remove_from_package_json(project_root) + + +def _remove_from_pyproject(project_root: Path) -> tuple[bool, str]: + """Remove [tool.codeflash] section from pyproject.toml.""" + pyproject_path = project_root / "pyproject.toml" + + if not pyproject_path.exists(): + return True, "No pyproject.toml found" + + try: + with pyproject_path.open("rb") as f: + doc = tomlkit.parse(f.read()) + + if "tool" in doc and "codeflash" in doc["tool"]: + del doc["tool"]["codeflash"] + + with pyproject_path.open("w", encoding="utf8") as f: + f.write(tomlkit.dumps(doc)) + + return True, "Removed [tool.codeflash] section from pyproject.toml" + + return True, "No codeflash config found in pyproject.toml" + + except Exception as e: + return False, f"Failed to remove config: {e}" + + +def _remove_from_package_json(project_root: Path) -> tuple[bool, str]: + """Remove codeflash section from package.json.""" + package_json_path = project_root / "package.json" + + if not package_json_path.exists(): + return True, "No package.json found" + + try: + with package_json_path.open(encoding="utf8") as f: + doc = json.load(f) + + if "codeflash" in doc: + del doc["codeflash"] + + with package_json_path.open("w", encoding="utf8") as f: + json.dump(doc, f, indent=2) + f.write("\n") + + return True, "Removed codeflash section from package.json" + + return True, "No codeflash config found in package.json" + + except Exception as e: + return False, f"Failed to remove config: {e}" diff --git a/codeflash/setup/detector.py b/codeflash/setup/detector.py new file mode 100644 index 000000000..5a5bb9e5a --- /dev/null +++ b/codeflash/setup/detector.py @@ -0,0 +1,692 @@ +"""Universal project detection engine for Codeflash. + +This module provides a single detection engine that works for all supported languages, +consolidating detection logic from various parts of the codebase. + +Usage: + from codeflash.setup import detect_project + + detected = detect_project() + print(f"Language: {detected.language}") + print(f"Module root: {detected.module_root}") + print(f"Test runner: {detected.test_runner}") +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import tomlkit + + +@dataclass +class DetectedProject: + """Result of project auto-detection. + + All paths are absolute. The confidence score indicates how certain + we are about the detection (0.0 = guessing, 1.0 = certain). + """ + + # Core detection results + language: str # "python" | "javascript" | "typescript" + project_root: Path + module_root: Path + tests_root: Path | None + + # Tooling detection + test_runner: str # "pytest" | "jest" | "vitest" | "mocha" + formatter_cmds: list[str] + + # Ignore paths (absolute paths to ignore) + ignore_paths: list[Path] = field(default_factory=list) + + # Confidence score for the detection (0.0 - 1.0) + confidence: float = 0.8 + + # Detection details (for debugging/display) + detection_details: dict[str, str] = field(default_factory=dict) + + def to_display_dict(self) -> dict[str, str]: + """Convert to dictionary for display purposes.""" + formatter_display = self.formatter_cmds[0] if self.formatter_cmds else "none detected" + if len(self.formatter_cmds) > 1: + formatter_display += f" (+{len(self.formatter_cmds) - 1} more)" + + ignore_display = ", ".join(p.name for p in self.ignore_paths[:3]) + if len(self.ignore_paths) > 3: + ignore_display += f" (+{len(self.ignore_paths) - 3} more)" + + return { + "Language": self.language.capitalize(), + "Module root": str(self.module_root.relative_to(self.project_root)) + if self.module_root != self.project_root + else ".", + "Tests root": str(self.tests_root.relative_to(self.project_root)) if self.tests_root else "not detected", + "Test runner": self.test_runner, + "Formatter": formatter_display or "none", + "Ignoring": ignore_display or "defaults only", + } + + +def detect_project(path: Path | None = None) -> DetectedProject: + """Auto-detect all project settings. + + This is the main entry point for project detection. It finds the project root, + detects the language, and auto-detects all configuration values. + + Args: + path: Starting path for detection. Defaults to current working directory. + + Returns: + DetectedProject with all detected settings. + + Raises: + ValueError: If no valid project can be detected. + + """ + start_path = path or Path.cwd() + detection_details: dict[str, str] = {} + + # Step 1: Find project root + project_root = _find_project_root(start_path) + if project_root is None: + # No project root found, use start_path + project_root = start_path + detection_details["project_root"] = "using current directory (no markers found)" + else: + detection_details["project_root"] = f"found at {project_root}" + + # Step 2: Detect language + language, lang_confidence, lang_detail = _detect_language(project_root) + detection_details["language"] = lang_detail + + # Step 3: Detect module root + module_root, module_detail = _detect_module_root(project_root, language) + detection_details["module_root"] = module_detail + + # Step 4: Detect tests root + tests_root, tests_detail = _detect_tests_root(project_root, language) + detection_details["tests_root"] = tests_detail + + # Step 5: Detect test runner + test_runner, runner_detail = _detect_test_runner(project_root, language) + detection_details["test_runner"] = runner_detail + + # Step 6: Detect formatter + formatter_cmds, formatter_detail = _detect_formatter(project_root, language) + detection_details["formatter"] = formatter_detail + + # Step 7: Detect ignore paths + ignore_paths, ignore_detail = _detect_ignore_paths(project_root, language) + detection_details["ignore_paths"] = ignore_detail + + # Calculate overall confidence + confidence = lang_confidence * 0.4 + 0.6 # Language detection is 40% of confidence + + return DetectedProject( + language=language, + project_root=project_root, + module_root=module_root, + tests_root=tests_root, + test_runner=test_runner, + formatter_cmds=formatter_cmds, + ignore_paths=ignore_paths, + confidence=confidence, + detection_details=detection_details, + ) + + +def _find_project_root(start_path: Path) -> Path | None: + """Find the project root by walking up the directory tree. + + Looks for: + - .git directory (git repository root) + - pyproject.toml (Python project) + - package.json (JavaScript/TypeScript project) + - Cargo.toml (Rust project - future) + + Args: + start_path: Starting directory for search. + + Returns: + Path to project root, or None if not found. + + """ + current = start_path.resolve() + + while current != current.parent: + # Check for project markers + markers = [".git", "pyproject.toml", "package.json", "Cargo.toml"] + for marker in markers: + if (current / marker).exists(): + return current + current = current.parent + + return None + + +def _detect_language(project_root: Path) -> tuple[str, float, str]: + """Detect the primary programming language of the project. + + Detection priority: + 1. tsconfig.json → TypeScript (high confidence) + 2. pyproject.toml or setup.py → Python (high confidence) + 3. package.json → JavaScript (medium confidence) + 4. File extension counting → best guess (low confidence) + + Args: + project_root: Root directory of the project. + + Returns: + Tuple of (language, confidence, detail_string). + + """ + has_tsconfig = (project_root / "tsconfig.json").exists() + has_pyproject = (project_root / "pyproject.toml").exists() + has_setup_py = (project_root / "setup.py").exists() + has_package_json = (project_root / "package.json").exists() + + # TypeScript (tsconfig.json is definitive) + if has_tsconfig: + return "typescript", 1.0, "tsconfig.json found" + + # Python (pyproject.toml or setup.py) + if has_pyproject or has_setup_py: + marker = "pyproject.toml" if has_pyproject else "setup.py" + # Check if it's also a JS project (monorepo) + if has_package_json: + # Count files to determine primary language + py_count = len(list(project_root.rglob("*.py"))) + js_count = len(list(project_root.rglob("*.js"))) + len(list(project_root.rglob("*.ts"))) + if js_count > py_count * 2: # JS files significantly outnumber Python + return "javascript", 0.7, "package.json found (more JS files than Python)" + return "python", 1.0, f"{marker} found" + + # JavaScript (package.json without Python markers) + if has_package_json: + return "javascript", 0.9, "package.json found" + + # Fall back to file extension counting + py_count = len(list(project_root.rglob("*.py"))) + js_count = len(list(project_root.rglob("*.js"))) + ts_count = len(list(project_root.rglob("*.ts"))) + + if ts_count > 0: + return "typescript", 0.5, f"found {ts_count} .ts files" + if js_count > py_count: + return "javascript", 0.5, f"found {js_count} .js files" + if py_count > 0: + return "python", 0.5, f"found {py_count} .py files" + + # Default to Python + return "python", 0.3, "defaulting to Python" + + +def _detect_module_root(project_root: Path, language: str) -> tuple[Path, str]: + """Detect the module/source root directory. + + Args: + project_root: Root directory of the project. + language: Detected language. + + Returns: + Tuple of (module_root_path, detail_string). + + """ + if language in ("javascript", "typescript"): + return _detect_js_module_root(project_root) + return _detect_python_module_root(project_root) + + +def _detect_python_module_root(project_root: Path) -> tuple[Path, str]: + """Detect Python module root. + + Priority: + 1. pyproject.toml [tool.poetry.name] or [project.name] + 2. src/ directory with __init__.py + 3. Directory with __init__.py matching project name + 4. src/ directory (even without __init__.py) + 5. Project root + + """ + # Try to get project name from pyproject.toml + pyproject_path = project_root / "pyproject.toml" + project_name = None + + if pyproject_path.exists(): + try: + with pyproject_path.open("rb") as f: + data = tomlkit.parse(f.read()) + + # Try poetry name + project_name = data.get("tool", {}).get("poetry", {}).get("name") + # Try standard project name + if not project_name: + project_name = data.get("project", {}).get("name") + except Exception: + pass + + # Check for src layout + src_dir = project_root / "src" + if src_dir.is_dir(): + # Check for package inside src + if project_name: + pkg_dir = src_dir / project_name + if pkg_dir.is_dir() and (pkg_dir / "__init__.py").exists(): + return pkg_dir, f"src/{project_name}/ (from pyproject.toml name)" + + # Check for any package in src + for child in src_dir.iterdir(): + if child.is_dir() and (child / "__init__.py").exists(): + return child, f"src/{child.name}/ (first package in src)" + + # Use src/ even without __init__.py + return src_dir, "src/ directory" + + # Check for package at project root + if project_name: + pkg_dir = project_root / project_name + if pkg_dir.is_dir() and (pkg_dir / "__init__.py").exists(): + return pkg_dir, f"{project_name}/ (from pyproject.toml name)" + + # Look for any directory with __init__.py at project root + for child in project_root.iterdir(): + if ( + child.is_dir() + and not child.name.startswith(".") + and child.name not in ("tests", "test", "docs", "venv", ".venv", "env", "node_modules") + ): + if (child / "__init__.py").exists(): + return child, f"{child.name}/ (has __init__.py)" + + # Default to project root + return project_root, "project root (no package structure detected)" + + +def _detect_js_module_root(project_root: Path) -> tuple[Path, str]: + """Detect JavaScript/TypeScript module root. + + Priority: + 1. package.json "exports" field + 2. package.json "module" field (ESM) + 3. package.json "main" field (CJS) + 4. src/ directory + 5. lib/ directory + 6. Project root + + """ + package_json_path = project_root / "package.json" + package_data: dict[str, Any] = {} + + if package_json_path.exists(): + try: + with package_json_path.open(encoding="utf8") as f: + package_data = json.load(f) + except (json.JSONDecodeError, OSError): + pass + + # Check exports field (modern Node.js) + exports = package_data.get("exports") + if exports: + entry_path = _extract_entry_path(exports) + if entry_path: + parent = Path(entry_path).parent + if parent != Path() and parent.as_posix() != "." and (project_root / parent).is_dir(): + return project_root / parent, f'{parent.as_posix()}/ (from package.json "exports")' + + # Check module field (ESM) + module_field = package_data.get("module") + if module_field and isinstance(module_field, str): + parent = Path(module_field).parent + if parent != Path() and parent.as_posix() != "." and (project_root / parent).is_dir(): + return project_root / parent, f'{parent.as_posix()}/ (from package.json "module")' + + # Check main field (CJS) + main_field = package_data.get("main") + if main_field and isinstance(main_field, str): + parent = Path(main_field).parent + if parent != Path() and parent.as_posix() != "." and (project_root / parent).is_dir(): + return project_root / parent, f'{parent.as_posix()}/ (from package.json "main")' + + # Check for common source directories + for src_dir in ["src", "lib", "source"]: + if (project_root / src_dir).is_dir(): + return project_root / src_dir, f"{src_dir}/ directory" + + # Default to project root + return project_root, "project root" + + +def _extract_entry_path(exports: Any) -> str | None: + """Extract entry path from package.json exports field.""" + if isinstance(exports, str): + return exports + if isinstance(exports, dict): + # Handle {"." : "./src/index.js"} or {".": {"import": "./src/index.js"}} + main_export = exports.get(".") or exports.get("import") or exports.get("default") + if isinstance(main_export, str): + return main_export + if isinstance(main_export, dict): + return main_export.get("import") or main_export.get("default") or main_export.get("require") + return None + + +def _detect_tests_root(project_root: Path, language: str) -> tuple[Path | None, str]: + """Detect the tests directory. + + Common patterns: + - tests/ or test/ + - __tests__/ (JavaScript) + - spec/ (Ruby/JavaScript) + + """ + # Common test directory names + test_dirs = ["tests", "test", "__tests__", "spec"] + + for test_dir in test_dirs: + test_path = project_root / test_dir + if test_path.is_dir(): + return test_path, f"{test_dir}/ directory" + + # For Python, check if tests are alongside source + if language == "python": + # Look for test_*.py files in project root + test_files = list(project_root.glob("test_*.py")) + if test_files: + return project_root, "test files in project root" + + # For JS/TS, check for *.test.js or *.spec.js files + if language in ("javascript", "typescript"): + test_patterns = ["*.test.js", "*.test.ts", "*.spec.js", "*.spec.ts"] + for pattern in test_patterns: + test_files = list(project_root.rglob(pattern)) + if test_files: + # Find common parent + return project_root, f"found {pattern} files" + + return None, "not detected" + + +def _detect_test_runner(project_root: Path, language: str) -> tuple[str, str]: + """Detect the test runner. + + Python: pytest > unittest + JavaScript: vitest > jest > mocha + + """ + if language in ("javascript", "typescript"): + return _detect_js_test_runner(project_root) + return _detect_python_test_runner(project_root) + + +def _detect_python_test_runner(project_root: Path) -> tuple[str, str]: + """Detect Python test runner.""" + # Check for pytest markers + pytest_markers = ["pytest.ini", "pyproject.toml", "conftest.py", "setup.cfg"] + for marker in pytest_markers: + marker_path = project_root / marker + if marker_path.exists(): + if marker == "pyproject.toml": + # Check for [tool.pytest] section + try: + with marker_path.open("rb") as f: + data = tomlkit.parse(f.read()) + if "tool" in data and "pytest" in data["tool"]: + return "pytest", "pyproject.toml [tool.pytest]" + except Exception: + pass + elif marker == "conftest.py": + return "pytest", "conftest.py found" + elif marker in ("pytest.ini", "setup.cfg"): + # Check for pytest section in setup.cfg + if marker == "setup.cfg": + try: + content = marker_path.read_text(encoding="utf8") + if "[tool:pytest]" in content or "[pytest]" in content: + return "pytest", "setup.cfg [pytest]" + except Exception: + pass + else: + return "pytest", "pytest.ini found" + + # Default to pytest (most common) + return "pytest", "default" + + +def _detect_js_test_runner(project_root: Path) -> tuple[str, str]: + """Detect JavaScript test runner.""" + package_json_path = project_root / "package.json" + + if not package_json_path.exists(): + return "jest", "default (no package.json)" + + try: + with package_json_path.open(encoding="utf8") as f: + package_data = json.load(f) + except (json.JSONDecodeError, OSError): + return "jest", "default (invalid package.json)" + + runners = ["vitest", "jest", "mocha"] + dev_deps = package_data.get("devDependencies", {}) + deps = package_data.get("dependencies", {}) + all_deps = {**deps, **dev_deps} + + # Check dependencies (order matters - prefer more modern runners) + for runner in runners: + if runner in all_deps: + return runner, "from devDependencies" + + # Parse scripts.test for hints + scripts = package_data.get("scripts", {}) + test_script = scripts.get("test", "") + if isinstance(test_script, str): + test_lower = test_script.lower() + for runner in runners: + if runner in test_lower: + return runner, "from scripts.test" + + # Check for config files + config_files = { + "vitest": ["vitest.config.js", "vitest.config.ts", "vitest.config.mjs"], + "jest": ["jest.config.js", "jest.config.ts", "jest.config.mjs", "jest.config.json"], + "mocha": [".mocharc.js", ".mocharc.json", ".mocharc.yaml"], + } + for runner, configs in config_files.items(): + for config in configs: + if (project_root / config).exists(): + return runner, f"{config} found" + + return "jest", "default" + + +def _detect_formatter(project_root: Path, language: str) -> tuple[list[str], str]: + """Detect code formatter. + + Python: ruff > black + JavaScript: prettier > eslint --fix + + """ + if language in ("javascript", "typescript"): + return _detect_js_formatter(project_root) + return _detect_python_formatter(project_root) + + +def _detect_python_formatter(project_root: Path) -> tuple[list[str], str]: + """Detect Python formatter.""" + pyproject_path = project_root / "pyproject.toml" + + if pyproject_path.exists(): + try: + with pyproject_path.open("rb") as f: + data = tomlkit.parse(f.read()) + + tool = data.get("tool", {}) + + # Check for ruff + if "ruff" in tool: + return ["ruff check --exit-zero --fix $file", "ruff format $file"], "from pyproject.toml [tool.ruff]" + + # Check for black + if "black" in tool: + return ["black $file"], "from pyproject.toml [tool.black]" + except Exception: + pass + + # Check for config files + if (project_root / "ruff.toml").exists() or (project_root / ".ruff.toml").exists(): + return ["ruff check --exit-zero --fix $file", "ruff format $file"], "ruff.toml found" + + if (project_root / ".black").exists() or (project_root / "pyproject.toml").exists(): + # Default to black if pyproject.toml exists (common setup) + return ["black $file"], "default (black)" + + return [], "none detected" + + +def _detect_js_formatter(project_root: Path) -> tuple[list[str], str]: + """Detect JavaScript formatter.""" + package_json_path = project_root / "package.json" + + # Check for prettier config files + prettier_configs = [".prettierrc", ".prettierrc.js", ".prettierrc.json", "prettier.config.js"] + for config in prettier_configs: + if (project_root / config).exists(): + return ["npx prettier --write $file"], f"{config} found" + + # Check for eslint config files + eslint_configs = [".eslintrc", ".eslintrc.js", ".eslintrc.json", "eslint.config.js"] + for config in eslint_configs: + if (project_root / config).exists(): + return ["npx eslint --fix $file"], f"{config} found" + + # Check package.json dependencies + if package_json_path.exists(): + try: + with package_json_path.open(encoding="utf8") as f: + package_data = json.load(f) + + dev_deps = package_data.get("devDependencies", {}) + deps = package_data.get("dependencies", {}) + all_deps = {**deps, **dev_deps} + + if "prettier" in all_deps: + return ["npx prettier --write $file"], "from devDependencies" + if "eslint" in all_deps: + return ["npx eslint --fix $file"], "from devDependencies" + except (json.JSONDecodeError, OSError): + pass + + return [], "none detected" + + +def _detect_ignore_paths(project_root: Path, language: str) -> tuple[list[Path], str]: + """Detect paths to ignore during optimization. + + Sources: + 1. .gitignore + 2. Language-specific defaults + + """ + ignore_paths: list[Path] = [] + sources: list[str] = [] + + # Default ignore patterns by language + default_ignores: dict[str, list[str]] = { + "python": [ + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + "venv", + ".venv", + "env", + ".env", + "dist", + "build", + "*.egg-info", + ".tox", + ".nox", + "htmlcov", + ".coverage", + ], + "javascript": ["node_modules", "dist", "build", ".next", ".nuxt", "coverage", ".cache"], + "typescript": ["node_modules", "dist", "build", ".next", ".nuxt", "coverage", ".cache"], + } + + # Add default ignores + for pattern in default_ignores.get(language, []): + path = project_root / pattern.replace("*", "") + if path.exists(): + ignore_paths.append(path) + + if ignore_paths: + sources.append("defaults") + + # Parse .gitignore + gitignore_path = project_root / ".gitignore" + if gitignore_path.exists(): + try: + content = gitignore_path.read_text(encoding="utf8") + for line in content.splitlines(): + line = line.strip() + # Skip comments and empty lines + if not line or line.startswith("#"): + continue + # Skip negation patterns + if line.startswith("!"): + continue + # Convert gitignore pattern to path + pattern = line.rstrip("/").lstrip("/") + # Skip complex patterns for now + if "*" in pattern or "?" in pattern: + continue + path = project_root / pattern + if path.exists() and path not in ignore_paths: + ignore_paths.append(path) + + if ".gitignore" not in sources: + sources.append(".gitignore") + except Exception: + pass + + detail = " + ".join(sources) if sources else "none" + return ignore_paths, detail + + +def has_existing_config(project_root: Path) -> tuple[bool, str | None]: + """Check if project has existing Codeflash configuration. + + Args: + project_root: Root directory of the project. + + Returns: + Tuple of (has_config, config_file_type). + config_file_type is "pyproject.toml", "package.json", or None. + + """ + # Check pyproject.toml + pyproject_path = project_root / "pyproject.toml" + if pyproject_path.exists(): + try: + with pyproject_path.open("rb") as f: + data = tomlkit.parse(f.read()) + if "tool" in data and "codeflash" in data["tool"]: + return True, "pyproject.toml" + except Exception: + pass + + # Check package.json + package_json_path = project_root / "package.json" + if package_json_path.exists(): + try: + with package_json_path.open(encoding="utf8") as f: + data = json.load(f) + if "codeflash" in data: + return True, "package.json" + except Exception: + pass + + return False, None diff --git a/codeflash/setup/first_run.py b/codeflash/setup/first_run.py new file mode 100644 index 000000000..b45fea17e --- /dev/null +++ b/codeflash/setup/first_run.py @@ -0,0 +1,294 @@ +"""First-run experience for Codeflash. + +This module handles the seamless first-run experience: +1. Auto-detect project settings +2. Display detected settings +3. Quick confirmation +4. API key setup +5. Save config and continue + +Usage: + from codeflash.setup.first_run import handle_first_run, is_first_run + + if is_first_run(): + args = handle_first_run(args) +""" + +from __future__ import annotations + +import os +import sys +from typing import TYPE_CHECKING + +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from codeflash.cli_cmds.console import console +from codeflash.setup.config_writer import write_config +from codeflash.setup.detector import detect_project, has_existing_config + +if TYPE_CHECKING: + from argparse import Namespace + from pathlib import Path + + +def is_first_run(project_root: Path | None = None) -> bool: + """Check if this is the first run (no config exists). + + Args: + project_root: Project root to check. Defaults to auto-detect. + + Returns: + True if no Codeflash config exists. + + """ + if project_root is None: + try: + detected = detect_project() + project_root = detected.project_root + except Exception: + return True + + has_config, _ = has_existing_config(project_root) + return not has_config + + +def handle_first_run( + args: Namespace | None = None, skip_confirm: bool = False, skip_api_key: bool = False +) -> Namespace | None: + """Handle the first-run experience with auto-detection and quick confirm. + + This is the main entry point for the frictionless setup experience. + + Args: + args: Optional CLI args namespace to update. + skip_confirm: Skip confirmation prompt (--yes flag). + skip_api_key: Skip API key prompt. + + Returns: + Updated args namespace with detected settings, or None if user cancelled. + + """ + from argparse import Namespace + + # Auto-detect project + try: + detected = detect_project() + except Exception as e: + _show_detection_error(str(e)) + return None + + # Show welcome message + _show_welcome() + + # Show detected settings + _show_detected_settings(detected) + + # Get user confirmation + if not skip_confirm: + choice = _prompt_confirmation() + if choice == "n": + _show_cancelled() + return None + if choice == "customize": + # TODO: Implement customize flow (redirect to codeflash init) + console.print("\nšŸ’” Run [cyan]codeflash init[/cyan] for full customization.\n") + return None + + # Handle API key + if not skip_api_key: + api_key_ok = _handle_api_key() + if not api_key_ok: + return None + + # Save config + success, message = write_config(detected) + if success: + console.print(f"\nāœ… {message}\n") + else: + console.print(f"\nāš ļø {message}\n") + console.print("Continuing with detected settings (not saved).\n") + + # Create/update args namespace + if args is None: + args = Namespace() + + # Populate args with detected values + args.module_root = str(detected.module_root) + args.tests_root = str(detected.tests_root) if detected.tests_root else None + args.project_root = str(detected.project_root) + args.formatter_cmds = detected.formatter_cmds + args.ignore_paths = [str(p) for p in detected.ignore_paths] + args.pytest_cmd = detected.test_runner + args.language = detected.language + + # Set defaults for other common args + if not hasattr(args, "disable_telemetry"): + args.disable_telemetry = False + if not hasattr(args, "git_remote"): + args.git_remote = "origin" + + return args + + +def _show_welcome() -> None: + """Show welcome message for first-time users.""" + welcome_panel = Panel( + Text( + "⚔ Welcome to Codeflash!\n\nI've auto-detected your project settings.\nThis will only take a moment.", + style="bold cyan", + justify="center", + ), + title="šŸš€ First-Time Setup", + border_style="bright_cyan", + padding=(1, 2), + ) + console.print(welcome_panel) + console.print() + + +def _show_detected_settings(detected: detect_project) -> None: + """Display detected settings in a nice table.""" + from codeflash.setup.detector import DetectedProject + + if not isinstance(detected, DetectedProject): + return + + # Create settings table + table = Table(show_header=False, box=None, padding=(0, 2)) + table.add_column("Setting", style="cyan", width=15) + table.add_column("Value", style="green") + table.add_column("Source", style="dim") + + display_dict = detected.to_display_dict() + details = detected.detection_details + + for key, value in display_dict.items(): + source = details.get(key.lower().replace(" ", "_"), "") + # Truncate long sources + if len(source) > 30: + source = source[:27] + "..." + table.add_row(key, value, f"({source})" if source else "") + + settings_panel = Panel(table, title="šŸ” Auto-Detected Settings", border_style="bright_blue", padding=(1, 2)) + console.print(settings_panel) + console.print() + + +def _prompt_confirmation() -> str: + """Prompt user for confirmation. + + Returns: + "y" for yes, "n" for no, "customize" for customization. + + """ + # Check if we're in a non-interactive environment + if not sys.stdin.isatty(): + console.print("āš ļø Non-interactive environment detected. Use --yes to skip confirmation.") + return "n" + + console.print("? [bold]Proceed with these settings?[/bold]") + console.print(" [green]Y[/green] - Yes, save and continue") + console.print(" [yellow]n[/yellow] - No, cancel") + console.print(" [cyan]c[/cyan] - Customize (run full setup)") + console.print() + + try: + choice = console.input("[bold]Your choice[/bold] [green][Y][/green]/n/c: ").strip().lower() + except (KeyboardInterrupt, EOFError): + return "n" + + if choice in ("", "y", "yes"): + return "y" + if choice in ("c", "customize"): + return "customize" + return "n" + + +def _handle_api_key() -> bool: + """Handle API key setup if not already configured. + + Returns: + True if API key is available, False if user cancelled. + + """ + from codeflash.code_utils.env_utils import get_codeflash_api_key + + # Check for existing API key + try: + existing_key = get_codeflash_api_key() + if existing_key: + display_key = f"{existing_key[:3]}****{existing_key[-4:]}" + console.print(f"āœ… Found API key: [green]{display_key}[/green]\n") + return True + except OSError: + pass + + # Prompt for API key + console.print("šŸ”‘ [bold]API Key Required[/bold]") + console.print(" Get your API key at: [cyan]https://app.codeflash.ai/app/apikeys[/cyan]\n") + + try: + api_key = console.input(" Enter API key (or press Enter to open browser): ").strip() + except (KeyboardInterrupt, EOFError): + return False + + if not api_key: + # Open browser + import click + + click.launch("https://app.codeflash.ai/app/apikeys") + console.print("\n Opening browser...") + try: + api_key = console.input(" Enter API key: ").strip() + except (KeyboardInterrupt, EOFError): + return False + + if not api_key: + console.print("\nāš ļø API key required. Run [cyan]codeflash init[/cyan] to set up.\n") + return False + + if not api_key.startswith("cf-"): + console.print("\nāš ļø Invalid API key format. Should start with 'cf-'.\n") + return False + + # Save API key to environment + os.environ["CODEFLASH_API_KEY"] = api_key + + # Try to save to shell rc + try: + from codeflash.code_utils.shell_utils import save_api_key_to_rc + from codeflash.either import is_successful + + result = save_api_key_to_rc(api_key) + if is_successful(result): + console.print(f"\nāœ… API key saved. {result.unwrap()}\n") + else: + console.print(f"\nāš ļø Could not save to shell: {result.failure()}") + console.print(" API key set for this session only.\n") + except Exception: + console.print("\nāœ… API key set for this session.\n") + + return True + + +def _show_detection_error(error: str) -> None: + """Show error message when detection fails.""" + error_panel = Panel( + Text( + f"āŒ Could not auto-detect project settings.\n\n" + f"Error: {error}\n\n" + "Please run [cyan]codeflash init[/cyan] for manual setup.", + style="red", + ), + title="āš ļø Detection Failed", + border_style="red", + padding=(1, 2), + ) + console.print(error_panel) + + +def _show_cancelled() -> None: + """Show cancellation message.""" + console.print("\nā¹ļø Setup cancelled. Run [cyan]codeflash init[/cyan] when ready.\n") diff --git a/tests/test_setup/__init__.py b/tests/test_setup/__init__.py new file mode 100644 index 000000000..8ae249fbd --- /dev/null +++ b/tests/test_setup/__init__.py @@ -0,0 +1 @@ +# Tests for the codeflash.setup module \ No newline at end of file diff --git a/tests/test_setup/test_config.py b/tests/test_setup/test_config.py new file mode 100644 index 000000000..3bfd81826 --- /dev/null +++ b/tests/test_setup/test_config.py @@ -0,0 +1,397 @@ +"""Tests for config schema and config writer.""" + +import json +from pathlib import Path + +import pytest +import tomlkit + +from codeflash.setup.config_schema import CodeflashConfig +from codeflash.setup.config_writer import ( + _write_package_json, + _write_pyproject_toml, + create_pyproject_toml, + remove_config, + write_config, +) +from codeflash.setup.detector import detect_project + + +class TestCodeflashConfig: + """Tests for CodeflashConfig Pydantic model.""" + + def test_default_values(self): + """Should have sensible defaults.""" + config = CodeflashConfig(language="python") + + assert config.language == "python" + assert config.module_root == "." + assert config.tests_root is None + assert config.formatter_cmds == [] + assert config.git_remote == "origin" + assert config.disable_telemetry is False + + def test_all_fields(self): + """Should accept all fields.""" + config = CodeflashConfig( + language="javascript", + module_root="src", + tests_root="tests", + test_runner="jest", + formatter_cmds=["npx prettier --write $file"], + ignore_paths=["dist", "node_modules"], + benchmarks_root="benchmarks", + git_remote="upstream", + disable_telemetry=True, + ) + + assert config.language == "javascript" + assert config.module_root == "src" + assert config.tests_root == "tests" + assert config.test_runner == "jest" + assert config.formatter_cmds == ["npx prettier --write $file"] + assert config.ignore_paths == ["dist", "node_modules"] + assert config.git_remote == "upstream" + assert config.disable_telemetry is True + + def test_to_pyproject_dict(self): + """Should convert to pyproject.toml format with kebab-case.""" + config = CodeflashConfig( + language="python", + module_root="codeflash", + tests_root="tests", + formatter_cmds=["ruff format $file"], + ignore_paths=["dist"], + ) + + result = config.to_pyproject_dict() + + assert result["module-root"] == "codeflash" + assert result["tests-root"] == "tests" + assert result["formatter-cmds"] == ["ruff format $file"] + assert result["ignore-paths"] == ["dist"] + # Should not include default values + assert "git-remote" not in result + assert "disable-telemetry" not in result + + def test_to_pyproject_dict_minimal(self): + """Should only include non-default values.""" + config = CodeflashConfig( + language="python", + module_root="src", + ) + + result = config.to_pyproject_dict() + + assert "module-root" in result + # Empty formatter should result in "disabled" + assert result.get("formatter-cmds") == ["disabled"] + + def test_to_package_json_dict(self): + """Should convert to package.json format with camelCase.""" + config = CodeflashConfig( + language="javascript", + module_root="lib", # Non-default + formatter_cmds=["npx prettier --write $file"], + ignore_paths=["dist"], + disable_telemetry=True, + ) + + result = config.to_package_json_dict() + + assert result["moduleRoot"] == "lib" + assert result["formatterCmds"] == ["npx prettier --write $file"] + assert result["ignorePaths"] == ["dist"] + assert result["disableTelemetry"] is True + # Should not include default values + assert "gitRemote" not in result + + def test_to_package_json_dict_minimal(self): + """Should be empty when all values are defaults.""" + config = CodeflashConfig( + language="javascript", + module_root="src", # Default for JS + ) + + result = config.to_package_json_dict() + + # src is a default, should not be included + assert "moduleRoot" not in result + + def test_from_detected_project(self, tmp_path): + """Should create config from DetectedProject.""" + # Create a simple Python project + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"') + (tmp_path / "test").mkdir() + (tmp_path / "test" / "__init__.py").write_text("") + (tmp_path / "tests").mkdir() + + detected = detect_project(tmp_path) + config = CodeflashConfig.from_detected_project(detected) + + assert config.language == detected.language + assert config.test_runner == detected.test_runner + + def test_from_pyproject_dict(self): + """Should create config from pyproject.toml dict.""" + data = { + "module-root": "src", + "tests-root": "tests", + "formatter-cmds": ["black $file"], + "disable-telemetry": True, + } + + config = CodeflashConfig.from_pyproject_dict(data) + + assert config.module_root == "src" + assert config.tests_root == "tests" + assert config.formatter_cmds == ["black $file"] + assert config.disable_telemetry is True + assert config.language == "python" # Default for pyproject + + def test_from_package_json_dict(self): + """Should create config from package.json dict.""" + data = { + "moduleRoot": "lib", + "formatterCmds": ["npx prettier --write $file"], + "disableTelemetry": True, + } + + config = CodeflashConfig.from_package_json_dict(data) + + assert config.module_root == "lib" + assert config.formatter_cmds == ["npx prettier --write $file"] + assert config.disable_telemetry is True + assert config.language == "javascript" # Default for package.json + + +class TestWritePyprojectToml: + """Tests for writing to pyproject.toml.""" + + def test_creates_new_pyproject(self, tmp_path): + """Should create pyproject.toml if it doesn't exist.""" + config = CodeflashConfig( + language="python", + module_root="src", + tests_root="tests", + ) + + success, message = _write_pyproject_toml(tmp_path, config) + + assert success is True + assert (tmp_path / "pyproject.toml").exists() + + # Verify content + content = (tmp_path / "pyproject.toml").read_text() + data = tomlkit.parse(content) + assert "tool" in data + assert "codeflash" in data["tool"] + assert data["tool"]["codeflash"]["module-root"] == "src" + + def test_preserves_existing_content(self, tmp_path): + """Should preserve existing pyproject.toml content.""" + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "myapp"\nversion = "1.0.0"\n\n[tool.ruff]\nline-length = 120' + ) + + config = CodeflashConfig( + language="python", + module_root="src", + ) + + success, message = _write_pyproject_toml(tmp_path, config) + + assert success is True + + # Verify existing content preserved + content = (tmp_path / "pyproject.toml").read_text() + data = tomlkit.parse(content) + assert data["project"]["name"] == "myapp" + assert data["tool"]["ruff"]["line-length"] == 120 + assert data["tool"]["codeflash"]["module-root"] == "src" + + def test_updates_existing_codeflash_section(self, tmp_path): + """Should update existing codeflash section.""" + (tmp_path / "pyproject.toml").write_text( + '[tool.codeflash]\nmodule-root = "old"\ntests-root = "old_tests"' + ) + + config = CodeflashConfig( + language="python", + module_root="new", + tests_root="new_tests", + ) + + success, message = _write_pyproject_toml(tmp_path, config) + + assert success is True + + content = (tmp_path / "pyproject.toml").read_text() + data = tomlkit.parse(content) + assert data["tool"]["codeflash"]["module-root"] == "new" + assert data["tool"]["codeflash"]["tests-root"] == "new_tests" + + +class TestWritePackageJson: + """Tests for writing to package.json.""" + + def test_adds_codeflash_section(self, tmp_path): + """Should add codeflash section to package.json.""" + (tmp_path / "package.json").write_text(json.dumps({ + "name": "myapp", + "version": "1.0.0" + }, indent=2)) + + config = CodeflashConfig( + language="javascript", + module_root="lib", + formatter_cmds=["npx prettier --write $file"], + ) + + success, message = _write_package_json(tmp_path, config) + + assert success is True + + # Verify content + with (tmp_path / "package.json").open() as f: + data = json.load(f) + assert data["name"] == "myapp" # Preserved + assert "codeflash" in data + assert data["codeflash"]["moduleRoot"] == "lib" + + def test_preserves_existing_content(self, tmp_path): + """Should preserve existing package.json content.""" + (tmp_path / "package.json").write_text(json.dumps({ + "name": "myapp", + "dependencies": {"lodash": "^4.17.0"}, + "devDependencies": {"jest": "^29.0.0"} + }, indent=2)) + + config = CodeflashConfig( + language="javascript", + module_root="lib", + ) + + success, message = _write_package_json(tmp_path, config) + + assert success is True + + with (tmp_path / "package.json").open() as f: + data = json.load(f) + assert data["dependencies"]["lodash"] == "^4.17.0" + assert data["devDependencies"]["jest"] == "^29.0.0" + + def test_removes_empty_codeflash_section(self, tmp_path): + """Should remove codeflash section if all defaults.""" + (tmp_path / "package.json").write_text(json.dumps({ + "name": "myapp", + "codeflash": {"moduleRoot": "old"} + }, indent=2)) + + # Config with all defaults - should result in empty dict + config = CodeflashConfig( + language="javascript", + module_root="src", # Default + ) + + success, message = _write_package_json(tmp_path, config) + + assert success is True + + with (tmp_path / "package.json").open() as f: + data = json.load(f) + # Empty codeflash section should be removed + assert "codeflash" not in data + + def test_fails_if_no_package_json(self, tmp_path): + """Should fail if package.json doesn't exist.""" + config = CodeflashConfig(language="javascript") + + success, message = _write_package_json(tmp_path, config) + + assert success is False + assert "No package.json" in message + + +class TestWriteConfig: + """Tests for the unified write_config function.""" + + def test_writes_to_pyproject_for_python(self, tmp_path): + """Should write to pyproject.toml for Python projects.""" + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"') + (tmp_path / "src").mkdir() + (tmp_path / "src" / "__init__.py").write_text("") + + detected = detect_project(tmp_path) + success, message = write_config(detected) + + assert success is True + assert "pyproject.toml" in message + + def test_writes_to_package_json_for_js(self, tmp_path): + """Should write to package.json for JavaScript projects.""" + (tmp_path / "package.json").write_text('{"name": "test"}') + (tmp_path / "src").mkdir() + + detected = detect_project(tmp_path) + success, message = write_config(detected) + + assert success is True + + +class TestRemoveConfig: + """Tests for remove_config function.""" + + def test_removes_from_pyproject(self, tmp_path): + """Should remove codeflash section from pyproject.toml.""" + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "test"\n\n[tool.codeflash]\nmodule-root = "src"' + ) + + success, message = remove_config(tmp_path, "python") + + assert success is True + + content = (tmp_path / "pyproject.toml").read_text() + data = tomlkit.parse(content) + assert data["project"]["name"] == "test" # Preserved + assert "codeflash" not in data.get("tool", {}) + + def test_removes_from_package_json(self, tmp_path): + """Should remove codeflash section from package.json.""" + (tmp_path / "package.json").write_text(json.dumps({ + "name": "test", + "codeflash": {"moduleRoot": "src"} + }, indent=2)) + + success, message = remove_config(tmp_path, "javascript") + + assert success is True + + with (tmp_path / "package.json").open() as f: + data = json.load(f) + assert data["name"] == "test" # Preserved + assert "codeflash" not in data + + +class TestCreatePyprojectToml: + """Tests for create_pyproject_toml function.""" + + def test_creates_minimal_pyproject(self, tmp_path): + """Should create minimal pyproject.toml.""" + success, message = create_pyproject_toml(tmp_path) + + assert success is True + assert (tmp_path / "pyproject.toml").exists() + + content = (tmp_path / "pyproject.toml").read_text() + assert "codeflash" in content.lower() + + def test_fails_if_already_exists(self, tmp_path): + """Should fail if pyproject.toml already exists.""" + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"') + + success, message = create_pyproject_toml(tmp_path) + + assert success is False + assert "already exists" in message \ No newline at end of file diff --git a/tests/test_setup/test_detector.py b/tests/test_setup/test_detector.py new file mode 100644 index 000000000..16870609f --- /dev/null +++ b/tests/test_setup/test_detector.py @@ -0,0 +1,389 @@ +"""Tests for the universal project detector.""" + +import json +import tempfile +from pathlib import Path + +import pytest +import tomlkit + +from codeflash.setup.detector import ( + DetectedProject, + _detect_formatter, + _detect_js_formatter, + _detect_js_module_root, + _detect_js_test_runner, + _detect_language, + _detect_module_root, + _detect_python_formatter, + _detect_python_module_root, + _detect_python_test_runner, + _detect_test_runner, + _detect_tests_root, + _find_project_root, + detect_project, + has_existing_config, +) + + +class TestFindProjectRoot: + """Tests for _find_project_root function.""" + + def test_finds_git_directory(self, tmp_path): + """Should find project root by .git directory.""" + (tmp_path / ".git").mkdir() + subdir = tmp_path / "src" / "deep" + subdir.mkdir(parents=True) + + result = _find_project_root(subdir) + assert result == tmp_path + + def test_finds_pyproject_toml(self, tmp_path): + """Should find project root by pyproject.toml.""" + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'test'") + subdir = tmp_path / "src" + subdir.mkdir() + + result = _find_project_root(subdir) + assert result == tmp_path + + def test_finds_package_json(self, tmp_path): + """Should find project root by package.json.""" + (tmp_path / "package.json").write_text('{"name": "test"}') + subdir = tmp_path / "lib" + subdir.mkdir() + + result = _find_project_root(subdir) + assert result == tmp_path + + def test_returns_none_when_no_markers(self, tmp_path): + """Should return None when no project markers found.""" + subdir = tmp_path / "orphan" + subdir.mkdir() + + result = _find_project_root(subdir) + # Will walk up to filesystem root and not find anything + assert result is None or result == tmp_path + + +class TestDetectLanguage: + """Tests for _detect_language function.""" + + def test_detects_typescript_from_tsconfig(self, tmp_path): + """Should detect TypeScript when tsconfig.json exists.""" + (tmp_path / "tsconfig.json").write_text("{}") + (tmp_path / "package.json").write_text('{"name": "test"}') + + lang, confidence, detail = _detect_language(tmp_path) + assert lang == "typescript" + assert confidence == 1.0 + assert "tsconfig.json" in detail + + def test_detects_python_from_pyproject(self, tmp_path): + """Should detect Python when pyproject.toml exists.""" + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'test'") + + lang, confidence, detail = _detect_language(tmp_path) + assert lang == "python" + assert confidence == 1.0 + assert "pyproject.toml" in detail + + def test_detects_python_from_setup_py(self, tmp_path): + """Should detect Python when setup.py exists.""" + (tmp_path / "setup.py").write_text("from setuptools import setup\nsetup()") + + lang, confidence, detail = _detect_language(tmp_path) + assert lang == "python" + assert confidence == 1.0 + assert "setup.py" in detail + + def test_detects_javascript_from_package_json(self, tmp_path): + """Should detect JavaScript when only package.json exists.""" + (tmp_path / "package.json").write_text('{"name": "test"}') + + lang, confidence, detail = _detect_language(tmp_path) + assert lang == "javascript" + assert confidence == 0.9 + assert "package.json" in detail + + def test_defaults_to_python(self, tmp_path): + """Should default to Python when no markers found.""" + lang, confidence, detail = _detect_language(tmp_path) + assert lang == "python" + assert confidence < 0.5 # Low confidence + + +class TestDetectModuleRoot: + """Tests for module root detection.""" + + def test_python_detects_src_layout(self, tmp_path): + """Should detect src/ layout for Python.""" + src_dir = tmp_path / "src" / "mypackage" + src_dir.mkdir(parents=True) + (src_dir / "__init__.py").write_text("") + + module_root, detail = _detect_python_module_root(tmp_path) + assert module_root == src_dir + assert "src" in str(module_root) + + def test_python_detects_package_at_root(self, tmp_path): + """Should detect package at project root.""" + pkg_dir = tmp_path / "mypackage" + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text("") + + module_root, detail = _detect_python_module_root(tmp_path) + assert module_root == pkg_dir + + def test_python_uses_pyproject_name(self, tmp_path): + """Should use project name from pyproject.toml.""" + (tmp_path / "pyproject.toml").write_text('[project]\nname = "myapp"') + pkg_dir = tmp_path / "myapp" + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text("") + + module_root, detail = _detect_python_module_root(tmp_path) + assert module_root == pkg_dir + assert "pyproject.toml" in detail + + def test_js_detects_from_exports(self, tmp_path): + """Should detect module root from package.json exports.""" + (tmp_path / "package.json").write_text(json.dumps({ + "name": "test", + "exports": {".": "./src/index.js"} + })) + (tmp_path / "src").mkdir() + + module_root, detail = _detect_js_module_root(tmp_path) + assert module_root == tmp_path / "src" + assert "exports" in detail + + def test_js_detects_src_convention(self, tmp_path): + """Should detect src/ directory for JS.""" + (tmp_path / "package.json").write_text('{"name": "test"}') + (tmp_path / "src").mkdir() + + module_root, detail = _detect_js_module_root(tmp_path) + assert module_root == tmp_path / "src" + + +class TestDetectTestsRoot: + """Tests for tests root detection.""" + + def test_detects_tests_directory(self, tmp_path): + """Should detect tests/ directory.""" + (tmp_path / "tests").mkdir() + + tests_root, detail = _detect_tests_root(tmp_path, "python") + assert tests_root == tmp_path / "tests" + + def test_detects_test_directory(self, tmp_path): + """Should detect test/ directory.""" + (tmp_path / "test").mkdir() + + tests_root, detail = _detect_tests_root(tmp_path, "python") + assert tests_root == tmp_path / "test" + + def test_detects_dunder_tests(self, tmp_path): + """Should detect __tests__/ directory (JS convention).""" + (tmp_path / "__tests__").mkdir() + + tests_root, detail = _detect_tests_root(tmp_path, "javascript") + assert tests_root == tmp_path / "__tests__" + + def test_returns_none_when_not_found(self, tmp_path): + """Should return None when no tests directory found.""" + tests_root, detail = _detect_tests_root(tmp_path, "python") + assert tests_root is None + + +class TestDetectTestRunner: + """Tests for test runner detection.""" + + def test_python_detects_pytest_from_ini(self, tmp_path): + """Should detect pytest from pytest.ini.""" + (tmp_path / "pytest.ini").write_text("[pytest]") + + runner, detail = _detect_python_test_runner(tmp_path) + assert runner == "pytest" + + def test_python_detects_pytest_from_conftest(self, tmp_path): + """Should detect pytest from conftest.py.""" + (tmp_path / "conftest.py").write_text("import pytest") + + runner, detail = _detect_python_test_runner(tmp_path) + assert runner == "pytest" + + def test_js_detects_jest_from_deps(self, tmp_path): + """Should detect jest from devDependencies.""" + (tmp_path / "package.json").write_text(json.dumps({ + "devDependencies": {"jest": "^29.0.0"} + })) + + runner, detail = _detect_js_test_runner(tmp_path) + assert runner == "jest" + + def test_js_detects_vitest_from_deps(self, tmp_path): + """Should detect vitest from devDependencies (preferred over jest).""" + (tmp_path / "package.json").write_text(json.dumps({ + "devDependencies": {"vitest": "^1.0.0", "jest": "^29.0.0"} + })) + + runner, detail = _detect_js_test_runner(tmp_path) + assert runner == "vitest" + + def test_js_detects_from_config_file(self, tmp_path): + """Should detect test runner from config file.""" + (tmp_path / "package.json").write_text('{"name": "test"}') + (tmp_path / "vitest.config.js").write_text("export default {}") + + runner, detail = _detect_js_test_runner(tmp_path) + assert runner == "vitest" + + +class TestDetectFormatter: + """Tests for formatter detection.""" + + def test_python_detects_ruff(self, tmp_path): + """Should detect ruff from pyproject.toml.""" + (tmp_path / "pyproject.toml").write_text("[tool.ruff]\nline-length = 120") + + formatter, detail = _detect_python_formatter(tmp_path) + assert any("ruff" in cmd for cmd in formatter) + + def test_python_detects_black(self, tmp_path): + """Should detect black from pyproject.toml.""" + (tmp_path / "pyproject.toml").write_text("[tool.black]\nline-length = 88") + + formatter, detail = _detect_python_formatter(tmp_path) + assert any("black" in cmd for cmd in formatter) + + def test_js_detects_prettier(self, tmp_path): + """Should detect prettier from config file.""" + (tmp_path / "package.json").write_text('{"name": "test"}') + (tmp_path / ".prettierrc").write_text("{}") + + formatter, detail = _detect_js_formatter(tmp_path) + assert any("prettier" in cmd for cmd in formatter) + + def test_js_detects_prettier_from_deps(self, tmp_path): + """Should detect prettier from devDependencies.""" + (tmp_path / "package.json").write_text(json.dumps({ + "devDependencies": {"prettier": "^3.0.0"} + })) + + formatter, detail = _detect_js_formatter(tmp_path) + assert any("prettier" in cmd for cmd in formatter) + + +class TestDetectProject: + """Integration tests for detect_project function.""" + + def test_detects_python_project(self, tmp_path): + """Should correctly detect a Python project.""" + # Create Python project structure + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "myapp"\n\n[tool.ruff]\nline-length = 120' + ) + (tmp_path / "myapp").mkdir() + (tmp_path / "myapp" / "__init__.py").write_text("") + (tmp_path / "tests").mkdir() + (tmp_path / ".git").mkdir() + + detected = detect_project(tmp_path) + + assert detected.language == "python" + assert detected.project_root == tmp_path + assert detected.module_root == tmp_path / "myapp" + assert detected.tests_root == tmp_path / "tests" + assert detected.test_runner == "pytest" + assert any("ruff" in cmd for cmd in detected.formatter_cmds) + + def test_detects_javascript_project(self, tmp_path): + """Should correctly detect a JavaScript project.""" + # Create JS project structure + (tmp_path / "package.json").write_text(json.dumps({ + "name": "myapp", + "devDependencies": {"jest": "^29.0.0", "prettier": "^3.0.0"} + })) + (tmp_path / "src").mkdir() + (tmp_path / "tests").mkdir() + (tmp_path / ".git").mkdir() + + detected = detect_project(tmp_path) + + assert detected.language == "javascript" + assert detected.project_root == tmp_path + assert detected.module_root == tmp_path / "src" + assert detected.tests_root == tmp_path / "tests" + assert detected.test_runner == "jest" + assert any("prettier" in cmd for cmd in detected.formatter_cmds) + + def test_detects_typescript_project(self, tmp_path): + """Should correctly detect a TypeScript project.""" + # Create TS project structure + (tmp_path / "package.json").write_text(json.dumps({ + "name": "myapp", + "devDependencies": {"vitest": "^1.0.0", "typescript": "^5.0.0"} + })) + (tmp_path / "tsconfig.json").write_text("{}") + (tmp_path / "src").mkdir() + (tmp_path / ".git").mkdir() + + detected = detect_project(tmp_path) + + assert detected.language == "typescript" + assert detected.test_runner == "vitest" + + def test_to_display_dict(self, tmp_path): + """Should generate display dictionary correctly.""" + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"') + (tmp_path / "test").mkdir() + (tmp_path / "test" / "__init__.py").write_text("") + (tmp_path / "tests").mkdir() + + detected = detect_project(tmp_path) + display = detected.to_display_dict() + + assert "Language" in display + assert "Module root" in display + assert "Test runner" in display + + +class TestHasExistingConfig: + """Tests for has_existing_config function.""" + + def test_detects_pyproject_config(self, tmp_path): + """Should detect config in pyproject.toml.""" + (tmp_path / "pyproject.toml").write_text( + '[tool.codeflash]\nmodule-root = "src"' + ) + + has_config, config_type = has_existing_config(tmp_path) + assert has_config is True + assert config_type == "pyproject.toml" + + def test_detects_package_json_config(self, tmp_path): + """Should detect config in package.json.""" + (tmp_path / "package.json").write_text(json.dumps({ + "name": "test", + "codeflash": {"moduleRoot": "src"} + })) + + has_config, config_type = has_existing_config(tmp_path) + assert has_config is True + assert config_type == "package.json" + + def test_returns_false_when_no_config(self, tmp_path): + """Should return False when no codeflash config exists.""" + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"') + + has_config, config_type = has_existing_config(tmp_path) + assert has_config is False + assert config_type is None + + def test_returns_false_for_empty_directory(self, tmp_path): + """Should return False for empty directory.""" + has_config, config_type = has_existing_config(tmp_path) + assert has_config is False + assert config_type is None \ No newline at end of file diff --git a/tests/test_setup/test_e2e_setup.py b/tests/test_setup/test_e2e_setup.py new file mode 100644 index 000000000..901e3b74d --- /dev/null +++ b/tests/test_setup/test_e2e_setup.py @@ -0,0 +1,914 @@ +"""End-to-end tests for the setup flow. + +These tests validate the complete setup experience across different: +- Languages (Python, JavaScript, TypeScript) +- Project structures (src/, flat, monorepo-like) +- Package managers (npm, yarn, pnpm, bun) +- Existing config scenarios +""" + +import json +import os +from argparse import Namespace +from pathlib import Path +from unittest.mock import patch + +import pytest +import tomlkit + +from codeflash.setup import ( + CodeflashConfig, + DetectedProject, + detect_project, + handle_first_run, + has_existing_config, + is_first_run, + write_config, +) + + +# ============================================================================= +# Fixtures for creating different project types +# ============================================================================= + + +@pytest.fixture +def python_src_layout(tmp_path): + """Create a Python project with src/ layout.""" + # pyproject.toml with poetry + (tmp_path / "pyproject.toml").write_text(""" +[tool.poetry] +name = "myapp" +version = "0.1.0" + +[tool.ruff] +line-length = 120 + +[tool.pytest.ini_options] +testpaths = ["tests"] +""".strip()) + + # src/myapp package + src_dir = tmp_path / "src" / "myapp" + src_dir.mkdir(parents=True) + (src_dir / "__init__.py").write_text('__version__ = "0.1.0"') + (src_dir / "main.py").write_text("def main(): pass") + (src_dir / "utils.py").write_text("def helper(): pass") + + # tests directory + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + (tests_dir / "__init__.py").write_text("") + (tests_dir / "test_main.py").write_text("def test_main(): pass") + (tests_dir / "conftest.py").write_text("import pytest") + + # .git directory + (tmp_path / ".git").mkdir() + + return tmp_path + + +@pytest.fixture +def python_flat_layout(tmp_path): + """Create a Python project with flat layout (package at root).""" + (tmp_path / "pyproject.toml").write_text(""" +[project] +name = "myapp" +version = "0.1.0" + +[tool.black] +line-length = 88 +""".strip()) + + # Package at root + pkg_dir = tmp_path / "myapp" + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text("") + (pkg_dir / "core.py").write_text("def process(): pass") + + # Tests at root + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + (tests_dir / "test_core.py").write_text("def test_process(): pass") + + (tmp_path / ".git").mkdir() + return tmp_path + + +@pytest.fixture +def python_setup_py_project(tmp_path): + """Create a Python project with setup.py (legacy).""" + (tmp_path / "setup.py").write_text(""" +from setuptools import setup, find_packages +setup( + name="legacyapp", + version="1.0.0", + packages=find_packages(), +) +""".strip()) + + pkg_dir = tmp_path / "legacyapp" + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text("") + + (tmp_path / "tests").mkdir() + (tmp_path / ".git").mkdir() + return tmp_path + + +@pytest.fixture +def javascript_npm_project(tmp_path): + """Create a JavaScript project with npm.""" + (tmp_path / "package.json").write_text(json.dumps({ + "name": "my-js-app", + "version": "1.0.0", + "main": "src/index.js", + "scripts": { + "test": "jest", + "lint": "eslint src/" + }, + "devDependencies": { + "jest": "^29.7.0", + "prettier": "^3.0.0" + } + }, indent=2)) + + (tmp_path / "package-lock.json").write_text("{}") + + src_dir = tmp_path / "src" + src_dir.mkdir() + (src_dir / "index.js").write_text("module.exports = {}") + (src_dir / "utils.js").write_text("function helper() {}") + + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + (tests_dir / "index.test.js").write_text("test('works', () => {})") + + (tmp_path / ".prettierrc").write_text("{}") + (tmp_path / ".git").mkdir() + return tmp_path + + +@pytest.fixture +def javascript_yarn_project(tmp_path): + """Create a JavaScript project with yarn.""" + (tmp_path / "package.json").write_text(json.dumps({ + "name": "yarn-app", + "version": "1.0.0", + "main": "lib/index.js", + "devDependencies": { + "jest": "^29.0.0", + "eslint": "^8.0.0" + } + }, indent=2)) + + (tmp_path / "yarn.lock").write_text("# yarn lockfile") + + lib_dir = tmp_path / "lib" + lib_dir.mkdir() + (lib_dir / "index.js").write_text("") + + (tmp_path / "__tests__").mkdir() + (tmp_path / ".git").mkdir() + return tmp_path + + +@pytest.fixture +def javascript_pnpm_project(tmp_path): + """Create a JavaScript project with pnpm.""" + (tmp_path / "package.json").write_text(json.dumps({ + "name": "pnpm-app", + "version": "1.0.0", + "exports": { + ".": "./dist/index.js" + }, + "devDependencies": { + "vitest": "^1.0.0" + } + }, indent=2)) + + (tmp_path / "pnpm-lock.yaml").write_text("lockfileVersion: 5.4") + + (tmp_path / "dist").mkdir() + (tmp_path / "src").mkdir() + (tmp_path / ".git").mkdir() + return tmp_path + + +@pytest.fixture +def javascript_bun_project(tmp_path): + """Create a JavaScript project with bun.""" + (tmp_path / "package.json").write_text(json.dumps({ + "name": "bun-app", + "version": "1.0.0", + "module": "src/index.ts", + "devDependencies": { + "bun-types": "latest" + } + }, indent=2)) + + (tmp_path / "bun.lockb").write_bytes(b"bun lockfile") + + (tmp_path / "src").mkdir() + (tmp_path / ".git").mkdir() + return tmp_path + + +@pytest.fixture +def typescript_project(tmp_path): + """Create a TypeScript project.""" + (tmp_path / "package.json").write_text(json.dumps({ + "name": "ts-app", + "version": "1.0.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "test": "vitest" + }, + "devDependencies": { + "typescript": "^5.0.0", + "vitest": "^1.0.0", + "@types/node": "^20.0.0" + } + }, indent=2)) + + (tmp_path / "tsconfig.json").write_text(json.dumps({ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "outDir": "./dist", + "rootDir": "./src", + "strict": True + }, + "include": ["src/**/*"] + }, indent=2)) + + src_dir = tmp_path / "src" + src_dir.mkdir() + (src_dir / "index.ts").write_text("export const main = () => {}") + (src_dir / "types.ts").write_text("export interface Config {}") + + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + (tests_dir / "index.test.ts").write_text("import { describe, it } from 'vitest'") + + (tmp_path / ".git").mkdir() + return tmp_path + + +@pytest.fixture +def typescript_react_project(tmp_path): + """Create a TypeScript React project (like Create React App).""" + (tmp_path / "package.json").write_text(json.dumps({ + "name": "react-app", + "version": "0.1.0", + "private": True, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-scripts": "5.0.1", + "jest": "^29.0.0" + }, + "devDependencies": { + "@types/react": "^18.0.0", + "@testing-library/react": "^14.0.0", + "typescript": "^5.0.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test" + } + }, indent=2)) + + (tmp_path / "tsconfig.json").write_text(json.dumps({ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "es2015"], + "jsx": "react-jsx" + } + }, indent=2)) + + src_dir = tmp_path / "src" + src_dir.mkdir() + (src_dir / "App.tsx").write_text("export default function App() { return
; }") + (src_dir / "index.tsx").write_text("import App from './App';") + (src_dir / "App.test.tsx").write_text("test('renders', () => {});") + + (tmp_path / "package-lock.json").write_text("{}") + (tmp_path / ".git").mkdir() + return tmp_path + + +@pytest.fixture +def project_with_existing_config(tmp_path): + """Create a project with existing codeflash config.""" + (tmp_path / "pyproject.toml").write_text(""" +[project] +name = "configured-app" + +[tool.codeflash] +module-root = "src" +tests-root = "tests" +formatter-cmds = ["black $file"] +""".strip()) + + (tmp_path / "src").mkdir() + (tmp_path / "tests").mkdir() + (tmp_path / ".git").mkdir() + return tmp_path + + +@pytest.fixture +def mixed_python_js_project(tmp_path): + """Create a project with both Python and JS files (monorepo-like).""" + # Python backend + (tmp_path / "pyproject.toml").write_text(""" +[project] +name = "fullstack-app" + +[tool.codeflash] +module-root = "backend" +""".strip()) + + backend_dir = tmp_path / "backend" + backend_dir.mkdir() + (backend_dir / "__init__.py").write_text("") + (backend_dir / "api.py").write_text("def handler(): pass") + + # JS frontend + frontend_dir = tmp_path / "frontend" + frontend_dir.mkdir() + (frontend_dir / "package.json").write_text(json.dumps({ + "name": "frontend", + "devDependencies": {"jest": "^29.0.0"} + })) + (frontend_dir / "src").mkdir() + (frontend_dir / "src" / "app.js").write_text("") + + (tmp_path / ".git").mkdir() + return tmp_path + + +# ============================================================================= +# E2E Tests: Detection +# ============================================================================= + + +class TestE2EDetection: + """E2E tests for project detection across different setups.""" + + def test_python_src_layout_detection(self, python_src_layout): + """Should correctly detect Python src/ layout project.""" + detected = detect_project(python_src_layout) + + assert detected.language == "python" + assert detected.project_root == python_src_layout + assert "myapp" in str(detected.module_root) + assert detected.tests_root == python_src_layout / "tests" + assert detected.test_runner == "pytest" + assert any("ruff" in cmd for cmd in detected.formatter_cmds) + assert detected.confidence >= 0.9 + + def test_python_flat_layout_detection(self, python_flat_layout): + """Should correctly detect Python flat layout project.""" + detected = detect_project(python_flat_layout) + + assert detected.language == "python" + assert "myapp" in str(detected.module_root) + assert any("black" in cmd for cmd in detected.formatter_cmds) + + def test_python_setup_py_detection(self, python_setup_py_project): + """Should correctly detect legacy setup.py project.""" + detected = detect_project(python_setup_py_project) + + assert detected.language == "python" + assert "legacyapp" in str(detected.module_root) + + def test_javascript_npm_detection(self, javascript_npm_project): + """Should correctly detect JavaScript npm project.""" + detected = detect_project(javascript_npm_project) + + assert detected.language == "javascript" + assert detected.module_root == javascript_npm_project / "src" + assert detected.test_runner == "jest" + assert any("prettier" in cmd for cmd in detected.formatter_cmds) + + def test_javascript_yarn_detection(self, javascript_yarn_project): + """Should correctly detect JavaScript yarn project.""" + detected = detect_project(javascript_yarn_project) + + assert detected.language == "javascript" + assert detected.module_root == javascript_yarn_project / "lib" + assert detected.tests_root == javascript_yarn_project / "__tests__" + + def test_javascript_pnpm_detection(self, javascript_pnpm_project): + """Should correctly detect JavaScript pnpm project.""" + detected = detect_project(javascript_pnpm_project) + + assert detected.language == "javascript" + assert detected.test_runner == "vitest" + + def test_javascript_bun_detection(self, javascript_bun_project): + """Should correctly detect JavaScript bun project.""" + detected = detect_project(javascript_bun_project) + + assert detected.language == "javascript" + assert detected.module_root == javascript_bun_project / "src" + + def test_typescript_detection(self, typescript_project): + """Should correctly detect TypeScript project.""" + detected = detect_project(typescript_project) + + assert detected.language == "typescript" + assert detected.test_runner == "vitest" + assert detected.tests_root == typescript_project / "tests" + + def test_typescript_react_detection(self, typescript_react_project): + """Should correctly detect TypeScript React project.""" + detected = detect_project(typescript_react_project) + + assert detected.language == "typescript" + assert detected.module_root == typescript_react_project / "src" + # React scripts uses jest under the hood + assert detected.test_runner == "jest" + + +# ============================================================================= +# E2E Tests: First Run Check +# ============================================================================= + + +class TestE2EFirstRunCheck: + """E2E tests for first-run detection.""" + + def test_is_first_run_new_python_project(self, python_src_layout): + """Should detect first run for new Python project.""" + assert is_first_run(python_src_layout) is True + + def test_is_first_run_new_js_project(self, javascript_npm_project): + """Should detect first run for new JS project.""" + assert is_first_run(javascript_npm_project) is True + + def test_is_not_first_run_configured_project(self, project_with_existing_config): + """Should detect existing config.""" + assert is_first_run(project_with_existing_config) is False + + def test_has_existing_config_python(self, project_with_existing_config): + """Should find existing config in pyproject.toml.""" + has_config, config_type = has_existing_config(project_with_existing_config) + assert has_config is True + assert config_type == "pyproject.toml" + + def test_has_existing_config_js(self, tmp_path): + """Should find existing config in package.json.""" + (tmp_path / "package.json").write_text(json.dumps({ + "name": "test", + "codeflash": {"moduleRoot": "src"} + })) + + has_config, config_type = has_existing_config(tmp_path) + assert has_config is True + assert config_type == "package.json" + + +# ============================================================================= +# E2E Tests: Config Writing +# ============================================================================= + + +class TestE2EConfigWriting: + """E2E tests for writing config to native files.""" + + def test_write_config_python_preserves_existing(self, python_src_layout): + """Should write config while preserving existing pyproject.toml content.""" + detected = detect_project(python_src_layout) + success, message = write_config(detected) + + assert success is True + + # Read back and verify + content = (python_src_layout / "pyproject.toml").read_text() + data = tomlkit.parse(content) + + # Original content preserved + assert data["tool"]["poetry"]["name"] == "myapp" + assert data["tool"]["ruff"]["line-length"] == 120 + assert data["tool"]["pytest"]["ini_options"]["testpaths"] == ["tests"] + + # Codeflash config added + assert "codeflash" in data["tool"] + assert "module-root" in data["tool"]["codeflash"] + + def test_write_config_javascript_preserves_existing(self, javascript_npm_project): + """Should write config while preserving existing package.json content.""" + detected = detect_project(javascript_npm_project) + success, message = write_config(detected) + + assert success is True + + # Read back and verify + with (javascript_npm_project / "package.json").open() as f: + data = json.load(f) + + # Original content preserved + assert data["name"] == "my-js-app" + assert data["devDependencies"]["jest"] == "^29.7.0" + assert data["scripts"]["test"] == "jest" + + def test_write_config_typescript(self, typescript_project): + """Should write config for TypeScript project.""" + detected = detect_project(typescript_project) + success, message = write_config(detected) + + assert success is True + + with (typescript_project / "package.json").open() as f: + data = json.load(f) + + # tsconfig.json should be unchanged + tsconfig = json.loads((typescript_project / "tsconfig.json").read_text()) + assert tsconfig["compilerOptions"]["strict"] is True + + def test_config_roundtrip_python(self, python_flat_layout): + """Should be able to read back written config.""" + # Detect and write + detected = detect_project(python_flat_layout) + write_config(detected) + + # Read back + content = (python_flat_layout / "pyproject.toml").read_text() + data = tomlkit.parse(content) + codeflash_section = data["tool"]["codeflash"] + + # Create config from written data + config = CodeflashConfig.from_pyproject_dict(dict(codeflash_section)) + + assert config.language == "python" + assert "myapp" in config.module_root + + def test_config_roundtrip_javascript(self, javascript_npm_project): + """Should be able to read back written config for JS.""" + detected = detect_project(javascript_npm_project) + write_config(detected) + + with (javascript_npm_project / "package.json").open() as f: + data = json.load(f) + + if "codeflash" in data: + config = CodeflashConfig.from_package_json_dict(data["codeflash"]) + assert config.language == "javascript" + + +# ============================================================================= +# E2E Tests: First Run Experience +# ============================================================================= + + +class TestE2EFirstRunExperience: + """E2E tests for the complete first-run experience.""" + + def test_first_run_python_project(self, python_src_layout, monkeypatch): + """Should complete first-run for Python project.""" + monkeypatch.chdir(python_src_layout) + monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key-12345") + + result = handle_first_run(skip_confirm=True, skip_api_key=True) + + assert result is not None + assert result.language == "python" + assert "myapp" in result.module_root + assert result.tests_root is not None + assert "tests" in result.tests_root + assert result.pytest_cmd == "pytest" + + # Config should be written + content = (python_src_layout / "pyproject.toml").read_text() + assert "codeflash" in content + + def test_first_run_javascript_project(self, javascript_npm_project, monkeypatch): + """Should complete first-run for JavaScript project.""" + monkeypatch.chdir(javascript_npm_project) + monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key-12345") + + result = handle_first_run(skip_confirm=True, skip_api_key=True) + + assert result is not None + assert result.language == "javascript" + assert "src" in result.module_root + assert result.pytest_cmd == "jest" # Maps to test_runner + + def test_first_run_typescript_project(self, typescript_project, monkeypatch): + """Should complete first-run for TypeScript project.""" + monkeypatch.chdir(typescript_project) + monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key-12345") + + result = handle_first_run(skip_confirm=True, skip_api_key=True) + + assert result is not None + assert result.language == "typescript" + assert result.pytest_cmd == "vitest" + + def test_first_run_with_existing_args(self, python_flat_layout, monkeypatch): + """Should merge with existing CLI args.""" + monkeypatch.chdir(python_flat_layout) + monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key-12345") + + existing_args = Namespace( + file="myapp/core.py", + function="process", + custom_flag=True, + ) + + result = handle_first_run( + args=existing_args, + skip_confirm=True, + skip_api_key=True, + ) + + assert result is not None + assert result.custom_flag is True # Preserved + assert result.file == "myapp/core.py" # Preserved + assert result.language == "python" # Added + + def test_subsequent_run_not_first_run(self, project_with_existing_config, monkeypatch): + """Should not trigger first-run for configured project.""" + monkeypatch.chdir(project_with_existing_config) + + assert is_first_run(project_with_existing_config) is False + + +# ============================================================================= +# E2E Tests: Edge Cases +# ============================================================================= + + +class TestE2EEdgeCases: + """E2E tests for edge cases and special scenarios.""" + + def test_empty_directory(self, tmp_path): + """Should handle empty directory gracefully.""" + detected = detect_project(tmp_path) + + # Should default to Python + assert detected.language == "python" + # Low language confidence (0.3) + base (0.6) = ~0.72 + assert detected.confidence < 0.8 + + def test_nested_project_detection(self, tmp_path): + """Should find project root from nested directory.""" + # Create project structure + (tmp_path / "pyproject.toml").write_text('[project]\nname = "root"') + deep_dir = tmp_path / "src" / "pkg" / "subpkg" + deep_dir.mkdir(parents=True) + + # Detect from nested dir + detected = detect_project(deep_dir) + + assert detected.project_root == tmp_path + + def test_mixed_project_uses_existing_config(self, mixed_python_js_project): + """Should respect existing config in mixed projects.""" + has_config, config_type = has_existing_config(mixed_python_js_project) + + assert has_config is True + assert config_type == "pyproject.toml" + + def test_project_without_tests_dir(self, tmp_path): + """Should handle project without tests directory.""" + (tmp_path / "pyproject.toml").write_text('[project]\nname = "notests"') + (tmp_path / "src").mkdir() + + detected = detect_project(tmp_path) + + assert detected.tests_root is None + + def test_project_without_formatter(self, tmp_path): + """Should handle project without detectable formatter.""" + (tmp_path / "package.json").write_text(json.dumps({ + "name": "no-formatter", + "devDependencies": {"jest": "^29.0.0"} + })) + + detected = detect_project(tmp_path) + + assert detected.formatter_cmds == [] + + def test_malformed_pyproject_toml(self, tmp_path): + """Should handle malformed pyproject.toml.""" + (tmp_path / "pyproject.toml").write_text("this is not valid toml {{{}}") + + # Should not crash, just detect with lower confidence + detected = detect_project(tmp_path) + assert detected is not None + + def test_malformed_package_json(self, tmp_path): + """Should handle malformed package.json.""" + (tmp_path / "package.json").write_text("not valid json") + + detected = detect_project(tmp_path) + assert detected is not None + + def test_display_dict_format(self, python_src_layout): + """Should generate proper display dict for UI.""" + detected = detect_project(python_src_layout) + display = detected.to_display_dict() + + assert "Language" in display + assert "Module root" in display + assert "Tests root" in display + assert "Test runner" in display + assert "Formatter" in display + assert "Ignoring" in display + + # Values should be user-friendly + assert display["Language"] == "Python" + assert display["Test runner"] == "pytest" + + +# ============================================================================= +# E2E Tests: Config Schema Conversion +# ============================================================================= + + +class TestE2EConfigConversion: + """E2E tests for config format conversion.""" + + def test_python_config_to_toml_and_back(self, python_src_layout): + """Should convert Python config to TOML and back without loss.""" + detected = detect_project(python_src_layout) + original_config = CodeflashConfig.from_detected_project(detected) + + # Convert to TOML dict + toml_dict = original_config.to_pyproject_dict() + + # Convert back + restored_config = CodeflashConfig.from_pyproject_dict(toml_dict) + + assert restored_config.module_root == original_config.module_root + assert restored_config.tests_root == original_config.tests_root + + def test_js_config_to_json_and_back(self, javascript_npm_project): + """Should convert JS config to JSON and back without loss.""" + detected = detect_project(javascript_npm_project) + original_config = CodeflashConfig.from_detected_project(detected) + + # Convert to JSON dict + json_dict = original_config.to_package_json_dict() + + # Convert back + restored_config = CodeflashConfig.from_package_json_dict(json_dict) + + # Note: Some defaults may differ, check key fields + if json_dict: # Only if there were non-default values + assert restored_config.language == "javascript" + + +# ============================================================================= +# E2E Tests: Real-world Scenarios +# ============================================================================= + + +class TestE2ERealWorldScenarios: + """E2E tests simulating real-world usage scenarios.""" + + def test_scenario_new_user_python(self, python_src_layout, monkeypatch): + """Scenario: New user runs codeflash on Python project for first time.""" + monkeypatch.chdir(python_src_layout) + monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key") + + # Step 1: Check if first run + assert is_first_run() is True + + # Step 2: Handle first run + args = handle_first_run(skip_confirm=True, skip_api_key=True) + assert args is not None + + # Step 3: Config is now saved + assert is_first_run() is False + + # Step 4: Next run should use saved config + has_config, _ = has_existing_config(python_src_layout) + assert has_config is True + + def test_scenario_new_user_javascript(self, javascript_npm_project, monkeypatch): + """Scenario: New user runs codeflash on JS project for first time.""" + monkeypatch.chdir(javascript_npm_project) + monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key") + + # Step 1: Check if first run + assert is_first_run() is True + + # Step 2: Handle first run + args = handle_first_run(skip_confirm=True, skip_api_key=True) + assert args is not None + assert args.language == "javascript" + + # Step 3: Verify package.json was updated (or left minimal) + with (javascript_npm_project / "package.json").open() as f: + data = json.load(f) + # Original content should still be there + assert data["name"] == "my-js-app" + + def test_scenario_existing_user_reconfigure(self, project_with_existing_config, monkeypatch): + """Scenario: Existing user wants to reconfigure.""" + monkeypatch.chdir(project_with_existing_config) + + # Not first run + assert is_first_run() is False + + # But user can still detect and see what would be configured + detected = detect_project() + assert detected.language == "python" + + def test_scenario_ci_environment(self, python_src_layout, monkeypatch): + """Scenario: Running in CI environment (non-interactive).""" + monkeypatch.chdir(python_src_layout) + monkeypatch.setenv("CI", "true") + monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key") + + # In CI, we need config to exist or use --yes flag + # First run should still work with skip flags + args = handle_first_run(skip_confirm=True, skip_api_key=True) + assert args is not None + + +# ============================================================================= +# E2E Tests: CLI Flags +# ============================================================================= + + +class TestE2ECLIFlags: + """E2E tests for --show-config and --reset-config CLI flags.""" + + def test_show_config_displays_detected_settings(self, python_src_layout, monkeypatch, capsys): + """Should display detected project settings.""" + monkeypatch.chdir(python_src_layout) + + from codeflash.cli_cmds.cli import _handle_show_config + + _handle_show_config() + + # Can't easily capture Rich output, but ensure it doesn't crash + # and the function completes successfully + + def test_show_config_indicates_saved_vs_detected(self, project_with_existing_config, monkeypatch): + """Should indicate when config is saved vs auto-detected.""" + monkeypatch.chdir(project_with_existing_config) + + from codeflash.cli_cmds.cli import _handle_show_config + + # Should complete without error + _handle_show_config() + + def test_reset_config_removes_from_pyproject(self, project_with_existing_config, monkeypatch): + """Should remove codeflash config from pyproject.toml.""" + monkeypatch.chdir(project_with_existing_config) + + from codeflash.cli_cmds.cli import _handle_reset_config + + # Verify config exists before + content_before = (project_with_existing_config / "pyproject.toml").read_text() + assert "[tool.codeflash]" in content_before + + _handle_reset_config(confirm=False) + + # Verify config removed after + content_after = (project_with_existing_config / "pyproject.toml").read_text() + assert "[tool.codeflash]" not in content_after + + # Other sections should remain + assert "[project]" in content_after + + def test_reset_config_removes_from_package_json(self, javascript_npm_project, monkeypatch): + """Should remove codeflash config from package.json.""" + # First add config + monkeypatch.chdir(javascript_npm_project) + + # Add codeflash section + with (javascript_npm_project / "package.json").open() as f: + data = json.load(f) + data["codeflash"] = {"moduleRoot": "src"} + with (javascript_npm_project / "package.json").open("w") as f: + json.dump(data, f, indent=2) + + from codeflash.cli_cmds.cli import _handle_reset_config + + _handle_reset_config(confirm=False) + + # Verify config removed + with (javascript_npm_project / "package.json").open() as f: + data_after = json.load(f) + + assert "codeflash" not in data_after + assert data_after["name"] == "my-js-app" # Other content preserved + + def test_reset_config_handles_no_config(self, python_src_layout, monkeypatch): + """Should handle gracefully when no config exists to reset.""" + monkeypatch.chdir(python_src_layout) + + from codeflash.cli_cmds.cli import _handle_reset_config + + # Should not crash when no codeflash config exists + _handle_reset_config(confirm=False) diff --git a/tests/test_setup/test_first_run.py b/tests/test_setup/test_first_run.py new file mode 100644 index 000000000..b8d12b280 --- /dev/null +++ b/tests/test_setup/test_first_run.py @@ -0,0 +1,290 @@ +"""Tests for the first-run experience.""" + +import json +import os +from argparse import Namespace +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from codeflash.setup.first_run import ( + _handle_api_key, + _prompt_confirmation, + _show_detected_settings, + _show_welcome, + handle_first_run, + is_first_run, +) + + +class TestIsFirstRun: + """Tests for is_first_run function.""" + + def test_returns_true_when_no_config(self, tmp_path): + """Should return True when no codeflash config exists.""" + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"') + + result = is_first_run(tmp_path) + assert result is True + + def test_returns_false_when_pyproject_config_exists(self, tmp_path): + """Should return False when codeflash config exists in pyproject.toml.""" + (tmp_path / "pyproject.toml").write_text( + '[tool.codeflash]\nmodule-root = "src"' + ) + + result = is_first_run(tmp_path) + assert result is False + + def test_returns_false_when_package_json_config_exists(self, tmp_path): + """Should return False when codeflash config exists in package.json.""" + (tmp_path / "package.json").write_text(json.dumps({ + "name": "test", + "codeflash": {"moduleRoot": "src"} + })) + + result = is_first_run(tmp_path) + assert result is False + + def test_returns_true_for_empty_directory(self, tmp_path): + """Should return True for empty directory.""" + result = is_first_run(tmp_path) + assert result is True + + +class TestHandleFirstRun: + """Tests for handle_first_run function.""" + + def test_returns_args_on_success(self, tmp_path, monkeypatch): + """Should return updated args on successful first run.""" + # Create Python project + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"') + (tmp_path / "src").mkdir() + (tmp_path / "src" / "__init__.py").write_text("") + (tmp_path / "tests").mkdir() + + # Mock user input and API key + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key") + + # Skip confirmation + result = handle_first_run(skip_confirm=True, skip_api_key=True) + + assert result is not None + assert hasattr(result, "module_root") + assert hasattr(result, "language") + assert result.language == "python" + + def test_returns_none_when_user_cancels(self, tmp_path, monkeypatch): + """Should return None when user cancels.""" + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"') + monkeypatch.chdir(tmp_path) + + # Mock user cancellation + with patch("codeflash.setup.first_run._prompt_confirmation", return_value="n"): + result = handle_first_run(skip_api_key=True) + + assert result is None + + def test_skips_confirm_with_flag(self, tmp_path, monkeypatch): + """Should skip confirmation when skip_confirm=True.""" + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"') + (tmp_path / "src").mkdir() + (tmp_path / "src" / "__init__.py").write_text("") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key") + + # Should not prompt for confirmation + with patch("codeflash.setup.first_run._prompt_confirmation") as mock_prompt: + result = handle_first_run(skip_confirm=True, skip_api_key=True) + + mock_prompt.assert_not_called() + assert result is not None + + def test_merges_with_existing_args(self, tmp_path, monkeypatch): + """Should merge detected settings with existing args.""" + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"') + (tmp_path / "src").mkdir() + (tmp_path / "src" / "__init__.py").write_text("") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key") + + existing_args = Namespace(custom_flag=True, module_root=None) + + result = handle_first_run( + args=existing_args, + skip_confirm=True, + skip_api_key=True, + ) + + assert result is not None + assert result.custom_flag is True # Preserved + assert result.module_root is not None # Updated + + +class TestPromptConfirmation: + """Tests for _prompt_confirmation function.""" + + def test_returns_y_for_yes(self, monkeypatch): + """Should return 'y' for yes input.""" + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + + with patch("codeflash.cli_cmds.console.console.input", return_value="y"): + result = _prompt_confirmation() + + assert result == "y" + + def test_returns_y_for_empty(self, monkeypatch): + """Should return 'y' for empty input (default).""" + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + + with patch("codeflash.cli_cmds.console.console.input", return_value=""): + result = _prompt_confirmation() + + assert result == "y" + + def test_returns_n_for_no(self, monkeypatch): + """Should return 'n' for no input.""" + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + + with patch("codeflash.cli_cmds.console.console.input", return_value="n"): + result = _prompt_confirmation() + + assert result == "n" + + def test_returns_customize_for_c(self, monkeypatch): + """Should return 'customize' for c input.""" + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + + with patch("codeflash.cli_cmds.console.console.input", return_value="c"): + result = _prompt_confirmation() + + assert result == "customize" + + def test_returns_n_for_non_interactive(self, monkeypatch): + """Should return 'n' for non-interactive environment.""" + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + result = _prompt_confirmation() + + assert result == "n" + + +class TestHandleApiKey: + """Tests for _handle_api_key function.""" + + def test_returns_true_when_key_exists(self, monkeypatch): + """Should return True when API key already exists.""" + monkeypatch.setenv("CODEFLASH_API_KEY", "cf-existing-key") + + with patch("codeflash.code_utils.env_utils.get_codeflash_api_key", return_value="cf-existing-key"): + result = _handle_api_key() + + assert result is True + + def test_accepts_valid_key(self, monkeypatch): + """Should accept valid API key starting with cf-.""" + monkeypatch.delenv("CODEFLASH_API_KEY", raising=False) + + with patch("codeflash.code_utils.env_utils.get_codeflash_api_key", side_effect=OSError): + with patch("codeflash.cli_cmds.console.console.input", return_value="cf-valid-key"): + # Patch the entire import to fail, triggering the exception handler + with patch.dict("sys.modules", {"codeflash.code_utils.shell_utils": None}): + result = _handle_api_key() + + assert result is True + assert os.environ.get("CODEFLASH_API_KEY") == "cf-valid-key" + + def test_rejects_invalid_key(self, monkeypatch): + """Should reject API key not starting with cf-.""" + monkeypatch.delenv("CODEFLASH_API_KEY", raising=False) + + with patch("codeflash.code_utils.env_utils.get_codeflash_api_key", side_effect=OSError): + with patch("codeflash.cli_cmds.console.console.input", return_value="invalid-key"): + result = _handle_api_key() + + assert result is False + + +class TestShowFunctions: + """Tests for display functions (smoke tests).""" + + def test_show_welcome_does_not_crash(self): + """Should not crash when showing welcome message.""" + # Just verify it doesn't raise an exception + _show_welcome() + + def test_show_detected_settings_does_not_crash(self, tmp_path): + """Should not crash when showing detected settings.""" + from codeflash.setup.detector import detect_project + + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"') + detected = detect_project(tmp_path) + + # Just verify it doesn't raise an exception + _show_detected_settings(detected) + + +class TestFirstRunIntegration: + """Integration tests for the complete first-run flow.""" + + def test_full_python_first_run(self, tmp_path, monkeypatch): + """Should complete full first-run for Python project.""" + # Create Python project + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "myapp"\n\n[tool.ruff]\nline-length = 120' + ) + pkg_dir = tmp_path / "myapp" + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text("") + (tmp_path / "tests").mkdir() + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key") + + result = handle_first_run(skip_confirm=True, skip_api_key=True) + + assert result is not None + assert result.language == "python" + assert "myapp" in result.module_root + assert "tests" in result.tests_root + + # Verify config was written + import tomlkit + + content = (tmp_path / "pyproject.toml").read_text() + data = tomlkit.parse(content) + assert "codeflash" in data.get("tool", {}) + + def test_full_javascript_first_run(self, tmp_path, monkeypatch): + """Should complete full first-run for JavaScript project.""" + # Create JS project + (tmp_path / "package.json").write_text(json.dumps({ + "name": "myapp", + "devDependencies": {"jest": "^29.0.0"} + }, indent=2)) + (tmp_path / "src").mkdir() + (tmp_path / "tests").mkdir() + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CODEFLASH_API_KEY", "cf-test-key") + + result = handle_first_run(skip_confirm=True, skip_api_key=True) + + assert result is not None + assert result.language == "javascript" + assert "src" in result.module_root + assert result.pytest_cmd == "jest" # test_runner mapped to pytest_cmd + + def test_subsequent_run_uses_saved_config(self, tmp_path, monkeypatch): + """After first run, subsequent runs should not trigger first-run.""" + # Create project with existing config + (tmp_path / "pyproject.toml").write_text( + '[tool.codeflash]\nmodule-root = "src"' + ) + + monkeypatch.chdir(tmp_path) + + # Should not be first run + assert is_first_run(tmp_path) is False \ No newline at end of file From 468ef10b9e93d4bd325555f4cc13df0a112650fa Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Sat, 31 Jan 2026 19:02:28 +0530 Subject: [PATCH 2/3] reset test strings to strict --- tests/test_setup/__init__.py | 2 +- tests/test_setup/test_config.py | 8 +++----- tests/test_setup/test_detector.py | 14 +++----------- tests/test_setup/test_e2e_setup.py | 19 +++++++------------ tests/test_setup/test_first_run.py | 15 ++++++--------- 5 files changed, 20 insertions(+), 38 deletions(-) diff --git a/tests/test_setup/__init__.py b/tests/test_setup/__init__.py index 8ae249fbd..7acecdcb9 100644 --- a/tests/test_setup/__init__.py +++ b/tests/test_setup/__init__.py @@ -1 +1 @@ -# Tests for the codeflash.setup module \ No newline at end of file +# Tests for the codeflash.setup module diff --git a/tests/test_setup/test_config.py b/tests/test_setup/test_config.py index 3bfd81826..5f10841fc 100644 --- a/tests/test_setup/test_config.py +++ b/tests/test_setup/test_config.py @@ -1,9 +1,7 @@ """Tests for config schema and config writer.""" import json -from pathlib import Path -import pytest import tomlkit from codeflash.setup.config_schema import CodeflashConfig @@ -310,7 +308,7 @@ class TestWritePackageJson: success, message = _write_package_json(tmp_path, config) assert success is False - assert "No package.json" in message + assert message == f"No package.json found at {tmp_path}" class TestWriteConfig: @@ -326,7 +324,7 @@ class TestWriteConfig: success, message = write_config(detected) assert success is True - assert "pyproject.toml" in message + assert message == f"Config saved to {tmp_path / 'pyproject.toml'}" def test_writes_to_package_json_for_js(self, tmp_path): """Should write to package.json for JavaScript projects.""" @@ -394,4 +392,4 @@ class TestCreatePyprojectToml: success, message = create_pyproject_toml(tmp_path) assert success is False - assert "already exists" in message \ No newline at end of file + assert message == f"pyproject.toml already exists at {tmp_path / 'pyproject.toml'}" diff --git a/tests/test_setup/test_detector.py b/tests/test_setup/test_detector.py index 16870609f..45a74d167 100644 --- a/tests/test_setup/test_detector.py +++ b/tests/test_setup/test_detector.py @@ -1,24 +1,15 @@ """Tests for the universal project detector.""" import json -import tempfile -from pathlib import Path - -import pytest -import tomlkit from codeflash.setup.detector import ( - DetectedProject, - _detect_formatter, _detect_js_formatter, _detect_js_module_root, _detect_js_test_runner, _detect_language, - _detect_module_root, _detect_python_formatter, _detect_python_module_root, _detect_python_test_runner, - _detect_test_runner, _detect_tests_root, _find_project_root, detect_project, @@ -124,7 +115,8 @@ class TestDetectModuleRoot: module_root, detail = _detect_python_module_root(tmp_path) assert module_root == src_dir - assert "src" in str(module_root) + assert module_root.name == "mypackage" + assert module_root.parent.name == "src" def test_python_detects_package_at_root(self, tmp_path): """Should detect package at project root.""" @@ -386,4 +378,4 @@ class TestHasExistingConfig: """Should return False for empty directory.""" has_config, config_type = has_existing_config(tmp_path) assert has_config is False - assert config_type is None \ No newline at end of file + assert config_type is None diff --git a/tests/test_setup/test_e2e_setup.py b/tests/test_setup/test_e2e_setup.py index 901e3b74d..e10fa6c93 100644 --- a/tests/test_setup/test_e2e_setup.py +++ b/tests/test_setup/test_e2e_setup.py @@ -8,17 +8,13 @@ These tests validate the complete setup experience across different: """ import json -import os from argparse import Namespace -from pathlib import Path -from unittest.mock import patch import pytest import tomlkit from codeflash.setup import ( CodeflashConfig, - DetectedProject, detect_project, handle_first_run, has_existing_config, @@ -26,7 +22,6 @@ from codeflash.setup import ( write_config, ) - # ============================================================================= # Fixtures for creating different project types # ============================================================================= @@ -365,7 +360,7 @@ class TestE2EDetection: assert detected.language == "python" assert detected.project_root == python_src_layout - assert "myapp" in str(detected.module_root) + assert detected.module_root.name == "myapp" assert detected.tests_root == python_src_layout / "tests" assert detected.test_runner == "pytest" assert any("ruff" in cmd for cmd in detected.formatter_cmds) @@ -376,7 +371,7 @@ class TestE2EDetection: detected = detect_project(python_flat_layout) assert detected.language == "python" - assert "myapp" in str(detected.module_root) + assert detected.module_root.name == "myapp" assert any("black" in cmd for cmd in detected.formatter_cmds) def test_python_setup_py_detection(self, python_setup_py_project): @@ -384,7 +379,7 @@ class TestE2EDetection: detected = detect_project(python_setup_py_project) assert detected.language == "python" - assert "legacyapp" in str(detected.module_root) + assert detected.module_root.name == "legacyapp" def test_javascript_npm_detection(self, javascript_npm_project): """Should correctly detect JavaScript npm project.""" @@ -578,14 +573,14 @@ class TestE2EFirstRunExperience: assert result is not None assert result.language == "python" - assert "myapp" in result.module_root + assert result.module_root.endswith("myapp") assert result.tests_root is not None - assert "tests" in result.tests_root + assert result.tests_root.endswith("tests") assert result.pytest_cmd == "pytest" # Config should be written content = (python_src_layout / "pyproject.toml").read_text() - assert "codeflash" in content + assert "[tool.codeflash]" in content def test_first_run_javascript_project(self, javascript_npm_project, monkeypatch): """Should complete first-run for JavaScript project.""" @@ -596,7 +591,7 @@ class TestE2EFirstRunExperience: assert result is not None assert result.language == "javascript" - assert "src" in result.module_root + assert result.module_root.endswith("src") assert result.pytest_cmd == "jest" # Maps to test_runner def test_first_run_typescript_project(self, typescript_project, monkeypatch): diff --git a/tests/test_setup/test_first_run.py b/tests/test_setup/test_first_run.py index b8d12b280..5f072a697 100644 --- a/tests/test_setup/test_first_run.py +++ b/tests/test_setup/test_first_run.py @@ -3,10 +3,7 @@ import json import os from argparse import Namespace -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest +from unittest.mock import patch from codeflash.setup.first_run import ( _handle_api_key, @@ -247,15 +244,15 @@ class TestFirstRunIntegration: assert result is not None assert result.language == "python" - assert "myapp" in result.module_root - assert "tests" in result.tests_root + assert result.module_root.endswith("myapp") + assert result.tests_root.endswith("tests") # Verify config was written import tomlkit content = (tmp_path / "pyproject.toml").read_text() data = tomlkit.parse(content) - assert "codeflash" in data.get("tool", {}) + assert "codeflash" in data["tool"] def test_full_javascript_first_run(self, tmp_path, monkeypatch): """Should complete full first-run for JavaScript project.""" @@ -274,7 +271,7 @@ class TestFirstRunIntegration: assert result is not None assert result.language == "javascript" - assert "src" in result.module_root + assert result.module_root.endswith("src") assert result.pytest_cmd == "jest" # test_runner mapped to pytest_cmd def test_subsequent_run_uses_saved_config(self, tmp_path, monkeypatch): @@ -287,4 +284,4 @@ class TestFirstRunIntegration: monkeypatch.chdir(tmp_path) # Should not be first run - assert is_first_run(tmp_path) is False \ No newline at end of file + assert is_first_run(tmp_path) is False From eb1d27e359848e22e8c2e2fe7f9e259c7f927766 Mon Sep 17 00:00:00 2001 From: Sarthak Agarwal Date: Sat, 31 Jan 2026 20:21:50 +0530 Subject: [PATCH 3/3] update package to 0.4.0 --- packages/codeflash/package-lock.json | 4 ++-- packages/codeflash/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/codeflash/package-lock.json b/packages/codeflash/package-lock.json index 8c829c7f6..5ef053cf3 100644 --- a/packages/codeflash/package-lock.json +++ b/packages/codeflash/package-lock.json @@ -1,12 +1,12 @@ { "name": "codeflash", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codeflash", - "version": "0.3.0", + "version": "0.4.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/packages/codeflash/package.json b/packages/codeflash/package.json index 414169c74..8ceaa67a9 100644 --- a/packages/codeflash/package.json +++ b/packages/codeflash/package.json @@ -1,6 +1,6 @@ { "name": "codeflash", - "version": "0.3.0", + "version": "0.4.0", "description": "Codeflash - AI-powered code optimization for JavaScript and TypeScript", "main": "runtime/index.js", "types": "runtime/index.d.ts",