322 lines
12 KiB
Rust
322 lines
12 KiB
Rust
//! Isolated CLI contract tests for Phase 1 machine output.
|
|
|
|
use std::fs;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
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 {
|
|
self.run_with_path(
|
|
arguments,
|
|
&format!(
|
|
"{}:{}",
|
|
self.0.join("bin").display(),
|
|
std::env::var("PATH").unwrap_or_default()
|
|
),
|
|
)
|
|
}
|
|
|
|
fn run_with_path(&self, arguments: &[&str], path: &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"))
|
|
.env("PATH", path)
|
|
.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();
|
|
let bin = self.0.join("bin");
|
|
fs::create_dir_all(&bin).unwrap();
|
|
let steam = bin.join("steam");
|
|
fs::write(&steam, "#!/bin/sh\nprintf 'steam %s\\n' \"$*\"\n").unwrap();
|
|
fs::set_permissions(steam, fs::Permissions::from_mode(0o755)).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_plans_and_submits_without_a_shell() {
|
|
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);
|
|
assert!(
|
|
records[0]["capabilities"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&Value::String("launch".into()))
|
|
);
|
|
|
|
let search = directory.run(&["search", "portal", "--provider", "steam", "--json"]);
|
|
assert!(search.status.success());
|
|
assert_eq!(
|
|
serde_json::from_slice::<Value>(&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::<Value>(&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);
|
|
|
|
let dry_run = directory.run(&[
|
|
"launch",
|
|
"Portal 2",
|
|
"--provider",
|
|
"steam",
|
|
"--dry-run",
|
|
"--json",
|
|
]);
|
|
assert!(dry_run.status.success());
|
|
let plan: Value = serde_json::from_slice(&dry_run.stdout).unwrap();
|
|
assert_eq!(plan["runtime"], "steam-managed");
|
|
assert_eq!(plan["lifecycle_confidence"], "unknown");
|
|
assert_eq!(plan["command"]["arguments"][2], "620");
|
|
|
|
let launch = directory.run(&["launch", "steam:620", "--json"]);
|
|
assert!(launch.status.success());
|
|
let outcome: Value = serde_json::from_slice(&launch.stdout).unwrap();
|
|
assert_eq!(outcome["status"], "submitted");
|
|
assert!(outcome["process_id"].as_u64().unwrap() > 0);
|
|
assert!(
|
|
fs::read_to_string(directory.0.join("state/kiln/logs/steam.log"))
|
|
.unwrap()
|
|
.contains("submitted")
|
|
);
|
|
|
|
fs::remove_file(directory.0.join("bin/steam")).unwrap();
|
|
let failed = directory.run_with_path(
|
|
&["launch", "steam:620", "--json"],
|
|
directory.0.join("bin").to_str().unwrap(),
|
|
);
|
|
assert_eq!(failed.status.code(), Some(2));
|
|
let error: Value = serde_json::from_slice(&failed.stderr).unwrap();
|
|
assert_eq!(error["code"], "launch-failed");
|
|
}
|