summaryrefslogtreecommitdiff
path: root/tests/integration/harness.rs
diff options
context:
space:
mode:
authorDawid Rycerz <dawid@rycerz.xyz>2026-01-22 22:07:32 +0100
committerDawid Rycerz <dawid@rycerz.xyz>2026-02-10 18:44:26 +0100
commit064a1d01c5c14f5ecc032fa9b8346a4a88b893f6 (patch)
treea2023f9ccd297ed8a41a3a0cc5699c2add09244d /tests/integration/harness.rs
witryna 0.1.0 — initial releasev0.1.0
Minimalist Git-based static site deployment orchestrator. Webhook-triggered builds in Podman/Docker containers with atomic symlink publishing, SIGHUP hot-reload, and zero-downtime deploys. See README.md for usage, CHANGELOG.md for details.
Diffstat (limited to 'tests/integration/harness.rs')
-rw-r--r--tests/integration/harness.rs356
1 files changed, 356 insertions, 0 deletions
diff --git a/tests/integration/harness.rs b/tests/integration/harness.rs
new file mode 100644
index 0000000..c015fa8
--- /dev/null
+++ b/tests/integration/harness.rs
@@ -0,0 +1,356 @@
+use governor::{Quota, RateLimiter};
+use std::collections::HashMap;
+use std::num::NonZeroU32;
+use std::path::PathBuf;
+use std::sync::Arc;
+use tempfile::TempDir;
+use tokio::net::TcpListener;
+use tokio::sync::{RwLock, oneshot};
+use witryna::build_guard::BuildScheduler;
+use witryna::config::{BuildOverrides, Config, SiteConfig};
+use witryna::polling::PollingManager;
+use witryna::server::AppState;
+
+/// A running test server with its own temp directory and shutdown handle.
+pub struct TestServer {
+ pub base_url: String,
+ pub state: AppState,
+ /// Kept alive for RAII cleanup of the config file written during startup.
+ #[allow(dead_code)]
+ pub tempdir: TempDir,
+ shutdown_tx: Option<oneshot::Sender<()>>,
+}
+
+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 {
+ let tempdir = TempDir::new().expect("failed to create temp dir");
+ let config_path = tempdir.path().join("witryna.toml");
+
+ // Write a minimal config file so SIGHUP reload has something to read
+ let config_toml = build_config_toml(&config);
+ tokio::fs::write(&config_path, &config_toml)
+ .await
+ .expect("failed to write test config");
+
+ config
+ .resolve_secrets()
+ .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 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");
+ });
+
+ Self {
+ base_url,
+ state,
+ tempdir,
+ shutdown_tx: Some(shutdown_tx),
+ }
+ }
+
+ /// Get an async reqwest client.
+ pub fn client() -> reqwest::Client {
+ reqwest::Client::new()
+ }
+
+ /// Build a URL for the given path.
+ pub fn url(&self, path: &str) -> String {
+ format!("{}/{}", self.base_url, path.trim_start_matches('/'))
+ }
+
+ /// Shut down the server gracefully.
+ pub fn shutdown(&mut self) {
+ if let Some(tx) = self.shutdown_tx.take() {
+ let _ = tx.send(());
+ }
+ }
+}
+
+impl Drop for TestServer {
+ fn drop(&mut self) {
+ self.shutdown();
+ }
+}
+
+/// Build a default test config pointing to the given base dir.
+pub fn test_config(base_dir: PathBuf) -> Config {
+ let log_dir = base_dir.join("logs");
+ Config {
+ listen_address: "127.0.0.1:0".to_owned(),
+ container_runtime: "podman".to_owned(),
+ base_dir,
+ log_dir,
+ log_level: "debug".to_owned(),
+ rate_limit_per_minute: 10,
+ max_builds_to_keep: 5,
+ git_timeout: None,
+ sites: vec![],
+ }
+}
+
+/// Build a test config with a single site.
+pub fn test_config_with_site(base_dir: PathBuf, site: SiteConfig) -> Config {
+ let log_dir = base_dir.join("logs");
+ Config {
+ listen_address: "127.0.0.1:0".to_owned(),
+ container_runtime: detect_container_runtime(),
+ 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],
+ }
+}
+
+/// Build a test config with multiple sites.
+pub fn test_config_with_sites(base_dir: PathBuf, sites: Vec<SiteConfig>) -> Config {
+ let log_dir = base_dir.join("logs");
+ Config {
+ listen_address: "127.0.0.1:0".to_owned(),
+ container_runtime: detect_container_runtime(),
+ base_dir,
+ log_dir,
+ log_level: "debug".to_owned(),
+ rate_limit_per_minute: 10,
+ max_builds_to_keep: 5,
+ git_timeout: None,
+ sites,
+ }
+}
+
+/// Builder for test `SiteConfig` instances.
+///
+/// Replaces `simple_site`, `site_with_overrides`, `site_with_hook`, and
+/// `site_with_cache` with a single fluent API.
+pub struct SiteBuilder {
+ name: String,
+ repo_url: String,
+ token: String,
+ webhook_token_file: Option<PathBuf>,
+ image: Option<String>,
+ command: Option<String>,
+ public: Option<String>,
+ cache_dirs: Option<Vec<String>>,
+ post_deploy: Option<Vec<String>>,
+ env: Option<HashMap<String, String>>,
+ container_workdir: Option<String>,
+}
+
+impl SiteBuilder {
+ pub fn new(name: &str, repo_url: &str, token: &str) -> Self {
+ Self {
+ name: name.to_owned(),
+ repo_url: repo_url.to_owned(),
+ token: token.to_owned(),
+ webhook_token_file: None,
+ image: None,
+ command: None,
+ public: None,
+ cache_dirs: None,
+ post_deploy: None,
+ env: None,
+ container_workdir: None,
+ }
+ }
+
+ /// Set complete build overrides (image, command, public dir).
+ pub fn overrides(mut self, image: &str, command: &str, public: &str) -> Self {
+ self.image = Some(image.to_owned());
+ self.command = Some(command.to_owned());
+ self.public = Some(public.to_owned());
+ self
+ }
+
+ pub fn webhook_token_file(mut self, path: PathBuf) -> Self {
+ self.webhook_token_file = Some(path);
+ self
+ }
+
+ pub fn post_deploy(mut self, hook: Vec<String>) -> Self {
+ self.post_deploy = Some(hook);
+ self
+ }
+
+ pub fn env(mut self, env_vars: HashMap<String, String>) -> Self {
+ self.env = Some(env_vars);
+ self
+ }
+
+ pub fn cache_dirs(mut self, dirs: Vec<String>) -> Self {
+ self.cache_dirs = Some(dirs);
+ self
+ }
+
+ #[allow(dead_code)]
+ pub fn container_workdir(mut self, path: &str) -> Self {
+ self.container_workdir = Some(path.to_owned());
+ self
+ }
+
+ pub fn build(self) -> SiteConfig {
+ SiteConfig {
+ name: self.name,
+ repo_url: self.repo_url,
+ branch: "main".to_owned(),
+ webhook_token: self.token,
+ webhook_token_file: self.webhook_token_file,
+ build_overrides: BuildOverrides {
+ image: self.image,
+ command: self.command,
+ public: self.public,
+ },
+ poll_interval: None,
+ build_timeout: None,
+ cache_dirs: self.cache_dirs,
+ post_deploy: self.post_deploy,
+ env: self.env,
+ container_memory: None,
+ container_cpus: None,
+ container_pids_limit: None,
+ container_network: "none".to_owned(),
+ git_depth: None,
+ container_workdir: self.container_workdir,
+ config_file: None,
+ }
+ }
+}
+
+/// Start a server with a single pre-configured site for simple tests.
+///
+/// Uses `my-site` with token `secret-token` — suitable for auth, 404, and basic endpoint tests.
+pub async fn server_with_site() -> TestServer {
+ let dir = tempfile::tempdir().unwrap().keep();
+ let site = SiteBuilder::new("my-site", "https://example.com/repo.git", "secret-token").build();
+ TestServer::start(test_config_with_site(dir, site)).await
+}
+
+/// Detect the first available container runtime.
+fn detect_container_runtime() -> String {
+ for runtime in &["podman", "docker"] {
+ if std::process::Command::new(runtime)
+ .args(["info"])
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .status()
+ .map(|s| s.success())
+ .unwrap_or(false)
+ {
+ return (*runtime).to_owned();
+ }
+ }
+ // Fallback — tests that need a runtime will skip themselves
+ "podman".to_owned()
+}
+
+/// Serialize a Config into a minimal TOML string for writing to disk.
+fn build_config_toml(config: &Config) -> String {
+ use std::fmt::Write as _;
+
+ let runtime_line = format!("container_runtime = \"{}\"\n", config.container_runtime);
+
+ let mut toml = format!(
+ r#"listen_address = "{}"
+{}base_dir = "{}"
+log_dir = "{}"
+log_level = "{}"
+rate_limit_per_minute = {}
+max_builds_to_keep = {}
+"#,
+ config.listen_address,
+ runtime_line,
+ config.base_dir.display(),
+ config.log_dir.display(),
+ config.log_level,
+ config.rate_limit_per_minute,
+ config.max_builds_to_keep,
+ );
+
+ if let Some(timeout) = config.git_timeout {
+ let _ = writeln!(toml, "git_timeout = \"{}s\"", timeout.as_secs());
+ }
+
+ for site in &config.sites {
+ let _ = writeln!(toml, "\n[[sites]]");
+ let _ = writeln!(toml, "name = \"{}\"", site.name);
+ let _ = writeln!(toml, "repo_url = \"{}\"", site.repo_url);
+ let _ = writeln!(toml, "branch = \"{}\"", site.branch);
+ if !site.webhook_token.is_empty() {
+ let _ = writeln!(toml, "webhook_token = \"{}\"", site.webhook_token);
+ }
+ if let Some(path) = &site.webhook_token_file {
+ let _ = writeln!(toml, "webhook_token_file = \"{}\"", path.display());
+ }
+
+ if let Some(image) = &site.build_overrides.image {
+ let _ = writeln!(toml, "image = \"{image}\"");
+ }
+ if let Some(command) = &site.build_overrides.command {
+ let _ = writeln!(toml, "command = \"{command}\"");
+ }
+ if let Some(public) = &site.build_overrides.public {
+ let _ = writeln!(toml, "public = \"{public}\"");
+ }
+ if let Some(interval) = site.poll_interval {
+ let _ = writeln!(toml, "poll_interval = \"{}s\"", interval.as_secs());
+ }
+ if let Some(timeout) = site.build_timeout {
+ let _ = writeln!(toml, "build_timeout = \"{}s\"", timeout.as_secs());
+ }
+ if let Some(depth) = site.git_depth {
+ let _ = writeln!(toml, "git_depth = {depth}");
+ }
+ if let Some(workdir) = &site.container_workdir {
+ let _ = writeln!(toml, "container_workdir = \"{workdir}\"");
+ }
+ if let Some(dirs) = &site.cache_dirs {
+ let quoted: Vec<_> = dirs.iter().map(|d| format!("\"{d}\"")).collect();
+ let _ = writeln!(toml, "cache_dirs = [{}]", quoted.join(", "));
+ }
+ if let Some(hook) = &site.post_deploy {
+ let quoted: Vec<_> = hook.iter().map(|a| format!("\"{a}\"")).collect();
+ let _ = writeln!(toml, "post_deploy = [{}]", quoted.join(", "));
+ }
+ if let Some(env_vars) = &site.env {
+ let _ = writeln!(toml, "\n[sites.env]");
+ for (key, value) in env_vars {
+ let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
+ let _ = writeln!(toml, "{key} = \"{escaped}\"");
+ }
+ }
+ }
+
+ toml
+}