Merge upstream/main (#2710 one test binary per crate, #2713) into round3a-integration

Conflicts:
- xtask/src/main.rs: upstream's runners (cargo test or nextest, no
  examples, doctests apart, one wasm `cargo check --keep-going`) with
  #2302's `ci --quick` on top: test_selected builds the same targets
  (--lib --bins --tests), the wasm check takes the changed crates in one
  call, the full test step is upstream's.
- apps/lightcraft/src/control_server.rs: upstream's `before` closure, with
  the batch's no-delay sockets kept.
- docs/parity-checklist.md: LR-BEHAV-ACCESS from main (tests/it path),
  LR-BEHAV-LOCALE / LR-BEHAV-LOCALIZE from the batch (Polish).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
francoisgn
2026-10-23 21:06:33 +03:00
co-authored by Claude Opus 5.5
103 changed files with 1132 additions and 897 deletions
+30 -4
View File
@@ -307,8 +307,16 @@ pub fn seconds(s: f64) -> String {
if s < 60.0 { format!("{s:.1} s") } else { format!("{}m {:02}s", (s / 60.0).floor() as u64, (s % 60.0).floor() as u64) }
}
/// `binary::test`, or the binary and the location when the test's name is unknown (thread
/// `main`). A crate's integration tests share one binary, `it`, named here after the crate's
/// directory: `gpu/it::equivalence::camera_tone`.
fn test_name(s: &Skip) -> String {
if s.test.is_empty() || s.test == "main" { format!("{} ({})", s.binary, s.location) } else { format!("{}::{}", s.binary, s.test) }
let mut dirs = s.location.split('/');
let binary = match (s.binary.as_str(), dirs.next(), dirs.next()) {
("it", Some("crates" | "apps"), Some(dir)) => format!("{dir}/it"),
_ => s.binary.clone(),
};
if s.test.is_empty() || s.test == "main" { format!("{binary} ({})", s.location) } else { format!("{binary}::{}", s.test) }
}
fn adapter_line(a: &Value) -> String {
@@ -505,9 +513,8 @@ mod tests {
fn skips_are_read_per_step_deduplicated_and_cut_free_of_the_root() {
let root = Path::new("/work/lightcraft");
let dir = tmp("skips");
let gpu =
r#"{"need":"gpu","reason":"no GPU adapter","test":"camera_tone","binary":"equivalence","location":"crates/gpu/tests/equivalence.rs:22"}"#;
let corpus = r#"{"need":"corpus","reason":"skip /work/lightcraft/crates/raw/../../corpus/raw absent","test":"decodes","binary":"corpus","location":"crates/raw/tests/corpus.rs:28"}"#;
let gpu = r#"{"need":"gpu","reason":"no GPU adapter","test":"equivalence::camera_tone","binary":"it","location":"crates/gpu/tests/it/equivalence.rs:22"}"#;
let corpus = r#"{"need":"corpus","reason":"skip /work/lightcraft/crates/raw/../../corpus/raw absent","test":"corpus::decodes","binary":"it","location":"crates/raw/tests/it/corpus.rs:28"}"#;
std::fs::write(dir.join("skips-heif.jsonl"), format!("{corpus}\n")).unwrap();
std::fs::write(dir.join("skips-test.jsonl"), format!("{gpu}\n{corpus}\nnot json\n")).unwrap();
std::fs::write(dir.join("other.jsonl"), format!("{gpu}\n")).unwrap();
@@ -521,6 +528,25 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn integration_test_skips_are_named_after_their_crate() {
let skip = |test: &str, binary: &str, location: &str| Skip {
need: "gpu".into(),
reason: String::new(),
test: test.into(),
binary: binary.into(),
location: location.into(),
steps: Vec::new(),
};
assert_eq!(test_name(&skip("equivalence::camera_tone", "it", "crates/gpu/tests/it/equivalence.rs:22")), "gpu/it::equivalence::camera_tone");
assert_eq!(
test_name(&skip("main", "it", "apps/lightcraft-cli/tests/it/cli.rs:9")),
"lightcraft-cli/it (apps/lightcraft-cli/tests/it/cli.rs:9)"
);
assert_eq!(test_name(&skip("memory", "memory", "crates/gpu/tests/memory.rs:12")), "memory::memory");
assert_eq!(test_name(&skip("tests::a", "lightcraft_engine", "crates/engine/src/tests.rs:3")), "lightcraft_engine::tests::a");
}
#[test]
fn prepare_clears_the_last_runs_files_only() {
let root = tmp("prepare");
+85 -35
View File
@@ -32,13 +32,15 @@ commands:
parity [--write]
check docs/parity-checklist.md (every cmd:/ctl: id and path it cites exists) and print the
Lightroom parity summary; --write refreshes the summary table in the document
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (+ the web app)
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (+ the web app), in one call
web [--serve [port]] [--dev]
build the browser app (apps/lightcraft-web) into <target>/web-site/;
--serve serves it on http://127.0.0.1:<port> (default 8080)
ci [--hardware] [--summary-md] [--quick [--base REF]]
fmt --check, clippy -D warnings, heif, test, GPU probe, parity refs, layers, assets, wasm
(stops at first failure); writes target/ci/report.json (machine, GPU adapters, data,
(stops at first failure). Tests run under cargo test (cargo nextest with
LIGHTCRAFT_NEXTEST=1), without examples, then doctests with cargo test --doc. Writes
target/ci/report.json (machine, GPU adapters, data,
steps, skipped tests) and target/ci/summary.md; --hardware also runs the GPU tests on
every backend with a hardware adapter; --summary-md prints the Markdown summary.
--quick: only what the changes since the merge base with origin/main (or REF) can
@@ -124,9 +126,12 @@ pub fn cargo() -> Command {
// wall-clock frame-budget tests flaky on a loaded machine.
(jobs.to_string(), jobs.min(4).to_string())
});
for (key, value) in
[("CARGO_PROFILE_DEV_DEBUG", "line-tables-only"), ("CARGO_BUILD_JOBS", build.as_str()), ("RUST_TEST_THREADS", threads.as_str())]
{
for (key, value) in [
("CARGO_PROFILE_DEV_DEBUG", "line-tables-only"),
("CARGO_BUILD_JOBS", build.as_str()),
("RUST_TEST_THREADS", threads.as_str()),
("NEXTEST_TEST_THREADS", threads.as_str()),
] {
if std::env::var_os(key).is_none() {
c.env(key, value);
}
@@ -190,6 +195,55 @@ fn total_ram_gb() -> Option<u64> {
Some(bytes.saturating_add(1 << 29) >> 30)
}
/// The targets the test runs build: no examples (clippy `--all-targets` still checks them).
/// Doctests run on their own, [`doctests`]: nextest can't run them.
const TEST_TARGETS: [&str; 3] = ["--lib", "--bins", "--tests"];
/// `args` followed by [`TEST_TARGETS`].
fn with_test_targets<'a>(args: &[&'a str]) -> Vec<&'a str> {
args.iter().copied().chain(TEST_TARGETS).collect()
}
/// Whether the tests run under `cargo nextest`: only when `LIGHTCRAFT_NEXTEST=1` asks for it and
/// nextest is installed. `cargo test` is the default because the demo library's generated scenes
/// are cached per process: libtest runs a binary's tests in one process and generates each scene
/// once, while nextest's process per test regenerates them (measured: the ui-egui tests take about
/// 30 min under nextest, under 5 min under cargo test).
fn nextest() -> bool {
static FOUND: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*FOUND.get_or_init(|| {
if std::env::var_os("LIGHTCRAFT_NEXTEST").is_none_or(|v| v != "1") {
return false;
}
let found = cargo()
.args(["nextest", "--version"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success());
if !found {
eprintln!("note: LIGHTCRAFT_NEXTEST=1 but cargo-nextest is not installed, so the tests run under cargo test (cargo install cargo-nextest --locked)");
}
found
})
}
/// A test run of `args` (packages, features, targets, filters): `cargo nextest run`, or `cargo
/// test`. The command and how to print it.
fn tests(args: &[&str]) -> (Command, String) {
let mut c = cargo();
let tool = if nextest() { "nextest run" } else { "test" };
c.args(tool.split(' ')).args(args);
(c, format!("cargo {tool} {}", args.join(" ")))
}
/// The workspace's doctests.
fn doctests() -> (Command, String) {
let mut c = cargo();
c.args(["test", "--workspace", "--doc"]);
(c, "cargo test --workspace --doc".to_owned())
}
/// `ci --quick`'s tests: build every workspace test target, then run only the test binaries of
/// `packages`. Building with `-p` instead would resolve features per selection: different
/// artifacts from `ci`'s and a warm worktree's (`cargo xtask warm`) workspace builds, so it would
@@ -202,10 +256,10 @@ fn test_selected(root: &Path, packages: &std::collections::BTreeSet<String>, ski
.filter_map(|p| Some((p["manifest_path"].as_str()?.to_owned(), p["name"].as_str()?.to_owned())))
.filter(|(_, name)| packages.contains(name))
.collect();
let what = "cargo test --workspace --no-run";
let what = "cargo test --workspace --no-run --lib --bins --tests";
eprintln!("$ {what} (then the test binaries of {})", packages.iter().map(String::as_str).collect::<Vec<_>>().join(", "));
let out = cargo()
.args(["test", "--workspace", "--no-run", "--message-format=json-render-diagnostics"])
.args(with_test_targets(&["test", "--workspace", "--no-run", "--message-format=json-render-diagnostics"]))
.stderr(std::process::Stdio::inherit())
.output()
.map_err(|e| format!("{what}: failed to spawn: {e}"))?;
@@ -308,6 +362,7 @@ fn wasm_set() -> Result<Vec<String>, String> {
.collect())
}
/// One `cargo check` over the whole set; `--keep-going` reports every crate that fails.
fn cmd_wasm() -> Result<(), String> {
cmd_wasm_for(None)
}
@@ -319,19 +374,12 @@ fn cmd_wasm_for(only: Option<&std::collections::BTreeSet<String>>) -> Result<(),
println!("wasm32-unknown-unknown check: no changed crate builds for wasm32");
return Ok(());
}
let mut results = Vec::new();
let mut c = cargo();
c.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]);
for pkg in &set {
let mut c = cargo();
c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]);
let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok();
results.push((pkg.clone(), ok));
c.args(["-p", pkg]);
}
println!("\nwasm32-unknown-unknown check:");
for (p, ok) in &results {
println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" });
}
let failed = results.iter().filter(|r| !r.1).count();
if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) }
run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going -p {}", set.join(" -p ")))
}
fn cmd_ci(args: &[&str]) -> Result<(), String> {
@@ -370,11 +418,11 @@ fn cmd_ci(args: &[&str]) -> Result<(), String> {
let report_dir = ci_report::prepare(&root)?;
let started = std::time::Instant::now();
let started_unix = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
// test runs append the skips their tests record (`lightcraft_testkit::skip!`) to target/ci/skips-<step>.jsonl
let test = |cargo_args: &[&str], step: &str| {
let mut c = cargo();
c.args(cargo_args).env(ci_report::SKIP_LOG_ENV, ci_report::skip_log(&root, step));
run(c, &format!("cargo {}", cargo_args.join(" ")))
// test runs append the skips their tests record (`lightcraft_testkit::skip!`) to
// target/ci/skips-<step>.jsonl: a file, so it works the same under nextest and cargo test
let test = |(mut c, what): (Command, String), step: &str| {
c.env(ci_report::SKIP_LOG_ENV, ci_report::skip_log(&root, step));
run(c, &what)
};
let gpu = std::cell::RefCell::new(serde_json::Value::Null);
let hardware_runs = std::cell::RefCell::new(Vec::new());
@@ -414,8 +462,8 @@ fn cmd_ci(args: &[&str]) -> Result<(), String> {
"clippy",
Box::new(|| {
let mut c = cargo();
c.args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]);
run(c, "cargo clippy --workspace --all-targets -- -D warnings")
c.args(["clippy", "--workspace", "--all-targets", "--keep-going", "--", "-D", "warnings"]);
run(c, "cargo clippy --workspace --all-targets --keep-going -- -D warnings")
}),
),
(
@@ -425,16 +473,19 @@ fn cmd_ci(args: &[&str]) -> Result<(), String> {
let mut c = cargo();
c.args(["clippy", "-p", "lightcraft-codecs", "--features", "heif", "--all-targets", "--", "-D", "warnings"]);
run(c, "cargo clippy -p lightcraft-codecs --features heif --all-targets -- -D warnings")?;
test(&["test", "-p", "lightcraft-codecs", "-p", "lightcraft-heif", "--features", "lightcraft-codecs/heif"], "heif")?;
test(
tests(&with_test_targets(&["-p", "lightcraft-codecs", "-p", "lightcraft-heif", "--features", "lightcraft-codecs/heif"])),
"heif",
)?;
// the engine's HEIC import test, with the decoder (the workspace run checks the error)
test(&["test", "-p", "lightcraft-engine", "--lib", "--features", "lightcraft-codecs/heif", "heic"], "heif")
test(tests(&["-p", "lightcraft-engine", "--lib", "--features", "lightcraft-codecs/heif", "heic"]), "heif")
}),
),
(
"test",
Box::new(|| match &plan {
Some(p) if !p.workspace_wide => test_selected(&root, &p.packages, &ci_report::skip_log(&root, "test")),
_ => test(&["test", "--workspace"], "test"),
_ => test(tests(&with_test_targets(&["--workspace"])), "test").and_then(|()| test(doctests(), "test")),
}),
),
];
@@ -452,12 +503,10 @@ fn cmd_ci(args: &[&str]) -> Result<(), String> {
let mut failed = Vec::new();
for (backend, adapter) in backends {
let step = format!("gpu-{backend}");
let mut c = cargo();
c.args(["test", "-p", "lightcraft-gpu", "--features", "denoise"])
.env("LIGHTCRAFT_GPU_BACKEND", &backend)
.env(ci_report::SKIP_LOG_ENV, ci_report::skip_log(&root, &step));
let (mut c, what) = tests(&with_test_targets(&["-p", "lightcraft-gpu", "--features", "denoise"]));
c.env("LIGHTCRAFT_GPU_BACKEND", &backend).env(ci_report::SKIP_LOG_ENV, ci_report::skip_log(&root, &step));
let t = std::time::Instant::now();
let ok = run(c, &format!("LIGHTCRAFT_GPU_BACKEND={backend} cargo test -p lightcraft-gpu --features denoise")).is_ok();
let ok = run(c, &format!("LIGHTCRAFT_GPU_BACKEND={backend} {what}")).is_ok();
hardware_runs.borrow_mut().push(serde_json::json!({
"backend": backend,
"adapter": adapter,
@@ -561,11 +610,12 @@ fn cmd_ci(args: &[&str]) -> Result<(), String> {
}
/// Run the ignored `gpu_probe` test of lightcraft-gpu: the pipeline's device (`None`) or one
/// backend's adapters. `--workspace`, like the test step, so its build is reused. Its stdout, or why
/// backend's adapters. `--workspace`, like the test step, so its build is reused (every crate's `it`
/// binary starts; only lightcraft-gpu's has the test). Its stdout, or why
/// it failed (a driver that crashes while it is enumerated, issue #136, ends up here, not in xtask).
fn probe_gpu(backend: Option<&str>) -> Result<String, String> {
let mut c = cargo();
c.args(["test", "-q", "--workspace", "--test", "gpu_probe", "--", "--ignored", "--exact", "gpu_probe", "--nocapture"]);
c.args(["test", "-q", "--workspace", "--test", "it", "--", "--ignored", "--exact", "gpu_probe::gpu_probe", "--nocapture"]);
c.env("LIGHTCRAFT_GPU_PROBE", backend.unwrap_or(""));
let out = c.output().map_err(|e| format!("gpu_probe: {e}"))?;
if !out.status.success() {