use std::io::{BufRead, BufReader, Read};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{Receiver, channel};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
struct WatchProcess {
child: Child,
lines: Receiver<String>,
seen: Vec<String>,
errors: Arc<Mutex<String>>,
}
impl WatchProcess {
fn spawn(dir: &Path, args: &[&str]) -> Self {
let mut child = Command::new(assert_cmd::cargo::cargo_bin("alba"))
.current_dir(dir)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let stdout = child.stdout.take().unwrap();
let (sender, lines) = channel();
std::thread::spawn(move || {
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
if sender.send(line).is_err() {
break;
}
}
});
let stderr = child.stderr.take().unwrap();
let errors = Arc::new(Mutex::new(String::new()));
std::thread::spawn({
let errors = Arc::clone(&errors);
move || {
let mut reader = BufReader::new(stderr);
let mut chunk = [0u8; 1024];
while let Ok(read) = reader.read(&mut chunk) {
if read == 0 {
break;
}
errors
.lock()
.unwrap()
.push_str(&String::from_utf8_lossy(&chunk[..read]));
}
}
});
Self {
child,
lines,
seen: Vec::new(),
errors,
}
}
fn wait_for(&mut self, needle: &str) -> String {
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
match self.lines.recv_timeout(remaining) {
Ok(line) => {
self.seen.push(line.clone());
if line.contains(needle) {
return line;
}
}
Err(_) => panic!("never saw {needle:?}{}", self.context()),
}
}
}
#[cfg(unix)]
fn wait_for_exit(&mut self) -> std::process::ExitStatus {
let deadline = Instant::now() + Duration::from_secs(60);
while Instant::now() < deadline {
if let Some(status) = self.child.try_wait().unwrap() {
return status;
}
std::thread::sleep(Duration::from_millis(20));
}
panic!("the session never exited{}", self.context())
}
fn context(&self) -> String {
format!(
"\nstdout so far:\n{}\nstderr so far:\n{}",
self.seen.join("\n"),
self.errors.lock().unwrap()
)
}
}
impl Drop for WatchProcess {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn watch_project() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/input.txt"), "one").unwrap();
std::fs::write(
dir.path().join("Beamfile"),
"default hello\n\
beam hello { inputs [\"src/**\"] run \"echo greeting-ran\" }\n",
)
.unwrap();
dir
}
fn git(dir: &Path, args: &[&str]) {
let output = Command::new("git")
.current_dir(dir)
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn git_watch_project() -> tempfile::TempDir {
let dir = watch_project();
std::fs::write(
dir.path().join("Beamfile"),
"default hello\n\
beam hello { inputs [\"src/**\"] run \"echo greeting-ran\" }\n\
beam other { inputs [\"docs/**\"] run \"echo other-ran\" }\n",
)
.unwrap();
std::fs::create_dir(dir.path().join("docs")).unwrap();
std::fs::write(dir.path().join("docs/a"), "one").unwrap();
git(dir.path(), &["init", "-q", "-b", "main"]);
git(dir.path(), &["config", "user.email", "alba@example.com"]);
git(dir.path(), &["config", "user.name", "Alba"]);
git(dir.path(), &["config", "commit.gpgsign", "false"]);
git(dir.path(), &["add", "-A"]);
git(dir.path(), &["commit", "-q", "-m", "init"]);
dir
}
#[test]
fn a_file_change_triggers_a_second_run() {
let dir = watch_project();
let mut alba = WatchProcess::spawn(
dir.path(),
&["run", "--watch", "hello", "--log-format", "json"],
);
alba.wait_for(r#""event":"run_finished""#);
alba.wait_for(r#""event":"watch_waiting""#);
std::fs::write(dir.path().join("src/input.txt"), "two").unwrap();
let triggered = alba.wait_for(r#""event":"watch_triggered""#);
assert!(triggered.contains(r#""src/input.txt""#), "got: {triggered}");
alba.wait_for(r#""event":"run_finished""#);
alba.wait_for(r#""event":"watch_waiting""#);
}
#[test]
fn watch_runs_the_default_beam() {
let dir = watch_project();
let mut alba = WatchProcess::spawn(dir.path(), &["run", "--watch", "--log-format", "json"]);
alba.wait_for(r#""beam":"hello""#);
}
#[test]
fn a_beamfile_edit_reloads_the_project() {
let dir = watch_project();
let mut alba = WatchProcess::spawn(
dir.path(),
&["run", "--watch", "hello", "--log-format", "json"],
);
alba.wait_for(r#""event":"watch_waiting""#);
std::fs::write(
dir.path().join("Beamfile"),
"default hello\n\
beam hello { inputs [\"src/**\"] run \"echo greeting-edited\" }\n",
)
.unwrap();
alba.wait_for(r#""event":"watch_triggered""#);
alba.wait_for("greeting-edited");
}
#[cfg(unix)]
#[test]
fn sigint_ends_the_session_with_code_0() {
let dir = watch_project();
let mut alba = WatchProcess::spawn(
dir.path(),
&["run", "--watch", "hello", "--log-format", "json"],
);
alba.wait_for(r#""event":"watch_waiting""#);
nix::sys::signal::kill(
nix::unistd::Pid::from_raw(alba.child.id() as i32),
nix::sys::signal::Signal::SIGINT,
)
.unwrap();
let status = alba.wait_for_exit();
assert_eq!(status.code(), Some(0));
}
#[test]
fn an_affected_session_picks_up_a_beam_once_its_input_changes() {
let dir = git_watch_project();
let mut session = WatchProcess::spawn(
dir.path(),
&[
"run",
"--affected",
"HEAD",
"--watch",
"--log-format",
"json",
"--no-ui",
],
);
let started = session.wait_for("run_started");
assert!(started.contains("\"targets\":[]"), "{started}");
session.wait_for("watch_waiting");
std::fs::write(dir.path().join("docs/a"), "v2").unwrap();
session.wait_for("watch_triggered");
let started = session.wait_for("run_started");
assert!(started.contains("\"targets\":[\"other\"]"), "{started}");
session.wait_for("other-ran");
}