Files
gamewrap/tests/cli_matrix.rs
T
44r0n7 f984acf0e3 feat: hook reliability, hook-errors setting, sparse config, and logging
Hook reliability:
- Add hook-errors = "warn" | "fail" setting (default: warn); in fail
  mode, abort launch when pre-launch hook exits nonzero or can't execute
- Ensure post-launch hook runs unconditionally, even when execute_wait()
  fails to spawn the game
- Propagate game's real exit status via std::process::exit(); report
  post-hook failures clearly to stderr
- Centralize hook execution via run_hook() helper (sh -c)

New features in this batch:
- Sparse config and profile support: only configured fields are written;
  unset fields fall back through profile → global chain
- config show --effective flag: renders the fully-resolved view
- Config migration: upgrades legacy flat config to current schema
- Structured decision logging (src/log.rs) for session-level audit trail
- Gamescope improvements: additional flags and validation
- CHANGELOG.md tracking template releases

Schema / UX:
- HookErrors enum (Warn/Fail) added to Settings and ResolvedSettings
- hook-errors key in keys.rs, mod.rs rendering, completion candidates,
  doctor output, help text, README, and dry-run display
- 9 focused tests covering warn/fail behavior, exit propagation,
  round-trip (set/show/reset), profile round-trip, export/import

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-14 09:35:24 -04:00

1997 lines
70 KiB
Rust

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 [--] <COMMAND>...")
);
let result = env.run(&["dry-run", "--help"]);
assert_ok(&result);
assert!(
result
.output
.contains("Usage: gamewrap dry-run [--] <COMMAND>...")
);
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"));
}