416 lines
13 KiB
Python
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"
|