frame-cli 0.4.0

CLI for Frame — six intention-verbs over one application: frame new scaffolds it, frame run serves it, frame dev hot-reloads it against the running node, frame test proves it (real browser included), frame check verifies it statically, frame doctor walks the prerequisites
Documentation
//! F-7b R6 acceptance over a REAL generated project: `frame dev` as the
//! user runs it — one command from the project root of an untouched
//! `frame new` output, driven end to end through real gleam builds, a
//! real node, and real hot-swaps.
//!
//! The scenes here are the live half of R6; the burst/coalescing timing
//! laws, the zero-edit idle counter, the rollback ladder, and the
//! structural tripwire run at the unit and integration layers (the
//! worker report maps every R6 bullet to its layer).

#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]

// Only a slice of the shared support helpers is used here; the rest
// stay compiled-but-unused in this binary.
#[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};

/// Bound on reaching RUNNING CURRENT generation 0 from a cold start: the
/// first `cargo build` of the generated host dominates.
const T_READY: Duration = Duration::from_secs(900);
/// Bound on one edit→reload lap (gleam build + swap + proofs).
const T_RELOAD: Duration = Duration::from_secs(300);
/// Bound on ordered teardown after SIGTERM.
const T_STOP: Duration = Duration::from_secs(30);

/// `frame dev` under test: the child, its merged output lines, and a
/// kill-on-drop guard so a failing assertion never leaks a node.
struct DevSession {
    child: Child,
    lines: mpsc::Receiver<String>,
    /// Every line consumed so far, for failure reports.
    transcript: Vec<String>,
}

impl DevSession {
    fn spawn(project: &Path) -> Self {
        // The session leads its own process group so the drop backstop
        // can reap the NODE — a grandchild this handle never sees.
        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(),
        }
    }

    /// Consumes lines until one satisfies `expected`, panicking with the
    /// transcript at the deadline. The bounded receive is test
    /// orchestration around a live child process.
    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")
                    )
                }
            }
        }
    }

    /// The port from the one `serving at http://HOST:PORT` line consumed
    /// so far (it precedes the generation-0 line in the boot output).
    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")
    }

    /// Ordered stop: one SIGTERM, then the exit inside the teardown
    /// bound.
    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) {
        // Ordered first: if the CLI is still up, SIGTERM it and give its
        // own teardown the same bound it gets everywhere else (T_STOP,
        // ~4.8x the observed clean teardown; see the orphan-pin evidence
        // file) to reap the node.
        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));
            }
        }
        // Backstop, always: SIGKILL the session's process group, so no
        // path — wedged CLI, crashed CLI, panic unwind — can orphan the
        // node grandchild.
        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)
}

/// The crown scene: from an untouched generated project, ONE command to a
/// running node; an edit to the component source reloads by content —
/// RUNNING CURRENT generation 1 with BOTH freshness ordinals advanced —
/// while the same node keeps serving (its boot output was never
/// reprinted, its process never respawned); a second edit reaches
/// generation 2. Ctrl-C-equivalent teardown is ordered and bounded.
#[test]
fn one_command_edit_reload_edit_reload() {
    let (_directory, project) = generated_project("dev-loop");
    let mut session = DevSession::spawn(&project);

    // The first line names the resolved root (ruling C1's condition).
    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,
    );

    // Edit one: append a new exported function — compiling, behavior
    // preserving, byte-changing.
    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,
    );

    // Edit two: the loop is a LOOP — the next burst reaches generation 2
    // on the same node.
    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,
    );

    // One node the whole way: a respawn would reprint the boot URL line
    // after the first activation. The transcript holds exactly one.
    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();
}

/// R4's honesty lap: a compiler error reports RELOAD FAILED — RUNNING
/// LAST GOOD with the diagnostics attached, the node survives, and the
/// fixing edit promotes the next generation WITHOUT a node restart.
#[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,
    );

    // The fix follows the ordinary coalescing path and promotes without a
    // node restart.
    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();
}

/// The orphan pin (the lap-3 battery finding): a scene that PANICS drops
/// its `DevSession` without the ordered stop, and the drop path must
/// still reap the NODE — a grandchild of this harness — releasing the
/// page port and leaving no process alive under the project. A dropped
/// session that only kills the `frame dev` child orphans the node to
/// launchd, where it squats the preferred page port for every later
/// scene on the machine.
#[test]
fn dropped_session_reaps_the_node_and_releases_the_port() {
    let (_directory, project) = generated_project("dev-orphan");

    // Lap one, ordered: the clean-teardown time printed here is the
    // derivation base for the drop grace (recorded in
    // evidence/red-f7b-orphan-pin-drop-path.txt).
    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());

    // Lap two, the panic path: drop WITHOUT stop — exactly what an
    // `expect_line` panic does during unwinding.
    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);

    // The node must be gone: its port re-bindable and no process left
    // running under the project path.
    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));
    }
}