After migrating from OpenClaw, leftover workspace directories contain state files (todo.json, sessions, logs) that confuse the agent — it discovers them and reads/writes to stale locations instead of the Hermes state directory, causing issues like cron jobs reading a different todo list than interactive sessions. Changes: - hermes claw migrate now offers to archive the source directory after successful migration (rename to .pre-migration, not delete) - New `hermes claw cleanup` subcommand for users who already migrated and need to archive leftover OpenClaw directories - Migration notes updated with explicit cleanup guidance - 42 tests covering all new functionality Reported by SteveSkedasticity — multiple todo.json files across ~/.hermes/, ~/.openclaw/workspace/, and ~/.openclaw/workspace-assistant/ caused cron jobs to read from wrong locations.
703 lines
27 KiB
Python
703 lines
27 KiB
Python
"""Tests for hermes claw commands."""
|
|
|
|
from argparse import Namespace
|
|
from types import ModuleType
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import claw as claw_mod
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _find_migration_script
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFindMigrationScript:
|
|
"""Test script discovery in known locations."""
|
|
|
|
def test_finds_project_root_script(self, tmp_path):
|
|
script = tmp_path / "openclaw_to_hermes.py"
|
|
script.write_text("# placeholder")
|
|
with patch.object(claw_mod, "_OPENCLAW_SCRIPT", script):
|
|
assert claw_mod._find_migration_script() == script
|
|
|
|
def test_finds_installed_script(self, tmp_path):
|
|
installed = tmp_path / "installed.py"
|
|
installed.write_text("# placeholder")
|
|
with (
|
|
patch.object(claw_mod, "_OPENCLAW_SCRIPT", tmp_path / "nonexistent.py"),
|
|
patch.object(claw_mod, "_OPENCLAW_SCRIPT_INSTALLED", installed),
|
|
):
|
|
assert claw_mod._find_migration_script() == installed
|
|
|
|
def test_returns_none_when_missing(self, tmp_path):
|
|
with (
|
|
patch.object(claw_mod, "_OPENCLAW_SCRIPT", tmp_path / "a.py"),
|
|
patch.object(claw_mod, "_OPENCLAW_SCRIPT_INSTALLED", tmp_path / "b.py"),
|
|
):
|
|
assert claw_mod._find_migration_script() is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _find_openclaw_dirs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFindOpenclawDirs:
|
|
"""Test discovery of OpenClaw directories."""
|
|
|
|
def test_finds_openclaw_dir(self, tmp_path):
|
|
openclaw = tmp_path / ".openclaw"
|
|
openclaw.mkdir()
|
|
with patch("pathlib.Path.home", return_value=tmp_path):
|
|
found = claw_mod._find_openclaw_dirs()
|
|
assert openclaw in found
|
|
|
|
def test_finds_legacy_dirs(self, tmp_path):
|
|
clawdbot = tmp_path / ".clawdbot"
|
|
clawdbot.mkdir()
|
|
moldbot = tmp_path / ".moldbot"
|
|
moldbot.mkdir()
|
|
with patch("pathlib.Path.home", return_value=tmp_path):
|
|
found = claw_mod._find_openclaw_dirs()
|
|
assert len(found) == 2
|
|
assert clawdbot in found
|
|
assert moldbot in found
|
|
|
|
def test_returns_empty_when_none_exist(self, tmp_path):
|
|
with patch("pathlib.Path.home", return_value=tmp_path):
|
|
found = claw_mod._find_openclaw_dirs()
|
|
assert found == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _scan_workspace_state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestScanWorkspaceState:
|
|
"""Test scanning for workspace state files."""
|
|
|
|
def test_finds_root_state_files(self, tmp_path):
|
|
(tmp_path / "todo.json").write_text("{}")
|
|
(tmp_path / "sessions").mkdir()
|
|
findings = claw_mod._scan_workspace_state(tmp_path)
|
|
descs = [desc for _, desc in findings]
|
|
assert any("todo.json" in d for d in descs)
|
|
assert any("sessions" in d for d in descs)
|
|
|
|
def test_finds_workspace_state_files(self, tmp_path):
|
|
ws = tmp_path / "workspace"
|
|
ws.mkdir()
|
|
(ws / "todo.json").write_text("{}")
|
|
(ws / "sessions").mkdir()
|
|
findings = claw_mod._scan_workspace_state(tmp_path)
|
|
descs = [desc for _, desc in findings]
|
|
assert any("workspace/todo.json" in d for d in descs)
|
|
assert any("workspace/sessions" in d for d in descs)
|
|
|
|
def test_ignores_hidden_dirs(self, tmp_path):
|
|
scan_dir = tmp_path / "scan_target"
|
|
scan_dir.mkdir()
|
|
hidden = scan_dir / ".git"
|
|
hidden.mkdir()
|
|
(hidden / "todo.json").write_text("{}")
|
|
findings = claw_mod._scan_workspace_state(scan_dir)
|
|
assert len(findings) == 0
|
|
|
|
def test_empty_dir_returns_empty(self, tmp_path):
|
|
scan_dir = tmp_path / "scan_target"
|
|
scan_dir.mkdir()
|
|
findings = claw_mod._scan_workspace_state(scan_dir)
|
|
assert findings == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _archive_directory
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestArchiveDirectory:
|
|
"""Test directory archival (rename)."""
|
|
|
|
def test_renames_to_pre_migration(self, tmp_path):
|
|
source = tmp_path / ".openclaw"
|
|
source.mkdir()
|
|
(source / "test.txt").write_text("data")
|
|
|
|
archive_path = claw_mod._archive_directory(source)
|
|
assert archive_path == tmp_path / ".openclaw.pre-migration"
|
|
assert archive_path.is_dir()
|
|
assert not source.exists()
|
|
assert (archive_path / "test.txt").read_text() == "data"
|
|
|
|
def test_adds_timestamp_when_archive_exists(self, tmp_path):
|
|
source = tmp_path / ".openclaw"
|
|
source.mkdir()
|
|
# Pre-existing archive
|
|
(tmp_path / ".openclaw.pre-migration").mkdir()
|
|
|
|
archive_path = claw_mod._archive_directory(source)
|
|
assert ".pre-migration-" in archive_path.name
|
|
assert archive_path.is_dir()
|
|
assert not source.exists()
|
|
|
|
def test_dry_run_does_not_rename(self, tmp_path):
|
|
source = tmp_path / ".openclaw"
|
|
source.mkdir()
|
|
|
|
archive_path = claw_mod._archive_directory(source, dry_run=True)
|
|
assert archive_path == tmp_path / ".openclaw.pre-migration"
|
|
assert source.is_dir() # Still exists
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# claw_command routing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestClawCommand:
|
|
"""Test the claw_command router."""
|
|
|
|
def test_routes_to_migrate(self):
|
|
args = Namespace(claw_action="migrate", source=None, dry_run=True,
|
|
preset="full", overwrite=False, migrate_secrets=False,
|
|
workspace_target=None, skill_conflict="skip", yes=False)
|
|
with patch.object(claw_mod, "_cmd_migrate") as mock:
|
|
claw_mod.claw_command(args)
|
|
mock.assert_called_once_with(args)
|
|
|
|
def test_routes_to_cleanup(self):
|
|
args = Namespace(claw_action="cleanup", source=None, dry_run=False, yes=False)
|
|
with patch.object(claw_mod, "_cmd_cleanup") as mock:
|
|
claw_mod.claw_command(args)
|
|
mock.assert_called_once_with(args)
|
|
|
|
def test_routes_clean_alias(self):
|
|
args = Namespace(claw_action="clean", source=None, dry_run=False, yes=False)
|
|
with patch.object(claw_mod, "_cmd_cleanup") as mock:
|
|
claw_mod.claw_command(args)
|
|
mock.assert_called_once_with(args)
|
|
|
|
def test_shows_help_for_no_action(self, capsys):
|
|
args = Namespace(claw_action=None)
|
|
claw_mod.claw_command(args)
|
|
captured = capsys.readouterr()
|
|
assert "migrate" in captured.out
|
|
assert "cleanup" in captured.out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _cmd_migrate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCmdMigrate:
|
|
"""Test the migrate command handler."""
|
|
|
|
def test_error_when_source_missing(self, tmp_path, capsys):
|
|
args = Namespace(
|
|
source=str(tmp_path / "nonexistent"),
|
|
dry_run=True, preset="full", overwrite=False,
|
|
migrate_secrets=False, workspace_target=None,
|
|
skill_conflict="skip", yes=False,
|
|
)
|
|
claw_mod._cmd_migrate(args)
|
|
captured = capsys.readouterr()
|
|
assert "not found" in captured.out
|
|
|
|
def test_error_when_script_missing(self, tmp_path, capsys):
|
|
openclaw_dir = tmp_path / ".openclaw"
|
|
openclaw_dir.mkdir()
|
|
args = Namespace(
|
|
source=str(openclaw_dir),
|
|
dry_run=True, preset="full", overwrite=False,
|
|
migrate_secrets=False, workspace_target=None,
|
|
skill_conflict="skip", yes=False,
|
|
)
|
|
with (
|
|
patch.object(claw_mod, "_OPENCLAW_SCRIPT", tmp_path / "a.py"),
|
|
patch.object(claw_mod, "_OPENCLAW_SCRIPT_INSTALLED", tmp_path / "b.py"),
|
|
):
|
|
claw_mod._cmd_migrate(args)
|
|
captured = capsys.readouterr()
|
|
assert "Migration script not found" in captured.out
|
|
|
|
def test_dry_run_succeeds(self, tmp_path, capsys):
|
|
openclaw_dir = tmp_path / ".openclaw"
|
|
openclaw_dir.mkdir()
|
|
script = tmp_path / "script.py"
|
|
script.write_text("# placeholder")
|
|
|
|
# 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": 0, "skipped": 5, "conflict": 0, "error": 0},
|
|
"items": [
|
|
{"kind": "soul", "status": "skipped", "reason": "Not found"},
|
|
],
|
|
"preset": "full",
|
|
}
|
|
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
|
|
|
args = Namespace(
|
|
source=str(openclaw_dir),
|
|
dry_run=True, preset="full", overwrite=False,
|
|
migrate_secrets=False, workspace_target=None,
|
|
skill_conflict="skip", yes=False,
|
|
)
|
|
|
|
with (
|
|
patch.object(claw_mod, "_find_migration_script", return_value=script),
|
|
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
|
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
|
|
patch.object(claw_mod, "save_config"),
|
|
patch.object(claw_mod, "load_config", return_value={}),
|
|
):
|
|
claw_mod._cmd_migrate(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Dry Run Results" in captured.out
|
|
assert "5 skipped" in captured.out
|
|
|
|
def test_execute_with_confirmation(self, tmp_path, capsys):
|
|
openclaw_dir = tmp_path / ".openclaw"
|
|
openclaw_dir.mkdir()
|
|
config_path = tmp_path / "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"})
|
|
fake_migrator = MagicMock()
|
|
fake_migrator.migrate.return_value = {
|
|
"summary": {"migrated": 2, "skipped": 1, "conflict": 0, "error": 0},
|
|
"items": [
|
|
{"kind": "soul", "status": "migrated", "destination": str(tmp_path / "SOUL.md")},
|
|
{"kind": "memory", "status": "migrated", "destination": str(tmp_path / "memories/MEMORY.md")},
|
|
],
|
|
}
|
|
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
|
|
|
args = Namespace(
|
|
source=str(openclaw_dir),
|
|
dry_run=False, preset="user-data", overwrite=False,
|
|
migrate_secrets=False, workspace_target=None,
|
|
skill_conflict="skip", yes=False,
|
|
)
|
|
|
|
with (
|
|
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
|
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
|
patch.object(claw_mod, "get_config_path", return_value=config_path),
|
|
patch.object(claw_mod, "prompt_yes_no", return_value=True),
|
|
patch.object(claw_mod, "_offer_source_archival"),
|
|
):
|
|
claw_mod._cmd_migrate(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Migration Results" in captured.out
|
|
assert "Migration complete!" in captured.out
|
|
|
|
def test_execute_offers_archival_on_success(self, tmp_path, capsys):
|
|
"""After successful migration, _offer_source_archival should be called."""
|
|
openclaw_dir = tmp_path / ".openclaw"
|
|
openclaw_dir.mkdir()
|
|
|
|
fake_mod = ModuleType("openclaw_to_hermes")
|
|
fake_mod.resolve_selected_options = MagicMock(return_value={"soul"})
|
|
fake_migrator = MagicMock()
|
|
fake_migrator.migrate.return_value = {
|
|
"summary": {"migrated": 3, "skipped": 0, "conflict": 0, "error": 0},
|
|
"items": [
|
|
{"kind": "soul", "status": "migrated", "destination": str(tmp_path / "SOUL.md")},
|
|
],
|
|
}
|
|
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
|
|
|
args = Namespace(
|
|
source=str(openclaw_dir),
|
|
dry_run=False, preset="full", overwrite=False,
|
|
migrate_secrets=False, workspace_target=None,
|
|
skill_conflict="skip", yes=True,
|
|
)
|
|
|
|
with (
|
|
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
|
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
|
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
|
|
patch.object(claw_mod, "save_config"),
|
|
patch.object(claw_mod, "load_config", return_value={}),
|
|
patch.object(claw_mod, "_offer_source_archival") as mock_archival,
|
|
):
|
|
claw_mod._cmd_migrate(args)
|
|
|
|
mock_archival.assert_called_once_with(openclaw_dir, True)
|
|
|
|
def test_dry_run_skips_archival(self, tmp_path, capsys):
|
|
"""Dry run should not offer archival."""
|
|
openclaw_dir = tmp_path / ".openclaw"
|
|
openclaw_dir.mkdir()
|
|
|
|
fake_mod = ModuleType("openclaw_to_hermes")
|
|
fake_mod.resolve_selected_options = MagicMock(return_value=set())
|
|
fake_migrator = MagicMock()
|
|
fake_migrator.migrate.return_value = {
|
|
"summary": {"migrated": 2, "skipped": 0, "conflict": 0, "error": 0},
|
|
"items": [],
|
|
"preset": "full",
|
|
}
|
|
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
|
|
|
args = Namespace(
|
|
source=str(openclaw_dir),
|
|
dry_run=True, preset="full", overwrite=False,
|
|
migrate_secrets=False, workspace_target=None,
|
|
skill_conflict="skip", yes=False,
|
|
)
|
|
|
|
with (
|
|
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
|
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
|
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
|
|
patch.object(claw_mod, "save_config"),
|
|
patch.object(claw_mod, "load_config", return_value={}),
|
|
patch.object(claw_mod, "_offer_source_archival") as mock_archival,
|
|
):
|
|
claw_mod._cmd_migrate(args)
|
|
|
|
mock_archival.assert_not_called()
|
|
|
|
def test_execute_cancelled_by_user(self, tmp_path, capsys):
|
|
openclaw_dir = tmp_path / ".openclaw"
|
|
openclaw_dir.mkdir()
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text("")
|
|
|
|
args = Namespace(
|
|
source=str(openclaw_dir),
|
|
dry_run=False, preset="full", overwrite=False,
|
|
migrate_secrets=False, workspace_target=None,
|
|
skill_conflict="skip", yes=False,
|
|
)
|
|
|
|
with (
|
|
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
|
patch.object(claw_mod, "prompt_yes_no", return_value=False),
|
|
):
|
|
claw_mod._cmd_migrate(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Migration cancelled" in captured.out
|
|
|
|
def test_execute_with_yes_skips_confirmation(self, tmp_path, capsys):
|
|
openclaw_dir = tmp_path / ".openclaw"
|
|
openclaw_dir.mkdir()
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text("")
|
|
|
|
fake_mod = ModuleType("openclaw_to_hermes")
|
|
fake_mod.resolve_selected_options = MagicMock(return_value=set())
|
|
fake_migrator = MagicMock()
|
|
fake_migrator.migrate.return_value = {
|
|
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
|
|
"items": [],
|
|
}
|
|
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
|
|
|
args = Namespace(
|
|
source=str(openclaw_dir),
|
|
dry_run=False, preset="full", overwrite=False,
|
|
migrate_secrets=False, workspace_target=None,
|
|
skill_conflict="skip", yes=True,
|
|
)
|
|
|
|
with (
|
|
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
|
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
|
patch.object(claw_mod, "get_config_path", return_value=config_path),
|
|
patch.object(claw_mod, "prompt_yes_no") as mock_prompt,
|
|
):
|
|
claw_mod._cmd_migrate(args)
|
|
|
|
mock_prompt.assert_not_called()
|
|
|
|
def test_handles_migration_error(self, tmp_path, capsys):
|
|
openclaw_dir = tmp_path / ".openclaw"
|
|
openclaw_dir.mkdir()
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text("")
|
|
|
|
args = Namespace(
|
|
source=str(openclaw_dir),
|
|
dry_run=True, preset="full", overwrite=False,
|
|
migrate_secrets=False, workspace_target=None,
|
|
skill_conflict="skip", yes=False,
|
|
)
|
|
|
|
with (
|
|
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
|
patch.object(claw_mod, "_load_migration_module", side_effect=RuntimeError("boom")),
|
|
patch.object(claw_mod, "get_config_path", return_value=config_path),
|
|
patch.object(claw_mod, "save_config"),
|
|
patch.object(claw_mod, "load_config", return_value={}),
|
|
):
|
|
claw_mod._cmd_migrate(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Migration failed" in captured.out
|
|
|
|
def test_full_preset_enables_secrets(self, tmp_path, capsys):
|
|
"""The 'full' preset should set migrate_secrets=True automatically."""
|
|
openclaw_dir = tmp_path / ".openclaw"
|
|
openclaw_dir.mkdir()
|
|
|
|
fake_mod = ModuleType("openclaw_to_hermes")
|
|
fake_mod.resolve_selected_options = MagicMock(return_value=set())
|
|
fake_migrator = MagicMock()
|
|
fake_migrator.migrate.return_value = {
|
|
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
|
|
"items": [],
|
|
}
|
|
fake_mod.Migrator = MagicMock(return_value=fake_migrator)
|
|
|
|
args = Namespace(
|
|
source=str(openclaw_dir),
|
|
dry_run=True, preset="full", overwrite=False,
|
|
migrate_secrets=False, # Not explicitly set by user
|
|
workspace_target=None,
|
|
skill_conflict="skip", yes=False,
|
|
)
|
|
|
|
with (
|
|
patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"),
|
|
patch.object(claw_mod, "_load_migration_module", return_value=fake_mod),
|
|
patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"),
|
|
patch.object(claw_mod, "save_config"),
|
|
patch.object(claw_mod, "load_config", return_value={}),
|
|
):
|
|
claw_mod._cmd_migrate(args)
|
|
|
|
# Migrator should have been called with migrate_secrets=True
|
|
call_kwargs = fake_mod.Migrator.call_args[1]
|
|
assert call_kwargs["migrate_secrets"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _offer_source_archival
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestOfferSourceArchival:
|
|
"""Test the post-migration archival offer."""
|
|
|
|
def test_archives_with_auto_yes(self, tmp_path, capsys):
|
|
source = tmp_path / ".openclaw"
|
|
source.mkdir()
|
|
(source / "workspace").mkdir()
|
|
(source / "workspace" / "todo.json").write_text("{}")
|
|
|
|
claw_mod._offer_source_archival(source, auto_yes=True)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Archived" in captured.out
|
|
assert not source.exists()
|
|
assert (tmp_path / ".openclaw.pre-migration").is_dir()
|
|
|
|
def test_skips_when_user_declines(self, tmp_path, capsys):
|
|
source = tmp_path / ".openclaw"
|
|
source.mkdir()
|
|
|
|
with patch.object(claw_mod, "prompt_yes_no", return_value=False):
|
|
claw_mod._offer_source_archival(source, auto_yes=False)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Skipped" in captured.out
|
|
assert source.is_dir() # Still exists
|
|
|
|
def test_noop_when_source_missing(self, tmp_path, capsys):
|
|
claw_mod._offer_source_archival(tmp_path / "nonexistent", auto_yes=True)
|
|
captured = capsys.readouterr()
|
|
assert captured.out == "" # No output
|
|
|
|
def test_shows_state_files(self, tmp_path, capsys):
|
|
source = tmp_path / ".openclaw"
|
|
source.mkdir()
|
|
ws = source / "workspace"
|
|
ws.mkdir()
|
|
(ws / "todo.json").write_text("{}")
|
|
|
|
with patch.object(claw_mod, "prompt_yes_no", return_value=False):
|
|
claw_mod._offer_source_archival(source, auto_yes=False)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "todo.json" in captured.out
|
|
|
|
def test_handles_archive_error(self, tmp_path, capsys):
|
|
source = tmp_path / ".openclaw"
|
|
source.mkdir()
|
|
|
|
with patch.object(claw_mod, "_archive_directory", side_effect=OSError("permission denied")):
|
|
claw_mod._offer_source_archival(source, auto_yes=True)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Could not archive" in captured.out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _cmd_cleanup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCmdCleanup:
|
|
"""Test the cleanup command handler."""
|
|
|
|
def test_no_dirs_found(self, tmp_path, capsys):
|
|
args = Namespace(source=None, dry_run=False, yes=False)
|
|
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[]):
|
|
claw_mod._cmd_cleanup(args)
|
|
captured = capsys.readouterr()
|
|
assert "No OpenClaw directories found" in captured.out
|
|
|
|
def test_dry_run_lists_dirs(self, tmp_path, capsys):
|
|
openclaw = tmp_path / ".openclaw"
|
|
openclaw.mkdir()
|
|
ws = openclaw / "workspace"
|
|
ws.mkdir()
|
|
(ws / "todo.json").write_text("{}")
|
|
|
|
args = Namespace(source=None, dry_run=True, yes=False)
|
|
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]):
|
|
claw_mod._cmd_cleanup(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Would archive" in captured.out
|
|
assert openclaw.is_dir() # Not actually archived
|
|
|
|
def test_archives_with_yes(self, tmp_path, capsys):
|
|
openclaw = tmp_path / ".openclaw"
|
|
openclaw.mkdir()
|
|
(openclaw / "workspace").mkdir()
|
|
(openclaw / "workspace" / "todo.json").write_text("{}")
|
|
|
|
args = Namespace(source=None, dry_run=False, yes=True)
|
|
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]):
|
|
claw_mod._cmd_cleanup(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Archived" in captured.out
|
|
assert "Cleaned up 1" in captured.out
|
|
assert not openclaw.exists()
|
|
assert (tmp_path / ".openclaw.pre-migration").is_dir()
|
|
|
|
def test_skips_when_user_declines(self, tmp_path, capsys):
|
|
openclaw = tmp_path / ".openclaw"
|
|
openclaw.mkdir()
|
|
|
|
args = Namespace(source=None, dry_run=False, yes=False)
|
|
with (
|
|
patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]),
|
|
patch.object(claw_mod, "prompt_yes_no", return_value=False),
|
|
):
|
|
claw_mod._cmd_cleanup(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Skipped" in captured.out
|
|
assert openclaw.is_dir()
|
|
|
|
def test_explicit_source(self, tmp_path, capsys):
|
|
custom_dir = tmp_path / "my-openclaw"
|
|
custom_dir.mkdir()
|
|
(custom_dir / "todo.json").write_text("{}")
|
|
|
|
args = Namespace(source=str(custom_dir), dry_run=False, yes=True)
|
|
claw_mod._cmd_cleanup(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Archived" in captured.out
|
|
assert not custom_dir.exists()
|
|
|
|
def test_shows_workspace_details(self, tmp_path, capsys):
|
|
openclaw = tmp_path / ".openclaw"
|
|
openclaw.mkdir()
|
|
ws = openclaw / "workspace"
|
|
ws.mkdir()
|
|
(ws / "todo.json").write_text("{}")
|
|
(ws / "SOUL.md").write_text("# Soul")
|
|
|
|
args = Namespace(source=None, dry_run=True, yes=False)
|
|
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]):
|
|
claw_mod._cmd_cleanup(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "workspace/" in captured.out
|
|
assert "todo.json" in captured.out
|
|
|
|
def test_handles_multiple_dirs(self, tmp_path, capsys):
|
|
openclaw = tmp_path / ".openclaw"
|
|
openclaw.mkdir()
|
|
clawdbot = tmp_path / ".clawdbot"
|
|
clawdbot.mkdir()
|
|
|
|
args = Namespace(source=None, dry_run=False, yes=True)
|
|
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw, clawdbot]):
|
|
claw_mod._cmd_cleanup(args)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Cleaned up 2" in captured.out
|
|
assert not openclaw.exists()
|
|
assert not clawdbot.exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _print_migration_report
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPrintMigrationReport:
|
|
"""Test the report formatting function."""
|
|
|
|
def test_dry_run_report(self, capsys):
|
|
report = {
|
|
"summary": {"migrated": 2, "skipped": 1, "conflict": 1, "error": 0},
|
|
"items": [
|
|
{"kind": "soul", "status": "migrated", "destination": "/home/user/.hermes/SOUL.md"},
|
|
{"kind": "memory", "status": "migrated", "destination": "/home/user/.hermes/memories/MEMORY.md"},
|
|
{"kind": "skills", "status": "conflict", "reason": "already exists"},
|
|
{"kind": "tts-assets", "status": "skipped", "reason": "not found"},
|
|
],
|
|
"preset": "full",
|
|
}
|
|
claw_mod._print_migration_report(report, dry_run=True)
|
|
captured = capsys.readouterr()
|
|
assert "Dry Run Results" in captured.out
|
|
assert "Would migrate" in captured.out
|
|
assert "2 would migrate" in captured.out
|
|
assert "--dry-run" in captured.out
|
|
|
|
def test_execute_report(self, capsys):
|
|
report = {
|
|
"summary": {"migrated": 3, "skipped": 0, "conflict": 0, "error": 0},
|
|
"items": [
|
|
{"kind": "soul", "status": "migrated", "destination": "/home/user/.hermes/SOUL.md"},
|
|
],
|
|
"output_dir": "/home/user/.hermes/migration/openclaw/20250312T120000",
|
|
}
|
|
claw_mod._print_migration_report(report, dry_run=False)
|
|
captured = capsys.readouterr()
|
|
assert "Migration Results" in captured.out
|
|
assert "Migrated" in captured.out
|
|
assert "Full report saved to" in captured.out
|
|
|
|
def test_empty_report(self, capsys):
|
|
report = {
|
|
"summary": {"migrated": 0, "skipped": 0, "conflict": 0, "error": 0},
|
|
"items": [],
|
|
}
|
|
claw_mod._print_migration_report(report, dry_run=False)
|
|
captured = capsys.readouterr()
|
|
assert "Nothing to migrate" in captured.out
|