Unlucky Launcher v0.1.0
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run

单提交快照:main 只保留已发布版本的代码树,开发史在 dev。
This commit is contained in:
AskaEth committed 2026-11-01 20:02:49 +08:00
commit 5f8382565e
570 files changed
+104468

No files matched your search

+293
View File
@@ -0,0 +1,293 @@
//! `ull accounts` 与 `ull launch --account` 的端到端断言。
//!
//! 职责:证明「账户能建/能删/能选」,并且**凭据红线守住了**——
//! stdout(含 `--json`)、`launch --dry-run` 的 argv 与身份块里都不出现 token 明文。
#[path = "support/mod.rs"]
mod support;
use std::path::{Path, PathBuf};
use support::{parse_json, run_cli, TempDir};
use unlucky_core::auth::offline::offline_uuid;
const VERSION: &str = "1.20.1";
/// 建一个带「版本本体 + 一个实例」的数据根。
fn data_root(tag: &str) -> TempDir {
let dir = TempDir::new(tag);
support::write_version(dir.path(), VERSION);
support::write_profiles(dir.path(), &[("生存服", VERSION)]);
dir
}
/// 覆盖版本 JSON:把 `${auth_access_token}` / `${auth_uuid}` 塞进命令行,
/// 这样「脱敏」才是被真的跑到,而不是「压根没有 token 所以没有明文」。
fn write_version_with_auth(root: &Path) {
let dir = root.join(".minecraft").join("versions").join(VERSION);
std::fs::create_dir_all(&dir).expect("建版本目录");
let version = serde_json::json!({
"id": VERSION,
"type": "release",
"mainClass": "net.minecraft.client.main.Main",
"assets": "5",
"assetIndex": { "id": "5" },
"arguments": {
"game": [
"--username", "${auth_player_name}",
"--uuid", "${auth_uuid}",
"--accessToken", "${auth_access_token}",
"--userType", "${auth_user_type}",
"--version", "${version_name}"
],
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"]
},
"libraries": [],
"downloads": { "client": { "size": 4, "url": "http://example.invalid/client.jar" } }
});
std::fs::write(
dir.join(format!("{VERSION}.json")),
serde_json::to_vec_pretty(&version).expect("序列化版本 JSON"),
)
.expect("写版本 JSON");
}
fn auth_dir(root: &Path) -> PathBuf {
root.join(".ULL").join("data").join("auth")
}
/// 拼一条 `--json --data-dir <root> ...` 命令。
fn cli(dir: &TempDir, tail: &[&str]) -> Vec<String> {
let mut args = vec!["--json".to_string(), "--data-dir".to_string(), dir.arg()];
args.extend(tail.iter().map(|item| (*item).to_string()));
args
}
/// 拼一条 `launch 生存服 --dry-run ...` 命令。
fn launch(dir: &TempDir, extra: &[&str]) -> Vec<String> {
let mut tail = vec!["launch", "生存服", "--dry-run"];
tail.extend_from_slice(extra);
cli(dir, &tail)
}
/// 跑一条命令并断言退出码。
fn run(args: &[String], expected: i32) -> (String, String) {
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let (code, out, err) = run_cli(&refs);
assert_eq!(code, expected, "args={refs:?}\nstdout={out}\nstderr={err}");
(out, err)
}
#[test]
fn accounts_list_is_empty_at_first() {
let dir = data_root("accounts-empty");
let (out, _) = run(&cli(&dir, &["accounts", "list"]), 0);
let envelope = parse_json(&out);
assert_eq!(envelope["data"]["count"], 0);
assert_eq!(
envelope["data"]["accounts"].as_array().map(Vec::len),
Some(0)
);
assert!(!auth_dir(dir.path()).join("accounts.json").exists());
}
#[test]
fn add_offline_writes_both_files_and_reports_hmcl_uuid() {
let dir = data_root("accounts-add");
let (out, _) = run(&cli(&dir, &["accounts", "add", "offline", "小明"]), 0);
let envelope = parse_json(&out);
let account = &envelope["data"]["account"];
assert_eq!(account["name"], "小明");
assert_eq!(account["kind"], "offline");
assert_eq!(account["profile_id"], offline_uuid("小明"));
let accounts_file = auth_dir(dir.path()).join("accounts.json");
let credentials_file = auth_dir(dir.path()).join("credentials.dat");
assert!(accounts_file.is_file(), "元数据文件必须落盘");
assert!(
credentials_file.is_file(),
"凭据文件必须落盘(哪怕离线轨是空的)"
);
// list 里能看到,且输出里没有任何凭据字段。
let (list_out, _) = run(&cli(&dir, &["accounts", "list"]), 0);
let list = parse_json(&list_out);
assert_eq!(list["data"]["count"], 1);
assert_eq!(list["data"]["accounts"][0]["name"], "小明");
for forbidden in ["access_token", "refresh_token", "client_token", "password"] {
assert!(
!list_out.contains(forbidden),
"accounts list 不该出现 {forbidden}"
);
}
}
#[test]
fn duplicate_offline_account_is_rejected() {
let dir = data_root("accounts-dup");
run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0);
let (out, _) = run(&cli(&dir, &["accounts", "add", "offline", "steve"]), 1);
assert_eq!(parse_json(&out)["error"]["code"], "conflict");
}
#[test]
fn add_offline_requires_a_valid_name() {
let dir = data_root("accounts-badname");
let (out, _) = run(&cli(&dir, &["accounts", "add", "offline"]), 1);
assert_eq!(parse_json(&out)["error"]["code"], "usage");
let (out, _) = run(
&cli(&dir, &["accounts", "add", "offline", "0123456789abcdefg"]),
1,
);
assert_eq!(parse_json(&out)["error"]["code"], "malformed");
}
#[test]
fn remove_deletes_account_and_reports_missing() {
let dir = data_root("accounts-remove");
run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0);
let (out, _) = run(&cli(&dir, &["accounts", "remove", "Steve"]), 0);
assert_eq!(parse_json(&out)["data"]["removed"]["name"], "Steve");
let (out, _) = run(&cli(&dir, &["accounts", "list"]), 0);
assert_eq!(parse_json(&out)["data"]["count"], 0);
let (out, _) = run(&cli(&dir, &["accounts", "remove", "Steve"]), 1);
assert_eq!(parse_json(&out)["error"]["code"], "not_found");
}
#[test]
fn add_msa_without_client_id_is_explained() {
// 没配 ULL_MSA_CLIENT_ID 时不该去联网,而是明确告知要配什么。
let saved = std::env::var("ULL_MSA_CLIENT_ID").ok();
std::env::remove_var("ULL_MSA_CLIENT_ID");
let dir = data_root("accounts-msa");
let (out, _) = run(&cli(&dir, &["accounts", "add", "msa"]), 1);
if let Some(value) = saved {
std::env::set_var("ULL_MSA_CLIENT_ID", value);
}
let envelope = parse_json(&out);
assert_eq!(envelope["error"]["code"], "unsupported");
assert!(envelope["error"]["hint"]
.as_str()
.unwrap_or_default()
.contains("ULL_MSA_CLIENT_ID"));
}
#[test]
fn launch_dry_run_uses_the_only_account() {
let dir = data_root("launch-account");
write_version_with_auth(dir.path());
run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0);
let (out, _) = run(&launch(&dir, &[]), 0);
let data = &parse_json(&out)["data"];
let account = &data["account"];
assert_eq!(account["name"], "Steve");
assert_eq!(account["kind"], "offline");
assert_eq!(account["profile_id"], offline_uuid("Steve"));
assert_eq!(account["registered"], true);
// argv 里玩家名与 UUID 都对上了,token 位置是脱敏占位。
let argv: Vec<String> = data["argv"]
.as_array()
.expect("argv")
.iter()
.map(|item| item.as_str().unwrap_or_default().to_string())
.collect();
let username_at = argv
.iter()
.position(|arg| arg == "--username")
.expect("有 --username");
assert_eq!(argv[username_at + 1], "Steve");
// launch 的脱敏器把 uuid 也当敏感值处理(M18 定案:dry-run 输出是「脱敏 uuid」),
// 真实 uuid 在下面的身份块里以可公开元数据的形式给出。
let uuid_at = argv
.iter()
.position(|arg| arg == "--uuid")
.expect("有 --uuid");
assert_eq!(argv[uuid_at + 1], "<redacted:token>");
// 真凭据位置:离线占位 token 是随机 32 hex,输出里必须只剩脱敏占位。
let token_at = argv
.iter()
.position(|arg| arg == "--accessToken")
.expect("有 --accessToken");
assert_eq!(argv[token_at + 1], "<redacted:token>");
assert!(out.contains("<redacted:token>"));
// 命令行与展示行里不该有任何 32 位十六进制串(= 没漏出 accessToken)。
let display = data["display"].as_str().unwrap_or_default();
assert!(!has_hex32(&argv.join(" ")), "argv 漏了 token:{argv:?}");
assert!(!has_hex32(display), "display 漏了 token:{display}");
assert!(
!out.contains("access_token"),
"输出不该出现 access_token 字段"
);
}
/// 输出里是否有 32 位十六进制串(token 形状)。
fn has_hex32(text: &str) -> bool {
text.split(|ch: char| !ch.is_ascii_hexdigit())
.any(|run| run.len() == 32)
}
#[test]
fn multiple_accounts_require_explicit_choice() {
let dir = data_root("launch-multi");
run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0);
run(&cli(&dir, &["accounts", "add", "offline", "Alex"]), 0);
let (out, _) = run(&launch(&dir, &[]), 1);
let envelope = parse_json(&out);
assert_eq!(envelope["error"]["code"], "account_ambiguous");
assert!(
envelope["error"]["hint"]
.as_str()
.unwrap_or_default()
.contains("Steve"),
"hint 要列候选"
);
// 指定名字就能选出来(大小写不敏感)。
let (out, _) = run(&launch(&dir, &["--account", "alex"]), 0);
let data = &parse_json(&out)["data"];
assert_eq!(data["account"]["name"], "Alex");
assert_eq!(data["account"]["profile_id"], offline_uuid("Alex"));
}
#[test]
fn unknown_account_and_username_conflict_are_usage_errors() {
let dir = data_root("launch-unknown");
run(&cli(&dir, &["accounts", "add", "offline", "Steve"]), 0);
let (out, _) = run(&launch(&dir, &["--account", "Nobody"]), 1);
assert_eq!(parse_json(&out)["error"]["code"], "not_found");
let (out, _) = run(&launch(&dir, &["--username", "Alex"]), 1);
assert_eq!(parse_json(&out)["error"]["code"], "usage");
}
#[test]
fn launch_without_accounts_keeps_the_offline_placeholder() {
let dir = data_root("launch-placeholder");
let (out, _) = run(&launch(&dir, &["--username", "Node"]), 0);
let data = &parse_json(&out)["data"];
assert_eq!(data["account"]["registered"], false);
assert_eq!(data["account"]["kind"], "offline-placeholder");
assert_eq!(data["account"]["name"], "Node");
let argv: Vec<&str> = data["argv"]
.as_array()
.expect("argv")
.iter()
.map(|item| item.as_str().unwrap_or_default())
.collect();
let at = argv
.iter()
.position(|arg| *arg == "--username")
.expect("--username");
assert_eq!(argv[at + 1], "Node");
}
#[test]
fn accounts_help_topic_exists() {
let (out, _) = run(&["help".to_string(), "accounts".to_string()], 0);
assert!(out.contains("credentials.dat"), "{out}");
assert!(out.contains("accounts add offline"), "{out}");
}
+185
View File
@@ -0,0 +1,185 @@
//! 真机验收:起 `ull agent serve --stdio`,用裸 JSON-RPC 走一遍握手 → 列工具 → 调用。
//!
//! 这是 M39 任务 5 的自动化形态:**不引任何 MCP 客户端库**,直接按协议喂行给子进程。
//! stdio 形态是 llama.cpp 内置 MCP client 的入口,它的 stdout 必须是纯 JSON-RPC——
//! 本测试顺带守住「stdout 里没有多余输出」这条最容易破的约束。
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use serde_json::{json, Value};
fn data_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("ull-agent-stdio-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时数据根");
dir
}
struct Session {
child: std::process::Child,
stdin: std::process::ChildStdin,
stdout: std::io::Lines<BufReader<std::process::ChildStdout>>,
}
impl Session {
fn start(dir: &Path) -> Self {
let mut child = Command::new(env!("CARGO_BIN_EXE_ull"))
.args([
"agent",
"serve",
"--stdio",
"--allow-write",
"--data-dir",
&dir.display().to_string(),
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("应能起 ull agent serve --stdio");
let stdin = child.stdin.take().expect("stdin");
let stdout = BufReader::new(child.stdout.take().expect("stdout")).lines();
Self {
child,
stdin,
stdout,
}
}
fn send(&mut self, message: &Value) {
writeln!(self.stdin, "{message}").expect("写请求");
self.stdin.flush().expect("刷新 stdin");
}
fn next_response(&mut self) -> Value {
let line = self
.stdout
.next()
.expect("stdio 应有一行响应(不应挂断)")
.expect("读响应行失败");
serde_json::from_str(&line).unwrap_or_else(|_| panic!("stdout 不是 JSON-RPC:{line}"))
}
fn rpc(&mut self, id: u32, method: &str, params: Value) -> Value {
self.send(&json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }));
self.next_response()
}
}
impl Drop for Session {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[test]
fn stdio_handshake_lists_tools_and_calls_them() {
let dir = data_dir("handshake");
let mut session = Session::start(&dir);
// 1. initialize
let init = session.rpc(
1,
"initialize",
json!({
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": { "name": "ull-test", "version": "0.1" }
}),
);
assert_eq!(
init["result"]["serverInfo"]["name"],
json!("unlucky-launcher"),
"{init}"
);
session.send(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }));
// 2. tools/list —— 写工具在(--allow-write),十个工具全在
let list = session.rpc(2, "tools/list", json!({}));
let names: Vec<String> = list["result"]["tools"]
.as_array()
.expect("tools 数组")
.iter()
.filter_map(|tool| tool["name"].as_str().map(str::to_string))
.collect();
assert_eq!(names.len(), 10, "{names:?}");
for expected in [
"get_config",
"set_config",
"list_instances",
"list_accounts",
"diagnose_crash",
] {
assert!(names.iter().any(|name| name == expected), "缺 {expected}");
}
// 3. 只读工具真返回(真实 core 端口:空数据根 → 空实例/空账户)
let instances = session.rpc(
3,
"tools/call",
json!({ "name": "list_instances", "arguments": {} }),
);
assert_ne!(instances["result"]["isError"], json!(true), "{instances}");
let text = instances["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(text.contains("\"total\":0"), "空数据根应 0 个实例:{text}");
// 4. 白名单普通键写入真落盘
let written = session.rpc(
4,
"tools/call",
json!({ "name": "set_config", "arguments": { "path": "download.concurrency", "value": 16 } }),
);
assert_ne!(written["result"]["isError"], json!(true), "{written}");
let launcher = dir.join(".ULL").join("config").join("launcher.json");
let content = std::fs::read_to_string(&launcher).expect("应写出 launcher.json");
assert!(content.contains("\"concurrency\""), "{content}");
// 5. 敏感键被拒(外部客户端没有弹窗),文案引导到设置页 / YOLO
let denied = session.rpc(
5,
"tools/call",
json!({ "name": "set_config", "arguments": { "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" } }),
);
assert_eq!(denied["result"]["isError"], json!(true), "{denied}");
let text = denied["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(text.contains("YOLO"), "{text}");
// 6. 审计落盘:每一条都在,且会话标签能区分「外部 MCP」
let audit_dir = dir.join(".ULL").join("data").join("agent").join("audit");
let mut records: Vec<Value> = Vec::new();
for entry in std::fs::read_dir(&audit_dir)
.expect("审计目录应存在")
.flatten()
{
let text = std::fs::read_to_string(entry.path()).unwrap_or_default();
for line in text.lines() {
if let Ok(value) = serde_json::from_str::<Value>(line) {
records.push(value);
}
}
}
assert!(
records
.iter()
.any(|record| record["tool"] == json!("set_config") && record["outcome"] == json!("ok")),
"普通键写入应有 ok 审计:{records:?}"
);
assert!(
records
.iter()
.any(|record| record["outcome"] == json!("denied")),
"敏感键拒绝应有 denied 审计:{records:?}"
);
assert!(records
.iter()
.all(|record| record["agent_session_id"] == json!("mcp-stdio")));
let _ = std::fs::remove_dir_all(&dir);
}
+179
View File
@@ -0,0 +1,179 @@
//! 敏感键脱敏(P1-1 回归):单键 get、无键全量 dump、set 的旧值回显三条路径
//! 都不能把凭据明文交给用户或脚本。
#[path = "support/mod.rs"]
mod support;
use support::{parse_json, run_cli, TempDir};
/// 一眼可 grep 的假凭据。
const SECRET: &str = "sk-live-THIS-MUST-NOT-LEAK";
/// 代理 URL 里的口令(`download.proxy_url` 是 schema 的 High 档,但键名不含敏感词)。
const PROXY: &str = "socks5://user:pw@127.0.0.1:1080";
/// 建一个已经写入敏感键的数据根。
fn data_dir_with_secrets() -> TempDir {
let dir = TempDir::new("secret");
let (code, _, stderr) = run_cli(&[
"config",
"set",
"ai.api_key",
SECRET,
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "{stderr}");
let (code, _, stderr) = run_cli(&[
"config",
"set",
"download.proxy_url",
PROXY,
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "{stderr}");
// 一个普通键:用来验证掩码没有把整份配置打成红条(默认值不落盘,必须先写一次)。
let (code, _, stderr) = run_cli(&[
"config",
"set",
"ui.language",
"en_us",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "{stderr}");
dir
}
#[test]
fn full_dump_redacts_sensitive_keys_by_default() {
let dir = data_dir_with_secrets();
let (code, stdout, stderr) = run_cli(&["config", "get", "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 0, "{stderr}");
assert!(!stdout.contains(SECRET), "全量 dump 泄出 API key:{stdout}");
assert!(
!stdout.contains("user:pw@"),
"全量 dump 泄出代理口令:{stdout}"
);
let value = parse_json(&stdout);
assert_eq!(value["data"]["config"]["ai"]["api_key"], "<redacted>");
assert_eq!(
value["data"]["config"]["download"]["proxy_url"],
"<redacted>"
);
assert_eq!(value["data"]["redacted"], true);
// 非敏感键照常可读(掩码不能把整份配置打成红条)。
assert_eq!(value["data"]["config"]["ui"]["language"], "en_us");
}
#[test]
fn full_dump_human_output_is_redacted_too() {
let dir = data_dir_with_secrets();
let (code, stdout, _) = run_cli(&["config", "get", "--data-dir", &dir.arg()]);
assert_eq!(code, 0);
assert!(!stdout.contains(SECRET), "{stdout}");
assert!(!stdout.contains("user:pw@"), "{stdout}");
assert!(stdout.contains("<redacted>"), "{stdout}");
assert!(stdout.contains("--show-secret"), "{stdout}");
}
#[test]
fn single_key_get_masks_by_default() {
let dir = data_dir_with_secrets();
let (code, stdout, _) = run_cli(&[
"config",
"get",
"ai.api_key",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0);
assert!(!stdout.contains(SECRET), "{stdout}");
let value = parse_json(&stdout);
assert_eq!(value["data"]["value"], "<redacted>");
assert_eq!(value["data"]["sensitive"], true);
assert_eq!(value["data"]["redacted"], true);
}
#[test]
fn show_secret_reveals_plaintext() {
let dir = data_dir_with_secrets();
// 全量 dump + --show-secret:明文,且 redacted=false。
let (code, stdout, _) = run_cli(&[
"config",
"get",
"--show-secret",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
assert_eq!(value["data"]["config"]["ai"]["api_key"], SECRET);
assert_eq!(value["data"]["config"]["download"]["proxy_url"], PROXY);
assert_eq!(value["data"]["redacted"], false);
// 单键 + --show-secret:人类输出就是明文。
let (code, stdout, _) = run_cli(&[
"config",
"get",
"ai.api_key",
"--show-secret",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0);
assert!(stdout.contains(SECRET), "{stdout}");
}
#[test]
fn set_masks_old_value_but_echoes_new() {
let dir = data_dir_with_secrets();
let (code, stdout, stderr) = run_cli(&[
"config",
"set",
"ai.api_key",
"sk-new-value",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "{stderr}");
assert!(!stdout.contains(SECRET), "set 回显了旧密钥:{stdout}");
let value = parse_json(&stdout);
assert_eq!(value["data"]["old"], "<redacted>");
assert_eq!(value["data"]["new"], "sk-new-value");
// 人类模式同样不回显旧值明文(新值照常,用户刚敲的)。
let (code, stdout, _) = run_cli(&[
"config",
"set",
"ai.api_key",
"sk-third",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0);
assert!(
!stdout.contains("sk-new-value"),
"人类输出回显旧值:{stdout}"
);
assert!(stdout.contains("<redacted>"), "{stdout}");
assert!(stdout.contains("sk-third"), "{stdout}");
// 非敏感键仍然正常回显原值。
let (code, stdout, _) = run_cli(&[
"config",
"set",
"ui.language",
"zh_cn",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0);
assert_eq!(parse_json(&stdout)["data"]["old"], "en_us");
}
+109
View File
@@ -0,0 +1,109 @@
//! 退出码与输出通道(0 成功 / 1 用户错误 / 2 内部错误)。
#[path = "support/mod.rs"]
mod support;
use support::{run_cli, write_version, TempDir};
use unlucky_cli::error::{CliError, ErrorKind, EXIT_INTERNAL, EXIT_OK, EXIT_USER};
#[test]
fn exit_code_constants_match_kinds() {
assert_eq!(EXIT_OK, 0);
assert_eq!(EXIT_USER, 1);
assert_eq!(EXIT_INTERNAL, 2);
assert_eq!(ErrorKind::User.exit_code(), 1);
assert_eq!(ErrorKind::Internal.exit_code(), 2);
assert_eq!(CliError::usage("x").exit_code(), 1);
assert_eq!(CliError::user("io", "x").exit_code(), 1);
assert_eq!(CliError::internal("bug", "x").exit_code(), 2);
}
#[test]
fn internal_errors_serialize_as_internal() {
let value = CliError::internal("serialize", "序列化失配").to_json();
assert_eq!(value["ok"], false);
assert_eq!(value["error"]["kind"], "internal");
assert_eq!(value["error"]["code"], "serialize");
assert!(value["error"].get("hint").is_none());
}
#[test]
fn success_exits_zero() {
let dir = TempDir::new("code-ok");
let (code, _, _) = run_cli(&["instances", "list", "--data-dir", &dir.arg()]);
assert_eq!(code, 0);
}
#[test]
fn user_error_exits_one() {
let dir = TempDir::new("code-user");
let cases: Vec<Vec<String>> = vec![
vec!["frobnicate".to_string()],
vec!["install".to_string()],
vec![
"launch".to_string(),
"nope".to_string(),
"--data-dir".to_string(),
dir.arg(),
],
vec![
"logs".to_string(),
"deadbeef".to_string(),
"--data-dir".to_string(),
dir.arg(),
],
// `java install` 已实装(会真联网下载)——用户错误只取参数校验这一层:
// 大版本号离谱时在联网之前就拒,测试因此保持零网络(M27 踩坑记录 G 节同款教训)。
vec![
"java".to_string(),
"install".to_string(),
"4".to_string(),
"--data-dir".to_string(),
dir.arg(),
],
vec![
"--data-dir".to_string(),
"relative/path".to_string(),
"instances".to_string(),
"list".to_string(),
],
];
for args in cases {
let (code, _, stderr) = run_cli(&args.iter().map(String::as_str).collect::<Vec<_>>());
assert_eq!(code, 1, "args={args:?} stderr={stderr}");
assert!(stderr.contains("错误:"), "args={args:?} stderr={stderr}");
}
}
#[test]
fn human_and_json_go_to_different_streams() {
let dir = TempDir::new("code-streams");
write_version(dir.path(), "1.20.1");
// 人类模式:结果进 stdout。
let (code, stdout, stderr) = run_cli(&["instances", "list", "--data-dir", &dir.arg()]);
assert_eq!(code, 0);
assert!(stdout.contains("实例"), "{stdout}");
assert!(stderr.is_empty(), "{stderr}");
// 错误进 stderr,不污染 stdout。
let (code, stdout, stderr) = run_cli(&["launch", "nope", "--data-dir", &dir.arg()]);
assert_eq!(code, 1);
assert!(stdout.is_empty(), "{stdout}");
assert!(stderr.contains("错误:"), "{stderr}");
}
#[test]
fn quiet_suppresses_human_output_only() {
let dir = TempDir::new("code-quiet");
let (code, stdout, stderr) =
run_cli(&["instances", "list", "--quiet", "--data-dir", &dir.arg()]);
assert_eq!(code, 0);
assert!(stdout.is_empty(), "{stdout}");
assert!(stderr.is_empty(), "{stderr}");
// 错误不受 --quiet 影响(脚本仍要能看到原因)。
let (code, _, stderr) = run_cli(&["launch", "nope", "--quiet", "--data-dir", &dir.arg()]);
assert_eq!(code, 1);
assert!(stderr.contains("错误:"), "{stderr}");
}
+345
View File
@@ -0,0 +1,345 @@
//! D20 隔离档位的 CLI 端到端:`ull launch` 按覆盖链把派生结果填进 `--gameDir`。
//!
//! 四条路径(全局 isolated / 全局 shared / 实例 override / custom)+ 旧档案读兼容 +
//! `--dry-run` 不落盘。断言走 `--json` 输出的 `isolation` / `isolation_source` / `game_dir`,
//! 也就是真正喂给 `${game_directory}` 与子进程 CWD 的那个值。
#[path = "support/mod.rs"]
mod support;
use std::path::{Path, PathBuf};
use support::{parse_json, run_cli, TempDir};
/// 实例 id(与 `profiles.json` 里写的必须一致)。
const INSTANCE_ID: &str = "11111111-2222-4333-8444-000000000001";
/// 版本 id。
const VERSION: &str = "1.20.1";
/// 建数据根:版本本体 + `profiles.json`(一个实例「生存服」)。
fn data_root(tag: &str) -> TempDir {
let dir = TempDir::new(tag);
write_version_with_game_dir(dir.path());
write_profiles(dir.path(), None);
dir
}
/// 写一份「带 `--gameDir ${game_directory}`」的最小版本 JSON + 空 client jar。
///
/// 比 `support::write_version` 多这一个参数——`--gameDir` 就是隔离档位的真实落点
/// (正式版清单 1.13+ 都有这条),没有它就无法从命令行验证派生结果。
fn write_version_with_game_dir(root: &Path) {
let dir = root.join(".minecraft").join("versions").join(VERSION);
std::fs::create_dir_all(&dir).expect("建版本目录");
let version = serde_json::json!({
"id": VERSION,
"type": "release",
"mainClass": "net.minecraft.client.main.Main",
"assets": "5",
"assetIndex": { "id": "5" },
"arguments": {
"game": [
"--username", "${auth_player_name}",
"--version", "${version_name}",
"--gameDir", "${game_directory}"
],
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"]
},
"libraries": [],
"downloads": { "client": { "size": 4, "url": "http://example.invalid/client.jar" } }
});
std::fs::write(
dir.join(format!("{VERSION}.json")),
serde_json::to_vec_pretty(&version).expect("序列化版本 JSON"),
)
.expect("写版本 JSON");
std::fs::write(dir.join(format!("{VERSION}.jar")), b"junk").expect("写 client jar");
}
/// 写 `config/profiles.json`(可选 `game_dir` 遗留字段)。
fn write_profiles(root: &Path, legacy_game_dir: Option<&Path>) {
let profiles_dir = root.join(".ULL").join("config");
std::fs::create_dir_all(&profiles_dir).expect("建配置目录");
let mut profile = serde_json::json!({
"id": INSTANCE_ID,
"name": "生存服",
"version_id": VERSION,
});
if let Some(game_dir) = legacy_game_dir {
profile["game_dir"] = serde_json::json!(game_dir.display().to_string());
}
let document = serde_json::json!({
"schema_version": 1,
"profiles": [profile],
});
std::fs::write(
profiles_dir.join("profiles.json"),
serde_json::to_vec_pretty(&document).expect("序列化 profiles.json"),
)
.expect("写 profiles.json");
}
/// 写实例设置文件(`config/instances/<id>.json`)。
fn write_instance_config(dir: &Path, value: &serde_json::Value) {
let path = dir
.join(".ULL")
.join("config")
.join("instances")
.join(format!("{INSTANCE_ID}.json"));
std::fs::create_dir_all(path.parent().expect("父目录")).expect("建实例配置目录");
std::fs::write(
&path,
serde_json::to_vec_pretty(value).expect("序列化实例设置"),
)
.expect("写实例设置");
}
/// 跑一次 `launch <instance> --dry-run --json`。
fn dry_run(dir: &TempDir) -> serde_json::Value {
let (code, stdout, stderr) = run_cli(&[
"launch",
"生存服",
"--dry-run",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "{stderr}");
parse_json(&stdout)["data"].clone()
}
/// 数据根下的游戏目录。
fn game_root(dir: &TempDir) -> PathBuf {
dir.path().join(".minecraft")
}
#[test]
fn default_mode_is_isolated_and_derives_version_dir() {
let dir = data_root("iso-default");
let data = dry_run(&dir);
assert_eq!(data["isolation"], "isolated", "D20 默认隔离");
assert_eq!(data["isolation_source"], "global");
assert_eq!(data["instance_found"], true, "实例名解析仍然有效");
assert_eq!(
PathBuf::from(data["game_dir"].as_str().expect("game_dir")),
game_root(&dir).join("versions").join(VERSION)
);
assert!(data["mode_change_warning"].is_null());
// 端到端:派生值必须是命令行里 `--gameDir` 的实参(占位符已替换、家目录已脱敏,
// 所以按路径后缀断言)。
let argv: Vec<&str> = data["argv"]
.as_array()
.expect("argv")
.iter()
.map(|token| token.as_str().expect("token"))
.collect();
let index = argv
.iter()
.position(|token| *token == "--gameDir")
.unwrap_or_else(|| panic!("命令行里没有 --gameDir:{argv:?}"));
let passed = PathBuf::from(argv[index + 1]);
assert!(
passed.ends_with(Path::new("versions").join(VERSION)),
"--gameDir 不是派生的隔离目录:{}",
passed.display()
);
}
#[test]
fn shared_mode_switches_game_dir_to_game_root() {
let dir = data_root("iso-shared");
let (code, _, stderr) = run_cli(&[
"config",
"set",
"instances.mode",
"shared",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "{stderr}");
let data = dry_run(&dir);
assert_eq!(data["isolation"], "shared");
assert_eq!(data["isolation_source"], "global");
assert_eq!(
PathBuf::from(data["game_dir"].as_str().expect("game_dir")),
game_root(&dir),
"共享档的运行目录就是 .minecraft 根"
);
}
#[test]
fn instance_isolation_overrides_global_mode() {
let dir = data_root("iso-instance");
let (code, _, stderr) = run_cli(&[
"config",
"set",
"instances.mode",
"shared",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "{stderr}");
write_instance_config(
dir.path(),
&serde_json::json!({
"schemaVersion": 1,
"id": INSTANCE_ID,
"versionId": VERSION,
"isolation": "isolated",
}),
);
let data = dry_run(&dir);
assert_eq!(data["isolation"], "isolated", "实例档位压过全局");
assert_eq!(data["isolation_source"], "instance");
assert_eq!(
PathBuf::from(data["game_dir"].as_str().expect("game_dir")),
game_root(&dir).join("versions").join(VERSION)
);
}
#[test]
fn custom_isolation_uses_the_recorded_game_dir() {
let dir = data_root("iso-custom");
let custom = dir.path().join("mcs").join("farm");
write_instance_config(
dir.path(),
&serde_json::json!({
"schemaVersion": 1,
"id": INSTANCE_ID,
"versionId": VERSION,
"isolation": "custom",
"gameDir": custom.display().to_string(),
}),
);
let data = dry_run(&dir);
assert_eq!(data["isolation"], "custom");
assert_eq!(
PathBuf::from(data["game_dir"].as_str().expect("game_dir")),
custom
);
}
#[test]
fn legacy_profile_game_dir_reads_as_custom() {
// M27 之前的档案只在 profiles.json 里存 game_dir(没有实例设置文件):
// 显式指定过的目录不能被静默退回全局档。
let dir = TempDir::new("iso-legacy");
write_version_with_game_dir(dir.path());
let legacy = dir.path().join("old-instance-dir");
write_profiles(dir.path(), Some(&legacy));
let data = dry_run(&dir);
assert_eq!(data["isolation"], "custom");
assert_eq!(
PathBuf::from(data["game_dir"].as_str().expect("game_dir")),
legacy
);
}
#[test]
fn dry_run_does_not_write_instance_state() {
// 切换检测只在真启动时落盘:`--dry-run` 不该吃掉「切换后首次启动提示一次」。
let dir = data_root("iso-dry-run");
let config_path = dir
.path()
.join(".ULL")
.join("config")
.join("instances")
.join(format!("{INSTANCE_ID}.json"));
assert!(!config_path.exists());
let _ = dry_run(&dir);
assert!(
!config_path.exists(),
"--dry-run 写盘了:{}",
config_path.display()
);
}
#[test]
fn unknown_instance_still_reports_available_names() {
let dir = data_root("iso-unknown");
let (code, _, stderr) = run_cli(&[
"launch",
"不存在的实例",
"--dry-run",
"--data-dir",
&dir.arg(),
]);
assert_ne!(code, 0, "未知实例要失败");
assert!(stderr.contains("生存服"), "提示里要列出可用实例:{stderr}");
}
/// 读实例设置文件(不存在返回 `None`)。
fn read_instance_config(dir: &TempDir) -> Option<serde_json::Value> {
let path = dir
.path()
.join(".ULL")
.join("config")
.join("instances")
.join(format!("{INSTANCE_ID}.json"));
let text = std::fs::read_to_string(path).ok()?;
Some(serde_json::from_str(&text).expect("实例设置应为合法 JSON"))
}
/// 真启动一次(用一个不存在的 java,保证 spawn 必失败、不会真的跑起游戏),
/// 只为驱动「真启动才写盘」的那段逻辑。
fn real_launch_that_fails(dir: &TempDir) -> (i32, String, String) {
let bogus_java = dir.path().join("no-such-java.exe");
run_cli(&[
"launch",
"生存服",
"--java",
&bogus_java.display().to_string(),
"--data-dir",
&dir.arg(),
])
}
#[test]
fn first_real_launch_bootstraps_instance_file() {
// 回归 P2-3(review r1):profiles.json 有档案但没有实例设置文件(M27 之前的形态),
// 真启动要补建基线文件,否则切换检测对这批遗留实例永远不启动。
let dir = data_root("iso-bootstrap");
assert!(read_instance_config(&dir).is_none(), "前置:还没有设置文件");
let (code, _, stderr) = real_launch_that_fails(&dir);
assert_ne!(code, 0, "假 java 必然启动失败:{stderr}");
let config = read_instance_config(&dir).expect("首次真启动应补建设置文件");
assert_eq!(config["versionId"], VERSION);
assert_eq!(config["id"], INSTANCE_ID);
// 基线:from == to,且已经标记 warned(不该平白弹一次提示)。
assert_eq!(config["lastModeChange"]["from"], "isolated");
assert_eq!(config["lastModeChange"]["to"], "isolated");
assert_eq!(config["lastModeChange"]["warned"], true);
assert!(config["lastModeChange"]["at"].is_string());
assert!(config["lastModeChange"]["probe"]["isolatedSide"].is_number());
assert!(config["lastModeChange"]["probe"]["sharedSide"].is_number());
// inherit 不落盘。
assert!(config.get("isolation").is_none(), "{config}");
}
#[test]
fn legacy_profile_dir_is_recorded_as_custom_on_bootstrap() {
// 同一批遗留档案里「显式指定过目录」的那部分:补建时把 old profiles.game_dir → custom
// 固化进实例文件,之后实例文件就是唯一真相。
let dir = TempDir::new("iso-bootstrap-legacy");
write_version_with_game_dir(dir.path());
let legacy = dir.path().join("old-instance-dir");
write_profiles(dir.path(), Some(&legacy));
let (code, _, stderr) = real_launch_that_fails(&dir);
assert_ne!(code, 0, "假 java 必然启动失败:{stderr}");
let config = read_instance_config(&dir).expect("首次真启动应补建设置文件");
assert_eq!(config["isolation"], "custom");
assert_eq!(
PathBuf::from(config["gameDir"].as_str().expect("gameDir")),
legacy
);
assert_eq!(config["lastModeChange"]["to"], "custom");
assert_eq!(config["lastModeChange"]["warned"], true, "基线不弹提示");
}
+197
View File
@@ -0,0 +1,197 @@
//! `ull java list` / `ull java install` / `ull launch` 的 Java 选择(D13)CLI 端。
//!
//! 不联网:`install` 只测参数校验与稳定错误码;`launch --dry-run` 断言 `java` 块的结构
//! 与「实例覆盖优先」——真正的下载/解压由 `core::game::java` 的测试与本机验收覆盖。
#[path = "support/mod.rs"]
mod support;
use support::{parse_json, run_cli, write_version, TempDir};
/// 一个可组装的版本 id。
const VERSION: &str = "1.20.1";
#[test]
fn java_list_reports_the_managed_root() {
let dir = TempDir::new("java-list");
let (code, stdout, stderr) = run_cli(&["--json", "--data-dir", &dir.arg(), "java", "list"]);
assert_eq!(code, 0, "stderr: {stderr}");
let envelope = parse_json(&stdout);
let json = &envelope["data"];
assert!(
json["managed_root"]
.as_str()
.is_some_and(|root| root.ends_with("java-managed")),
"managed_root 应是 data/java-managed:{json}"
);
// D23:多根扫描 + 落点模式要说清(`--data-dir` 是 custom 数据模式 → portable 落点)。
assert!(
json["managed_roots"]
.as_array()
.is_some_and(|roots| !roots.is_empty()),
"{json}"
);
assert_eq!(json["install"]["mode"], "portable", "{json}");
assert!(
json["install"]["root"]
.as_str()
.is_some_and(|root| root.ends_with("java-managed")),
"落点根应是数据根里的 java-managed:{json}"
);
assert!(json["candidates"].is_array(), "{json}");
}
#[test]
fn java_vendors_lists_the_four_vendors_with_the_current_order() {
let dir = TempDir::new("java-vendors");
let (code, stdout, stderr) = run_cli(&["--json", "--data-dir", &dir.arg(), "java", "vendors"]);
assert_eq!(code, 0, "stderr: {stderr}");
let envelope = parse_json(&stdout);
let json = &envelope["data"];
let order: Vec<&str> = json["order"]
.as_array()
.expect("order 数组")
.iter()
.filter_map(|item| item.as_str())
.collect();
assert_eq!(order.len(), 4, "{json}");
assert!(order.contains(&"temurin"), "{json}");
assert!(order.contains(&"oracle"), "{json}");
assert!(order.contains(&"microsoft"), "{json}");
assert!(order.contains(&"zulu"), "{json}");
// 本机时区/语言决定 domestic/global,两种默认序都合法,但来源必须明示。
let source = json["priority_source"].as_str().unwrap_or("?");
assert_eq!(
source, "region_default",
"没设 java.vendorPriority 时:{json}"
);
let vendors = json["vendors"].as_array().expect("vendors 数组");
assert_eq!(vendors.len(), 4);
let microsoft = vendors
.iter()
.find(|row| row["vendor"] == "microsoft")
.expect("应有 microsoft 行");
assert_eq!(
microsoft["supported"].as_array().map(Vec::len),
Some(4),
"MS 只发 25/21/17/11:{microsoft}"
);
assert!(
microsoft["sources"]
.as_array()
.is_some_and(|sources| !sources.is_empty()),
"MS 的源(aka.ms → VS CDN)要列出来:{microsoft}"
);
}
#[test]
fn java_install_rejects_an_unknown_vendor_without_network() {
let dir = TempDir::new("java-vendor-bad");
let (code, stdout, _stderr) = run_cli(&[
"--json",
"--data-dir",
&dir.arg(),
"java",
"install",
"21",
"--vendor",
"graalvm",
]);
assert_eq!(code, 1);
let envelope = parse_json(&stdout);
assert_eq!(envelope["error"]["code"], "usage", "{envelope}");
}
#[test]
fn java_install_rejects_an_absurd_major_without_network() {
let dir = TempDir::new("java-install");
let (code, stdout, _stderr) =
run_cli(&["--json", "--data-dir", &dir.arg(), "java", "install", "4"]);
assert_eq!(code, 1);
let envelope = parse_json(&stdout);
assert_eq!(envelope["error"]["code"], "usage", "{envelope}");
}
#[test]
fn launch_dry_run_reports_the_java_requirement_of_the_version() {
let dir = TempDir::new("java-launch");
write_version(dir.path(), VERSION);
let (code, stdout, stderr) = run_cli(&[
"--json",
"--data-dir",
&dir.arg(),
"launch",
VERSION,
"--dry-run",
]);
assert_eq!(code, 0, "stderr: {stderr}");
let envelope = parse_json(&stdout);
let json = &envelope["data"];
let java = &json["java"];
// 1.20.1 的清单没写 javaVersion → 静态表兜底 17。
assert_eq!(java["required_major"], 17, "{json}");
assert_eq!(java["requirement_source"], "table", "{json}");
assert_eq!(json["version_id"], VERSION);
// `source` 只可能是探测到的来源之一(本机未必装了 Java)。
let source = java["source"].as_str().unwrap_or("none");
assert!(
["instance", "managed", "JAVA_HOME", "PATH", "none"].contains(&source),
"意外的来源:{source}"
);
}
#[test]
fn instance_java_override_wins_over_the_system_java() {
let dir = TempDir::new("java-override");
write_version(dir.path(), VERSION);
// 故意给一个不存在的路径:只要能进 `source=instance` 就说明覆盖优先级生效
// (探测失败只影响 major,不该把用户指定的路径换成系统 Java)。
let fake = dir.path().join("some-jdk").join("bin").join("java");
let (code, stdout, stderr) = run_cli(&[
"--json",
"--data-dir",
&dir.arg(),
"launch",
VERSION,
"--dry-run",
"--java",
&fake.display().to_string(),
]);
assert_eq!(code, 0, "stderr: {stderr}");
let envelope = parse_json(&stdout);
let json = &envelope["data"];
assert_eq!(json["java"]["source"], "instance", "{json}");
assert_eq!(
json["java"]["binary"].as_str(),
Some(fake.display().to_string().as_str())
);
}
#[test]
fn legacy_version_requires_java_8_and_flags_a_newer_runtime() {
let dir = TempDir::new("java-legacy");
write_version(dir.path(), "1.12.2");
let (code, stdout, stderr) = run_cli(&[
"--json",
"--data-dir",
&dir.arg(),
"launch",
"1.12.2",
"--dry-run",
]);
assert_eq!(code, 0, "stderr: {stderr}");
let envelope = parse_json(&stdout);
let json = &envelope["data"];
assert_eq!(json["java"]["required_major"], 8, "{json}");
assert_eq!(json["java"]["required"], "Java 8", "{json}");
// 本机若有比 8 新的 Java,必须给出 java_too_new 告警(不能只靠「最低版本」)。
if let Some(code) = json["java"]["mismatch"]["code"].as_str() {
assert!(code == "java_too_new" || code == "java_too_old", "{json}");
assert!(
json["warnings"]
.as_array()
.is_some_and(|warnings| !warnings.is_empty()),
"不符时必须进 warnings:{json}"
);
}
}
+115
View File
@@ -0,0 +1,115 @@
//! D19/D21 支持基线:core 的纯判定函数 + CLI 的呈现层(注入样本,不动真机)。
use unlucky_cli::os;
use unlucky_core::platform::linux_caps::{GlProbe, SessionKind};
use unlucky_core::platform::os_info::{
self, linux_level, macos_level, support_level_for, windows_level, OsFamily, OsInfo,
SupportLevel,
};
use unlucky_core::platform::vk_probe::VkProbe;
/// 手工构造一份样本系统信息(字段全是探测事实,等级用 core 的纯函数算)。
fn sample(family: OsFamily, version: Option<(u32, u32)>, product: &str) -> OsInfo {
let version_string =
version.map_or_else(String::new, |(major, minor)| format!("{major}.{minor}"));
OsInfo {
family,
arch: "x86_64".to_string(),
major_minor: version,
build: None,
product: product.to_string(),
version_string,
session: SessionKind::Unknown,
gl: GlProbe::Unknown,
vk: VkProbe::Unknown,
kernel: None,
glibc: None,
support_level: support_level_for(family, version, None, None),
}
}
#[test]
fn windows_samples_below_win10_are_unsupported() {
// D21 用户拍板「直接 win10 起步」:Win8.1 / 8 / 7 及更旧一律不支持。
assert_eq!(windows_level(Some((6, 1))), SupportLevel::Unsupported); // Win7
assert_eq!(windows_level(Some((6, 2))), SupportLevel::Unsupported); // Win8
assert_eq!(windows_level(Some((6, 3))), SupportLevel::Unsupported); // Win8.1
assert_eq!(windows_level(Some((5, 1))), SupportLevel::Unsupported); // XP
assert_eq!(windows_level(Some((10, 0))), SupportLevel::One); // Win10
// 探不到版本 → 保守判不支持(不能因为"探不到"就放行)。
assert_eq!(windows_level(None), SupportLevel::Unsupported);
}
#[test]
fn macos_and_linux_samples() {
assert_eq!(macos_level(Some(11)), SupportLevel::Unsupported);
assert_eq!(macos_level(Some(12)), SupportLevel::One);
assert_eq!(macos_level(None), SupportLevel::Unsupported);
// Linux:内核/glibc 探得到但过低 → 不支持;取不到(容器/musl/Termux)不阻塞。
assert_eq!(linux_level(Some((2, 6)), None), SupportLevel::Unsupported);
assert_eq!(linux_level(None, Some((2, 12))), SupportLevel::Unsupported);
assert_eq!(linux_level(Some((6, 8)), Some((2, 39))), SupportLevel::One);
assert_eq!(linux_level(None, None), SupportLevel::One);
}
#[test]
fn unsupported_sample_renders_upgrade_hint() {
let info = sample(OsFamily::Windows, Some((6, 3)), "Windows 8.1");
assert!(!info.support_level.is_usable());
let line = os::summary_line(&info);
assert!(line.contains("Windows 8.1"), "{line}");
assert!(line.contains("6.3"), "{line}");
assert!(line.contains("不支持"), "{line}");
let notes = os::notes(&info);
assert_eq!(notes.len(), 1);
assert!(notes[0].contains("Windows 10 或更高版本"), "{notes:?}");
// 文案是中英双语(core 同一份措辞),脚本只读 --json。
let message = info.describe().combined();
assert!(message.contains("Windows 10 或更高版本"), "{message}");
assert!(message.contains("Windows 10 or newer"), "{message}");
let value = os::to_json(&info);
assert_eq!(value["family"], "windows");
assert_eq!(value["product"], "Windows 8.1");
assert_eq!(value["support"], "unsupported");
assert_eq!(value["major_minor"], "6.3");
}
#[test]
fn supported_sample_has_no_notes() {
let info = sample(OsFamily::Windows, Some((10, 0)), "Windows 11");
assert!(info.support_level.is_usable());
assert!(os::notes(&info).is_empty());
assert!(os::summary_line(&info).contains("满足最低要求"));
assert_eq!(os::to_json(&info)["support"], "one");
}
#[test]
fn missing_version_is_reported_as_null_not_zero() {
// 探不到版本时 `version` 是 null(脚本按 null 判定"未探测"),而不是空串。
let info = sample(OsFamily::Windows, None, "Windows");
let value = os::to_json(&info);
assert!(value["version"].is_null());
assert!(value["major_minor"].is_null());
assert_eq!(value["support"], "unsupported");
assert!(os::summary_line(&info).contains("版本未知"));
}
#[test]
fn real_detect_matches_core_cache() {
// 真机上 CLI 的摘要必须就是 core 的缓存快照(同一份事实,不做二次加工)。
let info = os::detect();
assert_eq!(&info, os_info::current());
assert!(!info.arch.is_empty());
assert!(!info.product.is_empty());
// 自检形态与事实一致。
let check = os::check();
assert_eq!(check.level, info.support_level);
assert_eq!(check.os, info);
assert_eq!(check.is_usable(), info.support_level.is_usable());
assert!(check.message.contains(&info.product), "{}", check.message);
}
+287
View File
@@ -0,0 +1,287 @@
//! `--json` 输出形状与真实 core 轻量路径(只读命令 + dry-run)。
#[path = "support/mod.rs"]
mod support;
use support::{parse_json, run_cli, write_profiles, write_session, write_version, TempDir};
#[test]
fn version_json_has_os_summary() {
let (code, stdout, _) = run_cli(&["--version", "--json"]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
assert_eq!(value["ok"], true);
assert_eq!(value["data"]["name"], "ull");
assert_eq!(value["data"]["version"], env!("CARGO_PKG_VERSION"));
// D19/D21:os 对象来自 core::platform::os_info(family + 两档 support)。
assert!(value["data"]["os"]["family"].is_string());
let support = value["data"]["os"]["support"].as_str().unwrap_or_default();
assert!(matches!(support, "one" | "unsupported"), "{support}");
}
#[test]
fn welcome_without_subcommand_prints_version_and_help() {
let (code, stdout, _) = run_cli(&[]);
assert_eq!(code, 0);
assert!(stdout.contains("ull "), "{stdout}");
assert!(stdout.contains("用法:"), "{stdout}");
let (code, stdout, _) = run_cli(&["--json"]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
assert!(value["data"]["help"]
.as_str()
.unwrap_or_default()
.contains("用法:"));
}
#[test]
fn instances_list_json_shape() {
let dir = TempDir::new("instances");
write_version(dir.path(), "1.20.1");
write_profiles(dir.path(), &[("生存服", "1.20.1"), ("空实例", "1.19.4")]);
let (code, stdout, _) = run_cli(&["instances", "list", "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
let instances = value["data"]["instances"]
.as_array()
.expect("instances 数组");
assert_eq!(instances.len(), 2);
assert_eq!(instances[0]["name"], "生存服");
assert_eq!(instances[0]["version_present"], true);
assert_eq!(instances[1]["version_present"], false);
let installed = value["data"]["installed_versions"]
.as_array()
.expect("已安装版本");
assert_eq!(installed.len(), 1);
assert_eq!(installed[0], "1.20.1");
}
#[test]
fn install_rejects_unknown_loader_before_touching_network() {
let dir = TempDir::new("install-loader");
let (code, stdout, _) = run_cli(&[
"install",
"1.20.1",
"--loader",
"liteloader",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 1, "未知加载器是用户错误");
let value = parse_json(&stdout);
assert_eq!(value["error"]["code"], "loader_unknown");
assert!(
!dir.path()
.join(".ULL")
.join("config")
.join("profiles.json")
.exists(),
"拒绝时不该登记任何实例"
);
}
#[test]
fn install_reports_name_conflict_without_downloading() {
let dir = TempDir::new("install-conflict");
write_profiles(dir.path(), &[("生存服", "1.19.4")]);
// 同名但绑定别的版本 → 冲突,且这一步在联网/下载之前就返回。
let (code, stdout, _) = run_cli(&[
"install",
"1.20.1",
"--name",
"生存服",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 1);
let value = parse_json(&stdout);
assert_eq!(value["error"]["code"], "instance_exists");
assert!(
value["error"]["message"]
.as_str()
.unwrap_or_default()
.contains("1.19.4"),
"错误里应说明冲突版本:{stdout}"
);
}
#[test]
fn install_requires_a_version_id() {
let dir = TempDir::new("install-empty");
let (code, _, stderr) = run_cli(&["install", " ", "--data-dir", &dir.arg()]);
assert_eq!(code, 1);
assert!(stderr.contains("版本 id"), "stderr={stderr}");
}
#[test]
fn launch_dry_run_prints_assembled_command() {
let dir = TempDir::new("dryrun");
write_version(dir.path(), "1.20.1");
write_profiles(dir.path(), &[("生存服", "1.20.1")]);
let (code, stdout, _) = run_cli(&[
"launch",
"生存服",
"--dry-run",
"--username",
"Steve",
"--max-memory",
"2048",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0, "{stdout}");
let value = parse_json(&stdout);
let data = &value["data"];
assert_eq!(data["dry_run"], true);
assert_eq!(data["instance_found"], true);
let argv = data["argv"].as_array().expect("argv 数组");
let joined = argv
.iter()
.filter_map(|item| item.as_str())
.collect::<Vec<_>>()
.join(" ");
assert!(joined.contains("-Xmx2048"), "{joined}");
assert!(
joined.contains("net.minecraft.client.main.Main"),
"{joined}"
);
assert!(joined.contains("--username Steve"), "{joined}");
// 凭据绝不落到 dry-run 输出里。
assert!(!joined.contains("accessToken "), "{joined}");
assert!(data["classpath_entries"].as_u64().unwrap_or(0) >= 1);
}
#[test]
fn sessions_logs_and_diag_read_the_index() {
let dir = TempDir::new("sessions");
let sid = write_session(dir.path());
let (code, stdout, _) = run_cli(&["sessions", "list", "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
let sessions = value["data"]["sessions"].as_array().expect("sessions 数组");
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0]["session_id"], sid);
assert_eq!(sessions[0]["exit_code"], 1);
let (code, stdout, _) = run_cli(&[
"logs",
&sid,
"--tail",
"1",
"--json",
"--data-dir",
&dir.arg(),
]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
let events = value["data"]["events"].as_array().expect("events 数组");
assert_eq!(events.len(), 1);
assert_eq!(events[0]["kind"], "stderr");
// 前缀匹配唯一即可(脚本里手打全 id 太长)。
let (code, stdout, _) = run_cli(&["diag", "20261030", "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 0, "{stdout}");
let value = parse_json(&stdout);
assert_eq!(value["data"]["session_id"], sid);
let candidates = value["data"]["diagnosis"]["candidates"]
.as_array()
.expect("candidates 数组");
assert!(!candidates.is_empty(), "OOM 日志应命中规则:{stdout}");
assert_eq!(value["data"]["diagnosis"]["needs_more_info"], false);
}
#[test]
fn logs_json_payload_is_redacted_like_human_output() {
// P2-1:`--json` 是机器消费路径,不该比人类输出拿到更多原文(主目录用户名段)。
let dir = TempDir::new("logs-redact");
let sid = write_session(dir.path());
let events = dir
.path()
.join(".ULL")
.join("data")
.join("agent")
.join("sessions")
.join("game")
.join(format!("{sid}.jsonl"));
let extra = serde_json::json!({
"v": 1, "seq": 9, "ts": "2026-10-30T10:15:07Z", "t_ms": 6000,
"kind": "stdout", "session_id": sid,
"payload": { "line": r"cat C:\Users\Bob\secret.txt failed", "level": "info" }
});
let mut text = std::fs::read_to_string(&events).expect("读事件流");
text.push_str(&serde_json::to_string(&extra).expect("序列化事件"));
text.push('\n');
std::fs::write(&events, text).expect("写事件流");
let (code, stdout, stderr) = run_cli(&["logs", &sid, "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 0, "{stderr}");
assert!(!stdout.contains("Bob"), "JSON 载荷泄出用户名:{stdout}");
assert!(stdout.contains("***"), "JSON 载荷未脱敏:{stdout}");
}
#[test]
fn config_get_set_round_trip() {
let dir = TempDir::new("config");
let data_dir = dir.arg();
let (code, stdout, _) = run_cli(&[
"config",
"get",
"ui.language",
"--json",
"--data-dir",
&data_dir,
]);
assert_eq!(code, 0);
let value = parse_json(&stdout);
assert_eq!(value["data"]["key"], "ui.language");
assert_eq!(value["data"]["value"], "zh_cn");
let (code, stdout, _) = run_cli(&[
"config",
"set",
"ui.language",
"en_us",
"--json",
"--data-dir",
&data_dir,
]);
assert_eq!(code, 0, "{stdout}");
let value = parse_json(&stdout);
assert_eq!(value["data"]["old"], "zh_cn");
assert_eq!(value["data"]["new"], "en_us");
let (_, stdout, _) = run_cli(&[
"config",
"get",
"ui.language",
"--json",
"--data-dir",
&data_dir,
]);
assert_eq!(parse_json(&stdout)["data"]["value"], "en_us");
}
#[test]
fn error_envelope_shape() {
let dir = TempDir::new("errors");
let (code, stdout, _) = run_cli(&["launch", "nope", "--json", "--data-dir", &dir.arg()]);
assert_eq!(code, 1);
let value = parse_json(&stdout);
assert_eq!(value["ok"], false);
assert_eq!(value["error"]["code"], "not_found");
assert_eq!(value["error"]["kind"], "user");
assert!(value["error"]["message"]
.as_str()
.unwrap_or_default()
.contains("nope"));
assert!(value["error"]["hint"].is_string());
}
+338
View File
@@ -0,0 +1,338 @@
//! 子命令解析:手写解析器的语法契约。
#[path = "support/mod.rs"]
mod support;
use unlucky_cli::cli::{parse, AccountsAddKind, Command, Globals};
use unlucky_cli::error::ErrorKind;
fn invoke(args: &[&str]) -> unlucky_cli::cli::Invocation {
let owned: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
parse(&owned).unwrap_or_else(|error| panic!("解析失败:{error}"))
}
#[test]
fn no_args_is_welcome() {
let invocation = invoke(&[]);
assert_eq!(invocation.command, Command::Welcome);
assert_eq!(invocation.globals, Globals::default());
}
#[test]
fn version_flags() {
assert_eq!(invoke(&["--version"]).command, Command::Version);
assert_eq!(invoke(&["-V"]).command, Command::Version);
assert_eq!(invoke(&["version"]).command, Command::Version);
}
#[test]
fn help_takes_topic_from_positional() {
assert_eq!(invoke(&["--help"]).command, Command::Help(None));
assert_eq!(
invoke(&["--help", "launch"]).command,
Command::Help(Some("launch".to_string()))
);
assert_eq!(
invoke(&["help", "config"]).command,
Command::Help(Some("config".to_string()))
);
}
#[test]
fn globals_are_position_independent() {
let invocation = invoke(&["instances", "list", "--json", "--quiet"]);
assert_eq!(invocation.command, Command::InstancesList);
assert!(invocation.globals.json);
assert!(invocation.globals.quiet);
let before = invoke(&["--json", "instances", "list"]);
assert!(before.globals.json);
assert_eq!(before.command, Command::InstancesList);
}
#[test]
fn data_dir_accepts_both_forms() {
assert_eq!(
invoke(&["--data-dir", "/tmp/ull", "instances", "list"])
.globals
.data_dir,
Some(std::path::PathBuf::from("/tmp/ull"))
);
assert_eq!(
invoke(&["instances", "list", "--data-dir=/tmp/ull"])
.globals
.data_dir,
Some(std::path::PathBuf::from("/tmp/ull"))
);
}
#[test]
fn install_flags() {
let Command::Install(args) =
invoke(&["install", "1.20.1", "--loader", "vanilla", "--name=x"]).command
else {
panic!("应为 install");
};
assert_eq!(args.version, "1.20.1");
assert_eq!(args.loader.as_deref(), Some("vanilla"));
assert_eq!(args.name.as_deref(), Some("x"));
assert!(!args.dry_run);
assert!(args.progress, "人类模式默认打实时进度");
}
#[test]
fn install_loader_version_flag() {
let Command::Install(args) = invoke(&[
"install",
"1.20.1",
"--loader",
"fabric",
"--loader-version",
"0.15.11",
])
.command
else {
panic!("应为 install");
};
assert_eq!(args.loader.as_deref(), Some("fabric"));
assert_eq!(args.loader_version.as_deref(), Some("0.15.11"));
// 不带该 flag 时为 None(由 core 取官方默认版本)。
let Command::Install(default) = invoke(&["install", "1.20.1"]).command else {
panic!("应为 install");
};
assert!(default.loader_version.is_none());
}
#[test]
fn install_dry_run_flag_and_progress_suppression() {
let Command::Install(args) = invoke(&["install", "1.20.1", "--dry-run"]).command else {
panic!("应为 install");
};
assert!(args.dry_run);
assert!(args.progress);
assert!(!args.json_progress, "人类模式打文本进度");
// `--json`:stderr 换成 JSONL 进度(stdout 仍是单个信封)。
let Command::Install(args) = invoke(&["--json", "install", "1.20.1"]).command else {
panic!("应为 install");
};
assert!(args.progress);
assert!(args.json_progress);
// `--quiet`:一律不打进度。
let Command::Install(args) = invoke(&["install", "1.20.1", "-q"]).command else {
panic!("应为 install");
};
assert!(!args.progress);
assert!(!args.dry_run);
}
#[test]
fn launch_flags_and_defaults() {
let Command::Launch(args) = invoke(&["launch", "生存服"]).command else {
panic!("应为 launch");
};
assert_eq!(args.instance, "生存服");
assert!(!args.dry_run);
assert_eq!(args.java, None);
assert_eq!(args.max_memory, None);
let Command::Launch(args) = invoke(&[
"launch",
"生存服",
"--dry-run",
"--java",
"/opt/jdk/bin/java",
"--username",
"Steve",
"--max-memory",
"4096",
])
.command
else {
panic!("应为 launch");
};
assert!(args.dry_run);
assert_eq!(args.username.as_deref(), Some("Steve"));
assert_eq!(args.max_memory, Some(4096));
}
#[test]
fn accounts_subcommands() {
assert_eq!(invoke(&["accounts"]).command, Command::AccountsList);
assert_eq!(invoke(&["accounts", "list"]).command, Command::AccountsList);
let Command::AccountsAdd(args) = invoke(&["accounts", "add", "offline", "Steve"]).command
else {
panic!("应为 accounts add offline");
};
assert_eq!(args.kind, AccountsAddKind::Offline);
assert_eq!(args.name.as_deref(), Some("Steve"));
let Command::AccountsAdd(args) = invoke(&["accounts", "add", "msa"]).command else {
panic!("应为 accounts add msa");
};
assert_eq!(args.kind, AccountsAddKind::Microsoft);
assert_eq!(args.name, None);
let Command::AccountsRemove { name } = invoke(&["accounts", "remove", "Steve"]).command else {
panic!("应为 accounts remove");
};
assert_eq!(name, "Steve");
}
#[test]
fn launch_account_flag() {
let Command::Launch(args) = invoke(&["launch", "生存服", "--account", "Steve"]).command
else {
panic!("应为 launch");
};
assert_eq!(args.account.as_deref(), Some("Steve"));
assert_eq!(args.username, None);
}
#[test]
fn logs_and_diag_default_tails() {
let Command::Logs(args) = invoke(&["logs", "abc"]).command else {
panic!("应为 logs");
};
assert_eq!(args.tail, 200);
let Command::Diag(args) = invoke(&["diag", "abc", "--tail", "10"]).command else {
panic!("应为 diag");
};
assert_eq!(args.tail, 10);
}
#[test]
fn config_get_set_forms() {
let Command::ConfigGet(args) = invoke(&["config", "get"]).command else {
panic!("应为 config get");
};
assert_eq!(args.key, None);
assert!(!args.show_secret);
let Command::ConfigGet(args) =
invoke(&["config", "get", "ui.language", "--show-secret"]).command
else {
panic!("应为 config get");
};
assert_eq!(args.key.as_deref(), Some("ui.language"));
assert!(args.show_secret);
let Command::ConfigSet(args) = invoke(&["config", "set", "ui.language", "en_us"]).command
else {
panic!("应为 config set");
};
assert_eq!(args.key, "ui.language");
assert_eq!(args.value, "en_us");
}
#[test]
fn java_subcommands() {
assert_eq!(invoke(&["java"]).command, Command::JavaList);
assert_eq!(invoke(&["java", "list"]).command, Command::JavaList);
assert_eq!(
invoke(&["java", "install", "21"]).command,
Command::JavaInstall {
major: 21,
vendor: None
}
);
// D23:`--vendor` 钉死发行版(别名也认),`java vendors` 列出四家。
assert_eq!(
invoke(&["java", "install", "21", "--vendor", "oracle"]).command,
Command::JavaInstall {
major: 21,
vendor: Some(unlucky_core::game::java::JdkVendor::Oracle),
}
);
assert_eq!(
invoke(&["java", "install", "17", "--vendor", "adoptium"]).command,
Command::JavaInstall {
major: 17,
vendor: Some(unlucky_core::game::java::JdkVendor::Temurin),
}
);
assert_eq!(invoke(&["java", "vendors"]).command, Command::JavaVendors);
}
#[test]
fn usage_errors_are_user_errors() {
let cases: &[&[&str]] = &[
&["frobnicate"],
&["install"],
&["launch"],
&["launch", "a", "b"],
&["instances", "list", "--nope"],
&["install", "1.20.1", "--dry-run=1"],
&["config", "set", "only-key"],
&["java", "install", "abc"],
&["--data-dir"],
&["sessions", "watch"],
&["accounts", "add"],
&["accounts", "add", "offline"],
&["accounts", "add", "authlib"],
&["accounts", "remove"],
&["accounts", "frobnicate"],
];
for args in cases {
let owned: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
let error = parse(&owned).unwrap_err();
assert_eq!(error.kind(), ErrorKind::User, "args={args:?}");
assert_eq!(error.exit_code(), 1, "args={args:?}");
}
}
#[test]
fn value_flags_do_not_swallow_the_next_flag() {
// P2-2:漏了值的写法必须在解析期报用法错误,而不是把 `--dry-run` 当成 Java 路径。
let cases: &[&[&str]] = &[
&["launch", "x", "--java", "--dry-run"],
&["launch", "x", "--username", "--json"],
&["install", "1.20.1", "--name", "--loader", "fabric"],
&["install", "1.20.1", "--loader", "--name", "x"],
&["logs", "abc", "--tail", "--json"],
&["--data-dir", "--json", "instances", "list"],
];
for args in cases {
let owned: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
let error = parse(&owned).expect_err(&format!("args={args:?} 应当报用法错误"));
assert_eq!(error.code(), "usage", "args={args:?}");
assert!(
error.message().contains("需要一个值"),
"args={args:?} message={}",
error.message()
);
}
// 负数值与正常值不受影响(只拒 `--` 开头的 token)。
let Command::Launch(args) = invoke(&["launch", "x", "--java", "/opt/jdk/bin/java"]).command
else {
panic!("应为 launch");
};
assert_eq!(
args.java.as_deref(),
Some(std::path::Path::new("/opt/jdk/bin/java"))
);
}
#[test]
fn help_text_covers_every_command() {
for topic in [
"instances",
"install",
"launch",
"sessions",
"logs",
"diag",
"config",
"java",
] {
assert!(
unlucky_cli::cli::help::topic(topic).is_some(),
"缺少 {topic} 的帮助"
);
}
assert!(unlucky_cli::cli::help::topic("nope").is_none());
}
+159
View File
@@ -0,0 +1,159 @@
//! 集成测试共用夹具:临时数据根、命令执行器、最小版本 JSON / 会话索引。
//!
//! 每个测试目标都会整份编译本文件但只用其中一部分,因此这里放开 dead_code。
#![allow(dead_code)]
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
/// 一次性临时目录(`Drop` 时清理)。
pub struct TempDir {
path: PathBuf,
}
impl TempDir {
/// 建一个唯一目录。
pub fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-cli-test-{tag}-{}-{seq}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("创建临时目录");
Self { path }
}
/// 目录路径。
pub fn path(&self) -> &Path {
&self.path
}
/// 目录的字符串形式(喂给 `--data-dir`)。
pub fn arg(&self) -> String {
self.path.display().to_string()
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
/// 跑一次 CLI,返回(退出码, stdout, stderr)。
pub fn run_cli(args: &[&str]) -> (i32, String, String) {
let owned: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
let mut out: Vec<u8> = Vec::new();
let mut err: Vec<u8> = Vec::new();
let code = unlucky_cli::run(&owned, &mut out, &mut err);
(
code,
String::from_utf8_lossy(&out).into_owned(),
String::from_utf8_lossy(&err).into_owned(),
)
}
/// 解析 `--json` 输出(断言它是合法 JSON 并返回)。
pub fn parse_json(stdout: &str) -> serde_json::Value {
serde_json::from_str(stdout).unwrap_or_else(|error| panic!("输出不是 JSON:{error}\n{stdout}"))
}
/// 写一份最小可组装的新版版本 JSON + 空 client jar。
pub fn write_version(data_root: &Path, id: &str) {
let dir = data_root.join(".minecraft").join("versions").join(id);
std::fs::create_dir_all(&dir).expect("创建版本目录");
let version = serde_json::json!({
"id": id,
"type": "release",
"mainClass": "net.minecraft.client.main.Main",
"assets": "5",
"assetIndex": { "id": "5" },
"arguments": {
"game": ["--username", "${auth_player_name}", "--version", "${version_name}"],
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"]
},
"libraries": [],
"downloads": { "client": { "size": 4, "url": "http://example.invalid/client.jar" } }
});
std::fs::write(
dir.join(format!("{id}.json")),
serde_json::to_vec_pretty(&version).expect("序列化版本 JSON"),
)
.expect("写版本 JSON");
std::fs::write(dir.join(format!("{id}.jar")), b"junk").expect("写 client jar");
}
/// 写 `config/profiles.json`(实例名 → 版本 id)。
pub fn write_profiles(data_root: &Path, profiles: &[(&str, &str)]) {
let dir = data_root.join(".ULL").join("config");
std::fs::create_dir_all(&dir).expect("创建配置目录");
let entries: Vec<serde_json::Value> = profiles
.iter()
.enumerate()
.map(|(index, (name, version))| {
serde_json::json!({
"id": format!("11111111-2222-4333-8444-{:012}", index + 1),
"name": name,
"version_id": version,
})
})
.collect();
let document = serde_json::json!({ "schema_version": 1, "profiles": entries });
std::fs::write(
dir.join("profiles.json"),
serde_json::to_vec_pretty(&document).expect("序列化 profiles.json"),
)
.expect("写 profiles.json");
}
/// 写一份「退出码 1 + OOM」的会话索引与事件流,返回会话 id。
pub fn write_session(data_root: &Path) -> String {
let sid = "20261030T101500-abcdef";
let dir = data_root
.join(".ULL")
.join("data")
.join("agent")
.join("sessions")
.join("game");
std::fs::create_dir_all(&dir).expect("创建会话目录");
let events = [
serde_json::json!({"v":1,"seq":0,"ts":"2026-10-30T10:15:00Z","t_ms":0,"kind":"session_start","session_id":sid,"payload":{"instance_name":"生存服","game_version":"1.20.1"}}),
serde_json::json!({"v":1,"seq":1,"ts":"2026-10-30T10:15:02Z","t_ms":1500,"kind":"stdout","session_id":sid,"payload":{"line":"[main/INFO]: Loading Minecraft 1.20.1","level":"info"}}),
serde_json::json!({"v":1,"seq":2,"ts":"2026-10-30T10:15:05Z","t_ms":4200,"kind":"stderr","session_id":sid,"payload":{"line":"java.lang.OutOfMemoryError: Java heap space","level":"error"}}),
];
let mut text = String::new();
for event in events {
text.push_str(&serde_json::to_string(&event).expect("序列化事件"));
text.push('\n');
}
std::fs::write(dir.join(format!("{sid}.jsonl")), text).expect("写事件流");
let entry = serde_json::json!({
"v": 1,
"session_id": sid,
"instance_id": "11111111-2222-4333-8444-555555555555",
"instance_name": "生存服",
"game_version": "1.20.1",
"started_at": "2026-10-30T10:15:00Z",
"ended_at": "2026-10-30T10:15:06Z",
"duration_ms": 5000,
"exit_code": 1,
"exit_type": "application_error",
"silent_failure": false,
"window_detected": false,
"event_count": 3,
"log_path": format!("{sid}.jsonl"),
"crash_found": false,
"diagnosis_top": null
});
std::fs::write(
dir.join("index.jsonl"),
format!("{}\n", serde_json::to_string(&entry).expect("序列化索引")),
)
.expect("写会话索引");
sid.to_string()
}