mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat: add project profiles for snapshot-based config switching
Add a profile feature that captures the current provider, MCP, skills and prompt state for Claude Code and Codex as a named snapshot, and re-applies it in one click from the header switcher or the tray Projects submenu. - New profiles table (schema v12) with current marker in settings - ProfileService orchestrates the four existing switch primitives (provider first, then MCP diff, skills diff, prompt enable) - Best-effort apply: dangling references become warnings, no rollback - Header combobox switcher + snapshot-style manage dialog - Tray Projects submenu shared with the UI apply/event pipeline - i18n for zh/en/ja/zh-TW under the new profiles domain - Integration tests covering roundtrip, dangling refs and clear
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
//! 项目 Profile 快照/应用的端到端集成测试
|
||||
//!
|
||||
//! 全链路 apply 会写 live 配置文件——support.rs 已把 HOME 指向临时目录,安全。
|
||||
|
||||
use std::fs;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use cc_switch_lib::{
|
||||
AppType, InstalledSkill, McpServer, McpService, ProfilePayload, ProfileService, Prompt,
|
||||
PromptService, Provider, ProviderService, SkillApps, SkillService,
|
||||
};
|
||||
|
||||
#[path = "support.rs"]
|
||||
mod support;
|
||||
use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};
|
||||
|
||||
fn claude_provider(id: &str, token: &str) -> Provider {
|
||||
Provider::with_id(
|
||||
id.to_string(),
|
||||
id.to_uppercase(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": token,
|
||||
"ANTHROPIC_BASE_URL": "https://api.test"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn mcp_server(id: &str, claude_enabled: bool) -> McpServer {
|
||||
serde_json::from_value(json!({
|
||||
"id": id,
|
||||
"name": id,
|
||||
"server": { "command": "echo", "args": [] },
|
||||
"apps": { "claude": claude_enabled }
|
||||
}))
|
||||
.expect("construct mcp server")
|
||||
}
|
||||
|
||||
fn prompt(id: &str, enabled: bool) -> Prompt {
|
||||
Prompt {
|
||||
id: id.to_string(),
|
||||
name: id.to_uppercase(),
|
||||
content: format!("# prompt {id}\n"),
|
||||
description: None,
|
||||
enabled,
|
||||
created_at: Some(1_000),
|
||||
updated_at: Some(1_000),
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_skill(id: &str, directory: &str, claude_enabled: bool) -> InstalledSkill {
|
||||
InstalledSkill {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
description: None,
|
||||
directory: directory.to_string(),
|
||||
repo_owner: None,
|
||||
repo_name: None,
|
||||
repo_branch: None,
|
||||
readme_url: None,
|
||||
apps: SkillApps {
|
||||
claude: claude_enabled,
|
||||
..Default::default()
|
||||
},
|
||||
installed_at: 1_000,
|
||||
content_hash: None,
|
||||
updated_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_ssot_skill(directory: &str) {
|
||||
let dir = SkillService::get_ssot_dir()
|
||||
.expect("resolve skills SSOT dir")
|
||||
.join(directory);
|
||||
fs::create_dir_all(&dir).expect("create skill dir");
|
||||
fs::write(
|
||||
dir.join("SKILL.md"),
|
||||
format!("---\nname: {directory}\ndescription: Test skill\n---\n"),
|
||||
)
|
||||
.expect("write SKILL.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_snapshot_apply_roundtrip_restores_configuration() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let home = ensure_test_home();
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
|
||||
// ---- 种子数据:2 个 Claude 供应商(p1 为当前)+ 2 个 MCP + 1 个 Skill + 2 个 Prompt ----
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::Claude.as_str(), &claude_provider("p1", "key-1"))
|
||||
.expect("save provider p1");
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::Claude.as_str(), &claude_provider("p2", "key-2"))
|
||||
.expect("save provider p2");
|
||||
state
|
||||
.db
|
||||
.set_current_provider(AppType::Claude.as_str(), "p1")
|
||||
.expect("set current provider p1");
|
||||
|
||||
// 让 live settings.json 与 p1 一致(switch_normal 回填需要)
|
||||
let claude_dir = home.join(".claude");
|
||||
fs::create_dir_all(&claude_dir).expect("create .claude dir");
|
||||
fs::write(
|
||||
claude_dir.join("settings.json"),
|
||||
serde_json::to_string_pretty(&claude_provider("p1", "key-1").settings_config)
|
||||
.expect("serialize p1 settings"),
|
||||
)
|
||||
.expect("seed live settings.json");
|
||||
|
||||
state
|
||||
.db
|
||||
.save_mcp_server(&mcp_server("m1", true))
|
||||
.expect("save mcp m1");
|
||||
state
|
||||
.db
|
||||
.save_mcp_server(&mcp_server("m2", false))
|
||||
.expect("save mcp m2");
|
||||
|
||||
write_ssot_skill("test-skill");
|
||||
state
|
||||
.db
|
||||
.save_skill(&installed_skill("local:test-skill", "test-skill", true))
|
||||
.expect("save skill");
|
||||
|
||||
state
|
||||
.db
|
||||
.save_prompt(AppType::Claude.as_str(), &prompt("pr1", true))
|
||||
.expect("save prompt pr1");
|
||||
state
|
||||
.db
|
||||
.save_prompt(AppType::Claude.as_str(), &prompt("pr2", false))
|
||||
.expect("save prompt pr2");
|
||||
|
||||
// ---- 保存项目 A(快照当前状态)----
|
||||
let profile_a = ProfileService::create(&state, "Project A").expect("create profile A");
|
||||
let payload: ProfilePayload =
|
||||
serde_json::from_str(&profile_a.payload).expect("parse profile A payload");
|
||||
assert_eq!(payload.providers.claude.as_deref(), Some("p1"));
|
||||
assert_eq!(payload.mcp.claude, vec!["m1".to_string()]);
|
||||
assert_eq!(payload.skills.claude, vec!["local:test-skill".to_string()]);
|
||||
assert_eq!(payload.prompts.claude.as_deref(), Some("pr1"));
|
||||
assert_eq!(
|
||||
payload.providers.codex, None,
|
||||
"codex has no current provider"
|
||||
);
|
||||
assert!(payload.mcp.codex.is_empty());
|
||||
|
||||
// ---- 改动全部四类配置(走真实切换路径)----
|
||||
ProviderService::switch(&state, AppType::Claude, "p2").expect("switch to p2");
|
||||
McpService::toggle_app(&state, "m1", AppType::Claude, false).expect("disable m1");
|
||||
McpService::toggle_app(&state, "m2", AppType::Claude, true).expect("enable m2");
|
||||
SkillService::toggle_app(&state.db, "local:test-skill", &AppType::Claude, false)
|
||||
.expect("disable skill");
|
||||
PromptService::enable_prompt(&state, AppType::Claude, "pr2").expect("enable pr2");
|
||||
|
||||
// ---- 应用项目 A:全部复原 ----
|
||||
let warnings = ProfileService::apply(&state, &profile_a.id).expect("apply profile A");
|
||||
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
|
||||
|
||||
let current = state
|
||||
.db
|
||||
.get_current_provider(AppType::Claude.as_str())
|
||||
.expect("get current provider");
|
||||
assert_eq!(current.as_deref(), Some("p1"), "provider restored to p1");
|
||||
|
||||
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
|
||||
assert!(servers.get("m1").expect("m1").apps.claude, "m1 re-enabled");
|
||||
assert!(!servers.get("m2").expect("m2").apps.claude, "m2 disabled");
|
||||
|
||||
let skills = state.db.get_all_installed_skills().expect("get skills");
|
||||
assert!(
|
||||
skills.get("local:test-skill").expect("skill").apps.claude,
|
||||
"skill re-enabled"
|
||||
);
|
||||
|
||||
let prompts = state
|
||||
.db
|
||||
.get_prompts(AppType::Claude.as_str())
|
||||
.expect("get prompts");
|
||||
assert!(prompts.get("pr1").expect("pr1").enabled, "pr1 re-enabled");
|
||||
assert!(!prompts.get("pr2").expect("pr2").enabled, "pr2 disabled");
|
||||
|
||||
let live_prompt = fs::read_to_string(claude_dir.join("CLAUDE.md")).expect("read CLAUDE.md");
|
||||
assert_eq!(
|
||||
live_prompt,
|
||||
prompt("pr1", true).content,
|
||||
"live memory file restored"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
.db
|
||||
.get_current_profile_id()
|
||||
.expect("get current profile id")
|
||||
.as_deref(),
|
||||
Some(profile_a.id.as_str()),
|
||||
"profile A marked as current"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_apply_reports_dangling_references_and_continues() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
|
||||
state
|
||||
.db
|
||||
.save_mcp_server(&mcp_server("m1", false))
|
||||
.expect("save mcp m1");
|
||||
|
||||
// 手工构造引用了不存在资源的 payload
|
||||
let payload = json!({
|
||||
"providers": { "claude": "ghost-provider" },
|
||||
"mcp": { "claude": ["m1", "ghost-mcp"] },
|
||||
"skills": { "claude": ["ghost-skill"] },
|
||||
"prompts": { "claude": "ghost-prompt" }
|
||||
});
|
||||
let profile = cc_switch_lib::Profile {
|
||||
id: "dangling-test".to_string(),
|
||||
name: "Dangling".to_string(),
|
||||
payload: payload.to_string(),
|
||||
sort_order: None,
|
||||
created_at: Some(1_000),
|
||||
updated_at: Some(1_000),
|
||||
};
|
||||
state.db.save_profile(&profile).expect("save profile");
|
||||
|
||||
let warnings = ProfileService::apply(&state, "dangling-test").expect("apply succeeds");
|
||||
assert_eq!(
|
||||
warnings.len(),
|
||||
4,
|
||||
"each dangling reference yields one warning: {warnings:?}"
|
||||
);
|
||||
|
||||
// 有效条目照常生效:m1 被启用
|
||||
let servers = state.db.get_all_mcp_servers().expect("get mcp servers");
|
||||
assert!(
|
||||
servers.get("m1").expect("m1").apps.claude,
|
||||
"m1 enabled despite warnings"
|
||||
);
|
||||
|
||||
// best-effort 完成后仍标记为当前项目
|
||||
assert_eq!(
|
||||
state
|
||||
.db
|
||||
.get_current_profile_id()
|
||||
.expect("get current profile id")
|
||||
.as_deref(),
|
||||
Some("dangling-test")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_current_profile_only_clears_marker() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
|
||||
state
|
||||
.db
|
||||
.set_current_profile_id(Some("some-profile"))
|
||||
.expect("set current profile");
|
||||
state
|
||||
.db
|
||||
.set_current_profile_id(None)
|
||||
.expect("clear current profile");
|
||||
assert_eq!(
|
||||
state
|
||||
.db
|
||||
.get_current_profile_id()
|
||||
.expect("get current profile id"),
|
||||
None
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user