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
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:
commit
5f8382565e
570 files changed
+104468
No files matched your search
@@ -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}");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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, "基线不弹提示");
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user