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
//! `frame test` — component tests, host tests, and the real-browser proof
//! as ONE verdict. Scope flags narrow; the default proves everything.

use std::time::Instant;

use crate::cli::TestScope;
use crate::doctor::{self, Tool};
use crate::error::CliError;
use crate::project::{App, ensure_page_toolchain, observe_step};

use super::build::build_page_if_stale;

/// One scope's outcome for the verdict table.
struct Outcome {
    scope: &'static str,
    passed: bool,
    seconds: f64,
}

/// Runs every selected scope — all of them by default, the real-browser
/// proof included — and prints one verdict.
///
/// # Errors
///
/// Refuses on missing prerequisites (exactly the ones the selected scopes
/// use); returns a failed verdict when any scope fails.
pub fn test(scope: TestScope) -> Result<(), CliError> {
    let app = App::find_from_current_dir()?;
    doctor::require(&prerequisites_for(scope), "frame test")?;
    if scope.browser_selected() {
        doctor::require_chrome().map_err(|reason| CliError::MissingChrome { reason })?;
    }
    if scope.host_selected() || scope.browser_selected() {
        ensure_page_toolchain(&app)?;
    }

    let mut outcomes: Vec<Outcome> = Vec::new();
    if scope.component_selected() {
        let start = Instant::now();
        let passed = observe_step("gleam", &["test"], &app.component_dir())?;
        outcomes.push(outcome("component", passed, start));
    }
    if scope.host_selected() {
        // The host's own suite serves the real compiled page, so the page
        // is brought current first; a page compile failure is the host
        // scope's failure, honestly attributed.
        let start = Instant::now();
        let page_current = match build_page_if_stale(&app) {
            Ok(()) => true,
            Err(CliError::CommandFailed { command, status }) => {
                eprintln!(
                    "page compile failed before the host tests: `{command}` exited with {status}"
                );
                false
            }
            Err(other) => return Err(other),
        };
        let passed = page_current && observe_step("cargo", &["test", "--workspace"], &app.root)?;
        outcomes.push(outcome("host", passed, start));
    }
    if scope.browser_selected() {
        let start = Instant::now();
        let passed = observe_step("npm", &["run", "e2e"], &app.page_dir())?;
        outcomes.push(outcome("browser", passed, start));
    }

    verdict("frame test", &outcomes)
}

fn outcome(scope: &'static str, passed: bool, start: Instant) -> Outcome {
    Outcome {
        scope,
        passed,
        seconds: start.elapsed().as_secs_f64(),
    }
}

/// Prints the verdict table and converts any failure into the one failed
/// verdict.
fn verdict(verb: &'static str, outcomes: &[Outcome]) -> Result<(), CliError> {
    println!();
    println!("{verb} verdict:");
    for entry in outcomes {
        let mark = if entry.passed { "PASS" } else { "FAIL" };
        println!("  {:<10} {mark}  {:.1}s", entry.scope, entry.seconds);
    }
    let failed: Vec<String> = outcomes
        .iter()
        .filter(|entry| !entry.passed)
        .map(|entry| entry.scope.to_owned())
        .collect();
    if failed.is_empty() {
        println!("{verb}: PASS");
        Ok(())
    } else {
        Err(CliError::VerdictFailed { verb, failed })
    }
}

/// Exactly the tools the selected scopes use — the earliest, smallest
/// honest requirement.
fn prerequisites_for(scope: TestScope) -> Vec<&'static Tool> {
    let mut tools: Vec<&'static Tool> = vec![&doctor::GLEAM, &doctor::ERLANG];
    if scope.host_selected() || scope.browser_selected() {
        tools.push(&doctor::RUST);
        tools.push(&doctor::NODE);
        tools.push(&doctor::NPM);
    }
    tools
}