diff --git a/strix/interface/main.py b/strix/interface/main.py index 8b263fae..faa90e57 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -392,11 +392,10 @@ def _print_model_connection_error(exc: BaseException, model_name: str) -> None: def _bootstrap_scan(args: argparse.Namespace) -> None: - """Warm up the model and prepare the run for a non-interactive scan. + """Warm up the model and prepare the run before the interface starts. - Interactive launches skip this: the model preflight and run preparation - happen inside the TUI so the interface paints immediately instead of - waiting on a model round trip. + Start-screen launches skip this: they verify the model and prepare the + run once the user has entered a target. """ set_scan_phase("preflight") try: @@ -483,7 +482,7 @@ def main() -> None: # Everything below imports the scan engine; do not race the warm-up thread. wait_for_import_warmup() - if args.non_interactive: + if args.non_interactive or not args.needs_setup: _bootstrap_scan(args) from strix.report.state import get_global_report_state diff --git a/strix/interface/tui/backend/controller.py b/strix/interface/tui/backend/controller.py index 11d09259..133b3a13 100644 --- a/strix/interface/tui/backend/controller.py +++ b/strix/interface/tui/backend/controller.py @@ -152,11 +152,6 @@ class TuiController: ] self.notify_changed() - def begin_preparation(self) -> None: - """Mark a directly-launched run as preparing behind the live TUI.""" - self.scan_state = "preparing" - self.notify_changed() - def fail_preparation(self, detail: str) -> None: self.scan_state = "failed" self.error = detail diff --git a/strix/interface/tui/runtime.py b/strix/interface/tui/runtime.py index 7473bc0c..31a47f2d 100644 --- a/strix/interface/tui/runtime.py +++ b/strix/interface/tui/runtime.py @@ -127,8 +127,8 @@ class GoTuiRuntime: async def check_setup_model(self) -> None: """Verify the model route as soon as the start screen is up. - The same round trip a direct launch makes in prepare_and_start, run in - the background so the screen paints first and the outcome lands in the + The same round trip main() makes before a direct launch, run in the + background so the screen paints first and the outcome lands in the setup log before the user has finished typing. """ if not (load_settings().llm.model or "").strip(): @@ -162,13 +162,14 @@ class GoTuiRuntime: await preflight_model_connection(model) self.model_verified = True - def _start_preparation(self) -> asyncio.Task[None]: + def _start_preparation(self) -> asyncio.Task[None] | None: """Kick off the work that runs behind the freshly painted TUI.""" if self.controller.setup_mode: self._setup_preflight = asyncio.create_task(self.check_setup_model()) return self._setup_preflight - self.controller.begin_preparation() - return asyncio.create_task(self.prepare_and_start()) + self.init_run_state() + self.start_scan() + return None async def start_from_setup(self) -> None: candidate = deepcopy(self.args) @@ -208,34 +209,6 @@ class GoTuiRuntime: self.init_run_state() self.start_scan() - async def prepare_and_start(self) -> None: - """Prepare a directly-launched scan once the TUI is on screen. - - The model round trip and run preparation run here rather than before - launch so the interface appears immediately. - """ - model = (load_settings().llm.model or "").strip() - set_scan_phase("preflight") - try: - await preflight_model_connection(model) - except Exception as exc: - logger.exception("Go TUI scan preparation failed") - report_error("model_connection_failed", exc) - self.controller.fail_preparation(str(exc)) - return - try: - persist_current() - prepare_run(self.args) - telemetry_start(self.args) - except Exception as exc: - logger.exception("Go TUI scan preparation failed") - report_error("scan_preparation_failed", exc) - self.controller.fail_preparation(str(exc)) - return - self.controller.scan_state = "running" - self.init_run_state() - self.start_scan() - def start_scan(self) -> None: if self.scan_task is None: self.scan_task = asyncio.create_task(self._run_scan()) diff --git a/tests/test_go_tui_runtime.py b/tests/test_go_tui_runtime.py index 18a0256a..828071b2 100644 --- a/tests/test_go_tui_runtime.py +++ b/tests/test_go_tui_runtime.py @@ -231,9 +231,9 @@ async def test_runtime_does_not_initialize_or_scan_before_ready( monkeypatch.setattr(go_tui, "launch_tui_process", launch) monkeypatch.setattr(go_tui, "wait_process", wait_process) monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) - monkeypatch.setattr(go_tui, "persist_current", lambda: None) - monkeypatch.setattr(go_tui, "prepare_run", lambda _args: None) - monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: None) + monkeypatch.setattr(go_tui, "persist_current", lambda: calls.append("persist")) + monkeypatch.setattr(go_tui, "prepare_run", lambda _args: calls.append("prepare")) + monkeypatch.setattr(go_tui, "telemetry_start", lambda _args: calls.append("telemetry")) monkeypatch.setattr(runtime, "init_run_state", init_state) monkeypatch.setattr(runtime, "start_scan", start_scan) @@ -258,7 +258,7 @@ async def test_runtime_does_not_initialize_or_scan_before_ready( }, ) await asyncio.wait_for(run_task, timeout=2) - assert calls == ["preflight", "state", "scan"] + assert calls == ["state", "scan"] finally: child.close() if not run_task.done(): @@ -355,7 +355,7 @@ async def test_setup_preflights_model_before_starting( await runtime.ensure_model_verified() await runtime.start_from_setup() - # The same steps, in the same order, as a direct launch's prepare_and_start. + # The same steps, in the same order, as main() takes before a direct launch. assert calls == ["preflight", "persist", "targets", "prepare", "telemetry", "state", "scan"] assert runtime.args.scan_mode == "quick" assert runtime.args.instruction == "" @@ -1009,28 +1009,7 @@ def _direct_launch_args() -> argparse.Namespace: @pytest.mark.asyncio -async def test_prepare_and_start_reports_ordinary_connection_failures( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runtime = GoTuiRuntime(_direct_launch_args()) - started: list[str] = [] - - async def preflight(_model: str) -> None: - raise TimeoutError("connection timed out") - - monkeypatch.setattr(go_tui, "preflight_model_connection", preflight) - monkeypatch.setattr(runtime, "start_scan", lambda: started.append("scan")) - - await runtime.prepare_and_start() - - assert started == [] - assert runtime.controller.setup_mode is False - assert runtime.controller.scan_state == "failed" - assert "connection timed out" in (runtime.controller.error or "") - - -@pytest.mark.asyncio -async def test_prepare_and_start_runs_the_scan_after_preparation( +async def test_direct_launch_starts_the_prepared_scan_without_a_model_round_trip( monkeypatch: pytest.MonkeyPatch, ) -> None: runtime = GoTuiRuntime(_direct_launch_args()) @@ -1046,9 +1025,10 @@ async def test_prepare_and_start_runs_the_scan_after_preparation( monkeypatch.setattr(runtime, "init_run_state", lambda: order.append("state")) monkeypatch.setattr(runtime, "start_scan", lambda: order.append("scan")) - await runtime.prepare_and_start() + assert runtime._start_preparation() is None - assert order == ["preflight", "persist", "prepare", "telemetry", "state", "scan"] + assert order == ["state", "scan"] + assert runtime.controller.setup_mode is False assert runtime.controller.scan_state == "running" diff --git a/tests/test_main_launch.py b/tests/test_main_launch.py new file mode 100644 index 00000000..4d1c6eb8 --- /dev/null +++ b/tests/test_main_launch.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import argparse +import importlib +import sys +from typing import TYPE_CHECKING, Any + + +if TYPE_CHECKING: + import pytest + + +cli_main: Any = importlib.import_module("strix.interface.main") + + +def _launch(monkeypatch: pytest.MonkeyPatch, *, needs_setup: bool) -> list[str]: + calls: list[str] = [] + args = argparse.Namespace( + non_interactive=False, + needs_setup=needs_setup, + run_name=None, + fail_on=None, + ) + + async def run_tui(_args: argparse.Namespace) -> None: + calls.append("tui") + + monkeypatch.setattr(sys, "argv", ["strix", "--target", "https://example.com"]) + monkeypatch.setattr(cli_main, "configure_dependency_logging", lambda: None) + monkeypatch.setattr(cli_main, "start_import_warmup", lambda: None) + monkeypatch.setattr(cli_main, "parse_arguments", lambda: args) + monkeypatch.setattr(cli_main, "start_background_check", lambda: None) + monkeypatch.setattr(cli_main, "prompt_update_if_available", lambda _console: False) + monkeypatch.setattr(cli_main, "check_docker_installed", lambda: None) + monkeypatch.setattr(cli_main, "pull_docker_image", lambda: None) + monkeypatch.setattr(cli_main, "validate_environment", lambda: None) + monkeypatch.setattr(cli_main, "wait_for_import_warmup", lambda: None) + monkeypatch.setattr(cli_main, "_bootstrap_scan", lambda _args: calls.append("bootstrap")) + monkeypatch.setattr(cli_main, "run_tui", run_tui) + monkeypatch.setattr(cli_main, "notify_update", lambda _console: None) + + cli_main.main() + return calls + + +def test_direct_launch_verifies_the_model_before_the_tui(monkeypatch: pytest.MonkeyPatch) -> None: + assert _launch(monkeypatch, needs_setup=False) == ["bootstrap", "tui"] + + +def test_start_screen_launch_defers_the_model_check_to_the_tui( + monkeypatch: pytest.MonkeyPatch, +) -> None: + assert _launch(monkeypatch, needs_setup=True) == ["tui"]