frame-cli 0.3.0

CLI for Frame — five intention-verbs over one application: frame new scaffolds it, frame run serves it, frame test proves it (real browser included), frame check verifies it statically, frame doctor walks the prerequisites
Documentation
//! Generated application lifecycle, storage, page-boot, and Gleam component
//! acceptance tests.

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;

/// Bound on any single wait for a boot-time condition (port accept, process
/// exit).
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(())
}

/// Boots the FULL stack (component, embedded messaging server, page server)
/// on ephemeral ports against the real built `page/dist`, probes
/// `/frame/config.json` and `index.html` over HTTP and the messaging
/// server's WebSocket port for an accepting listener, then tears the process
/// down and requires nothing is left listening.
///
/// The built page is a documented prerequisite (README: `npm --prefix page
/// install && npm --prefix page run build` before `cargo test --workspace`)
/// — never silently skipped: `page/dist` always carries the
/// scaffold-authored `index.html`, so the compiled `main.js` is the honest
/// built-page marker, and its absence fails this test loudly with the exact
/// command to run first.
#[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(())
    })();

    // Always request a clean stop before propagating any check failure.
    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)
}

/// Sends SIGTERM (never SIGKILL) so the process takes the same graceful,
/// ordered-teardown path an operator's Ctrl-C would.
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),
    }
}