use std::fmt::Write as _; use std::process::Stdio; use tempfile::TempDir; use tokio::process::Command; /// Build the binary path for the witryna executable. fn witryna_bin() -> std::path::PathBuf { let mut path = std::path::PathBuf::from(env!("CARGO_BIN_EXE_witryna")); if !path.exists() { path = std::path::PathBuf::from("target/debug/witryna"); } path } /// Write a minimal witryna.toml config for status tests. async fn write_status_config(dir: &std::path::Path, sites: &[&str]) -> std::path::PathBuf { let base_dir = dir.join("data"); let log_dir = dir.join("logs"); tokio::fs::create_dir_all(&base_dir).await.unwrap(); tokio::fs::create_dir_all(&log_dir).await.unwrap(); let mut sites_toml = String::new(); for name in sites { write!( sites_toml, r#" [[sites]] name = "{name}" repo_url = "https://example.com/{name}.git" branch = "main" webhook_token = "unused" "# ) .unwrap(); } let config_path = dir.join("witryna.toml"); let config = format!( r#"listen_address = "127.0.0.1:0" container_runtime = "podman" base_dir = "{base_dir}" log_dir = "{log_dir}" log_level = "info" {sites_toml}"#, base_dir = base_dir.display(), log_dir = log_dir.display(), ); tokio::fs::write(&config_path, config).await.unwrap(); config_path } /// Write a state.json for a site with the new format. async fn write_state_json( base_dir: &std::path::Path, site_name: &str, current: &str, builds: &[serde_json::Value], ) { let state_dir = base_dir.join("builds").join(site_name); tokio::fs::create_dir_all(&state_dir).await.unwrap(); let content = serde_json::json!({ "current": current, "builds": builds, }); let state_path = state_dir.join("state.json"); tokio::fs::write(&state_path, content.to_string()) .await .unwrap(); } /// Create a build entry JSON value. fn build_entry( status: &str, timestamp: &str, git_commit: &str, duration: &str, log: &str, ) -> serde_json::Value { serde_json::json!({ "status": status, "timestamp": timestamp, "started_at": "2026-02-10T12:00:00Z", "git_commit": git_commit, "duration": duration, "log": log, }) } // --------------------------------------------------------------------------- // Tier 1: no container runtime / git needed // --------------------------------------------------------------------------- #[tokio::test] async fn cli_status_no_builds() { let tempdir = TempDir::new().unwrap(); let config_path = write_status_config(tempdir.path(), &["empty-site"]).await; let output = Command::new(witryna_bin()) .args(["status", "--config", config_path.to_str().unwrap()]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await .unwrap(); assert!(output.status.success(), "should exit 0"); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("SITE"), "should have table header"); assert!( stdout.contains("(no builds)"), "should show (no builds), got: {stdout}" ); } #[tokio::test] async fn cli_status_single_build() { let tempdir = TempDir::new().unwrap(); let base_dir = tempdir.path().join("data"); let config_path = write_status_config(tempdir.path(), &["my-site"]).await; write_state_json( &base_dir, "my-site", "20260126-143000-123456", &[build_entry( "success", "20260126-143000-123456", "abc123d", "45s", "/logs/my-site/20260126-143000-123456.log", )], ) .await; let output = Command::new(witryna_bin()) .args(["status", "--config", config_path.to_str().unwrap()]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await .unwrap(); assert!(output.status.success(), "should exit 0"); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("my-site"), "should show site name"); assert!(stdout.contains("success"), "should show status"); assert!(stdout.contains("abc123d"), "should show commit"); assert!(stdout.contains("45s"), "should show duration"); } #[tokio::test] async fn cli_status_json_output() { let tempdir = TempDir::new().unwrap(); let base_dir = tempdir.path().join("data"); let config_path = write_status_config(tempdir.path(), &["json-site"]).await; write_state_json( &base_dir, "json-site", "20260126-143000-123456", &[build_entry( "success", "20260126-143000-123456", "abc123d", "45s", "/logs/json-site/20260126-143000-123456.log", )], ) .await; let output = Command::new(witryna_bin()) .args([ "status", "--json", "--config", config_path.to_str().unwrap(), ]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await .unwrap(); assert!(output.status.success(), "should exit 0"); let stdout = String::from_utf8_lossy(&output.stdout); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 1); assert_eq!(arr[0]["site_name"], "json-site"); assert_eq!(arr[0]["status"], "success"); assert_eq!(arr[0]["git_commit"], "abc123d"); assert_eq!(arr[0]["duration"], "45s"); } #[tokio::test] async fn cli_status_site_filter() { let tempdir = TempDir::new().unwrap(); let base_dir = tempdir.path().join("data"); let config_path = write_status_config(tempdir.path(), &["site-a", "site-b"]).await; write_state_json( &base_dir, "site-a", "20260126-143000-000000", &[build_entry( "success", "20260126-143000-000000", "aaa1111", "10s", "/logs/a.log", )], ) .await; write_state_json( &base_dir, "site-b", "20260126-150000-000000", &[build_entry( "success", "20260126-150000-000000", "bbb2222", "20s", "/logs/b.log", )], ) .await; let output = Command::new(witryna_bin()) .args([ "status", "site-a", "--config", config_path.to_str().unwrap(), ]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await .unwrap(); assert!(output.status.success(), "should exit 0"); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("site-a"), "should show filtered site"); assert!( !stdout.contains("site-b"), "should NOT show other site, got: {stdout}" ); } #[tokio::test] async fn cli_status_hook_failed() { let tempdir = TempDir::new().unwrap(); let base_dir = tempdir.path().join("data"); let config_path = write_status_config(tempdir.path(), &["hook-site"]).await; write_state_json( &base_dir, "hook-site", "20260126-143000-123456", &[build_entry( "hook failed", "20260126-143000-123456", "abc123d", "12s", "/logs/hook-site/20260126-143000-123456.log", )], ) .await; let output = Command::new(witryna_bin()) .args(["status", "--config", config_path.to_str().unwrap()]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await .unwrap(); assert!(output.status.success(), "should exit 0"); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("hook failed"), "should show 'hook failed', got: {stdout}" ); } #[tokio::test] async fn cli_status_building_shows_in_progress() { let tempdir = TempDir::new().unwrap(); let base_dir = tempdir.path().join("data"); let config_path = write_status_config(tempdir.path(), &["building-site"]).await; let started_at = witryna::time::format_rfc3339(std::time::SystemTime::now()); let state_dir = base_dir.join("builds").join("building-site"); tokio::fs::create_dir_all(&state_dir).await.unwrap(); let content = serde_json::json!({ "current": "", "builds": [{ "status": "building", "timestamp": "20260210-120000-000000", "started_at": started_at, "git_commit": "", "duration": "", "log": "/logs/building-site/20260210-120000-000000.log", }], }); tokio::fs::write(state_dir.join("state.json"), content.to_string()) .await .unwrap(); let output = Command::new(witryna_bin()) .args(["status", "--config", config_path.to_str().unwrap()]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await .unwrap(); assert!(output.status.success(), "should exit 0"); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("building"), "should show 'building' status, got: {stdout}" ); } #[tokio::test] async fn cli_status_json_includes_building_state() { let tempdir = TempDir::new().unwrap(); let base_dir = tempdir.path().join("data"); let config_path = write_status_config(tempdir.path(), &["json-building"]).await; let started_at = witryna::time::format_rfc3339(std::time::SystemTime::now()); let state_dir = base_dir.join("builds").join("json-building"); tokio::fs::create_dir_all(&state_dir).await.unwrap(); let content = serde_json::json!({ "current": "", "builds": [{ "status": "building", "timestamp": "20260210-120000-000000", "started_at": started_at, "git_commit": "", "duration": "", "log": "/logs/json-building/20260210-120000-000000.log", }], }); tokio::fs::write(state_dir.join("state.json"), content.to_string()) .await .unwrap(); let output = Command::new(witryna_bin()) .args([ "status", "--json", "--config", config_path.to_str().unwrap(), ]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await .unwrap(); assert!(output.status.success(), "should exit 0"); let stdout = String::from_utf8_lossy(&output.stdout); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 1); assert_eq!(arr[0]["site_name"], "json-building"); assert_eq!(arr[0]["status"], "building"); } // --------------------------------------------------------------------------- // current_build / "+" marker tests // --------------------------------------------------------------------------- #[tokio::test] async fn cli_status_marks_current_build() { let tempdir = TempDir::new().unwrap(); let base_dir = tempdir.path().join("data"); let config_path = write_status_config(tempdir.path(), &["my-site"]).await; write_state_json( &base_dir, "my-site", "20260126-143000-123456", &[build_entry( "success", "20260126-143000-123456", "abc123d", "45s", "/logs/my-site/20260126-143000-123456.log", )], ) .await; let output = Command::new(witryna_bin()) .args(["status", "--config", config_path.to_str().unwrap()]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await .unwrap(); assert!(output.status.success(), "should exit 0"); let stdout = String::from_utf8_lossy(&output.stdout); let data_line = stdout .lines() .find(|l| l.contains("my-site")) .expect("should have my-site row"); assert!( data_line.starts_with('+'), "current build row should start with '+', got: {data_line}" ); } #[tokio::test] async fn cli_status_no_current_no_marker() { let tempdir = TempDir::new().unwrap(); let base_dir = tempdir.path().join("data"); let config_path = write_status_config(tempdir.path(), &["my-site"]).await; write_state_json( &base_dir, "my-site", "", // no current &[build_entry( "success", "20260126-143000-123456", "abc123d", "45s", "/logs/my-site/20260126-143000-123456.log", )], ) .await; let output = Command::new(witryna_bin()) .args(["status", "--config", config_path.to_str().unwrap()]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await .unwrap(); assert!(output.status.success(), "should exit 0"); let stdout = String::from_utf8_lossy(&output.stdout); for line in stdout.lines().skip(1) { assert!( !line.starts_with('+'), "no row should have '+' without current, got: {line}" ); } } #[tokio::test] async fn cli_status_json_includes_current_build() { let tempdir = TempDir::new().unwrap(); let base_dir = tempdir.path().join("data"); let config_path = write_status_config(tempdir.path(), &["json-site"]).await; write_state_json( &base_dir, "json-site", "20260126-143000-123456", &[build_entry( "success", "20260126-143000-123456", "abc123d", "45s", "/logs/json-site/20260126-143000-123456.log", )], ) .await; let output = Command::new(witryna_bin()) .args([ "status", "--json", "--config", config_path.to_str().unwrap(), ]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await .unwrap(); assert!(output.status.success(), "should exit 0"); let stdout = String::from_utf8_lossy(&output.stdout); let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); let arr = parsed.as_array().unwrap(); assert_eq!(arr.len(), 1); assert_eq!( arr[0]["current_build"], "20260126-143000-123456", "JSON should include current_build field" ); } #[tokio::test] async fn cli_status_single_site_shows_all_builds() { let tempdir = TempDir::new().unwrap(); let base_dir = tempdir.path().join("data"); let config_path = write_status_config(tempdir.path(), &["my-site"]).await; write_state_json( &base_dir, "my-site", "20260126-143000-123456", &[ build_entry( "failed", "20260126-150000-000000", "def4567", "30s", "/logs/2.log", ), build_entry( "success", "20260126-143000-123456", "abc123d", "45s", "/logs/1.log", ), ], ) .await; // Single-site view let output = Command::new(witryna_bin()) .args([ "status", "my-site", "--config", config_path.to_str().unwrap(), ]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await .unwrap(); assert!(output.status.success(), "should exit 0"); let stdout = String::from_utf8_lossy(&output.stdout); let data_lines: Vec<&str> = stdout.lines().skip(1).collect(); assert_eq!(data_lines.len(), 2, "should have 2 build rows"); // First row: failed (no marker — current points to a different timestamp) assert!( !data_lines[0].starts_with('+'), "failed build should not have '+': {}", data_lines[0] ); // Second row: success (has marker — matches current) assert!( data_lines[1].starts_with('+'), "current build should have '+': {}", data_lines[1] ); } #[tokio::test] async fn cli_status_failed_build_shows_failed() { let tempdir = TempDir::new().unwrap(); let base_dir = tempdir.path().join("data"); let config_path = write_status_config(tempdir.path(), &["fail-site"]).await; write_state_json( &base_dir, "fail-site", "", &[build_entry( "failed", "20260126-160000-000000", "def4567", "2m 0s", "/logs/fail-site/20260126-160000-000000.log", )], ) .await; let output = Command::new(witryna_bin()) .args(["status", "--config", config_path.to_str().unwrap()]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await .unwrap(); assert!(output.status.success(), "should exit 0"); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("failed"), "should show 'failed' (not a long error), got: {stdout}" ); // Verify that status column is clean (no long error string breaking the table) let data_line = stdout .lines() .find(|l| l.contains("fail-site")) .expect("should have fail-site row"); assert!( data_line.contains("def4567"), "should show commit in correct column, got: {data_line}" ); }