--force overrides the kill refusal and nothing else (#81). The guarded worktree is recorded in the retained store with reason force-killed — never removed — and the outcome names what was kept so a client can say where the work went. A tree whose guard is clear never needed the override; force removes it as usual. The store write happens before teardown, so a failed retention leaves the session running and every artifact intact. DELETE /api/sessions/:id takes ?force=true and answers 200 with KillOutcome; the unforced kill keeps its 204.
220 lines
6.7 KiB
Rust
220 lines
6.7 KiB
Rust
//! Shared helpers for the maestro-daemon integration tests.
|
|
//!
|
|
//! Each integration test file compiles as its own binary and only uses a
|
|
//! subset of these helpers, so `dead_code` warnings are spurious here.
|
|
#![allow(dead_code)]
|
|
|
|
use std::future::Future;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::sync::Arc;
|
|
use std::thread;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use maestro_daemon::sidecar::SidecarStore;
|
|
use maestro_daemon::spawn_history::SpawnHistory;
|
|
use maestro_daemon::tmux::Tmux;
|
|
use maestro_daemon::worktree::WorktreeManager;
|
|
use maestro_daemon::SessionManager;
|
|
use tempfile::TempDir;
|
|
use uuid::Uuid;
|
|
|
|
/// An isolated tmux instance scoped to a temp `TMUX_TMPDIR` and unique socket
|
|
/// name. Drops kill the server so leftover sockets don't accumulate.
|
|
pub struct TmuxFixture {
|
|
pub tmux: Tmux,
|
|
_home: TempDir,
|
|
_tmpdir: TempDir,
|
|
}
|
|
|
|
impl TmuxFixture {
|
|
pub fn new() -> Self {
|
|
let home = TempDir::new().expect("tempdir HOME");
|
|
let tmpdir = TempDir::new().expect("tempdir TMUX_TMPDIR");
|
|
let socket = format!("maestro-test-{}", Uuid::new_v4().simple());
|
|
let tmux = Tmux::new()
|
|
.with_socket(socket)
|
|
.with_tmpdir(tmpdir.path().to_path_buf());
|
|
Self {
|
|
tmux,
|
|
_home: home,
|
|
_tmpdir: tmpdir,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for TmuxFixture {
|
|
fn drop(&mut self) {
|
|
let _ = self.tmux.kill_server();
|
|
}
|
|
}
|
|
|
|
/// Wait until `cond` returns `true`, polling every 25ms. Panics on timeout.
|
|
pub fn wait_for(timeout: Duration, mut cond: impl FnMut() -> bool) {
|
|
let deadline = Instant::now() + timeout;
|
|
while Instant::now() < deadline {
|
|
if cond() {
|
|
return;
|
|
}
|
|
thread::sleep(Duration::from_millis(25));
|
|
}
|
|
panic!("timed out after {timeout:?} waiting for condition");
|
|
}
|
|
|
|
/// `git init` a fresh repo in `dir` with one commit so it can host worktrees.
|
|
pub fn init_repo(dir: &Path) {
|
|
run(Command::new("git")
|
|
.arg("-C")
|
|
.arg(dir)
|
|
.args(["init", "-q", "-b", "main"]));
|
|
run(Command::new("git")
|
|
.arg("-C")
|
|
.arg(dir)
|
|
.args(["config", "user.email", "test@example.com"]));
|
|
run(Command::new("git")
|
|
.arg("-C")
|
|
.arg(dir)
|
|
.args(["config", "user.name", "Test"]));
|
|
std::fs::write(dir.join("README"), b"hello\n").unwrap();
|
|
run(Command::new("git").arg("-C").arg(dir).args(["add", "."]));
|
|
run(Command::new("git")
|
|
.arg("-C")
|
|
.arg(dir)
|
|
.args(["commit", "-q", "-m", "initial"]));
|
|
}
|
|
|
|
fn run(cmd: &mut Command) {
|
|
let out = cmd.output().expect("git spawn");
|
|
assert!(
|
|
out.status.success(),
|
|
"git command failed: {} stderr={}",
|
|
out.status,
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
}
|
|
|
|
/// An empty bare repository at `dir`, usable as a push target (#79).
|
|
pub fn init_bare_repo(dir: &Path) {
|
|
run(Command::new("git")
|
|
.arg("init")
|
|
.arg("-q")
|
|
.arg("--bare")
|
|
.arg(dir.to_str().expect("non-utf8 bare repo path")));
|
|
}
|
|
|
|
/// Push every branch of `repo` to `origin`, creating remote-tracking refs.
|
|
///
|
|
/// The unsaved-work probe (#79) counts commits reachable from no
|
|
/// remote-tracking ref as unpushed, so tests that want a killable worktree
|
|
/// must give its branch one. Safe to call again: re-adding an existing
|
|
/// remote is ignored.
|
|
pub fn push_all_to_origin(repo: &Path, origin: &Path) {
|
|
let _ = Command::new("git")
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args([
|
|
"remote",
|
|
"add",
|
|
"origin",
|
|
origin.to_str().expect("non-utf8 origin path"),
|
|
])
|
|
.status();
|
|
run(Command::new("git")
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(["push", "-q", "--all", "origin"]));
|
|
}
|
|
|
|
/// Async polling sibling of [`wait_for`] for tokio tests.
|
|
pub async fn wait_for_async<F, Fut>(timeout: Duration, mut cond: F)
|
|
where
|
|
F: FnMut() -> Fut,
|
|
Fut: Future<Output = bool>,
|
|
{
|
|
let deadline = Instant::now() + timeout;
|
|
while Instant::now() < deadline {
|
|
if cond().await {
|
|
return;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(25)).await;
|
|
}
|
|
panic!("timed out after {timeout:?} waiting for condition");
|
|
}
|
|
|
|
/// Bundle of dirs + a SessionManager hooked to an isolated tmux instance.
|
|
/// Holds the `TempDir`s so they survive the test lifetime.
|
|
pub struct DaemonFixture {
|
|
pub tmux: TmuxFixture,
|
|
pub manager: SessionManager,
|
|
pub state_dir: TempDir,
|
|
}
|
|
|
|
impl DaemonFixture {
|
|
pub fn new() -> Self {
|
|
Self::build(None)
|
|
}
|
|
|
|
/// Variant whose manager has a durable spawn history (#24) attached, with
|
|
/// `retention_days` recency cap, logging to `<state_dir>/spawn-history.jsonl`.
|
|
pub fn new_with_history(retention_days: u32) -> Self {
|
|
Self::build(Some(retention_days))
|
|
}
|
|
|
|
fn build(history_days: Option<u32>) -> Self {
|
|
let tmux = TmuxFixture::new();
|
|
let state_dir = TempDir::new().unwrap();
|
|
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
|
|
// Default placement: worktrees land under their base repo (#56).
|
|
let worktree_mgr = WorktreeManager::new(None);
|
|
let mut manager = SessionManager::new(
|
|
Arc::new(tmux.tmux.clone()),
|
|
sidecar,
|
|
worktree_mgr,
|
|
state_dir.path().join("fifos"),
|
|
);
|
|
if let Some(days) = history_days {
|
|
manager =
|
|
manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days));
|
|
}
|
|
// Same wiring as production: the manager records retained worktrees
|
|
// into the same dir the API reads back from (#78/#81).
|
|
let retained =
|
|
maestro_daemon::retained_store::RetainedStore::new(Self::retained_path(&state_dir));
|
|
manager = manager.with_retained_store(retained.clone());
|
|
Self {
|
|
tmux,
|
|
manager,
|
|
state_dir,
|
|
}
|
|
}
|
|
|
|
fn history_path(state_dir: &TempDir) -> PathBuf {
|
|
state_dir.path().join("spawn-history.jsonl")
|
|
}
|
|
|
|
fn retained_path(state_dir: &TempDir) -> PathBuf {
|
|
state_dir.path().join("retained-worktrees")
|
|
}
|
|
|
|
pub fn spawn_history_path(&self) -> PathBuf {
|
|
Self::history_path(&self.state_dir)
|
|
}
|
|
|
|
pub fn sidecar_dir(&self) -> PathBuf {
|
|
self.state_dir.path().join("sessions")
|
|
}
|
|
|
|
/// Retained-worktree store rooted at the fixture's own state dir (#78).
|
|
pub fn retained_store(&self) -> maestro_daemon::retained_store::RetainedStore {
|
|
maestro_daemon::retained_store::RetainedStore::new(self.retained_dir())
|
|
}
|
|
|
|
pub fn retained_dir(&self) -> PathBuf {
|
|
self.state_dir.path().join("retained-worktrees")
|
|
}
|
|
|
|
pub fn fifo_dir(&self) -> PathBuf {
|
|
self.state_dir.path().join("fifos")
|
|
}
|
|
}
|