Files
MyAiDesk/nomifun-tauri/crates/backend/nomifun-extension/tests/contribution_resolution_test.rs
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

603 lines
21 KiB
Rust

//! Integration tests for contribution resolution (test-plan CR-1..CR-10).
//!
//! These are black-box tests that exercise the public resolver APIs with
//! realistic extension manifests and file system fixtures.
use std::collections::HashMap;
use nomifun_extension::types::*;
use nomifun_extension::{resolve_all_contributions, resolve_extension_contributions, resolve_i18n_for_all};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn make_loaded_extension(name: &str, dir: &str, contributes: ExtContributes) -> LoadedExtension {
LoadedExtension {
manifest: ExtensionManifest {
name: name.to_owned(),
version: "1.0.0".to_owned(),
display_name: None,
description: None,
author: None,
license: None,
homepage: None,
icon: None,
engine: None,
api_version: None,
dependencies: HashMap::new(),
entry_point: None,
permissions: None,
contributes: Some(contributes),
lifecycle: None,
i18n: None,
},
directory: dir.to_owned(),
source: ExtensionSource::Local,
state: ExtensionState {
name: name.to_owned(),
version: "1.0.0".to_owned(),
enabled: true,
installed_at: None,
last_activated_at: None,
},
}
}
fn make_loaded_extension_with_i18n(name: &str, dir: &str, i18n: I18nConfig) -> LoadedExtension {
LoadedExtension {
manifest: ExtensionManifest {
name: name.to_owned(),
version: "1.0.0".to_owned(),
display_name: None,
description: None,
author: None,
license: None,
homepage: None,
icon: None,
engine: None,
api_version: None,
dependencies: HashMap::new(),
entry_point: None,
permissions: None,
contributes: None,
lifecycle: None,
i18n: Some(i18n),
},
directory: dir.to_owned(),
source: ExtensionSource::Local,
state: ExtensionState {
name: name.to_owned(),
version: "1.0.0".to_owned(),
enabled: true,
installed_at: None,
last_activated_at: None,
},
}
}
// ---------------------------------------------------------------------------
// CR-1: ACP Adapter resolution
// ---------------------------------------------------------------------------
#[test]
fn cr1_acp_adapter_resolved_with_env_and_avatar() {
unsafe { std::env::set_var("_CR1_API_KEY", "test-key-123") };
let mut env = HashMap::new();
env.insert("API_KEY".into(), "${_CR1_API_KEY}".into());
let contributes = ExtContributes {
acp_adapters: vec![ExtAcpAdapter {
id: "claude-adapter".into(),
name: "Claude Adapter".into(),
description: Some("Claude via ACP".into()),
cli_command: Some("claude".into()),
default_cli_path: None,
acp_args: vec!["--dangerously-skip-permissions".into()],
env,
avatar: Some("icons/claude.png".into()),
auth_required: Some(true),
supports_streaming: Some(true),
connection_type: Some("stdio".into()),
endpoint: None,
models: vec!["claude-sonnet-4-20250514".into()],
yolo_mode: Some(serde_json::json!({
"type": "session"
})),
health_check: None,
api_key_fields: vec![],
}],
..Default::default()
};
let ext = make_loaded_extension("claude-ext", "/ext/claude-ext", contributes);
let result = resolve_extension_contributions(&ext);
assert_eq!(result.acp_adapters.len(), 1);
let adapter = &result.acp_adapters[0];
assert_eq!(adapter.extension_name, "claude-ext");
assert_eq!(adapter.id, "claude-adapter");
assert_eq!(adapter.cli_command.as_deref(), Some("claude"));
assert_eq!(adapter.env["API_KEY"], "test-key-123");
assert!(adapter.avatar.as_ref().unwrap().contains("icons/claude.png"));
unsafe { std::env::remove_var("_CR1_API_KEY") };
}
// ---------------------------------------------------------------------------
// CR-2: MCP Server resolution
// ---------------------------------------------------------------------------
#[test]
fn cr2_mcp_server_resolved_as_opaque_config() {
let contributes = ExtContributes {
mcp_servers: vec![ExtMcpServer {
id: "sqlite-mcp".into(),
name: "SQLite MCP".into(),
description: Some("SQLite via MCP".into()),
config: serde_json::json!({
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-sqlite"],
"transport": "stdio"
}),
}],
..Default::default()
};
let ext = make_loaded_extension("sqlite-ext", "/ext/sqlite-ext", contributes);
let result = resolve_extension_contributions(&ext);
assert_eq!(result.mcp_servers.len(), 1);
let server = &result.mcp_servers[0];
assert_eq!(server.extension_name, "sqlite-ext");
assert_eq!(server.id, "sqlite-mcp");
assert_eq!(server.config["command"], "npx");
assert_eq!(server.config["transport"], "stdio");
}
// ---------------------------------------------------------------------------
// CR-3: Assistant resolution with @file: reference
// ---------------------------------------------------------------------------
#[test]
fn cr3_assistant_file_reference_resolved() {
let dir = std::env::temp_dir().join("cr3_assistant_resolve");
let prompts = dir.join("prompts");
std::fs::create_dir_all(&prompts).unwrap();
std::fs::write(prompts.join("system.md"), "You are a helpful coding assistant.").unwrap();
let contributes = ExtContributes {
assistants: vec![ExtAssistant {
id: "code-helper".into(),
name: "Code Helper".into(),
description: Some("AI coding assistant".into()),
system_prompt: Some("@file:prompts/system.md".into()),
icon: Some("icons/code.png".into()),
context: None,
preset_agent_type: Some("gemini".into()),
enabled_skills: vec!["code-review".into()],
prompts: vec!["Review this patch".into()],
models: vec!["gemini-2.0-flash".into()],
}],
..Default::default()
};
let ext = make_loaded_extension("helper-ext", &dir.to_string_lossy(), contributes);
let result = resolve_extension_contributions(&ext);
assert_eq!(result.assistants.len(), 1);
let assistant = &result.assistants[0];
assert_eq!(assistant.extension_name, "helper-ext");
assert_eq!(
assistant.system_prompt.as_deref(),
Some("You are a helpful coding assistant.")
);
assert_eq!(assistant.preset_agent_type.as_deref(), Some("gemini"));
assert_eq!(assistant.enabled_skills, vec!["code-review"]);
assert_eq!(assistant.prompts, vec!["Review this patch"]);
assert_eq!(assistant.models, vec!["gemini-2.0-flash"]);
std::fs::remove_dir_all(&dir).unwrap();
}
// ---------------------------------------------------------------------------
// CR-4: Agent resolution with @file: reference
// ---------------------------------------------------------------------------
#[test]
fn cr4_agent_file_reference_resolved() {
let dir = std::env::temp_dir().join("cr4_agent_resolve");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("agent_ctx.md"), "Agent context loaded from file.").unwrap();
let contributes = ExtContributes {
agents: vec![ExtAgent {
id: "auto-agent".into(),
name: "Auto Agent".into(),
description: Some("Autonomous coding agent".into()),
agent_type: Some("claude".into()),
context: Some("@file:agent_ctx.md".into()),
icon: None,
enabled_skills: vec!["ship-it".into()],
prompts: vec!["Fix the build".into()],
models: vec!["claude-sonnet-4".into()],
}],
..Default::default()
};
let ext = make_loaded_extension("agent-ext", &dir.to_string_lossy(), contributes);
let result = resolve_extension_contributions(&ext);
assert_eq!(result.agents.len(), 1);
let agent = &result.agents[0];
assert_eq!(agent.extension_name, "agent-ext");
assert_eq!(agent.agent_type.as_deref(), Some("claude"));
assert_eq!(agent.context.as_deref(), Some("Agent context loaded from file."));
assert_eq!(agent.enabled_skills, vec!["ship-it"]);
assert_eq!(agent.prompts, vec!["Fix the build"]);
assert_eq!(agent.models, vec!["claude-sonnet-4"]);
std::fs::remove_dir_all(&dir).unwrap();
}
// ---------------------------------------------------------------------------
// CR-5: Skill resolution
// ---------------------------------------------------------------------------
#[test]
fn cr5_skill_resolved_with_path() {
let dir = std::env::temp_dir().join("cr5_skill_resolved_with_path");
std::fs::create_dir_all(dir.join("skills")).unwrap();
std::fs::write(dir.join("skills/code-review.md"), "# review").unwrap();
let contributes = ExtContributes {
skills: vec![ExtSkill {
name: "code-review".into(),
description: Some("Review code for quality".into()),
path: Some("skills/code-review.md".into()),
}],
..Default::default()
};
let ext = make_loaded_extension("skill-ext", &dir.to_string_lossy(), contributes);
let result = resolve_extension_contributions(&ext);
assert_eq!(result.skills.len(), 1);
let skill = &result.skills[0];
assert_eq!(skill.extension_name, "skill-ext");
assert_eq!(skill.name, "code-review");
assert!(skill.path.as_ref().unwrap().contains("skills/code-review"));
std::fs::remove_dir_all(&dir).unwrap();
}
// ---------------------------------------------------------------------------
// CR-6: Theme resolution (CSS content loaded)
// ---------------------------------------------------------------------------
#[test]
fn cr6_theme_css_content_loaded() {
let dir = std::env::temp_dir().join("cr6_theme_resolve");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("dark.css"), ":root { --bg: #1a1a2e; --text: #eaeaea; }").unwrap();
let contributes = ExtContributes {
themes: vec![ExtTheme {
id: "dark-theme".into(),
name: "Dark Theme".into(),
description: Some("A dark color scheme".into()),
css_file: "dark.css".into(),
cover_image: Some("images/dark-preview.png".into()),
}],
..Default::default()
};
let ext = make_loaded_extension("theme-ext", &dir.to_string_lossy(), contributes);
let result = resolve_extension_contributions(&ext);
assert_eq!(result.themes.len(), 1);
let theme = &result.themes[0];
assert_eq!(theme.extension_name, "theme-ext");
assert_eq!(theme.css_content, ":root { --bg: #1a1a2e; --text: #eaeaea; }");
assert!(theme.cover_image.as_ref().unwrap().contains("images/dark-preview.png"));
std::fs::remove_dir_all(&dir).unwrap();
}
// ---------------------------------------------------------------------------
// CR-7: WebUI route namespace validation
// ---------------------------------------------------------------------------
#[test]
fn cr7_webui_valid_namespace_resolves() {
let contributes = ExtContributes {
webui: vec![ExtWebui {
id: "dashboard".into(),
directory: "dist".into(),
routes: vec![ExtWebuiRoute {
path: "/my-ext/dashboard".into(),
method: "GET".into(),
handler: "handler.js".into(),
}],
}],
..Default::default()
};
let ext = make_loaded_extension("my-ext", "/ext/my-ext", contributes);
let result = resolve_extension_contributions(&ext);
assert_eq!(result.webui.len(), 1);
assert_eq!(result.webui[0].extension_name, "my-ext");
}
#[test]
fn cr7_webui_wrong_namespace_rejected() {
let contributes = ExtContributes {
webui: vec![ExtWebui {
id: "bad-route".into(),
directory: "dist".into(),
routes: vec![ExtWebuiRoute {
path: "/other-ext/api".into(),
method: "GET".into(),
handler: "handler.js".into(),
}],
}],
..Default::default()
};
let ext = make_loaded_extension("my-ext", "/ext/my-ext", contributes);
let result = resolve_extension_contributions(&ext);
// Invalid route should be filtered out
assert!(result.webui.is_empty());
}
#[test]
fn cr7_webui_reserved_prefix_rejected() {
let contributes = ExtContributes {
webui: vec![ExtWebui {
id: "reserved".into(),
directory: "dist".into(),
routes: vec![ExtWebuiRoute {
path: "/api/data".into(),
method: "GET".into(),
handler: "handler.js".into(),
}],
}],
..Default::default()
};
// Extension name "api" would match namespace but /api/ is reserved
let ext = make_loaded_extension("api", "/ext/api", contributes);
let result = resolve_extension_contributions(&ext);
assert!(result.webui.is_empty());
}
// ---------------------------------------------------------------------------
// CR-8: Settings tab with position
// ---------------------------------------------------------------------------
#[test]
fn cr8_settings_tab_position_preserved() {
let contributes = ExtContributes {
settings_tabs: vec![ExtSettingsTab {
id: "ext-settings".into(),
label: "My Extension".into(),
icon: Some("icons/gear.svg".into()),
url: "settings/index.html".into(),
position: Some(SettingsTabPosition {
relative_to: "general".into(),
placement: "after".into(),
}),
order: 80,
}],
..Default::default()
};
let ext = make_loaded_extension("my-ext", "/ext/my-ext", contributes);
let result = resolve_extension_contributions(&ext);
assert_eq!(result.settings_tabs.len(), 1);
let tab = &result.settings_tabs[0];
assert_eq!(tab.extension_name, "my-ext");
assert_eq!(tab.id, "ext-my-ext-ext-settings");
assert_eq!(tab.url, "/api/extensions/my-ext/assets/settings/index.html");
assert_eq!(
tab.icon.as_deref(),
Some("/api/extensions/my-ext/assets/icons/gear.svg")
);
assert_eq!(tab.order, 80);
let pos = tab.position.as_ref().unwrap();
assert_eq!(pos.relative_to, "general");
assert_eq!(pos.placement, "after");
}
// ---------------------------------------------------------------------------
// CR-9: Model provider resolution
// ---------------------------------------------------------------------------
#[test]
fn cr9_model_provider_resolved() {
let contributes = ExtContributes {
model_providers: vec![ExtModelProvider {
id: "custom-provider".into(),
name: "Custom LLM".into(),
description: Some("Custom model provider".into()),
protocol: Some("openai".into()),
base_url: Some("https://api.custom.com/v1".into()),
models: vec!["custom-model-1".into(), "custom-model-2".into()],
}],
..Default::default()
};
let ext = make_loaded_extension("provider-ext", "/ext/provider-ext", contributes);
let result = resolve_extension_contributions(&ext);
assert_eq!(result.model_providers.len(), 1);
let provider = &result.model_providers[0];
assert_eq!(provider.extension_name, "provider-ext");
assert_eq!(provider.id, "custom-provider");
assert_eq!(provider.protocol.as_deref(), Some("openai"));
assert_eq!(provider.models.len(), 2);
}
// ---------------------------------------------------------------------------
// CR-10: i18n data loading
// ---------------------------------------------------------------------------
#[test]
fn cr10_i18n_data_loaded_for_supported_locale() {
let dir = std::env::temp_dir().join("cr10_i18n_resolve");
let i18n_dir = dir.join("i18n");
std::fs::create_dir_all(&i18n_dir).unwrap();
std::fs::write(
i18n_dir.join("zh-CN.json"),
r#"{"greeting": "你好", "settings.title": "设置"}"#,
)
.unwrap();
let ext = make_loaded_extension_with_i18n(
"i18n-ext",
&dir.to_string_lossy(),
I18nConfig {
locales: vec!["en".into(), "zh-CN".into()],
directory: "i18n".into(),
},
);
let result = resolve_i18n_for_all(&[ext], "zh-CN");
assert_eq!(result.len(), 1);
let messages = &result["i18n-ext"];
assert_eq!(messages["greeting"], "你好");
assert_eq!(messages["settings.title"], "设置");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn cr10_i18n_unsupported_locale_returns_empty() {
let dir = std::env::temp_dir().join("cr10_i18n_unsupported");
let i18n_dir = dir.join("i18n");
std::fs::create_dir_all(&i18n_dir).unwrap();
std::fs::write(i18n_dir.join("en.json"), r#"{"key": "value"}"#).unwrap();
let ext = make_loaded_extension_with_i18n(
"en-only-ext",
&dir.to_string_lossy(),
I18nConfig {
locales: vec!["en".into()],
directory: "i18n".into(),
},
);
let result = resolve_i18n_for_all(&[ext], "fr");
assert!(result.is_empty());
std::fs::remove_dir_all(&dir).unwrap();
}
// ---------------------------------------------------------------------------
// Cross-cutting: resolve_all_contributions merges and filters
// ---------------------------------------------------------------------------
#[test]
fn resolve_all_merges_contributions_from_multiple_extensions() {
let dir = std::env::temp_dir().join("resolve_all_merges_contributions_from_multiple_extensions");
std::fs::create_dir_all(dir.join("a/skills")).unwrap();
std::fs::create_dir_all(dir.join("b/skills")).unwrap();
std::fs::write(dir.join("a/skills/skill-a.md"), "# a").unwrap();
std::fs::write(dir.join("b/skills/skill-b.md"), "# b").unwrap();
let ext_a = make_loaded_extension(
"ext-a",
&dir.join("a").to_string_lossy(),
ExtContributes {
skills: vec![ExtSkill {
name: "skill-a".into(),
description: None,
path: Some("skills/skill-a.md".into()),
}],
model_providers: vec![ExtModelProvider {
id: "mp-a".into(),
name: "Provider A".into(),
description: None,
protocol: None,
base_url: None,
models: vec![],
}],
..Default::default()
},
);
let ext_b = make_loaded_extension(
"ext-b",
&dir.join("b").to_string_lossy(),
ExtContributes {
skills: vec![ExtSkill {
name: "skill-b".into(),
description: None,
path: Some("skills/skill-b.md".into()),
}],
..Default::default()
},
);
let result = resolve_all_contributions(&[ext_a, ext_b]);
assert_eq!(result.skills.len(), 2);
assert_eq!(result.model_providers.len(), 1);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn resolve_all_skips_disabled_extensions() {
let mut ext = make_loaded_extension(
"disabled-ext",
"/ext/disabled",
ExtContributes {
skills: vec![ExtSkill {
name: "hidden".into(),
description: None,
path: None,
}],
..Default::default()
},
);
ext.state.enabled = false;
let result = resolve_all_contributions(&[ext]);
assert!(result.skills.is_empty());
}
#[test]
fn channel_plugin_resolved_with_entry_point() {
let contributes = ExtContributes {
channel_plugins: vec![ExtChannelPlugin {
id: "slack".into(),
name: "Slack".into(),
description: Some("Slack channel plugin".into()),
platform: Some("slack".into()),
entry_point: Some("plugins/slack.js".into()),
icon: Some("icons/slack.png".into()),
credential_fields: vec![serde_json::json!({ "key": "token" })],
config_fields: vec![serde_json::json!({ "key": "channel" })],
}],
..Default::default()
};
let ext = make_loaded_extension("channel-ext", "/ext/channel-ext", contributes);
let result = resolve_extension_contributions(&ext);
assert_eq!(result.channel_plugins.len(), 1);
assert_eq!(result.channel_plugins[0].extension_name, "channel-ext");
assert!(
result.channel_plugins[0]
.entry_point
.as_ref()
.unwrap()
.contains("plugins/slack.js")
);
assert_eq!(result.channel_plugins[0].credential_fields.len(), 1);
assert_eq!(result.channel_plugins[0].config_fields.len(), 1);
}