//! Integration tests for the `unitydiag` command. use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use assert_cmd::Command; use filetime::{FileTime, set_file_mtime}; use predicates::prelude::*; fn cargo_command() -> Command { Command::cargo_bin("unitydiag").expect("binary") } fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") .join("..") .canonicalize() .expect("workspace root") } fn fixture(path: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests") .join("fixtures") .join(path) } fn create_temp_dir(label: &str) -> PathBuf { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock") .as_nanos(); let path = std::env::temp_dir().join(format!("unitydiag-{label}-{}-{unique}", std::process::id())); fs::create_dir_all(&path).expect("temp dir"); path } fn write_fixture(target: &Path, source_name: &str) { let content = fs::read(fixture(source_name)).expect("read fixture"); fs::write(target, content).expect("write fixture"); } fn set_mtime(target: &Path, unix_seconds: i64) { set_file_mtime(target, FileTime::from_unix_time(unix_seconds, 0)).expect("set file mtime"); } #[test] fn emits_grouped_text_output_by_incident() { let mut command = cargo_command(); command .arg(fixture("player.log")) .arg(fixture("LogOutput.log")) .assert() .success() .stdout(predicate::str::contains("summary logs=2")) .stdout(predicate::str::contains("domain=game")) .stdout(predicate::str::contains("domain=mod")) .stdout(predicate::str::contains("domain=bepinex")) .stdout(predicate::str::contains("count=2")) .stdout(predicate::str::contains( "message=ArgumentNullException: Value cannot be null. | Parameter name: source", )) .stdout(predicate::str::contains( "frame=Game.AI.ContractHasObjective.OnUpdate", )); } #[test] fn emits_stable_json_object_with_logs_incidents_and_summary() { let mut command = cargo_command(); command .arg("--json") .arg("--include-warnings") .arg("--include-info") .arg("--stack") .arg("top") .arg(fixture("player.log")) .arg(fixture("LogOutput.log")) .assert() .success() .stdout(predicate::str::contains("\"logs\":[")) .stdout(predicate::str::contains("\"incidents\":[")) .stdout(predicate::str::contains("\"summary\":{")) .stdout(predicate::str::contains("\"group_by\":\"incident\"")) .stdout(predicate::str::contains("\"domain\":\"telemetry\"")) .stdout(predicate::str::contains("\"domain\":\"harmony\"")) .stdout(predicate::str::contains("\"kind\":\"player\"")) .stdout(predicate::str::contains("\"kind\":\"bepinex\"")); } #[test] fn supports_powershell_pipeline_input() { let binary = assert_cmd::cargo::cargo_bin("unitydiag"); let input = fixture("player.log"); let script = format!( "Get-Content '{}' | & '{}' --include-warnings --json", input.display(), binary.display() ); let mut command = Command::new("pwsh"); command .current_dir(workspace_root()) .arg("-NoProfile") .arg("-Command") .arg(script) .assert() .success() .stdout(predicate::str::contains("\"incidents\":[")) .stdout(predicate::str::contains("\"domain\":\"game\"")); } #[test] fn discovers_logs_from_game_root_and_latest() { let root = create_temp_dir("discover"); let game_root = root.join("Solar Expanse"); let bepinex_dir = game_root.join("BepInEx"); let locallow_dir = root .join("AppData") .join("LocalLow") .join("SpaceOps") .join("Solar Expanse"); fs::create_dir_all(&bepinex_dir).expect("bepinex dir"); fs::create_dir_all(&locallow_dir).expect("locallow dir"); write_fixture(&locallow_dir.join("Player.log"), "player.log"); write_fixture(&bepinex_dir.join("LogOutput.log"), "LogOutput.log"); set_mtime(&locallow_dir.join("Player.log"), 1_700_000_000); set_mtime(&bepinex_dir.join("LogOutput.log"), 1_700_000_002); let mut command = cargo_command(); command .env("USERPROFILE", &root) .arg("--json") .arg("--include-info") .arg("--game-root") .arg(&game_root) .arg("--latest") .assert() .success() .stdout(predicate::str::contains("\"logs\":[")) .stdout(predicate::str::contains("LogOutput.log")) .stdout(predicate::str::contains("\"log_count\":1")); fs::remove_dir_all(&root).expect("cleanup"); } #[test] fn game_root_without_latest_includes_bepinex_and_matching_player_log() { let root = create_temp_dir("discover-both"); let game_root = root.join("Solar Expanse"); let bepinex_dir = game_root.join("BepInEx"); let locallow_dir = root .join("AppData") .join("LocalLow") .join("SpaceOps") .join("Solar Expanse"); fs::create_dir_all(&bepinex_dir).expect("bepinex dir"); fs::create_dir_all(&locallow_dir).expect("locallow dir"); write_fixture(&locallow_dir.join("Player.log"), "player.log"); write_fixture(&bepinex_dir.join("LogOutput.log"), "LogOutput.log"); let mut command = cargo_command(); command .env("USERPROFILE", &root) .arg("--json") .arg("--include-info") .arg("--game-root") .arg(&game_root) .assert() .success() .stdout(predicate::str::contains("\"log_count\":2")) .stdout(predicate::str::contains("LogOutput.log")) .stdout(predicate::str::contains("Player.log")); fs::remove_dir_all(&root).expect("cleanup"); } #[test] fn discovers_logs_from_relative_dot_game_root() { let root = create_temp_dir("discover-dot"); let game_root = root.join("Solar Expanse"); let locallow_root = root.join("AppData").join("LocalLow"); let target_locallow = locallow_root.join("SpaceOps").join("Solar Expanse"); let unrelated_locallow = locallow_root.join("OtherStudio").join("Another Game"); fs::create_dir_all(&game_root).expect("game root"); fs::create_dir_all(&target_locallow).expect("target locallow"); fs::create_dir_all(&unrelated_locallow).expect("unrelated locallow"); write_fixture(&target_locallow.join("Player.log"), "player.log"); write_fixture(&unrelated_locallow.join("Player.log"), "player.log"); set_mtime(&target_locallow.join("Player.log"), 1_700_000_000); set_mtime(&unrelated_locallow.join("Player.log"), 1_700_000_002); let mut command = cargo_command(); command .current_dir(&game_root) .env("USERPROFILE", &root) .arg("--json") .arg("--include-info") .arg("--game-root") .arg(".") .arg("--latest") .assert() .success() .stdout(predicate::str::contains("\"logs\":[")) .stdout(predicate::str::contains("SpaceOps")) .stdout(predicate::str::contains("Solar Expanse")) .stdout(predicate::str::contains("\"log_count\":1")) .stdout(predicate::str::contains("\"kind\":\"player\"")); fs::remove_dir_all(&root).expect("cleanup"); } #[test] fn help_includes_examples_and_grouping_flags() { let mut command = cargo_command(); command .arg("--help") .assert() .success() .stdout(predicate::str::contains("--group-by")) .stdout(predicate::str::contains("--latest")) .stdout(predicate::str::contains("ConvertFrom-Json")) .stdout(predicate::str::contains( "unitydiag --game-root 'C:\\game' --latest", )); } #[test] fn utf8_bom_log_file_is_parsed_without_losing_incidents() { let root = create_temp_dir("bom"); let log_path = root.join("Player.log"); let mut bytes = vec![0xEF, 0xBB, 0xBF]; bytes.extend(fs::read(fixture("player.log")).expect("fixture")); fs::write(&log_path, bytes).expect("bom fixture"); let mut command = cargo_command(); command .arg("--json") .arg(&log_path) .assert() .success() .stdout(predicate::str::contains("\"log_count\":1")) .stdout(predicate::str::contains("\"domain\":\"game\"")); fs::remove_dir_all(&root).expect("cleanup"); } #[test] fn invalid_utf8_log_file_reports_read_error_instead_of_summarizing() { let root = create_temp_dir("invalid-utf8"); let log_path = root.join("Player.log"); fs::write(&log_path, b"Initialize engine version\n\xff\xfe\xfd\n").expect("invalid fixture"); let mut command = cargo_command(); command .arg(&log_path) .assert() .failure() .stderr(predicate::str::contains("failed to read")) .stderr(predicate::str::contains("valid UTF-8")); fs::remove_dir_all(&root).expect("cleanup"); }