2616 lines
93 KiB
Python
2616 lines
93 KiB
Python
"""Tests for non-interactive onboarding flow halves."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import types
|
|
from io import StringIO
|
|
|
|
from rich.console import Console
|
|
|
|
|
|
def test_wait_for_setup_start_flushes_visible_prompt_before_accepting_enter(monkeypatch):
|
|
from opensquilla.onboarding import flow
|
|
|
|
events: list[str] = []
|
|
|
|
class _Console:
|
|
class _File:
|
|
def flush(self):
|
|
events.append("flush")
|
|
|
|
file = _File()
|
|
|
|
def print(self, message: str):
|
|
assert "Press Enter to start setup" in message
|
|
events.append("print")
|
|
|
|
monkeypatch.setattr(flow, "console", _Console())
|
|
monkeypatch.setattr(flow, "_flush_stdin_typeahead", lambda: events.append("clear"))
|
|
monkeypatch.setattr("builtins.input", lambda: events.append("input"))
|
|
|
|
flow._wait_for_setup_start()
|
|
|
|
assert events == ["print", "flush", "clear", "input"]
|
|
|
|
|
|
def test_flush_stdin_typeahead_uses_msvcrt_on_windows(monkeypatch):
|
|
from opensquilla.onboarding import flow
|
|
|
|
drained: list[str] = []
|
|
fake_msvcrt = types.SimpleNamespace(
|
|
kbhit=lambda: len(drained) < 2,
|
|
getwch=lambda: drained.append("key"),
|
|
)
|
|
|
|
monkeypatch.setattr(flow.os, "name", "nt")
|
|
monkeypatch.setitem(__import__("sys").modules, "msvcrt", fake_msvcrt)
|
|
|
|
flow._flush_stdin_typeahead()
|
|
|
|
assert drained == ["key", "key"]
|
|
|
|
|
|
def test_flush_stdin_typeahead_uses_termios_on_unix_tty(monkeypatch):
|
|
from opensquilla.onboarding import flow
|
|
|
|
calls: list[object] = []
|
|
fake_stdin = types.SimpleNamespace(isatty=lambda: True)
|
|
fake_termios = types.SimpleNamespace(
|
|
TCIFLUSH=123,
|
|
tcflush=lambda stream, selector: calls.extend([stream, selector]),
|
|
)
|
|
|
|
monkeypatch.setattr(flow.os, "name", "posix")
|
|
monkeypatch.setattr(flow.sys, "stdin", fake_stdin)
|
|
monkeypatch.setitem(__import__("sys").modules, "termios", fake_termios)
|
|
|
|
flow._flush_stdin_typeahead()
|
|
|
|
assert calls == [fake_stdin, 123]
|
|
|
|
|
|
def test_interactive_provider_choice_offers_all_runtime_supported_providers():
|
|
from opensquilla.onboarding.flow import OnboardOptions, _ask_provider_choice
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
class _Question:
|
|
def ask(self) -> str:
|
|
return "openrouter (OpenRouter)"
|
|
|
|
class _Questionary:
|
|
def select(
|
|
self, _message: str, *, choices: list[str], default: str, **kwargs
|
|
) -> _Question:
|
|
captured["choices"] = choices
|
|
captured["default"] = default
|
|
captured["kwargs"] = kwargs
|
|
return _Question()
|
|
|
|
_ask_provider_choice(_Questionary(), OnboardOptions())
|
|
|
|
choices = captured["choices"]
|
|
assert choices[0] == "tokenrhythm (TokenRhythm)"
|
|
assert choices[1] == "openrouter (OpenRouter)"
|
|
assert captured["default"] == "tokenrhythm (TokenRhythm)"
|
|
offered = {choice.split(" ")[0] for choice in choices}
|
|
from tests.test_onboarding.test_provider_specs import EXPECTED_SUPPORTED
|
|
|
|
assert offered == EXPECTED_SUPPORTED
|
|
# Experimental providers are offered, but with a visible caveat.
|
|
assert any("(experimental)" in choice for choice in choices)
|
|
# ~30 entries: the select must let the operator type to filter.
|
|
kwargs = captured["kwargs"]
|
|
assert kwargs["use_search_filter"] is True
|
|
assert kwargs["use_jk_keys"] is False
|
|
|
|
|
|
def test_interactive_router_supported_provider_does_not_prompt_for_model(monkeypatch):
|
|
from opensquilla.onboarding import flow
|
|
from opensquilla.onboarding.flow import OnboardOptions, _ask_provider_fields
|
|
from opensquilla.onboarding.provider_specs import get_provider_setup_spec
|
|
|
|
# The router-supported pre-save probe is exercised in
|
|
# test_flow_provider_verify.py; here it degrades silently (offline).
|
|
monkeypatch.setattr(flow, "_run_provider_probe", lambda **_kw: None)
|
|
|
|
class _Questionary:
|
|
def text(self, message: str, **_kwargs):
|
|
if message == "Model id":
|
|
raise AssertionError("router-supported providers should not prompt for model")
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
answers = _ask_provider_fields(
|
|
_Questionary(),
|
|
get_provider_setup_spec("openrouter"),
|
|
OnboardOptions(api_key_env="OPENROUTER_API_KEY"),
|
|
)
|
|
|
|
assert answers["model"] == ""
|
|
assert answers["api_key_env"] == "OPENROUTER_API_KEY"
|
|
|
|
|
|
def test_interactive_provider_fields_default_to_pasted_api_key(monkeypatch):
|
|
from opensquilla.onboarding import flow
|
|
from opensquilla.onboarding.flow import OnboardOptions, _ask_provider_fields
|
|
from opensquilla.onboarding.provider_specs import get_provider_setup_spec
|
|
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
monkeypatch.setattr(flow, "_run_provider_probe", lambda **_kw: None)
|
|
|
|
class _Answer:
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def ask(self):
|
|
return self.value
|
|
|
|
class _Questionary:
|
|
def select(self, message: str, **kwargs):
|
|
assert message == "LLM API key source"
|
|
assert kwargs.get("choices") == [
|
|
"Paste API key now",
|
|
"Use environment variable OPENROUTER_API_KEY",
|
|
]
|
|
assert kwargs.get("default") == "Paste API key now"
|
|
return _Answer("Paste API key now")
|
|
|
|
def text(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
assert message == "API key"
|
|
return _Answer("sk-live")
|
|
|
|
def confirm(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def checkbox(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected checkbox prompt: {message}")
|
|
|
|
answers = _ask_provider_fields(
|
|
_Questionary(),
|
|
get_provider_setup_spec("openrouter"),
|
|
OnboardOptions(),
|
|
)
|
|
|
|
assert answers["model"] == ""
|
|
assert answers["api_key"] == "sk-live"
|
|
assert answers["api_key_env"] == ""
|
|
|
|
|
|
def test_interactive_provider_fields_explains_detected_env_key(monkeypatch):
|
|
from opensquilla.onboarding import flow
|
|
from opensquilla.onboarding.flow import OnboardOptions, _ask_provider_fields
|
|
from opensquilla.onboarding.provider_specs import get_provider_setup_spec
|
|
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-from-env")
|
|
monkeypatch.setattr(flow, "_run_provider_probe", lambda **_kw: None)
|
|
|
|
class _Answer:
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def ask(self):
|
|
return self.value
|
|
|
|
class _Questionary:
|
|
def select(self, message: str, **kwargs):
|
|
assert message == "LLM API key source"
|
|
assert kwargs.get("choices") == [
|
|
"Paste API key now",
|
|
"Use environment variable OPENROUTER_API_KEY (detected)",
|
|
]
|
|
assert kwargs.get("default") == (
|
|
"Use environment variable OPENROUTER_API_KEY (detected)"
|
|
)
|
|
return _Answer("Use environment variable OPENROUTER_API_KEY (detected)")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
answers = _ask_provider_fields(
|
|
_Questionary(),
|
|
get_provider_setup_spec("openrouter"),
|
|
OnboardOptions(),
|
|
)
|
|
|
|
assert answers["api_key"] == ""
|
|
assert answers["api_key_env"] == "OPENROUTER_API_KEY"
|
|
|
|
|
|
def test_interactive_provider_fields_requires_pasted_api_key(monkeypatch):
|
|
from opensquilla.onboarding import flow
|
|
from opensquilla.onboarding.flow import OnboardOptions, _ask_provider_fields
|
|
from opensquilla.onboarding.provider_specs import get_provider_setup_spec
|
|
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
monkeypatch.setattr(flow, "_run_provider_probe", lambda **_kw: None)
|
|
|
|
class _Answer:
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def ask(self):
|
|
return self.value
|
|
|
|
class _Questionary:
|
|
def select(self, message: str, **kwargs):
|
|
assert message == "LLM API key source"
|
|
assert kwargs.get("default") == "Paste API key now"
|
|
return _Answer("Paste API key now")
|
|
|
|
def password(self, message: str, **kwargs):
|
|
assert message == "API key"
|
|
validate = kwargs.get("validate")
|
|
assert validate is not None
|
|
assert validate("") is not True
|
|
assert validate("sk-live") is True
|
|
return _Answer("sk-live")
|
|
|
|
answers = _ask_provider_fields(
|
|
_Questionary(),
|
|
get_provider_setup_spec("openrouter"),
|
|
OnboardOptions(),
|
|
)
|
|
|
|
assert answers["api_key"] == "sk-live"
|
|
assert answers["api_key_env"] == ""
|
|
|
|
|
|
def test_interactive_provider_fields_rejects_terminal_paste_escape(monkeypatch):
|
|
from opensquilla.onboarding import flow
|
|
from opensquilla.onboarding.flow import OnboardOptions, _ask_provider_fields
|
|
from opensquilla.onboarding.provider_specs import get_provider_setup_spec
|
|
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
monkeypatch.setattr(flow, "_run_provider_probe", lambda **_kw: None)
|
|
|
|
class _Answer:
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def ask(self):
|
|
return self.value
|
|
|
|
class _Questionary:
|
|
def select(self, message: str, **kwargs):
|
|
assert message == "LLM API key source"
|
|
assert kwargs.get("default") == "Paste API key now"
|
|
return _Answer("Paste API key now")
|
|
|
|
def password(self, message: str, **kwargs):
|
|
assert message == "API key"
|
|
validate = kwargs.get("validate")
|
|
assert validate is not None
|
|
assert validate("[2;2~") is not True
|
|
assert validate("\x1b[200~sk-live\x1b[201~") is not True
|
|
assert validate("sk-live-with-[2;2~-literal-suffix") is True
|
|
assert validate("sk-live") is True
|
|
return _Answer("sk-live")
|
|
|
|
answers = _ask_provider_fields(
|
|
_Questionary(),
|
|
get_provider_setup_spec("openrouter"),
|
|
OnboardOptions(),
|
|
)
|
|
|
|
assert answers["api_key"] == "sk-live"
|
|
|
|
|
|
def test_interactive_onboard_prompts_router_defaults_before_persist(tmp_path, monkeypatch):
|
|
import sys
|
|
import types
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
monkeypatch.setattr(flow, "_wait_for_setup_start", lambda: calls.append("start gate"))
|
|
monkeypatch.setattr(flow, "detect_default_sources", lambda: [])
|
|
monkeypatch.setattr(flow, "_run_provider_probe", lambda **_kw: None)
|
|
|
|
calls: list[str] = []
|
|
|
|
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):
|
|
calls.append(message)
|
|
if message == "LLM provider":
|
|
return _Answer("openrouter (OpenRouter)")
|
|
if message == "LLM API key source":
|
|
assert kwargs.get("default") == "Paste API key now"
|
|
return _Answer("Use environment variable OPENROUTER_API_KEY")
|
|
if message == "Router mode":
|
|
assert kwargs.get("choices") == ["SquillaRouter", "Disabled"]
|
|
assert kwargs.get("default") == "SquillaRouter"
|
|
return _Answer("SquillaRouter")
|
|
if message == "Default text model":
|
|
assert kwargs.get("choices") == [
|
|
"Route c0",
|
|
"Route c1",
|
|
"Route c2",
|
|
"Route c3",
|
|
]
|
|
assert kwargs.get("default") == "Route c1"
|
|
return _Answer("Route c2")
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **kwargs):
|
|
calls.append(message)
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
def confirm(self, message: str, **_kwargs):
|
|
calls.append(message)
|
|
if message == "Edit router tier models now?":
|
|
return _Answer(False)
|
|
if message in {
|
|
"Configure a messaging channel now?",
|
|
"Configure web search now?",
|
|
"Enable image generation now?",
|
|
}:
|
|
return _Answer(False)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_onboard(flow.OnboardOptions())
|
|
|
|
assert calls[0] == "start gate"
|
|
assert calls[1] == "LLM provider"
|
|
assert calls.index("Router mode") < calls.index("Configure a messaging channel now?")
|
|
# Persistence is sparse (default-equal values may be omitted from the
|
|
# TOML), so pin the reloaded semantic state rather than raw file lines.
|
|
saved = flow.load_config(target)
|
|
assert saved.llm.api_key == ""
|
|
assert saved.llm.api_key_env == "OPENROUTER_API_KEY"
|
|
assert saved.squilla_router.default_tier == "c2"
|
|
assert saved.llm.model == "z-ai/glm-5.2"
|
|
|
|
|
|
def test_interactive_onboard_migration_defaults_to_all_sources_and_keeps_imported_provider(
|
|
tmp_path, monkeypatch
|
|
):
|
|
import sys
|
|
import tomllib
|
|
import types
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-imported-env")
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
monkeypatch.setattr(flow, "_wait_for_setup_start", lambda: calls.append("start gate"))
|
|
detected = [
|
|
flow.DetectedMigrationSource("openclaw", tmp_path / ".openclaw"),
|
|
flow.DetectedMigrationSource("hermes", tmp_path / ".hermes"),
|
|
]
|
|
monkeypatch.setattr(flow, "detect_default_sources", lambda: detected)
|
|
|
|
calls: list[str] = []
|
|
batches: list[tuple[tuple[str, ...], bool, bool]] = []
|
|
|
|
def fake_run_migration_batch(_detected, selected, options):
|
|
batches.append((tuple(selected), options.apply, options.migrate_secrets))
|
|
if options.apply:
|
|
target.write_text(
|
|
"\n".join(
|
|
[
|
|
"[llm]",
|
|
'provider = "openrouter"',
|
|
'model = "anthropic/claude-sonnet-4.5"',
|
|
'api_key_env = "OPENROUTER_API_KEY"',
|
|
"",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return flow.MigrationBatchResult(
|
|
selected=tuple(selected),
|
|
apply=options.apply,
|
|
reports={
|
|
name: {
|
|
"output_dir": str(tmp_path / "reports" / name),
|
|
"items": [
|
|
{
|
|
"kind": "config",
|
|
"status": "applied" if options.apply else "planned",
|
|
}
|
|
],
|
|
}
|
|
for name in selected
|
|
},
|
|
)
|
|
|
|
monkeypatch.setattr(flow, "run_migration_batch", fake_run_migration_batch)
|
|
|
|
class _Answer:
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def ask(self):
|
|
return self.value
|
|
|
|
class _Choice:
|
|
def __init__(self, title, value, checked=False, description=None):
|
|
self.title = title
|
|
self.value = value
|
|
self.checked = checked
|
|
self.description = description
|
|
|
|
class _Questionary(types.SimpleNamespace):
|
|
Choice = _Choice
|
|
|
|
def checkbox(self, message: str, choices, **kwargs):
|
|
calls.append(message)
|
|
assert message == "Select sources to import"
|
|
assert kwargs.get("instruction") == (
|
|
"Space select | Enter continue | A toggle all"
|
|
)
|
|
assert [choice.value for choice in choices] == ["openclaw", "hermes"]
|
|
assert [choice.title for choice in choices] == ["OpenClaw", "Hermes Agent"]
|
|
assert [choice.description for choice in choices] == [
|
|
str(tmp_path / ".openclaw"),
|
|
str(tmp_path / ".hermes"),
|
|
]
|
|
assert all(choice.checked for choice in choices)
|
|
return _Answer([choice.value for choice in choices])
|
|
|
|
def confirm(self, message: str, **kwargs):
|
|
calls.append(message)
|
|
if message == "Review migration options now?":
|
|
assert kwargs.get("default") is True
|
|
return _Answer(True)
|
|
if message == "Import saved API keys/tokens from detected legacy .env files?":
|
|
assert kwargs.get("default") is False
|
|
return _Answer(False)
|
|
if message == "Apply this migration now?":
|
|
assert kwargs.get("default") is True
|
|
return _Answer(True)
|
|
if message == "Use imported provider credentials?":
|
|
assert kwargs.get("default") is True
|
|
return _Answer(True)
|
|
if message == "Edit router tier models now?":
|
|
return _Answer(False)
|
|
if message in {
|
|
"Configure a messaging channel now?",
|
|
"Configure web search now?",
|
|
"Enable image generation now?",
|
|
}:
|
|
return _Answer(False)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def select(self, message: str, **_kwargs):
|
|
calls.append(message)
|
|
if message == "Router mode":
|
|
return _Answer("SquillaRouter")
|
|
if message == "Default text model":
|
|
return _Answer(_kwargs.get("default"))
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **_kwargs):
|
|
calls.append(message)
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
calls.append(message)
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_onboard(flow.OnboardOptions())
|
|
|
|
assert batches == [
|
|
(("openclaw", "hermes"), False, False),
|
|
(("openclaw", "hermes"), True, False),
|
|
]
|
|
assert "LLM provider" not in calls
|
|
assert "Router mode" in calls
|
|
data = tomllib.loads(target.read_text())
|
|
# Sparse persistence may omit default-equal keys from the raw TOML, so
|
|
# the enabled/provider pins go through the reloaded semantic state.
|
|
saved = flow.load_config(target)
|
|
assert saved.llm.provider == "openrouter"
|
|
assert saved.llm.api_key_env == "OPENROUTER_API_KEY"
|
|
assert saved.llm.model == "deepseek/deepseek-v4-pro"
|
|
assert saved.squilla_router.enabled is True
|
|
assert saved.squilla_router.tier_profile == "openrouter"
|
|
assert "api_key" not in data.get("llm", {})
|
|
|
|
|
|
def test_interactive_onboard_imported_provider_prefers_inline_key_over_env(
|
|
tmp_path, monkeypatch
|
|
):
|
|
import sys
|
|
import tomllib
|
|
import types
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
monkeypatch.setattr(flow, "_wait_for_setup_start", lambda: None)
|
|
monkeypatch.setattr(
|
|
flow,
|
|
"detect_default_sources",
|
|
lambda: [flow.DetectedMigrationSource("openclaw", tmp_path / ".openclaw")],
|
|
)
|
|
|
|
calls: list[str] = []
|
|
|
|
def fake_run_migration_batch(_detected, selected, options):
|
|
if options.apply:
|
|
target.write_text(
|
|
"\n".join(
|
|
[
|
|
"[llm]",
|
|
'provider = "openrouter"',
|
|
'model = "anthropic/claude-sonnet-4.5"',
|
|
'api_key = "sk-imported"',
|
|
'api_key_env = "OPENROUTER_API_KEY"',
|
|
"",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return flow.MigrationBatchResult(
|
|
selected=tuple(selected),
|
|
apply=options.apply,
|
|
reports={
|
|
"openclaw": {
|
|
"output_dir": str(tmp_path / "reports" / "openclaw"),
|
|
"items": [{"kind": "config", "status": "planned"}],
|
|
}
|
|
},
|
|
)
|
|
|
|
monkeypatch.setattr(flow, "run_migration_batch", fake_run_migration_batch)
|
|
|
|
class _Answer:
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def ask(self):
|
|
return self.value
|
|
|
|
class _Questionary(types.SimpleNamespace):
|
|
def confirm(self, message: str, **kwargs):
|
|
calls.append(message)
|
|
if message == "Review migration options now?":
|
|
return _Answer(True)
|
|
if message == "Import saved API keys/tokens from detected legacy .env files?":
|
|
return _Answer(True)
|
|
if message == "Apply this migration now?":
|
|
return _Answer(True)
|
|
if message == "Use imported provider credentials?":
|
|
return _Answer(True)
|
|
if message == "Edit router tier models now?":
|
|
return _Answer(False)
|
|
if message in {
|
|
"Configure a messaging channel now?",
|
|
"Configure web search now?",
|
|
"Enable image generation now?",
|
|
}:
|
|
return _Answer(False)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def select(self, message: str, **kwargs):
|
|
calls.append(message)
|
|
if message == "Router mode":
|
|
return _Answer("SquillaRouter")
|
|
if message == "Default text model":
|
|
return _Answer(kwargs.get("default"))
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **_kwargs):
|
|
calls.append(message)
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
calls.append(message)
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_onboard(flow.OnboardOptions())
|
|
|
|
assert "LLM provider" not in calls
|
|
data = tomllib.loads(target.read_text())
|
|
assert data["llm"]["provider"] == "openrouter"
|
|
assert data["llm"]["api_key"] == "sk-imported"
|
|
assert data["llm"].get("api_key_env", "") == ""
|
|
assert data["llm"]["model"] == "deepseek/deepseek-v4-pro"
|
|
|
|
|
|
def test_interactive_onboard_imported_provider_finalize_error_continues_setup(
|
|
tmp_path, monkeypatch
|
|
):
|
|
import sys
|
|
import tomllib
|
|
import types
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
console_output = StringIO()
|
|
monkeypatch.setattr(
|
|
flow,
|
|
"console",
|
|
Console(file=console_output, force_terminal=False, highlight=False),
|
|
)
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-env")
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
monkeypatch.setattr(flow, "_wait_for_setup_start", lambda: None)
|
|
monkeypatch.setattr(
|
|
flow,
|
|
"detect_default_sources",
|
|
lambda: [flow.DetectedMigrationSource("openclaw", tmp_path / ".openclaw")],
|
|
)
|
|
monkeypatch.setattr(
|
|
flow,
|
|
"_use_imported_provider_credentials_with_router_defaults",
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("bad imported provider")),
|
|
)
|
|
monkeypatch.setattr(flow, "_run_provider_probe", lambda **_kw: None)
|
|
|
|
calls: list[str] = []
|
|
|
|
def fake_run_migration_batch(_detected, selected, options):
|
|
if options.apply:
|
|
target.write_text(
|
|
"\n".join(
|
|
[
|
|
"[llm]",
|
|
'provider = "openrouter"',
|
|
'model = "anthropic/claude-sonnet-4.5"',
|
|
'api_key_env = "OPENROUTER_API_KEY"',
|
|
"",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return flow.MigrationBatchResult(
|
|
selected=tuple(selected),
|
|
apply=options.apply,
|
|
reports={
|
|
"openclaw": {
|
|
"output_dir": str(tmp_path / "reports" / "openclaw"),
|
|
"items": [{"kind": "config", "status": "planned"}],
|
|
}
|
|
},
|
|
)
|
|
|
|
monkeypatch.setattr(flow, "run_migration_batch", fake_run_migration_batch)
|
|
|
|
class _Answer:
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def ask(self):
|
|
return self.value
|
|
|
|
class _Questionary(types.SimpleNamespace):
|
|
def confirm(self, message: str, **kwargs):
|
|
calls.append(message)
|
|
if message == "Review migration options now?":
|
|
return _Answer(True)
|
|
if message == "Import saved API keys/tokens from detected legacy .env files?":
|
|
return _Answer(False)
|
|
if message == "Apply this migration now?":
|
|
return _Answer(True)
|
|
if message == "Use imported provider credentials?":
|
|
return _Answer(True)
|
|
if message == "Edit router tier models now?":
|
|
return _Answer(False)
|
|
if message in {
|
|
"Configure a messaging channel now?",
|
|
"Configure web search now?",
|
|
"Enable image generation now?",
|
|
}:
|
|
return _Answer(False)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def select(self, message: str, **kwargs):
|
|
calls.append(message)
|
|
if message == "LLM provider":
|
|
return _Answer("openrouter (OpenRouter)")
|
|
if message == "LLM API key source":
|
|
return _Answer("Use environment variable OPENROUTER_API_KEY")
|
|
if message == "Router mode":
|
|
return _Answer("SquillaRouter")
|
|
if message == "Default text model":
|
|
return _Answer(kwargs.get("default"))
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **_kwargs):
|
|
calls.append(message)
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
calls.append(message)
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_onboard(flow.OnboardOptions())
|
|
|
|
assert "LLM provider" in calls
|
|
out = console_output.getvalue()
|
|
assert "Imported provider settings could not be finalized" in out
|
|
assert "Continue provider setup to finish onboarding" in out
|
|
data = tomllib.loads(target.read_text())
|
|
assert data["llm"]["provider"] == "openrouter"
|
|
assert data["llm"]["api_key_env"] == "OPENROUTER_API_KEY"
|
|
assert data["llm"]["model"] == "deepseek/deepseek-v4-pro"
|
|
|
|
|
|
def test_onboard_migration_selection_summary_lists_checked_sources(tmp_path, monkeypatch):
|
|
from opensquilla.onboarding import flow
|
|
|
|
console_output = StringIO()
|
|
monkeypatch.setattr(
|
|
flow,
|
|
"console",
|
|
Console(file=console_output, force_terminal=False, highlight=False),
|
|
)
|
|
|
|
detected = [
|
|
flow.DetectedMigrationSource("openclaw", tmp_path / ".openclaw"),
|
|
flow.DetectedMigrationSource("hermes", tmp_path / ".hermes"),
|
|
]
|
|
|
|
flow._print_selected_migration_sources(detected, ["openclaw", "hermes"])
|
|
|
|
out = console_output.getvalue()
|
|
assert "Selected migration sources" in out
|
|
assert "☑ OpenClaw" in out
|
|
assert "☑ Hermes Agent" in out
|
|
unwrapped_out = out.replace("\n", "")
|
|
assert str(tmp_path / ".openclaw") in unwrapped_out
|
|
assert str(tmp_path / ".hermes") in unwrapped_out
|
|
|
|
|
|
def test_onboard_migration_source_prompt_uses_clear_continue_language(tmp_path):
|
|
from opensquilla.onboarding import flow
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
class _Answer:
|
|
def ask(self):
|
|
return ["openclaw", "hermes"]
|
|
|
|
class _Choice:
|
|
def __init__(self, title, value, checked=False, description=None):
|
|
self.title = title
|
|
self.value = value
|
|
self.checked = checked
|
|
self.description = description
|
|
|
|
class _Questionary:
|
|
Choice = _Choice
|
|
|
|
def checkbox(self, message: str, **kwargs):
|
|
captured["message"] = message
|
|
captured["instruction"] = kwargs.get("instruction")
|
|
return _Answer()
|
|
|
|
selected = flow._ask_migration_sources(
|
|
_Questionary(),
|
|
[
|
|
flow.DetectedMigrationSource("openclaw", tmp_path / ".openclaw"),
|
|
flow.DetectedMigrationSource("hermes", tmp_path / ".hermes"),
|
|
],
|
|
)
|
|
|
|
assert selected == ["openclaw", "hermes"]
|
|
assert captured == {
|
|
"message": "Select sources to import",
|
|
"instruction": "Space select | Enter continue | A toggle all",
|
|
}
|
|
|
|
|
|
def test_onboard_migration_preview_hides_unwritten_report_path(tmp_path, monkeypatch):
|
|
from opensquilla.onboarding import flow
|
|
|
|
console_output = StringIO()
|
|
monkeypatch.setattr(
|
|
flow,
|
|
"console",
|
|
Console(file=console_output, force_terminal=False, highlight=False),
|
|
)
|
|
missing_report_dir = tmp_path / "dry-run-report"
|
|
|
|
flow._print_migration_summary(
|
|
flow.MigrationBatchResult(
|
|
selected=("openclaw",),
|
|
apply=False,
|
|
reports={
|
|
"openclaw": {
|
|
"output_dir": str(missing_report_dir),
|
|
"items": [{"kind": "config", "status": "planned"}],
|
|
}
|
|
},
|
|
),
|
|
title="Migration preview",
|
|
)
|
|
|
|
out = console_output.getvalue()
|
|
assert "Migration preview" in out
|
|
assert "planned=1" in out
|
|
assert str(missing_report_dir) not in out
|
|
|
|
|
|
def test_interactive_onboard_migration_preview_failure_continues_provider_setup(
|
|
tmp_path, monkeypatch
|
|
):
|
|
import sys
|
|
import types
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
monkeypatch.setattr(flow, "_wait_for_setup_start", lambda: calls.append("start gate"))
|
|
detected = [flow.DetectedMigrationSource("openclaw", tmp_path / ".openclaw")]
|
|
monkeypatch.setattr(flow, "detect_default_sources", lambda: detected)
|
|
monkeypatch.setattr(flow, "_run_provider_probe", lambda **_kw: None)
|
|
|
|
calls: list[str] = []
|
|
batches: list[tuple[tuple[str, ...], bool]] = []
|
|
|
|
def fake_run_migration_batch(_detected, selected, options):
|
|
batches.append((tuple(selected), options.apply))
|
|
return flow.MigrationBatchResult(
|
|
selected=tuple(selected),
|
|
apply=options.apply,
|
|
reports={
|
|
"openclaw": {
|
|
"output_dir": str(tmp_path / "reports" / "openclaw"),
|
|
"items": [{"kind": "source", "status": "error", "reason": "bad source"}],
|
|
}
|
|
},
|
|
)
|
|
|
|
monkeypatch.setattr(flow, "run_migration_batch", fake_run_migration_batch)
|
|
|
|
class _Answer:
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def ask(self):
|
|
return self.value
|
|
|
|
class _Questionary(types.SimpleNamespace):
|
|
def confirm(self, message: str, **kwargs):
|
|
calls.append(message)
|
|
if message == "Review migration options now?":
|
|
assert kwargs.get("default") is True
|
|
return _Answer(True)
|
|
if message == "Import saved API keys/tokens from detected legacy .env files?":
|
|
assert kwargs.get("default") is False
|
|
return _Answer(False)
|
|
if message == "Edit router tier models now?":
|
|
return _Answer(False)
|
|
if message in {
|
|
"Configure a messaging channel now?",
|
|
"Configure web search now?",
|
|
"Enable image generation now?",
|
|
}:
|
|
return _Answer(False)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def select(self, message: str, **kwargs):
|
|
calls.append(message)
|
|
if message == "LLM provider":
|
|
return _Answer("openrouter (OpenRouter)")
|
|
if message == "LLM API key source":
|
|
return _Answer("Use environment variable OPENROUTER_API_KEY")
|
|
if message == "Router mode":
|
|
return _Answer("SquillaRouter")
|
|
if message == "Default text model":
|
|
return _Answer(kwargs.get("default"))
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **_kwargs):
|
|
calls.append(message)
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
calls.append(message)
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_onboard(flow.OnboardOptions())
|
|
|
|
assert batches == [(("openclaw",), False)]
|
|
assert "Apply this migration now?" not in calls
|
|
assert "LLM provider" in calls
|
|
# Sparse persistence may omit default-equal keys; pin the reloaded state.
|
|
saved = flow.load_config(target)
|
|
assert saved.llm.provider == "openrouter"
|
|
assert saved.llm.api_key_env == "OPENROUTER_API_KEY"
|
|
|
|
|
|
def test_interactive_onboard_migration_prompts_for_missing_imported_provider_key(
|
|
tmp_path, monkeypatch
|
|
):
|
|
import sys
|
|
import tomllib
|
|
import types
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
monkeypatch.delenv("IMPORTED_OPENROUTER_KEY", raising=False)
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
monkeypatch.setattr(flow, "_wait_for_setup_start", lambda: calls.append("start gate"))
|
|
detected = [flow.DetectedMigrationSource("openclaw", tmp_path / ".openclaw")]
|
|
monkeypatch.setattr(flow, "detect_default_sources", lambda: detected)
|
|
|
|
calls: list[str] = []
|
|
|
|
def fake_run_migration_batch(_detected, selected, options):
|
|
if options.apply:
|
|
target.write_text(
|
|
"\n".join(
|
|
[
|
|
"[llm]",
|
|
'provider = "openrouter"',
|
|
'model = "anthropic/claude-sonnet-4.5"',
|
|
'api_key_env = "IMPORTED_OPENROUTER_KEY"',
|
|
"",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return flow.MigrationBatchResult(
|
|
selected=tuple(selected),
|
|
apply=options.apply,
|
|
reports={
|
|
"openclaw": {
|
|
"output_dir": str(tmp_path / "reports" / "openclaw"),
|
|
"items": [{"kind": "config", "status": "planned"}],
|
|
}
|
|
},
|
|
)
|
|
|
|
monkeypatch.setattr(flow, "run_migration_batch", fake_run_migration_batch)
|
|
|
|
class _Answer:
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def ask(self):
|
|
return self.value
|
|
|
|
class _Questionary(types.SimpleNamespace):
|
|
def confirm(self, message: str, **kwargs):
|
|
calls.append(message)
|
|
if message == "Review migration options now?":
|
|
return _Answer(True)
|
|
if message == "Import saved API keys/tokens from detected legacy .env files?":
|
|
assert kwargs.get("default") is False
|
|
return _Answer(False)
|
|
if message == "Apply this migration now?":
|
|
return _Answer(True)
|
|
if message == "Edit router tier models now?":
|
|
return _Answer(False)
|
|
if message in {
|
|
"Configure a messaging channel now?",
|
|
"Configure web search now?",
|
|
"Enable image generation now?",
|
|
}:
|
|
return _Answer(False)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def select(self, message: str, **kwargs):
|
|
calls.append(message)
|
|
if message == "LLM API key source":
|
|
assert "Use environment variable IMPORTED_OPENROUTER_KEY" in kwargs.get(
|
|
"choices", []
|
|
)
|
|
assert "Use environment variable OPENROUTER_API_KEY" in kwargs.get(
|
|
"choices", []
|
|
)
|
|
assert kwargs.get("default") == "Paste API key now"
|
|
return _Answer("Paste API key now")
|
|
if message == "Router mode":
|
|
return _Answer("SquillaRouter")
|
|
if message == "Default text model":
|
|
return _Answer(kwargs.get("default"))
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
calls.append(message)
|
|
if message == "API key":
|
|
return _Answer("sk-new")
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
def text(self, message: str, **_kwargs):
|
|
calls.append(message)
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_onboard(flow.OnboardOptions())
|
|
|
|
assert "LLM provider" not in calls
|
|
assert "Router mode" in calls
|
|
data = tomllib.loads(target.read_text())
|
|
assert data["llm"]["provider"] == "openrouter"
|
|
assert data["llm"]["api_key"] == "sk-new"
|
|
assert data["llm"]["model"] == "deepseek/deepseek-v4-pro"
|
|
|
|
|
|
def test_interactive_onboard_can_enable_image_generation(tmp_path, monkeypatch):
|
|
import sys
|
|
import tomllib
|
|
import types
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-image-env")
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
monkeypatch.setattr(flow, "_wait_for_setup_start", lambda: None)
|
|
monkeypatch.setattr(flow, "detect_default_sources", lambda: [])
|
|
monkeypatch.setattr(flow, "_run_provider_probe", lambda **_kw: None)
|
|
|
|
calls: list[str] = []
|
|
|
|
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):
|
|
calls.append(message)
|
|
if message == "LLM provider":
|
|
return _Answer("openrouter (OpenRouter)")
|
|
if message == "LLM API key source":
|
|
assert kwargs.get("default") == (
|
|
"Use environment variable OPENROUTER_API_KEY (detected)"
|
|
)
|
|
return _Answer("Use environment variable OPENROUTER_API_KEY (detected)")
|
|
if message == "Router mode":
|
|
return _Answer("SquillaRouter")
|
|
if message == "Default text model":
|
|
return _Answer(kwargs.get("default"))
|
|
if message == "Image generation provider":
|
|
assert kwargs.get("default") == "openrouter (OpenRouter Images)"
|
|
return _Answer("openrouter (OpenRouter Images)")
|
|
if message == "Image API key source":
|
|
assert (
|
|
"Use environment variable OPENROUTER_API_KEY"
|
|
in kwargs.get("choices", [])
|
|
)
|
|
assert "Reuse matching LLM provider key" not in kwargs.get("choices", [])
|
|
assert kwargs.get("default") == "Use environment variable OPENROUTER_API_KEY"
|
|
return _Answer("Use environment variable OPENROUTER_API_KEY")
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **kwargs):
|
|
calls.append(message)
|
|
if message == "Primary image model":
|
|
return _Answer(kwargs.get("default"))
|
|
if message == "Image base URL":
|
|
return _Answer(kwargs.get("default"))
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
if message == "API key":
|
|
return _Answer("sk-llm")
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
def confirm(self, message: str, **_kwargs):
|
|
calls.append(message)
|
|
if message == "Edit router tier models now?":
|
|
return _Answer(False)
|
|
if message in {
|
|
"Configure a messaging channel now?",
|
|
"Configure web search now?",
|
|
}:
|
|
return _Answer(False)
|
|
if message == "Enable image generation now?":
|
|
return _Answer(True)
|
|
if message == "Image generation enabled?":
|
|
return _Answer(True)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_onboard(flow.OnboardOptions())
|
|
|
|
assert calls.index("Enable image generation now?") > calls.index("Configure web search now?")
|
|
data = tomllib.loads(target.read_text())
|
|
assert data["image_generation"]["enabled"] is True
|
|
assert (
|
|
data["image_generation"]["primary"]
|
|
== "openrouter/google/gemini-3.1-flash-image-preview"
|
|
)
|
|
|
|
|
|
def test_onboard_if_needed_core_ready_repairs_memory_embedding_without_provider_setup(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
import sys
|
|
import tomllib
|
|
import types
|
|
|
|
from opensquilla.gateway.config import (
|
|
GatewayConfig,
|
|
LlmProviderConfig,
|
|
MemoryEmbeddingConfig,
|
|
)
|
|
from opensquilla.onboarding import flow
|
|
from opensquilla.onboarding.config_store import persist_config
|
|
|
|
target = tmp_path / "c.toml"
|
|
cfg = GatewayConfig(config_path=str(target))
|
|
cfg.llm = LlmProviderConfig(
|
|
provider="openrouter",
|
|
model="deepseek/deepseek-v4-flash",
|
|
api_key="sk-core",
|
|
)
|
|
cfg.memory.embedding = MemoryEmbeddingConfig(provider="openai")
|
|
persist_config(cfg, path=target, backup=False)
|
|
|
|
calls: list[str] = []
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-memory-env")
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
monkeypatch.setattr(flow, "_wait_for_setup_start", lambda: calls.append("start gate"))
|
|
monkeypatch.setattr(flow, "detect_default_sources", lambda: [])
|
|
|
|
banner_calls: list[tuple[str, str]] = []
|
|
monkeypatch.setattr(
|
|
flow,
|
|
"banner_panel",
|
|
lambda title, subtitle: banner_calls.append((title, subtitle)) or title,
|
|
)
|
|
|
|
class _Answer:
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def ask(self):
|
|
return self.value
|
|
|
|
class _Questionary(types.SimpleNamespace):
|
|
def confirm(self, message: str, **kwargs):
|
|
calls.append(message)
|
|
if message == "Configure memory embeddings now?":
|
|
assert kwargs.get("default") is True
|
|
return _Answer(True)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def select(self, message: str, **kwargs):
|
|
calls.append(message)
|
|
if message == "Memory embedding provider":
|
|
return _Answer("openai (OpenAI)")
|
|
if message == "Memory API key source":
|
|
assert "Use environment variable OPENAI_API_KEY (detected)" in kwargs.get(
|
|
"choices", []
|
|
)
|
|
return _Answer("Use environment variable OPENAI_API_KEY (detected)")
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **kwargs):
|
|
calls.append(message)
|
|
if message == "Memory embedding model":
|
|
return _Answer(kwargs.get("default"))
|
|
if message == "Memory embedding base URL":
|
|
return _Answer(kwargs.get("default"))
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_onboard(flow.OnboardOptions(if_needed=True))
|
|
|
|
assert "LLM provider" not in calls
|
|
assert calls.index("Configure memory embeddings now?") < calls.index(
|
|
"Memory embedding provider"
|
|
)
|
|
assert banner_calls == [
|
|
(
|
|
"OpenSquilla Onboarding",
|
|
"Migration · Provider · SquillaRouter · Channels · Capabilities",
|
|
)
|
|
]
|
|
data = tomllib.loads(target.read_text())
|
|
assert data["memory"]["embedding"]["provider"] == "openai"
|
|
assert data["memory"]["embedding"]["remote"]["api_key_env"] == "OPENAI_API_KEY"
|
|
assert "api_key" not in data["memory"]["embedding"]["remote"]
|
|
|
|
|
|
def test_interactive_configure_image_generation_persists(tmp_path, monkeypatch):
|
|
import sys
|
|
import types
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-image-env")
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
|
|
calls: list[str] = []
|
|
|
|
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):
|
|
calls.append(message)
|
|
if message == "Image generation provider":
|
|
return _Answer("openai (OpenAI Images)")
|
|
if message == "Image API key source":
|
|
return _Answer("Use environment variable OPENAI_API_KEY")
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **kwargs):
|
|
calls.append(message)
|
|
if message == "Primary image model":
|
|
return _Answer(kwargs.get("default"))
|
|
if message == "Image base URL":
|
|
return _Answer(kwargs.get("default"))
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
def confirm(self, message: str, **kwargs):
|
|
calls.append(message)
|
|
if message == "Image generation enabled?":
|
|
assert kwargs.get("default") is True
|
|
return _Answer(True)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_configure("image-generation")
|
|
|
|
assert calls == [
|
|
"Image generation provider",
|
|
"Primary image model",
|
|
"Image API key source",
|
|
"Image base URL",
|
|
"Image generation enabled?",
|
|
]
|
|
# Sparse persistence may omit default-equal keys; pin the reloaded state.
|
|
saved = flow.load_config(target)
|
|
assert saved.image_generation.enabled is True
|
|
assert saved.image_generation.primary == "openai/gpt-image-1"
|
|
|
|
|
|
def test_interactive_configure_image_generation_uses_explicit_config_path(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
import sys
|
|
import types
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
default_target = tmp_path / "default.toml"
|
|
target = tmp_path / "custom.toml"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(default_target))
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-image-env")
|
|
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):
|
|
if message == "Image generation provider":
|
|
return _Answer("openai (OpenAI Images)")
|
|
if message == "Image API key source":
|
|
return _Answer("Use environment variable OPENAI_API_KEY")
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **kwargs):
|
|
if message == "Primary image model":
|
|
return _Answer(kwargs.get("default"))
|
|
if message == "Image base URL":
|
|
return _Answer(kwargs.get("default"))
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
def confirm(self, message: str, **kwargs):
|
|
if message == "Image generation enabled?":
|
|
assert kwargs.get("default") is True
|
|
return _Answer(True)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_configure("image-generation", config_path=target)
|
|
|
|
# Sparse persistence may omit default-equal keys; pin the reloaded state.
|
|
saved = flow.load_config(target)
|
|
assert saved.image_generation.enabled is True
|
|
assert saved.image_generation.providers.openai.api_key_env == "OPENAI_API_KEY"
|
|
assert not default_target.exists()
|
|
|
|
|
|
def test_router_tier_overrides_edit_only_selected_tiers():
|
|
from opensquilla.gateway.config import GatewayConfig
|
|
from opensquilla.onboarding.flow import _router_tier_overrides
|
|
|
|
calls: list[str] = []
|
|
selections = iter(["Route c2", "Done"])
|
|
|
|
class _Answer:
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def ask(self):
|
|
return self.value
|
|
|
|
class _Questionary:
|
|
def select(self, message: str, **kwargs):
|
|
calls.append(message)
|
|
assert message == "Tier to edit"
|
|
assert kwargs.get("choices") == [
|
|
"Done",
|
|
"Route c0",
|
|
"Route c1",
|
|
"Route c2",
|
|
"Route c3",
|
|
"Image model",
|
|
]
|
|
return _Answer(next(selections))
|
|
|
|
def text(self, message: str, **kwargs):
|
|
calls.append(message)
|
|
if message == "c2 provider":
|
|
assert kwargs.get("default") == "openrouter"
|
|
return _Answer("openrouter")
|
|
if message == "c2 model":
|
|
assert kwargs.get("default") == "z-ai/glm-5.2"
|
|
return _Answer("custom/reasoner")
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
# Pin the packaged openrouter ladder: the prompt list and per-tier
|
|
# defaults asserted above include its curated image tier.
|
|
overrides = _router_tier_overrides(
|
|
_Questionary(), GatewayConfig(llm={"provider": "openrouter"})
|
|
)
|
|
|
|
assert calls == ["Tier to edit", "c2 provider", "c2 model", "Tier to edit"]
|
|
assert overrides == {"c2": {"provider": "openrouter", "model": "custom/reasoner"}}
|
|
|
|
|
|
def test_interactive_feishu_websocket_prompts_only_core_fields(tmp_path, monkeypatch):
|
|
import sys
|
|
import types
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
console_output = StringIO()
|
|
monkeypatch.setattr(
|
|
flow,
|
|
"console",
|
|
Console(file=console_output, force_terminal=False, highlight=False),
|
|
)
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
monkeypatch.setattr(flow.importlib.util, "find_spec", lambda name: None)
|
|
|
|
calls: list[str] = []
|
|
|
|
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):
|
|
calls.append(message)
|
|
if message == "Channel type":
|
|
return _Answer("feishu")
|
|
if message == "Connection mode":
|
|
return _Answer(kwargs.get("default") or "websocket")
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **kwargs):
|
|
calls.append(message)
|
|
if message == "Channel name":
|
|
assert kwargs.get("default") == "feishu"
|
|
return _Answer("feishu")
|
|
if message == "App id":
|
|
return _Answer("cli_test")
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
calls.append(message)
|
|
if message == "App secret":
|
|
return _Answer("secret")
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
def confirm(self, message: str, **_kwargs):
|
|
calls.append(message)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_channel_add(None)
|
|
|
|
out = console_output.getvalue()
|
|
normalized_out = " ".join(out.split())
|
|
assert "Feishu websocket mode requires the base lark-oapi dependency" in out
|
|
assert "Portable zip:" in out
|
|
assert "latest recommended portable package" in out
|
|
assert "OPENSQUILLA_INSTALL_EXTRAS" not in normalized_out
|
|
assert "https://opensquilla.ai/install.ps1" in normalized_out
|
|
assert "https://opensquilla.ai/install.sh" in normalized_out
|
|
assert "bash -s --" in normalized_out
|
|
assert "uv sync --extra recommended" in normalized_out
|
|
assert "--extra feishu" not in normalized_out
|
|
assert "Restarting alone will not install Python packages." in out
|
|
assert calls == ["Channel type", "Channel name", "App id", "App secret", "Connection mode"]
|
|
data = target.read_text()
|
|
assert 'type = "feishu"' in data
|
|
assert 'app_id = "cli_test"' in data
|
|
assert 'connection_mode = "websocket"' in data
|
|
|
|
|
|
def test_interactive_channel_add_uses_explicit_config_path(tmp_path, monkeypatch):
|
|
import sys
|
|
import types
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
default_target = tmp_path / "default.toml"
|
|
target = tmp_path / "custom.toml"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(default_target))
|
|
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):
|
|
if message == "Channel type":
|
|
return _Answer("slack")
|
|
if message == "Connection mode":
|
|
return _Answer("webhook")
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **kwargs):
|
|
if message == "Channel name":
|
|
return _Answer("slack-main")
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
if message == "Bot token (xoxb-...)":
|
|
return _Answer("xoxb-test")
|
|
if message == "Signing secret":
|
|
return _Answer("signing-secret")
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
def confirm(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def checkbox(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected checkbox prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_channel_add(None, config_path=target)
|
|
|
|
data = target.read_text()
|
|
assert 'type = "slack"' in data
|
|
assert 'connection_mode = "webhook"' in data
|
|
assert 'signing_secret = "signing-secret"' in data
|
|
assert not default_target.exists()
|
|
|
|
|
|
def test_interactive_slack_channel_add_can_select_socket_mode(tmp_path, monkeypatch):
|
|
import sys
|
|
import types
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "socket.toml"
|
|
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):
|
|
if message == "Channel type":
|
|
return _Answer("slack")
|
|
if message == "Connection mode":
|
|
return _Answer("socket")
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **kwargs):
|
|
if message == "Channel name":
|
|
return _Answer("slack-socket")
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
if message == "Bot token (xoxb-...)":
|
|
return _Answer("xoxb-test")
|
|
if message == "App-level token (xapp-...)":
|
|
return _Answer("xapp-test")
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
def confirm(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def checkbox(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected checkbox prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_channel_add(None, config_path=target)
|
|
|
|
data = target.read_text()
|
|
assert 'type = "slack"' in data
|
|
assert 'connection_mode = "socket"' in data
|
|
assert 'app_token = "xapp-test"' in data
|
|
assert "signing_secret" not in data
|
|
|
|
|
|
def test_optional_onboarding_section_receives_explicit_config_path(tmp_path):
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "custom.toml"
|
|
seen = {}
|
|
|
|
def runner(*, config_path=None):
|
|
seen["config_path"] = config_path
|
|
|
|
flow._run_optional_section(
|
|
section="search",
|
|
label="search",
|
|
runner=runner,
|
|
config_path=target,
|
|
)
|
|
|
|
assert seen["config_path"] == target
|
|
|
|
|
|
def test_channel_saved_output_separates_configured_from_connected(monkeypatch):
|
|
from opensquilla.onboarding import flow
|
|
from opensquilla.onboarding.flow import _print_channel_saved
|
|
|
|
console_output = StringIO()
|
|
monkeypatch.setattr(
|
|
flow,
|
|
"console",
|
|
Console(file=console_output, force_terminal=False, highlight=False),
|
|
)
|
|
|
|
_print_channel_saved("feishu")
|
|
|
|
out = console_output.getvalue()
|
|
assert "configured, not connected yet" in out
|
|
assert "Restart the gateway process" in out
|
|
assert "opensquilla channels status feishu --json" in out
|
|
|
|
|
|
def test_search_provider_key_defaults_to_pasted_key_with_brave_hint(monkeypatch):
|
|
from opensquilla.onboarding.flow import _ask_search_fields
|
|
from opensquilla.onboarding.search_specs import get_search_provider_setup_spec
|
|
|
|
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
|
|
|
class _Answer:
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def ask(self):
|
|
return self.value
|
|
|
|
class _Questionary:
|
|
def select(self, message: str, **kwargs):
|
|
if message == "Search fallback policy":
|
|
return _Answer(kwargs.get("default"))
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def confirm(self, message: str, **kwargs):
|
|
if message == "Use environment proxy for search?":
|
|
return _Answer(False)
|
|
if message == (
|
|
"Enable search diagnostics? Include provider attempt/error details "
|
|
"for troubleshooting?"
|
|
):
|
|
return _Answer(False)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
assert message == (
|
|
"Brave Search API key "
|
|
"(create one at https://api-dashboard.search.brave.com/app/keys)"
|
|
)
|
|
return _Answer("brave-secret")
|
|
|
|
def text(self, message: str, **kwargs):
|
|
if message == "Max search results":
|
|
return _Answer(kwargs.get("default"))
|
|
if message == "Search HTTP proxy":
|
|
return _Answer("")
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
answers = _ask_search_fields(
|
|
_Questionary(),
|
|
get_search_provider_setup_spec("brave"),
|
|
)
|
|
|
|
assert answers["api_key"] == "brave-secret"
|
|
assert answers["api_key_env"] == ""
|
|
|
|
|
|
def test_search_provider_detected_env_prefers_env_but_can_use_manual_key(monkeypatch):
|
|
from opensquilla.onboarding.flow import _ask_search_fields
|
|
from opensquilla.onboarding.search_specs import get_search_provider_setup_spec
|
|
|
|
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "from-env")
|
|
|
|
class _Answer:
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def ask(self):
|
|
return self.value
|
|
|
|
class _Questionary:
|
|
def select(self, message: str, **kwargs):
|
|
if message == "Search fallback policy":
|
|
return _Answer(kwargs.get("default"))
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def confirm(self, message: str, **kwargs):
|
|
if message == "Use BRAVE_SEARCH_API_KEY from environment?":
|
|
assert kwargs.get("default") is True
|
|
return _Answer(False)
|
|
if message == "Use environment proxy for search?":
|
|
return _Answer(False)
|
|
if message == (
|
|
"Enable search diagnostics? Include provider attempt/error details "
|
|
"for troubleshooting?"
|
|
):
|
|
return _Answer(False)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
assert message == (
|
|
"Brave Search API key "
|
|
"(create one at https://api-dashboard.search.brave.com/app/keys)"
|
|
)
|
|
return _Answer("manual-brave-secret")
|
|
|
|
def text(self, message: str, **kwargs):
|
|
if message == "Max search results":
|
|
return _Answer(kwargs.get("default"))
|
|
if message == "Search HTTP proxy":
|
|
return _Answer("")
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
answers = _ask_search_fields(
|
|
_Questionary(),
|
|
get_search_provider_setup_spec("brave"),
|
|
)
|
|
|
|
assert answers["api_key"] == "manual-brave-secret"
|
|
assert answers["api_key_env"] == ""
|
|
|
|
|
|
def test_search_provider_can_use_detected_env_when_requested(monkeypatch):
|
|
from opensquilla.onboarding.flow import _ask_search_fields
|
|
from opensquilla.onboarding.search_specs import get_search_provider_setup_spec
|
|
|
|
monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "from-env")
|
|
|
|
class _Answer:
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def ask(self):
|
|
return self.value
|
|
|
|
class _Questionary:
|
|
def select(self, message: str, **kwargs):
|
|
if message == "Search fallback policy":
|
|
return _Answer(kwargs.get("default"))
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def confirm(self, message: str, **kwargs):
|
|
if message == "Use BRAVE_SEARCH_API_KEY from environment?":
|
|
assert kwargs.get("default") is True
|
|
return _Answer(True)
|
|
if message == "Use environment proxy for search?":
|
|
return _Answer(False)
|
|
if message == (
|
|
"Enable search diagnostics? Include provider attempt/error details "
|
|
"for troubleshooting?"
|
|
):
|
|
return _Answer(False)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
def text(self, message: str, **kwargs):
|
|
if message == "Max search results":
|
|
return _Answer(kwargs.get("default"))
|
|
if message == "Search HTTP proxy":
|
|
return _Answer("")
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
answers = _ask_search_fields(
|
|
_Questionary(),
|
|
get_search_provider_setup_spec("brave"),
|
|
)
|
|
|
|
assert answers["api_key"] == ""
|
|
assert answers["api_key_env"] == "BRAVE_SEARCH_API_KEY"
|
|
|
|
|
|
def test_search_fallback_choice_names_duckduckgo_and_persists_value(monkeypatch):
|
|
from opensquilla.onboarding.flow import _ask_search_fields
|
|
from opensquilla.onboarding.search_specs import get_search_provider_setup_spec
|
|
|
|
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
|
|
|
class _Answer:
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def ask(self):
|
|
return self.value
|
|
|
|
class _Questionary:
|
|
def select(self, message: str, **kwargs):
|
|
if message == "Search fallback policy":
|
|
choices = kwargs.get("choices")
|
|
assert choices == [
|
|
"off - no fallback; surface the original provider error",
|
|
"network - retry with DuckDuckGo on timeout/network errors",
|
|
]
|
|
assert kwargs.get("default") == choices[0]
|
|
return _Answer(choices[1])
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def confirm(self, message: str, **_kwargs):
|
|
if message == "Use environment proxy for search?":
|
|
return _Answer(False)
|
|
if message == (
|
|
"Enable search diagnostics? Include provider attempt/error details "
|
|
"for troubleshooting?"
|
|
):
|
|
return _Answer(False)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
return _Answer("brave-secret")
|
|
|
|
def text(self, message: str, **kwargs):
|
|
if message == "Max search results":
|
|
return _Answer(kwargs.get("default"))
|
|
if message == "Search HTTP proxy":
|
|
return _Answer("")
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
answers = _ask_search_fields(
|
|
_Questionary(),
|
|
get_search_provider_setup_spec("brave"),
|
|
)
|
|
|
|
assert answers["fallback_policy"] == "network"
|
|
|
|
|
|
def test_search_provider_can_use_masked_api_key_prompt(monkeypatch):
|
|
from opensquilla.onboarding.flow import _ask_search_fields
|
|
from opensquilla.onboarding.search_specs import get_search_provider_setup_spec
|
|
|
|
monkeypatch.delenv("BRAVE_SEARCH_API_KEY", raising=False)
|
|
|
|
class _Answer:
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
def ask(self):
|
|
return self.value
|
|
|
|
class _Questionary:
|
|
def select(self, message: str, **kwargs):
|
|
if message == "Search fallback policy":
|
|
return _Answer(kwargs.get("default"))
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
assert message == (
|
|
"Brave Search API key "
|
|
"(create one at https://api-dashboard.search.brave.com/app/keys)"
|
|
)
|
|
return _Answer("brave-secret")
|
|
|
|
def text(self, message: str, **kwargs):
|
|
if message == "Max search results":
|
|
return _Answer(kwargs.get("default"))
|
|
if message == "Search HTTP proxy":
|
|
return _Answer("")
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def confirm(self, message: str, **_kwargs):
|
|
if message == "Use environment proxy for search?":
|
|
return _Answer(False)
|
|
if message == (
|
|
"Enable search diagnostics? Include provider attempt/error details "
|
|
"for troubleshooting?"
|
|
):
|
|
return _Answer(False)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
answers = _ask_search_fields(
|
|
_Questionary(),
|
|
get_search_provider_setup_spec("brave"),
|
|
)
|
|
|
|
assert answers["api_key"] == "brave-secret"
|
|
assert answers["api_key_env"] == ""
|
|
|
|
|
|
def test_noninteractive_provider_configure_writes_config(tmp_path, monkeypatch):
|
|
target = tmp_path / "c.toml"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
from opensquilla.onboarding.flow import run_noninteractive_provider_configure
|
|
|
|
result = run_noninteractive_provider_configure(
|
|
"openrouter",
|
|
{"model": "deepseek/deepseek-v4-flash", "api_key": "sk"},
|
|
)
|
|
assert result.path == target
|
|
assert "openrouter" in target.read_text()
|
|
|
|
|
|
def test_flow_module_exposes_no_engine_bypassing_noninteractive_writers():
|
|
"""Headless channel/search writes go through the CLI's SetupEngine path;
|
|
the old module-level helpers persisted config while bypassing the
|
|
engine's restart/warning accumulation and must stay deleted."""
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
assert not hasattr(flow, "run_noninteractive_channel_add")
|
|
assert not hasattr(flow, "run_noninteractive_search_configure")
|
|
|
|
|
|
def test_interactive_configure_search_uses_explicit_config_path(tmp_path, monkeypatch):
|
|
import sys
|
|
import tomllib
|
|
import types
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
default_target = tmp_path / "default.toml"
|
|
target = tmp_path / "custom.toml"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(default_target))
|
|
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):
|
|
if message == "Search provider":
|
|
return _Answer("duckduckgo (DuckDuckGo)")
|
|
if message == "Search fallback policy":
|
|
return _Answer(kwargs.get("default"))
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **kwargs):
|
|
if message == "Max search results":
|
|
return _Answer("7")
|
|
if message == "Search HTTP proxy":
|
|
return _Answer("")
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
def confirm(self, message: str, **_kwargs):
|
|
if message in {
|
|
"Use environment proxy for search?",
|
|
flow._SEARCH_DIAGNOSTICS_PROMPT,
|
|
}:
|
|
return _Answer(False)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_configure("search", config_path=target)
|
|
|
|
data = tomllib.loads(target.read_text())
|
|
# Sparse persistence may omit default-equal keys (duckduckgo is the
|
|
# default provider); pin the reloaded semantic state instead.
|
|
saved = flow.load_config(target)
|
|
assert saved.search_provider == "duckduckgo"
|
|
assert data["search_max_results"] == 7
|
|
assert not default_target.exists()
|
|
|
|
|
|
def test_interactive_configure_memory_embedding_is_in_section_menu(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
import sys
|
|
import tomllib
|
|
import types
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-memory-env")
|
|
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):
|
|
if message == "Section":
|
|
titles = kwargs["choices"]
|
|
target_title = next(
|
|
(t for t in titles if t.startswith("Memory embedding")), None
|
|
)
|
|
if target_title is not None and not getattr(self, "_dispatched", False):
|
|
self._dispatched = True
|
|
return _Answer(target_title)
|
|
done = next(
|
|
t for t in titles if t in ("Done", "Exit (nothing changed)")
|
|
)
|
|
return _Answer(done)
|
|
if message == "Memory embedding provider":
|
|
return _Answer("openai (OpenAI)")
|
|
if message == "Memory API key source":
|
|
return _Answer("Use environment variable OPENAI_API_KEY")
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **kwargs):
|
|
if message == "Memory embedding model":
|
|
return _Answer("text-embedding-3-small")
|
|
if message == "Memory embedding base URL":
|
|
return _Answer(kwargs.get("default"))
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_configure(config_path=target)
|
|
|
|
data = tomllib.loads(target.read_text())
|
|
remote = data["memory"]["embedding"]["remote"]
|
|
assert data["memory"]["embedding"]["provider"] == "openai"
|
|
assert remote["api_key_env"] == "OPENAI_API_KEY"
|
|
assert "api_key" not in remote
|
|
|
|
|
|
def test_interactive_memory_embedding_configure_without_tty_prints_hint(
|
|
tmp_path,
|
|
monkeypatch,
|
|
capsys,
|
|
):
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: False)
|
|
|
|
result = flow.run_interactive_memory_embedding_configure(config_path=target)
|
|
|
|
assert result.warnings == ["tty_required"]
|
|
assert not target.exists()
|
|
out = capsys.readouterr().out
|
|
assert "Headless memory embedding:" in out
|
|
assert "opensquilla onboard configure memory-embedding --config" in out
|
|
|
|
|
|
def test_interactive_configure_provider_accepts_singular_section_alias(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
from opensquilla.onboarding import flow
|
|
from opensquilla.onboarding.config_store import PersistResult
|
|
|
|
target = tmp_path / "custom.toml"
|
|
seen = {}
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
|
|
def fake_run_interactive_onboard(options):
|
|
seen["config_path"] = options.config_path
|
|
return PersistResult(
|
|
path=target,
|
|
backup_path=None,
|
|
restart_required=False,
|
|
warnings=[],
|
|
)
|
|
|
|
monkeypatch.setattr(flow, "run_interactive_onboard", fake_run_interactive_onboard)
|
|
|
|
result = flow.run_interactive_configure("provider", config_path=target)
|
|
|
|
assert result is not None
|
|
assert seen["config_path"] == target
|
|
|
|
|
|
def test_interactive_configure_router_persists(tmp_path, monkeypatch):
|
|
import sys
|
|
import tomllib
|
|
import types
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
target.write_text(
|
|
'[llm]\nprovider = "openrouter"\nmodel = "deepseek/deepseek-v4-flash"\n',
|
|
encoding="utf-8",
|
|
)
|
|
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):
|
|
if message == "Router mode":
|
|
return _Answer("Disabled")
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
def confirm(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def checkbox(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected checkbox prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
flow.run_interactive_configure("router", config_path=target)
|
|
|
|
data = tomllib.loads(target.read_text())
|
|
assert data["squilla_router"]["enabled"] is False
|
|
|
|
|
|
def test_interactive_configure_provider_receives_explicit_config_path(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
from opensquilla.onboarding import flow
|
|
from opensquilla.onboarding.config_store import PersistResult
|
|
|
|
target = tmp_path / "custom.toml"
|
|
seen = {}
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
|
|
def fake_run_interactive_onboard(options):
|
|
seen["config_path"] = options.config_path
|
|
return PersistResult(
|
|
path=target,
|
|
backup_path=None,
|
|
restart_required=False,
|
|
warnings=[],
|
|
)
|
|
|
|
monkeypatch.setattr(flow, "run_interactive_onboard", fake_run_interactive_onboard)
|
|
|
|
result = flow.run_interactive_configure("providers", config_path=target)
|
|
|
|
assert result is not None
|
|
assert seen["config_path"] == target
|
|
|
|
|
|
def test_interactive_configure_without_tty_does_not_create_config(
|
|
tmp_path, monkeypatch, capsys
|
|
):
|
|
target = tmp_path / "c.toml"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
from opensquilla.onboarding import flow
|
|
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: False)
|
|
result = flow.run_interactive_configure("providers")
|
|
|
|
assert result is None
|
|
out = capsys.readouterr().out
|
|
assert "Guided CLI:" in out
|
|
assert "Provider recipes:" in out
|
|
assert "Headless provider:" not in out
|
|
assert "Check status:" in out
|
|
assert not target.exists()
|
|
|
|
|
|
def test_interactive_configure_provider_scopes_out_migration_and_optional_sections(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
"""``onboard configure provider`` is a scoped key swap: it must not
|
|
re-trigger the legacy-migration pre-step or the optional image-generation
|
|
prompt that the full first-run wizard walks through."""
|
|
|
|
from opensquilla.onboarding import flow
|
|
from opensquilla.onboarding.config_store import PersistResult
|
|
|
|
target = tmp_path / "custom.toml"
|
|
seen = {}
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
|
|
def fake_run_interactive_onboard(options):
|
|
seen["options"] = options
|
|
return PersistResult(
|
|
path=target,
|
|
backup_path=None,
|
|
restart_required=False,
|
|
warnings=[],
|
|
)
|
|
|
|
monkeypatch.setattr(flow, "run_interactive_onboard", fake_run_interactive_onboard)
|
|
|
|
result = flow.run_interactive_configure("provider", config_path=target)
|
|
|
|
assert result is not None
|
|
options = seen["options"]
|
|
assert options.skip_migration is True
|
|
assert options.skip_image_generation is True
|
|
assert options.skip_channels is True
|
|
assert options.skip_search is True
|
|
assert options.config_path == target
|
|
|
|
|
|
def test_scoped_section_run_skips_banner_start_gate_and_trailing_prompts(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
"""A scoped section entry (``configure provider``, the hub's Provider
|
|
item) is not a first run: it must not re-print the onboarding banner,
|
|
must not block on the raw "Press Enter to start setup" ``input()`` gate
|
|
(where Ctrl+C would escape the hub's cancel handling entirely), and must
|
|
stop at its own section instead of appending the full walk's trailing
|
|
action-required prompts."""
|
|
|
|
import sys
|
|
import types
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
# Memory embedding needs action: on a FULL walk this appends the
|
|
# "Configure memory embeddings now?" prompt after the provider save.
|
|
target.write_text(
|
|
"[llm]\n"
|
|
'provider = "openrouter"\n'
|
|
'model = "dummy/model"\n'
|
|
'api_key = "sk-dummy"\n'
|
|
"\n"
|
|
"[memory.embedding]\n"
|
|
'provider = "openai"\n',
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
monkeypatch.setattr(flow, "_run_provider_probe", lambda **_kw: None)
|
|
|
|
banner_calls: list[tuple[str, str]] = []
|
|
monkeypatch.setattr(
|
|
flow,
|
|
"banner_panel",
|
|
lambda title, subtitle: banner_calls.append((title, subtitle)) or title,
|
|
)
|
|
gate_calls: list[str] = []
|
|
monkeypatch.setattr(flow, "_wait_for_setup_start", lambda: gate_calls.append("gate"))
|
|
|
|
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):
|
|
if message == "LLM provider":
|
|
# Pin OpenRouter explicitly: this test exercises scoped-section
|
|
# mechanics, not the provider choice, and the picker default is
|
|
# TokenRhythm (whose offline probe would add a "Save anyway?"
|
|
# prompt this walkthrough does not expect).
|
|
return _Answer("openrouter (OpenRouter)")
|
|
if message in {"Router mode", "Default text model"}:
|
|
choices = list(kwargs.get("choices") or [])
|
|
return _Answer(kwargs.get("default") or choices[0])
|
|
if message == "LLM API key source":
|
|
return _Answer("Paste API key now")
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
return _Answer("sk-scoped-rotation")
|
|
|
|
def text(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def confirm(self, message: str, **kwargs):
|
|
if message == "Edit router tier models now?":
|
|
return _Answer(False)
|
|
raise AssertionError(
|
|
f"a scoped section edit must not prompt: {message}"
|
|
)
|
|
|
|
def checkbox(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected checkbox prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
result = flow.run_interactive_configure("provider", config_path=target)
|
|
|
|
assert result is not None
|
|
assert banner_calls == [], "scoped edits must not re-print the first-run banner"
|
|
assert gate_calls == [], "scoped edits must not block on the Enter start gate"
|
|
saved = flow.load_config(target)
|
|
assert saved.llm.api_key == "sk-scoped-rotation"
|
|
|
|
|
|
def test_full_walk_folds_optional_section_restart_flag_into_result(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
"""An optional section's ``restart_required`` (e.g. memory embedding)
|
|
must reach the CLI's restart guidance: ``_run_optional_section`` used to
|
|
drop the runner's PersistResult, so the full walk returned only the
|
|
provider-stage result with ``restart_required=False``."""
|
|
|
|
import sys
|
|
import types
|
|
|
|
from opensquilla.onboarding import flow
|
|
from opensquilla.onboarding.config_store import PersistResult
|
|
|
|
target = tmp_path / "c.toml"
|
|
# Memory embedding action-required after the provider save (remote
|
|
# provider, no key), llm NOT configured so the walk runs linearly.
|
|
target.write_text('[memory.embedding]\nprovider = "openai"\n', encoding="utf-8")
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
monkeypatch.setattr(flow, "_wait_for_setup_start", lambda: None)
|
|
monkeypatch.setattr(flow, "detect_default_sources", lambda: [])
|
|
monkeypatch.setattr(flow, "_run_provider_probe", lambda **_kw: None)
|
|
|
|
embedding_runs: list[object] = []
|
|
|
|
def _fake_embedding_runner(*, config_path=None):
|
|
embedding_runs.append(config_path)
|
|
return PersistResult(
|
|
path=target,
|
|
backup_path=None,
|
|
restart_required=True,
|
|
warnings=["w-embedding"],
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
flow, "run_interactive_memory_embedding_configure", _fake_embedding_runner
|
|
)
|
|
|
|
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):
|
|
if message == "LLM provider":
|
|
# Pin OpenRouter explicitly: this test exercises scoped-section
|
|
# mechanics, not the provider choice, and the picker default is
|
|
# TokenRhythm (whose offline probe would add a "Save anyway?"
|
|
# prompt this walkthrough does not expect).
|
|
return _Answer("openrouter (OpenRouter)")
|
|
if message in {"Router mode", "Default text model"}:
|
|
choices = list(kwargs.get("choices") or [])
|
|
return _Answer(kwargs.get("default") or choices[0])
|
|
if message == "LLM API key source":
|
|
return _Answer("Paste API key now")
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
return _Answer("sk-full-walk")
|
|
|
|
def text(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def confirm(self, message: str, **kwargs):
|
|
if message == "Edit router tier models now?":
|
|
return _Answer(False)
|
|
if message in {
|
|
"Configure a messaging channel now?",
|
|
"Configure web search now?",
|
|
"Enable image generation now?",
|
|
}:
|
|
return _Answer(False)
|
|
if message == "Configure memory embeddings now?":
|
|
return _Answer(True)
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def checkbox(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected checkbox prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
result = flow.run_interactive_onboard(flow.OnboardOptions(config_path=target))
|
|
|
|
assert embedding_runs == [target]
|
|
assert result.restart_required is True, (
|
|
"the embedding section's restart flag must survive to the CLI boundary"
|
|
)
|
|
assert "w-embedding" in result.warnings
|
|
|
|
|
|
def test_interactive_configure_dispatches_short_image_and_memory_aliases(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
"""The CLI help advertises ``configure image`` / ``configure memory``;
|
|
the wizard dispatch must consume the setup engine's alias sets instead
|
|
of a hand-copied subset that dropped the short spellings and answered
|
|
"not yet supported"."""
|
|
|
|
from opensquilla.onboarding import flow
|
|
from opensquilla.onboarding.config_store import PersistResult
|
|
|
|
target = tmp_path / "custom.toml"
|
|
dispatched: list[str] = []
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
|
|
def _fake_runner(name):
|
|
def runner(config_path=None):
|
|
dispatched.append(name)
|
|
assert config_path == target
|
|
return PersistResult(
|
|
path=target,
|
|
backup_path=None,
|
|
restart_required=False,
|
|
warnings=[],
|
|
)
|
|
|
|
return runner
|
|
|
|
monkeypatch.setattr(
|
|
flow,
|
|
"run_interactive_image_generation_configure",
|
|
_fake_runner("image-generation"),
|
|
)
|
|
monkeypatch.setattr(
|
|
flow,
|
|
"run_interactive_memory_embedding_configure",
|
|
_fake_runner("memory-embedding"),
|
|
)
|
|
|
|
assert flow.run_interactive_configure("image", config_path=target) is not None
|
|
assert flow.run_interactive_configure("memory", config_path=target) is not None
|
|
assert dispatched == ["image-generation", "memory-embedding"]
|
|
|
|
|
|
class _RecordingConsole:
|
|
def __init__(self):
|
|
self.messages: list[str] = []
|
|
|
|
def print(self, message="", *_a, **_kw):
|
|
self.messages.append(str(message))
|
|
|
|
def joined(self) -> str:
|
|
return "\n".join(self.messages)
|
|
|
|
|
|
def test_interactive_configure_unknown_section_names_explicit_config_path(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
"""The unsupported-section notice must name the config file this run
|
|
would actually edit (the explicit ``--config`` path here), not a
|
|
hardcoded ``~/.opensquilla/config.toml``."""
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "custom.toml"
|
|
recorder = _RecordingConsole()
|
|
monkeypatch.setattr(flow, "console", recorder)
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
|
|
result = flow.run_interactive_configure(
|
|
"definitely-not-a-section", config_path=target
|
|
)
|
|
|
|
assert result is None
|
|
joined = recorder.joined()
|
|
assert str(target) in joined
|
|
assert "~/.opensquilla/config.toml" not in joined
|
|
|
|
|
|
def test_interactive_configure_unknown_section_honours_env_config_path(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "env-config.toml"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
recorder = _RecordingConsole()
|
|
monkeypatch.setattr(flow, "console", recorder)
|
|
monkeypatch.setattr(flow, "_is_tty", lambda: True)
|
|
|
|
result = flow.run_interactive_configure("definitely-not-a-section")
|
|
|
|
assert result is None
|
|
joined = recorder.joined()
|
|
assert str(target) in joined
|
|
assert "~/.opensquilla/config.toml" not in joined
|
|
|
|
|
|
def test_interactive_memory_embedding_configure_reports_mutation_restart_required(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
"""The embedding mutation reports ``restart_required`` when the setup
|
|
actually changed (embedding edits only take effect after a full gateway
|
|
restart); the interactive runner used to hardcode ``False`` over it."""
|
|
|
|
import sys
|
|
import types
|
|
|
|
from opensquilla.onboarding import flow
|
|
|
|
target = tmp_path / "c.toml"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-memory-env")
|
|
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):
|
|
if message == "Memory embedding provider":
|
|
return _Answer("openai (OpenAI)")
|
|
if message == "Memory API key source":
|
|
return _Answer("Use environment variable OPENAI_API_KEY (detected)")
|
|
raise AssertionError(f"unexpected select prompt: {message}")
|
|
|
|
def text(self, message: str, **kwargs):
|
|
if message in {"Memory embedding model", "Memory embedding base URL"}:
|
|
return _Answer(kwargs.get("default"))
|
|
raise AssertionError(f"unexpected text prompt: {message}")
|
|
|
|
def password(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected password prompt: {message}")
|
|
|
|
def confirm(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected confirm prompt: {message}")
|
|
|
|
def checkbox(self, message: str, **_kwargs):
|
|
raise AssertionError(f"unexpected checkbox prompt: {message}")
|
|
|
|
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
|
|
|
|
result = flow.run_interactive_memory_embedding_configure(config_path=target)
|
|
|
|
assert result.restart_required is True
|
|
|
|
|
|
def test_flow_config_cli_arg_is_powershell_safe_on_windows(monkeypatch):
|
|
"""flow's resume hints must quote --config with the shared platform-aware
|
|
helper: shlex's '"'"' escape is invalid PowerShell, while next_steps and
|
|
onboard status already print the PowerShell-quoted form of the same
|
|
command in the same session."""
|
|
|
|
from opensquilla.onboarding import flow, next_steps
|
|
|
|
monkeypatch.setattr(next_steps.platform, "system", lambda: "Windows")
|
|
|
|
arg = flow._config_cli_arg("C:\\Setup Files\\config.toml")
|
|
|
|
assert arg == " --config 'C:\\Setup Files\\config.toml'"
|
|
assert arg == next_steps._config_cli_arg("C:\\Setup Files\\config.toml")
|
|
|
|
quoted = flow._config_cli_arg("C:\\it's.toml")
|
|
assert quoted == " --config 'C:\\it''s.toml'"
|
|
assert '\'"\'"\'' not in quoted
|
|
|
|
|
|
def test_declared_questionary_floor_supports_use_search_filter():
|
|
"""The provider select passes ``use_search_filter=True`` — a questionary
|
|
2.1 kwarg that crashes 2.0.x installs at prompt construction with
|
|
``TypeError: PromptSession.__init__() got an unexpected keyword argument``.
|
|
The declared dependency floor must therefore exclude every 2.0.x release
|
|
the wizard cannot run on (dev/CI lockfiles mask this, downstream installs
|
|
resolving the floor do not)."""
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
from packaging.requirements import Requirement
|
|
|
|
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
|
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
requirement = next(
|
|
Requirement(dep)
|
|
for dep in data["project"]["dependencies"]
|
|
if Requirement(dep).name == "questionary"
|
|
)
|
|
|
|
assert not requirement.specifier.contains("2.0.1"), (
|
|
"questionary 2.0.x lacks use_search_filter; the floor must be >=2.1"
|
|
)
|
|
assert requirement.specifier.contains("2.1.0")
|