use std::process::Command; use std::{fs, path::PathBuf}; use std::os::unix::fs::PermissionsExt; use tempfile::TempDir; struct TestEnv { _root: TempDir, config_home: PathBuf, state_home: PathBuf, home: PathBuf, } struct CmdResult { status: i32, output: String, stdout: String, } impl TestEnv { fn new() -> Self { let root = tempfile::tempdir().expect("temp dir"); let config_home = root.path().join("config"); let state_home = root.path().join("state"); let home = root.path().join("home"); std::fs::create_dir_all(&config_home).expect("config dir"); std::fs::create_dir_all(&state_home).expect("state dir"); std::fs::create_dir_all(&home).expect("home dir"); Self { _root: root, config_home, state_home, home, } } fn run(&self, args: &[&str]) -> CmdResult { self.run_with_env(args, &[]) } fn run_with_env(&self, args: &[&str], extra_env: &[(&str, &str)]) -> CmdResult { let output = Command::new(env!("CARGO_BIN_EXE_gamewrap")) .args(args) .current_dir(&self.home) .env("XDG_CONFIG_HOME", &self.config_home) .env("XDG_STATE_HOME", &self.state_home) .env("HOME", &self.home) .env("NO_COLOR", "1") .env_remove("SteamAppId") .env_remove("SteamGameId") .env_remove("STEAM_COMPAT_DATA_PATH") .env_remove("STEAM_COMPAT_CLIENT_INSTALL_PATH") .env_remove("DISPLAY") .env_remove("WAYLAND_DISPLAY") .envs(extra_env.iter().copied()) .output() .expect("run gamewrap"); let mut combined = String::new(); combined.push_str(&String::from_utf8_lossy(&output.stdout)); combined.push_str(&String::from_utf8_lossy(&output.stderr)); CmdResult { status: output.status.code().unwrap_or(-1), output: combined, stdout: String::from_utf8_lossy(&output.stdout).into_owned(), } } fn path(&self, relative: &str) -> PathBuf { self.home.join(relative) } fn path_with_fake_bins(&self, names: &[&str]) -> String { let bin_dir = self.home.join("bin"); fs::create_dir_all(&bin_dir).expect("fake bin dir"); for name in names { create_fake_bin(&bin_dir.join(name)); } let current_path = std::env::var_os("PATH").unwrap_or_default(); format!("{}:{}", bin_dir.display(), current_path.to_string_lossy()) } // Like path_with_fake_bins but returns ONLY the fake bin dir, not prepended to system PATH. // Use this for "missing binary" dependency tests where the real binary may be installed. fn path_isolated_fake_bins(&self, names: &[&str]) -> String { let bin_dir = self.home.join("bin-isolated"); fs::create_dir_all(&bin_dir).expect("isolated bin dir"); for name in names { create_fake_bin(&bin_dir.join(name)); } bin_dir.display().to_string() } } fn create_fake_bin(path: &std::path::Path) { fs::write(path, "#!/bin/sh\nexit 0\n").expect("write fake bin"); let mut perms = fs::metadata(path).expect("fake bin metadata").permissions(); perms.set_mode(0o755); fs::set_permissions(path, perms).expect("chmod fake bin"); } fn assert_ok(result: &CmdResult) { assert_eq!(result.status, 0, "unexpected failure:\n{}", result.output); } fn assert_exit(result: &CmdResult, code: i32) { assert_eq!(result.status, code, "unexpected output:\n{}", result.output); } #[test] fn help_and_launch_commands_work() { let env = TestEnv::new(); let result = env.run(&["--version"]); assert_ok(&result); assert!(result.output.contains(env!("CARGO_PKG_VERSION"))); let result = env.run(&["--help"]); assert_ok(&result); assert!(result.output.contains("Commands:")); assert!(result.output.contains("profile")); assert!(result.output.contains("game")); assert!(result.output.contains("last")); assert!(result.output.contains("notify")); assert!(result.output.contains("completion")); let result = env.run(&["help"]); assert_ok(&result); assert!(result.output.contains("Common tasks:")); assert!(result.output.contains("gamewrap %command%")); assert!(result.output.contains("gamewrap completion bash")); assert!(result.output.contains("gamescope-width")); assert!(result.output.contains("profile env set")); assert!(result.output.contains("pre-launch")); let result = env.run(&[]); assert_exit(&result, 2); assert!(result.output.contains("No command was provided.")); assert!( result .output .contains("Steam and terminal usage are different.") ); let result = env.run(&["profils"]); assert_exit(&result, 2); assert!(result.output.contains("not a known gamewrap command")); assert!( result .output .contains("gamewrap run /path/to/game/executable") ); for topic in [ "settings", "doctor", "profiles", "bindings", "completion", "troubleshooting", ] { let result = env.run(&["help", topic]); assert_ok(&result); assert!(!result.output.is_empty()); } let result = env.run(&["help", "nonsense"]); assert_exit(&result, 2); assert!(result.output.contains("not a known help topic")); let result = env.run(&["help", "settings"]); assert_ok(&result); assert!(result.output.contains("── MangoHud")); assert!(result.output.contains("── Gamescope")); let result = env.run(&["help", "settings", "gamescope"]); assert_ok(&result); assert!(result.output.contains("Gamescope")); assert!(result.output.contains("gamescope-filter")); assert!(!result.output.contains("MangoHud")); let result = env.run(&["help", "settings", "mangohud"]); assert_ok(&result); assert!(result.output.contains("MangoHud")); assert!(result.output.contains("mangohud")); assert!(!result.output.contains("Gamescope")); let result = env.run(&["help", "settings", "unknowngroup"]); assert_exit(&result, 2); assert!(result.output.contains("not a known settings group")); for retired_setting in ["overlay", "performance"] { let result = env.run(&["config", "set", retired_setting, "on"]); assert_exit(&result, 3); assert!( result .output .contains(&format!("`{retired_setting}` is not a known setting")) ); } let result = env.run(&["config", "settings"]); assert_ok(&result); assert!(result.output.contains("mangohud")); assert!(result.output.contains("gamemode")); assert!(result.output.contains("gamescope-filter")); assert!(result.output.contains("gamescope-mangoapp")); assert!(result.output.contains("gamewrap help settings")); let result = env.run(&["config", "show", "--effective"]); assert_ok(&result); assert!( result .output .contains("gamescope-fps = (not set — unlimited)") ); assert!( result .output .contains("gamescope-filter = (not set — linear)") ); let result = env.run(&["status"]); assert_ok(&result); assert!(result.output.contains("Resolved defaults:")); assert!(result.output.contains("gamescope:")); assert!(result.output.contains("vkbasalt:")); assert!(result.output.contains("Bindings:")); let result = env.run(&["doctor"]); assert_ok(&result); assert!(result.output.contains("Assumed launch context: Steam")); assert!(result.output.contains("gamescope: off")); assert!(result.output.contains("vkbasalt: off")); assert!(result.output.contains("gamescope compositor")); assert!(result.output.contains("vkbasalt layer")); let result = env.run(&["doctor", "/usr/bin/true"]); assert_ok(&result); assert!(result.output.contains("/usr/bin/true")); let result = env.run(&["run", "--help"]); assert_ok(&result); assert!( result .output .contains("Usage: gamewrap run [--] ...") ); let result = env.run(&["dry-run", "--help"]); assert_ok(&result); assert!( result .output .contains("Usage: gamewrap dry-run [--] ...") ); let result = env.run(&["run", "/usr/bin/true"]); assert_ok(&result); let result = env.run(&["dry-run", "/usr/bin/true"]); assert_ok(&result); assert!(result.output.contains("Resolved profile: default")); assert!(result.output.contains("Final command:")); assert_ok(&env.run(&["config", "set", "pre-launch", "printf pre"])); assert_ok(&env.run(&["config", "set", "post-launch", "printf post"])); let result = env.run(&["dry-run", "/usr/bin/true"]); assert_ok(&result); assert!(result.output.contains("Pre-launch hook:")); assert!(result.output.contains("sh -c \"printf pre\"")); assert!( result .output .contains("Post-launch hook (runs after game exits):") ); assert!(result.output.contains("sh -c \"printf post\"")); let result = env.run(&["doctor", "/usr/bin/true"]); assert_ok(&result); assert!(result.output.contains("pre-launch: printf pre")); assert!(result.output.contains("post-launch: printf post")); assert_ok(&env.run(&["config", "set", "gamescope", "on"])); assert_ok(&env.run(&["config", "set", "gamescope-width", "1920"])); assert_ok(&env.run(&["config", "set", "gamescope-height", "1080"])); assert_ok(&env.run(&["config", "set", "gamescope-fps", "60"])); let fake_path = env.path_with_fake_bins(&["gamescope", "mangohud", "gamemoderun"]); let result = env.run_with_env(&["dry-run", "/usr/bin/true"], &[("PATH", &fake_path)]); assert_ok(&result); assert!( result .output .contains("gamescope -W 1920 -H 1080 -r 60 -- mangohud gamemoderun /usr/bin/true") ); let result = env.run(&["completion", "bash"]); assert_ok(&result); assert!(result.output.contains("GAMEWRAP_COMPLETE=\"bash\"")); let result = env.run(&["completion", "path", "zsh"]); assert_ok(&result); assert!(result.output.contains("Completion script path:")); assert!(result.output.contains(".zshrc")); let result = env.run(&["notify", "test"]); assert_ok(&result); assert!( result.output.contains("Sent test notification via") || result.output.contains("No notifier available.") ); let result = env.run(&["completion", "install", "zsh"]); assert_ok(&result); assert!(result.output.contains("Installed zsh completion")); assert!(result.output.contains("show up automatically")); let script_path = env .config_home .join("gamewrap") .join("completions") .join("gamewrap.zsh"); assert!(script_path.exists(), "missing installed script"); let script = fs::read_to_string(&script_path).expect("read installed zsh script"); assert!(script.contains("GAMEWRAP_COMPLETE=\"zsh\"")); let zshrc_path = env.home.join(".zshrc"); let zshrc = fs::read_to_string(&zshrc_path).expect("read zshrc"); assert!(zshrc.contains("gamewrap completion")); assert!(zshrc.contains("source")); let result = env.run(&["run", "--", "definitely-not-a-real-command"]); assert_exit(&result, 4); assert!(result.output.contains("was not found in PATH")); let result = env.run(&["run", "--", "--help"]); assert_exit(&result, 2); assert!( result .output .contains("No runnable target command was provided.") ); let result = env.run(&["dry-run", "--", "--help"]); assert_exit(&result, 2); assert!( result .output .contains("No runnable target command was provided.") ); } #[test] fn config_profiles_import_export_work() { let env = TestEnv::new(); let result = env.run(&["config", "--help"]); assert_ok(&result); assert!(result.output.contains("show")); assert!(result.output.contains("edit")); assert!(result.output.contains("reset")); assert!(result.output.contains("export")); assert!(result.output.contains("import")); let result = env.run(&["config", "show", "--effective"]); assert_ok(&result); assert!(result.output.contains("mangohud = on")); let result = env.run_with_env(&["config", "edit"], &[("EDITOR", "true")]); assert_ok(&result); assert!(result.output.is_empty()); for (setting, value) in [ ("mangohud", "off"), ("gamemode", "off"), ("steam-host-libs", "off"), ("host-libs", "on"), ("game-libs", "keep"), ("verbose", "on"), ("gamescope", "on"), ("gamescope-width", "1920"), ("gamescope-height", "1080"), ("gamescope-fps", "60"), ("gamescope-nested-width", "1280"), ("gamescope-nested-height", "720"), ("gamescope-unfocused-fps", "15"), ("gamescope-filter", "fsr"), ("gamescope-scaler", "fit"), ("gamescope-sharpness", "5"), ("gamescope-mode", "fullscreen"), ("gamescope-adaptive-sync", "on"), ("gamescope-hdr", "on"), ("gamescope-steam", "on"), ("gamescope-expose-wayland", "on"), ("gamescope-mangoapp", "on"), ("fps-cap", "60"), ("vkbasalt", "on"), ("vkbasalt-config", "/tmp/default-vkbasalt.conf"), ("esync", "off"), ("fsync", "on"), ("large-address-aware", "on"), ("pre-launch", "printf default-pre"), ("post-launch", "printf default-post"), ] { let result = env.run(&["config", "set", setting, value]); assert_ok(&result); assert!(result.output.contains("Updated default setting")); } let result = env.run(&["config", "show"]); assert_ok(&result); assert!(result.output.contains("mangohud = off")); assert!(result.output.contains("gamemode = off")); assert!(result.output.contains("steam-host-libs = on")); assert!(result.output.contains("game-libs = keep")); assert!(result.output.contains("verbose = on")); assert!(result.output.contains("gamescope = on")); assert!(result.output.contains("gamescope-width = 1920")); assert!(result.output.contains("gamescope-height = 1080")); assert!(result.output.contains("gamescope-fps = 60")); assert!(result.output.contains("gamescope-nested-width = 1280")); assert!(result.output.contains("gamescope-nested-height = 720")); assert!(result.output.contains("gamescope-unfocused-fps = 15")); assert!(result.output.contains("gamescope-filter = fsr")); assert!(result.output.contains("gamescope-scaler = fit")); assert!(result.output.contains("gamescope-sharpness = 5")); assert!(result.output.contains("gamescope-mode = fullscreen")); assert!(result.output.contains("gamescope-adaptive-sync = on")); assert!(result.output.contains("gamescope-hdr = on")); assert!(result.output.contains("gamescope-steam = on")); assert!(result.output.contains("gamescope-expose-wayland = on")); assert!(result.output.contains("gamescope-mangoapp = on")); assert!(result.output.contains("fps-cap = 60")); assert!(result.output.contains("vkbasalt = on")); assert!( result .output .contains("vkbasalt-config = /tmp/default-vkbasalt.conf") ); assert!(result.output.contains("esync = off")); assert!(result.output.contains("fsync = on")); assert!(result.output.contains("large-address-aware = on")); assert!(result.output.contains("pre-launch = printf default-pre")); assert!(result.output.contains("post-launch = printf default-post")); let result = env.run(&["config", "set", "banana", "on"]); assert_exit(&result, 3); assert!(result.output.contains("not a known setting")); let result = env.run(&["config", "set", "mangohud", "maybe"]); assert_exit(&result, 3); assert!(result.output.contains("valid on/off value")); let result = env.run(&["config", "set", "game-libs", "nonsense"]); assert_exit(&result, 3); assert!(result.output.contains("valid value for game-libs")); let result = env.run(&["config", "set", "fps-cap", "fast"]); assert_exit(&result, 3); assert!(result.output.contains("valid FPS cap")); let result = env.run(&["config", "set", "gamescope-width", "wide"]); assert_exit(&result, 3); assert!(result.output.contains("valid resolution")); let result = env.run(&["config", "set", "gamescope-mode", "maximized"]); assert_exit(&result, 3); assert!(result.output.contains("valid window mode")); assert_ok(&env.run(&["config", "set", "gamescope-width", "native"])); assert_ok(&env.run(&["config", "set", "gamescope-height", "native"])); let result = env.run(&["config", "show"]); assert_ok(&result); assert!(result.output.contains("gamescope-width = native")); assert!(result.output.contains("gamescope-height = native")); assert_ok(&env.run(&["config", "set", "gamescope-width", "1920"])); assert_ok(&env.run(&["config", "set", "gamescope-height", "1080"])); let result = env.run(&["config", "set", "gamescope-fps", "fast"]); assert_exit(&result, 3); assert!(result.output.contains("valid FPS")); let result = env.run(&["config", "reset", "mangohud"]); assert_ok(&result); assert!(result.output.contains("Reset default setting `mangohud`.")); let result = env.run(&["config", "reset", "nope"]); assert_exit(&result, 3); assert!(result.output.contains("not a known setting")); let result = env.run(&["profile", "--help"]); assert_ok(&result); assert!(result.output.contains("reset")); assert!(result.output.contains("duplicate")); assert!(result.output.contains("env")); assert!(result.output.contains("export")); assert!(result.output.contains("import")); let result = env.run(&["profile", "list"]); assert_ok(&result); assert!(result.output.contains("No profiles configured.")); for name in ["base", "benchmark", "recording"] { let result = env.run(&["profile", "create", name]); assert_ok(&result); assert!(result.output.contains("Created profile")); } let result = env.run(&["profile", "create", "default"]); assert_exit(&result, 3); assert!(result.output.contains("`default` is reserved")); let result = env.run(&["profile", "create", "benchmark"]); assert_exit(&result, 3); assert!(result.output.contains("already exists")); let result = env.run(&["profile", "show", "default"]); assert_ok(&result); assert!(result.output.contains("[defaults]")); let result = env.run(&["profile", "show", "benchmark", "--effective"]); assert_ok(&result); assert!(result.output.contains("mangohud = (default: on)")); assert!(result.output.contains("gamemode = (default: off)")); assert!(result.output.contains("gamescope-fps = (default: 60)")); assert!(result.output.contains("gamescope-filter = (default: fsr)")); let result = env.run(&[ "profile", "env", "set", "benchmark", "GW_CHILD", "bench-value", ]); assert_ok(&result); let result = env.run(&[ "profile", "env", "set", "benchmark", "GW_PARENT", "child-value", ]); assert_ok(&result); let result = env.run(&["profile", "env", "list", "benchmark"]); assert_ok(&result); assert!(result.output.contains("GW_CHILD=bench-value")); assert!(result.output.contains("GW_PARENT=child-value")); let result = env.run(&["profile", "env", "unset", "benchmark", "GW_CHILD"]); assert_ok(&result); assert!( result .output .contains("Unset env `GW_CHILD` on profile `benchmark`.") ); let result = env.run(&["profile", "env", "unset", "benchmark", "GW_MISSING"]); assert_ok(&result); assert!( result .output .contains("No env var `GW_MISSING` on profile `benchmark`.") ); let result = env.run(&["profile", "env", "clear", "benchmark"]); assert_ok(&result); assert!( result .output .contains("Cleared all env vars from profile `benchmark`.") ); let result = env.run(&["profile", "env", "list", "benchmark"]); assert_ok(&result); assert!(result.output.contains("(no env vars set on this profile)")); let result = env.run(&["game", "bind", "Demo.exe", "benchmark"]); assert_ok(&result); let result = env.run(&["profile", "list"]); assert_ok(&result); assert!(result.output.contains("benchmark (1 binding)")); let result = env.run(&["game", "unbind", "Demo.exe"]); assert_ok(&result); for (setting, value) in [ ("mangohud", "off"), ("gamemode", "on"), ("steam-host-libs", "off"), ("game-libs", "gamemode"), ("verbose", "off"), ("gamescope", "on"), ("gamescope-width", "2560"), ("gamescope-height", "1440"), ("gamescope-fps", "120"), ("gamescope-nested-width", "1920"), ("gamescope-nested-height", "1080"), ("gamescope-unfocused-fps", "30"), ("gamescope-filter", "nis"), ("gamescope-scaler", "fill"), ("gamescope-sharpness", "10"), ("gamescope-mode", "borderless"), ("gamescope-adaptive-sync", "on"), ("gamescope-hdr", "off"), ("gamescope-steam", "on"), ("gamescope-expose-wayland", "off"), ("gamescope-mangoapp", "on"), ("fps-cap", "120"), ("vkbasalt", "off"), ("vkbasalt-config", "/tmp/profile-vkbasalt.conf"), ("esync", "on"), ("fsync", "off"), ("laa", "off"), ("pre-launch", "printf profile-pre"), ("post-launch", "printf profile-post"), ] { let result = env.run(&["profile", "set", "benchmark", setting, value]); assert_ok(&result); assert!(result.output.contains("Updated profile benchmark")); } let result = env.run(&["profile", "show", "benchmark"]); assert_ok(&result); assert!(result.output.contains("mangohud = off")); assert!(result.output.contains("gamemode = on")); assert!(result.output.contains("steam-host-libs = off")); assert!(result.output.contains("game-libs = gamemode")); assert!(result.output.contains("verbose = off")); assert!(result.output.contains("gamescope = on")); assert!(result.output.contains("gamescope-width = 2560")); assert!(result.output.contains("gamescope-height = 1440")); assert!(result.output.contains("gamescope-fps = 120")); assert!(result.output.contains("gamescope-nested-width = 1920")); assert!(result.output.contains("gamescope-nested-height = 1080")); assert!(result.output.contains("gamescope-unfocused-fps = 30")); assert!(result.output.contains("gamescope-filter = nis")); assert!(result.output.contains("gamescope-scaler = fill")); assert!(result.output.contains("gamescope-sharpness = 10")); assert!(result.output.contains("gamescope-mode = borderless")); assert!(result.output.contains("gamescope-adaptive-sync = on")); assert!(result.output.contains("gamescope-steam = on")); assert!(result.output.contains("gamescope-mangoapp = on")); assert!(result.output.contains("fps-cap = 120")); assert!(result.output.contains("vkbasalt = off")); assert!( result .output .contains("vkbasalt-config = /tmp/profile-vkbasalt.conf") ); assert!(result.output.contains("esync = on")); assert!(result.output.contains("fsync = off")); assert!(result.output.contains("large-address-aware = off")); assert!(result.output.contains("pre-launch = printf profile-pre")); assert!(result.output.contains("post-launch = printf profile-post")); let result = env.run(&["profile", "duplicate", "benchmark", "benchmark-copy"]); assert_ok(&result); assert!( result .output .contains("Duplicated profile benchmark to benchmark-copy.") ); let result = env.run(&["profile", "show", "benchmark-copy"]); assert_ok(&result); assert!(result.output.contains("mangohud = off")); let result = env.run(&["profile", "duplicate", "missing", "whatever"]); assert_exit(&result, 3); assert!(result.output.contains("Profile `missing` does not exist.")); let result = env.run(&["profile", "duplicate", "benchmark", "benchmark-copy"]); assert_exit(&result, 3); assert!( result .output .contains("Profile `benchmark-copy` already exists.") ); let result = env.run(&["profile", "set", "missing", "mangohud", "on"]); assert_exit(&result, 3); assert!(result.output.contains("Profile `missing` does not exist.")); let result = env.run(&["profile", "set", "benchmark", "nope", "on"]); assert_exit(&result, 3); assert!(result.output.contains("not a known setting")); let result = env.run(&["profile", "set", "benchmark", "mangohud", "maybe"]); assert_exit(&result, 3); assert!(result.output.contains("valid on/off value")); for setting in [ "mangohud", "gamemode", "steam-host-libs", "game-libs", "verbose", "gamescope", "gamescope-width", "gamescope-height", "gamescope-fps", "gamescope-nested-width", "gamescope-nested-height", "gamescope-unfocused-fps", "gamescope-filter", "gamescope-scaler", "gamescope-sharpness", "gamescope-mode", "gamescope-adaptive-sync", "gamescope-hdr", "gamescope-steam", "gamescope-expose-wayland", "gamescope-mangoapp", "fps-cap", "vkbasalt", "vkbasalt-config", "esync", "fsync", "large-address-aware", "pre-launch", "post-launch", ] { let result = env.run(&["profile", "reset", "benchmark", setting]); assert_ok(&result); assert!(result.output.contains("Reset profile benchmark setting")); } let result = env.run(&["profile", "show", "benchmark", "--effective"]); assert_ok(&result); assert!(result.output.contains("mangohud = (default: on)")); assert!(result.output.contains("gamemode = (default: off)")); assert!(result.output.contains("steam-host-libs = (default: on)")); assert!(result.output.contains("game-libs = (default: keep)")); assert!(result.output.contains("verbose = (default: on)")); assert!(result.output.contains("gamescope = (default: on)")); assert!(result.output.contains("gamescope-width = (default: 1920)")); assert!(result.output.contains("gamescope-height = (default: 1080)")); assert!(result.output.contains("gamescope-fps = (default: 60)")); assert!( result .output .contains("gamescope-mode = (default: fullscreen)") ); assert!(result.output.contains("fps-cap = (default: 60)")); assert!(result.output.contains("vkbasalt = (default: on)")); assert!( result .output .contains("vkbasalt-config = (default: /tmp/default-vkbasalt.conf)") ); assert!(result.output.contains("esync = (default: off)")); assert!(result.output.contains("fsync = (default: on)")); assert!( result .output .contains("large-address-aware = (default: on)") ); assert!( result .output .contains("pre-launch = (default: printf default-pre)") ); assert!( result .output .contains("post-launch = (default: printf default-post)") ); let result = env.run(&["profile", "reset", "missing", "mangohud"]); assert_exit(&result, 3); assert!(result.output.contains("Profile `missing` does not exist.")); let result = env.run(&["profile", "show", "missing"]); assert_exit(&result, 3); assert!(result.output.contains("does not exist")); let export_base = env.path("exported-config"); let export_file = env.path("exported-config.gamewrap.toml"); // no-path export writes config.gamewrap.toml in cwd (self.home via current_dir) let default_export_file = env.path("config.gamewrap.toml"); let result = env.run(&["config", "export"]); assert_ok(&result); assert!(result.output.contains("Exported config to")); let default_exported = fs::read_to_string(&default_export_file).expect("default export file"); assert!(default_exported.contains("kind = \"gamewrap-config\"")); assert!(default_exported.contains("version = 1")); assert!(default_exported.contains("[defaults]")); assert!(default_exported.contains("[profiles.base]")); assert!(default_exported.contains("[profiles.benchmark]")); assert!(default_exported.contains("[profiles.benchmark-copy]")); let result = env.run(&["config", "export", export_base.to_str().expect("utf8 path")]); assert_ok(&result); assert!(result.output.contains("Exported config to")); let exported = fs::read_to_string(&export_file).expect("export file"); assert!(exported.contains("kind = \"gamewrap-config\"")); assert!(exported.contains("[profiles.base]")); assert!(exported.contains("[profiles.benchmark-copy]")); let imported_env = TestEnv::new(); let result = imported_env.run(&["config", "import", export_base.to_str().expect("utf8 path")]); assert_ok(&result); let result = imported_env.run(&["config", "show"]); assert_ok(&result); assert!(result.output.contains("benchmark")); assert!(result.output.contains("benchmark-copy")); let profile_export_base = env.path("benchmark"); let profile_export = env.path("benchmark.gamewrap-profile.toml"); let result = env.run(&[ "profile", "export", "benchmark", profile_export_base.to_str().expect("utf8 path"), ]); assert_ok(&result); assert!(result.output.contains("Exported profile benchmark")); let exported_profile = fs::read_to_string(&profile_export).expect("profile export"); assert!(exported_profile.contains("kind = \"gamewrap-profile\"")); assert!(exported_profile.contains("name = \"benchmark\"")); assert!(exported_profile.contains("[settings]")); let imported_profile_env = TestEnv::new(); let result = imported_profile_env.run(&[ "profile", "import", profile_export_base.to_str().expect("utf8 path"), ]); assert_ok(&result); assert!(result.output.contains("Imported profile benchmark")); let result = imported_profile_env.run(&["profile", "show", "benchmark"]); assert_ok(&result); assert!( result .output .contains("(no settings configured for this profile)") ); let result = imported_profile_env.run(&[ "profile", "import", profile_export_base.to_str().expect("utf8 path"), ]); assert_exit(&result, 3); assert!( result .output .contains("Profile `benchmark` already exists.") ); let invalid_file = env.path("invalid.toml"); fs::write(&invalid_file, "not = [valid").expect("write invalid config"); let result = env.run(&[ "config", "import", invalid_file.to_str().expect("utf8 path"), ]); assert_exit(&result, 3); assert!(result.output.contains("is invalid")); let invalid_profile = env.path("invalid.gamewrap-profile.toml"); fs::write( &invalid_profile, "kind = \"gamewrap-profile\"\nversion = 1\n", ) .expect("write invalid profile"); let result = env.run(&[ "profile", "import", invalid_profile.to_str().expect("utf8 path"), ]); assert_exit(&result, 3); assert!(result.output.contains("Profile import file")); } #[test] fn profile_export_is_sparse() { let env = TestEnv::new(); assert_ok(&env.run(&["profile", "create", "benchmark"])); assert_ok(&env.run(&["profile", "set", "benchmark", "gamescope", "on"])); assert_ok(&env.run(&["profile", "set", "benchmark", "gamescope-width", "1920"])); let export_base = env.path("sparse-benchmark"); let result = env.run(&[ "profile", "export", "benchmark", export_base.to_str().expect("utf8 path"), ]); assert_ok(&result); let exported = fs::read_to_string(env.path("sparse-benchmark.gamewrap-profile.toml")) .expect("profile export"); assert!(exported.contains("gamescope = true")); assert!(exported.contains("gamescope_width = 1920")); assert!(!exported.contains("mangohud")); assert!(!exported.contains("gamemode")); } #[test] fn config_reset_all_restores_built_in_defaults() { let env = TestEnv::new(); assert_ok(&env.run(&["config", "set", "mangohud", "off"])); assert_ok(&env.run(&["config", "set", "fps-cap", "60"])); let result = env.run(&["config", "reset"]); assert_exit(&result, 2); let result = env.run(&["config", "reset", "--all"]); assert_ok(&result); assert!( result .output .contains("Reset all default settings to built-in defaults.") ); let result = env.run(&["config", "show", "--effective"]); assert_ok(&result); assert!(result.output.contains("mangohud = on")); assert!(result.output.contains("fps-cap = (not set — no cap)")); assert!( result .output .contains("gamescope-width = (not set — gamescope default: 1280)") ); assert!( result .output .contains("vkbasalt-config = (not set — ~/.config/vkBasalt/vkBasalt.conf)") ); assert!(result.output.contains("esync = (not set — Proton default)")); } #[test] fn games_bindings_notes_and_filters_work() { let env = TestEnv::new(); assert_ok(&env.run(&["config", "set", "mangohud", "off"])); assert_ok(&env.run(&["config", "set", "gamemode", "off"])); assert_ok(&env.run(&["profile", "create", "benchmark"])); assert_ok(&env.run(&["profile", "create", "recording"])); let result = env.run(&["game", "--help"]); assert_ok(&result); assert!(result.output.contains("bind")); assert!(result.output.contains("unbind")); assert!(result.output.contains("forget")); assert!(result.output.contains("note")); assert!(result.output.contains("clear-note")); let result = env.run(&["game", "list"]); assert_ok(&result); assert!(result.output.contains("(no observed games yet)")); let result = env.run(&["game", "show", "missing.exe"]); assert_exit(&result, 3); assert!(result.output.contains("No observed game matched")); let result = env.run(&["game", "bind", "Example.exe", "unknown"]); assert_exit(&result, 3); assert!(result.output.contains("Profile `unknown` does not exist.")); let result = env.run_with_env( &[ "/usr/bin/true", "--", "/usr/bin/true", "waitforexitandrun", "/games/Grind Survivors/GrindSurvivors.exe", ], &[("SteamAppId", "12345")], ); assert_ok(&result); let result = env.run_with_env( &[ "/usr/bin/true", "--", "/usr/bin/true", "waitforexitandrun", "/games/StarRupture/StarRuptureGameSteam.exe", ], &[("SteamAppId", "67890")], ); assert_ok(&result); let result = env.run(&["game", "list"]); assert_ok(&result); assert!(result.output.contains("GrindSurvivors.exe")); assert!(result.output.contains("StarRuptureGameSteam.exe")); assert!(result.output.contains("default")); let result = env.run(&["last"]); assert_ok(&result); assert!( result .output .contains("Last played: StarRuptureGameSteam.exe") ); assert!(result.output.contains("Launches: 1")); assert!(result.output.contains("Last launch: 20")); let result = env.run(&["game", "list", "grind"]); assert_ok(&result); assert!(result.output.contains("GrindSurvivors.exe")); assert!(!result.output.contains("StarRuptureGameSteam.exe")); let result = env.run(&["game", "show", "starrupturegamesteam.exe"]); assert_ok(&result); assert!( result .output .contains("Executable: StarRuptureGameSteam.exe") ); assert!(result.output.contains("Launch count: 1")); assert!(result.output.contains("Last launched: 20")); let result = env.run(&["game", "show", "Grind Survivors"]); assert_ok(&result); assert!( result .output .contains("/games/Grind Survivors/GrindSurvivors.exe") ); let result = env.run(&["game", "rename", "StarRuptureGameSteam.exe", "Star Rupture"]); assert_ok(&result); assert!( result .output .contains("Renamed StarRuptureGameSteam.exe to Star Rupture.") ); let result = env.run(&["game", "list"]); assert_ok(&result); assert!(result.output.contains("Star Rupture")); assert!(!result.output.contains("StarRuptureGameSteam.exe default")); let result = env.run(&["game", "show", "StarRuptureGameSteam.exe"]); assert_ok(&result); assert!(result.output.contains("Display name: Star Rupture")); let result = env.run(&["game", "bind", "StarRuptureGameSteam.exe", "benchmark"]); assert_ok(&result); assert!( result .output .contains("Bound StarRuptureGameSteam.exe to profile benchmark.") ); let result = env.run(&[ "game", "bind", "/games/Grind Survivors/GrindSurvivors.exe", "recording", ]); assert_ok(&result); assert!( result .output .contains("Bound /games/Grind Survivors/GrindSurvivors.exe to profile recording.") ); let result = env.run(&["game", "list"]); assert_ok(&result); assert!(result.output.contains("Game")); assert!(result.output.contains("Profile")); assert!(result.output.contains("Path")); assert!(result.output.contains("benchmark")); assert!(result.output.contains("recording")); let result = env.run(&["game", "show", "StarRuptureGameSteam.exe"]); assert_ok(&result); assert!(result.output.contains("Resolved profile: benchmark")); assert!(result.output.contains("Last launched profile: default")); let result = env.run(&[ "game", "note", "StarRuptureGameSteam.exe", "needs", "game-libs", "gamemode", ]); assert_ok(&result); assert!( result .output .contains("Set note for StarRuptureGameSteam.exe.") ); let result = env.run(&["game", "show", "StarRuptureGameSteam.exe"]); assert_ok(&result); assert!(result.output.contains("Note: needs game-libs gamemode")); let result = env.run(&["status"]); assert_ok(&result); assert!(result.output.contains("note: needs game-libs gamemode")); let result = env.run(&["config", "show"]); assert_ok(&result); assert!( result .output .contains("StarRuptureGameSteam.exe -> benchmark") ); assert!( result .output .contains("/games/Grind Survivors/GrindSurvivors.exe -> recording") ); let result = env.run(&["game", "unbind", "starrupture"]); assert_ok(&result); assert!(result.output.contains("Removed binding for starrupture.")); let result = env.run(&["config", "show"]); assert_ok(&result); assert!( !result .output .contains("StarRuptureGameSteam.exe -> benchmark") ); assert!( result .output .contains("/games/Grind Survivors/GrindSurvivors.exe -> recording") ); let result = env.run(&["game", "list", "star"]); assert_ok(&result); assert!(result.output.contains("StarRuptureGameSteam.exe")); assert!(result.output.contains("default")); assert!(!result.output.contains("GrindSurvivors.exe")); let result = env.run(&["game", "clear-note", "StarRuptureGameSteam.exe"]); assert_ok(&result); assert!( result .output .contains("Cleared note for StarRuptureGameSteam.exe.") ); let result = env.run(&["game", "show", "StarRuptureGameSteam.exe"]); assert_ok(&result); assert!(!result.output.contains("Note:")); let result = env.run(&["game", "forget", "StarRuptureGameSteam.exe"]); assert_ok(&result); assert!( result .output .contains("Removed StarRuptureGameSteam.exe from observed games.") ); let result = env.run(&["game", "show", "StarRuptureGameSteam.exe"]); assert_exit(&result, 3); assert!(result.output.contains("No observed game matched")); let result = env.run(&["game", "list", "star"]); assert_ok(&result); assert!(!result.output.contains("StarRuptureGameSteam.exe")); let result = env.run(&["game", "unbind", "StarRuptureGameSteam.exe"]); assert_exit(&result, 3); assert!( result .output .contains("No binding exists for `StarRuptureGameSteam.exe`.") ); let result = env.run(&["profile", "delete", "benchmark"]); assert_ok(&result); assert!( result .output .contains("Deleted profile `benchmark` and removed bindings that pointed to it.") ); let result = env.run(&["game", "bind", "StarRuptureGameSteam.exe", "benchmark"]); assert_exit(&result, 3); assert!( result .output .contains("Profile `benchmark` does not exist.") ); let result = env.run(&["profile", "delete", "recording"]); assert_ok(&result); assert!( result .output .contains("Deleted profile `recording` and removed bindings that pointed to it.") ); let result = env.run(&["profile", "list"]); assert_ok(&result); assert!(result.output.contains("No profiles configured.")); let result = env.run(&["config", "show"]); assert_ok(&result); assert!(result.output.contains("[bindings]")); assert!(result.output.contains("(none)")); } #[test] fn subcommand_typos_and_extra_args_fail_cleanly() { let env = TestEnv::new(); for args in [&["game"][..], &["profile"][..], &["config"][..]] { let result = env.run(args); assert_exit(&result, 2); assert!(result.output.contains("Usage: gamewrap")); } let result = env.run(&["game", "shwo", "StarRuptureGameSteam.exe"]); assert_exit(&result, 2); assert!(result.output.contains("unrecognized subcommand 'shwo'")); let result = env.run(&["profile", "crtate", "foo"]); assert_exit(&result, 2); assert!(result.output.contains("unrecognized subcommand 'crtate'")); let result = env.run(&["config", "sett", "mangohud", "on"]); assert_exit(&result, 2); assert!(result.output.contains("unrecognized subcommand 'sett'")); let result = env.run(&["status", "now"]); assert_exit(&result, 2); assert!(result.output.contains("unexpected argument 'now' found")); } #[test] fn post_launch_hook_runs_after_game_exits() { let env = TestEnv::new(); // Disable mangohud and gamemode so we don't need mangohud/gamemoderun. assert_ok(&env.run(&["config", "set", "mangohud", "off"])); assert_ok(&env.run(&["config", "set", "gamemode", "off"])); // Set a post-launch hook that prints a distinctive string to stdout. assert_ok(&env.run(&["config", "set", "post-launch", "printf POSTHOOK_RAN"])); // Run a real command (true exits immediately). let result = env.run(&["run", "/usr/bin/true"]); assert_ok(&result); assert!( result.output.contains("POSTHOOK_RAN"), "post-launch hook did not run; output was:\n{}", result.output ); // dry-run should show the post-launch hook in preview. let result = env.run(&["dry-run", "/usr/bin/true"]); assert_ok(&result); assert!( result .output .contains("Post-launch hook (runs after game exits):") ); assert!(result.output.contains("printf POSTHOOK_RAN")); } #[test] fn launch_decision_log_records_hooks_exit_and_playtime() { let env = TestEnv::new(); let log_path = env.state_home.join("custom/gamewrap.log"); let log_path_str = log_path.to_string_lossy().into_owned(); assert_ok(&env.run(&["config", "set", "mangohud", "off"])); assert_ok(&env.run(&["config", "set", "gamemode", "off"])); assert_ok(&env.run(&["config", "set", "log-file", "on"])); assert_ok(&env.run(&["config", "set", "log-path", &log_path_str])); assert_ok(&env.run(&["config", "set", "pre-launch", "exit 3"])); assert_ok(&env.run(&["config", "set", "post-launch", "exit 4"])); let result = env.run(&["run", "/bin/sh", "-c", "exit 7"]); assert_exit(&result, 7); let log = fs::read_to_string(log_path).expect("decision log"); assert!(log.contains("--- launch ---")); assert!(log.contains("executable: sh")); assert!(log.contains("profile: default")); assert!(log.contains("command: /bin/sh -c exit 7")); assert!(log.contains("pre-launch: exit 3 → exit 3")); assert!(log.contains("exit: 7")); assert!(log.contains("playtime: 0s")); assert!(log.contains("post-launch: exit 4 → exit 4")); } #[test] fn logging_settings_round_trip_and_validate_values() { let env = TestEnv::new(); for (setting, value) in [ ("log-file", "on"), ("log-path", "/tmp/gamewrap.log"), ("mangohud-log", "on"), ("mangohud-log-path", "/tmp/mangohud.csv"), ("vkbasalt-log-level", "warning"), ] { assert_ok(&env.run(&["config", "set", setting, value])); } let result = env.run(&["config", "show"]); assert_ok(&result); assert!(result.output.contains("log-file = on")); assert!(result.output.contains("log-path = /tmp/gamewrap.log")); assert!(result.output.contains("mangohud-log = on")); assert!( result .output .contains("mangohud-log-path = /tmp/mangohud.csv") ); assert!(result.output.contains("vkbasalt-log-level = warning")); let invalid = env.run(&["config", "set", "vkbasalt-log-level", "warn"]); assert_exit(&invalid, 3); assert!(invalid.output.contains("debug, info, warning, error, none")); } #[test] fn env_vars_appear_in_verbose_dry_run() { let env = TestEnv::new(); assert_ok(&env.run(&["config", "set", "mangohud", "on"])); assert_ok(&env.run(&["config", "set", "gamemode", "off"])); assert_ok(&env.run(&["config", "set", "verbose", "on"])); assert_ok(&env.run(&["config", "set", "vkbasalt", "on"])); assert_ok(&env.run(&["config", "set", "vkbasalt-config", "/tmp/vkbasalt.conf"])); assert_ok(&env.run(&["config", "set", "esync", "on"])); assert_ok(&env.run(&["config", "set", "fsync", "off"])); assert_ok(&env.run(&["config", "set", "large-address-aware", "on"])); assert_ok(&env.run(&["config", "set", "fps-cap", "60"])); let fake_path = env.path_with_fake_bins(&["mangohud"]); let result = env.run_with_env(&["dry-run", "/usr/bin/true"], &[("PATH", &fake_path)]); assert_ok(&result); // Verbose mode shows environment changes. assert!(result.output.contains("Environment changes:")); assert!(result.output.contains("ENABLE_VKBASALT=1")); assert!( result .output .contains("VKBASALT_CONFIG_FILE=/tmp/vkbasalt.conf") ); assert!(result.output.contains("PROTON_NO_ESYNC=0")); assert!(result.output.contains("PROTON_NO_FSYNC=1")); assert!(result.output.contains("PROTON_LARGE_ADDRESS_AWARE=1")); assert!(result.output.contains("MANGOHUD_PARAMS=fps_limit=60")); // Final command should have mangohud prefix. assert!(result.output.contains("mangohud /usr/bin/true")); } #[test] fn gamescope_extended_settings_and_mangoapp_work() { let env = TestEnv::new(); // Set up: gamescope on, full output + nested res, filter, scaler, sharpness. assert_ok(&env.run(&["config", "set", "gamescope", "on"])); assert_ok(&env.run(&["config", "set", "gamescope-width", "1920"])); assert_ok(&env.run(&["config", "set", "gamescope-height", "1080"])); assert_ok(&env.run(&["config", "set", "gamescope-nested-width", "1280"])); assert_ok(&env.run(&["config", "set", "gamescope-nested-height", "720"])); assert_ok(&env.run(&["config", "set", "gamescope-fps", "60"])); assert_ok(&env.run(&["config", "set", "gamescope-unfocused-fps", "15"])); assert_ok(&env.run(&["config", "set", "gamescope-filter", "fsr"])); assert_ok(&env.run(&["config", "set", "gamescope-scaler", "fit"])); assert_ok(&env.run(&["config", "set", "gamescope-sharpness", "5"])); assert_ok(&env.run(&["config", "set", "gamescope-mode", "fullscreen"])); assert_ok(&env.run(&["config", "set", "gamescope-adaptive-sync", "on"])); assert_ok(&env.run(&["config", "set", "gamescope-hdr", "on"])); assert_ok(&env.run(&["config", "set", "gamescope-steam", "on"])); assert_ok(&env.run(&["config", "set", "gamescope-expose-wayland", "on"])); // Verify config show reflects the new keys. let result = env.run(&["config", "show"]); assert_ok(&result); assert!(result.output.contains("gamescope-nested-width = 1280")); assert!(result.output.contains("gamescope-nested-height = 720")); assert!(result.output.contains("gamescope-unfocused-fps = 15")); assert!(result.output.contains("gamescope-filter = fsr")); assert!(result.output.contains("gamescope-scaler = fit")); assert!(result.output.contains("gamescope-sharpness = 5")); assert!(result.output.contains("gamescope-mode = fullscreen")); assert!(result.output.contains("gamescope-adaptive-sync = on")); assert!(result.output.contains("gamescope-hdr = on")); assert!(result.output.contains("gamescope-steam = on")); assert!(result.output.contains("gamescope-expose-wayland = on")); // Dry-run: mangohud off — all new flags should appear in gamescope args. assert_ok(&env.run(&["config", "set", "mangohud", "off"])); assert_ok(&env.run(&["config", "set", "gamemode", "off"])); let fake_path = env.path_with_fake_bins(&["gamescope"]); let result = env.run_with_env(&["dry-run", "/usr/bin/true"], &[("PATH", &fake_path)]); assert_ok(&result); assert!(result.output.contains("-W 1920")); assert!(result.output.contains("-H 1080")); assert!(result.output.contains("-w 1280")); assert!(result.output.contains("-h 720")); assert!(result.output.contains("-r 60")); assert!(result.output.contains("-o 15")); assert!(result.output.contains("-F fsr")); assert!(result.output.contains("-S fit")); assert!(result.output.contains("--sharpness 5")); assert!(result.output.contains("-f")); assert!(result.output.contains("--adaptive-sync")); assert!(result.output.contains("--hdr-enabled")); assert!(result.output.contains("-e")); assert!(result.output.contains("--expose-wayland")); // No mangoapp flag since gamescope-mangoapp is off. assert!(!result.output.contains("--mangoapp")); // Validation: invalid filter value rejected. let result = env.run(&["config", "set", "gamescope-filter", "magic"]); assert_exit(&result, 3); assert!(result.output.contains("not a valid gamescope filter")); // Validation: invalid scaler value rejected. let result = env.run(&["config", "set", "gamescope-scaler", "zoom"]); assert_exit(&result, 3); assert!(result.output.contains("not a valid gamescope scaler")); // Validation: sharpness out of range rejected. let result = env.run(&["config", "set", "gamescope-sharpness", "25"]); assert_exit(&result, 3); assert!(result.output.contains("out of range")); // mangoapp ON + mangohud ON: --mangoapp in gamescope args, no mangohud prefix. assert_ok(&env.run(&["config", "set", "mangohud", "on"])); assert_ok(&env.run(&["config", "set", "gamescope-mangoapp", "on"])); let fake_path = env.path_with_fake_bins(&["gamescope", "mangoapp"]); let result = env.run_with_env(&["dry-run", "/usr/bin/true"], &[("PATH", &fake_path)]); assert_ok(&result); assert!(result.output.contains("--mangoapp")); assert!(!result.output.contains("mangohud")); // mangoapp OFF + mangohud ON + gamescope ON: mangohud prefix inside gamescope. assert_ok(&env.run(&["config", "set", "gamescope-mangoapp", "off"])); let fake_path = env.path_with_fake_bins(&["gamescope", "mangohud"]); let result = env.run_with_env(&["dry-run", "/usr/bin/true"], &[("PATH", &fake_path)]); assert_ok(&result); assert!(!result.output.contains("--mangoapp")); assert!(result.output.contains("-- mangohud")); // Doctor: warns about mangohud prefix inside gamescope when mangoapp is off. let result = env.run_with_env(&["doctor"], &[("PATH", &fake_path)]); assert_ok(&result); assert!(result.output.contains("gamescope overlay")); // mangoapp dependency error when mangoapp binary is missing. assert_ok(&env.run(&["config", "set", "gamescope-mangoapp", "on"])); // Use isolated PATH so the system's real mangoapp (from mangohud package) can't be found. let fake_path_no_mangoapp = env.path_isolated_fake_bins(&["gamescope"]); let result = env.run_with_env( &["dry-run", "/usr/bin/true"], &[("PATH", &fake_path_no_mangoapp)], ); assert_exit(&result, 4); assert!(result.output.contains("mangoapp")); assert!(result.output.contains("mangohud")); // Profile: gamescope-mangoapp and new keys round-trip through profile set/show. assert_ok(&env.run(&["profile", "create", "gaming"])); assert_ok(&env.run(&["profile", "set", "gaming", "gamescope-mangoapp", "on"])); assert_ok(&env.run(&["profile", "set", "gaming", "gamescope-filter", "nis"])); assert_ok(&env.run(&["profile", "set", "gaming", "gamescope-sharpness", "10"])); assert_ok(&env.run(&["profile", "set", "gaming", "gamescope-adaptive-sync", "on"])); assert_ok(&env.run(&["profile", "set", "gaming", "gamescope-hdr", "on"])); assert_ok(&env.run(&["profile", "set", "gaming", "gamescope-nested-width", "2560"])); assert_ok(&env.run(&[ "profile", "set", "gaming", "gamescope-nested-height", "1440", ])); let result = env.run(&["profile", "show", "gaming"]); assert_ok(&result); assert!(result.output.contains("gamescope-mangoapp = on")); assert!(result.output.contains("gamescope-filter = nis")); assert!(result.output.contains("gamescope-sharpness = 10")); assert!(result.output.contains("gamescope-adaptive-sync = on")); assert!(result.output.contains("gamescope-hdr = on")); assert!(result.output.contains("gamescope-nested-width = 2560")); assert!(result.output.contains("gamescope-nested-height = 1440")); // Reset new keys. for setting in [ "gamescope-nested-width", "gamescope-nested-height", "gamescope-unfocused-fps", "gamescope-filter", "gamescope-scaler", "gamescope-sharpness", "gamescope-mode", "gamescope-adaptive-sync", "gamescope-hdr", "gamescope-steam", "gamescope-expose-wayland", "gamescope-mangoapp", ] { let result = env.run(&["profile", "reset", "gaming", setting]); assert_ok(&result); } } #[test] fn test_config_show_modes() { let env = TestEnv::new(); // Configured-only (default) with nothing set: show placeholder let result = env.run(&["config", "show"]); assert_ok(&result); assert!( result.output.contains("no global settings configured"), "expected placeholder for empty config, got: {}", result.output ); // Set a single value, then show: only that setting appears assert_ok(&env.run(&["config", "set", "mangohud", "off"])); let result = env.run(&["config", "show"]); assert_ok(&result); assert!(result.output.contains("mangohud = off")); assert!( !result.output.contains("gamemode"), "configured mode should not show unset fields" ); // --effective shows all settings including defaults let result = env.run(&["config", "show", "--effective"]); assert_ok(&result); assert!(result.output.contains("mangohud = off")); assert!( result.output.contains("gamemode = on"), "--effective should show default settings" ); assert!( result.output.contains("gamescope = off"), "--effective should show all fields" ); assert_ok(&env.run(&["profile", "create", "benchmark"])); let result = env.run(&["config", "show"]); assert_ok(&result); assert!( result .output .contains("(no settings configured for this profile)") ); assert!(!result.output.contains("(default:")); let result = env.run(&["config", "show", "--effective"]); assert_ok(&result); assert!(result.output.contains("gamemode = (default: on)")); let result = env.run(&["profile", "show", "benchmark"]); assert_ok(&result); assert!( result .output .contains("(no settings configured for this profile)") ); assert!(!result.output.contains("(default:")); let result = env.run(&["profile", "show", "benchmark", "--effective"]); assert_ok(&result); assert!(result.output.contains("gamemode = (default: on)")); } #[test] fn test_config_auto_migrate() { let env = TestEnv::new(); let config_dir = env.config_home.join("gamewrap"); let config_file = config_dir.join("config.toml"); fs::create_dir_all(&config_dir).expect("config dir"); fs::write( &config_file, "[defaults]\noverlay = true\nperformance = false\n", ) .expect("write old config"); let result = env.run(&["config", "show"]); assert_ok(&result); let migrated = fs::read_to_string(config_file).expect("migrated config"); assert!(migrated.contains("mangohud = true")); assert!(migrated.contains("gamemode = false")); assert!(!migrated.contains("overlay")); assert!(!migrated.contains("performance")); } #[test] fn test_config_migrate_command() { let env = TestEnv::new(); let config_dir = env.config_home.join("gamewrap"); let config_file = config_dir.join("config.toml"); fs::create_dir_all(&config_dir).expect("config dir"); fs::write( &config_file, "[defaults]\noverlay = true\nperformance = false\nfps_cap = 60\n", ) .expect("write old config"); let result = env.run(&["config", "migrate"]); assert_ok(&result); assert!(result.output.contains("Config migrated successfully.")); let migrated = fs::read_to_string(config_file).expect("migrated config"); assert!(migrated.contains("mangohud = true")); assert!(migrated.contains("gamemode = false")); assert!(migrated.contains("fps_cap = 60")); assert!(!migrated.contains("overlay")); assert!(!migrated.contains("performance")); } #[test] fn test_profile_migrate_command() { let env = TestEnv::new(); let path = env.path("old-bench.gamewrap-profile.toml"); fs::write( &path, r#"kind = "gamewrap-profile" version = 1 name = "old-bench" [settings] overlay = true performance = false fps_cap = 60 gamescope = false "#, ) .expect("write old profile"); let result = env.run(&["profile", "migrate", path.to_str().expect("utf8 path")]); assert_ok(&result); assert!( result .output .contains("Profile export migrated successfully.") ); let migrated = fs::read_to_string(path).expect("migrated profile"); assert!(migrated.contains("mangohud = true")); assert!(migrated.contains("gamemode = false")); assert!(migrated.contains("fps_cap = 60")); assert!(!migrated.contains("overlay")); assert!(!migrated.contains("performance")); } #[test] fn test_profile_migrate_dry_run() { let env = TestEnv::new(); let path = env.path("dry-run.gamewrap-profile.toml"); let original = r#"kind = "gamewrap-profile" version = 1 name = "old-bench" [settings] overlay = true performance = false "#; fs::write(&path, original).expect("write old profile"); let result = env.run(&[ "profile", "migrate", "--dry-run", path.to_str().expect("utf8 path"), ]); assert_ok(&result); assert!(result.stdout.contains("mangohud = true")); assert!(result.stdout.contains("gamemode = false")); assert_eq!( fs::read_to_string(path).expect("original profile"), original ); } #[test] fn test_profile_import_warns_on_old_keys() { let env = TestEnv::new(); let path = env.path("old-import.gamewrap-profile.toml"); fs::write( &path, r#"kind = "gamewrap-profile" version = 1 name = "old-import" [settings] overlay = true performance = false fps_cap = 60 "#, ) .expect("write old profile"); let result = env.run(&["profile", "import", path.to_str().expect("utf8 path")]); assert_ok(&result); assert!(result.output.contains("renamed settings that were skipped")); assert!(result.output.contains("overlay")); assert!(result.output.contains("performance")); } #[test] fn test_config_migrate_no_op() { let env = TestEnv::new(); let config_dir = env.config_home.join("gamewrap"); let config_file = config_dir.join("config.toml"); fs::create_dir_all(&config_dir).expect("config dir"); fs::write( config_file, "[defaults]\nmangohud = true\ngamemode = false\n", ) .expect("write current config"); let result = env.run(&["config", "migrate"]); assert_ok(&result); assert!(result.output.contains("no migration needed")); } #[test] fn launch_count_and_playtime_tracked() { let env = TestEnv::new(); assert_ok(&env.run(&["config", "set", "mangohud", "off"])); assert_ok(&env.run(&["config", "set", "gamemode", "off"])); // Simulate two Steam launches of the same game. for _ in 0..2 { assert_ok(&env.run_with_env( &[ "/usr/bin/true", "--", "/usr/bin/true", "waitforexitandrun", "/games/TestGame/TestGame.exe", ], &[("SteamAppId", "99999")], )); } // game show should show launch count of 2. let result = env.run(&["game", "show", "TestGame.exe"]); assert_ok(&result); assert!(result.output.contains("Launch count: 2")); assert!(result.output.contains("Last launched: 20")); // gamewrap last should show it. let result = env.run(&["last"]); assert_ok(&result); assert!(result.output.contains("Last played: TestGame.exe")); assert!(result.output.contains("Launches: 2")); } #[test] fn hook_errors_setting_round_trip() { let env = TestEnv::new(); // Default: hook-errors is unset (show only shows explicitly configured) let result = env.run(&["config", "show"]); assert_ok(&result); // Not shown by default (no setting configured) assert!(!result.output.contains("hook-errors")); // Set to fail let result = env.run(&["config", "set", "hook-errors", "fail"]); assert_ok(&result); let result = env.run(&["config", "show"]); assert_ok(&result); assert!(result.output.contains("hook-errors")); assert!(result.output.contains("fail")); // Reset let result = env.run(&["config", "reset", "hook-errors"]); assert_ok(&result); let result = env.run(&["config", "show"]); assert_ok(&result); assert!(!result.output.contains("hook-errors")); // Invalid value let result = env.run(&["config", "set", "hook-errors", "crash"]); assert_exit(&result, 3); assert!(result.output.contains("not a valid hook-errors value")); } #[test] fn pre_hook_failure_warns_by_default() { let env = TestEnv::new(); let path = env.path_with_fake_bins(&["mangohud", "gamemoderun", "game.exe"]); // Configure a pre-hook that exits 1 let result = env.run(&["config", "set", "pre-launch", "exit 1"]); assert_ok(&result); // Default is warn mode — game should still launch (exit 0 from fake bin) let result = env.run_with_env( &["run", &env.path("bin/game.exe").to_string_lossy()], &[("PATH", &path)], ); // The fake game exits 0, so overall success assert_ok(&result); // Stderr should mention the hook failure assert!( result.output.contains("pre-launch hook exited 1") || result.output.contains("pre-launch") ); } #[test] fn pre_hook_failure_aborts_in_fail_mode() { let env = TestEnv::new(); let path = env.path_with_fake_bins(&["mangohud", "gamemoderun", "game.exe"]); env.run(&["config", "set", "pre-launch", "exit 1"]); env.run(&["config", "set", "hook-errors", "fail"]); let result = env.run_with_env( &["run", &env.path("bin/game.exe").to_string_lossy()], &[("PATH", &path)], ); // Should fail because pre-hook failed and hook-errors=fail assert_exit(&result, 1); assert!(result.output.contains("pre-launch hook exited 1")); } #[test] fn post_hook_runs_after_game_exits_nonzero() { let env = TestEnv::new(); // Create a game binary that exits with code 42 let bin_dir = env.home.join("bin"); std::fs::create_dir_all(&bin_dir).expect("bin dir"); let game_path = bin_dir.join("failing_game.sh"); std::fs::write(&game_path, "#!/bin/sh\nexit 42\n").expect("write failing game"); let mut perms = std::fs::metadata(&game_path).expect("meta").permissions(); perms.set_mode(0o755); std::fs::set_permissions(&game_path, perms).expect("chmod"); // Create a post-hook marker file so we can verify the hook ran let marker = env.home.join("post_hook_ran"); let post_hook_cmd = format!("touch {}", marker.display()); let fake_path = env.path_with_fake_bins(&["mangohud", "gamemoderun"]); env.run(&["config", "set", "mangohud", "off"]); env.run(&["config", "set", "gamemode", "off"]); let set_result = env.run(&["config", "set", "post-launch", &post_hook_cmd]); assert_ok(&set_result); let result = env.run_with_env( &["run", &game_path.to_string_lossy()], &[("PATH", &fake_path)], ); // Game exited 42, so gamewrap should exit 42 assert_eq!( result.status, 42, "expected game exit code propagated:\n{}", result.output ); // Post-hook must have run assert!( marker.exists(), "post-launch hook did not run after nonzero game exit" ); } #[test] fn post_hook_runs_after_successful_game_exit() { let env = TestEnv::new(); let bin_dir = env.home.join("bin"); std::fs::create_dir_all(&bin_dir).expect("bin dir"); let game_path = bin_dir.join("ok_game.sh"); std::fs::write(&game_path, "#!/bin/sh\nexit 0\n").expect("write ok game"); let mut perms = std::fs::metadata(&game_path).expect("meta").permissions(); perms.set_mode(0o755); std::fs::set_permissions(&game_path, perms).expect("chmod"); let marker = env.home.join("post_hook_ok_ran"); let post_hook_cmd = format!("touch {}", marker.display()); env.run(&["config", "set", "mangohud", "off"]); env.run(&["config", "set", "gamemode", "off"]); env.run(&["config", "set", "post-launch", &post_hook_cmd]); let result = env.run_with_env(&["run", &game_path.to_string_lossy()], &[]); assert_ok(&result); assert!( marker.exists(), "post-launch hook did not run after successful game exit" ); } #[test] fn post_hook_failure_is_reported() { let env = TestEnv::new(); let bin_dir = env.home.join("bin"); std::fs::create_dir_all(&bin_dir).expect("bin dir"); let game_path = bin_dir.join("noop_game.sh"); std::fs::write(&game_path, "#!/bin/sh\nexit 0\n").expect("write noop game"); let mut perms = std::fs::metadata(&game_path).expect("meta").permissions(); perms.set_mode(0o755); std::fs::set_permissions(&game_path, perms).expect("chmod"); env.run(&["config", "set", "mangohud", "off"]); env.run(&["config", "set", "gamemode", "off"]); env.run(&["config", "set", "post-launch", "exit 7"]); let result = env.run_with_env(&["run", &game_path.to_string_lossy()], &[]); // Overall should succeed (game exited 0, post-hook failure is reported but not fatal) assert_ok(&result); // Should report post-hook failure on stderr (captured in result.output) assert!( result.output.contains("post-launch hook exited 7"), "Expected post-hook failure report in output:\n{}", result.output ); } #[test] fn hook_errors_profile_round_trip() { let env = TestEnv::new(); env.run(&["profile", "create", "myprofile"]); let result = env.run(&["profile", "set", "myprofile", "hook-errors", "fail"]); assert_ok(&result); let result = env.run(&["profile", "show", "myprofile"]); assert_ok(&result); assert!(result.output.contains("hook-errors")); assert!(result.output.contains("fail")); let result = env.run(&["profile", "reset", "myprofile", "hook-errors"]); assert_ok(&result); let result = env.run(&["profile", "show", "myprofile"]); assert_ok(&result); // After reset, hook-errors should not appear (no explicit setting) assert!(!result.output.contains("hook-errors")); } #[test] fn hook_errors_config_export_import() { let env = TestEnv::new(); let export_path = env.path("my-config"); env.run(&["config", "set", "hook-errors", "fail"]); let result = env.run(&["config", "export", &export_path.to_string_lossy()]); assert_ok(&result); // The exported file should contain hook-errors let suffix_path = env.path("my-config.gamewrap.toml"); let content = std::fs::read_to_string(&suffix_path).expect("read export"); assert!( content.contains("hook-errors"), "export should contain hook-errors:\n{content}" ); assert!(content.contains("fail")); // Import into a fresh env let env2 = TestEnv::new(); let result = env2.run(&["config", "import", &suffix_path.to_string_lossy()]); assert_ok(&result); let result = env2.run(&["config", "show"]); assert_ok(&result); assert!(result.output.contains("hook-errors")); assert!(result.output.contains("fail")); } #[test] fn dry_run_shows_hook_errors_when_hooks_configured() { let env = TestEnv::new(); let path = env.path_with_fake_bins(&["mangohud", "gamemoderun", "game.exe"]); env.run(&["config", "set", "pre-launch", "echo hello"]); env.run(&["config", "set", "hook-errors", "fail"]); let result = env.run_with_env( &["dry-run", &env.path("bin/game.exe").to_string_lossy()], &[("PATH", &path)], ); assert_ok(&result); assert!(result.output.contains("hook-errors") || result.output.contains("Hook errors")); assert!(result.output.contains("fail")); } #[test] fn help_topics_all_valid_and_invalid() { let env = TestEnv::new(); // Valid help topics for topic in &[ "settings", "profiles", "bindings", "doctor", "completion", "troubleshooting", ] { let r = env.run(&["help", topic]); assert_ok(&r); assert!(!r.output.is_empty(), "help {topic} gave empty output"); } // help settings with filter let r = env.run(&["help", "settings", "mangohud"]); assert_ok(&r); assert!( r.output.contains("mangohud"), "help settings mangohud should mention mangohud" ); let r = env.run(&["help", "settings", "gamescope"]); assert_ok(&r); assert!(r.output.contains("gamescope")); // help settings bad group -> non-zero with message let r = env.run(&["help", "settings", "badgroup"]); assert!(r.status != 0, "help settings badgroup should fail"); assert!( r.output.contains("badgroup") || r.output.to_lowercase().contains("unknown") || r.output.to_lowercase().contains("not a known") ); // help bad topic -> non-zero with message let r = env.run(&["help", "completely-unknown-topic"]); assert!(r.status != 0, "help on unknown topic should fail"); assert!( r.output.contains("completely-unknown-topic") || r.output.to_lowercase().contains("not a known") ); } #[test] fn version_and_bare_help() { let env = TestEnv::new(); let r = env.run(&["--version"]); assert_ok(&r); assert!( !r.output.trim().is_empty(), "--version should print something" ); let r = env.run(&["--help"]); assert_ok(&r); assert!( r.output.contains("gamewrap"), "--help should mention gamewrap" ); } #[test] fn status_runs_cleanly() { let env = TestEnv::new(); let path = env.path_with_fake_bins(&["mangohud", "gamemoderun", "gamescope"]); let r = env.run_with_env(&["status"], &[("PATH", &path)]); assert_ok(&r); // status with no deps: still exits 0 (informational command) let empty_path = env.path_isolated_fake_bins(&[]); let r = env.run_with_env(&["status"], &[("PATH", &empty_path)]); assert_ok(&r); } #[test] fn all_toggle_settings_reject_invalid_values() { let env = TestEnv::new(); let toggle_settings = &[ "mangohud", "gamemode", "steam-host-libs", "verbose", "log-file", "gamescope", "gamescope-adaptive-sync", "gamescope-hdr", "gamescope-steam", "gamescope-expose-wayland", "gamescope-mangoapp", "mangohud-log", "vkbasalt", "esync", "fsync", "large-address-aware", ]; let bad_values = &["maybe", "yes", "1", "ON", ""]; for setting in toggle_settings { for bad in bad_values { let r = env.run(&["config", "set", setting, bad]); assert_eq!( r.status, 3, "toggle `{setting}` bad value `{bad}`:\n{}", r.output ); // Verify error output mentions the bad value or the setting name assert!( r.output.contains(bad) || r.output.contains(setting) || r.output.to_lowercase().contains("valid"), "toggle setting `{setting}` with value `{bad}` gave unhelpful error: {}", r.output ); } } } #[test] fn enum_settings_reject_invalid_values() { let env = TestEnv::new(); let cases = &[ ("game-libs", "badvalue"), ("gamescope-scaler", "badvalue"), ("gamescope-filter", "badvalue"), ("gamescope-mode", "badvalue"), ("vkbasalt-log-level", "badvalue"), ("hook-errors", "badvalue"), ]; for (setting, value) in cases { let r = env.run(&["config", "set", setting, value]); assert_eq!( r.status, 3, "enum `{setting}` with `{value}` should exit 3:\n{}", r.output ); } } #[test] fn numeric_settings_reject_bad_values() { let env = TestEnv::new(); let cases: &[(&str, &str)] = &[ ("gamescope-fps", "4294967296"), ("gamescope-fps", "abc"), ("gamescope-fps", "1.5"), ("gamescope-unfocused-fps", "4294967296"), ("gamescope-nested-width", "4294967296"), ("gamescope-nested-height", "4294967296"), ("fps-cap", "4294967296"), ("fps-cap", "abc"), ("gamescope-sharpness", "21"), ("gamescope-sharpness", "4294967296"), ("gamescope-sharpness", "abc"), ("gamescope-width", "abc"), ("gamescope-height", "abc"), ]; for (setting, value) in cases { let r = env.run(&["config", "set", setting, value]); assert_eq!( r.status, 3, "numeric `{setting}` with `{value}` should exit 3:\n{}", r.output ); } } #[test] fn numeric_settings_boundary_valid_values() { let env = TestEnv::new(); let valid_cases: &[(&str, &str)] = &[ ("gamescope-sharpness", "0"), ("gamescope-sharpness", "20"), ("gamescope-fps", "1"), ("gamescope-width", "native"), ("gamescope-width", "1920"), ("gamescope-height", "1080"), ("gamescope-height", "native"), ("gamescope-nested-width", "1"), ("gamescope-nested-height", "1"), ("gamescope-unfocused-fps", "1"), ("fps-cap", "1"), ]; for (setting, value) in valid_cases { let r = env.run(&["config", "set", setting, value]); assert_ok(&r); // reset afterwards so settings don't bleed across sub-cases env.run(&["config", "reset", setting]); } } #[test] fn unknown_setting_name_rejected() { let env = TestEnv::new(); let bad_names = &[ "nonexistent-setting", "MangoHud", // wrong case "mangohud-extra", // plausible but wrong "GAMEMODE", // all-caps "hook_errors", // underscore instead of dash ]; for name in bad_names { let r = env.run(&["config", "set", name, "on"]); assert_eq!( r.status, 3, "unknown setting `{name}` should exit 3:\n{}", r.output ); assert!( r.output.contains(name) || r.output.to_lowercase().contains("unknown") || r.output.to_lowercase().contains("not a known"), "error for `{name}` should mention the name or say 'unknown':\n{}", r.output ); } } #[test] fn setting_aliases_work() { let env = TestEnv::new(); // host-libs alias for steam-host-libs assert_ok(&env.run(&["config", "set", "host-libs", "on"])); let r = env.run(&["config", "show"]); assert_ok(&r); assert!( r.output.contains("steam-host-libs"), "alias host-libs should set steam-host-libs:\n{}", r.output ); assert_ok(&env.run(&["config", "reset", "host-libs"])); let r = env.run(&["config", "show"]); assert!( !r.output.contains("steam-host-libs"), "after reset via alias, steam-host-libs should be gone:\n{}", r.output ); // laa alias for large-address-aware assert_ok(&env.run(&["config", "set", "laa", "on"])); let r = env.run(&["config", "show"]); assert!( r.output.contains("large-address-aware"), "alias laa should set large-address-aware:\n{}", r.output ); assert_ok(&env.run(&["config", "reset", "laa"])); let r = env.run(&["config", "show"]); assert!( !r.output.contains("large-address-aware"), "after reset via laa alias, large-address-aware should be gone:\n{}", r.output ); } #[test] fn config_reset_edge_cases() { let env = TestEnv::new(); // config reset with no setting and no --all -> exit 2 let r = env.run(&["config", "reset"]); assert_eq!( r.status, 2, "config reset with no args should exit 2:\n{}", r.output ); // config reset --all with a setting name -> error (conflicts) let r = env.run(&["config", "reset", "--all", "mangohud"]); assert!( r.status != 0, "config reset --all with setting name should fail:\n{}", r.output ); // config reset unknown setting -> exit 3 let r = env.run(&["config", "reset", "nonexistent-setting"]); assert_eq!( r.status, 3, "config reset nonexistent setting should exit 3:\n{}", r.output ); } #[test] fn config_export_stdout_and_file() { let env = TestEnv::new(); assert_ok(&env.run(&["config", "set", "mangohud", "off"])); // Export to stdout let stdout_path = env.path("stdout.gamewrap.toml"); std::os::unix::fs::symlink("/dev/stdout", &stdout_path).unwrap(); let r = env.run(&["config", "export", stdout_path.to_str().unwrap()]); assert_ok(&r); assert!( r.stdout.contains("mangohud"), "export to stdout should contain set field:\n{}", r.stdout ); // Should be sparse - only the set field assert!( !r.stdout.contains("gamemode"), "export should be sparse, not contain unset gamemode:\n{}", r.stdout ); // Export to file let out_path = env.path("out"); let r = env.run(&["config", "export", out_path.to_str().unwrap()]); assert_ok(&r); let file_path = out_path.with_extension("gamewrap.toml"); assert!(file_path.exists(), "exported file should exist"); let content = std::fs::read_to_string(&file_path).unwrap(); assert!( content.contains("mangohud"), "exported file should contain the set field" ); assert!( !content.contains("gamemode"), "exported file should be sparse" ); } #[test] fn config_import_error_cases() { let env = TestEnv::new(); // Import nonexistent file -> non-zero (likely exit 1) let r = env.run(&["config", "import", "/nonexistent/path/config.gamewrap.toml"]); assert!( r.status != 0, "import nonexistent file should fail:\n{}", r.output ); // Import invalid TOML -> exit 3 let bad_toml = env.path("bad.gamewrap.toml"); std::fs::write(&bad_toml, "not valid toml [[[").unwrap(); let r = env.run(&["config", "import", bad_toml.to_str().unwrap()]); assert_eq!( r.status, 3, "import invalid TOML should exit 3:\n{}", r.output ); // Import config whose bindings reference a missing profile -> exit 3 let bad_config = env.path("bad-binding.gamewrap.toml"); std::fs::write( &bad_config, r#" [defaults] mangohud = true [[bindings]] matcher = "game.exe" profile = "nonexistent-profile" "#, ) .unwrap(); let r = env.run(&["config", "import", bad_config.to_str().unwrap()]); assert_eq!( r.status, 3, "import config with missing-profile binding should exit 3:\n{}", r.output ); } #[test] fn profile_reserved_name_rejected() { let env = TestEnv::new(); // profile create default -> exit 3 let r = env.run(&["profile", "create", "default"]); assert_eq!( r.status, 3, "profile create default should exit 3:\n{}", r.output ); assert!( r.output.to_lowercase().contains("reserved") || r.output.to_lowercase().contains("default"), "error should mention reserved:\n{}", r.output ); // profile export default -> exit 3 let r = env.run(&["profile", "export", "default"]); assert_eq!( r.status, 3, "profile export default should exit 3:\n{}", r.output ); // profile import a file where profile name field is "default" -> exit 3 let default_profile = env.path("default.gamewrap-profile.toml"); std::fs::write( &default_profile, r#" kind = "profile" name = "default" version = 1 [settings] mangohud = false "#, ) .unwrap(); let r = env.run(&["profile", "import", default_profile.to_str().unwrap()]); assert_eq!( r.status, 3, "importing a profile named 'default' should exit 3:\n{}", r.output ); } #[test] fn profile_duplicate_edge_cases() { let env = TestEnv::new(); // Duplicate nonexistent source -> exit 3 let r = env.run(&["profile", "duplicate", "nonexistent", "dest"]); assert_eq!( r.status, 3, "duplicate nonexistent source should exit 3:\n{}", r.output ); // Duplicate to existing destination -> exit 3 assert_ok(&env.run(&["profile", "create", "src"])); assert_ok(&env.run(&["profile", "create", "dest"])); let r = env.run(&["profile", "duplicate", "src", "dest"]); assert_eq!( r.status, 3, "duplicate to existing dest should exit 3:\n{}", r.output ); // Duplicate to reserved name "default" -> exit 3 let r = env.run(&["profile", "duplicate", "src", "default"]); assert_eq!( r.status, 3, "duplicate to 'default' should exit 3:\n{}", r.output ); // Valid duplicate copies settings assert_ok(&env.run(&["profile", "set", "src", "mangohud", "off"])); let r = env.run(&["profile", "duplicate", "src", "dst-copy"]); assert_ok(&r); let r = env.run(&["profile", "show", "dst-copy"]); assert_ok(&r); assert!( r.output.contains("mangohud"), "duplicated profile should have copied settings:\n{}", r.output ); } #[test] fn profile_import_error_cases() { let env = TestEnv::new(); // Import nonexistent file -> non-zero let r = env.run(&["profile", "import", "/no/such/file.gamewrap-profile.toml"]); assert!( r.status != 0, "import nonexistent profile file should fail:\n{}", r.output ); // Create and export a profile, then create a same-named profile and try importing -> exit 3 assert_ok(&env.run(&["profile", "create", "dupe-test"])); let export_path = env.path("dupe-test"); assert_ok(&env.run(&[ "profile", "export", "dupe-test", export_path.to_str().unwrap(), ])); // profile "dupe-test" still exists; re-importing should fail let export_file = export_path.with_extension("gamewrap-profile.toml"); let r = env.run(&["profile", "import", export_file.to_str().unwrap()]); assert_eq!( r.status, 3, "import duplicate profile name should exit 3:\n{}", r.output ); assert!( r.output.to_lowercase().contains("already exists") || r.output.to_lowercase().contains("dupe-test"), "error should mention the name:\n{}", r.output ); } #[test] fn profile_delete_nonexistent() { let env = TestEnv::new(); let r = env.run(&["profile", "delete", "no-such-profile"]); assert_eq!( r.status, 3, "delete nonexistent profile should exit 3:\n{}", r.output ); } #[test] fn profile_reset_edge_cases() { let env = TestEnv::new(); // Resetting a setting that was never set is a no-op -> exit 0 assert_ok(&env.run(&["profile", "create", "p"])); let r = env.run(&["profile", "reset", "p", "mangohud"]); assert_ok(&r); // Reset in nonexistent profile -> exit 3 let r = env.run(&["profile", "reset", "nonexistent", "mangohud"]); assert_eq!( r.status, 3, "reset in nonexistent profile should exit 3:\n{}", r.output ); } #[test] fn profile_env_edge_cases() { let env = TestEnv::new(); assert_ok(&env.run(&["profile", "create", "p"])); // env list when no vars set -> exit 0, informational message let r = env.run(&["profile", "env", "list", "p"]); assert_ok(&r); // env clear when no vars set -> exit 0 let r = env.run(&["profile", "env", "clear", "p"]); assert_ok(&r); // env unset a key that was never set -> exit 0 let r = env.run(&["profile", "env", "unset", "p", "NONEXISTENT_KEY"]); assert_ok(&r); // Set a var, then unset it, then list -> empty again assert_ok(&env.run(&["profile", "env", "set", "p", "MY_VAR", "hello"])); let r = env.run(&["profile", "env", "list", "p"]); assert_ok(&r); assert!(r.output.contains("MY_VAR")); assert_ok(&env.run(&["profile", "env", "unset", "p", "MY_VAR"])); let r = env.run(&["profile", "env", "list", "p"]); assert_ok(&r); assert!( !r.output.contains("MY_VAR"), "after unset, var should be gone:\n{}", r.output ); } #[test] fn game_command_error_cases() { let env = TestEnv::new(); // game show nonexistent matcher -> exit 3 let r = env.run(&["game", "show", "nonexistent.exe"]); assert_eq!( r.status, 3, "game show nonexistent should exit 3:\n{}", r.output ); // game bind to nonexistent profile -> exit 3 let r = env.run(&["game", "bind", "game.exe", "no-such-profile"]); assert_eq!( r.status, 3, "game bind to nonexistent profile should exit 3:\n{}", r.output ); // game unbind when no binding exists -> exit 3 let r = env.run(&["game", "unbind", "unbound.exe"]); assert_eq!( r.status, 3, "game unbind when no binding should exit 3:\n{}", r.output ); // game forget nonexistent -> exit 3 let r = env.run(&["game", "forget", "unknown.exe"]); assert_eq!( r.status, 3, "game forget unknown game should exit 3:\n{}", r.output ); // game rename nonexistent -> exit 3 let r = env.run(&["game", "rename", "nonexistent.exe", "New Name"]); assert_eq!( r.status, 3, "game rename nonexistent should exit 3:\n{}", r.output ); } #[test] fn game_list_empty() { let env = TestEnv::new(); let r = env.run(&["game", "list"]); assert_ok(&r); // Should print a "no games" placeholder rather than silently printing nothing assert!( !r.output.trim().is_empty(), "game list on empty state should print a message" ); } #[test] fn game_note_multiword() { let env = TestEnv::new(); // Observe a game via a Steam-context launch let path = env.path_with_fake_bins(&["gamemoderun", "mangohud"]); env.run_with_env( &["run", "--", "/bin/echo", "hello"], &[ ("PATH", &path), ("SteamAppId", "999"), ("SteamGameId", "999"), ], ); // After the launch, the game is observed as "/bin/echo" // Use the executable basename as matcher let r = env.run(&["game", "note", "echo", "word1", "word2", "word3"]); assert_ok(&r); let r = env.run(&["game", "show", "echo"]); assert_ok(&r); assert!( r.output.contains("word1 word2 word3") || r.output.contains("word1"), "note should contain joined words:\n{}", r.output ); } #[test] fn game_list_full_flag() { let env = TestEnv::new(); let path = env.path_with_fake_bins(&["gamemoderun", "mangohud"]); // Observe a game first env.run_with_env( &["run", "--", "/bin/echo", "hi"], &[("PATH", &path), ("SteamAppId", "99"), ("SteamGameId", "99")], ); // list without --full: path may be abbreviated let r_short = env.run_with_env(&["game", "list"], &[("PATH", &path)]); assert_ok(&r_short); // list with --full: path is not abbreviated let r_full = env.run_with_env(&["game", "list", "--full"], &[("PATH", &path)]); assert_ok(&r_full); // The full path /bin/echo should appear somewhere assert!( r_full.output.contains("/bin/echo"), "game list --full should show full path:\n{}", r_full.output ); } #[test] fn dry_run_with_invalid_target() { let env = TestEnv::new(); let path = env.path_with_fake_bins(&["mangohud", "gamemoderun"]); let r = env.run_with_env( &["dry-run", "--", "/nonexistent-binary"], &[("PATH", &path)], ); assert_eq!( r.status, 4, "dry-run with nonexistent binary should exit 4:\n{}", r.output ); assert!( r.output.to_lowercase().contains("nonexistent") || r.output.to_lowercase().contains("does not exist"), "error should mention the path:\n{}", r.output ); } #[test] fn dry_run_shows_gamescope_command() { let env = TestEnv::new(); let path = env.path_with_fake_bins(&["mangohud", "gamemoderun", "gamescope"]); assert_ok(&env.run(&["config", "set", "gamescope", "on"])); assert_ok(&env.run(&["config", "set", "gamescope-width", "1920"])); assert_ok(&env.run(&["config", "set", "gamescope-fps", "60"])); assert_ok(&env.run(&["config", "set", "gamescope-filter", "fsr"])); assert_ok(&env.run(&["config", "set", "mangohud", "off"])); assert_ok(&env.run(&["config", "set", "gamemode", "off"])); let r = env.run_with_env(&["dry-run", "--", "/bin/echo", "test"], &[("PATH", &path)]); assert_ok(&r); assert!( r.output.contains("gamescope"), "dry-run with gamescope=on should show gamescope in command:\n{}", r.output ); assert!( r.output.contains("-W") && r.output.contains("1920"), "dry-run should show -W 1920:\n{}", r.output ); assert!( r.output.contains("-r") && r.output.contains("60"), "dry-run should show -r 60:\n{}", r.output ); assert!( r.output.contains("-F") && r.output.contains("fsr"), "dry-run should show -F fsr:\n{}", r.output ); } #[test] fn launch_option_like_target_rejected() { let env = TestEnv::new(); let path = env.path_with_fake_bins(&["mangohud", "gamemoderun"]); // A target starting with '--' should be rejected let r = env.run_with_env(&["run", "--", "--help"], &[("PATH", &path)]); assert_eq!( r.status, 2, "option-like target should exit 2:\n{}", r.output ); } #[test] fn launch_no_command() { let env = TestEnv::new(); let r = env.run(&["run"]); // run with no target is a usage error assert!( r.status != 0, "run with no command should fail:\n{}", r.output ); } #[test] fn pre_fail_does_not_trigger_post_hook() { let env = TestEnv::new(); let path = env.path_with_fake_bins(&["mangohud", "gamemoderun"]); assert_ok(&env.run(&["config", "set", "hook-errors", "fail"])); assert_ok(&env.run(&["config", "set", "pre-launch", "exit 1"])); assert_ok(&env.run(&["config", "set", "post-launch", "echo POSTHOOK_RAN"])); let r = env.run_with_env(&["run", "--", "/bin/echo", "game"], &[("PATH", &path)]); assert_eq!( r.status, 1, "pre-hook fail mode should exit 1:\n{}", r.output ); assert!( !r.output.contains("POSTHOOK_RAN"), "post-hook should NOT run when pre-hook aborts:\n{}", r.output ); assert!( !r.output.contains("game"), "game should NOT have run:\n{}", r.output ); } #[test] fn both_hooks_run_on_success() { let env = TestEnv::new(); let path = env.path_with_fake_bins(&["mangohud", "gamemoderun"]); assert_ok(&env.run(&["config", "set", "mangohud", "off"])); assert_ok(&env.run(&["config", "set", "gamemode", "off"])); assert_ok(&env.run(&["config", "set", "pre-launch", "echo PRE_HOOK"])); assert_ok(&env.run(&["config", "set", "post-launch", "echo POST_HOOK"])); let r = env.run_with_env( &["run", "--", "/bin/echo", "GAME_OUTPUT"], &[("PATH", &path)], ); assert_ok(&r); assert!( r.output.contains("PRE_HOOK"), "pre-hook should have run:\n{}", r.output ); assert!( r.output.contains("POST_HOOK"), "post-hook should have run:\n{}", r.output ); assert!( r.output.contains("GAME_OUTPUT"), "game should have run:\n{}", r.output ); // Verify ordering: PRE comes before GAME comes before POST let pre_pos = r.output.find("PRE_HOOK").unwrap(); let game_pos = r.output.find("GAME_OUTPUT").unwrap(); let post_pos = r.output.find("POST_HOOK").unwrap(); assert!( pre_pos < game_pos, "PRE_HOOK should appear before GAME_OUTPUT" ); assert!( game_pos < post_pos, "GAME_OUTPUT should appear before POST_HOOK" ); } #[test] fn post_hook_runs_when_binary_missing() { let env = TestEnv::new(); let path = env.path_with_fake_bins(&["mangohud", "gamemoderun"]); assert_ok(&env.run(&["config", "set", "post-launch", "echo POSTHOOK_RAN"])); let r = env.run_with_env(&["run", "--", "/nonexistent-binary"], &[("PATH", &path)]); assert_eq!( r.status, 4, "nonexistent binary should exit 4:\n{}", r.output ); assert!( r.output.contains("POSTHOOK_RAN"), "post-hook should run even when binary is missing:\n{}", r.output ); } #[test] fn missing_dependency_errors() { let env = TestEnv::new(); // mangohud=on but mangohud missing let no_mangohud = env.path_isolated_fake_bins(&["gamemoderun"]); assert_ok(&env.run(&["config", "set", "mangohud", "on"])); assert_ok(&env.run(&["config", "set", "gamemode", "off"])); let r = env.run_with_env(&["run", "--", "/bin/echo", "hi"], &[("PATH", &no_mangohud)]); assert_eq!(r.status, 4, "missing mangohud should exit 4:\n{}", r.output); assert!( r.output.to_lowercase().contains("mangohud"), "error should mention mangohud:\n{}", r.output ); assert_ok(&env.run(&["config", "reset", "--all"])); // gamemode=on but gamemoderun missing let env = TestEnv::new(); let no_gamemode = env.path_isolated_fake_bins(&["mangohud"]); assert_ok(&env.run(&["config", "set", "gamemode", "on"])); assert_ok(&env.run(&["config", "set", "mangohud", "off"])); let r = env.run_with_env(&["run", "--", "/bin/echo", "hi"], &[("PATH", &no_gamemode)]); assert_eq!( r.status, 4, "missing gamemoderun should exit 4:\n{}", r.output ); assert!( r.output.to_lowercase().contains("gamemoderun") || r.output.to_lowercase().contains("gamemode"), "error should mention gamemode:\n{}", r.output ); assert_ok(&env.run(&["config", "reset", "--all"])); // gamescope=on but gamescope missing let env = TestEnv::new(); let no_gamescope = env.path_isolated_fake_bins(&["mangohud", "gamemoderun"]); assert_ok(&env.run(&["config", "set", "gamescope", "on"])); assert_ok(&env.run(&["config", "set", "mangohud", "off"])); assert_ok(&env.run(&["config", "set", "gamemode", "off"])); let r = env.run_with_env( &["run", "--", "/bin/echo", "hi"], &[("PATH", &no_gamescope)], ); assert_eq!( r.status, 4, "missing gamescope should exit 4:\n{}", r.output ); assert!( r.output.to_lowercase().contains("gamescope"), "error should mention gamescope:\n{}", r.output ); assert_ok(&env.run(&["config", "reset", "--all"])); // gamescope-mangoapp=on but mangoapp missing let env = TestEnv::new(); let no_mangoapp = env.path_isolated_fake_bins(&["gamescope", "gamemoderun", "mangohud"]); assert_ok(&env.run(&["config", "set", "gamescope", "on"])); assert_ok(&env.run(&["config", "set", "gamescope-mangoapp", "on"])); assert_ok(&env.run(&["config", "set", "mangohud", "off"])); assert_ok(&env.run(&["config", "set", "gamemode", "off"])); let r = env.run_with_env(&["run", "--", "/bin/echo", "hi"], &[("PATH", &no_mangoapp)]); assert_eq!(r.status, 4, "missing mangoapp should exit 4:\n{}", r.output); assert!( r.output.to_lowercase().contains("mangoapp"), "error should mention mangoapp:\n{}", r.output ); } #[test] fn last_with_no_history() { let env = TestEnv::new(); let r = env.run(&["last"]); assert_ok(&r); // Should print an informational message about no history assert!( !r.output.trim().is_empty(), "last with no history should print a message" ); } #[test] fn last_shows_most_recent() { let env = TestEnv::new(); let path = env.path_with_fake_bins(&["mangohud", "gamemoderun"]); let steam_env: &[(&str, &str)] = &[("PATH", &path), ("SteamAppId", "1"), ("SteamGameId", "1")]; // Launch first game env.run_with_env(&["run", "--", "/bin/echo", "first"], steam_env); // Launch second game (different binary) env.run_with_env(&["run", "--", "/bin/true"], steam_env); let r = env.run(&["last"]); assert_ok(&r); // The second game (true) should be shown as last played assert!( r.output.contains("true") || r.output.contains("/bin/true") || r.output.contains("true"), "last should show the most recently launched game:\n{}", r.output ); } #[test] fn settings_list_command() { let env = TestEnv::new(); let r = env.run(&["config", "settings"]); assert_ok(&r); // Spot-check a representative set of setting names for name in &[ "mangohud", "gamemode", "gamescope", "fps-cap", "vkbasalt", "hook-errors", "pre-launch", "post-launch", "log-file", "esync", "fsync", "large-address-aware", ] { assert!( r.output.contains(name), "config settings should list `{name}`:\n{}", r.output ); } // profile settings should produce the same list let r2 = env.run(&["profile", "settings"]); assert_ok(&r2); for name in &["mangohud", "hook-errors", "gamescope"] { assert!( r2.output.contains(name), "profile settings should list `{name}`:\n{}", r2.output ); } } #[test] fn completion_edge_cases() { let env = TestEnv::new(); // completion with no shell and no subcommand -> exit 2 let r = env.run(&["completion"]); assert_eq!( r.status, 2, "completion with no args should exit 2:\n{}", r.output ); // completion bash -> exit 0, produces a script let r = env.run(&["completion", "bash"]); assert_ok(&r); assert!( !r.stdout.is_empty(), "completion bash should produce output" ); // completion zsh -> exit 0, produces a script let r = env.run(&["completion", "zsh"]); assert_ok(&r); assert!(!r.stdout.is_empty(), "completion zsh should produce output"); // completion fish -> exit 0, produces a script let r = env.run(&["completion", "fish"]); assert_ok(&r); assert!( !r.stdout.is_empty(), "completion fish should produce output" ); }