mirror of
https://github.com/pjh456/OxideJS.git
synced 2026-10-23 18:21:13 +08:00
370 lines
17 KiB
Rust
370 lines
17 KiB
Rust
//! 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);
|
||
}
|