diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2619ef0..1feab5c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,3 +55,7 @@ jobs: - name: cargo build run: cargo build --locked + + - name: cargo test --ignored (network canaries, master only) + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + run: cargo test --locked --all-targets -- --ignored diff --git a/Cargo.lock b/Cargo.lock index 3fb39d2..c711d1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,6 +120,16 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -480,6 +490,17 @@ dependencies = [ "yaml-rust2", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -761,6 +782,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "deflate64" version = "0.1.12" @@ -927,6 +966,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1105,6 +1150,21 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1112,6 +1172,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1120,6 +1181,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.32" @@ -1149,12 +1221,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1235,6 +1314,31 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1282,6 +1386,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1336,6 +1446,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hybrid-array" version = "0.4.11" @@ -1355,9 +1471,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1622,6 +1740,18 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "insta" +version = "1.47.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + [[package]] name = "instability" version = "0.3.12" @@ -2118,6 +2248,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -2498,6 +2638,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -2945,6 +3094,12 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "reqwest" version = "0.12.28" @@ -3013,6 +3168,7 @@ dependencies = [ "dirs", "dirs-next", "image", + "insta", "log", "minecraft-msa-auth", "notify", @@ -3023,6 +3179,7 @@ dependencies = [ "ratatui-textarea", "ratatui-themekit", "reqwest", + "rstest", "serde", "serde_json", "tachyonfx", @@ -3040,6 +3197,7 @@ dependencies = [ "tui-scrollview", "tui-widget-list", "which", + "wiremock", "zip", ] @@ -3057,6 +3215,36 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rstest" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825ea780781b15345a146be27eaefb05085e337e869bff01b4306a4fd4a9ad5a" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.117", + "unicode-ident", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -3352,6 +3540,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "siphasher" version = "1.0.2" @@ -3783,6 +3977,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -4729,6 +4935,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 0b99f1b..ed11178 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,3 +73,7 @@ lto = "thin" [dev-dependencies] tempfile = "3" +insta = "1" +wiremock = "0.6" +rstest = "0.23" +image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/src/config/settings.rs b/src/config/settings.rs index 857f03d..ed2c545 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -197,43 +197,6 @@ mod tests { assert!(!resolved.to_string_lossy().starts_with('~')); } - #[test] - fn resolve_instances_dir_absolute() { - let paths = Paths { - instances_dir: "/opt/rmcl/instances".to_owned(), - ..Paths::default() - }; - assert_eq!( - paths.resolve_instances_dir(), - PathBuf::from("/opt/rmcl/instances") - ); - } - - #[test] - fn resolve_meta_dir_absolute() { - let paths = Paths { - meta_dir: "/opt/rmcl/meta".to_owned(), - ..Paths::default() - }; - assert_eq!(paths.resolve_meta_dir(), PathBuf::from("/opt/rmcl/meta")); - } - - #[test] - fn defaults_have_expected_values() { - let d = Defaults::default(); - assert_eq!(d.memory_min, "512M"); - assert_eq!(d.memory_max, "2G"); - } - - #[test] - fn ui_defaults_have_expected_values() { - let ui = Ui::default(); - assert_eq!(ui.error_auto_dismiss_ms, 5000); - assert_eq!(ui.error_slide_start_ms, 3500); - assert_eq!(ui.error_fly_out_ms, 300); - assert_eq!(ui.max_error_events, 50); - } - #[test] fn config_deserializes_from_empty_toml() { let config: Config = toml::from_str("").unwrap(); diff --git a/src/config/theme.rs b/src/config/theme.rs index 9ac25ba..5d782b1 100644 --- a/src/config/theme.rs +++ b/src/config/theme.rs @@ -172,14 +172,6 @@ pub static BORDER_STYLE: LazyLock = mod tests { use super::*; - #[test] - fn default_theme_config() { - let config = ThemeConfig::default(); - assert_eq!(config.theme, "catppuccin"); - assert_eq!(config.border_style, BorderStyle::Rounded); - assert!(config.custom.is_none()); - } - #[test] fn border_style_roundtrip() { let style = BorderStyle::Double; diff --git a/src/instance/content/resource_packs.rs b/src/instance/content/resource_packs.rs index 4586fc1..cbaaa0e 100644 --- a/src/instance/content/resource_packs.rs +++ b/src/instance/content/resource_packs.rs @@ -201,65 +201,4 @@ mod tests { let val = serde_json::json!(true); assert_eq!(extract_description(&val), ""); } - - fn setup_packs_dir(tmp: &std::path::Path, instance: &str) -> std::path::PathBuf { - let dir = tmp.join(instance).join(".minecraft").join("resourcepacks"); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - #[test] - fn scan_resource_packs_empty_dir() { - let tmp = tempfile::tempdir().unwrap(); - setup_packs_dir(tmp.path(), "inst"); - let packs = scan_resource_packs(tmp.path(), "inst"); - assert!(packs.is_empty()); - } - - #[test] - fn scan_resource_packs_missing_dir_returns_empty() { - let tmp = tempfile::tempdir().unwrap(); - let packs = scan_resource_packs(tmp.path(), "ghost"); - assert!(packs.is_empty()); - } - - #[test] - fn scan_resource_packs_finds_zips_and_dirs() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_packs_dir(tmp.path(), "inst"); - std::fs::write(dir.join("pack-a.zip"), b"PK\x03\x04").unwrap(); - std::fs::create_dir(dir.join("pack-b")).unwrap(); - let packs = scan_resource_packs(tmp.path(), "inst"); - assert_eq!(packs.len(), 2); - } - - #[test] - fn scan_resource_packs_disabled_variants() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_packs_dir(tmp.path(), "inst"); - std::fs::write(dir.join("on.zip"), b"PK\x03\x04").unwrap(); - std::fs::write(dir.join("off.zip.disabled"), b"PK\x03\x04").unwrap(); - std::fs::create_dir(dir.join("diron")).unwrap(); - std::fs::create_dir(dir.join("diroff.disabled")).unwrap(); - let packs = scan_resource_packs(tmp.path(), "inst"); - assert_eq!(packs.len(), 4); - let on_zip = packs.iter().find(|p| p.file_stem == "on").unwrap(); - let off_zip = packs.iter().find(|p| p.file_stem == "off").unwrap(); - let on_dir = packs.iter().find(|p| p.file_stem == "diron").unwrap(); - let off_dir = packs.iter().find(|p| p.file_stem == "diroff").unwrap(); - assert!(on_zip.enabled); - assert!(!off_zip.enabled); - assert!(on_dir.enabled); - assert!(!off_dir.enabled); - } - - #[test] - fn scan_resource_packs_ignores_non_pack_files() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_packs_dir(tmp.path(), "inst"); - std::fs::write(dir.join("notes.txt"), "not a pack").unwrap(); - std::fs::write(dir.join("valid.zip"), b"PK\x03\x04").unwrap(); - let packs = scan_resource_packs(tmp.path(), "inst"); - assert_eq!(packs.len(), 1); - } } diff --git a/src/instance/content/shaders.rs b/src/instance/content/shaders.rs index 9a46f08..775f8d7 100644 --- a/src/instance/content/shaders.rs +++ b/src/instance/content/shaders.rs @@ -90,64 +90,3 @@ fn read_shader_metadata_from_dir(dir: &Path) -> (String, Option>) { (description, icon_bytes) } -#[cfg(test)] -mod tests { - use super::*; - - fn setup_shaders_dir(tmp: &std::path::Path, instance: &str) -> std::path::PathBuf { - let dir = tmp.join(instance).join(".minecraft").join("shaderpacks"); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - #[test] - fn scan_shaders_empty_dir() { - let tmp = tempfile::tempdir().unwrap(); - setup_shaders_dir(tmp.path(), "inst"); - let shaders = scan_shaders(tmp.path(), "inst"); - assert!(shaders.is_empty()); - } - - #[test] - fn scan_shaders_missing_dir_returns_empty() { - let tmp = tempfile::tempdir().unwrap(); - let shaders = scan_shaders(tmp.path(), "ghost"); - assert!(shaders.is_empty()); - } - - #[test] - fn scan_shaders_finds_zip_and_dir() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_shaders_dir(tmp.path(), "inst"); - std::fs::write(dir.join("shader-a.zip"), b"PK\x03\x04").unwrap(); - std::fs::create_dir(dir.join("shader-b")).unwrap(); - let shaders = scan_shaders(tmp.path(), "inst"); - assert_eq!(shaders.len(), 2); - } - - #[test] - fn scan_shaders_disabled_variants() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_shaders_dir(tmp.path(), "inst"); - std::fs::write(dir.join("active.zip"), b"PK\x03\x04").unwrap(); - std::fs::write(dir.join("off.zip.disabled"), b"PK\x03\x04").unwrap(); - std::fs::create_dir(dir.join("dirshader.disabled")).unwrap(); - let shaders = scan_shaders(tmp.path(), "inst"); - let active = shaders.iter().find(|s| s.file_stem == "active").unwrap(); - let off = shaders.iter().find(|s| s.file_stem == "off").unwrap(); - let diroff = shaders.iter().find(|s| s.file_stem == "dirshader").unwrap(); - assert!(active.enabled); - assert!(!off.enabled); - assert!(!diroff.enabled); - } - - #[test] - fn scan_shaders_ignores_non_shader_files() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_shaders_dir(tmp.path(), "inst"); - std::fs::write(dir.join("readme.txt"), "not a shader").unwrap(); - std::fs::write(dir.join("valid.zip"), b"PK\x03\x04").unwrap(); - let shaders = scan_shaders(tmp.path(), "inst"); - assert_eq!(shaders.len(), 1); - } -} diff --git a/src/instance/content/worlds.rs b/src/instance/content/worlds.rs index fd11df5..fce0aa9 100644 --- a/src/instance/content/worlds.rs +++ b/src/instance/content/worlds.rs @@ -135,79 +135,3 @@ fn format_size(bytes: u64) -> String { } } -#[cfg(test)] -mod tests { - use super::*; - - fn setup_saves_dir(tmp: &Path, instance: &str) -> std::path::PathBuf { - let dir = tmp.join(instance).join(".minecraft").join("saves"); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - #[test] - fn scan_worlds_empty_dir() { - let tmp = tempfile::tempdir().unwrap(); - setup_saves_dir(tmp.path(), "inst"); - let worlds = scan_worlds(tmp.path(), "inst"); - assert!(worlds.is_empty()); - } - - #[test] - fn scan_worlds_missing_dir_returns_empty() { - let tmp = tempfile::tempdir().unwrap(); - let worlds = scan_worlds(tmp.path(), "ghost"); - assert!(worlds.is_empty()); - } - - #[test] - fn scan_worlds_finds_directories() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_saves_dir(tmp.path(), "inst"); - std::fs::create_dir(dir.join("My World")).unwrap(); - std::fs::create_dir(dir.join("Creative")).unwrap(); - let worlds = scan_worlds(tmp.path(), "inst"); - assert_eq!(worlds.len(), 2); - } - - #[test] - fn scan_worlds_ignores_files() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_saves_dir(tmp.path(), "inst"); - std::fs::create_dir(dir.join("World1")).unwrap(); - std::fs::write(dir.join("stray-file.txt"), "not a world").unwrap(); - let worlds = scan_worlds(tmp.path(), "inst"); - assert_eq!(worlds.len(), 1); - } - - #[test] - fn scan_worlds_disabled_world() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_saves_dir(tmp.path(), "inst"); - std::fs::create_dir(dir.join("ActiveWorld")).unwrap(); - std::fs::create_dir(dir.join("HiddenWorld.disabled")).unwrap(); - let worlds = scan_worlds(tmp.path(), "inst"); - let active = worlds - .iter() - .find(|w| w.file_stem == "ActiveWorld") - .unwrap(); - let hidden = worlds - .iter() - .find(|w| w.file_stem == "HiddenWorld") - .unwrap(); - assert!(active.enabled); - assert!(!hidden.enabled); - } - - #[test] - fn scan_worlds_sorted_case_insensitive() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_saves_dir(tmp.path(), "inst"); - std::fs::create_dir(dir.join("Zeta")).unwrap(); - std::fs::create_dir(dir.join("alpha")).unwrap(); - std::fs::create_dir(dir.join("Beta")).unwrap(); - let worlds = scan_worlds(tmp.path(), "inst"); - let names: Vec<&str> = worlds.iter().map(|w| w.name.as_str()).collect(); - assert_eq!(names, vec!["alpha", "Beta", "Zeta"]); - } -} diff --git a/src/instance/launch/mod.rs b/src/instance/launch/mod.rs index ba9086c..8a110db 100644 --- a/src/instance/launch/mod.rs +++ b/src/instance/launch/mod.rs @@ -202,13 +202,45 @@ async fn migrate_legacy_loader_profile_if_needed( Ok(Some(refreshed)) } -pub async fn launch( +// resolved auth credentials passed into the launch-invocation builder. +// keeping these as borrowed strs lets callers pass owned strings or string +// slices without forcing allocation. +#[derive(Debug, Clone)] +pub struct LaunchAuth<'a> { + pub username: &'a str, + pub uuid: &'a str, + pub token: &'a str, + // "msa" for Microsoft, "legacy" for offline; mirrors Mojang's user_type. + pub user_type: &'a str, +} + +// everything the spawner needs to construct the java command. assembled by +// build_launch_invocation, consumed by launch(). exposed so integration tests +// can assert on the rendered invocation without spawning a real process. +#[derive(Debug, Clone)] +pub struct LaunchInvocation { + pub java: String, + pub jvm_args: Vec, + pub classpath: Vec, + pub classpath_string: String, + pub main_class: String, + pub extra_args: Vec, + pub game_args: Vec, + pub working_dir: PathBuf, +} + +// builds a fully-resolved java invocation for the given instance. reads +// meta.json and the loader profile from disk, migrates legacy formats if +// needed (may hit Mojang to refetch), resolves inheritsFrom, applies +// loader-specific patches, and renders all template arguments. all I/O +// except auth resolution and process spawning happens here. +pub async fn build_launch_invocation( config: &InstanceConfig, instances_dir: &Path, meta_dir: &Path, -) -> Result<(), LaunchError> { - let name = config.name.clone(); - let instance_dir = instances_dir.join(&name); + auth: &LaunchAuth<'_>, +) -> Result { + let instance_dir = instances_dir.join(&config.name); let minecraft_dir = instance_dir.join(".minecraft"); let meta_path = meta_dir @@ -374,63 +406,6 @@ pub async fn launch( }) .unwrap_or_else(crate::net::detect_java_path); - // resolve auth credentials, refreshing the microsoft token if needed. - let mut account_store = crate::auth::AccountStore::load(); - let (mc_username, mc_uuid, mc_token, mc_user_type) = match account_store - .active_account() - .cloned() - { - Some(acc) => { - // offline accounts can only launch if a microsoft account exists - // (proves the user owns minecraft). - if acc.account_type != AccountType::Microsoft && !account_store.has_microsoft_account() - { - return Err(LaunchError::Auth( - "Offline accounts require a Microsoft account that owns Minecraft".to_owned(), - )); - } - let (token, new_refresh, new_expires) = match acc.account_type { - AccountType::Microsoft => match crate::auth::refresh_and_get_token(&acc).await { - Ok(triple) => triple, - Err(e) => { - return Err(LaunchError::Auth(format!("Authentication failed: {e}"))); - } - }, - AccountType::Offline => ("0".to_string(), None, None), - }; - if let Some(stored) = account_store - .accounts - .iter_mut() - .find(|a| a.uuid == acc.uuid) - { - let mut changed = false; - if let Some(new_rt) = new_refresh { - stored.refresh_token = Some(new_rt); - changed = true; - } - if let Some(expires) = new_expires { - stored.cached_mc_token = Some(token.clone()); - stored.cached_mc_token_expires_at = Some(expires); - changed = true; - } - if changed { - account_store.save(); - } - } - let user_type = match acc.account_type { - AccountType::Microsoft => "msa", - AccountType::Offline => "legacy", - }; - ( - acc.username.clone(), - acc.uuid.clone(), - token, - user_type.to_string(), - ) - } - None => return Err(LaunchError::Auth("No account selected".to_owned())), - }; - let assets_root = meta_dir.join("assets"); let natives_dir = meta_dir .join("versions") @@ -447,11 +422,11 @@ pub async fn launch( game_directory: &minecraft_dir, assets_root: &assets_root, assets_index_name: &asset_index_id, - auth_player_name: &mc_username, - auth_uuid: &mc_uuid, - auth_access_token: &mc_token, + auth_player_name: auth.username, + auth_uuid: auth.uuid, + auth_access_token: auth.token, auth_xuid: "0", - user_type: &mc_user_type, + user_type: auth.user_type, user_properties: "{}", launcher_name: "rmcl", launcher_version: env!("CARGO_PKG_VERSION"), @@ -461,13 +436,92 @@ pub async fn launch( let (upstream_jvm_args, game_args) = build_game_args(&merged_profile, &rule_ctx, &template_ctx)?; - let mut jvm: Vec = vec![ + let mut jvm_args: Vec = vec![ format!("-Xms{}", config.memory_min.as_deref().unwrap_or("512M")), format!("-Xmx{}", config.memory_max.as_deref().unwrap_or("2G")), ]; - jvm.extend(patch_jvm_args); - jvm.extend(upstream_jvm_args); - jvm.extend(config.jvm_args.clone()); + jvm_args.extend(patch_jvm_args); + jvm_args.extend(upstream_jvm_args); + jvm_args.extend(config.jvm_args.clone()); + + Ok(LaunchInvocation { + java, + jvm_args, + classpath, + classpath_string: cp_str, + main_class, + extra_args, + game_args, + working_dir: minecraft_dir, + }) +} + +// resolves auth credentials, then builds the launch invocation and spawns +// the java process. only thin wrapper logic lives here: token refresh, +// process spawn, child supervision. all the heavy lifting (profile loading, +// classpath assembly, template rendering) sits behind build_launch_invocation. +pub async fn launch( + config: &InstanceConfig, + instances_dir: &Path, + meta_dir: &Path, +) -> Result<(), LaunchError> { + let name = config.name.clone(); + + // resolve auth credentials, refreshing the microsoft token if needed. + let mut account_store = crate::auth::AccountStore::load(); + let Some(acc) = account_store.active_account().cloned() else { + return Err(LaunchError::Auth("No account selected".to_owned())); + }; + + // offline accounts can only launch if a microsoft account exists + // (proves the user owns minecraft). + if acc.account_type != AccountType::Microsoft && !account_store.has_microsoft_account() { + return Err(LaunchError::Auth( + "Offline accounts require a Microsoft account that owns Minecraft".to_owned(), + )); + } + + let (token, new_refresh, new_expires) = match acc.account_type { + AccountType::Microsoft => match crate::auth::refresh_and_get_token(&acc).await { + Ok(triple) => triple, + Err(e) => return Err(LaunchError::Auth(format!("Authentication failed: {e}"))), + }, + AccountType::Offline => ("0".to_string(), None, None), + }; + + if let Some(stored) = account_store + .accounts + .iter_mut() + .find(|a| a.uuid == acc.uuid) + { + let mut changed = false; + if let Some(new_rt) = new_refresh { + stored.refresh_token = Some(new_rt); + changed = true; + } + if let Some(expires) = new_expires { + stored.cached_mc_token = Some(token.clone()); + stored.cached_mc_token_expires_at = Some(expires); + changed = true; + } + if changed { + account_store.save(); + } + } + + let user_type = match acc.account_type { + AccountType::Microsoft => "msa", + AccountType::Offline => "legacy", + }; + + let auth = LaunchAuth { + username: &acc.username, + uuid: &acc.uuid, + token: &token, + user_type, + }; + + let invocation = build_launch_invocation(config, instances_dir, meta_dir, &auth).await?; let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); crate::running::register_kill(&name, kill_tx); @@ -479,26 +533,27 @@ pub async fn launch( config.loader ); - tracing::info!("[{}] Java: {}", name, java); - tracing::info!("[{}] JVM args: {:?}", name, jvm); + tracing::info!("[{}] Java: {}", name, invocation.java); + tracing::info!("[{}] JVM args: {:?}", name, invocation.jvm_args); tracing::info!( "[{}] Classpath:\n{}", name, - classpath + invocation + .classpath .iter() .map(|p| p.display().to_string()) .collect::>() .join("\n") ); - tracing::info!("[{}] Main class: {}", name, main_class); - - let mut cmd = tokio::process::Command::new(&java); - cmd.args(&jvm); - cmd.arg("-cp").arg(&cp_str); - cmd.arg(&main_class); - cmd.args(&extra_args); - cmd.args(&game_args); - cmd.current_dir(&minecraft_dir); + tracing::info!("[{}] Main class: {}", name, invocation.main_class); + + let mut cmd = tokio::process::Command::new(&invocation.java); + cmd.args(&invocation.jvm_args); + cmd.arg("-cp").arg(&invocation.classpath_string); + cmd.arg(&invocation.main_class); + cmd.args(&invocation.extra_args); + cmd.args(&invocation.game_args); + cmd.current_dir(&invocation.working_dir); cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); diff --git a/src/instance/log_files.rs b/src/instance/log_files.rs index ec244f0..858f6ff 100644 --- a/src/instance/log_files.rs +++ b/src/instance/log_files.rs @@ -61,12 +61,6 @@ pub fn read_log_file(path: &Path) -> Vec { mod tests { use super::*; - fn setup_log_dir(tmp: &Path, instance: &str) -> PathBuf { - let dir = log_dir(tmp, instance); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - #[test] fn log_dir_builds_correct_path() { let p = log_dir(Path::new("/instances"), "my-world"); @@ -75,67 +69,4 @@ mod tests { PathBuf::from("/instances/my-world/.minecraft/logs/launches") ); } - - #[test] - fn scan_log_files_empty_dir() { - let tmp = tempfile::tempdir().unwrap(); - setup_log_dir(tmp.path(), "inst"); - let entries = scan_log_files(tmp.path(), "inst"); - assert!(entries.is_empty()); - } - - #[test] - fn scan_log_files_finds_logs() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_log_dir(tmp.path(), "inst"); - std::fs::write(dir.join("2024-01-01_12-00-00.log"), "line1\nline2").unwrap(); - std::fs::write(dir.join("2024-01-02_12-00-00.log"), "line3").unwrap(); - let entries = scan_log_files(tmp.path(), "inst"); - assert_eq!(entries.len(), 2); - assert_eq!(entries[0].name, "2024-01-02_12-00-00.log"); - assert_eq!(entries[1].name, "2024-01-01_12-00-00.log"); - } - - #[test] - fn scan_log_files_ignores_non_log() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_log_dir(tmp.path(), "inst"); - std::fs::write(dir.join("notes.txt"), "not a log").unwrap(); - std::fs::write(dir.join("real.log"), "log line").unwrap(); - let entries = scan_log_files(tmp.path(), "inst"); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].name, "real.log"); - } - - #[test] - fn scan_log_files_missing_dir_returns_empty() { - let tmp = tempfile::tempdir().unwrap(); - let entries = scan_log_files(tmp.path(), "ghost"); - assert!(entries.is_empty()); - } - - #[test] - fn read_log_file_returns_lines() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("test.log"); - std::fs::write(&path, "alpha\nbeta\ngamma").unwrap(); - let lines = read_log_file(&path); - assert_eq!(lines, vec!["alpha", "beta", "gamma"]); - } - - #[test] - fn read_log_file_missing_returns_empty() { - let lines = read_log_file(Path::new("/nonexistent/test.log")); - assert!(lines.is_empty()); - } - - #[test] - fn create_log_file_creates_path() { - let tmp = tempfile::tempdir().unwrap(); - let path = create_log_file(tmp.path(), "inst"); - assert!(path.is_some()); - let path = path.unwrap(); - assert!(path.to_string_lossy().ends_with(".log")); - assert!(path.parent().unwrap().exists()); - } } diff --git a/src/instance/screenshots.rs b/src/instance/screenshots.rs index 95225bf..62b5acd 100644 --- a/src/instance/screenshots.rs +++ b/src/instance/screenshots.rs @@ -46,87 +46,3 @@ pub fn scan_screenshots(instances_dir: &Path, instance_name: &str) -> Vec PathBuf { - let dir = tmp.join(instance).join(".minecraft").join("screenshots"); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - fn tiny_png() -> Vec { - let img = image::RgbImage::from_pixel(1, 1, image::Rgb([255, 255, 255])); - let mut buf = std::io::Cursor::new(Vec::new()); - img.write_to(&mut buf, image::ImageFormat::Png).unwrap(); - buf.into_inner() - } - - #[test] - fn scan_screenshots_empty_dir() { - let tmp = tempfile::tempdir().unwrap(); - setup_screenshots_dir(tmp.path(), "inst"); - let screenshots = scan_screenshots(tmp.path(), "inst"); - assert!(screenshots.is_empty()); - } - - #[test] - fn scan_screenshots_missing_dir_returns_empty() { - let tmp = tempfile::tempdir().unwrap(); - let screenshots = scan_screenshots(tmp.path(), "ghost"); - assert!(screenshots.is_empty()); - } - - #[test] - fn scan_screenshots_finds_images() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_screenshots_dir(tmp.path(), "inst"); - std::fs::write(dir.join("2024-01-01.png"), tiny_png()).unwrap(); - std::fs::write(dir.join("2024-01-02.png"), tiny_png()).unwrap(); - let screenshots = scan_screenshots(tmp.path(), "inst"); - assert_eq!(screenshots.len(), 2); - } - - #[test] - fn scan_screenshots_ignores_non_images() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_screenshots_dir(tmp.path(), "inst"); - std::fs::write(dir.join("pic.png"), tiny_png()).unwrap(); - std::fs::write(dir.join("notes.txt"), "not an image").unwrap(); - let screenshots = scan_screenshots(tmp.path(), "inst"); - assert_eq!(screenshots.len(), 1); - } - - #[test] - fn scan_screenshots_sorted_newest_first() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_screenshots_dir(tmp.path(), "inst"); - std::fs::write(dir.join("aaa.png"), tiny_png()).unwrap(); - std::fs::write(dir.join("zzz.png"), tiny_png()).unwrap(); - let screenshots = scan_screenshots(tmp.path(), "inst"); - assert_eq!(screenshots[0].name, "zzz.png"); - assert_eq!(screenshots[1].name, "aaa.png"); - } - - #[test] - fn scan_screenshots_reads_dimensions() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_screenshots_dir(tmp.path(), "inst"); - std::fs::write(dir.join("shot.png"), tiny_png()).unwrap(); - let screenshots = scan_screenshots(tmp.path(), "inst"); - assert_eq!(screenshots[0].width, 1); - assert_eq!(screenshots[0].height, 1); - } - - #[test] - fn scan_screenshots_bad_image_uses_default_dimensions() { - let tmp = tempfile::tempdir().unwrap(); - let dir = setup_screenshots_dir(tmp.path(), "inst"); - std::fs::write(dir.join("corrupt.png"), b"not a real png").unwrap(); - let screenshots = scan_screenshots(tmp.path(), "inst"); - assert_eq!(screenshots.len(), 1); - assert_eq!(screenshots[0].width, 1920); - assert_eq!(screenshots[0].height, 1080); - } -} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..be43188 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,19 @@ +// crate root. main.rs is a thin wrapper that imports the two entry points +// re-exported below; everything else stays crate-private. integration tests +// in tests/ that need to reach in deeper can use `rmcl::auth`, `rmcl::net`, +// etc. directly; cli + migrate stay private because they have nothing +// general to expose. + +pub mod auth; +mod cli; +pub mod config; +pub mod instance; +pub mod instance_logs; +pub mod launch_profile; +mod migrate; +pub mod net; +pub mod running; +pub mod tui; + +pub use cli::init as cli_init; +pub use migrate::run_legacy_rename as migrate_legacy_rename; diff --git a/src/main.rs b/src/main.rs index c9cd4b6..2301b5d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,25 +1,12 @@ -pub mod auth; -mod cli; -pub mod config; -pub mod instance; -pub mod instance_logs; -pub mod launch_profile; -mod migrate; -pub mod net; -pub mod running; -pub mod tui; - #[tokio::main] async fn main() { - // Run before logging::init() so the cache rename isn't blocked by a - // freshly-created ~/.cache/rmcl/ directory. - migrate::run_legacy_rename(); + rmcl::migrate_legacy_rename(); // Guard must stay in scope to keep the log file writer alive - let _guard = tui::logging::init(); + let _guard = rmcl::tui::logging::init(); if let Err(e) = color_eyre::install() { eprintln!("Failed to install color-eyre: {}", e); } - cli::init().await + rmcl::cli_init().await } diff --git a/src/tui/error_buffer.rs b/src/tui/error_buffer.rs index 07d9d98..bbf8cfc 100644 --- a/src/tui/error_buffer.rs +++ b/src/tui/error_buffer.rs @@ -89,14 +89,6 @@ mod tests { } } - #[test] - fn push_and_pop_fifo() { - push_error(make_event("err_fifo_1")); - push_error(make_event("err_fifo_2")); - let first = pop_error(); - assert!(first.is_some() || has_errors()); - } - #[test] fn has_errors_after_push() { push_error(make_event("err_has")); diff --git a/src/tui/widgets/popups/error.rs b/src/tui/widgets/popups/error.rs index af208d1..4ca8511 100644 --- a/src/tui/widgets/popups/error.rs +++ b/src/tui/widgets/popups/error.rs @@ -94,3 +94,114 @@ pub fn popup_area(frame_area: Rect, message: &str, base_y: u16, elapsed_ms: u128 height: popup_h, }) } + +#[cfg(test)] +mod render_tests { + use super::*; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use std::time::Instant; + use tracing::Level; + + use crate::tui::error_buffer::ErrorEvent; + + fn render(event: ErrorEvent, width: u16, height: u16) -> Terminal { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + let popup = ErrorPopup::new(event); + f.render_widget(popup, f.area()); + }) + .unwrap(); + terminal + } + + fn event(level: Level, message: &str) -> ErrorEvent { + ErrorEvent { + id: 1, + level, + message: message.to_string(), + pushed_at: Instant::now(), + } + } + + #[test] + fn warn_level_renders() { + let term = render(event(Level::WARN, "Disk space low"), 40, 5); + insta::assert_snapshot!(term.backend()); + } + + #[test] + fn error_level_renders() { + let term = render(event(Level::ERROR, "Connection refused"), 40, 5); + insta::assert_snapshot!(term.backend()); + } + + // info-level events hit the catch-all `_` arm in the label match; previously + // there was no test covering it. + #[test] + fn info_level_renders() { + let term = render(event(Level::INFO, "Reloaded config"), 40, 5); + insta::assert_snapshot!(term.backend()); + } + + #[test] + fn long_message_wraps() { + let msg = "The Minecraft launcher could not reach the Mojang version manifest \ + after three retries. Check your network connection or proxy settings."; + let term = render(event(Level::ERROR, msg), 40, 10); + insta::assert_snapshot!(term.backend()); + } + + #[test] + fn narrow_frame_renders() { + let term = render(event(Level::WARN, "Short message"), 18, 5); + insta::assert_snapshot!(term.backend()); + } +} + +#[cfg(test)] +mod area_tests { + use super::popup_area; + use crate::config::SETTINGS; + use ratatui::layout::Rect; + + fn frame() -> Rect { + Rect::new(0, 0, 80, 24) + } + + #[test] + fn returns_none_after_dismiss_timeout() { + let past_dismiss = SETTINGS.ui.error_auto_dismiss_ms as u128 + 1; + assert!(popup_area(frame(), "msg", 0, past_dismiss).is_none()); + } + + #[test] + fn returns_some_inside_dismiss_window() { + assert!(popup_area(frame(), "msg", 0, 0).is_some()); + } + + #[test] + fn returns_none_when_vertical_room_too_small() { + // base_y = 22 leaves only height 24 - 22 - 1 = 1 row of usable space, + // less than the minimum 3 needed for border + content + border. + assert!(popup_area(frame(), "msg", 22, 0).is_none()); + } + + #[test] + fn clamps_popup_width_to_frame() { + // a wider-than-the-frame message gets clamped so the popup fits + // inside frame.width minus the right-edge padding (saturating_sub(4)). + let huge = "x".repeat(200); + let area = popup_area(frame(), &huge, 0, 0).unwrap(); + assert!(area.width <= frame().width.saturating_sub(4)); + } + + #[test] + fn anchors_popup_to_right_edge() { + let area = popup_area(frame(), "msg", 0, 0).unwrap(); + // popup_w is added to base_x to reach frame.width - 2 (right-edge gutter) + assert_eq!(area.x + area.width + 2, frame().width); + } +} diff --git a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap b/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap new file mode 100644 index 0000000..91cab2f --- /dev/null +++ b/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__error_level_renders.snap @@ -0,0 +1,9 @@ +--- +source: src/tui/widgets/popups/error.rs +expression: term.backend() +--- +"╭ ERROR ───────────────────────────────╮" +"│Connection refused │" +"│ │" +"│ │" +"╰──────────────────────────────────────╯" diff --git a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap b/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap new file mode 100644 index 0000000..8331205 --- /dev/null +++ b/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__info_level_renders.snap @@ -0,0 +1,9 @@ +--- +source: src/tui/widgets/popups/error.rs +expression: term.backend() +--- +"╭ INFO ────────────────────────────────╮" +"│Reloaded config │" +"│ │" +"│ │" +"╰──────────────────────────────────────╯" diff --git a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap b/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap new file mode 100644 index 0000000..be8c6ae --- /dev/null +++ b/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__long_message_wraps.snap @@ -0,0 +1,14 @@ +--- +source: src/tui/widgets/popups/error.rs +expression: term.backend() +--- +"╭ ERROR ───────────────────────────────╮" +"│The Minecraft launcher could not reach│" +"│the Mojang version manifest after │" +"│three retries. Check your network │" +"│connection or proxy settings. │" +"│ │" +"│ │" +"│ │" +"│ │" +"╰──────────────────────────────────────╯" diff --git a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap b/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap new file mode 100644 index 0000000..2a059ba --- /dev/null +++ b/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__narrow_frame_renders.snap @@ -0,0 +1,9 @@ +--- +source: src/tui/widgets/popups/error.rs +expression: term.backend() +--- +"╭ WARN ──────────╮" +"│Short message │" +"│ │" +"│ │" +"╰────────────────╯" diff --git a/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap b/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap new file mode 100644 index 0000000..cbca974 --- /dev/null +++ b/src/tui/widgets/popups/snapshots/rmcl__tui__widgets__popups__error__render_tests__warn_level_renders.snap @@ -0,0 +1,9 @@ +--- +source: src/tui/widgets/popups/error.rs +expression: term.backend() +--- +"╭ WARN ────────────────────────────────╮" +"│Disk space low │" +"│ │" +"│ │" +"╰──────────────────────────────────────╯" diff --git a/tests/content_scanners.rs b/tests/content_scanners.rs new file mode 100644 index 0000000..6f2df0b --- /dev/null +++ b/tests/content_scanners.rs @@ -0,0 +1,182 @@ +// integration tests for the public content-scanner APIs: shaders, worlds, +// and resource packs. each scanner walks an instance's .minecraft subdir +// and returns ContentEntry rows; shape varies slightly per content type +// (worlds are directories only, packs and shaders accept .zip or dir, etc.). + +use std::path::{Path, PathBuf}; + +use rmcl::instance::content::resource_packs::scan_resource_packs; +use rmcl::instance::content::shaders::scan_shaders; +use rmcl::instance::content::worlds::scan_worlds; + +fn setup_subdir(tmp: &Path, instance: &str, sub: &str) -> PathBuf { + let dir = tmp.join(instance).join(".minecraft").join(sub); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +// minimal zip header byte stream; enough that the scanner accepts the file +// without panicking even though it can't actually unpack metadata. +const ZIP_HEADER: &[u8] = b"PK\x03\x04"; + +#[test] +fn shaders_empty_dir_returns_empty() { + let tmp = tempfile::tempdir().unwrap(); + setup_subdir(tmp.path(), "inst", "shaderpacks"); + assert!(scan_shaders(tmp.path(), "inst").is_empty()); +} + +#[test] +fn shaders_missing_dir_returns_empty() { + let tmp = tempfile::tempdir().unwrap(); + assert!(scan_shaders(tmp.path(), "ghost").is_empty()); +} + +#[test] +fn shaders_finds_zip_and_dir() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_subdir(tmp.path(), "inst", "shaderpacks"); + std::fs::write(dir.join("shader-a.zip"), ZIP_HEADER).unwrap(); + std::fs::create_dir(dir.join("shader-b")).unwrap(); + assert_eq!(scan_shaders(tmp.path(), "inst").len(), 2); +} + +#[test] +fn shaders_disabled_variants() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_subdir(tmp.path(), "inst", "shaderpacks"); + std::fs::write(dir.join("active.zip"), ZIP_HEADER).unwrap(); + std::fs::write(dir.join("off.zip.disabled"), ZIP_HEADER).unwrap(); + std::fs::create_dir(dir.join("dirshader.disabled")).unwrap(); + let shaders = scan_shaders(tmp.path(), "inst"); + let active = shaders.iter().find(|s| s.file_stem == "active").unwrap(); + let off = shaders.iter().find(|s| s.file_stem == "off").unwrap(); + let diroff = shaders.iter().find(|s| s.file_stem == "dirshader").unwrap(); + assert!(active.enabled); + assert!(!off.enabled); + assert!(!diroff.enabled); +} + +#[test] +fn shaders_ignores_non_shader_files() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_subdir(tmp.path(), "inst", "shaderpacks"); + std::fs::write(dir.join("readme.txt"), "not a shader").unwrap(); + std::fs::write(dir.join("valid.zip"), ZIP_HEADER).unwrap(); + assert_eq!(scan_shaders(tmp.path(), "inst").len(), 1); +} + +// worlds are always directories (no zip form), so the test shape differs. + +#[test] +fn worlds_empty_dir_returns_empty() { + let tmp = tempfile::tempdir().unwrap(); + setup_subdir(tmp.path(), "inst", "saves"); + assert!(scan_worlds(tmp.path(), "inst").is_empty()); +} + +#[test] +fn worlds_missing_dir_returns_empty() { + let tmp = tempfile::tempdir().unwrap(); + assert!(scan_worlds(tmp.path(), "ghost").is_empty()); +} + +#[test] +fn worlds_finds_directories() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_subdir(tmp.path(), "inst", "saves"); + std::fs::create_dir(dir.join("My World")).unwrap(); + std::fs::create_dir(dir.join("Creative")).unwrap(); + assert_eq!(scan_worlds(tmp.path(), "inst").len(), 2); +} + +#[test] +fn worlds_ignores_files() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_subdir(tmp.path(), "inst", "saves"); + std::fs::create_dir(dir.join("World1")).unwrap(); + std::fs::write(dir.join("stray-file.txt"), "not a world").unwrap(); + assert_eq!(scan_worlds(tmp.path(), "inst").len(), 1); +} + +#[test] +fn worlds_disabled_world() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_subdir(tmp.path(), "inst", "saves"); + std::fs::create_dir(dir.join("ActiveWorld")).unwrap(); + std::fs::create_dir(dir.join("HiddenWorld.disabled")).unwrap(); + let worlds = scan_worlds(tmp.path(), "inst"); + let active = worlds + .iter() + .find(|w| w.file_stem == "ActiveWorld") + .unwrap(); + let hidden = worlds + .iter() + .find(|w| w.file_stem == "HiddenWorld") + .unwrap(); + assert!(active.enabled); + assert!(!hidden.enabled); +} + +#[test] +fn worlds_sorted_case_insensitive() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_subdir(tmp.path(), "inst", "saves"); + std::fs::create_dir(dir.join("Zeta")).unwrap(); + std::fs::create_dir(dir.join("alpha")).unwrap(); + std::fs::create_dir(dir.join("Beta")).unwrap(); + let worlds = scan_worlds(tmp.path(), "inst"); + let names: Vec<&str> = worlds.iter().map(|w| w.name.as_str()).collect(); + assert_eq!(names, vec!["alpha", "Beta", "Zeta"]); +} + +#[test] +fn resource_packs_empty_dir_returns_empty() { + let tmp = tempfile::tempdir().unwrap(); + setup_subdir(tmp.path(), "inst", "resourcepacks"); + assert!(scan_resource_packs(tmp.path(), "inst").is_empty()); +} + +#[test] +fn resource_packs_missing_dir_returns_empty() { + let tmp = tempfile::tempdir().unwrap(); + assert!(scan_resource_packs(tmp.path(), "ghost").is_empty()); +} + +#[test] +fn resource_packs_finds_zips_and_dirs() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_subdir(tmp.path(), "inst", "resourcepacks"); + std::fs::write(dir.join("pack-a.zip"), ZIP_HEADER).unwrap(); + std::fs::create_dir(dir.join("pack-b")).unwrap(); + assert_eq!(scan_resource_packs(tmp.path(), "inst").len(), 2); +} + +#[test] +fn resource_packs_disabled_variants() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_subdir(tmp.path(), "inst", "resourcepacks"); + std::fs::write(dir.join("on.zip"), ZIP_HEADER).unwrap(); + std::fs::write(dir.join("off.zip.disabled"), ZIP_HEADER).unwrap(); + std::fs::create_dir(dir.join("diron")).unwrap(); + std::fs::create_dir(dir.join("diroff.disabled")).unwrap(); + let packs = scan_resource_packs(tmp.path(), "inst"); + assert_eq!(packs.len(), 4); + let on_zip = packs.iter().find(|p| p.file_stem == "on").unwrap(); + let off_zip = packs.iter().find(|p| p.file_stem == "off").unwrap(); + let on_dir = packs.iter().find(|p| p.file_stem == "diron").unwrap(); + let off_dir = packs.iter().find(|p| p.file_stem == "diroff").unwrap(); + assert!(on_zip.enabled); + assert!(!off_zip.enabled); + assert!(on_dir.enabled); + assert!(!off_dir.enabled); +} + +#[test] +fn resource_packs_ignores_non_pack_files() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_subdir(tmp.path(), "inst", "resourcepacks"); + std::fs::write(dir.join("notes.txt"), "not a pack").unwrap(); + std::fs::write(dir.join("valid.zip"), ZIP_HEADER).unwrap(); + assert_eq!(scan_resource_packs(tmp.path(), "inst").len(), 1); +} diff --git a/tests/launch_pipeline.rs b/tests/launch_pipeline.rs new file mode 100644 index 0000000..f31fe14 --- /dev/null +++ b/tests/launch_pipeline.rs @@ -0,0 +1,604 @@ +// integration tests for build_launch_invocation - the seam that takes an +// instance config plus resolved auth credentials and returns the full java +// invocation that would be spawned. these tests build the expected on-disk +// fixture layout in a tempdir, call the seam, and assert on the rendered +// LaunchInvocation. nothing here actually spawns a process. + +use std::path::PathBuf; + +use chrono::Utc; +use serde_json::json; +use tempfile::TempDir; + +use rmcl::instance::launch::{LaunchAuth, LaunchError, build_launch_invocation}; +use rmcl::instance::models::{InstanceConfig, ModLoader}; + +// ---------- helpers ---------- + +const PLAYER: &str = "TestPlayer"; +const PLAYER_UUID: &str = "00000000-0000-0000-0000-000000000001"; +const PLAYER_TOKEN: &str = "secret-access-token"; + +fn test_auth() -> LaunchAuth<'static> { + LaunchAuth { + username: PLAYER, + uuid: PLAYER_UUID, + token: PLAYER_TOKEN, + user_type: "msa", + } +} + +fn make_config(name: &str, game_version: &str, loader: ModLoader) -> InstanceConfig { + make_config_with(name, game_version, loader, None) +} + +fn make_config_with( + name: &str, + game_version: &str, + loader: ModLoader, + loader_version: Option<&str>, +) -> InstanceConfig { + InstanceConfig { + name: name.into(), + game_version: game_version.into(), + loader, + loader_version: loader_version.map(str::to_owned), + created: Utc::now(), + last_played: None, + // pinned to a deterministic value so detect_java_path is never + // invoked and tests don't depend on the host's java install. + java_path: Some("/usr/bin/java-test".into()), + memory_max: None, + memory_min: None, + jvm_args: Vec::new(), + resolution: None, + } +} + +// builds the on-disk layout that build_launch_invocation expects: +// /instances//.minecraft/ (instance dir, created empty) +// /meta/versions//meta.json +// /meta/loader-profiles/ (created empty) +// /meta/libraries/ (created empty) +struct Fixture { + _tmp: TempDir, + instances_dir: PathBuf, + meta_dir: PathBuf, +} + +impl Fixture { + fn new(instance_name: &str, game_version: &str, vanilla_meta: serde_json::Value) -> Self { + let tmp = tempfile::tempdir().unwrap(); + let instances_dir = tmp.path().join("instances"); + let meta_dir = tmp.path().join("meta"); + + let instance_minecraft = instances_dir.join(instance_name).join(".minecraft"); + std::fs::create_dir_all(&instance_minecraft).unwrap(); + + std::fs::create_dir_all(meta_dir.join("libraries")).unwrap(); + std::fs::create_dir_all(meta_dir.join("loader-profiles")).unwrap(); + let version_dir = meta_dir.join("versions").join(game_version); + std::fs::create_dir_all(&version_dir).unwrap(); + std::fs::write( + version_dir.join("meta.json"), + serde_json::to_vec_pretty(&vanilla_meta).unwrap(), + ) + .unwrap(); + + Self { + _tmp: tmp, + instances_dir, + meta_dir, + } + } + + fn write_loader_profile(&self, filename: &str, content: serde_json::Value) { + std::fs::write( + self.meta_dir.join("loader-profiles").join(filename), + serde_json::to_vec_pretty(&content).unwrap(), + ) + .unwrap(); + } + + fn instance_libraries_dir(&self, instance_name: &str) -> PathBuf { + self.instances_dir + .join(instance_name) + .join(".minecraft") + .join("libraries") + } +} + +fn modern_vanilla_meta(id: &str) -> serde_json::Value { + json!({ + "id": id, + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "assetIndex": { + "id": "5", + "url": "https://example.com/assets/index.json", + "sha1": "0000000000000000000000000000000000000000" + }, + "libraries": [ + { + "name": "org.slf4j:slf4j-api:2.0.7", + "downloads": { + "artifact": { + "url": "https://example.com/slf4j.jar", + "path": "org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar", + "sha1": "0000000000000000000000000000000000000000", + "size": 0 + } + } + } + ], + "arguments": { + "game": [ + "--username", "${auth_player_name}", + "--version", "${version_name}", + "--uuid", "${auth_uuid}", + "--accessToken", "${auth_access_token}", + "--userType", "${user_type}", + "--versionType", "${version_type}" + ], + "jvm": [ + "-Djava.library.path=${natives_directory}", + "-Dminecraft.launcher.brand=${launcher_name}" + ] + } + }) +} + +fn legacy_vanilla_meta(id: &str) -> serde_json::Value { + json!({ + "id": id, + "type": "release", + "mainClass": "net.minecraft.launchwrapper.Launch", + "assetIndex": { + "id": "1.7.10", + "url": "https://example.com/assets/index.json", + "sha1": "0000000000000000000000000000000000000000" + }, + "libraries": [], + "minecraftArguments": "--username ${auth_player_name} --uuid ${auth_uuid} --accessToken ${auth_access_token} --userType ${user_type}" + }) +} + +fn platform_classpath_sep() -> &'static str { + if cfg!(windows) { ";" } else { ":" } +} + +// ---------- vanilla ---------- + +#[tokio::test] +async fn vanilla_modern_builds_complete_invocation() { + let fx = Fixture::new("v1", "1.20.1", modern_vanilla_meta("1.20.1")); + let config = make_config("v1", "1.20.1", ModLoader::Vanilla); + + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + .await + .unwrap(); + + assert_eq!(inv.java, "/usr/bin/java-test"); + assert_eq!(inv.main_class, "net.minecraft.client.main.Main"); + assert!(inv.extra_args.is_empty()); + + let expected_natives = fx + .meta_dir + .join("versions") + .join("1.20.1") + .join("natives"); + assert!( + inv.jvm_args.iter().any(|a| a + == &format!("-Djava.library.path={}", expected_natives.display())), + "jvm_args missing natives substitution: {:?}", + inv.jvm_args + ); + assert!( + inv.jvm_args + .iter() + .any(|a| a == "-Dminecraft.launcher.brand=rmcl"), + "jvm_args missing launcher brand: {:?}", + inv.jvm_args + ); + + // working_dir is //.minecraft + assert_eq!( + inv.working_dir, + fx.instances_dir.join("v1").join(".minecraft") + ); + // classpath = the one vanilla lib + the vanilla client jar + let slf4j = fx + .meta_dir + .join("libraries/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"); + let client_jar = fx.meta_dir.join("versions/1.20.1/1.20.1.jar"); + assert!(inv.classpath.contains(&slf4j)); + assert!(inv.classpath.contains(&client_jar)); +} + +#[tokio::test] +async fn vanilla_legacy_args_format_substitutes_tokens() { + let fx = Fixture::new("vlegacy", "1.7.10", legacy_vanilla_meta("1.7.10")); + let config = make_config("vlegacy", "1.7.10", ModLoader::Vanilla); + + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + .await + .unwrap(); + + assert_eq!(inv.main_class, "net.minecraft.launchwrapper.Launch"); + + // legacy format has no upstream jvm args; jvm_args should be just Xms/Xmx + assert_eq!(inv.jvm_args.len(), 2); + assert!(inv.jvm_args[0].starts_with("-Xms")); + assert!(inv.jvm_args[1].starts_with("-Xmx")); + + // game_args should be space-split with substitutions applied + let joined = inv.game_args.join(" "); + assert!(joined.contains(&format!("--username {}", PLAYER))); + assert!(joined.contains(&format!("--uuid {}", PLAYER_UUID))); + assert!(joined.contains(&format!("--accessToken {}", PLAYER_TOKEN))); + assert!(joined.contains("--userType msa")); +} + +// ---------- forge ---------- + +#[tokio::test] +async fn forge_modern_includes_add_opens() { + let fx = Fixture::new("f1", "1.20.1", modern_vanilla_meta("1.20.1")); + fx.write_loader_profile( + "forge-1.20.1-47.2.0.json", + json!({ + "id": "1.20.1-forge-47.2.0", + "inheritsFrom": "1.20.1", + "type": "release", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "libraries": [ + { "name": "net.minecraftforge:fmlloader:1.20.1-47.2.0" } + ], + "arguments": { + "game": ["--launchTarget", "forge_client"], + "jvm": [ + "--add-opens", + "java.base/sun.security.util=ALL-UNNAMED", + "-DignoreList=bootstraplauncher,securejarhandler" + ] + } + }), + ); + let config = make_config_with("f1", "1.20.1", ModLoader::Forge, Some("47.2.0")); + + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + .await + .unwrap(); + + assert_eq!(inv.main_class, "cpw.mods.bootstraplauncher.BootstrapLauncher"); + assert!( + inv.jvm_args.iter().any(|a| a == "--add-opens"), + "Forge --add-opens missing: {:?}", + inv.jvm_args + ); + assert!( + inv.jvm_args + .iter() + .any(|a| a == "java.base/sun.security.util=ALL-UNNAMED"), + "Forge --add-opens target missing: {:?}", + inv.jvm_args + ); + assert!( + inv.game_args + .windows(2) + .any(|w| w == ["--launchTarget", "forge_client"]), + "Forge launchTarget missing from game_args: {:?}", + inv.game_args + ); +} + +#[tokio::test] +async fn forge_local_lib_dir_preferred_over_meta_dir() { + let fx = Fixture::new("f2", "1.20.1", modern_vanilla_meta("1.20.1")); + fx.write_loader_profile( + "forge-1.20.1-47.2.0.json", + json!({ + "id": "1.20.1-forge-47.2.0", + "inheritsFrom": "1.20.1", + "type": "release", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "libraries": [ + { "name": "net.minecraftforge:fmlloader:1.20.1-47.2.0" } + ], + "arguments": { "game": [], "jvm": [] } + }), + ); + + // drop fmlloader into the instance-local libraries dir so the launcher + // finds it there rather than under the shared meta cache. + let local_lib = + fx.instance_libraries_dir("f2") + .join("net/minecraftforge/fmlloader/1.20.1-47.2.0"); + std::fs::create_dir_all(&local_lib).unwrap(); + let local_jar = local_lib.join("fmlloader-1.20.1-47.2.0.jar"); + std::fs::write(&local_jar, b"jar").unwrap(); + + let config = make_config_with("f2", "1.20.1", ModLoader::Forge, Some("47.2.0")); + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + .await + .unwrap(); + + assert!( + inv.classpath.contains(&local_jar), + "expected local fmlloader on classpath: {:?}", + inv.classpath + ); + // and the meta-dir candidate should not appear + let meta_candidate = fx + .meta_dir + .join("libraries/net/minecraftforge/fmlloader/1.20.1-47.2.0/fmlloader-1.20.1-47.2.0.jar"); + assert!( + !inv.classpath.contains(&meta_candidate), + "meta-dir candidate should not be on classpath when local exists" + ); +} + +// ---------- fabric ---------- + +#[tokio::test] +async fn fabric_implicit_inheritsfrom_resolves() { + let fx = Fixture::new("fab", "1.20.1", modern_vanilla_meta("1.20.1")); + // Fabric upstream profiles omit inheritsFrom; the launch flow patches it + // in before resolving. assert that the merge picks up Fabric's main class. + fx.write_loader_profile( + "fabric-1.20.1-0.15.0.json", + json!({ + "id": "fabric-loader-0.15.0-1.20.1", + "type": "release", + "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient", + "libraries": [ + { "name": "net.fabricmc:fabric-loader:0.15.0" } + ] + }), + ); + let config = make_config_with("fab", "1.20.1", ModLoader::Fabric, Some("0.15.0")); + + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + .await + .unwrap(); + + assert_eq!( + inv.main_class, + "net.fabricmc.loader.impl.launch.knot.KnotClient" + ); + // both fabric-loader and vanilla slf4j must appear on the merged classpath + let fabric_loader = fx + .meta_dir + .join("libraries/net/fabricmc/fabric-loader/0.15.0/fabric-loader-0.15.0.jar"); + let slf4j = fx + .meta_dir + .join("libraries/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"); + assert!( + inv.classpath.contains(&fabric_loader), + "fabric-loader missing from classpath: {:?}", + inv.classpath + ); + assert!( + inv.classpath.contains(&slf4j), + "vanilla slf4j missing from classpath: {:?}", + inv.classpath + ); +} + +// ---------- neoforge ---------- + +#[tokio::test] +async fn neoforge_inheritsfrom_resolves() { + let fx = Fixture::new("ne", "1.20.6", modern_vanilla_meta("1.20.6")); + fx.write_loader_profile( + "neoforge-20.4.190.json", + json!({ + "id": "neoforge-20.4.190", + "inheritsFrom": "1.20.6", + "type": "release", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "libraries": [ + { "name": "net.neoforged:neoforge:20.4.190" } + ], + "arguments": { + "game": [], + "jvm": ["--add-modules", "ALL-MODULE-PATH"] + } + }), + ); + let config = make_config_with("ne", "1.20.6", ModLoader::NeoForge, Some("20.4.190")); + + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + .await + .unwrap(); + + assert_eq!(inv.main_class, "cpw.mods.bootstraplauncher.BootstrapLauncher"); + assert!( + inv.jvm_args + .windows(2) + .any(|w| w == ["--add-modules", "ALL-MODULE-PATH"]), + "NeoForge --add-modules missing: {:?}", + inv.jvm_args + ); +} + +// ---------- template substitution ---------- + +#[tokio::test] +async fn auth_credentials_substituted_in_game_args() { + let fx = Fixture::new("auth", "1.20.1", modern_vanilla_meta("1.20.1")); + let config = make_config("auth", "1.20.1", ModLoader::Vanilla); + + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + .await + .unwrap(); + + assert!( + inv.game_args.iter().any(|a| a == PLAYER), + "auth_player_name not substituted: {:?}", + inv.game_args + ); + assert!( + inv.game_args.iter().any(|a| a == PLAYER_UUID), + "auth_uuid not substituted: {:?}", + inv.game_args + ); + assert!( + inv.game_args.iter().any(|a| a == PLAYER_TOKEN), + "auth_access_token not substituted: {:?}", + inv.game_args + ); + assert!( + inv.game_args.iter().any(|a| a == "msa"), + "user_type not substituted: {:?}", + inv.game_args + ); +} + +#[tokio::test] +async fn version_type_substituted() { + // type = "snapshot" in the fixture; ${version_type} should render that + let mut meta = modern_vanilla_meta("1.20.1"); + meta["type"] = json!("snapshot"); + let fx = Fixture::new("vt", "1.20.1", meta); + let config = make_config("vt", "1.20.1", ModLoader::Vanilla); + + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + .await + .unwrap(); + + assert!( + inv.game_args.iter().any(|a| a == "snapshot"), + "version_type not substituted to 'snapshot': {:?}", + inv.game_args + ); +} + +// ---------- classpath ---------- + +#[tokio::test] +async fn classpath_uses_platform_separator() { + let fx = Fixture::new("cp", "1.20.1", modern_vanilla_meta("1.20.1")); + let config = make_config("cp", "1.20.1", ModLoader::Vanilla); + + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + .await + .unwrap(); + + // with two classpath entries we should see exactly one separator + assert_eq!(inv.classpath.len(), 2); + let sep = platform_classpath_sep(); + assert_eq!(inv.classpath_string.matches(sep).count(), 1); +} + +#[tokio::test] +async fn rule_disallow_excludes_library() { + // a library whose rule always denies must not appear on the classpath + let mut meta = modern_vanilla_meta("1.20.1"); + meta["libraries"] = json!([ + { + "name": "org.slf4j:slf4j-api:2.0.7", + "downloads": { + "artifact": { + "url": "", + "path": "org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar", + "sha1": "0000000000000000000000000000000000000000", + "size": 0 + } + } + }, + { + "name": "com.denied:lib:1.0", + "rules": [{ "action": "disallow" }], + "downloads": { + "artifact": { + "url": "", + "path": "com/denied/lib/1.0/lib-1.0.jar", + "sha1": "0000000000000000000000000000000000000000", + "size": 0 + } + } + } + ]); + let fx = Fixture::new("rule", "1.20.1", meta); + let config = make_config("rule", "1.20.1", ModLoader::Vanilla); + + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + .await + .unwrap(); + + let denied = fx + .meta_dir + .join("libraries/com/denied/lib/1.0/lib-1.0.jar"); + assert!( + !inv.classpath.contains(&denied), + "denied library was included: {:?}", + inv.classpath + ); +} + +// ---------- memory ---------- + +#[tokio::test] +async fn xms_xmx_use_config_memory() { + let fx = Fixture::new("mem", "1.20.1", modern_vanilla_meta("1.20.1")); + let mut config = make_config("mem", "1.20.1", ModLoader::Vanilla); + config.memory_min = Some("1G".into()); + config.memory_max = Some("4G".into()); + + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + .await + .unwrap(); + + assert!(inv.jvm_args.iter().any(|a| a == "-Xms1G")); + assert!(inv.jvm_args.iter().any(|a| a == "-Xmx4G")); +} + +#[tokio::test] +async fn default_memory_used_when_unset() { + let fx = Fixture::new("memdef", "1.20.1", modern_vanilla_meta("1.20.1")); + let config = make_config("memdef", "1.20.1", ModLoader::Vanilla); + // memory_min and memory_max default to None in make_config + + let inv = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + .await + .unwrap(); + + assert!(inv.jvm_args.iter().any(|a| a == "-Xms512M")); + assert!(inv.jvm_args.iter().any(|a| a == "-Xmx2G")); +} + +// ---------- error paths ---------- + +#[tokio::test] +async fn meta_not_found_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let instances_dir = tmp.path().join("instances"); + let meta_dir = tmp.path().join("meta"); + std::fs::create_dir_all(instances_dir.join("ghost").join(".minecraft")).unwrap(); + std::fs::create_dir_all(meta_dir.join("versions")).unwrap(); + + let config = make_config("ghost", "1.20.1", ModLoader::Vanilla); + let err = build_launch_invocation(&config, &instances_dir, &meta_dir, &test_auth()) + .await + .unwrap_err(); + assert!( + matches!(err, LaunchError::MetaNotFound(_)), + "expected MetaNotFound, got: {err:?}" + ); +} + +#[tokio::test] +async fn loader_profile_missing_returns_error() { + let fx = Fixture::new("lpmiss", "1.20.1", modern_vanilla_meta("1.20.1")); + let config = make_config_with("lpmiss", "1.20.1", ModLoader::Fabric, Some("0.15.0")); + + let err = build_launch_invocation(&config, &fx.instances_dir, &fx.meta_dir, &test_auth()) + .await + .unwrap_err(); + assert!( + matches!(err, LaunchError::MetaNotFound(_)), + "expected MetaNotFound for missing loader profile, got: {err:?}" + ); +} + diff --git a/tests/log_files.rs b/tests/log_files.rs new file mode 100644 index 0000000..6e8b8f8 --- /dev/null +++ b/tests/log_files.rs @@ -0,0 +1,76 @@ +// integration tests for the public log_files API. +// these tests touch the filesystem and exercise the module as an external +// consumer would. + +use std::path::{Path, PathBuf}; + +use rmcl::instance::log_files::{create_log_file, log_dir, read_log_file, scan_log_files}; + +fn setup_log_dir(tmp: &Path, instance: &str) -> PathBuf { + let dir = log_dir(tmp, instance); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn scan_log_files_empty_dir() { + let tmp = tempfile::tempdir().unwrap(); + setup_log_dir(tmp.path(), "inst"); + let entries = scan_log_files(tmp.path(), "inst"); + assert!(entries.is_empty()); +} + +#[test] +fn scan_log_files_finds_logs() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_log_dir(tmp.path(), "inst"); + std::fs::write(dir.join("2024-01-01_12-00-00.log"), "line1\nline2").unwrap(); + std::fs::write(dir.join("2024-01-02_12-00-00.log"), "line3").unwrap(); + let entries = scan_log_files(tmp.path(), "inst"); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].name, "2024-01-02_12-00-00.log"); + assert_eq!(entries[1].name, "2024-01-01_12-00-00.log"); +} + +#[test] +fn scan_log_files_ignores_non_log() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_log_dir(tmp.path(), "inst"); + std::fs::write(dir.join("notes.txt"), "not a log").unwrap(); + std::fs::write(dir.join("real.log"), "log line").unwrap(); + let entries = scan_log_files(tmp.path(), "inst"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "real.log"); +} + +#[test] +fn scan_log_files_missing_dir_returns_empty() { + let tmp = tempfile::tempdir().unwrap(); + let entries = scan_log_files(tmp.path(), "ghost"); + assert!(entries.is_empty()); +} + +#[test] +fn read_log_file_returns_lines() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("test.log"); + std::fs::write(&path, "alpha\nbeta\ngamma").unwrap(); + let lines = read_log_file(&path); + assert_eq!(lines, vec!["alpha", "beta", "gamma"]); +} + +#[test] +fn read_log_file_missing_returns_empty() { + let lines = read_log_file(Path::new("/nonexistent/test.log")); + assert!(lines.is_empty()); +} + +#[test] +fn create_log_file_creates_path() { + let tmp = tempfile::tempdir().unwrap(); + let path = create_log_file(tmp.path(), "inst"); + assert!(path.is_some()); + let path = path.unwrap(); + assert!(path.to_string_lossy().ends_with(".log")); + assert!(path.parent().unwrap().exists()); +} diff --git a/tests/screenshots.rs b/tests/screenshots.rs new file mode 100644 index 0000000..699c104 --- /dev/null +++ b/tests/screenshots.rs @@ -0,0 +1,85 @@ +// integration tests for the public scan_screenshots API. + +use std::path::{Path, PathBuf}; + +use rmcl::instance::screenshots::scan_screenshots; + +fn setup_screenshots_dir(tmp: &Path, instance: &str) -> PathBuf { + let dir = tmp.join(instance).join(".minecraft").join("screenshots"); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn tiny_png() -> Vec { + let img = image::RgbImage::from_pixel(1, 1, image::Rgb([255, 255, 255])); + let mut buf = std::io::Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Png).unwrap(); + buf.into_inner() +} + +#[test] +fn scan_screenshots_empty_dir() { + let tmp = tempfile::tempdir().unwrap(); + setup_screenshots_dir(tmp.path(), "inst"); + let screenshots = scan_screenshots(tmp.path(), "inst"); + assert!(screenshots.is_empty()); +} + +#[test] +fn scan_screenshots_missing_dir_returns_empty() { + let tmp = tempfile::tempdir().unwrap(); + let screenshots = scan_screenshots(tmp.path(), "ghost"); + assert!(screenshots.is_empty()); +} + +#[test] +fn scan_screenshots_finds_images() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_screenshots_dir(tmp.path(), "inst"); + std::fs::write(dir.join("2024-01-01.png"), tiny_png()).unwrap(); + std::fs::write(dir.join("2024-01-02.png"), tiny_png()).unwrap(); + let screenshots = scan_screenshots(tmp.path(), "inst"); + assert_eq!(screenshots.len(), 2); +} + +#[test] +fn scan_screenshots_ignores_non_images() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_screenshots_dir(tmp.path(), "inst"); + std::fs::write(dir.join("pic.png"), tiny_png()).unwrap(); + std::fs::write(dir.join("notes.txt"), "not an image").unwrap(); + let screenshots = scan_screenshots(tmp.path(), "inst"); + assert_eq!(screenshots.len(), 1); +} + +#[test] +fn scan_screenshots_sorted_newest_first() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_screenshots_dir(tmp.path(), "inst"); + std::fs::write(dir.join("aaa.png"), tiny_png()).unwrap(); + std::fs::write(dir.join("zzz.png"), tiny_png()).unwrap(); + let screenshots = scan_screenshots(tmp.path(), "inst"); + assert_eq!(screenshots[0].name, "zzz.png"); + assert_eq!(screenshots[1].name, "aaa.png"); +} + +#[test] +fn scan_screenshots_reads_dimensions() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_screenshots_dir(tmp.path(), "inst"); + std::fs::write(dir.join("shot.png"), tiny_png()).unwrap(); + let screenshots = scan_screenshots(tmp.path(), "inst"); + assert_eq!(screenshots[0].width, 1); + assert_eq!(screenshots[0].height, 1); +} + +#[test] +fn scan_screenshots_bad_image_uses_default_dimensions() { + let tmp = tempfile::tempdir().unwrap(); + let dir = setup_screenshots_dir(tmp.path(), "inst"); + std::fs::write(dir.join("corrupt.png"), b"not a real png").unwrap(); + let screenshots = scan_screenshots(tmp.path(), "inst"); + assert_eq!(screenshots.len(), 1); + assert_eq!(screenshots[0].width, 1920); + assert_eq!(screenshots[0].height, 1080); +} diff --git a/tests/smoke.rs b/tests/smoke.rs new file mode 100644 index 0000000..3e81265 --- /dev/null +++ b/tests/smoke.rs @@ -0,0 +1,10 @@ +//! verifies the lib/main split worked: integration tests can see crate items +//! through the public API. + +#[test] +fn lib_target_is_importable() { + // touch one pure function from each major module so the linker fails if + // any module went private by mistake during the split. + assert!(rmcl::net::maven_coord_to_path("a:b:1.0").is_some()); + let _ = rmcl::config::SETTINGS.paths.effective_java_path(); +}