feat: vibed out some slop over here
This commit is contained in:
@@ -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
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+77
@@ -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 };
|
||||
@@ -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("}")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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 ")
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user