CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent), MCP server 接入外部 Agent 工具,GUI 开发中。
348 lines
12 KiB
Rust
348 lines
12 KiB
Rust
//! 顶层启动管线测试:dry-run(D18)、完整会话、停止、组装失败、崩溃现场。
|
||
//!
|
||
//! 真进程来自 shell(`cmd /C` 或 `sh -c`),所以这些是端到端用例:
|
||
//! 命令行组装 → 事件流落盘 → 退出判定 → 诊断挂钩 → 索引。
|
||
|
||
use std::path::PathBuf;
|
||
use std::sync::{Arc, Mutex};
|
||
use std::time::Duration;
|
||
|
||
use serde_json::json;
|
||
|
||
use crate::prelude::*;
|
||
|
||
/// 记录调用并返回固定结论的诊断挂钩。
|
||
#[derive(Default)]
|
||
struct RecordingHook {
|
||
calls: Mutex<Vec<String>>,
|
||
}
|
||
|
||
impl DiagnosisHook for RecordingHook {
|
||
fn analyze(&self, request: &DiagnosisRequest) -> Option<serde_json::Value> {
|
||
self.calls.lock().unwrap().push(request.summary());
|
||
Some(json!({
|
||
"source": "rules",
|
||
"needs_more_info": false,
|
||
"candidates": [{ "id": "stub_candidate", "severity": "high", "confidence": 0.9 }],
|
||
}))
|
||
}
|
||
}
|
||
|
||
fn launcher(sessions_root: &std::path::Path, hook: Option<Arc<RecordingHook>>) -> Launcher {
|
||
let mut config = LauncherConfig::new(sessions_root);
|
||
// 固定平台上下文,让命令行断言与运行平台无关。
|
||
config.platform = PlatformCtx::new(OsFamily::Windows, "x86_64");
|
||
config.process = ProcessConfig {
|
||
graceful_stop: Duration::from_millis(200),
|
||
..ProcessConfig::default()
|
||
};
|
||
let launcher = Launcher::new(config);
|
||
match hook {
|
||
Some(hook) => launcher.with_diagnosis_hook(hook),
|
||
None => launcher,
|
||
}
|
||
}
|
||
|
||
/// 用 shell 冒充 java:只为验证管线,不验证 JVM 语义。
|
||
fn shell_request(script: &str, run_dir: &std::path::Path) -> (LaunchRequest, LaunchRequest) {
|
||
let (mut request, ctx) = legacy_forge_request();
|
||
let _ = ctx;
|
||
#[cfg(windows)]
|
||
{
|
||
request.wrapper = vec!["cmd".to_string(), "/C".to_string(), script.to_string()];
|
||
}
|
||
#[cfg(not(windows))]
|
||
{
|
||
request.wrapper = vec!["/bin/sh".to_string(), "-c".to_string(), script.to_string()];
|
||
}
|
||
request.java = JavaSelection::new("fake-java", 17, 64);
|
||
request.manifest.legacy_arguments = None;
|
||
request.dirs.game_dir = run_dir.to_path_buf();
|
||
// natives 解压是管线第 6 段,会真写盘——指到临时目录,绝不碰 `C:/mc`。
|
||
request.dirs.natives_dir = run_dir.join("natives");
|
||
request.user.jvm = Vec::new();
|
||
request.user.game = Vec::new();
|
||
(request.clone(), request)
|
||
}
|
||
|
||
fn echo_script() -> String {
|
||
// 命令里带上 access_token 明文,用来验证“落盘前脱敏”。
|
||
#[cfg(windows)]
|
||
{
|
||
"echo LWJGL Version: 3.3.1 SECRET-TOKEN-123456".to_string()
|
||
}
|
||
#[cfg(not(windows))]
|
||
{
|
||
"echo 'LWJGL Version: 3.3.1 SECRET-TOKEN-123456'".to_string()
|
||
}
|
||
}
|
||
|
||
fn long_running_script() -> String {
|
||
#[cfg(windows)]
|
||
{
|
||
"ping -n 30 127.0.0.1 >nul".to_string()
|
||
}
|
||
#[cfg(not(windows))]
|
||
{
|
||
"sleep 30".to_string()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn dry_run_returns_command_without_spawning_or_writing() {
|
||
let sessions = temp_sessions_root("dry");
|
||
let run_dir = temp_dir("dry-run");
|
||
let launcher = launcher(&sessions, None);
|
||
let (request, _) = shell_request(&echo_script(), &run_dir);
|
||
|
||
let dry: DryRun = launcher.dry_run(&request).expect("dry-run 应成功");
|
||
assert!(!dry.argv.is_empty());
|
||
assert!(
|
||
dry.display.contains("fake-java")
|
||
|| dry.display.contains("cmd")
|
||
|| dry.display.contains("/bin/sh")
|
||
);
|
||
assert!(
|
||
dry.display.contains("<redacted:token>"),
|
||
"命令行必须脱敏:{}",
|
||
dry.display
|
||
);
|
||
assert!(!dry.display.contains("SECRET-TOKEN-123456"));
|
||
assert_eq!(dry.classpath_entries, 3);
|
||
assert_eq!(dry.line(), dry.display.as_str());
|
||
// 不 spawn、不写会话文件。
|
||
assert!(!sessions.exists(), "dry-run 不能创建会话目录");
|
||
}
|
||
|
||
#[test]
|
||
fn pipeline_records_session_events_diagnosis_and_index() {
|
||
let sessions = temp_sessions_root("pipeline");
|
||
let run_dir = temp_dir("pipeline-run");
|
||
let hook = Arc::new(RecordingHook::default());
|
||
let launcher = launcher(&sessions, Some(hook.clone()));
|
||
let (request, _) = shell_request(&echo_script(), &run_dir);
|
||
|
||
let handle: SessionHandle = launcher.launch(&request).expect("启动应成功");
|
||
assert_eq!(handle.exit.exit_type, ExitType::Normal);
|
||
assert_eq!(handle.exit.code, 0);
|
||
// 存活 <30s → 静默失败 + 需要诊断。
|
||
assert!(handle.exit.silent_failure.is_some());
|
||
assert!(handle.needs_diagnosis());
|
||
assert_eq!(hook.calls.lock().unwrap().len(), 1, "诊断挂钩应被调用一次");
|
||
assert_eq!(
|
||
handle
|
||
.diagnostics
|
||
.as_ref()
|
||
.and_then(|value| value["candidates"][0]["id"].as_str()),
|
||
Some("stub_candidate")
|
||
);
|
||
assert!(
|
||
handle.event_write_errors.is_empty(),
|
||
"{:?}",
|
||
handle.event_write_errors
|
||
);
|
||
// 对外展示的 argv 已是脱敏版本(脚本里带着 token 明文)。
|
||
assert!(handle
|
||
.redacted_argv()
|
||
.iter()
|
||
.any(|arg| arg.contains("<redacted:token>")));
|
||
assert!(!handle
|
||
.redacted_argv()
|
||
.iter()
|
||
.any(|arg| arg.contains("SECRET-TOKEN-123456")));
|
||
assert!(handle.redacted_display().contains("<redacted:token>"));
|
||
assert!(handle.index.is_some());
|
||
assert!(handle.uptime_ms < 30_000);
|
||
|
||
let events = read_events(&handle.log_path);
|
||
let kinds: Vec<&str> = events
|
||
.iter()
|
||
.filter_map(|event| event["kind"].as_str())
|
||
.collect();
|
||
for expected in [
|
||
"session_start",
|
||
"cmd_built",
|
||
"proc_start",
|
||
"stdout",
|
||
"window_detected",
|
||
"exit",
|
||
"diagnosis",
|
||
"session_end",
|
||
] {
|
||
assert!(kinds.contains(&expected), "缺少事件 {expected}:{kinds:?}");
|
||
}
|
||
|
||
// 日志行里的 access_token 必须已脱敏。
|
||
let raw = std::fs::read_to_string(&handle.log_path).expect("读会话文件");
|
||
assert!(!raw.contains("SECRET-TOKEN-123456"), "事件流不得含凭据明文");
|
||
|
||
// 索引落盘且字段正确。
|
||
let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引");
|
||
assert_eq!(index.len(), 1);
|
||
assert_eq!(index[0].session_id, handle.session_id);
|
||
assert!(index[0].silent_failure);
|
||
assert!(index[0].window_detected);
|
||
assert_eq!(index[0].exit_code, Some(0));
|
||
assert_eq!(index[0].diagnosis_top.as_deref(), Some("stub_candidate"));
|
||
}
|
||
|
||
#[test]
|
||
fn pipeline_stop_records_killed_exit() {
|
||
let sessions = temp_sessions_root("killed");
|
||
let run_dir = temp_dir("killed-run");
|
||
let launcher = launcher(&sessions, None);
|
||
let (request, _) = shell_request(&long_running_script(), &run_dir);
|
||
|
||
let mut session = launcher.spawn(&request).expect("spawn 应成功");
|
||
assert!(session.pid() > 0);
|
||
session.stop().expect("停止应成功");
|
||
let handle = session.finish().expect("收尾应成功");
|
||
|
||
assert_eq!(handle.exit.exit_type, ExitType::Killed);
|
||
assert!(!handle.succeeded());
|
||
let events = read_events(&handle.log_path);
|
||
let kinds: Vec<&str> = events
|
||
.iter()
|
||
.filter_map(|event| event["kind"].as_str())
|
||
.collect();
|
||
assert!(kinds.contains(&"killed"), "应记录 killed 事件:{kinds:?}");
|
||
let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引");
|
||
assert_eq!(index.len(), 1);
|
||
assert_eq!(index[0].exit_type.as_deref(), Some("killed"));
|
||
}
|
||
|
||
#[test]
|
||
fn pipeline_assembly_failure_still_writes_evidence() {
|
||
let sessions = temp_sessions_root("assembly-fail");
|
||
let run_dir = temp_dir("assembly-fail-run");
|
||
let launcher = launcher(&sessions, None);
|
||
let (mut request, _) = shell_request(&echo_script(), &run_dir);
|
||
request.missing_components = vec![MissingComponent::named("org.lwjgl:lwjgl:3.3.1")];
|
||
|
||
let error = launcher.spawn(&request).expect_err("缺组件必须启动前失败");
|
||
assert!(matches!(
|
||
error,
|
||
LaunchError::Assembly(AssemblyError::MissingComponents(_))
|
||
));
|
||
|
||
let game_dir = sessions.join("game");
|
||
let files: Vec<PathBuf> = std::fs::read_dir(&game_dir)
|
||
.expect("会话目录应存在")
|
||
.flatten()
|
||
.map(|entry| entry.path())
|
||
.filter(|path| path.extension().map(|ext| ext == "jsonl").unwrap_or(false))
|
||
.filter(|path| {
|
||
path.file_stem()
|
||
.map(|stem| stem != "index")
|
||
.unwrap_or(false)
|
||
})
|
||
.collect();
|
||
assert_eq!(files.len(), 1, "应留下一个会话文件");
|
||
let events = read_events(&files[0]);
|
||
let kinds: Vec<&str> = events
|
||
.iter()
|
||
.filter_map(|event| event["kind"].as_str())
|
||
.collect();
|
||
assert_eq!(
|
||
kinds,
|
||
vec![
|
||
"session_start",
|
||
"natives_extracted",
|
||
"assembly_failed",
|
||
"session_end"
|
||
]
|
||
);
|
||
let missing = &events[2]["payload"]["missing_components"][0]["name"];
|
||
assert_eq!(missing.as_str(), Some("org.lwjgl:lwjgl:3.3.1"));
|
||
|
||
let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引");
|
||
assert_eq!(index.len(), 1);
|
||
assert!(index[0].exit_type.is_none(), "没有进程就没有退出判定");
|
||
}
|
||
|
||
#[test]
|
||
fn pipeline_records_crash_report_artifact() {
|
||
let sessions = temp_sessions_root("crash");
|
||
let run_dir = temp_dir("crash-run");
|
||
let crash_dir = run_dir.join("crash-reports");
|
||
std::fs::create_dir_all(&crash_dir).expect("建 crash-reports");
|
||
std::fs::write(
|
||
crash_dir.join("crash-2026-10-29.txt"),
|
||
"---- Minecraft Crash Report ----\nDescription: Rendering screen\n",
|
||
)
|
||
.expect("写崩溃报告");
|
||
|
||
let launcher = launcher(&sessions, None);
|
||
let (request, _) = shell_request(&echo_script(), &run_dir);
|
||
let handle = launcher.launch(&request).expect("启动应成功");
|
||
|
||
assert!(
|
||
handle
|
||
.artifacts
|
||
.iter()
|
||
.any(|artifact| artifact.source == CrashSource::CrashReport),
|
||
"应发现崩溃报告:{:?}",
|
||
handle.artifacts
|
||
);
|
||
let events = read_events(&handle.log_path);
|
||
let crash = events
|
||
.iter()
|
||
.find(|event| event["kind"] == "crash_found")
|
||
.expect("应有 crash_found 事件");
|
||
let path = crash["payload"]["path"].as_str().unwrap_or_default();
|
||
assert!(path.ends_with("crash-2026-10-29.txt"));
|
||
assert!(crash["payload"]["excerpt"]
|
||
.as_str()
|
||
.unwrap_or_default()
|
||
.contains("Minecraft Crash Report"));
|
||
}
|
||
|
||
#[test]
|
||
fn pipeline_spawn_failure_writes_session_end() {
|
||
let sessions = temp_sessions_root("spawn-fail");
|
||
let run_dir = temp_dir("spawn-fail-run");
|
||
let launcher = launcher(&sessions, None);
|
||
let (mut request, _) = shell_request(&echo_script(), &run_dir);
|
||
// 包装器就是 program:给一个不存在的可执行文件,走派生失败分支。
|
||
request.wrapper = vec!["definitely-not-a-real-binary-ull".to_string()];
|
||
|
||
let error = launcher.spawn(&request).expect_err("程序不存在必须失败");
|
||
assert!(
|
||
matches!(error, LaunchError::Spawn { .. }),
|
||
"错误类型:{error:?}"
|
||
);
|
||
|
||
let game_dir = sessions.join("game");
|
||
let files: Vec<PathBuf> = std::fs::read_dir(&game_dir)
|
||
.expect("会话目录应存在")
|
||
.flatten()
|
||
.map(|entry| entry.path())
|
||
.filter(|path| path.extension().map(|ext| ext == "jsonl").unwrap_or(false))
|
||
.filter(|path| {
|
||
path.file_stem()
|
||
.map(|stem| stem != "index")
|
||
.unwrap_or(false)
|
||
})
|
||
.collect();
|
||
assert_eq!(files.len(), 1, "应留下一个会话文件");
|
||
let events = read_events(&files[0]);
|
||
let kinds: Vec<&str> = events
|
||
.iter()
|
||
.filter_map(|event| event["kind"].as_str())
|
||
.collect();
|
||
assert_eq!(
|
||
kinds,
|
||
vec![
|
||
"session_start",
|
||
"natives_extracted",
|
||
"cmd_built",
|
||
"session_end"
|
||
]
|
||
);
|
||
assert_eq!(events[3]["payload"]["reason"], "spawn_failed");
|
||
|
||
let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引");
|
||
assert_eq!(index.len(), 1, "派生失败也要进索引");
|
||
assert!(index[0].exit_type.is_none());
|
||
}
|