refactor(backend): phase 3 - expand integration tests for Codex and MCP sync
Expand test suite from 3 to 11 integration tests, adding comprehensive coverage for Codex dual-file atomicity and bidirectional MCP synchronization: New Codex sync tests: - sync_codex_provider_writes_auth_and_config: validates atomic write of auth.json and config.toml, plus SSOT backfill of latest toml content - sync_enabled_to_codex_writes_enabled_servers: MCP projection to config.toml - sync_enabled_to_codex_removes_servers_when_none_enabled: cleanup when all disabled - sync_enabled_to_codex_returns_error_on_invalid_toml: error handling for malformed TOML New Codex MCP import tests: - import_from_codex_adds_servers_from_mcp_servers_table: imports new servers from live config - import_from_codex_merges_into_existing_entries: smart merge preserving SSOT server configs New Claude MCP tests: - sync_claude_enabled_mcp_projects_to_user_config: enabled/disabled filtering for .claude.json - import_from_claude_merges_into_config: intelligent merge preserving existing configurations Expand lib.rs API exports: - Codex paths: get_codex_auth_path, get_codex_config_path - Claude MCP: get_claude_mcp_path - MCP sync: sync_enabled_to_claude, sync_enabled_to_codex - MCP import: import_from_claude, import_from_codex - Error type: AppError (for test assertions) Test infrastructure improvements: - Enhanced reset_test_fs() to clean .claude.json - All tests use isolated HOME directory with sequential execution via mutex Test results: 11/11 passed Files changed: 3 (+394/-6 lines) Next steps: Command layer integration tests and error recovery scenarios
This commit is contained in:
@@ -77,9 +77,9 @@
|
|||||||
- 每个文件聚焦单一功能域(供应商、MCP、配置、设置、杂项、插件),命令函数平均 150-250 行,可读性与后续维护性显著提升。
|
- 每个文件聚焦单一功能域(供应商、MCP、配置、设置、杂项、插件),命令函数平均 150-250 行,可读性与后续维护性显著提升。
|
||||||
- 相关依赖调整后 `cargo check` 通过,静态巡检确认无重复定义或未注册命令。
|
- 相关依赖调整后 `cargo check` 通过,静态巡检确认无重复定义或未注册命令。
|
||||||
- **阶段 3:补充测试 🚧**
|
- **阶段 3:补充测试 🚧**
|
||||||
- 新增 `tests/import_export_sync.rs` 集成测试,覆盖配置备份与 Claude 供应商 live 同步路径(使用隔离的 HOME 目录,避免污染真实环境)。
|
- `tests/import_export_sync.rs` 集成测试涵盖配置备份、Claude/Codex live 同步、MCP 投影与 Codex/Claude 双向导入流程,并新增启用项清理、非法 TOML 抛错等失败场景验证;统一使用隔离 HOME 目录避免污染真实用户环境。
|
||||||
- 扩展 `lib.rs` 对核心数据结构与错误处理 API 的导出,便于后续服务层测试复用。
|
- 扩展 `lib.rs` re-export,暴露 `AppType`、`MultiAppConfig`、`AppError`、配置 IO 以及 Codex/Claude MCP 路径与同步函数,方便服务层及测试直接复用核心逻辑。
|
||||||
- 当前覆盖率聚焦配置导入导出模块,后续待补充 MCP 同步、供应商切换等跨模块场景。
|
- 当前已覆盖配置、Codex/Claude MCP 核心路径及关键错误分支,后续仍需补齐命令层边界与导入导出异常回滚测试。
|
||||||
|
|
||||||
## 渐进式重构路线
|
## 渐进式重构路线
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ mod store;
|
|||||||
mod usage_script;
|
mod usage_script;
|
||||||
|
|
||||||
pub use app_config::{AppType, MultiAppConfig};
|
pub use app_config::{AppType, MultiAppConfig};
|
||||||
pub use config::{get_claude_settings_path, read_json_file};
|
pub use codex_config::{get_codex_auth_path, get_codex_config_path};
|
||||||
|
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
|
||||||
pub use import_export::{create_backup, sync_current_providers_to_live};
|
pub use import_export::{create_backup, sync_current_providers_to_live};
|
||||||
pub use provider::Provider;
|
pub use provider::Provider;
|
||||||
pub use settings::{update_settings, AppSettings};
|
pub use settings::{update_settings, AppSettings};
|
||||||
|
pub use mcp::{import_from_claude, import_from_codex, sync_enabled_to_claude, sync_enabled_to_codex};
|
||||||
|
pub use error::AppError;
|
||||||
|
|
||||||
use store::AppState;
|
use store::AppState;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
@@ -30,8 +33,6 @@ use tauri::{
|
|||||||
use tauri::{ActivationPolicy, RunEvent};
|
use tauri::{ActivationPolicy, RunEvent};
|
||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
|
|
||||||
/// 创建动态托盘菜单
|
/// 创建动态托盘菜单
|
||||||
fn create_tray_menu(
|
fn create_tray_menu(
|
||||||
app: &tauri::AppHandle,
|
app: &tauri::AppHandle,
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ fn reset_test_fs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let claude_json = home.join(".claude.json");
|
||||||
|
if claude_json.exists() {
|
||||||
|
let _ = fs::remove_file(&claude_json);
|
||||||
|
}
|
||||||
// 重置内存中的设置缓存,确保测试环境不受上一次调用影响
|
// 重置内存中的设置缓存,确保测试环境不受上一次调用影响
|
||||||
// 写入默认设置即可刷新 OnceLock 中的缓存数据
|
// 写入默认设置即可刷新 OnceLock 中的缓存数据
|
||||||
let _ = update_settings(AppSettings::default());
|
let _ = update_settings(AppSettings::default());
|
||||||
@@ -102,6 +106,389 @@ fn sync_claude_provider_writes_live_settings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_codex_provider_writes_auth_and_config() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
|
||||||
|
// 添加入测 MCP 启用项,确保 sync_enabled_to_codex 会写入 TOML
|
||||||
|
config
|
||||||
|
.mcp
|
||||||
|
.codex
|
||||||
|
.servers
|
||||||
|
.insert(
|
||||||
|
"echo-server".into(),
|
||||||
|
json!({
|
||||||
|
"id": "echo-server",
|
||||||
|
"enabled": true,
|
||||||
|
"server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo",
|
||||||
|
"args": ["hello"]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let provider_config = json!({
|
||||||
|
"auth": {
|
||||||
|
"OPENAI_API_KEY": "codex-key"
|
||||||
|
},
|
||||||
|
"config": r#"base_url = "https://codex.test""#
|
||||||
|
});
|
||||||
|
|
||||||
|
let provider = Provider::with_id(
|
||||||
|
"codex-1".to_string(),
|
||||||
|
"Codex Test".to_string(),
|
||||||
|
provider_config.clone(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Codex)
|
||||||
|
.expect("codex manager");
|
||||||
|
manager.providers.insert("codex-1".to_string(), provider);
|
||||||
|
manager.current = "codex-1".to_string();
|
||||||
|
|
||||||
|
sync_current_providers_to_live(&mut config).expect("sync codex live");
|
||||||
|
|
||||||
|
let auth_path = cc_switch_lib::get_codex_auth_path();
|
||||||
|
let config_path = cc_switch_lib::get_codex_config_path();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
auth_path.exists(),
|
||||||
|
"auth.json should exist at {}",
|
||||||
|
auth_path.display()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
config_path.exists(),
|
||||||
|
"config.toml should exist at {}",
|
||||||
|
config_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let auth_value: serde_json::Value = read_json_file(&auth_path).expect("read auth");
|
||||||
|
assert_eq!(
|
||||||
|
auth_value,
|
||||||
|
provider_config.get("auth").cloned().expect("auth object")
|
||||||
|
);
|
||||||
|
|
||||||
|
let toml_text = fs::read_to_string(&config_path).expect("read config.toml");
|
||||||
|
assert!(
|
||||||
|
toml_text.contains("command = \"echo\""),
|
||||||
|
"config.toml should contain serialized enabled MCP server"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 当前供应商应同步最新 config 文本
|
||||||
|
let manager = config
|
||||||
|
.get_manager(&AppType::Codex)
|
||||||
|
.expect("codex manager");
|
||||||
|
let synced = manager.providers.get("codex-1").expect("codex provider");
|
||||||
|
let synced_cfg = synced
|
||||||
|
.settings_config
|
||||||
|
.get("config")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.expect("config string");
|
||||||
|
assert_eq!(synced_cfg, toml_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_enabled_to_codex_writes_enabled_servers() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config
|
||||||
|
.mcp
|
||||||
|
.codex
|
||||||
|
.servers
|
||||||
|
.insert(
|
||||||
|
"stdio-enabled".into(),
|
||||||
|
json!({
|
||||||
|
"id": "stdio-enabled",
|
||||||
|
"enabled": true,
|
||||||
|
"server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo",
|
||||||
|
"args": ["ok"],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex");
|
||||||
|
|
||||||
|
let path = cc_switch_lib::get_codex_config_path();
|
||||||
|
assert!(path.exists(), "config.toml should be created");
|
||||||
|
let text = fs::read_to_string(&path).expect("read config.toml");
|
||||||
|
assert!(
|
||||||
|
text.contains("mcp_servers") && text.contains("stdio-enabled"),
|
||||||
|
"enabled servers should be serialized"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_enabled_to_codex_removes_servers_when_none_enabled() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let path = cc_switch_lib::get_codex_config_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("create codex dir");
|
||||||
|
}
|
||||||
|
fs::write(
|
||||||
|
&path,
|
||||||
|
r#"[mcp_servers]
|
||||||
|
disabled = { type = "stdio", command = "noop" }
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("seed config file");
|
||||||
|
|
||||||
|
let config = MultiAppConfig::default(); // 无启用项
|
||||||
|
cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex");
|
||||||
|
|
||||||
|
let text = fs::read_to_string(&path).expect("read config.toml");
|
||||||
|
assert!(
|
||||||
|
!text.contains("mcp_servers") && !text.contains("servers"),
|
||||||
|
"disabled entries should be removed from config.toml"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_enabled_to_codex_returns_error_on_invalid_toml() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let path = cc_switch_lib::get_codex_config_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("create codex dir");
|
||||||
|
}
|
||||||
|
fs::write(&path, "invalid = [").expect("write invalid config");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config.mcp.codex.servers.insert(
|
||||||
|
"broken".into(),
|
||||||
|
json!({
|
||||||
|
"id": "broken",
|
||||||
|
"enabled": true,
|
||||||
|
"server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = cc_switch_lib::sync_enabled_to_codex(&config).expect_err("sync should fail");
|
||||||
|
match err {
|
||||||
|
cc_switch_lib::AppError::Toml { path, .. } => {
|
||||||
|
assert!(path.ends_with("config.toml"), "path should reference config.toml");
|
||||||
|
}
|
||||||
|
cc_switch_lib::AppError::McpValidation(msg) => {
|
||||||
|
assert!(msg.contains("config.toml"), "error message should mention config.toml");
|
||||||
|
}
|
||||||
|
other => panic!("unexpected error: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_from_codex_adds_servers_from_mcp_servers_table() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let path = cc_switch_lib::get_codex_config_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("create codex dir");
|
||||||
|
}
|
||||||
|
fs::write(
|
||||||
|
&path,
|
||||||
|
r#"[mcp_servers.echo_server]
|
||||||
|
type = "stdio"
|
||||||
|
command = "echo"
|
||||||
|
args = ["hello"]
|
||||||
|
|
||||||
|
[mcp_servers.http_server]
|
||||||
|
type = "http"
|
||||||
|
url = "https://example.com"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("write codex config");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
let changed = cc_switch_lib::import_from_codex(&mut config).expect("import codex");
|
||||||
|
assert!(changed >= 2, "should import both servers");
|
||||||
|
|
||||||
|
let servers = &config.mcp.codex.servers;
|
||||||
|
let echo = servers.get("echo_server").and_then(|v| v.as_object()).expect("echo server");
|
||||||
|
assert_eq!(echo.get("enabled").and_then(|v| v.as_bool()), Some(true));
|
||||||
|
let server_spec = echo.get("server").and_then(|v| v.as_object()).expect("server spec");
|
||||||
|
assert_eq!(
|
||||||
|
server_spec.get("command").and_then(|v| v.as_str()).unwrap_or(""),
|
||||||
|
"echo"
|
||||||
|
);
|
||||||
|
|
||||||
|
let http = servers.get("http_server").and_then(|v| v.as_object()).expect("http server");
|
||||||
|
let http_spec = http.get("server").and_then(|v| v.as_object()).expect("http spec");
|
||||||
|
assert_eq!(
|
||||||
|
http_spec.get("url").and_then(|v| v.as_str()).unwrap_or(""),
|
||||||
|
"https://example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_from_codex_merges_into_existing_entries() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let path = cc_switch_lib::get_codex_config_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).expect("create codex dir");
|
||||||
|
}
|
||||||
|
fs::write(
|
||||||
|
&path,
|
||||||
|
r#"[mcp.servers.existing]
|
||||||
|
type = "stdio"
|
||||||
|
command = "echo"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("write codex config");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config
|
||||||
|
.mcp
|
||||||
|
.codex
|
||||||
|
.servers
|
||||||
|
.insert(
|
||||||
|
"existing".into(),
|
||||||
|
json!({
|
||||||
|
"id": "existing",
|
||||||
|
"name": "existing",
|
||||||
|
"enabled": false,
|
||||||
|
"server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "prev"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let changed = cc_switch_lib::import_from_codex(&mut config).expect("import codex");
|
||||||
|
assert!(changed >= 1, "should mark change for enabled flag");
|
||||||
|
|
||||||
|
let entry = config
|
||||||
|
.mcp
|
||||||
|
.codex
|
||||||
|
.servers
|
||||||
|
.get("existing")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.expect("existing entry");
|
||||||
|
assert_eq!(entry.get("enabled").and_then(|v| v.as_bool()), Some(true));
|
||||||
|
let spec = entry.get("server").and_then(|v| v.as_object()).expect("server spec");
|
||||||
|
// 保留原 command,确保导入不会覆盖现有 server 细节
|
||||||
|
assert_eq!(spec.get("command").and_then(|v| v.as_str()), Some("prev"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_claude_enabled_mcp_projects_to_user_config() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
|
||||||
|
config.mcp.claude.servers.insert(
|
||||||
|
"stdio-enabled".into(),
|
||||||
|
json!({
|
||||||
|
"id": "stdio-enabled",
|
||||||
|
"enabled": true,
|
||||||
|
"server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo",
|
||||||
|
"args": ["hi"],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
config.mcp.claude.servers.insert(
|
||||||
|
"http-disabled".into(),
|
||||||
|
json!({
|
||||||
|
"id": "http-disabled",
|
||||||
|
"enabled": false,
|
||||||
|
"server": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://example.com",
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
cc_switch_lib::sync_enabled_to_claude(&config).expect("sync Claude MCP");
|
||||||
|
|
||||||
|
let claude_path = cc_switch_lib::get_claude_mcp_path();
|
||||||
|
assert!(claude_path.exists(), "claude config should exist");
|
||||||
|
let text = fs::read_to_string(&claude_path).expect("read .claude.json");
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&text).expect("parse claude json");
|
||||||
|
let servers = value
|
||||||
|
.get("mcpServers")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.expect("mcpServers map");
|
||||||
|
assert_eq!(servers.len(), 1, "only enabled entries should be written");
|
||||||
|
let enabled = servers.get("stdio-enabled").expect("enabled entry");
|
||||||
|
assert_eq!(
|
||||||
|
enabled
|
||||||
|
.get("command")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
"echo"
|
||||||
|
);
|
||||||
|
assert!(servers.get("http-disabled").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_from_claude_merges_into_config() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
let claude_path = home.join(".claude.json");
|
||||||
|
|
||||||
|
fs::write(
|
||||||
|
&claude_path,
|
||||||
|
serde_json::to_string_pretty(&json!({
|
||||||
|
"mcpServers": {
|
||||||
|
"stdio-enabled": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo",
|
||||||
|
"args": ["hello"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.expect("write claude json");
|
||||||
|
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
config
|
||||||
|
.mcp
|
||||||
|
.claude
|
||||||
|
.servers
|
||||||
|
.insert("stdio-enabled".into(), json!({
|
||||||
|
"id": "stdio-enabled",
|
||||||
|
"name": "stdio-enabled",
|
||||||
|
"enabled": false,
|
||||||
|
"server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "prev"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let changed = cc_switch_lib::import_from_claude(&mut config).expect("import from claude");
|
||||||
|
assert!(changed >= 1, "should mark at least one change");
|
||||||
|
|
||||||
|
let entry = config
|
||||||
|
.mcp
|
||||||
|
.claude
|
||||||
|
.servers
|
||||||
|
.get("stdio-enabled")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.expect("entry exists");
|
||||||
|
assert_eq!(entry.get("enabled").and_then(|v| v.as_bool()), Some(true));
|
||||||
|
let server = entry.get("server").and_then(|v| v.as_object()).expect("server obj");
|
||||||
|
assert_eq!(
|
||||||
|
server.get("command").and_then(|v| v.as_str()).unwrap_or(""),
|
||||||
|
"prev",
|
||||||
|
"existing server config should be preserved"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_backup_skips_missing_file() {
|
fn create_backup_skips_missing_file() {
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
|||||||
Reference in New Issue
Block a user