#![cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use semlith::lock::StoreLock;
const DEAD: u32 = 999_999;
fn sandbox(tag: &str) -> (tempfile::TempDir, PathBuf, PathBuf) {
let dir = tempfile::Builder::new()
.prefix(&format!("semlith-start-{tag}-"))
.tempdir()
.expect("a temporary directory");
let home = dir.path().join("home");
let store = home.join("stores").join(tag);
std::fs::create_dir_all(&store).unwrap();
(dir, home, store)
}
fn discovery(store: &Path, pid: u32, port: u16, token: &str, version: &str) {
use std::io::Write;
let path = store.join("daemon.json");
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)
.expect("a discovery file");
write!(
file,
r#"{{"pid":{pid},"port":{port},"token":"{token}","version":"{version}"}}"#
)
.unwrap();
}
fn start(home: &Path, store: &Path) -> Output {
Command::new(env!("CARGO_BIN_EXE_semlith"))
.env("SEMLITH_HOME", home)
.env("HOME", home)
.env_remove("SEMLITH_STORE")
.env_remove("SEMLITH_PORT")
.arg("--store")
.arg(store)
.arg("start")
.arg("--port")
.arg("0")
.output()
.expect("semlith start runs")
}
fn text(out: &Output) -> String {
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
}
#[test]
fn a_store_a_live_daemon_holds_is_not_a_failure() {
let (_dir, home, store) = sandbox("live");
let token = "a".repeat(64);
let held = StoreLock::acquire(&store).expect("the test process takes the lock");
discovery(&store, std::process::id(), 7365, &token, "9.9.9");
let out = start(&home, &store);
let all = text(&out);
drop(held);
assert!(out.status.success(), "exit was not 0:\n{all}");
assert!(
!all.contains("Error:") && !all.contains("Caused by:"),
"an error chain was printed for a daemon that is running:\n{all}"
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout
.lines()
.any(|l| l.trim() == format!("http://127.0.0.1:7365/?token={token}")),
"the portal URL is not on a line of its own:\n{all}"
);
for fact in [
store.display().to_string(),
format!("pid {}", std::process::id()),
"port 7365".to_string(),
"semlith 9.9.9".to_string(),
] {
assert!(
stdout.contains(&fact),
"{fact} is not in the output:\n{all}"
);
}
}
#[test]
fn a_dead_daemon_keeps_the_error_it_always_had() {
let (_dir, home, store) = sandbox("dead");
let held = StoreLock::acquire(&store).expect("the test process takes the lock");
discovery(&store, DEAD, 7365, &"b".repeat(64), "9.9.9");
let out = start(&home, &store);
let all = text(&out);
drop(held);
assert!(
!out.status.success(),
"a store held by no daemon exited 0:\n{all}"
);
assert!(
all.contains("Error:") && all.contains("Caused by:"),
"the error chain did not print:\n{all}"
);
assert!(
all.contains("cannot be opened by the daemon")
&& all.contains("is held by a running `semlith start`"),
"the wording changed:\n{all}"
);
assert!(
!all.contains("?token="),
"a URL was offered for a daemon that is not there:\n{all}"
);
}
#[test]
fn a_store_being_indexed_is_opened_when_the_run_ends() {
use std::io::{BufRead, BufReader};
use std::sync::mpsc;
use std::time::{Duration, Instant};
let (_dir, home, store) = sandbox("busy");
let held = StoreLock::acquire(&store).expect("the test process takes the lock");
let mut child = Command::new(env!("CARGO_BIN_EXE_semlith"))
.env("SEMLITH_HOME", &home)
.env("HOME", &home)
.env("SEMLITH_AIRGAP", "1")
.env("SEMLITH_NO_SERVICE", "1")
.env_remove("SEMLITH_STORE")
.env_remove("SEMLITH_PORT")
.arg("--store")
.arg(&store)
.arg("start")
.arg("--port")
.arg("0")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("semlith start runs");
let (tx, rx) = mpsc::channel();
let stderr = child.stderr.take().unwrap();
std::thread::spawn(move || {
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
let _ = tx.send(line);
}
});
let mut seen: Vec<String> = Vec::new();
fn wait_for(
rx: &mpsc::Receiver<String>,
seen: &mut Vec<String>,
what: &str,
limit: Duration,
) -> bool {
let until = Instant::now() + limit;
while Instant::now() < until {
if let Ok(line) = rx.recv_timeout(Duration::from_millis(200)) {
let found = line.contains(what);
seen.push(line);
if found {
return true;
}
}
}
false
}
let waiting = wait_for(
&rx,
&mut seen,
"being indexed by another process",
Duration::from_secs(60),
);
let opened_early = seen.iter().any(|l| l.contains("busy — now serving"));
drop(held);
let opened = waiting
&& wait_for(
&rx,
&mut seen,
"busy — now serving",
Duration::from_secs(30),
);
let _ = child.kill();
let _ = child.wait();
assert!(
waiting,
"the daemon did not say it is waiting for the store:\n{}",
seen.join("\n")
);
assert!(
!opened_early,
"the store was opened while another process held it:\n{}",
seen.join("\n")
);
assert!(
opened,
"the store was not opened after the run let go:\n{}",
seen.join("\n")
);
}