feat: vibed out some slop over here

This commit is contained in:
h
2026-05-19 11:18:37 +02:00
commit 52e7528b86
60 changed files with 9176 additions and 0 deletions
View File
+112
View File
@@ -0,0 +1,112 @@
"""Shared test fixtures.
Fixtures here construct a *synthetic* app bundle on disk that mimics the
structural shape of a real Raycast install: same directory layout, a Mach-O-
sized binary containing a fake `window.signatureSecret = '...'` line, and a
JS bundle whose AST shape matches what the extractors look for. None of this
uses real Raycast secrets — the synthetic secret is `"DEAD" + "BEEF" * 15`.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Iterator
import pytest
FIXTURES = Path(__file__).parent / "fixtures"
FAKE_SECRET = (
"DEAD" + "BEEF" * 15
)
def pytest_addoption(parser: pytest.Parser) -> None:
"""Register the opt-in `--live` flag that enables real-backend tests."""
parser.addoption(
"--live",
action="store_true",
default=False,
help=(
"run tests marked `live` (hits backend.raycast.com — requires "
"RAYCAST_BEARER + RAYCAST_DEVICE_ID env vars)"
),
)
def pytest_collection_modifyitems(
config: pytest.Config, items: list[pytest.Item]
) -> None:
"""Skip `live`-marked tests unless `--live` was passed."""
if config.getoption("--live"):
return
skip = pytest.mark.skip(
reason="needs --live (and RAYCAST_BEARER / RAYCAST_DEVICE_ID)"
)
for item in items:
if "live" in item.keywords:
item.add_marker(skip)
@pytest.fixture(scope="session")
def fixtures_dir() -> Path:
return FIXTURES
@pytest.fixture
def mock_bundle_source() -> str:
"""Synthetic JS source containing all three structurally-matched functions.
Mirrors the real bundle shape: a rot fn `Roto` (1 param, the three required
numeric triplets), a duplicate `Roto2` (so dedup logic gets exercised), an
async 4-param signing fn `SigF` that calls .map(Roto), and a confusingly-
named decoy with a similar shape but missing one of the literals — the
extractor should ignore it.
"""
return (FIXTURES / "mock_bundle.mjs").read_text()
@pytest.fixture
def mock_binary_bytes() -> bytes:
"""A blob that looks like a Mach-O binary far enough to fool the extractor."""
return (FIXTURES / "mock_binary.bin").read_bytes()
@pytest.fixture
def mock_app(tmp_path: Path, mock_bundle_source: str, mock_binary_bytes: bytes) -> Path:
"""Build a fake `Foo.app` bundle on disk and return its path."""
app = tmp_path / "Foo.app"
(app / "Contents" / "MacOS").mkdir(parents=True)
sub_bundle = app / "Contents" / "Resources" / "test_RaycastDesktopApp.bundle"
(sub_bundle / "Contents" / "Resources" / "backend").mkdir(parents=True)
(app / "Contents" / "MacOS" / "Foo").write_bytes(mock_binary_bytes)
(sub_bundle / "Contents" / "Resources" / "backend" / "index.mjs").write_text(
mock_bundle_source
)
plist = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
'<plist version="1.0"><dict>'
"<key>CFBundleShortVersionString</key><string>9.9.9.0</string>"
"</dict></plist>"
)
(app / "Contents" / "Info.plist").write_text(plist)
return app
@pytest.fixture
def isolated_cache(tmp_path: Path) -> Iterator[Path]:
"""Point XDG_CACHE_HOME at a temp dir so DiscoveryCache doesn't touch ~/.cache."""
prev = os.environ.get("XDG_CACHE_HOME")
cache = tmp_path / "cache"
os.environ["XDG_CACHE_HOME"] = str(cache)
try:
yield cache
finally:
if prev is None:
os.environ.pop("XDG_CACHE_HOME", None)
else:
os.environ["XDG_CACHE_HOME"] = prev
BIN
View File
Binary file not shown.
+77
View File
@@ -0,0 +1,77 @@
// Synthetic bundle for raycast_api.discovery tests.
//
// Structurally mirrors Raycast's bundle: a rot13+rot5 function, an async
// 4-param HMAC signer that .map()s through the rot fn, and a couple of decoys
// the extractor must reject. Identifier names are intentionally NOT the
// minified ones from real Raycast (Sur, Nkt) — we use longer names to prove
// the extractor matches structurally, not by name.
// --- decoy 1: 1-param but missing the rot triplets ----------------------
function decoyOneParam(t) {
return t.toUpperCase();
}
// --- decoy 2: 4-param HMAC-ish but with no .map(rot) call --------------
async function decoyHmac(a, b, c, d) {
// Mentions HMAC + SHA-256 + importKey but never calls .map(rotFn).
let key = await crypto.subtle.importKey("raw", a, {
name: "HMAC",
hash: "SHA-256"
}, false, ["sign"]);
return crypto.subtle.sign("HMAC", key, b);
}
// --- the real rot fn ----------------------------------------------------
function RotXform(s) {
let out = "";
for (let ch of s) {
let r = ch.charCodeAt(0);
if (r >= 65 && r <= 90) {
out += String.fromCharCode((r - 65 + 13) % 26 + 65);
} else if (r >= 97 && r <= 122) {
out += String.fromCharCode((r - 97 + 13) % 26 + 97);
} else if (r >= 48 && r <= 57) {
out += String.fromCharCode((r - 48 + 5) % 10 + 48);
} else {
out += ch;
}
}
return out;
}
// --- duplicate rot (real bundle has two byte-identical copies) ---------
function RotXform2(s) {
let out = "";
for (let ch of s) {
let r = ch.charCodeAt(0);
if (r >= 65 && r <= 90) { out += String.fromCharCode((r - 65 + 13) % 26 + 65); }
else if (r >= 97 && r <= 122) { out += String.fromCharCode((r - 97 + 13) % 26 + 97); }
else if (r >= 48 && r <= 57) { out += String.fromCharCode((r - 48 + 5) % 10 + 48); }
else { out += ch; }
}
return out;
}
// --- the real signing fn -----------------------------------------------
async function SignReq(secret, timestamp, deviceId, body) {
let enc = new TextEncoder().encode(body);
let h = await crypto.subtle.digest("SHA-256", enc);
let bodyHash = Array.from(new Uint8Array(h)).map(b => b.toString(16).padStart(2, "0")).join("");
let canonical = [timestamp, deviceId, bodyHash].map(RotXform).join(".");
let keyBytes = new TextEncoder().encode(secret);
let canonBytes = new TextEncoder().encode(canonical);
let key = await crypto.subtle.importKey("raw", keyBytes, {
name: "HMAC",
hash: "SHA-256"
}, false, ["sign"]);
let sig = await crypto.subtle.sign("HMAC", key, canonBytes);
return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, "0")).join("");
}
// Decoy: looks like a string-handling function but with a `/` inside a
// regex literal — exercises the regex-aware brace matcher.
function noiseFn(input) {
return input.replace(/\}/g, "_");
}
export { SignReq, RotXform };
+112
View File
@@ -0,0 +1,112 @@
"""Tests for `raycast_api.discovery.ast_parse`."""
from __future__ import annotations
from raycast_api.discovery.ast_parse import (
collect_numeric_literals,
find_calls,
find_function_by_shape,
has_string_literal,
iter_function_declarations,
)
def test_finds_declarations_and_skips_decoys(mock_bundle_source: str) -> None:
fns = list(iter_function_declarations(mock_bundle_source))
names = [f.name for f in fns]
assert {
"RotXform",
"RotXform2",
"SignReq",
"decoyOneParam",
"decoyHmac",
"noiseFn",
} <= set(names)
def test_param_count_filter(mock_bundle_source: str) -> None:
fns = list(iter_function_declarations(mock_bundle_source))
one_param = {f.name for f in find_function_by_shape(fns, param_count=1)}
assert "RotXform" in one_param
assert "decoyOneParam" in one_param
four_param = {
f.name for f in find_function_by_shape(fns, is_async=True, param_count=4)
}
assert "SignReq" in four_param
assert "decoyHmac" in four_param
def test_body_contains_excludes_decoys(mock_bundle_source: str) -> None:
fns = list(iter_function_declarations(mock_bundle_source))
signing_like = find_function_by_shape(
fns,
is_async=True,
param_count=4,
body_contains_all=["HMAC", "SHA-256", "importKey"],
)
assert {f.name for f in signing_like} == {"SignReq", "decoyHmac"}
def test_find_calls_locates_map_calls(mock_bundle_source: str) -> None:
fns = list(iter_function_declarations(mock_bundle_source))
sign = next(f for f in fns if f.name == "SignReq")
map_calls = find_calls(sign, "map")
assert len(map_calls) == 3
def test_has_string_literal(mock_bundle_source: str) -> None:
fns = list(iter_function_declarations(mock_bundle_source))
sign = next(f for f in fns if f.name == "SignReq")
assert has_string_literal(sign, "HMAC")
assert has_string_literal(sign, "SHA-256")
assert not has_string_literal(sign, "MD5")
def test_collect_numeric_literals(mock_bundle_source: str) -> None:
fns = list(iter_function_declarations(mock_bundle_source))
rot = next(f for f in fns if f.name == "RotXform")
nums = collect_numeric_literals(rot)
assert {65, 90, 13, 26, 97, 122, 48, 57, 5, 10} <= nums
def test_regex_literal_with_brace_does_not_truncate_function() -> None:
"""The brace matcher must skip braces inside regex literals."""
src = """
function tricky(s) {
let re = /\\}/;
if (s.match(re)) { return true }
return false;
}
"""
fns = list(iter_function_declarations(src))
assert len(fns) == 1
assert fns[0].name == "tricky"
assert "return true" in fns[0].body_source
assert fns[0].body_source.endswith("}")
def test_template_literal_with_interpolation() -> None:
src = r"""
function templating(x) {
let nested = `prefix-${x.toString()}-${`inner-${x}`}-end`;
if (x) { return nested }
return "";
}
"""
fns = list(iter_function_declarations(src))
assert len(fns) == 1
assert fns[0].name == "templating"
assert "return nested" in fns[0].body_source
def test_strings_with_braces_dont_confuse_matcher() -> None:
src = """
function strs() {
let a = "{ not real }";
let b = '} also not };';
return a + b;
}
"""
fns = list(iter_function_declarations(src))
assert len(fns) == 1
assert fns[0].body_source.endswith("}")
+41
View File
@@ -0,0 +1,41 @@
"""Tests for `raycast_api.discovery.binary`."""
from __future__ import annotations
from pathlib import Path
import pytest
from raycast_api.discovery.binary import find_signature_secret
from raycast_api.errors import DiscoveryError
EXPECTED_FAKE_SECRET = ("DEAD" + "BEEF" * 15).lower()
def test_finds_secret_in_synthetic_app(mock_app: Path) -> None:
secret = find_signature_secret(mock_app)
assert secret == EXPECTED_FAKE_SECRET
def test_rejects_non_bundle(tmp_path: Path) -> None:
with pytest.raises(DiscoveryError, match="Not an app bundle"):
find_signature_secret(tmp_path / "nonexistent.app")
def test_rejects_missing_pattern(tmp_path: Path) -> None:
app = tmp_path / "Empty.app"
(app / "Contents" / "MacOS").mkdir(parents=True)
(app / "Contents" / "MacOS" / "Empty").write_bytes(b"\x00" * 1024)
with pytest.raises(DiscoveryError, match="Could not find"):
find_signature_secret(app)
def test_accepts_double_or_single_quoted_pattern(tmp_path: Path) -> None:
"""The launcher uses single quotes, but the regex tolerates either form."""
app = tmp_path / "DoubleQuote.app"
(app / "Contents" / "MacOS").mkdir(parents=True)
secret = "a" * 64
(app / "Contents" / "MacOS" / "DoubleQuote").write_bytes(
b"\x00" * 100 + f'window.signatureSecret = "{secret}"'.encode() + b"\x00" * 100
)
assert find_signature_secret(app) == secret
+42
View File
@@ -0,0 +1,42 @@
"""Tests for `raycast_api.discovery.bundle`."""
from __future__ import annotations
from pathlib import Path
import pytest
from raycast_api.discovery.bundle import (
bundle_hash,
find_index_mjs,
locate_node_bundle,
read_bundle_source,
)
from raycast_api.errors import DiscoveryError
def test_locate_node_bundle(mock_app: Path) -> None:
bundle = locate_node_bundle(mock_app)
assert bundle.name == "test_RaycastDesktopApp.bundle"
def test_locate_node_bundle_missing(tmp_path: Path) -> None:
app = tmp_path / "Empty.app"
(app / "Contents" / "Resources").mkdir(parents=True)
with pytest.raises(DiscoveryError, match="No RaycastDesktopApp bundle"):
locate_node_bundle(app)
def test_find_index_mjs(mock_app: Path) -> None:
p = find_index_mjs(mock_app)
assert p.name == "index.mjs"
assert "SignReq" in read_bundle_source(p)
def test_bundle_hash_stable(mock_app: Path, tmp_path: Path) -> None:
a = bundle_hash(find_index_mjs(mock_app))
b = bundle_hash(find_index_mjs(mock_app))
assert a == b
other = tmp_path / "other.mjs"
other.write_text("different content")
assert bundle_hash(other) != a
+35
View File
@@ -0,0 +1,35 @@
"""Tests for raycast_api.signing.canonical."""
from __future__ import annotations
from raycast_api.signing.canonical import build_canonical
from raycast_api.signing_spec import RotRange
RAYCAST_RANGES = [
RotRange(0x41, 0x5A, 13),
RotRange(0x61, 0x7A, 13),
RotRange(0x30, 0x39, 5),
]
def test_components_transformed_then_joined() -> None:
out = build_canonical(["abc", "123", "XYZ"], RAYCAST_RANGES, ".")
assert out == "nop.678.KLM"
def test_join_char_is_used_verbatim() -> None:
out = build_canonical(["abc", "abc"], RAYCAST_RANGES, ":")
assert out == "nop:nop"
def test_zero_components_yields_empty_string() -> None:
assert build_canonical([], RAYCAST_RANGES, ".") == ""
def test_single_component_no_separator() -> None:
assert build_canonical(["abc"], RAYCAST_RANGES, ".") == "nop"
def test_components_independent() -> None:
assert build_canonical(["a", "b"], RAYCAST_RANGES, ".") == "n.o"
+378
View File
@@ -0,0 +1,378 @@
"""Tests for `raycast_api.ai.chat.ChatAPI`.
Covers, in roughly increasing scope:
- `_build_body` produces fields in the right order with the right defaults
per `source`.
- Tool/message serialisation matches the wire shape from BUNDLE_NOTES §3.
- `UserPreferences.render()` matches the byte-exact preamble from the
real Raycast `Ya()` function.
- `complete(...)` accumulates deltas into a single `ChatResult` and
handles the streamed-arguments case (concatenating tool-call argument
fragments across chunks).
"""
from __future__ import annotations
import json
from typing import Any
import pytest
from aioresponses import aioresponses
from raycast_api.ai import (
ChatAPI,
ChatStreamChunk,
Message,
RemoteTool,
Source,
Tool,
ToolCall,
UserPreferences,
)
from raycast_api.client import Client
from raycast_api.config import Config
from raycast_api.signing_spec import RotRange, SigningSpec
REFERENCE_SECRET = "6bc455473576ce2cd6f70426caff867aabbe3f7291c1a79681af5e8ce0ca1408"
DEVICE_ID = "20eca913cada74f879e6535304f9d44da380c28eb855065c0d71017a3d7c3099"
FIXED_TIMESTAMP = 1778858809
def _config() -> Config:
return Config(
signature_secret=REFERENCE_SECRET,
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="0" * 64,
launcher_hash="0" * 64,
)
def _client(**kwargs: Any) -> Client:
return Client(
config=_config(),
bearer_token="rca_test_token",
device_id=DEVICE_ID,
clock=lambda: FIXED_TIMESTAMP,
locale="en-GB",
**kwargs,
)
class TestUserPreferences:
def test_render_matches_real_client_wording(self) -> None:
"""The block must be byte-identical to what `Ya()` emits.
The captured request body in `request_simple.curl.txt` contains:
<user-preferences>\\n
The user has the following system preferences:\\n
- Locale: en-GB\\n
- Timezone: Europe/Warsaw\\n
- Current Date: 2026-05-15\\n
- Use the system preferences to format your answers accordingly\\n
</user-preferences>
Any deviation (spacing, line breaks, punctuation) breaks the
fingerprint match.
"""
prefs = UserPreferences(
locale="en-GB", timezone="Europe/Warsaw", current_date="2026-05-15"
)
rendered = prefs.render()
assert rendered == (
"<user-preferences>\n"
" The user has the following system preferences:\n"
" - Locale: en-GB\n"
" - Timezone: Europe/Warsaw\n"
" - Current Date: 2026-05-15\n"
" - Use the system preferences to format your answers accordingly\n"
"</user-preferences>"
)
def test_auto_picks_today_and_locale_argument(self) -> None:
import datetime
prefs = UserPreferences.auto(locale="ru-RU")
assert prefs.locale == "ru-RU"
assert prefs.current_date == datetime.date.today().isoformat()
assert prefs.timezone
class TestSerialisation:
def test_remote_tool_shape(self) -> None:
assert Tool.remote("web_search").to_wire() == {
"type": "remote_tool",
"name": "web_search",
}
assert Tool.remote(RemoteTool.SEARCH_IMAGES).to_wire() == {
"type": "remote_tool",
"name": "search_images",
}
def test_local_tool_shape(self) -> None:
t = Tool.local(
name="weather__get",
description="get weather",
parameters={"type": "object", "properties": {"city": {"type": "string"}}},
)
assert t.to_wire() == {
"type": "local_tool",
"function": {
"name": "weather__get",
"description": "get weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
},
},
}
def test_user_message(self) -> None:
assert Message.user("hello").to_wire() == {
"role": "user",
"content": {"text": "hello"},
}
def test_assistant_with_tool_calls(self) -> None:
msg = Message.assistant(
text="",
tool_calls=[
ToolCall(
id="abc", name="coffee__caffeinate-for", arguments='{"minutes":5}'
)
],
extra_content={"google": {"thought_signature": "xyz"}},
)
assert msg.to_wire() == {
"role": "assistant",
"content": {"text": ""},
"tool_calls": [
{
"id": "abc",
"type": "function",
"function": {
"name": "coffee__caffeinate-for",
"arguments": '{"minutes":5}',
},
}
],
"extra_content": {"google": {"thought_signature": "xyz"}},
}
def test_tool_message_wraps_string_as_mcp_text(self) -> None:
msg = Message.tool(
tool_call_id="abc",
name="coffee__caffeinate-for",
result="Mac will stay awake for 5m",
)
assert msg.to_wire() == {
"role": "tool",
"content": {
"text": '[{"type":"text","text":"Mac will stay awake for 5m"}]'
},
"name": "coffee__caffeinate-for",
"tool_call_id": "abc",
}
class TestBuildBody:
def test_minimal_body_field_order(self) -> None:
"""First-turn body should serialise fields in the captured order."""
chat = ChatAPI(_client())
body = chat._build_body(
model="gemini-3.1-pro-preview",
provider="google",
messages=[Message.user("привет")],
source=Source.AI_CHAT,
buffer_id="8480fbbb-4592-4257-812d-f24a67da3c07",
message_id="2f138e1c-edcf-495b-915c-db5cbb154674",
locale="en-GB",
current_date="2026-05-15",
system_instructions="markdown",
additional_system_instructions="<user-preferences>\n The user has the following system preferences:\n - Locale: en-GB\n - Timezone: Europe/Warsaw\n - Current Date: 2026-05-15\n - Use the system preferences to format your answers accordingly\n</user-preferences>",
temperature=0,
reasoning_effort="high",
tools=[
Tool.remote(RemoteTool.WEB_SEARCH).to_wire(),
Tool.remote(RemoteTool.SEARCH_IMAGES).to_wire(),
Tool.remote(RemoteTool.READ_PAGE).to_wire(),
],
tool_choice="auto",
resume_from=None,
)
keys = list(body.keys())
assert keys == [
"system_instructions",
"additional_system_instructions",
"locale",
"temperature",
"current_date",
"message_id",
"reasoning_effort",
"messages",
"tools",
"tool_choice",
"source",
"model",
"provider",
"buffer_id",
]
def test_omits_optional_fields_when_none(self) -> None:
"""No tools → no tools / tool_choice in the body at all."""
chat = ChatAPI(_client())
body = chat._build_body(
model="m",
provider="p",
messages=[Message.user("hi")],
source=Source.AI_CHAT,
buffer_id="b",
message_id="m",
locale="en-US",
current_date=None,
system_instructions=None,
additional_system_instructions=None,
temperature=None,
reasoning_effort=None,
tools=None,
tool_choice=None,
resume_from=None,
)
assert "tools" not in body
assert "tool_choice" not in body
assert "temperature" not in body
assert "system_instructions" not in body
assert "additional_system_instructions" not in body
assert "reasoning_effort" not in body
assert "current_date" not in body
assert body["model"] == "m"
assert body["provider"] == "p"
assert body["buffer_id"] == "b"
assert body["source"] == "ai_chat"
def test_source_default_temperature_only_applies_when_unspecified(self) -> None:
"""Quick AI defaults to 0.2; passing temperature=0 overrides."""
chat = ChatAPI(_client())
from raycast_api.ai.chat import _SOURCE_DEFAULTS
defaults = _SOURCE_DEFAULTS[Source.QUICK_AI]
assert defaults["temperature"] == 0.2
assert defaults["system_instructions"] == "plain"
class TestComplete:
@pytest.mark.asyncio
async def test_complete_concatenates_streamed_tool_arguments(self) -> None:
"""If `arguments` arrives in multiple chunks, they're concatenated.
Constructs a synthetic SSE stream where the same tool_call id
appears across two chunks with partial `arguments` payloads.
"""
sse = (
b"id: 0\n"
b'data: {"text":"","tool_calls":[{"id":"tc1","name":"f","arguments":"{\\"a\\":"}]}\n\n'
b"id: 1\n"
b'data: {"text":"","tool_calls":[{"id":"tc1","arguments":"1}"}]}\n\n'
b"id: 2\n"
b'data: {"text":"","finish_reason":"STOP","usage":{"input_tokens":1,"output_tokens":1}}\n\n'
b'event: complete\ndata: {"complete":true}\n\n'
)
with aioresponses() as mocked:
mocked.post(
"https://backend.raycast.com/api/v1/ai/chat_completions",
status=200,
body=sse,
headers={"Content-Type": "text/event-stream"},
)
async with _client() as client:
result = await client.chat.complete(
model="m",
provider="p",
messages=[Message.user("x")],
user_preferences=False,
)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].id == "tc1"
assert result.tool_calls[0].name == "f"
assert result.tool_calls[0].arguments == '{"a":1}'
class TestSignedBytesMatch:
"""When we call `client.chat.stream`, the body bytes the request carries
must equal the bytes the Signer signed. `aioresponses` lets us capture
the outgoing body via a callback.
"""
@pytest.mark.asyncio
async def test_stream_post_body_matches_signed_bytes(self) -> None:
captured: dict[str, Any] = {}
def _cb(url: Any, **kwargs: Any) -> Any:
captured["data"] = kwargs.get("data")
captured["headers"] = kwargs.get("headers")
from aioresponses import CallbackResult
return CallbackResult(
status=200,
body=b'event: complete\ndata: {"complete":true}\n\n',
headers={"Content-Type": "text/event-stream"},
)
with aioresponses() as mocked:
mocked.post(
"https://backend.raycast.com/api/v1/ai/chat_completions", callback=_cb
)
async with _client() as client:
async for _ in client.chat.stream(
model="m",
provider="p",
messages=[Message.user("hi")],
user_preferences=False,
buffer_id="b",
message_id="mid",
current_date="2026-05-15",
):
pass
body_bytes = captured["data"]
if hasattr(body_bytes, "_value"):
body_bytes = body_bytes._value
assert isinstance(body_bytes, (bytes, bytearray))
from raycast_api.signing import Signer
signer = Signer(spec=_config().signing_spec, secret=REFERENCE_SECRET)
expected_sig = signer.sign(
timestamp=str(FIXED_TIMESTAMP), device_id=DEVICE_ID, body=bytes(body_bytes)
)
assert captured["headers"]["X-Raycast-Signature-v2"] == expected_sig
parsed = json.loads(bytes(body_bytes))
assert parsed["model"] == "m"
assert parsed["provider"] == "p"
assert parsed["buffer_id"] == "b"
assert parsed["message_id"] == "mid"
assert parsed["source"] == "ai_chat"
assert parsed["system_instructions"] == "markdown"
+215
View File
@@ -0,0 +1,215 @@
"""Tests for `ChatAPI._resolve_model` + the Client-level catalog cache.
Resolution rules (mirrored from PROGRESS.md "Phase 6 / 6a"):
1. `ModelInfo` argument → use `.model` and `.provider`, ignore the
`provider=` kwarg.
2. `str` + `provider=` → pass through verbatim, no catalog lookup.
3. `str` only:
- match catalog id (`info.id`),
- else match wire id (`info.model`),
- else match display name (`info.name`),
- else raise `ValueError`.
The catalog is fetched at most once per Client and cached. We assert this
by mocking `/ai/models` and counting requests across two `chat.complete`
calls.
"""
from __future__ import annotations
from typing import Any
import pytest
from aioresponses import aioresponses
from raycast_api.ai import Message
from raycast_api.ai.chat import ChatAPI
from raycast_api.ai.models import ModelInfo, ModelsResponse
from raycast_api.client import Client
from raycast_api.config import Config
from raycast_api.signing_spec import RotRange, SigningSpec
def _config() -> Config:
return Config(
signature_secret="0" * 64,
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="0" * 64,
launcher_hash="0" * 64,
)
def _client(**kwargs: Any) -> Client:
return Client(
config=_config(),
bearer_token="rca_test",
device_id="a" * 64,
clock=lambda: 1700000000,
**kwargs,
)
CATALOG_PAYLOAD = {
"models": [
{
"id": "google-gemini-3.1-pro-preview",
"name": "Gemini 3.1 Pro Preview",
"model": "gemini-3.1-pro-preview",
"provider": "google",
"provider_name": "Google",
},
{
"id": "anthropic-claude-sonnet-4-6",
"name": "Claude Sonnet 4.6",
"model": "claude-sonnet-4-6",
"provider": "anthropic",
"provider_name": "Anthropic",
},
],
"default_models": {"chat": "google-gemini-3.1-pro-preview"},
"free_models": ["anthropic-claude-sonnet-4-6"],
}
def _catalog() -> ModelsResponse:
return ModelsResponse.from_wire(CATALOG_PAYLOAD)
class TestResolveModel:
async def test_model_info_argument_wins(self) -> None:
"""A `ModelInfo` short-circuits any catalog lookup; `provider=` is ignored."""
async with _client(models=_catalog()) as client:
chat = ChatAPI(client)
info = _catalog().by_id("google-gemini-3.1-pro-preview")
assert info is not None
wire, provider = await chat._resolve_model(info, provider="ignored")
assert wire == "gemini-3.1-pro-preview"
assert provider == "google"
async def test_string_plus_provider_passes_through(self) -> None:
"""The escape hatch: explicit `provider=` skips the catalog entirely."""
async with _client(models=_catalog()) as client:
chat = ChatAPI(client)
wire, provider = await chat._resolve_model(
"some-future-model", provider="custom-provider"
)
assert wire == "some-future-model"
assert provider == "custom-provider"
async def test_string_matches_catalog_id(self) -> None:
async with _client(models=_catalog()) as client:
chat = ChatAPI(client)
wire, provider = await chat._resolve_model(
"google-gemini-3.1-pro-preview", provider=None
)
assert wire == "gemini-3.1-pro-preview"
assert provider == "google"
async def test_string_matches_wire_id(self) -> None:
async with _client(models=_catalog()) as client:
chat = ChatAPI(client)
wire, provider = await chat._resolve_model(
"gemini-3.1-pro-preview", provider=None
)
assert wire == "gemini-3.1-pro-preview"
assert provider == "google"
async def test_string_matches_display_name(self) -> None:
async with _client(models=_catalog()) as client:
chat = ChatAPI(client)
wire, provider = await chat._resolve_model(
"Claude Sonnet 4.6", provider=None
)
assert wire == "claude-sonnet-4-6"
assert provider == "anthropic"
async def test_unknown_string_raises_value_error(self) -> None:
async with _client(models=_catalog()) as client:
chat = ChatAPI(client)
with pytest.raises(ValueError, match="not found in catalog"):
await chat._resolve_model("totally-made-up", provider=None)
class TestCatalogCache:
async def test_catalog_fetched_once_and_reused(self) -> None:
"""Two `_resolve_model` calls with no `provider=` should hit
`/ai/models` exactly once (catalog cached on the Client)."""
call_count = {"n": 0}
def _cb(url: Any, **kwargs: Any) -> Any:
call_count["n"] += 1
from aioresponses import CallbackResult
return CallbackResult(status=200, payload=CATALOG_PAYLOAD)
with aioresponses() as mocked:
mocked.get(
"https://backend.raycast.com/api/v1/ai/models",
callback=_cb,
repeat=True,
)
async with _client() as client:
chat = ChatAPI(client)
a = await chat._resolve_model(
"google-gemini-3.1-pro-preview", provider=None
)
b = await chat._resolve_model("claude-sonnet-4-6", provider=None)
assert a == ("gemini-3.1-pro-preview", "google")
assert b == ("claude-sonnet-4-6", "anthropic")
assert call_count["n"] == 1
async def test_models_constructor_kwarg_skips_fetch(self) -> None:
"""Passing `models=` to Client should mean zero `/ai/models` hits."""
with aioresponses() as mocked:
mocked.get(
"https://backend.raycast.com/api/v1/ai/models",
status=500,
repeat=True,
)
async with _client(models=_catalog()) as client:
chat = ChatAPI(client)
wire, provider = await chat._resolve_model(
"google-gemini-3.1-pro-preview", provider=None
)
assert (wire, provider) == ("gemini-3.1-pro-preview", "google")
async def test_invalidate_models_cache_refetches(self) -> None:
call_count = {"n": 0}
def _cb(url: Any, **kwargs: Any) -> Any:
call_count["n"] += 1
from aioresponses import CallbackResult
return CallbackResult(status=200, payload=CATALOG_PAYLOAD)
with aioresponses() as mocked:
mocked.get(
"https://backend.raycast.com/api/v1/ai/models",
callback=_cb,
repeat=True,
)
async with _client() as client:
chat = ChatAPI(client)
await chat._resolve_model(
"google-gemini-3.1-pro-preview", provider=None
)
client.invalidate_models_cache()
await chat._resolve_model(
"google-gemini-3.1-pro-preview", provider=None
)
assert call_count["n"] == 2
+415
View File
@@ -0,0 +1,415 @@
"""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"
+256
View File
@@ -0,0 +1,256 @@
"""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
+74
View File
@@ -0,0 +1,74 @@
"""Tests for `raycast_api.discovery.extractors`."""
from __future__ import annotations
from pathlib import Path
import pytest
from raycast_api.discovery.extractors import (
extract_signing_spec,
extract_user_agent_template,
read_app_version,
)
from raycast_api.errors import DiscoveryError
def test_extract_signing_spec_from_synthetic(mock_bundle_source: str) -> None:
spec = extract_signing_spec(mock_bundle_source)
assert spec.rot_fn_name in {"RotXform", "RotXform2"}
assert spec.signing_fn_name == "SignReq"
assert spec.join_char == "."
assert spec.body_hash_algorithm == "SHA-256"
assert spec.hmac_algorithm == "SHA-256"
assert spec.key_encoding == "utf-8"
def test_rot_ranges_extracted(mock_bundle_source: str) -> None:
spec = extract_signing_spec(mock_bundle_source)
ranges = {(r.start, r.end, r.shift) for r in spec.rot_ranges}
assert ranges == {(65, 90, 13), (97, 122, 13), (48, 57, 5)}
def test_missing_rot_raises() -> None:
src = """
async function loneSigner(a, b, c, d) {
let k = await crypto.subtle.importKey("raw", new TextEncoder().encode(a), {name:"HMAC", hash:"SHA-256"}, false, ["sign"]);
return crypto.subtle.sign("HMAC", k, b);
}
"""
with pytest.raises(DiscoveryError, match="rot13"):
extract_signing_spec(src)
def test_signer_must_reference_rot() -> None:
"""If the only signing candidate doesn't .map() through the rot fn, fail loudly."""
src = """
function rotFn(s) {
let out = ""; for (let ch of s) {
let r = ch.charCodeAt(0);
if (r >= 65 && r <= 90) out += String.fromCharCode((r - 65 + 13) % 26 + 65);
else if (r >= 97 && r <= 122) out += String.fromCharCode((r - 97 + 13) % 26 + 97);
else if (r >= 48 && r <= 57) out += String.fromCharCode((r - 48 + 5) % 10 + 48);
else out += ch;
}
return out;
}
async function lone(a, b, c, d) {
// 4 params, HMAC/SHA-256/importKey — but doesn't call .map(rotFn).
let k = await crypto.subtle.importKey("raw", new TextEncoder().encode(a),
{name:"HMAC", hash:"SHA-256"}, false, ["sign"]);
return crypto.subtle.sign("HMAC", k, b);
}
"""
with pytest.raises(DiscoveryError, match=r"none of the signers calls\s+\.map"):
extract_signing_spec(src)
def test_read_app_version(mock_app: Path) -> None:
assert read_app_version(mock_app) == "9.9.9.0"
def test_user_agent_template(mock_app: Path) -> None:
ua = extract_user_agent_template(mock_app, platform_version="13.5")
assert ua == "Raycast/9.9.9.0 (x-macOS Version 13.5)"
+182
View File
@@ -0,0 +1,182 @@
"""Tests for `raycast_api.ai.files.FilesAPI`.
Three endpoints with three different signing quirks:
- POST /ai/files — normal signed JSON body, then unsigned PUT to a
presigned URL on a different host.
- GET /ai/files/{id} — signed body is the LITERAL two-byte string
`"{}"` (not empty!). This is the one trap callers must not break.
- DELETE /ai/files — signed JSON body sent ON a DELETE request.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
from typing import Any
import pytest
from aioresponses import aioresponses
from raycast_api.client import Client
from raycast_api.config import Config
from raycast_api.signing import Signer
from raycast_api.signing_spec import RotRange, SigningSpec
REFERENCE_SECRET = "6bc455473576ce2cd6f70426caff867aabbe3f7291c1a79681af5e8ce0ca1408"
DEVICE_ID = "20eca913cada74f879e6535304f9d44da380c28eb855065c0d71017a3d7c3099"
FIXED_TIMESTAMP = 1778858809
def _config() -> Config:
return Config(
signature_secret=REFERENCE_SECRET,
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="0" * 64,
launcher_hash="0" * 64,
)
def _client() -> Client:
return Client(
config=_config(),
bearer_token="rca_test",
device_id=DEVICE_ID,
clock=lambda: FIXED_TIMESTAMP,
)
class TestUpload:
@pytest.mark.asyncio
async def test_upload_registers_then_puts(self, tmp_path: Path) -> None:
f = tmp_path / "hello.txt"
f.write_bytes(b"hi there")
expected_checksum = hashlib.sha256(b"hi there").hexdigest()
captured: dict[str, Any] = {}
def _register_cb(url: Any, **kwargs: Any) -> Any:
captured["register_data"] = kwargs.get("data")
captured["register_headers"] = dict(kwargs.get("headers") or {})
from aioresponses import CallbackResult
return CallbackResult(
status=200,
payload={
"id": "file_abc123",
"direct_upload": {
"url": "https://blobs.example.com/u/abc",
"headers": {"X-Upload-Token": "deadbeef"},
},
},
)
def _put_cb(url: Any, **kwargs: Any) -> Any:
captured["put_data"] = kwargs.get("data")
captured["put_headers"] = dict(kwargs.get("headers") or {})
from aioresponses import CallbackResult
return CallbackResult(status=200)
with aioresponses() as mocked:
mocked.post(
"https://backend.raycast.com/api/v1/ai/files", callback=_register_cb
)
mocked.put("https://blobs.example.com/u/abc", callback=_put_cb)
async with _client() as client:
meta = await client.files.upload(path=f, chat_id="chat_1")
assert meta.file_id == "file_abc123"
assert meta.checksum == expected_checksum
body = captured["register_data"]
if hasattr(body, "_value"):
body = body._value
import json as _json
parsed = _json.loads(bytes(body))
assert parsed == {
"chat_id": "chat_1",
"blob": {
"filename": "hello.txt",
"byte_size": 8,
"content_type": "text/plain",
"checksum": expected_checksum,
},
}
assert "X-Raycast-Signature-v2" in captured["register_headers"]
assert "X-Raycast-Signature-v2" not in captured["put_headers"]
assert "Authorization" not in captured["put_headers"]
assert captured["put_headers"]["X-Upload-Token"] == "deadbeef"
assert bytes(captured["put_data"]) == b"hi there"
class TestGetSignsBraceBody:
@pytest.mark.asyncio
async def test_get_signs_two_byte_brace_body(self) -> None:
"""⚠ `GET /ai/files/{id}` signs the literal string `"{}"`, not `""`.
This is the BUNDLE_NOTES "surprise" — `uV` differs from the resume
GET. Server validates against the bytes the client claims to have
sent, so we must match.
"""
captured: dict[str, Any] = {}
def _cb(url: Any, **kwargs: Any) -> Any:
captured["headers"] = dict(kwargs.get("headers") or {})
captured["data"] = kwargs.get("data")
from aioresponses import CallbackResult
return CallbackResult(status=200, body=b"file-contents")
with aioresponses() as mocked:
mocked.get("https://backend.raycast.com/api/v1/ai/files/F1", callback=_cb)
async with _client() as client:
data = await client.files.get("F1")
assert data == b"file-contents"
wire_body = captured["data"]
if hasattr(wire_body, "_value"):
wire_body = wire_body._value
assert bytes(wire_body) == b"{}"
signer = Signer(spec=_config().signing_spec, secret=REFERENCE_SECRET)
expected = signer.sign(
timestamp=str(FIXED_TIMESTAMP), device_id=DEVICE_ID, body=b"{}"
)
assert captured["headers"]["X-Raycast-Signature-v2"] == expected
class TestDelete:
@pytest.mark.asyncio
async def test_delete_with_chat_ids_body(self) -> None:
captured: dict[str, Any] = {}
def _cb(url: Any, **kwargs: Any) -> Any:
captured["headers"] = dict(kwargs.get("headers") or {})
captured["data"] = kwargs.get("data")
from aioresponses import CallbackResult
return CallbackResult(status=204)
with aioresponses() as mocked:
mocked.delete("https://backend.raycast.com/api/v1/ai/files", callback=_cb)
async with _client() as client:
await client.files.delete(chat_ids=["c1", "c2"])
body = captured["data"]
if hasattr(body, "_value"):
body = body._value
assert bytes(body) == b'{"chat_ids":["c1","c2"]}'
assert "X-Raycast-Signature-v2" in captured["headers"]
assert captured["headers"]["Content-Type"] == "application/json"
+94
View File
@@ -0,0 +1,94 @@
"""Tests for raycast_api.signing.hmac."""
from __future__ import annotations
import hashlib
import hmac as _hmac
import pytest
from raycast_api.signing.hmac import HMACSigner, encode_key, encode_output, hash_body
FAKE_SECRET = "DEAD" + "BEEF" * 15
class TestEncodeKey:
def test_utf8_passes_secret_through_as_bytes(self) -> None:
assert encode_key(FAKE_SECRET, "utf-8") == FAKE_SECRET.encode("utf-8")
def test_hex_decoding_supported_for_other_specs(self) -> None:
assert encode_key("deadbeef", "hex") == bytes.fromhex("deadbeef")
def test_unsupported_encoding_raises(self) -> None:
with pytest.raises(ValueError):
encode_key(FAKE_SECRET, "rot13")
class TestEncodeOutput:
def test_hex_lower(self) -> None:
assert encode_output(b"\xde\xad\xbe\xef", "hex-lower") == "deadbeef"
def test_hex_upper(self) -> None:
assert encode_output(b"\xde\xad\xbe\xef", "hex-upper") == "DEADBEEF"
def test_base64(self) -> None:
assert encode_output(b"\x00\x01\x02\x03", "base64") == "AAECAw=="
def test_unsupported_encoding_raises(self) -> None:
with pytest.raises(ValueError):
encode_output(b"abc", "rot13")
class TestHashBody:
def test_sha256_lowercase_hex(self) -> None:
assert hash_body(b"hello", "SHA-256") == hashlib.sha256(b"hello").hexdigest()
def test_empty_body_hash(self) -> None:
assert hash_body(b"", "SHA-256") == hashlib.sha256(b"").hexdigest()
def test_bad_algorithm(self) -> None:
with pytest.raises(ValueError):
hash_body(b"x", "MD-not-a-thing")
class TestHMACSigner:
def test_known_vector(self) -> None:
signer = HMACSigner(
FAKE_SECRET,
algorithm="SHA-256",
key_encoding="utf-8",
output_encoding="hex-lower",
)
message = b"some.canonical.string"
expected = _hmac.new(
FAKE_SECRET.encode("utf-8"), message, hashlib.sha256
).hexdigest()
assert signer.sign(message) == expected
def test_signer_is_reusable(self) -> None:
signer = HMACSigner(
FAKE_SECRET,
algorithm="SHA-256",
key_encoding="utf-8",
output_encoding="hex-lower",
)
a = signer.sign(b"first message")
b = signer.sign(b"second message")
assert a != b
assert signer.sign(b"first message") == a
def test_algorithm_name_is_normalised(self) -> None:
a = HMACSigner(
FAKE_SECRET,
algorithm="SHA-256",
key_encoding="utf-8",
output_encoding="hex-lower",
)
b = HMACSigner(
FAKE_SECRET,
algorithm="sha256",
key_encoding="utf-8",
output_encoding="hex-lower",
)
assert a.sign(b"x") == b.sign(b"x")
+555
View File
@@ -0,0 +1,555 @@
"""HTTP client tests using `aioresponses` to mock aiohttp.
What's exercised here, in roughly increasing scope:
- Header construction, including signed-vs-unsigned, resume mode, content-type
omission, browser-fluff toggle.
- URL composition (relative path → `Config.backend_url` + path).
- End-to-end request flow against a mocked endpoint, including JSON body
serialisation byte-equal to the bytes we sign.
- Error mapping (401 → AuthError, 429 → RateLimitError, 5xx → HTTPStatusError).
- Retry behaviour: retries on 429/5xx with re-signing (fresh timestamp each
attempt), no retry on 4xx, respect for `Retry-After`.
- Streaming: synthetic SSE bytes parsed into `SSEEvent`s, error events,
no-retry-on-2xx.
Tests use a `Config` built in-memory (no discovery) with a deterministic
reference secret so signatures are reproducible across runs.
"""
from __future__ import annotations
import re
from typing import Any
import aiohttp
import pytest
from aioresponses import aioresponses
from raycast_api.client import Client, RetryPolicy
from raycast_api.client.streaming import SSEEvent
from raycast_api.config import Config
from raycast_api.errors import (
AuthError,
HTTPStatusError,
RateLimitError,
StreamError,
TransportError,
)
from raycast_api.signing_spec import RotRange, SigningSpec
REFERENCE_SECRET = "6bc455473576ce2cd6f70426caff867aabbe3f7291c1a79681af5e8ce0ca1408"
DEVICE_ID = "20eca913cada74f879e6535304f9d44da380c28eb855065c0d71017a3d7c3099"
FIXED_TIMESTAMP = 1778858809
def _make_config() -> Config:
"""Synthetic config with the real-world signing spec and reference secret.
The secret comes from `sign.py` (public reference value); the spec mirrors
what Phase 2's discovery produces for production Raycast Beta. Bundle /
launcher hashes are placeholder zeros — they're not exercised here.
"""
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),
],
)
return Config(
signature_secret=REFERENCE_SECRET,
signing_spec=spec,
app_version="0.60.1.0",
user_agent="Raycast/0.60.1.0 (x-macOS Version 26.3.1)",
bundle_hash="0" * 64,
launcher_hash="0" * 64,
)
def _make_client(**kwargs: Any) -> Client:
"""Default client with a fixed clock so signatures are reproducible."""
return Client(
config=_make_config(),
bearer_token="rca_test_token",
device_id=DEVICE_ID,
clock=lambda: FIXED_TIMESTAMP,
**kwargs,
)
class TestHeaderBuilding:
def test_signed_post_full_header_set(self) -> None:
client = _make_client()
body = b'{"hello":"world"}'
headers = client.build_headers(
sign=True, body=body, content_type="application/json"
)
assert headers["X-Raycast-Timestamp"] == str(FIXED_TIMESTAMP)
assert headers["X-Raycast-DeviceId"] == DEVICE_ID
assert headers["X-Raycast-Experimental"] == "autoModels"
assert "X-Raycast-Signature-v2" in headers
assert headers["Content-Type"] == "application/json"
assert headers["Authorization"] == "Bearer rca_test_token"
assert headers["User-Agent"].startswith("Raycast/")
def test_signed_resume_get_omits_content_type(self) -> None:
client = _make_client()
headers = client.build_headers(
sign=True,
body=b"",
is_resume=True,
last_event_id="42",
content_type="application/json",
)
assert "Content-Type" not in headers
assert headers["Last-Event-ID"] == "42"
assert "X-Raycast-Signature-v2" in headers
def test_unsigned_omits_raycast_headers(self) -> None:
client = _make_client()
headers = client.build_headers(sign=False, body=b"")
assert "X-Raycast-Signature-v2" not in headers
assert "X-Raycast-Timestamp" not in headers
assert "X-Raycast-DeviceId" not in headers
assert "X-Raycast-Experimental" not in headers
assert "Authorization" in headers
assert "User-Agent" in headers
def test_browser_headers_present_by_default(self) -> None:
client = _make_client()
headers = client.build_headers(sign=True, body=b"")
assert headers["Accept"] == "*/*"
assert headers["Origin"] == "file://"
assert headers["Sec-Fetch-Site"] == "cross-site"
assert headers["Sec-Fetch-Mode"] == "cors"
assert headers["Sec-Fetch-Dest"] == "empty"
assert headers["Accept-Language"] == "en-US"
def test_browser_headers_can_be_disabled(self) -> None:
client = _make_client(browser_headers=False, locale="de-DE")
headers = client.build_headers(sign=True, body=b"")
assert "Accept" not in headers
assert "Origin" not in headers
assert "Accept-Language" not in headers
def test_locale_drives_accept_language(self) -> None:
client = _make_client(locale="ru-RU")
headers = client.build_headers(sign=True, body=b"")
assert headers["Accept-Language"] == "ru-RU"
def test_empty_bearer_omits_authorization(self) -> None:
client = Client(
config=_make_config(),
bearer_token="",
device_id=DEVICE_ID,
clock=lambda: FIXED_TIMESTAMP,
)
headers = client.build_headers(sign=False, body=b"")
assert "Authorization" not in headers
def test_signature_matches_reference_signer(self) -> None:
"""The header builder uses the same Signer as `sign.py`, so byte-match."""
from raycast_api.signing import Signer
client = _make_client()
signer = Signer(spec=_make_config().signing_spec, secret=REFERENCE_SECRET)
body = b'{"buffer_id":"00000000-0000-0000-0000-000000000000"}'
headers = client.build_headers(
sign=True, body=body, content_type="application/json"
)
expected = signer.sign(
timestamp=str(FIXED_TIMESTAMP), device_id=DEVICE_ID, body=body
)
assert headers["X-Raycast-Signature-v2"] == expected
def test_extra_headers_merged_last(self) -> None:
client = _make_client()
headers = client.build_headers(
sign=True, body=b"", extra={"X-Trace-Id": "abc", "User-Agent": "Overridden"}
)
assert headers["X-Trace-Id"] == "abc"
assert headers["User-Agent"] == "Overridden"
class TestUrl:
def test_relative_path(self) -> None:
client = _make_client()
assert client._url("/api/v1/me") == "https://backend.raycast.com/api/v1/me"
def test_path_without_leading_slash(self) -> None:
client = _make_client()
assert client._url("api/v1/me") == "https://backend.raycast.com/api/v1/me"
def test_absolute_url_passthrough(self) -> None:
client = _make_client()
assert (
client._url("https://other.example.com/x") == "https://other.example.com/x"
)
def test_custom_backend_url(self) -> None:
cfg = _make_config()
cfg.backend_url = "http://localhost:5001"
client = Client(
config=cfg, bearer_token="x", device_id=DEVICE_ID, clock=lambda: 0
)
assert client._url("/api/v1/me") == "http://localhost:5001/api/v1/me"
URL_ME = "https://backend.raycast.com/api/v1/me"
URL_CHAT = "https://backend.raycast.com/api/v1/ai/chat_completions"
URL_FILES = "https://backend.raycast.com/api/v1/ai/files"
@pytest.fixture
def mock_aiohttp():
with aioresponses() as m:
yield m
class TestRequestHappyPath:
async def test_unsigned_get_returns_response(
self, mock_aiohttp: aioresponses
) -> None:
mock_aiohttp.get(URL_ME, status=200, payload={"id": "user_1"})
async with _make_client() as client:
async with client.request("GET", "/api/v1/me", sign=False) as resp:
data = await resp.json()
assert data == {"id": "user_1"}
async def test_signed_post_with_json_body(self, mock_aiohttp: aioresponses) -> None:
captured: dict[str, Any] = {}
def callback(url: object, **kwargs: Any) -> Any:
from aioresponses import CallbackResult
captured["headers"] = dict(kwargs["headers"])
captured["body"] = kwargs.get("data")
return CallbackResult(status=200, payload={"ok": True})
mock_aiohttp.post(URL_CHAT, callback=callback)
async with _make_client() as client:
async with client.request(
"POST",
"/api/v1/ai/chat_completions",
json_body={"messages": [{"role": "user", "content": {"text": "hi"}}]},
) as resp:
assert resp.status == 200
assert "X-Raycast-Signature-v2" in captured["headers"]
assert captured["headers"]["X-Raycast-Timestamp"] == str(FIXED_TIMESTAMP)
assert captured["headers"]["X-Raycast-DeviceId"] == DEVICE_ID
assert captured["headers"]["Content-Type"] == "application/json"
body = captured["body"]
assert isinstance(body, (bytes, bytearray))
assert b'"messages"' in body
assert b": " not in body
async def test_delete_with_body(self, mock_aiohttp: aioresponses) -> None:
captured: dict[str, Any] = {}
def callback(url: object, **kwargs: Any) -> Any:
from aioresponses import CallbackResult
captured["body"] = kwargs.get("data")
return CallbackResult(status=204)
mock_aiohttp.delete(URL_FILES, callback=callback)
async with _make_client() as client:
async with client.request(
"DELETE", "/api/v1/ai/files", json_body={"chat_ids": ["abc"]}
) as resp:
assert resp.status == 204
assert captured["body"] == b'{"chat_ids":["abc"]}'
async def test_resume_get_omits_content_type_in_request(
self, mock_aiohttp: aioresponses
) -> None:
captured: dict[str, Any] = {}
def callback(url: object, **kwargs: Any) -> Any:
from aioresponses import CallbackResult
captured["headers"] = dict(kwargs["headers"])
return CallbackResult(status=200, body=b"")
url_resume_re = re.compile(
r"^https://backend\.raycast\.com/api/v1/ai/chat_completions/resume.*$"
)
mock_aiohttp.get(url_resume_re, callback=callback)
async with _make_client() as client:
async with client.request(
"GET",
"/api/v1/ai/chat_completions/resume",
sign=True,
is_resume=True,
last_event_id="9",
params={"buffer_id": "abc"},
):
pass
assert "Content-Type" not in captured["headers"]
assert captured["headers"]["Last-Event-ID"] == "9"
assert "X-Raycast-Signature-v2" in captured["headers"]
async def test_body_passthrough_str(self, mock_aiohttp: aioresponses) -> None:
captured: dict[str, Any] = {}
def callback(url: object, **kwargs: Any) -> Any:
from aioresponses import CallbackResult
captured["body"] = kwargs.get("data")
return CallbackResult(status=200, body=b"")
mock_aiohttp.post(URL_FILES, callback=callback)
async with _make_client() as client:
async with client.request("POST", "/api/v1/ai/files", body='{"raw":1}'):
pass
assert captured["body"] == b'{"raw":1}'
async def test_body_passthrough_bytes(self, mock_aiohttp: aioresponses) -> None:
captured: dict[str, Any] = {}
def callback(url: object, **kwargs: Any) -> Any:
from aioresponses import CallbackResult
captured["body"] = kwargs.get("data")
return CallbackResult(status=200, body=b"")
mock_aiohttp.post(URL_FILES, callback=callback)
async with _make_client() as client:
async with client.request("POST", "/api/v1/ai/files", body=b"\x00raw"):
pass
assert captured["body"] == b"\x00raw"
async def test_both_body_and_json_rejected(self) -> None:
async with _make_client() as client:
with pytest.raises(ValueError, match="not both"):
async with client.request(
"POST", "/api/v1/ai/files", body=b"x", json_body={"a": 1}
):
pass
async def test_external_session_not_closed(self) -> None:
external = aiohttp.ClientSession()
try:
client = Client(
config=_make_config(),
bearer_token="t",
device_id=DEVICE_ID,
session=external,
clock=lambda: 0,
)
await client.close()
assert not external.closed
finally:
await external.close()
class TestErrorMapping:
async def test_401_becomes_auth_error(self, mock_aiohttp: aioresponses) -> None:
mock_aiohttp.get(URL_ME, status=401, body=b"unauthorized")
async with _make_client(retry=RetryPolicy(max_attempts=1)) as client:
with pytest.raises(AuthError) as ei:
async with client.request("GET", "/api/v1/me", sign=False):
pass
assert ei.value.status == 401
assert ei.value.body == "unauthorized"
async def test_429_becomes_rate_limit_error(
self, mock_aiohttp: aioresponses
) -> None:
mock_aiohttp.get(
URL_ME, status=429, headers={"Retry-After": "12"}, body=b"slow down"
)
async with _make_client(retry=RetryPolicy(max_attempts=1)) as client:
with pytest.raises(RateLimitError) as ei:
async with client.request("GET", "/api/v1/me", sign=False):
pass
assert ei.value.status == 429
assert ei.value.retry_after == 12.0
async def test_500_becomes_status_error(self, mock_aiohttp: aioresponses) -> None:
mock_aiohttp.get(URL_ME, status=500, body=b"oops")
async with _make_client(retry=RetryPolicy(max_attempts=1)) as client:
with pytest.raises(HTTPStatusError) as ei:
async with client.request("GET", "/api/v1/me", sign=False):
pass
assert ei.value.status == 500
assert not isinstance(ei.value, (AuthError, RateLimitError))
async def test_404_not_retried(self, mock_aiohttp: aioresponses) -> None:
mock_aiohttp.get(URL_ME, status=404)
async with _make_client() as client:
with pytest.raises(HTTPStatusError) as ei:
async with client.request("GET", "/api/v1/me", sign=False):
pass
assert ei.value.status == 404
class TestRetry:
async def test_retries_503_then_succeeds(self, mock_aiohttp: aioresponses) -> None:
sleeps: list[float] = []
async def fake_sleep(d: float) -> None:
sleeps.append(d)
mock_aiohttp.get(URL_ME, status=503)
mock_aiohttp.get(URL_ME, status=200, payload={"ok": True})
client = Client(
config=_make_config(),
bearer_token="t",
device_id=DEVICE_ID,
clock=lambda: 0,
sleep=fake_sleep,
retry=RetryPolicy(max_attempts=3, initial_delay=0.1, max_delay=1.0),
)
async with client:
async with client.request("GET", "/api/v1/me", sign=False) as resp:
data = await resp.json()
assert data == {"ok": True}
assert sleeps == [0.1]
async def test_respects_retry_after_on_429(
self, mock_aiohttp: aioresponses
) -> None:
sleeps: list[float] = []
async def fake_sleep(d: float) -> None:
sleeps.append(d)
mock_aiohttp.get(URL_ME, status=429, headers={"Retry-After": "2"})
mock_aiohttp.get(URL_ME, status=200, payload={"ok": True})
client = Client(
config=_make_config(),
bearer_token="t",
device_id=DEVICE_ID,
clock=lambda: 0,
sleep=fake_sleep,
retry=RetryPolicy(max_attempts=3, initial_delay=0.5, max_delay=10),
)
async with client:
async with client.request("GET", "/api/v1/me", sign=False):
pass
assert sleeps == [2.0]
async def test_gives_up_after_max_attempts(
self, mock_aiohttp: aioresponses
) -> None:
async def fake_sleep(_d: float) -> None:
pass
for _ in range(4):
mock_aiohttp.get(URL_ME, status=503)
client = Client(
config=_make_config(),
bearer_token="t",
device_id=DEVICE_ID,
clock=lambda: 0,
sleep=fake_sleep,
retry=RetryPolicy(max_attempts=3, initial_delay=0.01),
)
async with client:
with pytest.raises(HTTPStatusError) as ei:
async with client.request("GET", "/api/v1/me", sign=False):
pass
assert ei.value.status == 503
async def test_resigns_on_retry(self, mock_aiohttp: aioresponses) -> None:
"""Each attempt re-signs with a fresh timestamp, not the original one."""
clock = iter([1000, 1001])
captured: list[dict[str, str]] = []
def cb(url: object, **kwargs: Any) -> Any:
from aioresponses import CallbackResult
captured.append(dict(kwargs["headers"]))
status = 503 if len(captured) == 1 else 200
return CallbackResult(status=status, payload={})
mock_aiohttp.post(URL_CHAT, callback=cb)
mock_aiohttp.post(URL_CHAT, callback=cb)
async def fake_sleep(_d: float) -> None:
pass
client = Client(
config=_make_config(),
bearer_token="t",
device_id=DEVICE_ID,
clock=lambda: next(clock),
sleep=fake_sleep,
retry=RetryPolicy(max_attempts=2, initial_delay=0.01),
)
async with client:
async with client.request(
"POST", "/api/v1/ai/chat_completions", json_body={"x": 1}
):
pass
assert captured[0]["X-Raycast-Timestamp"] == "1000"
assert captured[1]["X-Raycast-Timestamp"] == "1001"
assert (
captured[0]["X-Raycast-Signature-v2"]
!= captured[1]["X-Raycast-Signature-v2"]
)
class TestStreaming:
async def test_error_event_raises(self, mock_aiohttp: aioresponses) -> None:
body = b'event: error\ndata: {"message":"boom"}\n\n'
mock_aiohttp.post(URL_CHAT, status=200, body=body)
async with _make_client() as client:
events: list[SSEEvent] = []
with pytest.raises(StreamError) as ei:
async for evt in client.stream(
"POST", "/api/v1/ai/chat_completions", json_body={"x": 1}
):
events.append(evt)
assert ei.value.payload == {"message": "boom"}
assert len(events) == 1
assert events[0].is_error
async def test_stream_does_not_retry_on_2xx(
self, mock_aiohttp: aioresponses
) -> None:
body = b'data: a\n\nevent: complete\ndata: {"complete":true}\n\n'
mock_aiohttp.post(URL_CHAT, status=200, body=body)
async with _make_client() as client:
events = [e async for e in client.stream("POST", URL_CHAT, json_body={})]
assert [e.data for e in events] == ["a", '{"complete":true}']
class TestSessionLifecycle:
async def test_request_without_session_raises_clear_error(self) -> None:
client = Client(
config=_make_config(),
bearer_token="t",
device_id=DEVICE_ID,
clock=lambda: 0,
)
with pytest.raises(RuntimeError, match="not initialised"):
async with client.request("GET", "/api/v1/me", sign=False):
pass
async def test_owned_session_closed_on_exit(self) -> None:
client = _make_client()
async with client:
assert client._session is not None and not client._session.closed
assert client._session is None
+79
View File
@@ -0,0 +1,79 @@
"""Integration tests against the real Raycast.app, if present.
These tests are gated on `RAYCAST_APP_PATH` (or, as a developer convenience,
the `Raycast Beta.app` sitting next to the repo). They prove that discovery
gives us the same signing_secret HANDOFF.md documents — i.e. that the AST
patterns we match against survive on the actual minified bundle, not just the
synthetic fixture.
Skipped automatically if no app is found, so CI without an .app stays green.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from raycast_api.config import Config
EXPECTED_SECRET = "6bc455473576ce2cd6f70426caff867aabbe3f7291c1a79681af5e8ce0ca1408"
def _app_path() -> Path | None:
env = os.environ.get("RAYCAST_APP_PATH")
candidates: list[Path] = []
if env:
candidates.append(Path(env))
candidates.extend(
[
Path(__file__).resolve().parents[2] / "Raycast Beta.app",
Path("/Applications/Raycast Beta.app"),
Path("/Applications/Raycast.app"),
]
)
for p in candidates:
if p.is_dir():
return p
return None
pytestmark = pytest.mark.local_app
@pytest.fixture
def real_app() -> Path:
app = _app_path()
if app is None:
pytest.skip("No local Raycast.app found (set RAYCAST_APP_PATH to override)")
raise AssertionError("unreachable")
return app
def test_discover_secret(real_app: Path, isolated_cache: Path) -> None:
cfg = Config.discover_from_app(real_app)
assert len(cfg.signature_secret) == 64
assert all(c in "0123456789abcdef" for c in cfg.signature_secret)
if cfg.signature_secret != EXPECTED_SECRET:
pytest.skip(
f"Local Raycast secret ({cfg.signature_secret[-8:]}) doesn't match HANDOFF "
f"({EXPECTED_SECRET[-8:]}) — likely a different Raycast version. Discovery shape OK."
)
def test_discover_signing_spec_shape(real_app: Path, isolated_cache: Path) -> None:
cfg = Config.discover_from_app(real_app)
spec = cfg.signing_spec
assert spec.join_char == "."
assert spec.body_hash_algorithm == "SHA-256"
assert spec.hmac_algorithm == "SHA-256"
assert spec.key_encoding == "utf-8"
ranges = {(r.start, r.end, r.shift) for r in spec.rot_ranges}
assert ranges == {(65, 90, 13), (97, 122, 13), (48, 57, 5)}
def test_app_version_and_ua(real_app: Path, isolated_cache: Path) -> None:
cfg = Config.discover_from_app(real_app)
assert cfg.app_version
assert cfg.user_agent.startswith(f"Raycast/{cfg.app_version} (x-macOS Version ")
+92
View File
@@ -0,0 +1,92 @@
"""Live integration tests against `backend.raycast.com`.
Opt-in: gated on the `--live` pytest flag AND on env vars supplying real
credentials. Without `--live` these are all skipped by the
`pytest_collection_modifyitems` hook in `conftest.py`.
Required env vars when running with `--live`:
RAYCAST_BEARER — a valid OAuth bearer token (rca_...)
RAYCAST_DEVICE_ID — a stable 64-hex device id
Optional:
RAYCAST_CONFIG — path to a `config.json` produced by `raycast-api init`.
Defaults to `./config.json` (repo-root one shipped with
this checkout).
Why these tests exist: the synthetic-fixture suite proves the signing math
is internally consistent. Only a real round-trip proves the bytes we send
on the wire actually match what the server expects — i.e. catches the
"refactor in `transforms.py` broke prod" class of regression.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from raycast_api.ai.types import Message
from raycast_api.client.http import Client
from raycast_api.config import Config
pytestmark = pytest.mark.live
def _bearer() -> str:
val = os.environ.get("RAYCAST_BEARER")
if not val:
pytest.skip("RAYCAST_BEARER not set")
return val
def _device_id() -> str:
val = os.environ.get("RAYCAST_DEVICE_ID")
if not val:
pytest.skip("RAYCAST_DEVICE_ID not set")
return val
def _config() -> Config:
path = Path(os.environ.get("RAYCAST_CONFIG", "config.json")).expanduser()
if not path.is_file():
pytest.skip(f"no config at {path} — run `raycast-api init` first")
return Config.load(path)
@pytest.fixture
def client() -> Client:
return Client(config=_config(), bearer_token=_bearer(), device_id=_device_id())
async def test_me(client: Client) -> None:
"""Bearer-token probe. Unsigned — proves the token is alive."""
async with client:
me = await client.me.get()
assert isinstance(me, dict)
assert me, "empty /me response — token likely invalid"
async def test_models_list(client: Client) -> None:
"""Lists `/ai/models`. Unsigned, but exercises auth + JSON decoding."""
async with client:
models = await client.models.list()
assert models.models, "no models returned"
async def test_chat_streaming(client: Client) -> None:
"""One-token chat completion. Proves signing works end-to-end.
Picks the first model from the live catalog so the test doesn't pin
a specific provider. Prompt is intentionally trivial to minimize cost.
"""
async with client:
catalog = await client.models.list()
model = catalog.models[0]
chunks: list[str] = []
async for chunk in client.chat.stream(
model=model, messages=[Message.user("Reply with the single word: OK")]
):
if chunk.text:
chunks.append(chunk.text)
assert chunks, "no text chunks streamed — signing or stream parsing broke"
+67
View File
@@ -0,0 +1,67 @@
"""Tests for `raycast_api.ai.me.MeAPI`.
Trivial endpoint — verify the unsigned GET goes to the right URL and the
response is returned as a raw dict.
"""
from __future__ import annotations
from typing import Any
import pytest
from aioresponses import aioresponses
from raycast_api.client import Client
from raycast_api.config import Config
from raycast_api.signing_spec import RotRange, SigningSpec
def _config() -> Config:
return Config(
signature_secret="0" * 64,
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="0" * 64,
launcher_hash="0" * 64,
)
class TestMeAPI:
@pytest.mark.asyncio
async def test_get_returns_dict_and_does_not_sign(self) -> None:
captured: dict[str, Any] = {}
def _cb(url: Any, **kwargs: Any) -> Any:
captured["headers"] = dict(kwargs.get("headers") or {})
from aioresponses import CallbackResult
return CallbackResult(
status=200,
payload={
"id": "u_1",
"email": "alice@example.com",
"has_pro_features": True,
},
)
with aioresponses() as mocked:
mocked.get("https://backend.raycast.com/api/v1/me", callback=_cb)
client = Client(
config=_config(), bearer_token="rca_test", device_id="a" * 64
)
async with client:
me = await client.me.get()
assert me["email"] == "alice@example.com"
assert me["has_pro_features"] is True
assert "X-Raycast-Signature-v2" not in captured["headers"]
assert captured["headers"]["Authorization"] == "Bearer rca_test"
+153
View File
@@ -0,0 +1,153 @@
"""Tests for `raycast_api.ai.models.ModelsAPI` and the typed response.
The `/ai/models` endpoint returns a large payload; we only exercise the
shape parsing (`ModelsResponse.from_wire`) and the `by_id` lookup plus
the header that distinguishes our request from older Raycast clients
(`X-Raycast-Experimental: autoModels`).
"""
from __future__ import annotations
from typing import Any
import pytest
from aioresponses import aioresponses
from raycast_api.ai.models import ModelInfo, ModelsResponse
from raycast_api.client import Client
from raycast_api.config import Config
from raycast_api.signing_spec import RotRange, SigningSpec
def _config() -> Config:
return Config(
signature_secret="0" * 64,
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="0" * 64,
launcher_hash="0" * 64,
)
def _client(**kwargs: Any) -> Client:
return Client(
config=_config(),
bearer_token="rca_test",
device_id="a" * 64,
clock=lambda: 0,
**kwargs,
)
SAMPLE_RESPONSE = {
"models": [
{
"id": "openai-gpt-5",
"name": "GPT-5",
"model": "gpt-5",
"provider": "openai",
"provider_name": "OpenAI",
"provider_brand": "openai",
"context": 200000,
"description": "Most capable.",
"status": "public",
"availability": "public",
"features": ["chat", "quick_ai", "commands"],
"abilities": {
"system_message": {"supported": True},
"temperature": {"supported": True},
"reasoning_effort": {
"supported": True,
"options": ["minimal", "low", "medium", "high"],
},
},
"in_better_ai_subscription": True,
"requires_better_ai": True,
},
{
"id": "anthropic-claude-sonnet-4-6",
"name": "Claude Sonnet 4.6",
"model": "claude-sonnet-4-6",
"provider": "anthropic",
"context": 200000,
"features": ["chat"],
"abilities": {"temperature": {"supported": True}},
"in_better_ai_subscription": True,
},
],
"default_models": {"chat": "openai-gpt-5", "quick_ai": "openai-gpt-5"},
"free_models": ["openai-gpt-4o-mini", "anthropic-claude-haiku-4-5"],
}
class TestModelsResponse:
def test_parses_model_list(self) -> None:
r = ModelsResponse.from_wire(SAMPLE_RESPONSE)
assert len(r.models) == 2
assert r.models[0].id == "openai-gpt-5"
assert r.models[0].provider == "openai"
assert r.default_models == {"chat": "openai-gpt-5", "quick_ai": "openai-gpt-5"}
assert r.free_model_ids == ["openai-gpt-4o-mini", "anthropic-claude-haiku-4-5"]
def test_free_models_tolerates_object_shape(self) -> None:
"""If the server ever switches back to emitting full objects, parse those too."""
r = ModelsResponse.from_wire(
{
"models": [],
"default_models": {},
"free_models": [{"id": "a"}, {"id": "b"}],
}
)
assert r.free_model_ids == ["a", "b"]
def test_by_id_lookup(self) -> None:
r = ModelsResponse.from_wire(SAMPLE_RESPONSE)
info = r.by_id("openai-gpt-5")
assert info is not None
assert info.name == "GPT-5"
assert r.by_id("missing") is None
def test_abilities_helpers(self) -> None:
r = ModelsResponse.from_wire(SAMPLE_RESPONSE)
gpt5 = r.by_id("openai-gpt-5")
assert gpt5 is not None
assert gpt5.supports_temperature is True
assert gpt5.supports_reasoning_effort is True
assert gpt5.reasoning_effort_options == ["minimal", "low", "medium", "high"]
claude = r.by_id("anthropic-claude-sonnet-4-6")
assert claude is not None
assert claude.supports_reasoning_effort is False
assert claude.reasoning_effort_options == []
class TestModelsAPI:
@pytest.mark.asyncio
async def test_list_sends_experimental_header_and_no_signature(self) -> None:
captured: dict[str, Any] = {}
def _cb(url: Any, **kwargs: Any) -> Any:
captured["headers"] = dict(kwargs.get("headers") or {})
from aioresponses import CallbackResult
return CallbackResult(status=200, payload=SAMPLE_RESPONSE)
with aioresponses() as mocked:
mocked.get("https://backend.raycast.com/api/v1/ai/models", callback=_cb)
async with _client() as client:
resp = await client.models.list()
assert resp.by_id("openai-gpt-5") is not None
assert captured["headers"]["X-Raycast-Experimental"] == "autoModels"
assert "X-Raycast-Signature-v2" not in captured["headers"]
assert "X-Raycast-Timestamp" not in captured["headers"]
assert "X-Raycast-DeviceId" not in captured["headers"]
assert captured["headers"]["Authorization"] == "Bearer rca_test"
+135
View File
@@ -0,0 +1,135 @@
"""Retry policy tests."""
from __future__ import annotations
import email.utils
import time
import pytest
from raycast_api.client.retry import (
DEFAULT_RETRY_STATUSES,
RetryPolicy,
parse_retry_after,
)
class TestRetryStatuses:
def test_default_includes_429_and_5xx(self) -> None:
for code in (429, 500, 502, 503, 504):
assert code in DEFAULT_RETRY_STATUSES
def test_default_includes_transient_4xx(self) -> None:
assert 408 in DEFAULT_RETRY_STATUSES
assert 425 in DEFAULT_RETRY_STATUSES
def test_default_excludes_permanent_errors(self) -> None:
for code in (400, 401, 403, 404, 422):
assert code not in DEFAULT_RETRY_STATUSES
class TestShouldRetry:
def test_retries_429(self) -> None:
p = RetryPolicy(max_attempts=3)
assert p.should_retry(attempt=1, status=429) is True
assert p.should_retry(attempt=2, status=429) is True
def test_stops_at_max_attempts(self) -> None:
p = RetryPolicy(max_attempts=3)
assert p.should_retry(attempt=3, status=429) is False
assert p.should_retry(attempt=99, status=503) is False
def test_does_not_retry_4xx_permanent(self) -> None:
p = RetryPolicy()
assert p.should_retry(attempt=1, status=400) is False
assert p.should_retry(attempt=1, status=401) is False
assert p.should_retry(attempt=1, status=404) is False
def test_retries_custom_status_set(self) -> None:
p = RetryPolicy(retry_statuses=frozenset({418}))
assert p.should_retry(attempt=1, status=418) is True
assert p.should_retry(attempt=1, status=503) is False
class TestDelaySchedule:
def test_exponential_backoff(self) -> None:
p = RetryPolicy(initial_delay=1.0, multiplier=2.0, max_delay=60.0)
assert p.delay_for_attempt(1) == 1.0
assert p.delay_for_attempt(2) == 2.0
assert p.delay_for_attempt(3) == 4.0
assert p.delay_for_attempt(4) == 8.0
def test_clamped_to_max_delay(self) -> None:
p = RetryPolicy(initial_delay=10.0, multiplier=10.0, max_delay=20.0)
assert p.delay_for_attempt(1) == 10.0
assert p.delay_for_attempt(2) == 20.0
assert p.delay_for_attempt(5) == 20.0
def test_retry_after_overrides_schedule(self) -> None:
p = RetryPolicy(initial_delay=1.0, max_delay=60.0)
assert p.delay_for_attempt(1, retry_after=7.0) == 7.0
assert p.delay_for_attempt(3, retry_after=2.5) == 2.5
def test_retry_after_clamped_to_max(self) -> None:
p = RetryPolicy(max_delay=10.0)
assert p.delay_for_attempt(1, retry_after=3600.0) == 10.0
def test_retry_after_ignored_when_disabled(self) -> None:
p = RetryPolicy(initial_delay=1.0, respect_retry_after=False)
assert p.delay_for_attempt(1, retry_after=999.0) == 1.0
def test_negative_retry_after_floors_to_zero(self) -> None:
p = RetryPolicy()
assert p.delay_for_attempt(1, retry_after=-5.0) == 0.0
class TestParseRetryAfter:
def test_none_input_returns_none(self) -> None:
assert parse_retry_after(None) is None
assert parse_retry_after("") is None
def test_integer_seconds(self) -> None:
assert parse_retry_after("0") == 0.0
assert parse_retry_after("42") == 42.0
assert parse_retry_after(" 120 ") == 120.0
def test_float_seconds(self) -> None:
assert parse_retry_after("2.5") == 2.5
def test_http_date(self) -> None:
now = 1_700_000_000.0
future = now + 30
date_str = email.utils.format_datetime(
__import__("datetime").datetime.fromtimestamp(
future, tz=__import__("datetime").timezone.utc
)
)
got = parse_retry_after(date_str, now=now)
assert got is not None
assert 29.0 <= got <= 31.0
def test_http_date_in_past_floors_to_zero(self) -> None:
date_str = "Sun, 06 Nov 1994 08:49:37 GMT"
assert parse_retry_after(date_str, now=time.time()) == 0.0
def test_garbage_returns_none(self) -> None:
assert parse_retry_after("not a date") is None
class TestNegativeShift:
def test_zero_attempts_pre_increment_guard(self) -> None:
p = RetryPolicy(max_attempts=1)
assert p.should_retry(attempt=1, status=429) is False
def test_default_policy_total_budget_is_under_30s() -> None:
"""Default config should not park the client for ages on a bad path."""
p = RetryPolicy()
total = sum(p.delay_for_attempt(a) for a in range(1, p.max_attempts))
assert total < 30.0, f"default total backoff = {total}s, too long"
@pytest.mark.parametrize("status", sorted(DEFAULT_RETRY_STATUSES))
def test_each_default_status_retryable_at_attempt_1(status: int) -> None:
p = RetryPolicy(max_attempts=2)
assert p.should_retry(attempt=1, status=status)
+122
View File
@@ -0,0 +1,122 @@
"""End-to-end tests for raycast_api.signing.Signer.
Hand-computed canonical/HMAC values for synthetic specs prove the spec
fields actually drive behaviour (not just defaults).
"""
from __future__ import annotations
import hashlib
import hmac as _hmac
import pytest
from raycast_api.signing import Signer
from raycast_api.signing_spec import RotRange, SigningSpec
RAYCAST_SPEC = SigningSpec(
rot_fn_name="Sur",
signing_fn_name="Nkt",
rot_ranges=[
RotRange(0x41, 0x5A, 13),
RotRange(0x61, 0x7A, 13),
RotRange(0x30, 0x39, 5),
],
)
FAKE_SECRET = "DEAD" + "BEEF" * 15
TS = "1778858809"
DEVICE = "20eca913cada74f879e6535304f9d44da380c28eb855065c0d71017a3d7c3099"
class TestCanonicalString:
"""Canonical string composition matches rot(ts) + join + rot(device) + join + rot(sha256(body))."""
def test_canonical_string_shape(self) -> None:
body = b"hello"
signer = Signer(spec=RAYCAST_SPEC, secret=FAKE_SECRET)
canonical = signer.canonical_string(TS, DEVICE, body)
body_hex = hashlib.sha256(body).hexdigest()
from raycast_api.signing.transforms import apply_rot
expected = ".".join(
apply_rot(p, RAYCAST_SPEC.rot_ranges) for p in (TS, DEVICE, body_hex)
)
assert canonical == expected
class TestSpecDriven:
"""Changing a spec field changes the signature — proves nothing is hardcoded."""
def test_join_char_change_changes_signature(self) -> None:
a = Signer(spec=RAYCAST_SPEC, secret=FAKE_SECRET)
b_spec = SigningSpec(
rot_fn_name="Sur",
signing_fn_name="Nkt",
rot_ranges=RAYCAST_SPEC.rot_ranges,
join_char=":",
)
b = Signer(spec=b_spec, secret=FAKE_SECRET)
assert a.sign(timestamp=TS, device_id=DEVICE, body=b"x") != b.sign(
timestamp=TS, device_id=DEVICE, body=b"x"
)
def test_rot_ranges_change_changes_signature(self) -> None:
a = Signer(spec=RAYCAST_SPEC, secret=FAKE_SECRET)
b_spec = SigningSpec(
rot_fn_name="Sur",
signing_fn_name="Nkt",
rot_ranges=[
RotRange(0x41, 0x5A, 1),
RotRange(0x61, 0x7A, 1),
RotRange(0x30, 0x39, 1),
],
)
b = Signer(spec=b_spec, secret=FAKE_SECRET)
assert a.sign(timestamp=TS, device_id=DEVICE, body=b"x") != b.sign(
timestamp=TS, device_id=DEVICE, body=b"x"
)
def test_secret_change_changes_signature(self) -> None:
a = Signer(spec=RAYCAST_SPEC, secret=FAKE_SECRET)
b = Signer(spec=RAYCAST_SPEC, secret="CAFE" + "BABE" * 15)
assert a.sign(timestamp=TS, device_id=DEVICE, body=b"x") != b.sign(
timestamp=TS, device_id=DEVICE, body=b"x"
)
def test_body_bytes_change_changes_signature(self) -> None:
signer = Signer(spec=RAYCAST_SPEC, secret=FAKE_SECRET)
sig_a = signer.sign(timestamp=TS, device_id=DEVICE, body=b'{"k":1}')
sig_b = signer.sign(timestamp=TS, device_id=DEVICE, body=b'{"k":2}')
assert sig_a != sig_b
def test_timestamp_string_used_verbatim(self) -> None:
signer = Signer(spec=RAYCAST_SPEC, secret=FAKE_SECRET)
a = signer.sign(timestamp="1778858809", device_id=DEVICE, body=b"x")
b = signer.sign(timestamp="01778858809", device_id=DEVICE, body=b"x")
assert a != b
class TestConstruction:
def test_empty_rot_ranges_rejected(self) -> None:
bad = SigningSpec(rot_fn_name="x", signing_fn_name="y", rot_ranges=[])
with pytest.raises(ValueError):
Signer(spec=bad, secret=FAKE_SECRET)
def test_signer_is_reusable_for_concurrent_messages(self) -> None:
signer = Signer(spec=RAYCAST_SPEC, secret=FAKE_SECRET)
outputs = {
signer.sign(timestamp=TS, device_id=DEVICE, body=f"{i}".encode())
for i in range(8)
}
assert len(outputs) == 8
def test_hmac_uses_utf8_encoded_secret_not_hex_decoded(self) -> None:
signer = Signer(spec=RAYCAST_SPEC, secret=FAKE_SECRET)
body = b'{"buffer_id":"x"}'
canonical = signer.canonical_string(TS, DEVICE, body)
expected = _hmac.new(
FAKE_SECRET.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256
).hexdigest()
assert signer.sign(timestamp=TS, device_id=DEVICE, body=body) == expected
+247
View File
@@ -0,0 +1,247 @@
"""SSE parser tests.
Strategy: the parser is byte-driven. Most tests feed bytes in one shot;
the chunking tests feed the same input split at every possible byte
boundary and assert the output is identical.
"""
from __future__ import annotations
import json
from typing import AsyncIterator
import pytest
from raycast_api.client.streaming import SSEEvent, SSEParser, iter_sse
def _drain_bytes(data: bytes) -> list[SSEEvent]:
"""Run the parser over `data` in one feed, then flush."""
p = SSEParser()
out = list(p.feed(data))
out.extend(p.flush())
return out
def _drain_chunked(data: bytes, chunk_size: int) -> list[SSEEvent]:
p = SSEParser()
out: list[SSEEvent] = []
for i in range(0, len(data), chunk_size):
out.extend(p.feed(data[i : i + chunk_size]))
out.extend(p.flush())
return out
class TestBasicShape:
def test_single_event(self) -> None:
events = _drain_bytes(b'data: {"text":"hi"}\n\n')
assert len(events) == 1
evt = events[0]
assert evt.id is None
assert evt.event is None
assert evt.data == '{"text":"hi"}'
assert evt.json() == {"text": "hi"}
def test_id_and_data(self) -> None:
events = _drain_bytes(b'id: 7\ndata: {"text":"hi"}\n\n')
assert len(events) == 1
assert events[0].id == "7"
assert events[0].data == '{"text":"hi"}'
def test_event_field(self) -> None:
events = _drain_bytes(b'event: complete\ndata: {"complete":true}\n\n')
assert events[0].event == "complete"
assert events[0].is_terminal
def test_default_event_is_none(self) -> None:
events = _drain_bytes(b"data: x\n\n")
assert events[0].event is None
def test_done_terminator_legacy(self) -> None:
events = _drain_bytes(b"data: [DONE]\n\n")
assert events[0].is_terminal
assert not events[0].is_error
class TestLineEndings:
def test_crlf_line_endings(self) -> None:
events = _drain_bytes(b'id: 1\r\ndata: {"text":"x"}\r\n\r\n')
assert len(events) == 1
assert events[0].id == "1"
assert events[0].data == '{"text":"x"}'
def test_mixed_endings(self) -> None:
events = _drain_bytes(b'id: 1\ndata: {"text":"x"}\r\n\n')
assert len(events) == 1
assert events[0].id == "1"
def test_lone_cr_is_not_a_terminator(self) -> None:
events = _drain_bytes(b"data: a\rcontinued\n\n")
assert len(events) == 1
assert "\r" in events[0].data
class TestMultilineData:
def test_two_data_lines_joined_with_newline(self) -> None:
events = _drain_bytes(b"data: line1\ndata: line2\n\n")
assert events[0].data == "line1\nline2"
def test_empty_data_line_still_contributes(self) -> None:
events = _drain_bytes(b"data: line1\ndata:\ndata: line3\n\n")
assert events[0].data == "line1\n\nline3"
class TestCommentsAndUnknownFields:
def test_colon_prefix_is_comment(self) -> None:
events = _drain_bytes(b": keepalive\ndata: x\n\n")
assert len(events) == 1
assert events[0].data == "x"
def test_unknown_field_dropped(self) -> None:
events = _drain_bytes(b"retry: 5000\ndata: x\n\n")
assert len(events) == 1
assert events[0].data == "x"
def test_only_comments_no_event(self) -> None:
events = _drain_bytes(b": just\n: comments\n\n")
assert events == []
def test_line_without_colon(self) -> None:
events = _drain_bytes(b"data\n\n")
assert len(events) == 1
assert events[0].data == ""
class TestFieldParsing:
def test_single_space_after_colon_is_stripped(self) -> None:
events = _drain_bytes(b"data: x\n\n")
assert events[0].data == "x"
def test_no_space_after_colon(self) -> None:
events = _drain_bytes(b"data:x\n\n")
assert events[0].data == "x"
def test_multiple_spaces_preserve_second(self) -> None:
events = _drain_bytes(b"data: x\n\n")
assert events[0].data == " x"
def test_extra_colons_in_value(self) -> None:
events = _drain_bytes(b'data: {"k":"v"}\n\n')
assert events[0].data == '{"k":"v"}'
class TestEventBoundaries:
def test_two_events(self) -> None:
data = b"id: 1\ndata: a\n\nid: 2\ndata: b\n\n"
events = _drain_bytes(data)
assert [e.id for e in events] == ["1", "2"]
assert [e.data for e in events] == ["a", "b"]
def test_no_trailing_blank_line_flushes_at_eof(self) -> None:
events = _drain_bytes(b"data: tail")
assert len(events) == 1
assert events[0].data == "tail"
class TestChunkingRobustness:
"""Same bytes split at every possible boundary must yield identical events."""
PAYLOAD = (
b'id: 0\ndata: {"reasoning":"","text":""}\n\n'
b'id: 1\ndata: {"text":"hello"}\n\n'
b'event: complete\ndata: {"complete":true}\n\n'
)
def _expected(self) -> list[tuple[str | None, str | None, str]]:
ref = _drain_bytes(self.PAYLOAD)
return [(e.id, e.event, e.data) for e in ref]
@pytest.mark.parametrize("size", [1, 2, 3, 5, 7, 13, 64])
def test_split_at_arbitrary_size(self, size: int) -> None:
chunks = _drain_chunked(self.PAYLOAD, size)
observed = [(e.id, e.event, e.data) for e in chunks]
assert observed == self._expected()
def test_split_inside_field_value(self) -> None:
a = b'id: 1\ndata: {"text":"hel'
b = b'lo"}\n\n'
p = SSEParser()
events = list(p.feed(a))
assert events == []
events.extend(p.feed(b))
events.extend(p.flush())
assert len(events) == 1
assert events[0].json() == {"text": "hello"}
def test_split_between_cr_and_lf(self) -> None:
a = b"id: 1\r"
b = b"\ndata: x\r\n\r\n"
p = SSEParser()
out = list(p.feed(a))
out.extend(p.feed(b))
out.extend(p.flush())
assert len(out) == 1
assert out[0].id == "1"
assert out[0].data == "x"
class TestLastEventIdTracking:
def test_advances_with_id_field(self) -> None:
p = SSEParser()
list(p.feed(b"id: 5\ndata: a\n\n"))
assert p.last_event_id == "5"
list(p.feed(b"id: 9\ndata: b\n\n"))
assert p.last_event_id == "9"
def test_event_without_id_keeps_previous(self) -> None:
p = SSEParser()
list(p.feed(b"id: 5\ndata: a\n\ndata: b\n\n"))
assert p.last_event_id == "5"
def test_starts_none(self) -> None:
p = SSEParser()
assert p.last_event_id is None
class TestIterSseAsync:
@pytest.mark.asyncio
async def test_async_iteration_over_chunks(self) -> None:
async def gen() -> AsyncIterator[bytes]:
yield b"id: 1\nda"
yield b'ta: {"text":"a"}\n\n'
yield b'id: 2\ndata: {"text":"b"}\n\nevent: complete\ndata: {"complete":true}\n\n'
seen = [e async for e in iter_sse(gen())]
assert [e.id for e in seen] == ["1", "2", None]
assert [e.event for e in seen] == [None, None, "complete"]
assert seen[-1].is_terminal
@pytest.mark.asyncio
async def test_empty_stream(self) -> None:
async def gen() -> AsyncIterator[bytes]:
if False:
yield b""
assert [e async for e in iter_sse(gen())] == []
class TestErrorEvent:
def test_error_event_recognised(self) -> None:
events = _drain_bytes(b'event: error\ndata: {"message":"boom"}\n\n')
assert events[0].is_error
assert events[0].json() == {"message": "boom"}
def test_default_event_not_error(self) -> None:
events = _drain_bytes(b'data: {"text":"x"}\n\n')
assert events[0].is_error is False
class TestJsonParsing:
def test_invalid_json_raises_on_demand(self) -> None:
events = _drain_bytes(b"data: not json\n\n")
with pytest.raises(json.JSONDecodeError):
events[0].json()
def test_event_data_kept_raw(self) -> None:
events = _drain_bytes(b"data: not json\n\n")
assert events[0].data == "not json"
+58
View File
@@ -0,0 +1,58 @@
"""Tests for raycast_api.signing.transforms."""
from __future__ import annotations
import pytest
from raycast_api.signing.transforms import apply_rot
from raycast_api.signing_spec import RotRange
RAYCAST_RANGES = [
RotRange(0x41, 0x5A, 13),
RotRange(0x61, 0x7A, 13),
RotRange(0x30, 0x39, 5),
]
class TestRaycastDefaults:
def test_letter_rotation_matches_handoff(self) -> None:
assert apply_rot("abcXYZ", RAYCAST_RANGES) == "nopKLM"
def test_digit_rotation(self) -> None:
assert apply_rot("01234", RAYCAST_RANGES) == "56789"
assert apply_rot("56789", RAYCAST_RANGES) == "01234"
def test_punctuation_unchanged(self) -> None:
assert apply_rot("a.b", RAYCAST_RANGES) == "n.o"
assert apply_rot(":/?-_=", RAYCAST_RANGES) == ":/?-_="
def test_double_apply_is_identity_for_letters_and_hex_digits(self) -> None:
sample = "deadbeef0123456789"
assert apply_rot(apply_rot(sample, RAYCAST_RANGES), RAYCAST_RANGES) == sample
def test_empty_string(self) -> None:
assert apply_rot("", RAYCAST_RANGES) == ""
def test_unicode_outside_ranges_passes_through(self) -> None:
assert apply_rot("café→", RAYCAST_RANGES) == "pnsé→"
class TestArbitraryRanges:
def test_single_range_no_overflow(self) -> None:
assert apply_rot("ABCXYZ", [RotRange(0x41, 0x5A, 0)]) == "ABCXYZ"
def test_shift_wraps(self) -> None:
assert apply_rot("hello", [RotRange(0x61, 0x7A, 26)]) == "hello"
assert apply_rot("abz", [RotRange(0x61, 0x7A, 1)]) == "bca"
def test_first_matching_range_wins(self) -> None:
ranges = [RotRange(0x61, 0x7A, 1), RotRange(0x61, 0x7A, 5)]
assert apply_rot("a", ranges) == "b"
def test_range_outside_input_does_nothing(self) -> None:
assert apply_rot("hello", [RotRange(0x30, 0x39, 5)]) == "hello"
def test_negative_shift_rejected_at_construction(self) -> None:
with pytest.raises(ValueError):
RotRange(0x41, 0x5A, -1)