#![cfg(all(feature = "embedded", feature = "ipc", feature = "web", unix))]
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
const PATIENCE: Duration = Duration::from_secs(90);
const FRESH_WITHIN: Duration = Duration::from_secs(20);
struct Door(Child);
impl Drop for Door {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn scratch(case: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("iki-sw-{}-{case}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("config").join("ikigai")).expect("scratch config home");
std::fs::create_dir_all(dir.join("files")).expect("scratch workspace");
std::fs::write(dir.join("files").join("live.txt"), "one").expect("the first version");
dir
}
fn spawn(home: &Path, args: &[&str]) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_ikigai"));
command
.args(args)
.env("HOME", home)
.env("XDG_CONFIG_HOME", home.join("config"))
.env("IKIGAI_FILES", home.join("files"));
command
}
fn start(home: &Path, args: &[&str], ready: &'static str) -> (Door, String) {
let mut child = spawn(home, args)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.expect("the binary under test starts");
let stderr = child.stderr.take().expect("piped stderr");
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut seen = String::new();
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
let done = line.contains(ready);
seen.push_str(&line);
seen.push('\n');
if done {
break;
}
}
let _ = tx.send(seen);
});
let door = Door(child);
let seen = rx
.recv_timeout(PATIENCE)
.unwrap_or_else(|e| panic!("`{args:?}` never printed `{ready}` in {PATIENCE:?}: {e}"));
(door, seen)
}
fn bound_port(banner: &str) -> u16 {
banner
.lines()
.find_map(|line| line.split("serving HTTP on ").nth(1))
.and_then(|rest| rest.split_whitespace().next())
.and_then(|addr| addr.parse::<std::net::SocketAddr>().ok())
.map(|addr| addr.port())
.unwrap_or_else(|| panic!("no bound address on the banner: {banner}"))
}
fn get(port: u16, path: &str) -> (String, String) {
let mut stream = TcpStream::connect(("127.0.0.1", port))
.unwrap_or_else(|e| panic!("nothing accepted on {port}: {e}"));
stream
.write_all(
format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
.as_bytes(),
)
.expect("the request is written");
let mut raw = String::new();
stream
.read_to_string(&mut raw)
.expect("the response is read");
let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((raw.as_str(), ""));
(
head.lines().next().unwrap_or_default().to_string(),
body.to_string(),
)
}
fn ask(home: &Path, connect: &str, command: &str) -> String {
let out = spawn(home, &["--connect", connect, "--plain", "-c", command])
.stdin(Stdio::null())
.output()
.expect("the client runs");
assert!(
out.status.success(),
"`{command}` failed: {}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).to_string()
}
fn served_fresh(door: &str, mut read: impl FnMut() -> String) {
#[allow(clippy::disallowed_methods)]
let started = Instant::now();
let mut last = String::new();
while started.elapsed() < FRESH_WITHIN {
last = read();
if last.trim() == "two" {
return;
}
std::thread::sleep(Duration::from_millis(100));
}
panic!(
"{door}: a file rewritten on disk was still served stale after {FRESH_WITHIN:?} \
(last answer: {last:?}); nothing cut its `urn:file:` thread"
);
}
#[test]
fn the_http_door_serves_a_file_changed_on_disk_without_a_restart() {
let home = scratch("http");
let files = home.join("files");
let cap = format!("urn:cap:fs:read:{}", files.display());
let (_door, banner) = start(
&home,
&["serve", "--http", "0", "--cap", &cap],
"serving HTTP on",
);
assert!(
banner.contains("ikigai: watching "),
"the door must say it watches the workspace: {banner}"
);
let port = bound_port(&banner);
let (status, body) = get(port, "/file/live.txt");
assert!(status.contains("200"), "{status} {body}");
assert_eq!(body, "one", "the first read, now cached");
std::fs::write(files.join("live.txt"), "two").expect("the out-of-band edit");
served_fresh("the HTTP door", || get(port, "/file/live.txt").1);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn the_ipc_door_serves_a_file_changed_on_disk_without_a_restart() {
let home = scratch("ipc");
let files = home.join("files");
let sock = format!("/tmp/iki-sw{}.sock", std::process::id());
let _ = std::fs::remove_file(&sock);
let (_door, banner) = start(&home, &["serve", &sock], "serving on");
assert!(
banner.contains("ikigai: watching "),
"the door must say it watches the workspace: {banner}"
);
assert_eq!(
ask(&home, &sock, "source urn:file:live.txt").trim(),
"one",
"the first read, now cached"
);
std::fs::write(files.join("live.txt"), "two").expect("the out-of-band edit");
served_fresh("the IPC door", || {
ask(&home, &sock, "source urn:file:live.txt")
});
let _ = std::fs::remove_file(&sock);
let _ = std::fs::remove_dir_all(&home);
}