feat: backup restore UI (#3)
Add list_backups / backup_label / backup_diff / restore_backup to bcc_core, RestoreDialog to bcc.py, and a "Restore…" button in the profile top bar. Restore is selective: only mcpServers and _disabledMcpServers are replaced; all other keys in the config (history, project state) are preserved verbatim. Goes through write_config() so a pre-restore backup is always created first. Closes #3 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -318,3 +318,118 @@ def test_spawn_test_large_stderr_does_not_deadlock():
|
||||
assert r["returncode"] == 3
|
||||
# stderr is capped, not the full 64 KB
|
||||
assert len(r["stderr"]) <= c._STDERR_CAP
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 8. Backup restore
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.fixture
|
||||
def config_with_backup(tmp_path):
|
||||
"""A config file with two backups already present."""
|
||||
cfgpath = tmp_path / "claude_desktop_config.json"
|
||||
original = {
|
||||
"globalShortcut": "Cmd+Shift+Space",
|
||||
"mcpServers": {"server-v1": {"command": "node", "args": ["v1.js"]}},
|
||||
}
|
||||
cfgpath.write_text(json.dumps(original, indent=2))
|
||||
|
||||
# Create first backup (v1 state) by writing a new version
|
||||
cfg = c.load_config(cfgpath)
|
||||
servers = c.extract_servers(cfg)
|
||||
servers[0] = c.ServerEntry("server-v2", {"command": "node", "args": ["v2.js"]}, True)
|
||||
c.apply_servers(cfg, servers)
|
||||
c.write_config(cfgpath, cfg)
|
||||
|
||||
return cfgpath
|
||||
|
||||
|
||||
def test_list_backups_returns_newest_first(config_with_backup):
|
||||
backups = c.list_backups(config_with_backup)
|
||||
assert len(backups) >= 1
|
||||
# Sorted newest-first means descending lexicographic order on timestamps
|
||||
names = [b.name for b in backups]
|
||||
assert names == sorted(names, reverse=True)
|
||||
|
||||
|
||||
def test_list_backups_empty_when_no_bdir(tmp_path):
|
||||
cfgpath = tmp_path / "claude_desktop_config.json"
|
||||
cfgpath.write_text("{}")
|
||||
assert c.list_backups(cfgpath) == []
|
||||
|
||||
|
||||
def test_backup_label_parses_timestamp(config_with_backup):
|
||||
backup = c.list_backups(config_with_backup)[0]
|
||||
label = c.backup_label(backup)
|
||||
# label should look like "2026-07-02 00:41:24" (date + time, separated by two spaces)
|
||||
assert "-" in label and ":" in label
|
||||
assert len(label) == 20 # "YYYY-MM-DD HH:MM:SS" = 10 + 2 + 8
|
||||
|
||||
|
||||
def test_backup_diff_shows_server_change(config_with_backup):
|
||||
backup = c.list_backups(config_with_backup)[0]
|
||||
diff = c.backup_diff(config_with_backup, backup)
|
||||
# The diff should show the transition back to v1 servers
|
||||
assert "server-v1" in diff or "server-v2" in diff
|
||||
|
||||
|
||||
def test_backup_diff_no_diff_when_identical(tmp_path):
|
||||
cfgpath = tmp_path / "cfg.json"
|
||||
content = {"mcpServers": {"s": {"command": "node"}}}
|
||||
cfgpath.write_text(json.dumps(content, indent=2))
|
||||
bdir = cfgpath.parent / c.BACKUP_DIRNAME
|
||||
bdir.mkdir(exist_ok=True)
|
||||
import time as _time
|
||||
|
||||
stamp = _time.strftime("%Y%m%d-%H%M%S")
|
||||
backup_path = bdir / f"cfg.{stamp}.json"
|
||||
backup_path.write_text(json.dumps(content, indent=2))
|
||||
diff = c.backup_diff(cfgpath, backup_path)
|
||||
assert "(no differences" in diff
|
||||
|
||||
|
||||
def test_restore_backup_replaces_servers(config_with_backup):
|
||||
# Get the v1 backup (only backup we have)
|
||||
backups = c.list_backups(config_with_backup)
|
||||
assert len(backups) >= 1
|
||||
v1_backup = backups[0]
|
||||
|
||||
# Current file has server-v2; restore should bring back server-v1
|
||||
c.restore_backup(config_with_backup, v1_backup)
|
||||
|
||||
restored = json.loads(config_with_backup.read_text())
|
||||
assert "server-v1" in restored.get("mcpServers", {})
|
||||
assert "server-v2" not in restored.get("mcpServers", {})
|
||||
|
||||
|
||||
def test_restore_preserves_non_server_keys(config_with_backup):
|
||||
backups = c.list_backups(config_with_backup)
|
||||
c.restore_backup(config_with_backup, backups[0])
|
||||
|
||||
restored = json.loads(config_with_backup.read_text())
|
||||
# Non-server key must survive
|
||||
assert restored.get("globalShortcut") == "Cmd+Shift+Space"
|
||||
|
||||
|
||||
def test_restore_creates_pre_restore_backup(config_with_backup):
|
||||
before_count = len(c.list_backups(config_with_backup))
|
||||
backups = c.list_backups(config_with_backup)
|
||||
c.restore_backup(config_with_backup, backups[0])
|
||||
after_count = len(c.list_backups(config_with_backup))
|
||||
# restore_backup goes through write_config which makes a backup first
|
||||
assert after_count == before_count + 1
|
||||
|
||||
|
||||
def test_restore_with_current_cfg_passed(config_with_backup):
|
||||
# When current_cfg is passed in (the GUI's in-memory config), it should be
|
||||
# used instead of re-reading from disk.
|
||||
backups = c.list_backups(config_with_backup)
|
||||
# Simulate the GUI passing a repaired in-memory version
|
||||
in_memory_cfg = {
|
||||
"globalShortcut": "Cmd+Shift+Space",
|
||||
"extraKey": "preserved",
|
||||
"mcpServers": {"server-v2": {"command": "node", "args": ["v2.js"]}},
|
||||
}
|
||||
c.restore_backup(config_with_backup, backups[0], current_cfg=in_memory_cfg)
|
||||
restored = json.loads(config_with_backup.read_text())
|
||||
assert restored.get("extraKey") == "preserved"
|
||||
assert "server-v1" in restored.get("mcpServers", {})
|
||||
|
||||
Reference in New Issue
Block a user