"""Tests for `raycast_api.cli`. Each subcommand is exercised through `cli.main([...])` with stdout/stderr captured. We cover one happy path plus one error path per subcommand. No network: `ask` uses `aioresponses` to mock `/ai/models` and `/ai/chat_completions`. `init` / `refresh` go through the real discovery pipeline against the synthetic `mock_app` fixture (defined in `conftest.py`), so this also exercises the cache-isolation path end-to-end without touching `~/.cache`. """ from __future__ import annotations import json from pathlib import Path from typing import Any import pytest from aioresponses import aioresponses from raycast_api import cli from raycast_api.config import Config from raycast_api.signing_spec import RotRange, SigningSpec def _make_config(path: Path) -> Config: """Write a minimal valid config.json suitable for `inspect` / `ask` tests.""" cfg = Config( signature_secret="6bc455473576ce2cd6f70426caff867aabbe3f7291c1a79681af5e8ce0ca1408", signing_spec=SigningSpec( rot_fn_name="Sur", signing_fn_name="Nkt", rot_ranges=[ RotRange(start=65, end=90, shift=13), RotRange(start=97, end=122, shift=13), RotRange(start=48, end=57, shift=5), ], ), app_version="0.60.1.0", user_agent="Raycast/0.60.1.0 (x-macOS Version 26.3.1)", bundle_hash="a" * 64, launcher_hash="b" * 64, ) cfg.save(path) return cfg CATALOG_PAYLOAD = { "models": [ { "id": "openai-gpt-4o-mini", "name": "GPT-4o mini", "model": "gpt-4o-mini", "provider": "openai", } ], "default_models": {"chat": "openai-gpt-4o-mini"}, "free_models": ["openai-gpt-4o-mini"], } ASK_SSE = ( b"id: 0\n" b'data: {"text":""}\n\n' b"id: 1\n" b'data: {"text":"hello"}\n\n' b"id: 2\n" b'data: {"finish_reason":"STOP","usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}\n\n' b"event: complete\ndata: \n\n" ) class TestInit: def test_init_writes_config( self, mock_app: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str], isolated_cache: Path, ) -> None: out = tmp_path / "config.json" rc = cli.main(["init", "--app-path", str(mock_app), "--output", str(out)]) assert rc == 0 assert out.exists() loaded = Config.load(out) assert loaded.app_version == "9.9.9.0" stdout = capsys.readouterr().out assert str(out) in stdout def test_init_refuses_overwrite_without_force( self, mock_app: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str], isolated_cache: Path, ) -> None: out = tmp_path / "config.json" out.write_text("{}") rc = cli.main(["init", "--app-path", str(mock_app), "--output", str(out)]) assert rc == 1 assert out.read_text() == "{}" err = capsys.readouterr().err assert "already exists" in err def test_init_with_bad_app_path( self, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: bogus = tmp_path / "nonexistent.app" rc = cli.main( ["init", "--app-path", str(bogus), "--output", str(tmp_path / "out.json")] ) assert rc == 1 err = capsys.readouterr().err assert "discovery failed" in err class TestRefresh: def test_refresh_overwrites( self, mock_app: Path, tmp_path: Path, isolated_cache: Path ) -> None: out = tmp_path / "config.json" out.write_text(json.dumps({"placeholder": True})) rc = cli.main(["refresh", "--app-path", str(mock_app), "--config", str(out)]) assert rc == 0 loaded = Config.load(out) assert loaded.app_version == "9.9.9.0" class TestInspect: def test_inspect_prints_summary( self, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: cfg_path = tmp_path / "config.json" _make_config(cfg_path) rc = cli.main(["inspect", "--config", str(cfg_path)]) assert rc == 0 out = capsys.readouterr().out assert "app_version : 0.60.1.0" in out assert "rot=Sur sign=Nkt" in out assert "6bc455" not in out assert "1408" in out def test_inspect_missing_file_exits( self, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: rc = cli.main(["inspect", "--config", str(tmp_path / "nope.json")]) assert rc == 1 err = capsys.readouterr().err assert "no config" in err def test_inspect_default_does_not_autodetect_app( self, tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch ) -> None: """Without --verify/--quiet/--app-path, inspect stays pure-offline. This pins the behavior even on a developer's machine that has /Applications/Raycast.app installed — we shouldn't fingerprint the local disk unless the user opts in. """ import raycast_api.cli as cli_mod sentinel: list[Path] = [] def _spy_compare(self: Config, app_path: Path) -> Any: sentinel.append(app_path) raise AssertionError("compare_with_app should not be called") monkeypatch.setattr(Config, "compare_with_app", _spy_compare) cfg_path = tmp_path / "config.json" _make_config(cfg_path) rc = cli_mod.main(["inspect", "--config", str(cfg_path)]) assert rc == 0 assert sentinel == [] out = capsys.readouterr().out assert "status" not in out def test_inspect_verify_app_path_current( self, mock_app: Path, isolated_cache: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: cfg = Config.discover_from_app(mock_app) cfg_path = tmp_path / "config.json" cfg.save(cfg_path) rc = cli.main( ["inspect", "--config", str(cfg_path), "--app-path", str(mock_app)] ) assert rc == 0 out = capsys.readouterr().out assert "status : CURRENT" in out assert "✓ matches" in out def test_inspect_verify_app_path_stale( self, mock_app: Path, isolated_cache: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: cfg = Config.discover_from_app(mock_app) cfg_path = tmp_path / "config.json" cfg.save(cfg_path) binary = mock_app / "Contents" / "MacOS" / "Foo" binary.write_bytes(binary.read_bytes() + b"\x55" * 8) rc = cli.main( ["inspect", "--config", str(cfg_path), "--app-path", str(mock_app)] ) assert rc == 1 out = capsys.readouterr().out assert "STALE" in out assert "launcher" in out assert "✗" in out def test_inspect_app_path_missing_is_error( self, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: cfg_path = tmp_path / "config.json" _make_config(cfg_path) rc = cli.main( [ "inspect", "--config", str(cfg_path), "--app-path", str(tmp_path / "absent.app"), ] ) assert rc == 2 assert "app path not found" in capsys.readouterr().err def test_inspect_quiet_current_exits_zero( self, mock_app: Path, isolated_cache: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: cfg = Config.discover_from_app(mock_app) cfg_path = tmp_path / "config.json" cfg.save(cfg_path) rc = cli.main( [ "inspect", "--config", str(cfg_path), "--app-path", str(mock_app), "--quiet", ] ) assert rc == 0 captured = capsys.readouterr() assert captured.out == "" and captured.err == "" def test_inspect_quiet_stale_exits_one( self, mock_app: Path, isolated_cache: Path, tmp_path: Path ) -> None: cfg = Config.discover_from_app(mock_app) cfg_path = tmp_path / "config.json" cfg.save(cfg_path) binary = mock_app / "Contents" / "MacOS" / "Foo" binary.write_bytes(binary.read_bytes() + b"\x66" * 8) rc = cli.main( [ "inspect", "--config", str(cfg_path), "--app-path", str(mock_app), "--quiet", ] ) assert rc == 1 def test_inspect_quiet_without_app_exits_two( self, tmp_path: Path, monkeypatch ) -> None: """`--quiet` with no findable app → exit 2 (unverifiable).""" import raycast_api.cli as cli_mod monkeypatch.setattr(cli_mod, "_try_resolve_app_path", lambda _: None) cfg_path = tmp_path / "config.json" _make_config(cfg_path) rc = cli_mod.main(["inspect", "--config", str(cfg_path), "--quiet"]) assert rc == 2 class TestAsk: def test_ask_complete_prints_reply( self, tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: cfg_path = tmp_path / "config.json" _make_config(cfg_path) monkeypatch.setenv("RAYCAST_DEVICE_ID", "a" * 64) monkeypatch.setenv("RAYCAST_BEARER", "rca_test") with aioresponses() as mocked: mocked.get( "https://backend.raycast.com/api/v1/ai/models", payload=CATALOG_PAYLOAD ) mocked.post( "https://backend.raycast.com/api/v1/ai/chat_completions", body=ASK_SSE, content_type="text/event-stream", ) rc = cli.main( ["ask", "--config", str(cfg_path), "--model", "gpt-4o-mini", "hi"] ) assert rc == 0 captured = capsys.readouterr() assert captured.out.strip() == "hello" assert "finish_reason=STOP" in captured.err def test_ask_stream_writes_tokens( self, tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: cfg_path = tmp_path / "config.json" _make_config(cfg_path) monkeypatch.setenv("RAYCAST_DEVICE_ID", "a" * 64) monkeypatch.setenv("RAYCAST_BEARER", "rca_test") with aioresponses() as mocked: mocked.get( "https://backend.raycast.com/api/v1/ai/models", payload=CATALOG_PAYLOAD ) mocked.post( "https://backend.raycast.com/api/v1/ai/chat_completions", body=ASK_SSE, content_type="text/event-stream", ) rc = cli.main( [ "ask", "--config", str(cfg_path), "--model", "gpt-4o-mini", "--stream", "hi", ] ) assert rc == 0 out = capsys.readouterr().out assert "hello" in out def test_ask_without_bearer_exits( self, tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: cfg_path = tmp_path / "config.json" _make_config(cfg_path) monkeypatch.delenv("RAYCAST_BEARER", raising=False) rc = cli.main( ["ask", "--config", str(cfg_path), "--model", "gpt-4o-mini", "hi"] ) assert rc == 2 err = capsys.readouterr().err assert "bearer" in err.lower() def test_ask_missing_config_exits( self, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: rc = cli.main( [ "ask", "--config", str(tmp_path / "missing.json"), "--model", "x", "--bearer", "rca_test", "hi", ] ) assert rc == 1 err = capsys.readouterr().err assert "no config" in err class TestDeviceIdPersistence: def test_creates_and_reuses(self, tmp_path: Path) -> None: path = tmp_path / "device_id" first = cli._load_or_create_device_id(path) assert len(first) == 64 second = cli._load_or_create_device_id(path) assert first == second def test_garbage_file_regenerates(self, tmp_path: Path) -> None: path = tmp_path / "device_id" path.write_text("not-hex\n") fresh = cli._load_or_create_device_id(path) assert len(fresh) == 64 assert fresh != "not-hex"