Files
EasyCLIProxyAPI/src-tauri/src/tests/core_runtime.rs
T
lzt404 18a9bfbaa9 fix(ui): unify bottom-right notifications and improve dialog interactions
- Move operation feedback into a shared floating stack to prevent layout shifts
- Allow identical errors to reappear after dismissal
- Support keyboard navigation, scrolling, and focus restoration in dialogs
- Add notification interaction regression tests
- Remove code comments throughout the project
2026-09-15 02:39:03 +08:00

792 lines
27 KiB
Rust

use super::support::*;
use super::*;
#[test]
fn core_process_starting_state_tracks_automatic_launch() {
let state = CoreProcessState::new(true);
assert!(state.is_starting());
state.set_starting(false);
assert!(!state.is_starting());
}
#[test]
fn executable_path_matching_keeps_core_instances_directory_scoped() {
let root = agent_test_home("core-process-path-scope");
let first_dir = root.join("first").join("cpa-core");
let second_dir = root.join("second").join("cpa-core");
fs::create_dir_all(&first_dir).unwrap();
fs::create_dir_all(&second_dir).unwrap();
let first_binary = first_dir.join(core_binary_name());
let second_binary = second_dir.join(core_binary_name());
fs::write(&first_binary, b"first").unwrap();
fs::write(&second_binary, b"second").unwrap();
assert!(executable_paths_match(&first_binary, &first_binary));
assert!(!executable_paths_match(&first_binary, &second_binary));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn core_process_discovery_sleep_helper() {
if env::var_os("EASYCLIPROXYAPI_PROCESS_DISCOVERY_TEST_HELPER").is_some() {
thread::sleep(Duration::from_secs(10));
}
}
fn core_child_sleep_command() -> Command {
let mut command = Command::new(env::current_exe().unwrap());
command
.args([
"--exact",
"tests::core_runtime::core_process_discovery_sleep_helper",
"--nocapture",
])
.env("EASYCLIPROXYAPI_PROCESS_DISCOVERY_TEST_HELPER", "1")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
configure_background_command(&mut command);
command
}
fn assert_core_child_survives(child: &mut Child) {
thread::sleep(Duration::from_millis(200));
let status = child.try_wait();
let _ = child.kill();
let _ = child.wait();
assert!(status.unwrap().is_none(), "core exited with its launcher");
}
#[test]
fn core_child_survives_launcher_thread_exit() {
let mut child = thread::spawn(|| spawn_core_child(core_child_sleep_command()).unwrap())
.join()
.unwrap();
assert_core_child_survives(&mut child);
}
#[test]
fn core_child_survives_blocking_runtime_shutdown() {
let runtime = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let mut child = runtime
.block_on(runtime.spawn_blocking(|| spawn_core_child(core_child_sleep_command()).unwrap()))
.unwrap();
drop(runtime);
assert_core_child_survives(&mut child);
}
#[test]
fn core_child_spawner_remains_available_after_spawn_failure() {
let missing_binary = agent_test_home("missing-core-spawner-binary").join(core_binary_name());
assert!(spawn_core_child_on_lifetime_thread(Command::new(missing_binary)).is_err());
let mut child =
thread::spawn(|| spawn_core_child_on_lifetime_thread(core_child_sleep_command()).unwrap())
.join()
.unwrap();
assert_core_child_survives(&mut child);
}
#[test]
fn exiting_during_core_startup_cancels_the_port_wait() {
let state = std::sync::Arc::new(CoreProcessState::new(true));
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
drop(listener);
let mut child = spawn_core_child(core_child_sleep_command()).unwrap();
let child_id = child.id();
let exiting_state = state.clone();
let exit = thread::spawn(move || {
thread::sleep(Duration::from_millis(100));
exiting_state.shutting_down.store(true, Ordering::Release);
});
let started = Instant::now();
let result = wait_for_core_management_port(&mut child, address, &state);
drop(child);
exit.join().unwrap();
assert!(matches!(result, Err(CoreStartupFailure::ShuttingDown)));
assert!(started.elapsed() < Duration::from_secs(2));
assert!(!is_process_alive(child_id));
}
#[test]
fn exiting_rejects_new_core_operations_and_cleans_up_an_in_flight_child() {
let state = CoreProcessState::new(false);
let child = spawn_core_child(core_child_sleep_command()).unwrap();
let child_id = child.id();
state.shutting_down.store(true, Ordering::Release);
assert!(lock_core_operation(&state)
.unwrap_err()
.contains("应用正在退出"));
assert!(state
.store_child(child)
.unwrap_err()
.contains("应用正在退出"));
assert!(!is_process_alive(child_id));
assert_eq!(state.managed_pid(), None);
}
#[cfg(windows)]
#[test]
fn core_config_file_lock_helper() {
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE};
let Some(path) = env::var_os("EASYCLIPROXYAPI_CORE_FILE_LOCK_TEST_HELPER") else {
return;
};
let _file = File::options()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
.open(path)
.unwrap();
println!("CORE_FILE_LOCKED");
io::stdout().flush().unwrap();
thread::sleep(Duration::from_secs(10));
}
#[cfg(windows)]
#[test]
fn updating_stops_an_adopted_core_without_a_port_before_replacing_its_config() {
use std::io::BufRead;
let root = agent_test_home("core-stop-before-replace");
let config_path = root.join(CORE_CONFIG_FILE);
let replacement = root.join("new-config.yaml");
fs::write(&config_path, b"old config").unwrap();
fs::write(&replacement, b"new config").unwrap();
let binary_path = env::current_exe().unwrap();
let mut command = Command::new(&binary_path);
command
.args([
"--exact",
"tests::core_runtime::core_config_file_lock_helper",
"--nocapture",
])
.env("EASYCLIPROXYAPI_CORE_FILE_LOCK_TEST_HELPER", &config_path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
configure_background_command(&mut command);
let mut child = command.spawn().unwrap();
let output = io::BufReader::new(child.stdout.take().unwrap());
let ready = output
.lines()
.any(|line| line.unwrap() == "CORE_FILE_LOCKED");
let state = CoreProcessState::new(false);
state
.adopt_process_ids(&binary_path, vec![child.id()])
.unwrap();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let status = current_core_status(Some(&state), Some(port)).unwrap();
let locked_result = copy_core_file_replace(&replacement, &config_path);
let stopped = pause_core_process_for_install(&state);
let exited = child.try_wait().unwrap().is_some();
let replace_result = copy_core_file_replace(&replacement, &config_path);
let _ = child.kill();
let _ = child.wait();
assert!(ready);
assert!(
!status.running,
"the orphan has no listening management port"
);
assert!(
locked_result.is_err(),
"the orphan must hold a real Windows file lock"
);
assert!(
stopped.unwrap(),
"the update must stop the orphan despite the closed port"
);
assert!(
exited,
"stopping must wait until the process has actually exited"
);
replace_result.unwrap();
assert_eq!(fs::read(&config_path).unwrap(), b"new config");
fs::remove_dir_all(root).unwrap();
}
#[cfg(any(target_os = "linux", windows))]
#[test]
fn core_child_owner_process_helper() {
if env::var_os("EASYCLIPROXYAPI_CORE_OWNER_TEST_HELPER").is_none() {
return;
}
let mut command = core_child_sleep_command();
command.stdout(Stdio::inherit());
let _child = spawn_core_child(command).unwrap();
println!("CORE_CHILD_READY");
io::stdout().flush().unwrap();
thread::sleep(Duration::from_secs(10));
}
#[cfg(any(target_os = "linux", windows))]
#[test]
fn core_child_stops_when_owner_process_is_killed() {
use std::io::BufRead;
let mut owner = Command::new(env::current_exe().unwrap())
.args([
"--exact",
"tests::core_runtime::core_child_owner_process_helper",
"--nocapture",
])
.env("EASYCLIPROXYAPI_CORE_OWNER_TEST_HELPER", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut output = io::BufReader::new(owner.stdout.take().unwrap());
let mut ready = false;
loop {
let mut line = String::new();
match output.read_line(&mut line) {
Ok(0) | Err(_) => break,
Ok(_) if line.trim() == "CORE_CHILD_READY" => {
ready = true;
break;
}
Ok(_) => {}
}
}
let killed = owner.kill();
let waited = owner.wait();
let shutdown_started = Instant::now();
let mut remaining_output = String::new();
let drained = output.read_to_string(&mut remaining_output);
assert!(ready, "owner did not finish spawning its core child");
killed.unwrap();
waited.unwrap();
drained.unwrap();
assert!(
shutdown_started.elapsed() < Duration::from_secs(5),
"core kept its output pipe open after the owner was killed"
);
}
#[test]
fn running_core_process_discovery_ignores_the_same_binary_name_in_another_directory() {
let root = agent_test_home("running-core-process-scope");
let first_dir = root.join("first").join("cpa-core");
let second_dir = root.join("second").join("cpa-core");
fs::create_dir_all(&first_dir).unwrap();
fs::create_dir_all(&second_dir).unwrap();
let first_binary = first_dir.join(core_binary_name());
let second_binary = second_dir.join(core_binary_name());
let source_binary = env::current_exe().unwrap();
fs::copy(&source_binary, &first_binary).unwrap();
fs::copy(&source_binary, &second_binary).unwrap();
let arguments = [
"--exact",
"tests::core_runtime::core_process_discovery_sleep_helper",
"--nocapture",
];
let mut first = Command::new(&first_binary)
.args(&arguments)
.env("EASYCLIPROXYAPI_PROCESS_DISCOVERY_TEST_HELPER", "1")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut second = Command::new(&second_binary)
.args(&arguments)
.env("EASYCLIPROXYAPI_PROCESS_DISCOVERY_TEST_HELPER", "1")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
thread::sleep(Duration::from_millis(200));
let first_running = first.try_wait().unwrap().is_none();
let second_running = second.try_wait().unwrap().is_none();
let candidate_process_ids = find_candidate_core_process_ids();
let first_actual_path = process_executable_path(first.id());
let second_actual_path = process_executable_path(second.id());
let first_matches = find_core_process_ids(&first_binary);
let second_matches = find_core_process_ids(&second_binary);
let _ = first.kill();
let _ = second.kill();
let _ = first.wait();
let _ = second.wait();
assert_eq!(
first_matches,
vec![first.id()],
"running={first_running}, candidate={}, actual={first_actual_path:?}, expected={first_binary:?}",
candidate_process_ids.contains(&first.id())
);
assert_eq!(
second_matches,
vec![second.id()],
"running={second_running}, candidate={}, actual={second_actual_path:?}, expected={second_binary:?}",
candidate_process_ids.contains(&second.id())
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn current_process_executable_path_can_be_resolved() {
let expected = env::current_exe().unwrap();
let actual = process_executable_path(std::process::id()).unwrap();
assert!(executable_paths_match(&expected, &actual));
}
#[test]
fn core_process_state_tracks_and_releases_adopted_processes() {
let state = CoreProcessState::new(false);
let binary_path = env::current_exe().unwrap();
state
.adopt_process_ids(&binary_path, vec![std::process::id(), std::process::id()])
.unwrap();
assert_eq!(state.managed_pid(), Some(std::process::id()));
assert_eq!(
state
.take_adopted_processes()
.into_iter()
.map(|process| process.process_id)
.collect::<Vec<_>>(),
vec![std::process::id()]
);
assert_eq!(state.managed_pid(), None);
}
#[test]
fn successful_core_install_remains_successful_when_restart_succeeds() {
let result = combine_install_and_restart_results(Ok("installed"), Ok(()));
assert_eq!(result.unwrap(), "installed");
}
#[test]
fn successful_core_install_reports_automatic_restart_failure() {
let result = combine_install_and_restart_results(Ok("installed"), Err("port busy".into()));
assert_eq!(
result.unwrap_err(),
"内核已安装,但自动恢复运行失败: port busy"
);
}
#[test]
fn failed_core_install_keeps_the_install_error_after_runtime_is_restored() {
let result =
combine_install_and_restart_results::<()>(Err("download cancelled".into()), Ok(()));
assert_eq!(result.unwrap_err(), "download cancelled");
}
#[test]
fn failed_core_install_reports_restart_failure_too() {
let result = combine_install_and_restart_results::<()>(
Err("checksum mismatch".into()),
Err("port busy".into()),
);
assert_eq!(
result.unwrap_err(),
"checksum mismatch;自动恢复原内核运行状态也失败: port busy"
);
}
#[test]
fn replacing_a_core_preserves_only_regular_bundled_assets() {
let root = agent_test_home("bundled-assets");
let source = root.join("source");
let target = root.join("target");
fs::create_dir_all(&source).unwrap();
fs::create_dir_all(&target).unwrap();
fs::write(
source.join("CLIProxyAPI_7.2.83_linux_amd64.tar.gz"),
b"archive",
)
.unwrap();
fs::write(
source.join("CLIProxyAPI_7.2.83_linux_amd64_no-plugin.tar.gz"),
b"portable",
)
.unwrap();
fs::write(source.join(CORE_CHECKSUMS_FILE), b"checksums").unwrap();
preserve_bundled_core_assets(&source, &target).unwrap();
assert!(target
.join("CLIProxyAPI_7.2.83_linux_amd64.tar.gz")
.is_file());
assert!(!target
.join("CLIProxyAPI_7.2.83_linux_amd64_no-plugin.tar.gz")
.exists());
assert!(target.join(CORE_CHECKSUMS_FILE).is_file());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn overlaying_a_core_updates_packaged_files_and_preserves_plugins() {
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
let root = agent_test_home("core-overlay-preserves-plugins");
let install_dir = root.join("cpa-core");
let staging_dir = root.join("cpa-core.staging");
fs::create_dir_all(install_dir.join("plugins/custom-router")).unwrap();
fs::create_dir_all(staging_dir.join("plugins/bundled-router")).unwrap();
fs::create_dir_all(staging_dir.join("runtime")).unwrap();
fs::write(install_dir.join(core_binary_name()), b"old core").unwrap();
fs::write(install_dir.join("README.md"), b"old readme").unwrap();
fs::write(install_dir.join("user-data.json"), b"user data").unwrap();
fs::write(
install_dir.join("plugins/custom-router/plugin.js"),
b"custom plugin",
)
.unwrap();
fs::write(staging_dir.join(core_binary_name()), b"new core").unwrap();
fs::write(staging_dir.join("README.md"), b"new readme").unwrap();
fs::write(
staging_dir.join("plugins/bundled-router/plugin.js"),
b"bundled plugin",
)
.unwrap();
fs::write(staging_dir.join("runtime/default.json"), b"new runtime").unwrap();
#[cfg(unix)]
let original_binary_inode = fs::metadata(install_dir.join(core_binary_name()))
.unwrap()
.ino();
overlay_install_dir(&install_dir, &staging_dir).unwrap();
assert_eq!(
fs::read(install_dir.join(core_binary_name())).unwrap(),
b"new core"
);
assert_eq!(
fs::read(install_dir.join("README.md")).unwrap(),
b"new readme"
);
assert_eq!(
fs::read(install_dir.join("runtime/default.json")).unwrap(),
b"new runtime"
);
assert_eq!(
fs::read(install_dir.join("plugins/custom-router/plugin.js")).unwrap(),
b"custom plugin"
);
assert_eq!(
fs::read(install_dir.join("plugins/bundled-router/plugin.js")).unwrap(),
b"bundled plugin"
);
assert_eq!(
fs::read(install_dir.join("user-data.json")).unwrap(),
b"user data"
);
#[cfg(unix)]
assert_ne!(
fs::metadata(install_dir.join(core_binary_name()))
.unwrap()
.ino(),
original_binary_inode,
"更新后的内核必须使用新 inode"
);
assert!(!staging_dir.exists());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn installing_a_core_into_a_missing_directory_moves_the_complete_staging_tree() {
let root = agent_test_home("core-overlay-first-install");
let install_dir = root.join("cpa-core");
let staging_dir = root.join("cpa-core.staging");
fs::create_dir_all(&staging_dir).unwrap();
fs::write(staging_dir.join(core_binary_name()), b"new core").unwrap();
fs::write(staging_dir.join(CORE_EXAMPLE_CONFIG_FILE), b"port: 8317\n").unwrap();
overlay_install_dir(&install_dir, &staging_dir).unwrap();
assert_eq!(
fs::read(install_dir.join(core_binary_name())).unwrap(),
b"new core"
);
assert!(install_dir.join(CORE_EXAMPLE_CONFIG_FILE).is_file());
assert!(!staging_dir.exists());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn replacing_a_core_migrates_old_fields_into_the_new_template() {
let root = agent_test_home("core-config-migrate");
let source = root.join("source");
let target = root.join("target");
fs::create_dir_all(&source).unwrap();
fs::create_dir_all(&target).unwrap();
let old_config = "# Old comment\nhost: 127.0.0.1\nport: 9527\nnested:\n keep: old\n old-only: retained\nlist:\n - old-a\n - old-b\nextra: true\n";
let new_template = "# New template\nhost: \"\"\nport: 8317\nnested:\n keep: new-default\n added: new-field\nlist:\n - new-default\nnew-option: true\n";
fs::write(source.join(CORE_CONFIG_FILE), old_config).unwrap();
fs::write(target.join(CORE_EXAMPLE_CONFIG_FILE), new_template).unwrap();
migrate_core_config_for_update(&source, &target).unwrap();
let migrated = fs::read_to_string(target.join(CORE_CONFIG_FILE)).unwrap();
let document = serde_norway::from_str::<serde_norway::Value>(&migrated).unwrap();
assert!(migrated.contains("# New template"));
assert_eq!(document["host"], "127.0.0.1");
assert_eq!(document["port"], 9527);
assert_eq!(document["nested"]["keep"], "old");
assert_eq!(document["nested"]["added"], "new-field");
assert_eq!(document["nested"]["old-only"], "retained");
assert_eq!(document["list"][0], "old-a");
assert_eq!(document["list"][1], "old-b");
assert_eq!(document["new-option"], true);
assert_eq!(document["extra"], true);
assert_eq!(
fs::read_to_string(target.join(CORE_EXAMPLE_CONFIG_FILE)).unwrap(),
new_template
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn replacing_a_core_rejects_invalid_config_without_overwriting_files() {
let root = agent_test_home("core-config-migrate-invalid");
let source = root.join("source");
let target = root.join("target");
fs::create_dir_all(&source).unwrap();
fs::create_dir_all(&target).unwrap();
fs::write(
source.join(CORE_CONFIG_FILE),
"host: 127.0.0.1\nbroken: [invalid\nport: 9527\napi-keys:\n- old-a\n- old-b\n",
)
.unwrap();
fs::write(
target.join(CORE_EXAMPLE_CONFIG_FILE),
"host: \"\"\nbroken: new-default\nport: 8317\napi-keys:\n - new-default\nnew-option: true\n",
)
.unwrap();
fs::write(target.join(CORE_CONFIG_FILE), "staged: untouched\n").unwrap();
let original = fs::read(source.join(CORE_CONFIG_FILE)).unwrap();
assert!(migrate_core_config_for_update(&source, &target).is_err());
assert_eq!(fs::read(source.join(CORE_CONFIG_FILE)).unwrap(), original);
assert_eq!(
fs::read_to_string(target.join(CORE_CONFIG_FILE)).unwrap(),
"staged: untouched\n"
);
fs::remove_dir_all(root).unwrap();
}
#[cfg(windows)]
#[test]
fn replacing_a_core_rejects_locked_config_without_using_defaults() {
use std::os::windows::fs::OpenOptionsExt;
let root = agent_test_home("core-config-locked");
let source = root.join("source");
let target = root.join("target");
fs::create_dir_all(&source).unwrap();
fs::create_dir_all(&target).unwrap();
let original = "codex-api-key:\n - api-key: test-key\n base-url: https://example.test\n";
let source_path = source.join(CORE_CONFIG_FILE);
fs::write(&source_path, original).unwrap();
fs::write(target.join(CORE_EXAMPLE_CONFIG_FILE), "port: 8317\n").unwrap();
fs::write(target.join(CORE_CONFIG_FILE), "staged: untouched\n").unwrap();
let locked = fs::OpenOptions::new()
.read(true)
.share_mode(0)
.open(&source_path)
.unwrap();
let result = migrate_core_config_for_update(&source, &target);
drop(locked);
assert!(
result.is_err(),
"unreadable configuration must not be replaced by defaults"
);
assert_eq!(fs::read_to_string(source_path).unwrap(), original);
assert_eq!(
fs::read_to_string(target.join(CORE_CONFIG_FILE)).unwrap(),
"staged: untouched\n"
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn bundled_core_bootstrap_runs_only_when_no_core_binary_exists() {
let root = agent_test_home("bundled-bootstrap-detection");
let install_dir = root.join("cpa-core");
assert!(core_needs_bundled_bootstrap(&install_dir));
let existing_version = install_dir.join("existing-version");
fs::create_dir_all(&existing_version).unwrap();
fs::write(existing_version.join(core_binary_name()), b"existing core").unwrap();
assert!(!core_needs_bundled_bootstrap(&install_dir));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn bundled_core_locations_include_macos_app_resources() {
let contents_dir = agent_test_home("bundled-macos-resources")
.join("EasyCLIProxyAPI.app")
.join("Contents");
let executable_dir = contents_dir.join("MacOS");
let base_dir = agent_test_home("bundled-macos-data");
let resource_location = (
contents_dir.join("Resources").join(CORE_VERSION_FILE),
contents_dir.join("Resources").join("cpa-core"),
);
assert_eq!(
macos_app_resources_dir(&executable_dir),
Some(contents_dir.join("Resources"))
);
assert!(bundled_core_locations(&base_dir, &executable_dir).contains(&resource_location));
}
#[test]
fn source_project_root_is_detected_from_the_portable_development_directory() {
let root = agent_test_home("bundled-source-root");
fs::create_dir_all(root.join("src-tauri")).unwrap();
fs::create_dir_all(root.join("bin-work")).unwrap();
fs::write(root.join("package.json"), b"{}").unwrap();
assert_eq!(
source_project_root(&root.join("bin-work")),
Some(root.clone())
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn selected_source_archive_and_checksums_are_copied_into_the_installation() {
let root = agent_test_home("selected-bundled-asset");
let source = root.join("source");
let target = root.join("target");
fs::create_dir_all(&source).unwrap();
fs::create_dir_all(&target).unwrap();
let archive = source.join("CLIProxyAPI_7.2.83_linux_amd64.tar.gz");
fs::write(&archive, b"archive").unwrap();
fs::write(source.join(CORE_CHECKSUMS_FILE), b"checksums").unwrap();
preserve_selected_bundled_core_asset(&archive, &target).unwrap();
assert_eq!(
fs::read(target.join("CLIProxyAPI_7.2.83_linux_amd64.tar.gz")).unwrap(),
b"archive"
);
assert_eq!(
fs::read(target.join(CORE_CHECKSUMS_FILE)).unwrap(),
b"checksums"
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn release_page_assets_parse_download_links_and_sha256() {
let html = r#"
<li><a href="/router-for-me/CLIProxyAPI/releases/download/v1.2.3/checksums.txt">checksums.txt</a></li>
<li><a href="/router-for-me/CLIProxyAPI/releases/download/v1.2.3/CLIProxyAPI_1.2.3_linux_amd64.tar.gz">asset</a>
<span>sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef</span>
</li>
"#;
let assets = parse_release_assets(html);
assert_eq!(assets.len(), 2);
assert_eq!(assets[1].name, "CLIProxyAPI_1.2.3_linux_amd64.tar.gz");
assert_eq!(
assets[1].digest.as_deref(),
Some("sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
);
assert!(assets[1]
.browser_download_url
.ends_with("/releases/download/v1.2.3/CLIProxyAPI_1.2.3_linux_amd64.tar.gz"));
}
#[test]
fn rematerializing_core_binary_preserves_bytes_and_cleans_up_temporary_file() {
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
let root = agent_test_home("core-rematerialize");
fs::create_dir_all(&root).unwrap();
let binary_path = root.join(core_binary_name());
fs::write(&binary_path, b"signed core bytes").unwrap();
#[cfg(unix)]
let original_inode = fs::metadata(&binary_path).unwrap().ino();
rematerialize_core_binary(&binary_path).unwrap();
assert_eq!(fs::read(&binary_path).unwrap(), b"signed core bytes");
#[cfg(unix)]
assert_ne!(fs::metadata(&binary_path).unwrap().ino(), original_inode);
assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn core_start_log_path_follows_the_managed_logs_directory() {
let base_dir = PathBuf::from("test-base");
let install_dir = base_dir.join("cpa-core");
assert_eq!(
core_start_log_path(&install_dir, DEFAULT_AUTH_DIR),
base_dir
.join("oauth")
.join("logs")
.join("core-start-output.log")
);
assert_eq!(
core_start_log_path(&install_dir, "custom-auth"),
install_dir
.join("custom-auth")
.join("logs")
.join("core-start-output.log")
);
}
#[test]
fn core_start_output_helper() {
if env::var_os("EASYCLIPROXYAPI_CORE_OUTPUT_TEST_HELPER").is_none() {
return;
}
println!("core stdout marker");
eprintln!("core stderr marker");
}
#[test]
fn core_start_log_captures_stdout_and_stderr() {
let root = agent_test_home("core-start-output");
let log_path = root.join("logs").join("core-start-output.log");
fs::create_dir_all(log_path.parent().unwrap()).unwrap();
fs::write(&log_path, "stale startup output").unwrap();
let (stdout, stderr) = core_start_stdio(&log_path).unwrap();
let mut command = Command::new(env::current_exe().unwrap());
command
.args([
"--exact",
"tests::core_runtime::core_start_output_helper",
"--nocapture",
])
.env("EASYCLIPROXYAPI_CORE_OUTPUT_TEST_HELPER", "1")
.stdin(Stdio::null())
.stdout(stdout)
.stderr(stderr);
configure_background_command(&mut command);
assert!(command.status().unwrap().success());
let output = fs::read_to_string(&log_path).unwrap();
assert!(output.contains("===== CPA 内核启动"));
assert!(output.contains("core stdout marker"));
assert!(output.contains("core stderr marker"));
assert!(!output.contains("stale startup output"));
fs::remove_dir_all(root).unwrap();
}