use std::io::{Read, Write};
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use frame_core::component::ComponentId;
use frame_state::ComponentStateStore;
const BOOT_DEADLINE: Duration = Duration::from_secs(30);
#[test]
fn install_start_round_trip_read_and_ordered_stop() -> Result<(), Box<dyn std::error::Error>> {
let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let state = std::env::temp_dir().join(format!("frame-generated-e2e-{unique}"));
let entity = app_host::bounded_round_trip(&state)?;
assert_eq!(entity.to_string().len(), 64);
assert_eq!(app_host::bounded_round_trip(&state)?, entity);
let reopened = ComponentStateStore::open(&state)?;
reopened.reconcile()?;
let id = ComponentId::derive("frame.generated", "{{NAME}}");
let handle = reopened.component_handle(id)?;
assert_eq!(
handle.get(entity)?.as_deref(),
Some(br#"{"kind":"welcome","message":"hello from {{NAME}}"}"#.as_slice())
);
remove_if_present(&state)?;
Ok(())
}
#[test]
fn gleam_component_test_passes() -> Result<(), Box<dyn std::error::Error>> {
let component = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../component");
let output = std::process::Command::new("gleam")
.arg("test")
.current_dir(component)
.output()
.map_err(|error| {
std::io::Error::new(
error.kind(),
format!("Gleam toolchain is required for component tests; install it from https://gleam.run/getting-started/installing/: {error}"),
)
})?;
if !output.status.success() {
return Err(std::io::Error::other(format!(
"gleam test failed: {}",
String::from_utf8_lossy(&output.stderr)
))
.into());
}
Ok(())
}
#[test]
fn full_stack_boots_serves_config_and_page_then_stops_cleanly()
-> Result<(), Box<dyn std::error::Error>> {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").map_err(|error| {
format!("CARGO_MANIFEST_DIR must be set by cargo at test runtime: {error}")
})?;
let host_dir = PathBuf::from(manifest_dir);
let project_dir = host_dir
.parent()
.ok_or("host/ crate directory had no parent")?;
let assets_dir = project_dir.join("page").join("dist");
if !assets_dir.join("index.html").is_file() || !assets_dir.join("main.js").is_file() {
return Err(format!(
"the page is not built: {} is missing index.html or main.js; run `npm --prefix page \
install && npm --prefix page run build` from the project root first",
assets_dir.display()
)
.into());
}
let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let stage = std::env::temp_dir().join(format!("frame-generated-boot-{unique}"));
std::fs::create_dir_all(&stage)?;
let [console_port, tcp_port, health_port, ws_port] = free_ports()?;
let config_path = stage.join("frame.toml");
std::fs::write(
&config_path,
format!(
r#"[frame]
bind = "127.0.0.1:{console_port}"
assets = "{assets}"
auth_token = ""
channel = "{{NAME}}.events"
[bus]
listen_address = "127.0.0.1:{tcp_port}"
health_listen_address = "127.0.0.1:{health_port}"
drain_timeout_ms = 1000
channels = [{{ name = "{{NAME}}.events", 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}"]
"#,
assets = assets_dir.display(),
),
)?;
let binary = PathBuf::from(env!("CARGO_BIN_EXE_app-host"));
let mut child = std::process::Command::new(&binary)
.current_dir(&stage)
.spawn()?;
let outcome = (|| -> Result<(), Box<dyn std::error::Error>> {
wait_for_port(console_port)?;
wait_for_port(ws_port)?;
wait_for_port(health_port)?;
let config_body = http_get(console_port, "/frame/config.json")?;
if !config_body.contains("\"busEndpoint\"") {
return Err(
format!("/frame/config.json did not advertise 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(())
})();
stop_process(&mut child)?;
let status = child.wait()?;
outcome?;
if !status.success() {
return Err(format!("app-host exited uncleanly after SIGTERM: {status}").into());
}
for port in [console_port, tcp_port, health_port, ws_port] {
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
return Err(format!("port {port} still accepts connections after teardown").into());
}
}
remove_if_present(&stage)?;
Ok(())
}
fn free_ports<const N: usize>() -> Result<[u16; N], Box<dyn std::error::Error>> {
let listeners: Vec<std::net::TcpListener> = (0..N)
.map(|_| std::net::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) -> Result<(), Box<dyn std::error::Error>> {
let deadline = Instant::now() + BOOT_DEADLINE;
while Instant::now() < deadline {
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
return Ok(());
}
std::thread::sleep(Duration::from_millis(50));
}
Err(format!("port {port} never accepted a connection within {BOOT_DEADLINE:?}").into())
}
fn http_get(port: u16, path: &str) -> Result<String, Box<dyn std::error::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 stop_process(child: &mut std::process::Child) -> Result<(), Box<dyn std::error::Error>> {
let status = std::process::Command::new("kill")
.arg("-TERM")
.arg(child.id().to_string())
.status()?;
if !status.success() {
return Err(format!("sending SIGTERM to pid {} failed: {status}", child.id()).into());
}
Ok(())
}
fn remove_if_present(path: &Path) -> std::io::Result<()> {
match std::fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}