Unlucky Launcher v0.2.0
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
首个 iced + Material 3 Expressive 版本的源码快照。 完整历史与日常提交在 dev 分支;main 只放阶段性快照。
This commit is contained in:
commit
8a8e5ca0c0
1079 files changed
+208035
No files matched your search
@@ -0,0 +1,166 @@
|
||||
//! game 快照 → `LaunchRequest` 适配层测试(缺失库显式记录、features 派生)。
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
fn write_file(path: &std::path::Path, content: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建目录");
|
||||
}
|
||||
std::fs::write(path, content).expect("写文件");
|
||||
}
|
||||
|
||||
fn jvm_tokens() -> Vec<String> {
|
||||
vec![
|
||||
"-Djava.library.path=${natives_directory}".to_string(),
|
||||
"-cp".to_string(),
|
||||
"${classpath}".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn game_tokens() -> Vec<String> {
|
||||
vec![
|
||||
"--username".to_string(),
|
||||
"${auth_player_name}".to_string(),
|
||||
"--version".to_string(),
|
||||
"${version_name}".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_records_missing_libraries_and_fails_before_start() {
|
||||
let dir = temp_dir("adapter-strict");
|
||||
let lib_ok = dir.join("libraries/a.jar");
|
||||
let lib_missing = dir.join("libraries/b.jar");
|
||||
let primary = dir.join("versions/1.20.1/1.20.1.jar");
|
||||
write_file(&lib_ok, "x");
|
||||
write_file(&primary, "x");
|
||||
let libraries = vec![
|
||||
LibraryInput::new(lib_ok.clone(), "org.example:a:1"),
|
||||
LibraryInput::new(lib_missing.clone(), "org.example:b:2"),
|
||||
];
|
||||
let jvm = jvm_tokens();
|
||||
let game = game_tokens();
|
||||
let snapshot = SnapshotInput {
|
||||
version_name: Some("1.20.1 Fabric"),
|
||||
version_type: Some("release"),
|
||||
game_version: Some("1.20.1"),
|
||||
main_class: Some("net.fabricmc.loader.impl.launch.knot.KnotClient"),
|
||||
jvm_arguments: &jvm,
|
||||
game_arguments: &game,
|
||||
libraries: &libraries,
|
||||
primary_jar: primary.clone(),
|
||||
game_dir: dir.clone(),
|
||||
assets_index: Some("17"),
|
||||
natives_dir: dir.join("versions/1.20.1/natives"),
|
||||
library_dir: dir.join("libraries"),
|
||||
assets_root: dir.join("assets"),
|
||||
game_assets: dir.join("assets/virtual/legacy"),
|
||||
..SnapshotInput::minimal("1.20.1", "")
|
||||
};
|
||||
let mut options = AdapterOptions::strict();
|
||||
options.java = JavaSelection::new("C:/java/bin/java.exe", 21, 64);
|
||||
options.memory = MemoryOptions {
|
||||
min_mb: Some(512),
|
||||
max_mb: Some(2048),
|
||||
};
|
||||
options.resolution = Some(Resolution {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
});
|
||||
|
||||
let output = build_request(&snapshot, options);
|
||||
assert_eq!(output.classpath_entries, 2, "a.jar + 主 jar");
|
||||
assert_eq!(output.missing.len(), 1);
|
||||
assert_eq!(output.missing[0].name, "org.example:b:2");
|
||||
assert!(output.warnings.iter().any(|warning| matches!(
|
||||
warning,
|
||||
AdapterWarning::MissingLibrary { name, .. } if name == "org.example:b:2"
|
||||
)));
|
||||
// features 派生:给了分辨率就打开 has_custom_resolution;1.20.1 支持 QuickPlay。
|
||||
assert!(output.request.features.has_custom_resolution);
|
||||
assert!(output.request.features.has_quick_plays_support);
|
||||
assert_eq!(output.request.instance.name, "1.20.1 Fabric");
|
||||
assert_eq!(
|
||||
output.request.manifest.main_class.as_deref(),
|
||||
Some("net.fabricmc.loader.impl.launch.knot.KnotClient")
|
||||
);
|
||||
|
||||
// 严格模式下缺失库 → 组装前显式失败(不再交给运行时 NoClassDefFoundError)。
|
||||
let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64");
|
||||
match assemble(&output.request, &ctx) {
|
||||
Err(LaunchError::Assembly(AssemblyError::MissingComponents(items))) => {
|
||||
assert_eq!(items.len(), 1);
|
||||
}
|
||||
other => panic!("应因缺组件失败,实际:{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_tolerant_mode_skips_missing_libraries() {
|
||||
let dir = temp_dir("adapter-tolerant");
|
||||
let lib_missing = dir.join("libraries/b.jar");
|
||||
let libraries = vec![LibraryInput::new(lib_missing.clone(), "org.example:b:2")];
|
||||
let jvm = jvm_tokens();
|
||||
let snapshot = SnapshotInput {
|
||||
main_class: Some("net.minecraft.client.main.Main"),
|
||||
jvm_arguments: &jvm,
|
||||
libraries: &libraries,
|
||||
primary_jar: dir.join("client.jar"),
|
||||
game_dir: dir.clone(),
|
||||
assets_index: Some("5"),
|
||||
..SnapshotInput::minimal("1.12.2", "")
|
||||
};
|
||||
let mut options = AdapterOptions::strict();
|
||||
options.check_classpath = false;
|
||||
options.java = JavaSelection::new("java", 8, 64);
|
||||
|
||||
let output = build_request(&snapshot, options);
|
||||
assert!(
|
||||
!output.request.classpath.is_empty(),
|
||||
"宽松模式仍保留库(交给启动期报错)"
|
||||
);
|
||||
assert!(output.request.missing_components.is_empty());
|
||||
// 宽松模式不报缺失库(其它告警——如缺游戏参数——与本用例无关)。
|
||||
assert!(!output
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| matches!(warning, AdapterWarning::MissingLibrary { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_warns_on_incomplete_manifest() {
|
||||
let dir = temp_dir("adapter-incomplete");
|
||||
let snapshot = SnapshotInput {
|
||||
main_class: None,
|
||||
..SnapshotInput::minimal("1.7.10", "")
|
||||
};
|
||||
let output = build_request(&snapshot, AdapterOptions::strict());
|
||||
let warnings = &output.warnings;
|
||||
assert!(warnings.contains(&AdapterWarning::MissingMainClass));
|
||||
assert!(warnings.contains(&AdapterWarning::MissingAssetsIndex));
|
||||
assert!(warnings.contains(&AdapterWarning::MissingGameArguments));
|
||||
// 1.7.10 < 1.20:不支持 QuickPlay。
|
||||
assert!(!output.request.features.has_quick_plays_support);
|
||||
assert_eq!(
|
||||
output.request.dirs.assets_index, "legacy",
|
||||
"缺索引时兜底 legacy"
|
||||
);
|
||||
let _ = dir;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_legacy_arguments_are_passed_through() {
|
||||
let dir = temp_dir("adapter-legacy");
|
||||
let snapshot = SnapshotInput {
|
||||
main_class: Some("net.minecraft.launchwrapper.Launch"),
|
||||
legacy_arguments: Some("--username ${auth_player_name} --gameDir ${game_directory}"),
|
||||
game_dir: dir.clone(),
|
||||
..SnapshotInput::minimal("1.12.2", "")
|
||||
};
|
||||
let output = build_request(&snapshot, AdapterOptions::strict());
|
||||
assert!(output.request.manifest.is_legacy());
|
||||
assert!(output.request.manifest.game.is_empty());
|
||||
assert!(!output
|
||||
.warnings
|
||||
.contains(&AdapterWarning::MissingGameArguments));
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! 命令行组装快照测试(`analyse/02` §4.1 的 33 项顺序表 + §4.2 默认值抑制)。
|
||||
//!
|
||||
//! 期望值是手工核对过的**完整 argv**:一旦组装顺序变了,这里必须一起改——
|
||||
//! 顺序本身有语义(后加的默认值会被先加的显式值抑制)。
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_forge_full_argv() {
|
||||
let (mut request, ctx) = legacy_forge_request();
|
||||
request.mod_loader = ModLoaderKind::Forge;
|
||||
request.manifest.legacy_arguments = Some(
|
||||
"--username ${auth_player_name} --version ${version_name} --gameDir ${game_directory} \
|
||||
--assetsDir ${assets_root} --assetIndex ${assets_index_name} --uuid ${auth_uuid} \
|
||||
--accessToken ${auth_session} --userType ${user_type} \
|
||||
--tweakClass net.minecraftforge.fml.common.launcher.FMLTweaker"
|
||||
.to_string(),
|
||||
);
|
||||
// 用户指定 GBK → 日志泵也必须用 GBK 解码(`analyse/02` §4.4)。
|
||||
request.user.jvm = vec!["-Dfile.encoding=GBK".to_string()];
|
||||
|
||||
let command = assemble(&request, &ctx).expect("组装应成功");
|
||||
assert_eq!(command.encoding, "GBK");
|
||||
assert_eq!(command.main_class, "net.minecraft.launchwrapper.Launch");
|
||||
assert_eq!(command.classpath.len(), 3, "主 jar 必须追加在末位");
|
||||
|
||||
assert_argv(
|
||||
&command,
|
||||
&[
|
||||
"C:/java/bin/java.exe",
|
||||
"-Xmx4096m",
|
||||
"-Xms1024m",
|
||||
"-XX:MetaspaceSize=64m",
|
||||
"-Dfile.encoding=GBK",
|
||||
"-Dsun.stdout.encoding=GBK",
|
||||
"-Dsun.stderr.encoding=GBK",
|
||||
"-Djava.rmi.server.useCodebaseOnly=true",
|
||||
"-Dcom.sun.jndi.rmi.object.trustURLCodebase=false",
|
||||
"-Dcom.sun.jndi.cosnaming.object.trustURLCodebase=false",
|
||||
"-Dlog4j2.formatMsgNoLookups=true",
|
||||
"-Dlog4j.configurationFile=C:/mc/log4j2.xml",
|
||||
"-Dminecraft.client.jar=C:/mc/versions/1.12.2/1.12.2.jar",
|
||||
"-Dfml.ignoreInvalidMinecraftCertificates=true",
|
||||
"-Dfml.ignorePatchDiscrepancies=true",
|
||||
"-Djava.library.path=C:/mc/versions/1.12.2/natives-windows-x86_64",
|
||||
"-cp",
|
||||
"C:/mc/libraries/a.jar;C:/mc/libraries/b.jar;C:/mc/versions/1.12.2/1.12.2.jar",
|
||||
"net.minecraft.launchwrapper.Launch",
|
||||
"--username",
|
||||
"Steve",
|
||||
"--version",
|
||||
"1.12.2-Forge",
|
||||
"--gameDir",
|
||||
"C:/mc",
|
||||
"--assetsDir",
|
||||
"C:/mc/assets",
|
||||
"--assetIndex",
|
||||
"1.12",
|
||||
"--uuid",
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
"--accessToken",
|
||||
"SECRET-TOKEN-123456",
|
||||
"--userType",
|
||||
"msa",
|
||||
"--tweakClass",
|
||||
"net.minecraftforge.fml.common.launcher.FMLTweaker",
|
||||
"--width",
|
||||
"854",
|
||||
"--height",
|
||||
"480",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modern_fabric_full_argv_with_rules() {
|
||||
let (mut request, ctx) = legacy_forge_request();
|
||||
request.instance.name = "1.20.1-Fabric".to_string();
|
||||
request.instance.version = GameVersion::parse("1.20.1");
|
||||
request.manifest.main_class =
|
||||
Some("net.fabricmc.loader.impl.launch.knot.KnotClient".to_string());
|
||||
request.manifest.legacy_arguments = None;
|
||||
request.manifest.jvm = vec![
|
||||
ArgumentEntry::plain(["-Djava.library.path=${natives_directory}"]),
|
||||
ArgumentEntry::plain(["-cp", "${classpath}"]),
|
||||
];
|
||||
request.manifest.game = vec![
|
||||
ArgumentEntry::plain(["--username", "${auth_player_name}"]),
|
||||
ArgumentEntry::plain(["--version", "${version_name}"]),
|
||||
ArgumentEntry::ruled(
|
||||
vec![Rule::allow().with_os_name("windows")],
|
||||
["-Dos.windows=true"],
|
||||
),
|
||||
ArgumentEntry::ruled(vec![Rule::allow().with_os_name("osx")], ["-Dos.macos=true"]),
|
||||
ArgumentEntry::ruled(
|
||||
vec![Rule::allow().with_feature("has_custom_resolution", true)],
|
||||
[
|
||||
"--width",
|
||||
"${resolution_width}",
|
||||
"--height",
|
||||
"${resolution_height}",
|
||||
],
|
||||
),
|
||||
// 先 allow 后 disallow:按“最后一个命中的 action”生效(`analyse/02` §4.7)。
|
||||
ArgumentEntry::ruled(
|
||||
vec![
|
||||
Rule::allow().with_os_name("windows"),
|
||||
Rule::disallow().with_feature("is_demo_user", true),
|
||||
],
|
||||
["--windows-only"],
|
||||
),
|
||||
];
|
||||
request.resolution = Some(Resolution {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
});
|
||||
request.user.game = vec!["--fullscreen".to_string()];
|
||||
|
||||
let command = assemble(&request, &ctx).expect("组装应成功");
|
||||
// 主 jar 由组装器追加到最后,manifest 里的 `-cp` 只覆盖库。
|
||||
assert_eq!(command.classpath.len(), 3);
|
||||
|
||||
assert_argv(
|
||||
&command,
|
||||
&[
|
||||
"C:/java/bin/java.exe",
|
||||
"-Xmx4096m",
|
||||
"-Xms1024m",
|
||||
"-XX:MetaspaceSize=64m",
|
||||
"-Dfile.encoding=UTF-8",
|
||||
"-Dsun.stdout.encoding=UTF-8",
|
||||
"-Dsun.stderr.encoding=UTF-8",
|
||||
"-Djava.rmi.server.useCodebaseOnly=true",
|
||||
"-Dcom.sun.jndi.rmi.object.trustURLCodebase=false",
|
||||
"-Dcom.sun.jndi.cosnaming.object.trustURLCodebase=false",
|
||||
"-Dlog4j2.formatMsgNoLookups=true",
|
||||
"-Dlog4j.configurationFile=C:/mc/log4j2.xml",
|
||||
"-Dminecraft.client.jar=C:/mc/versions/1.12.2/1.12.2.jar",
|
||||
"-Djava.library.path=C:/mc/versions/1.12.2/natives-windows-x86_64",
|
||||
"-cp",
|
||||
"C:/mc/libraries/a.jar;C:/mc/libraries/b.jar;C:/mc/versions/1.12.2/1.12.2.jar",
|
||||
"net.fabricmc.loader.impl.launch.knot.KnotClient",
|
||||
"--username",
|
||||
"Steve",
|
||||
"--version",
|
||||
"1.20.1-Fabric",
|
||||
"-Dos.windows=true",
|
||||
"--width",
|
||||
"1280",
|
||||
"--height",
|
||||
"720",
|
||||
"--windows-only",
|
||||
"--fullscreen",
|
||||
],
|
||||
);
|
||||
assert!(!command.args.iter().any(|arg| arg == "-Dos.macos=true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_override_beats_defaults() {
|
||||
let (mut request, ctx) = legacy_forge_request();
|
||||
request.java = JavaSelection::new("C:/java8/bin/java.exe", 8, 32);
|
||||
request.user.override_jvm = vec!["-Xmx8G".to_string(), "-XX:+UseSerialGC".to_string()];
|
||||
request.user.jvm = vec!["-Xmx2G".to_string()];
|
||||
request.optimization = OptimizationOptions {
|
||||
enabled: true,
|
||||
physical_memory_mb: Some(8192),
|
||||
};
|
||||
request.manifest.legacy_arguments = None;
|
||||
|
||||
let command = assemble(&request, &ctx).expect("组装应成功");
|
||||
let args = command.args;
|
||||
// override 的 -Xmx8G 生效,默认的 -Xmx4096m 被抑制。
|
||||
assert!(args.iter().any(|arg| arg == "-Xmx8G"));
|
||||
assert!(!args.iter().any(|arg| arg == "-Xmx4096m"));
|
||||
// 用户 JVM 参数里的 -Xmx2G 属于“默认值”,被 override 抑制。
|
||||
assert!(!args.iter().any(|arg| arg == "-Xmx2G"));
|
||||
// 用户显式挑了 GC → 不注入 G1 整组,但诊断关键项照旧。
|
||||
assert!(args.iter().any(|arg| arg == "-XX:+UseSerialGC"));
|
||||
assert!(!args.iter().any(|arg| arg == "-XX:+UseG1GC"));
|
||||
assert!(args
|
||||
.iter()
|
||||
.any(|arg| arg == "-XX:-OmitStackTraceInFastThrow"));
|
||||
// 32 位老 Java → PermSize,且不因内存充裕注入 CodeCache 组。
|
||||
assert!(args.iter().any(|arg| arg == "-XX:MetaspaceSize=64m"));
|
||||
assert!(!args
|
||||
.iter()
|
||||
.any(|arg| arg.starts_with("-XX:ReservedCodeCacheSize")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java8_uses_legacy_encoding_keys_and_keeps_xincgc() {
|
||||
let (mut request, ctx) = legacy_forge_request();
|
||||
request.java = JavaSelection::new("C:/java8/bin/java.exe", 8, 32);
|
||||
request.user.jvm = vec!["-Xincgc".to_string()];
|
||||
request.manifest.legacy_arguments = None;
|
||||
let command = assemble(&request, &ctx).expect("组装应成功");
|
||||
assert!(command
|
||||
.args
|
||||
.iter()
|
||||
.any(|arg| arg == "-Dsun.stdout.encoding=UTF-8"));
|
||||
assert!(!command
|
||||
.args
|
||||
.iter()
|
||||
.any(|arg| arg == "-Dstdout.encoding=UTF-8"));
|
||||
// Java 8 上 -Xincgc 仍合法(只在 ≥9 上被禁,`analyse/02` §4.1 #33)。
|
||||
assert!(command.args.iter().any(|arg| arg == "-Xincgc"));
|
||||
|
||||
let mut modern = request.clone();
|
||||
modern.java = JavaSelection::new("C:/java21/bin/java.exe", 21, 64);
|
||||
let command = assemble(&modern, &ctx).expect("组装应成功");
|
||||
assert!(command
|
||||
.args
|
||||
.iter()
|
||||
.any(|arg| arg == "-Dstdout.encoding=UTF-8"));
|
||||
assert!(
|
||||
!command.args.iter().any(|arg| arg == "-Xincgc"),
|
||||
"Java ≥9 必须删掉 -Xincgc"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quickplay_and_graphics_backend_are_version_gated() {
|
||||
let (mut request, ctx) = legacy_forge_request();
|
||||
request.manifest.legacy_arguments = None;
|
||||
request.quick_play = Some(QuickPlay::Multiplayer {
|
||||
address: "mc.example.com:25565".to_string(),
|
||||
});
|
||||
request.graphics_backend = Some("vulkan".to_string());
|
||||
|
||||
// 1.12.2:QuickPlay 退化成 --server/--port,--graphicsBackend 丢弃。
|
||||
let legacy = assemble(&request, &ctx).expect("组装应成功");
|
||||
let server = legacy
|
||||
.args
|
||||
.windows(2)
|
||||
.any(|pair| pair[0] == "--server" && pair[1] == "mc.example.com");
|
||||
assert!(server, "老版本应退化为 --server");
|
||||
assert!(legacy
|
||||
.args
|
||||
.windows(2)
|
||||
.any(|pair| pair[0] == "--port" && pair[1] == "25565"));
|
||||
assert!(!legacy.args.iter().any(|arg| arg == "--graphicsBackend"));
|
||||
assert!(legacy
|
||||
.warnings
|
||||
.contains(&AssemblyWarning::QuickPlayDegraded {
|
||||
address: "mc.example.com:25565".to_string()
|
||||
}));
|
||||
assert!(legacy
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })));
|
||||
|
||||
// 26.3:QuickPlay 直用,--graphicsBackend 允许。
|
||||
let mut modern = request.clone();
|
||||
modern.instance.version = GameVersion::parse("26.3");
|
||||
let modern = assemble(&modern, &ctx).expect("组装应成功");
|
||||
assert!(modern
|
||||
.args
|
||||
.windows(2)
|
||||
.any(|pair| pair[0] == "--quickPlayMultiplayer" && pair[1] == "mc.example.com:25565"));
|
||||
assert!(modern
|
||||
.args
|
||||
.windows(2)
|
||||
.any(|pair| pair[0] == "--graphicsBackend" && pair[1] == "vulkan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assembly_fails_fast_on_missing_components() {
|
||||
let (mut request, ctx) = legacy_forge_request();
|
||||
request.missing_components = vec![
|
||||
MissingComponent::named("org.lwjgl:lwjgl:3.3.1"),
|
||||
MissingComponent::at_path("net.minecraft:client", "C:/mc/versions/1.12.2/1.12.2.jar"),
|
||||
];
|
||||
let error = assemble(&request, &ctx).expect_err("缺组件必须启动前失败");
|
||||
match error {
|
||||
LaunchError::Assembly(AssemblyError::MissingComponents(items)) => {
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].name, "org.lwjgl:lwjgl:3.3.1");
|
||||
}
|
||||
other => panic!("错误类型不对:{other}"),
|
||||
}
|
||||
|
||||
let (mut request, ctx) = legacy_forge_request();
|
||||
request.manifest.main_class = None;
|
||||
assert!(matches!(
|
||||
assemble(&request, &ctx),
|
||||
Err(LaunchError::Assembly(AssemblyError::MissingMainClass))
|
||||
));
|
||||
|
||||
let (mut request, ctx) = legacy_forge_request();
|
||||
request.classpath.clear();
|
||||
request.dirs.primary_jar = std::path::PathBuf::new();
|
||||
assert!(matches!(
|
||||
assemble(&request, &ctx),
|
||||
Err(LaunchError::Assembly(AssemblyError::EmptyClasspath))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posix_gets_nice_and_colon_classpath() {
|
||||
let mut ctx = PlatformCtx::new(OsFamily::Linux, "x86_64");
|
||||
ctx.home_dir = Some(std::path::PathBuf::from("/home/aska"));
|
||||
let (mut request, _) = legacy_forge_request();
|
||||
request.nice = Some(5);
|
||||
request.wrapper = vec!["prime-run".to_string()];
|
||||
request.manifest.legacy_arguments = None;
|
||||
|
||||
let command = assemble(&request, &ctx).expect("组装应成功");
|
||||
assert_eq!(command.argv()[0], "nice");
|
||||
assert_eq!(command.argv()[1], "-n");
|
||||
assert_eq!(command.argv()[2], "5");
|
||||
assert_eq!(command.argv()[3], "prime-run");
|
||||
assert_eq!(command.argv()[4], "C:/java/bin/java.exe");
|
||||
assert!(command
|
||||
.args
|
||||
.iter()
|
||||
.any(|arg| arg
|
||||
== "C:/mc/libraries/a.jar:C:/mc/libraries/b.jar:C:/mc/versions/1.12.2/1.12.2.jar"));
|
||||
// 非 Windows 才注入 -Duser.home。
|
||||
assert!(command
|
||||
.args
|
||||
.iter()
|
||||
.any(|arg| arg.starts_with("-Duser.home=")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_table_is_complete() {
|
||||
let (request, ctx) = legacy_forge_request();
|
||||
let map = unlucky_core::launch::placeholders::PlaceholderMap::from_request(&request, &ctx);
|
||||
for key in unlucky_core::launch::placeholders::PLACEHOLDER_KEYS {
|
||||
assert!(map.get(key).is_some(), "占位符 ${key} 未登记");
|
||||
}
|
||||
assert_eq!(map.get("primary_jar_name"), Some("1.12.2.jar"));
|
||||
assert_eq!(map.get("version_name"), Some("1.12.2-Forge"));
|
||||
// 未定义的占位符原样保留(便于发现清单漏项)。
|
||||
assert_eq!(map.render("${nope}"), "${nope}");
|
||||
assert_eq!(map.render("a${version_name}b"), "a1.12.2-Forgeb");
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! `CommandBuilder` 默认值抑制语义测试(`analyse/02` §4.2)。
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
fn strings(items: &[&str]) -> Vec<String> {
|
||||
items.iter().map(|item| (*item).to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_default_suppresses_when_prefix_exists() {
|
||||
let mut builder = CommandBuilder::new();
|
||||
builder.add("-Xmx8G");
|
||||
let existing = builder.add_default("-Xmx", "4096m");
|
||||
assert_eq!(existing.as_deref(), Some("-Xmx8G"));
|
||||
assert_eq!(builder.args(), &strings(&["-Xmx8G"]));
|
||||
|
||||
let added = builder.add_default("-Xms", "1024m");
|
||||
assert_eq!(added, None);
|
||||
assert_eq!(builder.args(), &strings(&["-Xmx8G", "-Xms1024m"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unstable_flags_do_not_conflict() {
|
||||
let mut builder = CommandBuilder::new();
|
||||
builder.add("-XX:-UseG1GC");
|
||||
// 用户已经用 `-XX:-UseG1GC` 关掉了 G1,就不再补 `-XX:+UseG1GC`。
|
||||
assert!(builder.add_unstable_flag("UseG1GC", true).is_some());
|
||||
assert!(!builder.contains_exact("-XX:+UseG1GC"));
|
||||
|
||||
let mut builder = CommandBuilder::new();
|
||||
builder.add("-XX:MaxInlineLevel=20");
|
||||
assert!(builder
|
||||
.add_unstable_default("MaxInlineLevel", "15")
|
||||
.is_some());
|
||||
assert_eq!(builder.args(), &strings(&["-XX:MaxInlineLevel=20"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_all_default_handles_each_shape() {
|
||||
let mut builder = CommandBuilder::new();
|
||||
builder.add("-Dfile.encoding=GBK");
|
||||
builder.add("-XX:+UseSerialGC");
|
||||
builder.add("-Xmx8G");
|
||||
builder.add("-cp");
|
||||
builder.add("user-cp");
|
||||
|
||||
builder.add_all_default(&strings(&[
|
||||
"-Dfile.encoding=UTF-8",
|
||||
"-Dsun.stdout.encoding=UTF-8",
|
||||
"-XX:+UseG1GC",
|
||||
"-XX:G1MixedGCCountTarget=5",
|
||||
"-Xmx4096m",
|
||||
"-Xss1m",
|
||||
"-cp",
|
||||
"default-cp",
|
||||
"exact-arg",
|
||||
]));
|
||||
|
||||
assert_eq!(
|
||||
builder.args(),
|
||||
&strings(&[
|
||||
"-Dfile.encoding=GBK",
|
||||
"-XX:+UseSerialGC",
|
||||
"-Xmx8G",
|
||||
"-cp",
|
||||
"user-cp",
|
||||
"-Dsun.stdout.encoding=UTF-8",
|
||||
"-XX:+UseG1GC",
|
||||
"-XX:G1MixedGCCountTarget=5",
|
||||
"-Xss1m",
|
||||
"exact-arg",
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn argfile_expansion_suppresses_later_defaults() {
|
||||
let mut builder = CommandBuilder::new();
|
||||
builder.add_all_without_parsing_and_read_external(
|
||||
&strings(&["@options.txt"]),
|
||||
&|path: &str| {
|
||||
if path == "options.txt" {
|
||||
Some("-Xmx12G\n-Dfile.encoding=COMPAT\n".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
);
|
||||
// @argfile 里的 -Xmx12G 已登记为外部参数,默认值必须被抑制。
|
||||
assert!(builder.add_default("-Xmx", "4096m").is_some());
|
||||
assert!(builder.add_default("-Dfile.encoding=", "UTF-8").is_some());
|
||||
// 外部参数只参与判重,不进 argv。
|
||||
assert!(!builder.contains_exact("-Xmx12G"));
|
||||
assert_eq!(builder.args(), &strings(&["@options.txt"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokenize_handles_quotes_and_escapes() {
|
||||
assert_eq!(
|
||||
command_builder::tokenize(r#"--username "a b" --path 'C:\x y' plain"#),
|
||||
strings(&["--username", "a b", "--path", "C:\\x y", "plain"])
|
||||
);
|
||||
assert_eq!(
|
||||
command_builder::tokenize(r#"-Dmsg="say \"hi\"""#),
|
||||
strings(&[r#"-Dmsg=say "hi""#])
|
||||
);
|
||||
assert!(command_builder::tokenize(" ").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_argfile_ignores_comments_and_blank_lines() {
|
||||
let contents = "# 注释\n-Xmx2G\n\n-Xms512m -Dfoo=bar\n";
|
||||
assert_eq!(
|
||||
command_builder::parse_argfile(contents),
|
||||
strings(&["-Xmx2G", "-Xms512m", "-Dfoo=bar"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forbidden_args_are_removed_at_the_end() {
|
||||
let mut builder = CommandBuilder::new();
|
||||
builder.add_all(&strings(&["-Xincgc", "-Xmx1G", "-Xincgc"]));
|
||||
let removed = builder.remove_forbidden(&["-Xincgc"]);
|
||||
assert_eq!(removed.len(), 2);
|
||||
assert_eq!(builder.args(), &strings(&["-Xmx1G"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholders_are_rendered_on_add_but_not_on_raw_add() {
|
||||
let mut map = unlucky_core::launch::placeholders::PlaceholderMap::new();
|
||||
map.insert("version_name", "1.20.1");
|
||||
let mut builder = CommandBuilder::with_placeholders(map);
|
||||
builder.add("--version");
|
||||
builder.add("${version_name}");
|
||||
builder.add_without_parsing("--raw ${version_name}");
|
||||
assert_eq!(
|
||||
builder.args(),
|
||||
&strings(&["--version", "1.20.1", "--raw ${version_name}"])
|
||||
);
|
||||
// 人读形式给含空格的参数加引号(日志与快照测试用)。
|
||||
assert_eq!(
|
||||
builder.display_args(),
|
||||
"--version 1.20.1 \"--raw ${version_name}\""
|
||||
);
|
||||
let mut quoted = CommandBuilder::new();
|
||||
quoted.add("-Dmsg=hello world");
|
||||
assert_eq!(quoted.display_args(), "\"-Dmsg=hello world\"");
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! 退出分析测试:退出码语义 + `exit 0` 静默失败(`analyse/02` §6.3 §7.2 §7.10-10)。
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
fn line(stream: Stream, text: &str) -> LogLine {
|
||||
LogLine::new(stream, text)
|
||||
}
|
||||
|
||||
fn analyze(
|
||||
code: i32,
|
||||
uptime_ms: u64,
|
||||
lines: &[LogLine],
|
||||
posix: bool,
|
||||
) -> unlucky_core::launch::exit_analysis::ExitAnalysis {
|
||||
let mut signals = exit_analysis::ExitSignals::new(code, uptime_ms, lines);
|
||||
signals.is_posix = posix;
|
||||
exit_analysis::analyze(&signals)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_long_run_is_normal() {
|
||||
let lines = vec![line(
|
||||
Stream::Stdout,
|
||||
"[12:00:00] [Render thread/INFO]: started",
|
||||
)];
|
||||
let analysis = analyze(0, 120_000, &lines, true);
|
||||
assert_eq!(analysis.exit_type, ExitType::Normal);
|
||||
assert!(analysis.silent_failure.is_none());
|
||||
assert!(!analysis.needs_diagnosis);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_zero_short_lived_is_silent_failure() {
|
||||
let lines = vec![line(Stream::Stdout, "Stopping!")];
|
||||
let analysis = analyze(0, 8_000, &lines, true);
|
||||
assert_eq!(analysis.exit_type, ExitType::Normal);
|
||||
let silent = analysis.silent_failure.expect("应标记静默失败");
|
||||
assert!(silent.short_lived);
|
||||
assert!(!silent.error_output);
|
||||
assert!(analysis.needs_diagnosis, "静默失败必须进诊断流水线");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_zero_with_error_output_is_silent_failure() {
|
||||
let lines = vec![
|
||||
line(Stream::Stdout, "[12:00:00] [main/INFO]: loading"),
|
||||
line(
|
||||
Stream::Stderr,
|
||||
"java.lang.RuntimeException: mixin apply failed",
|
||||
),
|
||||
];
|
||||
let analysis = analyze(0, 90_000, &lines, true);
|
||||
assert_eq!(analysis.exit_type, ExitType::Normal);
|
||||
let silent = analysis.silent_failure.expect("应标记静默失败");
|
||||
assert!(silent.error_output);
|
||||
assert!(!silent.short_lived);
|
||||
assert!(silent.describe().contains("错误特征"));
|
||||
assert_eq!(analysis.error_lines, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jvm_keywords_take_precedence() {
|
||||
let lines = vec![line(
|
||||
Stream::Stderr,
|
||||
"Error occurred during initialization of VM",
|
||||
)];
|
||||
let analysis = analyze(1, 500, &lines, true);
|
||||
assert_eq!(analysis.exit_type, ExitType::JvmError);
|
||||
assert_eq!(analysis.jvm_keywords.len(), 1);
|
||||
assert!(analysis.needs_diagnosis);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonzero_without_keywords_is_application_error() {
|
||||
let lines = vec![line(Stream::Stdout, "[12:00:00] [main/INFO]: hi")];
|
||||
let analysis = analyze(1, 40_000, &lines, true);
|
||||
assert_eq!(analysis.exit_type, ExitType::ApplicationError);
|
||||
assert!(analysis.needs_diagnosis);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crash_keywords_promote_exit_zero() {
|
||||
let lines = vec![line(
|
||||
Stream::Stdout,
|
||||
"[12:00:00] [Render thread/ERROR]: Crash report saved to ./crash-reports/crash-1.txt",
|
||||
)];
|
||||
let analysis = analyze(0, 60_000, &lines, true);
|
||||
assert_eq!(analysis.exit_type, ExitType::ApplicationError);
|
||||
assert_eq!(analysis.crash_keywords.len(), 1);
|
||||
assert!(
|
||||
analysis.silent_failure.is_none(),
|
||||
"已判异常就不再是静默失败"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posix_signals_are_recognized() {
|
||||
assert_eq!(analyze(137, 60_000, &[], true).exit_type, ExitType::SigKill);
|
||||
assert_eq!(analyze(143, 60_000, &[], true).exit_type, ExitType::SigTerm);
|
||||
// Windows 上 137 没有信号语义,按应用错误处理。
|
||||
assert_eq!(
|
||||
analyze(137, 60_000, &[], false).exit_type,
|
||||
ExitType::ApplicationError
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_ntstatus_is_explained() {
|
||||
let analysis = analyze(-1_073_741_819, 60_000, &[], false);
|
||||
assert_eq!(analysis.exit_type, ExitType::ApplicationError);
|
||||
assert_eq!(
|
||||
analysis.windows_status.as_deref(),
|
||||
Some("0xC0000005 ACCESS_VIOLATION")
|
||||
);
|
||||
|
||||
let dll = analyze(-1_073_741_515, 60_000, &[], false);
|
||||
assert_eq!(
|
||||
dll.windows_status.as_deref(),
|
||||
Some("0xC0000135 DLL_NOT_FOUND")
|
||||
);
|
||||
|
||||
// 0xC000013A = STATUS_CONTROL_C_EXIT(控制台 Ctrl+C / 关窗口),十进制 -1073741510。
|
||||
let control_c = analyze(-1_073_741_510, 60_000, &[], false);
|
||||
assert_eq!(
|
||||
control_c.windows_status.as_deref(),
|
||||
Some("0xC000013A CONTROL_C_EXIT")
|
||||
);
|
||||
// 回归:-1073741743 其实是 0xC0000051,不得被当成 CONTROL_C_EXIT。
|
||||
assert!(analyze(-1_073_741_743, 60_000, &[], false)
|
||||
.windows_status
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn killed_and_interrupted_override_exit_code() {
|
||||
let mut signals = exit_analysis::ExitSignals::new(1, 5_000, &[]);
|
||||
signals.killed = true;
|
||||
assert_eq!(exit_analysis::analyze(&signals).exit_type, ExitType::Killed);
|
||||
|
||||
let mut signals = exit_analysis::ExitSignals::new(1, 5_000, &[]);
|
||||
signals.interrupted = true;
|
||||
assert_eq!(
|
||||
exit_analysis::analyze(&signals).exit_type,
|
||||
ExitType::Interrupted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_type_strings_match_contract() {
|
||||
// 与 M15 刻意保持一致的字符串集合(`launch-接口约定.md` §3)。
|
||||
assert_eq!(ExitType::Normal.as_str(), "normal");
|
||||
assert_eq!(ExitType::ApplicationError.as_str(), "application_error");
|
||||
assert_eq!(ExitType::JvmError.as_str(), "jvm_error");
|
||||
assert_eq!(ExitType::SigKill.as_str(), "sigkill");
|
||||
assert_eq!(ExitType::SigTerm.as_str(), "sigterm");
|
||||
assert_eq!(ExitType::Killed.as_str(), "killed");
|
||||
assert_eq!(ExitType::Interrupted.as_str(), "interrupted");
|
||||
assert!(!ExitType::Normal.is_abnormal());
|
||||
assert!(ExitType::JvmError.is_abnormal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_is_human_readable() {
|
||||
let analysis = analyze(-1_073_741_819, 61_500, &[], false);
|
||||
let summary = analysis.summary();
|
||||
assert!(summary.contains("-1073741819"));
|
||||
assert!(summary.contains("61.5s"));
|
||||
assert!(summary.contains("ACCESS_VIOLATION"));
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! `--graphicsBackend` 的两套判据(能力位 vs 精确版本门)与周快照放行。
|
||||
//!
|
||||
//! 事实基线(`research/05-mc-vulkan.md` §3.2/§4.2,实测):
|
||||
//! - `--graphicsBackend` 从 **26.2-snapshot-2**(2026-04-09)才有;
|
||||
//! - `org.lwjgl:lwjgl-vulkan` 从 **26.2-snapshot-1**(2026-04-07)就有 —— 差两天;
|
||||
//! - 周快照(`26w14a`)无法与 `26.2-snapshot-N` 排序,只能靠库表判。
|
||||
//!
|
||||
//! 结论:能力位**不能**替代精确版本门,两者的分工是本文件的断言对象。
|
||||
|
||||
use unlucky_core::launch::game_args::{graphics_backend_allowed, GRAPHICS_BACKEND_MIN_VERSION};
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn known_versions_use_the_exact_threshold_and_ignore_the_capability_bit() {
|
||||
// 26.2-snapshot-1:有 Vulkan 库却没有参数,绝不能因为能力位放行
|
||||
// (jopt-simple 未开 allowsUnrecognizedOptions,未声明选项会让游戏直接起不来)。
|
||||
assert!(!graphics_backend_allowed(
|
||||
&GameVersion::parse("26.2-snapshot-1"),
|
||||
true
|
||||
));
|
||||
assert!(!graphics_backend_allowed(
|
||||
&GameVersion::parse("26.2-snapshot-1"),
|
||||
false
|
||||
));
|
||||
// 门限上的那一版起放行(能力位无关)。
|
||||
assert!(graphics_backend_allowed(
|
||||
&GameVersion::parse(GRAPHICS_BACKEND_MIN_VERSION),
|
||||
false
|
||||
));
|
||||
assert!(graphics_backend_allowed(&GameVersion::parse("26.2"), false));
|
||||
assert!(graphics_backend_allowed(&GameVersion::parse("26.3"), false));
|
||||
// 可解析的老版本:能力位再真也不放行。
|
||||
assert!(!graphics_backend_allowed(
|
||||
&GameVersion::parse("1.20.1"),
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_snapshots_fall_back_to_the_capability_bit() {
|
||||
let weekly = GameVersion::parse("26w14a");
|
||||
assert!(!weekly.is_known(), "周快照不做数字排序(不猜)");
|
||||
assert!(weekly.is_weekly_snapshot());
|
||||
|
||||
// 26.1 线(含周快照)没有 lwjgl-vulkan → 丢弃,和以前一样。
|
||||
assert!(!graphics_backend_allowed(&weekly, false));
|
||||
// 带 Vulkan 库的周快照(26.2 之后的周快照)→ 不再一律丢弃(这就是被修的误伤)。
|
||||
assert!(graphics_backend_allowed(&weekly, true));
|
||||
assert!(graphics_backend_allowed(
|
||||
&GameVersion::parse("26w30a"),
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_weekly_unknown_versions_are_still_dropped() {
|
||||
// 第三方自定义版本串没有可依据的语义,能力位为真也不猜。
|
||||
let custom = GameVersion::parse("custom-build");
|
||||
assert!(!custom.is_known());
|
||||
assert!(!custom.is_weekly_snapshot());
|
||||
assert!(!graphics_backend_allowed(&custom, true));
|
||||
assert!(!graphics_backend_allowed(&GameVersion::default(), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_snapshot_shape_is_parsed_narrowly() {
|
||||
for shape in ["20w14a", "26w14a", "26W14A", "12w01a"] {
|
||||
assert!(
|
||||
GameVersion::parse(shape).is_weekly_snapshot(),
|
||||
"{shape} 应识别为周快照"
|
||||
);
|
||||
}
|
||||
for other in ["26.2-snapshot-1", "26w14", "26ww14a", "w14a", "26w14ab", ""] {
|
||||
assert!(
|
||||
!GameVersion::parse(other).is_weekly_snapshot(),
|
||||
"{other} 不该被当成周快照"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assemble_lets_a_vulkan_weekly_snapshot_through() {
|
||||
let (request, ctx) = weekly_request("26w30a", true);
|
||||
let assembled = assemble(&request, &ctx).expect("组装应成功");
|
||||
assert!(
|
||||
assembled
|
||||
.args
|
||||
.windows(2)
|
||||
.any(|pair| pair[0] == "--graphicsBackend" && pair[1] == "vulkan"),
|
||||
"带 Vulkan 库的周快照应下发 --graphicsBackend:{:?}",
|
||||
assembled.args
|
||||
);
|
||||
assert!(!assembled
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assemble_still_drops_it_for_a_pre_vulkan_weekly_snapshot() {
|
||||
let (request, ctx) = weekly_request("26w14a", false);
|
||||
let assembled = assemble(&request, &ctx).expect("组装应成功");
|
||||
assert!(!assembled.args.iter().any(|arg| arg == "--graphicsBackend"));
|
||||
assert!(assembled
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| matches!(warning, AssemblyWarning::GraphicsBackendSkipped { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_derives_the_capability_bit_from_the_library_names() {
|
||||
let dir = temp_dir("adapter-vulkan-capability");
|
||||
let with_vulkan = vec![
|
||||
LibraryInput::new(dir.join("libraries/lwjgl.jar"), "org.lwjgl:lwjgl:3.4.3"),
|
||||
LibraryInput::new(
|
||||
dir.join("libraries/lwjgl-vulkan.jar"),
|
||||
"org.lwjgl:lwjgl-vulkan:3.4.3",
|
||||
),
|
||||
];
|
||||
let without_vulkan = vec![LibraryInput::new(
|
||||
dir.join("libraries/lwjgl.jar"),
|
||||
"org.lwjgl:lwjgl:3.4.3",
|
||||
)];
|
||||
|
||||
for (libraries, expected) in [(&with_vulkan, true), (&without_vulkan, false)] {
|
||||
let snapshot = SnapshotInput {
|
||||
main_class: Some("net.minecraft.client.main.Main"),
|
||||
libraries,
|
||||
..SnapshotInput::minimal("26.3", "")
|
||||
};
|
||||
let output = build_request(&snapshot, AdapterOptions::strict());
|
||||
assert_eq!(
|
||||
output.request.manifest.has_vulkan_backend, expected,
|
||||
"库表 {libraries:?} 的能力位判定不对"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个只关心 `--graphicsBackend` 的请求(周快照版本 + 指定能力位)。
|
||||
fn weekly_request(version: &str, has_vulkan_backend: bool) -> (LaunchRequest, PlatformCtx) {
|
||||
let (mut request, ctx) = legacy_forge_request();
|
||||
request.instance.version = GameVersion::parse(version);
|
||||
request.manifest.has_vulkan_backend = has_vulkan_backend;
|
||||
request.graphics_backend = Some("vulkan".to_string());
|
||||
(request, ctx)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! launch 模块集成测试入口(`cargo test --test launch`)。
|
||||
|
||||
mod adapter_cases;
|
||||
mod cmdline_snapshot;
|
||||
mod command_builder_cases;
|
||||
mod exit_cases;
|
||||
mod graphics_backend_cases;
|
||||
mod natives_args_cases;
|
||||
mod natives_cases;
|
||||
mod pipeline_cases;
|
||||
mod prelude;
|
||||
mod process_smoke;
|
||||
mod pump_buffer;
|
||||
mod session_events;
|
||||
mod sink_cases;
|
||||
mod tree_kill_cases;
|
||||
@@ -0,0 +1,136 @@
|
||||
//! natives 时代分界与参数接线:清单口径收集、`-Djava.library.path` 归属、1.19+ 四个目录属性。
|
||||
//!
|
||||
//! 抽取行为本身在 `natives_cases.rs`;这里只验「参数从哪来、收集口径对不对」。
|
||||
|
||||
use unlucky_core::launch::natives;
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
/// 参数接线:legacy 清单没有 `arguments.jvm`,`-Djava.library.path` 由启动器补且只补一次。
|
||||
#[test]
|
||||
fn legacy_manifest_gets_single_java_library_path() {
|
||||
let (request, ctx) = legacy_forge_request();
|
||||
let command = assemble(&request, &ctx).expect("组装应成功");
|
||||
let natives = request.dirs.natives_dir.display().to_string();
|
||||
let library_path: Vec<&String> = command
|
||||
.args
|
||||
.iter()
|
||||
.filter(|arg| arg.starts_with("-Djava.library.path="))
|
||||
.collect();
|
||||
assert_eq!(library_path.len(), 1, "legacy 只该有一条:{library_path:?}");
|
||||
assert_eq!(library_path[0], &format!("-Djava.library.path={natives}"));
|
||||
}
|
||||
|
||||
/// 参数接线:1.13+ 清单自带 `-Djava.library.path=${natives_directory}`,启动器不得重复注入。
|
||||
#[test]
|
||||
fn modern_manifest_keeps_its_own_library_path_once() {
|
||||
let (mut request, ctx) = legacy_forge_request();
|
||||
let natives = request.dirs.natives_dir.display().to_string();
|
||||
request.manifest.jvm = vec![
|
||||
ArgumentEntry::plain(["-Djava.library.path=${natives_directory}".to_string()]),
|
||||
ArgumentEntry::plain(["-cp".to_string(), "${classpath}".to_string()]),
|
||||
];
|
||||
request.manifest.legacy_arguments = None;
|
||||
let command = assemble(&request, &ctx).expect("组装应成功");
|
||||
let library_path: Vec<&String> = command
|
||||
.args
|
||||
.iter()
|
||||
.filter(|arg| arg.starts_with("-Djava.library.path="))
|
||||
.collect();
|
||||
assert_eq!(library_path.len(), 1, "清单 + 默认值不该叠加");
|
||||
assert_eq!(library_path[0], &format!("-Djava.library.path={natives}"));
|
||||
}
|
||||
|
||||
/// 参数接线:1.19+ 的四个目录属性都要渲染到同一目录(LWJGL 3.3 运行期往那里解压)。
|
||||
#[test]
|
||||
fn classpath_native_era_renders_all_four_directory_properties() {
|
||||
let (mut request, ctx) = legacy_forge_request();
|
||||
let natives = request.dirs.natives_dir.display().to_string();
|
||||
request.manifest.jvm = vec![ArgumentEntry::plain([
|
||||
"-Djava.library.path=${natives_directory}".to_string(),
|
||||
"-Djna.tmpdir=${natives_directory}".to_string(),
|
||||
"-Dorg.lwjgl.system.SharedLibraryExtractPath=${natives_directory}".to_string(),
|
||||
"-Dio.netty.native.workdir=${natives_directory}".to_string(),
|
||||
])];
|
||||
request.manifest.legacy_arguments = None;
|
||||
let command = assemble(&request, &ctx).expect("组装应成功");
|
||||
for key in [
|
||||
"-Djava.library.path=",
|
||||
"-Djna.tmpdir=",
|
||||
"-Dorg.lwjgl.system.SharedLibraryExtractPath=",
|
||||
"-Dio.netty.native.workdir=",
|
||||
] {
|
||||
assert!(
|
||||
command
|
||||
.args
|
||||
.iter()
|
||||
.any(|arg| arg == &format!("{key}{natives}")),
|
||||
"缺少 {key}{natives}:{:?}",
|
||||
command.args
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 收集口径:`natives` 表按 OS 命中 → 分类器 jar;1.19+ 无表 → 空;
|
||||
/// `hint=local` 走实例私有目录(HMCL `analyse/03` §6.8 口径)。
|
||||
///
|
||||
/// 注意:安装层当前的 natives 分类器分支**不认** `hint=local`(走共享 `libraries/`),
|
||||
/// 所以这一条断言测的是 `collect` 的正确行为,不是「与安装层一致」——见
|
||||
/// `collect` 文档里的已知缺口说明。
|
||||
#[test]
|
||||
fn collect_picks_classifier_jars_per_environment() {
|
||||
use unlucky_core::game::{Environment, GameLayout, Library};
|
||||
|
||||
let libraries: Vec<Library> = serde_json::from_str(
|
||||
r#"[
|
||||
{"name":"org.lwjgl:lwjgl:3.2.2",
|
||||
"natives":{"linux":"natives-linux","windows":"natives-windows"},
|
||||
"extract":{"exclude":["META-INF/"]},
|
||||
"downloads":{"artifact":{"path":"org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2.jar","url":"https://libraries.minecraft.net/a.jar"},
|
||||
"classifiers":{"natives-windows":{"path":"org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-windows.jar","url":"https://libraries.minecraft.net/b.jar"}}}},
|
||||
{"name":"org.lwjgl:lwjgl-tinyfd:3.2.2","natives":{"windows":"natives-windows"},
|
||||
"hint":"local",
|
||||
"downloads":{"classifiers":{"natives-windows":{"path":"org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2-natives-windows.jar","url":"https://libraries.minecraft.net/c.jar"}}}},
|
||||
{"name":"org.lwjgl:lwjgl:3.3.3:natives-linux",
|
||||
"rules":[{"action":"allow","os":{"name":"linux"}}],
|
||||
"downloads":{"artifact":{"path":"org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-linux.jar","url":"https://libraries.minecraft.net/d.jar"}}}
|
||||
]"#,
|
||||
)
|
||||
.expect("库 JSON 应可解析");
|
||||
let layout = GameLayout::new("C:/mc");
|
||||
let environment = Environment {
|
||||
os: "windows".to_string(),
|
||||
os_version: "10.0.19045".to_string(),
|
||||
arch: "x86_64".to_string(),
|
||||
..Environment::default()
|
||||
};
|
||||
|
||||
let natives = natives::collect(&layout, "1.16.5", &libraries, &environment);
|
||||
assert_eq!(natives.len(), 2, "只该收 windows 分类器:{natives:?}");
|
||||
assert!(
|
||||
natives[0]
|
||||
.path
|
||||
.ends_with("libraries/org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-windows.jar"),
|
||||
"{:?}",
|
||||
natives[0].path
|
||||
);
|
||||
assert_eq!(natives[0].name, "org.lwjgl:lwjgl:3.2.2:natives-windows");
|
||||
assert_eq!(natives[0].exclude, vec!["META-INF/".to_string()]);
|
||||
// `hint=local`:按 HMCL 口径落实例私有目录(安装层尚未跟进,见函数文档)。
|
||||
assert!(
|
||||
natives[1]
|
||||
.path
|
||||
.ends_with("versions/1.16.5/libraries/org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2-natives-windows.jar"),
|
||||
"{:?}",
|
||||
natives[1].path
|
||||
);
|
||||
|
||||
// 1.19+ 形态(无 natives 表)→ 空表(natives jar 在 classpath 上)。
|
||||
let modern: Vec<Library> = serde_json::from_str(
|
||||
r#"[{"name":"org.lwjgl:lwjgl:3.3.3:natives-windows",
|
||||
"rules":[{"action":"allow","os":{"name":"windows"}}],
|
||||
"downloads":{"artifact":{"path":"org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows.jar","url":"https://libraries.minecraft.net/e.jar"}}}]"#,
|
||||
)
|
||||
.expect("库 JSON 应可解析");
|
||||
assert!(natives::collect(&layout, "1.20.1", &modern, &environment).is_empty());
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
//! natives 解压(`launch::natives`)的用例:解析正确性 / 排除项 / 幂等 / 时代参数 / 事件流。
|
||||
//!
|
||||
//! 假 jar 用 `zip` 的 Stored 模式现造(不依赖真下载),真机验收另有人工步骤。
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::{CompressionMethod, ZipWriter};
|
||||
|
||||
use unlucky_core::launch::natives::{self, NativeJar, NativesEra, NativesPlan, STAMP_FILE};
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
/// 造一个 natives jar:`entries` 是(条目名, 内容)。
|
||||
fn make_jar(path: &Path, entries: &[(&str, &[u8])]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建 jar 目录");
|
||||
}
|
||||
let file = File::create(path).expect("建 jar");
|
||||
let mut writer = ZipWriter::new(file);
|
||||
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
|
||||
for (name, bytes) in entries {
|
||||
writer.start_file(*name, options).expect("写条目");
|
||||
writer.write_all(bytes).expect("写条目内容");
|
||||
}
|
||||
writer.finish().expect("收尾 jar");
|
||||
}
|
||||
|
||||
/// 一份 1.16.5 风格的 natives jar 的条目集(原生库 + 该被排除的噪音)。
|
||||
fn lwjgl_jar_entries() -> Vec<(&'static str, &'static [u8])> {
|
||||
vec![
|
||||
("lwjgl.dll", b"DLL-BYTES".as_slice()),
|
||||
("OpenAL.dll", b"OPENAL-BYTES".as_slice()),
|
||||
("META-INF/MANIFEST.MF", b"manifest".as_slice()),
|
||||
("META-INF/LWJGL.SF", b"signature".as_slice()),
|
||||
("lwjgl.dll.sha1", b"deadbeef".as_slice()),
|
||||
("README.txt", b"noise".as_slice()),
|
||||
]
|
||||
}
|
||||
|
||||
fn plan(jars: Vec<NativeJar>) -> NativesPlan {
|
||||
NativesPlan::new(jars)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_native_libraries_and_drops_noise() {
|
||||
let root = temp_dir("natives-extract");
|
||||
let jar = root.join("libraries/lwjgl-natives-windows.jar");
|
||||
make_jar(&jar, &lwjgl_jar_entries());
|
||||
let target = root.join("versions/1.16.5/natives");
|
||||
|
||||
let report =
|
||||
natives::extract(&target, &plan(vec![NativeJar::new(&jar, "lwjgl")])).expect("解压应成功");
|
||||
|
||||
assert_eq!(report.files, 2, "只该解出两个原生库:{report:?}");
|
||||
assert!(!report.up_to_date);
|
||||
assert_eq!(report.jars, 1);
|
||||
// META-INF/ 两条 + .sha1 + README.txt。
|
||||
assert_eq!(report.ignored, 4, "噪音条目数:{report:?}");
|
||||
assert_eq!(
|
||||
std::fs::read(target.join("lwjgl.dll")).expect("读 dll"),
|
||||
b"DLL-BYTES"
|
||||
);
|
||||
assert!(target.join("OpenAL.dll").is_file());
|
||||
assert!(!target.join("META-INF").exists(), "META-INF/ 不该落地");
|
||||
assert!(!target.join("lwjgl.dll.sha1").exists(), "签名文件不该落地");
|
||||
assert!(!target.join("README.txt").exists(), "非原生库不该落地");
|
||||
assert!(target.join(STAMP_FILE).is_file(), "应写戳记");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_run_is_idempotent_and_source_change_reextracts() {
|
||||
let root = temp_dir("natives-idempotent");
|
||||
let jar = root.join("natives-windows.jar");
|
||||
make_jar(&jar, &[("lwjgl.dll", b"V1".as_slice())]);
|
||||
let target = root.join("natives");
|
||||
let extract_plan = plan(vec![NativeJar::new(&jar, "lwjgl")]);
|
||||
|
||||
let first = natives::extract(&target, &extract_plan).expect("首次解压");
|
||||
assert_eq!(first.files, 1);
|
||||
assert!(!first.up_to_date);
|
||||
|
||||
// 第二次:戳记命中,零写盘(文件 mtime 不变)。
|
||||
let before = std::fs::metadata(target.join("lwjgl.dll"))
|
||||
.expect("stat")
|
||||
.modified()
|
||||
.expect("mtime");
|
||||
let second = natives::extract(&target, &extract_plan).expect("二次解压");
|
||||
assert!(second.up_to_date, "应判定目录已就绪:{second:?}");
|
||||
assert_eq!(second.files, 0);
|
||||
assert_eq!(second.skipped, 1);
|
||||
let after = std::fs::metadata(target.join("lwjgl.dll"))
|
||||
.expect("stat")
|
||||
.modified()
|
||||
.expect("mtime");
|
||||
assert_eq!(before, after, "幂等路径不该重写文件");
|
||||
|
||||
// 源 jar 变了(内容 + 长度都变):必须重解,且旧的原生库被清掉。
|
||||
make_jar(&jar, &[("lwjgl.dll", b"V2-LONGER".as_slice())]);
|
||||
let third = natives::extract(&target, &extract_plan).expect("三次解压");
|
||||
assert!(!third.up_to_date, "源变了必须重解:{third:?}");
|
||||
assert_eq!(
|
||||
std::fs::read(target.join("lwjgl.dll")).expect("读 dll"),
|
||||
b"V2-LONGER"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_native_libraries_are_cleaned_before_reextract() {
|
||||
let root = temp_dir("natives-clean");
|
||||
let jar = root.join("natives-windows.jar");
|
||||
make_jar(&jar, &[("lwjgl.dll", b"NEW".as_slice())]);
|
||||
let target = root.join("natives");
|
||||
std::fs::create_dir_all(&target).expect("建目录");
|
||||
// 上一代遗留(旧版本 / 旧 loader 解出来的),必须清掉,否则两代 dll 混在 java.library.path 里。
|
||||
std::fs::write(target.join("old-lwjgl.dll"), b"stale").expect("写旧 dll");
|
||||
std::fs::write(target.join("keep-me.txt"), b"user file").expect("写用户文件");
|
||||
|
||||
natives::extract(&target, &plan(vec![NativeJar::new(&jar, "lwjgl")])).expect("解压");
|
||||
|
||||
assert!(!target.join("old-lwjgl.dll").exists(), "旧原生库应被清理");
|
||||
assert!(target.join("keep-me.txt").is_file(), "非原生库文件不该被删");
|
||||
assert!(target.join("lwjgl.dll").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_plan_still_creates_directory_for_1_19() {
|
||||
// 1.19+ 没有 natives 分类器:目录仍要存在(SharedLibraryExtractPath 指向它)。
|
||||
let root = temp_dir("natives-empty");
|
||||
let target = root.join("versions/1.21.4/natives");
|
||||
let report = natives::extract(&target, &NativesPlan::default()).expect("建目录");
|
||||
assert!(target.is_dir());
|
||||
assert!(report.up_to_date);
|
||||
assert_eq!(report.files, 0);
|
||||
assert_eq!(report.jars, 0);
|
||||
assert_eq!(
|
||||
report.era,
|
||||
NativesEra::ClasspathNative,
|
||||
"默认时代(无 jar)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_directory_is_an_error_not_a_silent_pass() {
|
||||
let plan = NativesPlan::new(vec![NativeJar::new("x.jar", "x")]);
|
||||
let err = natives::extract(Path::new(""), &plan).expect_err("空目录必须报错");
|
||||
assert!(err.message().contains("natives 目录"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_jar_reports_zip_error() {
|
||||
let root = temp_dir("natives-broken");
|
||||
let jar = root.join("natives-broken.jar");
|
||||
std::fs::write(&jar, b"not a zip at all").expect("写坏 jar");
|
||||
let target = root.join("natives");
|
||||
let err = natives::extract(&target, &plan(vec![NativeJar::new(&jar, "broken")]))
|
||||
.expect_err("坏 jar 必须报错");
|
||||
assert!(err.message().contains("zip"), "错误应点明 zip:{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn era_detect_covers_three_generations() {
|
||||
assert_eq!(NativesEra::detect(0, 3), NativesEra::Legacy);
|
||||
assert_eq!(NativesEra::detect(9, 3), NativesEra::Lwjgl3);
|
||||
assert_eq!(NativesEra::detect(9, 0), NativesEra::ClasspathNative);
|
||||
assert_eq!(NativesEra::Legacy.as_str(), "legacy");
|
||||
assert_eq!(NativesEra::Lwjgl3.as_str(), "lwjgl3");
|
||||
assert_eq!(NativesEra::ClasspathNative.as_str(), "classpath_native");
|
||||
}
|
||||
|
||||
/// 事件流:`natives_extracted` 落盘字段与接口约定一致(dir/files/skipped/custom)。
|
||||
#[test]
|
||||
fn natives_extracted_event_carries_report_fields() {
|
||||
let root = temp_dir("natives-event");
|
||||
let jar = root.join("natives-windows.jar");
|
||||
make_jar(&jar, &[("lwjgl.dll", b"DLL".as_slice())]);
|
||||
let target = root.join("natives");
|
||||
let report =
|
||||
natives::extract(&target, &plan(vec![NativeJar::new(&jar, "lwjgl")])).expect("解压");
|
||||
|
||||
let sessions = temp_sessions_root("natives-event-sessions");
|
||||
let mut writer = SessionWriter::create(
|
||||
&sessions,
|
||||
SessionMeta::new("inst", "1.16.5", "1.16.5"),
|
||||
Redactor::new(),
|
||||
)
|
||||
.expect("建会话");
|
||||
writer.natives_extracted(&report).expect("写事件");
|
||||
let path = writer.path().to_path_buf();
|
||||
writer.finish(EndReason::Exit).expect("收尾");
|
||||
|
||||
let events = read_events(&path);
|
||||
let event = events
|
||||
.iter()
|
||||
.find(|event| event["kind"] == "natives_extracted")
|
||||
.expect("应有 natives_extracted 事件");
|
||||
let payload = &event["payload"];
|
||||
assert_eq!(payload["files"], 1);
|
||||
assert_eq!(payload["skipped"], 0);
|
||||
assert_eq!(payload["custom"], false);
|
||||
assert_eq!(payload["jars"], 1);
|
||||
assert_eq!(payload["up_to_date"], false);
|
||||
assert_eq!(payload["era"], "lwjgl3");
|
||||
assert!(payload["dir"]
|
||||
.as_str()
|
||||
.expect("dir 是字符串")
|
||||
.ends_with("natives"));
|
||||
}
|
||||
|
||||
/// `--dry-run` 也做环境准备:第二次跑要看到 up_to_date(真机验收的判定依据)。
|
||||
#[test]
|
||||
fn dry_run_prepares_natives_and_is_idempotent() {
|
||||
let root = temp_dir("natives-dry-run");
|
||||
let jar = root.join("natives-windows.jar");
|
||||
make_jar(&jar, &[("lwjgl.dll", b"DLL".as_slice())]);
|
||||
let natives_dir: PathBuf = root.join("versions/1.16.5/natives");
|
||||
|
||||
let (mut request, _) = legacy_forge_request();
|
||||
request.dirs.natives_dir = natives_dir.clone();
|
||||
request.natives = NativesPlan::new(vec![NativeJar::new(&jar, "lwjgl")]);
|
||||
request.manifest.legacy_arguments = Some("--username ${auth_player_name}".to_string());
|
||||
|
||||
let sessions = temp_sessions_root("natives-dry-run-sessions");
|
||||
let launcher = Launcher::new(LauncherConfig::new(&sessions));
|
||||
|
||||
let first = launcher.dry_run(&request).expect("首次 dry-run");
|
||||
let report = first.natives.expect("应有 natives 报告");
|
||||
assert_eq!(report.files, 1, "{report:?}");
|
||||
assert!(!report.up_to_date);
|
||||
assert!(natives_dir.join("lwjgl.dll").is_file());
|
||||
|
||||
let second = launcher.dry_run(&request).expect("二次 dry-run");
|
||||
let report = second.natives.expect("应有 natives 报告");
|
||||
assert!(report.up_to_date, "第二次应命中戳记:{report:?}");
|
||||
assert_eq!(report.skipped, 1);
|
||||
}
|
||||
|
||||
/// 缺 natives jar:适配层当成组件缺失(组装前显式失败),而不是等到 UnsatisfiedLinkError。
|
||||
#[test]
|
||||
fn missing_native_jar_becomes_missing_component() {
|
||||
let (mut request, ctx) = legacy_forge_request();
|
||||
request.natives = NativesPlan::new(vec![NativeJar::new(
|
||||
"C:/mc/libraries/missing-natives-windows.jar",
|
||||
"org.lwjgl:lwjgl:3.2.2:natives-windows",
|
||||
)]);
|
||||
// 直接验证适配层:给一个不存在的 natives jar,严格模式应产出 MissingNativeJar 告警。
|
||||
let libraries: Vec<LibraryInput> = request
|
||||
.classpath
|
||||
.iter()
|
||||
.map(|path| LibraryInput::new(path.clone(), "x"))
|
||||
.collect();
|
||||
let natives = request.natives.jars.clone();
|
||||
let snapshot = SnapshotInput {
|
||||
version_id: "1.16.5",
|
||||
main_class: Some("net.minecraft.client.main.Main"),
|
||||
libraries: &libraries,
|
||||
natives: &natives,
|
||||
..SnapshotInput::minimal("1.16.5", "net.minecraft.client.main.Main")
|
||||
};
|
||||
let options = AdapterOptions {
|
||||
java: JavaSelection::new("C:/java/bin/java.exe", 17, 64),
|
||||
..AdapterOptions::strict()
|
||||
};
|
||||
let output = build_request(&snapshot, options);
|
||||
assert!(
|
||||
output
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| matches!(warning, AdapterWarning::MissingNativeJar { .. })),
|
||||
"应有 MissingNativeJar 告警:{:?}",
|
||||
output.warnings
|
||||
);
|
||||
assert_eq!(output.request.natives.era, NativesEra::Legacy);
|
||||
// 组装按既有语义直接失败(missing_components 非空)。
|
||||
assert!(matches!(
|
||||
assemble(&output.request, &ctx).err(),
|
||||
Some(LaunchError::Assembly(AssemblyError::MissingComponents(_)))
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! 顶层启动管线测试:dry-run(D18)、完整会话、停止、组装失败、崩溃现场。
|
||||
//!
|
||||
//! 真进程来自 shell(`cmd /C` 或 `sh -c`),所以这些是端到端用例:
|
||||
//! 命令行组装 → 事件流落盘 → 退出判定 → 诊断挂钩 → 索引。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
/// 记录调用并返回固定结论的诊断挂钩。
|
||||
#[derive(Default)]
|
||||
struct RecordingHook {
|
||||
calls: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl DiagnosisHook for RecordingHook {
|
||||
fn analyze(&self, request: &DiagnosisRequest) -> Option<serde_json::Value> {
|
||||
self.calls.lock().unwrap().push(request.summary());
|
||||
Some(json!({
|
||||
"source": "rules",
|
||||
"needs_more_info": false,
|
||||
"candidates": [{ "id": "stub_candidate", "severity": "high", "confidence": 0.9 }],
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn launcher(sessions_root: &std::path::Path, hook: Option<Arc<RecordingHook>>) -> Launcher {
|
||||
let mut config = LauncherConfig::new(sessions_root);
|
||||
// 固定平台上下文,让命令行断言与运行平台无关。
|
||||
config.platform = PlatformCtx::new(OsFamily::Windows, "x86_64");
|
||||
config.process = ProcessConfig {
|
||||
graceful_stop: Duration::from_millis(200),
|
||||
..ProcessConfig::default()
|
||||
};
|
||||
let launcher = Launcher::new(config);
|
||||
match hook {
|
||||
Some(hook) => launcher.with_diagnosis_hook(hook),
|
||||
None => launcher,
|
||||
}
|
||||
}
|
||||
|
||||
/// 用 shell 冒充 java:只为验证管线,不验证 JVM 语义。
|
||||
fn shell_request(script: &str, run_dir: &std::path::Path) -> (LaunchRequest, LaunchRequest) {
|
||||
let (mut request, ctx) = legacy_forge_request();
|
||||
let _ = ctx;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
request.wrapper = vec!["cmd".to_string(), "/C".to_string(), script.to_string()];
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
request.wrapper = vec!["/bin/sh".to_string(), "-c".to_string(), script.to_string()];
|
||||
}
|
||||
request.java = JavaSelection::new("fake-java", 17, 64);
|
||||
request.manifest.legacy_arguments = None;
|
||||
request.dirs.game_dir = run_dir.to_path_buf();
|
||||
// natives 解压是管线第 6 段,会真写盘——指到临时目录,绝不碰 `C:/mc`。
|
||||
request.dirs.natives_dir = run_dir.join("natives");
|
||||
request.user.jvm = Vec::new();
|
||||
request.user.game = Vec::new();
|
||||
(request.clone(), request)
|
||||
}
|
||||
|
||||
fn echo_script() -> String {
|
||||
// 命令里带上 access_token 明文,用来验证“落盘前脱敏”。
|
||||
#[cfg(windows)]
|
||||
{
|
||||
"echo LWJGL Version: 3.3.1 SECRET-TOKEN-123456".to_string()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
"echo 'LWJGL Version: 3.3.1 SECRET-TOKEN-123456'".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn long_running_script() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
"ping -n 30 127.0.0.1 >nul".to_string()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
"sleep 30".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_returns_command_without_spawning_or_writing() {
|
||||
let sessions = temp_sessions_root("dry");
|
||||
let run_dir = temp_dir("dry-run");
|
||||
let launcher = launcher(&sessions, None);
|
||||
let (request, _) = shell_request(&echo_script(), &run_dir);
|
||||
|
||||
let dry: DryRun = launcher.dry_run(&request).expect("dry-run 应成功");
|
||||
assert!(!dry.argv.is_empty());
|
||||
assert!(
|
||||
dry.display.contains("fake-java")
|
||||
|| dry.display.contains("cmd")
|
||||
|| dry.display.contains("/bin/sh")
|
||||
);
|
||||
assert!(
|
||||
dry.display.contains("<redacted:token>"),
|
||||
"命令行必须脱敏:{}",
|
||||
dry.display
|
||||
);
|
||||
assert!(!dry.display.contains("SECRET-TOKEN-123456"));
|
||||
assert_eq!(dry.classpath_entries, 3);
|
||||
assert_eq!(dry.line(), dry.display.as_str());
|
||||
// 不 spawn、不写会话文件。
|
||||
assert!(!sessions.exists(), "dry-run 不能创建会话目录");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_records_session_events_diagnosis_and_index() {
|
||||
let sessions = temp_sessions_root("pipeline");
|
||||
let run_dir = temp_dir("pipeline-run");
|
||||
let hook = Arc::new(RecordingHook::default());
|
||||
let launcher = launcher(&sessions, Some(hook.clone()));
|
||||
let (request, _) = shell_request(&echo_script(), &run_dir);
|
||||
|
||||
let handle: SessionHandle = launcher.launch(&request).expect("启动应成功");
|
||||
assert_eq!(handle.exit.exit_type, ExitType::Normal);
|
||||
assert_eq!(handle.exit.code, 0);
|
||||
// 存活 <30s → 静默失败 + 需要诊断。
|
||||
assert!(handle.exit.silent_failure.is_some());
|
||||
assert!(handle.needs_diagnosis());
|
||||
assert_eq!(hook.calls.lock().unwrap().len(), 1, "诊断挂钩应被调用一次");
|
||||
assert_eq!(
|
||||
handle
|
||||
.diagnostics
|
||||
.as_ref()
|
||||
.and_then(|value| value["candidates"][0]["id"].as_str()),
|
||||
Some("stub_candidate")
|
||||
);
|
||||
assert!(
|
||||
handle.event_write_errors.is_empty(),
|
||||
"{:?}",
|
||||
handle.event_write_errors
|
||||
);
|
||||
// 对外展示的 argv 已是脱敏版本(脚本里带着 token 明文)。
|
||||
assert!(handle
|
||||
.redacted_argv()
|
||||
.iter()
|
||||
.any(|arg| arg.contains("<redacted:token>")));
|
||||
assert!(!handle
|
||||
.redacted_argv()
|
||||
.iter()
|
||||
.any(|arg| arg.contains("SECRET-TOKEN-123456")));
|
||||
assert!(handle.redacted_display().contains("<redacted:token>"));
|
||||
assert!(handle.index.is_some());
|
||||
assert!(handle.uptime_ms < 30_000);
|
||||
|
||||
let events = read_events(&handle.log_path);
|
||||
let kinds: Vec<&str> = events
|
||||
.iter()
|
||||
.filter_map(|event| event["kind"].as_str())
|
||||
.collect();
|
||||
for expected in [
|
||||
"session_start",
|
||||
"cmd_built",
|
||||
"proc_start",
|
||||
"stdout",
|
||||
"window_detected",
|
||||
"exit",
|
||||
"diagnosis",
|
||||
"session_end",
|
||||
] {
|
||||
assert!(kinds.contains(&expected), "缺少事件 {expected}:{kinds:?}");
|
||||
}
|
||||
|
||||
// 日志行里的 access_token 必须已脱敏。
|
||||
let raw = std::fs::read_to_string(&handle.log_path).expect("读会话文件");
|
||||
assert!(!raw.contains("SECRET-TOKEN-123456"), "事件流不得含凭据明文");
|
||||
|
||||
// 索引落盘且字段正确。
|
||||
let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引");
|
||||
assert_eq!(index.len(), 1);
|
||||
assert_eq!(index[0].session_id, handle.session_id);
|
||||
assert!(index[0].silent_failure);
|
||||
assert!(index[0].window_detected);
|
||||
assert_eq!(index[0].exit_code, Some(0));
|
||||
assert_eq!(index[0].diagnosis_top.as_deref(), Some("stub_candidate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_stop_records_killed_exit() {
|
||||
let sessions = temp_sessions_root("killed");
|
||||
let run_dir = temp_dir("killed-run");
|
||||
let launcher = launcher(&sessions, None);
|
||||
let (request, _) = shell_request(&long_running_script(), &run_dir);
|
||||
|
||||
let mut session = launcher.spawn(&request).expect("spawn 应成功");
|
||||
assert!(session.pid() > 0);
|
||||
session.stop().expect("停止应成功");
|
||||
let handle = session.finish().expect("收尾应成功");
|
||||
|
||||
assert_eq!(handle.exit.exit_type, ExitType::Killed);
|
||||
assert!(!handle.succeeded());
|
||||
let events = read_events(&handle.log_path);
|
||||
let kinds: Vec<&str> = events
|
||||
.iter()
|
||||
.filter_map(|event| event["kind"].as_str())
|
||||
.collect();
|
||||
assert!(kinds.contains(&"killed"), "应记录 killed 事件:{kinds:?}");
|
||||
let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引");
|
||||
assert_eq!(index.len(), 1);
|
||||
assert_eq!(index[0].exit_type.as_deref(), Some("killed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_assembly_failure_still_writes_evidence() {
|
||||
let sessions = temp_sessions_root("assembly-fail");
|
||||
let run_dir = temp_dir("assembly-fail-run");
|
||||
let launcher = launcher(&sessions, None);
|
||||
let (mut request, _) = shell_request(&echo_script(), &run_dir);
|
||||
request.missing_components = vec![MissingComponent::named("org.lwjgl:lwjgl:3.3.1")];
|
||||
|
||||
let error = launcher.spawn(&request).expect_err("缺组件必须启动前失败");
|
||||
assert!(matches!(
|
||||
error,
|
||||
LaunchError::Assembly(AssemblyError::MissingComponents(_))
|
||||
));
|
||||
|
||||
let game_dir = sessions.join("game");
|
||||
let files: Vec<PathBuf> = std::fs::read_dir(&game_dir)
|
||||
.expect("会话目录应存在")
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.extension().map(|ext| ext == "jsonl").unwrap_or(false))
|
||||
.filter(|path| {
|
||||
path.file_stem()
|
||||
.map(|stem| stem != "index")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(files.len(), 1, "应留下一个会话文件");
|
||||
let events = read_events(&files[0]);
|
||||
let kinds: Vec<&str> = events
|
||||
.iter()
|
||||
.filter_map(|event| event["kind"].as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
vec![
|
||||
"session_start",
|
||||
"natives_extracted",
|
||||
"assembly_failed",
|
||||
"session_end"
|
||||
]
|
||||
);
|
||||
let missing = &events[2]["payload"]["missing_components"][0]["name"];
|
||||
assert_eq!(missing.as_str(), Some("org.lwjgl:lwjgl:3.3.1"));
|
||||
|
||||
let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引");
|
||||
assert_eq!(index.len(), 1);
|
||||
assert!(index[0].exit_type.is_none(), "没有进程就没有退出判定");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_records_crash_report_artifact() {
|
||||
let sessions = temp_sessions_root("crash");
|
||||
let run_dir = temp_dir("crash-run");
|
||||
let crash_dir = run_dir.join("crash-reports");
|
||||
std::fs::create_dir_all(&crash_dir).expect("建 crash-reports");
|
||||
std::fs::write(
|
||||
crash_dir.join("crash-2026-10-29.txt"),
|
||||
"---- Minecraft Crash Report ----\nDescription: Rendering screen\n",
|
||||
)
|
||||
.expect("写崩溃报告");
|
||||
|
||||
let launcher = launcher(&sessions, None);
|
||||
let (request, _) = shell_request(&echo_script(), &run_dir);
|
||||
let handle = launcher.launch(&request).expect("启动应成功");
|
||||
|
||||
assert!(
|
||||
handle
|
||||
.artifacts
|
||||
.iter()
|
||||
.any(|artifact| artifact.source == CrashSource::CrashReport),
|
||||
"应发现崩溃报告:{:?}",
|
||||
handle.artifacts
|
||||
);
|
||||
let events = read_events(&handle.log_path);
|
||||
let crash = events
|
||||
.iter()
|
||||
.find(|event| event["kind"] == "crash_found")
|
||||
.expect("应有 crash_found 事件");
|
||||
let path = crash["payload"]["path"].as_str().unwrap_or_default();
|
||||
assert!(path.ends_with("crash-2026-10-29.txt"));
|
||||
assert!(crash["payload"]["excerpt"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("Minecraft Crash Report"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_spawn_failure_writes_session_end() {
|
||||
let sessions = temp_sessions_root("spawn-fail");
|
||||
let run_dir = temp_dir("spawn-fail-run");
|
||||
let launcher = launcher(&sessions, None);
|
||||
let (mut request, _) = shell_request(&echo_script(), &run_dir);
|
||||
// 包装器就是 program:给一个不存在的可执行文件,走派生失败分支。
|
||||
request.wrapper = vec!["definitely-not-a-real-binary-ull".to_string()];
|
||||
|
||||
let error = launcher.spawn(&request).expect_err("程序不存在必须失败");
|
||||
assert!(
|
||||
matches!(error, LaunchError::Spawn { .. }),
|
||||
"错误类型:{error:?}"
|
||||
);
|
||||
|
||||
let game_dir = sessions.join("game");
|
||||
let files: Vec<PathBuf> = std::fs::read_dir(&game_dir)
|
||||
.expect("会话目录应存在")
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.extension().map(|ext| ext == "jsonl").unwrap_or(false))
|
||||
.filter(|path| {
|
||||
path.file_stem()
|
||||
.map(|stem| stem != "index")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(files.len(), 1, "应留下一个会话文件");
|
||||
let events = read_events(&files[0]);
|
||||
let kinds: Vec<&str> = events
|
||||
.iter()
|
||||
.filter_map(|event| event["kind"].as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
vec![
|
||||
"session_start",
|
||||
"natives_extracted",
|
||||
"cmd_built",
|
||||
"session_end"
|
||||
]
|
||||
);
|
||||
assert_eq!(events[3]["payload"]["reason"], "spawn_failed");
|
||||
|
||||
let index = session_index::read_all(&session_index::index_path(&sessions)).expect("读索引");
|
||||
assert_eq!(index.len(), 1, "派生失败也要进索引");
|
||||
assert!(index[0].exit_type.is_none());
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! 测试共用设施。
|
||||
//!
|
||||
//! **crate 名只在本文件出现**(tower 定案 `unlucky_core`;若 M11 改包名,只改这里)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub use unlucky_core::launch::args_table::{assemble, AssembledCommand, AssemblyWarning};
|
||||
pub use unlucky_core::launch::argument::{ArgumentEntry, Rule};
|
||||
pub use unlucky_core::launch::command_builder::{self, CommandBuilder};
|
||||
pub use unlucky_core::launch::error::{AssemblyError, LaunchError, MissingComponent};
|
||||
pub use unlucky_core::launch::exit_analysis::{self, ExitType};
|
||||
pub use unlucky_core::launch::game_version::GameVersion;
|
||||
pub use unlucky_core::launch::log_level::{self, Level};
|
||||
pub use unlucky_core::launch::platform::{OsFamily, PlatformCtx};
|
||||
pub use unlucky_core::launch::pump::{LineSink, LogLine, RingBuffer, RingBufferConfig, Stream};
|
||||
pub use unlucky_core::launch::redact::{Redactor, TOKEN_PLACEHOLDER};
|
||||
pub use unlucky_core::launch::request::*;
|
||||
pub use unlucky_core::launch::session::{EndReason, SessionMeta, SessionSink, SessionWriter};
|
||||
pub use unlucky_core::launch::session_index;
|
||||
pub use unlucky_core::launch::window_detect::WindowDetector;
|
||||
|
||||
pub use unlucky_core::launch::adapter::{
|
||||
build_request, AdapterOptions, AdapterWarning, LibraryInput, SnapshotInput,
|
||||
};
|
||||
pub use unlucky_core::launch::crash_scan::CrashSource;
|
||||
pub use unlucky_core::launch::diagnosis_hook::{DiagnosisHook, DiagnosisRequest};
|
||||
pub use unlucky_core::launch::launch_session::SessionHandle;
|
||||
pub use unlucky_core::launch::launcher::{DryRun, Launcher, LauncherConfig};
|
||||
pub use unlucky_core::launch::process::ProcessConfig;
|
||||
pub use unlucky_core::launch::sink::{CompositeSink, CountingSink, FnSink};
|
||||
|
||||
/// 断言 argv 逐项相等,失败时打印全部实际值。
|
||||
pub fn assert_argv(command: &AssembledCommand, expected: &[&str]) {
|
||||
let actual = command.argv();
|
||||
if actual.len() != expected.len() {
|
||||
panic!(
|
||||
"argv 长度 {} != 期望 {}\n实际:\n{:#?}",
|
||||
actual.len(),
|
||||
expected.len(),
|
||||
actual
|
||||
);
|
||||
}
|
||||
for (index, (actual, expected)) in actual.iter().zip(expected.iter()).enumerate() {
|
||||
assert_eq!(actual, expected, "第 {index} 个参数不同");
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个“1.12.2 + Forge”的基准请求(Windows/x86_64)。
|
||||
pub fn legacy_forge_request() -> (LaunchRequest, PlatformCtx) {
|
||||
let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64");
|
||||
let request = LaunchRequest {
|
||||
instance: InstanceInfo {
|
||||
id: "inst-1".to_string(),
|
||||
name: "1.12.2-Forge".to_string(),
|
||||
version: GameVersion::parse("1.12.2"),
|
||||
version_type: "release".to_string(),
|
||||
},
|
||||
launcher: LauncherInfo {
|
||||
name: "UnluckyLauncher".to_string(),
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
java: JavaSelection::new("C:/java/bin/java.exe", 17, 64),
|
||||
memory: MemoryOptions {
|
||||
min_mb: Some(1024),
|
||||
max_mb: Some(4096),
|
||||
},
|
||||
manifest: ManifestArgs {
|
||||
main_class: Some("net.minecraft.launchwrapper.Launch".to_string()),
|
||||
..ManifestArgs::default()
|
||||
},
|
||||
dirs: LaunchDirs {
|
||||
game_dir: PathBuf::from("C:/mc"),
|
||||
assets_root: PathBuf::from("C:/mc/assets"),
|
||||
game_assets: PathBuf::from("C:/mc/assets/virtual/legacy"),
|
||||
assets_index: "1.12".to_string(),
|
||||
natives_dir: PathBuf::from("C:/mc/versions/1.12.2/natives-windows-x86_64"),
|
||||
library_dir: PathBuf::from("C:/mc/libraries"),
|
||||
primary_jar: PathBuf::from("C:/mc/versions/1.12.2/1.12.2.jar"),
|
||||
},
|
||||
classpath: vec![
|
||||
PathBuf::from("C:/mc/libraries/a.jar"),
|
||||
PathBuf::from("C:/mc/libraries/b.jar"),
|
||||
],
|
||||
auth: AuthPlaceholders {
|
||||
player_name: "Steve".to_string(),
|
||||
uuid: "0123456789abcdef0123456789abcdef".to_string(),
|
||||
access_token: "SECRET-TOKEN-123456".to_string(),
|
||||
user_type: "msa".to_string(),
|
||||
user_properties: "{}".to_string(),
|
||||
},
|
||||
log4j: Log4jOptions {
|
||||
enabled: true,
|
||||
config_file: Some(PathBuf::from("C:/mc/log4j2.xml")),
|
||||
debug: false,
|
||||
},
|
||||
// 大部分断言只关心“命令骨架”,优化参数会淹没差异;需要时单独打开。
|
||||
optimization: OptimizationOptions {
|
||||
enabled: false,
|
||||
physical_memory_mb: None,
|
||||
},
|
||||
language: "zh-CN".to_string(),
|
||||
features: FeatureSet {
|
||||
has_custom_resolution: true,
|
||||
..FeatureSet::default()
|
||||
},
|
||||
resolution: Some(Resolution {
|
||||
width: 854,
|
||||
height: 480,
|
||||
}),
|
||||
..LaunchRequest::default()
|
||||
};
|
||||
(request, ctx)
|
||||
}
|
||||
|
||||
/// 一个唯一的临时目录(不自动清理,便于失败时排查;OS 会回收)。
|
||||
pub fn temp_dir(name: &str) -> PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|value| value.as_nanos())
|
||||
.unwrap_or_default();
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("ull-launch-{name}-{}-{nanos}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("创建临时目录");
|
||||
dir
|
||||
}
|
||||
|
||||
/// 一个唯一的临时会话根目录(测试结束不自动清理,便于失败时排查;OS 会回收)。
|
||||
pub fn temp_sessions_root(name: &str) -> PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|value| value.as_nanos())
|
||||
.unwrap_or_default();
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"ull-launch-test-{name}-{}-{nanos}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
root
|
||||
}
|
||||
|
||||
/// 读回 jsonl 的每一行并解析成 JSON。
|
||||
pub fn read_events(path: &std::path::Path) -> Vec<serde_json::Value> {
|
||||
let text = std::fs::read_to_string(path).expect("读取会话文件");
|
||||
text.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| serde_json::from_str(line).expect("解析事件行"))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! 进程冒烟测试:真实 spawn 一个 shell,验证泵、退出码、进程树 kill。
|
||||
//!
|
||||
//! 这些用例跑的是真进程(不是 mock),是“事件流 + 退出分析 + 树 kill”端到端的最小证据。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
use unlucky_core::launch::process::{self, ProcessConfig, RunningProcess};
|
||||
use unlucky_core::launch::pump::{LineSink, NullSink};
|
||||
|
||||
fn shell_command(script: &str) -> AssembledCommand {
|
||||
#[cfg(windows)]
|
||||
let (program, args) = (
|
||||
"cmd".to_string(),
|
||||
vec!["/C".to_string(), script.to_string()],
|
||||
);
|
||||
#[cfg(not(windows))]
|
||||
let (program, args) = (
|
||||
"/bin/sh".to_string(),
|
||||
vec!["-c".to_string(), script.to_string()],
|
||||
);
|
||||
AssembledCommand {
|
||||
program: PathBuf::from(program),
|
||||
args,
|
||||
classpath: Vec::new(),
|
||||
encoding: "UTF-8".to_string(),
|
||||
memory: MemoryOptions::default(),
|
||||
env: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
main_class: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn config() -> ProcessConfig {
|
||||
ProcessConfig {
|
||||
graceful_stop: Duration::from_millis(200),
|
||||
..ProcessConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_collects_both_streams_and_exit_code() {
|
||||
let root = temp_sessions_root("smoke");
|
||||
let meta = SessionMeta::new("inst-1", "smoke", "1.20.1");
|
||||
let writer =
|
||||
SessionWriter::create_with_id(&root, "smoke-1", meta, Redactor::new()).expect("创建会话");
|
||||
let path = writer.path().to_path_buf();
|
||||
let sink = Arc::new(SessionSink::new(writer));
|
||||
|
||||
let mut child = RunningProcess::spawn(
|
||||
&shell_command("echo hello-from-stdout & echo hello-from-stderr 1>&2"),
|
||||
None,
|
||||
sink.clone(),
|
||||
config(),
|
||||
)
|
||||
.expect("spawn 应成功");
|
||||
|
||||
let status = child.wait().expect("wait 应成功");
|
||||
assert_eq!(process::exit_code(&status), 0);
|
||||
assert!(child.pid() > 0);
|
||||
|
||||
let verdict = child.supervise();
|
||||
assert!(verdict.is_complete(), "两条流都应在预算内结束");
|
||||
|
||||
let texts: Vec<String> = child.lines().into_iter().map(|line| line.text).collect();
|
||||
assert!(
|
||||
texts.iter().any(|text| text.contains("hello-from-stdout")),
|
||||
"stdout 未收集到:{texts:?}"
|
||||
);
|
||||
assert!(
|
||||
texts.iter().any(|text| text.contains("hello-from-stderr")),
|
||||
"stderr 未收集到:{texts:?}"
|
||||
);
|
||||
|
||||
// 事件流里应有 stdout/stderr 行事件。
|
||||
let events = read_events(&path);
|
||||
let stdout_lines = events
|
||||
.iter()
|
||||
.filter(|event| event["kind"] == "stdout")
|
||||
.count();
|
||||
let stderr_lines = events
|
||||
.iter()
|
||||
.filter(|event| event["kind"] == "stderr")
|
||||
.count();
|
||||
assert!(stdout_lines >= 1, "应有 stdout 事件");
|
||||
assert!(stderr_lines >= 1, "应有 stderr 事件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_kills_process_tree() {
|
||||
#[cfg(windows)]
|
||||
let script = "ping -n 30 127.0.0.1 >nul";
|
||||
#[cfg(not(windows))]
|
||||
let script = "sleep 30";
|
||||
|
||||
let mut child =
|
||||
RunningProcess::spawn(&shell_command(script), None, Arc::new(NullSink), config())
|
||||
.expect("spawn 应成功");
|
||||
assert!(child.is_running());
|
||||
|
||||
child.stop().expect("停止应成功");
|
||||
let status = child.wait().expect("wait 应成功");
|
||||
assert_ne!(
|
||||
process::exit_code(&status),
|
||||
0,
|
||||
"被结束的进程不应返回退出码 0"
|
||||
);
|
||||
assert!(!child.is_running());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn spawn_puts_child_in_its_own_process_group() {
|
||||
/// `/proc/<pid>/stat` 里的 `pgrp` 字段。
|
||||
fn pgid(pid: u32) -> u32 {
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).expect("读 /proc stat");
|
||||
let tail = &stat[stat.rfind(')').expect("stat 应有 comm 括号") + 1..];
|
||||
tail.split_whitespace()
|
||||
.nth(2)
|
||||
.expect("stat 应有 pgrp 字段")
|
||||
.parse()
|
||||
.expect("pgrp 应是数字")
|
||||
}
|
||||
|
||||
let mut child = RunningProcess::spawn(
|
||||
&shell_command("sleep 30"),
|
||||
None,
|
||||
Arc::new(NullSink),
|
||||
config(),
|
||||
)
|
||||
.expect("spawn 应成功");
|
||||
let pid = child.pid();
|
||||
let own_pgid = pgid(std::process::id());
|
||||
|
||||
assert_eq!(
|
||||
pgid(pid),
|
||||
pid,
|
||||
"子进程应是自己的进程组组长(process_group(0))"
|
||||
);
|
||||
assert_ne!(pgid(pid), own_pgid, "子进程不能挂在测试进程的进程组上");
|
||||
assert_eq!(
|
||||
unlucky_core::launch::tree_kill::group_kill_target(pid),
|
||||
Some(pid),
|
||||
"独立组的组长才是合法的组杀目标"
|
||||
);
|
||||
|
||||
child.stop().expect("停止应成功");
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_reports_missing_program() {
|
||||
let mut command = shell_command("echo hi");
|
||||
command.program = PathBuf::from("definitely-not-a-real-binary-ull");
|
||||
let error = RunningProcess::spawn(&command, None, Arc::new(NullSink), config())
|
||||
.expect_err("程序不存在必须报错");
|
||||
assert!(matches!(error, LaunchError::Spawn { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_sink_trait_object_is_usable() {
|
||||
let sink: Arc<dyn LineSink> = Arc::new(NullSink);
|
||||
sink.on_line(&LogLine::new(Stream::Stdout, "ignored"));
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! 环形缓冲 / 级别猜测 / 窗口检测。
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
fn line(stream: Stream, text: &str) -> LogLine {
|
||||
LogLine::new(stream, text)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_buffer_respects_line_limit() {
|
||||
let mut buffer = RingBuffer::new(RingBufferConfig {
|
||||
max_lines: 3,
|
||||
max_bytes: 1024 * 1024,
|
||||
});
|
||||
for index in 0..5 {
|
||||
buffer.push(line(Stream::Stdout, &format!("line-{index}")));
|
||||
}
|
||||
assert_eq!(buffer.len(), 3);
|
||||
let texts: Vec<String> = buffer.lines().map(|item| item.text.clone()).collect();
|
||||
assert_eq!(texts, vec!["line-2", "line-3", "line-4"]);
|
||||
assert_eq!(buffer.total_pushed(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_buffer_keeeps_key_lines_after_eviction() {
|
||||
let mut buffer = RingBuffer::new(RingBufferConfig {
|
||||
max_lines: 2,
|
||||
max_bytes: 1024 * 1024,
|
||||
});
|
||||
buffer.push(line(
|
||||
Stream::Stderr,
|
||||
"java.lang.ExceptionInInitializerError",
|
||||
));
|
||||
buffer.push(line(Stream::Stdout, "noise-1"));
|
||||
buffer.push(line(Stream::Stdout, "noise-2"));
|
||||
buffer.push(line(Stream::Stdout, "noise-3"));
|
||||
|
||||
let texts: Vec<String> = buffer.lines().map(|item| item.text.clone()).collect();
|
||||
assert!(texts
|
||||
.iter()
|
||||
.any(|text| text.contains("ExceptionInInitializer")));
|
||||
assert!(texts.contains(&"noise-3".to_string()));
|
||||
assert!(!texts.contains(&"noise-1".to_string()));
|
||||
assert_eq!(buffer.error_lines().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_buffer_respects_byte_limit() {
|
||||
let mut buffer = RingBuffer::new(RingBufferConfig {
|
||||
max_lines: 1000,
|
||||
max_bytes: 32,
|
||||
});
|
||||
buffer.push(line(Stream::Stdout, "0123456789"));
|
||||
buffer.push(line(Stream::Stdout, "abcdefghij"));
|
||||
buffer.push(line(Stream::Stdout, "ABCDEFGHIJ"));
|
||||
assert!(buffer.len() < 4, "字节上限应触发淘汰");
|
||||
assert!(buffer.text().contains("ABCDEFGHIJ"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_guessing_matches_hmcl_rules() {
|
||||
assert_eq!(
|
||||
log_level::guess(false, "[12:34:56] [Render thread/ERROR]: boom"),
|
||||
Level::Error
|
||||
);
|
||||
assert_eq!(
|
||||
log_level::guess(false, "[12:34:56] [main/INFO]: hi"),
|
||||
Level::Info
|
||||
);
|
||||
assert_eq!(
|
||||
log_level::guess(false, "[12:34:56] [Worker-Main-1/WARN] [mixin/]: careful"),
|
||||
Level::Warn
|
||||
);
|
||||
assert_eq!(log_level::guess(false, "[SEVERE] java error"), Level::Error);
|
||||
assert_eq!(log_level::guess(false, "plain text"), Level::Unknown);
|
||||
// stderr 一律当错误,authlib-injector 例外。
|
||||
assert_eq!(log_level::guess(true, "anything"), Level::Error);
|
||||
assert_eq!(
|
||||
log_level::guess(true, "[authlib-injector] loaded"),
|
||||
Level::Info
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_level_uses_stream_and_text() {
|
||||
assert_eq!(line(Stream::Stderr, "boom").level(), Level::Error);
|
||||
assert_eq!(
|
||||
line(Stream::Stdout, "[12:34:56] [main/DEBUG]: detail").level(),
|
||||
Level::Debug
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_detector_reports_heuristic_once() {
|
||||
let mut detector = WindowDetector::new();
|
||||
assert!(detector
|
||||
.observe("[12:00:00] [main/INFO]: Loading LWJGL")
|
||||
.is_none());
|
||||
let detection = detector
|
||||
.observe("[12:00:01] [Render thread/INFO]: LWJGL Version: 3.3.1")
|
||||
.expect("应命中 lwjgl version");
|
||||
assert_eq!(detection.heuristic, "lwjgl_version");
|
||||
assert!(detection.evidence.contains("LWJGL Version"));
|
||||
assert!(detector.is_detected());
|
||||
// 只报一次。
|
||||
assert!(detector.observe("LWJGL Version: 3.3.1").is_none());
|
||||
assert_eq!(
|
||||
detector.detection().map(|d| d.heuristic.as_str()),
|
||||
Some("lwjgl_version")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
//! 会话事件流(JSONL)与索引测试。
|
||||
//!
|
||||
//! 校验 `DevDocs/SubAgentsTalk/launch-接口约定.md` 的硬约定:
|
||||
//! `seq` 单调、写前脱敏、索引同 id 取最后一行。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use unlucky_core::launch::pump::LineSink;
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
fn command_with_token() -> AssembledCommand {
|
||||
let (mut request, ctx) = legacy_forge_request();
|
||||
request.manifest.legacy_arguments =
|
||||
Some("--accessToken ${auth_session} --username ${auth_player_name}".to_string());
|
||||
assemble(&request, &ctx).expect("组装应成功")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_stream_records_all_event_kinds() {
|
||||
let root = temp_sessions_root("events");
|
||||
let ctx = PlatformCtx::new(OsFamily::Windows, "x86_64");
|
||||
let meta = SessionMeta::new("inst-1", "1.12.2-Forge", "1.12.2")
|
||||
.with_launcher("UnluckyLauncher", "0.1.0")
|
||||
.with_platform(&ctx);
|
||||
let redactor = Redactor::new().with_secret("SECRET-TOKEN-123456");
|
||||
let mut writer = SessionWriter::create_with_id(&root, "20261029T120000-abcdef", meta, redactor)
|
||||
.expect("创建会话");
|
||||
|
||||
writer.session_start().expect("session_start");
|
||||
writer.cmd_built(&command_with_token()).expect("cmd_built");
|
||||
writer.proc_start(4242).expect("proc_start");
|
||||
writer
|
||||
.line(&LogLine::new(Stream::Stdout, "LWJGL Version: 3.3.1"))
|
||||
.expect("stdout");
|
||||
writer
|
||||
.line(&LogLine::new(
|
||||
Stream::Stderr,
|
||||
"java.lang.IllegalStateException: boom",
|
||||
))
|
||||
.expect("stderr");
|
||||
writer
|
||||
.window_detected(&unlucky_core::launch::window_detect::Detection {
|
||||
heuristic: "lwjgl_version".to_string(),
|
||||
took_ms: 820,
|
||||
evidence: "LWJGL Version: 3.3.1".to_string(),
|
||||
})
|
||||
.expect("window_detected");
|
||||
|
||||
let lines = vec![LogLine::new(Stream::Stderr, "java.lang.Exception: boom")];
|
||||
let analysis = exit_analysis::analyze(&exit_analysis::ExitSignals::new(1, 40_000, &lines));
|
||||
writer.exit(&analysis).expect("exit");
|
||||
writer
|
||||
.diagnosis(json!({
|
||||
"source": "rules",
|
||||
"needs_more_info": false,
|
||||
"candidates": [{ "id": "mixin_apply_failed", "confidence": 0.9 }],
|
||||
}))
|
||||
.expect("diagnosis");
|
||||
|
||||
let entry = writer.finish(EndReason::Exit).expect("session_end");
|
||||
assert!(writer.is_finished());
|
||||
assert_eq!(entry.session_id, "20261029T120000-abcdef");
|
||||
assert_eq!(entry.exit_code, Some(1));
|
||||
assert_eq!(entry.exit_type.as_deref(), Some("application_error"));
|
||||
assert!(entry.window_detected);
|
||||
assert_eq!(entry.diagnosis_top.as_deref(), Some("mixin_apply_failed"));
|
||||
assert_eq!(entry.log_path, "game/20261029T120000-abcdef.jsonl");
|
||||
|
||||
let events = read_events(writer.path());
|
||||
let kinds: Vec<&str> = events
|
||||
.iter()
|
||||
.filter_map(|event| event.get("kind").and_then(|value| value.as_str()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
vec![
|
||||
"session_start",
|
||||
"cmd_built",
|
||||
"proc_start",
|
||||
"stdout",
|
||||
"stderr",
|
||||
"window_detected",
|
||||
"exit",
|
||||
"diagnosis",
|
||||
"session_end",
|
||||
]
|
||||
);
|
||||
|
||||
// seq 从 0 单调递增,v/ts/t_ms/session_id 齐全。
|
||||
for (index, event) in events.iter().enumerate() {
|
||||
assert_eq!(event["v"], 1);
|
||||
assert_eq!(event["seq"], index as u64);
|
||||
assert_eq!(event["session_id"], "20261029T120000-abcdef");
|
||||
assert!(event["ts"].as_str().unwrap_or_default().ends_with('Z'));
|
||||
assert!(event["t_ms"].is_u64());
|
||||
}
|
||||
assert_eq!(entry.event_count, events.len() as u64);
|
||||
|
||||
// 索引落盘且可读回。
|
||||
let index = session_index::index_path(&root);
|
||||
let listed = session_index::read_all(&index).expect("读索引");
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].session_id, entry.session_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_built_argv_is_redacted_before_writing() {
|
||||
let root = temp_sessions_root("redact");
|
||||
let meta = SessionMeta::new("inst-1", "1.12.2-Forge", "1.12.2");
|
||||
let redactor = Redactor::new().with_secret("SECRET-TOKEN-123456");
|
||||
let mut writer =
|
||||
SessionWriter::create_with_id(&root, "redact-1", meta, redactor).expect("创建会话");
|
||||
writer.cmd_built(&command_with_token()).expect("cmd_built");
|
||||
|
||||
let raw = std::fs::read_to_string(writer.path()).expect("读文件");
|
||||
assert!(!raw.contains("SECRET-TOKEN-123456"), "命令行必须脱敏后落盘");
|
||||
assert!(raw.contains(TOKEN_PLACEHOLDER));
|
||||
|
||||
let events = read_events(writer.path());
|
||||
let argv = events[0]["payload"]["argv"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
assert!(argv
|
||||
.iter()
|
||||
.any(|arg| arg.as_str() == Some(TOKEN_PLACEHOLDER)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnosis_payload_is_redacted_before_writing() {
|
||||
let root = temp_sessions_root("diagnosis-redact");
|
||||
let meta = SessionMeta::new("inst-1", "1.20.1", "1.20.1");
|
||||
let redactor = Redactor::new()
|
||||
.with_secret("SECRET-TOKEN-123456")
|
||||
.with_home_str("C:/Users/aska");
|
||||
let mut writer =
|
||||
SessionWriter::create_with_id(&root, "diag-redact-1", meta, redactor).expect("创建会话");
|
||||
writer
|
||||
.diagnosis(json!({
|
||||
"source": "rules",
|
||||
"needs_more_info": false,
|
||||
"candidates": [{
|
||||
"id": "mixin_failed",
|
||||
"confidence": 0.9,
|
||||
"message_key": "launch.crash.reason.mixin_failed",
|
||||
"evidence": [
|
||||
"hs_err:C:/Users/aska/.minecraft/hs_err_pid123.log: java.lang.Exception",
|
||||
"token=SECRET-TOKEN-123456",
|
||||
],
|
||||
}],
|
||||
}))
|
||||
.expect("写 diagnosis");
|
||||
|
||||
let raw = std::fs::read_to_string(writer.path()).expect("读文件");
|
||||
assert!(!raw.contains("C:/Users/aska"), "诊断证据里的主目录必须脱敏");
|
||||
assert!(
|
||||
!raw.contains("SECRET-TOKEN-123456"),
|
||||
"诊断证据里的凭据必须脱敏"
|
||||
);
|
||||
assert!(raw.contains("<home>"));
|
||||
assert!(raw.contains("<redacted:token>"), "同一个值内也允许混合脱敏");
|
||||
|
||||
// 字段名/数值/布尔原样保留(launch 只脱敏、不改写字段)。
|
||||
let events = read_events(writer.path());
|
||||
let payload = &events[0]["payload"];
|
||||
assert_eq!(payload["needs_more_info"], json!(false));
|
||||
assert_eq!(payload["candidates"][0]["id"], json!("mixin_failed"));
|
||||
assert_eq!(payload["candidates"][0]["confidence"], json!(0.9));
|
||||
assert_eq!(
|
||||
payload["candidates"][0]["message_key"],
|
||||
json!("launch.crash.reason.mixin_failed")
|
||||
);
|
||||
assert_eq!(payload["source"], json!("rules"));
|
||||
// diagnosis_top 仍能从脱敏后的 candidates 取出。
|
||||
assert_eq!(
|
||||
writer
|
||||
.finish(EndReason::Exit)
|
||||
.expect("结束会话")
|
||||
.diagnosis_top
|
||||
.as_deref(),
|
||||
Some("mixin_failed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_keeps_last_line_per_session_id() {
|
||||
let root = temp_sessions_root("index");
|
||||
let mut entry = session_index::SessionIndexEntry {
|
||||
v: 1,
|
||||
session_id: "same-id".to_string(),
|
||||
instance_id: "inst-1".to_string(),
|
||||
instance_name: "one".to_string(),
|
||||
game_version: "1.20.1".to_string(),
|
||||
started_at: "2026-10-29T12:00:00.000Z".to_string(),
|
||||
ended_at: "2026-10-29T12:01:00.000Z".to_string(),
|
||||
duration_ms: 60_000,
|
||||
exit_code: Some(0),
|
||||
exit_type: Some("normal".to_string()),
|
||||
..session_index::SessionIndexEntry::default()
|
||||
};
|
||||
let index = session_index::index_path(&root);
|
||||
session_index::append(&index, &entry).expect("第一次写索引");
|
||||
|
||||
// 崩溃重启后重写同一会话的索引行。
|
||||
entry.exit_code = Some(1);
|
||||
entry.exit_type = Some("application_error".to_string());
|
||||
entry.duration_ms = 30_000;
|
||||
session_index::append(&index, &entry).expect("第二次写索引");
|
||||
|
||||
let listed = session_index::read_all(&index).expect("读索引");
|
||||
assert_eq!(listed.len(), 1, "同 id 只保留一条");
|
||||
assert_eq!(listed[0].exit_code, Some(1));
|
||||
assert_eq!(listed[0].duration_ms, 30_000);
|
||||
|
||||
// 追加另一实例的会话,按实例过滤生效。
|
||||
let mut other = listed[0].clone();
|
||||
other.session_id = "other-id".to_string();
|
||||
other.instance_id = "inst-2".to_string();
|
||||
other.started_at = "2026-10-30T12:00:00.000Z".to_string();
|
||||
session_index::append(&index, &other).expect("第三次写索引");
|
||||
let only = session_index::list(&index, 10, Some("inst-2")).expect("过滤");
|
||||
assert_eq!(only.len(), 1);
|
||||
assert_eq!(only[0].session_id, "other-id");
|
||||
// 倒序:最新的在前。
|
||||
let all = session_index::list(&index, 10, None).expect("全部");
|
||||
assert_eq!(
|
||||
all.first().map(|item| item.session_id.as_str()),
|
||||
Some("other-id")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_sink_writes_lines_and_detects_window() {
|
||||
let root = temp_sessions_root("sink");
|
||||
let meta = SessionMeta::new("inst-1", "1.20.1", "1.20.1");
|
||||
let writer =
|
||||
SessionWriter::create_with_id(&root, "sink-1", meta, Redactor::new()).expect("创建会话");
|
||||
let path = writer.path().to_path_buf();
|
||||
let sink = Arc::new(SessionSink::new(writer));
|
||||
|
||||
sink.on_line(&LogLine::new(Stream::Stdout, "preparing"));
|
||||
sink.on_line(&LogLine::new(Stream::Stdout, "LWJGL Version: 3.3.1"));
|
||||
sink.on_line(&LogLine::new(Stream::Stderr, "warn: something"));
|
||||
|
||||
let kinds: Vec<String> = read_events(&path)
|
||||
.iter()
|
||||
.filter_map(|event| event["kind"].as_str().map(str::to_string))
|
||||
.collect();
|
||||
assert!(
|
||||
kinds.contains(&"window_detected".to_string()),
|
||||
"sink 应自动检测窗口"
|
||||
);
|
||||
assert_eq!(kinds.iter().filter(|kind| *kind == "stdout").count(), 2);
|
||||
assert_eq!(kinds.iter().filter(|kind| *kind == "stderr").count(), 1);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! sink 组合测试(复合 sink 是接 M15 采集器 / UI 日志页的接缝)。
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
fn line(stream: Stream, text: &str) -> LogLine {
|
||||
LogLine::new(stream, text)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_sink_broadcasts_in_registration_order() {
|
||||
let order = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let first = {
|
||||
let order = order.clone();
|
||||
FnSink::new(move |line: &LogLine| {
|
||||
order.lock().unwrap().push(format!("first:{}", line.text));
|
||||
})
|
||||
};
|
||||
let second = {
|
||||
let order = order.clone();
|
||||
FnSink::new(move |line: &LogLine| {
|
||||
order.lock().unwrap().push(format!("second:{}", line.text));
|
||||
})
|
||||
};
|
||||
let composite = CompositeSink::new()
|
||||
.with(first.shared())
|
||||
.with(second.shared());
|
||||
assert_eq!(composite.len(), 2);
|
||||
composite.on_line(&line(Stream::Stdout, "hello"));
|
||||
|
||||
let recorded = order.lock().unwrap().clone();
|
||||
assert_eq!(recorded, vec!["first:hello", "second:hello"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_sink_passes_stream_and_text() {
|
||||
let seen = Arc::new(Mutex::new(Vec::<(Stream, String)>::new()));
|
||||
let recorder = {
|
||||
let seen = seen.clone();
|
||||
FnSink::new(move |line: &LogLine| {
|
||||
seen.lock().unwrap().push((line.stream, line.text.clone()));
|
||||
})
|
||||
};
|
||||
// 模拟 diagnose 侧:只记文本 + 流标记(不解析 jsonl、不 import launch 类型)。
|
||||
let mut composite = CompositeSink::new();
|
||||
composite.push(recorder.shared());
|
||||
composite.on_line(&line(Stream::Stdout, "out"));
|
||||
composite.on_line(&line(Stream::Stderr, "err"));
|
||||
|
||||
let seen = seen.lock().unwrap().clone();
|
||||
assert_eq!(seen.len(), 2);
|
||||
assert_eq!(seen[0], (Stream::Stdout, "out".to_string()));
|
||||
assert_eq!(seen[1], (Stream::Stderr, "err".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counting_sink_counts_per_stream() {
|
||||
let counter = Arc::new(CountingSink::new());
|
||||
counter.on_line(&line(Stream::Stdout, "a"));
|
||||
counter.on_line(&line(Stream::Stdout, "b"));
|
||||
counter.on_line(&line(Stream::Stderr, "c"));
|
||||
assert_eq!(counter.stdout_lines(), 2);
|
||||
assert_eq!(counter.stderr_lines(), 1);
|
||||
assert_eq!(counter.total_lines(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_composite_is_harmless() {
|
||||
let composite = CompositeSink::new();
|
||||
assert!(composite.is_empty());
|
||||
composite.on_line(&line(Stream::Stdout, "ignored"));
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! 进程树回收的安全钉子:组杀只打“目标自己的独立进程组”,绝不命中我们所在的组。
|
||||
//!
|
||||
//! 这些用例钉的是 M48 的两条根因:
|
||||
//! - 子进程必须自成一个进程组(`process_group(0)`),否则 `kill -- -<pgid>` 没有可用的组;
|
||||
//! - 组杀前必须验组([`tree_kill::group_kill_target`]),否则 CI 容器里“全体进程 pgid=1”
|
||||
//! 的那种环境会被一发组杀连测试进程一起带走。
|
||||
//!
|
||||
//! `/proc` 只有 Linux 有,进程组相关的用例都用 `#[cfg(target_os = "linux")]` 门住;
|
||||
//! `pid == 0` 的守卫与平台无关,各平台都跑。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use unlucky_core::launch::tree_kill;
|
||||
|
||||
/// `pid` 0 在 POSIX `kill` 里表示“当前进程组”——绝不能顺手发出去。
|
||||
#[test]
|
||||
fn pid_zero_is_rejected() {
|
||||
assert!(tree_kill::terminate(0).is_err(), "terminate(0) 必须拒绝");
|
||||
assert!(tree_kill::force_kill(0).is_err(), "force_kill(0) 必须拒绝");
|
||||
assert!(
|
||||
tree_kill::kill_tree(0, Duration::ZERO).is_err(),
|
||||
"kill_tree(0) 必须拒绝"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use super::*;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
|
||||
use unlucky_core::launch::tree_kill::group_kill_target;
|
||||
|
||||
/// `/proc/<pid>/stat` 里 `)` 之后的字段(state ppid pgrp ...)。
|
||||
fn stat_tail(pid: u32) -> Option<Vec<String>> {
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
let tail = stat.get(stat.rfind(')')? + 1..)?;
|
||||
Some(tail.split_whitespace().map(str::to_string).collect())
|
||||
}
|
||||
|
||||
fn pgid(pid: u32) -> Option<u32> {
|
||||
stat_tail(pid)?.get(2)?.parse().ok()
|
||||
}
|
||||
|
||||
/// 还活着(僵尸不算:已经死了只是没人 wait)。
|
||||
fn running(pid: u32) -> bool {
|
||||
matches!(stat_tail(pid).as_deref().and_then(|fields| fields.first().map(String::as_str)), Some(state) if state != "Z")
|
||||
}
|
||||
|
||||
fn wait_gone(pid: u32, budget: Duration) -> bool {
|
||||
let deadline = std::time::Instant::now() + budget;
|
||||
while std::time::Instant::now() < deadline {
|
||||
if !running(pid) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
!running(pid)
|
||||
}
|
||||
|
||||
/// 组长的子进程(`process_group(0)`):自带一个组。
|
||||
fn spawn_grouped(script: &str) -> Child {
|
||||
use std::os::unix::process::CommandExt;
|
||||
Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(script)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.expect("spawn 应成功")
|
||||
}
|
||||
|
||||
/// 和测试进程同组的子进程(不调 `process_group`):组长不是它。
|
||||
fn spawn_in_our_group(script: &str) -> Child {
|
||||
Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(script)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn 应成功")
|
||||
}
|
||||
|
||||
/// 测试失败时也别把 `sleep 30` 留在机器上。
|
||||
struct KillOnDrop(u32);
|
||||
|
||||
impl Drop for KillOnDrop {
|
||||
fn drop(&mut self) {
|
||||
let _ = tree_kill::force_kill(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 自己的进程组号:组杀安全的基准。
|
||||
fn self_pgid() -> u32 {
|
||||
pgid(std::process::id()).expect("/proc/self/stat 应可读")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_kill_target_accepts_only_a_verified_own_group() {
|
||||
let own_pgid = self_pgid();
|
||||
// 我们自己所在的组永远不是合法目标(哪怕我们正好是组长)。
|
||||
assert_eq!(
|
||||
group_kill_target(std::process::id()),
|
||||
None,
|
||||
"自己的进程号绝不能成为组杀目标"
|
||||
);
|
||||
assert_eq!(
|
||||
group_kill_target(own_pgid),
|
||||
None,
|
||||
"自己的进程组绝不能成为组杀目标"
|
||||
);
|
||||
assert_eq!(group_kill_target(0), None, "pid 0 不是合法目标");
|
||||
|
||||
// 同组的子进程:它不是组长,`-<pid>` 指向的不是“它的”组。
|
||||
let mut child = spawn_in_our_group("sleep 30");
|
||||
let pid = child.id();
|
||||
let _guard = KillOnDrop(pid);
|
||||
assert_eq!(pgid(pid), Some(own_pgid), "同组子进程应继承我们的 pgid");
|
||||
assert_eq!(
|
||||
group_kill_target(pid),
|
||||
None,
|
||||
"非组长子进程不能走组杀(`-<pid>` 会打到别人的组)"
|
||||
);
|
||||
|
||||
// 独立组的子进程:合法的组杀目标。
|
||||
let mut grouped = spawn_grouped("sleep 30");
|
||||
let grouped_pid = grouped.id();
|
||||
let _grouped_guard = KillOnDrop(grouped_pid);
|
||||
assert_eq!(pgid(grouped_pid), Some(grouped_pid), "子进程应是组长");
|
||||
assert_eq!(
|
||||
group_kill_target(grouped_pid),
|
||||
Some(grouped_pid),
|
||||
"独立组的组长可以组杀"
|
||||
);
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let _ = grouped.kill();
|
||||
let _ = grouped.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_kill_reaps_grandchildren_without_touching_our_group() {
|
||||
let mut child = spawn_grouped("sleep 30 & sleep 30");
|
||||
let pid = child.id();
|
||||
let _guard = KillOnDrop(pid);
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
|
||||
let children_of = || -> Vec<u32> {
|
||||
std::fs::read_to_string(format!("/proc/{pid}/task/{pid}/children"))
|
||||
.unwrap_or_default()
|
||||
.split_whitespace()
|
||||
.filter_map(|value| value.parse().ok())
|
||||
.collect()
|
||||
};
|
||||
let grandchildren = children_of();
|
||||
assert!(
|
||||
!grandchildren.is_empty(),
|
||||
"shell 应当先 fork 出一个 sleep 子进程:{grandchildren:?}"
|
||||
);
|
||||
for grandchild in &grandchildren {
|
||||
assert_eq!(pgid(*grandchild), Some(pid), "后代必须落在子进程自己的组里");
|
||||
}
|
||||
|
||||
tree_kill::terminate(pid).expect("组杀应送达");
|
||||
assert!(wait_gone(pid, Duration::from_secs(3)), "组长应被组杀带走");
|
||||
for grandchild in &grandchildren {
|
||||
assert!(
|
||||
wait_gone(*grandchild, Duration::from_secs(3)),
|
||||
"组杀应覆盖后代 {grandchild}"
|
||||
);
|
||||
}
|
||||
|
||||
// 组杀没有溢出:我们自己还在原来的组里,而且没有被信号带走。
|
||||
assert_eq!(self_pgid(), pgid(std::process::id()).expect("仍然可读"));
|
||||
assert_ne!(pid, self_pgid(), "目标组和我们的组必须不同");
|
||||
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminate_still_kills_a_child_that_shares_our_group() {
|
||||
let own_pgid = self_pgid();
|
||||
let mut child = spawn_in_our_group("sleep 30");
|
||||
let pid = child.id();
|
||||
let _guard = KillOnDrop(pid);
|
||||
assert_eq!(pgid(pid), Some(own_pgid));
|
||||
|
||||
tree_kill::terminate(pid).expect("单 pid 兜底应送达");
|
||||
assert!(
|
||||
wait_gone(pid, Duration::from_secs(3)),
|
||||
"同组子进程也必须能被单 pid TERM 干掉"
|
||||
);
|
||||
// 走到这里本身就说明“组杀没有命中自身进程组”——否则测试进程已被 TERM。
|
||||
assert_eq!(self_pgid(), pgid(std::process::id()).expect("仍然可读"));
|
||||
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user