From fd7bc076938a161eed951c4e15ee750265256dec Mon Sep 17 00:00:00 2001 From: Jeffery Lofoneh Asamani Date: Sat, 29 Aug 2026 08:13:44 +0000 Subject: [PATCH] test: run integration suite on Windows --- .github/workflows/ci.yml | 2 +- Cargo.toml | 10 ++++ tests/bin/fake_pgbot.rs | 109 ++++++++++++++++++++++++++++++++++++ tests/cli_add.rs | 1 - tests/common/mod.rs | 73 +++--------------------- tests/monitor.rs | 1 - tests/runner_integration.rs | 1 - 7 files changed, 128 insertions(+), 69 deletions(-) create mode 100644 tests/bin/fake_pgbot.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5cc7b7..193eb59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 diff --git a/Cargo.toml b/Cargo.toml index 77f910c..d042a7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,16 @@ description = "htop for all your Postgres databases — a multi-database termina license = "Apache-2.0" homepage = "https://pgterm.dev" +# fake-pgbot is a test fixture; declaring it as a bin gives the integration +# tests CARGO_BIN_EXE_fake-pgbot. The release workflow packages only pgterm. +[[bin]] +name = "pgterm" +path = "src/main.rs" + +[[bin]] +name = "fake-pgbot" +path = "tests/bin/fake_pgbot.rs" + [dependencies] anyhow = "1" crossterm = "0.29" diff --git a/tests/bin/fake_pgbot.rs b/tests/bin/fake_pgbot.rs new file mode 100644 index 0000000..6cdbe7e --- /dev/null +++ b/tests/bin/fake_pgbot.rs @@ -0,0 +1,109 @@ +//! Deterministic stand-in for the pgbot CLI. Behavior keys off the DSN in +//! $DATABASE_URL; scratch state lives beside the executable, which +//! `write_fake_pgbot` copies into each test's temp directory. + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures"); + +fn scratch_dir() -> PathBuf { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| PathBuf::from(".")) +} + +fn append_line(path: &Path, line: &str) { + if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) { + // One write_all, newline included: concurrent fakes append to these + // files, and a split write interleaves into "22\n\n". + let _ = f.write_all(format!("{line}\n").as_bytes()); + } +} + +fn emit(dir: &Path, fixture: &str, code: i32) -> ! { + match std::fs::read_to_string(Path::new(FIXTURES).join(fixture)) { + Ok(body) => print!("{body}"), + Err(e) => { + eprintln!("fake-pgbot: cannot read fixture {fixture}: {e}"); + finish(dir, 64); + } + } + let _ = std::io::stdout().flush(); + finish(dir, code) +} + +fn finish(dir: &Path, code: i32) -> ! { + let _ = std::fs::remove_file(dir.join(format!("running.{}", std::process::id()))); + std::process::exit(code) +} + +fn delay() { + let secs = std::env::var("FAKE_PGBOT_DELAY") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(0.0); + if secs > 0.0 { + std::thread::sleep(Duration::from_secs_f64(secs)); + } +} + +fn live_markers(dir: &Path) -> usize { + let Ok(entries) = std::fs::read_dir(dir) else { + return 0; + }; + entries + .filter_map(Result::ok) + .filter(|e| e.file_name().to_string_lossy().starts_with("running.")) + .count() +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + + if matches!(args.first().map(String::as_str), Some("--version" | "-v")) { + println!("pgbot version 0.9.9"); + return; + } + + let dir = scratch_dir(); + let dsn = std::env::var("DATABASE_URL").unwrap_or_default(); + + append_line( + &dir.join("invocations.log"), + &format!("{} url={dsn}", args.join(" ")), + ); + + let _ = std::fs::write(dir.join(format!("running.{}", std::process::id())), b""); + append_line(&dir.join("peaks.log"), &live_markers(&dir).to_string()); + + match args.first().map(String::as_str) { + Some("indexes") => emit(&dir, "indexes_report.json", 0), + Some("why") => emit(&dir, "why_report.json", 0), + _ => {} + } + + if dsn.contains("mode-healthy") { + delay(); + emit(&dir, "context_healthy.json", 0); + } else if dsn.contains("mode-warn") { + delay(); + emit(&dir, "context_warn.json", 1); + } else if dsn.contains("mode-critical") { + emit(&dir, "context_critical.json", 2); + } else if dsn.contains("mode-refuse") { + eprintln!( + "pgbot: connect postgres://alex:sekret-pw@db.internal:5432/app: connection refused" + ); + finish(&dir, 3); + } else if dsn.contains("mode-hang") { + std::thread::sleep(Duration::from_secs(60)); + finish(&dir, 0); + } else { + eprintln!("pgbot: no connection string (pass one or set $DATABASE_URL)"); + finish(&dir, 3); + } +} diff --git a/tests/cli_add.rs b/tests/cli_add.rs index 66116b0..552d9c5 100644 --- a/tests/cli_add.rs +++ b/tests/cli_add.rs @@ -2,7 +2,6 @@ //! real binary with a fake pgbot as PGBOT_BIN. Each test gets its own config //! file and a scrubbed child environment — no process-env races, no real //! PostgreSQL. -#![cfg(unix)] mod common; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index a0f2d8f..9058ed2 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,16 +1,10 @@ -//! Shared test scaffolding: a deterministic fake pgbot binary (a POSIX shell -//! script — this is a test fixture, not product code; the product itself never -//! touches a shell) plus an env-mutation lock, since Rust tests share one -//! process and `std::env::set_var` is not thread-safe. +//! Shared test scaffolding: the fake pgbot binary plus an env-mutation lock, +//! since Rust tests share one process and `set_var` is not thread-safe. #![allow(dead_code)] use std::path::{Path, PathBuf}; use std::sync::{Mutex, MutexGuard, OnceLock}; -pub fn fixtures_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") -} - /// Serializes tests that mutate process env. Hold the guard for the whole test. pub fn env_lock() -> MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); @@ -44,64 +38,13 @@ impl Drop for TempDir { } } -/// Writes the fake pgbot. Behavior is selected by the DSN it receives via -/// $DATABASE_URL (`postgres://mode-warn@x/db` → warn), mirroring how the real -/// pgbot reads its connection from the child environment: -/// healthy → context_healthy.json, exit 0 -/// warn → context_warn.json, exit 1 -/// critical → context_critical.json, exit 2 -/// refuse → connection-refused stderr (with a DSN in it), exit 3 -/// hang → sleep 60 -/// `indexes`/`why` subcommands emit their own reports. Every invocation -/// appends a line to invocations.log; while running, a `running.` marker -/// exists so tests can measure peak concurrency. +/// Copies the built `fake-pgbot` binary into `dir`; it keeps its scratch state +/// (invocations.log, running. markers, peaks.log) next to itself. pub fn write_fake_pgbot(dir: &Path) -> PathBuf { - let fixtures = fixtures_dir(); - let bin = dir.join("fake-pgbot"); - let log = dir.join("invocations.log"); - let script = format!( - r#"#!/bin/sh -FIX="{fixtures}" -DIR="{dir}" -case "$1" in - --version|-v) echo "pgbot version 0.9.9"; exit 0;; -esac -echo "$* url=$DATABASE_URL" >> "{log}" -touch "$DIR/running.$$" -finish() {{ rm -f "$DIR/running.$$"; exit "$1"; }} -n=$(ls "$DIR" | grep -c '^running\.') -[ "$n" -gt "${{PEAK:-0}}" ] && echo "$n" >> "$DIR/peaks.log" -mode=other -case "$DATABASE_URL" in - *mode-healthy*) mode=healthy;; - *mode-warn*) mode=warn;; - *mode-critical*) mode=critical;; - *mode-refuse*) mode=refuse;; - *mode-hang*) mode=hang;; -esac -case "$1" in - indexes) cat "$FIX/indexes_report.json"; finish 0;; - why) cat "$FIX/why_report.json"; finish 0;; -esac -case "$mode" in - healthy) sleep "${{FAKE_PGBOT_DELAY:-0}}"; cat "$FIX/context_healthy.json"; finish 0;; - warn) sleep "${{FAKE_PGBOT_DELAY:-0}}"; cat "$FIX/context_warn.json"; finish 1;; - critical) cat "$FIX/context_critical.json"; finish 2;; - refuse) echo "pgbot: connect postgres://alex:sekret-pw@db.internal:5432/app: connection refused" >&2; finish 3;; - hang) sleep 60; finish 0;; - *) echo "pgbot: no connection string (pass one or set \$DATABASE_URL)" >&2; finish 3;; -esac -"#, - fixtures = fixtures.display(), - dir = dir.display(), - log = log.display(), - ); - std::fs::write(&bin, script).expect("write fake pgbot"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); - } + let built = PathBuf::from(env!("CARGO_BIN_EXE_fake-pgbot")); + let bin = dir.join(format!("fake-pgbot{}", std::env::consts::EXE_SUFFIX)); + std::fs::copy(&built, &bin) + .unwrap_or_else(|e| panic!("copying {} -> {}: {e}", built.display(), bin.display())); bin } diff --git a/tests/monitor.rs b/tests/monitor.rs index 36495f1..9dc73a2 100644 --- a/tests/monitor.rs +++ b/tests/monitor.rs @@ -1,7 +1,6 @@ //! End-to-end monitoring: App::update drives real pgbot subprocess runs //! (the deterministic fake) under the bounded-concurrency semaphore, and the //! per-database states stay independent. -#![cfg(unix)] // The env-mutation lock intentionally spans awaits: the pgbot child reads // the vars we set, so they must stay stable for the whole run. #![allow(clippy::await_holding_lock)] diff --git a/tests/runner_integration.rs b/tests/runner_integration.rs index b9d8618..917a6aa 100644 --- a/tests/runner_integration.rs +++ b/tests/runner_integration.rs @@ -1,5 +1,4 @@ //! Runner behavior against a deterministic fake pgbot — no real PostgreSQL. -#![cfg(unix)] // The env-mutation lock intentionally spans awaits: the pgbot child reads // the vars we set, so they must stay stable for the whole run. #![allow(clippy::await_holding_lock)]