From 70d2f2bb601adef7c04c7286f2780119ba567ebe Mon Sep 17 00:00:00 2001 From: namabeeru Date: Fri, 3 Jul 2026 12:16:36 +0900 Subject: [PATCH] Harden launcher preflight follow-up --- app/main.py | 4 + app/runtime_preflight.py | 245 ++++++++++++++++++++++++++ craftbot.py | 165 ++++++++++++++--- installer/api.py | 7 +- run.py | 15 +- startup_constants.py | 4 + tests/test_craftbot_service.py | 180 +++++++++++++++++++ tests/test_run_dependency_check.py | 110 ++++++++++++ tests/test_startup_constants_usage.py | 15 ++ 9 files changed, 718 insertions(+), 27 deletions(-) create mode 100644 app/runtime_preflight.py create mode 100644 startup_constants.py create mode 100644 tests/test_craftbot_service.py create mode 100644 tests/test_run_dependency_check.py create mode 100644 tests/test_startup_constants_usage.py diff --git a/app/main.py b/app/main.py index 02455d5b..ca05dfd1 100644 --- a/app/main.py +++ b/app/main.py @@ -51,6 +51,10 @@ def _suppress_console_logging_early() -> None: import argparse import asyncio +from app.runtime_preflight import ensure_current_runtime_dependencies + +ensure_current_runtime_dependencies() + # Register agent_core state provider and config before importing AgentBase # This ensures shared code can access state via get_state() from agent_core import StateRegistry, ConfigRegistry diff --git a/app/runtime_preflight.py b/app/runtime_preflight.py new file mode 100644 index 00000000..ba450ca2 --- /dev/null +++ b/app/runtime_preflight.py @@ -0,0 +1,245 @@ +# -*- coding: utf-8 -*- +"""Runtime dependency checks that can run before dependency-heavy imports.""" + +from dataclasses import dataclass +import json +import os +import subprocess +import sys +from typing import Dict, List, Optional, Tuple + + +_PREFLIGHT_OK_ENV = "CRAFTBOT_RUNTIME_PREFLIGHT_OK" +_MISSING_SENTINEL = "__CRAFTBOT_MISSING_RUNTIME_IMPORTS__" +# Conservative upper bound for slower conda environment startup. A timeout is +# inconclusive and warns/continues; it is not treated as missing dependencies. +RUNTIME_PROBE_TIMEOUT_SECONDS = 60 + +RUNTIME_IMPORT_CHECKS = { + # Packages imported during backend startup. Provider SDKs stay deferred so + # unused providers are not forced through unrelated SDK imports at startup. + "requests": "requests", + "pyyaml": "yaml", + "loguru": "loguru", + "nest-asyncio": "nest_asyncio", + "pymongo": "pymongo", + "tzlocal": "tzlocal", + "aiohttp": "aiohttp", + "chromadb": "chromadb", + "tiktoken": "tiktoken", + "mss": "mss", + "httpx": "httpx", + "websockets": "websockets", + "tenacity": "tenacity", + "gradio_client": "gradio_client", + "python-dotenv": "dotenv", + "scikit-learn": "sklearn", + "watchdog": "watchdog", + "croniter": "croniter", +} + + +@dataclass(frozen=True) +class RuntimeDependencyResult: + missing: List[str] + runtime_label: str + inconclusive_reason: Optional[str] = None + + @property + def is_inconclusive(self) -> bool: + return self.inconclusive_reason is not None + + +def _runtime_import_script(checks: Dict[str, str]) -> str: + return ( + "import importlib\n" + "import json\n" + f"checks = {list(checks.items())!r}\n" + "missing = []\n" + "for package_name, import_name in checks:\n" + " try:\n" + " importlib.import_module(import_name)\n" + " except Exception:\n" + " missing.append(package_name)\n" + f"print({_MISSING_SENTINEL!r} + json.dumps(missing))\n" + ) + + +def _runtime_import_command( + use_conda: bool, + env_name: Optional[str], + checks: Dict[str, str], + conda_command: str, +) -> Tuple[List[str], str]: + script = _runtime_import_script(checks) + if use_conda and env_name: + return ( + [ + conda_command, + "run", + "-n", + env_name, + "python", + "-c", + script, + ], + f"conda environment '{env_name}'", + ) + return ([sys.executable, "-c", script], sys.executable) + + +def check_runtime_dependencies( + *, + use_conda: bool, + env_name: Optional[str], + checks: Optional[Dict[str, str]] = None, + conda_command: str = "conda", + timeout: int = RUNTIME_PROBE_TIMEOUT_SECONDS, +) -> RuntimeDependencyResult: + """Probe imports for the Python runtime that will run the agent. + + Only a successful probe with valid sentinel JSON is treated as conclusive. + Probe infrastructure failures are warnings, not startup blockers. + """ + if checks is None: + checks = RUNTIME_IMPORT_CHECKS + cmd, runtime_label = _runtime_import_command( + use_conda, env_name, checks, conda_command + ) + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + return RuntimeDependencyResult( + [], + runtime_label, + f"dependency probe timed out after {timeout}s", + ) + except Exception as exc: + return RuntimeDependencyResult( + [], + runtime_label, + f"dependency probe could not run: {exc}", + ) + + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() + reason = "dependency probe exited before reporting imports" + if detail: + reason = f"{reason}: {detail.splitlines()[-1]}" + return RuntimeDependencyResult([], runtime_label, reason) + + for line in reversed(result.stdout.splitlines()): + if line.startswith(_MISSING_SENTINEL): + try: + missing = json.loads(line[len(_MISSING_SENTINEL) :]) + except json.JSONDecodeError: + return RuntimeDependencyResult( + [], + runtime_label, + "unexpected probe output: malformed dependency JSON", + ) + if not isinstance(missing, list) or not all( + isinstance(item, str) for item in missing + ): + return RuntimeDependencyResult( + [], + runtime_label, + "unexpected probe output: dependency JSON was not a string list", + ) + return RuntimeDependencyResult(missing, runtime_label) + + return RuntimeDependencyResult( + [], + runtime_label, + "unexpected probe output: missing dependency sentinel", + ) + + +def print_missing_runtime_dependencies( + *, + missing: List[str], + runtime_label: str, + use_conda: bool, + env_name: Optional[str], +) -> None: + print("\nError: CraftBot Python dependencies are missing.") + print(f"Runtime checked: {runtime_label}") + print("\nMissing imports:") + for package_name in missing: + print(f" - {package_name}") + + print( + "\nThis usually means CraftBot is running with a different Python " + "than the one used during install." + ) + print("\nFix:") + if use_conda and env_name: + print(" python install.py --conda") + print(f" conda run -n {env_name} python run.py") + else: + print(f" {sys.executable} install.py") + print(f" {sys.executable} run.py") + print("\nIf you installed CraftBot with another Python, start it with that Python.") + + +def print_inconclusive_runtime_dependency_warning( + *, + reason: str, + runtime_label: str, + use_conda: bool, + env_name: Optional[str], +) -> None: + print("\nWarning: CraftBot could not verify Python dependencies.") + print(f"Runtime checked: {runtime_label}") + print(f"Reason: {reason}") + print("Continuing startup. If imports fail, reinstall dependencies for this runtime:") + if use_conda and env_name: + print(" python install.py --conda") + else: + print(f" {sys.executable} install.py") + + +def ensure_runtime_dependencies( + *, + use_conda: bool, + env_name: Optional[str], + conda_command: str = "conda", + checks: Optional[Dict[str, str]] = None, +) -> None: + if getattr(sys, "frozen", False): + return + + result = check_runtime_dependencies( + use_conda=use_conda, + env_name=env_name, + conda_command=conda_command, + checks=checks, + ) + if result.is_inconclusive: + print_inconclusive_runtime_dependency_warning( + reason=result.inconclusive_reason or "unknown probe failure", + runtime_label=result.runtime_label, + use_conda=use_conda, + env_name=env_name, + ) + return + if result.missing: + print_missing_runtime_dependencies( + missing=result.missing, + runtime_label=result.runtime_label, + use_conda=use_conda, + env_name=env_name, + ) + sys.exit(1) + + +def mark_runtime_dependencies_checked() -> None: + os.environ[_PREFLIGHT_OK_ENV] = "1" + + +def ensure_current_runtime_dependencies() -> None: + """Check imports for direct app.main usage with the current interpreter.""" + if os.environ.get(_PREFLIGHT_OK_ENV) == "1": + return + ensure_runtime_dependencies(use_conda=False, env_name=None) diff --git a/craftbot.py b/craftbot.py index 8a3eeb7e..67de794a 100644 --- a/craftbot.py +++ b/craftbot.py @@ -63,6 +63,7 @@ def flush(self) -> None: sys.stderr = _NullIO() import os +import shlex import shutil import signal import subprocess @@ -74,6 +75,7 @@ def flush(self) -> None: from installer import helpers as _helpers from installer import metadata as _metadata from installer import payload as _payload +from startup_constants import CRAFTBOT_READY_MARKER # Store platform once so static analysers don't short-circuit platform branches _PLATFORM: str = sys.platform @@ -182,7 +184,9 @@ def installed_exe_path() -> Optional[str]: TASK_NAME = "CraftBot" # Windows Task Scheduler task name SYSTEMD_SERVICE = "craftbot" # Linux systemd service name LAUNCHD_LABEL = "com.craftbot.agent" # macOS launchd label -BROWSER_URL = "http://localhost:7925" +DEFAULT_FRONTEND_PORT = 7925 +DEFAULT_BACKEND_PORT = 7926 +BROWSER_URL = f"http://localhost:{DEFAULT_FRONTEND_PORT}" SHORTCUT_NAME = "CraftBot.lnk" # Bundled icons live in sys._MEIPASS in frozen mode (PyInstaller's runtime # extract dir) and alongside craftbot.py in source mode. _ensure_ico() copies @@ -322,6 +326,75 @@ def _remove_pid() -> None: pass +def _parse_port_arg(args: List[str], flag: str, default: int) -> int: + prefix = f"{flag}=" + for i, arg in enumerate(args): + value = None + if arg == flag and i + 1 < len(args): + value = args[i + 1] + elif arg.startswith(prefix): + value = arg[len(prefix) :] + if value is not None: + try: + return int(value) + except ValueError: + return default + return default + + +def _frontend_url(args: List[str]) -> str: + port = _parse_port_arg(args, "--frontend-port", DEFAULT_FRONTEND_PORT) + return f"http://localhost:{port}" + + +def _backend_url(args: List[str]) -> str: + port = _parse_port_arg(args, "--backend-port", DEFAULT_BACKEND_PORT) + return f"http://localhost:{port}" + + +def _port_args(args: List[str]) -> List[str]: + result = [] + skip_next = False + for i, arg in enumerate(args): + if skip_next: + skip_next = False + continue + if arg in ("--frontend-port", "--backend-port"): + if i + 1 < len(args): + result.extend([arg, args[i + 1]]) + skip_next = True + continue + if arg.startswith("--frontend-port=") or arg.startswith("--backend-port="): + result.append(arg) + return result + + +def _tail_log_lines(n: int = 30, start_offset: int = 0) -> str: + if not os.path.isfile(LOG_FILE): + return "" + try: + with open(LOG_FILE, "r", errors="replace") as f: + if start_offset: + f.seek(start_offset) + lines = f.readlines() + except Exception: + return "" + return "".join(lines[-n:]) + + +def _wait_for_startup_exit( + proc, timeout: float = 8.0, ready_log_offset: int = 0 +) -> Optional[int]: + deadline = time.time() + timeout + while time.time() < deadline: + try: + return proc.wait(timeout=0.1) + except subprocess.TimeoutExpired: + if CRAFTBOT_READY_MARKER in _tail_log_lines(80, ready_log_offset): + return None + return None + + def _is_running(pid: int) -> bool: """Return True if a process with the given PID is currently alive.""" if _PLATFORM == "win32": @@ -442,8 +515,12 @@ def _poll_and_open() -> None: subprocess.Popen([python, "-c", poll_script], **kwargs) -def cmd_start(extra_args: List[str]) -> None: - """Start CraftBot as a detached background process.""" +def cmd_start(extra_args: List[str]) -> bool: + """Start CraftBot as a detached background process. + + Returns True once the service survives the early startup check; False when + launch fails before CraftBot can be used. + """ pid = _read_pid() if pid and _is_running(pid): cmd_stop() @@ -462,7 +539,7 @@ def cmd_start(extra_args: List[str]) -> None: installed = installed_exe_path() if not installed: print("Error: no installed agent found — run install first.") - return + return False cmd = [installed] + run_args else: python = _python_exe() @@ -479,6 +556,7 @@ def cmd_start(extra_args: List[str]) -> None: log_fh.write(f"Command: {' '.join(cmd)}\n") log_fh.write(f"{'=' * 60}\n") log_fh.flush() + ready_log_offset = log_fh.tell() env = os.environ.copy() env["PYTHONIOENCODING"] = "utf-8" @@ -498,11 +576,27 @@ def cmd_start(extra_args: List[str]) -> None: except FileNotFoundError as e: log_fh.close() print(f" {RED}✗{RESET} {WHITE}Could not launch CraftBot — {e}{RESET}") - return + return False # Parent closes its copy — the child process (run.py) keeps the fd open log_fh.close() _write_pid(proc.pid) + + # Catch immediate startup failures before reporting success. This surfaces + # wrong-Python dependency errors from run.py instead of leaving a stale PID. + exit_code = _wait_for_startup_exit(proc, ready_log_offset=ready_log_offset) + + if exit_code is not None: + _remove_pid() + print( + f" {RED}✗{RESET} {WHITE}CraftBot failed to start{RESET} {DIM}exit {exit_code}{RESET}" + ) + log_tail = _tail_log_lines() + if log_tail: + print(f"\n{DIM}Last log lines:{RESET}\n{log_tail}", end="") + print(f"\nCheck logs: {sys.executable} craftbot.py logs") + return False + print( f" {GREEN}▸{RESET} {WHITE}CRAFTBOT STARTED{RESET} {DIM}PID {proc.pid}{RESET}" ) @@ -512,12 +606,15 @@ def cmd_start(extra_args: List[str]) -> None: if _PLATFORM == "win32": _create_desktop_shortcut_windows() else: - _create_desktop_shortcut_unix() + _create_desktop_shortcut_unix(extra_args) open_browser = "--cli" not in run_args and "--no-open-browser" not in extra_args if open_browser: - print(f" {DIM}░░{RESET} {ORANGE}{BROWSER_URL}{RESET}") - _open_browser_detached(BROWSER_URL) + browser_url = _frontend_url(extra_args) + print(f" {DIM}░░{RESET} {ORANGE}{browser_url}{RESET}") + _open_browser_detached(browser_url) + + return True def cmd_stop() -> None: @@ -613,10 +710,10 @@ def cmd_logs(n: int = 50) -> None: print(f" {RED}✗{RESET} {WHITE}Error reading log: {e}{RESET}") -def cmd_restart(extra_args: List[str]) -> None: +def cmd_restart(extra_args: List[str]) -> bool: cmd_stop() time.sleep(1) - cmd_start(extra_args) + return cmd_start(extra_args) # ─── Desktop shortcut ───────────────────────────────────────────────────────── @@ -759,16 +856,31 @@ def _create_desktop_shortcut_windows() -> None: print(f" (Could not create desktop shortcut: {e})") -def _create_desktop_shortcut_unix() -> None: +def _create_desktop_shortcut_unix(extra_args: Optional[List[str]] = None) -> None: """Create a desktop shortcut on Linux or macOS.""" + if extra_args is None: + extra_args = [] desktop = _find_desktop() if not desktop: return try: + browser_url = _frontend_url(extra_args) if _PLATFORM == "darwin": # macOS does not support XDG .desktop files — create a double-clickable .command script shortcut_path = os.path.join(desktop, "CraftBot.command") - content = f"#!/bin/sh\nopen '{BROWSER_URL}'\n" + backend_url = _backend_url(extra_args) + restart_args = _port_args(extra_args) + start_cmd = shlex.join([_python_exe(), "craftbot.py", "start"] + restart_args) + content = ( + "#!/bin/sh\n" + f"cd {shlex.quote(BASE_DIR)} || exit 1\n" + f"if curl -fsS {shlex.quote(browser_url)} >/dev/null 2>&1 " + f"&& curl -fsS {shlex.quote(backend_url)} >/dev/null 2>&1; then\n" + f" open {shlex.quote(browser_url)}\n" + "else\n" + f" exec {start_cmd}\n" + "fi\n" + ) with open(shortcut_path, "w") as f: f.write(content) os.chmod(shortcut_path, 0o755) @@ -785,7 +897,7 @@ def _create_desktop_shortcut_unix() -> None: "[Desktop Entry]\n" "Type=Application\n" "Name=CraftBot\n" - f"Exec={open_cmd} {BROWSER_URL}\n" + f"Exec={open_cmd} {browser_url}\n" "Icon=web-browser\n" "Terminal=false\n" ) @@ -1205,10 +1317,11 @@ def _full_install_frozen( )(run_args) # 6. Start the service via the extracted agent EXE - cmd_start(extra_args) + if not cmd_start(extra_args): + raise RuntimeError("CraftBot installed but failed to start.") -def cmd_install(extra_args: List[str]) -> None: +def cmd_install(extra_args: List[str]) -> bool: """Install dependencies (source mode) or copy-and-register (frozen mode), then start the service.""" if IS_FROZEN: @@ -1219,7 +1332,7 @@ def cmd_install(extra_args: List[str]) -> None: target_dir = default_install_location() print(f" {ORANGE}▸{RESET} {WHITE}Installing CraftBot to {target_dir}{RESET}") _full_install_frozen(target_dir, extra_args) - return + return True _warn_path_issues() # ── Step 1: Install dependencies via install.py ──────────────────────── @@ -1241,7 +1354,7 @@ def cmd_install(extra_args: List[str]) -> None: print( f" {DIM}Run 'python install.py' directly to see the full error.{RESET}" ) - return + return False # Verify critical packages are actually importable with this interpreter. # install.py may exit 0 while packages ended up in a different site-packages. @@ -1257,7 +1370,7 @@ def cmd_install(extra_args: List[str]) -> None: print( f" {DIM}Run 'python install.py' to reinstall with this Python.{RESET}" ) - return + return False print() else: print(f" {DIM}(install.py not found — skipping dependency install){RESET}\n") @@ -1281,13 +1394,16 @@ def cmd_install(extra_args: List[str]) -> None: # ── Step 3: Start the service now ────────────────────────────────────── _retro_step(3, 3, "Starting CraftBot") - cmd_start(extra_args) + if not cmd_start(extra_args): + print(f"\n {RED}✗{RESET} {WHITE}CraftBot failed to start.{RESET}") + return False print(f"\n {GREEN}▸{RESET} {WHITE}CRAFTBOT IS RUNNING IN THE BACKGROUND{RESET}") - print(f" {DIM}░░{RESET} {ORANGE}{BROWSER_URL}{RESET}") + print(f" {DIM}░░{RESET} {ORANGE}{_frontend_url(extra_args)}{RESET}") print("You can close this window now.") time.sleep(2) _close_console_window() + return True def _remove_desktop_shortcut() -> None: @@ -1541,13 +1657,15 @@ def main() -> None: rest = args[1:] if command == "start": - cmd_start(rest) + if not cmd_start(rest): + sys.exit(1) elif command == "stop": cmd_stop() elif command == "restart": - cmd_restart(rest) + if not cmd_restart(rest): + sys.exit(1) elif command == "status": cmd_status() @@ -1563,7 +1681,8 @@ def main() -> None: cmd_logs(n) elif command == "install": - cmd_install(rest) + if not cmd_install(rest): + sys.exit(1) elif command == "uninstall": cmd_uninstall() diff --git a/installer/api.py b/installer/api.py index 6338ccc1..b923f140 100644 --- a/installer/api.py +++ b/installer/api.py @@ -21,6 +21,7 @@ from typing import Callable, Optional import craftbot +from startup_constants import CRAFTBOT_READY_MARKER # webview imported lazily inside `attach` so a syntax error here doesn't # break source-mode tests that don't have pywebview installed. @@ -178,7 +179,7 @@ def _do_install(self, target_dir: str) -> None: craftbot._full_install_frozen(target_dir, [], progress_cb=self._on_progress) # Spin tailing off so the worker thread completes immediately — # otherwise worker_busy stays True for up to 90s while the tail - # waits for "CRAFTBOT IS READY", and JS keeps stop/repair/uninstall + # waits for the ready marker, and JS keeps stop/repair/uninstall # disabled the whole time. self._spawn_log_tail(start_offset) @@ -204,10 +205,10 @@ def _log_size() -> int: def _tail_log(self, start_offset: int, deadline_s: float = 90.0) -> None: """Stream new bytes appended to craftbot.log into the JS log panel. - Stops when "CRAFTBOT IS READY" appears (run.py prints this once the + Stops when the ready marker appears (run.py prints this once the frontend + agent are both up) or after `deadline_s` seconds.""" offset = start_offset - end_marker = "CRAFTBOT IS READY" + end_marker = CRAFTBOT_READY_MARKER end_time = time.monotonic() + deadline_s announced = False while time.monotonic() < end_time: diff --git a/run.py b/run.py index 7db8a51b..e5be6846 100644 --- a/run.py +++ b/run.py @@ -30,6 +30,12 @@ import atexit from typing import Tuple, Optional, Dict, Any, List +from app.runtime_preflight import ( + ensure_runtime_dependencies, + mark_runtime_dependencies_checked, +) +from startup_constants import CRAFTBOT_READY_MARKER + multiprocessing.freeze_support() # Configuration is loaded from settings.json via the agent startup @@ -795,7 +801,7 @@ def print_ready_banner(url: str): W = 62 print(f"\n{ORANGE}╔{'═' * W}╗{RESET}") print(f"{ORANGE}║{' ' * W}║{RESET}") - _r1 = " ▸ CRAFTBOT IS READY" + _r1 = f" ▸ {CRAFTBOT_READY_MARKER}" _r2 = f" ░░ {url}" print(f"{ORANGE}║{RESET}{GREEN}{_r1.ljust(W)}{RESET}{ORANGE}║{RESET}") print(f"{ORANGE}║{RESET}{ORANGE}{_r2.ljust(W)}{RESET}{ORANGE}║{RESET}") @@ -1254,6 +1260,13 @@ def launch_agent(env_name: Optional[str], conda_base: Optional[str], use_conda: print("Run 'python install.py' or 'python install.py --conda' first.\n") sys.exit(1) + ensure_runtime_dependencies( + use_conda=use_conda, + env_name=env_name, + conda_command=get_conda_command() if use_conda else "conda", + ) + mark_runtime_dependencies_checked() + # Start OmniParser only if GUI mode and it was installed if gui_mode and gui_installed: if not launch_omniparser(use_conda): diff --git a/startup_constants.py b/startup_constants.py new file mode 100644 index 00000000..c0d88235 --- /dev/null +++ b/startup_constants.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +"""Shared startup markers used by launchers and installer log tailing.""" + +CRAFTBOT_READY_MARKER = "CRAFTBOT IS READY" diff --git a/tests/test_craftbot_service.py b/tests/test_craftbot_service.py new file mode 100644 index 00000000..4175c859 --- /dev/null +++ b/tests/test_craftbot_service.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +import shlex +import subprocess +import sys + +import pytest + +import craftbot +from startup_constants import CRAFTBOT_READY_MARKER + + +class _ExitedProcess: + pid = 12345 + + def wait(self, timeout=None): + return 1 + + +class _RunningProcess: + pid = 23456 + + def wait(self, timeout=None): + raise subprocess.TimeoutExpired("craftbot", timeout) + + +class _DelayedFailureProcess: + pid = 34567 + + def __init__(self): + self.calls = 0 + + def wait(self, timeout=None): + self.calls += 1 + if self.calls == 1: + raise subprocess.TimeoutExpired("craftbot", timeout) + return 1 + + +def test_start_reports_immediate_child_failure(tmp_path, monkeypatch, capsys): + pid_file = tmp_path / "craftbot.pid" + log_file = tmp_path / "craftbot.log" + log_file.write_text("dependency failure\n", encoding="utf-8") + events = [] + + monkeypatch.setattr(craftbot, "PID_FILE", str(pid_file)) + monkeypatch.setattr(craftbot, "LOG_FILE", str(log_file)) + monkeypatch.setattr(craftbot, "RUN_SCRIPT", str(tmp_path / "run.py")) + monkeypatch.setattr( + craftbot, "_create_desktop_shortcut_unix", lambda args: events.append("shortcut") + ) + monkeypatch.setattr( + craftbot, "_open_browser_detached", lambda url: events.append("browser") + ) + + def fake_popen(*args, **kwargs): + return _ExitedProcess() + + monkeypatch.setattr(craftbot.subprocess, "Popen", fake_popen) + + assert craftbot.cmd_start([]) is False + + output = capsys.readouterr().out + assert "CraftBot failed to start" in output + assert "dependency failure" in output + assert not pid_file.exists() + assert events == [] + + +def test_macos_source_shortcut_uses_custom_backend_port( + tmp_path, monkeypatch, capsys +): + desktop = tmp_path / "Desktop" + desktop.mkdir() + base_dir = tmp_path / "Craft Bot" + base_dir.mkdir() + python_exe = "/Applications/Python 3.10/bin/python3.10" + + monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") + monkeypatch.setattr(craftbot, "IS_FROZEN", False) + monkeypatch.setattr(craftbot, "BASE_DIR", str(base_dir)) + monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) + monkeypatch.setattr(craftbot, "_python_exe", lambda: python_exe) + + craftbot._create_desktop_shortcut_unix(["--backend-port", "8123"]) + + shortcut = desktop / "CraftBot.command" + content = shortcut.read_text() + assert f"cd {shlex.quote(str(base_dir))}" in content + assert "curl -fsS http://localhost:7925" in content + assert "curl -fsS http://localhost:8123" in content + assert "curl -fsS http://localhost:7926" not in content + assert "open http://localhost:7925" in content + assert f"exec {shlex.quote(python_exe)} craftbot.py start --backend-port 8123" in content + + output = capsys.readouterr().out + assert "Desktop shortcut created" in output + + +def test_macos_source_shortcut_accepts_equals_backend_port(tmp_path, monkeypatch): + desktop = tmp_path / "Desktop" + desktop.mkdir() + base_dir = tmp_path / "CraftBot" + base_dir.mkdir() + + monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") + monkeypatch.setattr(craftbot, "IS_FROZEN", False) + monkeypatch.setattr(craftbot, "BASE_DIR", str(base_dir)) + monkeypatch.setattr(craftbot, "_find_desktop", lambda: str(desktop)) + monkeypatch.setattr(craftbot, "_python_exe", lambda: "/usr/local/bin/python3.10") + + craftbot._create_desktop_shortcut_unix(["--backend-port=8123"]) + + content = (desktop / "CraftBot.command").read_text() + assert "curl -fsS http://localhost:8123" in content + assert "craftbot.py start --backend-port=8123" in content + + +def test_start_ignores_stale_ready_marker_when_child_exits( + tmp_path, monkeypatch, capsys +): + pid_file = tmp_path / "craftbot.pid" + log_file = tmp_path / "craftbot.log" + log_file.write_text(f"old run\n{CRAFTBOT_READY_MARKER}\n", encoding="utf-8") + events = [] + + monkeypatch.setattr(craftbot, "PID_FILE", str(pid_file)) + monkeypatch.setattr(craftbot, "LOG_FILE", str(log_file)) + monkeypatch.setattr(craftbot, "RUN_SCRIPT", str(tmp_path / "run.py")) + monkeypatch.setattr( + craftbot, "_create_desktop_shortcut_unix", lambda args: events.append("shortcut") + ) + monkeypatch.setattr( + craftbot, "_open_browser_detached", lambda url: events.append(("browser", url)) + ) + + def fake_popen(*args, **kwargs): + return _DelayedFailureProcess() + + monkeypatch.setattr(craftbot.subprocess, "Popen", fake_popen) + + assert craftbot.cmd_start([]) is False + + output = capsys.readouterr().out + assert "CraftBot failed to start" in output + assert not pid_file.exists() + assert events == [] + + +def test_cli_start_exits_nonzero_when_start_fails(monkeypatch): + monkeypatch.setattr(sys, "argv", ["craftbot.py", "start"]) + monkeypatch.setattr(craftbot, "cmd_start", lambda args: False) + + with pytest.raises(SystemExit) as exc: + craftbot.main() + + assert exc.value.code == 1 + + +def test_source_install_returns_false_when_service_start_fails( + tmp_path, monkeypatch, capsys +): + monkeypatch.setattr(craftbot, "IS_FROZEN", False) + monkeypatch.setattr(craftbot, "BASE_DIR", str(tmp_path)) + monkeypatch.setattr(craftbot, "_PLATFORM", "darwin") + monkeypatch.setattr(craftbot, "_is_installed", lambda: True) + monkeypatch.setattr(craftbot, "cmd_start", lambda args: False) + monkeypatch.setattr( + craftbot, + "_close_console_window", + lambda: (_ for _ in ()).throw(AssertionError("should not close")), + ) + + assert craftbot.cmd_install([]) is False + + output = capsys.readouterr().out + assert "CraftBot failed to start" in output + + +def test_ready_marker_constant_is_shared(): + assert CRAFTBOT_READY_MARKER == "CRAFTBOT IS READY" diff --git a/tests/test_run_dependency_check.py b/tests/test_run_dependency_check.py new file mode 100644 index 00000000..66fca347 --- /dev/null +++ b/tests/test_run_dependency_check.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +import subprocess +import sys +import textwrap + +import pytest + +from app import runtime_preflight + + +def test_confirmed_missing_runtime_dependencies_exit(monkeypatch, capsys): + def fake_run(cmd, capture_output, text, timeout): + return subprocess.CompletedProcess( + cmd, + 0, + stdout='noise\n__CRAFTBOT_MISSING_RUNTIME_IMPORTS__["requests"]\n', + ) + + monkeypatch.setattr(runtime_preflight.subprocess, "run", fake_run) + + with pytest.raises(SystemExit) as exc: + runtime_preflight.ensure_runtime_dependencies( + use_conda=False, + env_name=None, + checks={"requests": "requests"}, + ) + + assert exc.value.code == 1 + output = capsys.readouterr().out + assert "CraftBot Python dependencies are missing" in output + assert "requests" in output + + +def test_probe_timeout_warns_and_continues(monkeypatch, capsys): + def fake_run(cmd, capture_output, text, timeout): + raise subprocess.TimeoutExpired(cmd, timeout) + + monkeypatch.setattr(runtime_preflight.subprocess, "run", fake_run) + + runtime_preflight.ensure_runtime_dependencies( + use_conda=False, + env_name=None, + checks={"requests": "requests"}, + ) + + output = capsys.readouterr().out + assert "Warning: CraftBot could not verify Python dependencies" in output + assert "timed out" in output + + +def test_malformed_probe_output_warns_and_continues(monkeypatch, capsys): + def fake_run(cmd, capture_output, text, timeout): + return subprocess.CompletedProcess( + cmd, + 0, + stdout="__CRAFTBOT_MISSING_RUNTIME_IMPORTS__not-json\n", + ) + + monkeypatch.setattr(runtime_preflight.subprocess, "run", fake_run) + + runtime_preflight.ensure_runtime_dependencies( + use_conda=False, + env_name=None, + checks={"requests": "requests"}, + ) + + output = capsys.readouterr().out + assert "Warning: CraftBot could not verify Python dependencies" in output + assert "unexpected probe output" in output + + +def test_app_main_runs_preflight_before_agent_core_import(): + code = textwrap.dedent( + """ + import importlib.abc + import sys + import types + + fake_preflight = types.ModuleType("app.runtime_preflight") + + def ensure_current_runtime_dependencies(): + raise SystemExit(77) + + fake_preflight.ensure_current_runtime_dependencies = ensure_current_runtime_dependencies + sys.modules["app.runtime_preflight"] = fake_preflight + + class BlockAgentCore(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "agent_core" or fullname.startswith("agent_core."): + raise AssertionError("agent_core imported before runtime preflight") + return None + + sys.meta_path.insert(0, BlockAgentCore()) + + try: + import app.main # noqa: F401 + except SystemExit as exc: + assert exc.code == 77 + else: + raise AssertionError("app.main did not run runtime preflight") + """ + ) + + result = subprocess.run( + [sys.executable, "-c", code], + text=True, + capture_output=True, + ) + + assert result.returncode == 0, result.stderr diff --git a/tests/test_startup_constants_usage.py b/tests/test_startup_constants_usage.py new file mode 100644 index 00000000..d3b63a98 --- /dev/null +++ b/tests/test_startup_constants_usage.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from pathlib import Path + +from startup_constants import CRAFTBOT_READY_MARKER + + +def test_ready_marker_literal_is_centralized(): + repo = Path(__file__).resolve().parents[1] + offenders = [] + for relative in ("craftbot.py", "run.py", "installer/api.py"): + text = (repo / relative).read_text(encoding="utf-8") + if f'"{CRAFTBOT_READY_MARKER}"' in text or f"'{CRAFTBOT_READY_MARKER}'" in text: + offenders.append(relative) + + assert offenders == []