Files
raycast-api/tests/test_cli.py
T

416 lines
13 KiB
Python

"""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"