mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-12 23:02:41 +00:00
feat(dev): group cargo dev spa and docs commands
This commit is contained in:
parent
05e5623b2b
commit
d37f75a878
20 changed files with 656 additions and 418 deletions
2
.github/workflows/nightly.yml
vendored
2
.github/workflows/nightly.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
|
|
@ -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 }})
|
||||
|
|
|
|||
3
.github/workflows/rust.yml
vendored
3
.github/workflows/rust.yml
vendored
|
|
@ -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)
|
||||
|
|
|
|||
4
.github/workflows/typescript.yml
vendored
4
.github/workflows/typescript.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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-<hash>.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 <name>` (default `fabro-sh/fabro`), `--compile-only` (stages `tmp/docker-context/<arch>/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
|
||||
|
|
|
|||
|
|
@ -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<BudgetReport> {
|
|||
|
||||
#[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<u64> {
|
||||
let output = Command::new("gzip")
|
||||
|
|
@ -129,3 +168,42 @@ fn ensure_gzip_success(file: &Path, output: &Output) -> Result<u64> {
|
|||
|
||||
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<BTreeMap<PathBuf, Vec<u8>>> {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
62
lib/crates/fabro-dev/src/commands/docs.rs
Normal file
62
lib/crates/fabro-dev/src/commands/docs.rs
Normal file
|
|
@ -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<DocsCommand>,
|
||||
}
|
||||
|
||||
#[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<PathBuf>,
|
||||
}
|
||||
|
||||
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>"),
|
||||
);
|
||||
command.print_help()?;
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -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 = "<!-- generated:cli -->";
|
||||
const FENCE_END: &str = "<!-- /generated:cli -->";
|
||||
|
||||
#[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<PathBuf>,
|
||||
}
|
||||
|
||||
#[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(());
|
||||
|
|
|
|||
|
|
@ -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 = "<!-- generated:options -->";
|
||||
const FENCE_END: &str = "<!-- /generated:options -->";
|
||||
|
||||
#[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<PathBuf>,
|
||||
}
|
||||
|
||||
#[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(());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<PathBuf>,
|
||||
root: Option<PathBuf>,
|
||||
/// 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<Self> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PrereleaseLabel>,
|
||||
/// 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<NaiveDate>,
|
||||
release_date: Option<NaiveDate>,
|
||||
/// Repository root to release.
|
||||
#[arg(long, hide = true)]
|
||||
root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[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<PathBuf>,
|
||||
}
|
||||
|
||||
struct ReleasePlan {
|
||||
prerelease_label: Option<PrereleaseLabel>,
|
||||
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<String> {
|
||||
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 {
|
||||
|
|
|
|||
42
lib/crates/fabro-dev/src/commands/spa.rs
Normal file
42
lib/crates/fabro-dev/src/commands/spa.rs
Normal file
|
|
@ -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<SpaCommand>,
|
||||
}
|
||||
|
||||
#[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>"),
|
||||
);
|
||||
command.print_help()?;
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
152
lib/crates/fabro-dev/tests/it/docs.rs
Normal file
152
lib/crates/fabro-dev/tests/it/docs.rs
Normal file
|
|
@ -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.
|
||||
|
||||
<!-- generated:cli -->
|
||||
stale cli
|
||||
<!-- /generated:cli -->
|
||||
|
||||
Tail copy.
|
||||
",
|
||||
);
|
||||
write_file(
|
||||
root,
|
||||
"docs/reference/user-configuration.mdx",
|
||||
r"---
|
||||
title: Settings
|
||||
---
|
||||
|
||||
Settings intro copy.
|
||||
|
||||
<!-- generated:options -->
|
||||
stale options
|
||||
<!-- /generated: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);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
<!-- generated:cli -->
|
||||
stale
|
||||
<!-- /generated:cli -->
|
||||
|
||||
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"<!-- generated:cli -->
|
||||
stale
|
||||
<!-- /generated:cli -->
|
||||
",
|
||||
);
|
||||
|
||||
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"<!-- generated:cli -->
|
||||
stale
|
||||
<!-- /generated:cli -->
|
||||
",
|
||||
);
|
||||
|
||||
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"<!-- generated:cli -->
|
||||
stale
|
||||
<!-- /generated:cli -->
|
||||
",
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
<!-- generated:options -->
|
||||
stale
|
||||
<!-- /generated:options -->
|
||||
|
||||
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"<!-- generated:options -->
|
||||
stale
|
||||
<!-- /generated:options -->
|
||||
",
|
||||
);
|
||||
|
||||
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"<!-- generated:options -->
|
||||
stale
|
||||
<!-- /generated:options -->
|
||||
",
|
||||
);
|
||||
|
||||
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"<!-- generated:options -->
|
||||
stale
|
||||
<!-- /generated:options -->
|
||||
",
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<u8> {
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue