Files
2026-07-13 13:12:33 +08:00

709 lines
26 KiB
Python

"""The multi-level settings entry points: the `onboard configure` section hub
and the re-run fork in `opensquilla onboard`.
Contract under test:
- Bare `configure` on a TTY is a menu LOOP (edit a section, come back, Done
exits); an explicit section stays a one-shot edit.
- Cancelling inside a section returns to the menu; cancelling at the menu
itself raises like every other wizard prompt.
- Restart-required guidance survives multi-section sittings.
- A full interactive re-run over a configured install offers
update / change-sections / start-fresh, and start-fresh backs the config
file up instead of deleting it.
Everything is offline: prompts are faked at the questionary seam and section
runners are monkeypatched.
"""
from __future__ import annotations
import os
import sys
import types
from typing import Any
import pytest
from opensquilla.onboarding import flow
from opensquilla.onboarding.config_store import PersistResult, load_config, persist_config
from opensquilla.onboarding.errors import UserCancelledError
from opensquilla.onboarding.flow import OnboardOptions
from opensquilla.onboarding.mutations import upsert_llm_provider
class _Answer:
def __init__(self, value: Any) -> None:
self.value = value
def ask(self) -> Any:
return self.value
class _InterruptAnswer:
"""A prompt whose ``ask()`` raises like a raw Ctrl+C (no questionary
None-conversion)."""
def ask(self) -> Any:
raise KeyboardInterrupt
class _BaseQuestionary(types.SimpleNamespace):
def confirm(self, message: str, **_kwargs: Any) -> _Answer:
raise AssertionError(f"unexpected confirm prompt: {message}")
def select(self, message: str, **_kwargs: Any) -> _Answer:
raise AssertionError(f"unexpected select prompt: {message}")
def text(self, message: str, **_kwargs: Any) -> _Answer:
raise AssertionError(f"unexpected text prompt: {message}")
def password(self, message: str, **_kwargs: Any) -> _Answer:
raise AssertionError(f"unexpected password prompt: {message}")
def checkbox(self, message: str, **_kwargs: Any) -> _Answer:
raise AssertionError(f"unexpected checkbox prompt: {message}")
def _persist_result(target, *, restart_required=False, warnings=()):
return PersistResult(
path=target,
backup_path=None,
restart_required=restart_required,
warnings=list(warnings),
)
def _pick_done(titles: list[str]) -> str:
return next(t for t in titles if t in ("Done", "Exit (nothing changed)"))
def _seed_configured_install(target) -> None:
cfg = load_config(target)
res = upsert_llm_provider(
cfg,
provider_id="openrouter",
model="dummy/model",
api_key="sk-dummy-hub-test",
)
persist_config(res.config, path=target, restart_required=False, backup=False)
# --- the configure hub -----------------------------------------------------
def test_hub_loops_dispatching_multiple_sections_then_done(tmp_path, monkeypatch):
target = tmp_path / "c.toml"
monkeypatch.setattr(flow, "_is_tty", lambda: True)
dispatched: list[str] = []
def _fake_runner(name, *, restart_required=False):
def runner(config_path=None):
assert config_path == target
dispatched.append(name)
return _persist_result(target, restart_required=restart_required)
return runner
monkeypatch.setattr(
flow, "run_interactive_search_configure", _fake_runner("search")
)
monkeypatch.setattr(
flow,
"run_interactive_image_generation_configure",
_fake_runner("image-generation", restart_required=True),
)
menus: list[list[str]] = []
class _Questionary(_BaseQuestionary):
def select(self, message: str, **kwargs: Any) -> _Answer:
assert message == "Section"
titles = kwargs["choices"]
menus.append(titles)
if len(menus) == 1:
return _Answer(next(t for t in titles if t.startswith("Web search")))
if len(menus) == 2:
return _Answer(
next(t for t in titles if t.startswith("Image generation"))
)
return _Answer(_pick_done(titles))
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
result = flow.run_interactive_configure(config_path=target)
assert dispatched == ["search", "image-generation"]
assert len(menus) == 3, "the menu must reappear after each section"
assert result is not None
assert result.restart_required is True, "restart flag must stay sticky"
def test_hub_menu_titles_carry_live_status_words(tmp_path, monkeypatch):
target = tmp_path / "c.toml"
monkeypatch.setattr(flow, "_is_tty", lambda: True)
seen: dict[str, list[str]] = {}
class _Questionary(_BaseQuestionary):
def select(self, message: str, **kwargs: Any) -> _Answer:
seen["titles"] = kwargs["choices"]
return _Answer(_pick_done(kwargs["choices"]))
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
result = flow.run_interactive_configure(config_path=target)
assert result is None
titles = seen["titles"]
# Fresh install: provider setup is outstanding, search is a deliberate
# later. The exact words come from the shared SECTION_STATUS_DISPLAY map.
assert any(t.startswith("Provider — ") for t in titles)
assert "Provider — Missing" in titles
assert "Channels — Later" in titles
assert titles[-1] == "Exit (nothing changed)"
# Audio has no configure path and must not be offered as a menu entry.
assert not any(t.lower().startswith("voice audio") for t in titles)
assert not any(t.lower().startswith("audio") for t in titles)
def test_hub_section_cancel_returns_to_menu_instead_of_aborting(
tmp_path, monkeypatch
):
target = tmp_path / "c.toml"
monkeypatch.setattr(flow, "_is_tty", lambda: True)
attempts: list[str] = []
def _cancelling_runner(config_path=None):
attempts.append("search")
raise UserCancelledError("search")
monkeypatch.setattr(flow, "run_interactive_search_configure", _cancelling_runner)
menu_calls = {"n": 0}
class _Questionary(_BaseQuestionary):
def select(self, message: str, **kwargs: Any) -> _Answer:
menu_calls["n"] += 1
titles = kwargs["choices"]
if menu_calls["n"] == 1:
return _Answer(next(t for t in titles if t.startswith("Web search")))
return _Answer(_pick_done(titles))
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
result = flow.run_interactive_configure(config_path=target)
assert attempts == ["search"]
assert menu_calls["n"] == 2, "cancel inside a section must return to the menu"
assert result is None
def test_hub_menu_cancel_raises_user_cancelled(tmp_path, monkeypatch):
target = tmp_path / "c.toml"
monkeypatch.setattr(flow, "_is_tty", lambda: True)
class _Questionary(_BaseQuestionary):
def select(self, message: str, **kwargs: Any) -> _Answer:
return _Answer(None) # questionary returns None on Esc/Ctrl-C
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
with pytest.raises(UserCancelledError):
flow.run_interactive_configure(config_path=target)
def test_hub_section_keyboard_interrupt_returns_to_menu_instead_of_aborting(
tmp_path, monkeypatch
):
"""Ctrl+C inside a section is the same operator intent as Esc: leave the
section unchanged and come back to the menu. Letting the raw
KeyboardInterrupt escape used to abort the whole hub sitting."""
target = tmp_path / "c.toml"
monkeypatch.setattr(flow, "_is_tty", lambda: True)
def _interrupting_runner(config_path=None):
raise KeyboardInterrupt
monkeypatch.setattr(
flow, "run_interactive_search_configure", _interrupting_runner
)
menu_calls = {"n": 0}
class _Questionary(_BaseQuestionary):
def select(self, message: str, **kwargs: Any) -> _Answer:
menu_calls["n"] += 1
titles = kwargs["choices"]
if menu_calls["n"] == 1:
return _Answer(next(t for t in titles if t.startswith("Web search")))
return _Answer(_pick_done(titles))
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
result = flow.run_interactive_configure(config_path=target)
assert menu_calls["n"] == 2, "Ctrl+C inside a section must return to the menu"
assert result is None
def test_hub_menu_cancel_after_saved_sections_returns_the_aggregate(
tmp_path, monkeypatch
):
"""Esc at the menu AFTER a section persisted must exit like "Done": the
saved changes are on disk and their sticky restart guidance must reach
the CLI boundary instead of being discarded as "Setup cancelled"."""
target = tmp_path / "c.toml"
monkeypatch.setattr(flow, "_is_tty", lambda: True)
monkeypatch.setattr(
flow,
"run_interactive_search_configure",
lambda config_path=None: _persist_result(
target, restart_required=True, warnings=["w-search"]
),
)
menu_calls = {"n": 0}
class _Questionary(_BaseQuestionary):
def select(self, message: str, **kwargs: Any) -> _Answer:
menu_calls["n"] += 1
titles = kwargs["choices"]
if menu_calls["n"] == 1:
return _Answer(next(t for t in titles if t.startswith("Web search")))
return _Answer(None) # Esc at the menu — after a save
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
result = flow.run_interactive_configure(config_path=target)
assert result is not None, "a saved sitting must not be reported as cancelled"
assert result.restart_required is True
assert result.warnings == ["w-search"]
def test_hub_menu_keyboard_interrupt_after_saved_sections_returns_the_aggregate(
tmp_path, monkeypatch
):
target = tmp_path / "c.toml"
monkeypatch.setattr(flow, "_is_tty", lambda: True)
monkeypatch.setattr(
flow,
"run_interactive_search_configure",
lambda config_path=None: _persist_result(target, restart_required=True),
)
menu_calls = {"n": 0}
class _Questionary(_BaseQuestionary):
def select(self, message: str, **kwargs: Any) -> Any:
menu_calls["n"] += 1
titles = kwargs["choices"]
if menu_calls["n"] == 1:
return _Answer(next(t for t in titles if t.startswith("Web search")))
return _InterruptAnswer() # raw Ctrl+C at the menu — after a save
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
result = flow.run_interactive_configure(config_path=target)
assert result is not None
assert result.restart_required is True
def test_hub_merges_warnings_across_sections(tmp_path, monkeypatch):
target = tmp_path / "c.toml"
monkeypatch.setattr(flow, "_is_tty", lambda: True)
monkeypatch.setattr(
flow,
"run_interactive_search_configure",
lambda config_path=None: _persist_result(target, warnings=["w-search"]),
)
monkeypatch.setattr(
flow,
"run_interactive_image_generation_configure",
lambda config_path=None: _persist_result(target, warnings=["w-image"]),
)
step = {"n": 0}
class _Questionary(_BaseQuestionary):
def select(self, message: str, **kwargs: Any) -> _Answer:
step["n"] += 1
titles = kwargs["choices"]
if step["n"] == 1:
return _Answer(next(t for t in titles if t.startswith("Web search")))
if step["n"] == 2:
return _Answer(
next(t for t in titles if t.startswith("Image generation"))
)
return _Answer(_pick_done(titles))
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
result = flow.run_interactive_configure(config_path=target)
assert result is not None
assert result.warnings == ["w-search", "w-image"]
def test_explicit_section_stays_one_shot(tmp_path, monkeypatch):
target = tmp_path / "c.toml"
monkeypatch.setattr(flow, "_is_tty", lambda: True)
dispatched: list[str] = []
monkeypatch.setattr(
flow,
"run_interactive_search_configure",
lambda config_path=None: (dispatched.append("search"), _persist_result(target))[1],
)
class _Questionary(_BaseQuestionary):
pass # any menu render would raise via the base class
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
result = flow.run_interactive_configure("search", config_path=target)
assert dispatched == ["search"]
assert result is not None
# --- the onboard re-run fork -------------------------------------------------
def test_rerun_fork_routes_change_sections_to_the_hub(tmp_path, monkeypatch):
target = tmp_path / "c.toml"
_seed_configured_install(target)
monkeypatch.setattr(flow, "_is_tty", lambda: True)
monkeypatch.setattr(flow, "_wait_for_setup_start", lambda: None)
monkeypatch.setattr(flow, "_ensure_config_dir_writable", lambda _path: None)
hub_calls: list[Any] = []
sentinel = _persist_result(target, restart_required=True)
def _fake_hub(questionary, *, config_path=None):
hub_calls.append(config_path)
return sentinel
monkeypatch.setattr(flow, "_run_configure_hub", _fake_hub)
class _Questionary(_BaseQuestionary):
def select(self, message: str, **kwargs: Any) -> _Answer:
assert message.startswith("This install is already configured")
assert kwargs["default"] == flow._ONBOARD_UPDATE_CHOICE
return _Answer(flow._ONBOARD_SECTIONS_CHOICE)
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
result = flow.run_interactive_onboard(OnboardOptions(config_path=target))
assert hub_calls == [target]
assert result is sentinel
def test_rerun_fork_start_fresh_backs_up_config_and_restarts_walk(
tmp_path, monkeypatch
):
target = tmp_path / "c.toml"
_seed_configured_install(target)
monkeypatch.setattr(flow, "_is_tty", lambda: True)
monkeypatch.setattr(flow, "_wait_for_setup_start", lambda: None)
monkeypatch.setattr(flow, "_ensure_config_dir_writable", lambda _path: None)
monkeypatch.setattr(
flow, "_run_onboard_migration_step", lambda *_a, **_kw: None
)
walk_state: dict[str, Any] = {}
class _Questionary(_BaseQuestionary):
def select(self, message: str, **kwargs: Any) -> _Answer:
if message.startswith("This install is already configured"):
return _Answer(flow._ONBOARD_RESET_CHOICE)
if message == "LLM provider":
# The walk restarted from scratch; record that the reset
# really moved the config aside, then cancel to end the test.
walk_state["target_during_walk"] = target.exists()
walk_state["backups_during_walk"] = len(
list(tmp_path.glob("c.toml.backup.*"))
)
return _Answer(None)
raise AssertionError(f"unexpected select prompt: {message}")
def confirm(self, message: str, **kwargs: Any) -> _Answer:
assert message.startswith("Back up")
assert kwargs["default"] is False
return _Answer(True)
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
with pytest.raises(UserCancelledError):
flow.run_interactive_onboard(OnboardOptions(config_path=target))
# The fresh walk really did start over a reset config...
assert walk_state == {"target_during_walk": False, "backups_during_walk": 1}
# ...but cancelling it must restore the previous config, not leave the
# install unconfigured with only a dim backup notice (F10).
assert target.exists()
assert "sk-dummy-hub-test" in target.read_text()
assert list(tmp_path.glob("c.toml.backup.*")) == []
def test_restore_reset_backup_keeps_a_newly_persisted_config(tmp_path):
"""When the fresh walk already persisted a new config, a late cancel must
keep the operator's new answers — the restore only fills a hole, it never
clobbers a config the walk just wrote."""
target = tmp_path / "config.toml"
backup = tmp_path / "config.toml.backup.20260101T000000Z"
target.write_text("port = 28791\n") # the fresh walk's new config
backup.write_text("port = 18791\n") # the pre-reset original
flow._restore_reset_backup((target, backup))
assert target.read_text() == "port = 28791\n"
assert backup.read_text() == "port = 18791\n"
def test_start_fresh_pins_the_cwd_resolved_config_path(tmp_path, monkeypatch):
"""After the backup renames a cwd-resolved ./opensquilla.toml away, the
rest of the reset walk must keep operating on THAT file: a dynamic
re-resolve would fall through to the HOME config — seeding the "fresh"
walk from a stale file and overwriting one the confirmation never named."""
proj = tmp_path / "proj"
proj.mkdir()
proj_config = proj / "opensquilla.toml"
_seed_configured_install(proj_config)
original_project_config = proj_config.read_text()
home = tmp_path / "home-state"
home.mkdir()
stale_home_config = home / "config.toml"
stale_home_config.write_text(
'[llm]\nprovider = "openai"\nmodel = "stale-home-model"\n'
'api_key = "sk-stale-home"\n'
)
stale_raw = stale_home_config.read_text()
monkeypatch.chdir(proj)
monkeypatch.delenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", raising=False)
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
monkeypatch.setattr(flow, "_is_tty", lambda: True)
monkeypatch.setattr(flow, "_wait_for_setup_start", lambda: None)
monkeypatch.setattr(flow, "_ensure_config_dir_writable", lambda _path: None)
walk_seen: dict[str, Any] = {}
def _fake_walk(questionary, cfg, status, options, *, config_path):
walk_seen["config_path"] = config_path
walk_seen["options_path"] = options.config_path
walk_seen["seed_model"] = str(cfg.llm.model)
walk_seen["seed_key"] = str(cfg.llm.api_key)
raise UserCancelledError(section="provider")
monkeypatch.setattr(flow, "_run_onboard_walk", _fake_walk)
class _Questionary(_BaseQuestionary):
def select(self, message: str, **kwargs: Any) -> _Answer:
assert message.startswith("This install is already configured")
return _Answer(flow._ONBOARD_RESET_CHOICE)
def confirm(self, message: str, **kwargs: Any) -> _Answer:
assert message.startswith("Back up")
# The confirmation must name the cwd-resolved project file.
assert str(proj_config) in message
return _Answer(True)
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
with pytest.raises(UserCancelledError):
flow.run_interactive_onboard(OnboardOptions())
# The walk was pinned to the pre-backup path, seeded from defaults.
assert walk_seen["config_path"] == proj_config
assert walk_seen["options_path"] == proj_config
assert walk_seen["seed_key"] == ""
assert walk_seen["seed_model"] != "stale-home-model"
# Cancel restored the project config; the home config was never touched.
assert proj_config.read_text() == original_project_config
assert stale_home_config.read_text() == stale_raw
assert list(proj.glob("opensquilla.toml.backup.*")) == []
def test_onboard_fork_hub_exit_without_changes_does_not_rewrite_config(
tmp_path, monkeypatch
):
"""`opensquilla onboard` -> "Change specific sections" -> "Exit (nothing
changed)" is an explicit no-op: it must not persist_config over a
hand-maintained config.toml (stripping comments, normalizing key order,
bumping the mtime, forcing mode 0600)."""
target = tmp_path / "c.toml"
raw = (
"# operator-managed: keep my comments\n"
"[llm]\n"
'provider = "openrouter"\n'
'model = "dummy/model"\n'
'api_key = "sk-dummy-hub-test"\n'
)
target.write_text(raw)
target.chmod(0o644)
before = target.stat()
monkeypatch.setattr(flow, "_is_tty", lambda: True)
monkeypatch.setattr(flow, "_wait_for_setup_start", lambda: None)
monkeypatch.setattr(flow, "_ensure_config_dir_writable", lambda _path: None)
class _Questionary(_BaseQuestionary):
def select(self, message: str, **kwargs: Any) -> _Answer:
if message.startswith("This install is already configured"):
return _Answer(flow._ONBOARD_SECTIONS_CHOICE)
if message == "Section":
return _Answer("Exit (nothing changed)")
raise AssertionError(f"unexpected select prompt: {message}")
monkeypatch.setitem(sys.modules, "questionary", _Questionary())
result = flow.run_interactive_onboard(OnboardOptions(config_path=target))
after = target.stat()
assert target.read_text() == raw, "a no-change exit must not rewrite the file"
assert after.st_ino == before.st_ino
assert after.st_mtime_ns == before.st_mtime_ns
if os.name != "nt":
assert (after.st_mode & 0o777) == 0o644
assert result.path == target
assert result.backup_path is None
assert result.restart_required is False
def test_start_fresh_backs_up_symlink_target_without_replacing_link(tmp_path):
real = tmp_path / "real-config.toml"
link = tmp_path / "config.toml"
real.write_text("port = 18791\n")
link.symlink_to(real)
flow._backup_and_reset_config(link)
assert link.is_symlink()
assert not real.exists()
backups = list(tmp_path.glob("real-config.toml.backup.*"))
assert len(backups) == 1
assert backups[0].read_text() == "port = 18791\n"
def test_start_fresh_backup_uses_the_shared_backup_naming_scheme(tmp_path):
"""Reset backups must come from the shared collision-safe helper so the
state dir carries ONE backup naming scheme: the ``config.toml.backup.*``
pattern that backup-aware tooling (e.g. the uninstall purge inventory)
already enumerates. The old hand-rolled ``.bak-<stamp>`` files escaped
that tooling while still holding secrets."""
target = tmp_path / "config.toml"
target.write_text('api_key = "sk-dummy-reset"\n')
result = flow._backup_and_reset_config(target)
assert result is not None
original, backup = result
assert original == target
assert not target.exists()
assert backup.parent == tmp_path
assert backup.name.startswith("config.toml.backup.")
assert backup.read_text() == 'api_key = "sk-dummy-reset"\n'
# No stray files under the retired hand-rolled scheme.
assert list(tmp_path.glob("config.toml.bak-*")) == []
# POSIX mode 0600: the backup holds the same secrets as the config it replaces.
if os.name != "nt":
assert (backup.stat().st_mode & 0o777) == 0o600
def test_rerun_fork_decline_reset_falls_back_to_update(tmp_path, monkeypatch):
target = tmp_path / "c.toml"
_seed_configured_install(target)
cfg = load_config(target)
status = flow.get_onboarding_status(cfg)
class _Questionary(_BaseQuestionary):
def select(self, message: str, **kwargs: Any) -> _Answer:
return _Answer(flow._ONBOARD_RESET_CHOICE)
def confirm(self, message: str, **kwargs: Any) -> _Answer:
return _Answer(False)
action = flow._ask_existing_setup_action(
_Questionary(), cfg, status, OnboardOptions(config_path=target)
)
assert action == "update"
assert target.exists()
@pytest.mark.parametrize(
"options",
[
OnboardOptions(skip_migration=True),
OnboardOptions(skip_channels=True),
OnboardOptions(skip_search=True),
OnboardOptions(skip_image_generation=True),
OnboardOptions(minimal=True),
OnboardOptions(if_needed=True),
OnboardOptions(provider_id="openrouter"),
OnboardOptions(api_key="sk-headless"),
# An explicit --router is headless-shaped: routing it into the fork
# would let the "sections" path silently drop the requested mode.
OnboardOptions(router_mode="disabled"),
OnboardOptions(router_mode="recommended"),
OnboardOptions(scoped_section=True),
],
)
def test_rerun_fork_not_offered_for_scoped_or_headless_runs(
tmp_path, monkeypatch, options
):
target = tmp_path / "c.toml"
_seed_configured_install(target)
cfg = load_config(target)
status = flow.get_onboarding_status(cfg)
options = OnboardOptions(
**{**options.__dict__, "config_path": target}
)
action = flow._ask_existing_setup_action(
_BaseQuestionary(), cfg, status, options
)
assert action is None
def test_rerun_fork_gate_scopes_every_non_default_option_field():
"""The gate compares field-by-field against OnboardOptions() so a future
field cannot drift out of the check unnoticed: ANY non-default value
outside the reviewed allowlist must scope the run away from the fork."""
from dataclasses import fields, replace
defaults = OnboardOptions()
assert flow._is_full_interactive_rerun(defaults) is True
# config_path is the one reviewed exception: an explicit --config targets
# a different file, not a different walk.
assert flow._FORK_COMPATIBLE_OPTION_FIELDS == frozenset({"config_path"})
assert flow._is_full_interactive_rerun(
replace(defaults, config_path="/tmp/other.toml")
) is True
for fld in fields(OnboardOptions):
if fld.name in flow._FORK_COMPATIBLE_OPTION_FIELDS:
continue
default_value = getattr(defaults, fld.name)
if default_value is False:
non_default: Any = True
elif default_value is None:
non_default = "non-default-sample"
else:
raise AssertionError(
f"OnboardOptions.{fld.name} has an unhandled default shape "
f"{default_value!r}; teach this drift test its non-default sample"
)
assert not flow._is_full_interactive_rerun(
replace(defaults, **{fld.name: non_default})
), f"non-default {fld.name} must scope the run out of the re-run fork"
def test_rerun_fork_not_offered_for_fresh_or_unfinished_installs(tmp_path):
target = tmp_path / "c.toml"
cfg = load_config(target)
status = flow.get_onboarding_status(cfg)
action = flow._ask_existing_setup_action(
_BaseQuestionary(), cfg, status, OnboardOptions(config_path=target)
)
assert action is None