chore: import upstream snapshot with attribution
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
"""CLI tests for `opensquilla agents`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli.main import app
|
||||
from opensquilla.onboarding.config_store import load_config
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _setenv(monkeypatch, tmp_path: Path) -> Path:
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
return target
|
||||
|
||||
|
||||
def test_agents_list_json_includes_builtin_main(tmp_path, monkeypatch):
|
||||
_setenv(monkeypatch, tmp_path)
|
||||
|
||||
result = runner.invoke(app, ["agents", "list", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload[0]["id"] == "main"
|
||||
assert payload[0]["isBuiltin"] is True
|
||||
|
||||
|
||||
def test_agents_add_json_persists_config_entry(tmp_path, monkeypatch):
|
||||
target = _setenv(monkeypatch, tmp_path)
|
||||
|
||||
result = runner.invoke(app, ["agents", "add", "ops", "--model", "openai/test", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["id"] == "ops"
|
||||
assert payload["model"] == "openai/test"
|
||||
cfg = load_config(target)
|
||||
assert [entry.id for entry in cfg.agents] == ["ops"]
|
||||
text = target.read_text(encoding="utf-8")
|
||||
assert "agents = [" in text
|
||||
assert 'id = "ops"' in text
|
||||
|
||||
|
||||
def test_agents_add_duplicate_fails(tmp_path, monkeypatch):
|
||||
_setenv(monkeypatch, tmp_path)
|
||||
first = runner.invoke(app, ["agents", "add", "ops", "--json"])
|
||||
assert first.exit_code == 0, first.stdout
|
||||
|
||||
result = runner.invoke(app, ["agents", "add", "ops", "--json"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
combined = result.stdout + (result.stderr or "")
|
||||
assert "already exists" in combined
|
||||
|
||||
|
||||
def test_agents_delete_main_rejected(tmp_path, monkeypatch):
|
||||
_setenv(monkeypatch, tmp_path)
|
||||
|
||||
result = runner.invoke(app, ["agents", "delete", "main", "--force", "--json"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
combined = result.stdout + (result.stderr or "")
|
||||
assert "builtin agent" in combined
|
||||
|
||||
|
||||
def test_agents_delete_force_json_removes_config_entry(tmp_path, monkeypatch):
|
||||
target = _setenv(monkeypatch, tmp_path)
|
||||
add_result = runner.invoke(app, ["agents", "add", "ops", "--json"])
|
||||
assert add_result.exit_code == 0, add_result.stdout
|
||||
|
||||
result = runner.invoke(app, ["agents", "delete", "ops", "--force", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload == {
|
||||
"id": "ops",
|
||||
"deleted": True,
|
||||
"workspaceDeleted": False,
|
||||
"stateDeleted": False,
|
||||
}
|
||||
assert load_config(target).agents == []
|
||||
@@ -0,0 +1,88 @@
|
||||
"""``opensquilla bundle`` — offline diagnostics bundle collection.
|
||||
|
||||
Offline: state/log dirs point at tmp_path; no gateway needs to run (live
|
||||
enrichment is best-effort and silently skipped, and stubbed out here for
|
||||
determinism). An autouse fixture also pins OPENSQUILLA_GATEWAY_CONFIG_PATH
|
||||
to a synthetic TOML so no test can ever read or rewrite a real config.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli.main import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hermetic_offline(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Pin config resolution to a synthetic file and stub live enrichment.
|
||||
|
||||
Without the config pin, resolve_config_path(None) falls back to
|
||||
./opensquilla.toml and then the developer's real home config — which the
|
||||
bundle's doctor collector must never see. The enrichment stub keeps tests
|
||||
deterministic and fast instead of relying on a quick connection failure.
|
||||
"""
|
||||
config_path = tmp_path / "synthetic-config.toml"
|
||||
config_path.write_text("# synthetic bundle-cmd test config\n", encoding="utf-8")
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(config_path))
|
||||
try:
|
||||
from opensquilla.cli import bundle_cmd
|
||||
except ImportError:
|
||||
# First TDD run: module does not exist yet; let the CLI invocation
|
||||
# fail with "No such command" instead of a fixture error.
|
||||
return
|
||||
monkeypatch.setattr(bundle_cmd, "_live_enrichment", lambda: {})
|
||||
|
||||
|
||||
def test_bundle_writes_zip_and_prints_path(tmp_path, monkeypatch) -> None:
|
||||
home = tmp_path / "home"
|
||||
(home / "logs").mkdir(parents=True)
|
||||
(home / "logs" / "debug.log").write_text("2026-07-07 [INFO] opensquilla: ok\n")
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
|
||||
monkeypatch.setenv("OPENSQUILLA_LOG_DIR", str(home / "logs"))
|
||||
output = tmp_path / "out.zip"
|
||||
|
||||
result = runner.invoke(app, ["bundle", "--output", str(output)])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert output.exists()
|
||||
assert str(output) in result.stdout
|
||||
with zipfile.ZipFile(output) as archive:
|
||||
assert "manifest.json" in archive.namelist()
|
||||
|
||||
|
||||
def test_bundle_json_output(tmp_path, monkeypatch) -> None:
|
||||
home = tmp_path / "home"
|
||||
(home / "logs").mkdir(parents=True)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
|
||||
monkeypatch.setenv("OPENSQUILLA_LOG_DIR", str(home / "logs"))
|
||||
output = tmp_path / "out.zip"
|
||||
|
||||
result = runner.invoke(app, ["bundle", "--output", str(output), "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["path"].endswith("out.zip")
|
||||
assert payload["bundle_schema"] == 1
|
||||
|
||||
|
||||
def test_bundle_include_content_flag(tmp_path, monkeypatch) -> None:
|
||||
home = tmp_path / "home"
|
||||
(home / "logs").mkdir(parents=True)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
|
||||
monkeypatch.setenv("OPENSQUILLA_LOG_DIR", str(home / "logs"))
|
||||
output = tmp_path / "out.zip"
|
||||
|
||||
result = runner.invoke(app, ["bundle", "--output", str(output), "--include-content"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
with zipfile.ZipFile(output) as archive:
|
||||
manifest = json.loads(archive.read("manifest.json"))
|
||||
assert manifest["content_tier"] is True
|
||||
@@ -0,0 +1,310 @@
|
||||
"""CLI tests for `opensquilla channels`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli.main import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _setenv(monkeypatch, tmp_path: Path) -> Path:
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
return target
|
||||
|
||||
|
||||
def test_channels_list_empty(tmp_path, monkeypatch):
|
||||
_setenv(monkeypatch, tmp_path)
|
||||
result = runner.invoke(app, ["channels", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert "0 channels" in result.stdout.lower()
|
||||
|
||||
|
||||
def test_channels_add_telegram_polling_minimal(tmp_path, monkeypatch):
|
||||
target = _setenv(monkeypatch, tmp_path)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["channels", "add", "telegram", "--name", "tg", "--token", "abc"],
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
text = target.read_text()
|
||||
assert "tg" in text
|
||||
assert "telegram" in text
|
||||
assert "abc" not in result.stdout
|
||||
assert "restart" in result.stdout.lower()
|
||||
|
||||
|
||||
def test_channels_add_slack_missing_token_fails(tmp_path, monkeypatch):
|
||||
_setenv(monkeypatch, tmp_path)
|
||||
result = runner.invoke(app, ["channels", "add", "slack", "--name", "w"])
|
||||
assert result.exit_code != 0
|
||||
combined = (result.stdout + (result.stderr or "")).lower()
|
||||
assert "token" in combined
|
||||
|
||||
|
||||
def test_channels_add_slack_succeeds_with_token(tmp_path, monkeypatch):
|
||||
target = _setenv(monkeypatch, tmp_path)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"channels", "add", "slack",
|
||||
"--name", "w", "--token", "xoxb-x",
|
||||
"--field", "signing_secret=ss",
|
||||
"--field", "slack_channel_id=C123",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
text = target.read_text()
|
||||
assert "C123" in text
|
||||
assert "xoxb-x" not in result.stdout
|
||||
|
||||
|
||||
def test_channels_remove(tmp_path, monkeypatch):
|
||||
target = _setenv(monkeypatch, tmp_path)
|
||||
runner.invoke(app, ["channels", "add", "slack",
|
||||
"--name", "w", "--token", "x",
|
||||
"--field", "signing_secret=ss"])
|
||||
result = runner.invoke(app, ["channels", "remove", "w"])
|
||||
assert result.exit_code == 0
|
||||
# Either the channel is gone, or the [[channels.channels]] table is empty.
|
||||
text = target.read_text()
|
||||
assert 'name = "w"' not in text
|
||||
|
||||
|
||||
def test_channels_disable_then_enable(tmp_path, monkeypatch):
|
||||
target = _setenv(monkeypatch, tmp_path)
|
||||
runner.invoke(app, ["channels", "add", "slack",
|
||||
"--name", "w", "--token", "x",
|
||||
"--field", "signing_secret=ss"])
|
||||
r1 = runner.invoke(app, ["channels", "disable", "w"])
|
||||
assert r1.exit_code == 0
|
||||
assert "enabled = false" in target.read_text()
|
||||
r2 = runner.invoke(app, ["channels", "enable", "w"])
|
||||
assert r2.exit_code == 0
|
||||
assert "enabled = true" in target.read_text()
|
||||
|
||||
|
||||
def test_channels_list_redacts_secrets(tmp_path, monkeypatch):
|
||||
_setenv(monkeypatch, tmp_path)
|
||||
runner.invoke(app, ["channels", "add", "slack",
|
||||
"--name", "w", "--token", "supersecret",
|
||||
"--field", "signing_secret=ss"])
|
||||
result = runner.invoke(app, ["channels", "list"])
|
||||
assert "supersecret" not in result.stdout
|
||||
assert "***" in result.stdout
|
||||
|
||||
|
||||
def test_channels_edit_updates_only_provided_fields(tmp_path, monkeypatch):
|
||||
target = _setenv(monkeypatch, tmp_path)
|
||||
runner.invoke(
|
||||
app,
|
||||
["channels", "add", "slack", "--name", "w",
|
||||
"--token", "xoxb-original",
|
||||
"--field", "signing_secret=ss",
|
||||
"--field", "slack_channel_id=C111"],
|
||||
)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["channels", "edit", "w", "--field", "slack_channel_id=C222"],
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
text = target.read_text()
|
||||
assert "C222" in text
|
||||
assert "C111" not in text
|
||||
assert "xoxb-original" in text
|
||||
|
||||
|
||||
def test_channels_edit_unknown_name_fails(tmp_path, monkeypatch):
|
||||
_setenv(monkeypatch, tmp_path)
|
||||
result = runner.invoke(app, ["channels", "edit", "missing"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
def test_channels_edit_preserves_enabled_when_unchanged(tmp_path, monkeypatch):
|
||||
target = _setenv(monkeypatch, tmp_path)
|
||||
runner.invoke(
|
||||
app,
|
||||
["channels", "add", "slack", "--name", "w",
|
||||
"--token", "xoxb-x", "--field", "signing_secret=ss", "--disabled"],
|
||||
)
|
||||
assert "enabled = false" in target.read_text()
|
||||
r = runner.invoke(
|
||||
app, ["channels", "edit", "w", "--field", "slack_channel_id=C9"]
|
||||
)
|
||||
assert r.exit_code == 0, r.stdout
|
||||
text = target.read_text()
|
||||
assert "enabled = false" in text
|
||||
assert "C9" in text
|
||||
|
||||
|
||||
def test_channels_edit_preserves_agent_id_when_unchanged(tmp_path, monkeypatch):
|
||||
target = _setenv(monkeypatch, tmp_path)
|
||||
runner.invoke(
|
||||
app,
|
||||
["channels", "add", "slack", "--name", "w",
|
||||
"--token", "xoxb-x", "--field", "signing_secret=ss", "--agent-id", "ops"],
|
||||
)
|
||||
assert 'agent_id = "ops"' in target.read_text()
|
||||
r = runner.invoke(
|
||||
app, ["channels", "edit", "w", "--field", "slack_channel_id=C9"]
|
||||
)
|
||||
assert r.exit_code == 0, r.stdout
|
||||
assert 'agent_id = "ops"' in target.read_text()
|
||||
|
||||
|
||||
def test_channels_edit_preserves_non_secret_bool_when_unchanged(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = _setenv(monkeypatch, tmp_path)
|
||||
runner.invoke(
|
||||
app,
|
||||
["channels", "add", "slack", "--name", "w",
|
||||
"--token", "xoxb-x",
|
||||
"--field", "signing_secret=ss",
|
||||
"--field", "reply_in_thread=true"],
|
||||
)
|
||||
assert "reply_in_thread = true" in target.read_text()
|
||||
r = runner.invoke(
|
||||
app, ["channels", "edit", "w", "--field", "slack_channel_id=C9"]
|
||||
)
|
||||
assert r.exit_code == 0, r.stdout
|
||||
assert "reply_in_thread = true" in target.read_text()
|
||||
|
||||
|
||||
def test_channels_add_token_resolves_to_alias_order_not_field_order(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""For wecom, --token should resolve to the literal 'token' field
|
||||
(alias index 0), not 'corp_secret' (alias index 5) which would happen
|
||||
under naive spec-field-order resolution.
|
||||
"""
|
||||
_setenv(monkeypatch, tmp_path)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["channels", "add", "wecom",
|
||||
"--name", "wc",
|
||||
"--token", "wecom-token",
|
||||
"--field", "corp_id=cid",
|
||||
"--field", "corp_secret=cs",
|
||||
"--field", "agent_id_int=1000",
|
||||
"--field", "encoding_aes_key=" + "a" * 43],
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert "wecom.token" in result.stdout
|
||||
assert "wecom.corp_secret" not in result.stdout
|
||||
|
||||
|
||||
def test_channels_add_token_flag_echoes_resolved_field(tmp_path, monkeypatch):
|
||||
_setenv(monkeypatch, tmp_path)
|
||||
result = runner.invoke(
|
||||
app, ["channels", "add", "matrix",
|
||||
"--name", "m",
|
||||
"--token", "syt-abc",
|
||||
"--field", "homeserver_url=https://matrix.org",
|
||||
"--field", "user_id=@me:matrix.org"]
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert "--token" in result.stdout
|
||||
assert "matrix.access_token" in result.stdout
|
||||
|
||||
|
||||
def test_channels_add_token_flag_for_slack_resolves_to_token(tmp_path, monkeypatch):
|
||||
_setenv(monkeypatch, tmp_path)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"channels", "add", "slack",
|
||||
"--name", "w", "--token", "xoxb-x",
|
||||
"--field", "signing_secret=ss",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert "slack.token" in result.stdout
|
||||
|
||||
|
||||
def test_channels_describe_slack(tmp_path, monkeypatch):
|
||||
_setenv(monkeypatch, tmp_path)
|
||||
result = runner.invoke(app, ["channels", "describe", "slack"])
|
||||
assert result.exit_code == 0
|
||||
out = result.stdout
|
||||
assert "token" in out
|
||||
assert "signing_secret" in out
|
||||
assert "webhook" in out.lower()
|
||||
assert "api.slack.com" in out
|
||||
|
||||
|
||||
def test_channels_describe_unknown_type_fails(tmp_path, monkeypatch):
|
||||
_setenv(monkeypatch, tmp_path)
|
||||
result = runner.invoke(app, ["channels", "describe", "no-such-type"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
def test_channels_types_lists_all_supported(tmp_path, monkeypatch):
|
||||
_setenv(monkeypatch, tmp_path)
|
||||
result = runner.invoke(app, ["channels", "types"])
|
||||
assert result.exit_code == 0
|
||||
out = result.stdout
|
||||
for t in ("slack", "telegram", "discord", "feishu",
|
||||
"dingtalk", "wecom", "qq", "matrix"):
|
||||
assert t in out
|
||||
# msteams is intentionally hidden from the channel catalog CLI surface
|
||||
# until first-class support lands.
|
||||
assert "msteams" not in out
|
||||
assert "transport" in out.lower()
|
||||
|
||||
|
||||
def test_channels_add_restart_notice_disambiguates(tmp_path, monkeypatch):
|
||||
_setenv(monkeypatch, tmp_path)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"channels", "add", "slack",
|
||||
"--name", "w", "--token", "x",
|
||||
"--field", "signing_secret=ss",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
out = result.stdout.lower()
|
||||
assert "gateway process" in out
|
||||
assert "not the same as" in out
|
||||
assert "channels restart" in out
|
||||
|
||||
|
||||
def test_channels_add_prints_status_verification_next_step(tmp_path, monkeypatch):
|
||||
_setenv(monkeypatch, tmp_path)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"channels", "add", "slack",
|
||||
"--name", "w", "--token", "x",
|
||||
"--field", "signing_secret=ss",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
out = result.stdout.lower()
|
||||
assert "opensquilla gateway restart" in out
|
||||
assert "opensquilla channels status w --json" in out
|
||||
|
||||
|
||||
def test_channels_add_echoes_resolved_path_and_source(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", raising=False)
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
(project / "opensquilla.toml").write_text("")
|
||||
monkeypatch.chdir(project)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"channels", "add", "slack",
|
||||
"--name", "w", "--token", "x",
|
||||
"--field", "signing_secret=ss",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert str(project / "opensquilla.toml") in result.stdout
|
||||
assert "cwd" in result.stdout.lower()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,325 @@
|
||||
"""Tests for the CLI ``/file`` command.
|
||||
|
||||
The ``/file`` command:
|
||||
|
||||
- Accepts the broader allow-list (PDF, text-family, JSON, plus images).
|
||||
- Routes by size: <= 2 MB inlines as base64 (same shape as /image today).
|
||||
- > 2 MB uploads to /api/v1/files/upload via the gateway bridge and
|
||||
references the returned ``file_uuid`` in the chat.send payload.
|
||||
- Hard-fails with a clear, actionable error when the bridge is
|
||||
unreachable AND the file exceeds the inline cap.
|
||||
|
||||
These tests target the helper directly so the contract is locked
|
||||
without requiring a live gateway. The hooks the helper accepts (an
|
||||
``upload_callable`` parameter) make it trivial to inject a fake
|
||||
bridge in tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from opensquilla.cli import chat_cmd
|
||||
from opensquilla.cli.attachments import (
|
||||
CLI_IMAGE_ATTACHMENT_BYTES,
|
||||
CLI_INLINE_THRESHOLD_BYTES,
|
||||
CLI_STAGED_PDF_BYTES,
|
||||
CLI_TEXT_ATTACHMENT_BYTES,
|
||||
)
|
||||
from opensquilla.cli.chat_cmd import (
|
||||
_file_prompt_and_attachments,
|
||||
_image_prompt_and_attachments,
|
||||
_image_prompt_from_command,
|
||||
)
|
||||
from opensquilla.cli.repl import input_bridge
|
||||
|
||||
|
||||
def _write(tmp_path: Path, name: str, payload: bytes) -> Path:
|
||||
path = tmp_path / name
|
||||
path.write_bytes(payload)
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 1 — small CSV inlines as base64.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_file_command_inline_for_small_csv(tmp_path: Path) -> None:
|
||||
csv_bytes = b"col_a,col_b\n1,2\n3,4\n"
|
||||
path = _write(tmp_path, "data.csv", csv_bytes)
|
||||
|
||||
prompt, attachments = _file_prompt_and_attachments(
|
||||
f"/file {path} summarise this", upload_callable=None
|
||||
)
|
||||
assert prompt == "summarise this"
|
||||
assert len(attachments) == 1
|
||||
att = attachments[0]
|
||||
assert att["type"] == "text/csv"
|
||||
assert att["name"] == "data.csv"
|
||||
assert "data" in att and "file_uuid" not in att
|
||||
assert base64.b64decode(att["data"]) == csv_bytes
|
||||
|
||||
|
||||
def test_file_command_parses_quoted_path_with_spaces(tmp_path: Path) -> None:
|
||||
csv_bytes = b"col_a,col_b\n1,2\n"
|
||||
path = _write(tmp_path, "data set.csv", csv_bytes)
|
||||
|
||||
prompt, attachments = _file_prompt_and_attachments(
|
||||
f'/file "{path}" summarise this', upload_callable=None
|
||||
)
|
||||
|
||||
assert prompt == "summarise this"
|
||||
assert attachments[0]["name"] == "data set.csv"
|
||||
assert attachments[0]["type"] == "text/csv"
|
||||
assert base64.b64decode(attachments[0]["data"]) == csv_bytes
|
||||
|
||||
|
||||
def test_file_command_rejects_unclosed_quoted_path() -> None:
|
||||
with pytest.raises(ValueError, match=r"Usage: /file"):
|
||||
_file_prompt_and_attachments('/file "unterminated', upload_callable=None)
|
||||
|
||||
|
||||
def test_image_command_parses_quoted_path_with_spaces(tmp_path: Path) -> None:
|
||||
png_bytes = b"\x89PNG\r\n\x1a\n" + b"payload"
|
||||
path = _write(tmp_path, "screen shot.png", png_bytes)
|
||||
|
||||
prompt, attachments = _image_prompt_and_attachments(f'/image "{path}" describe it')
|
||||
|
||||
assert _image_prompt_from_command(f'/image "{path}" describe it') == "describe it"
|
||||
assert prompt == "describe it"
|
||||
assert attachments[0]["name"] == "screen shot.png"
|
||||
assert attachments[0]["type"] == "image/png"
|
||||
assert base64.b64decode(attachments[0]["data"]) == png_bytes
|
||||
|
||||
|
||||
def test_image_command_reports_attachment_size(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
png_bytes = b"\x89PNG\r\n\x1a\n" + b"payload"
|
||||
path = _write(tmp_path, "screen shot.png", png_bytes)
|
||||
prints: list[str] = []
|
||||
|
||||
class FakeConsole:
|
||||
def print(self, message: str) -> None:
|
||||
prints.append(message)
|
||||
|
||||
monkeypatch.setattr(input_bridge, "console", FakeConsole())
|
||||
|
||||
chat_cmd._image_prompt_and_attachments(f'/image "{path}" describe it')
|
||||
|
||||
assert prints == ["[dim]Sending image: screen shot.png (0KB base64)[/dim]"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 2 — large PDF goes through the bridge upload callable.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_file_command_uses_bridge_for_large_pdf(tmp_path: Path) -> None:
|
||||
big_pdf = b"%PDF-1.4\n" + b"a" * (3 * 1024 * 1024) # 3 MB > 2 MB threshold
|
||||
path = _write(tmp_path, "big.pdf", big_pdf)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_upload(local_path: Path, mime: str, name: str) -> str:
|
||||
captured["local_path"] = Path(local_path)
|
||||
captured["mime"] = mime
|
||||
captured["name"] = name
|
||||
return "u-fake-uuid-1234"
|
||||
|
||||
prompt, attachments = _file_prompt_and_attachments(
|
||||
f"/file {path}", upload_callable=fake_upload
|
||||
)
|
||||
assert prompt # default prompt assigned by the helper
|
||||
assert len(attachments) == 1
|
||||
att = attachments[0]
|
||||
assert att["type"] == "application/pdf"
|
||||
assert att["name"] == "big.pdf"
|
||||
assert att["file_uuid"] == "u-fake-uuid-1234"
|
||||
assert "data" not in att
|
||||
assert captured["local_path"] == path
|
||||
assert captured["mime"] == "application/pdf"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 3 — bridge unreachable AND file > inline cap → hard-fail.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_file_command_hard_fails_when_bridge_unreachable_and_file_too_large(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
big_pdf = b"%PDF-1.4\n" + b"a" * (3 * 1024 * 1024)
|
||||
path = _write(tmp_path, "big.pdf", big_pdf)
|
||||
|
||||
def unreachable(local_path: Path, mime: str, name: str) -> str:
|
||||
raise ConnectionError("gateway upload endpoint unavailable: connection refused")
|
||||
|
||||
with pytest.raises(ValueError, match=r"too large|gateway upload"):
|
||||
_file_prompt_and_attachments(f"/file {path}", upload_callable=unreachable)
|
||||
|
||||
|
||||
def test_file_command_unreachable_bridge_falls_back_to_inline_for_small_file(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""When the bridge is unreachable but the file is below the inline cap,
|
||||
the helper still inlines the bytes — never silently truncates, never fails.
|
||||
"""
|
||||
small_csv = b"a,b\n1,2\n"
|
||||
path = _write(tmp_path, "small.csv", small_csv)
|
||||
|
||||
def unreachable(local_path: Path, mime: str, name: str) -> str:
|
||||
raise ConnectionError("would not be invoked for inline size")
|
||||
|
||||
prompt, attachments = _file_prompt_and_attachments(
|
||||
f"/file {path} read", upload_callable=unreachable
|
||||
)
|
||||
assert prompt == "read"
|
||||
assert attachments[0]["type"] == "text/csv"
|
||||
assert "data" in attachments[0]
|
||||
|
||||
|
||||
def test_file_command_accepts_unknown_binary_as_opaque(tmp_path: Path) -> None:
|
||||
# Unknown extension + binary content attaches as an opaque item: the bytes
|
||||
# land in the agent workspace, never in the provider prompt.
|
||||
path = _write(tmp_path, "x.bin", b"\x00\x01\x02\x03 binary blob")
|
||||
prompt, attachments = _file_prompt_and_attachments(
|
||||
f"/file {path}", upload_callable=None
|
||||
)
|
||||
assert prompt == "Read this file"
|
||||
att = attachments[0]
|
||||
assert att["type"] == "application/octet-stream"
|
||||
assert base64.b64decode(att["data"]) == b"\x00\x01\x02\x03 binary blob"
|
||||
|
||||
|
||||
def test_file_command_stages_zip_archive(tmp_path: Path) -> None:
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as archive:
|
||||
archive.writestr("paper/main.tex", "x" * (CLI_INLINE_THRESHOLD_BYTES + 64))
|
||||
payload = buffer.getvalue()
|
||||
assert len(payload) > CLI_INLINE_THRESHOLD_BYTES
|
||||
path = _write(tmp_path, "paper.zip", payload)
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_upload(local_path: Path, mime: str, name: str) -> str:
|
||||
captured.update({"mime": mime, "name": name})
|
||||
return "u-zip"
|
||||
|
||||
_prompt, attachments = _file_prompt_and_attachments(
|
||||
f"/file {path}", upload_callable=fake_upload
|
||||
)
|
||||
|
||||
assert captured["mime"] == "application/zip"
|
||||
assert attachments == [
|
||||
{
|
||||
"type": "application/zip",
|
||||
"file_uuid": "u-zip",
|
||||
"name": "paper.zip",
|
||||
"mime": "application/zip",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_file_command_accepts_unknown_utf8_text_as_plain(tmp_path: Path) -> None:
|
||||
# Unknown extension whose bytes are clean UTF-8 text is accepted as
|
||||
# text/plain, so the gateway's unknown-text fallback is reachable from the
|
||||
# CLI rather than rejected at the client.
|
||||
body = b"#!/bin/sh\necho hi\n"
|
||||
path = _write(tmp_path, "script.sh", body)
|
||||
|
||||
prompt, attachments = _file_prompt_and_attachments(
|
||||
f"/file {path} run this", upload_callable=None
|
||||
)
|
||||
assert prompt == "run this"
|
||||
assert len(attachments) == 1
|
||||
att = attachments[0]
|
||||
assert att["type"] == "text/plain"
|
||||
assert att["name"] == "script.sh"
|
||||
assert base64.b64decode(att["data"]) == body
|
||||
|
||||
|
||||
def test_file_command_stages_large_text_family(tmp_path: Path) -> None:
|
||||
# Text above the inline threshold routes through the staged upload path
|
||||
# instead of dead-ending on the old text-family rejection.
|
||||
path = _write(tmp_path, "large.csv", b"a" * (CLI_TEXT_ATTACHMENT_BYTES + 1))
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_upload(local_path: Path, mime: str, name: str) -> str:
|
||||
captured.update({"local_path": local_path, "mime": mime, "name": name})
|
||||
return "u-large-text"
|
||||
|
||||
_prompt, attachments = _file_prompt_and_attachments(
|
||||
f"/file {path}", upload_callable=fake_upload
|
||||
)
|
||||
|
||||
assert captured["mime"] == "text/csv"
|
||||
assert attachments == [
|
||||
{
|
||||
"type": "text/csv",
|
||||
"file_uuid": "u-large-text",
|
||||
"name": "large.csv",
|
||||
"mime": "text/csv",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_file_command_stages_large_image_within_image_cap(tmp_path: Path) -> None:
|
||||
payload = b"\x89PNG\r\n\x1a\n" + b"a" * CLI_INLINE_THRESHOLD_BYTES
|
||||
assert CLI_INLINE_THRESHOLD_BYTES < len(payload) <= CLI_IMAGE_ATTACHMENT_BYTES
|
||||
path = _write(tmp_path, "large.png", payload)
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_upload(local_path: Path, mime: str, name: str) -> str:
|
||||
captured.update({"local_path": local_path, "mime": mime, "name": name})
|
||||
return "u-image"
|
||||
|
||||
_prompt, attachments = _file_prompt_and_attachments(
|
||||
f"/file {path}",
|
||||
upload_callable=fake_upload,
|
||||
)
|
||||
|
||||
assert attachments == [
|
||||
{"type": "image/png", "file_uuid": "u-image", "name": "large.png", "mime": "image/png"}
|
||||
]
|
||||
assert captured["local_path"] == path
|
||||
assert captured["mime"] == "image/png"
|
||||
|
||||
|
||||
def test_file_command_rejects_image_above_image_cap_before_upload(tmp_path: Path) -> None:
|
||||
path = _write(
|
||||
tmp_path,
|
||||
"too-large.png",
|
||||
b"\x89PNG\r\n\x1a\n" + b"a" * CLI_IMAGE_ATTACHMENT_BYTES,
|
||||
)
|
||||
called = False
|
||||
|
||||
def fake_upload(local_path: Path, mime: str, name: str) -> str:
|
||||
nonlocal called
|
||||
called = True
|
||||
return "u-should-not-upload"
|
||||
|
||||
with pytest.raises(ValueError, match=r"image attachment limit|too large"):
|
||||
_file_prompt_and_attachments(f"/file {path}", upload_callable=fake_upload)
|
||||
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_file_command_rejects_pdf_above_staged_cap_before_upload(tmp_path: Path) -> None:
|
||||
path = _write(tmp_path, "too-large.pdf", b"%PDF-1.4\n" + b"a" * CLI_STAGED_PDF_BYTES)
|
||||
called = False
|
||||
|
||||
def fake_upload(local_path: Path, mime: str, name: str) -> str:
|
||||
nonlocal called
|
||||
called = True
|
||||
return "u-should-not-upload"
|
||||
|
||||
with pytest.raises(ValueError, match=r"PDF limit|too large"):
|
||||
_file_prompt_and_attachments(f"/file {path}", upload_callable=fake_upload)
|
||||
|
||||
assert called is False
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from opensquilla.cli.chat_cmd import (
|
||||
_parse_path_command,
|
||||
_path_prompt_and_attachments,
|
||||
_path_strategy_hint,
|
||||
)
|
||||
from opensquilla.cli.repl.commands import REGISTRY
|
||||
from opensquilla.engine.commands import DEFAULT_REGISTRY, Surface
|
||||
|
||||
|
||||
def test_path_command_parses_quoted_path_with_prompt(tmp_path: Path) -> None:
|
||||
target = tmp_path / "file with spaces.log"
|
||||
target.write_text("hello\n", encoding="utf-8")
|
||||
|
||||
prompt, attachments = _path_prompt_and_attachments(
|
||||
f'/path "{target}" summarize errors'
|
||||
)
|
||||
|
||||
assert str(target.resolve(strict=False)) in prompt
|
||||
assert "summarize errors" in prompt
|
||||
assert "attachments=[]" in prompt
|
||||
assert attachments == []
|
||||
|
||||
|
||||
def test_path_command_parses_unquoted_existing_path_with_spaces(tmp_path: Path) -> None:
|
||||
target = tmp_path / "file with spaces.log"
|
||||
target.write_text("hello\n", encoding="utf-8")
|
||||
|
||||
path, trailing_prompt = _parse_path_command(f"/path {target} inspect it")
|
||||
|
||||
assert path == target
|
||||
assert trailing_prompt == "inspect it"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unsafe", ["<", ">", "\n", "\r"])
|
||||
def test_path_command_rejects_unsafe_path_token(unsafe: str) -> None:
|
||||
with pytest.raises(ValueError, match="not allowed|Invalid"):
|
||||
_path_prompt_and_attachments(f"/path bad{unsafe}name")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", ["/path", '/path "unterminated'])
|
||||
def test_path_command_rejects_missing_or_unclosed_quote(command: str) -> None:
|
||||
with pytest.raises(ValueError, match="Usage"):
|
||||
_path_prompt_and_attachments(command)
|
||||
|
||||
|
||||
def test_path_command_default_prompt_mentions_no_upload(tmp_path: Path) -> None:
|
||||
target = tmp_path / "notes.md"
|
||||
target.write_text("# Notes\n", encoding="utf-8")
|
||||
|
||||
prompt, attachments = _path_prompt_and_attachments(f"/path {target}")
|
||||
|
||||
assert "Analyze this local path" in prompt
|
||||
assert "did not upload or attach file bytes" in prompt
|
||||
assert "path string" in prompt
|
||||
assert str(target.resolve(strict=False)) in prompt
|
||||
assert attachments == []
|
||||
|
||||
|
||||
def test_path_strategy_directory_uses_list_and_search(tmp_path: Path) -> None:
|
||||
hint = _path_strategy_hint(tmp_path)
|
||||
assert "list_dir" in hint
|
||||
assert "glob_search" in hint
|
||||
assert "grep_search" in hint
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".csv", ".tsv", ".xlsx"])
|
||||
def test_path_strategy_spreadsheet_uses_read_spreadsheet(
|
||||
tmp_path: Path,
|
||||
suffix: str,
|
||||
) -> None:
|
||||
target = tmp_path / f"data{suffix}"
|
||||
target.write_bytes(b"a,b\n1,2\n")
|
||||
assert "read_spreadsheet" in _path_strategy_hint(target)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".txt", ".log", ".md", ".json", ".html"])
|
||||
def test_path_strategy_text_uses_read_file_or_grep(tmp_path: Path, suffix: str) -> None:
|
||||
target = tmp_path / f"data{suffix}"
|
||||
target.write_text("hello\n", encoding="utf-8")
|
||||
hint = _path_strategy_hint(target)
|
||||
assert "read_file" in hint
|
||||
assert "grep_search" in hint
|
||||
|
||||
|
||||
def test_path_strategy_pdf_rejects_with_file_guidance(tmp_path: Path) -> None:
|
||||
target = tmp_path / "paper.pdf"
|
||||
target.write_bytes(b"%PDF-1.4\n")
|
||||
with pytest.raises(ValueError, match="/file"):
|
||||
_path_strategy_hint(target)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".zip", ".exe"])
|
||||
def test_path_strategy_obvious_binary_rejects(tmp_path: Path, suffix: str) -> None:
|
||||
target = tmp_path / f"payload{suffix}"
|
||||
target.write_bytes(b"binary")
|
||||
with pytest.raises(ValueError, match="not suitable"):
|
||||
_path_strategy_hint(target)
|
||||
|
||||
|
||||
def test_path_strategy_nul_sample_rejects(tmp_path: Path) -> None:
|
||||
target = tmp_path / "payload.txt"
|
||||
target.write_bytes(b"abc\x00def")
|
||||
with pytest.raises(ValueError, match="NUL|not suitable"):
|
||||
_path_strategy_hint(target)
|
||||
|
||||
|
||||
def test_tui_help_includes_path_command() -> None:
|
||||
commands = DEFAULT_REGISTRY.for_surface(Surface.TUI)
|
||||
path_command = next(cmd for cmd in commands if cmd.name == "/path")
|
||||
|
||||
assert path_command.usage == "/path <path> [prompt]"
|
||||
assert "without uploading bytes" in path_command.description
|
||||
assert any(command.usage == "/path <path> [prompt]" for command in REGISTRY)
|
||||
@@ -0,0 +1,495 @@
|
||||
"""Unit tests for CLI clarify_form pure helpers and async prompter (PR6)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from opensquilla.cli.repl.clarify_form import (
|
||||
ClarifyFormResult,
|
||||
coerce_field_input,
|
||||
fields_to_chat_send_message,
|
||||
is_cancel_token,
|
||||
prompt_clarify_form,
|
||||
render_field_label,
|
||||
)
|
||||
|
||||
# ── render_field_label ──
|
||||
|
||||
def test_label_string_required_with_prompt():
|
||||
label = render_field_label({
|
||||
"name": "destination", "type": "string", "required": True,
|
||||
"prompt": "目的地",
|
||||
})
|
||||
assert label == "destination (string, required): 目的地"
|
||||
|
||||
|
||||
def test_label_int_with_range():
|
||||
label = render_field_label({
|
||||
"name": "days", "type": "int", "required": True,
|
||||
"prompt": "天数", "min": 1, "max": 14,
|
||||
})
|
||||
assert label == "days (int 1-14, required): 天数"
|
||||
|
||||
|
||||
def test_label_enum_with_choices_and_default():
|
||||
label = render_field_label({
|
||||
"name": "budget", "type": "enum", "required": False,
|
||||
"prompt": "预算", "choices": ["budget", "mid", "premium"],
|
||||
"default": "mid",
|
||||
})
|
||||
assert label == "budget (enum [budget|mid|premium], default=mid): 预算"
|
||||
|
||||
|
||||
def test_label_string_with_max_chars():
|
||||
label = render_field_label({
|
||||
"name": "notes", "type": "string", "required": False,
|
||||
"prompt": "备注", "max_chars": 100,
|
||||
})
|
||||
assert "≤100 chars" in label
|
||||
assert "optional" in label
|
||||
|
||||
|
||||
def test_label_int_only_min():
|
||||
label = render_field_label({
|
||||
"name": "n", "type": "int", "required": True, "min": 5,
|
||||
})
|
||||
assert ">=5" in label
|
||||
|
||||
|
||||
def test_label_int_only_max():
|
||||
label = render_field_label({
|
||||
"name": "n", "type": "int", "required": True, "max": 100,
|
||||
})
|
||||
assert "<=100" in label
|
||||
|
||||
|
||||
def test_label_without_prompt():
|
||||
label = render_field_label({"name": "x", "type": "string", "required": True})
|
||||
assert label == "x (string, required)"
|
||||
|
||||
|
||||
# ── coerce_field_input ──
|
||||
|
||||
def test_coerce_string_simple():
|
||||
value, err = coerce_field_input(
|
||||
{"name": "x", "type": "string", "required": True}, "Tokyo",
|
||||
)
|
||||
assert err is None
|
||||
assert value == "Tokyo"
|
||||
|
||||
|
||||
def test_coerce_string_max_chars_exceeded():
|
||||
value, err = coerce_field_input(
|
||||
{"name": "n", "type": "string", "required": True, "max_chars": 5},
|
||||
"longer than five",
|
||||
)
|
||||
assert value is None
|
||||
assert "max_chars" in err
|
||||
|
||||
|
||||
def test_coerce_int_valid():
|
||||
value, err = coerce_field_input(
|
||||
{"name": "days", "type": "int", "required": True, "min": 1, "max": 14},
|
||||
"5",
|
||||
)
|
||||
assert err is None
|
||||
assert value == 5
|
||||
|
||||
|
||||
def test_coerce_int_non_numeric():
|
||||
value, err = coerce_field_input(
|
||||
{"name": "days", "type": "int", "required": True}, "abc",
|
||||
)
|
||||
assert value is None
|
||||
assert "integer" in err
|
||||
|
||||
|
||||
def test_coerce_int_below_min():
|
||||
value, err = coerce_field_input(
|
||||
{"name": "days", "type": "int", "required": True, "min": 1, "max": 14},
|
||||
"0",
|
||||
)
|
||||
assert value is None
|
||||
assert "min=1" in err
|
||||
|
||||
|
||||
def test_coerce_int_above_max():
|
||||
value, err = coerce_field_input(
|
||||
{"name": "days", "type": "int", "required": True, "min": 1, "max": 14},
|
||||
"100",
|
||||
)
|
||||
assert value is None
|
||||
assert "max=14" in err
|
||||
|
||||
|
||||
def test_coerce_bool_true_variants():
|
||||
for s in ("true", "True", "yes", "Y", "1", "是"):
|
||||
value, err = coerce_field_input(
|
||||
{"name": "b", "type": "bool", "required": True}, s,
|
||||
)
|
||||
assert err is None, s
|
||||
assert value is True, s
|
||||
|
||||
|
||||
def test_coerce_bool_false_variants():
|
||||
for s in ("false", "False", "no", "N", "0", "否"):
|
||||
value, err = coerce_field_input(
|
||||
{"name": "b", "type": "bool", "required": True}, s,
|
||||
)
|
||||
assert err is None, s
|
||||
assert value is False, s
|
||||
|
||||
|
||||
def test_coerce_bool_invalid():
|
||||
value, err = coerce_field_input(
|
||||
{"name": "b", "type": "bool", "required": True}, "maybe",
|
||||
)
|
||||
assert value is None
|
||||
assert "bool" in err
|
||||
|
||||
|
||||
def test_coerce_enum_valid():
|
||||
value, err = coerce_field_input(
|
||||
{"name": "budget", "type": "enum", "required": True,
|
||||
"choices": ["budget", "mid", "premium"]},
|
||||
"mid",
|
||||
)
|
||||
assert err is None
|
||||
assert value == "mid"
|
||||
|
||||
|
||||
def test_coerce_enum_invalid():
|
||||
value, err = coerce_field_input(
|
||||
{"name": "budget", "type": "enum", "required": True,
|
||||
"choices": ["budget", "mid", "premium"]},
|
||||
"luxury",
|
||||
)
|
||||
assert value is None
|
||||
assert "luxury" in err
|
||||
|
||||
|
||||
def test_coerce_required_empty_errors():
|
||||
value, err = coerce_field_input(
|
||||
{"name": "x", "type": "string", "required": True}, "",
|
||||
)
|
||||
assert value is None
|
||||
assert "required" in err
|
||||
|
||||
|
||||
def test_coerce_optional_empty_is_silent():
|
||||
"""Empty input on an optional field returns (None, None) so the caller
|
||||
can simply skip the field — not an error condition."""
|
||||
value, err = coerce_field_input(
|
||||
{"name": "x", "type": "string", "required": False}, "",
|
||||
)
|
||||
assert value is None
|
||||
assert err is None
|
||||
|
||||
|
||||
def test_coerce_whitespace_only_treated_as_empty():
|
||||
value, err = coerce_field_input(
|
||||
{"name": "x", "type": "string", "required": True}, " ",
|
||||
)
|
||||
assert value is None
|
||||
assert "required" in err
|
||||
|
||||
|
||||
# ── is_cancel_token ──
|
||||
|
||||
def test_cancel_token_exact_match():
|
||||
assert is_cancel_token("cancel", ("cancel", "取消"))
|
||||
|
||||
|
||||
def test_cancel_token_case_insensitive():
|
||||
assert is_cancel_token("CANCEL", ("cancel",))
|
||||
|
||||
|
||||
def test_cancel_token_trims_whitespace():
|
||||
assert is_cancel_token(" cancel ", ("cancel",))
|
||||
|
||||
|
||||
def test_cancel_token_no_match_when_substring():
|
||||
"""The token must match the WHOLE input, not be a substring of it."""
|
||||
assert not is_cancel_token("I want to cancel my booking", ("cancel",))
|
||||
|
||||
|
||||
def test_cancel_token_empty_keywords_false():
|
||||
assert not is_cancel_token("anything", ())
|
||||
|
||||
|
||||
def test_cancel_token_cjk():
|
||||
assert is_cancel_token("取消", ("取消", "cancel"))
|
||||
|
||||
|
||||
# ── fields_to_chat_send_message ──
|
||||
|
||||
def test_fields_to_chat_send_message_basic():
|
||||
out = fields_to_chat_send_message({
|
||||
"destination": "Tokyo",
|
||||
"days": 5,
|
||||
})
|
||||
assert "destination: Tokyo" in out
|
||||
assert "days: 5" in out
|
||||
|
||||
|
||||
def test_fields_to_chat_send_message_bool_lowercase():
|
||||
out = fields_to_chat_send_message({"flag": True, "off": False})
|
||||
assert "flag: true" in out
|
||||
assert "off: false" in out
|
||||
|
||||
|
||||
def test_fields_to_chat_send_message_skips_empty():
|
||||
out = fields_to_chat_send_message({
|
||||
"destination": "Tokyo",
|
||||
"notes": "",
|
||||
"extra": None,
|
||||
})
|
||||
assert "destination: Tokyo" in out
|
||||
assert "notes" not in out
|
||||
assert "extra" not in out
|
||||
|
||||
|
||||
# ── prompt_clarify_form (async, stubbed prompt) ──
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_form_happy_path():
|
||||
"""Walk through 2 fields with valid input; collect both."""
|
||||
schema = {
|
||||
"intro": "trip facts please",
|
||||
"fields": [
|
||||
{"name": "destination", "type": "string", "required": True,
|
||||
"prompt": "目的地"},
|
||||
{"name": "days", "type": "int", "required": True, "min": 1, "max": 14,
|
||||
"prompt": "天数"},
|
||||
],
|
||||
"cancel_keywords": [],
|
||||
}
|
||||
answers = iter(["Tokyo", "5"])
|
||||
out_lines: list[str] = []
|
||||
|
||||
async def _stub_prompt(prefix: str) -> str | None:
|
||||
return next(answers)
|
||||
|
||||
result = await prompt_clarify_form(
|
||||
schema, prompt_fn=_stub_prompt, writer=out_lines.append,
|
||||
)
|
||||
assert result.cancelled is False
|
||||
assert result.fields == {"destination": "Tokyo", "days": 5}
|
||||
# intro was printed
|
||||
assert any("trip facts" in line for line in out_lines)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_form_retries_on_validation_error():
|
||||
"""First answer fails int validation; second succeeds; field collected."""
|
||||
schema = {
|
||||
"fields": [
|
||||
{"name": "days", "type": "int", "required": True, "min": 1, "max": 14},
|
||||
],
|
||||
"cancel_keywords": [],
|
||||
}
|
||||
answers = iter(["abc", "5"])
|
||||
out_lines: list[str] = []
|
||||
|
||||
async def _stub(prefix: str) -> str | None:
|
||||
return next(answers)
|
||||
|
||||
result = await prompt_clarify_form(
|
||||
schema, prompt_fn=_stub, writer=out_lines.append,
|
||||
)
|
||||
assert result.cancelled is False
|
||||
assert result.fields == {"days": 5}
|
||||
# error message was printed for the bad input
|
||||
assert any("integer" in line for line in out_lines)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_form_cancel_keyword_bails():
|
||||
schema = {
|
||||
"fields": [
|
||||
{"name": "x", "type": "string", "required": True},
|
||||
{"name": "y", "type": "string", "required": True},
|
||||
],
|
||||
"cancel_keywords": ["cancel"],
|
||||
}
|
||||
answers = iter(["Tokyo", "cancel"])
|
||||
|
||||
async def _stub(prefix: str) -> str | None:
|
||||
return next(answers)
|
||||
|
||||
result = await prompt_clarify_form(schema, prompt_fn=_stub, writer=lambda _: None)
|
||||
assert result.cancelled is True
|
||||
assert result.fields == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_form_eof_bails():
|
||||
"""Ctrl-D (prompt_fn returns None) cancels the whole form."""
|
||||
schema = {
|
||||
"fields": [
|
||||
{"name": "x", "type": "string", "required": True},
|
||||
],
|
||||
"cancel_keywords": [],
|
||||
}
|
||||
|
||||
async def _stub(prefix: str) -> str | None:
|
||||
return None
|
||||
|
||||
result = await prompt_clarify_form(schema, prompt_fn=_stub, writer=lambda _: None)
|
||||
assert result.cancelled is True
|
||||
assert result.fields == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_form_optional_field_can_be_skipped():
|
||||
schema = {
|
||||
"fields": [
|
||||
{"name": "destination", "type": "string", "required": True},
|
||||
{"name": "notes", "type": "string", "required": False},
|
||||
],
|
||||
"cancel_keywords": [],
|
||||
}
|
||||
answers = iter(["Tokyo", ""])
|
||||
|
||||
async def _stub(prefix: str) -> str | None:
|
||||
return next(answers)
|
||||
|
||||
result = await prompt_clarify_form(schema, prompt_fn=_stub, writer=lambda _: None)
|
||||
assert result.cancelled is False
|
||||
assert result.fields == {"destination": "Tokyo"}
|
||||
|
||||
|
||||
def test_result_dataclass_minimal():
|
||||
r = ClarifyFormResult(fields={"x": 1}, cancelled=False)
|
||||
assert r.fields == {"x": 1}
|
||||
assert r.cancelled is False
|
||||
|
||||
|
||||
# ── Surface rendering of (d) protocol: confirmed / ambiguous / unknowns ──
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_form_renders_confirmed_fields_and_accepts_enter() -> None:
|
||||
"""When the schema carries ``confirmed_fields`` from the prefill
|
||||
scan, the user must see the inferred values up-front and be able
|
||||
to accept each by hitting Enter on an empty line. The accepted
|
||||
value lands in the result exactly as the audit reported."""
|
||||
schema = {
|
||||
"mode": "form",
|
||||
"intro": "",
|
||||
"fields": [
|
||||
{"name": "destination", "type": "string", "required": True, "prompt": "city"},
|
||||
{
|
||||
"name": "days",
|
||||
"type": "int",
|
||||
"required": True,
|
||||
"prompt": "days",
|
||||
"min": 1,
|
||||
"max": 30,
|
||||
},
|
||||
],
|
||||
"cancel_keywords": [],
|
||||
"timeout_hours": 24,
|
||||
"confirmed_fields": [
|
||||
{"name": "destination", "value": "Tokyo", "source": "auto_prefill"},
|
||||
],
|
||||
"ambiguous_fields": [
|
||||
{"name": "days", "reason": "duration not stated"},
|
||||
],
|
||||
"unknown_mentions": [],
|
||||
}
|
||||
answers = iter(["", "5"]) # Enter on destination → confirm; "5" for days
|
||||
|
||||
async def _stub(_prefix: str) -> str | None:
|
||||
return next(answers)
|
||||
|
||||
out: list[str] = []
|
||||
result = await prompt_clarify_form(schema, prompt_fn=_stub, writer=out.append)
|
||||
|
||||
assert result.cancelled is False
|
||||
assert result.fields == {"destination": "Tokyo", "days": 5}
|
||||
transcript = "\n".join(out)
|
||||
assert "noticed details" in transcript
|
||||
assert "destination" in transcript
|
||||
assert "Tokyo" in transcript
|
||||
assert "duration not stated" in transcript
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_form_confirmed_field_can_be_overridden() -> None:
|
||||
"""A user who disagrees with the inferred value must be able to
|
||||
type a new value to override. The overridden value wins; the
|
||||
confirmed value is discarded."""
|
||||
schema = {
|
||||
"mode": "form",
|
||||
"intro": "",
|
||||
"fields": [
|
||||
{"name": "destination", "type": "string", "required": True, "prompt": "city"},
|
||||
],
|
||||
"cancel_keywords": [],
|
||||
"timeout_hours": 24,
|
||||
"confirmed_fields": [
|
||||
{"name": "destination", "value": "Tokyo", "source": "auto_prefill"},
|
||||
],
|
||||
}
|
||||
|
||||
async def _stub(_prefix: str) -> str | None:
|
||||
return "Osaka"
|
||||
|
||||
result = await prompt_clarify_form(schema, prompt_fn=_stub, writer=lambda _: None)
|
||||
assert result.cancelled is False
|
||||
assert result.fields == {"destination": "Osaka"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_form_renders_unknown_mentions() -> None:
|
||||
"""``unknown_mentions`` must surface verbatim so the user knows the
|
||||
system noticed something it could not map to a field."""
|
||||
schema = {
|
||||
"mode": "form",
|
||||
"intro": "",
|
||||
"fields": [
|
||||
{"name": "destination", "type": "string", "required": True, "prompt": "city"},
|
||||
],
|
||||
"cancel_keywords": [],
|
||||
"timeout_hours": 24,
|
||||
"confirmed_fields": [],
|
||||
"ambiguous_fields": [],
|
||||
"unknown_mentions": [
|
||||
{"text": "next month", "guess": "departure timing?"},
|
||||
],
|
||||
}
|
||||
|
||||
async def _stub(_prefix: str) -> str | None:
|
||||
return "Tokyo"
|
||||
|
||||
out: list[str] = []
|
||||
await prompt_clarify_form(schema, prompt_fn=_stub, writer=out.append)
|
||||
transcript = "\n".join(out)
|
||||
assert "next month" in transcript
|
||||
assert "departure timing?" in transcript
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_form_without_prefill_payload_unchanged() -> None:
|
||||
"""Backwards compatibility: a schema without any prefill payload
|
||||
must render exactly as before — no transparency header, no extra
|
||||
blocks, no behavioural change."""
|
||||
schema = {
|
||||
"mode": "form",
|
||||
"intro": "",
|
||||
"fields": [
|
||||
{"name": "destination", "type": "string", "required": True, "prompt": "city"},
|
||||
],
|
||||
"cancel_keywords": [],
|
||||
"timeout_hours": 24,
|
||||
}
|
||||
|
||||
async def _stub(_prefix: str) -> str | None:
|
||||
return "Tokyo"
|
||||
|
||||
out: list[str] = []
|
||||
result = await prompt_clarify_form(schema, prompt_fn=_stub, writer=out.append)
|
||||
assert result.fields == {"destination": "Tokyo"}
|
||||
transcript = "\n".join(out)
|
||||
assert "noticed details" not in transcript
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,921 @@
|
||||
"""CLI cron command coverage.
|
||||
|
||||
Verifies the new helpers (_parse_duration_seconds, _resolve_webhook_token,
|
||||
_build_delivery_params) and that cron add/update build the right RPC params
|
||||
when the new flags are exercised. Network is stubbed so we never actually
|
||||
hit a gateway.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import typer
|
||||
|
||||
from opensquilla.cli import cron_cmd
|
||||
|
||||
# --- _parse_duration_seconds ---------------------------------------------
|
||||
|
||||
|
||||
def test_parse_duration_none_returns_none() -> None:
|
||||
assert cron_cmd._parse_duration_seconds(None) is None
|
||||
assert cron_cmd._parse_duration_seconds("") is None
|
||||
|
||||
|
||||
def test_parse_duration_seconds_suffix() -> None:
|
||||
assert cron_cmd._parse_duration_seconds("30s") == 30.0
|
||||
assert cron_cmd._parse_duration_seconds("5m") == 300.0
|
||||
assert cron_cmd._parse_duration_seconds("1h") == 3600.0
|
||||
assert cron_cmd._parse_duration_seconds("90sec") == 90.0
|
||||
assert cron_cmd._parse_duration_seconds("2hrs") == 7200.0
|
||||
|
||||
|
||||
def test_parse_duration_plain_number() -> None:
|
||||
assert cron_cmd._parse_duration_seconds("45") == 45.0
|
||||
assert cron_cmd._parse_duration_seconds("0") == 0.0
|
||||
assert cron_cmd._parse_duration_seconds(60) == 60.0
|
||||
assert cron_cmd._parse_duration_seconds(60.5) == 60.5
|
||||
|
||||
|
||||
def test_parse_duration_rejects_garbage() -> None:
|
||||
with pytest.raises(typer.BadParameter):
|
||||
cron_cmd._parse_duration_seconds("nope")
|
||||
with pytest.raises(typer.BadParameter):
|
||||
cron_cmd._parse_duration_seconds("5x")
|
||||
|
||||
|
||||
# --- _resolve_webhook_token ----------------------------------------------
|
||||
|
||||
|
||||
def test_webhook_token_none_when_no_source() -> None:
|
||||
assert cron_cmd._resolve_webhook_token(inline=None, env=None, path=None) is None
|
||||
|
||||
|
||||
def test_webhook_token_from_env(monkeypatch) -> None:
|
||||
monkeypatch.setenv("MY_HOOK_TOKEN", "secret-bearer")
|
||||
assert (
|
||||
cron_cmd._resolve_webhook_token(inline=None, env="MY_HOOK_TOKEN", path=None)
|
||||
== "secret-bearer"
|
||||
)
|
||||
|
||||
|
||||
def test_webhook_token_from_env_unset(monkeypatch) -> None:
|
||||
monkeypatch.delenv("UNSET_TOKEN", raising=False)
|
||||
with pytest.raises(typer.BadParameter, match="UNSET_TOKEN"):
|
||||
cron_cmd._resolve_webhook_token(inline=None, env="UNSET_TOKEN", path=None)
|
||||
|
||||
|
||||
def test_webhook_token_from_file(tmp_path: Path) -> None:
|
||||
secret = tmp_path / "token.txt"
|
||||
secret.write_text(" file-token \n", encoding="utf-8")
|
||||
assert (
|
||||
cron_cmd._resolve_webhook_token(inline=None, env=None, path=str(secret))
|
||||
== "file-token"
|
||||
)
|
||||
|
||||
|
||||
def test_webhook_token_inline_emits_warning(capsys) -> None:
|
||||
out = cron_cmd._resolve_webhook_token(
|
||||
inline="raw-token", env=None, path=None
|
||||
)
|
||||
assert out == "raw-token"
|
||||
err = capsys.readouterr().err
|
||||
assert "shell history" in err
|
||||
|
||||
|
||||
def test_webhook_token_rejects_multiple_sources(monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setenv("T", "x")
|
||||
secret = tmp_path / "t.txt"
|
||||
secret.write_text("y", encoding="utf-8")
|
||||
with pytest.raises(typer.BadParameter, match="at most one"):
|
||||
cron_cmd._resolve_webhook_token(inline=None, env="T", path=str(secret))
|
||||
|
||||
|
||||
# --- _build_delivery_params ---------------------------------------------
|
||||
|
||||
|
||||
def test_build_delivery_none_when_no_flags() -> None:
|
||||
assert (
|
||||
cron_cmd._build_delivery_params(
|
||||
announce=False,
|
||||
no_deliver=False,
|
||||
channel=None,
|
||||
to=None,
|
||||
account=None,
|
||||
best_effort=False,
|
||||
webhook_url=None,
|
||||
webhook_token=None,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_build_delivery_announce_with_target() -> None:
|
||||
d = cron_cmd._build_delivery_params(
|
||||
announce=True,
|
||||
no_deliver=False,
|
||||
channel="slack",
|
||||
to="C123",
|
||||
account=None,
|
||||
best_effort=False,
|
||||
webhook_url=None,
|
||||
webhook_token=None,
|
||||
)
|
||||
assert d == {"mode": "announce", "channelName": "slack", "to": "C123"}
|
||||
|
||||
|
||||
def test_build_delivery_announce_with_last_omits_channel_name() -> None:
|
||||
"""'last' is a CLI sentinel meaning 'let the backend infer'."""
|
||||
d = cron_cmd._build_delivery_params(
|
||||
announce=True,
|
||||
no_deliver=False,
|
||||
channel="last",
|
||||
to=None,
|
||||
account=None,
|
||||
best_effort=False,
|
||||
webhook_url=None,
|
||||
webhook_token=None,
|
||||
)
|
||||
assert d == {"mode": "announce"}
|
||||
assert "channelName" not in d
|
||||
|
||||
|
||||
def test_build_delivery_no_deliver() -> None:
|
||||
d = cron_cmd._build_delivery_params(
|
||||
announce=False,
|
||||
no_deliver=True,
|
||||
channel=None,
|
||||
to=None,
|
||||
account=None,
|
||||
best_effort=False,
|
||||
webhook_url=None,
|
||||
webhook_token=None,
|
||||
)
|
||||
assert d == {"mode": "none"}
|
||||
|
||||
|
||||
def test_build_delivery_webhook() -> None:
|
||||
d = cron_cmd._build_delivery_params(
|
||||
announce=False,
|
||||
no_deliver=False,
|
||||
channel=None,
|
||||
to=None,
|
||||
account=None,
|
||||
best_effort=True,
|
||||
webhook_url="https://hooks.example/cron",
|
||||
webhook_token="bearer-x",
|
||||
)
|
||||
assert d == {
|
||||
"mode": "webhook",
|
||||
"webhookUrl": "https://hooks.example/cron",
|
||||
"webhookToken": "bearer-x",
|
||||
"bestEffort": True,
|
||||
}
|
||||
|
||||
|
||||
def test_build_delivery_mutex_webhook_vs_announce() -> None:
|
||||
with pytest.raises(typer.BadParameter, match="at most one"):
|
||||
cron_cmd._build_delivery_params(
|
||||
announce=True,
|
||||
no_deliver=False,
|
||||
channel=None,
|
||||
to=None,
|
||||
account=None,
|
||||
best_effort=False,
|
||||
webhook_url="https://hooks.example/x",
|
||||
webhook_token=None,
|
||||
)
|
||||
|
||||
|
||||
def test_build_delivery_mutex_announce_vs_no_deliver() -> None:
|
||||
with pytest.raises(typer.BadParameter, match="at most one"):
|
||||
cron_cmd._build_delivery_params(
|
||||
announce=True,
|
||||
no_deliver=True,
|
||||
channel=None,
|
||||
to=None,
|
||||
account=None,
|
||||
best_effort=False,
|
||||
webhook_url=None,
|
||||
webhook_token=None,
|
||||
)
|
||||
|
||||
|
||||
def test_build_delivery_token_without_url() -> None:
|
||||
with pytest.raises(typer.BadParameter, match="requires --webhook-url"):
|
||||
cron_cmd._build_delivery_params(
|
||||
announce=False,
|
||||
no_deliver=False,
|
||||
channel=None,
|
||||
to=None,
|
||||
account=None,
|
||||
best_effort=False,
|
||||
webhook_url=None,
|
||||
webhook_token="dangling-token",
|
||||
)
|
||||
|
||||
|
||||
def test_build_delivery_with_account_implies_announce() -> None:
|
||||
d = cron_cmd._build_delivery_params(
|
||||
announce=False,
|
||||
no_deliver=False,
|
||||
channel="slack",
|
||||
to="C123",
|
||||
account="acct-1",
|
||||
best_effort=True,
|
||||
webhook_url=None,
|
||||
webhook_token=None,
|
||||
)
|
||||
assert d == {
|
||||
"mode": "announce",
|
||||
"channelName": "slack",
|
||||
"to": "C123",
|
||||
"accountId": "acct-1",
|
||||
"bestEffort": True,
|
||||
}
|
||||
|
||||
|
||||
# --- _build_failure_destination_dict -------------------------------------
|
||||
|
||||
|
||||
def test_failure_dest_none_when_no_flags() -> None:
|
||||
assert (
|
||||
cron_cmd._build_failure_destination_dict(
|
||||
mode=None,
|
||||
channel=None,
|
||||
to=None,
|
||||
account=None,
|
||||
webhook_url=None,
|
||||
webhook_token=None,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_failure_dest_webhook() -> None:
|
||||
fd = cron_cmd._build_failure_destination_dict(
|
||||
mode="webhook",
|
||||
channel=None,
|
||||
to=None,
|
||||
account=None,
|
||||
webhook_url="https://hooks.example/alert",
|
||||
webhook_token="bearer-x",
|
||||
)
|
||||
assert fd == {
|
||||
"mode": "webhook",
|
||||
"webhookUrl": "https://hooks.example/alert",
|
||||
"webhookToken": "bearer-x",
|
||||
}
|
||||
|
||||
|
||||
def test_failure_dest_channel() -> None:
|
||||
fd = cron_cmd._build_failure_destination_dict(
|
||||
mode="channel",
|
||||
channel="Slack",
|
||||
to="C-ops",
|
||||
account="acct-1",
|
||||
webhook_url=None,
|
||||
webhook_token=None,
|
||||
)
|
||||
assert fd == {
|
||||
"mode": "channel",
|
||||
"channelName": "slack",
|
||||
"to": "C-ops",
|
||||
"accountId": "acct-1",
|
||||
}
|
||||
|
||||
|
||||
def test_failure_dest_webhook_requires_url() -> None:
|
||||
with pytest.raises(typer.BadParameter, match="--failure-webhook-url"):
|
||||
cron_cmd._build_failure_destination_dict(
|
||||
mode="webhook",
|
||||
channel=None,
|
||||
to=None,
|
||||
account=None,
|
||||
webhook_url=None,
|
||||
webhook_token=None,
|
||||
)
|
||||
|
||||
|
||||
def test_failure_dest_rejects_unknown_mode() -> None:
|
||||
with pytest.raises(typer.BadParameter, match="channel.*webhook"):
|
||||
cron_cmd._build_failure_destination_dict(
|
||||
mode="email",
|
||||
channel=None,
|
||||
to=None,
|
||||
account=None,
|
||||
webhook_url=None,
|
||||
webhook_token=None,
|
||||
)
|
||||
|
||||
|
||||
def test_failure_dest_flag_without_mode() -> None:
|
||||
with pytest.raises(typer.BadParameter, match="--failure-mode"):
|
||||
cron_cmd._build_failure_destination_dict(
|
||||
mode=None,
|
||||
channel="slack",
|
||||
to="C-x",
|
||||
account=None,
|
||||
webhook_url=None,
|
||||
webhook_token=None,
|
||||
)
|
||||
|
||||
|
||||
# --- cron_add / cron_update RPC param construction ------------------------
|
||||
|
||||
|
||||
class _StubClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
async def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
self.calls.append((method, params))
|
||||
return {"id": "stub", "method": method}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stub_gateway(monkeypatch):
|
||||
client = _StubClient()
|
||||
|
||||
def _runner(fn, *, json_output: bool = False): # noqa: ARG001
|
||||
import asyncio
|
||||
return asyncio.run(fn(client))
|
||||
|
||||
monkeypatch.setattr(cron_cmd, "run_gateway_sync", _runner)
|
||||
monkeypatch.setattr(cron_cmd, "confirm_or_exit", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(cron_cmd, "_emit_success", lambda *a, **kw: None)
|
||||
return client
|
||||
|
||||
|
||||
def test_cron_add_with_announce_and_channel(stub_gateway) -> None:
|
||||
cron_cmd.cron_add(
|
||||
expression="0 9 * * *",
|
||||
text="Daily brief",
|
||||
name=None,
|
||||
agent=None,
|
||||
session_target="isolated",
|
||||
timeout=None,
|
||||
tz=None,
|
||||
wake=None,
|
||||
exact=False,
|
||||
jitter=None,
|
||||
announce=True,
|
||||
no_deliver=False,
|
||||
channel="slack",
|
||||
to="C123",
|
||||
account=None,
|
||||
best_effort_deliver=False,
|
||||
webhook_url=None,
|
||||
webhook_token=None,
|
||||
webhook_token_env=None,
|
||||
webhook_token_file=None,
|
||||
failure_mode=None,
|
||||
failure_channel=None,
|
||||
failure_to=None,
|
||||
failure_account=None,
|
||||
failure_webhook_url=None,
|
||||
failure_webhook_token=None,
|
||||
failure_webhook_token_env=None,
|
||||
failure_webhook_token_file=None,
|
||||
json_output=False,
|
||||
)
|
||||
assert stub_gateway.calls, "no RPC call was issued"
|
||||
method, params = stub_gateway.calls[-1]
|
||||
assert method == "cron.add"
|
||||
assert params["delivery"] == {
|
||||
"mode": "announce",
|
||||
"channelName": "slack",
|
||||
"to": "C123",
|
||||
}
|
||||
|
||||
|
||||
def test_cron_add_rejects_current_target_without_session_binding(stub_gateway) -> None:
|
||||
with pytest.raises(typer.BadParameter, match="current.*session-bound"):
|
||||
cron_cmd.cron_add(
|
||||
expression="0 9 * * *",
|
||||
text="Daily brief",
|
||||
name=None,
|
||||
agent=None,
|
||||
session_target="current",
|
||||
timeout=None,
|
||||
tz=None,
|
||||
wake=None,
|
||||
exact=False,
|
||||
jitter=None,
|
||||
announce=False,
|
||||
no_deliver=False,
|
||||
channel=None,
|
||||
to=None,
|
||||
account=None,
|
||||
best_effort_deliver=False,
|
||||
webhook_url=None,
|
||||
webhook_token=None,
|
||||
webhook_token_env=None,
|
||||
webhook_token_file=None,
|
||||
failure_mode=None,
|
||||
failure_channel=None,
|
||||
failure_to=None,
|
||||
failure_account=None,
|
||||
failure_webhook_url=None,
|
||||
failure_webhook_token=None,
|
||||
failure_webhook_token_env=None,
|
||||
failure_webhook_token_file=None,
|
||||
json_output=False,
|
||||
)
|
||||
|
||||
assert stub_gateway.calls == []
|
||||
|
||||
|
||||
def test_cron_add_with_webhook(stub_gateway, monkeypatch) -> None:
|
||||
monkeypatch.setenv("HOOK_TOKEN", "from-env")
|
||||
cron_cmd.cron_add(
|
||||
expression="*/5 * * * *",
|
||||
text="poke",
|
||||
name=None,
|
||||
agent=None,
|
||||
session_target="isolated",
|
||||
timeout=None,
|
||||
tz=None,
|
||||
wake=None,
|
||||
exact=False,
|
||||
jitter=None,
|
||||
announce=False,
|
||||
no_deliver=False,
|
||||
channel=None,
|
||||
to=None,
|
||||
account=None,
|
||||
best_effort_deliver=False,
|
||||
webhook_url="https://hooks.example/cron",
|
||||
webhook_token=None,
|
||||
webhook_token_env="HOOK_TOKEN",
|
||||
webhook_token_file=None,
|
||||
failure_mode=None,
|
||||
failure_channel=None,
|
||||
failure_to=None,
|
||||
failure_account=None,
|
||||
failure_webhook_url=None,
|
||||
failure_webhook_token=None,
|
||||
failure_webhook_token_env=None,
|
||||
failure_webhook_token_file=None,
|
||||
json_output=False,
|
||||
)
|
||||
method, params = stub_gateway.calls[-1]
|
||||
assert method == "cron.add"
|
||||
assert params["delivery"] == {
|
||||
"mode": "webhook",
|
||||
"webhookUrl": "https://hooks.example/cron",
|
||||
"webhookToken": "from-env",
|
||||
}
|
||||
|
||||
|
||||
def test_cron_add_with_failure_destination(stub_gateway) -> None:
|
||||
cron_cmd.cron_add(
|
||||
expression="0 9 * * *",
|
||||
text="Daily brief",
|
||||
name=None,
|
||||
agent=None,
|
||||
session_target="isolated",
|
||||
timeout=None,
|
||||
tz=None,
|
||||
wake=None,
|
||||
exact=False,
|
||||
jitter=None,
|
||||
announce=True,
|
||||
no_deliver=False,
|
||||
channel="slack",
|
||||
to="C123",
|
||||
account=None,
|
||||
best_effort_deliver=False,
|
||||
webhook_url=None,
|
||||
webhook_token=None,
|
||||
webhook_token_env=None,
|
||||
webhook_token_file=None,
|
||||
failure_mode="webhook",
|
||||
failure_channel=None,
|
||||
failure_to=None,
|
||||
failure_account=None,
|
||||
failure_webhook_url="https://hooks.example/alert",
|
||||
failure_webhook_token=None,
|
||||
failure_webhook_token_env=None,
|
||||
failure_webhook_token_file=None,
|
||||
json_output=False,
|
||||
)
|
||||
method, params = stub_gateway.calls[-1]
|
||||
assert method == "cron.add"
|
||||
assert params["delivery"] == {
|
||||
"mode": "announce",
|
||||
"channelName": "slack",
|
||||
"to": "C123",
|
||||
"failureDestination": {
|
||||
"mode": "webhook",
|
||||
"webhookUrl": "https://hooks.example/alert",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_cron_add_with_wake_and_jitter_duration(stub_gateway) -> None:
|
||||
cron_cmd.cron_add(
|
||||
expression="*/5 * * * *",
|
||||
text="x",
|
||||
name=None,
|
||||
agent=None,
|
||||
session_target="main",
|
||||
timeout=None,
|
||||
tz=None,
|
||||
wake="next-heartbeat",
|
||||
exact=False,
|
||||
jitter="30s",
|
||||
announce=False,
|
||||
no_deliver=False,
|
||||
channel=None,
|
||||
to=None,
|
||||
account=None,
|
||||
best_effort_deliver=False,
|
||||
webhook_url=None,
|
||||
webhook_token=None,
|
||||
webhook_token_env=None,
|
||||
webhook_token_file=None,
|
||||
failure_mode=None,
|
||||
failure_channel=None,
|
||||
failure_to=None,
|
||||
failure_account=None,
|
||||
failure_webhook_url=None,
|
||||
failure_webhook_token=None,
|
||||
failure_webhook_token_env=None,
|
||||
failure_webhook_token_file=None,
|
||||
json_output=False,
|
||||
)
|
||||
method, params = stub_gateway.calls[-1]
|
||||
assert params["wakeMode"] == "next-heartbeat"
|
||||
assert params["jitterSeconds"] == 30.0
|
||||
assert "delivery" not in params # no delivery flags → not sent
|
||||
|
||||
|
||||
def test_cron_add_with_every_builds_canonical_schedule(stub_gateway) -> None:
|
||||
cron_cmd.cron_add(
|
||||
expression=None,
|
||||
cron=None,
|
||||
every="5m",
|
||||
at=None,
|
||||
text="Drink water",
|
||||
name=None,
|
||||
agent=None,
|
||||
session_target="isolated",
|
||||
timeout=None,
|
||||
tz=None,
|
||||
wake=None,
|
||||
exact=False,
|
||||
jitter=None,
|
||||
announce=False,
|
||||
no_deliver=False,
|
||||
channel=None,
|
||||
to=None,
|
||||
account=None,
|
||||
best_effort_deliver=False,
|
||||
webhook_url=None,
|
||||
webhook_token=None,
|
||||
webhook_token_env=None,
|
||||
webhook_token_file=None,
|
||||
failure_mode=None,
|
||||
failure_channel=None,
|
||||
failure_to=None,
|
||||
failure_account=None,
|
||||
failure_webhook_url=None,
|
||||
failure_webhook_token=None,
|
||||
failure_webhook_token_env=None,
|
||||
failure_webhook_token_file=None,
|
||||
json_output=False,
|
||||
)
|
||||
|
||||
method, params = stub_gateway.calls[-1]
|
||||
assert method == "cron.add"
|
||||
assert params["schedule"] == {"kind": "every", "every_seconds": 300}
|
||||
assert params["payloadKind"] == "reminder"
|
||||
assert "expression" not in params
|
||||
|
||||
|
||||
def test_cron_add_rejects_multiple_schedule_sources(stub_gateway) -> None:
|
||||
with pytest.raises(typer.BadParameter, match="exactly one"):
|
||||
cron_cmd.cron_add(
|
||||
expression="*/5 * * * *",
|
||||
cron="0 9 * * *",
|
||||
every=None,
|
||||
at=None,
|
||||
text="x",
|
||||
name=None,
|
||||
agent=None,
|
||||
session_target="isolated",
|
||||
timeout=None,
|
||||
tz=None,
|
||||
wake=None,
|
||||
exact=False,
|
||||
jitter=None,
|
||||
announce=False,
|
||||
no_deliver=False,
|
||||
channel=None,
|
||||
to=None,
|
||||
account=None,
|
||||
best_effort_deliver=False,
|
||||
webhook_url=None,
|
||||
webhook_token=None,
|
||||
webhook_token_env=None,
|
||||
webhook_token_file=None,
|
||||
failure_mode=None,
|
||||
failure_channel=None,
|
||||
failure_to=None,
|
||||
failure_account=None,
|
||||
failure_webhook_url=None,
|
||||
failure_webhook_token=None,
|
||||
failure_webhook_token_env=None,
|
||||
failure_webhook_token_file=None,
|
||||
json_output=False,
|
||||
)
|
||||
|
||||
|
||||
def test_cron_add_rejects_fractional_every(stub_gateway) -> None:
|
||||
with pytest.raises(typer.BadParameter, match="whole seconds"):
|
||||
cron_cmd.cron_add(
|
||||
expression=None,
|
||||
cron=None,
|
||||
every="1.9s",
|
||||
at=None,
|
||||
text="x",
|
||||
name=None,
|
||||
agent=None,
|
||||
session_target="isolated",
|
||||
timeout=None,
|
||||
tz=None,
|
||||
wake=None,
|
||||
exact=False,
|
||||
jitter=None,
|
||||
announce=False,
|
||||
no_deliver=False,
|
||||
channel=None,
|
||||
to=None,
|
||||
account=None,
|
||||
best_effort_deliver=False,
|
||||
webhook_url=None,
|
||||
webhook_token=None,
|
||||
webhook_token_env=None,
|
||||
webhook_token_file=None,
|
||||
failure_mode=None,
|
||||
failure_channel=None,
|
||||
failure_to=None,
|
||||
failure_account=None,
|
||||
failure_webhook_url=None,
|
||||
failure_webhook_token=None,
|
||||
failure_webhook_token_env=None,
|
||||
failure_webhook_token_file=None,
|
||||
json_output=False,
|
||||
)
|
||||
|
||||
assert stub_gateway.calls == []
|
||||
|
||||
|
||||
def test_cron_add_rejects_invalid_wake(stub_gateway) -> None:
|
||||
with pytest.raises(typer.BadParameter, match="now or next-heartbeat"):
|
||||
cron_cmd.cron_add(
|
||||
expression="*/5 * * * *",
|
||||
text="x",
|
||||
name=None,
|
||||
agent=None,
|
||||
session_target="main",
|
||||
timeout=None,
|
||||
tz=None,
|
||||
wake="LATER",
|
||||
exact=False,
|
||||
jitter=None,
|
||||
announce=False,
|
||||
no_deliver=False,
|
||||
channel=None,
|
||||
to=None,
|
||||
account=None,
|
||||
best_effort_deliver=False,
|
||||
webhook_url=None,
|
||||
webhook_token=None,
|
||||
webhook_token_env=None,
|
||||
webhook_token_file=None,
|
||||
json_output=False,
|
||||
)
|
||||
|
||||
|
||||
def test_cron_update_with_wake(stub_gateway) -> None:
|
||||
cron_cmd.cron_update(
|
||||
job_id="job-1",
|
||||
expression=None,
|
||||
cron=None,
|
||||
every=None,
|
||||
at=None,
|
||||
text=None,
|
||||
name=None,
|
||||
enabled=None,
|
||||
timeout=None,
|
||||
tz=None,
|
||||
wake="now",
|
||||
failure_mode=None,
|
||||
failure_channel=None,
|
||||
failure_to=None,
|
||||
failure_account=None,
|
||||
failure_webhook_url=None,
|
||||
failure_webhook_token=None,
|
||||
failure_webhook_token_env=None,
|
||||
failure_webhook_token_file=None,
|
||||
json_output=False,
|
||||
)
|
||||
method, params = stub_gateway.calls[-1]
|
||||
assert method == "cron.update"
|
||||
assert params["id"] == "job-1"
|
||||
assert params["wakeMode"] == "now"
|
||||
|
||||
|
||||
def test_cron_update_with_every_builds_canonical_schedule(stub_gateway) -> None:
|
||||
cron_cmd.cron_update(
|
||||
job_id="job-1",
|
||||
expression=None,
|
||||
cron=None,
|
||||
every="10m",
|
||||
at=None,
|
||||
text=None,
|
||||
name=None,
|
||||
enabled=None,
|
||||
timeout=None,
|
||||
tz=None,
|
||||
wake=None,
|
||||
failure_mode=None,
|
||||
failure_channel=None,
|
||||
failure_to=None,
|
||||
failure_account=None,
|
||||
failure_webhook_url=None,
|
||||
failure_webhook_token=None,
|
||||
failure_webhook_token_env=None,
|
||||
failure_webhook_token_file=None,
|
||||
json_output=False,
|
||||
)
|
||||
|
||||
method, params = stub_gateway.calls[-1]
|
||||
assert method == "cron.update"
|
||||
assert params["schedule"] == {"kind": "every", "every_seconds": 600}
|
||||
assert "expression" not in params
|
||||
|
||||
|
||||
def test_cron_update_with_tz_only_sends_timezone_patch(stub_gateway) -> None:
|
||||
cron_cmd.cron_update(
|
||||
job_id="job-1",
|
||||
expression=None,
|
||||
cron=None,
|
||||
every=None,
|
||||
at=None,
|
||||
text=None,
|
||||
name=None,
|
||||
enabled=None,
|
||||
timeout=None,
|
||||
tz="Asia/Shanghai",
|
||||
wake=None,
|
||||
failure_mode=None,
|
||||
failure_channel=None,
|
||||
failure_to=None,
|
||||
failure_account=None,
|
||||
failure_webhook_url=None,
|
||||
failure_webhook_token=None,
|
||||
failure_webhook_token_env=None,
|
||||
failure_webhook_token_file=None,
|
||||
json_output=False,
|
||||
)
|
||||
|
||||
method, params = stub_gateway.calls[-1]
|
||||
assert method == "cron.update"
|
||||
assert params == {"id": "job-1", "tz": "Asia/Shanghai"}
|
||||
|
||||
|
||||
def test_cron_update_rejects_multiple_schedule_sources(stub_gateway) -> None:
|
||||
with pytest.raises(typer.BadParameter, match="at most one"):
|
||||
cron_cmd.cron_update(
|
||||
job_id="job-1",
|
||||
expression="*/5 * * * *",
|
||||
cron="0 9 * * *",
|
||||
every=None,
|
||||
at=None,
|
||||
text=None,
|
||||
name=None,
|
||||
enabled=None,
|
||||
timeout=None,
|
||||
tz=None,
|
||||
wake=None,
|
||||
failure_mode=None,
|
||||
failure_channel=None,
|
||||
failure_to=None,
|
||||
failure_account=None,
|
||||
failure_webhook_url=None,
|
||||
failure_webhook_token=None,
|
||||
failure_webhook_token_env=None,
|
||||
failure_webhook_token_file=None,
|
||||
json_output=False,
|
||||
)
|
||||
|
||||
|
||||
def test_cron_update_requires_at_least_one_field(stub_gateway) -> None:
|
||||
with pytest.raises(typer.BadParameter, match="at least one"):
|
||||
cron_cmd.cron_update(
|
||||
job_id="job-1",
|
||||
expression=None,
|
||||
text=None,
|
||||
name=None,
|
||||
enabled=None,
|
||||
timeout=None,
|
||||
tz=None,
|
||||
wake=None,
|
||||
failure_mode=None,
|
||||
failure_channel=None,
|
||||
failure_to=None,
|
||||
failure_account=None,
|
||||
failure_webhook_url=None,
|
||||
failure_webhook_token=None,
|
||||
failure_webhook_token_env=None,
|
||||
failure_webhook_token_file=None,
|
||||
json_output=False,
|
||||
)
|
||||
|
||||
|
||||
def test_cron_update_with_failure_destination_webhook(stub_gateway) -> None:
|
||||
cron_cmd.cron_update(
|
||||
job_id="job-1",
|
||||
expression=None,
|
||||
text=None,
|
||||
name=None,
|
||||
enabled=None,
|
||||
timeout=None,
|
||||
tz=None,
|
||||
wake=None,
|
||||
failure_mode="webhook",
|
||||
failure_channel=None,
|
||||
failure_to=None,
|
||||
failure_account=None,
|
||||
failure_webhook_url="https://hooks.example/alert",
|
||||
failure_webhook_token=None,
|
||||
failure_webhook_token_env=None,
|
||||
failure_webhook_token_file=None,
|
||||
json_output=False,
|
||||
)
|
||||
method, params = stub_gateway.calls[-1]
|
||||
assert method == "cron.update"
|
||||
assert params["delivery"] == {
|
||||
"failureDestination": {
|
||||
"mode": "webhook",
|
||||
"webhookUrl": "https://hooks.example/alert",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_cron_update_with_failure_destination_channel(stub_gateway) -> None:
|
||||
cron_cmd.cron_update(
|
||||
job_id="job-1",
|
||||
expression=None,
|
||||
text=None,
|
||||
name=None,
|
||||
enabled=None,
|
||||
timeout=None,
|
||||
tz=None,
|
||||
wake=None,
|
||||
failure_mode="channel",
|
||||
failure_channel="slack",
|
||||
failure_to="C-ops",
|
||||
failure_account=None,
|
||||
failure_webhook_url=None,
|
||||
failure_webhook_token=None,
|
||||
failure_webhook_token_env=None,
|
||||
failure_webhook_token_file=None,
|
||||
json_output=False,
|
||||
)
|
||||
method, params = stub_gateway.calls[-1]
|
||||
assert params["delivery"] == {
|
||||
"failureDestination": {
|
||||
"mode": "channel",
|
||||
"channelName": "slack",
|
||||
"to": "C-ops",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_cron_update_failure_webhook_missing_url(stub_gateway) -> None:
|
||||
with pytest.raises(typer.BadParameter, match="--failure-webhook-url"):
|
||||
cron_cmd.cron_update(
|
||||
job_id="job-1",
|
||||
expression=None,
|
||||
text=None,
|
||||
name=None,
|
||||
enabled=None,
|
||||
timeout=None,
|
||||
tz=None,
|
||||
wake=None,
|
||||
failure_mode="webhook",
|
||||
failure_channel=None,
|
||||
failure_to=None,
|
||||
failure_account=None,
|
||||
failure_webhook_url=None,
|
||||
failure_webhook_token=None,
|
||||
failure_webhook_token_env=None,
|
||||
failure_webhook_token_file=None,
|
||||
json_output=False,
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli.main import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
class _FakeGatewayClient:
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
payloads: dict[str, Any] = {}
|
||||
|
||||
async def connect(self, url: str, *, token: str | None = None) -> None:
|
||||
type(self).calls.append(("connect", {"url": url, "token": token}))
|
||||
|
||||
async def close(self) -> None:
|
||||
type(self).calls.append(("close", {}))
|
||||
|
||||
async def call(self, method: str, params: dict | None = None) -> Any:
|
||||
type(self).calls.append((method, params or {}))
|
||||
return type(self).payloads.get(method, {})
|
||||
|
||||
|
||||
def _install_fake_gateway(monkeypatch) -> type[_FakeGatewayClient]:
|
||||
_FakeGatewayClient.calls = []
|
||||
_FakeGatewayClient.payloads = {
|
||||
"diagnostics.status": {
|
||||
"enabled": False,
|
||||
"detail": "off",
|
||||
"raw_turn_call": {"enabled": False, "source": "off", "env_override": False},
|
||||
"applies_to": "next_turn",
|
||||
},
|
||||
"diagnostics.set": {
|
||||
"enabled": True,
|
||||
"detail": "standard",
|
||||
"raw_turn_call": {"enabled": False, "source": "off", "env_override": False},
|
||||
"applies_to": "next_turn",
|
||||
},
|
||||
}
|
||||
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", _FakeGatewayClient)
|
||||
return _FakeGatewayClient
|
||||
|
||||
|
||||
def test_diagnostics_status_uses_status_rpc(monkeypatch) -> None:
|
||||
fake = _install_fake_gateway(monkeypatch)
|
||||
|
||||
result = runner.invoke(app, ["diagnostics", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert json.loads(result.stdout)["detail"] == "off"
|
||||
assert ("diagnostics.status", {}) in fake.calls
|
||||
|
||||
|
||||
def test_diagnostics_on_raw_uses_set_rpc(monkeypatch) -> None:
|
||||
fake = _install_fake_gateway(monkeypatch)
|
||||
|
||||
result = runner.invoke(app, ["diagnostics", "on", "--raw", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert ("diagnostics.set", {"enabled": True, "raw": True}) in fake.calls
|
||||
|
||||
|
||||
def test_diagnostics_off_uses_set_rpc(monkeypatch) -> None:
|
||||
fake = _install_fake_gateway(monkeypatch)
|
||||
|
||||
result = runner.invoke(app, ["diagnostics", "off", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert ("diagnostics.set", {"enabled": False}) in fake.calls
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
"""CLI tests for `opensquilla ensemble bench` (offline dry-run only).
|
||||
|
||||
The dry-run path drives synthetic providers through a scripted FailureInjector,
|
||||
so these tests never touch the network. The live path is exercised only under a
|
||||
live invocation and is not covered here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli.main import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_bench_dry_run_json_shape() -> None:
|
||||
result = runner.invoke(app, ["ensemble", "bench", "--dry-run", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
report = json.loads(result.stdout)
|
||||
assert set(report) == {"ensemble", "baseline", "deltas"}
|
||||
|
||||
ens = report["ensemble"]
|
||||
for key in (
|
||||
"label",
|
||||
"runs",
|
||||
"successes",
|
||||
"failures",
|
||||
"success_rate",
|
||||
"mean_latency_s",
|
||||
"p95_latency_s",
|
||||
"total_estimated_cost_usd",
|
||||
"failure_kinds",
|
||||
"mean_successful_proposers",
|
||||
"outcomes",
|
||||
):
|
||||
assert key in ens, key
|
||||
# 5 built-in prompts, repeat=1.
|
||||
assert ens["runs"] == 5
|
||||
assert len(ens["outcomes"]) == 5
|
||||
# The ensemble arm reports public-trace proposer counts; baseline does not.
|
||||
assert ens["mean_successful_proposers"] == 3.0
|
||||
assert report["baseline"]["mean_successful_proposers"] is None
|
||||
# Scripted failures: ensemble 1 overload, baseline 2 rate-limits.
|
||||
assert ens["failures"] == 1
|
||||
assert report["baseline"]["failures"] == 2
|
||||
# Cost estimate resolved offline from the static pricing table.
|
||||
assert ens["total_estimated_cost_usd"] is not None
|
||||
assert "deltas" in report and "latency_delta_s" in report["deltas"]
|
||||
|
||||
|
||||
def test_bench_dry_run_table_output() -> None:
|
||||
result = runner.invoke(app, ["ensemble", "bench", "--dry-run"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Ensemble benchmark" in result.output
|
||||
assert "ensemble" in result.output
|
||||
assert "baseline" in result.output
|
||||
|
||||
|
||||
def test_bench_dry_run_repeat_scales_runs() -> None:
|
||||
result = runner.invoke(app, ["ensemble", "bench", "--dry-run", "--repeat", "3", "--json"])
|
||||
assert result.exit_code == 0, result.output
|
||||
report = json.loads(result.stdout)
|
||||
assert report["ensemble"]["runs"] == 15 # 5 prompts * 3
|
||||
|
||||
|
||||
def test_bench_dry_run_no_cost_disables_estimate() -> None:
|
||||
result = runner.invoke(app, ["ensemble", "bench", "--dry-run", "--no-cost", "--json"])
|
||||
assert result.exit_code == 0, result.output
|
||||
report = json.loads(result.stdout)
|
||||
assert report["ensemble"]["total_estimated_cost_usd"] is None
|
||||
|
||||
|
||||
def test_bench_dry_run_custom_prompts_file(tmp_path: Path) -> None:
|
||||
prompts_file = tmp_path / "prompts.json"
|
||||
prompts_file.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"id": "a", "text": "dummy one"},
|
||||
{"id": "b", "text": "dummy two", "system": "be terse"},
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["ensemble", "bench", "--dry-run", "--prompts", str(prompts_file), "--json"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
report = json.loads(result.stdout)
|
||||
assert report["ensemble"]["runs"] == 2
|
||||
ids = {outcome["prompt_id"] for outcome in report["ensemble"]["outcomes"]}
|
||||
assert ids == {"a", "b"}
|
||||
|
||||
|
||||
def test_bench_invalid_prompts_file_exits_two(tmp_path: Path) -> None:
|
||||
bad = tmp_path / "bad.json"
|
||||
bad.write_text(textwrap.dedent('{"not": "a list"}'), encoding="utf-8")
|
||||
result = runner.invoke(app, ["ensemble", "bench", "--dry-run", "--prompts", str(bad)])
|
||||
assert result.exit_code == 2
|
||||
combined = result.output + (result.stderr or "")
|
||||
assert "Invalid prompts file" in combined
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Regression: the gateway bind preflight must match uvicorn's real bind.
|
||||
|
||||
uvicorn/asyncio sets SO_REUSEADDR on POSIX, so it can bind a port still in
|
||||
TIME_WAIT after a restart. The preflight probe must do the same or it reports a
|
||||
false "already in use" when the operator restarts the gateway. It must still
|
||||
report a truly-live listener as unavailable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
|
||||
from opensquilla.cli.gateway_cmd import _gateway_bind_available
|
||||
|
||||
|
||||
def test_bind_available_false_for_live_listener() -> None:
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind(("127.0.0.1", 0))
|
||||
port = srv.getsockname()[1]
|
||||
srv.listen()
|
||||
try:
|
||||
assert _gateway_bind_available("127.0.0.1", port) is False
|
||||
finally:
|
||||
srv.close()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(socket, "SO_REUSEADDR"), reason="needs SO_REUSEADDR")
|
||||
def test_bind_available_true_after_restart_timewait() -> None:
|
||||
# Force the port into TIME_WAIT with a real server-side active close, the
|
||||
# exact state a gateway restart leaves behind. A probe without SO_REUSEADDR
|
||||
# would report this as "in use"; uvicorn (and now the preflight) binds fine.
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind(("127.0.0.1", 0))
|
||||
port = srv.getsockname()[1]
|
||||
srv.listen()
|
||||
client = socket.create_connection(("127.0.0.1", port))
|
||||
conn, _ = srv.accept()
|
||||
conn.shutdown(socket.SHUT_RDWR)
|
||||
conn.close()
|
||||
client.close()
|
||||
srv.close()
|
||||
|
||||
assert _gateway_bind_available("127.0.0.1", port) is True
|
||||
@@ -0,0 +1,230 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from opensquilla.cli.gateway_client import (
|
||||
GatewayClient,
|
||||
_normalize_session_error_payload,
|
||||
_task_terminal_as_session_event,
|
||||
)
|
||||
|
||||
_STOP = object()
|
||||
|
||||
|
||||
class _FakeWebSocket:
|
||||
def __init__(self, recv_frames: list[dict[str, Any]] | None = None) -> None:
|
||||
self._recv_frames = list(recv_frames or [])
|
||||
self.sent: list[str] = []
|
||||
self.iter_queue: asyncio.Queue[str | object] = asyncio.Queue()
|
||||
self.closed = False
|
||||
|
||||
async def recv(self) -> str:
|
||||
if not self._recv_frames:
|
||||
raise AssertionError("unexpected recv() call")
|
||||
return json.dumps(self._recv_frames.pop(0))
|
||||
|
||||
async def send(self, payload: str) -> None:
|
||||
self.sent.append(payload)
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
def __aiter__(self) -> _FakeWebSocket:
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> str:
|
||||
item = await self.iter_queue.get()
|
||||
if item is _STOP:
|
||||
raise StopAsyncIteration
|
||||
assert isinstance(item, str)
|
||||
return item
|
||||
|
||||
|
||||
class _BrokenSendWebSocket:
|
||||
async def send(self, payload: str) -> None:
|
||||
raise RuntimeError("socket already closed")
|
||||
|
||||
|
||||
async def _wait_for(predicate, *, timeout: float = 1.0) -> None:
|
||||
deadline = asyncio.get_running_loop().time() + timeout
|
||||
while not predicate():
|
||||
if asyncio.get_running_loop().time() >= deadline:
|
||||
raise AssertionError("condition was not met before timeout")
|
||||
await asyncio.sleep(0.005)
|
||||
|
||||
|
||||
def _install_fake_websockets(monkeypatch: pytest.MonkeyPatch, ws: _FakeWebSocket) -> None:
|
||||
async def _connect(url: str) -> _FakeWebSocket:
|
||||
return ws
|
||||
|
||||
monkeypatch.setitem(sys.modules, "websockets", SimpleNamespace(connect=_connect))
|
||||
|
||||
|
||||
def test_task_terminal_event_uses_terminal_message() -> None:
|
||||
event = _task_terminal_as_session_event(
|
||||
"task.timeout",
|
||||
{
|
||||
"task_id": "task-1",
|
||||
"terminal_reason": "timeout",
|
||||
"terminal_message": "The task timed out before it could finish.",
|
||||
"error_message": "Gateway task timeout: Stream idle for more than 60s",
|
||||
},
|
||||
)
|
||||
|
||||
assert event is not None
|
||||
assert event["event"] == "session.event.error"
|
||||
assert event["message"] == "The task timed out before it could finish."
|
||||
assert "Gateway task" not in event["message"]
|
||||
|
||||
|
||||
def test_session_error_payload_is_normalized_for_gateway_client() -> None:
|
||||
payload = _normalize_session_error_payload(
|
||||
{
|
||||
"message": "Iteration 1 exceeded iteration_timeout",
|
||||
"code": "iteration_timeout",
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["message"] == "The task timed out before it could finish."
|
||||
assert payload["terminal_message"] == "The task timed out before it could finish."
|
||||
assert payload["error_message"] == "The task timed out before it could finish."
|
||||
|
||||
|
||||
def test_gateway_client_error_normalization_preserves_generic_error_detail() -> None:
|
||||
payload = _normalize_session_error_payload(
|
||||
{
|
||||
"message": "Tool failed with exit code 2",
|
||||
"code": "agent_error",
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["message"] == "The task failed before it could finish."
|
||||
assert payload["error_message"] == "Tool failed with exit code 2"
|
||||
|
||||
|
||||
def _handshake_frames(*, keepalive_ms: int = 60_000) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"type": "event", "event": "connect.challenge", "payload": {"nonce": "n"}},
|
||||
{
|
||||
"type": "hello-ok",
|
||||
"policy": {"client_ws_keepalive_timeout_ms": keepalive_ms},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_interval_derived_from_hello_policy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
ws = _FakeWebSocket(_handshake_frames(keepalive_ms=60_000))
|
||||
_install_fake_websockets(monkeypatch, ws)
|
||||
client = GatewayClient()
|
||||
|
||||
await client.connect()
|
||||
try:
|
||||
assert client._heartbeat_interval == 24.0 # noqa: SLF001
|
||||
assert client._heartbeat_task is not None # noqa: SLF001
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_interval_stays_below_short_hello_policy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
ws = _FakeWebSocket(_handshake_frames(keepalive_ms=2_000))
|
||||
_install_fake_websockets(monkeypatch, ws)
|
||||
client = GatewayClient()
|
||||
|
||||
await client.connect()
|
||||
try:
|
||||
assert client._heartbeat_interval == 0.8 # noqa: SLF001
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_loop_sends_text_ping_not_rpc() -> None:
|
||||
ws = _FakeWebSocket()
|
||||
client = GatewayClient()
|
||||
client._ws = ws # noqa: SLF001
|
||||
client._heartbeat_interval = 0.01 # noqa: SLF001
|
||||
|
||||
task = asyncio.create_task(client._heartbeat_loop()) # noqa: SLF001
|
||||
try:
|
||||
await _wait_for(lambda: bool(ws.sent))
|
||||
finally:
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
frame = json.loads(ws.sent[0])
|
||||
assert frame == {"type": "ping"}
|
||||
assert "id" not in frame
|
||||
assert "method" not in frame
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_listener_ignores_pong_frames() -> None:
|
||||
ws = _FakeWebSocket()
|
||||
client = GatewayClient()
|
||||
client._ws = ws # noqa: SLF001
|
||||
|
||||
task = asyncio.create_task(client._listen()) # noqa: SLF001
|
||||
try:
|
||||
await ws.iter_queue.put(json.dumps({"type": "pong"}))
|
||||
await asyncio.sleep(0.02)
|
||||
assert client._recv_queue.empty() # noqa: SLF001
|
||||
finally:
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_cancels_heartbeat(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
ws = _FakeWebSocket(_handshake_frames(keepalive_ms=60_000))
|
||||
_install_fake_websockets(monkeypatch, ws)
|
||||
client = GatewayClient()
|
||||
|
||||
await client.connect()
|
||||
heartbeat_task = client._heartbeat_task # noqa: SLF001
|
||||
assert heartbeat_task is not None
|
||||
|
||||
await client.close()
|
||||
|
||||
assert heartbeat_task.done()
|
||||
assert ws.closed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_listener_close_stops_heartbeat_task() -> None:
|
||||
ws = _FakeWebSocket()
|
||||
client = GatewayClient()
|
||||
client._ws = ws # noqa: SLF001
|
||||
client._heartbeat_interval = 60.0 # noqa: SLF001
|
||||
client._heartbeat_task = asyncio.create_task(client._heartbeat_loop(ws)) # noqa: SLF001
|
||||
|
||||
listener_task = asyncio.create_task(client._listen()) # noqa: SLF001
|
||||
await ws.iter_queue.put(_STOP)
|
||||
await _wait_for(lambda: client._connection_error is not None) # noqa: SLF001
|
||||
|
||||
assert client._heartbeat_task.done() # noqa: SLF001
|
||||
await listener_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_after_send_failure_raises_clear_connection_error() -> None:
|
||||
client = GatewayClient()
|
||||
client._ws = _BrokenSendWebSocket() # noqa: SLF001
|
||||
|
||||
with pytest.raises(ConnectionError, match="Gateway connection lost"):
|
||||
await client._call("sessions.messages.subscribe", {"key": "agent:main:x"}) # noqa: SLF001
|
||||
|
||||
assert client._pending == {} # noqa: SLF001
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
"""``opensquilla gateway reload`` CLI — calls the admin ``config.reload`` RPC.
|
||||
|
||||
Offline: the gateway client is replaced by a fake, no network, no credentials.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli.main import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
class FakeGatewayClient:
|
||||
calls: list[tuple[str, Any]] = []
|
||||
payload: dict[str, Any] = {}
|
||||
|
||||
async def connect(self, url: str, *, token=None) -> None:
|
||||
type(self).calls.append(("connect", url))
|
||||
|
||||
async def close(self) -> None:
|
||||
type(self).calls.append(("close", None))
|
||||
|
||||
async def call(self, method: str, params: dict | None = None) -> Any:
|
||||
type(self).calls.append((method, params or {}))
|
||||
return type(self).payload
|
||||
|
||||
|
||||
def _install(monkeypatch, payload: dict[str, Any]) -> type[FakeGatewayClient]:
|
||||
FakeGatewayClient.calls = []
|
||||
FakeGatewayClient.payload = payload
|
||||
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", FakeGatewayClient)
|
||||
return FakeGatewayClient
|
||||
|
||||
|
||||
def test_gateway_reload_calls_rpc_and_prints_summary(monkeypatch) -> None:
|
||||
_install(
|
||||
monkeypatch,
|
||||
{
|
||||
"ok": True,
|
||||
"path": "/tmp/example-config.toml",
|
||||
"restartRequired": True,
|
||||
"restartSections": ["channels"],
|
||||
"liveApplied": ["naming", "skills"],
|
||||
},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "reload"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert ("config.reload", {}) in FakeGatewayClient.calls
|
||||
assert "naming, skills" in result.stdout
|
||||
assert "Restart required" in result.stdout
|
||||
assert "channels" in result.stdout
|
||||
|
||||
|
||||
def test_gateway_reload_no_restart_needed(monkeypatch) -> None:
|
||||
_install(
|
||||
monkeypatch,
|
||||
{
|
||||
"ok": True,
|
||||
"path": "/tmp/example-config.toml",
|
||||
"restartRequired": False,
|
||||
"restartSections": [],
|
||||
"liveApplied": [],
|
||||
},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "reload"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "(no changes)" in result.stdout
|
||||
assert "Restart required" not in result.stdout
|
||||
|
||||
|
||||
def test_gateway_reload_failure_exits_nonzero(monkeypatch) -> None:
|
||||
_install(
|
||||
monkeypatch,
|
||||
{"ok": False, "path": "/tmp/example-config.toml", "error": "bad toml"},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "reload"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Reload failed" in result.stderr
|
||||
assert "bad toml" in result.stderr
|
||||
assert "left unchanged" in result.stderr
|
||||
|
||||
|
||||
def test_gateway_reload_json_output(monkeypatch) -> None:
|
||||
_install(
|
||||
monkeypatch,
|
||||
{
|
||||
"ok": True,
|
||||
"path": "/tmp/example-config.toml",
|
||||
"restartRequired": False,
|
||||
"restartSections": [],
|
||||
"liveApplied": ["naming"],
|
||||
},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "reload", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is True
|
||||
assert payload["liveApplied"] == ["naming"]
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from opensquilla.cli.gateway_rpc import default_gateway_token, default_gateway_url
|
||||
|
||||
|
||||
def test_default_gateway_url_uses_implicit_home_config(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.delenv("OPENSQUILLA_GATEWAY_URL", raising=False)
|
||||
monkeypatch.delenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", raising=False)
|
||||
monkeypatch.delenv("OPENSQUILLA_GATEWAY_HOST", raising=False)
|
||||
monkeypatch.delenv("OPENSQUILLA_GATEWAY_PORT", raising=False)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "state"))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
config = tmp_path / "state" / "config.toml"
|
||||
config.parent.mkdir(parents=True)
|
||||
config.write_text(
|
||||
"""
|
||||
host = "127.0.0.1"
|
||||
port = 18790
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert default_gateway_url() == "ws://127.0.0.1:18790/ws"
|
||||
|
||||
|
||||
def test_default_gateway_token_uses_explicit_config_path(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.delenv("OPENSQUILLA_GATEWAY_TOKEN", raising=False)
|
||||
monkeypatch.delenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", raising=False)
|
||||
config = tmp_path / "custom-opensquilla.toml"
|
||||
config.write_text(
|
||||
"""
|
||||
[auth]
|
||||
mode = "token"
|
||||
token = "from-explicit-config"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert default_gateway_token(config) == "from-explicit-config"
|
||||
|
||||
|
||||
def test_default_gateway_token_env_override_wins_over_explicit_config(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_TOKEN", "from-env")
|
||||
config = tmp_path / "custom-opensquilla.toml"
|
||||
config.write_text(
|
||||
"""
|
||||
[auth]
|
||||
mode = "token"
|
||||
token = "from-explicit-config"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert default_gateway_token(config) == "from-env"
|
||||
@@ -0,0 +1,191 @@
|
||||
"""CLI help presentation tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from typer import rich_utils
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli.main import app
|
||||
from opensquilla.ui import ACCENT, questionary_style
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_rich_help_uses_opensquilla_accent() -> None:
|
||||
assert rich_utils.STYLE_OPTIONS_PANEL_BORDER == ACCENT
|
||||
assert rich_utils.STYLE_COMMANDS_PANEL_BORDER == ACCENT
|
||||
assert rich_utils.STYLE_OPTION == f"bold {ACCENT}"
|
||||
assert rich_utils.STYLE_COMMANDS_TABLE_FIRST_COLUMN == f"bold {ACCENT}"
|
||||
|
||||
|
||||
def test_questionary_checkbox_selection_avoids_reverse_row_highlight() -> None:
|
||||
style = questionary_style()
|
||||
assert style is not None
|
||||
rules = dict(style.style_rules)
|
||||
|
||||
for token in ("pointer", "highlighted", "selected"):
|
||||
assert "noreverse" in rules[token]
|
||||
assert "bg:" not in rules[token]
|
||||
|
||||
|
||||
def test_onboard_help_keeps_router_option_readable() -> None:
|
||||
result = runner.invoke(app, ["onboard", "--help"], terminal_width=100)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
output = click.unstyle(result.output)
|
||||
assert "--router" in output
|
||||
assert "--router MODE" in output
|
||||
assert "Router profile: recommended," in output
|
||||
assert "openrouter-mix, or" in output
|
||||
assert "disabled." in output
|
||||
assert "TEXT recommended | openrouter-mix" not in output
|
||||
|
||||
|
||||
def test_onboard_help_uses_compact_option_columns() -> None:
|
||||
result = runner.invoke(app, ["onboard", "--help"], terminal_width=100)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
output = click.unstyle(result.output)
|
||||
assert "--provider TEXT" in output
|
||||
assert "--router MODE" in output
|
||||
assert not re.search(r"--provider\s{12,}TEXT", output)
|
||||
|
||||
|
||||
def test_onboard_help_explains_first_run_inputs() -> None:
|
||||
result = runner.invoke(app, ["onboard", "--help"], terminal_width=110)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
output = click.unstyle(result.output)
|
||||
assert "OpenSquilla setup cockpit" in output
|
||||
assert "SquillaRouter" in output
|
||||
assert "Provider id to configure" in output
|
||||
assert "Model id for the provider" in output
|
||||
assert "Read the provider key from this environment" in output
|
||||
assert "variable." in output
|
||||
assert "Only run the wizard when required setup is" in output
|
||||
assert "incomplete." in output
|
||||
assert "Leave channel setup for later" in output
|
||||
assert "Leave optional Web search setup for later" in output
|
||||
assert "Leave optional image generation setup for later" in output
|
||||
|
||||
|
||||
def test_configure_help_explains_section_specific_inputs() -> None:
|
||||
result = runner.invoke(app, ["configure", "--help"], terminal_width=110)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
output = click.unstyle(result.output)
|
||||
normalized = " ".join(output.replace("│", " ").split())
|
||||
assert (
|
||||
"Reconfigure provider, router, ensemble, channels, search, image generation, "
|
||||
"or memory" in normalized
|
||||
)
|
||||
assert "Usage:" in output
|
||||
assert "[SECTION]" in output
|
||||
assert "SECTION_ARG" not in output
|
||||
assert "Section to configure" in output
|
||||
assert "Text provider id for provider setup" in normalized
|
||||
assert "Search provider id" in normalized
|
||||
assert "Image provider id" in normalized
|
||||
assert "Model id for provider or remote memory embedding" in normalized
|
||||
assert "Channel type such as slack, discord, feishu" in normalized
|
||||
assert "Image model id" in normalized
|
||||
assert "Memory embedding provider" in output
|
||||
|
||||
|
||||
def test_help_theme_supports_click_make_metavar_without_context(monkeypatch) -> None:
|
||||
def legacy_make_metavar(self: click.Option) -> str:
|
||||
if self.is_bool_flag:
|
||||
return "BOOLEAN"
|
||||
return self.metavar or self.type.name.upper()
|
||||
|
||||
monkeypatch.setattr(click.Option, "make_metavar", legacy_make_metavar)
|
||||
|
||||
result = runner.invoke(app, ["onboard", "--help"], terminal_width=100)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
output = click.unstyle(result.output)
|
||||
assert "--provider TEXT" in output
|
||||
assert "--router MODE" in output
|
||||
|
||||
|
||||
def test_help_theme_accepts_typer_vendored_click_parameters() -> None:
|
||||
option = SimpleNamespace(
|
||||
param_type_name="option",
|
||||
name="provider",
|
||||
opts=["--provider"],
|
||||
secondary_opts=[],
|
||||
metavar=None,
|
||||
type=SimpleNamespace(name="text"),
|
||||
required=False,
|
||||
help="Provider id to configure.",
|
||||
)
|
||||
argument = SimpleNamespace(
|
||||
param_type_name="argument",
|
||||
name="section_arg",
|
||||
opts=["section_arg"],
|
||||
secondary_opts=[],
|
||||
metavar="SECTION",
|
||||
type=SimpleNamespace(name="text"),
|
||||
required=False,
|
||||
help="Section to configure.",
|
||||
)
|
||||
console = Console(file=StringIO(), force_terminal=False, color_system=None)
|
||||
|
||||
rich_utils._print_options_panel(
|
||||
name="Options",
|
||||
params=[option, argument],
|
||||
ctx=click.Context(click.Command("demo")),
|
||||
markup_mode="rich",
|
||||
console=console,
|
||||
)
|
||||
|
||||
output = click.unstyle(console.file.getvalue())
|
||||
assert "--provider TEXT" in output
|
||||
assert "SECTION" in output
|
||||
|
||||
|
||||
def test_cli_brand_surfaces_do_not_use_cyan() -> None:
|
||||
cli_files = [*Path("src/opensquilla/cli").rglob("*.py"), Path("src/opensquilla/ui.py")]
|
||||
forbidden = (
|
||||
"bold cyan",
|
||||
"[cyan]",
|
||||
"[/cyan]",
|
||||
"typer.colors.CYAN",
|
||||
'style="cyan"',
|
||||
)
|
||||
|
||||
offenders: list[str] = []
|
||||
for path in cli_files:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for needle in forbidden:
|
||||
if needle in text:
|
||||
offenders.append(f"{path}:{needle}")
|
||||
|
||||
assert offenders == []
|
||||
|
||||
|
||||
def test_opentui_theme_accents_are_not_cyan() -> None:
|
||||
# The brand's no-cyan rule is about the ACCENT, not secondary semantics: every
|
||||
# OpenTUI theme's brand accent must be warm/orange, never cyan. (OpenSquilla's
|
||||
# own --info token is cyan-ish #56C2E6 and is allowed for the route/info role.)
|
||||
theme_mjs = Path("src/opensquilla/cli/tui/opentui/package/src/theme.mjs").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
block = theme_mjs.split("PALETTES = Object.freeze({", 1)[1].split("});", 1)[0]
|
||||
accents = re.findall(r"\baccent(?:Secondary)?:\s*\"(#[0-9a-fA-F]{6})\"", block)
|
||||
|
||||
def is_cyan(hex_value: str) -> bool:
|
||||
h = hex_value.lstrip("#")
|
||||
r, g, b = (int(h[i : i + 2], 16) for i in (0, 2, 4))
|
||||
return r < 0x80 and g > 0xB0 and b > 0xB0 and abs(g - b) < 0x40
|
||||
|
||||
assert accents, "could not parse theme accents from theme.mjs"
|
||||
offenders = [hex_value for hex_value in accents if is_cyan(hex_value)]
|
||||
assert offenders == [], f"theme brand accent must not be cyan: {offenders}"
|
||||
@@ -0,0 +1,9 @@
|
||||
from opensquilla.cli.init_cmd import _default_model_for_provider
|
||||
|
||||
|
||||
def test_init_uses_direct_deepseek_model_default() -> None:
|
||||
assert _default_model_for_provider("deepseek") == "deepseek-v4-flash"
|
||||
|
||||
|
||||
def test_init_keeps_openrouter_model_default() -> None:
|
||||
assert _default_model_for_provider("openrouter") == "deepseek/deepseek-v4-pro"
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import opensquilla.cli.main as cli_main
|
||||
from opensquilla.gateway.config import GatewayConfig
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("agent_id", "expected_suffix"),
|
||||
[
|
||||
("main", ()),
|
||||
("ops", ("agents", "ops")),
|
||||
],
|
||||
)
|
||||
def test_cli_dream_uses_configured_agent_workspace(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
agent_id: str,
|
||||
expected_suffix: tuple[str, ...],
|
||||
) -> None:
|
||||
configured_workspace = tmp_path / "configured workspace"
|
||||
cwd = tmp_path / "launch cwd"
|
||||
cwd.mkdir()
|
||||
monkeypatch.chdir(cwd)
|
||||
|
||||
cfg = GatewayConfig(workspace_dir=str(configured_workspace))
|
||||
monkeypatch.setattr(GatewayConfig, "load", classmethod(lambda cls, _path=None: cfg))
|
||||
|
||||
dream = cli_main._build_cli_dream(agent_id, need_provider=False)
|
||||
|
||||
assert dream.workspace == configured_workspace.joinpath(*expected_suffix)
|
||||
assert not (cwd / ".opensquilla").exists()
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from opensquilla.cli.memory_flush_cmd import (
|
||||
MemoryFlushSessionResult,
|
||||
_emit_text_result,
|
||||
_receipt_is_complete_flush,
|
||||
_zero_usage,
|
||||
)
|
||||
|
||||
|
||||
def test_receipt_is_complete_flush_rejects_raw_and_degraded_llm() -> None:
|
||||
assert not _receipt_is_complete_flush(
|
||||
{
|
||||
"mode": "raw",
|
||||
"flushed_paths": ["memory/.raw_fallbacks/raw.md"],
|
||||
"raw_reason": "timeout",
|
||||
}
|
||||
)
|
||||
assert not _receipt_is_complete_flush(
|
||||
{
|
||||
"mode": "llm",
|
||||
"indexed_chunk_count": 1,
|
||||
"integrity_status": "missing_chunks",
|
||||
"output_coverage_status": "ok",
|
||||
}
|
||||
)
|
||||
assert _receipt_is_complete_flush(
|
||||
{
|
||||
"mode": "llm",
|
||||
"indexed_chunk_count": 1,
|
||||
"integrity_status": "ok",
|
||||
"output_coverage_status": "ok",
|
||||
"invalid_candidate_count": 0,
|
||||
"candidate_missing_ids": [],
|
||||
"obligation_status": "ok",
|
||||
"obligation_missing_ids": [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_emit_text_result_labels_raw_fallback_as_degraded(capsys) -> None:
|
||||
result = MemoryFlushSessionResult(
|
||||
ok=False,
|
||||
key="agent:main:webchat:s1",
|
||||
agent_id="main",
|
||||
message_window="all",
|
||||
flush_max_chars="default",
|
||||
segment_mode="auto",
|
||||
segment_max_chars="default",
|
||||
segment_overlap_messages=0,
|
||||
flush_receipt={
|
||||
"mode": "raw",
|
||||
"flushed_paths": ["memory/.raw_fallbacks/raw.md"],
|
||||
"raw_reason": "timeout",
|
||||
},
|
||||
usage=_zero_usage(),
|
||||
usage_path=None,
|
||||
)
|
||||
|
||||
_emit_text_result(result, success=False)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Flush degraded to raw backup" in captured.out
|
||||
assert "Backup path: memory/.raw_fallbacks/raw.md" in captured.out
|
||||
assert "not searchable durable memory" in captured.err
|
||||
@@ -0,0 +1,969 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import tomli_w
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli.main import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_profile_operation_locks(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Keep CLI migration locks out of the runner's real user-state tree."""
|
||||
|
||||
monkeypatch.setenv("OPENSQUILLA_TEST", "1")
|
||||
monkeypatch.setenv("OPENSQUILLA_USER_STATE_DIR", str(tmp_path / "user-state"))
|
||||
|
||||
|
||||
def _set_fake_home(monkeypatch, home: Path) -> None:
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setenv("USERPROFILE", str(home))
|
||||
|
||||
|
||||
def _make_source(root: Path) -> Path:
|
||||
source = root / ".openclaw"
|
||||
workspace = source / "workspace"
|
||||
workspace.mkdir(parents=True)
|
||||
(workspace / "SOUL.md").write_text("soul\n", encoding="utf-8")
|
||||
(workspace / "MEMORY.md").write_text("memory\n", encoding="utf-8")
|
||||
(source / "openclaw.json").write_text(
|
||||
json.dumps({"agents": {"defaults": {"model": "deepseek-chat"}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return source
|
||||
|
||||
|
||||
def test_migrate_openclaw_json_dry_run(tmp_path: Path, monkeypatch) -> None:
|
||||
source = _make_source(tmp_path)
|
||||
home = tmp_path / "opensquilla-home"
|
||||
target = tmp_path / "config.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"migrate",
|
||||
"openclaw",
|
||||
"--source",
|
||||
str(source),
|
||||
"--config",
|
||||
str(target),
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["apply"] is False
|
||||
assert not target.exists()
|
||||
assert any(item["status"] == "planned" for item in payload["items"])
|
||||
|
||||
|
||||
def test_migrate_openclaw_apply_writes_config_and_workspace(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
source = _make_source(tmp_path)
|
||||
home = tmp_path / "opensquilla-home"
|
||||
target = tmp_path / "config.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"migrate",
|
||||
"openclaw",
|
||||
"--source",
|
||||
str(source),
|
||||
"--config",
|
||||
str(target),
|
||||
"--apply",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert "OpenClaw migration complete" in result.stdout
|
||||
assert (home / "workspace" / "SOUL.md").read_text(encoding="utf-8") == "soul\n"
|
||||
config = tomllib.loads(target.read_text(encoding="utf-8"))
|
||||
assert config["llm"]["model"] == "deepseek-chat"
|
||||
|
||||
|
||||
def test_migrate_openclaw_missing_source_exits_nonzero(tmp_path: Path) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"migrate",
|
||||
"openclaw",
|
||||
"--source",
|
||||
str(tmp_path / "missing"),
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["items"][0]["status"] == "error"
|
||||
|
||||
|
||||
def test_migrate_openclaw_exclude_skips_workspace_item(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
source = _make_source(tmp_path)
|
||||
home = tmp_path / "opensquilla-home"
|
||||
target = tmp_path / "config.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"migrate",
|
||||
"openclaw",
|
||||
"--source",
|
||||
str(source),
|
||||
"--config",
|
||||
str(target),
|
||||
"--apply",
|
||||
"--exclude",
|
||||
"soul",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert not (home / "workspace" / "SOUL.md").exists()
|
||||
config = tomllib.loads(target.read_text(encoding="utf-8"))
|
||||
assert config["llm"]["model"] == "deepseek-chat"
|
||||
|
||||
|
||||
def test_migrate_openclaw_rejects_unknown_include(tmp_path: Path) -> None:
|
||||
source = _make_source(tmp_path)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"migrate",
|
||||
"openclaw",
|
||||
"--source",
|
||||
str(source),
|
||||
"--include",
|
||||
"not-a-real-option",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Unknown migration option" in result.stdout
|
||||
|
||||
|
||||
def test_migrate_openclaw_rejects_unknown_preset(tmp_path: Path) -> None:
|
||||
source = _make_source(tmp_path)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"migrate",
|
||||
"openclaw",
|
||||
"--source",
|
||||
str(source),
|
||||
"--preset",
|
||||
"everything",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Unknown migration preset" in result.stdout
|
||||
|
||||
|
||||
def test_migrate_openclaw_rejects_unknown_skill_conflict(tmp_path: Path) -> None:
|
||||
source = _make_source(tmp_path)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"migrate",
|
||||
"openclaw",
|
||||
"--source",
|
||||
str(source),
|
||||
"--skill-conflict",
|
||||
"merge",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Unknown skill conflict behavior" in result.stdout
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenSquilla self-migration CLI contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_migrate_opensquilla_plain_error_reports_failure(tmp_path: Path) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["migrate", "opensquilla", "--source", str(tmp_path / "missing")],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "OpenSquilla self-migration failed" in result.stdout
|
||||
assert "error: 1" in result.stdout
|
||||
assert "complete" not in result.stdout.lower()
|
||||
|
||||
|
||||
def test_migrate_opensquilla_json_error_is_parseable_and_exits_nonzero(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"migrate",
|
||||
"opensquilla",
|
||||
"--source",
|
||||
str(tmp_path / "missing"),
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["items"][0]["status"] == "error"
|
||||
|
||||
|
||||
def test_migrate_opensquilla_rejects_config_with_target_home_guidance(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"migrate",
|
||||
"opensquilla",
|
||||
"--source",
|
||||
str(tmp_path / "source"),
|
||||
"--config",
|
||||
str(tmp_path / "config.toml"),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "--config is not supported for OpenSquilla self-migration" in result.stdout
|
||||
assert "OPENSQUILLA_STATE_DIR" in result.stdout
|
||||
assert "target home" in result.stdout
|
||||
|
||||
|
||||
def test_migrate_opensquilla_keeps_its_internal_multi_profile_lock(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from opensquilla import recovery
|
||||
from opensquilla.cli import migrate_cmd
|
||||
|
||||
source = tmp_path / "source-profile"
|
||||
(source / "workspace").mkdir(parents=True)
|
||||
(source / "state").mkdir()
|
||||
(source / "config.toml").write_text("port = 18790\n", encoding="utf-8")
|
||||
(source / "workspace" / "SOUL.md").write_text("source soul\n", encoding="utf-8")
|
||||
target = tmp_path / "desktop-primary"
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(target))
|
||||
monkeypatch.setenv("OPENSQUILLA_PROFILE_KIND", "desktop-primary")
|
||||
monkeypatch.delattr(os, "fchmod", raising=False)
|
||||
|
||||
def fail_foreign_guard() -> None:
|
||||
pytest.fail("OpenSquilla self-import must not take the foreign migration lifecycle")
|
||||
|
||||
acquired: list[tuple[Path, ...]] = []
|
||||
original_acquire = recovery.acquire_profile_locks
|
||||
|
||||
def track_internal_locks(*homes: str | Path, **kwargs: object) -> object:
|
||||
acquired.append(tuple(Path(home) for home in homes))
|
||||
return original_acquire(*homes, **kwargs)
|
||||
|
||||
monkeypatch.setattr(migrate_cmd, "_guard_foreign_migration_target", fail_foreign_guard)
|
||||
monkeypatch.setattr(recovery, "acquire_profile_locks", track_internal_locks)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"migrate",
|
||||
"opensquilla",
|
||||
"--source",
|
||||
str(source),
|
||||
"--apply",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert acquired == [(source, target, target.parent)]
|
||||
assert (target / "workspace" / "SOUL.md").read_text(encoding="utf-8") == (
|
||||
"source soul\n"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-detect entry point: ``opensquilla migrate`` (no subcommand)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_openclaw(home: Path) -> Path:
|
||||
source = home / ".openclaw"
|
||||
workspace = source / "workspace"
|
||||
workspace.mkdir(parents=True)
|
||||
(workspace / "SOUL.md").write_text("openclaw soul\n", encoding="utf-8")
|
||||
(workspace / "MEMORY.md").write_text("openclaw memory\n", encoding="utf-8")
|
||||
(source / "openclaw.json").write_text("{}", encoding="utf-8")
|
||||
return source
|
||||
|
||||
|
||||
def _seed_opensquilla(home: Path) -> Path:
|
||||
source = home / ".opensquilla"
|
||||
source.mkdir(parents=True)
|
||||
(source / "config.toml").write_text("port = 18790\n", encoding="utf-8")
|
||||
return source
|
||||
|
||||
|
||||
def _seed_portable(base: Path) -> Path:
|
||||
source = base / "OpenSquilla" / "portable" / "dummy-release"
|
||||
source.mkdir(parents=True)
|
||||
(source / "config.toml").write_text("port = 18790\n", encoding="utf-8")
|
||||
return source
|
||||
|
||||
|
||||
def test_migrate_batch_rejects_config_when_opensquilla_is_selected(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
fake_home = tmp_path / "fake-home"
|
||||
fake_home.mkdir()
|
||||
_seed_opensquilla(fake_home)
|
||||
_set_fake_home(monkeypatch, fake_home)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "desktop-home"))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"migrate",
|
||||
"--source",
|
||||
"opensquilla",
|
||||
"--config",
|
||||
str(tmp_path / "custom-config.toml"),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2, result.stdout
|
||||
assert "--config is not supported for OpenSquilla self-migration" in result.stdout
|
||||
|
||||
|
||||
def _seed_hermes(home: Path) -> Path:
|
||||
source = home / ".hermes"
|
||||
source.mkdir(parents=True)
|
||||
(source / "config.yaml").write_text(
|
||||
"model:\n provider: openrouter\n", encoding="utf-8"
|
||||
)
|
||||
(source / "SOUL.md").write_text("hermes soul\n", encoding="utf-8")
|
||||
return source
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source_name", "entrypoint"),
|
||||
[
|
||||
("openclaw", "subcommand"),
|
||||
("hermes", "subcommand"),
|
||||
("openclaw", "auto-detect"),
|
||||
("hermes", "auto-detect"),
|
||||
],
|
||||
)
|
||||
def test_foreign_migration_blocks_unsafe_desktop_before_any_write(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
source_name: str,
|
||||
entrypoint: str,
|
||||
) -> None:
|
||||
from opensquilla.recovery import RecoveryRequiredError
|
||||
|
||||
fake_home = tmp_path / "fake-home"
|
||||
fake_home.mkdir()
|
||||
source_path = (
|
||||
_seed_openclaw(fake_home)
|
||||
if source_name == "openclaw"
|
||||
else _seed_hermes(fake_home)
|
||||
)
|
||||
target = tmp_path / "desktop-primary"
|
||||
target.mkdir()
|
||||
missing_workspace = tmp_path / "missing-workspace"
|
||||
config = target / "config.toml"
|
||||
config_bytes = f"workspace_dir = {json.dumps(str(missing_workspace))}\n".encode()
|
||||
config.write_bytes(config_bytes)
|
||||
_set_fake_home(monkeypatch, fake_home)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(target))
|
||||
monkeypatch.setenv("OPENSQUILLA_PROFILE_KIND", "desktop-primary")
|
||||
|
||||
if entrypoint == "subcommand":
|
||||
args = [
|
||||
"migrate",
|
||||
source_name,
|
||||
"--source",
|
||||
str(source_path),
|
||||
"--apply",
|
||||
"--json",
|
||||
]
|
||||
else:
|
||||
args = ["migrate", "--source", source_name, "--apply", "--json"]
|
||||
result = runner.invoke(app, args)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert isinstance(result.exception, RecoveryRequiredError)
|
||||
assert result.exception.report.stable_code == "effective_state_missing"
|
||||
assert config.read_bytes() == config_bytes
|
||||
assert not missing_workspace.exists()
|
||||
assert not (target / "workspace").exists()
|
||||
assert not (target / "migration").exists()
|
||||
assert not (target / "state" / "gateway.pid.lock").exists()
|
||||
assert sorted(path.relative_to(target).as_posix() for path in target.rglob("*")) == [
|
||||
"config.toml"
|
||||
]
|
||||
|
||||
|
||||
def test_foreign_migration_guard_is_noop_for_ordinary_cli_profile(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
source = _make_source(tmp_path / "source-root")
|
||||
target = tmp_path / "cli-home"
|
||||
target.mkdir()
|
||||
workspace = tmp_path / "explicit-cli-workspace"
|
||||
(target / "config.toml").write_text(
|
||||
tomli_w.dumps({"workspace_dir": str(workspace)}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(target))
|
||||
monkeypatch.delenv("OPENSQUILLA_PROFILE_KIND", raising=False)
|
||||
monkeypatch.delenv("OPENSQUILLA_DESKTOP", raising=False)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"migrate",
|
||||
"openclaw",
|
||||
"--source",
|
||||
str(source),
|
||||
"--apply",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert (workspace / "SOUL.md").read_text(encoding="utf-8") == "soul\n"
|
||||
assert (target / "migration" / "openclaw").is_dir()
|
||||
assert not (target / "state" / "gateway.pid.lock").exists()
|
||||
|
||||
|
||||
def test_migrate_auto_detect_no_source_reports_nothing(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
home = tmp_path / "fake_home"
|
||||
home.mkdir()
|
||||
_set_fake_home(monkeypatch, home)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "opensquilla"))
|
||||
|
||||
result = runner.invoke(app, ["migrate", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["detected"] == []
|
||||
assert "No migration source detected" in payload["message"]
|
||||
assert "CLI, desktop, and portable locations" in payload["message"]
|
||||
|
||||
|
||||
def test_migrate_auto_detect_single_source_auto_picks(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
# Only hermes present: don't prompt, just run it.
|
||||
home = tmp_path / "fake_home"
|
||||
home.mkdir()
|
||||
_seed_hermes(home)
|
||||
_set_fake_home(monkeypatch, home)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "opensquilla"))
|
||||
|
||||
result = runner.invoke(app, ["migrate", "--apply", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["selected"] == ["hermes"]
|
||||
assert "hermes" in payload["reports"]
|
||||
|
||||
|
||||
def test_migrate_auto_detect_single_cli_home_still_requires_confirmation(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
home = tmp_path / "fake_home"
|
||||
home.mkdir()
|
||||
source = _seed_opensquilla(home)
|
||||
_set_fake_home(monkeypatch, home)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "desktop-home"))
|
||||
|
||||
result = runner.invoke(app, ["migrate", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["detected"] == [
|
||||
{
|
||||
"name": "opensquilla",
|
||||
"kind": "cli-home",
|
||||
"path": str(source),
|
||||
"version": None,
|
||||
"estimated_activity_at": payload["detected"][0]["estimated_activity_at"],
|
||||
"session_count": 0,
|
||||
"size_bytes": payload["detected"][0]["size_bytes"],
|
||||
"previously_imported": False,
|
||||
}
|
||||
]
|
||||
assert payload["detected"][0]["estimated_activity_at"] is not None
|
||||
assert payload["detected"][0]["size_bytes"] > 0
|
||||
assert "explicit" in payload["message"].lower()
|
||||
assert "--source opensquilla" in payload["message"]
|
||||
|
||||
|
||||
def test_migrate_auto_detect_single_portable_requires_explicit_confirmation(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
home = tmp_path / "fake_home"
|
||||
home.mkdir()
|
||||
portable_base = tmp_path / "local-app-data"
|
||||
portable = _seed_portable(portable_base)
|
||||
_set_fake_home(monkeypatch, home)
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(portable_base))
|
||||
monkeypatch.delenv("TEMP", raising=False)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "opensquilla"))
|
||||
|
||||
result = runner.invoke(app, ["migrate", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
candidate = payload["detected"][0]
|
||||
assert candidate["name"] == "opensquilla"
|
||||
assert candidate["kind"] == "windows-portable"
|
||||
assert candidate["path"] == str(portable)
|
||||
assert candidate["session_count"] == 0
|
||||
assert candidate["size_bytes"] > 0
|
||||
assert "estimated_activity_at" in candidate
|
||||
assert candidate["previously_imported"] is False
|
||||
assert "Re-run with" in payload["message"]
|
||||
|
||||
|
||||
def test_migrate_opensquilla_single_portable_requires_home_flag(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
portable_base = tmp_path / "local-app-data"
|
||||
portable = _seed_portable(portable_base)
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(portable_base))
|
||||
monkeypatch.delenv("TEMP", raising=False)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "target-home"))
|
||||
|
||||
refused = runner.invoke(
|
||||
app, ["migrate", "opensquilla", "--kind", "windows-portable", "--json"]
|
||||
)
|
||||
accepted = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"migrate",
|
||||
"opensquilla",
|
||||
"--kind",
|
||||
"windows-portable",
|
||||
"--home",
|
||||
str(portable),
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert refused.exit_code == 2
|
||||
refused_payload = json.loads(refused.stdout)
|
||||
assert refused_payload["requires_selection"] is True
|
||||
assert refused_payload["candidates"][0]["path"] == str(portable)
|
||||
assert refused_payload["candidates"][0]["session_count"] == 0
|
||||
assert "estimated_activity_at" in refused_payload["candidates"][0]
|
||||
assert accepted.exit_code == 0, accepted.stdout
|
||||
assert json.loads(accepted.stdout)["source"] == str(portable)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["cli-home", "desktop-home"])
|
||||
def test_migrate_opensquilla_direct_same_product_source_requires_confirmation(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
kind: str,
|
||||
) -> None:
|
||||
import opensquilla.cli.migrate_cmd as migrate_cmd
|
||||
|
||||
fake_home = tmp_path / "fake-home"
|
||||
fake_home.mkdir()
|
||||
source = (
|
||||
_seed_opensquilla(fake_home)
|
||||
if kind == "cli-home"
|
||||
else tmp_path / "detected-desktop" / "opensquilla"
|
||||
)
|
||||
if kind == "desktop-home":
|
||||
source.mkdir(parents=True)
|
||||
(source / "config.toml").write_text("port = 18790\n", encoding="utf-8")
|
||||
monkeypatch.setattr(migrate_cmd, "detect_desktop_home", lambda: source)
|
||||
_set_fake_home(monkeypatch, fake_home)
|
||||
target = tmp_path / "target-home"
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(target))
|
||||
|
||||
refused = runner.invoke(
|
||||
app,
|
||||
["migrate", "opensquilla", "--kind", kind, "--json"],
|
||||
)
|
||||
accepted = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"migrate",
|
||||
"opensquilla",
|
||||
"--kind",
|
||||
kind,
|
||||
"--source",
|
||||
str(source),
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert refused.exit_code == 2
|
||||
payload = json.loads(refused.stdout)
|
||||
assert payload["requires_selection"] is True
|
||||
assert payload["candidates"][0]["path"] == str(source)
|
||||
assert not target.exists()
|
||||
assert accepted.exit_code == 0, accepted.stdout
|
||||
assert json.loads(accepted.stdout)["source"] == str(source)
|
||||
|
||||
|
||||
def test_migrate_help_treats_cli_and_desktop_as_supported_profiles() -> None:
|
||||
group_help = runner.invoke(app, ["migrate", "--help"])
|
||||
command_help = runner.invoke(app, ["migrate", "opensquilla", "--help"])
|
||||
|
||||
assert group_help.exit_code == 0
|
||||
assert command_help.exit_code == 0
|
||||
combined = f"{group_help.stdout}\n{command_help.stdout}"
|
||||
assert "legacy OpenSquilla home" not in combined
|
||||
assert "supported OpenSquilla CLI or Desktop profile" in combined
|
||||
assert "historical Windows Portable" in combined
|
||||
|
||||
|
||||
def test_portable_text_chooser_labels_activity_as_estimated_and_shows_metadata(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
portable_base = tmp_path / "local-app-data"
|
||||
portable = _seed_portable(portable_base)
|
||||
(portable / "install-receipt.json").write_text(
|
||||
json.dumps({"version": "0.5.0rc3"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(portable_base))
|
||||
monkeypatch.delenv("TEMP", raising=False)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "target-home"))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["migrate", "opensquilla", "--kind", "windows-portable"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert str(portable) in result.stdout
|
||||
assert "version 0.5.0rc3" in result.stdout
|
||||
assert "estimated recent activity" in result.stdout
|
||||
assert "0 sessions" in result.stdout
|
||||
assert "bytes" in result.stdout
|
||||
|
||||
|
||||
def test_migrate_opensquilla_inspect_candidate_json_is_metadata_only(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
source = _seed_portable(tmp_path / "portable-base")
|
||||
(source / "install-receipt.json").write_text(
|
||||
json.dumps({"version": "0.5.0rc3"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
state = source / "state"
|
||||
state.mkdir()
|
||||
connection = sqlite3.connect(state / "sessions.db")
|
||||
try:
|
||||
connection.execute("CREATE TABLE sessions (session_key TEXT PRIMARY KEY, title TEXT)")
|
||||
connection.execute("INSERT INTO sessions VALUES (?, ?)", ("secret-id", "secret-title"))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
target = tmp_path / "target"
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"migrate",
|
||||
"opensquilla",
|
||||
"--source",
|
||||
str(source),
|
||||
"--kind",
|
||||
"windows-portable",
|
||||
"--inspect-candidate",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["candidate"]["version"] == "0.5.0rc3"
|
||||
assert payload["candidate"]["session_count"] == 1
|
||||
assert payload["candidate"]["path"] == str(source)
|
||||
assert "secret-id" not in result.stdout
|
||||
assert "secret-title" not in result.stdout
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_migrate_opensquilla_replacement_flags_require_exact_target(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
source = tmp_path / "legacy-home"
|
||||
source.mkdir()
|
||||
(source / "config.toml").write_text("port = 18791\n", encoding="utf-8")
|
||||
(source / "workspace").mkdir()
|
||||
(source / "state").mkdir()
|
||||
target = tmp_path / "target-home"
|
||||
target.mkdir()
|
||||
(target / "existing.txt").write_text("preserve", encoding="utf-8")
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(target))
|
||||
|
||||
refused = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"migrate",
|
||||
"opensquilla",
|
||||
"--source",
|
||||
str(source),
|
||||
"--apply",
|
||||
"--overwrite",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
accepted = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"migrate",
|
||||
"opensquilla",
|
||||
"--source",
|
||||
str(source),
|
||||
"--apply",
|
||||
"--replace-target",
|
||||
"--confirm-replace-target",
|
||||
str(target.resolve()),
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert refused.exit_code == 1
|
||||
assert "exact confirmation" in refused.stdout
|
||||
assert accepted.exit_code == 0, accepted.stdout
|
||||
assert not (target / "existing.txt").exists()
|
||||
assert (target / "config.toml").is_file()
|
||||
|
||||
|
||||
def test_migrate_auto_detect_multiple_sources_non_tty_lists_and_exits(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
# Both sources present and no --source filter: in non-TTY (CliRunner)
|
||||
# the user must opt in explicitly. We print the discovered sources
|
||||
# and exit 0 so CI doesn't silently migrate things.
|
||||
home = tmp_path / "fake_home"
|
||||
home.mkdir()
|
||||
_seed_openclaw(home)
|
||||
_seed_hermes(home)
|
||||
_set_fake_home(monkeypatch, home)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "opensquilla"))
|
||||
|
||||
result = runner.invoke(app, ["migrate", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
detected_names = [entry["name"] for entry in payload["detected"]]
|
||||
assert detected_names == ["openclaw", "hermes"]
|
||||
assert "Re-run with" in payload["message"]
|
||||
|
||||
|
||||
def test_migrate_auto_detect_source_filter_runs_only_selected(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
home = tmp_path / "fake_home"
|
||||
home.mkdir()
|
||||
_seed_openclaw(home)
|
||||
_seed_hermes(home)
|
||||
_set_fake_home(monkeypatch, home)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "opensquilla"))
|
||||
|
||||
result = runner.invoke(
|
||||
app, ["migrate", "--source", "hermes", "--apply", "--json"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["selected"] == ["hermes"]
|
||||
assert "openclaw" not in payload["reports"]
|
||||
|
||||
|
||||
def test_migrate_auto_detect_source_filter_runs_both_in_order(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
home = tmp_path / "fake_home"
|
||||
home.mkdir()
|
||||
_seed_openclaw(home)
|
||||
_seed_hermes(home)
|
||||
_set_fake_home(monkeypatch, home)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "opensquilla"))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["migrate", "--source", "hermes,openclaw", "--apply", "--json"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
# Order is canonical (openclaw first, then hermes) regardless of how
|
||||
# the user wrote the --source flag, so the second migrator sees
|
||||
# whatever the first one wrote.
|
||||
assert payload["selected"] == ["openclaw", "hermes"]
|
||||
assert set(payload["reports"]) == {"openclaw", "hermes"}
|
||||
|
||||
|
||||
def test_migrate_auto_detect_rejects_unknown_source_name(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
home = tmp_path / "fake_home"
|
||||
home.mkdir()
|
||||
_seed_openclaw(home)
|
||||
_set_fake_home(monkeypatch, home)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "opensquilla"))
|
||||
|
||||
result = runner.invoke(app, ["migrate", "--source", "bogus", "--json"])
|
||||
|
||||
assert result.exit_code == 2, result.stdout
|
||||
assert "Unknown migration source" in result.stdout
|
||||
|
||||
|
||||
def test_migrate_auto_detect_rejects_requested_but_undetected_source(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
# ``--source hermes`` when hermes is not on disk should fail loudly
|
||||
# rather than silently no-op.
|
||||
home = tmp_path / "fake_home"
|
||||
home.mkdir()
|
||||
_seed_openclaw(home)
|
||||
_set_fake_home(monkeypatch, home)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "opensquilla"))
|
||||
|
||||
result = runner.invoke(app, ["migrate", "--source", "hermes", "--json"])
|
||||
|
||||
assert result.exit_code == 2, result.stdout
|
||||
assert "not detected" in result.stdout
|
||||
|
||||
|
||||
def test_migrate_auto_detect_tty_prompt_path_is_invoked(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
# When both sources are present and stdin is a TTY (real interactive
|
||||
# use), we should reach the questionary prompt instead of the
|
||||
# non-TTY exit branch. Patch the prompt helper so the test doesn't
|
||||
# need a real terminal. ``--json`` short-circuits to the non-TTY
|
||||
# branch on purpose (scripting context), so this test uses plain
|
||||
# text output and checks that the migration actually ran via the
|
||||
# files it wrote.
|
||||
home = tmp_path / "fake_home"
|
||||
home.mkdir()
|
||||
_seed_openclaw(home)
|
||||
_seed_hermes(home)
|
||||
state = tmp_path / "opensquilla"
|
||||
_set_fake_home(monkeypatch, home)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(state))
|
||||
|
||||
from opensquilla.cli import migrate_cmd
|
||||
|
||||
monkeypatch.setattr("opensquilla.cli.migrate_cmd._stdin_is_tty", lambda: True)
|
||||
captured: list[list[object]] = []
|
||||
|
||||
def fake_prompt(detected):
|
||||
captured.append(list(detected))
|
||||
# User picks only hermes.
|
||||
return ["hermes"]
|
||||
|
||||
monkeypatch.setattr(migrate_cmd, "_prompt_source_selection", fake_prompt)
|
||||
|
||||
result = runner.invoke(app, ["migrate", "--apply"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert "hermes migration complete" in result.stdout
|
||||
# openclaw was offered but the fake prompt only picked hermes, so
|
||||
# the openclaw migrator must NOT have run.
|
||||
assert "openclaw migration complete" not in result.stdout
|
||||
assert len(captured) == 1
|
||||
assert {source.name for source in captured[0]} == {"openclaw", "hermes"}
|
||||
|
||||
|
||||
def test_migrate_auto_detect_validates_all_selected_before_running_any(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
# Pre-validate so an invalid flag for the second migrator never
|
||||
# half-applies the first one. ``persona_conflict`` is the openclaw-only
|
||||
# flag, so a bogus value for it must error out even though hermes
|
||||
# would happily ignore it.
|
||||
home = tmp_path / "fake_home"
|
||||
home.mkdir()
|
||||
_seed_openclaw(home)
|
||||
_seed_hermes(home)
|
||||
state = tmp_path / "opensquilla"
|
||||
_set_fake_home(monkeypatch, home)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(state))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"migrate",
|
||||
"--source",
|
||||
"openclaw,hermes",
|
||||
"--persona-conflict",
|
||||
"absolutely-bogus",
|
||||
"--apply",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2, result.stdout
|
||||
assert "Unknown persona conflict behavior" in result.stdout
|
||||
# Neither migrator should have left state behind: the workspace dir
|
||||
# is created by the first migrator's apply, so its absence is proof
|
||||
# we bailed before running anything.
|
||||
assert not (state / "workspace").exists()
|
||||
|
||||
|
||||
def test_migrate_auto_detect_tty_prompt_cancellation_exits_cleanly(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
home = tmp_path / "fake_home"
|
||||
home.mkdir()
|
||||
_seed_openclaw(home)
|
||||
_seed_hermes(home)
|
||||
_set_fake_home(monkeypatch, home)
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "opensquilla"))
|
||||
|
||||
from opensquilla.cli import migrate_cmd
|
||||
|
||||
monkeypatch.setattr("opensquilla.cli.migrate_cmd._stdin_is_tty", lambda: True)
|
||||
monkeypatch.setattr(migrate_cmd, "_prompt_source_selection", lambda _detected: [])
|
||||
|
||||
result = runner.invoke(app, ["migrate"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert "No source selected" in result.stdout
|
||||
@@ -0,0 +1,323 @@
|
||||
"""CLI tests for `opensquilla models probe` (offline, injected/fake results).
|
||||
|
||||
The probe command is live by nature, so these tests never let it reach a
|
||||
network: probe results are injected by monkeypatching the shared onboarding
|
||||
probe helpers, and the two real-path cases (missing key, unknown provider id)
|
||||
short-circuit inside validation before any provider is contacted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli.main import app
|
||||
from opensquilla.onboarding.probe import ProviderModelsDiscoverResult, ProviderProbeResult
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Synthetic sentinel; never a real credential. If redaction ever regresses,
|
||||
# this exact token would leak into the rendered output and fail the tests.
|
||||
SENTINEL_SECRET = "sk-test-sentinel-000000000000" # noqa: S105 - synthetic dummy
|
||||
|
||||
|
||||
def _write_config(tmp_path: Path, body: str) -> Path:
|
||||
path = tmp_path / "config.toml"
|
||||
path.write_text(textwrap.dedent(body), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _primary_openai_config(tmp_path: Path) -> Path:
|
||||
return _write_config(
|
||||
tmp_path,
|
||||
"""
|
||||
[llm]
|
||||
provider = "openai"
|
||||
model = "gpt-test-dummy"
|
||||
api_key = "sk-test-dummy-key"
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
def _fake_probe(results: dict[str, ProviderProbeResult], calls: list[dict[str, Any]]):
|
||||
async def fake(**kwargs: Any) -> ProviderProbeResult:
|
||||
calls.append(kwargs)
|
||||
return results[kwargs["provider_id"]]
|
||||
|
||||
return fake
|
||||
|
||||
|
||||
def _fake_discover(
|
||||
results: dict[str, ProviderModelsDiscoverResult], calls: list[dict[str, Any]]
|
||||
):
|
||||
async def fake(**kwargs: Any) -> ProviderModelsDiscoverResult:
|
||||
calls.append(kwargs)
|
||||
return results[kwargs["provider_id"]]
|
||||
|
||||
return fake
|
||||
|
||||
|
||||
def test_probe_ok_renders_table_and_exits_zero(tmp_path: Path, monkeypatch) -> None:
|
||||
config = _primary_openai_config(tmp_path)
|
||||
calls: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(
|
||||
"opensquilla.cli.models_cmd.probe_llm_provider",
|
||||
_fake_probe(
|
||||
{"openai": ProviderProbeResult(ok=True, provider_id="openai", model="gpt-test-dummy")},
|
||||
calls,
|
||||
),
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["models", "probe", "--config", str(config)])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "openai" in result.output
|
||||
assert "gpt-test-dummy" in result.output
|
||||
assert "ok" in result.output
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["provider_id"] == "openai"
|
||||
assert calls[0]["model"] == "gpt-test-dummy"
|
||||
|
||||
|
||||
def test_probe_failure_classifies_and_exits_nonzero(tmp_path: Path, monkeypatch) -> None:
|
||||
config = _primary_openai_config(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"opensquilla.cli.models_cmd.probe_llm_provider",
|
||||
_fake_probe(
|
||||
{
|
||||
"openai": ProviderProbeResult(
|
||||
ok=False,
|
||||
provider_id="openai",
|
||||
model="gpt-test-dummy",
|
||||
failure_kind="transport_transient",
|
||||
message="injected connection timeout",
|
||||
)
|
||||
},
|
||||
[],
|
||||
),
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["models", "probe", "--config", str(config)])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "transport_transient" in result.output
|
||||
assert "injected connection timeout" in result.output
|
||||
|
||||
|
||||
def test_probe_redacts_sentinel_secret_from_error_detail(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
config = _primary_openai_config(tmp_path)
|
||||
poisoned = ProviderProbeResult(
|
||||
ok=False,
|
||||
provider_id="openai",
|
||||
model="gpt-test-dummy",
|
||||
failure_kind="auth_invalid",
|
||||
message=f"Invalid api_key={SENTINEL_SECRET} rejected (Bearer {SENTINEL_SECRET})",
|
||||
code="401",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"opensquilla.cli.models_cmd.probe_llm_provider",
|
||||
_fake_probe({"openai": poisoned}, []),
|
||||
)
|
||||
|
||||
table_result = runner.invoke(app, ["models", "probe", "--config", str(config)])
|
||||
json_result = runner.invoke(
|
||||
app, ["models", "probe", "--config", str(config), "--json"]
|
||||
)
|
||||
|
||||
assert table_result.exit_code == 1
|
||||
assert json_result.exit_code == 1
|
||||
assert "auth_invalid" in table_result.output
|
||||
assert SENTINEL_SECRET not in table_result.output
|
||||
assert SENTINEL_SECRET not in json_result.output
|
||||
|
||||
|
||||
def test_probe_json_shape(tmp_path: Path, monkeypatch) -> None:
|
||||
config = _primary_openai_config(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"opensquilla.cli.models_cmd.probe_llm_provider",
|
||||
_fake_probe(
|
||||
{
|
||||
"openai": ProviderProbeResult(
|
||||
ok=False,
|
||||
provider_id="openai",
|
||||
model="gpt-test-dummy",
|
||||
failure_kind="rate_limited",
|
||||
message="injected rate limit",
|
||||
code="429",
|
||||
latency_ms=123,
|
||||
)
|
||||
},
|
||||
[],
|
||||
),
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["models", "probe", "--config", str(config), "--json"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
rows = json.loads(result.stdout)
|
||||
assert isinstance(rows, list) and len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["provider"] == "openai"
|
||||
assert row["model"] == "gpt-test-dummy"
|
||||
assert row["ok"] is False
|
||||
assert row["kind"] == "rate_limited"
|
||||
assert row["detail"] == "injected rate limit"
|
||||
assert row["code"] == "429"
|
||||
assert row["method"] == "chat"
|
||||
assert row["source"] == "llm"
|
||||
assert row["latency_ms"] == 123
|
||||
|
||||
|
||||
def test_probe_unknown_provider_filter_exits_two(tmp_path: Path) -> None:
|
||||
config = _primary_openai_config(tmp_path)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["models", "probe", "--config", str(config), "--provider", "not-configured"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
combined = result.output + (result.stderr or "")
|
||||
assert "not configured" in combined.lower()
|
||||
|
||||
|
||||
def test_probe_missing_key_classifies_auth_invalid_offline(tmp_path: Path) -> None:
|
||||
# Real probe path (no monkeypatch): the conftest strips provider env keys
|
||||
# and the config has none, so probe_llm_provider short-circuits with
|
||||
# AUTH_INVALID before any provider is even built — fully offline.
|
||||
config = _write_config(
|
||||
tmp_path,
|
||||
"""
|
||||
[llm]
|
||||
provider = "openai"
|
||||
model = "gpt-test-dummy"
|
||||
""",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["models", "probe", "--config", str(config)])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "auth_invalid" in result.output
|
||||
assert "No API key available" in result.output
|
||||
|
||||
|
||||
def test_probe_unknown_provider_id_reports_invalid_config(tmp_path: Path) -> None:
|
||||
# Real probe path: an unregistered provider id fails spec validation
|
||||
# before any network contact.
|
||||
config = _write_config(
|
||||
tmp_path,
|
||||
"""
|
||||
[llm]
|
||||
provider = "not-a-real-provider"
|
||||
model = "dummy-model"
|
||||
""",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["models", "probe", "--config", str(config)])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "invalid_config" in result.output
|
||||
|
||||
|
||||
def test_probe_profile_without_tier_model_uses_models_list(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
config = _write_config(
|
||||
tmp_path,
|
||||
"""
|
||||
[llm]
|
||||
provider = "openai"
|
||||
model = "gpt-test-dummy"
|
||||
api_key = "sk-test-dummy-key"
|
||||
|
||||
[llm_profiles.anthropic]
|
||||
api_key = "sk-test-dummy-profile-key"
|
||||
""",
|
||||
)
|
||||
probe_calls: list[dict[str, Any]] = []
|
||||
discover_calls: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(
|
||||
"opensquilla.cli.models_cmd.probe_llm_provider",
|
||||
_fake_probe(
|
||||
{"openai": ProviderProbeResult(ok=True, provider_id="openai", model="gpt-test-dummy")},
|
||||
probe_calls,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"opensquilla.cli.models_cmd.discover_provider_models",
|
||||
_fake_discover(
|
||||
{
|
||||
"anthropic": ProviderModelsDiscoverResult(
|
||||
ok=True, provider_id="anthropic", source="live"
|
||||
)
|
||||
},
|
||||
discover_calls,
|
||||
),
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["models", "probe", "--config", str(config), "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
rows = json.loads(result.stdout)
|
||||
by_provider = {row["provider"]: row for row in rows}
|
||||
assert set(by_provider) == {"openai", "anthropic"}
|
||||
assert by_provider["openai"]["method"] == "chat"
|
||||
assert by_provider["anthropic"]["method"] == "models_list"
|
||||
assert by_provider["anthropic"]["source"] == "llm_profiles"
|
||||
assert by_provider["anthropic"]["latency_ms"] == 0
|
||||
assert [call["provider_id"] for call in probe_calls] == ["openai"]
|
||||
assert [call["provider_id"] for call in discover_calls] == ["anthropic"]
|
||||
|
||||
|
||||
def test_probe_provider_filter_and_model_override(tmp_path: Path, monkeypatch) -> None:
|
||||
config = _write_config(
|
||||
tmp_path,
|
||||
"""
|
||||
[llm]
|
||||
provider = "openai"
|
||||
model = "gpt-test-dummy"
|
||||
api_key = "sk-test-dummy-key"
|
||||
|
||||
[llm_profiles.anthropic]
|
||||
api_key = "sk-test-dummy-profile-key"
|
||||
""",
|
||||
)
|
||||
calls: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(
|
||||
"opensquilla.cli.models_cmd.probe_llm_provider",
|
||||
_fake_probe(
|
||||
{
|
||||
"openai": ProviderProbeResult(
|
||||
ok=True, provider_id="openai", model="override-model-dummy"
|
||||
)
|
||||
},
|
||||
calls,
|
||||
),
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"models",
|
||||
"probe",
|
||||
"--config",
|
||||
str(config),
|
||||
"--provider",
|
||||
"openai",
|
||||
"--model",
|
||||
"override-model-dummy",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
rows = json.loads(result.stdout)
|
||||
assert len(rows) == 1 # the filter drops the profile target
|
||||
assert rows[0]["model"] == "override-model-dummy"
|
||||
assert calls[0]["model"] == "override-model-dummy"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,863 @@
|
||||
"""Headless `onboard`/`configure` CLI semantics.
|
||||
|
||||
Pins the gate-flag error contract (explicit-but-incomplete flag sets exit 2),
|
||||
keep-current re-saves, cancellation and config-error productization, restart
|
||||
guidance, the voice-audio catalog section, and strict `--field` coercion.
|
||||
All tests are offline and use synthetic dummy data only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli.main import app
|
||||
from opensquilla.onboarding.config_store import load_config
|
||||
|
||||
runner = CliRunner()
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||
|
||||
|
||||
def _plain(value: str) -> str:
|
||||
return " ".join(_ANSI_RE.sub("", value).split())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-1: explicit-but-incomplete headless flag combinations must exit 2 and
|
||||
# name the missing gate flag instead of silently no-opping with exit 0.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "missing_flag"),
|
||||
[
|
||||
(["configure", "router", "--default-tier", "c2"], "--router"),
|
||||
(["configure", "provider", "--model", "some/model"], "--provider"),
|
||||
(["configure", "search", "--max-results", "9"], "--search-provider"),
|
||||
(["configure", "channels", "--channel-type", "slack"], "--name"),
|
||||
(["configure", "channels", "--name", "work"], "--channel-type"),
|
||||
(["configure", "image", "--primary", "openrouter/x"], "--image-provider"),
|
||||
(["configure", "memory", "--onnx-dir", "models/bge"], "--memory-provider"),
|
||||
],
|
||||
)
|
||||
def test_configure_incomplete_flags_exit_2_naming_missing_gate(
|
||||
tmp_path, monkeypatch, args, missing_flag
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", *args])
|
||||
|
||||
assert result.exit_code == 2, result.output
|
||||
assert missing_flag in _plain(result.stderr)
|
||||
assert "no changes were made" in _plain(result.stderr)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_configure_router_incomplete_flags_do_not_touch_existing_config(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
'[llm]\nprovider = "openrouter"\nmodel = "m"\napi_key = "sk"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
before = target.read_text(encoding="utf-8")
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", "configure", "router", "--default-tier", "c2"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert target.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
def test_configure_flags_without_section_exit_2(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", "configure", "--router", "recommended"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "target section" in _plain(result.stderr)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_configure_unknown_section_exits_2(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", "configure", "not-a-section"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "unknown configure section" in _plain(result.stderr)
|
||||
assert "not-a-section" in _plain(result.stderr)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_configure_audio_section_exits_2_with_catalog_pointer(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", "configure", "audio"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "opensquilla onboard catalog audio" in _plain(result.stderr)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_configure_bare_non_tty_exits_2_with_hint(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", "configure"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "requires a TTY" in result.stdout
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-2: `onboard catalog audio` must render usable output, and the overview
|
||||
# must not leak the raw audioProviders key.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_onboard_catalog_audio_prints_provider_rows(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", "catalog", "audio"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert result.stdout.strip()
|
||||
assert "voice audio provider options" in result.stdout
|
||||
assert "elevenlabs" in result.stdout
|
||||
assert "ELEVENLABS_API_KEY" in result.stdout
|
||||
assert "Try:" in result.stdout
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_onboard_catalog_overview_names_voice_audio_without_raw_key(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", "catalog"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert "Voice audio providers" in result.stdout
|
||||
assert "audioProviders" not in result.stdout
|
||||
compact = "".join(result.stdout.split())
|
||||
assert "opensquillaonboardcatalogaudio" in compact
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-3: an explicitly stored enabled=false must survive an image key rotation
|
||||
# that omits --image-enabled/--no-image-enabled.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_configure_image_rotation_keeps_deliberate_disabled_state(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
'[llm]\nprovider = "openrouter"\nmodel = "m"\napi_key = "sk"\n'
|
||||
"\n"
|
||||
"[image_generation]\n"
|
||||
"enabled = false\n"
|
||||
'primary = "openrouter/google/gemini-3.1-flash-image-preview"\n'
|
||||
"\n"
|
||||
"[image_generation.providers.openrouter]\n"
|
||||
'api_key_env = "OPENSQUILLA_TEST_IMAGE_KEY"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
monkeypatch.setenv("OPENSQUILLA_TEST_IMAGE_KEY", "sk-image-env")
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"configure",
|
||||
"image",
|
||||
"--image-provider",
|
||||
"openrouter",
|
||||
"--primary",
|
||||
"openrouter/google/gemini-3.1-flash-image-preview",
|
||||
"--api-key-env",
|
||||
"OPENSQUILLA_TEST_IMAGE_KEY",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert load_config(target).image_generation.enabled is False
|
||||
|
||||
|
||||
def test_configure_image_rotation_keeps_enabled_true(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
"[image_generation]\n"
|
||||
"enabled = true\n"
|
||||
'primary = "openrouter/google/gemini-3.1-flash-image-preview"\n'
|
||||
"\n"
|
||||
"[image_generation.providers.openrouter]\n"
|
||||
'api_key_env = "OPENSQUILLA_TEST_IMAGE_KEY"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
monkeypatch.setenv("OPENSQUILLA_TEST_IMAGE_KEY", "sk-image-env")
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"configure",
|
||||
"image",
|
||||
"--image-provider",
|
||||
"openrouter",
|
||||
"--primary",
|
||||
"openrouter/google/gemini-3.1-flash-image-preview",
|
||||
"--api-key-env",
|
||||
"OPENSQUILLA_TEST_IMAGE_KEY",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert load_config(target).image_generation.enabled is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-4: key rotation must not clobber stored provider/search settings when
|
||||
# the corresponding flags are omitted.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_configure_provider_key_rotation_keeps_model_base_url_and_proxy(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
"[llm]\n"
|
||||
'provider = "openrouter"\n'
|
||||
'model = "custom/model-x"\n'
|
||||
'api_key = "sk-old"\n'
|
||||
'base_url = "https://gateway.example.test/v1"\n'
|
||||
'proxy = "http://127.0.0.1:7890"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"configure",
|
||||
"provider",
|
||||
"--provider",
|
||||
"openrouter",
|
||||
"--api-key",
|
||||
"sk-new",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
cfg = load_config(target)
|
||||
assert cfg.llm.model == "custom/model-x"
|
||||
assert cfg.llm.base_url == "https://gateway.example.test/v1"
|
||||
assert cfg.llm.proxy == "http://127.0.0.1:7890"
|
||||
assert cfg.llm.api_key == "sk-new"
|
||||
|
||||
|
||||
def test_onboard_provider_key_rotation_keeps_base_url_and_proxy(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
# The top-level `onboard --provider` path must honor the same
|
||||
# keep-current contract as `configure provider`: an omitted --router
|
||||
# never re-applies a router profile on a configured install, so the
|
||||
# stored model and endpoint settings all survive a key rotation.
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
"[llm]\n"
|
||||
'provider = "openrouter"\n'
|
||||
'model = "custom/model-x"\n'
|
||||
'api_key = "sk-old"\n'
|
||||
'base_url = "https://gateway.example.test/v1"\n'
|
||||
'proxy = "http://127.0.0.1:7890"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["onboard", "--provider", "openrouter", "--api-key", "sk-new", "--minimal"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
cfg = load_config(target)
|
||||
assert cfg.llm.model == "custom/model-x"
|
||||
assert cfg.llm.base_url == "https://gateway.example.test/v1"
|
||||
assert cfg.llm.proxy == "http://127.0.0.1:7890"
|
||||
assert cfg.llm.api_key == "sk-new"
|
||||
|
||||
|
||||
def test_onboard_provider_key_rotation_with_router_disabled_keeps_model(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
"[llm]\n"
|
||||
'provider = "openrouter"\n'
|
||||
'model = "custom/model-x"\n'
|
||||
'api_key = "sk-old"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"--provider",
|
||||
"openrouter",
|
||||
"--api-key",
|
||||
"sk-new",
|
||||
"--router",
|
||||
"disabled",
|
||||
"--minimal",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
cfg = load_config(target)
|
||||
assert cfg.llm.model == "custom/model-x"
|
||||
assert cfg.llm.api_key == "sk-new"
|
||||
|
||||
|
||||
def test_configure_search_key_rotation_keeps_stored_global_settings(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
'search_provider = "brave"\n'
|
||||
'search_api_key = "sk-old"\n'
|
||||
"search_max_results = 9\n"
|
||||
'search_proxy = "http://127.0.0.1:7890"\n'
|
||||
"search_use_env_proxy = true\n"
|
||||
'search_fallback_policy = "network"\n'
|
||||
"search_diagnostics = true\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"configure",
|
||||
"search",
|
||||
"--search-provider",
|
||||
"brave",
|
||||
"--api-key",
|
||||
"sk-new",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
cfg = load_config(target)
|
||||
assert cfg.search_max_results == 9
|
||||
assert cfg.search_proxy == "http://127.0.0.1:7890"
|
||||
assert cfg.search_use_env_proxy is True
|
||||
assert cfg.search_fallback_policy == "network"
|
||||
assert cfg.search_diagnostics is True
|
||||
assert cfg.search_api_key == "sk-new"
|
||||
|
||||
|
||||
def test_configure_search_explicit_flags_still_override_stored(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
'search_provider = "duckduckgo"\n'
|
||||
"search_max_results = 9\n"
|
||||
'search_proxy = "http://127.0.0.1:7890"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"configure",
|
||||
"search",
|
||||
"--search-provider",
|
||||
"duckduckgo",
|
||||
"--max-results",
|
||||
"3",
|
||||
"--proxy",
|
||||
"",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
cfg = load_config(target)
|
||||
assert cfg.search_max_results == 3
|
||||
assert cfg.search_proxy == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-5: a wizard cancellation (Esc/Ctrl+C) must exit with one short line and
|
||||
# code 130 instead of a raw traceback.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_onboard_wizard_cancellation_is_productized(tmp_path, monkeypatch):
|
||||
from opensquilla.cli import onboard_cmd
|
||||
from opensquilla.onboarding.errors import UserCancelledError
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
def cancelled(_options):
|
||||
raise UserCancelledError(section="provider")
|
||||
|
||||
monkeypatch.setattr(onboard_cmd, "run_interactive_onboard", cancelled)
|
||||
|
||||
result = runner.invoke(app, ["onboard"])
|
||||
|
||||
assert result.exit_code == 130
|
||||
assert "Setup cancelled" in _plain(result.stderr)
|
||||
assert "Traceback" not in result.output + result.stderr
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_configure_wizard_cancellation_is_productized(tmp_path, monkeypatch):
|
||||
from opensquilla.cli import onboard_cmd
|
||||
from opensquilla.onboarding.errors import UserCancelledError
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
def cancelled(_section, *, config_path=None):
|
||||
raise UserCancelledError(section="configure")
|
||||
|
||||
monkeypatch.setattr(onboard_cmd, "run_interactive_configure", cancelled)
|
||||
|
||||
result = runner.invoke(app, ["onboard", "configure", "router"])
|
||||
|
||||
assert result.exit_code == 130
|
||||
assert "Setup cancelled" in _plain(result.stderr)
|
||||
assert "Traceback" not in result.output + result.stderr
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-6: bare `onboard` with a corrupt config must route through the
|
||||
# productized config-error handoff (exit 2), never a raw traceback.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bare_onboard_toml_decode_error_is_productized(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text("not toml :::", encoding="utf-8")
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "OpenSquilla config error" in result.stderr
|
||||
assert "Traceback" not in result.output + result.stderr
|
||||
|
||||
|
||||
def test_bare_onboard_validation_error_is_productized(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text("[search]\n", encoding="utf-8")
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "OpenSquilla config error" in result.stderr
|
||||
assert "pydantic_core" not in result.stderr
|
||||
assert "Traceback" not in result.output + result.stderr
|
||||
|
||||
|
||||
def test_bare_onboard_os_error_is_productized(tmp_path, monkeypatch):
|
||||
target = tmp_path / "config-dir.toml"
|
||||
target.mkdir() # opening a directory raises IsADirectoryError (OSError)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "OpenSquilla config error" in result.stderr
|
||||
assert "Traceback" not in result.output + result.stderr
|
||||
|
||||
|
||||
def test_onboard_provider_with_corrupt_config_is_productized(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text("not toml :::", encoding="utf-8")
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["onboard", "--provider", "openrouter", "--api-key", "sk", "--minimal"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "OpenSquilla config error" in result.stderr
|
||||
assert "Traceback" not in result.output + result.stderr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-7: a ValidationError on the `onboard --provider` path must not echo
|
||||
# pydantic's input_value (which can carry a mispasted secret).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_onboard_provider_validation_error_never_echoes_input_value(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from opensquilla.cli import onboard_cmd
|
||||
|
||||
class _Probe(BaseModel):
|
||||
max_tokens: int
|
||||
|
||||
try:
|
||||
_Probe(max_tokens="sk-super-secret-value") # type: ignore[arg-type]
|
||||
except ValidationError as exc:
|
||||
captured = exc
|
||||
else: # pragma: no cover - the construction above always fails
|
||||
raise AssertionError("expected a ValidationError")
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
class _RaisingEngine:
|
||||
def __init__(self, *_a, **_kw):
|
||||
pass
|
||||
|
||||
def apply(self, *_a, **_kw):
|
||||
raise captured
|
||||
|
||||
def persist(self, *_a, **_kw): # pragma: no cover - apply raises first
|
||||
raise AssertionError("persist must not run after a failed apply")
|
||||
|
||||
monkeypatch.setattr(onboard_cmd, "SetupEngine", _RaisingEngine)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["onboard", "--provider", "openrouter", "--api-key", "sk", "--minimal"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
combined = result.output + result.stderr
|
||||
assert "sk-super-secret-value" not in combined
|
||||
assert "input_value" not in combined
|
||||
assert "max_tokens" in _plain(result.stderr)
|
||||
assert "Traceback" not in combined
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-8: headless channels/memory saves must surface PersistResult's
|
||||
# restart_required instead of printing only the saved path.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_configure_channels_prints_restart_guidance(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"configure",
|
||||
"channels",
|
||||
"--channel-type",
|
||||
"slack",
|
||||
"--name",
|
||||
"work",
|
||||
"--token",
|
||||
"xoxb-secret",
|
||||
"--field",
|
||||
"signing_secret=ss",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
plain = _plain(result.stdout)
|
||||
assert "restart required" in plain
|
||||
assert "opensquilla gateway restart" in plain
|
||||
|
||||
|
||||
def test_configure_memory_prints_restart_guidance_with_config_path(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
default_target = tmp_path / "default.toml"
|
||||
target = tmp_path / "custom.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(default_target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"configure",
|
||||
"memory",
|
||||
"--memory-provider",
|
||||
"local",
|
||||
"--onnx-dir",
|
||||
"models/bge",
|
||||
"--config",
|
||||
str(target),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
plain = _plain(result.stdout)
|
||||
assert "restart required" in plain
|
||||
assert "opensquilla gateway restart" in plain
|
||||
assert str(target) in plain
|
||||
assert not default_target.exists()
|
||||
|
||||
|
||||
def test_configure_search_does_not_print_restart_guidance(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["onboard", "configure", "search", "--search-provider", "duckduckgo"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "restart required" not in _plain(result.stdout)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-10: strict --field coercion (bool typos and numeric garbage must name
|
||||
# the offending field and the accepted spellings).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_configure_channels_field_bool_typo_exits_2_naming_field(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"configure",
|
||||
"channels",
|
||||
"--channel-type",
|
||||
"slack",
|
||||
"--name",
|
||||
"work",
|
||||
"--token",
|
||||
"xoxb-secret",
|
||||
"--field",
|
||||
"signing_secret=ss",
|
||||
"--field",
|
||||
"enabled=ture",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
plain = _plain(result.output + result.stderr)
|
||||
assert "enabled" in plain
|
||||
assert "true/false" in plain
|
||||
assert "'ture'" in plain
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_configure_channels_field_int_garbage_exits_2_naming_field(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"configure",
|
||||
"channels",
|
||||
"--channel-type",
|
||||
"discord",
|
||||
"--name",
|
||||
"guild",
|
||||
"--token",
|
||||
"bot-secret",
|
||||
"--field",
|
||||
"intents=lots",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
plain = _plain(result.output + result.stderr)
|
||||
assert "intents" in plain
|
||||
assert "integer" in plain
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_parse_channel_field_pairs_strict_coercion_unit():
|
||||
import typer
|
||||
|
||||
from opensquilla.cli.channel_fields import parse_channel_field_pairs
|
||||
|
||||
assert parse_channel_field_pairs(["enabled=TRUE"], "slack")["enabled"] is True
|
||||
assert parse_channel_field_pairs(["enabled=off"], "slack")["enabled"] is False
|
||||
|
||||
with pytest.raises(typer.BadParameter, match=r"--field enabled .*'ture'"):
|
||||
parse_channel_field_pairs(["enabled=ture"], "slack")
|
||||
with pytest.raises(typer.BadParameter, match=r"--field intents .*integer"):
|
||||
parse_channel_field_pairs(["intents=lots"], "discord")
|
||||
with pytest.raises(typer.BadParameter, match=r"--field poll_idle_sleep_s .*number"):
|
||||
parse_channel_field_pairs(["poll_idle_sleep_s=fast"], "telegram")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-11: previously untested branches.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_onboard_probe_flag_exits_nonzero_when_probe_raises(tmp_path, monkeypatch):
|
||||
from opensquilla.onboarding import probe as probe_module
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
async def exploding_probe(**_kwargs):
|
||||
raise RuntimeError("socket exploded mid-flight")
|
||||
|
||||
monkeypatch.setattr(probe_module, "probe_llm_provider", exploding_probe)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"--provider",
|
||||
"openrouter",
|
||||
"--model",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"--api-key",
|
||||
"sk",
|
||||
"--minimal",
|
||||
"--probe",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Probe failed" in result.stderr
|
||||
assert "socket exploded mid-flight" in result.stderr
|
||||
assert "Traceback" not in result.output + result.stderr
|
||||
# The probe is a gate, not a rollback: the save sticks.
|
||||
assert "openrouter" in target.read_text()
|
||||
|
||||
|
||||
def test_onboard_catalog_unknown_section_exits_2(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", "catalog", "not-a-section"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "unknown setup section" in _plain(result.stderr)
|
||||
assert "Traceback" not in result.output + result.stderr
|
||||
|
||||
|
||||
def test_onboard_if_needed_names_unfinished_sections(tmp_path, monkeypatch):
|
||||
# has_config=True but the referenced env key is missing: the gate must
|
||||
# explain which sections are unfinished before falling into the wizard.
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
"[llm]\n"
|
||||
'provider = "openrouter"\n'
|
||||
'model = "deepseek/deepseek-v4-flash"\n'
|
||||
'api_key_env = "CUSTOM_LLM_KEY"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.delenv("CUSTOM_LLM_KEY", raising=False)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(app, ["onboard", "--if-needed"])
|
||||
|
||||
assert result.exit_code == 2 # non-TTY still exits 2 afterwards
|
||||
assert "onboarding has unfinished sections" in result.stdout
|
||||
assert "Provider" in result.stdout
|
||||
|
||||
|
||||
def test_bare_configure_hub_exit_without_changes_is_success(tmp_path, monkeypatch):
|
||||
import sys
|
||||
import types
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
from opensquilla.onboarding import flow
|
||||
|
||||
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
||||
|
||||
class _Answer:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def ask(self):
|
||||
return self.value
|
||||
|
||||
class _Questionary(types.SimpleNamespace):
|
||||
def select(self, message: str, **kwargs):
|
||||
assert message == "Section"
|
||||
assert kwargs["choices"][-1] == "Exit (nothing changed)"
|
||||
return _Answer("Exit (nothing changed)")
|
||||
|
||||
def text(self, message: str, **_kwargs):
|
||||
raise AssertionError(f"unexpected text prompt: {message}")
|
||||
|
||||
def confirm(self, message: str, **_kwargs):
|
||||
raise AssertionError(f"unexpected confirm prompt: {message}")
|
||||
|
||||
def password(self, message: str, **_kwargs):
|
||||
raise AssertionError(f"unexpected password prompt: {message}")
|
||||
|
||||
def checkbox(self, message: str, **_kwargs):
|
||||
raise AssertionError(f"unexpected checkbox prompt: {message}")
|
||||
|
||||
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
||||
|
||||
result = runner.invoke(app, ["onboard", "configure"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "requires a TTY" not in result.stdout
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B2-9: router tier ids in the CLI stay sourced from the router_tiers
|
||||
# helpers instead of raw tuples/literals.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_router_catalog_tracks_router_tier_helpers(tmp_path, monkeypatch):
|
||||
from opensquilla.cli import onboard_cmd
|
||||
from opensquilla.router_tiers import DEFAULT_TEXT_TIER, TEXT_TIERS
|
||||
|
||||
assert (
|
||||
f"--default-tier {DEFAULT_TEXT_TIER}"
|
||||
in onboard_cmd._CATALOG_COMMANDS["routerProfiles"]
|
||||
)
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
result = runner.invoke(app, ["onboard", "catalog", "router"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
for tier in TEXT_TIERS:
|
||||
assert f"{tier}:" in result.stdout
|
||||
@@ -0,0 +1,474 @@
|
||||
"""Regression tests for review findings on the headless onboard CLI.
|
||||
|
||||
Covers: keep-current --router on re-saves (S1), EOFError cancellation
|
||||
productization (S4), restart guidance at the bare-onboard boundary (F17),
|
||||
wizard-phase OSError diagnosis (F27), validation-error redaction (F29), and
|
||||
the single-preflight-load contract of the --provider path (F41).
|
||||
All tests are offline and use synthetic dummy data only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli.main import app
|
||||
from opensquilla.onboarding.config_store import load_config
|
||||
|
||||
runner = CliRunner()
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||
|
||||
|
||||
def _plain(value: str) -> str:
|
||||
return " ".join(_ANSI_RE.sub("", value).split())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S1: bare `onboard --provider` re-saves keep the stored router state when
|
||||
# --router is omitted; explicit --router and first-run behavior are unchanged.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_onboard_provider_key_rotation_keeps_router_disabled_and_model(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
"[llm]\n"
|
||||
'provider = "openrouter"\n'
|
||||
'model = "custom/model-x"\n'
|
||||
'api_key = "sk-old"\n'
|
||||
"\n"
|
||||
"[squilla_router]\n"
|
||||
"enabled = false\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["onboard", "--provider", "openrouter", "--api-key", "sk-new", "--minimal"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
cfg = load_config(target)
|
||||
assert cfg.squilla_router.enabled is False
|
||||
assert cfg.llm.model == "custom/model-x"
|
||||
assert cfg.llm.api_key == "sk-new"
|
||||
data = tomllib.loads(target.read_text(encoding="utf-8"))
|
||||
assert data["squilla_router"]["enabled"] is False
|
||||
|
||||
|
||||
def test_onboard_provider_key_rotation_keeps_hand_customized_tiers(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
"[llm]\n"
|
||||
'provider = "openrouter"\n'
|
||||
'model = "custom/model-x"\n'
|
||||
'api_key = "sk-old"\n'
|
||||
"\n"
|
||||
"[squilla_router.tiers.c0]\n"
|
||||
'provider = "openrouter"\n'
|
||||
'model = "my-org/custom-c0"\n'
|
||||
"[squilla_router.tiers.c1]\n"
|
||||
'provider = "openrouter"\n'
|
||||
'model = "my-org/custom-c1"\n'
|
||||
"[squilla_router.tiers.c2]\n"
|
||||
'provider = "openrouter"\n'
|
||||
'model = "my-org/custom-c2"\n'
|
||||
"[squilla_router.tiers.c3]\n"
|
||||
'provider = "openrouter"\n'
|
||||
'model = "my-org/custom-c3"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["onboard", "--provider", "openrouter", "--api-key", "sk-new", "--minimal"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
cfg = load_config(target)
|
||||
for tier in ("c0", "c1", "c2", "c3"):
|
||||
assert cfg.squilla_router.tiers[tier]["model"] == f"my-org/custom-{tier}"
|
||||
assert cfg.llm.api_key == "sk-new"
|
||||
|
||||
|
||||
def test_onboard_provider_explicit_router_flag_still_applies_on_resave(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
"[llm]\n"
|
||||
'provider = "openrouter"\n'
|
||||
'model = "custom/model-x"\n'
|
||||
'api_key = "sk-old"\n'
|
||||
"\n"
|
||||
"[squilla_router]\n"
|
||||
"enabled = false\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"--provider",
|
||||
"openrouter",
|
||||
"--api-key",
|
||||
"sk-new",
|
||||
"--router",
|
||||
"recommended",
|
||||
"--minimal",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
cfg = load_config(target)
|
||||
assert cfg.squilla_router.enabled is True
|
||||
assert cfg.squilla_router.tier_profile == "openrouter"
|
||||
|
||||
|
||||
def test_onboard_provider_first_run_still_applies_recommended_router(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
# Fresh/unconfigured install: an omitted --router keeps today's first-run
|
||||
# behavior and applies the recommended profile.
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
monkeypatch.delenv("OPENSQUILLA_LLM_API_KEY", raising=False)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["onboard", "--provider", "openrouter", "--api-key", "sk", "--minimal"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
data = tomllib.loads(target.read_text(encoding="utf-8"))
|
||||
assert data["squilla_router"]["tier_profile"] == "openrouter"
|
||||
cfg = load_config(target)
|
||||
assert cfg.squilla_router.enabled is True
|
||||
|
||||
|
||||
def test_onboard_provider_first_run_with_env_key_still_applies_router(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
# An env-absorbed credential must not make a fresh install look
|
||||
# configured: the recommended profile still applies on first setup.
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
monkeypatch.setenv("OPENSQUILLA_LLM_API_KEY", "sk-from-env")
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["onboard", "--provider", "openrouter", "--api-key", "sk", "--minimal"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
data = tomllib.loads(target.read_text(encoding="utf-8"))
|
||||
assert data["squilla_router"]["tier_profile"] == "openrouter"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S4: EOFError (Ctrl+D / exhausted piped stdin) must be productized exactly
|
||||
# like Esc/Ctrl+C — one short "Setup cancelled" line, exit 130.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_onboard_wizard_eof_is_productized_as_cancellation(tmp_path, monkeypatch):
|
||||
from opensquilla.cli import onboard_cmd
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
def eof(_options):
|
||||
raise EOFError
|
||||
|
||||
monkeypatch.setattr(onboard_cmd, "run_interactive_onboard", eof)
|
||||
|
||||
result = runner.invoke(app, ["onboard"])
|
||||
|
||||
assert result.exit_code == 130
|
||||
assert "Setup cancelled" in _plain(result.stderr)
|
||||
assert "Aborted" not in result.output + result.stderr
|
||||
assert "Traceback" not in result.output + result.stderr
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_configure_wizard_eof_is_productized_as_cancellation(tmp_path, monkeypatch):
|
||||
from opensquilla.cli import onboard_cmd
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
def eof(_section, *, config_path=None):
|
||||
raise EOFError
|
||||
|
||||
monkeypatch.setattr(onboard_cmd, "run_interactive_configure", eof)
|
||||
|
||||
result = runner.invoke(app, ["onboard", "configure", "router"])
|
||||
|
||||
assert result.exit_code == 130
|
||||
assert "Setup cancelled" in _plain(result.stderr)
|
||||
assert "Aborted" not in result.output + result.stderr
|
||||
assert "Traceback" not in result.output + result.stderr
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F17: bare `onboard` must surface PersistResult.restart_required with the
|
||||
# same structured guidance `onboard configure` prints.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bare_onboard_prints_restart_guidance_when_result_requires_it(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
from opensquilla.cli import onboard_cmd
|
||||
from opensquilla.onboarding.config_store import PersistResult
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
'[llm]\nprovider = "openrouter"\nmodel = "m"\napi_key = "sk"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
def hub_edit(_options):
|
||||
# Simulates onboard -> "Change specific sections" -> a channel edit:
|
||||
# the aggregated result carries the sticky restart flag.
|
||||
return PersistResult(
|
||||
path=target,
|
||||
backup_path=None,
|
||||
restart_required=True,
|
||||
warnings=[],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(onboard_cmd, "run_interactive_onboard", hub_edit)
|
||||
|
||||
result = runner.invoke(app, ["onboard"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
plain = _plain(result.stdout)
|
||||
assert "restart required" in plain
|
||||
assert "opensquilla gateway restart" in plain
|
||||
|
||||
|
||||
def test_bare_onboard_prints_no_restart_guidance_without_flag(tmp_path, monkeypatch):
|
||||
from opensquilla.cli import onboard_cmd
|
||||
from opensquilla.onboarding.config_store import PersistResult
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
'[llm]\nprovider = "openrouter"\nmodel = "m"\napi_key = "sk"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
monkeypatch.setattr(
|
||||
onboard_cmd,
|
||||
"run_interactive_onboard",
|
||||
lambda _options: PersistResult(
|
||||
path=target,
|
||||
backup_path=None,
|
||||
restart_required=False,
|
||||
warnings=[],
|
||||
),
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["onboard"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "restart required" not in _plain(result.stdout)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F27: an OSError raised INSIDE the wizard (e.g. disk full during the final
|
||||
# persist) is a write failure, not a config-load error — the CLI must not
|
||||
# claim the config failed to load or suggest editing/moving it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_onboard_wizard_write_oserror_is_not_misdiagnosed_as_load_error(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
from opensquilla.cli import onboard_cmd
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text(
|
||||
'[llm]\nprovider = "openrouter"\nmodel = "m"\napi_key = "sk"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
def disk_full(_options):
|
||||
raise OSError(errno.ENOSPC, "No space left on device")
|
||||
|
||||
monkeypatch.setattr(onboard_cmd, "run_interactive_onboard", disk_full)
|
||||
|
||||
result = runner.invoke(app, ["onboard"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
plain = _plain(result.stderr)
|
||||
assert "No space left on device" in plain
|
||||
assert "OpenSquilla config error" not in plain
|
||||
assert "edit or move this config" not in plain
|
||||
assert "Traceback" not in result.output + result.stderr
|
||||
|
||||
|
||||
def test_onboard_prevalidates_config_before_entering_wizard(tmp_path, monkeypatch):
|
||||
from opensquilla.cli import onboard_cmd
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
target.write_text("not toml :::", encoding="utf-8")
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
def must_not_run(_options): # pragma: no cover - the pre-check exits first
|
||||
raise AssertionError("the wizard must not start over a corrupt config")
|
||||
|
||||
monkeypatch.setattr(onboard_cmd, "run_interactive_onboard", must_not_run)
|
||||
|
||||
result = runner.invoke(app, ["onboard"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "OpenSquilla config error" in result.stderr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F29: a validator message that interpolates the offending input must be
|
||||
# masked by the free-text redactor before it reaches stderr.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_onboard_provider_validation_error_redacts_secret_shaped_values(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
from pydantic import BaseModel, ValidationError, field_validator
|
||||
|
||||
from opensquilla.cli import onboard_cmd
|
||||
|
||||
secret = "sk-live-SECRETSECRETSECRETSECRET123456"
|
||||
|
||||
class _Probe(BaseModel):
|
||||
api_key: str
|
||||
|
||||
@field_validator("api_key")
|
||||
@classmethod
|
||||
def _reject(cls, value: str) -> str:
|
||||
raise ValueError(f"key {value!r} looks malformed")
|
||||
|
||||
try:
|
||||
_Probe(api_key=secret)
|
||||
except ValidationError as exc:
|
||||
captured = exc
|
||||
else: # pragma: no cover - the construction above always fails
|
||||
raise AssertionError("expected a ValidationError")
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
class _RaisingEngine:
|
||||
def __init__(self, *_a, **_kw):
|
||||
pass
|
||||
|
||||
def apply(self, *_a, **_kw):
|
||||
raise captured
|
||||
|
||||
def persist(self, *_a, **_kw): # pragma: no cover - apply raises first
|
||||
raise AssertionError("persist must not run after a failed apply")
|
||||
|
||||
monkeypatch.setattr(onboard_cmd, "SetupEngine", _RaisingEngine)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["onboard", "--provider", "openrouter", "--api-key", secret, "--minimal"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
combined = result.output + result.stderr
|
||||
assert secret not in combined
|
||||
assert "***" in _plain(result.stderr)
|
||||
assert "api_key" in _plain(result.stderr)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_format_validation_error_masks_interpolated_secret_unit():
|
||||
from pydantic import BaseModel, ValidationError, field_validator
|
||||
|
||||
from opensquilla.cli.onboard_cmd import _format_validation_error
|
||||
|
||||
secret = "sk-live-UNITSECRETUNITSECRET987654"
|
||||
|
||||
class _Probe(BaseModel):
|
||||
api_key: str
|
||||
|
||||
@field_validator("api_key")
|
||||
@classmethod
|
||||
def _reject(cls, value: str) -> str:
|
||||
raise ValueError(f"key {value!r} looks malformed")
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
_Probe(api_key=secret)
|
||||
|
||||
rendered = _format_validation_error(excinfo.value)
|
||||
assert secret not in rendered
|
||||
assert "***" in rendered
|
||||
assert "api_key" in rendered
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F41: the `onboard --provider` path must reuse its preflight load for the
|
||||
# save instead of loading the config again inside the engine.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_onboard_provider_reuses_preflight_load_for_the_save(tmp_path, monkeypatch):
|
||||
from opensquilla.cli import onboard_cmd
|
||||
from opensquilla.onboarding import setup_engine
|
||||
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
|
||||
def engine_must_not_load(*_a, **_kw): # pragma: no cover - regression guard
|
||||
raise AssertionError(
|
||||
"SetupEngine must receive the preflight-loaded config, not reload it"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(setup_engine, "load_config", engine_must_not_load)
|
||||
|
||||
load_calls: list[object] = []
|
||||
real_load = onboard_cmd.load_config
|
||||
|
||||
def counting_load(path=None):
|
||||
load_calls.append(path)
|
||||
return real_load(path)
|
||||
|
||||
monkeypatch.setattr(onboard_cmd, "load_config", counting_load)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"onboard",
|
||||
"--provider",
|
||||
"openrouter",
|
||||
"--model",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"--api-key",
|
||||
"sk",
|
||||
"--minimal",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# One preflight load feeding the engine + one post-save load for the
|
||||
# handoff summary; the previous shape performed three full loads.
|
||||
assert len(load_calls) == 2
|
||||
assert "openrouter" in target.read_text(encoding="utf-8")
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Shape freeze for ``opensquilla onboard status --json``.
|
||||
|
||||
The CLI JSON payload must stay a strict superset of the RPC
|
||||
``onboarding.status`` payload (contract-frozen in
|
||||
tests/test_contracts/test_onboarding_status.py): every RPC key appears here
|
||||
under the same name, and ``sectionAliases`` (plus the ``provider`` alias
|
||||
entries inside ``sections``/``sectionDetails``) is the only CLI-side
|
||||
addition. Renaming or dropping a key is a contract break and must fail here;
|
||||
adding one requires consciously extending the frozen set below.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli.main import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# RPC onboarding.status keys (mirrors STATUS_TOP_LEVEL_KEYS in
|
||||
# tests/test_contracts/test_onboarding_status.py) — every one of these must
|
||||
# also appear in the CLI JSON payload.
|
||||
RPC_STATUS_KEYS = frozenset(
|
||||
{
|
||||
"configPath",
|
||||
"hasConfig",
|
||||
"llmConfigured",
|
||||
"llmSource",
|
||||
"llmEnvKey",
|
||||
"llmCredentialStatus",
|
||||
"imageGenerationConfigured",
|
||||
"imageGenerationEnabled",
|
||||
"imageGenerationSource",
|
||||
"imageGenerationProvider",
|
||||
"imageGenerationPrimary",
|
||||
"imageGenerationEnvKey",
|
||||
"audioConfigured",
|
||||
"audioEnabled",
|
||||
"audioSource",
|
||||
"audioProvider",
|
||||
"audioEnvKey",
|
||||
"searchConfigured",
|
||||
"searchProvider",
|
||||
"searchSource",
|
||||
"searchEnvKey",
|
||||
"memoryEmbeddingConfigured",
|
||||
"memoryEmbeddingProvider",
|
||||
"memoryEmbeddingSource",
|
||||
"memoryEmbeddingEnvKey",
|
||||
"channelCount",
|
||||
"channelsConfigured",
|
||||
"ensembleCredentialStatus",
|
||||
"needsOnboarding",
|
||||
"sections",
|
||||
"sectionDetails",
|
||||
"envRecoveryCommands",
|
||||
"warnings",
|
||||
# Nullable legacy-data advisory block (see the deliberate additive
|
||||
# extension in tests/test_contracts/test_onboarding_status.py).
|
||||
"legacyData",
|
||||
}
|
||||
)
|
||||
|
||||
# Exact CLI JSON shape: the RPC keys plus the CLI-only alias map.
|
||||
CLI_STATUS_KEYS = RPC_STATUS_KEYS | {"sectionAliases"}
|
||||
|
||||
|
||||
def _write_config(tmp_path, monkeypatch):
|
||||
target = tmp_path / "config.toml"
|
||||
target.write_text(
|
||||
"[llm]\n"
|
||||
'provider = "openrouter"\n'
|
||||
'model = "deepseek/deepseek-v4-flash"\n'
|
||||
'api_key_env = "DUMMY_UNSET_LLM_KEY"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.delenv("DUMMY_UNSET_LLM_KEY", raising=False)
|
||||
return target
|
||||
|
||||
|
||||
def _status_json(target):
|
||||
result = runner.invoke(app, ["onboard", "status", "--json", "--config", str(target)])
|
||||
assert result.exit_code == 0, result.output
|
||||
return _json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_status_json_key_set_is_frozen(tmp_path, monkeypatch):
|
||||
payload = _status_json(_write_config(tmp_path, monkeypatch))
|
||||
|
||||
assert set(payload) == CLI_STATUS_KEYS
|
||||
|
||||
|
||||
def test_status_json_is_a_superset_of_the_rpc_payload(tmp_path, monkeypatch):
|
||||
"""Anti-drift: compare against the live RPC payload, not a copied list."""
|
||||
from opensquilla.gateway.rpc import RpcContext
|
||||
from opensquilla.gateway.rpc_onboarding import _status_payload as rpc_status_payload
|
||||
from opensquilla.onboarding.config_store import load_config
|
||||
|
||||
target = _write_config(tmp_path, monkeypatch)
|
||||
cli_payload = _status_json(target)
|
||||
rpc_payload = rpc_status_payload(
|
||||
RpcContext(conn_id="cli-freeze", config=load_config(target))
|
||||
)
|
||||
|
||||
missing = set(rpc_payload) - set(cli_payload)
|
||||
assert not missing, f"CLI status --json lost RPC keys: {sorted(missing)}"
|
||||
assert set(cli_payload) - set(rpc_payload) == {"sectionAliases"}
|
||||
|
||||
|
||||
def test_status_json_new_keys_carry_the_expected_values(tmp_path, monkeypatch):
|
||||
payload = _status_json(_write_config(tmp_path, monkeypatch))
|
||||
|
||||
assert payload["llmConfigured"] is False
|
||||
assert payload["llmSource"] == "missing_env"
|
||||
credential = payload["llmCredentialStatus"]
|
||||
assert credential["provider"] == "openrouter"
|
||||
assert credential["available"] is False
|
||||
assert credential["source"] == "missing_env"
|
||||
assert credential["envKey"] == "DUMMY_UNSET_LLM_KEY"
|
||||
assert payload["audioConfigured"] is False
|
||||
assert payload["audioEnabled"] is False
|
||||
# Default search provider (duckduckgo) needs no key, so the section is
|
||||
# already configured on a fresh config.
|
||||
assert payload["searchConfigured"] is True
|
||||
assert payload["channelsConfigured"] is False
|
||||
assert isinstance(payload["ensembleCredentialStatus"], list)
|
||||
assert isinstance(payload["warnings"], list)
|
||||
|
||||
|
||||
def test_status_json_command_field_is_bare_on_posix(tmp_path, monkeypatch):
|
||||
from opensquilla.onboarding import next_steps
|
||||
|
||||
monkeypatch.setattr(next_steps.platform, "system", lambda: "Linux")
|
||||
payload = _status_json(_write_config(tmp_path, monkeypatch))
|
||||
|
||||
commands = payload["envRecoveryCommands"]
|
||||
assert commands
|
||||
assert commands[0]["command"] == 'export DUMMY_UNSET_LLM_KEY="<your-key>"'
|
||||
|
||||
|
||||
def test_status_json_command_field_has_no_shell_label_on_windows(tmp_path, monkeypatch):
|
||||
"""Machine-readable command fields must contain only the command."""
|
||||
from opensquilla.onboarding import next_steps
|
||||
|
||||
monkeypatch.setattr(next_steps.platform, "system", lambda: "Windows")
|
||||
payload = _status_json(_write_config(tmp_path, monkeypatch))
|
||||
|
||||
commands = payload["envRecoveryCommands"]
|
||||
assert commands
|
||||
assert commands[0]["command"] == '$env:DUMMY_UNSET_LLM_KEY = "<your-key>"'
|
||||
assert all("PowerShell" not in entry["command"] for entry in commands)
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from opensquilla.cli.output import emit_error, print_json
|
||||
|
||||
|
||||
def test_print_json_uses_stdout(capsys):
|
||||
print_json({"text": "héllo", "value": object()})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
payload = json.loads(captured.out)
|
||||
assert payload["text"] == "héllo"
|
||||
assert captured.err == ""
|
||||
|
||||
|
||||
def test_emit_error_json_uses_stderr(capsys):
|
||||
emit_error("bad input", json_output=True, code="INVALID_REQUEST", details={"field": "x"})
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
payload = json.loads(captured.err)
|
||||
assert payload == {
|
||||
"error": {
|
||||
"message": "bad input",
|
||||
"code": "INVALID_REQUEST",
|
||||
"details": {"field": "x"},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Regression tests for CLI profile resolution before home .env loading."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli.main import app
|
||||
from opensquilla.paths import default_opensquilla_home
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _write_profile(home: Path, name: str, env_lines: list[str]) -> Path:
|
||||
profile_dir = home / name
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
(profile_dir / ".env").write_text("\n".join(env_lines) + "\n", encoding="utf-8")
|
||||
return profile_dir
|
||||
|
||||
|
||||
def test_cli_profile_loads_selected_profile_env(monkeypatch, tmp_path: Path) -> None:
|
||||
home = tmp_path / "profiles"
|
||||
_write_profile(home, "default", ["PROFILE_MARK=loaded-default"])
|
||||
_write_profile(home, "coder", ["CODER_MARK=loaded-coder"])
|
||||
|
||||
for key in (
|
||||
"OPENSQUILLA_HOME",
|
||||
"OPENSQUILLA_PROFILE",
|
||||
"OPENSQUILLA_STATE_DIR",
|
||||
"PROFILE_MARK",
|
||||
"CODER_MARK",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setenv("OPENSQUILLA_HOME", str(home))
|
||||
|
||||
result = runner.invoke(app, ["--profile", "coder", "providers", "list", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert os.environ["OPENSQUILLA_PROFILE"] == "coder"
|
||||
assert os.environ["CODER_MARK"] == "loaded-coder"
|
||||
assert "PROFILE_MARK" not in os.environ
|
||||
assert default_opensquilla_home() == home / "coder"
|
||||
|
||||
|
||||
def test_cli_without_profile_keeps_legacy_home(monkeypatch, tmp_path: Path) -> None:
|
||||
legacy_home = tmp_path / ".opensquilla"
|
||||
legacy_home.mkdir()
|
||||
(legacy_home / ".env").write_text("LEGACY_MARK=loaded\n", encoding="utf-8")
|
||||
|
||||
for key in (
|
||||
"OPENSQUILLA_HOME",
|
||||
"OPENSQUILLA_PROFILE",
|
||||
"OPENSQUILLA_STATE_DIR",
|
||||
"LEGACY_MARK",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
result = runner.invoke(app, ["providers", "list", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert os.environ["LEGACY_MARK"] == "loaded"
|
||||
assert default_opensquilla_home() == legacy_home
|
||||
|
||||
|
||||
def test_cli_does_not_reload_same_home_env_after_key_removed(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
legacy_home = tmp_path / ".opensquilla"
|
||||
legacy_home.mkdir()
|
||||
(legacy_home / ".env").write_text("RELOAD_MARK=loaded\n", encoding="utf-8")
|
||||
|
||||
for key in (
|
||||
"OPENSQUILLA_HOME",
|
||||
"OPENSQUILLA_PROFILE",
|
||||
"OPENSQUILLA_STATE_DIR",
|
||||
"RELOAD_MARK",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
result = runner.invoke(app, ["providers", "list", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert os.environ["RELOAD_MARK"] == "loaded"
|
||||
|
||||
monkeypatch.delenv("RELOAD_MARK", raising=False)
|
||||
|
||||
result = runner.invoke(app, ["providers", "list", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "RELOAD_MARK" not in os.environ
|
||||
|
||||
|
||||
def test_cli_rejects_invalid_profile(monkeypatch) -> None:
|
||||
monkeypatch.delenv("OPENSQUILLA_STATE_DIR", raising=False)
|
||||
|
||||
result = runner.invoke(app, ["--profile", "../escape", "providers", "list"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "lowercase letters" in result.output
|
||||
|
||||
|
||||
def test_env_load_uses_provided_home(monkeypatch, tmp_path: Path) -> None:
|
||||
from opensquilla.env import load_env
|
||||
|
||||
home = tmp_path / "custom"
|
||||
home.mkdir(parents=True)
|
||||
(home / ".env").write_text("CUSTOM_MARK=ok\n", encoding="utf-8")
|
||||
|
||||
for key in (
|
||||
"OPENSQUILLA_HOME",
|
||||
"OPENSQUILLA_PROFILE",
|
||||
"OPENSQUILLA_STATE_DIR",
|
||||
"CUSTOM_MARK",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
assert load_env(home=home) >= 1
|
||||
assert os.environ["CUSTOM_MARK"] == "ok"
|
||||
|
||||
|
||||
def test_cli_profile_env_wins_over_legacy_home_for_same_key(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
legacy_home = tmp_path / ".opensquilla"
|
||||
profiles_root = legacy_home / "profiles"
|
||||
legacy_home.mkdir()
|
||||
(legacy_home / ".env").write_text("PROFILE_SHARED_MARK=legacy\n", encoding="utf-8")
|
||||
_write_profile(profiles_root, "coder", ["PROFILE_SHARED_MARK=coder"])
|
||||
|
||||
for key in (
|
||||
"OPENSQUILLA_HOME",
|
||||
"OPENSQUILLA_PROFILE",
|
||||
"OPENSQUILLA_STATE_DIR",
|
||||
"PROFILE_SHARED_MARK",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
cwd = tmp_path / "cwd"
|
||||
cwd.mkdir()
|
||||
monkeypatch.chdir(cwd)
|
||||
|
||||
result = runner.invoke(app, ["--profile", "coder", "providers", "list", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert os.environ["PROFILE_SHARED_MARK"] == "coder"
|
||||
assert default_opensquilla_home() == profiles_root / "coder"
|
||||
|
||||
|
||||
def test_cli_profile_env_wins_on_cold_start_import(tmp_path: Path) -> None:
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
cwd = tmp_path / "cwd"
|
||||
cwd.mkdir()
|
||||
legacy_home = tmp_path / ".opensquilla"
|
||||
profiles_root = legacy_home / "profiles"
|
||||
legacy_home.mkdir()
|
||||
(legacy_home / ".env").write_text("PROFILE_SHARED_MARK=legacy\n", encoding="utf-8")
|
||||
_write_profile(profiles_root, "coder", ["PROFILE_SHARED_MARK=coder"])
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update({"HOME": str(tmp_path)})
|
||||
env.pop("OPENSQUILLA_HOME", None)
|
||||
env.pop("OPENSQUILLA_PROFILE", None)
|
||||
env.pop("OPENSQUILLA_STATE_DIR", None)
|
||||
env.pop("PROFILE_SHARED_MARK", None)
|
||||
pythonpath = [
|
||||
str(repo_root / "src"),
|
||||
str(repo_root),
|
||||
env.get("PYTHONPATH", ""),
|
||||
]
|
||||
env["PYTHONPATH"] = os.pathsep.join(path for path in pythonpath if path)
|
||||
|
||||
script = """
|
||||
import os
|
||||
import sys
|
||||
from typer.testing import CliRunner
|
||||
|
||||
sys.argv = ["opensquilla", "--profile", "coder", "providers", "list", "--json"]
|
||||
from opensquilla.cli.main import app
|
||||
|
||||
cold_start_mark = os.environ.get("PROFILE_SHARED_MARK", "")
|
||||
if cold_start_mark != "coder":
|
||||
print(cold_start_mark)
|
||||
raise SystemExit(2)
|
||||
|
||||
result = CliRunner().invoke(app, ["--profile", "coder", "providers", "list", "--json"])
|
||||
if result.exit_code != 0:
|
||||
print(result.output)
|
||||
raise SystemExit(result.exit_code)
|
||||
|
||||
print(os.environ.get("PROFILE_SHARED_MARK", ""))
|
||||
raise SystemExit(0 if os.environ.get("PROFILE_SHARED_MARK") == "coder" else 2)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_cli_profile_from_cwd_env_wins_over_legacy_home_on_cold_start(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
cwd = tmp_path / "cwd"
|
||||
cwd.mkdir()
|
||||
(cwd / ".env").write_text("OPENSQUILLA_PROFILE=coder\n", encoding="utf-8")
|
||||
legacy_home = tmp_path / ".opensquilla"
|
||||
profiles_root = legacy_home / "profiles"
|
||||
legacy_home.mkdir()
|
||||
(legacy_home / ".env").write_text("PROFILE_SHARED_MARK=legacy\n", encoding="utf-8")
|
||||
_write_profile(profiles_root, "coder", ["PROFILE_SHARED_MARK=coder"])
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update({"HOME": str(tmp_path)})
|
||||
env.pop("OPENSQUILLA_HOME", None)
|
||||
env.pop("OPENSQUILLA_PROFILE", None)
|
||||
env.pop("OPENSQUILLA_STATE_DIR", None)
|
||||
env.pop("PROFILE_SHARED_MARK", None)
|
||||
pythonpath = [
|
||||
str(repo_root / "src"),
|
||||
str(repo_root),
|
||||
env.get("PYTHONPATH", ""),
|
||||
]
|
||||
env["PYTHONPATH"] = os.pathsep.join(path for path in pythonpath if path)
|
||||
|
||||
script = """
|
||||
import os
|
||||
from opensquilla.paths import default_opensquilla_home
|
||||
|
||||
from opensquilla.cli.main import app
|
||||
|
||||
if os.environ.get("PROFILE_SHARED_MARK") != "coder":
|
||||
print(os.environ.get("PROFILE_SHARED_MARK", ""))
|
||||
raise SystemExit(2)
|
||||
if default_opensquilla_home().name != "coder":
|
||||
print(default_opensquilla_home())
|
||||
raise SystemExit(3)
|
||||
raise SystemExit(0)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
@@ -0,0 +1,97 @@
|
||||
"""CLI tests for `opensquilla providers`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli.main import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_providers_list_shows_all_supported(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(tmp_path / "c.toml"))
|
||||
result = runner.invoke(app, ["providers", "list"])
|
||||
assert result.exit_code == 0
|
||||
out = result.stdout
|
||||
for pid in ("openrouter", "openai", "ollama", "vllm", "azure"):
|
||||
assert pid in out
|
||||
|
||||
|
||||
def test_providers_list_marks_unsupported(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(tmp_path / "c.toml"))
|
||||
result = runner.invoke(app, ["providers", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert "openai_codex" in result.stdout
|
||||
assert "unsupported" in result.stdout.lower() or "disabled" in result.stdout.lower()
|
||||
|
||||
|
||||
def test_providers_configure_writes_config(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"providers", "configure", "openrouter",
|
||||
"--model", "deepseek/deepseek-v4-flash",
|
||||
"--api-key", "sk-test",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
text = target.read_text()
|
||||
assert "openrouter" in text
|
||||
assert "deepseek/deepseek-v4-flash" in text
|
||||
assert "sk-test" not in result.stdout
|
||||
|
||||
|
||||
def test_providers_configure_unsupported_fails(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(tmp_path / "c.toml"))
|
||||
result = runner.invoke(
|
||||
app, ["providers", "configure", "github_copilot", "--model", "x"]
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert (
|
||||
"not runtime-supported" in result.stdout.lower()
|
||||
or "not runtime-supported" in (result.stderr or "").lower()
|
||||
)
|
||||
|
||||
|
||||
def test_providers_configure_ollama_no_key_required(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
result = runner.invoke(
|
||||
app, ["providers", "configure", "ollama", "--model", "llama3"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "ollama" in target.read_text()
|
||||
|
||||
|
||||
def test_providers_configure_vllm_requires_base_url(tmp_path, monkeypatch):
|
||||
# vllm is experimental (registry-runnable, unverified): configurable, but
|
||||
# its explicit base_url requirement still validates.
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(tmp_path / "c.toml"))
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["providers", "configure", "vllm", "--model", "x", "--api-key", "k"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
combined = (result.stdout + (result.stderr or "")).lower()
|
||||
assert "base_url" in combined
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"providers",
|
||||
"configure",
|
||||
"vllm",
|
||||
"--model",
|
||||
"x",
|
||||
"--api-key",
|
||||
"k",
|
||||
"--base-url",
|
||||
"http://localhost:8000/v1",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
@@ -0,0 +1,115 @@
|
||||
"""``opensquilla router calibrate`` CLI.
|
||||
|
||||
Seeds a synthetic ``router_decisions`` table (hand-created — never real state)
|
||||
and drives the command through Typer's ``CliRunner``. The output file always
|
||||
lands under a monkeypatched ``OPENSQUILLA_STATE_DIR`` temp dir, never the real
|
||||
home.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli.main import app
|
||||
from opensquilla.persistence.router_decision_writer import RouterDecisionWriter
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
_CREATE_TABLE = (
|
||||
"CREATE TABLE router_decisions ("
|
||||
" decision_id TEXT PRIMARY KEY, session_key TEXT NOT NULL,"
|
||||
" turn_index INTEGER, ts_ms INTEGER NOT NULL, classifier TEXT,"
|
||||
" proposed_tier TEXT, confidence REAL, probs TEXT, flags TEXT,"
|
||||
" final_tier TEXT, provider TEXT, model TEXT, thinking_level TEXT,"
|
||||
" source TEXT, trail TEXT, baseline_model TEXT, savings_pct REAL,"
|
||||
" executed_kind TEXT, ensemble_profile TEXT,"
|
||||
" fallback_hops INTEGER NOT NULL DEFAULT 0)"
|
||||
)
|
||||
|
||||
|
||||
def _seed_db(path: Path, *, count: int = 40) -> None:
|
||||
conn = sqlite3.connect(str(path), check_same_thread=False, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(_CREATE_TABLE)
|
||||
writer = RouterDecisionWriter(conn)
|
||||
for index in range(count):
|
||||
writer.record_decision(
|
||||
{
|
||||
"decision_id": f"c{index}",
|
||||
"session_key": "agent:calib:main",
|
||||
"turn_index": index,
|
||||
"proposed_tier": "c2",
|
||||
"final_tier": "c1",
|
||||
"source": "v4_phase3",
|
||||
"trail": [{"stage": "confidence_gate", "applied": True}],
|
||||
}
|
||||
)
|
||||
writer.close()
|
||||
|
||||
|
||||
def _env(monkeypatch: Any, tmp_path: Path, db: Path | None) -> None:
|
||||
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
|
||||
if db is not None:
|
||||
monkeypatch.setenv("OPENSQUILLA_ROUTER_DECISIONS_DB", str(db))
|
||||
else:
|
||||
monkeypatch.delenv("OPENSQUILLA_ROUTER_DECISIONS_DB", raising=False)
|
||||
|
||||
|
||||
def _output_file(tmp_path: Path) -> Path:
|
||||
return tmp_path / "home" / "state" / "router_calibration.json"
|
||||
|
||||
|
||||
def test_calibrate_dry_run_prints_without_writing(tmp_path: Path, monkeypatch: Any) -> None:
|
||||
db = tmp_path / "sessions.db"
|
||||
_seed_db(db)
|
||||
_env(monkeypatch, tmp_path, db)
|
||||
|
||||
result = runner.invoke(app, ["router", "calibrate", "--dry-run"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "samples:" in result.output
|
||||
assert "threshold_adjust:" in result.output
|
||||
assert "dry-run" in result.output
|
||||
assert not _output_file(tmp_path).exists()
|
||||
|
||||
|
||||
def test_calibrate_writes_file(tmp_path: Path, monkeypatch: Any) -> None:
|
||||
db = tmp_path / "sessions.db"
|
||||
_seed_db(db)
|
||||
_env(monkeypatch, tmp_path, db)
|
||||
|
||||
result = runner.invoke(app, ["router", "calibrate"])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
out = _output_file(tmp_path)
|
||||
assert out.exists()
|
||||
payload = json.loads(out.read_text(encoding="utf-8"))
|
||||
assert payload["sample_count"] == 40
|
||||
# 40 clean c2 downgrades -> bias c2 down (clamped), threshold up.
|
||||
assert payload["per_class_bias"] == {"c2": -0.15}
|
||||
assert payload["threshold_adjust"] > 0.0
|
||||
|
||||
|
||||
def test_calibrate_json_output(tmp_path: Path, monkeypatch: Any) -> None:
|
||||
db = tmp_path / "sessions.db"
|
||||
_seed_db(db)
|
||||
_env(monkeypatch, tmp_path, db)
|
||||
|
||||
result = runner.invoke(app, ["router", "calibrate", "--json"])
|
||||
assert result.exit_code == 0, result.output
|
||||
doc = json.loads(result.output)
|
||||
assert doc["wrote"] is True
|
||||
assert doc["calibration"]["sample_count"] == 40
|
||||
|
||||
|
||||
def test_calibrate_missing_db_is_neutral_no_crash(tmp_path: Path, monkeypatch: Any) -> None:
|
||||
_env(monkeypatch, tmp_path, tmp_path / "does-not-exist.db")
|
||||
|
||||
result = runner.invoke(app, ["router", "calibrate", "--dry-run"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "samples: 0" in result.output
|
||||
assert not _output_file(tmp_path).exists()
|
||||
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli.main import app
|
||||
from opensquilla.onboarding.config_store import load_config, persist_config
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _invoke(config_path: Path, *args: str):
|
||||
return runner.invoke(app, ["sandbox", *args, "--config", str(config_path)])
|
||||
|
||||
|
||||
def test_sandbox_status_reports_default_full_host_access(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "config.toml"
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["sandbox", "status", "--config", str(config_path), "--json"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
assert payload["run_mode"] == "full"
|
||||
assert payload["run_mode_label"] == "Full Host Access"
|
||||
assert payload["execution_target"] == "host"
|
||||
assert payload["posture"] == "full"
|
||||
assert payload["sandbox"]["sandbox"] is False
|
||||
assert payload["sandbox"]["security_grading"] is False
|
||||
assert payload["permissions"]["default_mode"] == "full"
|
||||
assert payload["restart_required"] is False
|
||||
|
||||
|
||||
def test_sandbox_trust_persists_trusted_run_mode(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "config.toml"
|
||||
|
||||
result = _invoke(config_path, "trust")
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
cfg = load_config(config_path)
|
||||
assert cfg.sandbox.run_mode == "trusted"
|
||||
assert cfg.sandbox.sandbox is True
|
||||
assert cfg.sandbox.security_grading is True
|
||||
assert cfg.sandbox.network_default == "proxy_allowlist"
|
||||
assert cfg.permissions.default_mode == "off"
|
||||
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert data["sandbox"]["run_mode"] == "trusted"
|
||||
# Sparse persistence omits values equal to the built-in defaults
|
||||
# (sandbox on, grading on, proxy allowlist, permissions off); the
|
||||
# effective posture is asserted via load_config above. Any value that
|
||||
# is written must match the trusted posture.
|
||||
assert data["sandbox"].get("sandbox", True) is True
|
||||
assert data["sandbox"].get("security_grading", True) is True
|
||||
assert data["sandbox"].get("network_default", "proxy_allowlist") == "proxy_allowlist"
|
||||
assert data.get("permissions", {}).get("default_mode", "off") == "off"
|
||||
assert "restart" in result.output.lower()
|
||||
|
||||
|
||||
def test_sandbox_trust_repairs_legacy_disabled_network_default(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "config.toml"
|
||||
full = _invoke(config_path, "full")
|
||||
assert full.exit_code == 0, full.output
|
||||
cfg = load_config(config_path)
|
||||
cfg.sandbox.network_default = "none"
|
||||
persist_config(cfg, path=config_path)
|
||||
|
||||
result = _invoke(config_path, "trust")
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
cfg = load_config(config_path)
|
||||
assert cfg.sandbox.run_mode == "trusted"
|
||||
assert cfg.sandbox.network_default == "proxy_allowlist"
|
||||
|
||||
|
||||
def test_sandbox_bypass_fails_without_mutating_config(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "config.toml"
|
||||
before = _invoke(config_path, "on")
|
||||
assert before.exit_code == 0, before.output
|
||||
|
||||
result = _invoke(config_path, "bypass")
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "removed" in result.output.lower()
|
||||
assert "sandbox trust" in result.output
|
||||
cfg = load_config(config_path)
|
||||
assert cfg.sandbox.run_mode == "trusted"
|
||||
assert cfg.sandbox.sandbox is True
|
||||
assert cfg.sandbox.security_grading is True
|
||||
assert cfg.permissions.default_mode == "off"
|
||||
|
||||
|
||||
def test_sandbox_full_and_on_are_reversible(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "config.toml"
|
||||
|
||||
full = _invoke(config_path, "full")
|
||||
assert full.exit_code == 0, full.output
|
||||
cfg = load_config(config_path)
|
||||
assert cfg.sandbox.run_mode == "full"
|
||||
assert cfg.sandbox.sandbox is False
|
||||
assert cfg.sandbox.security_grading is False
|
||||
assert cfg.sandbox.network_default == "none"
|
||||
assert cfg.permissions.default_mode == "full"
|
||||
|
||||
on = _invoke(config_path, "on")
|
||||
assert on.exit_code == 0, on.output
|
||||
cfg = load_config(config_path)
|
||||
assert cfg.sandbox.run_mode == "trusted"
|
||||
assert cfg.sandbox.sandbox is True
|
||||
assert cfg.sandbox.security_grading is True
|
||||
assert cfg.sandbox.network_default == "proxy_allowlist"
|
||||
assert cfg.permissions.default_mode == "off"
|
||||
|
||||
|
||||
def test_sandbox_reset_restores_full_host_access(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "config.toml"
|
||||
|
||||
full = _invoke(config_path, "full")
|
||||
reset = _invoke(config_path, "reset")
|
||||
|
||||
assert full.exit_code == 0, full.output
|
||||
assert reset.exit_code == 0, reset.output
|
||||
cfg = load_config(config_path)
|
||||
assert cfg.sandbox.run_mode == "full"
|
||||
assert cfg.sandbox.sandbox is False
|
||||
assert cfg.sandbox.security_grading is False
|
||||
assert cfg.sandbox.network_default == "none"
|
||||
assert cfg.permissions.default_mode == "full"
|
||||
@@ -0,0 +1,200 @@
|
||||
"""CLI tests for `opensquilla search benchmark`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import cast
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
import opensquilla.cli.search_cmd as search_cmd # type: ignore[import-untyped]
|
||||
from opensquilla.cli.main import app # type: ignore[import-untyped]
|
||||
from opensquilla.search.types import DEFAULT_SEARCH_MAX_RESULTS
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _benchmark_payload() -> dict[str, object]:
|
||||
return {
|
||||
"profile": "smoke",
|
||||
"measurement_kind": "synthetic_smoke",
|
||||
"live": False,
|
||||
"v1": {
|
||||
"p50_latency_ms": 10,
|
||||
"p95_latency_ms": 20,
|
||||
"success_at_k": 0.7,
|
||||
"external_tool_calls_per_question": 2,
|
||||
"avg_returned_chars": 500,
|
||||
"duplicate_url_rate": 0.2,
|
||||
"fetch_success_rate": 0.8,
|
||||
"provider_fallback_count": 1,
|
||||
},
|
||||
"v2": {
|
||||
"p50_latency_ms": 12,
|
||||
"p95_latency_ms": 24,
|
||||
"success_at_k": 0.9,
|
||||
"external_tool_calls_per_question": 1,
|
||||
"avg_returned_chars": 300,
|
||||
"duplicate_url_rate": 0.0,
|
||||
"fetch_success_rate": 1.0,
|
||||
"provider_fallback_count": 0,
|
||||
},
|
||||
"delta": {
|
||||
"success_at_k": 0.2,
|
||||
"external_tool_calls_per_question": -1,
|
||||
"avg_returned_chars": -200,
|
||||
},
|
||||
"cases": [
|
||||
{"id": "official_docs", "query": "python release", "kind": "technical_docs"}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _numeric_metric(metrics: dict[str, object], key: str) -> int | float:
|
||||
value = metrics[key]
|
||||
assert isinstance(value, int | float)
|
||||
return value
|
||||
|
||||
|
||||
def _assert_benchmark_metrics(payload: dict[str, object]) -> None:
|
||||
assert set(payload) >= {"v1", "v2", "delta"}
|
||||
for arm in ("v1", "v2"):
|
||||
assert isinstance(payload[arm], dict)
|
||||
metrics = cast(dict[str, object], payload[arm])
|
||||
assert set(metrics) >= {
|
||||
"p50_latency_ms",
|
||||
"p95_latency_ms",
|
||||
"success_at_k",
|
||||
"external_tool_calls_per_question",
|
||||
"avg_returned_chars",
|
||||
"duplicate_url_rate",
|
||||
"fetch_success_rate",
|
||||
"provider_fallback_count",
|
||||
}
|
||||
assert isinstance(payload["delta"], dict)
|
||||
delta = cast(dict[str, object], payload["delta"])
|
||||
assert set(delta) >= {
|
||||
"external_tool_calls_per_question",
|
||||
"avg_returned_chars",
|
||||
"success_at_k",
|
||||
}
|
||||
assert isinstance(payload.get("cases"), list)
|
||||
assert payload["cases"]
|
||||
for row in cast(list[dict[str, object]], payload["cases"]):
|
||||
assert set(row) >= {"id", "query", "kind"}
|
||||
v1 = cast(dict[str, object], payload["v1"])
|
||||
v2 = cast(dict[str, object], payload["v2"])
|
||||
assert _numeric_metric(v2, "external_tool_calls_per_question") < _numeric_metric(
|
||||
v1, "external_tool_calls_per_question"
|
||||
)
|
||||
assert _numeric_metric(v2, "avg_returned_chars") < _numeric_metric(v1, "avg_returned_chars")
|
||||
|
||||
|
||||
def test_search_benchmark_smoke_json_uses_real_synthetic_output():
|
||||
result = runner.invoke(app, ["search", "benchmark", "--profile", "smoke", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["profile"] == "smoke"
|
||||
assert payload["measurement_kind"] == "synthetic_smoke"
|
||||
assert payload["live"] is False
|
||||
_assert_benchmark_metrics(payload)
|
||||
assert payload["query_count"] == len(payload["cases"])
|
||||
|
||||
|
||||
def test_search_benchmark_passes_options_to_helper(monkeypatch):
|
||||
seen: list[tuple[str, int, int, bool]] = []
|
||||
|
||||
def fake_run_search_benchmark(
|
||||
profile: str,
|
||||
fetch_top_k: int,
|
||||
max_results: int,
|
||||
live: bool = False,
|
||||
) -> dict[str, object]:
|
||||
seen.append((profile, fetch_top_k, max_results, live))
|
||||
return _benchmark_payload()
|
||||
|
||||
monkeypatch.setattr(search_cmd, "_run_search_benchmark", fake_run_search_benchmark)
|
||||
|
||||
result = runner.invoke(app, ["search", "benchmark", "--profile", "smoke", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["profile"] == "smoke"
|
||||
assert payload["measurement_kind"] == "synthetic_smoke"
|
||||
assert payload["live"] is False
|
||||
_assert_benchmark_metrics(payload)
|
||||
assert payload["delta"]["external_tool_calls_per_question"] == -1
|
||||
assert payload["delta"]["avg_returned_chars"] == -200
|
||||
assert seen == [("smoke", 3, DEFAULT_SEARCH_MAX_RESULTS, False)]
|
||||
|
||||
|
||||
def test_search_benchmark_live_json_rejects_without_env(monkeypatch):
|
||||
monkeypatch.delenv("OPENSQUILLA_LIVE_SEARCH", raising=False)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["search", "benchmark", "--profile", "smoke", "--live", "--json"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is False
|
||||
assert payload["error_kind"] == "invalid_request"
|
||||
assert "live benchmark" in payload["error"]
|
||||
|
||||
|
||||
def test_search_benchmark_live_json_rejects_even_with_env(monkeypatch):
|
||||
monkeypatch.setenv("OPENSQUILLA_LIVE_SEARCH", "1")
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["search", "benchmark", "--profile", "smoke", "--live", "--json"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is False
|
||||
assert payload["error_kind"] == "invalid_request"
|
||||
assert "live gate test suite" in payload["error"]
|
||||
assert "measurement_kind" not in payload
|
||||
assert "v1" not in payload
|
||||
assert "v2" not in payload
|
||||
|
||||
|
||||
def test_search_benchmark_rejects_zero_max_results_json():
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["search", "benchmark", "--profile", "smoke", "--max-results", "0", "--json"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is False
|
||||
assert payload["error_kind"] == "invalid_request"
|
||||
assert "max_results" in payload["error"]
|
||||
|
||||
|
||||
def test_search_benchmark_rejects_negative_fetch_top_k_json():
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["search", "benchmark", "--profile", "smoke", "--fetch-top-k", "-1", "--json"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is False
|
||||
assert payload["error_kind"] == "invalid_request"
|
||||
assert "fetch_top_k" in payload["error"]
|
||||
|
||||
|
||||
def test_search_benchmark_rejects_unknown_profile_json():
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["search", "benchmark", "--profile", "unknown", "--json"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 2
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is False
|
||||
assert payload["error_kind"] == "invalid_request"
|
||||
@@ -0,0 +1,59 @@
|
||||
"""CLI tests for `opensquilla search`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli.main import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_search_list_shows_runtime_providers():
|
||||
result = runner.invoke(app, ["search", "list"])
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert "brave" in result.stdout
|
||||
assert "duckduckgo" in result.stdout
|
||||
|
||||
|
||||
def test_search_configure_writes_config(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"search",
|
||||
"configure",
|
||||
"brave",
|
||||
"--api-key",
|
||||
"brave-secret",
|
||||
"--max-results",
|
||||
"8",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
text = target.read_text()
|
||||
assert 'search_provider = "brave"' in text
|
||||
assert 'search_api_key = "brave-secret"' in text
|
||||
assert "brave-secret" not in result.stdout
|
||||
|
||||
|
||||
def test_search_configure_warns_when_env_key_is_not_set(tmp_path, monkeypatch):
|
||||
target = tmp_path / "c.toml"
|
||||
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
||||
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"search",
|
||||
"configure",
|
||||
"brave",
|
||||
"--api-key-env",
|
||||
"BRAVE_SEARCH_API_KEY",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert "Warning" in result.stdout
|
||||
assert "BRAVE_SEARCH_API_KEY" in result.stdout
|
||||
@@ -0,0 +1,148 @@
|
||||
"""CLI tests for `opensquilla search query` canonical web search mode."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
import opensquilla.cli.search_cmd as search_cmd
|
||||
from opensquilla.cli.main import app
|
||||
from opensquilla.search.types import SearchOptions
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_search_query_web_search_options_use_local_search(monkeypatch):
|
||||
seen_options: list[SearchOptions] = []
|
||||
|
||||
async def fake_run_canonical_web_search(
|
||||
options: SearchOptions,
|
||||
**kwargs: object,
|
||||
) -> dict[str, object]:
|
||||
seen_options.append(options)
|
||||
assert "fetcher" in kwargs
|
||||
return {
|
||||
"ok": True,
|
||||
"query": options.query,
|
||||
"mode": options.mode,
|
||||
"provider_attempts": [{"provider": "tavily", "status": "success"}],
|
||||
"diagnostics": {"returned_chars": 120},
|
||||
"results": [
|
||||
{
|
||||
"rank": 1,
|
||||
"title": "Python release",
|
||||
"domain": "python.org",
|
||||
"url": "https://www.python.org/downloads/",
|
||||
"provider": "tavily",
|
||||
"fetched": False,
|
||||
"excerpt": "Python release notes",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
search_cmd,
|
||||
"run_canonical_web_search",
|
||||
fake_run_canonical_web_search,
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"search",
|
||||
"query",
|
||||
"python release",
|
||||
"--mode",
|
||||
"auto",
|
||||
"--fetch-top-k",
|
||||
"0",
|
||||
"--max-results",
|
||||
"5",
|
||||
"--json",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["query"] == "python release"
|
||||
assert payload["mode"] == "auto"
|
||||
assert payload["provider_attempts"] == [{"provider": "tavily", "status": "success"}]
|
||||
assert payload["diagnostics"] == {"returned_chars": 120}
|
||||
assert payload["results"][0]["title"] == "Python release"
|
||||
assert seen_options == [
|
||||
SearchOptions(query="python release", mode="auto", fetch_top_k=0, max_results=5)
|
||||
]
|
||||
|
||||
|
||||
def test_search_query_web_search_text_output_renders_results(monkeypatch):
|
||||
async def fake_run_canonical_web_search(
|
||||
options: SearchOptions,
|
||||
**kwargs: object,
|
||||
) -> dict[str, object]:
|
||||
assert "fetcher" in kwargs
|
||||
return {
|
||||
"ok": True,
|
||||
"query": options.query,
|
||||
"mode": options.mode,
|
||||
"provider_attempts": [],
|
||||
"diagnostics": {},
|
||||
"results": [
|
||||
{
|
||||
"rank": 1,
|
||||
"title": "Python release notes",
|
||||
"domain": "python.org",
|
||||
"url": "https://www.python.org/downloads/",
|
||||
"provider": "tavily",
|
||||
"fetched": True,
|
||||
"excerpt": "Python 3 release details.",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
search_cmd,
|
||||
"run_canonical_web_search",
|
||||
fake_run_canonical_web_search,
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["search", "query", "python release", "--fetch-top-k", "0"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert "Python release notes" in result.stdout
|
||||
assert "python.org" in result.stdout
|
||||
assert "https://www.python.org/downloads/" in result.stdout
|
||||
|
||||
|
||||
def test_search_query_web_search_json_failure_exits_nonzero(monkeypatch):
|
||||
async def fake_run_canonical_web_search(
|
||||
options: SearchOptions,
|
||||
**kwargs: object,
|
||||
) -> dict[str, object]:
|
||||
assert "fetcher" in kwargs
|
||||
return {
|
||||
"ok": False,
|
||||
"query": options.query,
|
||||
"mode": options.mode,
|
||||
"provider_attempts": [{"provider": "tavily", "status": "error"}],
|
||||
"diagnostics": {},
|
||||
"results": [],
|
||||
"error": {"message": "network down"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
search_cmd,
|
||||
"run_canonical_web_search",
|
||||
fake_run_canonical_web_search,
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["search", "query", "python release", "--mode", "auto", "--json"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert json.loads(result.stdout)["error"]["message"] == "network down"
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Tests for _model_alias and PromptState.label in session_state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from opensquilla.cli.repl.session_state import PromptState, _model_alias
|
||||
|
||||
|
||||
def test_alias_none_returns_ellipsis() -> None:
|
||||
assert _model_alias(None) == "…"
|
||||
|
||||
|
||||
def test_alias_empty_string_returns_ellipsis() -> None:
|
||||
assert _model_alias("") == "…"
|
||||
|
||||
|
||||
def test_alias_with_slash_returns_last_segment() -> None:
|
||||
# "deepseek-v4-flash-20260423" is 26 chars — under 28, no ellipsis
|
||||
result = _model_alias("deepseek/deepseek-v4-flash-20260423")
|
||||
assert result == "deepseek-v4-flash-20260423"
|
||||
assert len(result) == 26
|
||||
|
||||
|
||||
def test_alias_long_segment_is_middle_ellipsized() -> None:
|
||||
# Build a 40-char tail segment
|
||||
tail = "a" * 20 + "b" * 20 # 40 chars — over 28
|
||||
full = f"provider/{tail}"
|
||||
result = _model_alias(full)
|
||||
assert result == tail[:12] + "…" + tail[-12:]
|
||||
assert len(result) == 25 # 12 + 1 + 12
|
||||
|
||||
|
||||
def test_alias_exactly_28_chars_no_ellipsis() -> None:
|
||||
seg = "x" * 28
|
||||
result = _model_alias(f"provider/{seg}")
|
||||
assert result == seg
|
||||
assert "…" not in result
|
||||
|
||||
|
||||
def test_alias_29_chars_is_ellipsized() -> None:
|
||||
seg = "x" * 29
|
||||
result = _model_alias(f"provider/{seg}")
|
||||
assert "…" in result
|
||||
assert len(result) == 25 # 12 + 1 + 12
|
||||
|
||||
|
||||
def test_alias_no_slash_returns_whole_string() -> None:
|
||||
assert _model_alias("gpt-5.1") == "gpt-5.1"
|
||||
|
||||
|
||||
def test_alias_no_slash_long_string_is_ellipsized() -> None:
|
||||
# 40-char string with no slash — treated as the whole segment
|
||||
seg = "z" * 40
|
||||
result = _model_alias(seg)
|
||||
assert result == seg[:12] + "…" + seg[-12:]
|
||||
|
||||
|
||||
def test_prompt_state_label_uses_alias() -> None:
|
||||
state = PromptState(model="openai/gpt-5.1", elevated=None)
|
||||
assert state.label == "[gpt-5.1 normal] you ▸ "
|
||||
|
||||
|
||||
def test_prompt_state_label_none_model() -> None:
|
||||
state = PromptState(model=None, elevated=None)
|
||||
assert state.label == "[… normal] you ▸ "
|
||||
|
||||
|
||||
def test_prompt_state_label_elevated_mode() -> None:
|
||||
state = PromptState(model="anthropic/claude-opus-4", elevated="bypass")
|
||||
assert state.label == "[claude-opus-4 bypass] you ▸ "
|
||||
@@ -0,0 +1,215 @@
|
||||
"""CLI smoke tests for `opensquilla skills meta runs ...`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli.main import app as cli_app
|
||||
from opensquilla.persistence.meta_run_writer import open_meta_run_writer
|
||||
from opensquilla.persistence.migrator import apply_pending
|
||||
from opensquilla.skills.meta.types import MetaPlan, MetaResult, MetaStep
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).resolve().parents[1].parent / "migrations"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner() -> CliRunner:
|
||||
return CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seeded_db(tmp_path: Path, monkeypatch):
|
||||
db = str(tmp_path / "test.db")
|
||||
apply_pending(db, MIGRATIONS_DIR)
|
||||
w = open_meta_run_writer(db)
|
||||
|
||||
plan_a = MetaPlan(
|
||||
name="alpha-skill", triggers=("t",), priority=10,
|
||||
steps=(MetaStep(id="s1", skill="x", kind="agent"),),
|
||||
)
|
||||
plan_b = MetaPlan(
|
||||
name="beta-skill", triggers=("t",), priority=10,
|
||||
steps=(MetaStep(id="s1", skill="y", kind="agent"),),
|
||||
)
|
||||
rid_ok = w.begin_run_sync(
|
||||
meta_skill_name="alpha-skill", meta_plan=plan_a,
|
||||
triggered_by="soft_meta_invoke", inputs={"user_message": "hi"},
|
||||
session_key="sess-1", turn_id="turn-1",
|
||||
)
|
||||
w.begin_step_sync(
|
||||
run_id=rid_ok, step=plan_a.steps[0], effective_skill="x",
|
||||
rendered_inputs={"a": 1},
|
||||
)
|
||||
w.finish_step_sync(
|
||||
run_id=rid_ok, step_id="s1", status="ok", output_text="alpha-out",
|
||||
)
|
||||
w.finish_run_sync(
|
||||
run_id=rid_ok, status="ok",
|
||||
result=MetaResult(ok=True, final_text="alpha-out"),
|
||||
)
|
||||
|
||||
rid_fail = w.begin_run_sync(
|
||||
meta_skill_name="beta-skill", meta_plan=plan_b,
|
||||
triggered_by="hard_takeover", inputs={},
|
||||
session_key=None, turn_id=None,
|
||||
)
|
||||
w.finish_run_sync(
|
||||
run_id=rid_fail, status="failed",
|
||||
result=MetaResult(ok=False, error="boom", failed_step_id="s1"),
|
||||
)
|
||||
w.close()
|
||||
|
||||
monkeypatch.setenv("OPENSQUILLA_META_RUNS_DB", db)
|
||||
return {"db": db, "rid_ok": rid_ok, "rid_fail": rid_fail}
|
||||
|
||||
|
||||
def test_runs_list(runner: CliRunner, seeded_db) -> None:
|
||||
result = runner.invoke(cli_app, ["skills", "meta", "runs", "list", "--json"])
|
||||
assert result.exit_code == 0, result.output
|
||||
data = json.loads(result.output)
|
||||
assert len(data) == 2
|
||||
|
||||
|
||||
def test_runs_list_filter_status(runner: CliRunner, seeded_db) -> None:
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["skills", "meta", "runs", "list", "--status", "failed", "--json"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert len(data) == 1
|
||||
assert data[0]["status"] == "failed"
|
||||
|
||||
|
||||
def test_runs_show(runner: CliRunner, seeded_db) -> None:
|
||||
result = runner.invoke(
|
||||
cli_app, ["skills", "meta", "runs", "show", seeded_db["rid_ok"], "--json"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert data["meta_skill_name"] == "alpha-skill"
|
||||
assert data["status"] == "ok"
|
||||
assert data["summary"]["step_count"] == 1
|
||||
assert data["summary"]["usage"]["available"] is False
|
||||
|
||||
|
||||
def test_runs_steps(runner: CliRunner, seeded_db) -> None:
|
||||
result = runner.invoke(
|
||||
cli_app, ["skills", "meta", "runs", "steps", seeded_db["rid_ok"], "--json"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert len(data) == 1
|
||||
assert data[0]["step_id"] == "s1"
|
||||
assert data[0]["status"] == "ok"
|
||||
|
||||
|
||||
def test_runs_failures(runner: CliRunner, seeded_db) -> None:
|
||||
result = runner.invoke(cli_app, ["skills", "meta", "runs", "failures", "--json"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert len(data) == 1
|
||||
assert data[0]["status"] == "failed"
|
||||
|
||||
|
||||
def test_runs_replay_dry_run(runner: CliRunner, seeded_db) -> None:
|
||||
"""W8: --dry-run prints DAG in the spec'd format."""
|
||||
result = runner.invoke(
|
||||
cli_app,
|
||||
["skills", "meta", "runs", "replay", seeded_db["rid_ok"], "--dry-run", "--json"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert data["meta_skill_name"] == "alpha-skill"
|
||||
assert data["plan_source"] == "historical_snapshot"
|
||||
assert len(data["steps"]) == 1
|
||||
|
||||
|
||||
def test_runs_draft_json(runner: CliRunner, seeded_db) -> None:
|
||||
result = runner.invoke(
|
||||
cli_app, ["skills", "meta", "runs", "draft", seeded_db["rid_ok"], "--json"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
data = json.loads(result.output)
|
||||
assert data["source_run"]["run_id"] == seeded_db["rid_ok"]
|
||||
assert data["name"] == "alpha-skill-draft"
|
||||
assert data["composition"]["steps"][0]["id"] == "s1"
|
||||
assert data["trigger_candidates"]
|
||||
|
||||
|
||||
def test_runs_show_bad_id(runner: CliRunner, seeded_db) -> None:
|
||||
result = runner.invoke(cli_app, ["skills", "meta", "runs", "show", "BOGUS", "--json"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
def test_runs_list_empty(runner: CliRunner, tmp_path: Path, monkeypatch) -> None:
|
||||
db = str(tmp_path / "empty.db")
|
||||
apply_pending(db, MIGRATIONS_DIR)
|
||||
monkeypatch.setenv("OPENSQUILLA_META_RUNS_DB", db)
|
||||
result = runner.invoke(cli_app, ["skills", "meta", "runs", "list", "--json"])
|
||||
assert result.exit_code == 0
|
||||
assert json.loads(result.output) == []
|
||||
|
||||
|
||||
def test_runs_list_missing_db_prints_friendly_notice(
|
||||
runner: CliRunner, tmp_path: Path, monkeypatch,
|
||||
) -> None:
|
||||
"""Before first gateway boot there is no sessions.db: the CLI must not
|
||||
traceback on a missing state dir and must not create a stray DB file."""
|
||||
db = tmp_path / "state" / "sessions.db" # parent dir intentionally missing
|
||||
monkeypatch.setenv("OPENSQUILLA_META_RUNS_DB", str(db))
|
||||
result = runner.invoke(cli_app, ["skills", "meta", "runs", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "no meta-run history yet" in result.output
|
||||
assert not db.exists()
|
||||
assert not db.parent.exists()
|
||||
|
||||
|
||||
def test_runs_list_missing_db_json_emits_empty_list(
|
||||
runner: CliRunner, tmp_path: Path, monkeypatch,
|
||||
) -> None:
|
||||
db = tmp_path / "state" / "sessions.db"
|
||||
monkeypatch.setenv("OPENSQUILLA_META_RUNS_DB", str(db))
|
||||
result = runner.invoke(cli_app, ["skills", "meta", "runs", "list", "--json"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert json.loads(result.output) == []
|
||||
assert not db.exists()
|
||||
|
||||
|
||||
def test_runs_failures_missing_db_json_emits_empty_list(
|
||||
runner: CliRunner, tmp_path: Path, monkeypatch,
|
||||
) -> None:
|
||||
db = tmp_path / "state" / "sessions.db"
|
||||
monkeypatch.setenv("OPENSQUILLA_META_RUNS_DB", str(db))
|
||||
result = runner.invoke(cli_app, ["skills", "meta", "runs", "failures", "--json"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert json.loads(result.output) == []
|
||||
assert not db.exists()
|
||||
|
||||
|
||||
def test_runs_cost_missing_db_json_emits_empty_summary_shape(
|
||||
runner: CliRunner, tmp_path: Path, monkeypatch,
|
||||
) -> None:
|
||||
db = tmp_path / "state" / "sessions.db"
|
||||
monkeypatch.setenv("OPENSQUILLA_META_RUNS_DB", str(db))
|
||||
result = runner.invoke(cli_app, ["skills", "meta", "runs", "cost", "--json"])
|
||||
assert result.exit_code == 0, result.output
|
||||
data = json.loads(result.output)
|
||||
assert data["aggregate"]["run_count"] == 0
|
||||
assert data["runs"] == []
|
||||
assert not db.exists()
|
||||
|
||||
|
||||
def test_runs_show_missing_db_exits_not_found_without_creating_db(
|
||||
runner: CliRunner, tmp_path: Path, monkeypatch,
|
||||
) -> None:
|
||||
db = tmp_path / "state" / "sessions.db"
|
||||
monkeypatch.setenv("OPENSQUILLA_META_RUNS_DB", str(db))
|
||||
result = runner.invoke(cli_app, ["skills", "meta", "runs", "show", "SOMEID"])
|
||||
assert result.exit_code == 2
|
||||
assert not db.exists()
|
||||
assert not db.parent.exists()
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from io import BytesIO, TextIOWrapper
|
||||
|
||||
import typer
|
||||
|
||||
from opensquilla.cli.stdio import configure_stdio_for_unicode
|
||||
|
||||
|
||||
def test_configure_stdio_for_unicode_allows_typer_echo_on_gbk_stream(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
raw = BytesIO()
|
||||
stream = TextIOWrapper(raw, encoding="cp936", errors="strict")
|
||||
monkeypatch.setattr(sys, "stdout", stream)
|
||||
|
||||
configure_stdio_for_unicode()
|
||||
typer.echo("hello 🦐")
|
||||
stream.flush()
|
||||
|
||||
assert raw.getvalue().decode("utf-8").strip() == "hello 🦐"
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Tests for the /meta gateway slash command (list + run)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from opensquilla.cli.repl.session_state import ChatSessionState
|
||||
from opensquilla.cli.repl.stream import TurnResult
|
||||
|
||||
|
||||
class _FakeGatewayClient:
|
||||
"""Records generic RPC ``call`` invocations and returns canned payloads."""
|
||||
|
||||
def __init__(self, responses: dict[str, Any] | None = None) -> None:
|
||||
self.calls: list[tuple[str, dict[str, Any] | None]] = []
|
||||
self.responses = responses or {}
|
||||
|
||||
async def call(self, method: str, params: dict | None = None) -> Any:
|
||||
self.calls.append((method, params))
|
||||
return self.responses.get(method, {})
|
||||
|
||||
|
||||
def _make_console() -> tuple[Console, io.StringIO]:
|
||||
buffer = io.StringIO()
|
||||
console = Console(file=buffer, force_terminal=False, width=100, highlight=False, no_color=True)
|
||||
return console, buffer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_meta_no_arg_lists_skills(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from opensquilla.cli.repl import slash_adapter
|
||||
from opensquilla.cli.repl.slash_adapter import (
|
||||
GatewaySlashContext,
|
||||
handle_gateway_slash_command,
|
||||
)
|
||||
|
||||
console, buffer = _make_console()
|
||||
monkeypatch.setattr(slash_adapter, "console", console)
|
||||
|
||||
client = _FakeGatewayClient(
|
||||
responses={
|
||||
"meta.list": {
|
||||
"skills": [
|
||||
{"name": "meta-tiny", "description": "A tiny meta-skill."},
|
||||
{"name": "meta-plan", "description": "Plan things."},
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
state = ChatSessionState(session_key="agent:main:test", model="local/test")
|
||||
|
||||
handled = await handle_gateway_slash_command(
|
||||
"/meta",
|
||||
GatewaySlashContext(state=state, client=client, elevated_state={"mode": None}),
|
||||
)
|
||||
|
||||
assert handled is True
|
||||
assert client.calls == [("meta.list", {})]
|
||||
rendered = buffer.getvalue()
|
||||
assert "meta-tiny" in rendered
|
||||
assert "meta-plan" in rendered
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_meta_no_arg_disabled_prints_notice(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from opensquilla.cli.repl import slash_adapter
|
||||
from opensquilla.cli.repl.slash_adapter import (
|
||||
GatewaySlashContext,
|
||||
handle_gateway_slash_command,
|
||||
)
|
||||
|
||||
console, buffer = _make_console()
|
||||
monkeypatch.setattr(slash_adapter, "console", console)
|
||||
|
||||
client = _FakeGatewayClient(responses={"meta.list": {"disabled": True}})
|
||||
state = ChatSessionState(session_key="agent:main:test", model="local/test")
|
||||
|
||||
handled = await handle_gateway_slash_command(
|
||||
"/meta",
|
||||
GatewaySlashContext(state=state, client=client, elevated_state={"mode": None}),
|
||||
)
|
||||
|
||||
assert handled is True
|
||||
assert client.calls == [("meta.list", {})]
|
||||
assert "disabled" in buffer.getvalue().lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_meta_run_ok_triggers_turn(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from opensquilla.cli.repl import slash_adapter
|
||||
from opensquilla.cli.repl.slash_adapter import (
|
||||
GatewaySlashContext,
|
||||
handle_gateway_slash_command,
|
||||
)
|
||||
|
||||
console, _buffer = _make_console()
|
||||
monkeypatch.setattr(slash_adapter, "console", console)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_stream_response_gateway(
|
||||
gateway_client: object,
|
||||
session_key: str,
|
||||
message: str,
|
||||
elevated_state: dict[str, str | None],
|
||||
attachments: list[dict[str, Any]] | None = None,
|
||||
*,
|
||||
tui_output: object | None = None,
|
||||
) -> TurnResult:
|
||||
captured.update(
|
||||
{
|
||||
"client": gateway_client,
|
||||
"session_key": session_key,
|
||||
"message": message,
|
||||
"elevated_state": elevated_state,
|
||||
}
|
||||
)
|
||||
return TurnResult(text="launched")
|
||||
|
||||
monkeypatch.setattr(slash_adapter, "stream_response_gateway", fake_stream_response_gateway)
|
||||
|
||||
client = _FakeGatewayClient(responses={"meta.run": {"ok": True}})
|
||||
state = ChatSessionState(session_key="agent:main:test", model="local/test")
|
||||
|
||||
handled = await handle_gateway_slash_command(
|
||||
"/meta meta-tiny",
|
||||
GatewaySlashContext(state=state, client=client, elevated_state={"mode": "on"}),
|
||||
)
|
||||
|
||||
assert handled is True
|
||||
assert client.calls == [
|
||||
("meta.run", {"name": "meta-tiny", "sessionKey": "agent:main:test"}),
|
||||
]
|
||||
# A turn was triggered through the same path /image uses.
|
||||
assert captured["session_key"] == "agent:main:test"
|
||||
assert captured["message"] == "/meta meta-tiny"
|
||||
assert captured["elevated_state"] == {"mode": "on"}
|
||||
assert state.transcript.to_markdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_meta_run_not_ok_prints_error_and_skips_turn(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from opensquilla.cli.repl import slash_adapter
|
||||
from opensquilla.cli.repl.slash_adapter import (
|
||||
GatewaySlashContext,
|
||||
handle_gateway_slash_command,
|
||||
)
|
||||
|
||||
console, buffer = _make_console()
|
||||
monkeypatch.setattr(slash_adapter, "console", console)
|
||||
|
||||
stream_calls: list[str] = []
|
||||
|
||||
async def fake_stream_response_gateway(*args: Any, **kwargs: Any) -> TurnResult:
|
||||
stream_calls.append("called")
|
||||
return TurnResult(text="should-not-happen")
|
||||
|
||||
monkeypatch.setattr(slash_adapter, "stream_response_gateway", fake_stream_response_gateway)
|
||||
|
||||
client = _FakeGatewayClient(
|
||||
responses={"meta.run": {"ok": False, "error": "unknown meta-skill: bad"}}
|
||||
)
|
||||
state = ChatSessionState(session_key="agent:main:test", model="local/test")
|
||||
|
||||
handled = await handle_gateway_slash_command(
|
||||
"/meta bad",
|
||||
GatewaySlashContext(state=state, client=client, elevated_state={"mode": None}),
|
||||
)
|
||||
|
||||
assert handled is True
|
||||
assert client.calls == [
|
||||
("meta.run", {"name": "bad", "sessionKey": "agent:main:test"}),
|
||||
]
|
||||
# No turn fired.
|
||||
assert stream_calls == []
|
||||
assert "unknown meta-skill: bad" in buffer.getvalue()
|
||||
@@ -0,0 +1,210 @@
|
||||
"""CLI surface for `opensquilla uninstall` — flags, guards, confirmation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from opensquilla.cli import codetask_cmd
|
||||
from opensquilla.cli.main import app
|
||||
from opensquilla.uninstall import actions as actions_module
|
||||
from opensquilla.uninstall import inventory as inventory_module
|
||||
from opensquilla.uninstall.actions import ActionResult, ExecutionResult
|
||||
from opensquilla.uninstall.inventory import DataBucket, Inventory
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _fake_inventory(home: Path) -> Inventory:
|
||||
state = home / "state"
|
||||
state.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.toml").write_text("x")
|
||||
return Inventory(
|
||||
method="pip",
|
||||
home=home,
|
||||
state_root=state,
|
||||
config_path=None,
|
||||
entrypoints=[],
|
||||
program_paths=[],
|
||||
package_uninstall=["pip", "uninstall", "-y", "opensquilla"],
|
||||
buckets=[
|
||||
DataBucket("config.toml", home / "config.toml", "config", "config"),
|
||||
DataBucket("state directory", state, "user-data", "state"),
|
||||
],
|
||||
services=[],
|
||||
receipt=None,
|
||||
notes=[],
|
||||
home_recognized=True,
|
||||
)
|
||||
|
||||
|
||||
def _patch_discover(monkeypatch, home: Path) -> None:
|
||||
monkeypatch.setattr(inventory_module, "discover", lambda: _fake_inventory(home))
|
||||
|
||||
|
||||
def test_dry_run_json_emits_plan_and_does_nothing(monkeypatch, tmp_path: Path) -> None:
|
||||
_patch_discover(monkeypatch, tmp_path / "home")
|
||||
|
||||
def _boom(*_a, **_k):
|
||||
raise AssertionError("execute must not run during --dry-run")
|
||||
|
||||
monkeypatch.setattr(actions_module, "execute", _boom)
|
||||
|
||||
result = runner.invoke(app, ["uninstall", "--dry-run", "--json"])
|
||||
assert result.exit_code == 0, result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["dry_run"] is True
|
||||
assert payload["plan"]["method"] == "pip"
|
||||
|
||||
|
||||
def test_dry_run_json_subprocess_stdout_stays_machine_readable(tmp_path: Path) -> None:
|
||||
"""Import-time env loading must not pollute stdout before JSON output."""
|
||||
home = tmp_path / "home"
|
||||
env_home = home / ".opensquilla"
|
||||
env_home.mkdir(parents=True)
|
||||
(env_home / ".env").write_text("OPENSQUILLA_ENV_JSON_TEST=1\n", encoding="utf-8")
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"HOME": str(home),
|
||||
"USERPROFILE": str(home),
|
||||
"HTTP_PROXY": "http://127.0.0.1:9",
|
||||
}
|
||||
)
|
||||
env.pop("OPENSQUILLA_TRUST_ENV", None)
|
||||
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-m", "opensquilla.cli.main", "uninstall", "--dry-run", "--json"],
|
||||
cwd=Path(__file__).resolve().parents[2],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr + completed.stdout
|
||||
assert completed.stdout.lstrip().startswith("{")
|
||||
payload = json.loads(completed.stdout)
|
||||
assert payload["dry_run"] is True
|
||||
|
||||
|
||||
def test_non_interactive_without_yes_refuses(monkeypatch, tmp_path: Path) -> None:
|
||||
_patch_discover(monkeypatch, tmp_path / "home")
|
||||
|
||||
def _boom(*_a, **_k):
|
||||
raise AssertionError("execute must not run without confirmation")
|
||||
|
||||
monkeypatch.setattr(actions_module, "execute", _boom)
|
||||
|
||||
result = runner.invoke(app, ["uninstall", "--json"])
|
||||
assert result.exit_code == 2, result.stdout
|
||||
assert "CONFIRMATION_REQUIRED" in (result.stdout + (result.stderr or ""))
|
||||
|
||||
|
||||
def test_yes_json_executes(monkeypatch, tmp_path: Path) -> None:
|
||||
_patch_discover(monkeypatch, tmp_path / "home")
|
||||
captured = {}
|
||||
|
||||
def _fake_execute(plan, inventory, **_kwargs):
|
||||
captured["ran"] = True
|
||||
return ExecutionResult(
|
||||
results=[ActionResult("run-package-uninstall", "ok", ok=True)], ok=True
|
||||
)
|
||||
|
||||
monkeypatch.setattr(actions_module, "execute", _fake_execute)
|
||||
|
||||
result = runner.invoke(app, ["uninstall", "--yes", "--json"])
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert captured.get("ran") is True
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["ok"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("purge_flag", ["--purge-state", "--purge-config", "--purge-all"])
|
||||
def test_desktop_profile_routes_data_deletion_to_complete_desktop_cleanup(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
purge_flag: str,
|
||||
) -> None:
|
||||
monkeypatch.setenv("OPENSQUILLA_PROFILE_KIND", "desktop-primary")
|
||||
|
||||
def _boom(*_args, **_kwargs):
|
||||
raise AssertionError("generic Desktop purge must not inspect or delete partial data")
|
||||
|
||||
monkeypatch.setattr(inventory_module, "discover", _boom)
|
||||
monkeypatch.setattr(actions_module, "execute", _boom)
|
||||
|
||||
result = runner.invoke(app, ["uninstall", purge_flag, "--yes", "--json"])
|
||||
|
||||
assert result.exit_code == 2, result.stdout
|
||||
assert "DESKTOP_CLEANUP_REQUIRED" in (result.stdout + (result.stderr or ""))
|
||||
|
||||
|
||||
def test_purge_all_requires_confirmation_phrase(monkeypatch, tmp_path: Path) -> None:
|
||||
_patch_discover(monkeypatch, tmp_path / "home")
|
||||
monkeypatch.setattr(codetask_cmd, "_stdin_is_tty", lambda: True) # allow interactive path
|
||||
|
||||
def _boom(*_a, **_k):
|
||||
raise AssertionError("execute must not run on a mismatched phrase")
|
||||
|
||||
monkeypatch.setattr(actions_module, "execute", _boom)
|
||||
|
||||
result = runner.invoke(app, ["uninstall", "--purge-all"], input="nope\n")
|
||||
assert result.exit_code == 2, result.stdout
|
||||
assert "requires confirmation" in (result.stdout + (result.stderr or ""))
|
||||
|
||||
|
||||
def test_yes_purge_all_without_phrase_is_refused(monkeypatch, tmp_path: Path) -> None:
|
||||
"""`--yes --purge-all` must NOT wipe without the explicit second-factor phrase."""
|
||||
_patch_discover(monkeypatch, tmp_path / "home")
|
||||
|
||||
def _boom(*_a, **_k):
|
||||
raise AssertionError("execute must not run without the purge-all phrase")
|
||||
|
||||
monkeypatch.setattr(actions_module, "execute", _boom)
|
||||
|
||||
result = runner.invoke(app, ["uninstall", "--yes", "--purge-all", "--json"])
|
||||
assert result.exit_code == 2, result.stdout
|
||||
assert "CONFIRMATION_REQUIRED" in (result.stdout + (result.stderr or ""))
|
||||
|
||||
|
||||
def test_yes_purge_all_with_phrase_executes(monkeypatch, tmp_path: Path) -> None:
|
||||
_patch_discover(monkeypatch, tmp_path / "home")
|
||||
captured = {}
|
||||
|
||||
def _fake_execute(plan, inventory, **_kwargs):
|
||||
captured["ran"] = True
|
||||
return ExecutionResult(results=[], ok=True)
|
||||
|
||||
monkeypatch.setattr(actions_module, "execute", _fake_execute)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["uninstall", "--yes", "--purge-all", "--json", "--confirm-purge-all", "delete everything"],
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert captured.get("ran") is True
|
||||
|
||||
|
||||
def test_purge_all_proceeds_on_correct_phrase(monkeypatch, tmp_path: Path) -> None:
|
||||
_patch_discover(monkeypatch, tmp_path / "home")
|
||||
monkeypatch.setattr(codetask_cmd, "_stdin_is_tty", lambda: True)
|
||||
captured = {}
|
||||
|
||||
def _fake_execute(plan, inventory, **_kwargs):
|
||||
captured["ran"] = True
|
||||
return ExecutionResult(results=[], ok=True)
|
||||
|
||||
monkeypatch.setattr(actions_module, "execute", _fake_execute)
|
||||
|
||||
result = runner.invoke(app, ["uninstall", "--purge-all"], input="delete everything\n")
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert captured.get("ran") is True
|
||||
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from opensquilla.cli.url_utils import normalize_gateway_url
|
||||
|
||||
|
||||
def test_normalize_gateway_url_preserves_query_and_fragment() -> None:
|
||||
assert (
|
||||
normalize_gateway_url("https://gateway.example.com/ws?token=abc#trace")
|
||||
== "wss://gateway.example.com/ws?token=abc#trace"
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_gateway_url_adds_ws_path_without_dropping_query() -> None:
|
||||
assert normalize_gateway_url("gateway.example.com?token=abc") == "ws://gateway.example.com/ws?token=abc"
|
||||
Reference in New Issue
Block a user