Files
cc-switch/src-tauri/tests/skill_sync.rs
Jason 1227e29d8b feat(skills): enable Hermes in unified Skills management
Wire hermes through SkillApps struct, DAO SQL, command parser, and
SKILLS_APP_IDS. Add a Skills entry to the Hermes toolbar. Simplify
skill_sync test fixtures to use SkillApps::default().
2026-04-21 11:57:07 +08:00

381 lines
12 KiB
Rust

use std::fs;
use cc_switch_lib::{
migrate_skills_to_ssot, AppType, ImportSkillSelection, InstalledSkill, SkillApps, SkillService,
};
#[path = "support.rs"]
mod support;
use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};
fn write_skill(dir: &std::path::Path, name: &str) {
fs::create_dir_all(dir).expect("create skill dir");
fs::write(
dir.join("SKILL.md"),
format!("---\nname: {name}\ndescription: Test skill\n---\n"),
)
.expect("write SKILL.md");
}
#[cfg(unix)]
fn symlink_dir(src: &std::path::Path, dest: &std::path::Path) {
std::os::unix::fs::symlink(src, dest).expect("create symlink");
}
#[cfg(windows)]
fn symlink_dir(src: &std::path::Path, dest: &std::path::Path) {
std::os::windows::fs::symlink_dir(src, dest).expect("create symlink");
}
#[test]
fn import_from_apps_respects_explicit_app_selection() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
write_skill(
&home.join(".claude").join("skills").join("shared-skill"),
"Shared",
);
write_skill(
&home
.join(".config")
.join("opencode")
.join("skills")
.join("shared-skill"),
"Shared",
);
let state = create_test_state().expect("create test state");
let imported = SkillService::import_from_apps(
&state.db,
vec![ImportSkillSelection {
directory: "shared-skill".to_string(),
apps: SkillApps {
opencode: true,
..Default::default()
},
}],
)
.expect("import skills");
assert_eq!(imported.len(), 1, "expected exactly one imported skill");
let skill = imported.first().expect("imported skill");
assert!(
skill.apps.opencode,
"explicitly selected OpenCode app should remain enabled"
);
assert!(
!skill.apps.claude && !skill.apps.codex && !skill.apps.gemini,
"import should no longer infer apps from every matching source path"
);
}
#[test]
fn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let ssot_dir = home.join(".cc-switch").join("skills");
let disabled_skill = ssot_dir.join("disabled-skill");
let orphan_skill = ssot_dir.join("orphan-skill");
write_skill(&disabled_skill, "Disabled");
write_skill(&orphan_skill, "Orphan");
let opencode_skills_dir = home.join(".config").join("opencode").join("skills");
fs::create_dir_all(&opencode_skills_dir).expect("create opencode skills dir");
symlink_dir(&disabled_skill, &opencode_skills_dir.join("disabled-skill"));
symlink_dir(&orphan_skill, &opencode_skills_dir.join("orphan-skill"));
let state = create_test_state().expect("create test state");
state
.db
.save_skill(&InstalledSkill {
id: "local:disabled-skill".to_string(),
name: "Disabled".to_string(),
description: None,
directory: "disabled-skill".to_string(),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps: SkillApps::default(),
installed_at: 0,
content_hash: None,
updated_at: 0,
})
.expect("save disabled skill");
SkillService::sync_to_app(&state.db, &AppType::OpenCode).expect("reconcile skills");
assert!(
!opencode_skills_dir.join("disabled-skill").exists(),
"DB-known disabled skill should be removed from OpenCode live dir"
);
assert!(
!opencode_skills_dir.join("orphan-skill").exists(),
"orphaned symlink into SSOT should be cleaned up"
);
}
#[test]
fn uninstall_skill_creates_backup_before_removing_ssot() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let ssot_skill_dir = home.join(".cc-switch").join("skills").join("backup-skill");
write_skill(&ssot_skill_dir, "Backup Skill");
fs::write(ssot_skill_dir.join("prompt.md"), "backup me").expect("write prompt.md");
let state = create_test_state().expect("create test state");
state
.db
.save_skill(&InstalledSkill {
id: "local:backup-skill".to_string(),
name: "Backup Skill".to_string(),
description: Some("Back me up before uninstall".to_string()),
directory: "backup-skill".to_string(),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps: SkillApps {
claude: true,
..Default::default()
},
installed_at: 123,
content_hash: None,
updated_at: 0,
})
.expect("save skill");
let result = SkillService::uninstall(&state.db, "local:backup-skill").expect("uninstall skill");
let backup_path = result.backup_path.expect("backup path should be returned");
let backup_dir = std::path::PathBuf::from(&backup_path);
assert!(backup_dir.exists(), "backup directory should exist");
assert!(
backup_dir.join("skill").join("SKILL.md").exists(),
"backup should include SKILL.md"
);
assert_eq!(
fs::read_to_string(backup_dir.join("skill").join("prompt.md"))
.expect("read backed up prompt"),
"backup me"
);
let metadata: serde_json::Value = serde_json::from_str(
&fs::read_to_string(backup_dir.join("meta.json")).expect("read backup metadata"),
)
.expect("parse backup metadata");
assert_eq!(metadata["skill"]["directory"], "backup-skill");
assert_eq!(metadata["skill"]["name"], "Backup Skill");
assert!(
!ssot_skill_dir.exists(),
"SSOT skill directory should be removed after uninstall"
);
assert!(
state
.db
.get_installed_skill("local:backup-skill")
.expect("query skill")
.is_none(),
"database row should be deleted after uninstall"
);
}
#[test]
fn restore_skill_backup_restores_files_to_ssot_and_current_app() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let ssot_skill_dir = home.join(".cc-switch").join("skills").join("restore-skill");
write_skill(&ssot_skill_dir, "Restore Skill");
fs::write(ssot_skill_dir.join("prompt.md"), "restore me").expect("write prompt.md");
let state = create_test_state().expect("create test state");
state
.db
.save_skill(&InstalledSkill {
id: "local:restore-skill".to_string(),
name: "Restore Skill".to_string(),
description: Some("Bring the files back".to_string()),
directory: "restore-skill".to_string(),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps: SkillApps {
claude: true,
..Default::default()
},
installed_at: 456,
content_hash: None,
updated_at: 0,
})
.expect("save skill");
let uninstall =
SkillService::uninstall(&state.db, "local:restore-skill").expect("uninstall skill");
let backup_id = std::path::Path::new(
&uninstall
.backup_path
.expect("backup path should be returned on uninstall"),
)
.file_name()
.expect("backup dir name")
.to_string_lossy()
.to_string();
let restored = SkillService::restore_from_backup(&state.db, &backup_id, &AppType::Claude)
.expect("restore from backup");
assert_eq!(restored.directory, "restore-skill");
assert!(restored.apps.claude, "restored skill should enable Claude");
assert!(
!restored.apps.codex && !restored.apps.gemini && !restored.apps.opencode,
"restore should only enable the selected app"
);
assert!(
home.join(".cc-switch")
.join("skills")
.join("restore-skill")
.join("prompt.md")
.exists(),
"restored skill should exist in SSOT"
);
assert!(
home.join(".claude")
.join("skills")
.join("restore-skill")
.join("prompt.md")
.exists(),
"restored skill should sync to the selected app"
);
assert!(
state
.db
.get_installed_skill("local:restore-skill")
.expect("query restored skill")
.is_some(),
"restored skill should be written back to the database"
);
}
#[test]
fn delete_skill_backup_removes_backup_directory() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
let ssot_skill_dir = home
.join(".cc-switch")
.join("skills")
.join("delete-backup-skill");
write_skill(&ssot_skill_dir, "Delete Backup Skill");
let state = create_test_state().expect("create test state");
state
.db
.save_skill(&InstalledSkill {
id: "local:delete-backup-skill".to_string(),
name: "Delete Backup Skill".to_string(),
description: Some("Remove my backup".to_string()),
directory: "delete-backup-skill".to_string(),
repo_owner: None,
repo_name: None,
repo_branch: None,
readme_url: None,
apps: SkillApps {
claude: true,
..Default::default()
},
installed_at: 789,
content_hash: None,
updated_at: 0,
})
.expect("save skill");
let uninstall =
SkillService::uninstall(&state.db, "local:delete-backup-skill").expect("uninstall skill");
let backup_path = uninstall
.backup_path
.expect("backup path should be returned on uninstall");
let backup_id = std::path::Path::new(&backup_path)
.file_name()
.expect("backup dir name")
.to_string_lossy()
.to_string();
assert!(
std::path::Path::new(&backup_path).exists(),
"backup directory should exist before deletion"
);
SkillService::delete_backup(&backup_id).expect("delete backup");
assert!(
!std::path::Path::new(&backup_path).exists(),
"backup directory should be removed"
);
assert!(
SkillService::list_backups()
.expect("list backups")
.into_iter()
.all(|entry| entry.backup_id != backup_id),
"deleted backup should no longer appear in backup list"
);
}
#[test]
fn migration_snapshot_overrides_multi_source_directory_inference() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
write_skill(
&home.join(".claude").join("skills").join("demo-skill"),
"Demo",
);
write_skill(
&home
.join(".config")
.join("opencode")
.join("skills")
.join("demo-skill"),
"Demo",
);
let state = create_test_state().expect("create test state");
state
.db
.set_setting(
"skills_ssot_migration_snapshot",
r#"[{"directory":"demo-skill","app_type":"claude"}]"#,
)
.expect("seed migration snapshot");
let count = migrate_skills_to_ssot(&state.db).expect("migrate skills to ssot");
assert_eq!(count, 1, "expected one migrated skill");
let skills = state.db.get_all_installed_skills().expect("get skills");
let migrated = skills
.values()
.find(|skill| skill.directory == "demo-skill")
.expect("migrated demo-skill");
assert!(
migrated.apps.claude,
"legacy snapshot should preserve Claude enablement"
);
assert!(
!migrated.apps.opencode,
"migration should no longer infer OpenCode enablement from a duplicate directory alone"
);
}