#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
#[allow(dead_code)]
mod support;
use std::io::BufRead;
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use support::{TestDirectory, generate, options, patch_frame_stack_for_local_acceptance};
const T_READY: Duration = Duration::from_secs(900);
const T_RELOAD: Duration = Duration::from_secs(300);
const T_STOP: Duration = Duration::from_secs(30);
struct DevSession {
child: Child,
lines: mpsc::Receiver<String>,
transcript: Vec<String>,
}
impl DevSession {
fn spawn(project: &Path) -> Self {
let mut child = Command::new(env!("CARGO_BIN_EXE_frame"))
.arg("dev")
.current_dir(project)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.process_group(0)
.spawn()
.expect("frame dev spawns");
let (sender, lines) = mpsc::channel();
let stdout = child.stdout.take().expect("stdout piped");
let stderr = child.stderr.take().expect("stderr piped");
for reader in [
Box::new(stdout) as Box<dyn std::io::Read + Send>,
Box::new(stderr),
] {
let sender = sender.clone();
std::thread::spawn(move || {
for line in std::io::BufReader::new(reader).lines() {
match line {
Ok(line) => {
eprintln!("[frame dev] {line}");
if sender.send(line).is_err() {
return;
}
}
Err(_) => return,
}
}
});
}
Self {
child,
lines,
transcript: Vec::new(),
}
}
fn expect_line(&mut self, what: &str, expected: impl Fn(&str) -> bool, deadline: Duration) {
let until = Instant::now() + deadline;
loop {
let remaining = until
.checked_duration_since(Instant::now())
.unwrap_or_else(|| {
panic!(
"waiting for {what} exceeded {deadline:?}; transcript:\n{}",
self.transcript.join("\n")
)
});
match self.lines.recv_timeout(remaining) {
Ok(line) => {
let hit = expected(&line);
self.transcript.push(line);
if hit {
return;
}
}
Err(mpsc::RecvTimeoutError::Timeout | mpsc::RecvTimeoutError::Disconnected) => {
panic!(
"waiting for {what}: output ended or {deadline:?} elapsed; transcript:\n{}",
self.transcript.join("\n")
)
}
}
}
}
fn serving_port(&self) -> u16 {
let line = self
.transcript
.iter()
.find(|line| line.contains("frame dev: serving at http://"))
.expect("a serving line was consumed before asking for the port");
let address = line
.split("http://")
.nth(1)
.and_then(|rest| rest.split_whitespace().next())
.expect("serving line carries an address");
address
.rsplit(':')
.next()
.and_then(|port| port.parse().ok())
.expect("serving address carries a port")
}
fn stop(mut self) {
let id = self.child.id();
let _ = Command::new("kill")
.args(["-TERM", &id.to_string()])
.status();
let until = Instant::now() + T_STOP;
loop {
match self.child.try_wait() {
Ok(Some(_status)) => return,
Ok(None) if Instant::now() < until => {
std::thread::yield_now();
}
_ => {
let _ = self.child.kill();
panic!(
"frame dev missed the ordered-teardown bound; transcript:\n{}",
self.transcript.join("\n")
);
}
}
}
}
}
impl Drop for DevSession {
fn drop(&mut self) {
if !matches!(self.child.try_wait(), Ok(Some(_))) {
let _ = Command::new("kill")
.args(["-TERM", &self.child.id().to_string()])
.status();
let until = Instant::now() + T_STOP;
while Instant::now() < until {
if matches!(self.child.try_wait(), Ok(Some(_))) {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
}
let _ = Command::new("/bin/kill")
.args(["-9", "--", &format!("-{}", self.child.id())])
.status();
let _ = self.child.wait();
}
}
fn generated_project(label: &str) -> (TestDirectory, PathBuf) {
let directory = TestDirectory::new(label).expect("scratch dir");
patch_frame_stack_for_local_acceptance(directory.path()).expect("local-stack patch");
let project = generate(&options(directory.path(), "dev_scene")).expect("frame new");
(directory, project)
}
#[test]
fn one_command_edit_reload_edit_reload() {
let (_directory, project) = generated_project("dev-loop");
let mut session = DevSession::spawn(&project);
session.expect_line(
"the resolved-root first line",
|line| line.contains("frame dev: application root"),
Duration::from_secs(10),
);
session.expect_line(
"RUNNING CURRENT generation 0",
|line| line.contains("RUNNING CURRENT generation 0"),
T_READY,
);
let source = project.join("component/src/dev_scene.gleam");
let original = std::fs::read_to_string(&source).expect("component source");
std::fs::write(
&source,
format!("{original}\npub fn dev_probe() -> Int {{\n 41\n}}\n"),
)
.expect("edit lands");
session.expect_line(
"generation 1 activation with advanced freshness ordinals",
|line| {
line.contains("RUNNING CURRENT generation 1")
&& line.contains("incarnation 2")
&& line.contains("module generation 2")
},
T_RELOAD,
);
let grown = std::fs::read_to_string(&source).expect("component source");
std::fs::write(
&source,
format!("{grown}\npub fn dev_probe_two() -> Int {{\n 42\n}}\n"),
)
.expect("second edit lands");
session.expect_line(
"generation 2 activation",
|line| {
line.contains("RUNNING CURRENT generation 2")
&& line.contains("incarnation 3")
&& line.contains("module generation 3")
},
T_RELOAD,
);
let serving_lines = session
.transcript
.iter()
.filter(|line| line.contains("frame dev: serving at http://"))
.count();
assert_eq!(serving_lines, 1, "the node was never restarted");
session.stop();
}
#[test]
fn compiler_error_reports_last_good_and_recovers() {
let (_directory, project) = generated_project("dev-recover");
let mut session = DevSession::spawn(&project);
session.expect_line(
"RUNNING CURRENT generation 0",
|line| line.contains("RUNNING CURRENT generation 0"),
T_READY,
);
let source = project.join("component/src/dev_scene.gleam");
let original = std::fs::read_to_string(&source).expect("component source");
std::fs::write(&source, format!("{original}\npub fn broken( -> {{\n")).expect("break lands");
session.expect_line(
"the typed RELOAD FAILED report",
|line| line.contains("RELOAD FAILED — RUNNING LAST GOOD"),
T_RELOAD,
);
std::fs::write(
&source,
format!("{original}\npub fn fixed() -> Int {{\n 43\n}}\n"),
)
.expect("fix lands");
session.expect_line(
"the fixing generation activates",
|line| line.contains("RUNNING CURRENT generation"),
T_RELOAD,
);
let serving_lines = session
.transcript
.iter()
.filter(|line| line.contains("frame dev: serving at http://"))
.count();
assert_eq!(serving_lines, 1, "failure and recovery on ONE node");
session.stop();
}
#[test]
fn dropped_session_reaps_the_node_and_releases_the_port() {
let (_directory, project) = generated_project("dev-orphan");
let session = {
let mut session = DevSession::spawn(&project);
session.expect_line(
"RUNNING CURRENT generation 0",
|line| line.contains("RUNNING CURRENT generation 0"),
T_READY,
);
session
};
let ordered = Instant::now();
session.stop();
eprintln!("clean ordered teardown took {:?}", ordered.elapsed());
let mut session = DevSession::spawn(&project);
session.expect_line(
"RUNNING CURRENT generation 0",
|line| line.contains("RUNNING CURRENT generation 0"),
T_READY,
);
let port = session.serving_port();
drop(session);
let pattern = project.to_string_lossy().into_owned();
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let port_free = std::net::TcpListener::bind(("127.0.0.1", port)).is_ok();
let survivors = Command::new("pgrep")
.args(["-lf", &pattern])
.output()
.expect("pgrep runs");
let none_survive = !survivors.status.success();
if port_free && none_survive {
return;
}
assert!(
Instant::now() < deadline,
"dropped session left the node behind: port {port} free: {port_free}; survivors:\n{}",
String::from_utf8_lossy(&survivors.stdout)
);
std::thread::sleep(Duration::from_millis(100));
}
}