use std::cell::RefCell;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
pub struct LiveStack {
pub dir: PathBuf,
pub work_dir: PathBuf,
pub name: String,
port: u16,
inputs: RefCell<Vec<(String, String)>>,
}
pub struct Outcome {
pub phase: String,
pub status: Option<i32>,
pub stdout: String,
pub stderr: String,
}
impl LiveStack {
pub fn new(name: &str, port: u16) -> Self {
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let dir = manifest_dir.join("tests").join("live_stacks").join(name);
assert!(dir.is_dir(), "live stack not found: {}", dir.display());
let work_dir = manifest_dir.join("target").join("live");
fs::create_dir_all(&work_dir).expect("create target/live");
let _ = fs::remove_file(work_dir.join(".stackql-deploy-exports"));
LiveStack {
dir,
work_dir,
name: name.to_string(),
port,
inputs: RefCell::new(Vec::new()),
}
}
pub fn input(&self, key: &str, value: &str) -> &Self {
let mut inputs = self.inputs.borrow_mut();
inputs.retain(|(k, _)| k != key);
inputs.push((key.to_string(), value.to_string()));
self
}
pub fn run(&self, command: &str, stack_env: &str, extra: &[&str]) -> Outcome {
let phase = format!("{} {} {}", command, stack_env, extra.join(" "))
.trim()
.to_string();
let mut cmd = Command::new(env!("CARGO_BIN_EXE_stackql-deploy"));
cmd.current_dir(&self.work_dir)
.arg("--port")
.arg(self.port.to_string())
.arg(command)
.arg(&self.dir)
.arg(stack_env);
for (k, v) in self.inputs.borrow().iter() {
cmd.arg("-e").arg(format!("{}={}", k, v));
}
cmd.args(extra);
eprintln!(
"
[{}] $ stackql-deploy {}",
self.name, phase
);
let output = cmd.output().expect("spawn stackql-deploy");
let outcome = Outcome {
phase,
status: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
};
outcome.print();
outcome
}
pub fn exports_file(&self) -> PathBuf {
self.work_dir.join(".stackql-deploy-exports")
}
pub fn scratch(&self, file_name: &str) -> PathBuf {
let path = self.work_dir.join(format!("{}-{}", self.name, file_name));
let _ = fs::remove_file(&path);
path
}
}
impl Outcome {
pub fn log(&self) -> String {
format!("{}\n{}", self.stdout, self.stderr)
}
pub fn contains(&self, needle: &str) -> bool {
self.log().contains(needle)
}
pub fn count(&self, needle: &str) -> usize {
self.log().matches(needle).count()
}
pub fn assert_success(&self) -> &Self {
assert_eq!(
self.status,
Some(0),
"expected `{}` to succeed, exit status {:?}",
self.phase,
self.status
);
self
}
pub fn assert_failure(&self) -> &Self {
assert_ne!(
self.status,
Some(0),
"expected `{}` to fail, but it exited 0",
self.phase
);
self
}
pub fn assert_contains(&self, needle: &str) -> &Self {
assert!(
self.contains(needle),
"expected `{}` output to contain {:?}",
self.phase,
needle
);
self
}
pub fn assert_not_contains(&self, needle: &str) -> &Self {
assert!(
!self.contains(needle),
"expected `{}` output NOT to contain {:?}",
self.phase,
needle
);
self
}
fn print(&self) {
let on_actions = std::env::var_os("GITHUB_ACTIONS").is_some();
if on_actions {
eprintln!("::group::{} (exit {:?})", self.phase, self.status);
}
eprintln!("{}", self.log());
if on_actions {
eprintln!("::endgroup::");
}
eprintln!("[{}] exit status: {:?}", self.phase, self.status);
}
}
pub struct TeardownGuard<'a> {
stack: &'a LiveStack,
stack_env: String,
armed: bool,
}
impl<'a> TeardownGuard<'a> {
pub fn new(stack: &'a LiveStack, stack_env: &str) -> Self {
TeardownGuard {
stack,
stack_env: stack_env.to_string(),
armed: true,
}
}
pub fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for TeardownGuard<'_> {
fn drop(&mut self) {
if self.armed {
eprintln!(
"[{}] test did not reach its own teardown, cleaning up",
self.stack.name
);
let _ = self
.stack
.run("teardown", &self.stack_env, &["--on-failure", "ignore"]);
}
}
}
pub fn read_exports_json(path: &Path) -> serde_json::Map<String, serde_json::Value> {
let text = fs::read_to_string(path)
.unwrap_or_else(|e| panic!("read exports file {}: {}", path.display(), e));
serde_json::from_str::<serde_json::Value>(&text)
.expect("exports file is valid JSON")
.as_object()
.expect("exports file is a JSON object")
.clone()
}
pub fn export_str(map: &serde_json::Map<String, serde_json::Value>, key: &str) -> String {
match map.get(key) {
Some(serde_json::Value::String(s)) => s.clone(),
Some(other) => other.to_string(),
None => panic!(
"export {:?} missing from {:?}",
key,
map.keys().collect::<Vec<_>>()
),
}
}
pub fn env_or(key: &str, default: &str) -> String {
std::env::var(key).unwrap_or_else(|_| default.to_string())
}
pub fn require_env(key: &str, purpose: &str) {
if std::env::var_os(key).is_none() {
panic!(
"environment variable {} is required for {}; see tests/README.md",
key, purpose
);
}
}
pub fn run_id() -> String {
std::env::var("STACKQL_DEPLOY_LIVE_RUN_ID").unwrap_or_else(|_| {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("local-{}", secs)
})
}