Files
AskaEth bc41dfcf7d
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
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

258 lines
9.6 KiB
Rust

//! 会话事件流(JSONL)与索引测试。
//!
//! 校验 `DevDocs/SubAgentsTalk/launch-接口约定.md` 的硬约定:
//! `seq` 单调、写前脱敏、索引同 id 取最后一行。
use std::sync::Arc;
use serde_json::json;
use unlucky_core::launch::pump::LineSink;
use crate::prelude::*;
fn command_with_token() -> AssembledCommand {
let (mut request, ctx) = legacy_forge_request();
request.manifest.legacy_arguments =
Some("--accessToken ${auth_session} --username ${auth_player_name}".to_string());
assemble(&request, &ctx).expect("组装应成功")
}
#[test]
fn session_stream_records_all_event_kinds() {
let root = temp_sessions_root("events");
let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64");
let meta = SessionMeta::new("inst-1", "1.12.2-Forge", "1.12.2")
.with_launcher("UnluckyLauncher", "0.1.0")
.with_platform(&ctx);
let redactor = Redactor::new().with_secret("SECRET-TOKEN-123456");
let mut writer = SessionWriter::create_with_id(&root, "20261029T120000-abcdef", meta, redactor)
.expect("创建会话");
writer.session_start().expect("session_start");
writer.cmd_built(&command_with_token()).expect("cmd_built");
writer.proc_start(4242).expect("proc_start");
writer
.line(&LogLine::new(Stream::Stdout, "LWJGL Version: 3.3.1"))
.expect("stdout");
writer
.line(&LogLine::new(
Stream::Stderr,
"java.lang.IllegalStateException: boom",
))
.expect("stderr");
writer
.window_detected(&unlucky_core::launch::window_detect::Detection {
heuristic: "lwjgl_version".to_string(),
took_ms: 820,
evidence: "LWJGL Version: 3.3.1".to_string(),
})
.expect("window_detected");
let lines = vec![LogLine::new(Stream::Stderr, "java.lang.Exception: boom")];
let analysis = exit_analysis::analyze(&exit_analysis::ExitSignals::new(1, 40_000, &lines));
writer.exit(&analysis).expect("exit");
writer
.diagnosis(json!({
"source": "rules",
"needs_more_info": false,
"candidates": [{ "id": "mixin_apply_failed", "confidence": 0.9 }],
}))
.expect("diagnosis");
let entry = writer.finish(EndReason::Exit).expect("session_end");
assert!(writer.is_finished());
assert_eq!(entry.session_id, "20261029T120000-abcdef");
assert_eq!(entry.exit_code, Some(1));
assert_eq!(entry.exit_type.as_deref(), Some("application_error"));
assert!(entry.window_detected);
assert_eq!(entry.diagnosis_top.as_deref(), Some("mixin_apply_failed"));
assert_eq!(entry.log_path, "game/20261029T120000-abcdef.jsonl");
let events = read_events(writer.path());
let kinds: Vec<&str> = events
.iter()
.filter_map(|event| event.get("kind").and_then(|value| value.as_str()))
.collect();
assert_eq!(
kinds,
vec![
"session_start",
"cmd_built",
"proc_start",
"stdout",
"stderr",
"window_detected",
"exit",
"diagnosis",
"session_end",
]
);
// seq 从 0 单调递增,v/ts/t_ms/session_id 齐全。
for (index, event) in events.iter().enumerate() {
assert_eq!(event["v"], 1);
assert_eq!(event["seq"], index as u64);
assert_eq!(event["session_id"], "20261029T120000-abcdef");
assert!(event["ts"].as_str().unwrap_or_default().ends_with('Z'));
assert!(event["t_ms"].is_u64());
}
assert_eq!(entry.event_count, events.len() as u64);
// 索引落盘且可读回。
let index = session_index::index_path(&root);
let listed = session_index::read_all(&index).expect("读索引");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].session_id, entry.session_id);
}
#[test]
fn cmd_built_argv_is_redacted_before_writing() {
let root = temp_sessions_root("redact");
let meta = SessionMeta::new("inst-1", "1.12.2-Forge", "1.12.2");
let redactor = Redactor::new().with_secret("SECRET-TOKEN-123456");
let mut writer =
SessionWriter::create_with_id(&root, "redact-1", meta, redactor).expect("创建会话");
writer.cmd_built(&command_with_token()).expect("cmd_built");
let raw = std::fs::read_to_string(writer.path()).expect("读文件");
assert!(!raw.contains("SECRET-TOKEN-123456"), "命令行必须脱敏后落盘");
assert!(raw.contains(TOKEN_PLACEHOLDER));
let events = read_events(writer.path());
let argv = events[0]["payload"]["argv"]
.as_array()
.cloned()
.unwrap_or_default();
assert!(argv
.iter()
.any(|arg| arg.as_str() == Some(TOKEN_PLACEHOLDER)));
}
#[test]
fn diagnosis_payload_is_redacted_before_writing() {
let root = temp_sessions_root("diagnosis-redact");
let meta = SessionMeta::new("inst-1", "1.20.1", "1.20.1");
let redactor = Redactor::new()
.with_secret("SECRET-TOKEN-123456")
.with_home_str("C:/Users/aska");
let mut writer =
SessionWriter::create_with_id(&root, "diag-redact-1", meta, redactor).expect("创建会话");
writer
.diagnosis(json!({
"source": "rules",
"needs_more_info": false,
"candidates": [{
"id": "mixin_failed",
"confidence": 0.9,
"message_key": "launch.crash.reason.mixin_failed",
"evidence": [
"hs_err:C:/Users/aska/.minecraft/hs_err_pid123.log: java.lang.Exception",
"token=SECRET-TOKEN-123456",
],
}],
}))
.expect("写 diagnosis");
let raw = std::fs::read_to_string(writer.path()).expect("读文件");
assert!(!raw.contains("C:/Users/aska"), "诊断证据里的主目录必须脱敏");
assert!(
!raw.contains("SECRET-TOKEN-123456"),
"诊断证据里的凭据必须脱敏"
);
assert!(raw.contains("<home>"));
assert!(raw.contains("<redacted:token>"), "同一个值内也允许混合脱敏");
// 字段名/数值/布尔原样保留(launch 只脱敏、不改写字段)。
let events = read_events(writer.path());
let payload = &events[0]["payload"];
assert_eq!(payload["needs_more_info"], json!(false));
assert_eq!(payload["candidates"][0]["id"], json!("mixin_failed"));
assert_eq!(payload["candidates"][0]["confidence"], json!(0.9));
assert_eq!(
payload["candidates"][0]["message_key"],
json!("launch.crash.reason.mixin_failed")
);
assert_eq!(payload["source"], json!("rules"));
// diagnosis_top 仍能从脱敏后的 candidates 取出。
assert_eq!(
writer
.finish(EndReason::Exit)
.expect("结束会话")
.diagnosis_top
.as_deref(),
Some("mixin_failed")
);
}
#[test]
fn index_keeps_last_line_per_session_id() {
let root = temp_sessions_root("index");
let mut entry = session_index::SessionIndexEntry {
v: 1,
session_id: "same-id".to_string(),
instance_id: "inst-1".to_string(),
instance_name: "one".to_string(),
game_version: "1.20.1".to_string(),
started_at: "2026-10-29T12:00:00.000Z".to_string(),
ended_at: "2026-10-29T12:01:00.000Z".to_string(),
duration_ms: 60_000,
exit_code: Some(0),
exit_type: Some("normal".to_string()),
..session_index::SessionIndexEntry::default()
};
let index = session_index::index_path(&root);
session_index::append(&index, &entry).expect("第一次写索引");
// 崩溃重启后重写同一会话的索引行。
entry.exit_code = Some(1);
entry.exit_type = Some("application_error".to_string());
entry.duration_ms = 30_000;
session_index::append(&index, &entry).expect("第二次写索引");
let listed = session_index::read_all(&index).expect("读索引");
assert_eq!(listed.len(), 1, "同 id 只保留一条");
assert_eq!(listed[0].exit_code, Some(1));
assert_eq!(listed[0].duration_ms, 30_000);
// 追加另一实例的会话,按实例过滤生效。
let mut other = listed[0].clone();
other.session_id = "other-id".to_string();
other.instance_id = "inst-2".to_string();
other.started_at = "2026-10-30T12:00:00.000Z".to_string();
session_index::append(&index, &other).expect("第三次写索引");
let only = session_index::list(&index, 10, Some("inst-2")).expect("过滤");
assert_eq!(only.len(), 1);
assert_eq!(only[0].session_id, "other-id");
// 倒序:最新的在前。
let all = session_index::list(&index, 10, None).expect("全部");
assert_eq!(
all.first().map(|item| item.session_id.as_str()),
Some("other-id")
);
}
#[test]
fn session_sink_writes_lines_and_detects_window() {
let root = temp_sessions_root("sink");
let meta = SessionMeta::new("inst-1", "1.20.1", "1.20.1");
let writer =
SessionWriter::create_with_id(&root, "sink-1", meta, Redactor::new()).expect("创建会话");
let path = writer.path().to_path_buf();
let sink = Arc::new(SessionSink::new(writer));
sink.on_line(&LogLine::new(Stream::Stdout, "preparing"));
sink.on_line(&LogLine::new(Stream::Stdout, "LWJGL Version: 3.3.1"));
sink.on_line(&LogLine::new(Stream::Stderr, "warn: something"));
let kinds: Vec<String> = read_events(&path)
.iter()
.filter_map(|event| event["kind"].as_str().map(str::to_string))
.collect();
assert!(
kinds.contains(&"window_detected".to_string()),
"sink 应自动检测窗口"
);
assert_eq!(kinds.iter().filter(|kind| *kind == "stdout").count(), 2);
assert_eq!(kinds.iter().filter(|kind| *kind == "stderr").count(), 1);
}