plano/cli/test/test_claude_desktop.py

366 lines
12 KiB
Python

"""Tests for `planoai launch claude-desktop` configuration logic."""
from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
from planoai import claude_desktop as cd
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def fake_home(tmp_path, monkeypatch):
"""Pretend we're on macOS with a fresh home directory.
Plano's local gateway has no API key concept, so by default we ensure
``$PLANO_API_KEY`` is unset; tests that exercise the env-override path
re-set it explicitly.
"""
monkeypatch.setattr(cd, "_GOOS", "darwin")
monkeypatch.setattr(cd, "_user_home", lambda _: str(tmp_path))
monkeypatch.delenv("PLANO_API_KEY", raising=False)
return tmp_path
def _normal_config_path(home: Path) -> Path:
return (
home
/ "Library"
/ "Application Support"
/ "Claude"
/ "claude_desktop_config.json"
)
def _third_party_root(home: Path) -> Path:
return home / "Library" / "Application Support" / "Claude-3p"
def _third_party_config_path(home: Path) -> Path:
return _third_party_root(home) / "claude_desktop_config.json"
def _meta_path(home: Path) -> Path:
return _third_party_root(home) / "configLibrary" / "_meta.json"
def _profile_path(home: Path) -> Path:
return _third_party_root(home) / "configLibrary" / f"{cd.PROFILE_ID}.json"
# ---------------------------------------------------------------------------
# configure() / restore()
# ---------------------------------------------------------------------------
def test_configure_writes_all_four_files_with_default_api_key(fake_home):
cd.configure("http://localhost:12000")
normal_cfg = json.loads(_normal_config_path(fake_home).read_text())
assert normal_cfg["deploymentMode"] == "3p"
third_cfg = json.loads(_third_party_config_path(fake_home).read_text())
assert third_cfg["deploymentMode"] == "3p"
meta = json.loads(_meta_path(fake_home).read_text())
assert meta["appliedId"] == cd.PROFILE_ID
assert any(
isinstance(e, dict) and e.get("id") == cd.PROFILE_ID for e in meta["entries"]
)
profile = json.loads(_profile_path(fake_home).read_text())
assert profile["inferenceProvider"] == "gateway"
assert profile["inferenceGatewayBaseUrl"] == "http://localhost:12000"
# No env override and no pre-existing profile -> placeholder is written.
assert profile["inferenceGatewayApiKey"] == cd.DEFAULT_API_KEY
assert profile["inferenceGatewayAuthScheme"] == "bearer"
assert profile["disableDeploymentModeChooser"] is True
assert "inferenceModels" not in profile
def test_configure_uses_env_override_when_set(fake_home, monkeypatch):
monkeypatch.setenv("PLANO_API_KEY", "from-env")
cd.configure("http://localhost:12000")
profile = json.loads(_profile_path(fake_home).read_text())
assert profile["inferenceGatewayApiKey"] == "from-env"
def test_configure_preserves_existing_profile_api_key(fake_home):
profile = _profile_path(fake_home)
profile.parent.mkdir(parents=True, exist_ok=True)
profile.write_text(json.dumps({"inferenceGatewayApiKey": "from-profile"}))
cd.configure("http://localhost:12000")
written = json.loads(profile.read_text())
assert written["inferenceGatewayApiKey"] == "from-profile"
def test_configure_does_not_call_network(fake_home, monkeypatch):
"""Plano's local gateway is not validated at configure time. We must not
attempt any HTTP request — a 503 from the gateway must not block setup.
"""
def boom(*_args, **_kwargs):
raise AssertionError("configure() must not perform network calls")
monkeypatch.setattr("urllib.request.urlopen", boom)
cd.configure("http://localhost:12000")
profile = json.loads(_profile_path(fake_home).read_text())
assert profile["inferenceProvider"] == "gateway"
def test_configure_preserves_existing_unrelated_keys(fake_home):
normal_path = _normal_config_path(fake_home)
normal_path.parent.mkdir(parents=True, exist_ok=True)
normal_path.write_text(
json.dumps({"someOtherSetting": 123, "deploymentMode": "1p"})
)
cd.configure("http://localhost:12000")
cfg = json.loads(normal_path.read_text())
assert cfg["someOtherSetting"] == 123
assert cfg["deploymentMode"] == "3p"
def test_configure_writes_backup_of_existing_files(fake_home):
normal_path = _normal_config_path(fake_home)
normal_path.parent.mkdir(parents=True, exist_ok=True)
normal_path.write_text('{"deploymentMode":"1p"}')
cd.configure("http://localhost:12000")
backup = normal_path.with_suffix(normal_path.suffix + ".bak")
assert backup.exists()
assert json.loads(backup.read_text())["deploymentMode"] == "1p"
def test_restore_reverts_deployment_mode_and_strips_gateway_keys(fake_home):
cd.configure("http://localhost:12000")
cd.restore()
assert (
json.loads(_normal_config_path(fake_home).read_text())["deploymentMode"] == "1p"
)
third_cfg = json.loads(_third_party_config_path(fake_home).read_text())
assert third_cfg["deploymentMode"] == "1p"
meta = json.loads(_meta_path(fake_home).read_text())
assert meta.get("appliedId") != cd.PROFILE_ID
assert all(
not (isinstance(e, dict) and e.get("id") == cd.PROFILE_ID)
for e in meta.get("entries", [])
)
profile = json.loads(_profile_path(fake_home).read_text())
assert profile["disableDeploymentModeChooser"] is False
for stripped in (
"inferenceProvider",
"inferenceGatewayBaseUrl",
"inferenceGatewayAuthScheme",
"inferenceModels",
):
assert stripped not in profile
def test_restore_meta_keeps_unrelated_entries(fake_home):
meta_path = _meta_path(fake_home)
meta_path.parent.mkdir(parents=True, exist_ok=True)
meta_path.write_text(
json.dumps(
{
"appliedId": cd.PROFILE_ID,
"entries": [
{"id": cd.PROFILE_ID, "name": "Plano"},
{"id": "00000000-0000-0000-0000-000000000001", "name": "Other"},
],
}
)
)
cd._restore_meta(str(meta_path))
meta = json.loads(meta_path.read_text())
assert meta.get("appliedId") in (None, "")
ids = [e["id"] for e in meta["entries"] if isinstance(e, dict)]
assert ids == ["00000000-0000-0000-0000-000000000001"]
# ---------------------------------------------------------------------------
# is_configured()
# ---------------------------------------------------------------------------
def test_is_configured_false_on_fresh_home(fake_home):
assert cd.is_configured() is False
def test_is_configured_true_after_configure(fake_home):
cd.configure("http://localhost:12000")
assert cd.is_configured() is True
def test_is_configured_false_when_only_normal_config_set(fake_home):
cd.configure("http://localhost:12000")
third_cfg = _third_party_config_path(fake_home)
data = json.loads(third_cfg.read_text())
data["deploymentMode"] = "1p"
third_cfg.write_text(json.dumps(data))
assert cd.is_configured() is False
# ---------------------------------------------------------------------------
# API key resolution (placeholder by default; env override; profile preserve)
# ---------------------------------------------------------------------------
def test_resolve_api_key_returns_placeholder_when_no_inputs(fake_home):
assert cd._resolve_api_key([]) == cd.DEFAULT_API_KEY
def test_resolve_api_key_uses_env_when_set(fake_home, monkeypatch):
monkeypatch.setenv("PLANO_API_KEY", "from-env")
profile = _profile_path(fake_home)
profile.parent.mkdir(parents=True, exist_ok=True)
profile.write_text(json.dumps({"inferenceGatewayApiKey": "from-profile"}))
# Env wins over profile.
assert cd._resolve_api_key([str(profile)]) == "from-env"
def test_resolve_api_key_falls_back_to_existing_profile(fake_home):
profile = _profile_path(fake_home)
profile.parent.mkdir(parents=True, exist_ok=True)
profile.write_text(json.dumps({"inferenceGatewayApiKey": "from-profile"}))
assert cd._resolve_api_key([str(profile)]) == "from-profile"
def test_resolve_api_key_skips_blank_env(fake_home, monkeypatch):
monkeypatch.setenv("PLANO_API_KEY", " ")
assert cd._resolve_api_key([]) == cd.DEFAULT_API_KEY
# ---------------------------------------------------------------------------
# Atomic write
# ---------------------------------------------------------------------------
def test_atomic_write_creates_backup_of_existing_file(tmp_path):
target = tmp_path / "deep" / "nested" / "file.json"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("ORIGINAL")
cd._atomic_write_with_backup(str(target), b"NEW")
assert target.read_text() == "NEW"
assert (tmp_path / "deep" / "nested" / "file.json.bak").read_text() == "ORIGINAL"
def test_atomic_write_skips_backup_when_no_existing_file(tmp_path):
target = tmp_path / "fresh.json"
cd._atomic_write_with_backup(str(target), b"DATA")
assert target.read_text() == "DATA"
assert not (tmp_path / "fresh.json.bak").exists()
def test_atomic_write_does_not_truncate_on_failure(tmp_path, monkeypatch):
target = tmp_path / "file.json"
target.write_text("ORIGINAL")
real_replace = os.replace
def boom(_src, _dst):
raise OSError("disk full")
monkeypatch.setattr(os, "replace", boom)
with pytest.raises(OSError):
cd._atomic_write_with_backup(str(target), b"NEW")
monkeypatch.setattr(os, "replace", real_replace)
assert target.read_text() == "ORIGINAL"
leftover = list(tmp_path.glob(".plano_*.tmp"))
assert leftover == []
# ---------------------------------------------------------------------------
# Platform support
# ---------------------------------------------------------------------------
def test_supported_returns_error_on_linux(monkeypatch):
monkeypatch.setattr(cd, "_GOOS", "linux")
msg = cd.supported()
assert msg is not None
assert "macOS" in msg and "Windows" in msg
def test_supported_returns_none_on_darwin(monkeypatch):
monkeypatch.setattr(cd, "_GOOS", "darwin")
assert cd.supported() is None
def test_configure_raises_on_unsupported_platform(monkeypatch):
monkeypatch.setattr(cd, "_GOOS", "linux")
with pytest.raises(RuntimeError, match="macOS"):
cd.configure()
def test_restore_raises_on_unsupported_platform(monkeypatch):
monkeypatch.setattr(cd, "_GOOS", "linux")
with pytest.raises(RuntimeError, match="macOS"):
cd.restore()
# ---------------------------------------------------------------------------
# launch_or_restart()
# ---------------------------------------------------------------------------
def test_launch_or_restart_opens_when_not_running(monkeypatch):
monkeypatch.setattr(cd, "_GOOS", "darwin")
monkeypatch.setattr(cd, "_is_running", lambda: False)
opened = []
monkeypatch.setattr(cd, "_open", lambda: opened.append(True))
monkeypatch.setattr(
cd, "_quit", lambda: pytest.fail("should not quit when not running")
)
cd.launch_or_restart("prompt", yes=True)
assert opened == [True]
def test_launch_or_restart_with_yes_quits_then_opens(monkeypatch):
monkeypatch.setattr(cd, "_GOOS", "darwin")
running = [True]
monkeypatch.setattr(cd, "_is_running", lambda: running[0])
def quit_app():
running[0] = False
quit_calls = []
open_calls = []
monkeypatch.setattr(
cd,
"_quit",
lambda: (quit_calls.append(True), quit_app()),
)
monkeypatch.setattr(cd, "_open", lambda: open_calls.append(True))
monkeypatch.setattr(cd, "_sleep", lambda _: None)
cd.launch_or_restart("Restart?", yes=True)
assert quit_calls == [True]
assert open_calls == [True]