"""Tests for `raycast_api.config` and `raycast_api.discovery.cache`.""" from __future__ import annotations from pathlib import Path from typing import Any import pytest from raycast_api.config import DEFAULT_BACKEND_URL, Config, ConfigComparison from raycast_api.discovery.cache import DiscoveryCache, default_cache_dir from raycast_api.errors import ConfigError, DiscoveryError from raycast_api.signing_spec import RotRange, SigningSpec FAKE_SECRET = ("DEAD" + "BEEF" * 15).lower() def _make_spec() -> SigningSpec: return SigningSpec( rot_fn_name="r", signing_fn_name="s", rot_ranges=[RotRange(65, 90, 13), RotRange(97, 122, 13), RotRange(48, 57, 5)], ) def _make_config() -> Config: return Config( signature_secret=FAKE_SECRET, signing_spec=_make_spec(), app_version="1.2.3", user_agent="Raycast/1.2.3 (x-macOS Version 14.0)", bundle_hash="0" * 64, launcher_hash="1" * 64, ) def test_roundtrip_serialization(tmp_path: Path) -> None: cfg = _make_config() out = tmp_path / "config.json" cfg.save(out) loaded = Config.load(out) assert loaded.to_dict() == cfg.to_dict() def test_save_creates_parent_dirs_and_sets_perms(tmp_path: Path) -> None: cfg = _make_config() out = tmp_path / "deep" / "nested" / "config.json" cfg.save(out) assert out.exists() mode = out.stat().st_mode & 0o777 assert mode == 0o600 def test_redacted_secret() -> None: cfg = _make_config() assert cfg.redacted_secret() == "…" + FAKE_SECRET[-4:] short = Config( signature_secret="abc", signing_spec=_make_spec(), app_version="x", user_agent="x", bundle_hash="x", launcher_hash="x", ) assert short.redacted_secret() == "***" def test_load_rejects_missing_required(tmp_path: Path) -> None: bad = tmp_path / "bad.json" bad.write_text('{"signing_spec": {}}') with pytest.raises(ConfigError): Config.load(bad) def test_default_constants() -> None: cfg = _make_config() assert cfg.backend_url == DEFAULT_BACKEND_URL assert cfg.api_prefix == "/api/v1" assert cfg.experimental_header == "autoModels" def test_discover_from_app_synthetic(mock_app: Path, isolated_cache: Path) -> None: cfg = Config.discover_from_app(mock_app) assert cfg.signature_secret == FAKE_SECRET assert cfg.app_version == "9.9.9.0" assert cfg.signing_spec.signing_fn_name == "SignReq" assert cfg.signing_spec.rot_fn_name in {"RotXform", "RotXform2"} assert cfg.user_agent.startswith("Raycast/9.9.9.0 (x-macOS Version ") def test_discover_from_app_rejects_missing(tmp_path: Path) -> None: with pytest.raises(DiscoveryError): Config.discover_from_app(tmp_path / "does-not-exist.app") def test_cache_hit(mock_app: Path, isolated_cache: Path) -> None: cfg1 = Config.discover_from_app(mock_app) cache_files = list((isolated_cache / "raycast-api").glob("*.json")) assert len(cache_files) == 1 data: dict[str, Any] = __import__("json").loads(cache_files[0].read_text()) data["app_version"] = "TAMPERED" cache_files[0].write_text(__import__("json").dumps(data)) cfg2 = Config.discover_from_app(mock_app) assert cfg2.app_version == "TAMPERED" assert cfg2.bundle_hash == cfg1.bundle_hash def test_cache_bypass(mock_app: Path, isolated_cache: Path) -> None: Config.discover_from_app(mock_app) cfg2 = Config.discover_from_app(mock_app, use_cache=False) assert cfg2.app_version == "9.9.9.0" def test_default_cache_dir_honors_xdg( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) assert default_cache_dir() == tmp_path / "raycast-api" def test_discovery_cache_clear(tmp_path: Path) -> None: cache = DiscoveryCache(root=tmp_path) cfg = _make_config() cache.set("abc", cfg) assert (tmp_path / "abc.json").exists() cache.clear() assert not (tmp_path / "abc.json").exists() def test_discovery_cache_corrupt_returns_none(tmp_path: Path) -> None: cache = DiscoveryCache(root=tmp_path) (tmp_path / "abc.json").write_text("not json") assert cache.get("abc") is None def test_cache_key_depends_on_both_hashes() -> None: base = _make_config() different_bundle = Config( signature_secret=base.signature_secret, signing_spec=base.signing_spec, app_version=base.app_version, user_agent=base.user_agent, bundle_hash="9" * 64, launcher_hash=base.launcher_hash, ) different_launcher = Config( signature_secret=base.signature_secret, signing_spec=base.signing_spec, app_version=base.app_version, user_agent=base.user_agent, bundle_hash=base.bundle_hash, launcher_hash="9" * 64, ) assert base.cache_key() != different_bundle.cache_key() assert base.cache_key() != different_launcher.cache_key() assert different_bundle.cache_key() != different_launcher.cache_key() def test_launcher_rebuild_invalidates_cache( mock_app: Path, isolated_cache: Path ) -> None: """A launcher-only change must still invalidate the cache, even if the JS bundle is byte-identical.""" cfg1 = Config.discover_from_app(mock_app) binary_path = mock_app / "Contents" / "MacOS" / "Foo" current = binary_path.read_bytes() binary_path.write_bytes(current + b"\x99" * 16) cfg2 = Config.discover_from_app(mock_app) assert cfg2.launcher_hash != cfg1.launcher_hash assert cfg2.cache_key() != cfg1.cache_key() class TestCompareWithApp: """Freshness comparison between a saved Config and a local app on disk.""" def test_freshly_discovered_is_current( self, mock_app: Path, isolated_cache: Path ) -> None: cfg = Config.discover_from_app(mock_app) cmp = cfg.compare_with_app(mock_app) assert cmp.is_current assert cmp.bundle_matches assert cmp.launcher_matches assert cmp.app_version_matches assert cmp.reasons() == [] assert cfg.is_current_for(mock_app) is True def test_bundle_change_is_stale(self, mock_app: Path, isolated_cache: Path) -> None: cfg = Config.discover_from_app(mock_app) mjs = ( mock_app / "Contents" / "Resources" / "test_RaycastDesktopApp.bundle" / "Contents" / "Resources" / "backend" / "index.mjs" ) mjs.write_text(mjs.read_text() + "\n// touch") cmp = cfg.compare_with_app(mock_app) assert not cmp.is_current assert cmp.bundle_matches is False assert cmp.launcher_matches is True assert "bundle rebuilt" in cmp.reasons()[0] assert cfg.is_current_for(mock_app) is False def test_launcher_change_is_stale( self, mock_app: Path, isolated_cache: Path ) -> None: cfg = Config.discover_from_app(mock_app) binary = mock_app / "Contents" / "MacOS" / "Foo" binary.write_bytes(binary.read_bytes() + b"\x77" * 8) cmp = cfg.compare_with_app(mock_app) assert not cmp.is_current assert cmp.bundle_matches is True assert cmp.launcher_matches is False assert any("launcher rebuilt" in r for r in cmp.reasons()) def test_app_version_drift_alone_is_not_stale( self, mock_app: Path, isolated_cache: Path ) -> None: """Version-only drift is informational — `is_current` stays True.""" cfg = Config.discover_from_app(mock_app) from dataclasses import replace cfg_drift = replace(cfg, app_version="0.0.0") cmp = cfg_drift.compare_with_app(mock_app) assert cmp.is_current assert cmp.app_version_matches is False assert any("app version" in r for r in cmp.reasons()) def test_missing_dir_raises(self, tmp_path: Path) -> None: cfg = _make_config() with pytest.raises(DiscoveryError, match="not a directory"): cfg.compare_with_app(tmp_path / "nope.app") def test_config_comparison_dataclass_is_frozen() -> None: """Ensure the comparison can be used as a hashable value (e.g. in a set).""" cmp = ConfigComparison( bundle_matches=True, launcher_matches=True, app_version_matches=True, saved_bundle_hash="a", current_bundle_hash="a", saved_launcher_hash="b", current_launcher_hash="b", saved_app_version="1", current_app_version="1", ) with pytest.raises(Exception): # noqa: B017, BLE001 — FrozenInstanceError cmp.bundle_matches = False # type: ignore[misc] assert cmp.is_current