summaryrefslogtreecommitdiff
path: root/tests/integration
diff options
context:
space:
mode:
authorDawid Rycerz <dawid@rycerz.xyz>2026-02-15 21:27:00 +0100
committerDawid Rycerz <dawid@rycerz.xyz>2026-02-15 21:27:00 +0100
commitce0dbf6b249956700c6a1705bf4ad85a09d53e8c (patch)
treed7c3236807cfbf75d7f3a355eb5df5a5e2cc4ad7 /tests/integration
parent064a1d01c5c14f5ecc032fa9b8346a4a88b893f6 (diff)
feat: witryna 0.2.0HEADv0.2.0main
Switch, cleanup, and status CLI commands. Persistent build state via state.json. Post-deploy hooks on success and failure with WITRYNA_BUILD_STATUS. Dependency diet (axum→tiny_http, clap→argh, tracing→log). Drop built-in rate limiting. Nix flake with NixOS module. Arch Linux PKGBUILD. Centralized version management. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'tests/integration')
-rw-r--r--tests/integration/cache.rs2
-rw-r--r--tests/integration/cleanup.rs3
-rw-r--r--tests/integration/cli_cleanup.rs341
-rw-r--r--tests/integration/cli_run.rs16
-rw-r--r--tests/integration/cli_status.rs544
-rw-r--r--tests/integration/cli_switch.rs330
-rw-r--r--tests/integration/cli_validate.rs137
-rw-r--r--tests/integration/concurrent.rs8
-rw-r--r--tests/integration/env_vars.rs4
-rw-r--r--tests/integration/harness.rs77
-rw-r--r--tests/integration/hooks.rs148
-rw-r--r--tests/integration/main.rs4
-rw-r--r--tests/integration/polling.rs1
-rw-r--r--tests/integration/rate_limit.rs114
-rw-r--r--tests/integration/sighup.rs34
15 files changed, 1469 insertions, 294 deletions
diff --git a/tests/integration/cache.rs b/tests/integration/cache.rs
index 42d2a15..cc20cdd 100644
--- a/tests/integration/cache.rs
+++ b/tests/integration/cache.rs
@@ -81,6 +81,8 @@ async fn cache_dir_persists_across_builds() {
.state
.build_scheduler
.in_progress
+ .lock()
+ .unwrap()
.contains("cache-site")
{
break;
diff --git a/tests/integration/cleanup.rs b/tests/integration/cleanup.rs
index e0cc902..a8bc84f 100644
--- a/tests/integration/cleanup.rs
+++ b/tests/integration/cleanup.rs
@@ -31,7 +31,6 @@ async fn old_builds_cleaned_up() {
base_dir: base_dir.clone(),
log_dir: base_dir.join("logs"),
log_level: "debug".to_owned(),
- rate_limit_per_minute: 100,
max_builds_to_keep: 2,
git_timeout: None,
sites: vec![site],
@@ -62,6 +61,8 @@ async fn old_builds_cleaned_up() {
.state
.build_scheduler
.in_progress
+ .lock()
+ .unwrap()
.contains("cleanup-site")
{
break;
diff --git a/tests/integration/cli_cleanup.rs b/tests/integration/cli_cleanup.rs
new file mode 100644
index 0000000..822c7bc
--- /dev/null
+++ b/tests/integration/cli_cleanup.rs
@@ -0,0 +1,341 @@
+use std::fmt::Write as _;
+use std::process::Stdio;
+use tempfile::TempDir;
+use tokio::process::Command;
+
+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
+}
+
+async fn write_cleanup_config(
+ dir: &std::path::Path,
+ sites: &[&str],
+ max_builds: u32,
+) -> (std::path::PathBuf, std::path::PathBuf, 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"
+"#
+ )
+ .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"
+max_builds_to_keep = {max_builds}
+{sites_toml}"#,
+ base_dir = base_dir.display(),
+ log_dir = log_dir.display(),
+ );
+ tokio::fs::write(&config_path, config).await.unwrap();
+ (config_path, base_dir, log_dir)
+}
+
+async fn create_fake_builds(
+ base_dir: &std::path::Path,
+ log_dir: &std::path::Path,
+ site: &str,
+ timestamps: &[&str],
+) {
+ let builds_dir = base_dir.join("builds").join(site);
+ let site_log_dir = log_dir.join(site);
+ tokio::fs::create_dir_all(&builds_dir).await.unwrap();
+ tokio::fs::create_dir_all(&site_log_dir).await.unwrap();
+
+ for ts in timestamps {
+ tokio::fs::create_dir_all(builds_dir.join(ts))
+ .await
+ .unwrap();
+ tokio::fs::write(site_log_dir.join(format!("{ts}.log")), "build log")
+ .await
+ .unwrap();
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tier 1: no container runtime / git needed
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn cli_cleanup_unknown_site() {
+ let tempdir = TempDir::new().unwrap();
+ let (config_path, _, _) = write_cleanup_config(tempdir.path(), &["real-site"], 5).await;
+
+ let output = Command::new(witryna_bin())
+ .args([
+ "cleanup",
+ "--config",
+ config_path.to_str().unwrap(),
+ "nonexistent",
+ ])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output()
+ .await
+ .unwrap();
+
+ assert!(!output.status.success(), "should exit non-zero");
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("not found"),
+ "should mention 'not found', got: {stderr}"
+ );
+}
+
+#[tokio::test]
+async fn cli_cleanup_keep_zero_refused() {
+ let tempdir = TempDir::new().unwrap();
+ let (config_path, _, _) = write_cleanup_config(tempdir.path(), &["my-site"], 5).await;
+
+ let output = Command::new(witryna_bin())
+ .args([
+ "cleanup",
+ "--config",
+ config_path.to_str().unwrap(),
+ "--keep",
+ "0",
+ ])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output()
+ .await
+ .unwrap();
+
+ assert!(!output.status.success(), "should exit non-zero");
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("--keep 0 would delete all builds"),
+ "should refuse --keep 0, got: {stderr}"
+ );
+}
+
+#[tokio::test]
+async fn cli_cleanup_disabled_when_max_zero() {
+ let tempdir = TempDir::new().unwrap();
+ let (config_path, _, _) = write_cleanup_config(tempdir.path(), &["my-site"], 0).await;
+
+ let output = Command::new(witryna_bin())
+ .args(["cleanup", "--config", config_path.to_str().unwrap()])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output()
+ .await
+ .unwrap();
+
+ assert!(
+ output.status.success(),
+ "should exit 0, stderr: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("cleanup disabled"),
+ "should say 'cleanup disabled', got: {stderr}"
+ );
+}
+
+#[tokio::test]
+async fn cli_cleanup_removes_old_builds() {
+ let tempdir = TempDir::new().unwrap();
+ let (config_path, base_dir, log_dir) =
+ write_cleanup_config(tempdir.path(), &["site-a", "site-b"], 5).await;
+
+ let timestamps = &[
+ "20260126-100000-000001",
+ "20260126-100000-000002",
+ "20260126-100000-000003",
+ "20260126-100000-000004",
+ ];
+
+ create_fake_builds(&base_dir, &log_dir, "site-a", timestamps).await;
+ create_fake_builds(&base_dir, &log_dir, "site-b", timestamps).await;
+
+ let output = Command::new(witryna_bin())
+ .args([
+ "cleanup",
+ "--config",
+ config_path.to_str().unwrap(),
+ "--keep",
+ "2",
+ ])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output()
+ .await
+ .unwrap();
+
+ assert!(
+ output.status.success(),
+ "should exit 0, stderr: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("site-a: removed"),
+ "should report site-a removals, got: {stderr}"
+ );
+ assert!(
+ stderr.contains("site-b: removed"),
+ "should report site-b removals, got: {stderr}"
+ );
+ assert!(
+ stderr.contains("total:"),
+ "should print total summary for multi-site, got: {stderr}"
+ );
+
+ // Verify filesystem: oldest 2 gone, newest 2 remain for each site
+ for site in &["site-a", "site-b"] {
+ let builds = base_dir.join("builds").join(site);
+ assert!(!builds.join("20260126-100000-000001").exists());
+ assert!(!builds.join("20260126-100000-000002").exists());
+ assert!(builds.join("20260126-100000-000003").exists());
+ assert!(builds.join("20260126-100000-000004").exists());
+
+ let logs = log_dir.join(site);
+ assert!(!logs.join("20260126-100000-000001.log").exists());
+ assert!(!logs.join("20260126-100000-000002.log").exists());
+ assert!(logs.join("20260126-100000-000003.log").exists());
+ assert!(logs.join("20260126-100000-000004.log").exists());
+ }
+}
+
+#[tokio::test]
+async fn cli_cleanup_single_site_filter() {
+ let tempdir = TempDir::new().unwrap();
+ let (config_path, base_dir, log_dir) =
+ write_cleanup_config(tempdir.path(), &["site-a", "site-b"], 5).await;
+
+ let timestamps = &[
+ "20260126-100000-000001",
+ "20260126-100000-000002",
+ "20260126-100000-000003",
+ "20260126-100000-000004",
+ ];
+
+ create_fake_builds(&base_dir, &log_dir, "site-a", timestamps).await;
+ create_fake_builds(&base_dir, &log_dir, "site-b", timestamps).await;
+
+ let output = Command::new(witryna_bin())
+ .args([
+ "cleanup",
+ "--config",
+ config_path.to_str().unwrap(),
+ "--keep",
+ "2",
+ "site-a",
+ ])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output()
+ .await
+ .unwrap();
+
+ assert!(
+ output.status.success(),
+ "should exit 0, stderr: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+
+ let stderr = String::from_utf8_lossy(&output.stderr);
+
+ // site-a should be cleaned
+ assert!(
+ stderr.contains("site-a: removed"),
+ "should report site-a removals, got: {stderr}"
+ );
+
+ // site-b should be untouched — not mentioned in output
+ assert!(
+ !stderr.contains("site-b"),
+ "site-b should not appear in output, got: {stderr}"
+ );
+
+ // No total line for single-site cleanup
+ assert!(
+ !stderr.contains("total:"),
+ "should not print total for single site, got: {stderr}"
+ );
+
+ // Verify site-b filesystem is untouched
+ let site_b_builds = base_dir.join("builds").join("site-b");
+ assert!(site_b_builds.join("20260126-100000-000001").exists());
+ assert!(site_b_builds.join("20260126-100000-000004").exists());
+}
+
+#[tokio::test]
+async fn cli_cleanup_keep_overrides_config() {
+ let tempdir = TempDir::new().unwrap();
+ // Config says max_builds_to_keep = 1
+ let (config_path, base_dir, log_dir) =
+ write_cleanup_config(tempdir.path(), &["my-site"], 1).await;
+
+ let timestamps = &[
+ "20260126-100000-000001",
+ "20260126-100000-000002",
+ "20260126-100000-000003",
+ "20260126-100000-000004",
+ ];
+
+ create_fake_builds(&base_dir, &log_dir, "my-site", timestamps).await;
+
+ // --keep 3 should override config's max_builds_to_keep=1
+ let output = Command::new(witryna_bin())
+ .args([
+ "cleanup",
+ "--config",
+ config_path.to_str().unwrap(),
+ "--keep",
+ "3",
+ ])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output()
+ .await
+ .unwrap();
+
+ assert!(
+ output.status.success(),
+ "should exit 0, stderr: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+
+ // With --keep 3 and 4 builds: only 1 should be removed
+ let builds = base_dir.join("builds").join("my-site");
+ assert!(
+ !builds.join("20260126-100000-000001").exists(),
+ "oldest should be removed"
+ );
+ assert!(
+ builds.join("20260126-100000-000002").exists(),
+ "second should remain"
+ );
+ assert!(
+ builds.join("20260126-100000-000003").exists(),
+ "third should remain"
+ );
+ assert!(
+ builds.join("20260126-100000-000004").exists(),
+ "newest should remain"
+ );
+}
diff --git a/tests/integration/cli_run.rs b/tests/integration/cli_run.rs
index 0ea8d20..e12beb5 100644
--- a/tests/integration/cli_run.rs
+++ b/tests/integration/cli_run.rs
@@ -80,10 +80,10 @@ sites = []
let output = Command::new(witryna_bin())
.args([
- "--config",
- config_path.to_str().unwrap(),
"run",
"nonexistent",
+ "--config",
+ config_path.to_str().unwrap(),
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -130,10 +130,10 @@ async fn cli_run_build_failure_exits_nonzero() {
let output = Command::new(witryna_bin())
.args([
- "--config",
- config_path.to_str().unwrap(),
"run",
"fail-site",
+ "--config",
+ config_path.to_str().unwrap(),
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -193,10 +193,10 @@ async fn cli_run_builds_site_successfully() {
let output = Command::new(witryna_bin())
.args([
- "--config",
- config_path.to_str().unwrap(),
"run",
"test-site",
+ "--config",
+ config_path.to_str().unwrap(),
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -254,11 +254,11 @@ async fn cli_run_verbose_shows_build_output() {
let output = Command::new(witryna_bin())
.args([
- "--config",
- config_path.to_str().unwrap(),
"run",
"verbose-site",
"--verbose",
+ "--config",
+ config_path.to_str().unwrap(),
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
diff --git a/tests/integration/cli_status.rs b/tests/integration/cli_status.rs
index 25135fb..4eb50a4 100644
--- a/tests/integration/cli_status.rs
+++ b/tests/integration/cli_status.rs
@@ -1,3 +1,4 @@
+use std::fmt::Write as _;
use std::process::Stdio;
use tempfile::TempDir;
use tokio::process::Command;
@@ -12,17 +13,16 @@ fn witryna_bin() -> std::path::PathBuf {
}
/// Write a minimal witryna.toml config for status tests.
-async fn write_status_config(
- dir: &std::path::Path,
- sites: &[&str],
- log_dir: &std::path::Path,
-) -> std::path::PathBuf {
+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 {
- sites_toml.push_str(&format!(
+ write!(
+ sites_toml,
r#"
[[sites]]
name = "{name}"
@@ -30,7 +30,8 @@ repo_url = "https://example.com/{name}.git"
branch = "main"
webhook_token = "unused"
"#
- ));
+ )
+ .unwrap();
}
let config_path = dir.join("witryna.toml");
@@ -48,63 +49,43 @@ log_level = "info"
config_path
}
-/// Write a fake build log with a valid header.
-async fn write_test_build_log(
- log_dir: &std::path::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,
- timestamp: &str,
- status: &str,
- commit: &str,
- image: &str,
- duration: &str,
+ current: &str,
+ builds: &[serde_json::Value],
) {
- let site_log_dir = log_dir.join(site_name);
- tokio::fs::create_dir_all(&site_log_dir).await.unwrap();
-
- let content = format!(
- "=== BUILD LOG ===\n\
- Site: {site_name}\n\
- Timestamp: {timestamp}\n\
- Git Commit: {commit}\n\
- Image: {image}\n\
- Duration: {duration}\n\
- Status: {status}\n\
- \n\
- === STDOUT ===\n\
- build output\n\
- \n\
- === STDERR ===\n"
- );
+ 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 log_file = site_log_dir.join(format!("{timestamp}.log"));
- tokio::fs::write(&log_file, content).await.unwrap();
+ let state_path = state_dir.join("state.json");
+ tokio::fs::write(&state_path, content.to_string())
+ .await
+ .unwrap();
}
-/// Write a fake hook log with a valid header.
-async fn write_test_hook_log(
- log_dir: &std::path::Path,
- site_name: &str,
- timestamp: &str,
+/// Create a build entry JSON value.
+fn build_entry(
status: &str,
-) {
- let site_log_dir = log_dir.join(site_name);
- tokio::fs::create_dir_all(&site_log_dir).await.unwrap();
-
- let content = format!(
- "=== HOOK LOG ===\n\
- Site: {site_name}\n\
- Timestamp: {timestamp}\n\
- Command: hook-cmd\n\
- Duration: 1s\n\
- Status: {status}\n\
- \n\
- === STDOUT ===\n\
- \n\
- === STDERR ===\n"
- );
-
- let log_file = site_log_dir.join(format!("{timestamp}-hook.log"));
- tokio::fs::write(&log_file, content).await.unwrap();
+ 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,
+ })
}
// ---------------------------------------------------------------------------
@@ -114,13 +95,10 @@ async fn write_test_hook_log(
#[tokio::test]
async fn cli_status_no_builds() {
let tempdir = TempDir::new().unwrap();
- let log_dir = tempdir.path().join("logs");
- tokio::fs::create_dir_all(&log_dir).await.unwrap();
-
- let config_path = write_status_config(tempdir.path(), &["empty-site"], &log_dir).await;
+ let config_path = write_status_config(tempdir.path(), &["empty-site"]).await;
let output = Command::new(witryna_bin())
- .args(["--config", config_path.to_str().unwrap(), "status"])
+ .args(["status", "--config", config_path.to_str().unwrap()])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
@@ -139,24 +117,25 @@ async fn cli_status_no_builds() {
#[tokio::test]
async fn cli_status_single_build() {
let tempdir = TempDir::new().unwrap();
- let log_dir = tempdir.path().join("logs");
- tokio::fs::create_dir_all(&log_dir).await.unwrap();
+ let base_dir = tempdir.path().join("data");
+ let config_path = write_status_config(tempdir.path(), &["my-site"]).await;
- write_test_build_log(
- &log_dir,
+ write_state_json(
+ &base_dir,
"my-site",
"20260126-143000-123456",
- "success",
- "abc123d",
- "node:20-alpine",
- "45s",
+ &[build_entry(
+ "success",
+ "20260126-143000-123456",
+ "abc123d",
+ "45s",
+ "/logs/my-site/20260126-143000-123456.log",
+ )],
)
.await;
- let config_path = write_status_config(tempdir.path(), &["my-site"], &log_dir).await;
-
let output = Command::new(witryna_bin())
- .args(["--config", config_path.to_str().unwrap(), "status"])
+ .args(["status", "--config", config_path.to_str().unwrap()])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
@@ -174,28 +153,29 @@ async fn cli_status_single_build() {
#[tokio::test]
async fn cli_status_json_output() {
let tempdir = TempDir::new().unwrap();
- let log_dir = tempdir.path().join("logs");
- tokio::fs::create_dir_all(&log_dir).await.unwrap();
+ let base_dir = tempdir.path().join("data");
+ let config_path = write_status_config(tempdir.path(), &["json-site"]).await;
- write_test_build_log(
- &log_dir,
+ write_state_json(
+ &base_dir,
"json-site",
"20260126-143000-123456",
- "success",
- "abc123d",
- "node:20-alpine",
- "45s",
+ &[build_entry(
+ "success",
+ "20260126-143000-123456",
+ "abc123d",
+ "45s",
+ "/logs/json-site/20260126-143000-123456.log",
+ )],
)
.await;
- let config_path = write_status_config(tempdir.path(), &["json-site"], &log_dir).await;
-
let output = Command::new(witryna_bin())
.args([
- "--config",
- config_path.to_str().unwrap(),
"status",
"--json",
+ "--config",
+ config_path.to_str().unwrap(),
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -217,41 +197,43 @@ async fn cli_status_json_output() {
#[tokio::test]
async fn cli_status_site_filter() {
let tempdir = TempDir::new().unwrap();
- let log_dir = tempdir.path().join("logs");
- tokio::fs::create_dir_all(&log_dir).await.unwrap();
+ let base_dir = tempdir.path().join("data");
+ let config_path = write_status_config(tempdir.path(), &["site-a", "site-b"]).await;
- // Create logs for two sites
- write_test_build_log(
- &log_dir,
+ write_state_json(
+ &base_dir,
"site-a",
"20260126-143000-000000",
- "success",
- "aaa1111",
- "alpine:latest",
- "10s",
+ &[build_entry(
+ "success",
+ "20260126-143000-000000",
+ "aaa1111",
+ "10s",
+ "/logs/a.log",
+ )],
)
.await;
- write_test_build_log(
- &log_dir,
+ write_state_json(
+ &base_dir,
"site-b",
"20260126-150000-000000",
- "success",
- "bbb2222",
- "alpine:latest",
- "20s",
+ &[build_entry(
+ "success",
+ "20260126-150000-000000",
+ "bbb2222",
+ "20s",
+ "/logs/b.log",
+ )],
)
.await;
- let config_path = write_status_config(tempdir.path(), &["site-a", "site-b"], &log_dir).await;
-
let output = Command::new(witryna_bin())
.args([
- "--config",
- config_path.to_str().unwrap(),
"status",
- "--site",
"site-a",
+ "--config",
+ config_path.to_str().unwrap(),
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -271,33 +253,340 @@ async fn cli_status_site_filter() {
#[tokio::test]
async fn cli_status_hook_failed() {
let tempdir = TempDir::new().unwrap();
- let log_dir = tempdir.path().join("logs");
- tokio::fs::create_dir_all(&log_dir).await.unwrap();
+ let base_dir = tempdir.path().join("data");
+ let config_path = write_status_config(tempdir.path(), &["hook-site"]).await;
- // Build succeeded, but hook failed
- write_test_build_log(
- &log_dir,
+ write_state_json(
+ &base_dir,
"hook-site",
"20260126-143000-123456",
- "success",
- "abc123d",
- "alpine:latest",
- "12s",
+ &[build_entry(
+ "hook failed",
+ "20260126-143000-123456",
+ "abc123d",
+ "12s",
+ "/logs/hook-site/20260126-143000-123456.log",
+ )],
)
.await;
- write_test_hook_log(
- &log_dir,
- "hook-site",
+ 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",
- "failed (exit code 1)",
+ &[
+ build_entry(
+ "failed",
+ "20260126-150000-000000",
+ "def4567",
+ "30s",
+ "/logs/2.log",
+ ),
+ build_entry(
+ "success",
+ "20260126-143000-123456",
+ "abc123d",
+ "45s",
+ "/logs/1.log",
+ ),
+ ],
)
.await;
- let config_path = write_status_config(tempdir.path(), &["hook-site"], &log_dir).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(["--config", config_path.to_str().unwrap(), "status"])
+ .args(["status", "--config", config_path.to_str().unwrap()])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
@@ -307,7 +596,16 @@ async fn cli_status_hook_failed() {
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}"
+ 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}"
);
}
diff --git a/tests/integration/cli_switch.rs b/tests/integration/cli_switch.rs
new file mode 100644
index 0000000..fcbced6
--- /dev/null
+++ b/tests/integration/cli_switch.rs
@@ -0,0 +1,330 @@
+use std::fmt::Write as _;
+use std::process::Stdio;
+use tempfile::TempDir;
+use tokio::process::Command;
+
+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
+}
+
+async fn write_switch_config(
+ dir: &std::path::Path,
+ sites: &[&str],
+) -> (std::path::PathBuf, std::path::PathBuf) {
+ let base_dir = dir.join("data");
+ tokio::fs::create_dir_all(&base_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 = dir.join("logs").display(),
+ );
+ tokio::fs::write(&config_path, config).await.unwrap();
+ (config_path, base_dir)
+}
+
+// ---------------------------------------------------------------------------
+// Tier 1: no container runtime / git needed
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn cli_switch_unknown_site_exits_nonzero() {
+ let tempdir = TempDir::new().unwrap();
+ let (config_path, _) = write_switch_config(tempdir.path(), &["real-site"]).await;
+
+ let output = Command::new(witryna_bin())
+ .args([
+ "switch",
+ "--config",
+ config_path.to_str().unwrap(),
+ "nonexistent",
+ "20260126-143000-123456",
+ ])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output()
+ .await
+ .unwrap();
+
+ assert!(!output.status.success(), "should exit non-zero");
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("not found"),
+ "should mention 'not found', got: {stderr}"
+ );
+}
+
+#[tokio::test]
+async fn cli_switch_nonexistent_build_exits_nonzero() {
+ let tempdir = TempDir::new().unwrap();
+ let (config_path, base_dir) = write_switch_config(tempdir.path(), &["my-site"]).await;
+
+ // Create builds dir with one existing build
+ let builds_dir = base_dir.join("builds").join("my-site");
+ tokio::fs::create_dir_all(builds_dir.join("20260126-100000-000001"))
+ .await
+ .unwrap();
+
+ let output = Command::new(witryna_bin())
+ .args([
+ "switch",
+ "--config",
+ config_path.to_str().unwrap(),
+ "my-site",
+ "20260126-999999-999999",
+ ])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output()
+ .await
+ .unwrap();
+
+ assert!(!output.status.success(), "should exit non-zero");
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("not found"),
+ "should mention 'not found', got: {stderr}"
+ );
+ assert!(
+ stderr.contains("20260126-100000-000001"),
+ "should list available builds, got: {stderr}"
+ );
+}
+
+#[tokio::test]
+async fn cli_switch_invalid_timestamp_format_exits_nonzero() {
+ let tempdir = TempDir::new().unwrap();
+ let (config_path, base_dir) = write_switch_config(tempdir.path(), &["my-site"]).await;
+
+ // Create builds dir so the "no builds dir" check doesn't fire first
+ let builds_dir = base_dir.join("builds").join("my-site");
+ tokio::fs::create_dir_all(&builds_dir).await.unwrap();
+
+ let output = Command::new(witryna_bin())
+ .args([
+ "switch",
+ "--config",
+ config_path.to_str().unwrap(),
+ "my-site",
+ "not-a-timestamp",
+ ])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output()
+ .await
+ .unwrap();
+
+ assert!(!output.status.success(), "should exit non-zero");
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("not a valid build timestamp"),
+ "should mention invalid timestamp, got: {stderr}"
+ );
+}
+
+#[tokio::test]
+async fn cli_switch_updates_symlink() {
+ let tempdir = TempDir::new().unwrap();
+ let (config_path, base_dir) = write_switch_config(tempdir.path(), &["my-site"]).await;
+
+ let builds_dir = base_dir.join("builds").join("my-site");
+ let build1 = builds_dir.join("20260126-100000-000001");
+ let build2 = builds_dir.join("20260126-100000-000002");
+ tokio::fs::create_dir_all(&build1).await.unwrap();
+ tokio::fs::create_dir_all(&build2).await.unwrap();
+
+ // Point current at build1
+ let current = builds_dir.join("current");
+ tokio::fs::symlink(&build1, &current).await.unwrap();
+
+ // Switch to build2
+ let output = Command::new(witryna_bin())
+ .args([
+ "switch",
+ "--config",
+ config_path.to_str().unwrap(),
+ "my-site",
+ "20260126-100000-000002",
+ ])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output()
+ .await
+ .unwrap();
+
+ assert!(
+ output.status.success(),
+ "should exit 0, stderr: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+
+ // Verify symlink now points to build2
+ let target = tokio::fs::read_link(&current).await.unwrap();
+ assert_eq!(target, build2, "symlink should point to build2");
+
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("switched my-site to build 20260126-100000-000002"),
+ "should confirm switch, got: {stderr}"
+ );
+}
+
+#[tokio::test]
+async fn cli_switch_preserves_builds() {
+ let tempdir = TempDir::new().unwrap();
+ let (config_path, base_dir) = write_switch_config(tempdir.path(), &["my-site"]).await;
+
+ let builds_dir = base_dir.join("builds").join("my-site");
+ let build1 = builds_dir.join("20260126-100000-000001");
+ let build2 = builds_dir.join("20260126-100000-000002");
+ tokio::fs::create_dir_all(&build1).await.unwrap();
+ tokio::fs::create_dir_all(&build2).await.unwrap();
+
+ // Point current at build1
+ let current = builds_dir.join("current");
+ tokio::fs::symlink(&build1, &current).await.unwrap();
+
+ // Switch to build2
+ let output = Command::new(witryna_bin())
+ .args([
+ "switch",
+ "--config",
+ config_path.to_str().unwrap(),
+ "my-site",
+ "20260126-100000-000002",
+ ])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output()
+ .await
+ .unwrap();
+
+ assert!(output.status.success(), "should exit 0");
+
+ // Both builds should still exist
+ assert!(build1.exists(), "build1 should still exist after switch");
+ assert!(build2.exists(), "build2 should still exist after switch");
+}
+
+#[tokio::test]
+async fn cli_switch_updates_state_json() {
+ let tempdir = TempDir::new().unwrap();
+ let (config_path, base_dir) = write_switch_config(tempdir.path(), &["my-site"]).await;
+
+ let builds_dir = base_dir.join("builds").join("my-site");
+ let build1 = builds_dir.join("20260126-100000-000001");
+ let build2 = builds_dir.join("20260126-100000-000002");
+ tokio::fs::create_dir_all(&build1).await.unwrap();
+ tokio::fs::create_dir_all(&build2).await.unwrap();
+
+ // Write initial state.json with current pointing to build1
+ let state_json = serde_json::json!({
+ "current": "20260126-100000-000001",
+ "builds": [
+ {
+ "status": "success",
+ "timestamp": "20260126-100000-000002",
+ "started_at": "2026-01-26T10:00:00Z",
+ "git_commit": "bbb2222",
+ "duration": "20s",
+ "log": "/logs/2.log",
+ },
+ {
+ "status": "success",
+ "timestamp": "20260126-100000-000001",
+ "started_at": "2026-01-26T10:00:00Z",
+ "git_commit": "aaa1111",
+ "duration": "10s",
+ "log": "/logs/1.log",
+ }
+ ],
+ });
+ tokio::fs::write(builds_dir.join("state.json"), state_json.to_string())
+ .await
+ .unwrap();
+
+ // Create symlink
+ let current = builds_dir.join("current");
+ tokio::fs::symlink(&build1, &current).await.unwrap();
+
+ // Switch to build2
+ let output = Command::new(witryna_bin())
+ .args([
+ "switch",
+ "--config",
+ config_path.to_str().unwrap(),
+ "my-site",
+ "20260126-100000-000002",
+ ])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output()
+ .await
+ .unwrap();
+
+ assert!(output.status.success(), "should exit 0");
+
+ // Verify state.json was updated
+ let state_content = tokio::fs::read_to_string(builds_dir.join("state.json"))
+ .await
+ .unwrap();
+ let state: serde_json::Value = serde_json::from_str(&state_content).unwrap();
+ assert_eq!(
+ state["current"], "20260126-100000-000002",
+ "state.json current should be updated after switch"
+ );
+}
+
+#[tokio::test]
+async fn cli_switch_no_builds_dir_exits_nonzero() {
+ let tempdir = TempDir::new().unwrap();
+ let (config_path, _) = write_switch_config(tempdir.path(), &["my-site"]).await;
+
+ // Don't create builds directory at all
+
+ let output = Command::new(witryna_bin())
+ .args([
+ "switch",
+ "--config",
+ config_path.to_str().unwrap(),
+ "my-site",
+ "20260126-100000-000001",
+ ])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output()
+ .await
+ .unwrap();
+
+ assert!(!output.status.success(), "should exit non-zero");
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("no builds found"),
+ "should mention no builds, got: {stderr}"
+ );
+}
diff --git a/tests/integration/cli_validate.rs b/tests/integration/cli_validate.rs
new file mode 100644
index 0000000..c8a62c8
--- /dev/null
+++ b/tests/integration/cli_validate.rs
@@ -0,0 +1,137 @@
+use std::process::Stdio;
+use tempfile::TempDir;
+use tokio::process::Command;
+
+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
+}
+
+async fn write_validate_config(dir: &std::path::Path, content: &str) -> std::path::PathBuf {
+ let config_path = dir.join("witryna.toml");
+ tokio::fs::write(&config_path, content).await.unwrap();
+ config_path
+}
+
+// ---------------------------------------------------------------------------
+// Tier 1: no container runtime / git needed
+// ---------------------------------------------------------------------------
+
+#[tokio::test]
+async fn cli_validate_valid_config() {
+ let tempdir = TempDir::new().unwrap();
+ let base_dir = tempdir.path().join("data");
+ tokio::fs::create_dir_all(&base_dir).await.unwrap();
+
+ let config = format!(
+ r#"listen_address = "127.0.0.1:8080"
+container_runtime = "podman"
+base_dir = "{base_dir}"
+log_level = "info"
+
+[[sites]]
+name = "site-a"
+repo_url = "https://example.com/a.git"
+branch = "main"
+
+[[sites]]
+name = "site-b"
+repo_url = "https://example.com/b.git"
+branch = "main"
+"#,
+ base_dir = base_dir.display(),
+ );
+ let config_path = write_validate_config(tempdir.path(), &config).await;
+
+ let output = Command::new(witryna_bin())
+ .args(["validate", "--config", config_path.to_str().unwrap()])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output()
+ .await
+ .unwrap();
+
+ assert!(
+ output.status.success(),
+ "should exit 0, stderr: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("Configuration valid:"),
+ "should say 'Configuration valid:', got: {stderr}"
+ );
+ assert!(
+ stderr.contains("127.0.0.1:8080"),
+ "should show listen address, got: {stderr}"
+ );
+ assert!(
+ stderr.contains("podman"),
+ "should show runtime, got: {stderr}"
+ );
+ assert!(
+ stderr.contains("Sites: 2"),
+ "should show site count, got: {stderr}"
+ );
+ assert!(
+ stderr.contains("site-a"),
+ "should list site-a, got: {stderr}"
+ );
+ assert!(
+ stderr.contains("site-b"),
+ "should list site-b, got: {stderr}"
+ );
+}
+
+#[tokio::test]
+async fn cli_validate_missing_config_file() {
+ let output = Command::new(witryna_bin())
+ .args(["validate", "--config", "/nonexistent/witryna.toml"])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output()
+ .await
+ .unwrap();
+
+ assert!(!output.status.success(), "should exit non-zero");
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("config file not found"),
+ "should mention 'config file not found', got: {stderr}"
+ );
+}
+
+#[tokio::test]
+async fn cli_validate_invalid_config() {
+ let tempdir = TempDir::new().unwrap();
+
+ // listen_address = "" is invalid — config validation rejects it
+ let config = r#"listen_address = ""
+container_runtime = "podman"
+base_dir = "/tmp/witryna"
+log_level = "info"
+
+[[sites]]
+name = "test"
+repo_url = "https://example.com/test.git"
+branch = "main"
+"#;
+ let config_path = write_validate_config(tempdir.path(), config).await;
+
+ let output = Command::new(witryna_bin())
+ .args(["validate", "--config", config_path.to_str().unwrap()])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .output()
+ .await
+ .unwrap();
+
+ assert!(
+ !output.status.success(),
+ "should exit non-zero for invalid config"
+ );
+}
diff --git a/tests/integration/concurrent.rs b/tests/integration/concurrent.rs
index e7f2b64..da09ac9 100644
--- a/tests/integration/concurrent.rs
+++ b/tests/integration/concurrent.rs
@@ -11,6 +11,8 @@ async fn concurrent_build_gets_queued() {
.state
.build_scheduler
.in_progress
+ .lock()
+ .unwrap()
.insert("my-site".to_owned());
let resp = TestServer::client()
@@ -37,11 +39,15 @@ async fn concurrent_build_queue_collapse() {
.state
.build_scheduler
.in_progress
+ .lock()
+ .unwrap()
.insert("my-site".to_owned());
server
.state
.build_scheduler
.queued
+ .lock()
+ .unwrap()
.insert("my-site".to_owned());
// Third request should collapse (202, no body)
@@ -97,6 +103,8 @@ async fn build_in_progress_checked_after_auth() {
.state
.build_scheduler
.in_progress
+ .lock()
+ .unwrap()
.insert("my-site".to_owned());
// Request with wrong token should return 401 (auth checked before build status)
diff --git a/tests/integration/env_vars.rs b/tests/integration/env_vars.rs
index 44f74fa..5638149 100644
--- a/tests/integration/env_vars.rs
+++ b/tests/integration/env_vars.rs
@@ -159,4 +159,8 @@ async fn env_vars_passed_to_post_deploy_hook() {
content.contains("WITRYNA_BUILD_TIMESTAMP="),
"WITRYNA_BUILD_TIMESTAMP should be set"
);
+ assert!(
+ content.contains("WITRYNA_BUILD_STATUS=success"),
+ "WITRYNA_BUILD_STATUS should be set to success"
+ );
}
diff --git a/tests/integration/harness.rs b/tests/integration/harness.rs
index c015fa8..b985971 100644
--- a/tests/integration/harness.rs
+++ b/tests/integration/harness.rs
@@ -1,11 +1,8 @@
-use governor::{Quota, RateLimiter};
use std::collections::HashMap;
-use std::num::NonZeroU32;
use std::path::PathBuf;
-use std::sync::Arc;
+use std::sync::{Arc, RwLock};
use tempfile::TempDir;
-use tokio::net::TcpListener;
-use tokio::sync::{RwLock, oneshot};
+use tiny_http::Server;
use witryna::build_guard::BuildScheduler;
use witryna::config::{BuildOverrides, Config, SiteConfig};
use witryna::polling::PollingManager;
@@ -18,18 +15,14 @@ pub struct TestServer {
/// Kept alive for RAII cleanup of the config file written during startup.
#[allow(dead_code)]
pub tempdir: TempDir,
- shutdown_tx: Option<oneshot::Sender<()>>,
+ server: Arc<Server>,
+ server_thread: Option<std::thread::JoinHandle<()>>,
}
impl TestServer {
/// Start a new test server with the given config.
/// Binds to `127.0.0.1:0` (OS-assigned port).
- pub async fn start(config: Config) -> Self {
- Self::start_with_rate_limit(config, 1000).await
- }
-
- /// Start a new test server with a specific rate limit.
- pub async fn start_with_rate_limit(mut config: Config, rate_limit: u32) -> Self {
+ pub async fn start(mut config: Config) -> Self {
let tempdir = TempDir::new().expect("failed to create temp dir");
let config_path = tempdir.path().join("witryna.toml");
@@ -44,38 +37,50 @@ impl TestServer {
.await
.expect("failed to resolve secrets");
- let quota = Quota::per_minute(NonZeroU32::new(rate_limit).expect("rate limit must be > 0"));
-
let state = AppState {
config: Arc::new(RwLock::new(config)),
config_path: Arc::new(config_path),
build_scheduler: Arc::new(BuildScheduler::new()),
- rate_limiter: Arc::new(RateLimiter::dashmap(quota)),
polling_manager: Arc::new(PollingManager::new()),
};
- let listener = TcpListener::bind("127.0.0.1:0")
- .await
- .expect("failed to bind to random port");
- let port = listener.local_addr().unwrap().port();
+ let server = Arc::new(Server::http("127.0.0.1:0").expect("failed to bind"));
+ let port = match server.server_addr() {
+ tiny_http::ListenAddr::IP(addr) => addr.port(),
+ _ => unreachable!("expected IP address"),
+ };
let base_url = format!("http://127.0.0.1:{port}");
- let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
-
- let server_state = state.clone();
- tokio::spawn(async move {
- witryna::test_support::run_server(server_state, listener, async {
- let _ = shutdown_rx.await;
- })
- .await
- .expect("server failed");
- });
+ // Shutdown uses server.unblock() directly — no oneshot needed.
+ // We pass a future that never resolves; shutdown is triggered
+ // by calling server.unblock() from TestServer::shutdown().
+ let server_thread = witryna::test_support::run_server(
+ state.clone(),
+ server.clone(),
+ std::future::pending(),
+ );
+
+ // Readiness probe: wait for server to accept connections
+ let client = reqwest::Client::new();
+ for _ in 0..50 {
+ if client
+ .get(format!("{base_url}/health"))
+ .send()
+ .await
+ .map(|r| r.status().as_u16() == 200)
+ .unwrap_or(false)
+ {
+ break;
+ }
+ tokio::time::sleep(std::time::Duration::from_millis(10)).await;
+ }
Self {
base_url,
state,
tempdir,
- shutdown_tx: Some(shutdown_tx),
+ server,
+ server_thread: Some(server_thread),
}
}
@@ -91,8 +96,11 @@ impl TestServer {
/// Shut down the server gracefully.
pub fn shutdown(&mut self) {
- if let Some(tx) = self.shutdown_tx.take() {
- let _ = tx.send(());
+ // Unblock the HTTP request loop directly — no async channel needed
+ self.server.unblock();
+ // Join the HTTP thread to ensure clean teardown
+ if let Some(handle) = self.server_thread.take() {
+ let _ = handle.join();
}
}
}
@@ -112,7 +120,6 @@ pub fn test_config(base_dir: PathBuf) -> Config {
base_dir,
log_dir,
log_level: "debug".to_owned(),
- rate_limit_per_minute: 10,
max_builds_to_keep: 5,
git_timeout: None,
sites: vec![],
@@ -128,7 +135,6 @@ pub fn test_config_with_site(base_dir: PathBuf, site: SiteConfig) -> Config {
base_dir,
log_dir,
log_level: "debug".to_owned(),
- rate_limit_per_minute: 10,
max_builds_to_keep: 5,
git_timeout: None,
sites: vec![site],
@@ -144,7 +150,6 @@ pub fn test_config_with_sites(base_dir: PathBuf, sites: Vec<SiteConfig>) -> Conf
base_dir,
log_dir,
log_level: "debug".to_owned(),
- rate_limit_per_minute: 10,
max_builds_to_keep: 5,
git_timeout: None,
sites,
@@ -286,7 +291,6 @@ fn build_config_toml(config: &Config) -> String {
{}base_dir = "{}"
log_dir = "{}"
log_level = "{}"
-rate_limit_per_minute = {}
max_builds_to_keep = {}
"#,
config.listen_address,
@@ -294,7 +298,6 @@ max_builds_to_keep = {}
config.base_dir.display(),
config.log_dir.display(),
config.log_level,
- config.rate_limit_per_minute,
config.max_builds_to_keep,
);
diff --git a/tests/integration/hooks.rs b/tests/integration/hooks.rs
index 86684cc..d8b4fa3 100644
--- a/tests/integration/hooks.rs
+++ b/tests/integration/hooks.rs
@@ -1,6 +1,7 @@
use crate::git_helpers::create_local_repo;
use crate::harness::{SiteBuilder, TestServer, test_config_with_site};
use crate::runtime::{skip_without_git, skip_without_runtime};
+use std::path::Path;
use std::time::Duration;
// ---------------------------------------------------------------------------
@@ -135,3 +136,150 @@ async fn post_deploy_hook_failure_nonfatal() {
}
assert!(found_hook_log, "hook log should exist for failed hook");
}
+
+#[tokio::test]
+async fn post_deploy_hook_runs_on_build_failure() {
+ skip_without_git!();
+ skip_without_runtime!();
+
+ let tempdir = tempfile::tempdir().unwrap();
+ let base_dir = tempdir.path().to_path_buf();
+
+ let repo_dir = tempdir.path().join("repos");
+ tokio::fs::create_dir_all(&repo_dir).await.unwrap();
+ let repo_url = create_local_repo(&repo_dir, "main").await;
+
+ // Build command fails (exit 1); hook writes WITRYNA_BUILD_STATUS to a file in clone dir
+ let site = SiteBuilder::new("hook-on-fail", &repo_url, "test-token")
+ .overrides("alpine:latest", "exit 1", "out")
+ .post_deploy(vec![
+ "sh".to_owned(),
+ "-c".to_owned(),
+ "echo \"$WITRYNA_BUILD_STATUS\" > hook-status.txt".to_owned(),
+ ])
+ .build();
+
+ let server = TestServer::start(test_config_with_site(base_dir.clone(), site)).await;
+
+ let resp = TestServer::client()
+ .post(server.url("/hook-on-fail"))
+ .header("Authorization", "Bearer test-token")
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(resp.status().as_u16(), 202);
+
+ // Wait for state.json to show "failed" (no current symlink on build failure)
+ let state_path = base_dir.join("builds/hook-on-fail/state.json");
+ let max_wait = Duration::from_secs(120);
+ let start = std::time::Instant::now();
+
+ loop {
+ assert!(start.elapsed() <= max_wait, "build timed out");
+ if state_path.exists() {
+ let content = tokio::fs::read_to_string(&state_path)
+ .await
+ .unwrap_or_default();
+ if content.contains("\"failed\"") {
+ // Give the hook a moment to finish writing
+ tokio::time::sleep(Duration::from_secs(2)).await;
+ break;
+ }
+ }
+ tokio::time::sleep(Duration::from_millis(500)).await;
+ }
+
+ // Verify hook ran and received build_status=failed
+ let clone_dir = base_dir.join("clones/hook-on-fail");
+ let hook_status_path = clone_dir.join("hook-status.txt");
+ assert!(
+ hook_status_path.exists(),
+ "hook should have created hook-status.txt in clone dir"
+ );
+ let status = tokio::fs::read_to_string(&hook_status_path).await.unwrap();
+ assert_eq!(
+ status.trim(),
+ "failed",
+ "hook should receive build_status=failed"
+ );
+
+ // Verify state.json says "failed" (not "hook failed")
+ let state_content = tokio::fs::read_to_string(&state_path).await.unwrap();
+ assert!(
+ state_content.contains("\"failed\""),
+ "state.json should show failed status"
+ );
+
+ // No current symlink should exist (build failed)
+ assert!(
+ !Path::new(&base_dir.join("builds/hook-on-fail/current")).is_symlink(),
+ "current symlink should not exist on build failure"
+ );
+}
+
+#[tokio::test]
+async fn post_deploy_hook_receives_success_status() {
+ skip_without_git!();
+ skip_without_runtime!();
+
+ let tempdir = tempfile::tempdir().unwrap();
+ let base_dir = tempdir.path().to_path_buf();
+
+ let repo_dir = tempdir.path().join("repos");
+ tokio::fs::create_dir_all(&repo_dir).await.unwrap();
+ let repo_url = create_local_repo(&repo_dir, "main").await;
+
+ // Successful build; hook writes WITRYNA_BUILD_STATUS to build dir
+ let site = SiteBuilder::new("hook-success-status", &repo_url, "test-token")
+ .overrides(
+ "alpine:latest",
+ "mkdir -p out && echo test > out/index.html",
+ "out",
+ )
+ .post_deploy(vec![
+ "sh".to_owned(),
+ "-c".to_owned(),
+ "echo \"$WITRYNA_BUILD_STATUS\" > \"$WITRYNA_BUILD_DIR/build-status.txt\"".to_owned(),
+ ])
+ .build();
+
+ let server = TestServer::start(test_config_with_site(base_dir.clone(), site)).await;
+
+ let resp = TestServer::client()
+ .post(server.url("/hook-success-status"))
+ .header("Authorization", "Bearer test-token")
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(resp.status().as_u16(), 202);
+
+ // Wait for current symlink
+ let builds_dir = base_dir.join("builds/hook-success-status");
+ let max_wait = Duration::from_secs(120);
+ let start = std::time::Instant::now();
+
+ loop {
+ assert!(start.elapsed() <= max_wait, "build timed out");
+ if builds_dir.join("current").is_symlink() {
+ tokio::time::sleep(Duration::from_secs(3)).await;
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(500)).await;
+ }
+
+ // Read build-status.txt from build dir
+ let current_target = tokio::fs::read_link(builds_dir.join("current"))
+ .await
+ .expect("current symlink should exist");
+ let status_path = current_target.join("build-status.txt");
+ assert!(
+ status_path.exists(),
+ "hook should have created build-status.txt"
+ );
+ let status = tokio::fs::read_to_string(&status_path).await.unwrap();
+ assert_eq!(
+ status.trim(),
+ "success",
+ "hook should receive build_status=success"
+ );
+}
diff --git a/tests/integration/main.rs b/tests/integration/main.rs
index 7ee422e..be0d316 100644
--- a/tests/integration/main.rs
+++ b/tests/integration/main.rs
@@ -13,8 +13,11 @@ mod runtime;
mod auth;
mod cache;
mod cleanup;
+mod cli_cleanup;
mod cli_run;
mod cli_status;
+mod cli_switch;
+mod cli_validate;
mod concurrent;
mod deploy;
mod edge_cases;
@@ -26,6 +29,5 @@ mod not_found;
mod overrides;
mod packaging;
mod polling;
-mod rate_limit;
mod secrets;
mod sighup;
diff --git a/tests/integration/polling.rs b/tests/integration/polling.rs
index a4447cc..4bf8a05 100644
--- a/tests/integration/polling.rs
+++ b/tests/integration/polling.rs
@@ -53,7 +53,6 @@ async fn polling_triggers_build_on_new_commits() {
base_dir: base_dir.clone(),
log_dir: base_dir.join("logs"),
log_level: "debug".to_owned(),
- rate_limit_per_minute: 100,
max_builds_to_keep: 5,
git_timeout: None,
sites: vec![site],
diff --git a/tests/integration/rate_limit.rs b/tests/integration/rate_limit.rs
deleted file mode 100644
index 81378a2..0000000
--- a/tests/integration/rate_limit.rs
+++ /dev/null
@@ -1,114 +0,0 @@
-use crate::harness::{SiteBuilder, TestServer, test_config_with_site, test_config_with_sites};
-
-#[tokio::test]
-async fn rate_limit_exceeded_returns_429() {
- let dir = tempfile::tempdir().unwrap().keep();
- let site = SiteBuilder::new("my-site", "https://example.com/repo.git", "secret-token").build();
- let config = test_config_with_site(dir, site);
-
- // Rate limit of 2 per minute
- let server = TestServer::start_with_rate_limit(config, 2).await;
-
- // First request — accepted (or 202)
- let resp1 = TestServer::client()
- .post(server.url("/my-site"))
- .header("Authorization", "Bearer secret-token")
- .send()
- .await
- .unwrap();
- let status1 = resp1.status().as_u16();
- assert!(
- status1 == 202 || status1 == 409,
- "expected 202 or 409, got {status1}"
- );
-
- // Second request
- let resp2 = TestServer::client()
- .post(server.url("/my-site"))
- .header("Authorization", "Bearer secret-token")
- .send()
- .await
- .unwrap();
- let status2 = resp2.status().as_u16();
- assert!(
- status2 == 202 || status2 == 409,
- "expected 202 or 409, got {status2}"
- );
-
- // Third request should hit rate limit
- let resp3 = TestServer::client()
- .post(server.url("/my-site"))
- .header("Authorization", "Bearer secret-token")
- .send()
- .await
- .unwrap();
- assert_eq!(resp3.status().as_u16(), 429);
- let body = resp3.text().await.unwrap();
- let json: serde_json::Value = serde_json::from_str(&body).unwrap();
- assert_eq!(json["error"], "rate_limit_exceeded");
-}
-
-#[tokio::test]
-async fn rate_limit_different_tokens_independent() {
- let dir = tempfile::tempdir().unwrap().keep();
- let sites = vec![
- SiteBuilder::new("site-one", "https://example.com/one.git", "token-one").build(),
- SiteBuilder::new("site-two", "https://example.com/two.git", "token-two").build(),
- ];
- let config = test_config_with_sites(dir, sites);
-
- // Rate limit of 1 per minute
- let server = TestServer::start_with_rate_limit(config, 1).await;
-
- // token-one: first request succeeds
- let resp1 = TestServer::client()
- .post(server.url("/site-one"))
- .header("Authorization", "Bearer token-one")
- .send()
- .await
- .unwrap();
- assert_eq!(resp1.status().as_u16(), 202);
-
- // token-one: second request hits rate limit
- let resp2 = TestServer::client()
- .post(server.url("/site-one"))
- .header("Authorization", "Bearer token-one")
- .send()
- .await
- .unwrap();
- assert_eq!(resp2.status().as_u16(), 429);
-
- // token-two: still has its own budget
- let resp3 = TestServer::client()
- .post(server.url("/site-two"))
- .header("Authorization", "Bearer token-two")
- .send()
- .await
- .unwrap();
- assert_eq!(resp3.status().as_u16(), 202);
-}
-
-#[tokio::test]
-async fn rate_limit_checked_after_auth() {
- let dir = tempfile::tempdir().unwrap().keep();
- let site = SiteBuilder::new("my-site", "https://example.com/repo.git", "secret-token").build();
- let config = test_config_with_site(dir, site);
- let server = TestServer::start_with_rate_limit(config, 1).await;
-
- // Exhaust rate limit
- let _ = TestServer::client()
- .post(server.url("/my-site"))
- .header("Authorization", "Bearer secret-token")
- .send()
- .await
- .unwrap();
-
- // Wrong token should get 401, not 429
- let resp = TestServer::client()
- .post(server.url("/my-site"))
- .header("Authorization", "Bearer wrong-token")
- .send()
- .await
- .unwrap();
- assert_eq!(resp.status().as_u16(), 401);
-}
diff --git a/tests/integration/sighup.rs b/tests/integration/sighup.rs
index 23c0dfd..0474f1d 100644
--- a/tests/integration/sighup.rs
+++ b/tests/integration/sighup.rs
@@ -116,8 +116,20 @@ repo_url = "https://example.com/new.git"
branch = "main"
webhook_token = "new-token"
"#,
- server.state.config.read().await.base_dir.display(),
- server.state.config.read().await.log_dir.display(),
+ server
+ .state
+ .config
+ .read()
+ .expect("config lock poisoned")
+ .base_dir
+ .display(),
+ server
+ .state
+ .config
+ .read()
+ .expect("config lock poisoned")
+ .log_dir
+ .display(),
);
tokio::fs::write(config_path, &new_toml).await.unwrap();
@@ -134,16 +146,20 @@ webhook_token = "new-token"
assert_eq!(resp.status().as_u16(), 200);
// Verify the reloadable field (sites) was updated
- let config = server.state.config.read().await;
- assert_eq!(config.sites.len(), 2, "sites should have been reloaded");
- assert!(
- config.find_site("new-site").is_some(),
- "new-site should exist after reload"
- );
+ let (sites_len, has_new_site, listen_addr) = {
+ let config = server.state.config.read().expect("config lock poisoned");
+ (
+ config.sites.len(),
+ config.find_site("new-site").is_some(),
+ config.listen_address.clone(),
+ )
+ };
+ assert_eq!(sites_len, 2, "sites should have been reloaded");
+ assert!(has_new_site, "new-site should exist after reload");
// Verify non-reloadable field was preserved (not overwritten with "127.0.0.1:19999")
assert_ne!(
- config.listen_address, "127.0.0.1:19999",
+ listen_addr, "127.0.0.1:19999",
"listen_address should be preserved from original config"
);
}