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

328 lines
12 KiB
Rust

//! 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);
}