release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
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
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (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
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
This commit is contained in:
commit
a5d1dec495
1580 files changed
+320938
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,332 @@
|
||||
//! M221 CLI 端到端:`ull instances scan` / `ull instances adopt` 的真实入口(解析 → 执行 → 两条输出)。
|
||||
//!
|
||||
//! 用的都是**真磁盘**数据根(`support::TempDir`)+ 一份「别的启动器铺好的」`.minecraft`。
|
||||
//! 每条用例都断言外部文件哈希/mtime 不变(`fixture::assert_unchanged`),
|
||||
//! 因为「只登记不搬文件」是本 mission 的核心承诺。
|
||||
|
||||
#[path = "adopt_test/cli_surface.rs"]
|
||||
mod cli_surface;
|
||||
#[path = "adopt_test/collision_cases.rs"]
|
||||
mod collision_cases;
|
||||
#[path = "adopt_test/fixture.rs"]
|
||||
mod fixture;
|
||||
#[path = "support/mod.rs"]
|
||||
mod support;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use support::{parse_json, run_cli, TempDir};
|
||||
|
||||
use crate::fixture::{assert_unchanged, foreign_data_root, snapshot, snapshot_opt, Node};
|
||||
|
||||
/// 缺省登记:落 ULL 数据区的独立目录,隔离档 `custom`,**不沿用全局 `isolated`**。
|
||||
///
|
||||
/// 抓得住:
|
||||
/// - 缺省沿用全局 `isolated`(运行目录会变成 `versions/1.20.1`,第二条断言会红)——
|
||||
/// 这是本 mission 点名的最容易漏的一条;
|
||||
/// - 命令只登记不输出(`--json` 缺 `instance`);
|
||||
/// - 登记过程写了外部 `.minecraft`(哈希断言)。
|
||||
///
|
||||
/// 抓不住:GUI 是否也能触发同一条路(GUI 接线是后续 mission)。
|
||||
#[test]
|
||||
fn adopt_without_isolation_uses_a_separate_run_dir_and_keeps_global_isolated_out_of_it() {
|
||||
let dir = TempDir::new("adopt-default");
|
||||
let root = dir.path();
|
||||
foreign_data_root(root);
|
||||
// 全局档显式写成 isolated(D20 的缺省):登记**不许**继承它去踩别人的版本目录。
|
||||
set_global_isolated(&dir);
|
||||
let minecraft = root.join(".minecraft");
|
||||
let before = snapshot(&minecraft);
|
||||
|
||||
let (code, stdout, stderr) = run_cli(&[
|
||||
"instances",
|
||||
"adopt",
|
||||
"1.20.1",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
&dir.arg(),
|
||||
]);
|
||||
assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}");
|
||||
let after = snapshot(&minecraft);
|
||||
assert_unchanged(&before, &after, "adopt 之后");
|
||||
|
||||
let value = parse_json(&stdout);
|
||||
let instance = &value["data"]["instance"];
|
||||
assert_eq!(instance["isolation"], "custom");
|
||||
assert_eq!(instance["run_dir_source"], "instance_dir");
|
||||
let run_dir = instance["run_dir"].as_str().expect("run_dir");
|
||||
assert!(
|
||||
run_dir.contains("instances"),
|
||||
"缺省运行目录应落 ULL 数据区:{run_dir}"
|
||||
);
|
||||
assert!(
|
||||
!Path::new(run_dir).starts_with(&minecraft),
|
||||
"缺省运行目录不许落进别人的 .minecraft:{run_dir}"
|
||||
);
|
||||
assert!(
|
||||
!run_dir.ends_with("versions/1.20.1") && !run_dir.ends_with("versions\\1.20.1"),
|
||||
"不许沿用全局 isolated 的 versions/<id>:{run_dir}"
|
||||
);
|
||||
assert_eq!(instance["created"], true);
|
||||
|
||||
// 再走一次 `instances list`:档案真的进去了(不是只写了实例文件)。
|
||||
let (code, stdout, _) = run_cli(&["instances", "list", "--json", "--data-dir", &dir.arg()]);
|
||||
assert_eq!(code, 0);
|
||||
let listed = parse_json(&stdout);
|
||||
assert_eq!(listed["data"]["instances"][0]["version_id"], "1.20.1");
|
||||
assert_eq!(listed["data"]["instances"][0]["version_present"], true);
|
||||
}
|
||||
|
||||
/// 登记之后 `launch --dry-run` 的 `--gameDir` 就是选定的运行目录(本 mission 的功能落点)。
|
||||
///
|
||||
/// 抓得住:登记把隔离档写错(`inherit`)⇒ 启动派生坚持全局 `isolated`,`game_dir` 变成
|
||||
/// `versions/1.20.1`(第二条断言会红);`gameDir` 没进启动计划。
|
||||
/// 抓不住:真进程是否真的用这个 CWD(要真跑游戏,本 mission 不跑)。
|
||||
#[test]
|
||||
fn the_launch_plan_points_game_dir_at_the_chosen_run_dir() {
|
||||
let dir = TempDir::new("adopt-launch");
|
||||
let root = dir.path();
|
||||
foreign_data_root(root);
|
||||
set_global_isolated(&dir);
|
||||
|
||||
let (code, stdout, stderr) = run_cli(&[
|
||||
"instances",
|
||||
"adopt",
|
||||
"1.20.1",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
&dir.arg(),
|
||||
]);
|
||||
assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}");
|
||||
let adopted = parse_json(&stdout);
|
||||
let run_dir = adopted["data"]["instance"]["run_dir"]
|
||||
.as_str()
|
||||
.expect("run_dir")
|
||||
.to_string();
|
||||
let instance_id = adopted["data"]["instance"]["id"]
|
||||
.as_str()
|
||||
.expect("instance id")
|
||||
.to_string();
|
||||
|
||||
let dir_arg = dir.arg();
|
||||
let args = [
|
||||
"launch",
|
||||
instance_id.as_str(),
|
||||
"--dry-run",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
dir_arg.as_str(),
|
||||
];
|
||||
let (code, stdout, stderr) = run_cli(&args);
|
||||
assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}");
|
||||
let plan = parse_json(&stdout);
|
||||
assert_eq!(plan["data"]["game_dir"], run_dir.as_str());
|
||||
assert_eq!(plan["data"]["isolation"], "custom");
|
||||
assert_eq!(plan["data"]["isolation_source"], "instance");
|
||||
let command = plan["data"]["argv"]
|
||||
.as_array()
|
||||
.expect("启动计划里应有 argv");
|
||||
let game_dir_flag = command
|
||||
.windows(2)
|
||||
.find(|pair| pair[0].as_str() == Some("--gameDir"))
|
||||
.map(|pair| pair[1].as_str().unwrap_or_default().to_string())
|
||||
.expect("命令行里应有 --gameDir");
|
||||
// `argv` 是**脱敏后**的(主目录 → `<home>`,见 `core::launch::redact`),所以比尾段而不是全等;
|
||||
// 全等的那条断言在 `data.game_dir` 上(那是未脱敏的权威值)。
|
||||
let tail = format!(
|
||||
"instances/{}",
|
||||
Path::new(&run_dir)
|
||||
.file_name()
|
||||
.expect("运行目录名")
|
||||
.to_string_lossy()
|
||||
);
|
||||
assert!(
|
||||
game_dir_flag.ends_with(&tail) || game_dir_flag.ends_with(&tail.replace('/', "\\")),
|
||||
"--gameDir 必须指向选定的运行目录:{game_dir_flag} 应以 {tail} 结尾"
|
||||
);
|
||||
}
|
||||
|
||||
/// `--isolation shared`:明确选共享 ⇒ 运行目录 = 别人的游戏根,**人读输出必须说出后果**。
|
||||
///
|
||||
/// 抓得住:shared 档没有告知(stdout 里没有「共用」)、shared 被写成 custom + 自己的目录、
|
||||
/// 登记往游戏根写文件(哈希断言)。
|
||||
/// 抓不住:用户是否真的看见这句(UI 验收另说)。
|
||||
#[test]
|
||||
fn shared_isolation_states_the_consequence_in_human_output() {
|
||||
let dir = TempDir::new("adopt-shared");
|
||||
let root = dir.path();
|
||||
foreign_data_root(root);
|
||||
let minecraft = root.join(".minecraft");
|
||||
let before = snapshot(&minecraft);
|
||||
|
||||
let (code, stdout, stderr) = run_cli(&[
|
||||
"instances",
|
||||
"adopt",
|
||||
"1.20.1",
|
||||
"--isolation",
|
||||
"shared",
|
||||
"--data-dir",
|
||||
&dir.arg(),
|
||||
]);
|
||||
assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}");
|
||||
let after = snapshot(&minecraft);
|
||||
assert_unchanged(&before, &after, "shared 登记之后");
|
||||
assert!(
|
||||
stdout.contains("共用"),
|
||||
"必须说出「共用别人的 .minecraft」:{stdout}"
|
||||
);
|
||||
assert!(stdout.contains("一个字节都没动"), "{stdout}");
|
||||
}
|
||||
|
||||
/// `--dry-run`:只检查不写盘(连配置目录都不建)。
|
||||
///
|
||||
/// 抓得住:`--dry-run` 真的写了档案或建了运行目录(快照会红)。
|
||||
/// 抓不住:`--dry-run` 有没有顺带读了不该读的东西。
|
||||
#[test]
|
||||
fn dry_run_writes_nothing() {
|
||||
let dir = TempDir::new("adopt-dry");
|
||||
let root = dir.path();
|
||||
foreign_data_root(root);
|
||||
let before = snapshot(root);
|
||||
// 判据强度自检(R5):快照必须含**目录**节点,否则「凭空建个空目录」看不出来。
|
||||
assert!(
|
||||
matches!(before.get(".minecraft"), Some(Node::Dir { .. })),
|
||||
"快照缺少目录节点:{:?}",
|
||||
before.get(".minecraft")
|
||||
);
|
||||
assert!(
|
||||
before.contains_key(".minecraft/versions"),
|
||||
"快照缺少 versions 目录节点"
|
||||
);
|
||||
|
||||
let (code, stdout, stderr) = run_cli(&[
|
||||
"instances",
|
||||
"adopt",
|
||||
"1.20.1",
|
||||
"--dry-run",
|
||||
"--data-dir",
|
||||
&dir.arg(),
|
||||
]);
|
||||
assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}");
|
||||
let after = snapshot(root);
|
||||
assert_unchanged(&before, &after, "--dry-run 之后");
|
||||
// `--dry-run` 连 `.ULL` 这个**空目录**都不许建(存在性判据用 `snapshot_opt`)。
|
||||
assert!(
|
||||
snapshot_opt(&root.join(".ULL")).is_none(),
|
||||
"--dry-run 不该建配置目录"
|
||||
);
|
||||
assert!(stdout.contains("检查通过"), "{stdout}");
|
||||
assert!(stdout.contains("没有写任何文件"), "{stdout}");
|
||||
}
|
||||
|
||||
/// 坏的版本(缺父)⇒ 退出码 1,`--json` 里带**逐条**问题码。
|
||||
///
|
||||
/// 抓得住:不合格版本被登记成功(退出码变 0)、问题码被折叠成一句散文(`detail.problems` 缺项)。
|
||||
/// 抓不住:用户在原来的启动器里修好之后重试(自然覆盖)。
|
||||
#[test]
|
||||
fn not_adoptable_version_exits_one_with_structured_problems() {
|
||||
let dir = TempDir::new("adopt-refuse");
|
||||
let root = dir.path();
|
||||
foreign_data_root(root);
|
||||
let before = snapshot(root);
|
||||
|
||||
let (code, stdout, _) = run_cli(&[
|
||||
"instances",
|
||||
"adopt",
|
||||
"1.20.1-orphan",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
&dir.arg(),
|
||||
]);
|
||||
assert_eq!(code, 1, "{stdout}");
|
||||
let value = parse_json(&stdout);
|
||||
assert_eq!(value["ok"], false);
|
||||
assert_eq!(value["error"]["code"], "adopt_not_adoptable");
|
||||
assert_eq!(
|
||||
value["error"]["detail"]["problems"][0]["code"],
|
||||
"missing_parent"
|
||||
);
|
||||
let after = snapshot(root);
|
||||
assert_unchanged(&before, &after, "失败之后");
|
||||
}
|
||||
|
||||
/// 重复登记 ⇒ 退出码 1 + 无副作用(档案逐字节不变)。
|
||||
///
|
||||
/// 抓得住:重复登记当成功(退出码 0)或失败时改了档案。
|
||||
/// 抓不住:并发两次登记(没有锁;这不是并发入口)。
|
||||
#[test]
|
||||
fn repeating_the_same_adopt_exits_one_without_side_effects() {
|
||||
let dir = TempDir::new("adopt-repeat");
|
||||
let root = dir.path();
|
||||
foreign_data_root(root);
|
||||
let (code, _, stderr) = run_cli(&["instances", "adopt", "1.20.1", "--data-dir", &dir.arg()]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
let config = root.join(".ULL/config");
|
||||
let before = snapshot(&config);
|
||||
|
||||
let (code, stdout, _) = run_cli(&[
|
||||
"instances",
|
||||
"adopt",
|
||||
"1.20.1",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
&dir.arg(),
|
||||
]);
|
||||
assert_eq!(code, 1, "{stdout}");
|
||||
assert_eq!(
|
||||
parse_json(&stdout)["error"]["code"],
|
||||
"adopt_already_registered"
|
||||
);
|
||||
let after = snapshot(&config);
|
||||
assert_unchanged(&before, &after, "重复登记失败之后");
|
||||
}
|
||||
|
||||
/// `instances scan`:只读列出候选,坏版本逐条给原因。
|
||||
///
|
||||
/// 抓得住:扫描变成空列表(那是本 mission 点名不许的行为)、坏版本被静默跳过、
|
||||
/// 扫描写了文件。
|
||||
/// 抓不住:真实 `.minecraft` 里那些平台特有的怪目录(夹具只覆盖已知形状)。
|
||||
#[test]
|
||||
fn scan_lists_candidates_and_reasons_without_writing() {
|
||||
let dir = TempDir::new("adopt-scan");
|
||||
let root = dir.path();
|
||||
foreign_data_root(root);
|
||||
let before = snapshot(root);
|
||||
|
||||
let (code, stdout, stderr) =
|
||||
run_cli(&["instances", "scan", "--json", "--data-dir", &dir.arg()]);
|
||||
assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}");
|
||||
let after = snapshot(root);
|
||||
assert_unchanged(&before, &after, "scan 之后");
|
||||
|
||||
let value = parse_json(&stdout);
|
||||
assert_eq!(value["data"]["counts"]["total"], 2);
|
||||
assert_eq!(value["data"]["counts"]["adoptable"], 1);
|
||||
let candidates = value["data"]["candidates"].as_array().expect("candidates");
|
||||
let ok = candidates
|
||||
.iter()
|
||||
.find(|item| item["version_id"] == "1.20.1")
|
||||
.expect("有 1.20.1");
|
||||
assert_eq!(ok["adoptable"], true);
|
||||
assert_eq!(ok["jar_present"], true);
|
||||
let orphan = candidates
|
||||
.iter()
|
||||
.find(|item| item["version_id"] == "1.20.1-orphan")
|
||||
.expect("有 1.20.1-orphan");
|
||||
assert_eq!(orphan["adoptable"], false);
|
||||
assert_eq!(orphan["problems"][0]["code"], "missing_parent");
|
||||
}
|
||||
|
||||
/// 把全局隔离档设成 `isolated`(走 schema 校验的正式入口,不手写 launcher.json)。
|
||||
fn set_global_isolated(dir: &TempDir) {
|
||||
let dir_arg = dir.arg();
|
||||
let (code, _, stderr) = run_cli(&[
|
||||
"config",
|
||||
"set",
|
||||
"instances.mode",
|
||||
"isolated",
|
||||
"--data-dir",
|
||||
dir_arg.as_str(),
|
||||
]);
|
||||
assert_eq!(code, 0, "设置全局隔离档失败:{stderr}");
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! 命令面:help 文案、用法错误、错误码——不碰数据或者只碰「拒绝」那条路。
|
||||
//!
|
||||
//! 从 `tests/adopt_test.rs` 拆出来只为守 400 行硬线;搬动是纯搬,断言未变。
|
||||
|
||||
use crate::support::{parse_json, run_cli, TempDir};
|
||||
|
||||
use crate::fixture::foreign_data_root;
|
||||
|
||||
/// 运行目录选项的用法错误:一律退出码 1 + `code=usage`(不是内部错误)。
|
||||
///
|
||||
/// 抓得住:`--isolation custom` 少 `--game-dir` 被默默当成缺省、`shared` 同时给 `--game-dir`
|
||||
/// 被静默忽略、未知词被当缺省。
|
||||
/// 抓不住:提示文案的措辞(只断言 code 与关键字)。
|
||||
#[test]
|
||||
fn isolation_option_misuse_is_a_usage_error() {
|
||||
let dir = TempDir::new("adopt-usage");
|
||||
foreign_data_root(dir.path());
|
||||
let cases: Vec<Vec<&str>> = vec![
|
||||
vec!["--isolation", "custom"],
|
||||
vec!["--isolation", "shared", "--game-dir", "C:/tmp/x"],
|
||||
vec!["--isolation", "nope"],
|
||||
vec!["--game-dir", "relative/dir"],
|
||||
];
|
||||
for extra in cases {
|
||||
let mut args = vec!["instances", "adopt", "1.20.1", "--json", "--data-dir"];
|
||||
let dir_arg = dir.arg();
|
||||
args.push(&dir_arg);
|
||||
args.extend(extra.iter().copied());
|
||||
let (code, stdout, stderr) = run_cli(&args);
|
||||
assert_eq!(code, 1, "args={args:?}\nstdout={stdout}\nstderr={stderr}");
|
||||
let value = parse_json(&stdout);
|
||||
assert_eq!(
|
||||
value["error"]["code"], "usage",
|
||||
"args={args:?} 应是用法错误:{stdout}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 不存在的版本 ⇒ `adopt_version_not_found`(不联网、不偷偷装一个)。
|
||||
///
|
||||
/// 抓得住:不存在的 id 被登记成功。
|
||||
/// 抓不住:`versions/` 整个不存在时 `scan` 的空结果(core 侧用例覆盖)。
|
||||
#[test]
|
||||
fn unknown_version_reports_not_found() {
|
||||
let dir = TempDir::new("adopt-unknown");
|
||||
foreign_data_root(dir.path());
|
||||
let (code, stdout, _) = run_cli(&[
|
||||
"instances",
|
||||
"adopt",
|
||||
"9.9.9",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
&dir.arg(),
|
||||
]);
|
||||
assert_eq!(code, 1, "{stdout}");
|
||||
assert_eq!(
|
||||
parse_json(&stdout)["error"]["code"],
|
||||
"adopt_version_not_found"
|
||||
);
|
||||
}
|
||||
|
||||
/// help 里必须有这两条命令(新命令不留「有实现没文档」的状态),且旧命令仍在。
|
||||
///
|
||||
/// 抓得住:help 漏列 `instances scan` / `instances adopt`(子串断言),或把旧的
|
||||
/// `instances launch-args` / `pack import` 弄丢。
|
||||
/// 抓不住:帮助文案与真实行为是否一致(那要靠上面的行为用例 + 人读)。
|
||||
#[test]
|
||||
fn help_lists_the_new_subcommands_next_to_the_old_ones() {
|
||||
let general = unlucky_cli::cli::help::general();
|
||||
for needle in [
|
||||
"instances scan",
|
||||
"instances adopt",
|
||||
"instances list",
|
||||
"install <version>",
|
||||
] {
|
||||
assert!(general.contains(needle), "总帮助缺 `{needle}`:\n{general}");
|
||||
}
|
||||
let topic = unlucky_cli::cli::help::topic("instances").expect("help instances 存在");
|
||||
for needle in [
|
||||
"instances scan",
|
||||
"instances adopt",
|
||||
"缺省不沿用全局 isolated",
|
||||
"一个字节都不写",
|
||||
] {
|
||||
assert!(topic.contains(needle), "help instances 缺 `{needle}`");
|
||||
}
|
||||
// 旧的 `per_version` 字面量只在 core 的 `Isolation::parse` 里被兼容,CLI 词表不接受它。
|
||||
let bogus = std::env::temp_dir().join("ull-adopt-help-nonexistent");
|
||||
let bogus = bogus.display().to_string();
|
||||
let args = [
|
||||
"instances",
|
||||
"adopt",
|
||||
"1.20.1",
|
||||
"--isolation",
|
||||
"per_version",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
bogus.as_str(),
|
||||
];
|
||||
let (code, stdout, _) = run_cli(&args);
|
||||
assert_eq!(code, 1, "`per_version` 不是 CLI 词表:{stdout}");
|
||||
}
|
||||
|
||||
/// **更高祖先断链**(r2 R2.3 的 CLI 面):`--data-dir` 指到迁移联接下面的 nested 目录,
|
||||
/// 联接目标被搬走后 `instances scan` 必须非零 + 结构化错误,不能 `ok:true / total:0`。
|
||||
///
|
||||
/// 抓得住:只判 `versions`/游戏根两层(更高层断链会漏,这里会看到 exit0 与 total 0)。
|
||||
///
|
||||
/// 抓不住:真正的权限不足(EACCES)那一支。
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_dangling_ancestor_is_a_structured_scan_error() {
|
||||
let dir = TempDir::new("scan-ancestor-dangling");
|
||||
let outer = TempDir::new("scan-ancestor-target");
|
||||
let real = outer.path();
|
||||
let game = real.join("nested").join(".minecraft");
|
||||
std::fs::create_dir_all(game.join("versions").join("1.20.1")).expect("建完整版本树");
|
||||
std::fs::write(
|
||||
game.join("versions/1.20.1/1.20.1.json"),
|
||||
br#"{"id":"1.20.1","type":"release","mainClass":"x","libraries":[]}"#,
|
||||
)
|
||||
.expect("写清单");
|
||||
let link = dir.path().join("game-parent-link");
|
||||
let ok = std::process::Command::new("cmd")
|
||||
.args(["/c", "mklink", "/J"])
|
||||
.arg(&link)
|
||||
.arg(real)
|
||||
.output()
|
||||
.is_ok_and(|out| out.status.success());
|
||||
if !ok {
|
||||
eprintln!("[link-guard] 建不出目录联接,跳过祖先断链 CLI 用例");
|
||||
return;
|
||||
}
|
||||
let data_arg = link.join("nested").display().to_string();
|
||||
|
||||
// 断开之前:能扫到 1 个候选。
|
||||
let (code, stdout, stderr) = run_cli(&["instances", "scan", "--json", "--data-dir", &data_arg]);
|
||||
assert_eq!(code, 0, "stdout={stdout} stderr={stderr}");
|
||||
assert_eq!(parse_json(&stdout)["data"]["counts"]["total"], 1);
|
||||
|
||||
// 断开:联接目标改名(入口还在,versions 与游戏根都 NotFound)。
|
||||
let moved = dir.path().join("moved-target");
|
||||
std::fs::rename(real, &moved).expect("移走联接目标");
|
||||
|
||||
let (code, stdout, stderr) = run_cli(&["instances", "scan", "--json", "--data-dir", &data_arg]);
|
||||
assert_eq!(
|
||||
code, 1,
|
||||
"祖先断链必须非零退出:stdout={stdout} stderr={stderr}"
|
||||
);
|
||||
let value = parse_json(&stdout);
|
||||
assert_eq!(value["ok"], false, "{stdout}");
|
||||
assert_eq!(
|
||||
value["error"]["code"], "adopt_versions_unreadable",
|
||||
"{stdout}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//! 运行目录**碰撞**的 CLI 面:slug 同名 / 大小写不敏感不许让两个实例共用一个目录(复审 r1 的 R1)。
|
||||
//!
|
||||
//! 从 `tests/adopt_test.rs` 拆出来只为守 400 行硬线;用例体未改。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::support::{parse_json, run_cli, TempDir};
|
||||
|
||||
use crate::fixture::foreign_data_root;
|
||||
|
||||
/// **slug 碰撞的两个展示名不能绑定同一个物理运行目录**(R1 的 CLI 面)。
|
||||
///
|
||||
/// 复审 r1 的实跑:`Better MC` 与 `Better-MC` 都成功、两个 UUID、却指向同一个目录,
|
||||
/// 随后 `launch --dry-run` 的 `--gameDir` 也是同一个。这条把它钉住。
|
||||
///
|
||||
/// 抓得住:
|
||||
/// - 登记侧不查已有实例占用的目录(两个 `run_dir` 相同,第一条断言红);
|
||||
/// - `launch` 的 `game_dir` 与登记的目录不一致(第三条断言红)。
|
||||
///
|
||||
/// 抓不住:`pack_dir` 的 slug 规则本身(既有行为,本 mission 不改)。
|
||||
#[test]
|
||||
fn colliding_slug_names_get_separate_run_dirs() {
|
||||
let dir = TempDir::new("adopt-slug");
|
||||
let root = dir.path();
|
||||
foreign_data_root(root);
|
||||
let dir_arg = dir.arg();
|
||||
|
||||
let first = run_cli(&[
|
||||
"instances",
|
||||
"adopt",
|
||||
"1.20.1",
|
||||
"--name",
|
||||
"Better MC",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
dir_arg.as_str(),
|
||||
]);
|
||||
assert_eq!(
|
||||
first.0, 0,
|
||||
"第一次登记应成功:stdout={} stderr={}",
|
||||
first.1, first.2
|
||||
);
|
||||
let first = parse_json(&first.1);
|
||||
let second = run_cli(&[
|
||||
"instances",
|
||||
"adopt",
|
||||
"1.20.1",
|
||||
"--name",
|
||||
"Better-MC",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
dir_arg.as_str(),
|
||||
]);
|
||||
assert_eq!(
|
||||
second.0, 0,
|
||||
"第二次登记应成功:stdout={} stderr={}",
|
||||
second.1, second.2
|
||||
);
|
||||
let second = parse_json(&second.1);
|
||||
|
||||
let first_dir = first["data"]["instance"]["run_dir"]
|
||||
.as_str()
|
||||
.expect("run_dir A")
|
||||
.to_string();
|
||||
let second_dir = second["data"]["instance"]["run_dir"]
|
||||
.as_str()
|
||||
.expect("run_dir B")
|
||||
.to_string();
|
||||
assert_ne!(first_dir, second_dir, "两个实例不许共用运行目录");
|
||||
assert!(
|
||||
Path::new(&first_dir).is_dir() && Path::new(&second_dir).is_dir(),
|
||||
"两个运行目录都要真实存在"
|
||||
);
|
||||
|
||||
// 真启动计划(dry-run)也要分开:两条实例各自的 game_dir 不同。
|
||||
for (value, expect) in [(&first, &first_dir), (&second, &second_dir)] {
|
||||
let id = value["data"]["instance"]["id"]
|
||||
.as_str()
|
||||
.expect("instance id");
|
||||
let args = [
|
||||
"launch",
|
||||
id,
|
||||
"--dry-run",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
dir_arg.as_str(),
|
||||
];
|
||||
let (code, stdout, stderr) = run_cli(&args);
|
||||
assert_eq!(
|
||||
code, 0,
|
||||
"stdout={stdout}
|
||||
stderr={stderr}"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_json(&stdout)["data"]["game_dir"]
|
||||
.as_str()
|
||||
.expect("game_dir"),
|
||||
expect.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **Win32 尾点/尾空格**(r2 R2.1 的 CLI 面):两次登记 + 两条 `launch --dry-run` 不许落到同一物理目录。
|
||||
///
|
||||
/// 抓得住:认领目录的尾点/尾空格不归一(第二次默认登记仍拿到 `Foo`,`run_dir` 相同断言红)。
|
||||
///
|
||||
/// 抓不住:`\?\` verbatim 写法(那本来就不折叠,是另一个目录)。
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn win32_trailing_dot_or_space_claims_do_not_share_a_run_dir() {
|
||||
for suffix in [".", " "] {
|
||||
let dir = TempDir::new("adopt-win32-alias");
|
||||
let root = dir.path();
|
||||
foreign_data_root(root);
|
||||
let dir_arg = dir.arg();
|
||||
let claimed = root
|
||||
.join(".ULL/data/instances")
|
||||
.join(format!("Foo{suffix}"));
|
||||
let claimed_arg = claimed.display().to_string();
|
||||
|
||||
let first = run_cli(&[
|
||||
"instances",
|
||||
"adopt",
|
||||
"1.20.1",
|
||||
"--name",
|
||||
"First",
|
||||
"--isolation",
|
||||
"custom",
|
||||
"--game-dir",
|
||||
claimed_arg.as_str(),
|
||||
"--json",
|
||||
"--data-dir",
|
||||
dir_arg.as_str(),
|
||||
]);
|
||||
assert_eq!(
|
||||
first.0, 0,
|
||||
"custom 登记应成功:stdout={} stderr={}",
|
||||
first.1, first.2
|
||||
);
|
||||
let first = parse_json(&first.1);
|
||||
|
||||
let second = run_cli(&[
|
||||
"instances",
|
||||
"adopt",
|
||||
"1.20.1",
|
||||
"--name",
|
||||
"Foo",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
dir_arg.as_str(),
|
||||
]);
|
||||
assert_eq!(
|
||||
second.0, 0,
|
||||
"默认登记应成功:stdout={} stderr={}",
|
||||
second.1, second.2
|
||||
);
|
||||
let second = parse_json(&second.1);
|
||||
|
||||
let first_dir = first["data"]["instance"]["run_dir"]
|
||||
.as_str()
|
||||
.expect("run_dir A")
|
||||
.to_string();
|
||||
let second_dir = second["data"]["instance"]["run_dir"]
|
||||
.as_str()
|
||||
.expect("run_dir B")
|
||||
.to_string();
|
||||
assert_ne!(
|
||||
first_dir, second_dir,
|
||||
"尾点/尾空格别名不许共用运行目录(suffix={suffix:?})"
|
||||
);
|
||||
|
||||
// 两条启动计划的 --gameDir 也必须分开(argv 已脱敏,比尾段)。
|
||||
for (value, expect) in [(&first, &first_dir), (&second, &second_dir)] {
|
||||
let id = value["data"]["instance"]["id"]
|
||||
.as_str()
|
||||
.expect("instance id");
|
||||
let args = [
|
||||
"launch",
|
||||
id,
|
||||
"--dry-run",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
dir_arg.as_str(),
|
||||
];
|
||||
let (code, stdout, stderr) = run_cli(&args);
|
||||
assert_eq!(code, 0, "stdout={stdout} stderr={stderr}");
|
||||
let plan = parse_json(&stdout);
|
||||
assert_eq!(
|
||||
plan["data"]["game_dir"].as_str().expect("game_dir"),
|
||||
expect.as_str(),
|
||||
"启动计划必须落在自己的运行目录(suffix={suffix:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! CLI 侧夹具:一个「别的启动器铺好了 `.minecraft`」的数据根。
|
||||
//!
|
||||
//! 快照工具**复用 core 测试那一份**(`crates/core/tests/adopt/snapshot.rs`,全节点:目录/文件/链接
|
||||
//! 各记形状与 mtime)。以前 CLI 自己抄了一份 walk,两份都漏了目录节点——复审 r1 的 R5 正是这个洞,
|
||||
//! 所以这里直接 `#[path]` 引用同一份源码:判据只有一处,不会再漂。
|
||||
|
||||
#[path = "../../../core/tests/adopt/snapshot.rs"]
|
||||
mod snapshot;
|
||||
|
||||
pub use snapshot::{assert_unchanged, snapshot, snapshot_opt, Node};
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// 造一份「别的启动器已经铺好的」数据根(`<root>/.minecraft` + `<root>/.ULL`):
|
||||
///
|
||||
/// | 路径 | 用意 |
|
||||
/// | --- | --- |
|
||||
/// | `versions/1.20.1/` | 正常版本(清单 + jar),可登记、可 `launch --dry-run` |
|
||||
/// | `versions/1.20.1-orphan/` | 缺父版本 ⇒ 不可登记 |
|
||||
/// | `libraries/` `assets/` | 恒共享层(登记不该碰) |
|
||||
/// | `mods/` `saves/` | 玩家数据(登记不该碰) |
|
||||
/// | `launcher_profiles.json` | 别的启动器的元数据(连读都不该读,更不该写) |
|
||||
pub fn foreign_data_root(root: &Path) {
|
||||
let minecraft = root.join(".minecraft");
|
||||
let versions = minecraft.join("versions");
|
||||
write_version(&versions, "1.20.1", MANIFEST_OK, true);
|
||||
write_version(&versions, "1.20.1-orphan", MANIFEST_ORPHAN, true);
|
||||
write_file(
|
||||
&minecraft.join("libraries/com/mojang/brigadier/1.0.18/brigadier-1.0.18.jar"),
|
||||
b"library-bytes",
|
||||
);
|
||||
write_file(
|
||||
&minecraft.join("assets/indexes/5.json"),
|
||||
b"{\"objects\":{}}",
|
||||
);
|
||||
write_file(&minecraft.join("mods/jei.jar"), b"mod-bytes");
|
||||
write_file(&minecraft.join("saves/World/level.dat"), b"save-bytes");
|
||||
write_file(
|
||||
&minecraft.join("launcher_profiles.json"),
|
||||
br#"{"profiles":{"1.20.1":{"name":"1.20.1","lastVersionId":"1.20.1"}}}"#,
|
||||
);
|
||||
}
|
||||
|
||||
fn write_version(versions: &Path, id: &str, manifest: &str, jar: bool) {
|
||||
let dir = versions.join(id);
|
||||
std::fs::create_dir_all(&dir).expect("建版本目录");
|
||||
std::fs::write(dir.join(format!("{id}.json")), manifest.as_bytes()).expect("写清单");
|
||||
if jar {
|
||||
std::fs::write(dir.join(format!("{id}.jar")), b"junk").expect("写 jar");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, bytes: &[u8]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建父目录");
|
||||
}
|
||||
std::fs::write(path, bytes).expect("写文件");
|
||||
}
|
||||
|
||||
const MANIFEST_OK: &str = r#"{
|
||||
"id": "1.20.1",
|
||||
"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" } }
|
||||
}"#;
|
||||
|
||||
const MANIFEST_ORPHAN: &str = r#"{
|
||||
"id": "1.20.1-orphan",
|
||||
"type": "release",
|
||||
"inheritsFrom": "1.20.1-not-on-disk",
|
||||
"mainClass": "net.minecraft.client.main.Main",
|
||||
"libraries": []
|
||||
}"#;
|
||||
@@ -0,0 +1,257 @@
|
||||
//! 真机验收:起 `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};
|
||||
|
||||
#[path = "support/mod.rs"]
|
||||
mod support;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// 抓得住:真实会话(索引 + 事件流按**生产形状**落盘)经 MCP 走 `read_game_log` /
|
||||
/// `diagnose_crash` 时真读得到行,而不是「0 行 / 找不到事件流」。
|
||||
/// 抓不住:事件流内容本身对不对(`ports::session` / `ports::diagnose` 的用例守),
|
||||
/// 也抓不住 `ull agent` 之外的读取点(`ull logs` / GUI 由 M211 与 presenter 用例守)。
|
||||
#[test]
|
||||
fn mcp_reads_a_real_session_log_and_diagnoses_it() {
|
||||
let dir = data_dir("session-read");
|
||||
let sid = support::write_session(&dir);
|
||||
let mut session = Session::start(&dir);
|
||||
|
||||
session.rpc(
|
||||
1,
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {},
|
||||
"clientInfo": { "name": "ull-test", "version": "0.1" }
|
||||
}),
|
||||
);
|
||||
session.send(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }));
|
||||
|
||||
let listed = session.rpc(
|
||||
2,
|
||||
"tools/call",
|
||||
json!({ "name": "list_game_sessions", "arguments": {} }),
|
||||
);
|
||||
let text = listed["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
assert!(text.contains(&sid), "应列出该会话:{text}");
|
||||
|
||||
// 读日志:三行事件都在;只要 stderr 时正好那一行 OOM。
|
||||
let page = session.rpc(
|
||||
3,
|
||||
"tools/call",
|
||||
json!({ "name": "read_game_log", "arguments": { "session_id": sid, "kinds": ["stderr"] } }),
|
||||
);
|
||||
assert_ne!(page["result"]["isError"], json!(true), "{page}");
|
||||
let text = page["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
text.contains("\"total_events\":3"),
|
||||
"应读到全部 3 行事件:{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("OutOfMemoryError"),
|
||||
"应读到 stderr 原文:{text}"
|
||||
);
|
||||
|
||||
// 诊断:同一份 OOM 事件流要命中规则,而不是 needs_more_info。
|
||||
let report = session.rpc(
|
||||
4,
|
||||
"tools/call",
|
||||
json!({ "name": "diagnose_crash", "arguments": { "session_id": sid } }),
|
||||
);
|
||||
assert_ne!(report["result"]["isError"], json!(true), "{report}");
|
||||
let text = report["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
assert!(text.contains("\"needs_more_info\":false"), "{text}");
|
||||
assert!(
|
||||
!text.contains("\"candidates\":[]"),
|
||||
"应命中至少一条规则:{text}"
|
||||
);
|
||||
|
||||
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,114 @@
|
||||
//! M220:`ull launch` 真正 spawn 的 Java 必须是 D4 校验过的那个。
|
||||
//!
|
||||
//! 钉路径的**决策**是纯函数(`commands::java_launch::plan_java`,那边有五种来源的单测);
|
||||
//! 本文件补**端到端**:装配之后命令行里那个 java 也得是同一个。
|
||||
//!
|
||||
//! 手法:把 `JAVA_HOME` 指到一个**没有 `bin/java` 的空目录**,让「装配期按环境兜底」
|
||||
//! 与「D4 选中的候选」必然不同——兜底会退回裸名字 `java[.exe]`(相对名,靠 `PATH` 找),
|
||||
//! 而 D4 选中的是探出来的绝对路径。不动 `JAVA_HOME` 的话兜底恰好也落在 `JAVA_HOME`
|
||||
//! 那一个文件上,「只钉 Managed」的老实现也能蒙对,正是复审抓到的那个缺陷。
|
||||
//!
|
||||
//! 用真子进程(`CARGO_BIN_EXE_ull` + `Command::env`)而不是 `support::run_cli`:
|
||||
//! `JAVA_HOME` 要按进程注入,改本进程的环境会与同二进制里的其它用例抢。
|
||||
|
||||
#[path = "support/mod.rs"]
|
||||
mod support;
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use support::{parse_json, write_version, TempDir};
|
||||
|
||||
/// 一个可组装的版本 id:静态表要求 **Java 21+**。
|
||||
///
|
||||
/// 选 21+ 是有意的:「托管区里只有 17(距离 4)、环境/系统上有 21/25(距离 0)」,
|
||||
/// 按 D4 的版本距离优先,选中的必然是托管的**外面**那个——正是复审抓到的
|
||||
/// 「只有 managed 会钉路径」缺陷所在的那条分支。要求 17 的话托管 17 会赢,
|
||||
/// 老实现也能蒙对,用例就白跑了。
|
||||
const VERSION: &str = "1.21";
|
||||
|
||||
/// 跑一次 `ull --data-dir <root> <tail...>`,`JAVA_HOME` 注入成 `java_home`。
|
||||
///
|
||||
/// `json` 决定加不加全局 `--json`(加了就只有 JSON envelope,人读行被收起来)。
|
||||
fn cli_with_java_home(
|
||||
dir: &TempDir,
|
||||
java_home: &Path,
|
||||
json: bool,
|
||||
tail: &[&str],
|
||||
) -> (i32, String, String) {
|
||||
let mut args = vec!["--data-dir".to_string(), dir.arg()];
|
||||
if json {
|
||||
args.push("--json".to_string());
|
||||
}
|
||||
args.extend(tail.iter().map(|arg| arg.to_string()));
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_ull"))
|
||||
.args(&args)
|
||||
.env("JAVA_HOME", java_home)
|
||||
.output()
|
||||
.expect("应能起 ull");
|
||||
(
|
||||
output.status.code().unwrap_or(-1),
|
||||
String::from_utf8_lossy(&output.stdout).into_owned(),
|
||||
String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_line_runs_the_java_that_d4_validated() {
|
||||
let dir = TempDir::new("java-pin");
|
||||
write_version(dir.path(), VERSION);
|
||||
// 空 JAVA_HOME:`JAVA_HOME/bin/java` 不存在 → 环境兜底这条路走不通。
|
||||
let empty_home = dir.path().join("empty-java-home");
|
||||
std::fs::create_dir_all(&empty_home).expect("建空 JAVA_HOME");
|
||||
|
||||
let (code, stdout, stderr) =
|
||||
cli_with_java_home(&dir, &empty_home, true, &["launch", VERSION, "--dry-run"]);
|
||||
assert_eq!(code, 0, "stderr: {stderr}");
|
||||
let data = parse_json(&stdout)["data"].clone();
|
||||
let java = &data["java"];
|
||||
let source = java["source"].as_str().unwrap_or("none");
|
||||
assert!(
|
||||
["PATH", "system"].contains(&source),
|
||||
"前置:`JAVA_HOME` 已置空,且这条版本要 Java 21+(托管区的 17 不满足),\
|
||||
来源只该是 PATH 或标准安装位置:{data}"
|
||||
);
|
||||
let binary = java["binary"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("本机一个 Java 都探不到,这条端到端验不到东西(判红):{data}"));
|
||||
assert!(
|
||||
Path::new(binary).is_absolute(),
|
||||
"前置:探到的 Java 必须是绝对路径(否则「钉住」与「裸名字」无从区分,判红):{data}"
|
||||
);
|
||||
|
||||
// ① 装配落地的就是校验过的那个(`--json` 里并排给的两个事实必须相等)。
|
||||
assert_eq!(
|
||||
java["launch_binary"].as_str(),
|
||||
Some(binary),
|
||||
"装配落地的 java 必须是 D4 校验过的那个:{data}"
|
||||
);
|
||||
// ② 命令行里真正要跑的也是它 —— 这一条才是「spawn 的是哪个」。
|
||||
// 注意 `program` 会过脱敏(主目录 → `<home>`),所以按「绝对路径 + 文件名」判,
|
||||
// 不按整串判;退回裸 `java[.exe]` 的老实现在这里必红。
|
||||
let program = data["program"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
program.starts_with("<home>") || Path::new(program).is_absolute(),
|
||||
"命令行里的 java 必须是钉住的绝对路径(裸 `java` = 又退回装配期重选了):{data}"
|
||||
);
|
||||
assert_eq!(
|
||||
Path::new(program).file_name(),
|
||||
Path::new(binary).file_name(),
|
||||
"命令行里的 java 与 D4 校验的不是同一个:{data}"
|
||||
);
|
||||
// ③ 人读输出也要说清「实际用哪个、来源是什么」(终端里对着日志排查时只有一个依据)。
|
||||
let (code, stdout, stderr) =
|
||||
cli_with_java_home(&dir, &empty_home, false, &["launch", VERSION, "--dry-run"]);
|
||||
assert_eq!(code, 0, "stderr: {stderr}");
|
||||
assert!(
|
||||
stdout.contains(&format!("{source} · ")),
|
||||
"人读行要写出 Java 来源:{stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains(binary),
|
||||
"人读行要写出实际使用的 java 路径({binary}):{stdout}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
//! `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}"
|
||||
);
|
||||
// M220:`binary` 是 D4 校验过的那个,`launch_binary` 是装配落地的那个——两者都得给出来。
|
||||
assert!(
|
||||
java["launch_binary"].as_str().is_some(),
|
||||
"`--json` 的 java 块要写明实际装配的 java 路径:{json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 写一份「清单自己声明要 Java `required`」的版本 JSON(其余字段与 `support::write_version` 同)。
|
||||
///
|
||||
/// 本机任何一个 Java 都不满足 `required = 99` ⇒ D4 必然要拦(`java_too_old`),
|
||||
/// 与「这台机器装了哪几个 JDK」无关、也不需要联网:用例的观察点是**拦截发生在哪一步**。
|
||||
fn write_version_requiring_java(root: &std::path::Path, required: u32) {
|
||||
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" },
|
||||
"javaVersion": { "majorVersion": required },
|
||||
"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!("{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");
|
||||
}
|
||||
|
||||
/// M220 nit3:D4 的「Java 不符」拦截必须排在身份解析**之前**。
|
||||
///
|
||||
/// 身份解析有副作用(微软轨会联网刷新令牌、刷新成功还会回写凭据),一个注定要被拦下的
|
||||
/// 启动不该先跑那一遍。观察点:数据根里放两个离线账户 ⇒ 解析身份必然报 `account_ambiguous`,
|
||||
/// 而这次启动注定被 D4 拦下 ⇒ 报出来的必须是 `java_mismatch`。
|
||||
/// 老实现(拦在身份之后)在这里会先撞上 `account_ambiguous`,用例必红。
|
||||
///
|
||||
/// 对照组:`--dry-run` 本来就只提示不拦(D4 只管真启动),所以同一条版本在 dry-run 下
|
||||
/// 会走到身份解析那一步——这一对断言把「拦截在哪一步」钉死。
|
||||
#[test]
|
||||
fn the_java_mismatch_block_runs_before_identity_resolution() {
|
||||
let dir = TempDir::new("java-block-order");
|
||||
write_version_requiring_java(dir.path(), 99);
|
||||
for name in ["alpha", "beta"] {
|
||||
let (code, _, stderr) =
|
||||
run_cli(&["accounts", "add", "offline", name, "--data-dir", &dir.arg()]);
|
||||
assert_eq!(code, 0, "建离线账户失败:{stderr}");
|
||||
}
|
||||
|
||||
// 前置:这条版本的要求确实没人满足(否则「注定被拦」这个前提不成立,直接判红)。
|
||||
let (code, stdout, stderr) = run_cli(&[
|
||||
"--json",
|
||||
"--data-dir",
|
||||
&dir.arg(),
|
||||
"launch",
|
||||
VERSION,
|
||||
"--dry-run",
|
||||
"--account",
|
||||
"alpha",
|
||||
]);
|
||||
assert_eq!(code, 0, "stderr: {stderr}");
|
||||
let envelope = parse_json(&stdout);
|
||||
assert_eq!(
|
||||
envelope["data"]["java"]["mismatch"]["code"], "java_too_old",
|
||||
"前置:本机探测到的 Java 必须都不满足 Java 99:{envelope}"
|
||||
);
|
||||
|
||||
// 真启动(不指定账户):两个账户 ⇒ 解析身份会报 account_ambiguous,但 D4 拦得更早。
|
||||
let (code, stdout, _stderr) = run_cli(&["--json", "--data-dir", &dir.arg(), "launch", VERSION]);
|
||||
assert_eq!(code, 1);
|
||||
let envelope = parse_json(&stdout);
|
||||
assert_eq!(
|
||||
envelope["error"]["code"], "java_mismatch",
|
||||
"D4 拦截必须排在身份解析之前(否则注定被拦的启动也先刷了一遍令牌):{envelope}"
|
||||
);
|
||||
|
||||
// 对照:dry-run 不拦(D4 只管真启动)⇒ 它会走到身份解析,报的是账户歧义。
|
||||
let (code, stdout, _stderr) = run_cli(&[
|
||||
"--json",
|
||||
"--data-dir",
|
||||
&dir.arg(),
|
||||
"launch",
|
||||
VERSION,
|
||||
"--dry-run",
|
||||
]);
|
||||
assert_eq!(code, 1);
|
||||
let envelope = parse_json(&stdout);
|
||||
assert_eq!(
|
||||
envelope["error"]["code"], "account_ambiguous",
|
||||
"dry-run 不该被 D4 拦住(它只提示),应当走到身份解析:{envelope}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! `ull install --loader liteloader` 的**离线**可验面:词表分流、父版本缺失、help 文案。
|
||||
//!
|
||||
//! 这里刻意不联网:LiteLoader 的版本表在 `dl.liteloader.com`,把「拉到表之后」的链路放在
|
||||
//! `crates/core/tests/liteloader/**`(真实响应夹具 + 合成 + 计划 + 落盘 + 启动参数)。
|
||||
//! 本文件守的是「进门那一步」:`optifine` 与随机拼法的错误码必须分开、父版本没装必须具名报错、
|
||||
//! 非 LiteLoader 不许叠在别的加载器层上。
|
||||
|
||||
#[path = "support/mod.rs"]
|
||||
mod support;
|
||||
|
||||
use support::{parse_json, run_cli, TempDir};
|
||||
|
||||
#[test]
|
||||
fn optifine_is_recognized_but_not_supported() {
|
||||
let dir = TempDir::new("liteloader-optifine");
|
||||
let (code, stdout, _) = run_cli(&[
|
||||
"install",
|
||||
"1.20.1",
|
||||
"--loader",
|
||||
"optifine",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
&dir.arg(),
|
||||
]);
|
||||
assert_eq!(code, 1);
|
||||
let value = parse_json(&stdout);
|
||||
assert_eq!(
|
||||
value["error"]["code"], "loader_unsupported",
|
||||
"optifine 是「认得但装不了」,不是未知加载器:{stdout}"
|
||||
);
|
||||
assert!(
|
||||
value["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("optifine"),
|
||||
"{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `optifine` 之外,真正不认识的拼法仍是 `loader_unknown`(样本用**明显无效**的名字,
|
||||
/// 不拿任何真实加载器名当「未知」前提)。
|
||||
#[test]
|
||||
fn genuinely_unknown_loader_stays_unknown() {
|
||||
let dir = TempDir::new("liteloader-unknown");
|
||||
let (code, stdout, _) = run_cli(&[
|
||||
"install",
|
||||
"1.20.1",
|
||||
"--loader",
|
||||
"ull-invalid-loader-test",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
&dir.arg(),
|
||||
]);
|
||||
assert_eq!(code, 1);
|
||||
let value = parse_json(&stdout);
|
||||
assert_eq!(value["error"]["code"], "loader_unknown", "{stdout}");
|
||||
let hint = value["error"]["hint"].as_str().unwrap_or_default();
|
||||
assert!(hint.contains("liteloader"), "提示要列全六家:{hint}");
|
||||
}
|
||||
|
||||
/// 父版本(`1.12.2-forge-…`)没装时:具名报缺哪个父版本,且**不发网络请求**。
|
||||
#[test]
|
||||
fn liteloader_on_a_missing_forge_parent_names_the_parent() {
|
||||
let dir = TempDir::new("liteloader-missing-parent");
|
||||
let (code, stdout, _) = run_cli(&[
|
||||
"install",
|
||||
"1.12.2-forge-14.23.5.2860",
|
||||
"--loader",
|
||||
"liteloader",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
&dir.arg(),
|
||||
]);
|
||||
assert_eq!(code, 1);
|
||||
let value = parse_json(&stdout);
|
||||
assert_eq!(value["error"]["code"], "loader_not_found", "{stdout}");
|
||||
let message = value["error"]["message"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
message.contains("1.12.2-forge-14.23.5.2860"),
|
||||
"要点名缺哪个父版本:{message}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 只有 LiteLoader 接受「叠在别的加载器层上」;别家在进门处就说清(不静默改成叠原版)。
|
||||
#[test]
|
||||
fn only_liteloader_accepts_stacking_on_another_loader_layer() {
|
||||
let dir = TempDir::new("liteloader-stack-guard");
|
||||
let (code, stdout, _) = run_cli(&[
|
||||
"install",
|
||||
"1.12.2-forge-14.23.5.2860",
|
||||
"--loader",
|
||||
"fabric",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
&dir.arg(),
|
||||
]);
|
||||
assert_eq!(code, 1);
|
||||
let value = parse_json(&stdout);
|
||||
assert_eq!(value["error"]["code"], "usage", "{stdout}");
|
||||
let message = value["error"]["message"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
message.contains("LiteLoader") && message.contains("Fabric"),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_help_names_the_liteloader_route() {
|
||||
let (code, stdout, _) = run_cli(&["help", "install"]);
|
||||
assert_eq!(code, 0);
|
||||
for needle in [
|
||||
"liteloader",
|
||||
"optifine",
|
||||
"launchwrapper",
|
||||
"tweakClass",
|
||||
"loader_unsupported",
|
||||
"loader_unknown",
|
||||
] {
|
||||
assert!(
|
||||
stdout.contains(needle),
|
||||
"`ull help install` 少了 `{needle}`:\n{stdout}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn general_help_lists_liteloader_under_install() {
|
||||
let (code, stdout, _) = run_cli(&["--help"]);
|
||||
assert_eq!(code, 0);
|
||||
let install_block = stdout
|
||||
.lines()
|
||||
.skip_while(|line| !line.trim_start().starts_with("install <version>"))
|
||||
.take(3)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
install_block.contains("liteloader"),
|
||||
"总帮助的 install 段要提到 liteloader:\n{install_block}"
|
||||
);
|
||||
}
|
||||
@@ -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,297 @@
|
||||
//! `--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);
|
||||
// M82:自称的名字跟着被调用的二进制走(test harness 里是测试二进制名,`ull` 里是 `ull`);
|
||||
// argv[0] → 名字的映射由 cli::program 的单测钉死。
|
||||
assert_eq!(
|
||||
value["data"]["name"],
|
||||
unlucky_cli::cli::program::name(),
|
||||
"{stdout}"
|
||||
);
|
||||
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);
|
||||
// banner 用被调用的二进制名(M82),不写死 `ull`。
|
||||
let banner = format!("{} ", unlucky_cli::cli::program::name());
|
||||
assert!(stdout.starts_with(&banner), "{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");
|
||||
// 样本必须是一个**任何词表里都没有**的拼法:`liteloader` 自 M222 起是受支持加载器,
|
||||
// 拿它当「未知」会让这条用例变成真去拉 dl.liteloader.com 的联网测试。
|
||||
let (code, stdout, _) = run_cli(&[
|
||||
"install",
|
||||
"1.20.1",
|
||||
"--loader",
|
||||
"ull-invalid-loader-test",
|
||||
"--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,303 @@
|
||||
//! `ull pack import` 的三态契约:退出码 0 / 3 / 1 + `--json` 信封恒有结构。
|
||||
//!
|
||||
//! **全部离线、全部不依赖 zip**:
|
||||
//!
|
||||
//! - MultiMC / Prism 的真实形态就是**目录**,直接拷 → `0`;
|
||||
//! - mrpack 也能以**目录**打开(`PackArchive` 对目录与 zip 一视同仁),造一条 `downloads` 为空的
|
||||
//! `files[]` 条目 ⇒ 补不出来 ⇒ `3`,不会发任何请求;
|
||||
//! - 坏包用**一个不是 zip 的普通文件** ⇒ 开包即拒 ⇒ `1`(具体的恶意样本分类在 core 的
|
||||
//! `addon/tests/import_failures.rs`,这里只钉 CLI 的退出码与 JSON 契约)。
|
||||
|
||||
#[path = "support/mod.rs"]
|
||||
mod support;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::Value;
|
||||
use support::{run_cli, TempDir};
|
||||
|
||||
/// 写一个文件(自动建父目录)。
|
||||
fn write_file(path: &Path, bytes: &[u8]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建目录");
|
||||
}
|
||||
std::fs::write(path, bytes).expect("写文件");
|
||||
}
|
||||
|
||||
/// 造一个最小 MultiMC / Prism 实例目录。
|
||||
fn multimc_instance(root: &Path) -> PathBuf {
|
||||
let instance = root.join("mmc");
|
||||
write_file(
|
||||
&instance.join("mmc-pack.json"),
|
||||
br#"{"formatVersion": 1, "components": [
|
||||
{"uid": "net.minecraft", "version": "1.20.1"},
|
||||
{"uid": "net.fabricmc.fabric-loader", "version": "0.15.11"},
|
||||
{"uid": "org.lwjgl3", "version": "3.3.2"}]}"#,
|
||||
);
|
||||
write_file(
|
||||
&instance.join("instance.cfg"),
|
||||
b"InstanceType=OneSix\nname=cli pack\n",
|
||||
);
|
||||
write_file(&instance.join(".minecraft/mods/a.jar"), b"jar bytes");
|
||||
instance
|
||||
}
|
||||
|
||||
/// 造一个目录形式的 mrpack:一条远端条目,但 `downloads` 为空(补不出来 ⇒ 缺文件)。
|
||||
fn mrpack_directory(root: &Path) -> PathBuf {
|
||||
let source = root.join("mr-src");
|
||||
write_file(
|
||||
&source.join("modrinth.index.json"),
|
||||
br#"{"game": "minecraft", "formatVersion": 1, "versionId": "1.0.0",
|
||||
"name": "cli mrpack", "summary": "dir-form mrpack",
|
||||
"files": [{"path": "mods/ghost.jar",
|
||||
"hashes": {"sha1": "0123456789abcdef0123456789abcdef01234567",
|
||||
"sha512": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"},
|
||||
"env": {"client": "required", "server": "required"},
|
||||
"downloads": [], "fileSize": 11}],
|
||||
"dependencies": {"minecraft": "1.20.1", "fabric-loader": "0.15.11"}}"#,
|
||||
);
|
||||
write_file(&source.join("overrides/config/known.toml"), b"k = 1\n");
|
||||
source
|
||||
}
|
||||
|
||||
fn parse_json(stdout: &str) -> Value {
|
||||
serde_json::from_str(stdout)
|
||||
.unwrap_or_else(|error| panic!("stdout 不是合法 JSON({error}):{stdout}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_a_multimc_instance_and_exits_zero() {
|
||||
let data = TempDir::new("pack-ok");
|
||||
let source = multimc_instance(data.path());
|
||||
let target = data.path().join("inst");
|
||||
let (code, stdout, stderr) = run_cli(&[
|
||||
"pack",
|
||||
"import",
|
||||
&source.display().to_string(),
|
||||
"--target",
|
||||
&target.display().to_string(),
|
||||
"--data-dir",
|
||||
&data.arg(),
|
||||
"--no-register",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
assert_eq!(code, 0, "stdout={stdout} stderr={stderr}");
|
||||
let value = parse_json(&stdout);
|
||||
assert_eq!(value["ok"], Value::Bool(true));
|
||||
assert_eq!(value["data"]["status"], Value::String("success".into()));
|
||||
assert_eq!(value["data"]["format"], Value::String("multimc".into()));
|
||||
assert_eq!(
|
||||
value["data"]["loader"]["kind"],
|
||||
Value::String("fabric".into())
|
||||
);
|
||||
// 走过的阶段都记下来了。
|
||||
let stages = value["data"]["stages"].as_array().expect("stages 数组");
|
||||
assert!(stages.iter().any(|stage| stage == "probe"), "{stages:?}");
|
||||
assert!(stages.iter().any(|stage| stage == "commit"), "{stages:?}");
|
||||
// 内容真的落地了。
|
||||
assert_eq!(
|
||||
std::fs::read(target.join("mods/a.jar")).expect("a.jar"),
|
||||
b"jar bytes"
|
||||
);
|
||||
// 未支持的 component 逐项进 JSON。
|
||||
assert_eq!(
|
||||
value["data"]["rejected_components"][0]["uid"],
|
||||
Value::String("org.lwjgl3".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_files_make_it_partial_and_exit_three() {
|
||||
let data = TempDir::new("pack-partial");
|
||||
let source = mrpack_directory(data.path());
|
||||
let target = data.path().join("inst");
|
||||
let (code, stdout, stderr) = run_cli(&[
|
||||
"pack",
|
||||
"import",
|
||||
&source.display().to_string(),
|
||||
"--target",
|
||||
&target.display().to_string(),
|
||||
"--data-dir",
|
||||
&data.arg(),
|
||||
"--no-register",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
code, 3,
|
||||
"部分成功是 3,不是 0/1:stdout={stdout} stderr={stderr}"
|
||||
);
|
||||
let value = parse_json(&stdout);
|
||||
assert_eq!(value["data"]["status"], Value::String("partial".into()));
|
||||
assert_eq!(
|
||||
value["data"]["missing"].as_array().map(Vec::len),
|
||||
Some(1),
|
||||
"补不出来的条目要如实记缺:{stdout}"
|
||||
);
|
||||
// 覆盖内容照装。
|
||||
assert!(target.join("config/known.toml").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_import_exits_one_and_still_emits_structured_json() {
|
||||
let data = TempDir::new("pack-fail");
|
||||
let pack = data.path().join("broken.zip");
|
||||
// 一个根本不是 zip 的文件:开包即拒。
|
||||
write_file(&pack, b"this is not a zip archive");
|
||||
|
||||
let target = data.path().join("inst");
|
||||
let (code, stdout, stderr) = run_cli(&[
|
||||
"pack",
|
||||
"import",
|
||||
&pack.display().to_string(),
|
||||
"--target",
|
||||
&target.display().to_string(),
|
||||
"--data-dir",
|
||||
&data.arg(),
|
||||
"--no-register",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
assert_eq!(code, 1, "stdout={stdout} stderr={stderr}");
|
||||
// 失败也**必须**是结构化 JSON(不抛栈、不打人类可读文本到 stdout)。
|
||||
let value = parse_json(&stdout);
|
||||
assert_eq!(value["ok"], Value::Bool(true));
|
||||
assert_eq!(value["data"]["status"], Value::String("failed".into()));
|
||||
assert!(
|
||||
value["data"]["error"]["code"].is_string(),
|
||||
"失败要带稳定码:{stdout}"
|
||||
);
|
||||
assert_eq!(value["data"]["rolled_back"], Value::Bool(true));
|
||||
// 零残留。
|
||||
assert!(!target.exists(), "致命失败后目标目录必须不存在");
|
||||
// 失败也不是"崩了":stderr 里不该出现 panic 之类的栈信息。
|
||||
assert!(!stderr.contains("panicked"), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_commands_still_exit_zero() {
|
||||
// `Outcome::exit_code` 是给 pack import 用的;其它命令的行为不许变。
|
||||
let data = TempDir::new("pack-regression");
|
||||
let (code, stdout, _) = run_cli(&["instances", "list", "--data-dir", &data.arg()]);
|
||||
assert_eq!(code, 0, "{stdout}");
|
||||
let (code, _, _) = run_cli(&["--help"]);
|
||||
assert_eq!(code, 0);
|
||||
let (code, _, _) = run_cli(&["config", "get", "--data-dir", &data.arg()]);
|
||||
assert_eq!(code, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_lists_the_pack_command() {
|
||||
let (code, stdout, _) = run_cli(&["help", "pack"]);
|
||||
assert_eq!(code, 0);
|
||||
assert!(stdout.contains("pack import"), "{stdout}");
|
||||
assert!(stdout.contains("部分成功"), "{stdout}");
|
||||
let (code, stdout, _) = run_cli(&["--help"]);
|
||||
assert_eq!(code, 0);
|
||||
assert!(stdout.contains("pack import"), "总帮助里也要有:{stdout}");
|
||||
}
|
||||
|
||||
/// **M216 P3-3 + §5**:不带 `--target` 时落点是**新实例目录**(不是共享 `.minecraft` 根),
|
||||
/// 而且登记出来的实例被绑到那个目录(`isolation=custom` + `gameDir`),否则导入内容永远不会
|
||||
/// 被启动用到。
|
||||
///
|
||||
/// 抓得住:CLI 自己兜底回 `ctx.game().root()`(第一条断言会变成 `.minecraft`);不建实例目录而
|
||||
/// 直接铺共享根;不做绑定(`isolation` 仍是 `null`);绑定写错目录。抓不住:真实启动那一条
|
||||
/// `derive_game_dir` 的派生(core 的 `isolation.rs` 用例钉,这里只断言设置文件的内容)。
|
||||
#[test]
|
||||
fn the_default_target_is_a_new_instance_dir_and_the_instance_is_bound_to_it() {
|
||||
let data = TempDir::new("pack-default-target");
|
||||
let source = multimc_instance(data.path());
|
||||
let (code, stdout, stderr) = run_cli(&[
|
||||
"pack",
|
||||
"import",
|
||||
&source.display().to_string(),
|
||||
"--name",
|
||||
"My Pack",
|
||||
"--data-dir",
|
||||
&data.arg(),
|
||||
"--json",
|
||||
]);
|
||||
assert_eq!(code, 0, "stdout={stdout} stderr={stderr}");
|
||||
|
||||
// **M216 r2**:绑定成败也进 `--json`(结构化,不必猜文案)。
|
||||
let value = parse_json(&stdout);
|
||||
assert_eq!(
|
||||
value["data"]["instance_bound"],
|
||||
Value::Bool(true),
|
||||
"缺省导入应当把实例绑到内容目录并报 true:{stdout}"
|
||||
);
|
||||
|
||||
let instance_dir = data.path().join(".ULL/data/instances/My-Pack");
|
||||
assert!(
|
||||
instance_dir.join("mods/a.jar").is_file(),
|
||||
"缺省落点应当是 <data>/instances/My-Pack:{stdout}"
|
||||
);
|
||||
assert!(
|
||||
!data.path().join(".minecraft/mods/a.jar").exists(),
|
||||
"不许再往共享 .minecraft 根铺:{stdout}"
|
||||
);
|
||||
|
||||
// 登记的实例被绑到这个目录(§5)。
|
||||
let profiles: Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(data.path().join(".ULL/config/profiles.json"))
|
||||
.expect("profiles.json"),
|
||||
)
|
||||
.expect("profiles.json 是 JSON");
|
||||
assert_eq!(
|
||||
profiles["profiles"][0]["name"],
|
||||
Value::String("My Pack".into())
|
||||
);
|
||||
let id = profiles["profiles"][0]["id"]
|
||||
.as_str()
|
||||
.expect("登记出来的实例有 id")
|
||||
.to_string();
|
||||
let config: Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(
|
||||
data.path()
|
||||
.join(".ULL/config/instances")
|
||||
.join(format!("{id}.json")),
|
||||
)
|
||||
.expect("实例设置文件"),
|
||||
)
|
||||
.expect("实例设置是 JSON");
|
||||
assert_eq!(
|
||||
config["isolation"],
|
||||
Value::String("custom".into()),
|
||||
"isolation 应当是 custom:{config}"
|
||||
);
|
||||
assert_eq!(
|
||||
config["gameDir"].as_str().map(Path::new),
|
||||
Some(instance_dir.as_path()),
|
||||
"gameDir 应当指向导入目录:{config}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **M216 P2-1 / r2**:`--help` 里的缺省落点必须是**真路径**。
|
||||
///
|
||||
/// 「数据根」在这份帮助自己的定义里是「含 `.ULL/` 与 `.minecraft/` 的父目录」(`--data-dir` 那行),
|
||||
/// 所以实例目录的真路径是 `<数据根>/.ULL/data/instances/<目录名>`。r1 写成了
|
||||
/// `<数据根>/instances/<实例名>` —— 少一层 `.ULL/data`,照读会去一个不存在的位置找包内容。
|
||||
///
|
||||
/// 抓得住:缺 `.ULL/data` 这一层(下面第二条断言就是抓它的——少写时该子串恰好出现);
|
||||
/// 文案退回「共享游戏目录」;目录名归一化这件事没提。抓不住:`model.rs` 的字段文档
|
||||
/// (编译期文档,测试读不到),以及真实 `--data-dir` 下的绝对路径拼接(那由上面那条端到端
|
||||
/// 用例按真目录断言)。
|
||||
#[test]
|
||||
fn help_describes_the_new_default_target_with_the_real_path() {
|
||||
let (code, stdout, _) = run_cli(&["help", "pack"]);
|
||||
assert_eq!(code, 0);
|
||||
assert!(
|
||||
stdout.contains("<数据根>/.ULL/data/instances/<实例目录名>"),
|
||||
"帮助里没写真路径 <数据根>/.ULL/data/instances/<实例目录名>:{stdout}"
|
||||
);
|
||||
assert!(
|
||||
!stdout.contains("<数据根>/instances/"),
|
||||
"帮助里的落点少了 `.ULL/data` 这一层(r2 抓的就是这个错法):{stdout}"
|
||||
);
|
||||
assert!(
|
||||
!stdout.contains("缺省 = 数据根的共享游戏目录"),
|
||||
"帮助里还留着旧缺省落点:{stdout}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
//! 子命令解析:手写解析器的语法契约。
|
||||
|
||||
#[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 update_subcommands() {
|
||||
use unlucky_cli::cli::{UpdateApplyArgs, UpdateCheckArgs};
|
||||
|
||||
assert_eq!(
|
||||
invoke(&["update", "check"]).command,
|
||||
Command::UpdateCheck(UpdateCheckArgs {})
|
||||
);
|
||||
assert_eq!(
|
||||
invoke(&["update", "apply"]).command,
|
||||
Command::UpdateApply(UpdateApplyArgs { dest: None })
|
||||
);
|
||||
// `--dest` 两种写法都要认(钉替换目标 = 取证 / 自定义安装位置)
|
||||
for args in [
|
||||
vec!["update", "apply", "--dest", "D:/tmp/ull.exe"],
|
||||
vec!["update", "apply", "--dest=D:/tmp/ull.exe"],
|
||||
] {
|
||||
assert_eq!(
|
||||
invoke(&args).command,
|
||||
Command::UpdateApply(UpdateApplyArgs {
|
||||
dest: Some(std::path::PathBuf::from("D:/tmp/ull.exe")),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `update` 的用法错误:没子命令 / 未知子命令 / 多余位置参数 / 未知选项——四条都是 `usage`
|
||||
/// (退出码 1),不许静默退化成某个默认动作。
|
||||
#[test]
|
||||
fn update_usage_errors() {
|
||||
for args in [
|
||||
vec!["update"],
|
||||
vec!["update", "install"],
|
||||
vec!["update", "check", "extra"],
|
||||
vec!["update", "apply", "extra"],
|
||||
vec!["update", "apply", "--dest"],
|
||||
vec!["update", "apply", "--nope"],
|
||||
] {
|
||||
let owned: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
|
||||
let error = parse(&owned).expect_err(&format!("{args:?} 应当报用法错误"));
|
||||
assert_eq!(error.kind(), ErrorKind::User, "{args:?}");
|
||||
assert_eq!(error.code(), "usage", "{args:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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",
|
||||
"update",
|
||||
] {
|
||||
assert!(
|
||||
unlucky_cli::cli::help::topic(topic).is_some(),
|
||||
"缺少 {topic} 的帮助"
|
||||
);
|
||||
}
|
||||
assert!(unlucky_cli::cli::help::topic("nope").is_none());
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! 集成测试共用夹具:临时数据根、命令执行器、最小版本 JSON / 会话索引。
|
||||
//!
|
||||
//! 每个测试目标都会整份编译本文件但只用其中一部分,因此这里放开 dead_code。
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod update_e2e;
|
||||
pub mod update_e2e_sign;
|
||||
|
||||
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 {
|
||||
Self::in_root(&std::env::temp_dir(), tag).expect("创建临时目录")
|
||||
}
|
||||
|
||||
/// 建在**另一块卷**上(跨卷验收用);本机只有一块可写卷时返回 `None`。
|
||||
pub fn on_other_volume(tag: &str) -> Option<Self> {
|
||||
other_volume_root().and_then(|root| Self::in_root(&root, tag))
|
||||
}
|
||||
|
||||
fn in_root(root: &Path, tag: &str) -> Option<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 = root.join(format!(
|
||||
"ull-cli-test-{tag}-{}-{seq}-{nanos}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&path).ok()?;
|
||||
Some(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 指定异卷根目录的环境变量(本机找不到第二块卷时,用它显式指一块)。
|
||||
pub const XDEV_ROOT_ENV: &str = "ULL_XDEV_TEST_ROOT";
|
||||
|
||||
/// 「我知道本机没有第二块卷」的显式豁免(值必须是 `1`)。
|
||||
///
|
||||
/// 与 `crates/core/src/update/tests/support.rs` 的同名常量是**同一条政策**(源头在
|
||||
/// `crates/core/src/update/tests/install_xdev_cases.rs::xdev_guard`,改一处要改两处):
|
||||
/// 探不到第二块卷又不豁免 ⇒ 这条 e2e 必须红,不许静静 `return`(M171 r2 P2-1)。
|
||||
pub const XDEV_WAIVE_ENV: &str = "ULL_XDEV_TEST_WAIVE";
|
||||
|
||||
/// 「本机/本作业必须有第二块卷」的显式声明(值必须是 `1`),优先于豁免。
|
||||
pub const XDEV_REQUIRE_ENV: &str = "ULL_XDEV_TEST_REQUIRE";
|
||||
|
||||
/// 跨卷 e2e 探不到第二块卷时的裁决:**显式豁免**才允许跳过(打印一行记录),否则 panic。
|
||||
///
|
||||
/// `what` 只用来在输出里点名是哪条用例,裁决规则与 core 侧完全一致(见 [`XDEV_WAIVE_ENV`])。
|
||||
pub fn cross_volume_waived(what: &str) {
|
||||
let required = std::env::var(XDEV_REQUIRE_ENV).ok();
|
||||
let waived = std::env::var(XDEV_WAIVE_ENV).ok();
|
||||
let required = required.as_deref().map(str::trim) == Some("1");
|
||||
let waived = waived.as_deref().map(str::trim) == Some("1");
|
||||
assert!(
|
||||
waived && !required,
|
||||
"[xdev-guard] {what}:本机没有第二块可写卷,也没有显式豁免——跨卷 e2e 空跑了,\
|
||||
所以判红。三种合规做法任选其一:\
|
||||
① ULL_XDEV_TEST_ROOT=<另一块卷上的可写目录>;② ULL_XDEV_TEST_WAIVE=1(值必须是 1);\
|
||||
③ 认为本机本该有第二块卷就用 ULL_XDEV_TEST_REQUIRE=1 声明。\
|
||||
政策与理由见 crates/core/src/update/tests/install_xdev_cases.rs 的模块文档。"
|
||||
);
|
||||
eprintln!(
|
||||
"[xdev-guard] {what}:本机没有第二块可写卷,已显式豁免({XDEV_WAIVE_ENV}=1),\
|
||||
这条 e2e 跳过。"
|
||||
);
|
||||
}
|
||||
|
||||
/// 一块与会话临时目录**真的**不同卷的可写根目录(探出来的,不是按平台猜的)。
|
||||
///
|
||||
/// 候选:`ULL_XDEV_TEST_ROOT` → Windows 各盘符的 `Temp`(没有就盘根)/ Unix 的 `TMPDIR`、
|
||||
/// `/dev/shm`、`/tmp`、`/run/user/<uid>`。每个候选都要过「从 temp 目录 rename 进它必须报
|
||||
/// `CrossesDevices`」这一关,所以返回值**一定**是异卷——同一块卷上的候选会被跳过,
|
||||
/// 否则跨卷用例会在同卷上跑,等于什么都没验。
|
||||
pub fn other_volume_root() -> Option<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(root) = std::env::var_os(XDEV_ROOT_ENV) {
|
||||
candidates.push(PathBuf::from(root));
|
||||
}
|
||||
candidates.extend(volume_candidates());
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|candidate| is_other_volume(candidate))
|
||||
}
|
||||
|
||||
fn volume_candidates() -> Vec<PathBuf> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
('A'..='Z')
|
||||
.map(|letter| PathBuf::from(format!("{letter}:/")))
|
||||
.filter(|root| root.is_dir())
|
||||
.map(|root| {
|
||||
if root.join("Temp").is_dir() {
|
||||
root.join("Temp")
|
||||
} else {
|
||||
root
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let mut candidates: Vec<PathBuf> = ["TMPDIR", "TMP", "TEMP"]
|
||||
.iter()
|
||||
.filter_map(|key| std::env::var_os(key).map(PathBuf::from))
|
||||
.collect();
|
||||
for root in ["/dev/shm", "/tmp", "/var/tmp"] {
|
||||
candidates.push(PathBuf::from(root));
|
||||
}
|
||||
if let Some(uid) = std::env::var_os("UID") {
|
||||
candidates.push(PathBuf::from("/run/user").join(uid));
|
||||
}
|
||||
candidates
|
||||
}
|
||||
}
|
||||
|
||||
fn is_other_volume(dir: &Path) -> bool {
|
||||
if dir.as_os_str().is_empty() || std::fs::create_dir_all(dir).is_err() {
|
||||
return false;
|
||||
}
|
||||
let probe = std::env::temp_dir().join(format!("ull-cli-probe-{}", std::process::id()));
|
||||
if std::fs::write(&probe, b"probe").is_err() {
|
||||
return false;
|
||||
}
|
||||
let landed = dir.join(format!("ull-cli-probe-{}", std::process::id()));
|
||||
let cross = matches!(
|
||||
std::fs::rename(&probe, &landed),
|
||||
Err(ref error) if error.kind() == std::io::ErrorKind::CrossesDevices
|
||||
);
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
let _ = std::fs::remove_file(&landed);
|
||||
cross
|
||||
}
|
||||
|
||||
/// 跑一次 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。
|
||||
///
|
||||
/// **形状必须与生产写入一致**(M211/D2):`log_path` 是**相对会话根**的 `game/<sid>.jsonl`,
|
||||
/// 事件流真落点是 `<会话根>/game/<sid>.jsonl`。这里用的是旧家(`data/agent/sessions`)作会话根
|
||||
/// ——那是 M188 之前的生产落点,也是用户盘上存量会话的真实形状,读取侧必须还能读回它们。
|
||||
/// 夹具此前写的是不带 `game/` 的 `"{sid}.jsonl"`,于是「读取侧多 join 一层 `game/`」这个 bug
|
||||
/// 在测试里永远看不见:夹具自己说了另一种形状。
|
||||
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": unlucky_core::launch::session_index::log_path_for(sid),
|
||||
"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()
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! 更新通道端到端验收的夹具(M169):临时现场、真形状清单、测试通道的环境变量守卫。
|
||||
//!
|
||||
//! 两个测试目标共用(`update_e2e_test` 正路 / 幂等,`update_e2e_tamper_test` 篡改);每个目标只
|
||||
//! 用到其中一部分,所以与 `support/mod.rs` 一样放开 dead_code。
|
||||
//!
|
||||
//! 整个夹具只在 `update-test-source` feature 下真的被用到:那两个测试目标自己 `#![cfg]` 掉,
|
||||
//! 这里只是跟着编译(不跑)。判据(feature + 两档环境变量)在 `core::update::job`。
|
||||
//!
|
||||
//! # 为什么这套「真形状」只在 Windows 上成立(r1 复审 P2-4)
|
||||
//!
|
||||
//! 产物名与形态按 **Windows** 的真实发布走:`unlucky-launcher_<ver>_windows_x64.exe` +
|
||||
//! `portable-exe`(口径见 `packaging/make_artifacts.py` 的头注释)。Linux/macOS 的真实产物是
|
||||
//! `.tar.gz` + `portable-zip`,会被 `ensure_self_replaceable` **直接拒**(不是可原地自替换的形态)。
|
||||
//!
|
||||
//! * 在 Windows 上跑 = 验真实形状;
|
||||
//! * 在别的平台上跑绿只能证明「一个世上不存在的 linux/macOS portable-exe 能装上」——**假绿**。
|
||||
//!
|
||||
//! 所以 CI 里它是**一个独立的 Windows job**(`.github/workflows/ci.yml` 的 `update-e2e`,
|
||||
//! `runs-on: windows-latest`),本文件也**不**为非 Windows 平台改断言:要真支持,得让产物名与
|
||||
//! `format` 一起按平台分叉,那是另一个 mission 的事(真实 Linux 自更新根本不支持,D50 只发裸 exe)。
|
||||
//!
|
||||
//! ⚠ **验收必须给 `--dest`**:用例在**进程内**跑 `unlucky_cli::run`,`Job::for_process` 的
|
||||
//! 「本进程的 exe」就是测试二进制——`apply` 不带 `--dest` 会真的把它 `rename` 成新字节
|
||||
//! (Windows 允许改名正在运行的映像,见 `core::update::install` 的文档),整轮测试当场崩。
|
||||
//! `Fixture::run` 里有结构性断言兜这一条(r1 复审 P2-5),不是只靠这段注释。
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::update_e2e_sign::write_signature;
|
||||
use unlucky_core::update::{HashAlgo, OsFamily, Target, UPDATE_DIR_ENV, UPDATE_TEST_SOURCE_ENV};
|
||||
|
||||
/// 假装的「已安装版本」字节:与产物**必须不同**,否则「目标没被动过」这类断言会假绿。
|
||||
pub const INSTALLED: &[u8] = b"installed 0.2.3 build (pretend)";
|
||||
|
||||
/// 产物体量上限(1 MiB)。
|
||||
///
|
||||
/// 现场构建出来的 `ull.exe` 是 **~400 MB**(debug + 全套 reqwest/rmcp/axum);拿整份当过测材,
|
||||
/// 光 sha256 + 拷贝就要几十秒,验收会变成「谁都不想跑」。所以截取**前 1 MiB**:PE 头与头几节都在,
|
||||
/// 「这是真 exe 的字节」这一点保留,代价是「装上去能不能启动」不再由它证明——那一跳本来就要人工
|
||||
/// (见报告「不验的部分」)。清单里的 `size` / `sha256` 按这份截短后的真实字节算,仍然自洽。
|
||||
const PAYLOAD_LIMIT: usize = 1024 * 1024;
|
||||
|
||||
/// 清单里的更新说明(断言它会原样出现在 `check --json` 里)。
|
||||
const NOTES: &str = "修掉启动崩溃;实例页加载更快";
|
||||
|
||||
/// SHA-256 按真实字节算(与双检同一条实现:`core::download::verify::digest_bytes`)。
|
||||
pub fn sha256(bytes: &[u8]) -> String {
|
||||
unlucky_core::download::verify::digest_bytes(HashAlgo::Sha256, bytes)
|
||||
}
|
||||
|
||||
/// 清单版本:**比编译期版本严格大一档次版本**。
|
||||
///
|
||||
/// 不能写死 `0.2.4`:workspace 版本一升,用例就变成「已是最新」的假红。次版本 +1 恒大于当前
|
||||
/// 版本,且仍在同一 major 内(不会意外触发 `min_supported` 那条判据)。
|
||||
pub fn next_version(current: &str) -> String {
|
||||
let mut parts = current.split('.');
|
||||
let major = parts.next().unwrap_or("0");
|
||||
let minor: u64 = parts
|
||||
.next()
|
||||
.unwrap_or("0")
|
||||
.parse()
|
||||
.unwrap_or_else(|_| panic!("编译期版本号应是三段式:{current}"));
|
||||
assert!(parts.next().is_some(), "编译期版本号应是三段式:{current}");
|
||||
format!("{major}.{}.0", minor + 1)
|
||||
}
|
||||
|
||||
/// 真实产物命名(D49):`unlucky-launcher_<ver>_windows_x64.exe`。
|
||||
///
|
||||
/// 拿 `Target::as_str()`(`win-x64`)直接拼会得到一个世上不存在的文件名,清单那一栏也就
|
||||
/// 与真实发布对不上——「真形状」的意义正在这里。
|
||||
fn asset_name(version: &str) -> String {
|
||||
let target = Target::current();
|
||||
let os = match target.os {
|
||||
OsFamily::Windows => "windows",
|
||||
OsFamily::MacOs => "macos",
|
||||
OsFamily::Linux => "linux",
|
||||
};
|
||||
format!(
|
||||
"unlucky-launcher_{version}_{os}_{}.exe",
|
||||
target.arch.as_str()
|
||||
)
|
||||
}
|
||||
|
||||
/// 一份与 `packaging/tests/manifest.golden.json` **同字段**的清单。
|
||||
///
|
||||
/// 字段一个不多一个不少:core 的 `deny_unknown_fields` 会把多余字段当清单损坏拒掉,所以这条
|
||||
/// 同时也是「清单形状与真实生成器(`packaging/make_manifest.py`)对齐」的断言。
|
||||
fn manifest(version: &str, asset: &str, size: u64, sha256: &str) -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
"channel": "stable",
|
||||
"releases": [
|
||||
{{
|
||||
"version": "{version}",
|
||||
"channel": "stable",
|
||||
"released_at": "2026-01-02T03:04:05Z",
|
||||
"notes": "{NOTES}",
|
||||
"force": false,
|
||||
"min_supported": "0.1.0",
|
||||
"artifacts": [
|
||||
{{
|
||||
"target": "{}",
|
||||
"format": "portable-exe",
|
||||
"url": "{asset}",
|
||||
"size": {size},
|
||||
"sha256": "{sha256}"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}}"#,
|
||||
Target::current().as_str()
|
||||
)
|
||||
}
|
||||
|
||||
/// 进程环境是共享的(同一测试进程里各用例并行):动环境变量的用例都拿这把锁串起来。
|
||||
pub fn channel_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
/// 两个测试通道变量的现场:构造时记住原值,`Drop` 还原(用例 panic 也还原)。
|
||||
pub struct Channel {
|
||||
dir: Option<std::ffi::OsString>,
|
||||
ack: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub fn capture() -> Self {
|
||||
Self {
|
||||
dir: std::env::var_os(UPDATE_DIR_ENV),
|
||||
ack: std::env::var_os(UPDATE_TEST_SOURCE_ENV),
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试通道开启:两档环境变量全给(二进制还得带 `update-test-source` feature;只给路径不会被
|
||||
/// 认,判据见 `core::update::job`)。
|
||||
pub fn enable(root: &Path) {
|
||||
std::env::set_var(UPDATE_DIR_ENV, root);
|
||||
std::env::set_var(UPDATE_TEST_SOURCE_ENV, "1");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Channel {
|
||||
fn drop(&mut self) {
|
||||
for (key, value) in [
|
||||
(UPDATE_DIR_ENV, self.dir.clone()),
|
||||
(UPDATE_TEST_SOURCE_ENV, self.ack.clone()),
|
||||
] {
|
||||
match value {
|
||||
Some(value) => std::env::set_var(key, value),
|
||||
None => std::env::remove_var(key),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--json` 信封 → 命令载荷。
|
||||
///
|
||||
/// 成功信封是 `{"ok":true,"data":{…},"warnings":[…]}`,失败信封是 `{"ok":false,"error":{…}}`
|
||||
/// (没有 `data`)。所以「取载荷」= 有 `data` 取 `data`,否则原样返回——失败用例照样能看
|
||||
/// `error.code` / `error.detail.kind`。
|
||||
pub fn payload(json: &serde_json::Value) -> &serde_json::Value {
|
||||
json.get("data").unwrap_or(json)
|
||||
}
|
||||
|
||||
/// 目录里的文件名(读不到就当空)。
|
||||
fn list_dir(dir: &Path) -> Vec<String> {
|
||||
match std::fs::read_dir(dir) {
|
||||
Ok(entries) => entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
||||
.collect(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次验收的全部现场:
|
||||
///
|
||||
/// ```text
|
||||
/// <temp>/<tag>-data/ --data-dir(自定义模式 ⇒ <data>/.ULL 与 <data>/.minecraft)
|
||||
/// <temp>/<tag>-dist/ ULL_UPDATE_DIR(stable.json + 产物)
|
||||
/// <temp>/<tag>-install/ --dest 所在目录(**临时副本**,不是本进程的 exe)
|
||||
/// ```
|
||||
///
|
||||
/// 三个 `TempDir` 都要活到用例结束(`Fixture` 持有它们)。
|
||||
pub struct Fixture {
|
||||
pub data: super::TempDir,
|
||||
pub dist: super::TempDir,
|
||||
install: super::TempDir,
|
||||
/// 产物字节 = 现场构建出来的 `ull.exe` 的前 1 MiB(真 PE 头,理由见 [`PAYLOAD_LIMIT`])。
|
||||
pub payload: Vec<u8>,
|
||||
/// 清单里的版本号(= 编译期版本次版本 +1)。
|
||||
pub version: String,
|
||||
/// 产物文件名(清单 `url` 与盘上文件同名)。
|
||||
pub asset: String,
|
||||
/// 替换目标(安装目录里的副本)。
|
||||
pub dest: PathBuf,
|
||||
}
|
||||
|
||||
/// 现场构建出来的 `ull.exe` 的前 [`PAYLOAD_LIMIT`] 字节(读一次,整份不超过 1 MiB)。
|
||||
///
|
||||
/// 为什么不用占位字符串:这是**发布矩阵里那个单文件 exe** 的真字节,PE 头、节表、体积都对得上;
|
||||
/// 为什么截短:整份 ~400 MB,测材太重(理由见 [`PAYLOAD_LIMIT`])。
|
||||
fn payload_bytes() -> Vec<u8> {
|
||||
let path = env!("CARGO_BIN_EXE_ull");
|
||||
let mut payload = std::fs::read(path).expect("读构建出来的 ull.exe");
|
||||
payload.truncate(PAYLOAD_LIMIT);
|
||||
assert!(
|
||||
payload.len() >= 64 * 1024,
|
||||
"产物要像真的(至少 64 KiB),实际 {} 字节",
|
||||
payload.len()
|
||||
);
|
||||
if cfg!(windows) {
|
||||
assert!(
|
||||
payload.starts_with(b"MZ"),
|
||||
"Windows 产物得是 PE 映像(前两个字节 MZ),否则「真形状」这句话就没意义"
|
||||
);
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
pub fn new(tag: &str) -> Self {
|
||||
Self::with_install(tag, super::TempDir::new(&format!("{tag}-install")))
|
||||
}
|
||||
|
||||
/// 安装目录在**另一块卷**上的现场(数据根与暂存仍在会话 temp):M171 的真机跨卷形态。
|
||||
///
|
||||
/// 找不到第二块可写卷时返回 `None`(调用方跳过并说明原因)——这条用例在只有一块卷的
|
||||
/// 机器/CI 上**不生效**,跨卷逻辑本身由 `crates/core` 的注入用例守着。
|
||||
pub fn cross_volume(tag: &str) -> Option<Self> {
|
||||
let install = super::TempDir::on_other_volume(&format!("{tag}-install"))?;
|
||||
Some(Self::with_install(tag, install))
|
||||
}
|
||||
|
||||
fn with_install(tag: &str, install: super::TempDir) -> Self {
|
||||
let payload = payload_bytes();
|
||||
let version = next_version(env!("CARGO_PKG_VERSION"));
|
||||
let dest = install.path().join("unlucky-launcher.exe");
|
||||
std::fs::write(&dest, INSTALLED).expect("写旧版本");
|
||||
Self {
|
||||
data: super::TempDir::new(&format!("{tag}-data")),
|
||||
dist: super::TempDir::new(&format!("{tag}-dist")),
|
||||
install,
|
||||
asset: asset_name(&version),
|
||||
payload,
|
||||
version,
|
||||
dest,
|
||||
}
|
||||
}
|
||||
|
||||
/// 发一版:清单 + 产物都按 `payload` 的真实长度与摘要。
|
||||
pub fn publish_good(&self) {
|
||||
self.publish(&sha256(&self.payload), self.payload.len() as u64);
|
||||
}
|
||||
|
||||
/// 发一版,摘要/长度由调用方给(篡改用例用)。
|
||||
pub fn publish(&self, sha256: &str, size: u64) {
|
||||
std::fs::write(self.dist.path().join(&self.asset), &self.payload).expect("写产物");
|
||||
let path = self.dist.path().join("stable.json");
|
||||
std::fs::write(&path, manifest(&self.version, &self.asset, size, sha256)).expect("写清单");
|
||||
// M190:清单与签名成对落盘。篡改用例给的是「错的 sha/size」,签的是**改过之后**那份清单
|
||||
// —— 那正是「清单本身可信、包体不对」的现场(摘要判据要独立于签名判据被证到)。
|
||||
write_signature(&path);
|
||||
}
|
||||
|
||||
/// 跑一条 `update` 子命令:`--data-dir` 指向临时数据根(与 GUI 一样由数据根派生暂存目录)。
|
||||
///
|
||||
/// **结构性保险(r1 复审 P2-5 / r2 复审 P2-3)**:`apply` 必须显式带 `--dest`,且**这一次调用实际
|
||||
/// 传下去的那个 `--dest` 值**不许是本进程的 exe。不带 `--dest` 就是 `Job::for_process` 的「本进程
|
||||
/// exe」——在这套用例里那是**测试二进制**,而 Windows 允许改名正在运行的映像,于是整轮测试会把它
|
||||
/// 换成一个假启动器(症状极难查)。
|
||||
///
|
||||
/// 比的是 **args 里那个 `--dest` 的值**(含 `--dest=<path>` 形式),不是夹具字段:r2 复审指出
|
||||
/// 「比 `self.dest`」等于只守住了夹具自己那一份,手写
|
||||
/// `fx.run(&["apply", "--dest", "<测试二进制>", "--json"])` 照样能过。夹具字段那一份作为额外一道
|
||||
/// 保留(`Fixture::apply()` 走的就是它)。
|
||||
///
|
||||
/// 抓得住:漏 `--dest`、把 `--dest` 指到本进程 exe、夹具 dest 被改成 exe——都**当场 panic**。
|
||||
/// 抓不住:绕开 `Fixture` 直接调 `unlucky_cli::run`(那等于另写一套夹具,`--data-dir` 也没人拼了)。
|
||||
pub fn run(&self, args: &[&str]) -> (i32, String, String) {
|
||||
let requested = args
|
||||
.iter()
|
||||
.position(|arg| *arg == "--dest")
|
||||
.and_then(|index| args.get(index + 1).copied())
|
||||
.or_else(|| args.iter().find_map(|arg| arg.strip_prefix("--dest=")));
|
||||
if args.first() == Some(&"apply") {
|
||||
assert!(
|
||||
requested.is_some(),
|
||||
"apply 必须带 --dest:不带会替换本进程 exe(这套用例里就是测试二进制)。\
|
||||
唯一的 apply 入口是 `Fixture::apply()`"
|
||||
);
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe().and_then(std::fs::canonicalize) {
|
||||
let asked = requested.and_then(|path| std::fs::canonicalize(path).ok());
|
||||
let fixture = std::fs::canonicalize(&self.dest).ok();
|
||||
for dest in [asked, fixture].into_iter().flatten() {
|
||||
assert_ne!(
|
||||
dest, exe,
|
||||
"替换目标不许是本进程的 exe(`--dest` 点的就是它)"
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut full = vec!["update"];
|
||||
full.extend_from_slice(args);
|
||||
let data_dir = self.data.arg();
|
||||
full.push("--data-dir");
|
||||
full.push(data_dir.as_str());
|
||||
super::run_cli(&full)
|
||||
}
|
||||
|
||||
/// 跑 `apply --dest <副本> --json`(**永远**带 `--dest`,见文件头)。
|
||||
pub fn apply(&self) -> (i32, serde_json::Value) {
|
||||
let dest = self.dest.display().to_string();
|
||||
let (code, out, _) = self.run(&["apply", "--dest", &dest, "--json"]);
|
||||
(code, super::parse_json(&out))
|
||||
}
|
||||
|
||||
/// 暂存目录(`<数据根>/.ULL/cache/update/staging`,与 `core::update::staging_dir` 同形)。
|
||||
pub fn staging(&self) -> PathBuf {
|
||||
self.data
|
||||
.path()
|
||||
.join(".ULL")
|
||||
.join("cache")
|
||||
.join("update")
|
||||
.join("staging")
|
||||
}
|
||||
|
||||
/// 暂存目录里还留着什么(`StagedPackage` 落地/被搬走之后应为空)。
|
||||
pub fn staging_leftovers(&self) -> Vec<String> {
|
||||
list_dir(&self.staging())
|
||||
}
|
||||
|
||||
/// 安装目录里的备份(`.unlucky-launcher.exe.ullold-*`)。
|
||||
pub fn backups(&self) -> Vec<String> {
|
||||
list_dir(self.install.path())
|
||||
.into_iter()
|
||||
.filter(|name| name.contains(".ullold-"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 目标文件当前字节。
|
||||
pub fn installed_bytes(&self) -> Vec<u8> {
|
||||
std::fs::read(&self.dest).expect("读目标")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! 更新 e2e 夹具的**签名**部分(M190):用 `packaging/sign_manifest.py` 给清单签 `.sig`。
|
||||
//!
|
||||
//! 为什么单独一个文件:`support/update_e2e.rs` 已经贴着全仓 400 行红线(M190 r1 复审 P1-3),
|
||||
//! 而签名这一摊只被 `Fixture::publish` 用到。搬出来是纯搬家,语义未改。
|
||||
//!
|
||||
//! 为什么走**真脚本**而不是在测试里再写一份签名:这样这套端到端用例同时是「发布侧签名脚本 ⇒
|
||||
//! 真二进制验签」的跨实现证据(Python 签、Rust 验)。脚本零第三方依赖,CI runner 与开发机都自带
|
||||
//! python3/python;找不到 python 时这套用例**直接红**(不静默跳过——跳过等于这条链没有证据)。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// 测试签名钥的种子(**公开**:只对 `update-test-source` 构建有意义,正式信任表里没有这把钥,
|
||||
/// 见 `crates/core/src/update/trust.rs::TEST_ONLY`)。
|
||||
const TEST_KEY_SEED: &str = "ull-m190-test-key-seed-000000001";
|
||||
/// 测试钥的 `key_id`(与 `trust.rs::TEST_ONLY` 一致)。
|
||||
const TEST_KEY_ID: &str = "test-only-key";
|
||||
|
||||
/// 用 `packaging/sign_manifest.py` 给刚落盘的清单签一份 `.sig`(M190)。
|
||||
///
|
||||
/// 为什么走**真脚本**而不是在测试里再写一份签名:这样这套端到端用例同时是「发布侧签名脚本 ⇒
|
||||
/// 真二进制验签」的跨实现证据(Python 签、Rust 验)。脚本零第三方依赖,CI runner 与开发机都自带
|
||||
/// python3/python;找不到 python 时这套用例**直接红**(不静默跳过——跳过等于这条链没有证据)。
|
||||
pub fn write_signature(manifest_path: &Path) {
|
||||
let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packaging/sign_manifest.py");
|
||||
assert!(script.is_file(), "找不到签名脚本:{}", script.display());
|
||||
let seed_hex: String = TEST_KEY_SEED
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
let secret = format!(r#"{{"key_id":"{TEST_KEY_ID}","seed_hex":"{seed_hex}"}}"#);
|
||||
let mut command = None;
|
||||
for candidate in ["python3", "python"] {
|
||||
if std::process::Command::new(candidate)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.is_ok_and(|output| output.status.success())
|
||||
{
|
||||
command = Some(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let python = command.unwrap_or_else(|| panic!("这套用例需要 python3/python 跑 {script:?}"));
|
||||
let output = std::process::Command::new(python)
|
||||
.arg(&script)
|
||||
.arg("--sign")
|
||||
.arg(manifest_path)
|
||||
.env("ULL_MANIFEST_SIGNING_KEY", &secret)
|
||||
.output()
|
||||
.expect("跑签名脚本");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"签名脚本失败:stdout={} stderr={}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
manifest_path
|
||||
.with_file_name(format!(
|
||||
"{}.sig",
|
||||
manifest_path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
))
|
||||
.is_file(),
|
||||
"签名脚本没产出 .sig"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! 更新通道端到端验收(M169)的**拒绝侧**:篡改摘要、篡改包体、截断包体。
|
||||
//!
|
||||
//! 正路与幂等在 `update_e2e_test.rs`(现场说明、抓得住/抓不住、不验的部分都在那边,别重复读)。
|
||||
//! 这里只钉一条底线:**校验不过就一个字节都不许落到目标上**,而且失败不会把现场搞脏到
|
||||
//! 「修好清单也装不上」。
|
||||
//!
|
||||
//! ⚠ 这三例**抓不住回滚/备份**:摘要与长度失败都发生在 `install_staged` 之前,那一步走不到
|
||||
//! (r1 复审 ⑤ 的变异实测:去掉 dest 备份后这三例仍全绿)。回滚由 `update_e2e_test.rs` 的备份
|
||||
//! 断言与 `core::update::tests::install_cases` 守。
|
||||
//!
|
||||
//! 一键跑(必须带 feature;tower 定案:验收落在 release profile 上):
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo test --release -p unlucky-cli --features update-test-source --test update_e2e_tamper_test
|
||||
//! ```
|
||||
//!
|
||||
//! 不带 feature 时整个目标 `#![cfg]` 掉(0 个用例)——测试通道在那种构建里不存在。
|
||||
|
||||
#![cfg(feature = "update-test-source")]
|
||||
|
||||
#[path = "support/mod.rs"]
|
||||
mod support;
|
||||
|
||||
use support::update_e2e::{channel_lock, sha256, Channel, Fixture, INSTALLED};
|
||||
|
||||
/// 清单的 `sha256` 与盘上字节不符 ⇒ 拒绝,且**现有安装一个字节都不动**。
|
||||
///
|
||||
/// 抓得住:校验和判据被绕过(写成恒真、先搬后校、只比长度)。抓不住:清单与包体一起被换
|
||||
/// (同时改 sha),那条路上没有可信根。
|
||||
#[test]
|
||||
fn a_tampered_digest_is_refused_and_leaves_the_install_intact() {
|
||||
let _serial = channel_lock();
|
||||
let _restore = Channel::capture();
|
||||
let fx = Fixture::new("e2e-digest");
|
||||
Channel::enable(fx.dist.path());
|
||||
// 长度对、摘要错:先过长度判据,才能测到摘要这一关。
|
||||
fx.publish(&sha256(b"another build entirely"), fx.payload.len() as u64);
|
||||
|
||||
let (code, json) = fx.apply();
|
||||
assert_ne!(code, 0, "摘要不符必须拒绝(实际退出码 0):{json}");
|
||||
assert_eq!(json["error"]["code"], "update_apply_failed", "{json}");
|
||||
assert_eq!(json["error"]["detail"]["kind"], "digest_mismatch", "{json}");
|
||||
|
||||
assert_eq!(fx.installed_bytes(), INSTALLED, "校验失败不许动目标文件");
|
||||
assert!(
|
||||
fx.backups().is_empty(),
|
||||
"还没走到替换就失败了,安装目录里不该出现 .ullold:{:?}",
|
||||
fx.backups()
|
||||
);
|
||||
assert!(
|
||||
fx.staging().exists(),
|
||||
"下载过才会建暂存目录(说明失败点在双检,不是根本没下)"
|
||||
);
|
||||
assert!(
|
||||
fx.staging_leftovers().is_empty(),
|
||||
"校验失败的 .ullupd 必须被删掉:{:?}",
|
||||
fx.staging_leftovers()
|
||||
);
|
||||
|
||||
// 修好清单再装一次:失败没把现场搞脏,这条路还能走通。
|
||||
fx.publish_good();
|
||||
let (code, json) = fx.apply();
|
||||
assert_eq!(code, 0, "修好清单之后应能装上:{json}");
|
||||
let data = support::update_e2e::payload(&json);
|
||||
assert_eq!(data["status"], "installed", "{json}");
|
||||
assert_eq!(fx.installed_bytes(), fx.payload);
|
||||
}
|
||||
|
||||
/// 产物被换(清单没动)⇒ 一样拒绝:篡改落在哪一侧都能抓到。
|
||||
#[test]
|
||||
fn a_tampered_artifact_is_refused_too() {
|
||||
let _serial = channel_lock();
|
||||
let _restore = Channel::capture();
|
||||
let fx = Fixture::new("e2e-artifact");
|
||||
Channel::enable(fx.dist.path());
|
||||
fx.publish_good();
|
||||
// 盘上换成同长度的另一份字节:只有摘要能抓(长度判据看不出区别)。
|
||||
let mut evil = fx.payload.clone();
|
||||
evil[0] ^= 0xff;
|
||||
std::fs::write(fx.dist.path().join(&fx.asset), &evil).expect("篡改产物");
|
||||
|
||||
let (code, json) = fx.apply();
|
||||
assert_ne!(code, 0, "篡改的包必须拒绝:{json}");
|
||||
assert_eq!(
|
||||
json["error"]["detail"]["kind"], "digest_mismatch",
|
||||
"篡改产物应当报摘要不符:{json}"
|
||||
);
|
||||
assert_eq!(fx.installed_bytes(), INSTALLED);
|
||||
assert!(fx.staging_leftovers().is_empty());
|
||||
}
|
||||
|
||||
/// 包体被截断(清单长度按完整包算)⇒ 长度判据先拒:不读摘要、不碰目标。
|
||||
#[test]
|
||||
fn a_truncated_payload_is_refused_as_size_mismatch() {
|
||||
let _serial = channel_lock();
|
||||
let _restore = Channel::capture();
|
||||
let fx = Fixture::new("e2e-truncated");
|
||||
Channel::enable(fx.dist.path());
|
||||
fx.publish_good();
|
||||
let full = fx.payload.clone();
|
||||
std::fs::write(fx.dist.path().join(&fx.asset), &full[..full.len() / 2]).expect("截断产物");
|
||||
|
||||
let (code, json) = fx.apply();
|
||||
assert_ne!(code, 0, "截断的包必须拒绝:{json}");
|
||||
assert_eq!(json["error"]["detail"]["kind"], "size_mismatch", "{json}");
|
||||
assert_eq!(fx.installed_bytes(), INSTALLED);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! 更新通道端到端验收(M169):**临时数据根 + 本地更新源 + 真形状的清单与产物**,
|
||||
//! 从 CLI 入口走完「发现新版本 → 下载 → 长度/SHA256 双检 → 就位」。
|
||||
//!
|
||||
//! 一键跑(**必须带 feature**;tower 要求验收落在 release profile 上,debug 与 release 的
|
||||
//! `debug_assertions`、优化与 panic 行为都不同,用 debug 验收等于没验真实产物):
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo test --release -p unlucky-cli --features update-test-source \
|
||||
//! --test update_e2e_test --test update_e2e_tamper_test
|
||||
//! ```
|
||||
//!
|
||||
//! (两个测试目标各跑一半:本文件是正路 + 幂等,`update_e2e_tamper_test` 是篡改/截断。)
|
||||
//!
|
||||
//! 不带 `--features update-test-source` 时整个目标 `#![cfg]` 掉(**0 个用例**,不是绿):测试通道
|
||||
//! 在那种构建里根本不存在,「环境变量设满也走 GitHub」由 `crates/core` 的
|
||||
//! `the_test_channel_is_a_compile_time_opt_in` 在**不带 feature 的跑法**下守着。
|
||||
//!
|
||||
//! 为什么需要 feature:`ULL_UPDATE_DIR` 曾经只在 `debug_assertions` 下生效,于是发布产物(release)
|
||||
//! **没法**指向本地源,这条链在 release 上从来没有端到端证据。M169 把判据换成编译期 feature
|
||||
//! (默认关 ⇒ 官方二进制里没有这条路径),验收构建显式打开它。
|
||||
//!
|
||||
//! # 现场
|
||||
//!
|
||||
//! 见 `tests/support/update_e2e.rs`:数据根 / 本地源 / 安装目录三份临时目录,产物是现场构建出来的
|
||||
//! `ull.exe` 的**前 1 MiB**(真 PE 头、真字节;整份 ~400 MB,测材太重在 `PAYLOAD_LIMIT` 的注释里
|
||||
//! 写了原因),清单与 `packaging/tests/manifest.golden.json` 同字段。**`--dest` 永远指向临时副本**
|
||||
//! ——这个文件里没有一处会替换本进程的 exe。
|
||||
//!
|
||||
//! # 抓得住什么 / 抓不住什么
|
||||
//!
|
||||
//! | 本套断言 | 抓得住 | 抓不住 |
|
||||
//! |---|---|---|
|
||||
//! | 清单 → 选版本 → 下载 → 双检 → 就位整环跑通 | 任一环断了(字段拼错、渠道不符、没下、没校验、没搬)都会红 | 真 GitHub Release 的下载链路(本套一个字节不出网) |
|
||||
//! | 清单 `sha256` 与盘上字节不符 ⇒ 拒绝 + 目标不动 | 「校验和判据被绕过/写成恒真」;篡改包体或篡改清单都能抓(见 `update_e2e_tamper_test`) | 「CDN 换了内容**而清单里的 sha 也一起换**」——这条路上没有可信根,只能靠 GitHub 侧 |
|
||||
//! | 清单 `size` 与盘上长度不符 ⇒ 拒绝 | 截断/拼接(长度判据先跑,比摘要便宜) | 同上:清单自己撒谎时没有任何判据能发现 |
|
||||
//! | 重复 `apply` 后目标字节稳定、暂存无残留、只留一份备份 | 「第二次把 dest 写坏 / 暂存留下垃圾 / 备份无限堆积」 | 「版本号真的前进了」——`current` 取的是**编译期**版本,不读 dest 里的 exe |
|
||||
//! | 失败后目标仍是旧字节、无 `.ullold-*` | 「校验失败却已经动过目标」 | 跨卷失败路径:本文件只验了跨卷**成功**那一半(见下一行);跨卷下的中途失败由 `install_io_cases` 注入验 |
|
||||
//! | 跨卷 `apply`(数据根一卷、安装目录另一卷,M171) | 「rename 不能跨卷 ⇒ 更新必然失败」这条已发布版本上的真 bug;用例自己先证明前提(rename 报 `CrossesDevices`) | 只有一块可写卷的机器上**只有显式豁免才允许跳过**(`ULL_XDEV_TEST_WAIVE=1`,探不到又没豁免 ⇒ 红);macOS 的跨卷行为一台没跑过 |
|
||||
//! | 失败路径三例(`update_e2e_tamper_test`:篡改清单 sha / 篡改包体 / 截断) | 「校验判据被绕过」「失败却已动过目标」「失败留下 `.ullupd`」 | **回滚与备份**:摘要/长度失败发生在 `install_staged` **之前**,那一步根本走不到——回滚靠正路的备份断言 + `install_cases` 单测(r1 复审 ⑤ 的变异实测:去掉 dest 备份后失败路径三例仍全绿,只有正路/幂等变红) |
|
||||
//!
|
||||
//! # 不验的部分(如实划界)
|
||||
//!
|
||||
//! 1. **真实 GitHub Release 下载**:本套一个字节都不出网(源是本地目录)。`releases/latest` 的
|
||||
//! 真实响应、镜像/gh-proxy 回退、CDN 的 ETag/断点续传都属于真网络验收。
|
||||
//! 2. **GUI 的点击与重启替换**:设置页「下载并安装」、提示条、`RestartNow` 之后的进程重启不在
|
||||
//! 自动化里(替换自身 exe 需要真机,且要人工确认新版本能起来)。
|
||||
//! 3. **按版本号忽略**:`presenter::host::update_state` 的 `skipped` 是会话级行为,有单测
|
||||
//! (`update_session_tests.rs`),CLI 侧没有对应开关,所以这里不覆盖。
|
||||
//! 4. **`--dest` 之外的自替换**(替换本进程 exe):`tests/support/update_e2e.rs` 的文件头写了为什么
|
||||
//! 这几条用例永远带 `--dest`,以及「去掉它会真的改名换掉测试二进制」这件事。
|
||||
//! 5. **macOS / Linux 上的跨卷**:跨卷用例只在**本机 Windows**(C: NVMe + D: 机械盘)上真跑过;
|
||||
//! macOS 的卷(APFS 宗卷之间 `/Volumes` 挂载)与 Linux 的 tmpfs↔ext4 一次都没跑——两边的
|
||||
//! `EXDEV` 语义相同,但「同目录中转 + 改名」在同一台机器上有没有别的坑,本套没有证据。
|
||||
|
||||
#![cfg(feature = "update-test-source")]
|
||||
|
||||
#[path = "support/mod.rs"]
|
||||
mod support;
|
||||
|
||||
use support::update_e2e::{channel_lock, Channel, Fixture, INSTALLED};
|
||||
|
||||
use unlucky_core::update::Target;
|
||||
|
||||
/// 发现新版本 → 下载 → 双检 → 就位:整环从 CLI 入口跑一遍,并核对每一步的产物。
|
||||
#[test]
|
||||
fn discovers_downloads_and_installs_from_the_local_channel() {
|
||||
let _serial = channel_lock();
|
||||
let _restore = Channel::capture();
|
||||
let fx = Fixture::new("e2e-happy");
|
||||
Channel::enable(fx.dist.path());
|
||||
fx.publish_good();
|
||||
|
||||
// 1) 发现新版本:源是本地目录,脚本能看出这一份结论来自测试通道。
|
||||
let (code, out, err) = fx.run(&["check", "--json"]);
|
||||
assert_eq!(code, 0, "check 应成功:{err}");
|
||||
let json = support::parse_json(&out);
|
||||
let data = support::update_e2e::payload(&json);
|
||||
assert_eq!(data["status"], "available", "{json}");
|
||||
assert_eq!(data["version"], fx.version.as_str());
|
||||
assert_eq!(data["current"], env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(
|
||||
data["size"].as_u64(),
|
||||
Some(fx.payload.len() as u64),
|
||||
"清单声明的字节数 = 产物真实字节数:{json}"
|
||||
);
|
||||
assert_eq!(data["target"], Target::current().as_str());
|
||||
assert_eq!(data["notes"], "修掉启动崩溃;实例页加载更快");
|
||||
assert_eq!(data["forced"], false);
|
||||
assert_eq!(data["test_source"], true, "测试通道必须在输出里点名");
|
||||
assert_eq!(
|
||||
data["source"],
|
||||
format!("file:{}", fx.dist.path().display()),
|
||||
"源必须是本地目录(不是 GitHub)"
|
||||
);
|
||||
|
||||
// 2) 人读输出也点名测试通道:人看的那份不能比 JSON 少说一句。
|
||||
let (code, human, _) = fx.run(&["check"]);
|
||||
assert_eq!(code, 0);
|
||||
assert!(human.contains("发现新版本"), "{human}");
|
||||
assert!(
|
||||
human.contains("测试通道生效"),
|
||||
"人读输出要点名「这一份结论来自本地夹具」:{human}"
|
||||
);
|
||||
|
||||
// 3) 下载 → 长度/SHA256 双检 → 就位。
|
||||
let (code, json) = fx.apply();
|
||||
assert_eq!(code, 0, "apply 应成功:{json}");
|
||||
let data = support::update_e2e::payload(&json);
|
||||
assert_eq!(data["status"], "installed", "{json}");
|
||||
assert_eq!(data["version"], fx.version.as_str());
|
||||
assert_eq!(
|
||||
data["bytes"].as_u64(),
|
||||
Some(fx.payload.len() as u64),
|
||||
"{json}"
|
||||
);
|
||||
assert_eq!(data["path"], fx.dest.display().to_string().as_str());
|
||||
assert_eq!(data["test_source"], true);
|
||||
let backup = data["backup"]
|
||||
.as_str()
|
||||
.expect("旧版本应留备份(回滚用)")
|
||||
.to_string();
|
||||
|
||||
// 4) 盘上事实:目标 = 产物字节,备份 = 旧字节,暂存无残留。
|
||||
assert_eq!(
|
||||
fx.installed_bytes(),
|
||||
fx.payload,
|
||||
"就位的必须就是清单里那一份字节"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(&backup).expect("读备份"),
|
||||
INSTALLED,
|
||||
"旧版本要在备份里,不能凭空消失"
|
||||
);
|
||||
assert!(
|
||||
fx.staging_leftovers().is_empty(),
|
||||
"暂存目录里不许留 .ullupd:{:?}",
|
||||
fx.staging_leftovers()
|
||||
);
|
||||
|
||||
// 5) 装完再查:**仍然报有更新**——因为 current 取的是编译期版本,不读 dest 里的 exe。
|
||||
// 这不是 bug(真实场景里替换完就会重启),但脚本作者必须知道这一条。
|
||||
let (code, out, _) = fx.run(&["check", "--json"]);
|
||||
assert_eq!(code, 0);
|
||||
let json = support::parse_json(&out);
|
||||
assert_eq!(support::update_e2e::payload(&json)["status"], "available");
|
||||
}
|
||||
|
||||
/// 没有清单的本地渠道 = 「还没有任何发布」,不是错误、也不是「已是最新」以外的胡话。
|
||||
///
|
||||
/// 顺带把「源真的被换成本地目录」在没有产物的情况下也验一遍(全程离线)。
|
||||
#[test]
|
||||
fn an_empty_local_channel_is_up_to_date_not_an_error() {
|
||||
let _serial = channel_lock();
|
||||
let _restore = Channel::capture();
|
||||
let fx = Fixture::new("e2e-empty");
|
||||
Channel::enable(fx.dist.path());
|
||||
|
||||
let (code, out, err) = fx.run(&["check", "--json"]);
|
||||
assert_eq!(code, 0, "缺清单不该是错误:{err}");
|
||||
let json = support::parse_json(&out);
|
||||
let data = support::update_e2e::payload(&json);
|
||||
assert_eq!(data["status"], "up_to_date", "{json}");
|
||||
assert_eq!(data["latest"], serde_json::Value::Null);
|
||||
assert_eq!(data["test_source"], true);
|
||||
}
|
||||
|
||||
/// 重复 `apply` 的**终态**与一次相同:目标字节不变、暂存不留垃圾、备份只有一份。
|
||||
///
|
||||
/// 如实记两条:`apply` 每次都留一份备份(CLI 从不删,等下一次启动/人工清理),但**只留最近一份**
|
||||
/// (`install::prune_stale_artifacts`,M171);第二次之所以还会「装上」,是因为 `current` 是编译期
|
||||
/// 版本——重复执行不是空操作,只是结果幂等。
|
||||
#[test]
|
||||
fn applying_twice_leaves_the_same_target_bytes() {
|
||||
let _serial = channel_lock();
|
||||
let _restore = Channel::capture();
|
||||
let fx = Fixture::new("e2e-twice");
|
||||
Channel::enable(fx.dist.path());
|
||||
fx.publish_good();
|
||||
|
||||
for round in 1..=2 {
|
||||
let (code, json) = fx.apply();
|
||||
assert_eq!(code, 0, "第 {round} 次 apply 应成功:{json}");
|
||||
assert_eq!(support::update_e2e::payload(&json)["status"], "installed");
|
||||
assert_eq!(
|
||||
fx.installed_bytes(),
|
||||
fx.payload,
|
||||
"第 {round} 次 apply 之后目标字节必须仍是产物"
|
||||
);
|
||||
assert!(
|
||||
fx.staging_leftovers().is_empty(),
|
||||
"第 {round} 次 apply 之后暂存目录还有 {:?}",
|
||||
fx.staging_leftovers()
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
fx.backups().len(),
|
||||
1,
|
||||
"只留最近一份备份(每次 apply 建一份、成功即清更早的):{:?}",
|
||||
fx.backups()
|
||||
);
|
||||
}
|
||||
|
||||
/// 跨卷(M171):数据根在一卷、安装目录在另一卷时,整环从 CLI 入口仍然跑通。
|
||||
///
|
||||
/// 这正是已发布版本上必然失败的那个场景(便携版放 U 盘、数据根在 D 盘而程序在 C 盘):
|
||||
/// `stage()` 把包落在 `<数据根>/cache/update/staging`,`install_staged()` 要把包搬进 exe 目录,
|
||||
/// 而 `rename` 不能跨卷。修法是先落到**目标同目录**的中转名(同卷改名)再顶上去。
|
||||
///
|
||||
/// 用例先自己证明**前提成立**(从数据根到安装目录 rename 必须报 `CrossesDevices`),否则
|
||||
/// 「跨卷成功」这句话就是假的。本机只有一块可写卷时**只有显式豁免才允许跳过**
|
||||
/// (`ULL_XDEV_TEST_WAIVE=1`,见 `support::cross_volume_waived`):探不到又没豁免 ⇒ 红。
|
||||
/// 跨卷逻辑本身另由 `crates/core` 的 `install_xdev_cases`(真跨卷)与 `install_io_cases`(注入)守着。
|
||||
#[test]
|
||||
fn installs_across_volumes_from_the_cli() {
|
||||
let _serial = channel_lock();
|
||||
let _restore = Channel::capture();
|
||||
let Some(fx) = Fixture::cross_volume("e2e-xdev") else {
|
||||
support::cross_volume_waived("cli-e2e");
|
||||
return;
|
||||
};
|
||||
Channel::enable(fx.dist.path());
|
||||
fx.publish_good();
|
||||
|
||||
// 前提:数据根(暂存所在地)与安装目录**真的**不同卷。
|
||||
let install_dir = fx.dest.parent().expect("安装目录").to_path_buf();
|
||||
let probe = fx.data.path().join("volume-probe.bin");
|
||||
std::fs::write(&probe, b"probe").expect("写探针");
|
||||
let cross = std::fs::rename(&probe, install_dir.join("volume-probe.bin"))
|
||||
.expect_err("跨卷 rename 必须失败");
|
||||
assert_eq!(
|
||||
cross.kind(),
|
||||
std::io::ErrorKind::CrossesDevices,
|
||||
"前提不成立:数据根与安装目录同卷({cross})"
|
||||
);
|
||||
|
||||
let (code, json) = fx.apply();
|
||||
assert_eq!(code, 0, "跨卷 apply 应成功:{json}");
|
||||
let data = support::update_e2e::payload(&json);
|
||||
assert_eq!(data["status"], "installed", "{json}");
|
||||
assert_eq!(data["path"], fx.dest.display().to_string().as_str());
|
||||
assert_eq!(
|
||||
fx.installed_bytes(),
|
||||
fx.payload,
|
||||
"装上去的必须是清单里那一份完整字节(不是半截)"
|
||||
);
|
||||
assert!(
|
||||
fx.staging_leftovers().is_empty(),
|
||||
"暂存目录里不许留 .ullupd:{:?}",
|
||||
fx.staging_leftovers()
|
||||
);
|
||||
let backups = fx.backups();
|
||||
assert_eq!(backups.len(), 1, "旧版本要留一份备份:{backups:?}");
|
||||
let backup = install_dir.join(&backups[0]);
|
||||
assert_eq!(
|
||||
std::fs::read(&backup).expect("读备份"),
|
||||
INSTALLED,
|
||||
"旧字节必须完整留在备份里"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user