Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,432 @@
//! Integration coverage for the `/api/skills/assistant-rule/*` and
//! `/api/skills/assistant-skill/*` source dispatch introduced by T1b.
//!
//! Exercises the three dispatch paths (builtin → assets, extension → empty,
//! user → writable dir) using a fake [`AssistantRuleDispatcher`] that
//! captures call inputs so we can assert the handler routed through it.
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use nomifun_api_types::{ApiResponse, AssistantSource};
use nomifun_common::AppError;
use nomifun_extension::classifier::{AssistantClassifier, AssistantRuleDispatcher};
use nomifun_extension::external_paths::ExternalPathsManager;
use nomifun_extension::skill_routes::{SkillRouterState, skill_routes};
use nomifun_extension::skill_service::SkillPaths;
use tower::ServiceExt;
// ---------------------------------------------------------------------------
// Fake dispatcher
// ---------------------------------------------------------------------------
#[derive(Default)]
struct CallLog {
rule_reads: Vec<(String, Option<String>)>,
rule_writes: Vec<(String, Option<String>, String)>,
rule_deletes: Vec<String>,
skill_reads: Vec<(String, Option<String>)>,
skill_writes: Vec<(String, Option<String>, String)>,
skill_deletes: Vec<String>,
}
struct FakeDispatcher {
// Pre-seeded responses by id.
rule_content: std::collections::HashMap<String, String>,
skill_content: std::collections::HashMap<String, String>,
// Ids that should be treated as builtin (write rejects with 400).
reject_writes_for: std::collections::HashSet<String>,
log: Mutex<CallLog>,
}
#[async_trait::async_trait]
impl AssistantClassifier for FakeDispatcher {
async fn classify(&self, id: &str) -> AssistantSource {
if self.reject_writes_for.contains(id) {
AssistantSource::Builtin
} else {
AssistantSource::User
}
}
}
#[async_trait::async_trait]
impl AssistantRuleDispatcher for FakeDispatcher {
async fn read_rule(&self, id: &str, locale: Option<&str>) -> Result<String, AppError> {
self.log
.lock()
.unwrap()
.rule_reads
.push((id.to_string(), locale.map(str::to_string)));
Ok(self.rule_content.get(id).cloned().unwrap_or_default())
}
async fn write_rule(&self, id: &str, locale: Option<&str>, content: &str) -> Result<(), AppError> {
if self.reject_writes_for.contains(id) {
return Err(AppError::BadRequest("Cannot write rule for built-in assistant".into()));
}
self.log
.lock()
.unwrap()
.rule_writes
.push((id.to_string(), locale.map(str::to_string), content.to_string()));
Ok(())
}
async fn delete_rule(&self, id: &str) -> Result<bool, AppError> {
if self.reject_writes_for.contains(id) {
return Err(AppError::BadRequest("Cannot delete rule for built-in assistant".into()));
}
self.log.lock().unwrap().rule_deletes.push(id.to_string());
Ok(true)
}
async fn read_skill(&self, id: &str, locale: Option<&str>) -> Result<String, AppError> {
self.log
.lock()
.unwrap()
.skill_reads
.push((id.to_string(), locale.map(str::to_string)));
Ok(self.skill_content.get(id).cloned().unwrap_or_default())
}
async fn write_skill(&self, id: &str, locale: Option<&str>, content: &str) -> Result<(), AppError> {
if self.reject_writes_for.contains(id) {
return Err(AppError::BadRequest("Cannot write skill for built-in assistant".into()));
}
self.log
.lock()
.unwrap()
.skill_writes
.push((id.to_string(), locale.map(str::to_string), content.to_string()));
Ok(())
}
async fn delete_skill(&self, id: &str) -> Result<bool, AppError> {
if self.reject_writes_for.contains(id) {
return Err(AppError::BadRequest(
"Cannot delete skill for built-in assistant".into(),
));
}
self.log.lock().unwrap().skill_deletes.push(id.to_string());
Ok(true)
}
}
// ---------------------------------------------------------------------------
// Router fixture
// ---------------------------------------------------------------------------
async fn router_with_dispatcher(dispatcher: Arc<FakeDispatcher>) -> axum::Router {
let tmp = tempfile::TempDir::new().unwrap();
let root: PathBuf = tmp.path().to_path_buf();
let paths = SkillPaths {
data_dir: root.clone(),
user_skills_dir: root.join("skills"),
cron_skills_dir: root.join("cron").join("skills"),
builtin_skills_dir: root.join("builtin-skills"),
builtin_rules_dir: root.join("builtin-rules"),
assistant_rules_dir: root.join("assistant-rules"),
assistant_skills_dir: root.join("assistant-skills"),
};
let ext_mgr = Arc::new(ExternalPathsManager::with_file(root.join("paths.json")).await);
std::mem::forget(tmp);
let state = SkillRouterState {
skill_paths: paths,
external_paths_manager: ext_mgr,
assistant_dispatcher: Some(dispatcher),
skill_tag_repo: {
let db = nomifun_db::init_database_memory().await.unwrap();
Arc::new(nomifun_db::SqliteSkillTagRepository::new(db.pool().clone()))
},
builtin_skill_tags: Arc::new(std::collections::HashMap::new()),
};
skill_routes(state)
}
async fn body_json<T: serde::de::DeserializeOwned>(resp: axum::response::Response) -> T {
let body = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&body).unwrap()
}
/// Construct the request body for rule/skill read using the wire field names.
fn read_body(assistant_id: &str, locale: Option<&str>) -> Vec<u8> {
let body = match locale {
Some(loc) => serde_json::json!({ "assistant_id": assistant_id, "locale": loc }),
None => serde_json::json!({ "assistant_id": assistant_id }),
};
serde_json::to_vec(&body).unwrap()
}
fn write_body(assistant_id: &str, content: &str, locale: Option<&str>) -> Vec<u8> {
let body = match locale {
Some(loc) => serde_json::json!({
"assistant_id": assistant_id,
"content": content,
"locale": loc,
}),
None => serde_json::json!({
"assistant_id": assistant_id,
"content": content,
}),
};
serde_json::to_vec(&body).unwrap()
}
// ---------------------------------------------------------------------------
// Test cases
// ---------------------------------------------------------------------------
#[tokio::test]
async fn read_rule_routes_through_dispatcher_for_builtin() {
let mut rule_content = std::collections::HashMap::new();
rule_content.insert("builtin-office".into(), "office rule body".into());
let dispatcher = Arc::new(FakeDispatcher {
rule_content,
skill_content: Default::default(),
reject_writes_for: Default::default(),
log: Mutex::new(CallLog::default()),
});
let router = router_with_dispatcher(dispatcher.clone()).await;
let req_body = read_body("builtin-office", Some("en-US"));
let req = Request::builder()
.method("POST")
.uri("/api/skills/assistant-rule/read")
.header("content-type", "application/json")
.body(Body::from(req_body))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body: ApiResponse<String> = body_json(resp).await;
assert_eq!(body.data.unwrap(), "office rule body");
let log = dispatcher.log.lock().unwrap();
assert_eq!(log.rule_reads.len(), 1);
assert_eq!(log.rule_reads[0].0, "builtin-office");
assert_eq!(log.rule_reads[0].1.as_deref(), Some("en-US"));
}
#[tokio::test]
async fn read_rule_routes_through_dispatcher_for_user() {
// Classification returns User by default (not in reject set).
let dispatcher = Arc::new(FakeDispatcher {
rule_content: std::collections::HashMap::from([("u1".into(), "user body".into())]),
skill_content: Default::default(),
reject_writes_for: Default::default(),
log: Mutex::new(CallLog::default()),
});
let router = router_with_dispatcher(dispatcher.clone()).await;
let req_body = read_body("u1", None);
let req = Request::builder()
.method("POST")
.uri("/api/skills/assistant-rule/read")
.header("content-type", "application/json")
.body(Body::from(req_body))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body: ApiResponse<String> = body_json(resp).await;
assert_eq!(body.data.unwrap(), "user body");
}
#[tokio::test]
async fn read_rule_routes_through_dispatcher_for_extension_returns_empty() {
let dispatcher = Arc::new(FakeDispatcher {
rule_content: Default::default(), // no content for any id
skill_content: Default::default(),
reject_writes_for: Default::default(),
log: Mutex::new(CallLog::default()),
});
let router = router_with_dispatcher(dispatcher).await;
let req_body = read_body("ext-assistant", Some("en-US"));
let req = Request::builder()
.method("POST")
.uri("/api/skills/assistant-rule/read")
.header("content-type", "application/json")
.body(Body::from(req_body))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body: ApiResponse<String> = body_json(resp).await;
assert_eq!(body.data.unwrap(), "");
}
#[tokio::test]
async fn write_rule_rejects_builtin() {
let mut reject = std::collections::HashSet::new();
reject.insert("builtin-office".to_string());
let dispatcher = Arc::new(FakeDispatcher {
rule_content: Default::default(),
skill_content: Default::default(),
reject_writes_for: reject,
log: Mutex::new(CallLog::default()),
});
let router = router_with_dispatcher(dispatcher).await;
let req_body = write_body("builtin-office", "hack", None);
let req = Request::builder()
.method("POST")
.uri("/api/skills/assistant-rule/write")
.header("content-type", "application/json")
.body(Body::from(req_body))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn write_rule_allows_user() {
let dispatcher = Arc::new(FakeDispatcher {
rule_content: Default::default(),
skill_content: Default::default(),
reject_writes_for: Default::default(),
log: Mutex::new(CallLog::default()),
});
let router = router_with_dispatcher(dispatcher.clone()).await;
let req_body = write_body("u1", "rule!", Some("en-US"));
let req = Request::builder()
.method("POST")
.uri("/api/skills/assistant-rule/write")
.header("content-type", "application/json")
.body(Body::from(req_body))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body: ApiResponse<bool> = body_json(resp).await;
assert!(body.data.unwrap());
let log = dispatcher.log.lock().unwrap();
assert_eq!(log.rule_writes.len(), 1);
assert_eq!(log.rule_writes[0].2, "rule!");
}
#[tokio::test]
async fn delete_rule_rejects_builtin() {
let mut reject = std::collections::HashSet::new();
reject.insert("builtin-office".to_string());
let dispatcher = Arc::new(FakeDispatcher {
rule_content: Default::default(),
skill_content: Default::default(),
reject_writes_for: reject,
log: Mutex::new(CallLog::default()),
});
let router = router_with_dispatcher(dispatcher).await;
let req = Request::builder()
.method("DELETE")
.uri("/api/skills/assistant-rule/builtin-office")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn delete_rule_user_dispatches() {
let dispatcher = Arc::new(FakeDispatcher {
rule_content: Default::default(),
skill_content: Default::default(),
reject_writes_for: Default::default(),
log: Mutex::new(CallLog::default()),
});
let router = router_with_dispatcher(dispatcher.clone()).await;
let req = Request::builder()
.method("DELETE")
.uri("/api/skills/assistant-rule/u1")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let log = dispatcher.log.lock().unwrap();
assert_eq!(log.rule_deletes, vec!["u1".to_string()]);
}
#[tokio::test]
async fn read_skill_routes_through_dispatcher_for_builtin() {
let dispatcher = Arc::new(FakeDispatcher {
rule_content: Default::default(),
skill_content: std::collections::HashMap::from([("builtin-office".into(), "skill body".into())]),
reject_writes_for: Default::default(),
log: Mutex::new(CallLog::default()),
});
let router = router_with_dispatcher(dispatcher.clone()).await;
let req_body = read_body("builtin-office", Some("en-US"));
let req = Request::builder()
.method("POST")
.uri("/api/skills/assistant-skill/read")
.header("content-type", "application/json")
.body(Body::from(req_body))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body: ApiResponse<String> = body_json(resp).await;
assert_eq!(body.data.unwrap(), "skill body");
let log = dispatcher.log.lock().unwrap();
assert_eq!(log.skill_reads.len(), 1);
}
#[tokio::test]
async fn write_skill_rejects_builtin() {
let mut reject = std::collections::HashSet::new();
reject.insert("builtin-office".to_string());
let dispatcher = Arc::new(FakeDispatcher {
rule_content: Default::default(),
skill_content: Default::default(),
reject_writes_for: reject,
log: Mutex::new(CallLog::default()),
});
let router = router_with_dispatcher(dispatcher).await;
let req_body = write_body("builtin-office", "x", None);
let req = Request::builder()
.method("POST")
.uri("/api/skills/assistant-skill/write")
.header("content-type", "application/json")
.body(Body::from(req_body))
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn delete_skill_user_dispatches() {
let dispatcher = Arc::new(FakeDispatcher {
rule_content: Default::default(),
skill_content: Default::default(),
reject_writes_for: Default::default(),
log: Mutex::new(CallLog::default()),
});
let router = router_with_dispatcher(dispatcher.clone()).await;
let req = Request::builder()
.method("DELETE")
.uri("/api/skills/assistant-skill/u1")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let log = dispatcher.log.lock().unwrap();
assert_eq!(log.skill_deletes, vec!["u1".to_string()]);
}
@@ -0,0 +1,602 @@
//! 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);
}
@@ -0,0 +1,45 @@
use nomifun_extension::{resolve_skill_paths, skill_service};
use std::time::{SystemTime, UNIX_EPOCH};
fn unique_temp_dir(label: &str) -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
std::env::temp_dir().join(format!("nomifun-extension-{label}-{}-{nanos}", std::process::id()))
}
#[tokio::test]
async fn resolve_skill_paths_includes_cron_skills_dir() {
let base = unique_temp_dir("cron-paths");
std::fs::create_dir_all(&base).unwrap();
let paths = resolve_skill_paths(&base, &base);
assert_eq!(paths.cron_skills_dir, base.join("cron").join("skills"));
std::fs::remove_dir_all(&base).unwrap();
}
#[tokio::test]
async fn materialize_resolves_saved_cron_skill() {
let base = unique_temp_dir("cron-materialize");
let skill_dir = base.join("cron").join("skills").join("cron-job-123");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: cron-job-123\ndescription: Saved cron skill\n---\nUse the saved steps.",
)
.unwrap();
let paths = resolve_skill_paths(&base, &base);
let resolved = skill_service::materialize_skills_for_agent(&paths, "conv-1", &["cron-job-123".to_owned()])
.await
.unwrap();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].name, "cron-job-123");
assert_eq!(resolved[0].source_path, skill_dir);
assert!(resolved[0].source_path.join("SKILL.md").exists());
std::fs::remove_dir_all(&base).unwrap();
}
@@ -0,0 +1,284 @@
//! Integration tests for dependency management (test-plan DM-1 through DM-9).
//!
//! Black-box tests exercising the public API surface of
//! `nomifun_extension::dependency`.
use std::collections::HashMap;
use nomifun_extension::{
DependencyIssue, ExtensionManifest, ExtensionSource, ExtensionState, LoadedExtension, validate_dependencies,
};
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
fn ext(name: &str, version: &str, deps: &[(&str, &str)]) -> LoadedExtension {
LoadedExtension {
manifest: ExtensionManifest {
name: name.to_string(),
version: version.to_string(),
display_name: None,
description: None,
author: None,
license: None,
homepage: None,
icon: None,
engine: None,
api_version: None,
dependencies: deps
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect::<HashMap<_, _>>(),
entry_point: None,
permissions: None,
contributes: None,
lifecycle: None,
i18n: None,
},
directory: format!("/extensions/{name}"),
source: ExtensionSource::Local,
state: ExtensionState {
name: name.to_string(),
version: version.to_string(),
enabled: true,
installed_at: None,
last_activated_at: None,
},
}
}
// ---------------------------------------------------------------------------
// DM-1: satisfied deps load in order
// ---------------------------------------------------------------------------
#[test]
fn dm1_satisfied_deps_load_in_order() {
let exts = vec![
ext("ext-a", "1.0.0", &[("ext-b", "^1.0.0")]),
ext("ext-b", "1.2.0", &[]),
];
let result = validate_dependencies(&exts);
assert!(result.valid);
assert!(result.issues.is_empty());
assert_eq!(result.load_order, vec!["ext-b", "ext-a"]);
}
// ---------------------------------------------------------------------------
// DM-2: missing dependency detection
// ---------------------------------------------------------------------------
#[test]
fn dm2_missing_dependency_detected() {
let exts = vec![ext("ext-a", "1.0.0", &[("ext-missing", "^1.0.0")])];
let result = validate_dependencies(&exts);
assert!(!result.valid);
assert_eq!(result.issues.len(), 1);
match &result.issues[0] {
DependencyIssue::Missing {
extension,
dependency,
required,
} => {
assert_eq!(extension, "ext-a");
assert_eq!(dependency, "ext-missing");
assert_eq!(required, "^1.0.0");
}
other => panic!("expected Missing issue, got {other:?}"),
}
}
// ---------------------------------------------------------------------------
// DM-3: version mismatch detection (exact)
// ---------------------------------------------------------------------------
#[test]
fn dm3_exact_version_mismatch() {
let exts = vec![ext("ext-b", "1.5.0", &[]), ext("ext-a", "1.0.0", &[("ext-b", "2.0.0")])];
let result = validate_dependencies(&exts);
assert!(!result.valid);
let mismatch = result
.issues
.iter()
.find(|i| matches!(i, DependencyIssue::VersionMismatch { .. }))
.expect("should contain VersionMismatch");
match mismatch {
DependencyIssue::VersionMismatch { required, actual, .. } => {
assert_eq!(required, "2.0.0");
assert_eq!(actual, "1.5.0");
}
_ => unreachable!(),
}
}
// ---------------------------------------------------------------------------
// DM-4: caret (^) match success
// ---------------------------------------------------------------------------
#[test]
fn dm4_caret_match_succeeds() {
let exts = vec![
ext("base", "1.9.0", &[]),
ext("consumer", "1.0.0", &[("base", "^1.2.3")]),
];
let result = validate_dependencies(&exts);
assert!(result.valid);
assert!(result.issues.is_empty());
}
// ---------------------------------------------------------------------------
// DM-5: caret (^) match failure
// ---------------------------------------------------------------------------
#[test]
fn dm5_caret_match_fails() {
let exts = vec![
ext("base", "2.0.0", &[]),
ext("consumer", "1.0.0", &[("base", "^1.2.3")]),
];
let result = validate_dependencies(&exts);
assert!(!result.valid);
assert!(
result
.issues
.iter()
.any(|i| matches!(i, DependencyIssue::VersionMismatch { .. }))
);
}
// ---------------------------------------------------------------------------
// DM-6: tilde (~) match success
// ---------------------------------------------------------------------------
#[test]
fn dm6_tilde_match_succeeds() {
let exts = vec![
ext("base", "1.2.9", &[]),
ext("consumer", "1.0.0", &[("base", "~1.2.3")]),
];
let result = validate_dependencies(&exts);
assert!(result.valid);
assert!(result.issues.is_empty());
}
// ---------------------------------------------------------------------------
// DM-7: tilde (~) match failure
// ---------------------------------------------------------------------------
#[test]
fn dm7_tilde_match_fails() {
let exts = vec![
ext("base", "1.3.0", &[]),
ext("consumer", "1.0.0", &[("base", "~1.2.3")]),
];
let result = validate_dependencies(&exts);
assert!(!result.valid);
assert!(
result
.issues
.iter()
.any(|i| matches!(i, DependencyIssue::VersionMismatch { .. }))
);
}
// ---------------------------------------------------------------------------
// DM-8: circular dependency detection
// ---------------------------------------------------------------------------
#[test]
fn dm8_circular_dependency_detected() {
let exts = vec![
ext("ext-a", "1.0.0", &[("ext-c", "^1.0.0")]),
ext("ext-b", "1.0.0", &[("ext-a", "^1.0.0")]),
ext("ext-c", "1.0.0", &[("ext-b", "^1.0.0")]),
];
let result = validate_dependencies(&exts);
assert!(!result.valid);
let circulars: Vec<&Vec<String>> = result
.issues
.iter()
.filter_map(|i| match i {
DependencyIssue::Circular { cycle } => Some(cycle),
_ => None,
})
.collect();
assert!(!circulars.is_empty(), "should detect at least one cycle");
// Verify cycle path closes (first == last) and contains all three.
let cycle = &circulars[0];
assert_eq!(cycle.first(), cycle.last(), "cycle must close");
let cycle_members: std::collections::HashSet<&str> = cycle.iter().map(|s| s.as_str()).collect();
assert!(cycle_members.contains("ext-a"));
assert!(cycle_members.contains("ext-b"));
assert!(cycle_members.contains("ext-c"));
// Still attempt to load all extensions.
assert_eq!(result.load_order.len(), 3);
}
// ---------------------------------------------------------------------------
// DM-9: extensions with no dependencies
// ---------------------------------------------------------------------------
#[test]
fn dm9_no_deps_all_valid() {
let exts = vec![
ext("ext-x", "1.0.0", &[]),
ext("ext-y", "2.0.0", &[]),
ext("ext-z", "0.1.0", &[]),
];
let result = validate_dependencies(&exts);
assert!(result.valid);
assert!(result.issues.is_empty());
assert_eq!(result.load_order.len(), 3);
}
// ---------------------------------------------------------------------------
// Additional edge cases
// ---------------------------------------------------------------------------
#[test]
fn mixed_acyclic_and_cyclic_subsets() {
// c is acyclic; a↔b form a cycle.
let exts = vec![
ext("a", "1.0.0", &[("b", "^1.0.0")]),
ext("b", "1.0.0", &[("a", "^1.0.0")]),
ext("c", "1.0.0", &[]),
];
let result = validate_dependencies(&exts);
assert!(!result.valid);
// c should come before the cyclic pair in load order.
let pos = |n: &str| result.load_order.iter().position(|x| x == n).unwrap();
assert!(pos("c") < pos("a"));
assert!(pos("c") < pos("b"));
}
#[test]
fn large_chain_preserves_order() {
// e → d → c → b → a
let exts = vec![
ext("a", "1.0.0", &[]),
ext("b", "1.0.0", &[("a", "^1.0.0")]),
ext("c", "1.0.0", &[("b", "^1.0.0")]),
ext("d", "1.0.0", &[("c", "^1.0.0")]),
ext("e", "1.0.0", &[("d", "^1.0.0")]),
];
let result = validate_dependencies(&exts);
assert!(result.valid);
assert_eq!(result.load_order, vec!["a", "b", "c", "d", "e"]);
}
#[test]
fn multiple_missing_deps_all_reported() {
let exts = vec![ext("ext-a", "1.0.0", &[("dep-1", "^1.0.0"), ("dep-2", "~2.0.0")])];
let result = validate_dependencies(&exts);
assert!(!result.valid);
let missing: Vec<_> = result
.issues
.iter()
.filter(|i| matches!(i, DependencyIssue::Missing { .. }))
.collect();
assert_eq!(missing.len(), 2);
}
@@ -0,0 +1,290 @@
//! Integration tests for extension loading (test-plan EL-1..EL-5).
//!
//! These tests exercise `load_all` and `filter_by_engine_compatibility` as
//! black-box functions, verifying scan priority, engine filtering, invalid
//! manifest handling, E2E isolation, and empty directory behaviour.
use std::fs;
use std::path::Path;
use nomifun_extension::{ExtensionSource, ScanPath, filter_by_engine_compatibility, load_all};
use tempfile::TempDir;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn write_manifest(dir: &Path, name: &str, version: &str) {
write_manifest_full(dir, name, version, None, None);
}
fn write_manifest_full(dir: &Path, name: &str, version: &str, engine_nomifun: Option<&str>, api_version: Option<&str>) {
let mut manifest = serde_json::json!({
"name": name,
"version": version,
});
if let Some(eng) = engine_nomifun {
manifest["engine"] = serde_json::json!({ "nomifun": eng });
}
if let Some(api) = api_version {
manifest["api_version"] = serde_json::json!(api);
}
fs::write(
dir.join("nomi-extension.json"),
serde_json::to_vec_pretty(&manifest).unwrap(),
)
.unwrap();
}
fn create_ext_dir(parent: &Path, name: &str) -> std::path::PathBuf {
let dir = parent.join(name);
fs::create_dir_all(&dir).unwrap();
dir
}
// ---------------------------------------------------------------------------
// EL-1: Scan priority — env > user > appdata, same-name deduplication
// ---------------------------------------------------------------------------
#[test]
fn el1_scan_priority_env_wins_over_local() {
let env_dir = TempDir::new().unwrap();
let local_dir = TempDir::new().unwrap();
let appdata_dir = TempDir::new().unwrap();
// Same extension name in all three directories with different versions.
let ext = create_ext_dir(env_dir.path(), "my-ext");
write_manifest(&ext, "my-ext", "3.0.0");
let ext = create_ext_dir(local_dir.path(), "my-ext");
write_manifest(&ext, "my-ext", "2.0.0");
let ext = create_ext_dir(appdata_dir.path(), "my-ext");
write_manifest(&ext, "my-ext", "1.0.0");
let scan_paths = vec![
ScanPath {
path: env_dir.path().to_path_buf(),
source: ExtensionSource::Env,
},
ScanPath {
path: local_dir.path().to_path_buf(),
source: ExtensionSource::Local,
},
ScanPath {
path: appdata_dir.path().to_path_buf(),
source: ExtensionSource::Appdata,
},
];
let loaded = load_all(&scan_paths);
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].manifest.version, "3.0.0");
assert_eq!(loaded[0].source, ExtensionSource::Env);
}
// ---------------------------------------------------------------------------
// EL-2: Engine compatibility filtering
// ---------------------------------------------------------------------------
#[test]
fn el2_engine_incompatible_extension_filtered_out() {
let tmp = TempDir::new().unwrap();
// Extension requires nomifun ^2.0.0 but app is 1.5.0.
let ext = create_ext_dir(tmp.path(), "future-ext");
write_manifest_full(&ext, "future-ext", "1.0.0", Some("^2.0.0"), None);
// Compatible extension.
let ext2 = create_ext_dir(tmp.path(), "good-ext");
write_manifest_full(&ext2, "good-ext", "1.0.0", Some("^1.0.0"), None);
let scan = vec![ScanPath {
path: tmp.path().to_path_buf(),
source: ExtensionSource::Local,
}];
let loaded = load_all(&scan);
assert_eq!(loaded.len(), 2);
let filtered = filter_by_engine_compatibility(loaded, "1.5.0");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].manifest.name, "good-ext");
}
// ---------------------------------------------------------------------------
// EL-3: Invalid manifest skipped, other extensions load normally
// ---------------------------------------------------------------------------
#[test]
fn el3_invalid_manifest_skipped_others_load() {
let tmp = TempDir::new().unwrap();
// Valid extension.
let good = create_ext_dir(tmp.path(), "valid-ext");
write_manifest(&good, "valid-ext", "1.0.0");
// Invalid JSON.
let bad = create_ext_dir(tmp.path(), "bad-json");
fs::write(bad.join("nomi-extension.json"), b"{ broken json").unwrap();
// Missing required fields.
let incomplete = create_ext_dir(tmp.path(), "incomplete");
fs::write(
incomplete.join("nomi-extension.json"),
serde_json::to_vec_pretty(&serde_json::json!({"name": "incomplete"})).unwrap(),
)
.unwrap();
let scan = vec![ScanPath {
path: tmp.path().to_path_buf(),
source: ExtensionSource::Local,
}];
let loaded = load_all(&scan);
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].manifest.name, "valid-ext");
}
// ---------------------------------------------------------------------------
// EL-4: E2E test mode isolation
//
// Since NOMIFUN_E2E_TEST=1 changes the behaviour of resolve_scan_paths()
// (a global function reading env vars), this test validates the semantics
// rather than calling resolve_scan_paths() directly to avoid data races
// with other tests.
//
// The behaviour is: when E2E mode is on, only ScanPaths with source=Env
// should be used. We validate this by checking that load_all with only Env
// sources works correctly.
// ---------------------------------------------------------------------------
#[test]
fn el4_e2e_test_mode_only_env_sources() {
let env_dir = TempDir::new().unwrap();
let local_dir = TempDir::new().unwrap();
let ext = create_ext_dir(env_dir.path(), "env-ext");
write_manifest(&ext, "env-ext", "1.0.0");
let ext = create_ext_dir(local_dir.path(), "local-ext");
write_manifest(&ext, "local-ext", "1.0.0");
// Simulate E2E mode: only include env-sourced paths.
let scan = vec![ScanPath {
path: env_dir.path().to_path_buf(),
source: ExtensionSource::Env,
}];
let loaded = load_all(&scan);
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].manifest.name, "env-ext");
assert_eq!(loaded[0].source, ExtensionSource::Env);
}
// ---------------------------------------------------------------------------
// EL-5: Empty scan directories → empty result, no error
// ---------------------------------------------------------------------------
#[test]
fn el5_empty_scan_directories_return_empty() {
let empty1 = TempDir::new().unwrap();
let empty2 = TempDir::new().unwrap();
let scan = vec![
ScanPath {
path: empty1.path().to_path_buf(),
source: ExtensionSource::Local,
},
ScanPath {
path: empty2.path().to_path_buf(),
source: ExtensionSource::Appdata,
},
];
let loaded = load_all(&scan);
assert!(loaded.is_empty());
}
#[test]
fn el5_nonexistent_scan_directory_returns_empty() {
let scan = vec![ScanPath {
path: std::path::PathBuf::from("/nonexistent/extensions/dir"),
source: ExtensionSource::Local,
}];
let loaded = load_all(&scan);
assert!(loaded.is_empty());
}
// ---------------------------------------------------------------------------
// Additional edge cases
// ---------------------------------------------------------------------------
#[test]
fn api_version_filtering() {
let tmp = TempDir::new().unwrap();
// Extension requiring API 2.0.0 — incompatible with current 1.0.0.
let ext = create_ext_dir(tmp.path(), "future-api");
write_manifest_full(&ext, "future-api", "1.0.0", None, Some("2.0.0"));
// Extension with compatible API version.
let ext2 = create_ext_dir(tmp.path(), "current-api");
write_manifest_full(&ext2, "current-api", "1.0.0", None, Some("1.0.0"));
// Extension with no API version constraint.
let ext3 = create_ext_dir(tmp.path(), "no-api");
write_manifest(&ext3, "no-api", "1.0.0");
let scan = vec![ScanPath {
path: tmp.path().to_path_buf(),
source: ExtensionSource::Local,
}];
let loaded = load_all(&scan);
assert_eq!(loaded.len(), 3);
let filtered = filter_by_engine_compatibility(loaded, "1.0.0");
assert_eq!(filtered.len(), 2);
let names: Vec<&str> = filtered.iter().map(|e| e.manifest.name.as_str()).collect();
assert!(names.contains(&"current-api"));
assert!(names.contains(&"no-api"));
assert!(!names.contains(&"future-api"));
}
#[test]
fn multiple_extensions_from_single_directory() {
let tmp = TempDir::new().unwrap();
for i in 0..5 {
let ext = create_ext_dir(tmp.path(), &format!("ext-{i}"));
write_manifest(&ext, &format!("ext-{i}"), "1.0.0");
}
let scan = vec![ScanPath {
path: tmp.path().to_path_buf(),
source: ExtensionSource::Local,
}];
let loaded = load_all(&scan);
assert_eq!(loaded.len(), 5);
}
#[test]
fn extension_state_defaults_to_enabled() {
let tmp = TempDir::new().unwrap();
let ext = create_ext_dir(tmp.path(), "my-ext");
write_manifest(&ext, "my-ext", "1.0.0");
let scan = vec![ScanPath {
path: tmp.path().to_path_buf(),
source: ExtensionSource::Local,
}];
let loaded = load_all(&scan);
assert_eq!(loaded.len(), 1);
assert!(loaded[0].state.enabled);
assert_eq!(loaded[0].state.name, "my-ext");
assert_eq!(loaded[0].state.version, "1.0.0");
}
@@ -0,0 +1,6 @@
---
name: example-skill
description: Fixture skill used by startup_materialize integration tests.
---
Fixture body.
@@ -0,0 +1,397 @@
//! Integration tests for Hub extension marketplace (test-plan HM scenarios).
use std::sync::Arc;
use nomifun_extension::hub::{HubIndexManager, HubInstaller};
use nomifun_extension::registry::ExtensionRegistry;
use nomifun_extension::state::ExtensionStateStore;
use nomifun_extension::types::HubExtensionStatus;
use nomifun_realtime::BroadcastEventBus;
use serde_json::json;
use tempfile::TempDir;
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
struct TestHarness {
hub_dir: TempDir,
index_manager: HubIndexManager,
installer: HubInstaller,
registry: ExtensionRegistry,
_state_dir: TempDir,
}
fn setup() -> TestHarness {
let hub_dir = TempDir::new().unwrap();
let state_dir = TempDir::new().unwrap();
let store = ExtensionStateStore::new(state_dir.path().join("states.json"));
let bus = Arc::new(BroadcastEventBus::new(64));
let registry = ExtensionRegistry::new(store, bus, "1.0.0".into());
let index_manager = HubIndexManager::new(hub_dir.path().to_path_buf(), registry.clone());
let installer = HubInstaller::new(index_manager.clone(), registry.clone());
TestHarness {
hub_dir,
index_manager,
installer,
registry,
_state_dir: state_dir,
}
}
fn write_hub_index(hub_dir: &std::path::Path, extensions: &[serde_json::Value]) {
let index = json!({
"schema_version": 1,
"extensions": extensions,
});
std::fs::write(hub_dir.join("index.json"), serde_json::to_vec_pretty(&index).unwrap()).unwrap();
}
fn write_extension_manifest(ext_dir: &std::path::Path, name: &str, version: &str) {
std::fs::create_dir_all(ext_dir).unwrap();
let manifest = json!({
"name": name,
"version": version,
});
std::fs::write(
ext_dir.join("nomi-extension.json"),
serde_json::to_vec_pretty(&manifest).unwrap(),
)
.unwrap();
}
// ---------------------------------------------------------------------------
// HM-1: Get extension list
// ---------------------------------------------------------------------------
#[tokio::test]
async fn hm1_empty_index_returns_empty_list() {
let h = setup();
// No index.json at all.
let list = h.index_manager.load_index().await;
assert!(list.is_empty());
}
#[tokio::test]
async fn hm1_load_index_with_entries() {
let h = setup();
write_hub_index(
h.hub_dir.path(),
&[
json!({
"name": "ext-alpha",
"version": "1.0.0",
"display_name": "Alpha Extension",
"tags": ["tools"],
"bundled": false,
}),
json!({
"name": "ext-beta",
"version": "2.0.0",
"bundled": true,
}),
],
);
let list = h.index_manager.load_index().await;
assert_eq!(list.len(), 2);
assert_eq!(list[0].name, "ext-alpha");
assert_eq!(list[0].status, HubExtensionStatus::NotInstalled);
assert_eq!(list[1].name, "ext-beta");
// Bundled always installed.
assert_eq!(list[1].status, HubExtensionStatus::Installed);
}
// ---------------------------------------------------------------------------
// HM-2: Install extension
// ---------------------------------------------------------------------------
#[tokio::test]
async fn hm2_install_extension_with_valid_directory() {
let h = setup();
// Create index entry and extension directory.
write_hub_index(
h.hub_dir.path(),
&[json!({
"name": "my-ext",
"version": "1.0.0",
})],
);
write_extension_manifest(&h.hub_dir.path().join("my-ext"), "my-ext", "1.0.0");
let result = h.installer.install("my-ext").await;
assert!(result.success, "install should succeed: {:?}", result.msg);
}
// ---------------------------------------------------------------------------
// HM-3: Install failure (nonexistent extension)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn hm3_install_nonexistent_extension() {
let h = setup();
write_hub_index(h.hub_dir.path(), &[]);
let result = h.installer.install("nonexistent-ext").await;
assert!(!result.success);
assert!(result.msg.is_some());
assert!(result.msg.unwrap().contains("not found in hub index"));
}
#[tokio::test]
async fn hm3_install_no_directory() {
let h = setup();
// Extension is in the index but no directory exists.
write_hub_index(
h.hub_dir.path(),
&[json!({
"name": "remote-ext",
"version": "1.0.0",
})],
);
let result = h.installer.install("remote-ext").await;
assert!(!result.success);
assert!(result.msg.is_some());
assert!(result.msg.unwrap().contains("not found"));
}
// ---------------------------------------------------------------------------
// HM-4: Retry install
// ---------------------------------------------------------------------------
#[tokio::test]
async fn hm4_retry_install_succeeds_after_directory_created() {
let h = setup();
write_hub_index(
h.hub_dir.path(),
&[json!({
"name": "retry-ext",
"version": "1.0.0",
})],
);
// First attempt fails — no directory.
let result = h.installer.retry_install("retry-ext").await;
assert!(!result.success);
// Create the directory, then retry.
write_extension_manifest(&h.hub_dir.path().join("retry-ext"), "retry-ext", "1.0.0");
let result = h.installer.retry_install("retry-ext").await;
assert!(result.success);
}
// ---------------------------------------------------------------------------
// HM-5: Check updates
// ---------------------------------------------------------------------------
#[tokio::test]
async fn hm5_check_updates_empty_when_no_installed() {
let h = setup();
write_hub_index(
h.hub_dir.path(),
&[json!({
"name": "ext-a",
"version": "2.0.0",
})],
);
let updates = h.installer.check_updates().await;
// No extensions installed → no updates.
assert!(updates.is_empty());
}
#[tokio::test]
async fn hm5_check_updates_detects_newer_version() {
let h = setup();
// Create an extension and initialize the registry with it.
let ext_dir = h.hub_dir.path().join("my-ext");
write_extension_manifest(&ext_dir, "my-ext", "1.0.0");
let scan_paths = vec![nomifun_extension::loader::ScanPath {
path: h.hub_dir.path().to_path_buf(),
source: nomifun_extension::types::ExtensionSource::Env,
}];
h.registry.initialize_with_scan_paths(scan_paths).await.unwrap();
// Index has a newer version.
write_hub_index(
h.hub_dir.path(),
&[json!({
"name": "my-ext",
"version": "2.0.0",
})],
);
let updates = h.installer.check_updates().await;
assert_eq!(updates.len(), 1);
assert_eq!(updates[0].name, "my-ext");
assert_eq!(updates[0].current_version, "1.0.0");
assert_eq!(updates[0].latest_version, "2.0.0");
}
// ---------------------------------------------------------------------------
// HM-6: Update extension
// ---------------------------------------------------------------------------
#[tokio::test]
async fn hm6_update_extension() {
let h = setup();
write_hub_index(
h.hub_dir.path(),
&[json!({
"name": "upd-ext",
"version": "2.0.0",
})],
);
// Extension directory exists with a valid manifest.
write_extension_manifest(&h.hub_dir.path().join("upd-ext"), "upd-ext", "2.0.0");
let result = h.installer.update("upd-ext").await;
assert!(result.success, "update should succeed: {:?}", result.msg);
}
#[tokio::test]
async fn hm6_update_nonexistent_extension() {
let h = setup();
write_hub_index(h.hub_dir.path(), &[]);
let result = h.installer.update("no-ext").await;
assert!(!result.success);
}
// ---------------------------------------------------------------------------
// HM-7: Bundled extension status
// ---------------------------------------------------------------------------
#[tokio::test]
async fn hm7_bundled_extensions_always_installed() {
let h = setup();
write_hub_index(
h.hub_dir.path(),
&[json!({
"name": "bundled-ext",
"version": "1.0.0",
"bundled": true,
})],
);
let list = h.index_manager.load_index().await;
assert_eq!(list.len(), 1);
assert_eq!(list[0].status, HubExtensionStatus::Installed);
assert!(list[0].bundled);
}
// ---------------------------------------------------------------------------
// HM (extra): Uninstall extension
// ---------------------------------------------------------------------------
#[tokio::test]
async fn hub_uninstall_removes_directory() {
let h = setup();
let ext_dir = h.hub_dir.path().join("remove-ext");
write_extension_manifest(&ext_dir, "remove-ext", "1.0.0");
assert!(ext_dir.exists());
let result = h.installer.uninstall("remove-ext").await;
assert!(result.success, "uninstall should succeed: {:?}", result.msg);
assert!(!ext_dir.exists(), "extension directory should be removed");
}
#[tokio::test]
async fn hub_uninstall_nonexistent_returns_error() {
let h = setup();
let result = h.installer.uninstall("no-such-ext").await;
assert!(!result.success);
assert!(result.msg.unwrap().contains("not installed"));
}
// ---------------------------------------------------------------------------
// Verification tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn verify_installation_catches_invalid_manifest() {
let h = setup();
let ext_dir = h.hub_dir.path().join("bad-ext");
std::fs::create_dir_all(&ext_dir).unwrap();
std::fs::write(ext_dir.join("nomi-extension.json"), b"not valid json").unwrap();
let result = h.installer.verify_installation(&ext_dir);
assert!(result.is_err());
}
#[tokio::test]
async fn verify_installation_catches_reserved_name() {
let h = setup();
let ext_dir = h.hub_dir.path().join("nomi-bad");
write_extension_manifest(&ext_dir, "nomi-bad", "1.0.0");
let result = h.installer.verify_installation(&ext_dir);
assert!(result.is_err());
}
// ---------------------------------------------------------------------------
// Index status merge
// ---------------------------------------------------------------------------
#[tokio::test]
async fn index_merge_shows_update_available() {
let h = setup();
// Initialize registry with v1.0.0.
let ext_dir = h.hub_dir.path().join("status-ext");
write_extension_manifest(&ext_dir, "status-ext", "1.0.0");
let scan_paths = vec![nomifun_extension::loader::ScanPath {
path: h.hub_dir.path().to_path_buf(),
source: nomifun_extension::types::ExtensionSource::Env,
}];
h.registry.initialize_with_scan_paths(scan_paths).await.unwrap();
// Index advertises v2.0.0.
write_hub_index(
h.hub_dir.path(),
&[json!({
"name": "status-ext",
"version": "2.0.0",
})],
);
let list = h.index_manager.load_index().await;
assert_eq!(list.len(), 1);
assert_eq!(list[0].status, HubExtensionStatus::UpdateAvailable);
}
#[tokio::test]
async fn index_merge_shows_installed_when_same_version() {
let h = setup();
let ext_dir = h.hub_dir.path().join("same-ext");
write_extension_manifest(&ext_dir, "same-ext", "1.0.0");
let scan_paths = vec![nomifun_extension::loader::ScanPath {
path: h.hub_dir.path().to_path_buf(),
source: nomifun_extension::types::ExtensionSource::Env,
}];
h.registry.initialize_with_scan_paths(scan_paths).await.unwrap();
write_hub_index(
h.hub_dir.path(),
&[json!({
"name": "same-ext",
"version": "1.0.0",
})],
);
let list = h.index_manager.load_index().await;
assert_eq!(list.len(), 1);
assert_eq!(list[0].status, HubExtensionStatus::Installed);
}
@@ -0,0 +1,327 @@
//! Integration tests for lifecycle hooks (test-plan LH-1..LH-6).
//!
//! These tests exercise `execute_hook`, `needs_install_hook`, and
//! `resolve_hook_path` as black-box functions, verifying first install,
//! version change, activate/deactivate execution, timeout behaviour,
//! and graceful handling of missing scripts.
use std::fs;
use std::path::Path;
use nomifun_extension::{HookKind, LifecycleHooks, execute_hook, needs_install_hook, resolve_hook_path};
use tempfile::TempDir;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Write a platform-appropriate lifecycle hook script and return its path
/// relative to `dir` (including the platform-correct extension) to hand to
/// `execute_hook`.
///
/// `rel_stem` is the relative path WITHOUT a file extension (e.g.
/// `"scripts/install"`). On Windows a `.cmd` batch file is written using
/// `windows_body`; elsewhere a `#!/bin/sh` script (made executable) using
/// `unix_body`. `execute_hook`'s extension→interpreter dispatch then picks the
/// matching interpreter (`cmd /C` vs `sh`).
fn write_script(dir: &Path, rel_stem: &str, unix_body: &str, windows_body: &str) -> String {
#[cfg(windows)]
let rel_path = format!("{rel_stem}.cmd");
#[cfg(not(windows))]
let rel_path = format!("{rel_stem}.sh");
let full = dir.join(&rel_path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).unwrap();
}
#[cfg(windows)]
{
// `@echo off` stops the interpreter from echoing commands into stdout;
// CRLF endings keep cmd.exe happy.
let content = format!("@echo off\r\n{}\r\n", windows_body.replace('\n', "\r\n"));
fs::write(&full, content).unwrap();
let _ = unix_body;
}
#[cfg(not(windows))]
{
fs::write(&full, format!("#!/bin/sh\n{unix_body}\n")).unwrap();
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&full, fs::Permissions::from_mode(0o755)).unwrap();
}
let _ = windows_body;
}
rel_path
}
fn setup_ext_dir() -> TempDir {
tempfile::tempdir().unwrap()
}
// ---------------------------------------------------------------------------
// LH-1: First install executes onInstall
// ---------------------------------------------------------------------------
#[tokio::test]
async fn lh1_first_install_executes_on_install() {
let dir = setup_ext_dir();
let marker = dir.path().join("installed.marker");
let rel = write_script(
dir.path(),
"scripts/install",
&format!("touch '{}'", marker.display()),
&format!("type nul > \"{}\"", marker.display()),
);
let hooks = LifecycleHooks {
on_install: Some(rel),
..Default::default()
};
// First install: no persisted version
assert!(needs_install_hook("1.0.0", None));
let hook_path = resolve_hook_path(&hooks, HookKind::OnInstall).unwrap();
let result = execute_hook(dir.path(), hook_path, HookKind::OnInstall, "test-ext").await;
assert!(result.is_ok());
assert!(marker.exists(), "onInstall marker file should be created");
}
// ---------------------------------------------------------------------------
// LH-2: Version change executes onInstall
// ---------------------------------------------------------------------------
#[tokio::test]
async fn lh2_version_change_executes_on_install() {
let dir = setup_ext_dir();
let marker = dir.path().join("upgraded.marker");
let rel = write_script(
dir.path(),
"scripts/install",
&format!("touch '{}'", marker.display()),
&format!("type nul > \"{}\"", marker.display()),
);
let hooks = LifecycleHooks {
on_install: Some(rel),
..Default::default()
};
// Version changed from 1.0.0 to 2.0.0
assert!(needs_install_hook("2.0.0", Some("1.0.0")));
let hook_path = resolve_hook_path(&hooks, HookKind::OnInstall).unwrap();
let result = execute_hook(dir.path(), hook_path, HookKind::OnInstall, "test-ext").await;
assert!(result.is_ok());
assert!(marker.exists(), "onInstall marker should be created on upgrade");
}
// ---------------------------------------------------------------------------
// LH-2 (negative): Same version does NOT trigger onInstall
// ---------------------------------------------------------------------------
#[test]
fn lh2_same_version_skips_install() {
assert!(!needs_install_hook("1.0.0", Some("1.0.0")));
}
// ---------------------------------------------------------------------------
// LH-3: Each activation executes onActivate
// ---------------------------------------------------------------------------
#[tokio::test]
async fn lh3_activate_executes_on_activate() {
let dir = setup_ext_dir();
let counter_file = dir.path().join("activate_count.txt");
// Append a line on each activation to count calls
let rel = write_script(
dir.path(),
"scripts/activate",
&format!("echo 'activated' >> '{}'", counter_file.display()),
&format!("echo activated >> \"{}\"", counter_file.display()),
);
let hooks = LifecycleHooks {
on_activate: Some(rel),
..Default::default()
};
let hook_path = resolve_hook_path(&hooks, HookKind::OnActivate).unwrap();
// Activate twice
execute_hook(dir.path(), hook_path, HookKind::OnActivate, "test-ext")
.await
.unwrap();
execute_hook(dir.path(), hook_path, HookKind::OnActivate, "test-ext")
.await
.unwrap();
let content = fs::read_to_string(&counter_file).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2, "onActivate should run on each activation");
}
// ---------------------------------------------------------------------------
// LH-4: Deactivation executes onDeactivate
// ---------------------------------------------------------------------------
#[tokio::test]
async fn lh4_deactivate_executes_on_deactivate() {
let dir = setup_ext_dir();
let marker = dir.path().join("deactivated.marker");
let rel = write_script(
dir.path(),
"scripts/deactivate",
&format!("touch '{}'", marker.display()),
&format!("type nul > \"{}\"", marker.display()),
);
let hooks = LifecycleHooks {
on_deactivate: Some(rel),
..Default::default()
};
let hook_path = resolve_hook_path(&hooks, HookKind::OnDeactivate).unwrap();
let result = execute_hook(dir.path(), hook_path, HookKind::OnDeactivate, "test-ext").await;
assert!(result.is_ok());
assert!(marker.exists(), "onDeactivate marker should be created");
}
// ---------------------------------------------------------------------------
// LH-5: Hook timeout
// ---------------------------------------------------------------------------
#[tokio::test]
async fn lh5_hook_timeout() {
let dir = setup_ext_dir();
// A hook that outlives the short deadline below on each platform: unix
// `sleep`, Windows `ping` to localhost (a portable busy-wait — Windows has
// no `sleep`). `CmdBuilder::output()` blocks the executor (not cooperatively
// cancellable), so the wrapping `timeout` only reports `Elapsed` after the
// child exits — keep it short (~1s) so the test is fast while still
// comfortably exceeding the 200ms deadline.
let rel = write_script(
dir.path(),
"scripts/slow",
"sleep 1",
"ping -n 2 127.0.0.1 >NUL",
);
let hooks = LifecycleHooks {
on_activate: Some(rel.clone()),
..Default::default()
};
let hook_path = resolve_hook_path(&hooks, HookKind::OnActivate).unwrap();
assert_eq!(hook_path, rel);
assert!(dir.path().join(&rel).exists());
// `execute_hook`'s built-in timeout (30s+) is far too long for a unit
// test, so we wrap our own short deadline around it. The hook never
// finishes within 200ms, so the deadline elapses (Err == timed out).
// Critically, this routes through `execute_hook` → the real interpreter
// dispatch + spawn — on the old code a Windows `.sh` spawn failed
// instantly and this would resolve before the deadline (Ok), the exact
// regression this guards.
let result = tokio::time::timeout(
std::time::Duration::from_millis(200),
execute_hook(dir.path(), hook_path, HookKind::OnActivate, "test-ext"),
)
.await;
assert!(result.is_err(), "should time out before script completes");
}
// ---------------------------------------------------------------------------
// LH-6: Hook script does not exist — graceful handling
// ---------------------------------------------------------------------------
#[tokio::test]
async fn lh6_missing_script_graceful() {
let dir = setup_ext_dir();
let hooks = LifecycleHooks {
on_activate: Some("nonexistent.sh".into()),
..Default::default()
};
let hook_path = resolve_hook_path(&hooks, HookKind::OnActivate).unwrap();
let result = execute_hook(dir.path(), hook_path, HookKind::OnActivate, "test-ext").await;
assert!(result.is_err());
match result.unwrap_err() {
nomifun_extension::ExtensionError::HookNotFound(path) => {
assert!(
path.contains("nonexistent.sh"),
"error should mention the missing script path"
);
}
other => panic!("expected HookNotFound, got {other:?}"),
}
}
// ---------------------------------------------------------------------------
// Additional: resolve_hook_path returns None when hook is not declared
// ---------------------------------------------------------------------------
#[test]
fn resolve_hook_path_none_when_not_declared() {
let hooks = LifecycleHooks::default();
assert!(resolve_hook_path(&hooks, HookKind::OnInstall).is_none());
assert!(resolve_hook_path(&hooks, HookKind::OnUninstall).is_none());
assert!(resolve_hook_path(&hooks, HookKind::OnActivate).is_none());
assert!(resolve_hook_path(&hooks, HookKind::OnDeactivate).is_none());
}
// ---------------------------------------------------------------------------
// Additional: Hook script exits with non-zero status
// ---------------------------------------------------------------------------
#[tokio::test]
async fn hook_nonzero_exit_returns_hook_failed() {
let dir = setup_ext_dir();
let rel = write_script(
dir.path(),
"scripts/fail",
"echo 'setup failed' >&2; exit 42",
"echo setup failed 1>&2 & exit /b 42",
);
let result = execute_hook(dir.path(), &rel, HookKind::OnInstall, "failing-ext").await;
assert!(result.is_err());
match result.unwrap_err() {
nomifun_extension::ExtensionError::HookFailed {
extension_name,
hook,
reason,
} => {
assert_eq!(extension_name, "failing-ext");
assert_eq!(hook, "onInstall");
assert!(reason.contains("42"), "should include exit code");
assert!(reason.contains("setup failed"), "should include stderr");
}
other => panic!("expected HookFailed, got {other:?}"),
}
}
// ---------------------------------------------------------------------------
// Additional: Hook uses working directory correctly
// ---------------------------------------------------------------------------
#[tokio::test]
async fn hook_working_directory_is_ext_dir() {
let dir = setup_ext_dir();
// print cwd to a file: unix `pwd`, cmd `cd` with no args prints cwd.
let rel = write_script(dir.path(), "check_dir", "pwd > cwd_out.txt", "cd > cwd_out.txt");
let result = execute_hook(dir.path(), &rel, HookKind::OnActivate, "cwd-ext").await;
assert!(result.is_ok());
let cwd_file = dir.path().join("cwd_out.txt");
assert!(cwd_file.exists());
let cwd = fs::read_to_string(&cwd_file).unwrap();
let expected = dir.path().canonicalize().unwrap();
let actual = std::path::Path::new(cwd.trim()).canonicalize().unwrap();
assert_eq!(actual, expected);
}
@@ -0,0 +1,146 @@
//! Integration tests for manifest validation (test-plan MV-1 through MV-4).
//!
//! These test the public API surface of `nomifun_extension::manifest`.
use nomifun_extension::{ExtensionError, ExtensionManifest, parse_manifest, validate_manifest};
// -- MV-1: valid manifest loads successfully --
#[test]
fn mv1_valid_manifest_parses_and_validates() {
let json = serde_json::json!({
"name": "my-cool-extension",
"version": "1.0.0",
"display_name": "My Cool Extension",
"description": "A test extension",
"contributes": {
"skills": [{ "name": "test-skill" }]
}
});
let bytes = serde_json::to_vec(&json).unwrap();
let manifest = parse_manifest(&bytes).unwrap();
assert_eq!(manifest.name, "my-cool-extension");
assert_eq!(manifest.version, "1.0.0");
assert!(manifest.contributes.is_some());
}
// -- MV-2: reserved name prefix rejected --
#[test]
fn mv2_reserved_prefix_nomi_rejected() {
let json = serde_json::json!({"name": "nomi-my-ext", "version": "1.0.0"});
let bytes = serde_json::to_vec(&json).unwrap();
let err = parse_manifest(&bytes).unwrap_err();
assert!(matches!(err, ExtensionError::ReservedNamePrefix { ref prefix, .. } if prefix == "nomi-"));
}
#[test]
fn mv2_reserved_prefix_internal_rejected() {
let json = serde_json::json!({"name": "internal-utils", "version": "1.0.0"});
let bytes = serde_json::to_vec(&json).unwrap();
assert!(parse_manifest(&bytes).is_err());
}
#[test]
fn mv2_reserved_prefix_builtin_rejected() {
let json = serde_json::json!({"name": "builtin-theme", "version": "1.0.0"});
let bytes = serde_json::to_vec(&json).unwrap();
assert!(parse_manifest(&bytes).is_err());
}
#[test]
fn mv2_reserved_prefix_system_rejected() {
let json = serde_json::json!({"name": "system-core", "version": "1.0.0"});
let bytes = serde_json::to_vec(&json).unwrap();
assert!(parse_manifest(&bytes).is_err());
}
// -- MV-3: missing required fields --
#[test]
fn mv3_missing_version_rejected() {
let json = serde_json::json!({"name": "my-ext"});
let bytes = serde_json::to_vec(&json).unwrap();
// serde will fail because `version` is a required field
assert!(parse_manifest(&bytes).is_err());
}
#[test]
fn mv3_missing_name_rejected() {
let json = serde_json::json!({"version": "1.0.0"});
let bytes = serde_json::to_vec(&json).unwrap();
assert!(parse_manifest(&bytes).is_err());
}
// -- MV-4: invalid version rejected --
#[test]
fn mv4_invalid_version_rejected() {
let json = serde_json::json!({"name": "my-ext", "version": "not-semver"});
let bytes = serde_json::to_vec(&json).unwrap();
let err = parse_manifest(&bytes).unwrap_err();
assert!(matches!(err, ExtensionError::InvalidVersion { .. }));
}
#[test]
fn mv4_partial_version_rejected() {
let json = serde_json::json!({"name": "my-ext", "version": "1.0"});
let bytes = serde_json::to_vec(&json).unwrap();
assert!(parse_manifest(&bytes).is_err());
}
// -- Edge cases --
#[test]
fn manifest_with_all_optional_fields() {
let json = serde_json::json!({
"name": "full-ext",
"version": "2.0.0",
"display_name": "Full Extension",
"description": "Has everything",
"author": "Test",
"license": "MIT",
"homepage": "https://example.com",
"icon": "icon.png",
"engine": { "nomifun": "^1.0.0" },
"api_version": "1.0.0",
"dependencies": { "other-ext": "^1.0.0" },
"entry_point": "main.js",
"permissions": { "storage": true, "events": true },
"contributes": {},
"lifecycle": {
"on_install": "scripts/install.sh",
"on_activate": "scripts/activate.sh"
},
"i18n": { "locales": ["en", "zh-CN"] }
});
let bytes = serde_json::to_vec(&json).unwrap();
let manifest = parse_manifest(&bytes).unwrap();
assert_eq!(manifest.display_name.as_deref(), Some("Full Extension"));
assert!(manifest.engine.is_some());
assert!(manifest.lifecycle.is_some());
assert!(manifest.i18n.is_some());
}
#[test]
fn validate_manifest_directly() {
let manifest = ExtensionManifest {
name: "valid-ext".into(),
version: "1.0.0-beta.1".into(),
display_name: None,
description: None,
author: None,
license: None,
homepage: None,
icon: None,
engine: None,
api_version: None,
dependencies: Default::default(),
entry_point: None,
permissions: None,
contributes: None,
lifecycle: None,
i18n: None,
};
assert!(validate_manifest(&manifest).is_ok());
}
@@ -0,0 +1,139 @@
//! Integration tests for permission system (test-plan PS-1 through PS-7).
//!
//! These test the public API surface of risk level calculation and permission summary.
use nomifun_extension::{
ExtPermissions, FilesystemScope, NetworkPermission, PermissionLevel, RiskLevel, build_permission_summary,
calculate_risk_level,
};
// -- PS-1: storage + events only → safe --
#[test]
fn ps1_storage_and_events_is_safe() {
let perms = ExtPermissions {
storage: Some(true),
events: Some(true),
..Default::default()
};
assert_eq!(calculate_risk_level(&perms), RiskLevel::Safe);
}
// -- PS-2: scoped network → moderate --
#[test]
fn ps2_scoped_network_is_moderate() {
let perms = ExtPermissions {
network: Some(NetworkPermission::Scoped {
allowed_domains: vec!["api.example.com".into()],
reasoning: "API calls".into(),
}),
..Default::default()
};
assert_eq!(calculate_risk_level(&perms), RiskLevel::Moderate);
}
// -- PS-3: shell → dangerous --
#[test]
fn ps3_shell_is_dangerous() {
let perms = ExtPermissions {
shell: Some(true),
..Default::default()
};
assert_eq!(calculate_risk_level(&perms), RiskLevel::Dangerous);
}
// -- PS-4: full filesystem → dangerous --
#[test]
fn ps4_full_filesystem_is_dangerous() {
let perms = ExtPermissions {
filesystem: Some(FilesystemScope::Full),
..Default::default()
};
assert_eq!(calculate_risk_level(&perms), RiskLevel::Dangerous);
}
// -- PS-5: workspace filesystem → moderate --
#[test]
fn ps5_workspace_filesystem_is_moderate() {
let perms = ExtPermissions {
filesystem: Some(FilesystemScope::Workspace),
..Default::default()
};
assert_eq!(calculate_risk_level(&perms), RiskLevel::Moderate);
}
// -- PS-6: unrestricted network → dangerous --
#[test]
fn ps6_unrestricted_network_is_dangerous() {
let perms = ExtPermissions {
network: Some(NetworkPermission::Unrestricted(true)),
..Default::default()
};
assert_eq!(calculate_risk_level(&perms), RiskLevel::Dangerous);
}
// -- PS-7: no permissions → safe --
#[test]
fn ps7_no_permissions_is_safe() {
let perms = ExtPermissions::default();
assert_eq!(calculate_risk_level(&perms), RiskLevel::Safe);
}
// -- Summary integration --
#[test]
fn summary_contains_correct_risk_and_details() {
let perms = ExtPermissions {
storage: Some(true),
network: Some(NetworkPermission::Scoped {
allowed_domains: vec!["api.example.com".into()],
reasoning: "needed".into(),
}),
..Default::default()
};
let summary = build_permission_summary(&perms);
assert_eq!(summary.risk_level, RiskLevel::Moderate);
assert_eq!(summary.permissions, perms);
// Should have 7 detail entries (one per permission type)
assert_eq!(summary.details.len(), 7);
// Storage should be Full
let storage = summary.details.iter().find(|d| d.permission == "storage").unwrap();
assert_eq!(storage.level, PermissionLevel::Full);
// Network should be Limited
let network = summary.details.iter().find(|d| d.permission == "network").unwrap();
assert_eq!(network.level, PermissionLevel::Limited);
// Shell should be None
let shell = summary.details.iter().find(|d| d.permission == "shell").unwrap();
assert_eq!(shell.level, PermissionLevel::None);
}
#[test]
fn summary_dangerous_permissions_detail() {
let perms = ExtPermissions {
shell: Some(true),
filesystem: Some(FilesystemScope::Full),
network: Some(NetworkPermission::Unrestricted(true)),
..Default::default()
};
let summary = build_permission_summary(&perms);
assert_eq!(summary.risk_level, RiskLevel::Dangerous);
let shell = summary.details.iter().find(|d| d.permission == "shell").unwrap();
assert_eq!(shell.level, PermissionLevel::Full);
let fs = summary.details.iter().find(|d| d.permission == "filesystem").unwrap();
assert_eq!(fs.level, PermissionLevel::Full);
let net = summary.details.iter().find(|d| d.permission == "network").unwrap();
assert_eq!(net.level, PermissionLevel::Full);
}
@@ -0,0 +1,408 @@
//! Integration tests for the extension registry (test-plan EM-1..EM-4, HR-3,
//! EQ-1..EQ-2, SP-1..SP-3 at registry level).
//!
//! These tests exercise `ExtensionRegistry` as a black box: initialization,
//! enable/disable with event broadcasting, hot-reload sequence, and state
//! persistence through the registry API.
//!
//! All tests use `initialize_with_scan_paths` with explicit paths to avoid
//! process-level env var races when running in parallel.
use std::collections::HashMap;
use std::sync::Arc;
use nomifun_extension::{
ExtensionManifest, ExtensionRegistry, ExtensionSource, ExtensionState, ExtensionStateStore, LoadedExtension,
ScanPath, save_states_to_file,
};
use nomifun_realtime::BroadcastEventBus;
use tempfile::TempDir;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn make_ext(name: &str, version: &str, enabled: bool) -> LoadedExtension {
LoadedExtension {
manifest: ExtensionManifest {
name: name.to_owned(),
version: version.to_owned(),
display_name: Some(format!("{name} Display")),
description: Some(format!("Description for {name}")),
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: None,
},
directory: format!("/tmp/test-ext/{name}"),
source: ExtensionSource::Local,
state: ExtensionState {
name: name.to_owned(),
version: version.to_owned(),
enabled,
installed_at: Some(1_700_000_000_000),
last_activated_at: None,
},
}
}
/// Write extension fixture files to `ext_dir` and return scan paths for them.
fn write_fixtures(tmp: &TempDir, extensions: &[LoadedExtension]) -> (std::path::PathBuf, Vec<ScanPath>) {
let ext_dir = tmp.path().join("extensions");
std::fs::create_dir_all(&ext_dir).unwrap();
for ext in extensions {
let dir = ext_dir.join(&ext.manifest.name);
std::fs::create_dir_all(&dir).unwrap();
let manifest = serde_json::json!({
"name": ext.manifest.name,
"version": ext.manifest.version,
"display_name": ext.manifest.display_name,
"description": ext.manifest.description,
});
std::fs::write(
dir.join("nomi-extension.json"),
serde_json::to_vec_pretty(&manifest).unwrap(),
)
.unwrap();
}
let scan_paths = vec![ScanPath {
path: ext_dir.clone(),
source: ExtensionSource::Env,
}];
(ext_dir, scan_paths)
}
/// Create a registry pre-seeded with extensions (bypasses env var resolution).
async fn seeded_registry(extensions: Vec<LoadedExtension>) -> (ExtensionRegistry, Arc<BroadcastEventBus>, TempDir) {
let tmp = TempDir::new().unwrap();
let store = ExtensionStateStore::new(tmp.path().join("states.json"));
let bus = Arc::new(BroadcastEventBus::new(64));
let registry = ExtensionRegistry::new(store, bus.clone(), "1.0.0".to_owned());
let (_, scan_paths) = write_fixtures(&tmp, &extensions);
// Pre-populate state file for disabled extensions.
let states: HashMap<String, ExtensionState> = extensions
.iter()
.map(|e| (e.state.name.clone(), e.state.clone()))
.collect();
save_states_to_file(&tmp.path().join("states.json"), &states).unwrap();
// Initialize using explicit scan paths — no env vars needed.
registry.initialize_with_scan_paths(scan_paths).await.unwrap();
(registry, bus, tmp)
}
// ---------------------------------------------------------------------------
// EQ-1: Get loaded extensions — empty
// ---------------------------------------------------------------------------
#[tokio::test]
async fn eq1_get_loaded_extensions_empty() {
let tmp = TempDir::new().unwrap();
let store = ExtensionStateStore::new(tmp.path().join("states.json"));
let bus = Arc::new(BroadcastEventBus::new(16));
let registry = ExtensionRegistry::new(store, bus, "1.0.0".to_owned());
// Empty directory — no extensions to load.
let empty_dir = tmp.path().join("empty-exts");
std::fs::create_dir_all(&empty_dir).unwrap();
let scan_paths = vec![ScanPath {
path: empty_dir,
source: ExtensionSource::Env,
}];
registry.initialize_with_scan_paths(scan_paths).await.unwrap();
let exts = registry.get_loaded_extensions().await;
assert!(exts.is_empty(), "expected empty extension list");
}
// ---------------------------------------------------------------------------
// EQ-2: Get loaded extensions — with extensions
// ---------------------------------------------------------------------------
#[tokio::test]
async fn eq2_get_loaded_extensions_with_content() {
let extensions = vec![
make_ext("ext-alpha", "1.0.0", true),
make_ext("ext-beta", "2.0.0", true),
];
let (registry, _, _tmp) = seeded_registry(extensions).await;
let summaries = registry.get_loaded_extensions().await;
assert_eq!(summaries.len(), 2);
let names: Vec<&str> = summaries.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"ext-alpha"));
assert!(names.contains(&"ext-beta"));
// Verify summary fields.
let alpha = summaries.iter().find(|s| s.name == "ext-alpha").unwrap();
assert_eq!(alpha.version, "1.0.0");
assert!(alpha.enabled);
assert_eq!(alpha.display_name.as_deref(), Some("ext-alpha Display"));
}
// ---------------------------------------------------------------------------
// EM-1: Enable extension → stateChanged event
// ---------------------------------------------------------------------------
#[tokio::test]
async fn em1_enable_extension_broadcasts_state_changed() {
let extensions = vec![make_ext("my-ext", "1.0.0", false)];
let (registry, bus, _tmp) = seeded_registry(extensions).await;
let mut rx = bus.subscribe();
registry.enable_extension("my-ext").await.unwrap();
// Verify the extension is now enabled.
let ext = registry.get_extension_by_name("my-ext").await.unwrap();
assert!(ext.state.enabled);
assert!(ext.state.last_activated_at.is_some());
// Verify stateChanged event was broadcast.
let msg = rx.recv().await.unwrap();
assert_eq!(msg.name, "extensions.state-changed");
assert_eq!(msg.data["name"], "my-ext");
assert_eq!(msg.data["enabled"], true);
}
// ---------------------------------------------------------------------------
// EM-2: Disable extension with reason → stateChanged event
// ---------------------------------------------------------------------------
#[tokio::test]
async fn em2_disable_extension_broadcasts_state_changed() {
let extensions = vec![make_ext("my-ext", "1.0.0", true)];
let (registry, bus, _tmp) = seeded_registry(extensions).await;
let mut rx = bus.subscribe();
registry
.disable_extension("my-ext", Some("Security concern"))
.await
.unwrap();
// Verify the extension is now disabled.
let ext = registry.get_extension_by_name("my-ext").await.unwrap();
assert!(!ext.state.enabled);
// Verify stateChanged event was broadcast.
let msg = rx.recv().await.unwrap();
assert_eq!(msg.name, "extensions.state-changed");
assert_eq!(msg.data["name"], "my-ext");
assert_eq!(msg.data["enabled"], false);
}
// ---------------------------------------------------------------------------
// EM-3: Enable non-existent extension → error
// ---------------------------------------------------------------------------
#[tokio::test]
async fn em3_enable_nonexistent_returns_error() {
let (registry, _, _tmp) = seeded_registry(vec![]).await;
let result = registry.enable_extension("nonexistent").await;
assert!(result.is_err());
}
// ---------------------------------------------------------------------------
// EM-4: Disable non-existent extension → error
// ---------------------------------------------------------------------------
#[tokio::test]
async fn em4_disable_nonexistent_returns_error() {
let (registry, _, _tmp) = seeded_registry(vec![]).await;
let result = registry.disable_extension("nonexistent", None).await;
assert!(result.is_err());
}
// ---------------------------------------------------------------------------
// HR-3: Hot-reload sequence — deactivate → clear → reload → event
// ---------------------------------------------------------------------------
#[tokio::test]
async fn hr3_hot_reload_emits_registry_reloaded() {
let extensions = vec![make_ext("test-ext", "1.0.0", true)];
let (registry, bus, _tmp) = seeded_registry(extensions).await;
let mut rx = bus.subscribe();
registry.hot_reload().await;
// Should receive at least one lifecycle event for REGISTRY_RELOADED.
// Drain events until we find it (there may be EXTENSION_ACTIVATED events first).
let mut found_reload = false;
while let Ok(msg) = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
if let Ok(msg) = msg
&& msg.name == "extensions.lifecycle"
&& msg.data["event"] == "REGISTRY_RELOADED"
{
found_reload = true;
break;
}
}
assert!(found_reload, "expected REGISTRY_RELOADED event");
// Extensions should still be loaded after reload.
let exts = registry.get_loaded_extensions().await;
assert_eq!(exts.len(), 1);
assert_eq!(exts[0].name, "test-ext");
}
// ---------------------------------------------------------------------------
// HR-3 (continued): Hot-reload preserves enabled/disabled state
// ---------------------------------------------------------------------------
#[tokio::test]
async fn hr3_hot_reload_preserves_disabled_state() {
let extensions = vec![
make_ext("enabled-ext", "1.0.0", true),
make_ext("disabled-ext", "1.0.0", false),
];
let (registry, _, _tmp) = seeded_registry(extensions).await;
// Disable an extension, then hot-reload.
registry.disable_extension("enabled-ext", None).await.unwrap();
registry.hot_reload().await;
let exts = registry.get_loaded_extensions().await;
let enabled = exts.iter().find(|e| e.name == "enabled-ext").unwrap();
assert!(!enabled.enabled, "disabled state should persist through reload");
}
// ---------------------------------------------------------------------------
// SP: State persistence through registry enable/disable
// ---------------------------------------------------------------------------
#[tokio::test]
async fn sp_enable_disable_persists_through_reload() {
let tmp = TempDir::new().unwrap();
let state_path = tmp.path().join("states.json");
let store = ExtensionStateStore::new(state_path.clone());
let bus = Arc::new(BroadcastEventBus::new(16));
// Create fixture extensions.
let ext_dir = tmp.path().join("extensions");
let ext_a_dir = ext_dir.join("ext-a");
std::fs::create_dir_all(&ext_a_dir).unwrap();
std::fs::write(
ext_a_dir.join("nomi-extension.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"name": "ext-a",
"version": "1.0.0",
}))
.unwrap(),
)
.unwrap();
let scan_paths = vec![ScanPath {
path: ext_dir,
source: ExtensionSource::Env,
}];
let registry = ExtensionRegistry::new(store.clone(), bus, "1.0.0".to_owned());
registry.initialize_with_scan_paths(scan_paths.clone()).await.unwrap();
// Extension should be enabled by default (SP-3).
let exts = registry.get_loaded_extensions().await;
assert!(exts[0].enabled, "first load should default to enabled");
// Disable it.
registry.disable_extension("ext-a", None).await.unwrap();
// Flush to disk.
store.flush().await.unwrap();
// Verify persisted state (SP-1).
let loaded = nomifun_extension::load_states_from_file(&state_path).unwrap();
assert!(!loaded["ext-a"].enabled);
// Re-initialize (simulate restart) — should restore disabled state (SP-2).
let store2 = ExtensionStateStore::new(state_path);
let bus2 = Arc::new(BroadcastEventBus::new(16));
let registry2 = ExtensionRegistry::new(store2, bus2, "1.0.0".to_owned());
registry2.initialize_with_scan_paths(scan_paths).await.unwrap();
let exts2 = registry2.get_loaded_extensions().await;
assert!(!exts2.is_empty(), "second registry should find extensions");
assert!(!exts2[0].enabled, "disabled state should persist across restarts");
}
// ---------------------------------------------------------------------------
// Query: contribution getters return empty on no extensions
// ---------------------------------------------------------------------------
#[tokio::test]
async fn query_empty_contributions_on_no_extensions() {
let (registry, _, _tmp) = seeded_registry(vec![]).await;
assert!(registry.get_themes().await.is_empty());
assert!(registry.get_assistants().await.is_empty());
assert!(registry.get_acp_adapters().await.is_empty());
assert!(registry.get_agents().await.is_empty());
assert!(registry.get_mcp_servers().await.is_empty());
assert!(registry.get_skills().await.is_empty());
assert!(registry.get_settings_tabs().await.is_empty());
assert!(registry.get_webui_contributions().await.is_empty());
assert!(registry.get_channel_plugins().await.is_empty());
assert!(registry.get_model_providers().await.is_empty());
}
// ---------------------------------------------------------------------------
// Enable idempotent — enabling an already-enabled extension is a no-op
// ---------------------------------------------------------------------------
#[tokio::test]
async fn enable_already_enabled_is_noop() {
let extensions = vec![make_ext("my-ext", "1.0.0", true)];
let (registry, bus, _tmp) = seeded_registry(extensions).await;
let mut rx = bus.subscribe();
// Enable an already-enabled extension — should not broadcast.
registry.enable_extension("my-ext").await.unwrap();
// No stateChanged event expected — timeout means success.
let result = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv()).await;
assert!(result.is_err(), "no event expected for no-op enable");
}
// ---------------------------------------------------------------------------
// Initialize sets initialized flag
// ---------------------------------------------------------------------------
#[tokio::test]
async fn initialize_sets_flag() {
let tmp = TempDir::new().unwrap();
let store = ExtensionStateStore::new(tmp.path().join("states.json"));
let bus = Arc::new(BroadcastEventBus::new(16));
let registry = ExtensionRegistry::new(store, bus, "1.0.0".to_owned());
assert!(!registry.is_initialized().await);
let empty_dir = tmp.path().join("empty-exts");
std::fs::create_dir_all(&empty_dir).unwrap();
let scan_paths = vec![ScanPath {
path: empty_dir,
source: ExtensionSource::Env,
}];
registry.initialize_with_scan_paths(scan_paths).await.unwrap();
assert!(registry.is_initialized().await);
}
@@ -0,0 +1,611 @@
//! Black-box integration tests for skill & rule management (test-plan SM, RM, CP).
//!
//! These tests exercise the public API surface of `nomifun_extension::skill_service`
//! and `nomifun_extension::external_paths` against the functional requirements in
//! `08-file-workspace.md` §B.
use std::path::Path;
use nomifun_extension::external_paths::ExternalPathsManager;
use nomifun_extension::skill_service::{
NamedPath, SkillPaths, delete_assistant_rule, delete_assistant_skill, delete_skill,
detect_and_count_external_skills, export_skill_with_symlink, import_skill, import_skill_with_symlink,
list_available_skills, read_assistant_rule, read_assistant_skill, read_builtin_rule, read_builtin_skill,
read_skill_info, resolve_skill_paths, scan_for_skills, write_assistant_rule, write_assistant_skill,
};
use tempfile::TempDir;
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
const SKILL_MD: &str = "SKILL.md";
fn make_paths(base: &Path) -> SkillPaths {
SkillPaths {
data_dir: base.to_path_buf(),
user_skills_dir: base.join("skills"),
cron_skills_dir: base.join("cron").join("skills"),
builtin_skills_dir: base.join("builtin-skills"),
builtin_rules_dir: base.join("builtin-rules"),
assistant_rules_dir: base.join("assistant-rules"),
assistant_skills_dir: base.join("assistant-skills"),
}
}
fn builtin_dir(paths: &SkillPaths) -> &Path {
&paths.builtin_skills_dir
}
fn create_skill(base: &Path, name: &str, desc: &str) {
let dir = base.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(SKILL_MD),
format!("---\nname: {name}\ndescription: {desc}\n---\nBody of {name}."),
)
.unwrap();
}
fn create_builtin_rule(base: &Path, name: &str, content: &str) {
std::fs::create_dir_all(base).unwrap();
std::fs::write(base.join(name), content).unwrap();
}
fn create_builtin_skill(base: &Path, name: &str, content: &str) {
std::fs::create_dir_all(base).unwrap();
std::fs::write(base.join(name), content).unwrap();
}
// ===========================================================================
// SM — Skill Management
// ===========================================================================
/// SM-1: List available skills (builtin + custom, deduplication).
#[tokio::test]
async fn sm1_list_available_skills_deduplication() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(tmp.path());
// 3 built-in, 2 custom (1 overlaps)
create_skill(builtin_dir(&paths), "review", "Built-in review");
create_skill(builtin_dir(&paths), "debug", "Built-in debug");
create_skill(builtin_dir(&paths), "test", "Built-in test");
create_skill(&paths.user_skills_dir, "review", "Custom review override");
create_skill(&paths.user_skills_dir, "my-tool", "My custom tool");
let skills = list_available_skills(&paths).await.unwrap();
// 4 total: debug(builtin) + my-tool(custom) + review(custom) + test(builtin)
assert_eq!(skills.len(), 4);
// Verify deduplication: review should be the custom version
let review = skills.iter().find(|s| s.name == "review").unwrap();
assert!(review.is_custom);
assert_eq!(review.description, "Custom review override");
// Verify each has required fields
for s in &skills {
assert!(!s.name.is_empty());
assert!(!s.description.is_empty());
assert!(!s.location.is_empty());
}
}
/// SM-2: Read skill info from path.
#[tokio::test]
async fn sm2_read_skill_info() {
let tmp = TempDir::new().unwrap();
let skill_dir = tmp.path().join("my-skill");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join(SKILL_MD),
"---\nname: my-skill\ndescription: A test skill\n---\nBody content.",
)
.unwrap();
let (name, desc) = read_skill_info(&skill_dir).await.unwrap();
assert_eq!(name, "my-skill");
assert_eq!(desc, "A test skill");
}
/// SM-2 error: Path does not exist → error.
#[tokio::test]
async fn sm2_read_skill_info_not_found() {
let result = read_skill_info(Path::new("/nonexistent/skill")).await;
assert!(result.is_err());
}
/// SM-3: Import skill (copy).
#[tokio::test]
async fn sm3_import_skill_copy() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(tmp.path());
let source = tmp.path().join("external-skill");
std::fs::create_dir_all(&source).unwrap();
std::fs::write(
source.join(SKILL_MD),
"---\nname: ext-tool\ndescription: External tool\n---\nContent.",
)
.unwrap();
std::fs::write(source.join("helper.py"), "print('hello')").unwrap();
let name = import_skill(&paths, &source).await.unwrap();
assert_eq!(name, "ext-tool");
// Verify files were copied
let imported = paths.user_skills_dir.join("ext-tool");
assert!(imported.join(SKILL_MD).exists());
assert!(imported.join("helper.py").exists());
}
/// SM-4: Import skill (symlink).
#[tokio::test]
async fn sm4_import_skill_symlink() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(tmp.path());
let source = tmp.path().join("linked-skill");
std::fs::create_dir_all(&source).unwrap();
std::fs::write(
source.join(SKILL_MD),
"---\nname: linked\ndescription: Linked skill\n---\nBody.",
)
.unwrap();
let name = import_skill_with_symlink(&paths, &source).await.unwrap();
assert_eq!(name, "linked");
let link = paths.user_skills_dir.join("linked");
assert!(link.is_symlink());
// Verify content is accessible through the symlink
let content = std::fs::read_to_string(link.join(SKILL_MD)).unwrap();
assert!(content.contains("Linked skill"));
}
/// SM-5: Export skill (symlink).
#[tokio::test]
async fn sm5_export_skill_symlink() {
let tmp = TempDir::new().unwrap();
let source = tmp.path().join("source-skill");
std::fs::create_dir_all(&source).unwrap();
std::fs::write(
source.join(SKILL_MD),
"---\nname: source\ndescription: Source\n---\nBody.",
)
.unwrap();
let export_dir = tmp.path().join("exports");
export_skill_with_symlink(&source, &export_dir).await.unwrap();
let link = export_dir.join("source-skill");
assert!(link.is_symlink());
}
/// SM-6: Delete user custom skill.
#[tokio::test]
async fn sm6_delete_custom_skill() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(tmp.path());
create_skill(&paths.user_skills_dir, "deletable", "Will be deleted");
assert!(paths.user_skills_dir.join("deletable").exists());
delete_skill(&paths, "deletable").await.unwrap();
assert!(!paths.user_skills_dir.join("deletable").exists());
}
/// SM-7: Delete built-in skill → rejected.
#[tokio::test]
async fn sm7_delete_builtin_skill_rejected() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(tmp.path());
create_skill(builtin_dir(&paths), "protected", "Cannot delete");
let result = delete_skill(&paths, "protected").await;
assert!(result.is_err());
// Verify it still exists
assert!(builtin_dir(&paths).join("protected").exists());
}
/// SM-8: Scan for skills in a directory.
#[tokio::test]
async fn sm8_scan_for_skills() {
let tmp = TempDir::new().unwrap();
let scan_dir = tmp.path().join("scan-target");
create_skill(&scan_dir, "alpha", "Alpha skill");
create_skill(&scan_dir, "beta", "Beta skill");
// Directory without SKILL.md
std::fs::create_dir_all(scan_dir.join("not-a-skill")).unwrap();
let skills = scan_for_skills(&scan_dir).await.unwrap();
assert_eq!(skills.len(), 2);
assert!(skills.iter().any(|s| s.name == "alpha"));
assert!(skills.iter().any(|s| s.name == "beta"));
}
/// SM-11: Get skill directory paths.
///
/// Production mode: no `NOMIFUN_BUILTIN_SKILLS_PATH` set — the built-in
/// skills tree lives at `{data_dir}/builtin-skills/`, populated at
/// startup by `startup_materialize::materialize_if_needed`. The user
/// skills directory is derived from `data_dir`, not `resource_dir`.
#[tokio::test]
async fn sm11_get_skill_paths() {
// Ensure the env var is unset for a deterministic assertion.
// Safe: the test runs single-threaded w.r.t. this env var.
// (SAFETY: `remove_var` is unsafe in 2024 edition due to process-wide
// side-effects.)
unsafe {
std::env::remove_var("NOMIFUN_BUILTIN_SKILLS_PATH");
}
let resource_dir = Path::new("/app/resources");
let data_dir = Path::new("/home/user/.nomifun");
let paths = resolve_skill_paths(resource_dir, data_dir);
assert!(paths.user_skills_dir.to_string_lossy().contains("skills"));
assert_eq!(
paths.builtin_skills_dir,
data_dir.join("builtin-skills"),
"production mode must resolve builtin_skills_dir under data_dir"
);
}
// ===========================================================================
// RM — Rule Management
// ===========================================================================
/// RM-1: Read built-in rule.
#[tokio::test]
async fn rm1_read_builtin_rule() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(tmp.path());
create_builtin_rule(&paths.builtin_rules_dir, "code-review.md", "# Review Rules");
let content = read_builtin_rule(&paths, "code-review.md").await.unwrap();
assert_eq!(content, "# Review Rules");
}
/// RM-1 error: File not found → empty string.
#[tokio::test]
async fn rm1_read_builtin_rule_not_found() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(tmp.path());
let content = read_builtin_rule(&paths, "nonexistent.md").await.unwrap();
assert!(content.is_empty());
}
/// RM-1 variant: Read built-in skill.
#[tokio::test]
async fn rm1_read_builtin_skill() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(tmp.path());
create_builtin_skill(builtin_dir(&paths), "tdd.md", "# TDD Workflow");
let content = read_builtin_skill(&paths, "tdd.md").await.unwrap();
assert_eq!(content, "# TDD Workflow");
}
/// RM-2: Read assistant rule with locale fallback.
#[tokio::test]
async fn rm2_assistant_rule_locale_fallback() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(tmp.path());
// Write both default and locale-specific
write_assistant_rule(&paths, "abc123", "Default rule", None)
.await
.unwrap();
write_assistant_rule(&paths, "abc123", "中文规则", Some("zh-CN"))
.await
.unwrap();
// 1. Matching locale → locale-specific content
let content = read_assistant_rule(&paths, "abc123", Some("zh-CN")).await.unwrap();
assert_eq!(content, "中文规则");
// 2. Non-matching locale → fallback to default
let content = read_assistant_rule(&paths, "abc123", Some("en-US")).await.unwrap();
assert_eq!(content, "Default rule");
// 3. No locale → default
let content = read_assistant_rule(&paths, "abc123", None).await.unwrap();
assert_eq!(content, "Default rule");
// 4. Not found → empty string
let content = read_assistant_rule(&paths, "missing", None).await.unwrap();
assert!(content.is_empty());
}
/// RM-3: Write assistant rule.
#[tokio::test]
async fn rm3_write_assistant_rule() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(tmp.path());
let result = write_assistant_rule(&paths, "abc123", "New rule content", Some("en-US"))
.await
.unwrap();
assert!(result);
// Verify file created
let file = paths.assistant_rules_dir.join("abc123.en-US.md");
assert!(file.exists());
let content = std::fs::read_to_string(file).unwrap();
assert_eq!(content, "New rule content");
}
/// RM-4: Delete assistant rule (all locales).
#[tokio::test]
async fn rm4_delete_assistant_rule_all_locales() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(tmp.path());
write_assistant_rule(&paths, "abc123", "Default", None).await.unwrap();
write_assistant_rule(&paths, "abc123", "Chinese", Some("zh-CN"))
.await
.unwrap();
write_assistant_rule(&paths, "abc123", "English", Some("en-US"))
.await
.unwrap();
let deleted = delete_assistant_rule(&paths, "abc123").await.unwrap();
assert!(deleted);
// Verify all versions removed
let content = read_assistant_rule(&paths, "abc123", None).await.unwrap();
assert!(content.is_empty());
let content = read_assistant_rule(&paths, "abc123", Some("zh-CN")).await.unwrap();
assert!(content.is_empty());
let content = read_assistant_rule(&paths, "abc123", Some("en-US")).await.unwrap();
assert!(content.is_empty());
}
/// RM-5: Read assistant skill with locale fallback (same as RM-2 pattern).
#[tokio::test]
async fn rm5_assistant_skill_locale_fallback() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(tmp.path());
write_assistant_skill(&paths, "abc123", "Default skill", None)
.await
.unwrap();
write_assistant_skill(&paths, "abc123", "English skill", Some("en-US"))
.await
.unwrap();
let content = read_assistant_skill(&paths, "abc123", Some("en-US")).await.unwrap();
assert_eq!(content, "English skill");
let content = read_assistant_skill(&paths, "abc123", Some("fr-FR")).await.unwrap();
assert_eq!(content, "Default skill");
}
/// RM-6: Write and delete assistant skill.
#[tokio::test]
async fn rm6_write_and_delete_assistant_skill() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(tmp.path());
write_assistant_skill(&paths, "abc123", "Content", None).await.unwrap();
write_assistant_skill(&paths, "abc123", "Locale", Some("zh-CN"))
.await
.unwrap();
let deleted = delete_assistant_skill(&paths, "abc123").await.unwrap();
assert!(deleted);
let content = read_assistant_skill(&paths, "abc123", None).await.unwrap();
assert!(content.is_empty());
}
// ===========================================================================
// CP — Custom External Paths
// ===========================================================================
/// CP-1: Get custom paths (initially empty).
#[tokio::test]
async fn cp1_get_custom_paths_empty() {
let tmp = TempDir::new().unwrap();
let mgr = ExternalPathsManager::new(tmp.path()).await;
let paths = mgr.get_custom_external_paths().await;
assert!(paths.is_empty());
}
/// CP-2: Add custom path and verify persistence.
#[tokio::test]
async fn cp2_add_custom_path() {
let tmp = TempDir::new().unwrap();
let mgr = ExternalPathsManager::new(tmp.path()).await;
mgr.add_custom_external_path("My Skills", "/home/user/skills")
.await
.unwrap();
let paths = mgr.get_custom_external_paths().await;
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].name, "My Skills");
assert_eq!(paths[0].path, "/home/user/skills");
// Verify persistence across reload
drop(mgr);
let mgr2 = ExternalPathsManager::new(tmp.path()).await;
let paths = mgr2.get_custom_external_paths().await;
assert_eq!(paths.len(), 1);
}
/// CP-3: Remove custom path.
#[tokio::test]
async fn cp3_remove_custom_path() {
let tmp = TempDir::new().unwrap();
let mgr = ExternalPathsManager::new(tmp.path()).await;
mgr.add_custom_external_path("A", "/path/a").await.unwrap();
mgr.add_custom_external_path("B", "/path/b").await.unwrap();
mgr.remove_custom_external_path("/path/a").await.unwrap();
let paths = mgr.get_custom_external_paths().await;
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].path, "/path/b");
}
/// CP-4: Enable skills market.
#[tokio::test]
async fn cp4_enable_skills_market() {
let tmp = TempDir::new().unwrap();
let mgr = ExternalPathsManager::new(tmp.path()).await;
mgr.enable_skills_market().await.unwrap();
let paths = mgr.get_custom_external_paths().await;
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].name, "nomifun-skills");
}
/// CP-5: Disable skills market.
#[tokio::test]
async fn cp5_disable_skills_market() {
let tmp = TempDir::new().unwrap();
let mgr = ExternalPathsManager::new(tmp.path()).await;
mgr.enable_skills_market().await.unwrap();
mgr.disable_skills_market().await.unwrap();
let paths = mgr.get_custom_external_paths().await;
assert!(paths.is_empty());
}
// ===========================================================================
// External skill discovery
// ===========================================================================
/// Test detect_and_count_external_skills with custom paths.
#[tokio::test]
async fn detect_external_skills_from_custom_paths() {
let tmp = TempDir::new().unwrap();
let ext_dir = tmp.path().join("external-skills");
create_skill(&ext_dir, "ext-a", "External A");
create_skill(&ext_dir, "ext-b", "External B");
let custom_paths = vec![NamedPath {
name: "External".to_string(),
path: ext_dir.to_string_lossy().into_owned(),
}];
let sources = detect_and_count_external_skills(&custom_paths).await;
// Should have at least the custom path source
let external = sources
.iter()
.find(|s| s.name == "External")
.expect("custom external source should be found");
assert_eq!(external.skill_count, 2);
assert!(external.skills.iter().any(|s| s.name == "ext-a"));
assert!(external.skills.iter().any(|s| s.name == "ext-b"));
// `source` for custom paths is `custom-<abs-path>` — used by the renderer
// as a React key / testid suffix, and asserted by e2e spec
// `edge-cases.e2e.ts` (prefix `external-source-tab-custom-`).
assert_eq!(external.source, format!("custom-{}", ext_dir.to_string_lossy()));
assert!(external.source.starts_with("custom-"));
}
/// Custom paths with distinct filesystem locations get distinct slugs so
/// the renderer can use them as unique React keys / testid suffixes.
#[tokio::test]
async fn detect_external_skills_custom_sources_are_unique() {
let tmp = TempDir::new().unwrap();
let dir_a = tmp.path().join("a");
let dir_b = tmp.path().join("b");
create_skill(&dir_a, "a-skill", "A");
create_skill(&dir_b, "b-skill", "B");
let custom_paths = vec![
NamedPath {
name: "A".into(),
path: dir_a.to_string_lossy().into_owned(),
},
NamedPath {
name: "B".into(),
path: dir_b.to_string_lossy().into_owned(),
},
];
let sources = detect_and_count_external_skills(&custom_paths).await;
let slugs: Vec<&str> = sources
.iter()
.filter(|s| s.name == "A" || s.name == "B")
.map(|s| s.source.as_str())
.collect();
assert_eq!(slugs.len(), 2);
assert_ne!(slugs[0], slugs[1]);
}
/// Verify path traversal is blocked in skill deletion.
#[tokio::test]
async fn security_path_traversal_blocked() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(tmp.path());
// Path traversal attempts
assert!(delete_skill(&paths, "../escape").await.is_err());
assert!(delete_skill(&paths, "foo/bar").await.is_err());
assert!(delete_skill(&paths, "foo\\bar").await.is_err());
}
/// Verify built-in resource reads block path traversal.
#[tokio::test]
async fn security_builtin_read_path_traversal() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(tmp.path());
assert!(read_builtin_rule(&paths, "../secret.md").await.is_err());
assert!(read_builtin_skill(&paths, "../../etc/passwd").await.is_err());
assert!(read_builtin_rule(&paths, "").await.is_err());
}
/// Verify assistant CRUD functions block path traversal in assistant_id.
#[tokio::test]
async fn security_assistant_crud_path_traversal_id() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(tmp.path());
// read
assert!(read_assistant_rule(&paths, "../escape", None).await.is_err());
assert!(read_assistant_skill(&paths, "foo/bar", None).await.is_err());
// write
assert!(write_assistant_rule(&paths, "../escape", "x", None).await.is_err());
assert!(write_assistant_skill(&paths, "foo\\bar", "x", None).await.is_err());
// delete
assert!(delete_assistant_rule(&paths, "../escape").await.is_err());
assert!(delete_assistant_skill(&paths, "a/b").await.is_err());
}
/// Verify assistant read/write functions block path traversal in locale.
#[tokio::test]
async fn security_assistant_crud_path_traversal_locale() {
let tmp = TempDir::new().unwrap();
let paths = make_paths(tmp.path());
assert!(read_assistant_rule(&paths, "valid", Some("../bad")).await.is_err());
assert!(
write_assistant_rule(&paths, "valid", "x", Some("../../evil"))
.await
.is_err()
);
assert!(read_assistant_skill(&paths, "valid", Some("a/b")).await.is_err());
assert!(write_assistant_skill(&paths, "valid", "x", Some("a\\b")).await.is_err());
}
@@ -0,0 +1,51 @@
use nomifun_extension::{resolve_skill_paths, skill_service};
use tempfile::TempDir;
/// `BUILTIN_SKILLS_ENV_VAR` is process-global; this test mutates it, so
/// it must not run in parallel with other `skill_service` tests that
/// touch the same env var. Vitest-style serialization inside a single
/// test is sufficient here.
#[tokio::test]
async fn materialize_returns_only_listed_skill_source_paths() {
let tmp = TempDir::new().unwrap();
// Stage two builtin auto-inject skills on disk.
let builtin_root = tmp.path().join("builtin-skills");
let auto_dir = builtin_root.join("auto-inject");
std::fs::create_dir_all(auto_dir.join("cron")).unwrap();
std::fs::write(
auto_dir.join("cron").join("SKILL.md"),
"---\nname: cron\ndescription: \n---",
)
.unwrap();
std::fs::create_dir_all(auto_dir.join("todo")).unwrap();
std::fs::write(
auto_dir.join("todo").join("SKILL.md"),
"---\nname: todo\ndescription: \n---",
)
.unwrap();
// SAFETY: single-threaded test harness.
unsafe {
std::env::set_var(nomifun_extension::BUILTIN_SKILLS_ENV_VAR, &builtin_root);
}
let paths = resolve_skill_paths(tmp.path(), tmp.path());
let resolved = skill_service::materialize_skills_for_agent(&paths, "conv-1", &["cron".to_owned()])
.await
.unwrap();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].name, "cron");
assert_eq!(resolved[0].source_path, auto_dir.join("cron"));
assert!(resolved[0].source_path.is_dir());
assert!(resolved[0].source_path.join("SKILL.md").exists());
// Guardrail: the new contract forbids any per-conversation dir on
// disk. Nothing under data_dir should have been created.
assert!(!tmp.path().join("agent-skills").exists());
assert!(!tmp.path().join("conversations").exists());
unsafe {
std::env::remove_var(nomifun_extension::BUILTIN_SKILLS_ENV_VAR);
}
}
@@ -0,0 +1,205 @@
//! Integration tests for startup materialization of the embedded
//! builtin-skills corpus. Uses a purpose-built in-test `include_dir`
//! tree so results are deterministic and independent of the real
//! embedded corpus's contents.
use std::path::Path;
use include_dir::{Dir, include_dir};
use nomifun_extension::startup_materialize::{materialize_embedded_builtin_skills, materialize_if_needed};
use tempfile::TempDir;
static FIXTURE_CORPUS: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/tests/fixtures/builtin-skills-fixture");
fn read_version(target: &Path) -> Option<String> {
std::fs::read_to_string(target.join(".version")).ok()
}
#[tokio::test]
async fn materialize_writes_tree_and_version_file() {
let tmp = TempDir::new().unwrap();
materialize_embedded_builtin_skills(tmp.path(), &FIXTURE_CORPUS, "1.2.3")
.await
.unwrap();
let target = tmp.path().join("builtin-skills");
assert!(target.is_dir(), "target dir should exist");
assert_eq!(read_version(&target).as_deref(), Some("1.2.3"));
assert!(
target.join("example-skill").join("SKILL.md").is_file(),
"expected fixture skill to be materialized"
);
}
#[tokio::test]
async fn materialize_overwrites_existing_target() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("builtin-skills");
std::fs::create_dir_all(target.join("stale-dir")).unwrap();
std::fs::write(target.join("junk.txt"), b"old").unwrap();
materialize_embedded_builtin_skills(tmp.path(), &FIXTURE_CORPUS, "1.2.3")
.await
.unwrap();
assert!(!target.join("junk.txt").exists(), "stale file should be gone");
assert!(!target.join("stale-dir").exists(), "stale dir should be gone");
assert_eq!(read_version(&target).as_deref(), Some("1.2.3"));
}
#[tokio::test]
async fn materialize_cleans_staging_from_prior_crash() {
let tmp = TempDir::new().unwrap();
let staging = tmp.path().join(".builtin-skills.tmp");
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("leftover.txt"), b"x").unwrap();
materialize_embedded_builtin_skills(tmp.path(), &FIXTURE_CORPUS, "1.2.3")
.await
.unwrap();
assert!(!staging.exists(), "staging should be cleaned after success");
}
#[tokio::test]
async fn gate_skips_when_version_matches() {
let tmp = TempDir::new().unwrap();
// Pre-populate target with matching version.
let target = tmp.path().join("builtin-skills");
std::fs::create_dir_all(&target).unwrap();
std::fs::write(target.join(".version"), "1.2.3").unwrap();
std::fs::write(target.join("sentinel"), "do-not-delete").unwrap();
let wrote = materialize_if_needed(tmp.path(), &FIXTURE_CORPUS, "1.2.3")
.await
.unwrap();
assert!(!wrote, "version match should skip materialize");
assert!(
target.join("sentinel").is_file(),
"sentinel must be preserved when gate says skip"
);
}
#[tokio::test]
async fn gate_triggers_when_version_mismatches() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("builtin-skills");
std::fs::create_dir_all(&target).unwrap();
std::fs::write(target.join(".version"), "0.0.0").unwrap();
std::fs::write(target.join("sentinel"), "will-be-wiped").unwrap();
let wrote = materialize_if_needed(tmp.path(), &FIXTURE_CORPUS, "1.2.3")
.await
.unwrap();
assert!(wrote, "version mismatch should materialize");
assert!(
!target.join("sentinel").exists(),
"sentinel must be wiped by fresh materialize"
);
assert_eq!(read_version(&target).as_deref(), Some("1.2.3"));
}
#[tokio::test]
async fn gate_triggers_when_version_file_missing() {
let tmp = TempDir::new().unwrap();
let wrote = materialize_if_needed(tmp.path(), &FIXTURE_CORPUS, "1.2.3")
.await
.unwrap();
assert!(wrote, "no existing version should materialize");
assert_eq!(
read_version(&tmp.path().join("builtin-skills")).as_deref(),
Some("1.2.3")
);
}
#[tokio::test]
async fn gate_keeps_existing_tree_when_refresh_fails() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("builtin-skills");
std::fs::create_dir_all(&target).unwrap();
std::fs::write(target.join(".version"), "0.0.0").unwrap();
std::fs::write(target.join("sentinel"), "keep-existing").unwrap();
std::fs::write(tmp.path().join(".builtin-skills.tmp"), "stale-file").unwrap();
let wrote = materialize_if_needed(tmp.path(), &FIXTURE_CORPUS, "1.2.3")
.await
.unwrap();
assert!(!wrote, "refresh failure should fall back to existing tree");
assert_eq!(read_version(&target).as_deref(), Some("0.0.0"));
assert!(
target.join("sentinel").is_file(),
"existing tree must be preserved when refresh fails"
);
}
#[tokio::test]
async fn gate_fails_when_initial_materialize_fails_without_existing_tree() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join(".builtin-skills.tmp"), "stale-file").unwrap();
let result = materialize_if_needed(tmp.path(), &FIXTURE_CORPUS, "1.2.3").await;
assert!(
result.is_err(),
"first startup must not silently continue without a usable builtin-skills tree"
);
}
#[tokio::test]
async fn concurrent_materialize_produces_consistent_tree() {
// Two concurrent invocations share the same staging/target paths, so
// at most one reliably wins the atomic rename; the other may legally
// fail mid-staging. What matters is that after both complete, the
// on-disk tree is in a consistent, fully-populated state driven by
// whichever call succeeded.
let tmp = TempDir::new().unwrap();
let dir1 = tmp.path().to_path_buf();
let dir2 = tmp.path().to_path_buf();
let (a, b) = tokio::join!(
materialize_embedded_builtin_skills(&dir1, &FIXTURE_CORPUS, "1.2.3"),
materialize_embedded_builtin_skills(&dir2, &FIXTURE_CORPUS, "1.2.3"),
);
assert!(
a.is_ok() || b.is_ok(),
"at least one concurrent materialize must succeed; a={a:?}, b={b:?}"
);
let target = tmp.path().join("builtin-skills");
assert_eq!(read_version(&target).as_deref(), Some("1.2.3"));
assert!(target.join("example-skill").join("SKILL.md").is_file());
}
#[tokio::test]
async fn concurrent_gate_materialize_all_callers_succeed() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().to_path_buf();
let mut handles = Vec::new();
for _ in 0..8 {
let dir = data_dir.clone();
handles.push(tokio::spawn(async move {
materialize_if_needed(&dir, &FIXTURE_CORPUS, "1.2.3").await
}));
}
let mut results = Vec::new();
for handle in handles {
results.push(handle.await.unwrap());
}
for result in &results {
assert!(
result.is_ok(),
"concurrent startup materialize callers should not fail: {results:?}"
);
}
assert!(
results.iter().filter(|result| matches!(result, Ok(true))).count() >= 1,
"at least one caller should perform the materialize write: {results:?}"
);
let target = tmp.path().join("builtin-skills");
assert_eq!(read_version(&target).as_deref(), Some("1.2.3"));
assert!(target.join("example-skill").join("SKILL.md").is_file());
}
@@ -0,0 +1,217 @@
//! Integration tests for state persistence (test-plan SP-1..SP-3).
//!
//! These tests exercise `ExtensionStateStore` and the underlying file I/O
//! as black-box functionality: saving states after enable/disable, restoring
//! states across "restarts", and default-enabled behaviour on first launch.
use std::collections::HashMap;
use nomifun_extension::{ExtensionState, ExtensionStateStore, load_states_from_file, save_states_to_file};
use tempfile::TempDir;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn make_state(name: &str, version: &str, enabled: bool) -> ExtensionState {
ExtensionState {
name: name.to_string(),
version: version.to_string(),
enabled,
installed_at: Some(1_700_000_000_000),
last_activated_at: None,
}
}
// ---------------------------------------------------------------------------
// SP-1: State save — enable/disable persists to extension-states.json
// ---------------------------------------------------------------------------
#[tokio::test]
async fn sp1_state_saved_after_enable_disable() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("extension-states.json");
let store = ExtensionStateStore::new(path.clone());
store.load().await.unwrap();
// Enable an extension.
store.set(make_state("my-ext", "1.0.0", true)).await;
// Disable another.
store.set(make_state("other-ext", "2.0.0", false)).await;
// Flush to disk immediately.
store.flush().await.unwrap();
// Verify the file contains both states.
let loaded = load_states_from_file(&path).unwrap();
assert_eq!(loaded.len(), 2);
assert!(loaded["my-ext"].enabled);
assert_eq!(loaded["my-ext"].version, "1.0.0");
assert!(!loaded["other-ext"].enabled);
assert_eq!(loaded["other-ext"].version, "2.0.0");
}
#[tokio::test]
async fn sp1_state_contains_timestamps() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("extension-states.json");
let store = ExtensionStateStore::new(path.clone());
store.load().await.unwrap();
store.set(make_state("my-ext", "1.0.0", true)).await;
store.flush().await.unwrap();
let loaded = load_states_from_file(&path).unwrap();
assert!(loaded["my-ext"].installed_at.is_some());
}
// ---------------------------------------------------------------------------
// SP-2: State restore — app restart restores previous enabled/disabled state
// ---------------------------------------------------------------------------
#[tokio::test]
async fn sp2_state_restored_after_restart() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("extension-states.json");
// "First session": save states.
{
let store = ExtensionStateStore::new(path.clone());
store.load().await.unwrap();
store.set(make_state("ext-a", "1.0.0", true)).await;
store.set(make_state("ext-b", "2.0.0", false)).await;
store.flush().await.unwrap();
}
// "Second session": load from the same file.
{
let store = ExtensionStateStore::new(path.clone());
let states = store.load().await.unwrap();
assert_eq!(states.len(), 2);
assert!(states["ext-a"].enabled);
assert!(!states["ext-b"].enabled);
// Also verify in-memory read.
let a = store.get("ext-a").await.unwrap();
assert!(a.enabled);
assert_eq!(a.version, "1.0.0");
let b = store.get("ext-b").await.unwrap();
assert!(!b.enabled);
assert_eq!(b.version, "2.0.0");
}
}
// ---------------------------------------------------------------------------
// SP-3: No state file on first launch → all extensions default to enabled
// ---------------------------------------------------------------------------
#[tokio::test]
async fn sp3_no_state_file_returns_empty_map() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("nonexistent-states.json");
let store = ExtensionStateStore::new(path);
let states = store.load().await.unwrap();
assert!(states.is_empty());
// An empty map means no overrides — the loader creates extensions with
// enabled=true by default (verified in extension_loading_test.rs).
}
#[test]
fn sp3_load_states_from_nonexistent_file_returns_empty() {
let states = load_states_from_file(std::path::Path::new("/nonexistent/states.json")).unwrap();
assert!(states.is_empty());
}
// ---------------------------------------------------------------------------
// Additional edge cases
// ---------------------------------------------------------------------------
#[tokio::test]
async fn state_update_overwrites_previous() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("extension-states.json");
let store = ExtensionStateStore::new(path.clone());
store.load().await.unwrap();
// Enable, then disable.
store.set(make_state("my-ext", "1.0.0", true)).await;
store.set(make_state("my-ext", "1.0.0", false)).await;
store.flush().await.unwrap();
let loaded = load_states_from_file(&path).unwrap();
assert_eq!(loaded.len(), 1);
assert!(!loaded["my-ext"].enabled);
}
#[tokio::test]
async fn set_all_replaces_entire_state() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("extension-states.json");
let store = ExtensionStateStore::new(path.clone());
store.load().await.unwrap();
store.set(make_state("old-ext", "1.0.0", true)).await;
let mut new_states = HashMap::new();
new_states.insert("new-ext".to_string(), make_state("new-ext", "2.0.0", false));
store.set_all(new_states).await;
store.flush().await.unwrap();
let loaded = load_states_from_file(&path).unwrap();
assert_eq!(loaded.len(), 1);
assert!(loaded.contains_key("new-ext"));
assert!(!loaded.contains_key("old-ext"));
}
#[tokio::test]
async fn remove_state() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("extension-states.json");
let store = ExtensionStateStore::new(path.clone());
store.load().await.unwrap();
store.set(make_state("ext-a", "1.0.0", true)).await;
store.set(make_state("ext-b", "1.0.0", true)).await;
store.remove("ext-a").await;
store.flush().await.unwrap();
let loaded = load_states_from_file(&path).unwrap();
assert_eq!(loaded.len(), 1);
assert!(loaded.contains_key("ext-b"));
}
#[test]
fn save_and_load_file_roundtrip() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("states.json");
let mut states = HashMap::new();
states.insert("ext-a".to_string(), make_state("ext-a", "1.0.0", true));
states.insert("ext-b".to_string(), make_state("ext-b", "2.0.0", false));
save_states_to_file(&path, &states).unwrap();
let loaded = load_states_from_file(&path).unwrap();
assert_eq!(loaded.len(), 2);
assert!(loaded["ext-a"].enabled);
assert!(!loaded["ext-b"].enabled);
}
#[test]
fn save_creates_parent_dirs() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("nested").join("dir").join("states.json");
save_states_to_file(&path, &HashMap::new()).unwrap();
assert!(path.exists());
}
@@ -0,0 +1,155 @@
//! Integration tests for template resolution (test-plan MV-5 through MV-9).
//!
//! These test the public API surface of env var and @file: template resolution.
use std::collections::HashMap;
use nomifun_extension::{resolve_env_map, resolve_env_templates, resolve_file_reference};
// -- MV-5: env var template resolution --
#[test]
fn mv5_env_var_resolved() {
unsafe { std::env::set_var("_IT_MY_API_KEY", "test123") };
let result = resolve_env_templates("${_IT_MY_API_KEY}", false).unwrap();
assert_eq!(result, "test123");
unsafe { std::env::remove_var("_IT_MY_API_KEY") };
}
#[test]
fn mv5_env_var_in_map() {
unsafe { std::env::set_var("_IT_MAP_KEY", "secret_value") };
let mut env = HashMap::new();
env.insert("API_KEY".into(), "${_IT_MAP_KEY}".into());
let resolved = resolve_env_map(&env, false).unwrap();
assert_eq!(resolved["API_KEY"], "secret_value");
unsafe { std::env::remove_var("_IT_MAP_KEY") };
}
// -- MV-6: strict mode undefined variable error --
#[test]
fn mv6_strict_mode_undefined_var_error() {
let err = resolve_env_templates("${_IT_UNDEFINED_VAR}", true).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("_IT_UNDEFINED_VAR"));
}
// -- MV-7: lenient mode undefined variable -> empty string --
#[test]
fn mv7_lenient_mode_undefined_var_empty() {
let result = resolve_env_templates("prefix_${_IT_UNDEFINED_VAR}_suffix", false).unwrap();
assert_eq!(result, "prefix__suffix");
}
// -- MV-8: @file: reference resolved --
#[test]
fn mv8_file_reference_resolved() {
let dir = std::env::temp_dir().join("ext_it_file_ref");
std::fs::create_dir_all(&dir).unwrap();
let prompt_dir = dir.join("prompts");
std::fs::create_dir_all(&prompt_dir).unwrap();
std::fs::write(prompt_dir.join("system.md"), "You are a helpful assistant.").unwrap();
let result = resolve_file_reference("@file:prompts/system.md", &dir).unwrap();
assert_eq!(result, "You are a helpful assistant.");
std::fs::remove_dir_all(&dir).unwrap();
}
// -- MV-9: @file: reference file not found --
#[test]
fn mv9_file_reference_not_found() {
let dir = std::env::temp_dir().join("ext_it_file_ref_missing");
std::fs::create_dir_all(&dir).unwrap();
let err = resolve_file_reference("@file:nonexistent.md", &dir).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("nonexistent.md"));
std::fs::remove_dir_all(&dir).unwrap();
}
// -- Edge cases --
#[test]
fn non_file_reference_passes_through() {
let result = resolve_file_reference("Just a normal system prompt", std::path::Path::new("/tmp")).unwrap();
assert_eq!(result, "Just a normal system prompt");
}
#[test]
fn multiple_env_vars_in_one_string() {
unsafe { std::env::set_var("_IT_HOST", "localhost") };
unsafe { std::env::set_var("_IT_PORT", "8080") };
let result = resolve_env_templates("http://${_IT_HOST}:${_IT_PORT}/api", false).unwrap();
assert_eq!(result, "http://localhost:8080/api");
unsafe { std::env::remove_var("_IT_HOST") };
unsafe { std::env::remove_var("_IT_PORT") };
}
#[test]
fn env_map_strict_mode_error() {
let mut env = HashMap::new();
env.insert("KEY".into(), "${_IT_STRICT_MISSING}".into());
assert!(resolve_env_map(&env, true).is_err());
}
#[test]
fn dollar_without_brace_is_literal() {
let result = resolve_env_templates("price is $50 each", false).unwrap();
assert_eq!(result, "price is $50 each");
}
// -- Path traversal protection --
#[test]
fn path_traversal_with_dotdot_blocked() {
let dir = std::env::temp_dir().join("ext_it_traversal");
std::fs::create_dir_all(&dir).unwrap();
let outside = std::env::temp_dir().join("ext_it_traversal_outside.txt");
std::fs::write(&outside, "should not be readable").unwrap();
let err = resolve_file_reference("@file:../ext_it_traversal_outside.txt", &dir).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("traversal") || msg.contains("Path traversal"));
std::fs::remove_dir_all(&dir).unwrap();
std::fs::remove_file(&outside).unwrap();
}
#[test]
fn path_traversal_nested_dotdot_blocked() {
let dir = std::env::temp_dir().join("ext_it_traversal_nested");
let sub = dir.join("deep");
std::fs::create_dir_all(&sub).unwrap();
let outside = std::env::temp_dir().join("ext_it_traversal_nested_secret.txt");
std::fs::write(&outside, "nested secret").unwrap();
let err = resolve_file_reference("@file:deep/../../ext_it_traversal_nested_secret.txt", &dir).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("traversal") || msg.contains("Path traversal"));
std::fs::remove_dir_all(&dir).unwrap();
std::fs::remove_file(&outside).unwrap();
}
#[test]
fn valid_subdirectory_reference_allowed() {
let dir = std::env::temp_dir().join("ext_it_valid_subdir");
let sub = dir.join("data");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("config.json"), r#"{"key": "value"}"#).unwrap();
let result = resolve_file_reference("@file:data/config.json", &dir).unwrap();
assert_eq!(result, r#"{"key": "value"}"#);
std::fs::remove_dir_all(&dir).unwrap();
}