Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
109 changes: 109 additions & 0 deletions tests/bin/fake_pgbot.rs
Original file line number Diff line number Diff line change
@@ -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::<f64>().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<String> = 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);
}
}
1 change: 0 additions & 1 deletion tests/cli_add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
73 changes: 8 additions & 65 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -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<Mutex<()>> = OnceLock::new();
Expand Down Expand Up @@ -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.<pid>` marker
/// exists so tests can measure peak concurrency.
/// Copies the built `fake-pgbot` binary into `dir`; it keeps its scratch state
/// (invocations.log, running.<pid> 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
}

Expand Down
1 change: 0 additions & 1 deletion tests/monitor.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down
1 change: 0 additions & 1 deletion tests/runner_integration.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down