Files
Unlucky-Launcher/crates/cli/tests/java_test.rs
T
AskaEth 75aa5ee82c fix(cli): 启动用的 Java 钉死 D4 校验过的那个 + M214 复审 nit1/nit2/nit3
- override_path 五来源统一钉住 D4 选中的运行时:以前只有 managed 钉路径,
  JAVA_HOME/PATH/system 三种会被 snapshot::prepare 的 selection() 按环境再挑一遍,
  D4 判过的 Java 根本不是真启动的那个。现在 `plan_java` 只做「选中 → 钉路径」,
  人读行写出「版本(来源 · 具体可执行文件)」,--json 的 java 块并排给出
  binary(校验过的)/ launch_binary(装配落地的)/ source,两者不一致时当场告警。
  实例设置/全局设置里的 javaPath 也并入「谁指定了 Java」,不会被自动挑的结果顶掉。
- nit3:D4 的 Java 不符拦截挪到 identity::resolve 之前(微软轨解析身份会联网刷新
  令牌、还会回写凭据),拦下的启动不再先触发一次刷新;不符提示补上「挑中的是哪个
  java」,文案只加不减。
- nit1:error.rs / launcher.rs 的人读串里残留的 ** 标记去掉,并补两条回归断言。
- nit2:java_launch.rs 里「只提示不阻断」等旧注释对齐 D4 现状。
- launch.rs(399 行零余量):D20 的隔离档位派生与切换检测整体搬到新文件
  launch_isolation.rs,纯挪函数体(逐行 diff 只多 6 处 pub(super)),
  用 #[path] 挂载以免改 commands/mod.rs。

用例:钉路径纯函数单测 3 条 + 端到端 2 条(JAVA_HOME 置空证明非 managed 来源也钉住;
数据根放两个账户证明 D4 拦在身份解析之前)+ 人读串无 ** 的 2 条回归。
两条变异(①退回只钉 Managed ②退回身份解析之后)各自让新用例变红,已实测。
2026-11-09 21:30:55 +08:00

295 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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}"
);
}