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")); }