mod support;
use std::error::Error;
use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use support::{TestDirectory, generate, generated_files, options};
type TestResult = Result<(), Box<dyn Error>>;
const FRAME: &str = env!("CARGO_BIN_EXE_frame");
const BOOT_DEADLINE: Duration = Duration::from_secs(600);
const EXIT_DEADLINE: Duration = Duration::from_secs(30);
static APP_BUILD_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn new_without_a_toolchain_refuses_before_touching_disk_with_install_links() -> TestResult {
let directory = TestDirectory::new("verbs-new-refusal")?;
let empty_path = directory.path().join("empty-path");
fs::create_dir_all(&empty_path)?;
let output = Command::new(FRAME)
.args(["new", "tool_app"])
.current_dir(directory.path())
.env("PATH", &empty_path)
.output()?;
assert!(
!output.status.success(),
"frame new must refuse when the toolchain is missing"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("frame new needs tools this machine is missing"),
"refusal must be the doctor's batched report, got:\n{stderr}"
);
for link in [
"https://rustup.rs",
"https://gleam.run/getting-started/installing/",
"https://nodejs.org/",
] {
assert!(
stderr.contains(link),
"refusal must carry {link}:\n{stderr}"
);
}
assert!(
!directory.path().join("tool_app").exists(),
"a refused frame new must create nothing"
);
Ok(())
}
#[test]
fn new_with_a_full_toolchain_creates_the_app_and_prints_the_verbs() -> TestResult {
let directory = TestDirectory::new("verbs-new-ok")?;
let output = Command::new(FRAME)
.args(["new", "fresh_app"])
.current_dir(directory.path())
.output()?;
assert_success(&output, "frame new fresh_app")?;
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("created"), "{stdout}");
assert!(stdout.contains("frame run"), "{stdout}");
assert!(stdout.contains("frame test"), "{stdout}");
assert!(directory.path().join("fresh_app/frame.toml").is_file());
assert!(
directory
.path()
.join("fresh_app/page/dist/main.js")
.is_file(),
"the scaffold must ship the compiled page"
);
Ok(())
}
#[test]
fn doctor_reports_missing_tools_with_install_links_and_a_real_exit_code() -> TestResult {
let directory = TestDirectory::new("verbs-doctor-missing")?;
let empty_path = directory.path().join("empty-path");
fs::create_dir_all(&empty_path)?;
let output = Command::new(FRAME)
.arg("doctor")
.current_dir(directory.path())
.env("PATH", &empty_path)
.output()?;
assert!(
!output.status.success(),
"frame doctor must exit non-zero when tools are missing"
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("MISSING"), "{stdout}");
assert!(stdout.contains("https://rustup.rs"), "{stdout}");
assert!(
stdout.contains("https://gleam.run/getting-started/installing/"),
"{stdout}"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("frame doctor found"), "{stderr}");
Ok(())
}
#[test]
fn doctor_passes_on_this_machine_and_reports_the_application_context() -> TestResult {
let directory = TestDirectory::new("verbs-doctor-ok")?;
let project = generate(&options(directory.path(), "doc_app"))?;
let output = Command::new(FRAME)
.arg("doctor")
.current_dir(&project)
.output()?;
assert_success(&output, "frame doctor (full toolchain is mandatory here)")?;
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("toolchain:"), "{stdout}");
assert!(stdout.contains("application at"), "{stdout}");
assert!(stdout.contains("frame.toml"), "{stdout}");
assert!(
stdout.contains("compiled page ok"),
"a fresh scaffold's compiled page must report current:\n{stdout}"
);
assert!(stdout.contains("everything present"), "{stdout}");
Ok(())
}
#[test]
fn run_refuses_outside_an_application_with_the_fix() -> TestResult {
let directory = TestDirectory::new("verbs-run-outside")?;
let output = Command::new(FRAME)
.arg("run")
.current_dir(directory.path())
.output()?;
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("not inside a Frame application"),
"{stderr}"
);
assert!(stderr.contains("frame new"), "{stderr}");
Ok(())
}
#[test]
fn run_boots_a_fresh_scaffold_without_npm_and_tears_down_cleanly_on_sigterm() -> TestResult {
let _serialized = lock_app_builds();
let directory = TestDirectory::new("verbs-run")?;
let project = generate(&options(directory.path(), "run_app"))?;
let [console_port, tcp_port, health_port, ws_port] = free_ports()?;
write_app_config(
&project,
"run_app",
console_port,
tcp_port,
health_port,
ws_port,
)?;
let mut child = Command::new(FRAME)
.arg("run")
.current_dir(&project)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdout_reader = drain(child.stdout.take());
let stderr_reader = drain(child.stderr.take());
let boot = wait_for_port(console_port, &mut child);
if let Err(error) = boot {
let mut report = vec![format!("frame run never became ready: {error}")];
if let Err(kill_error) = child.kill() {
report.push(format!("killing frame run also failed: {kill_error}"));
}
match child.wait() {
Ok(status) => report.push(format!("final status: {status}")),
Err(wait_error) => {
report.push(format!("waiting for frame run also failed: {wait_error}"));
}
}
report.push(format!("stdout:\n{}", join_reader(stdout_reader)));
report.push(format!("stderr:\n{}", join_reader(stderr_reader)));
return Err(report.join("\n").into());
}
std::thread::sleep(Duration::from_secs(2));
let kill = Command::new("kill")
.arg("-TERM")
.arg(child.id().to_string())
.status()?;
assert!(kill.success(), "sending SIGTERM to frame run failed");
let status = wait_with_deadline(&mut child, EXIT_DEADLINE)?;
let stdout = join_reader(stdout_reader);
let stderr = join_reader(stderr_reader);
assert!(
status.success(),
"frame run must exit 0 after SIGTERM teardown; got {status}\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
stdout.contains(&format!("serving at http://127.0.0.1:{console_port}")),
"frame run must print the URL it serves:\n{stdout}"
);
for port in [console_port, tcp_port, health_port, ws_port] {
assert!(
TcpStream::connect(("127.0.0.1", port)).is_err(),
"port {port} still accepts connections after frame run teardown"
);
}
assert!(
!project.join("page/node_modules").exists(),
"the first frame run must involve no npm at all"
);
Ok(())
}
#[test]
fn build_alias_builds_the_host_without_booting_or_npm() -> TestResult {
let _serialized = lock_app_builds();
let directory = TestDirectory::new("verbs-build")?;
let project = generate(&options(directory.path(), "built_app"))?;
let output = Command::new(FRAME)
.arg("build")
.current_dir(&project)
.output()?;
assert_success(&output, "frame build")?;
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("page: compiled modules are current"),
"a fresh scaffold's page must be judged current:\n{stderr}"
);
assert!(
host_binary_path(&project).is_file(),
"frame build must leave the host binary at {}",
host_binary_path(&project).display()
);
assert!(!project.join("page/node_modules").exists());
Ok(())
}
#[test]
fn check_delivers_one_static_verdict_on_a_fresh_scaffold() -> TestResult {
let _serialized = lock_app_builds();
let directory = TestDirectory::new("verbs-check")?;
let project = generate(&options(directory.path(), "check_app"))?;
let output = Command::new(FRAME)
.arg("check")
.current_dir(&project)
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
assert_success(&output, "frame check")?;
assert!(stdout.contains("frame.toml OK"), "{stdout}");
assert!(stdout.contains("page types"), "{stdout}");
assert!(stdout.contains("component"), "{stdout}");
assert!(stdout.contains("host"), "{stdout}");
assert!(stdout.contains("frame check: PASS"), "{stdout}");
assert!(project.join("page/node_modules").is_dir());
Ok(())
}
#[test]
fn test_component_scope_narrows_to_gleam_without_touching_npm() -> TestResult {
let _serialized = lock_app_builds();
let directory = TestDirectory::new("verbs-test-component")?;
let project = generate(&options(directory.path(), "scoped_app"))?;
let before = generated_files(&project)?;
let output = Command::new(FRAME)
.args(["test", "--component"])
.current_dir(&project)
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
assert_success(&output, "frame test --component")?;
assert!(stdout.contains("component"), "{stdout}");
assert!(stdout.contains("frame test: PASS"), "{stdout}");
assert!(
!stdout.contains("browser"),
"a narrowed verdict must not name unselected scopes:\n{stdout}"
);
assert!(
!project.join("page/node_modules").exists(),
"the component scope must not install the page toolchain"
);
assert_eq!(
before,
generated_files(&project)?,
"frame test --component changed generated source"
);
Ok(())
}
#[test]
fn host_subcommand_boots_a_config_and_serves_frame_config_json() -> TestResult {
let _serialized = lock_app_builds();
let directory = TestDirectory::new("verbs-host")?;
let assets = directory.path().join("assets");
fs::create_dir_all(&assets)?;
fs::write(
assets.join("index.html"),
"<!doctype html><html><head><title>estate page</title></head><body></body></html>",
)?;
let [console_port, tcp_port, health_port, ws_port] = free_ports()?;
let config_path = directory.path().join("frame.toml");
fs::write(
&config_path,
app_config_toml(
&assets.display().to_string(),
"estate.events",
console_port,
tcp_port,
health_port,
ws_port,
),
)?;
let mut child = Command::new(FRAME)
.args(["host", "--config"])
.arg(&config_path)
.current_dir(directory.path())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdout_reader = drain(child.stdout.take());
let stderr_reader = drain(child.stderr.take());
let outcome = (|| -> TestResult {
wait_for_port(console_port, &mut child)?;
let config_body = http_get(console_port, "/frame/config.json")?;
if !config_body.contains("\"busEndpoint\"") {
return Err(format!("/frame/config.json lacked busEndpoint: {config_body}").into());
}
let index_body = http_get(console_port, "/")?;
if !index_body.contains("<title>") {
return Err(format!("/ did not serve the page shell: {index_body}").into());
}
Ok(())
})();
let kill = Command::new("kill")
.arg("-TERM")
.arg(child.id().to_string())
.status()?;
assert!(kill.success());
let status = wait_with_deadline(&mut child, EXIT_DEADLINE)?;
let stdout = join_reader(stdout_reader);
let stderr = join_reader(stderr_reader);
if let Err(error) = outcome {
return Err(format!("{error}\nstdout:\n{stdout}\nstderr:\n{stderr}").into());
}
assert!(
status.success(),
"frame host must exit 0 after SIGTERM; got {status}\nstderr:\n{stderr}"
);
for port in [console_port, tcp_port, health_port, ws_port] {
assert!(
TcpStream::connect(("127.0.0.1", port)).is_err(),
"port {port} still accepts connections after frame host teardown"
);
}
Ok(())
}
fn lock_app_builds() -> std::sync::MutexGuard<'static, ()> {
APP_BUILD_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn write_app_config(
project: &Path,
name: &str,
console_port: u16,
tcp_port: u16,
health_port: u16,
ws_port: u16,
) -> TestResult {
let channel = format!("{name}.events");
fs::write(
project.join("frame.toml"),
app_config_toml(
"page/dist",
&channel,
console_port,
tcp_port,
health_port,
ws_port,
),
)?;
Ok(())
}
fn app_config_toml(
assets: &str,
channel: &str,
console_port: u16,
tcp_port: u16,
health_port: u16,
ws_port: u16,
) -> String {
format!(
r#"[frame]
bind = "127.0.0.1:{console_port}"
assets = "{assets}"
auth_token = ""
channel = "{channel}"
[bus]
listen_address = "127.0.0.1:{tcp_port}"
health_listen_address = "127.0.0.1:{health_port}"
drain_timeout_ms = 1000
channels = [{{ name = "{channel}", durable = false }}]
routing_rules = []
[bus.websocket]
listen_address = "127.0.0.1:{ws_port}"
path = "/liminal"
allowed_origins = ["http://127.0.0.1:{console_port}"]
"#
)
}
fn free_ports<const N: usize>() -> Result<[u16; N], Box<dyn Error>> {
let listeners: Vec<TcpListener> = (0..N)
.map(|_| TcpListener::bind("127.0.0.1:0"))
.collect::<Result<_, _>>()?;
let mut ports = [0_u16; N];
for (slot, listener) in ports.iter_mut().zip(&listeners) {
*slot = listener.local_addr()?.port();
}
drop(listeners);
Ok(ports)
}
fn wait_for_port(port: u16, child: &mut Child) -> TestResult {
let deadline = Instant::now() + BOOT_DEADLINE;
while Instant::now() < deadline {
if let Some(status) = child.try_wait()? {
return Err(format!("process exited during boot with {status}").into());
}
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
return Ok(());
}
std::thread::sleep(Duration::from_millis(100));
}
Err(format!("port {port} never accepted a connection within {BOOT_DEADLINE:?}").into())
}
fn wait_with_deadline(
child: &mut Child,
deadline: Duration,
) -> Result<std::process::ExitStatus, Box<dyn Error>> {
let end = Instant::now() + deadline;
loop {
if let Some(status) = child.try_wait()? {
return Ok(status);
}
if Instant::now() >= end {
child.kill()?;
return Err(format!("process did not exit within {deadline:?} of SIGTERM").into());
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn drain<R: Read + Send + 'static>(stream: Option<R>) -> std::thread::JoinHandle<String> {
std::thread::spawn(move || {
let mut text = String::new();
if let Some(mut stream) = stream {
let mut bytes = Vec::new();
if stream.read_to_end(&mut bytes).is_ok() {
text = String::from_utf8_lossy(&bytes).into_owned();
}
}
text
})
}
fn join_reader(handle: std::thread::JoinHandle<String>) -> String {
handle
.join()
.unwrap_or_else(|_| "<reader thread panicked>".to_owned())
}
fn http_get(port: u16, path: &str) -> Result<String, Box<dyn Error>> {
let mut stream = TcpStream::connect(("127.0.0.1", port))?;
let request =
format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n");
stream.write_all(request.as_bytes())?;
let mut raw = Vec::new();
stream.read_to_end(&mut raw)?;
let text = String::from_utf8_lossy(&raw).into_owned();
let status_line = text
.lines()
.next()
.ok_or_else(|| format!("empty HTTP response for {path}"))?;
if !status_line.contains(" 200 ") {
return Err(format!("GET {path} did not return HTTP 200: {status_line}").into());
}
Ok(text)
}
fn assert_success(output: &Output, command: &str) -> TestResult {
if output.status.success() {
return Ok(());
}
Err(format!(
"{command} failed with {}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
.into())
}
fn host_binary_path(project: &Path) -> PathBuf {
std::env::var_os("CARGO_TARGET_DIR")
.map_or_else(|| project.join("target"), PathBuf::from)
.join("debug")
.join("app-host")
}