cargo xtask test runs every test binary at once (cargo test runs them one after another): 323 s to 75 s on 12 cores. cargo xtask ci runs the quick gates (fmt, assets, licences, layers) first, then clippy, then the tests with the wasm check and doc tests alongside; the wasm check uses the ci profile to reuse clippy's host build scripts and proc macros. GitHub CI runs its tests through cargo xtask test

This commit is contained in:
CypherNaugh_0x authored and bherbruck committed 2026-10-10 13:24:08 -04:00
1 parent aa268f0f0e
commit 29796762de
4 files changed
+143 -18

No files matched your search

+2 -1
View File
@@ -107,7 +107,8 @@ jobs:
- name: Compile tests
run: cargo test --workspace --profile ci --locked --no-run
- name: Run tests
run: cargo test --workspace --profile ci --locked
# Every test binary at once (cargo test runs them one after another).
run: cargo xtask test --locked
# Preserve the existing check name for branch protection, including docs-only PRs.
ci:
+8 -5
View File
@@ -18,7 +18,7 @@ build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](htt
```sh
git clone https://github.com/bherbruck/solvecraft && cd solvecraft
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
cargo test --workspace --profile ci # unit, engine and UI-scenario tests
cargo xtask test # unit, engine and UI-scenario tests
cargo xtask ci # everything a PR must pass (see below)
```
@@ -81,7 +81,7 @@ the most complete description of the house style.
| What | Where | Run |
|---|---|---|
| Unit and engine tests | next to the code, or `*_tests.rs` files | `cargo test --workspace --profile ci` |
| Unit and engine tests | next to the code, or `*_tests.rs` files | `cargo xtask test` (or `cargo test --workspace --profile ci`) |
| UI scenarios | `crates/ui-egui/tests/scenarios/*.json` | part of `cargo test` |
| Fusion oracle | `cargo xtask oracle` | needs the local oracle data (see below) |
@@ -105,7 +105,10 @@ put `{"pending": "why"}` in the scenario, and remove it in the PR that fixes the
**Use the `ci` profile.** CI runs the tests with `--profile ci`: light optimisation, line tables,
no LTO, and debug assertions off (truck has debug-only assertions that reject valid geometry). It
builds several times faster than `--release`. If a test fails only in a plain debug build, say so
in the PR.
in the PR. `cargo xtask test` builds the tests with it and then runs every test binary at once
(`cargo test` runs them one after another); arguments after `--` go to each binary, e.g.
`cargo xtask test -- shell`. Tests run beside each other on a busy machine, so a test must not
depend on wall-clock timing.
**The Fusion oracle** is a set of 76 reference parts, each built in Fusion from a recipe.
SolveCraft rebuilds the same recipe and compares volume, area and topology counts with Fusion's
@@ -120,8 +123,8 @@ install. If you work on geometry and want to check against it, ask in an issue.
comment on the issue so it gets the `in progress` label and two people don't build the same
thing. For anything bigger than a bug fix that has no issue yet, open one first.
2. Branch from `main` and keep the PR to one topic.
3. Run `cargo xtask ci` before pushing. It runs fmt, clippy (`-D warnings`), the tests in
release mode, the asset and layer checks, and the wasm build check. A PR has to pass it.
3. Run `cargo xtask ci` before pushing. It runs fmt, the asset, licence and layer checks,
clippy (`-D warnings`), then the tests with the wasm build check alongside. A PR has to pass it.
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
the expected ones).
+28 -12
View File
@@ -8,6 +8,7 @@ mod book;
mod layers;
mod licences;
mod parity;
mod test;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
@@ -33,8 +34,12 @@ commands:
licences [--check]
write THIRD-PARTY-LICENSES.txt (the licences of every shipped crate, embedded
in the programs); --check fails when it is out of date
test [CARGO ARGS] [-- TEST ARGS]
build the tests with the ci profile, then run every test binary at once
(and the doc tests alongside)
wasm cargo check every library crate and the web app for wasm32-unknown-unknown
ci fmt --check, clippy -D warnings, test, assets, licences --check, layers, wasm (stops at first failure)
ci fmt --check, assets, licences --check, layers, clippy -D warnings, then the tests
with the wasm check alongside (stops at the first failing step)
";
fn main() -> ExitCode {
@@ -48,6 +53,10 @@ fn main() -> ExitCode {
Some("step-corpus") => cmd_step_corpus(rest.iter().find(|a| !a.starts_with("--")).copied(), rest.contains(&"--iges")),
Some("book") => book::run(&root(), !rest.contains(&"--no-build")),
Some("licences") => licences::run(&root(), rest.contains(&"--check")),
Some("test") => {
let (cargo_args, test_args) = rest.iter().position(|a| *a == "--").map_or((&rest[..], &[][..]), |i| (&rest[..i], &rest[i + 1..]));
test::run(cargo_args, test_args, vec![test::doc_tests(cargo_args, test_args)])
}
Some("ci") => cmd_ci(),
Some("wasm") => cmd_wasm(),
Some("-h" | "--help" | "help") | None => {
@@ -201,18 +210,25 @@ fn cmd_oracle() -> Result<(), String> {
/// Never break wasm: every layered crate (everything below the apps) and the web app must
/// build for the browser.
fn cmd_wasm() -> Result<(), String> {
let (c, n) = wasm_check()?;
run(c, &format!("cargo check --target wasm32-unknown-unknown ({n} crates)"))?;
println!("wasm: {n} crates build for wasm32-unknown-unknown");
Ok(())
}
/// The wasm check and how many crates it covers. It uses the ci profile so the host build
/// scripts and proc macros built for clippy and the tests are reused.
fn wasm_check() -> Result<(Command, usize), String> {
let crates = layers::from_metadata(&metadata()?)?;
let mut set: Vec<String> =
crates.iter().filter(|c| matches!(layers::classify(&c.name), Some(layers::Class::Layer(_)))).map(|c| c.name.clone()).collect();
set.push("solvecraft-web".into());
let mut c = cargo();
c.args(["check", "--target", "wasm32-unknown-unknown"]);
c.args(["check", "--target", "wasm32-unknown-unknown", "--profile", "ci"]);
for p in &set {
c.args(["-p", p]);
}
run(c, &format!("cargo check --target wasm32-unknown-unknown ({} crates)", set.len()))?;
println!("wasm: {} crates build for wasm32-unknown-unknown", set.len());
Ok(())
Ok((c, set.len()))
}
/// Import every oracle STEP file (exported by Fusion) and compare with Fusion's measurements;
@@ -317,20 +333,20 @@ a manifold solid B-rep on the same surfaces a STEP export uses, with its name an
if pass == list.len() { Ok(()) } else { Err(format!("{} file(s) failed", list.len() - pass)) }
}
/// The quick gates first, so they fail fast; the wasm check (and doc tests) run while the test
/// binaries do, which leaves cargo's build lock free.
fn cmd_ci() -> Result<(), String> {
let mut c = cargo();
c.args(["fmt", "--all", "--", "--check"]);
run(c, "cargo fmt --check")?;
let mut c = cargo();
c.args(["clippy", "--workspace", "--all-targets", "--profile", "ci", "--", "-D", "warnings"]);
run(c, "cargo clippy -D warnings")?;
let mut c = cargo();
c.args(["test", "--workspace", "--profile", "ci"]);
run(c, "cargo test")?;
assets::run(&root())?;
licences::run(&root(), true)?;
cmd_layers()?;
cmd_wasm()?;
let mut c = cargo();
c.args(["clippy", "--workspace", "--all-targets", "--profile", "ci", "--", "-D", "warnings"]);
run(c, "cargo clippy -D warnings")?;
let (wasm, n) = wasm_check()?;
test::run(&[], &[], vec![(format!("wasm32-unknown-unknown check ({n} crates)"), wasm), test::doc_tests(&[], &[])])?;
eprintln!("ci: all gates passed");
Ok(())
}
+105
View File
@@ -0,0 +1,105 @@
//! `cargo xtask test`: build the workspace's tests once, then run every test binary at the same
//! time. `cargo test` runs them one after another, so one slow binary held up all the others;
//! the doc tests and any other `side` jobs run alongside on their own thread.
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::Instant;
use crate::{cargo, root};
/// A test executable from `cargo test --no-run`.
struct TestBin {
label: String,
exe: String,
dir: String,
}
/// Build the tests with `cargo_args` (e.g. `--locked`), run each binary with `test_args`, and run
/// the `side` jobs one after another meanwhile. Each prints a line when it ends; a failure prints
/// its whole output.
pub fn run(cargo_args: &[&str], test_args: &[&str], side: Vec<(String, Command)>) -> Result<(), String> {
let bins = build(cargo_args)?;
let start = Instant::now();
let (tx, rx) = mpsc::channel::<(String, Result<String, String>, f64)>();
let mut failed = Vec::new();
std::thread::scope(|scope| {
for b in &bins {
let tx = tx.clone();
scope.spawn(move || {
let t0 = Instant::now();
let mut c = Command::new(&b.exe);
c.args(test_args).current_dir(&b.dir).env("CARGO_MANIFEST_DIR", &b.dir);
let _ = tx.send((b.label.clone(), finish(c), t0.elapsed().as_secs_f64()));
});
}
let tx_side = tx.clone();
scope.spawn(move || {
for (label, c) in side {
let t0 = Instant::now();
let _ = tx_side.send((label, finish(c), t0.elapsed().as_secs_f64()));
}
});
drop(tx);
for (label, result, secs) in rx {
match result {
Ok(summary) => println!("ok {label:<44} {secs:6.1}s {summary}"),
Err(output) => {
println!("FAIL {label:<44} {secs:6.1}s\n{output}");
failed.push(label);
}
}
}
});
println!("test: {} binaries in {:.1}s", bins.len(), start.elapsed().as_secs_f64());
if failed.is_empty() { Ok(()) } else { Err(format!("failed: {}", failed.join(", "))) }
}
/// Run to the end: the last `test result:` line on success, all output on failure.
fn finish(mut c: Command) -> Result<String, String> {
let out = c.stdin(Stdio::null()).output().map_err(|e| format!("failed to spawn: {e}"))?;
let stdout = String::from_utf8_lossy(&out.stdout);
if out.status.success() {
Ok(stdout.lines().rev().find(|l| l.starts_with("test result:")).unwrap_or("").trim_start_matches("test result: ").to_string())
} else {
Err(format!("{stdout}{}\n({})", String::from_utf8_lossy(&out.stderr), out.status))
}
}
/// `cargo test --workspace --profile ci --no-run`, and the executables it built.
fn build(cargo_args: &[&str]) -> Result<Vec<TestBin>, String> {
let mut c = cargo();
c.args(["test", "--workspace", "--profile", "ci", "--no-run", "--message-format=json-render-diagnostics"]).args(cargo_args);
eprintln!("$ cargo test --workspace --profile ci --no-run");
let out = c.stderr(Stdio::inherit()).output().map_err(|e| format!("cargo test --no-run: failed to spawn: {e}"))?;
if !out.status.success() {
return Err(format!("cargo test --no-run: exited with {}", out.status));
}
let mut bins: Vec<TestBin> = Vec::new();
for line in String::from_utf8_lossy(&out.stdout).lines() {
let Ok(m) = serde_json::from_str::<serde_json::Value>(line) else { continue };
let Some(exe) = m["executable"].as_str().filter(|_| m["reason"] == "compiler-artifact" && m["profile"]["test"] == true) else {
continue;
};
if bins.iter().any(|b| b.exe == exe) {
continue;
}
let manifest = std::path::Path::new(m["manifest_path"].as_str().unwrap_or_default()).to_path_buf();
let dir = manifest.parent().map(|d| d.to_path_buf()).unwrap_or_else(root);
let pkg = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let kind = m["target"]["kind"][0].as_str().unwrap_or("");
let label = format!("{pkg} {kind} {}", m["target"]["name"].as_str().unwrap_or(""));
bins.push(TestBin { label, exe: exe.to_string(), dir: dir.to_string_lossy().into_owned() });
}
if bins.is_empty() {
return Err("cargo test --no-run built no test executables".into());
}
Ok(bins)
}
/// The doc tests (`cargo test --doc`), to run as a side job.
pub fn doc_tests(cargo_args: &[&str], test_args: &[&str]) -> (String, Command) {
let mut c = cargo();
c.args(["test", "--workspace", "--profile", "ci", "--doc"]).args(cargo_args).arg("--").args(test_args);
("doc tests".into(), c)
}