Skip to content

Commit 77e4640

Browse files
zackeesclaude
andcommitted
test(build): #231 follow-up -- AVR Uno tar-extract e2e regression gate
Adds `cache_survives_tar_extract_uno` to `crates/fbuild-build/tests/avr_build.rs` as the integration-level companion to the hermetic unit test landed in #232. The unit test exercises the watch-set hash and `build_rebuild_signature` directly; this test drives a real AVR Uno build through `AvrOrchestrator.build()`, tars the resulting project tree, extracts it under a different parent directory, stomps mtimes, and rebuilds. It then asserts the warm rebuild hits the fast-path (`reused cached artifacts` in `BuildResult::message`), produces a byte-identical firmware.hex, and runs faster than the cold build. Catches failure modes the unit test cannot: - orchestrator state persisted outside the watch set - fast-path predicate bugs that pass per-layer unit tests but reject a legitimately-cached `BuildResult` - absolute paths baked into `build_fingerprint.json` or other artifacts the predicate reads Test sets `FBUILD_NO_ZCCACHE=1` (via an RAII guard) on purpose: zccache has its own fingerprint-state machinery that is covered by `zccache_hit_across_workspace_rename.rs`, and decoupling lets this test focus on the fbuild-owned fast-path predicate that the #147 fix actually changed. Gated `#[ignore]` per the established AVR-test pattern (downloads avr-gcc + Arduino-AVR core on first run). Also re-adds `tar = { workspace = true }` to fbuild-build dev-deps (the dependency was introduced on the #232 branch and the test on this branch also uses it). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent aa46e24 commit 77e4640

3 files changed

Lines changed: 259 additions & 1 deletion

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/fbuild-build/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ tempfile = { workspace = true }
2828
[dev-dependencies]
2929
filetime = { workspace = true }
3030
fbuild-test-support = { path = "../fbuild-test-support" }
31+
tar = { workspace = true }

crates/fbuild-build/tests/avr_build.rs

Lines changed: 257 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
//! Run with: `uv run soldr cargo test -p fbuild-build --test avr_build -- --ignored`
77
88
use std::fs;
9-
use std::path::PathBuf;
9+
use std::io::Cursor;
10+
use std::path::{Path, PathBuf};
11+
12+
use filetime::{set_file_mtime, FileTime};
13+
use tar::{Archive, Builder};
1014

