//! Isolated CLI contract tests for Phase 1 machine output. use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use serde_json::Value; fn kiln(arguments: &[&str]) -> std::process::Output { Command::new(env!("CARGO_BIN_EXE_kiln")) .args(arguments) .output() .expect("kiln runs") } struct TestDirectory(PathBuf); impl TestDirectory { fn new() -> Self { let path = std::env::temp_dir().join(format!( "kiln-cli-test-{}-{}", std::process::id(), std::thread::current().name().unwrap_or("unnamed") )); let _ = fs::remove_dir_all(&path); fs::create_dir(&path).expect("test directory"); Self(path) } fn run(&self, arguments: &[&str]) -> std::process::Output { Command::new(env!("CARGO_BIN_EXE_kiln")) .args(arguments) .env("XDG_CONFIG_HOME", self.0.join("config")) .env("XDG_STATE_HOME", self.0.join("state")) .env("HOME", self.0.join("home")) .output() .expect("kiln runs") } fn add_game(&self, id: &str, name: &str, executable: &Path) { let manifests = self.0.join("config/kiln/native"); let install = self.0.join("games").join(id); fs::create_dir_all(&manifests).expect("manifest directory"); fs::create_dir_all(&install).expect("install directory"); fs::write( manifests.join(format!("{id}.toml")), format!( "schema_version = 1\nid = \"{id}\"\nname = \"{name}\"\ninstall_path = \"{}\"\nexecutable = \"{}\"\narguments = [\"native-output\"]\n", install.display(), executable.display() ), ) .expect("manifest"); } fn add_steam_game(&self, app_id: &str, name: &str) { let root = self.0.join("home/.local/share/Steam"); let steamapps = root.join("steamapps"); let install = steamapps.join("common").join(name); fs::create_dir_all(&install).unwrap(); fs::write( steamapps.join("libraryfolders.vdf"), format!( "\"libraryfolders\" {{ \"0\" {{ \"path\" \"{}\" }} }}", root.display() ), ) .unwrap(); fs::write( steamapps.join(format!("appmanifest_{app_id}.acf")), format!( "\"AppState\" {{ \"appid\" \"{app_id}\" \"name\" \"{name}\" \"StateFlags\" \"4\" \"installdir\" \"{name}\" }}" ), ) .unwrap(); } } impl Drop for TestDirectory { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } #[test] fn doctor_reports_every_phase_one_schema() { let output = kiln(&["doctor", "--json"]); assert!(output.status.success()); let json: Value = serde_json::from_slice(&output.stdout).expect("doctor JSON"); assert_eq!(json["status"], "ok"); for contract in [ "game_record", "installation_registry", "launch_plan", "error", "configuration", ] { let expected = if contract == "launch_plan" { 2 } else { 1 }; assert_eq!(json["contracts"][contract], expected); } } #[test] fn json_invalid_arguments_return_structured_error() { let output = kiln(&["unknown", "--json"]); assert_eq!(output.status.code(), Some(2)); let json: Value = serde_json::from_slice(&output.stderr).expect("error JSON"); assert_eq!(json["schema_version"], 1); assert_eq!(json["code"], "invalid-input"); } #[test] fn unavailable_frontend_returns_unsupported_capability() { let output = kiln(&["frontend", "sync", "--json"]); assert_eq!(output.status.code(), Some(2)); let json: Value = serde_json::from_slice(&output.stderr).expect("error JSON"); assert_eq!(json["code"], "unsupported-capability"); assert_eq!(json["capability"], "frontend"); } #[test] fn native_cli_completes_discovery_search_plan_execution_and_logging() { let directory = TestDirectory::new(); directory.add_game("sample", "Sample Game", Path::new("/usr/bin/printf")); let list = directory.run(&["list", "--json"]); assert!(list.status.success()); let records: Value = serde_json::from_slice(&list.stdout).expect("list JSON"); assert_eq!(records[0]["id"], "native:sample"); let search = directory.run(&["search", "sample", "--json"]); assert!(search.status.success()); let records: Value = serde_json::from_slice(&search.stdout).expect("search JSON"); assert_eq!(records.as_array().unwrap().len(), 1); let info = directory.run(&["info", "native:sample", "--json"]); assert!(info.status.success()); let record: Value = serde_json::from_slice(&info.stdout).expect("info JSON"); assert_eq!(record["runtime"], "native"); let scan = directory.run(&["scan", "--provider", "native", "--no-cache", "--json"]); assert!(scan.status.success()); let result: Value = serde_json::from_slice(&scan.stdout).expect("scan JSON"); assert_eq!(result["discovered"], 1); let dry_run = directory.run(&["launch", "Sample Game", "--dry-run", "--json"]); assert!(dry_run.status.success()); let plan: Value = serde_json::from_slice(&dry_run.stdout).expect("plan JSON"); assert_eq!(plan["command"]["arguments"][0], "native-output"); let launch = directory.run(&["launch", "native:sample", "--json"]); assert!(launch.status.success()); let outcome: Value = serde_json::from_slice(&launch.stdout).expect("outcome JSON"); assert_eq!(outcome["exit_code"], 0); assert!( fs::read_to_string(directory.0.join("state/kiln/logs/native.log")) .expect("launch log") .contains("native-output") ); } #[test] fn malformed_native_manifest_returns_structured_failure() { let directory = TestDirectory::new(); let manifests = directory.0.join("config/kiln/native"); fs::create_dir_all(&manifests).unwrap(); fs::write(manifests.join("bad.toml"), "schema_version = 99\n").unwrap(); let output = directory.run(&["list", "--json"]); assert_eq!(output.status.code(), Some(2)); let error: Value = serde_json::from_slice(&output.stderr).expect("error JSON"); assert!(matches!( error["code"].as_str(), Some("invalid-input" | "unsupported-version") )); } #[test] fn config_defaults_validate_set_and_explain_are_consistent() { let directory = TestDirectory::new(); let defaults = directory.run(&["config", "defaults"]); assert!(defaults.status.success()); assert!( String::from_utf8(defaults.stdout) .unwrap() .contains("schema_version = 1") ); let validate = directory.run(&["config", "validate", "--json"]); assert!(validate.status.success()); let validated: Value = serde_json::from_slice(&validate.stdout).unwrap(); assert_eq!(validated["exists"], false); let runtime = directory.run(&["config", "set", "runtime", "umu", "--json"]); assert!(runtime.status.success()); let backend = directory.run(&["config", "set", "session-backend", "wayland", "--json"]); assert!(backend.status.success()); let configuration = fs::read_to_string(directory.0.join("config/kiln/config.toml")).unwrap(); assert!(configuration.contains("# Project Kiln private configuration")); assert!(configuration.contains("runtime = \"umu\"")); assert!(configuration.contains("session_backend = \"wayland\"")); let explain = directory.run(&["config", "explain", "--json"]); assert!(explain.status.success()); let resolved: Value = serde_json::from_slice(&explain.stdout).unwrap(); assert_eq!(resolved["runtime"]["value"], "umu"); assert_eq!(resolved["runtime"]["source"]["tier"], "global-user"); assert_eq!(resolved["session_backend"]["value"], "wayland"); } #[test] fn invalid_configuration_and_failed_launch_are_structured() { let directory = TestDirectory::new(); fs::create_dir_all(directory.0.join("config/kiln")).unwrap(); fs::write( directory.0.join("config/kiln/config.toml"), "schema_version = 99\n", ) .unwrap(); let invalid = directory.run(&["config", "validate", "--json"]); assert_eq!(invalid.status.code(), Some(2)); let error: Value = serde_json::from_slice(&invalid.stderr).unwrap(); assert_eq!(error["code"], "invalid-input"); fs::remove_file(directory.0.join("config/kiln/config.toml")).unwrap(); directory.add_game("failure", "Failure", Path::new("/usr/bin/false")); let failed = directory.run(&["launch", "native:failure", "--json"]); assert_eq!(failed.status.code(), Some(2)); let error: Value = serde_json::from_slice(&failed.stderr).unwrap(); assert_eq!(error["code"], "launch-failed"); } #[test] fn steam_provider_cli_discovers_searches_and_resolves_without_launching() { let directory = TestDirectory::new(); directory.add_steam_game("620", "Portal 2"); let list = directory.run(&["list", "--provider", "steam", "--json"]); assert!(list.status.success()); let records: Value = serde_json::from_slice(&list.stdout).unwrap(); assert_eq!(records[0]["id"], "steam:620"); assert_eq!(records[0]["runtime"], "steam-managed"); assert_eq!(records[0]["client_required"], true); let search = directory.run(&["search", "portal", "--provider", "steam", "--json"]); assert!(search.status.success()); assert_eq!( serde_json::from_slice::(&search.stdout).unwrap()[0]["name"], "Portal 2" ); let info = directory.run(&["info", "steam:620", "--json"]); assert!(info.status.success()); assert_eq!( serde_json::from_slice::(&info.stdout).unwrap()["store"], "steam" ); let scan = directory.run(&["scan", "--provider", "steam", "--json"]); assert!(scan.status.success()); let result: Value = serde_json::from_slice(&scan.stdout).unwrap(); assert_eq!(result["provider"], "steam"); assert_eq!(result["discovered"], 1); }