Files
OxideJS-New/crates/oxide_cli/tests/server_cli.rs
T

370 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! server CLI 集成测试:真实二进制的分派面(help 列子命令、占位臂退出码、
//! cleanup 幂等、start 与 restart 端到端)。
//!
//! start / restart 端到端与 cleanup 走 well-known 全局路径(每用户单例):
//! 测试先探活,socket 存活即 panic 不抢占存活 server;三枚触碰全局路径的
//! 测试经同一把锁串行,避免相互干扰。
use std::io::{BufReader, Write};
use std::os::unix::net::UnixStream;
use std::process::{Command, Output};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use oxide_cli::server::protocol::{self, FrameReader, ServerRequest, ServerResponse};
use oxide_cli::server::sidecar;
/// well-known 全局路径互斥锁:同测试二进制内触碰全局路径的测试串行执行。
static WELL_KNOWN_LOCK: Mutex<()> = Mutex::new(());
/// 运行 oxide 二进制并返回输出。
fn oxide(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_oxide"))
.args(args)
.output()
.expect("failed to run oxide")
}
/// 发送请求帧并读回一帧响应。
fn send_and_recv(stream: &mut UnixStream, request: &ServerRequest) -> ServerResponse {
stream
.write_all(protocol::encode_request(request).as_bytes())
.expect("写请求应成功");
let mut reader = FrameReader::new(BufReader::new(stream));
let frame = reader.read_frame().expect("读响应应成功").expect("应读到响应帧");
protocol::parse_response(&frame).expect("响应帧解析应成功")
}
/// help 列全部十个子命令。
#[test]
fn server_help_lists_subcommands() {
let output = oxide(&["server", "--help"]);
assert!(output.status.success(), "server --help 应退 0");
let stdout = String::from_utf8_lossy(&output.stdout);
for name in [
"start", "stop", "status", "health", "info", "version", "cleanup", "restart", "log", "forge",
] {
assert!(stdout.contains(name), "help 应列出 {name}:{stdout}");
}
}
/// 占位臂退 2,stderr 含 not yet implemented。
///
/// version/status/health/info/stop/restart/log 七臂已是真实实现(无 server
/// 时退 1),不在此列;forge 仍是占位。
#[test]
fn server_stub_subcommands_exit_2() {
for sub in ["forge"] {
let output = oxide(&["server", sub]);
assert_eq!(output.status.code(), Some(2), "{sub} 应退 2");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("not yet implemented"), "{sub} 应打印未实现提示:{stderr}");
}
}
/// log 子命令无日志文件时退 1,stderr 含「日志文件不存在」。
///
/// 走 well-known 全局路径:先探活,存活 server 占用时 panic 不抢占;删除
/// well-known 日志文件(无存活 server 时删除安全)。
#[test]
fn server_log_no_file_exit_1() {
let _guard = WELL_KNOWN_LOCK.lock().expect("全局路径锁不应中毒");
assert!(
!sidecar::is_server_alive(&sidecar::well_known_socket_path()),
"存活 server 占用 well-known 路径,测试不抢占"
);
let log_path = sidecar::well_known_log_path();
let _ = std::fs::remove_file(&log_path);
let output = oxide(&["server", "log"]);
assert_eq!(output.status.code(), Some(1), "log 应退 1");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("日志文件不存在"), "log 应打印无日志文件提示:{stderr}");
}
/// 五个控制臂无 server 时各退 1,stderr 含「无已注册的 server」提示。
///
/// 走 well-known 全局路径:先探活,存活 server 占用时 panic 不抢占。
#[test]
fn server_control_arms_no_server_exit_1() {
let _guard = WELL_KNOWN_LOCK.lock().expect("全局路径锁不应中毒");
assert!(
!sidecar::is_server_alive(&sidecar::well_known_socket_path()),
"存活 server 占用 well-known 路径,测试不抢占"
);
for sub in ["stop", "status", "health", "info", "version"] {
let output = oxide(&["server", sub]);
assert_eq!(output.status.code(), Some(1), "{sub} 应退 1");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("无已注册的 server"),
"{sub} 应打印无 server 提示:{stderr}"
);
}
}
/// cleanup 退 0(幂等:有残留则清理、无残留则报无残留),事后全局路径无文件。
#[test]
fn server_cleanup_exit_0() {
let _guard = WELL_KNOWN_LOCK.lock().expect("全局路径锁不应中毒");
assert!(
!sidecar::is_server_alive(&sidecar::well_known_socket_path()),
"存活 server 占用 well-known 路径,测试不抢占"
);
let output = oxide(&["server", "cleanup"]);
assert_eq!(output.status.code(), Some(0), "cleanup 应退 0");
assert!(!sidecar::well_known_socket_path().exists(), "socket 文件应不存在");
assert!(!sidecar::well_known_sidecar_path().exists(), "sidecar 文件应不存在");
}
/// start 端到端:CLI 即刻返回(server 是脱离的孙进程),断言退 0 且打印
/// 「已启动」,well-known 路径连接,版本帧断言构建期版本,关闭帧后 server
/// 进程退出,socket 与 sidecar 文件被删。
#[test]
fn server_start_e2e() {
let _guard = WELL_KNOWN_LOCK.lock().expect("全局路径锁不应中毒");
let socket = sidecar::well_known_socket_path();
let sidecar_path = sidecar::well_known_sidecar_path();
// 探活先行:存活 server 占用全局路径时 panic,不抢占。
assert!(!sidecar::is_server_alive(&socket), "存活 server 占用 well-known 路径,测试不抢占");
// start 为守护形态:CLI 即刻返回,server 是脱离的孙进程;worker 数压到 2
// 避免按宿主核数预热的成本。
let output = oxide(&["server", "start", "--workers", "2"]);
assert_eq!(output.status.code(), Some(0), "start 应退 0");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("已启动"), "start 应打印「已启动」:{stdout}");
// 读 sidecar 取 server 进程号。
let id = sidecar::read_identity(&sidecar_path).expect("读 sidecar 应成功");
// 版本请求帧:版本应等于构建期版本。
let mut stream = UnixStream::connect(&socket).expect("连接 server 应成功");
let response = send_and_recv(&mut stream, &ServerRequest::Version);
match response {
ServerResponse::Version { version } => {
assert_eq!(version, env!("CARGO_PKG_VERSION"), "版本应与构建期版本一致");
}
other => panic!("应得 Version 帧,实得 {other:?}"),
}
// 关闭请求帧:读回关闭确认帧。
let response = send_and_recv(&mut stream, &ServerRequest::Shutdown);
assert!(matches!(response, ServerResponse::Shutdown), "应得关闭确认帧:{response:?}");
drop(stream);
// 轮询 sidecar 文件消失(30 秒截止、50 毫秒间隔)判 server 进程已退出。
let deadline = Instant::now() + Duration::from_secs(30);
while sidecar_path.exists() {
assert!(Instant::now() < deadline, "server 应在有界时间内退出");
std::thread::sleep(Duration::from_millis(50));
}
assert!(!socket.exists(), "socket 文件应被删除");
assert!(!sidecar_path.exists(), "sidecar 文件应被删除");
assert!(!sidecar::pid_alive(id.pid), "server 进程应已退出");
}
/// start 双启动幂等:已有存活 server 时再跑 start,断言第二次退 0 且打印
/// 「已启动」(第二个 spawn 的子进程认领被拒退 1 不触碰文件,探测看到第一个
/// server 即通过)。
#[test]
fn server_start_when_running() {
let _guard = WELL_KNOWN_LOCK.lock().expect("全局路径锁不应中毒");
let socket = sidecar::well_known_socket_path();
let sidecar_path = sidecar::well_known_sidecar_path();
// 探活先行:存活 server 占用全局路径时 panic,不抢占。
assert!(!sidecar::is_server_alive(&socket), "存活 server 占用 well-known 路径,测试不抢占");
// 首次 start:CLI 即刻返回,server 是脱离的孙进程。
let output = oxide(&["server", "start", "--workers", "2"]);
assert_eq!(output.status.code(), Some(0), "首次 start 应退 0");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("已启动"), "首次 start 应打印「已启动」:{stdout}");
// 二次 start:第二个 spawn 的子进程认领被拒,探测看到第一个 server 即通过。
let output = oxide(&["server", "start", "--workers", "2"]);
assert_eq!(output.status.code(), Some(0), "二次 start 应退 0");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("已启动"), "二次 start 应打印「已启动」:{stdout}");
// 收尾:发关闭请求,等 sidecar 消失。
let mut stream = UnixStream::connect(&socket).expect("连接 server 应成功");
let response = send_and_recv(&mut stream, &ServerRequest::Shutdown);
assert!(matches!(response, ServerResponse::Shutdown), "应得关闭确认帧:{response:?}");
drop(stream);
let deadline = Instant::now() + Duration::from_secs(30);
while sidecar_path.exists() {
assert!(Instant::now() < deadline, "server 应在有界时间内退出");
std::thread::sleep(Duration::from_millis(50));
}
assert!(!socket.exists(), "socket 文件应被删除");
assert!(!sidecar_path.exists(), "sidecar 文件应被删除");
}
/// restart 端到端:起真实子进程 A,跑 `oxide server restart`(发关闭、等退出、
/// 拉起新 server、就绪探测),断言退 0 且打印「已重启」,旧进程退 0,新 sidecar
/// 进程号不同且版本为构建期版本,新 server 健康与版本请求正常,收尾关闭后文件被删。
#[test]
fn server_restart_e2e() {
let _guard = WELL_KNOWN_LOCK.lock().expect("全局路径锁不应中毒");
let socket = sidecar::well_known_socket_path();
let sidecar_path = sidecar::well_known_sidecar_path();
// 探活先行:存活 server 占用全局路径时 panic,不抢占。
assert!(!sidecar::is_server_alive(&socket), "存活 server 占用 well-known 路径,测试不抢占");
// 起子进程 A:worker 数压到 2,轮询 socket 文件出现(10 秒截止)判就绪。
let mut child = Command::new(env!("CARGO_BIN_EXE_oxide"))
.args(["server", "start", "--workers", "2"])
.spawn()
.expect("oxide server start 应可启动");
let deadline = Instant::now() + Duration::from_secs(10);
while !socket.exists() {
assert!(Instant::now() < deadline, "socket 文件未出现");
std::thread::sleep(Duration::from_millis(10));
}
// 读 sidecar 取旧进程号。
let old_id = sidecar::read_identity(&sidecar_path).expect("读旧 sidecar 应成功");
// 跑 restart:断言退 0、stdout 含「已重启」。
let output = oxide(&["server", "restart"]);
assert_eq!(output.status.code(), Some(0), "restart 应退 0");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("已重启"), "restart 应打印「已重启」:{stdout}");
// 断言子进程 A 以退出码 0 退出(30 秒截止,try_wait 轮询)。
let deadline = Instant::now() + Duration::from_secs(30);
loop {
match child.try_wait() {
Ok(Some(status)) => {
assert_eq!(status.code(), Some(0), "旧 server 应以退出码 0 退出:{status:?}");
break;
}
Ok(None) => {
assert!(Instant::now() < deadline, "旧 server 应在有界时间内退出");
std::thread::sleep(Duration::from_millis(50));
}
Err(err) => panic!("等待子进程失败:{err}"),
}
}
// 断言新 sidecar 存在、进程号与旧进程号不同、版本等于构建期版本。
let new_id = sidecar::read_identity(&sidecar_path).expect("读新 sidecar 应成功");
assert_ne!(new_id.pid, old_id.pid, "新 server 进程号应不同于旧 server");
assert_eq!(new_id.version, env!("CARGO_PKG_VERSION"), "新 sidecar 版本应与构建期版本一致");
// 连接新 server:健康请求得 healthy,版本请求得构建期版本。
let mut stream = UnixStream::connect(&socket).expect("连接新 server 应成功");
match send_and_recv(&mut stream, &ServerRequest::Health) {
ServerResponse::Health { healthy } => assert!(healthy, "新 server 应健康"),
other => panic!("应得 Health 帧,实得 {other:?}"),
}
match send_and_recv(&mut stream, &ServerRequest::Version) {
ServerResponse::Version { version } => {
assert_eq!(version, env!("CARGO_PKG_VERSION"), "版本应与构建期版本一致");
}
other => panic!("应得 Version 帧,实得 {other:?}"),
}
// 收尾:发关闭请求,等新 server 退出,断言 socket 与 sidecar 文件被删。
let response = send_and_recv(&mut stream, &ServerRequest::Shutdown);
assert!(matches!(response, ServerResponse::Shutdown), "应得关闭确认帧:{response:?}");
drop(stream);
let deadline = Instant::now() + Duration::from_secs(30);
while sidecar_path.exists() {
assert!(Instant::now() < deadline, "新 server 应在有界时间内退出");
std::thread::sleep(Duration::from_millis(50));
}
assert!(!socket.exists(), "socket 文件应被删除");
assert!(!sidecar_path.exists(), "sidecar 文件应被删除");
}
/// log 端到端:守护形态 start 后轮询日志文件出现「KernelCore initialized」
/// 行判就绪(socket 文件出现早于内核构造,不能直接作为日志就绪信号),
/// 断言 `log` 退 0 且含该行、`--lines 1` 恰一行、`--level error` 为空
/// (Info 级无 ERROR 行),关闭后日志文件有意保留。
#[test]
fn server_log_e2e() {
let _guard = WELL_KNOWN_LOCK.lock().expect("全局路径锁不应中毒");
let socket = sidecar::well_known_socket_path();
let sidecar_path = sidecar::well_known_sidecar_path();
let log_path = sidecar::well_known_log_path();
// 探活先行:存活 server 占用全局路径时 panic,不抢占。
assert!(!sidecar::is_server_alive(&socket), "存活 server 占用 well-known 路径,测试不抢占");
// 删旧日志文件:server 即将全新启动,内容断言不受历史污染。
let _ = std::fs::remove_file(&log_path);
// start 为守护形态:CLI 子进程即刻退 0 并打印「已启动」,server 是脱离
// 的孙进程(其日志初始化在孙进程内确定性生效,内容断言无竞态)。
let output = oxide(&["server", "start", "--workers", "2"]);
assert_eq!(output.status.code(), Some(0), "start 应退 0");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("已启动"), "start 应打印「已启动」:{stdout}");
// 读 sidecar 取 server 进程号。
let id = sidecar::read_identity(&sidecar_path).expect("读 sidecar 应成功");
// 轮询日志文件出现「KernelCore initialized」行判就绪(30 秒截止、
// 50 毫秒间隔)。
let deadline = Instant::now() + Duration::from_secs(30);
let content = loop {
let content = std::fs::read_to_string(&log_path).unwrap_or_default();
if content.contains("KernelCore initialized") {
break content;
}
assert!(Instant::now() < deadline, "日志文件应在 30 秒内含内核初始化行");
std::thread::sleep(Duration::from_millis(50));
};
assert!(!content.is_empty(), "日志文件内容应非空");
// log:退 0 且含内核初始化行。
let output = oxide(&["server", "log"]);
assert_eq!(output.status.code(), Some(0), "log 应退 0");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("KernelCore initialized"), "log 应含内核初始化行:{stdout}");
// --lines 1:恰一行。
let output = oxide(&["server", "log", "--lines", "1"]);
assert_eq!(output.status.code(), Some(0), "log --lines 1 应退 0");
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(stdout.lines().count(), 1, "log --lines 1 应恰一行:{stdout}");
// --level error:空(Info 级无 ERROR 行)。
let output = oxide(&["server", "log", "--level", "error"]);
assert_eq!(output.status.code(), Some(0), "log --level error 应退 0");
assert!(
output.stdout.is_empty(),
"log --level error 应为空:{}",
String::from_utf8_lossy(&output.stdout)
);
// 关闭请求帧:读回关闭确认帧。
let mut stream = UnixStream::connect(&socket).expect("连接 server 应成功");
let response = send_and_recv(&mut stream, &ServerRequest::Shutdown);
assert!(matches!(response, ServerResponse::Shutdown), "应得关闭确认帧:{response:?}");
drop(stream);
// 轮询 sidecar 文件消失(30 秒截止、50 毫秒间隔)判 server 进程已退出。
let deadline = Instant::now() + Duration::from_secs(30);
while sidecar_path.exists() {
assert!(Instant::now() < deadline, "server 应在有界时间内退出");
std::thread::sleep(Duration::from_millis(50));
}
assert!(!sidecar::pid_alive(id.pid), "server 进程应已退出");
// 日志文件有意保留(跨重启的诊断工件)。
assert!(log_path.exists(), "日志文件退出后应保留");
// 收尾:删除日志文件恢复状态。
let _ = std::fs::remove_file(&log_path);
}