hermes-agent/tests/hermes_cli/test_setup_openclaw_migration.py
Teknium 3eade90b39
fix: OpenClaw migration now shows dry-run preview before executing (#6769)
The setup wizard's OpenClaw migration previously ran immediately with
aggressive defaults (overwrite=True, preset=full) after a single
'Would you like to import?' prompt. This caused several problems:

- Config values with different semantics (e.g. tool_call_execution:
  'auto' in OpenClaw vs 'off' for Hermes yolo mode) were imported
  without translation
- Gateway tokens were hijacked from OpenClaw without warning, taking
  over Telegram/Slack/Discord channels
- Instruction files (.md) containing OpenClaw-specific setup/restart
  procedures were copied, causing Hermes restart failures

Now the migration:
1. Asks 'Would you like to see what can be imported?' (softer framing)
2. Runs a dry-run preview showing everything that would be imported
3. Displays categorized warnings for high-impact items (gateway
   takeover, config value differences, instruction files)
4. Asks for explicit confirmation with default=No
5. Executes with overwrite=False (preserves existing Hermes config)

Also extracts _load_openclaw_migration_module() for reuse and adds
_print_migration_preview() with keyword-based warning detection.

Tests updated for two-phase behavior + new test for decline-after-preview.
2026-04-09 12:15:06 -07:00

538 lines
23 KiB
Python

"""Tests for OpenClaw migration integration in the setup wizard."""
from argparse import Namespace
from types import ModuleType
from unittest.mock import MagicMock, patch
from hermes_cli import setup as setup_mod
# ---------------------------------------------------------------------------
# _offer_openclaw_migration — unit tests
# ---------------------------------------------------------------------------
class TestOfferOpenclawMigration:
"""Test the _offer_openclaw_migration helper in isolation."""
def test_skips_when_no_openclaw_dir(self, tmp_path):
"""Should return False immediately when ~/.openclaw does not exist."""
with patch("hermes_cli.setup.Path.home", return_value=tmp_path):
assert setup_mod._offer_openclaw_migration(tmp_path / ".hermes") is False
def test_skips_when_migration_script_missing(self, tmp_path):
"""Should return False when the migration script file is absent."""
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
with (
patch("hermes_cli.setup.Path.home", return_value=tmp_path),
patch.object(setup_mod, "_OPENCLAW_SCRIPT", tmp_path / "nonexistent.py"),
):
assert setup_mod._offer_openclaw_migration(tmp_path / ".hermes") is False
def test_skips_when_user_declines(self, tmp_path):
"""Should return False when user declines the migration prompt."""
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
script = tmp_path / "openclaw_to_hermes.py"
script.write_text("# placeholder")
with (
patch("hermes_cli.setup.Path.home", return_value=tmp_path),
patch.object(setup_mod, "_OPENCLAW_SCRIPT", script),
patch.object(setup_mod, "prompt_yes_no", return_value=False),
):
assert setup_mod._offer_openclaw_migration(tmp_path / ".hermes") is False
def test_runs_migration_when_user_accepts(self, tmp_path):
"""Should run dry-run preview first, then execute after confirmation."""
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
# Create a fake hermes home with config
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text("agent:\n max_turns: 90\n")
# Build a fake migration module
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value={"soul", "memory"})
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 3, "skipped": 1, "conflict": 0, "error": 0},
"items": [{"kind": "config", "status": "migrated", "destination": "/tmp/x"}],
"output_dir": str(hermes_home / "migration"),
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
script = tmp_path / "openclaw_to_hermes.py"
script.write_text("# placeholder")
with (
patch("hermes_cli.setup.Path.home", return_value=tmp_path),
patch.object(setup_mod, "_OPENCLAW_SCRIPT", script),
# Both prompts answered Yes: preview offer + proceed confirmation
patch.object(setup_mod, "prompt_yes_no", return_value=True),
patch.object(setup_mod, "get_config_path", return_value=config_path),
patch("importlib.util.spec_from_file_location") as mock_spec_fn,
):
# Wire up the fake module loading
mock_spec = MagicMock()
mock_spec.loader = MagicMock()
mock_spec_fn.return_value = mock_spec
def exec_module(mod):
mod.resolve_selected_options = fake_mod.resolve_selected_options
mod.Migrator = fake_mod.Migrator
mock_spec.loader.exec_module = exec_module
result = setup_mod._offer_openclaw_migration(hermes_home)
assert result is True
fake_mod.resolve_selected_options.assert_called_once_with(
None, None, preset="full"
)
# Migrator called twice: once for dry-run preview, once for execution
assert fake_mod.Migrator.call_count == 2
# First call: dry-run preview (execute=False, overwrite=True to show all)
preview_kwargs = fake_mod.Migrator.call_args_list[0][1]
assert preview_kwargs["execute"] is False
assert preview_kwargs["overwrite"] is True
assert preview_kwargs["migrate_secrets"] is True
assert preview_kwargs["preset_name"] == "full"
# Second call: actual execution (execute=True, overwrite=False to preserve)
exec_kwargs = fake_mod.Migrator.call_args_list[1][1]
assert exec_kwargs["execute"] is True
assert exec_kwargs["overwrite"] is False
assert exec_kwargs["migrate_secrets"] is True
assert exec_kwargs["preset_name"] == "full"
# migrate() called twice (once per Migrator instance)
assert fake_migrator.migrate.call_count == 2
def test_user_declines_after_preview(self, tmp_path):
"""Should return False when user sees preview but declines to proceed."""
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text("agent:\n max_turns: 90\n")
fake_mod = ModuleType("openclaw_to_hermes")
fake_mod.resolve_selected_options = MagicMock(return_value={"soul", "memory"})
fake_migrator = MagicMock()
fake_migrator.migrate.return_value = {
"summary": {"migrated": 3, "skipped": 0, "conflict": 0, "error": 0},
"items": [{"kind": "config", "status": "migrated", "destination": "/tmp/x"}],
}
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
script = tmp_path / "openclaw_to_hermes.py"
script.write_text("# placeholder")
# First prompt (preview): Yes, Second prompt (proceed): No
prompt_responses = iter([True, False])
with (
patch("hermes_cli.setup.Path.home", return_value=tmp_path),
patch.object(setup_mod, "_OPENCLAW_SCRIPT", script),
patch.object(setup_mod, "prompt_yes_no", side_effect=prompt_responses),
patch.object(setup_mod, "get_config_path", return_value=config_path),
patch("importlib.util.spec_from_file_location") as mock_spec_fn,
):
mock_spec = MagicMock()
mock_spec.loader = MagicMock()
mock_spec_fn.return_value = mock_spec
def exec_module(mod):
mod.resolve_selected_options = fake_mod.resolve_selected_options
mod.Migrator = fake_mod.Migrator
mock_spec.loader.exec_module = exec_module
result = setup_mod._offer_openclaw_migration(hermes_home)
assert result is False
# Only dry-run Migrator was created, not the execute one
assert fake_mod.Migrator.call_count == 1
preview_kwargs = fake_mod.Migrator.call_args[1]
assert preview_kwargs["execute"] is False
def test_handles_migration_error_gracefully(self, tmp_path):
"""Should catch exceptions and return False."""
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text("")
script = tmp_path / "openclaw_to_hermes.py"
script.write_text("# placeholder")
with (
patch("hermes_cli.setup.Path.home", return_value=tmp_path),
patch.object(setup_mod, "_OPENCLAW_SCRIPT", script),
patch.object(setup_mod, "prompt_yes_no", return_value=True),
patch.object(setup_mod, "get_config_path", return_value=config_path),
patch(
"importlib.util.spec_from_file_location",
side_effect=RuntimeError("boom"),
),
):
result = setup_mod._offer_openclaw_migration(hermes_home)
assert result is False
def test_creates_config_if_missing(self, tmp_path):
"""Should bootstrap config.yaml before running migration."""
openclaw_dir = tmp_path / ".openclaw"
openclaw_dir.mkdir()
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
# config does NOT exist yet
script = tmp_path / "openclaw_to_hermes.py"
script.write_text("# placeholder")
with (
patch("hermes_cli.setup.Path.home", return_value=tmp_path),
patch.object(setup_mod, "_OPENCLAW_SCRIPT", script),
patch.object(setup_mod, "prompt_yes_no", return_value=True),
patch.object(setup_mod, "get_config_path", return_value=config_path),
patch.object(setup_mod, "load_config", return_value={"agent": {}}),
patch.object(setup_mod, "save_config") as mock_save,
patch(
"importlib.util.spec_from_file_location",
side_effect=RuntimeError("stop early"),
),
):
setup_mod._offer_openclaw_migration(hermes_home)
# save_config should have been called to bootstrap the file
mock_save.assert_called_once_with({"agent": {}})
# ---------------------------------------------------------------------------
# Integration with run_setup_wizard — first-time flow
# ---------------------------------------------------------------------------
def _first_time_args() -> Namespace:
return Namespace(
section=None,
non_interactive=False,
reset=False,
)
class TestSetupWizardOpenclawIntegration:
"""Verify _offer_openclaw_migration is called during first-time setup."""
def test_migration_offered_during_first_time_setup(self, tmp_path):
"""On first-time setup, _offer_openclaw_migration should be called."""
args = _first_time_args()
with (
patch.object(setup_mod, "ensure_hermes_home"),
patch.object(setup_mod, "load_config", return_value={}),
patch.object(setup_mod, "get_hermes_home", return_value=tmp_path),
patch.object(setup_mod, "get_env_value", return_value=""),
patch.object(setup_mod, "is_interactive_stdin", return_value=True),
patch("hermes_cli.auth.get_active_provider", return_value=None),
# User presses Enter to start
patch("builtins.input", return_value=""),
# Select "Full setup" (index 1) so we exercise the full path
patch.object(setup_mod, "prompt_choice", return_value=1),
# Mock the migration offer
patch.object(
setup_mod, "_offer_openclaw_migration", return_value=False
) as mock_migration,
# Mock the actual setup sections so they don't run
patch.object(setup_mod, "setup_model_provider"),
patch.object(setup_mod, "setup_terminal_backend"),
patch.object(setup_mod, "setup_agent_settings"),
patch.object(setup_mod, "setup_gateway"),
patch.object(setup_mod, "setup_tools"),
patch.object(setup_mod, "save_config"),
patch.object(setup_mod, "_print_setup_summary"),
patch.object(setup_mod, "_offer_launch_chat"),
):
setup_mod.run_setup_wizard(args)
mock_migration.assert_called_once_with(tmp_path)
def test_migration_reloads_config_on_success(self, tmp_path):
"""When migration returns True, config should be reloaded."""
args = _first_time_args()
call_order = []
def tracking_load_config():
call_order.append("load_config")
return {}
with (
patch.object(setup_mod, "ensure_hermes_home"),
patch.object(setup_mod, "load_config", side_effect=tracking_load_config),
patch.object(setup_mod, "get_hermes_home", return_value=tmp_path),
patch.object(setup_mod, "get_env_value", return_value=""),
patch.object(setup_mod, "is_interactive_stdin", return_value=True),
patch("hermes_cli.auth.get_active_provider", return_value=None),
patch("builtins.input", return_value=""),
patch.object(setup_mod, "prompt_choice", return_value=1),
patch.object(setup_mod, "_offer_openclaw_migration", return_value=True),
patch.object(setup_mod, "setup_model_provider"),
patch.object(setup_mod, "setup_terminal_backend"),
patch.object(setup_mod, "setup_agent_settings"),
patch.object(setup_mod, "setup_gateway"),
patch.object(setup_mod, "setup_tools"),
patch.object(setup_mod, "save_config"),
patch.object(setup_mod, "_print_setup_summary"),
patch.object(setup_mod, "_offer_launch_chat"),
):
setup_mod.run_setup_wizard(args)
# load_config called twice: once at start, once after migration
assert call_order.count("load_config") == 2
def test_reloaded_config_flows_into_remaining_setup_sections(self, tmp_path):
args = _first_time_args()
initial_config = {}
reloaded_config = {"model": {"provider": "openrouter"}}
with (
patch.object(setup_mod, "ensure_hermes_home"),
patch.object(
setup_mod,
"load_config",
side_effect=[initial_config, reloaded_config],
),
patch.object(setup_mod, "get_hermes_home", return_value=tmp_path),
patch.object(setup_mod, "get_env_value", return_value=""),
patch.object(setup_mod, "is_interactive_stdin", return_value=True),
patch("hermes_cli.auth.get_active_provider", return_value=None),
patch("builtins.input", return_value=""),
patch.object(setup_mod, "prompt_choice", return_value=1),
patch.object(setup_mod, "_offer_openclaw_migration", return_value=True),
patch.object(setup_mod, "setup_model_provider") as setup_model_provider,
patch.object(setup_mod, "setup_terminal_backend"),
patch.object(setup_mod, "setup_agent_settings"),
patch.object(setup_mod, "setup_gateway"),
patch.object(setup_mod, "setup_tools"),
patch.object(setup_mod, "save_config"),
patch.object(setup_mod, "_print_setup_summary"),
patch.object(setup_mod, "_offer_launch_chat"),
):
setup_mod.run_setup_wizard(args)
setup_model_provider.assert_called_once_with(reloaded_config)
def test_migration_not_offered_for_existing_install(self, tmp_path):
"""Returning users should not see the migration prompt."""
args = _first_time_args()
with (
patch.object(setup_mod, "ensure_hermes_home"),
patch.object(setup_mod, "load_config", return_value={}),
patch.object(setup_mod, "get_hermes_home", return_value=tmp_path),
patch.object(
setup_mod,
"get_env_value",
side_effect=lambda k: "sk-xxx" if k == "OPENROUTER_API_KEY" else "",
),
patch("hermes_cli.auth.get_active_provider", return_value=None),
# Returning user picks "Exit"
patch.object(setup_mod, "prompt_choice", return_value=9),
patch.object(
setup_mod, "_offer_openclaw_migration", return_value=False
) as mock_migration,
):
setup_mod.run_setup_wizard(args)
mock_migration.assert_not_called()
# ---------------------------------------------------------------------------
# _get_section_config_summary / _skip_configured_section — unit tests
# ---------------------------------------------------------------------------
class TestGetSectionConfigSummary:
"""Test the _get_section_config_summary helper."""
def test_model_returns_none_without_api_key(self):
with patch.object(setup_mod, "get_env_value", return_value=""):
result = setup_mod._get_section_config_summary({}, "model")
assert result is None
def test_model_returns_summary_with_api_key(self):
def env_side(key):
return "sk-xxx" if key == "OPENROUTER_API_KEY" else ""
with patch.object(setup_mod, "get_env_value", side_effect=env_side):
result = setup_mod._get_section_config_summary(
{"model": "openai/gpt-4"}, "model"
)
assert result == "openai/gpt-4"
def test_model_returns_dict_default_key(self):
def env_side(key):
return "sk-xxx" if key == "OPENAI_API_KEY" else ""
with patch.object(setup_mod, "get_env_value", side_effect=env_side):
result = setup_mod._get_section_config_summary(
{"model": {"default": "claude-opus-4", "provider": "anthropic"}},
"model",
)
assert result == "claude-opus-4"
def test_terminal_always_returns(self):
with patch.object(setup_mod, "get_env_value", return_value=""):
result = setup_mod._get_section_config_summary(
{"terminal": {"backend": "docker"}}, "terminal"
)
assert result == "backend: docker"
def test_agent_always_returns(self):
with patch.object(setup_mod, "get_env_value", return_value=""):
result = setup_mod._get_section_config_summary(
{"agent": {"max_turns": 120}}, "agent"
)
assert result == "max turns: 120"
def test_gateway_returns_none_without_tokens(self):
with patch.object(setup_mod, "get_env_value", return_value=""):
result = setup_mod._get_section_config_summary({}, "gateway")
assert result is None
def test_gateway_lists_platforms(self):
def env_side(key):
if key == "TELEGRAM_BOT_TOKEN":
return "tok123"
if key == "DISCORD_BOT_TOKEN":
return "disc456"
return ""
with patch.object(setup_mod, "get_env_value", side_effect=env_side):
result = setup_mod._get_section_config_summary({}, "gateway")
assert "Telegram" in result
assert "Discord" in result
def test_tools_returns_none_without_keys(self):
with patch.object(setup_mod, "get_env_value", return_value=""):
result = setup_mod._get_section_config_summary({}, "tools")
assert result is None
def test_tools_lists_configured(self):
def env_side(key):
return "key" if key == "BROWSERBASE_API_KEY" else ""
with patch.object(setup_mod, "get_env_value", side_effect=env_side):
result = setup_mod._get_section_config_summary({}, "tools")
assert "Browser" in result
class TestSkipConfiguredSection:
"""Test the _skip_configured_section helper."""
def test_returns_false_when_not_configured(self):
with patch.object(setup_mod, "get_env_value", return_value=""):
result = setup_mod._skip_configured_section({}, "model", "Model")
assert result is False
def test_returns_true_when_user_skips(self):
def env_side(key):
return "sk-xxx" if key == "OPENROUTER_API_KEY" else ""
with (
patch.object(setup_mod, "get_env_value", side_effect=env_side),
patch.object(setup_mod, "prompt_yes_no", return_value=False),
):
result = setup_mod._skip_configured_section(
{"model": "openai/gpt-4"}, "model", "Model"
)
assert result is True
def test_returns_false_when_user_wants_reconfig(self):
def env_side(key):
return "sk-xxx" if key == "OPENROUTER_API_KEY" else ""
with (
patch.object(setup_mod, "get_env_value", side_effect=env_side),
patch.object(setup_mod, "prompt_yes_no", return_value=True),
):
result = setup_mod._skip_configured_section(
{"model": "openai/gpt-4"}, "model", "Model"
)
assert result is False
class TestSetupWizardSkipsConfiguredSections:
"""After migration, already-configured sections should offer skip."""
def test_sections_skipped_when_migration_imported_settings(self, tmp_path):
"""When migration ran and API key exists, model section should be skippable.
Simulates the real flow: get_env_value returns "" during the is_existing
check (before migration), then returns a key after migration imported it.
"""
args = _first_time_args()
# Track whether migration has "run" — after it does, API key is available
migration_done = {"value": False}
def env_side(key):
if migration_done["value"] and key == "OPENROUTER_API_KEY":
return "sk-xxx"
return ""
def fake_migration(hermes_home):
migration_done["value"] = True
return True
reloaded_config = {"model": "openai/gpt-4"}
with (
patch.object(setup_mod, "ensure_hermes_home"),
patch.object(
setup_mod, "load_config",
side_effect=[{}, reloaded_config],
),
patch.object(setup_mod, "get_hermes_home", return_value=tmp_path),
patch.object(setup_mod, "get_env_value", side_effect=env_side),
patch.object(setup_mod, "is_interactive_stdin", return_value=True),
patch("hermes_cli.auth.get_active_provider", return_value=None),
patch("builtins.input", return_value=""),
patch.object(setup_mod, "prompt_choice", return_value=1),
# Migration succeeds and flips the env_side flag
patch.object(
setup_mod, "_offer_openclaw_migration",
side_effect=fake_migration,
),
# User says No to all reconfig prompts
patch.object(setup_mod, "prompt_yes_no", return_value=False),
patch.object(setup_mod, "setup_model_provider") as mock_model,
patch.object(setup_mod, "setup_terminal_backend") as mock_terminal,
patch.object(setup_mod, "setup_agent_settings") as mock_agent,
patch.object(setup_mod, "setup_gateway") as mock_gateway,
patch.object(setup_mod, "setup_tools") as mock_tools,
patch.object(setup_mod, "save_config"),
patch.object(setup_mod, "_print_setup_summary"),
):
setup_mod.run_setup_wizard(args)
# Model has API key → skip offered, user said No → section NOT called
mock_model.assert_not_called()
# Terminal/agent always have a summary → skip offered, user said No
mock_terminal.assert_not_called()
mock_agent.assert_not_called()
# Gateway has no tokens (env_side returns "" for gateway keys) → section runs
mock_gateway.assert_called_once()
# Tools have no keys → section runs
mock_tools.assert_called_once()