From d37f75a8787516b088935c73da22ecdcc7776d05 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 25 Apr 2026 19:37:10 -0400 Subject: [PATCH] feat(dev): group cargo dev spa and docs commands --- .github/workflows/nightly.yml | 2 +- .github/workflows/release.yml | 5 +- .github/workflows/rust.yml | 3 +- .github/workflows/typescript.yml | 4 +- AGENTS.md | 6 +- .../src/commands/check_spa_budgets.rs | 94 ++++++++++- lib/crates/fabro-dev/src/commands/docs.rs | 62 +++++++ .../src/commands/generate_cli_reference.rs | 21 +-- .../commands/generate_options_reference.rs | 22 +-- lib/crates/fabro-dev/src/commands/mod.rs | 10 +- .../fabro-dev/src/commands/refresh_spa.rs | 83 ++++++++-- lib/crates/fabro-dev/src/commands/release.rs | 83 ++++------ lib/crates/fabro-dev/src/commands/spa.rs | 42 +++++ lib/crates/fabro-dev/src/main.rs | 18 +-- lib/crates/fabro-dev/tests/it/docs.rs | 152 ++++++++++++++++++ .../tests/it/generate_cli_reference.rs | 122 -------------- .../tests/it/generate_options_reference.rs | 128 --------------- lib/crates/fabro-dev/tests/it/main.rs | 68 ++++++-- lib/crates/fabro-dev/tests/it/release.rs | 25 ++- lib/crates/fabro-dev/tests/it/spa.rs | 124 ++++++++++++-- 20 files changed, 656 insertions(+), 418 deletions(-) create mode 100644 lib/crates/fabro-dev/src/commands/docs.rs create mode 100644 lib/crates/fabro-dev/src/commands/spa.rs create mode 100644 lib/crates/fabro-dev/tests/it/docs.rs delete mode 100644 lib/crates/fabro-dev/tests/it/generate_cli_reference.rs delete mode 100644 lib/crates/fabro-dev/tests/it/generate_options_reference.rs diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 58a7e020d..f63324d41 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -67,4 +67,4 @@ jobs: git remote set-url origin \ "https://x-access-token:${release_token}@github.com/${GITHUB_REPOSITORY}.git" unset release_token - cargo dev release nightly + cargo dev release --nightly diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07cd3e155..f45229a07 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,10 +25,9 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: no-cache: true - - run: bun install + - run: bun install --frozen-lockfile - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - - run: cargo dev refresh-spa - - run: git diff --exit-code -- lib/crates/fabro-spa/assets + - run: cargo dev spa check compile: name: Compile (${{ matrix.target }}) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 598bb64c5..83d5739e6 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -87,8 +87,7 @@ jobs: - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 with: cache-on-failure: true - - run: cargo dev generate-cli-reference --check - - run: cargo dev generate-options-reference --check + - run: cargo dev docs check test: name: Test (Linux) diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index 5ec713e7d..ae1d50e48 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -72,8 +72,6 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - run: bun install --frozen-lockfile - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable - - run: cargo dev refresh-spa - - run: git diff --exit-code -- lib/crates/fabro-spa/assets - - run: cargo dev check-spa-budgets + - run: cargo dev spa check - run: cargo build -p fabro-cli --release - run: wc -c < target/release/fabro diff --git a/AGENTS.md b/AGENTS.md index 6c95d8168..bf4e71a6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,14 +22,14 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24) - `cd apps/fabro-web && bun test` — run tests - `cd apps/fabro-web && bun run typecheck` — type check - `cd apps/fabro-web && bun run build` — production build (writes to `apps/fabro-web/dist/` only; does NOT update the bundled SPA that ships in the Rust binary) -- `cargo dev refresh-spa` — **run this before committing any TypeScript change in `apps/fabro-web/` or `lib/packages/fabro-api-client/`**. It runs the production build and then copies `dist/` into `lib/crates/fabro-spa/assets/` (which is tracked in git). CI's TypeScript `Build` job reruns this command and then `git diff --exit-code -- lib/crates/fabro-spa/assets` — if the committed bundle drifts from source (e.g. content-hashed filenames like `entry-.js` change), the check fails. `bun run build` on its own is not enough. +- `cargo dev spa refresh` — **run this before committing any TypeScript change in `apps/fabro-web/` or `lib/packages/fabro-api-client/`**. It runs the production build, verifies SPA asset budgets, and then copies `dist/` into `lib/crates/fabro-spa/assets/` (which is tracked in git). CI's TypeScript `Build` job runs `cargo dev spa check` — if the committed bundle drifts from source or exceeds budgets, the check fails. `bun run build` on its own is not enough. ### Docker image - `cargo dev docker-build` — builds the local Docker image from the current tree using the release pipeline's cargo-zigbuild approach. Honors `--arch amd64|arm64`, `--tag ` (default `fabro-sh/fabro`), `--compile-only` (stages `tmp/docker-context//fabro` without `docker build`), and `--dry-run` (prints the Docker commands without running them). Prefer this over writing a throwaway Dockerfile; the release pipeline, `Dockerfile`, and this command share the same binary layout. -- Refresh the embedded SPA before rebuilding the image after any `apps/fabro-web` change: `cargo dev refresh-spa` runs the bun build and copies `dist/` into `lib/crates/fabro-spa/assets/`. Skipping this step produces a Docker image whose Rust binary embeds a stale SPA bundle. +- Refresh the embedded SPA before rebuilding the image after any `apps/fabro-web` change: `cargo dev spa refresh` runs the bun build, verifies budgets, and copies `dist/` into `lib/crates/fabro-spa/assets/`. Skipping this step produces a Docker image whose Rust binary embeds a stale SPA bundle. ### Release automation -- `cargo dev release [nightly]` — creates the next stable release or nightly prerelease tag. Use `--dry-run` to print planned commands without mutating git or running Cargo, `--skip-tests` only after running the release-mode smoke yourself, and `--release-date YYYY-MM-DD` or `FABRO_RELEASE_DATE` for deterministic version computation. +- `cargo dev release` — creates the next stable release tag. Use `cargo dev release --nightly` for a nightly prerelease. Use `--dry-run` to print planned commands without mutating git or running Cargo, `--skip-tests` only after running the release-mode smoke yourself, and `--release-date YYYY-MM-DD` or `FABRO_RELEASE_DATE` for deterministic version computation. ### Marketing site (apps/marketing) - `cd apps/marketing && bun run dev` — start Astro dev server diff --git a/lib/crates/fabro-dev/src/commands/check_spa_budgets.rs b/lib/crates/fabro-dev/src/commands/check_spa_budgets.rs index a7dab7a49..9184e863f 100644 --- a/lib/crates/fabro-dev/src/commands/check_spa_budgets.rs +++ b/lib/crates/fabro-dev/src/commands/check_spa_budgets.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; @@ -5,6 +6,7 @@ use anyhow::{Context, Result, bail}; use clap::Args; use walkdir::WalkDir; +use super::refresh_spa::{TempDir, mirror_dist, run_bun_build}; use super::workspace_root; const DEFAULT_ASSET_BUDGET_BYTES: u64 = 15 * 1024 * 1024; @@ -21,16 +23,53 @@ pub(crate) struct CheckSpaBudgetsArgs { /// Override the estimated gzip payload budget. #[arg(long, hide = true, default_value_t = DEFAULT_PAYLOAD_BUDGET_BYTES)] payload_budget_bytes: u64, + /// Skip bun run build and compare an existing dist directory. + #[arg(long, hide = true)] + skip_build: bool, } #[expect( clippy::print_stdout, - reason = "dev check-spa-budgets command reports measured budgets directly" + reason = "dev spa check command reports measured budgets directly" )] pub(crate) fn check_spa_budgets(args: CheckSpaBudgetsArgs) -> Result<()> { let root = args.root.unwrap_or_else(workspace_root); + let web_dir = root.join("apps/fabro-web"); + let dist_dir = web_dir.join("dist"); let asset_dir = root.join("lib/crates/fabro-spa/assets"); - let report = budget_report(&asset_dir)?; + check_spa_asset_budgets( + &asset_dir, + args.asset_budget_bytes, + args.payload_budget_bytes, + )?; + + if !args.skip_build { + println!("Running bun run build in apps/fabro-web..."); + run_bun_build(&web_dir)?; + } + + let staging = TempDir::new(&root, "check")?; + mirror_dist(&dist_dir, staging.path())?; + check_spa_asset_budgets( + staging.path(), + args.asset_budget_bytes, + args.payload_budget_bytes, + )?; + ensure_assets_match(staging.path(), &asset_dir)?; + + Ok(()) +} + +#[expect( + clippy::print_stdout, + reason = "dev spa commands report measured budgets directly" +)] +pub(super) fn check_spa_asset_budgets( + asset_dir: &Path, + asset_budget_bytes: u64, + payload_budget_bytes: u64, +) -> Result<()> { + let report = budget_report(asset_dir)?; println!("fabro-spa asset bytes: {}", report.asset_bytes); println!( @@ -38,19 +77,19 @@ pub(crate) fn check_spa_budgets(args: CheckSpaBudgetsArgs) -> Result<()> { report.compressed_payload_bytes ); - if report.asset_bytes > args.asset_budget_bytes { + if report.asset_bytes > asset_budget_bytes { bail!( - "fabro-spa committed assets exceed budget: {} > {}", + "fabro-spa assets exceed budget: {} > {}", report.asset_bytes, - args.asset_budget_bytes + asset_budget_bytes ); } - if report.compressed_payload_bytes > args.payload_budget_bytes { + if report.compressed_payload_bytes > payload_budget_bytes { bail!( "fabro-spa compressed payload exceeds budget: {} > {}", report.compressed_payload_bytes, - args.payload_budget_bytes + payload_budget_bytes ); } @@ -106,7 +145,7 @@ fn budget_report(asset_dir: &Path) -> Result { #[expect( clippy::disallowed_methods, - reason = "dev check-spa-budgets intentionally shells out to gzip to match the legacy script" + reason = "dev spa check intentionally shells out to gzip to match the legacy script" )] fn gzip_size(file: &Path) -> Result { let output = Command::new("gzip") @@ -129,3 +168,42 @@ fn ensure_gzip_success(file: &Path, output: &Output) -> Result { Ok(output.stdout.len() as u64) } + +fn ensure_assets_match(expected_dir: &Path, actual_dir: &Path) -> Result<()> { + let expected = directory_snapshot(expected_dir)?; + let actual = directory_snapshot(actual_dir)?; + + if expected != actual { + bail!("fabro-spa assets are stale; run `cargo dev spa refresh`"); + } + + Ok(()) +} + +#[expect( + clippy::disallowed_methods, + reason = "dev spa check compares generated asset directories synchronously" +)] +fn directory_snapshot(root: &Path) -> Result>> { + if !root.is_dir() { + bail!("fabro-spa assets directory is missing: {}", root.display()); + } + + let mut snapshot = BTreeMap::new(); + for entry in WalkDir::new(root) { + let entry = entry.context("walking fabro-spa assets")?; + if !entry.file_type().is_file() { + continue; + } + + let path = entry.path(); + let relative = path + .strip_prefix(root) + .with_context(|| format!("{} is not under {}", path.display(), root.display()))? + .to_path_buf(); + let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?; + snapshot.insert(relative, bytes); + } + + Ok(snapshot) +} diff --git a/lib/crates/fabro-dev/src/commands/docs.rs b/lib/crates/fabro-dev/src/commands/docs.rs new file mode 100644 index 000000000..e5aa1af4b --- /dev/null +++ b/lib/crates/fabro-dev/src/commands/docs.rs @@ -0,0 +1,62 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use clap::{Args, Subcommand}; + +use super::generate_cli_reference::generate_cli_reference_root; +use super::generate_options_reference::generate_options_reference_root; +use super::workspace_root; + +#[derive(Debug, Args)] +pub(crate) struct DocsArgs { + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Subcommand)] +enum DocsCommand { + /// Regenerate generated reference documentation. + Refresh(DocsCommandArgs), + /// Verify generated reference documentation is up to date. + Check(DocsCommandArgs), +} + +#[derive(Debug, Args)] +struct DocsCommandArgs { + /// Workspace root containing docs/reference. + #[arg(long, hide = true)] + root: Option, +} + +pub(crate) fn docs(args: DocsArgs) -> Result<()> { + match args.command { + Some(DocsCommand::Refresh(args)) => refresh_docs(&args.root.unwrap_or_else(workspace_root)), + Some(DocsCommand::Check(args)) => check_docs(&args.root.unwrap_or_else(workspace_root)), + None => print_docs_help(), + } +} + +fn refresh_docs(root: &Path) -> Result<()> { + generate_cli_reference_root(root, false)?; + generate_options_reference_root(root, false) +} + +fn check_docs(root: &Path) -> Result<()> { + generate_cli_reference_root(root, true)?; + generate_options_reference_root(root, true) +} + +#[expect( + clippy::print_stdout, + reason = "dev docs command prints group help for non-mutating discovery" +)] +fn print_docs_help() -> Result<()> { + let mut command = DocsCommand::augment_subcommands( + clap::Command::new("docs") + .about("Manage generated reference documentation") + .override_usage("fabro-dev docs "), + ); + command.print_help()?; + println!(); + Ok(()) +} diff --git a/lib/crates/fabro-dev/src/commands/generate_cli_reference.rs b/lib/crates/fabro-dev/src/commands/generate_cli_reference.rs index dc755c8a9..dbf694c9b 100644 --- a/lib/crates/fabro-dev/src/commands/generate_cli_reference.rs +++ b/lib/crates/fabro-dev/src/commands/generate_cli_reference.rs @@ -1,32 +1,21 @@ use std::ffi::OsStr; -use std::path::PathBuf; +use std::path::Path; use anyhow::{Context, Result, bail}; use clap::{Arg, ArgAction, Command}; -use super::{markdown_cell, replace_generated_region, workspace_root}; +use super::{markdown_cell, replace_generated_region}; const CLI_REFERENCE_PATH: &str = "docs/reference/cli.mdx"; const FENCE_START: &str = ""; const FENCE_END: &str = ""; -#[derive(Debug, clap::Args)] -pub(crate) struct GenerateCliReferenceArgs { - /// Verify docs/reference/cli.mdx is up to date without rewriting it. - #[arg(long)] - check: bool, - /// Workspace root containing docs/reference/cli.mdx. - #[arg(long, hide = true)] - root: Option, -} - #[expect( clippy::print_stdout, clippy::disallowed_methods, reason = "dev generator reports the generated docs path directly and intentionally uses sync filesystem I/O" )] -pub(crate) fn generate_cli_reference(args: GenerateCliReferenceArgs) -> Result<()> { - let root = args.root.unwrap_or_else(workspace_root); +pub(crate) fn generate_cli_reference_root(root: &Path, check: bool) -> Result<()> { let path = root.join(CLI_REFERENCE_PATH); let current = std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; @@ -39,9 +28,9 @@ pub(crate) fn generate_cli_reference(args: GenerateCliReferenceArgs) -> Result<( FENCE_END, )?; - if args.check { + if check { if current != updated { - bail!("{CLI_REFERENCE_PATH} is stale; run `cargo dev generate-cli-reference`"); + bail!("{CLI_REFERENCE_PATH} is stale; run `cargo dev docs refresh`"); } println!("{CLI_REFERENCE_PATH} is up to date."); return Ok(()); diff --git a/lib/crates/fabro-dev/src/commands/generate_options_reference.rs b/lib/crates/fabro-dev/src/commands/generate_options_reference.rs index 775e75bce..f0ab12a62 100644 --- a/lib/crates/fabro-dev/src/commands/generate_options_reference.rs +++ b/lib/crates/fabro-dev/src/commands/generate_options_reference.rs @@ -1,33 +1,21 @@ use std::collections::BTreeMap; -use std::path::PathBuf; +use std::path::Path; use anyhow::{Context, Result, bail}; use fabro_options_metadata::{OptionField, OptionSet}; -use super::{markdown_cell, replace_generated_region, workspace_root}; +use super::{markdown_cell, replace_generated_region}; const OPTIONS_REFERENCE_PATH: &str = "docs/reference/user-configuration.mdx"; const FENCE_START: &str = ""; const FENCE_END: &str = ""; -#[derive(Debug, clap::Args)] -pub(crate) struct GenerateOptionsReferenceArgs { - /// Verify docs/reference/user-configuration.mdx is up to date without - /// rewriting it. - #[arg(long)] - check: bool, - /// Workspace root containing docs/reference/user-configuration.mdx. - #[arg(long, hide = true)] - root: Option, -} - #[expect( clippy::print_stdout, clippy::disallowed_methods, reason = "dev generator reports the generated docs path directly and intentionally uses sync filesystem I/O" )] -pub(crate) fn generate_options_reference(args: GenerateOptionsReferenceArgs) -> Result<()> { - let root = args.root.unwrap_or_else(workspace_root); +pub(crate) fn generate_options_reference_root(root: &Path, check: bool) -> Result<()> { let path = root.join(OPTIONS_REFERENCE_PATH); let current = std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; @@ -40,9 +28,9 @@ pub(crate) fn generate_options_reference(args: GenerateOptionsReferenceArgs) -> FENCE_END, )?; - if args.check { + if check { if current != updated { - bail!("{OPTIONS_REFERENCE_PATH} is stale; run `cargo dev generate-options-reference`"); + bail!("{OPTIONS_REFERENCE_PATH} is stale; run `cargo dev docs refresh`"); } println!("{OPTIONS_REFERENCE_PATH} is up to date."); return Ok(()); diff --git a/lib/crates/fabro-dev/src/commands/mod.rs b/lib/crates/fabro-dev/src/commands/mod.rs index 4fbaef977..9a44b07ac 100644 --- a/lib/crates/fabro-dev/src/commands/mod.rs +++ b/lib/crates/fabro-dev/src/commands/mod.rs @@ -1,22 +1,20 @@ mod check_spa_budgets; mod docker_build; +mod docs; mod generate_cli_reference; mod generate_options_reference; mod refresh_spa; mod release; +mod spa; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use anyhow::{Context, Result}; -pub(crate) use check_spa_budgets::{CheckSpaBudgetsArgs, check_spa_budgets}; pub(crate) use docker_build::{DockerBuildArgs, docker_build}; -pub(crate) use generate_cli_reference::{GenerateCliReferenceArgs, generate_cli_reference}; -pub(crate) use generate_options_reference::{ - GenerateOptionsReferenceArgs, generate_options_reference, -}; -pub(crate) use refresh_spa::{RefreshSpaArgs, refresh_spa}; +pub(crate) use docs::{DocsArgs, docs}; pub(crate) use release::{ReleaseArgs, release}; +pub(crate) use spa::{SpaArgs, spa}; pub(crate) fn workspace_root() -> PathBuf { let mut root = Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf(); diff --git a/lib/crates/fabro-dev/src/commands/refresh_spa.rs b/lib/crates/fabro-dev/src/commands/refresh_spa.rs index 57aee06ad..899fe4b36 100644 --- a/lib/crates/fabro-dev/src/commands/refresh_spa.rs +++ b/lib/crates/fabro-dev/src/commands/refresh_spa.rs @@ -5,28 +5,48 @@ use anyhow::{Context, Result, bail}; use clap::Args; use walkdir::WalkDir; +use super::check_spa_budgets::check_spa_asset_budgets; use super::workspace_root; +const DEFAULT_ASSET_BUDGET_BYTES: u64 = 15 * 1024 * 1024; +const DEFAULT_PAYLOAD_BUDGET_BYTES: u64 = 5 * 1024 * 1024; + #[derive(Debug, Args)] pub(crate) struct RefreshSpaArgs { /// Repository root containing apps/fabro-web and lib/crates/fabro-spa. #[arg(long, hide = true)] - root: Option, + root: Option, /// Skip bun run build and only mirror an existing dist directory. #[arg(long, hide = true)] - skip_build: bool, + pub(super) skip_build: bool, + /// Override the raw asset budget. + #[arg(long, hide = true, default_value_t = DEFAULT_ASSET_BUDGET_BYTES)] + pub(super) asset_budget_bytes: u64, + /// Override the estimated gzip payload budget. + #[arg(long, hide = true, default_value_t = DEFAULT_PAYLOAD_BUDGET_BYTES)] + pub(super) payload_budget_bytes: u64, } pub(crate) fn refresh_spa(args: RefreshSpaArgs) -> Result<()> { let root = args.root.unwrap_or_else(workspace_root); - refresh_spa_root(&root, args.skip_build) + refresh_spa_root( + &root, + args.skip_build, + args.asset_budget_bytes, + args.payload_budget_bytes, + ) } #[expect( clippy::print_stdout, - reason = "dev refresh-spa command reports progress directly" + reason = "dev spa refresh command reports progress directly" )] -pub(super) fn refresh_spa_root(root: &Path, skip_build: bool) -> Result<()> { +pub(super) fn refresh_spa_root( + root: &Path, + skip_build: bool, + asset_budget_bytes: u64, + payload_budget_bytes: u64, +) -> Result<()> { let web_dir = root.join("apps/fabro-web"); let dist_dir = web_dir.join("dist"); let asset_dir = root.join("lib/crates/fabro-spa/assets"); @@ -36,7 +56,10 @@ pub(super) fn refresh_spa_root(root: &Path, skip_build: bool) -> Result<()> { run_bun_build(&web_dir)?; } - mirror_dist(&dist_dir, &asset_dir)?; + let staging = TempDir::new(root, "refresh")?; + mirror_dist(&dist_dir, staging.path())?; + check_spa_asset_budgets(staging.path(), asset_budget_bytes, payload_budget_bytes)?; + mirror_dist(staging.path(), &asset_dir)?; println!("Refreshed lib/crates/fabro-spa/assets"); Ok(()) @@ -44,9 +67,9 @@ pub(super) fn refresh_spa_root(root: &Path, skip_build: bool) -> Result<()> { #[expect( clippy::disallowed_methods, - reason = "dev refresh-spa intentionally runs a synchronous Bun subprocess" + reason = "dev spa refresh intentionally runs a synchronous Bun subprocess" )] -fn run_bun_build(web_dir: &Path) -> Result<()> { +pub(super) fn run_bun_build(web_dir: &Path) -> Result<()> { let status = Command::new("bun") .args(["run", "build"]) .current_dir(web_dir) @@ -61,9 +84,9 @@ fn run_bun_build(web_dir: &Path) -> Result<()> { #[expect( clippy::disallowed_methods, - reason = "dev refresh-spa mirrors build output with synchronous filesystem operations" + reason = "dev spa refresh mirrors build output with synchronous filesystem operations" )] -fn mirror_dist(dist_dir: &Path, asset_dir: &Path) -> Result<()> { +pub(super) fn mirror_dist(dist_dir: &Path, asset_dir: &Path) -> Result<()> { if !dist_dir.is_dir() { bail!("apps/fabro-web/dist is missing; run `bun run build` before mirroring SPA assets"); } @@ -107,3 +130,43 @@ fn mirror_dist(dist_dir: &Path, asset_dir: &Path) -> Result<()> { Ok(()) } + +pub(super) struct TempDir { + path: PathBuf, +} + +impl TempDir { + pub(super) fn new(root: &Path, label: &str) -> Result { + let base = root.join("tmp"); + std::fs::create_dir_all(&base).with_context(|| format!("creating {}", base.display()))?; + + for attempt in 0..100 { + let path = base.join(format!( + "fabro-dev-spa-{label}-{}-{attempt}", + std::process::id() + )); + match std::fs::create_dir(&path) { + Ok(()) => return Ok(Self { path }), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error).with_context(|| format!("creating {}", path.display())); + } + } + } + + bail!( + "could not create temporary SPA staging directory under {}", + base.display() + ) + } + + pub(super) fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} diff --git a/lib/crates/fabro-dev/src/commands/release.rs b/lib/crates/fabro-dev/src/commands/release.rs index dbbeda0b6..17e8f45c1 100644 --- a/lib/crates/fabro-dev/src/commands/release.rs +++ b/lib/crates/fabro-dev/src/commands/release.rs @@ -1,11 +1,9 @@ -use std::fmt; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use chrono::{Local, NaiveDate}; -use clap::{Args, ValueEnum}; +use clap::Args; -use super::refresh_spa::refresh_spa_root; use super::{PlannedCommand, capture_command, run_command, workspace_root}; const RELEASE_EPOCH: &str = "2026-01-01"; @@ -13,42 +11,29 @@ const RELEASE_TEST_SEGMENT_WRITE_KEY: &str = "fake-for-local-smoke"; #[derive(Debug, Args)] pub(crate) struct ReleaseArgs { - /// Pre-release label to create. - #[arg(value_enum)] - prerelease_label: Option, + /// Cut a nightly prerelease instead of a stable release. + #[arg(long)] + nightly: bool, /// Print planned release steps without mutating git or running Cargo. #[arg(long)] - dry_run: bool, + dry_run: bool, /// Skip the release-mode test smoke. #[arg(long)] - skip_tests: bool, + skip_tests: bool, /// Release date to use for version computation. #[arg(long, value_name = "YYYY-MM-DD", env = "FABRO_RELEASE_DATE")] - release_date: Option, + release_date: Option, /// Repository root to release. #[arg(long, hide = true)] - root: Option, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] -enum PrereleaseLabel { - Nightly, -} - -impl fmt::Display for PrereleaseLabel { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Nightly => formatter.write_str("nightly"), - } - } + root: Option, } struct ReleasePlan { - prerelease_label: Option, - release_date: NaiveDate, - dry_run: bool, - skip_tests: bool, - root: PathBuf, + nightly: bool, + release_date: NaiveDate, + dry_run: bool, + skip_tests: bool, + root: PathBuf, } #[expect( @@ -57,13 +42,13 @@ struct ReleasePlan { )] pub(crate) fn release(args: ReleaseArgs) -> Result<()> { let plan = ReleasePlan { - prerelease_label: args.prerelease_label, - release_date: args + nightly: args.nightly, + release_date: args .release_date .unwrap_or_else(|| Local::now().date_naive()), - dry_run: args.dry_run, - skip_tests: args.skip_tests, - root: args.root.unwrap_or_else(workspace_root), + dry_run: args.dry_run, + skip_tests: args.skip_tests, + root: args.root.unwrap_or_else(workspace_root), }; let cargo_toml = plan.root.join("Cargo.toml"); @@ -157,13 +142,13 @@ impl ReleasePlan { } fn compute_release_version(&self, base_version: &str) -> Result { - let Some(prerelease_label) = self.prerelease_label else { + if !self.nightly { return Ok(base_version.to_string()); - }; + } let mut prerelease_number = 0; loop { - let version = format!("{base_version}-{prerelease_label}.{prerelease_number}"); + let version = format!("{base_version}-nightly.{prerelease_number}"); if !self.tag_exists(&format!("v{version}"))? { return Ok(version); } @@ -193,13 +178,7 @@ impl ReleasePlan { } fn verify_spa_assets(&self) -> Result<()> { - refresh_spa_root(&self.root, false)?; - let output = capture_command(&self.root, &Self::spa_assets_diff_command())?; - if !output.status.success() { - bail!("fabro-spa assets are stale. Commit the refreshed assets before releasing."); - } - - Ok(()) + run_command(&self.root, &Self::spa_check_command()) } #[expect( @@ -222,8 +201,7 @@ impl ReleasePlan { )] fn print_dry_run(&self, current_version: &str, new_version: &str, tag: &str) { println!("DRY RUN: would verify SPA assets:"); - println!("{}", Self::refresh_spa_command().to_shell_line()); - println!("{}", Self::spa_assets_diff_command().to_shell_line()); + println!("{}", Self::spa_check_command().to_shell_line()); if self.skip_tests { println!("--skip-tests set, would skip release-mode test smoke"); @@ -261,16 +239,11 @@ impl ReleasePlan { } } - fn refresh_spa_command() -> PlannedCommand { - PlannedCommand::new("cargo").arg("dev").arg("refresh-spa") - } - - fn spa_assets_diff_command() -> PlannedCommand { - PlannedCommand::new("git") - .arg("diff") - .arg("--exit-code") - .arg("--") - .arg("lib/crates/fabro-spa/assets") + fn spa_check_command() -> PlannedCommand { + PlannedCommand::new("cargo") + .arg("dev") + .arg("spa") + .arg("check") } fn release_tests_command() -> PlannedCommand { diff --git a/lib/crates/fabro-dev/src/commands/spa.rs b/lib/crates/fabro-dev/src/commands/spa.rs new file mode 100644 index 000000000..7be521b09 --- /dev/null +++ b/lib/crates/fabro-dev/src/commands/spa.rs @@ -0,0 +1,42 @@ +use anyhow::Result; +use clap::{Args, Subcommand}; + +use super::check_spa_budgets::{CheckSpaBudgetsArgs, check_spa_budgets}; +use super::refresh_spa::{RefreshSpaArgs, refresh_spa}; + +#[derive(Debug, Args)] +pub(crate) struct SpaArgs { + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Subcommand)] +enum SpaCommand { + /// Rebuild and refresh the embedded Fabro web SPA bundle. + Refresh(RefreshSpaArgs), + /// Verify embedded Fabro web SPA assets are current and within budget. + Check(CheckSpaBudgetsArgs), +} + +pub(crate) fn spa(args: SpaArgs) -> Result<()> { + match args.command { + Some(SpaCommand::Refresh(args)) => refresh_spa(args), + Some(SpaCommand::Check(args)) => check_spa_budgets(args), + None => print_spa_help(), + } +} + +#[expect( + clippy::print_stdout, + reason = "dev spa command prints group help for non-mutating discovery" +)] +fn print_spa_help() -> Result<()> { + let mut command = SpaCommand::augment_subcommands( + clap::Command::new("spa") + .about("Manage embedded Fabro web SPA assets") + .override_usage("fabro-dev spa "), + ); + command.print_help()?; + println!(); + Ok(()) +} diff --git a/lib/crates/fabro-dev/src/main.rs b/lib/crates/fabro-dev/src/main.rs index 476b54ed6..c18f74ce2 100644 --- a/lib/crates/fabro-dev/src/main.rs +++ b/lib/crates/fabro-dev/src/main.rs @@ -20,27 +20,21 @@ struct Cli { enum Command { /// Build Fabro Docker images with the release pipeline layout. DockerBuild(commands::DockerBuildArgs), - /// Generate docs/reference/cli.mdx from the Fabro clap command tree. - GenerateCliReference(commands::GenerateCliReferenceArgs), - /// Generate docs/reference/user-configuration.mdx from settings metadata. - GenerateOptionsReference(commands::GenerateOptionsReferenceArgs), + /// Manage generated reference documentation. + Docs(commands::DocsArgs), /// Run Fabro release automation. Release(commands::ReleaseArgs), - /// Refresh the embedded Fabro web SPA bundle. - RefreshSpa(commands::RefreshSpaArgs), - /// Check embedded Fabro web SPA asset budgets. - CheckSpaBudgets(commands::CheckSpaBudgetsArgs), + /// Manage embedded Fabro web SPA assets. + Spa(commands::SpaArgs), } impl Command { fn run(self) -> Result<()> { match self { Self::DockerBuild(args) => commands::docker_build(args), - Self::GenerateCliReference(args) => commands::generate_cli_reference(args), - Self::GenerateOptionsReference(args) => commands::generate_options_reference(args), + Self::Docs(args) => commands::docs(args), Self::Release(args) => commands::release(args), - Self::RefreshSpa(args) => commands::refresh_spa(args), - Self::CheckSpaBudgets(args) => commands::check_spa_budgets(args), + Self::Spa(args) => commands::spa(args), } } } diff --git a/lib/crates/fabro-dev/tests/it/docs.rs b/lib/crates/fabro-dev/tests/it/docs.rs new file mode 100644 index 000000000..f619d6a4b --- /dev/null +++ b/lib/crates/fabro-dev/tests/it/docs.rs @@ -0,0 +1,152 @@ +use std::path::Path; + +use super::{fabro_dev, output_text, read_file, write_file}; + +fn docs_refresh(root: &Path) -> assert_cmd::Command { + let mut cmd = fabro_dev(); + cmd.args(["docs", "refresh", "--root"]).arg(root); + cmd +} + +fn docs_check(root: &Path) -> assert_cmd::Command { + let mut cmd = fabro_dev(); + cmd.args(["docs", "check", "--root"]).arg(root); + cmd +} + +fn write_reference_fixtures(root: &Path) { + write_file( + root, + "docs/reference/cli.mdx", + r"--- +title: CLI +--- + +Intro copy. + + +stale cli + + +Tail copy. +", + ); + write_file( + root, + "docs/reference/user-configuration.mdx", + r"--- +title: Settings +--- + +Settings intro copy. + + +stale options + + +Settings tail copy. +", + ); +} + +#[test] +fn refresh_updates_both_reference_files() { + let fixture = tempfile::tempdir().expect("creating fixture"); + write_reference_fixtures(fixture.path()); + + docs_refresh(fixture.path()).assert().success(); + + let cli = read_file(fixture.path(), "docs/reference/cli.mdx"); + assert!( + cli.contains("Intro copy."), + "manual cli intro should be preserved:\n{cli}" + ); + assert!( + cli.contains("Tail copy."), + "manual cli tail should be preserved:\n{cli}" + ); + assert!( + cli.contains("## `fabro`"), + "generated cli output should include root command reference:\n{cli}" + ); + assert!( + cli.contains("### `fabro run`"), + "generated cli output should include subcommand reference:\n{cli}" + ); + assert!( + !cli.contains("stale cli"), + "stale cli generated content should be replaced:\n{cli}" + ); + + let options = read_file(fixture.path(), "docs/reference/user-configuration.mdx"); + assert!( + options.contains("Settings intro copy."), + "manual options intro should be preserved:\n{options}" + ); + assert!( + options.contains("Settings tail copy."), + "manual options tail should be preserved:\n{options}" + ); + assert!( + options.contains("## `[cli.output]`"), + "generated options output should include cli output settings:\n{options}" + ); + assert!( + options.contains("| `format` |"), + "generated options output should include option fields:\n{options}" + ); + assert!( + options.contains("## `[run.model]`"), + "generated options output should include run model settings:\n{options}" + ); + assert!( + !options.contains("stale options"), + "stale options generated content should be replaced:\n{options}" + ); +} + +#[test] +fn check_passes_after_refresh() { + let fixture = tempfile::tempdir().expect("creating fixture"); + write_reference_fixtures(fixture.path()); + + docs_refresh(fixture.path()).assert().success(); + + docs_check(fixture.path()).assert().success(); +} + +#[test] +fn check_fails_when_either_generated_region_is_stale() { + let fixture = tempfile::tempdir().expect("creating fixture"); + write_reference_fixtures(fixture.path()); + + let output = docs_check(fixture.path()) + .assert() + .failure() + .code(1) + .get_output() + .clone(); + let stderr = output_text(&output.stderr); + + assert!( + stderr.contains("docs/reference/cli.mdx is stale; run `cargo dev docs refresh`"), + "check failure should explain how to regenerate docs:\n{stderr}" + ); +} + +#[test] +fn refresh_is_deterministic() { + let fixture = tempfile::tempdir().expect("creating fixture"); + write_reference_fixtures(fixture.path()); + + docs_refresh(fixture.path()).assert().success(); + let first_cli = read_file(fixture.path(), "docs/reference/cli.mdx"); + let first_options = read_file(fixture.path(), "docs/reference/user-configuration.mdx"); + + docs_refresh(fixture.path()).assert().success(); + let second_cli = read_file(fixture.path(), "docs/reference/cli.mdx"); + let second_options = read_file(fixture.path(), "docs/reference/user-configuration.mdx"); + + assert_eq!(first_cli, second_cli); + assert_eq!(first_options, second_options); +} diff --git a/lib/crates/fabro-dev/tests/it/generate_cli_reference.rs b/lib/crates/fabro-dev/tests/it/generate_cli_reference.rs deleted file mode 100644 index ab54cab2a..000000000 --- a/lib/crates/fabro-dev/tests/it/generate_cli_reference.rs +++ /dev/null @@ -1,122 +0,0 @@ -use std::path::Path; - -use super::{fabro_dev, output_text, read_file, write_file}; - -fn cli_reference(root: &Path) -> assert_cmd::Command { - let mut cmd = fabro_dev(); - cmd.args(["generate-cli-reference", "--root"]).arg(root); - cmd -} - -#[test] -fn write_updates_only_generated_region() { - let fixture = tempfile::tempdir().expect("creating fixture"); - write_file( - fixture.path(), - "docs/reference/cli.mdx", - r"--- -title: CLI ---- - -Intro copy. - - -stale - - -Tail copy. -", - ); - - cli_reference(fixture.path()).assert().success(); - - let contents = read_file(fixture.path(), "docs/reference/cli.mdx"); - assert!( - contents.contains("Intro copy."), - "manual intro should be preserved:\n{contents}" - ); - assert!( - contents.contains("Tail copy."), - "manual tail should be preserved:\n{contents}" - ); - assert!( - contents.contains("## `fabro`"), - "generated output should include root command reference:\n{contents}" - ); - assert!( - contents.contains("### `fabro run`"), - "generated output should include subcommand reference:\n{contents}" - ); - assert!( - !contents.contains("stale"), - "stale generated content should be replaced:\n{contents}" - ); -} - -#[test] -fn check_passes_after_write() { - let fixture = tempfile::tempdir().expect("creating fixture"); - write_file( - fixture.path(), - "docs/reference/cli.mdx", - r" -stale - -", - ); - - cli_reference(fixture.path()).assert().success(); - - cli_reference(fixture.path()) - .arg("--check") - .assert() - .success(); -} - -#[test] -fn check_fails_when_generated_region_is_stale() { - let fixture = tempfile::tempdir().expect("creating fixture"); - write_file( - fixture.path(), - "docs/reference/cli.mdx", - r" -stale - -", - ); - - let output = cli_reference(fixture.path()) - .arg("--check") - .assert() - .failure() - .code(1) - .get_output() - .clone(); - let stderr = output_text(&output.stderr); - - assert!( - stderr.contains("docs/reference/cli.mdx is stale; run `cargo dev generate-cli-reference`"), - "check failure should explain how to regenerate:\n{stderr}" - ); -} - -#[test] -fn generated_reference_is_deterministic() { - let fixture = tempfile::tempdir().expect("creating fixture"); - write_file( - fixture.path(), - "docs/reference/cli.mdx", - r" -stale - -", - ); - - cli_reference(fixture.path()).assert().success(); - let first = read_file(fixture.path(), "docs/reference/cli.mdx"); - - cli_reference(fixture.path()).assert().success(); - let second = read_file(fixture.path(), "docs/reference/cli.mdx"); - - assert_eq!(first, second); -} diff --git a/lib/crates/fabro-dev/tests/it/generate_options_reference.rs b/lib/crates/fabro-dev/tests/it/generate_options_reference.rs deleted file mode 100644 index 7d9da4e80..000000000 --- a/lib/crates/fabro-dev/tests/it/generate_options_reference.rs +++ /dev/null @@ -1,128 +0,0 @@ -use std::path::Path; - -use super::{fabro_dev, output_text, read_file, write_file}; - -fn options_reference(root: &Path) -> assert_cmd::Command { - let mut cmd = fabro_dev(); - cmd.args(["generate-options-reference", "--root"]).arg(root); - cmd -} - -#[test] -fn write_updates_only_generated_region() { - let fixture = tempfile::tempdir().expect("creating fixture"); - write_file( - fixture.path(), - "docs/reference/user-configuration.mdx", - r"--- -title: Settings ---- - -Intro copy. - - -stale - - -Tail copy. -", - ); - - options_reference(fixture.path()).assert().success(); - - let contents = read_file(fixture.path(), "docs/reference/user-configuration.mdx"); - assert!( - contents.contains("Intro copy."), - "manual intro should be preserved:\n{contents}" - ); - assert!( - contents.contains("Tail copy."), - "manual tail should be preserved:\n{contents}" - ); - assert!( - contents.contains("## `[cli.output]`"), - "generated output should include cli output settings:\n{contents}" - ); - assert!( - contents.contains("| `format` |"), - "generated output should include option fields:\n{contents}" - ); - assert!( - contents.contains("## `[run.model]`"), - "generated output should include run model settings:\n{contents}" - ); - assert!( - !contents.contains("stale"), - "stale generated content should be replaced:\n{contents}" - ); -} - -#[test] -fn check_passes_after_write() { - let fixture = tempfile::tempdir().expect("creating fixture"); - write_file( - fixture.path(), - "docs/reference/user-configuration.mdx", - r" -stale - -", - ); - - options_reference(fixture.path()).assert().success(); - - options_reference(fixture.path()) - .arg("--check") - .assert() - .success(); -} - -#[test] -fn check_fails_when_generated_region_is_stale() { - let fixture = tempfile::tempdir().expect("creating fixture"); - write_file( - fixture.path(), - "docs/reference/user-configuration.mdx", - r" -stale - -", - ); - - let output = options_reference(fixture.path()) - .arg("--check") - .assert() - .failure() - .code(1) - .get_output() - .clone(); - let stderr = output_text(&output.stderr); - - assert!( - stderr.contains( - "docs/reference/user-configuration.mdx is stale; run `cargo dev generate-options-reference`" - ), - "check failure should explain how to regenerate:\n{stderr}" - ); -} - -#[test] -fn generated_reference_is_deterministic() { - let fixture = tempfile::tempdir().expect("creating fixture"); - write_file( - fixture.path(), - "docs/reference/user-configuration.mdx", - r" -stale - -", - ); - - options_reference(fixture.path()).assert().success(); - let first = read_file(fixture.path(), "docs/reference/user-configuration.mdx"); - - options_reference(fixture.path()).assert().success(); - let second = read_file(fixture.path(), "docs/reference/user-configuration.mdx"); - - assert_eq!(first, second); -} diff --git a/lib/crates/fabro-dev/tests/it/main.rs b/lib/crates/fabro-dev/tests/it/main.rs index dc1f83a76..a04f68b36 100644 --- a/lib/crates/fabro-dev/tests/it/main.rs +++ b/lib/crates/fabro-dev/tests/it/main.rs @@ -1,8 +1,7 @@ use std::path::{Path, PathBuf}; mod docker_build; -mod generate_cli_reference; -mod generate_options_reference; +mod docs; mod release; mod spa; @@ -41,6 +40,14 @@ fn read_file(root: &Path, path: &str) -> String { std::fs::read_to_string(root.join(path)).expect("reading fixture file") } +#[expect( + clippy::disallowed_methods, + reason = "integration tests inspect fixture files with sync std::fs::read" +)] +fn read_bytes(root: &Path, path: &str) -> Vec { + std::fs::read(root.join(path)).expect("reading fixture file") +} + #[test] fn help_lists_scaffolded_commands() { let output = fabro_dev() @@ -51,19 +58,60 @@ fn help_lists_scaffolded_commands() { .clone(); let stdout = output_text(&output.stdout); - for command in [ - "docker-build", - "generate-cli-reference", - "generate-options-reference", - "release", - "refresh-spa", - "check-spa-budgets", - ] { + for command in ["docker-build", "docs", "release", "spa"] { assert!( stdout.contains(command), "top-level help should list {command}:\n{stdout}" ); } + + for removed_command in [ + concat!("generate-cli", "-reference"), + concat!("generate-options", "-reference"), + concat!("refresh", "-spa"), + concat!("check-spa", "-budgets"), + ] { + assert!( + !stdout.contains(removed_command), + "top-level help should not list removed command {removed_command}:\n{stdout}" + ); + } +} + +#[test] +fn group_only_spa_prints_subcommand_help_successfully() { + let output = fabro_dev() + .arg("spa") + .assert() + .success() + .get_output() + .clone(); + let stdout = output_text(&output.stdout); + + for command in ["refresh", "check"] { + assert!( + stdout.contains(command), + "spa help should list {command}:\n{stdout}" + ); + } +} + +#[test] +fn group_only_docs_prints_subcommand_help_successfully() { + let output = fabro_dev() + .arg("docs") + .assert() + .success() + .get_output() + .clone(); + let stdout = output_text(&output.stdout); + + for command in ["refresh", "check"] { + assert!( + stdout.contains(command), + "docs help should list {command}:\n{stdout}" + ); + } } #[test] diff --git a/lib/crates/fabro-dev/tests/it/release.rs b/lib/crates/fabro-dev/tests/it/release.rs index 193c3c3fe..4bc95cee0 100644 --- a/lib/crates/fabro-dev/tests/it/release.rs +++ b/lib/crates/fabro-dev/tests/it/release.rs @@ -56,12 +56,17 @@ fn help_lists_release_flags() { .clone(); let stdout = output_text(&output.stdout); - for flag in ["--dry-run", "--skip-tests", "--release-date"] { + for flag in ["--dry-run", "--skip-tests", "--release-date", "--nightly"] { assert!( stdout.contains(flag), "release help should list {flag}:\n{stdout}" ); } + + assert!( + !stdout.contains("[PRERELEASE_LABEL]"), + "release help should not list a prerelease positional:\n{stdout}" + ); } #[test] @@ -90,6 +95,14 @@ fn dry_run_computes_stable_version_from_date() { stdout.contains("Releasing 0.100.0 (tag v0.100.0)"), "dry-run should compute base version from date:\n{stdout}" ); + assert!( + stdout.contains("cargo dev spa check"), + "dry-run should print one SPA verification command:\n{stdout}" + ); + assert!( + !stdout.contains("git diff --exit-code -- lib/crates/fabro-spa/assets"), + "dry-run should not print a separate SPA asset diff command:\n{stdout}" + ); assert!( stdout.contains("git tag -a v0.100.0 -m v0.100.0"), "dry-run should print release tag command:\n{stdout}" @@ -118,7 +131,7 @@ fn dry_run_increments_existing_prerelease_number() { "--release-date", "2026-01-01", "--dry-run", - "nightly", + "--nightly", ]) .assert() .success() @@ -133,9 +146,9 @@ fn dry_run_increments_existing_prerelease_number() { } #[test] -fn invalid_prerelease_label_fails_with_clap_error() { +fn positional_nightly_fails_with_clap_error() { let output = fabro_dev() - .args(["release", "beta"]) + .args(["release", "nightly"]) .assert() .failure() .code(2) @@ -144,8 +157,8 @@ fn invalid_prerelease_label_fails_with_clap_error() { let stderr = output_text(&output.stderr); assert!( - stderr.contains("invalid value 'beta'"), - "invalid prerelease label should be rejected by clap:\n{stderr}" + stderr.contains("unexpected argument 'nightly'"), + "nightly positional should be rejected by clap:\n{stderr}" ); } diff --git a/lib/crates/fabro-dev/tests/it/spa.rs b/lib/crates/fabro-dev/tests/it/spa.rs index ced259598..07b038d2e 100644 --- a/lib/crates/fabro-dev/tests/it/spa.rs +++ b/lib/crates/fabro-dev/tests/it/spa.rs @@ -1,7 +1,7 @@ -use super::{fabro_dev, output_text, write_file}; +use super::{fabro_dev, output_text, read_bytes, write_file}; #[test] -fn refresh_spa_mirrors_dist_and_removes_source_maps() { +fn refresh_mirrors_dist_and_removes_source_maps() { let fixture = tempfile::tempdir().expect("creating fixture"); write_file(fixture.path(), "apps/fabro-web/dist/index.html", b"index"); write_file(fixture.path(), "apps/fabro-web/dist/assets/app.js", b"app"); @@ -18,7 +18,8 @@ fn refresh_spa_mirrors_dist_and_removes_source_maps() { let output = fabro_dev() .args([ - "refresh-spa", + "spa", + "refresh", "--root", fixture .path() @@ -34,7 +35,7 @@ fn refresh_spa_mirrors_dist_and_removes_source_maps() { assert!( stdout.contains("Refreshed lib/crates/fabro-spa/assets"), - "refresh-spa should report refreshed assets:\n{stdout}" + "spa refresh should report refreshed assets:\n{stdout}" ); assert!( fixture @@ -63,12 +64,13 @@ fn refresh_spa_mirrors_dist_and_removes_source_maps() { } #[test] -fn refresh_spa_missing_dist_errors_cleanly() { +fn refresh_missing_dist_errors_cleanly() { let fixture = tempfile::tempdir().expect("creating fixture"); let output = fabro_dev() .args([ - "refresh-spa", + "spa", + "refresh", "--root", fixture .path() @@ -90,8 +92,51 @@ fn refresh_spa_missing_dist_errors_cleanly() { } #[test] -fn check_spa_budgets_passes_fixture_assets() { +fn refresh_budget_failure_leaves_assets_untouched() { let fixture = tempfile::tempdir().expect("creating fixture"); + write_file(fixture.path(), "apps/fabro-web/dist/index.html", b"hello"); + write_file( + fixture.path(), + "lib/crates/fabro-spa/assets/index.html", + b"committed", + ); + + let output = fabro_dev() + .args([ + "spa", + "refresh", + "--root", + fixture + .path() + .to_str() + .expect("fixture path should be utf-8"), + "--skip-build", + "--asset-budget-bytes", + "4", + "--payload-budget-bytes", + "100", + ]) + .assert() + .failure() + .code(1) + .get_output() + .clone(); + let stderr = output_text(&output.stderr); + + assert!( + stderr.contains("fabro-spa assets exceed budget: 5 > 4"), + "budget failure should report raw byte overage:\n{stderr}" + ); + assert_eq!( + read_bytes(fixture.path(), "lib/crates/fabro-spa/assets/index.html"), + b"committed" + ); +} + +#[test] +fn check_passes_when_dist_matches_assets_and_budgets_pass() { + let fixture = tempfile::tempdir().expect("creating fixture"); + write_file(fixture.path(), "apps/fabro-web/dist/index.html", b"hello"); write_file( fixture.path(), "lib/crates/fabro-spa/assets/index.html", @@ -100,7 +145,8 @@ fn check_spa_budgets_passes_fixture_assets() { let output = fabro_dev() .args([ - "check-spa-budgets", + "spa", + "check", "--root", fixture .path() @@ -110,6 +156,7 @@ fn check_spa_budgets_passes_fixture_assets() { "100", "--payload-budget-bytes", "100", + "--skip-build", ]) .assert() .success() @@ -128,8 +175,9 @@ fn check_spa_budgets_passes_fixture_assets() { } #[test] -fn check_spa_budgets_fails_when_assets_exceed_budget() { +fn check_fails_when_assets_exceed_budget() { let fixture = tempfile::tempdir().expect("creating fixture"); + write_file(fixture.path(), "apps/fabro-web/dist/index.html", b"hello"); write_file( fixture.path(), "lib/crates/fabro-spa/assets/index.html", @@ -138,7 +186,8 @@ fn check_spa_budgets_fails_when_assets_exceed_budget() { let output = fabro_dev() .args([ - "check-spa-budgets", + "spa", + "check", "--root", fixture .path() @@ -157,14 +206,53 @@ fn check_spa_budgets_fails_when_assets_exceed_budget() { let stderr = output_text(&output.stderr); assert!( - stderr.contains("fabro-spa committed assets exceed budget: 5 > 4"), + stderr.contains("fabro-spa assets exceed budget: 5 > 4"), "budget failure should report raw byte overage:\n{stderr}" ); } #[test] -fn check_spa_budgets_fails_when_source_map_is_present() { +fn check_fails_when_assets_do_not_match_dist() { let fixture = tempfile::tempdir().expect("creating fixture"); + write_file(fixture.path(), "apps/fabro-web/dist/index.html", b"current"); + write_file( + fixture.path(), + "lib/crates/fabro-spa/assets/index.html", + b"committed", + ); + + let output = fabro_dev() + .args([ + "spa", + "check", + "--root", + fixture + .path() + .to_str() + .expect("fixture path should be utf-8"), + "--skip-build", + ]) + .assert() + .failure() + .code(1) + .get_output() + .clone(); + let stderr = output_text(&output.stderr); + + assert!( + stderr.contains("fabro-spa assets are stale; run `cargo dev spa refresh`"), + "stale assets should fail the check:\n{stderr}" + ); + assert_eq!( + read_bytes(fixture.path(), "lib/crates/fabro-spa/assets/index.html"), + b"committed" + ); +} + +#[test] +fn check_fails_when_source_map_is_present_in_assets() { + let fixture = tempfile::tempdir().expect("creating fixture"); + write_file(fixture.path(), "apps/fabro-web/dist/index.html", b"hello"); write_file( fixture.path(), "lib/crates/fabro-spa/assets/assets/app.js.map", @@ -173,12 +261,14 @@ fn check_spa_budgets_fails_when_source_map_is_present() { let output = fabro_dev() .args([ - "check-spa-budgets", + "spa", + "check", "--root", fixture .path() .to_str() .expect("fixture path should be utf-8"), + "--skip-build", ]) .assert() .failure() @@ -189,17 +279,19 @@ fn check_spa_budgets_fails_when_source_map_is_present() { assert!( stderr.contains("source map files are not allowed in fabro-spa assets"), - "source maps should fail the budget check:\n{stderr}" + "source maps should fail the check:\n{stderr}" ); } #[test] -fn check_spa_budgets_missing_assets_errors_cleanly() { +fn check_missing_assets_errors_cleanly() { let fixture = tempfile::tempdir().expect("creating fixture"); + write_file(fixture.path(), "apps/fabro-web/dist/index.html", b"hello"); let output = fabro_dev() .args([ - "check-spa-budgets", + "spa", + "check", "--root", fixture .path()