1115
use fbuild_build::{BuildOrchestrator, BuildParams};
1216
use fbuild_core::BuildProfile;
@@ -294,3 +298,255 @@ void loop() {
294298
size.total_flash, size.total_ram, result.build_time_secs
295299
);
296300
}
301+
302+
const UNO_PLATFORMIO_INI: &str =
303+
"[env:uno]\nplatform = atmelavr\nboard = uno\nframework = arduino\n";
304+
305+
const UNO_BLINK_INO: &str = "\
306+
void setup() {
307+
pinMode(13, OUTPUT);
308+
}
309+
310+
void loop() {
311+
digitalWrite(13, HIGH);
312+
delay(1000);
313+
digitalWrite(13, LOW);
314+
delay(1000);
315+
}
316+
";
317+
318+
fn scaffold_uno_blink(project_dir: &Path) {
319+
fs::write(project_dir.join("platformio.ini"), UNO_PLATFORMIO_INI).unwrap();
320+
let src_dir = project_dir.join("src");
321+
fs::create_dir_all(&src_dir).unwrap();
322+
fs::write(src_dir.join("blink.ino"), UNO_BLINK_INO).unwrap();
323+
}
324+
325+
fn uno_build_params(project_dir: &Path, build_dir: PathBuf, clean: bool) -> BuildParams {
326+
BuildParams {
327+
project_dir: project_dir.to_path_buf(),
328+
env_name: "uno".to_string(),
329+
clean,
330+
profile: BuildProfile::Release,
331+
build_dir,
332+
verbose: false,
333+
jobs: None,
334+
generate_compiledb: false,
335+
compiledb_only: false,
336+
log_sender: None,
337+
symbol_analysis: false,
338+
symbol_analysis_path: None,
339+
no_timestamp: false,
340+
src_dir: None,
341+
pio_env: Default::default(),
342+
extra_build_flags: Vec::new(),
343+
watch_set_cache: None,
344+
}
345+
}
346+
347+
fn tar_directory(root: &Path) -> Vec<u8> {
348+
let mut builder = Builder::new(Vec::new());
349+
builder.follow_symlinks(false);
350+
builder.append_dir_all("proj", root).unwrap();
351+
builder.into_inner().unwrap()
352+
}
353+
354+
fn untar_into(bytes: &[u8], dest: &Path) {
355+
fs::create_dir_all(dest).unwrap();
356+
let mut archive = Archive::new(Cursor::new(bytes));
357+
archive.set_preserve_mtime(false);
358+
archive.unpack(dest).unwrap();
359+
}
360+
361+
fn stomp_mtimes(root: &Path, mtime: FileTime) {
362+
for entry in walkdir::WalkDir::new(root).into_iter().flatten() {
363+
if entry.file_type().is_file() {
364+
// Best-effort: some restored files (e.g. read-only artifacts on Windows)
365+
// may refuse mtime updates; the test's correctness does not depend on every
366+
// file being stomped, only on enough source-tree files having mtimes that
367+
// differ from the originals.
368+
let _ = set_file_mtime(entry.path(), mtime);
369+
}
370+
}
371+
}
372+
373+
fn fingerprint_path(project_dir: &Path) -> PathBuf {
374+
project_dir.join(".fbuild/build/uno/release/build_fingerprint.json")
375+
}
376+
377+
/// RAII guard for an env var: sets it on construction, restores the previous
378+
/// value on drop. Test-only helper so the env var leak does not pollute other
379+
/// tests that share this process.
380+
struct EnvVarGuard {
381+
key: &'static str,
382+
previous: Option<std::ffi::OsString>,
383+
}
384+
385+
impl EnvVarGuard {
386+
fn set(key: &'static str, value: &str) -> Self {
387+
let previous = std::env::var_os(key);
388+
std::env::set_var(key, value);
389+
Self { key, previous }
390+
}
391+
}
392+
393+
impl Drop for EnvVarGuard {
394+
fn drop(&mut self) {
395+
match &self.previous {
396+
Some(value) => std::env::set_var(self.key, value),
397+
None => std::env::remove_var(self.key),
398+
}
399+
}
400+
}
401+
402+
/// End-to-end regression gate for #147: build → tar → restore at a different parent →
403+
/// rebuild must hit the warm fast path. Integration-level companion to the hermetic
404+
/// unit test in `tests/cache_survives_tar_extract.rs`.
405+
///
406+
/// Failure modes this catches that the unit test cannot:
407+
/// * Orchestrator state persisted outside the watch set (e.g., a side file the
408+
/// fast-path predicate forgot about) that gets invalidated by tar-extract.
409+
/// * Fast-path predicate bugs that pass the per-layer unit tests but reject a
410+
/// legitimately-cached `BuildResult`.
411+
/// * Absolute paths baked into a build artifact that the fast-path check actually
412+
/// reads (compile DB, build_fingerprint.json) but the unit test does not cover.
413+
///
414+
/// `FBUILD_NO_ZCCACHE=1` is set for this test on purpose: the zccache layer has its
415+
/// own fingerprint-state machinery that may not survive a tar-restore on every
416+
/// platform, and that concern is already covered by
417+
/// `zccache_hit_across_workspace_rename.rs`. This test isolates the fbuild-owned
418+
/// fast-path predicate (build_fingerprint.json + watch-set stamps) -- which is the
419+
/// machinery the #147 fix actually changed.
420+
///
421+
/// Gated `#[ignore]` because it downloads avr-gcc + Arduino-AVR core (cached globally
422+
/// after first run, but still adds 30s+ to first invocation).
423+
#[test]
424+
#[ignore]
425+
fn cache_survives_tar_extract_uno() {
426+
let _no_zccache = EnvVarGuard::set("FBUILD_NO_ZCCACHE", "1");
427+
428+
let tmp_a = tempfile::TempDir::new().unwrap();
429+
let proj_a = tmp_a.path().join("proj");
430+
fs::create_dir_all(&proj_a).unwrap();
431+
scaffold_uno_blink(&proj_a);
432+
433+
let orchestrator = fbuild_build::avr::orchestrator::AvrOrchestrator;
434+
435+
let cold_result = orchestrator
436+
.build(&uno_build_params(
437+
&proj_a,
438+
proj_a.join(".fbuild/build"),
439+
true,
440+
))
441+
.expect("cold AVR build should succeed");
442+
assert!(cold_result.success, "cold build should report success");
443+
assert!(
444+
!cold_result.message.contains("reused cached artifacts"),
445+
"cold build hit fast path unexpectedly; test setup invariant broken: {}",
446+
cold_result.message,
447+
);
448+
assert!(
449+
fingerprint_path(&proj_a).exists(),
450+
"cold build did not persist build_fingerprint.json at {} -- \
451+
orchestrator never reached persist_fast_path_success.",
452+
fingerprint_path(&proj_a).display()
453+
);
454+
let cold_hex = fs::read(
455+
cold_result
456+
.firmware_path
457+
.as_ref()
458+
.expect("cold build should produce hex"),
459+
)
460+
.unwrap();
461+
let cold_time = cold_result.build_time_secs;
462+
eprintln!("cold build: {:.2}s", cold_time);
463+
464+
// Sanity gate: a same-project warm rebuild MUST hit the fast path before we
465+
// bother testing the tar-extract case. If this asserts, the test is failing
466+
// because of an orchestrator/fast-path bug unrelated to tar restoration.
467+
let same_project_warm = orchestrator
468+
.build(&uno_build_params(
469+
&proj_a,
470+
proj_a.join(".fbuild/build"),
471+
false,
472+
))
473+
.expect("same-project warm build should succeed");
474+
assert!(
475+
same_project_warm
476+
.message
477+
.contains("reused cached artifacts"),
478+
"same-project warm rebuild did not hit fast path -- the regression is in the \
479+
fast-path predicate itself, not in tar-restore handling. message: {}",
480+
same_project_warm.message,
481+
);
482+
eprintln!(
483+
"same-project warm: {:.2}s (fast-path hit confirmed)",
484+
same_project_warm.build_time_secs
485+
);
486+
487+
let tarball = tar_directory(&proj_a);
488+
489+
let tmp_b = tempfile::TempDir::new().unwrap();
490+
let relocation_root = tmp_b.path().join("nested").join("run-b").join("deeper");
491+
fs::create_dir_all(&relocation_root).unwrap();
492+
untar_into(&tarball, &relocation_root);
493+
let proj_b = relocation_root.join("proj");
494+
assert!(
495+
proj_b.join("src/blink.ino").exists(),
496+
"tar restore left no src/blink.ino at {}",
497+
proj_b.display()
498+
);
499+
assert!(
500+
proj_b.join(".fbuild/build").exists(),
501+
"tar restore left no .fbuild/build/ at {}",
502+
proj_b.display()
503+
);
504+
assert_ne!(
505+
proj_a.parent(),
506+
proj_b.parent(),
507+
"test setup invariant: restored project must live under a different parent path"
508+
);
509+
510+
stomp_mtimes(&proj_b, FileTime::from_unix_time(1_577_836_800, 0)); // 2020-01-01 UTC
511+
512+
let warm_result = orchestrator
513+
.build(&uno_build_params(
514+
&proj_b,
515+
proj_b.join(".fbuild/build"),
516+
false,
517+
))
518+
.expect("warm AVR build (post tar-extract) should succeed");
519+
assert!(warm_result.success, "warm build should report success");
520+
assert!(
521+
warm_result.message.contains("reused cached artifacts"),
522+
"warm build did NOT hit fast path -- this is the regression #147 was supposed to \
523+
prevent. message: {}",
524+
warm_result.message,
525+
);
526+
527+
let warm_hex = fs::read(
528+
warm_result
529+
.firmware_path
530+
.as_ref()
531+
.expect("warm build should still report a hex path"),
532+
)
533+
.unwrap();
534+
assert_eq!(
535+
cold_hex, warm_hex,
536+
"warm build returned a different firmware.hex than the cold build; \
537+
fast-path artifacts diverged across tar-restore."
538+
);
539+
540+
let warm_time = warm_result.build_time_secs;
541+
eprintln!(
542+
"warm build (post tar-extract relocation): {:.2}s (cold was {:.2}s)",
543+
warm_time, cold_time
544+
);
545+
assert!(
546+
warm_time < cold_time,
547+
"warm build ({:.2}s) was not faster than cold build ({:.2}s); fast-path is not \
548+
actually short-circuiting the compile/link stack.",
549+
warm_time,
550+
cold_time,
551+
);
552+
}

0 commit comments

Comments
 (0)