Skip to content

Commit 24cc874

Browse files
author
Roy Lin
committed
test(ci): cover parsing, Display, orchestration, and edge paths (cov 99%)
Extracts `snapshot ls` parsing into a pure `parse_snapshot_id` helper; tests every `CiError` Display arm; drives warm_base + step end-to-end against a POSIX-sh `a3s-box` stub (happy + cache-hit + allow_failure + stale-snapshot pre-clean + no-cache paths); and covers the box_run non-zero, wait_ready timeout, and missing-binary error paths (`wait_ready` split into a parameterized inner so the timeout is testable without waiting). Raises a3s-box-ci line coverage 31% -> 99% (cargo-llvm-cov).
1 parent 84f318f commit 24cc874

1 file changed

Lines changed: 158 additions & 9 deletions

File tree

src/ci/src/lib.rs

Lines changed: 158 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,12 @@ fn slug(s: &str) -> String {
149149

150150
/// Poll `exec <box> -- true` until the box is ready (box has no "wait-ready" verb).
151151
fn wait_ready(box_name: &str) -> Result<()> {
152-
for _ in 0..60 {
152+
wait_ready_inner(box_name, 60, 500)
153+
}
154+
155+
/// Polling core, parameterized so tests can drive the timeout path without waiting.
156+
fn wait_ready_inner(box_name: &str, tries: u32, delay_ms: u64) -> Result<()> {
157+
for _ in 0..tries {
153158
let ready = Command::new(box_bin())
154159
.args(["exec", box_name, "--", "true"])
155160
.output()
@@ -158,7 +163,7 @@ fn wait_ready(box_name: &str) -> Result<()> {
158163
if ready {
159164
return Ok(());
160165
}
161-
std::thread::sleep(std::time::Duration::from_millis(500));
166+
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
162167
}
163168
Err(CiError::NotReady(box_name.to_string()))
164169
}
@@ -338,19 +343,24 @@ impl Base<'_> {
338343
fn snapshot_id(name: &str) -> Result<String> {
339344
let out = box_run(&["snapshot", "ls"])?;
340345
let text = String::from_utf8_lossy(&out.stdout);
341-
for line in text.lines() {
346+
parse_snapshot_id(&text, name).ok_or_else(|| CiError::Cli {
347+
args: format!("snapshot ls (locate '{name}')"),
348+
code: None,
349+
stderr: "snapshot not found after create".into(),
350+
})
351+
}
352+
353+
/// Find a snapshot's ID (first column) by name (second column) in `snapshot ls` output.
354+
fn parse_snapshot_id(ls_output: &str, name: &str) -> Option<String> {
355+
for line in ls_output.lines() {
342356
let mut cols = line.split_whitespace();
343357
if let (Some(id), Some(nm)) = (cols.next(), cols.next()) {
344358
if nm == name {
345-
return Ok(id.to_string());
359+
return Some(id.to_string());
346360
}
347361
}
348362
}
349-
Err(CiError::Cli {
350-
args: format!("snapshot ls (locate '{name}')"),
351-
code: None,
352-
stderr: "snapshot not found after create".into(),
353-
})
363+
None
354364
}
355365

356366
/// Warm a base box: run `setup` once, snapshot the result, return a forkable [`Base`].
@@ -446,4 +456,143 @@ mod tests {
446456
);
447457
let _ = std::fs::remove_dir_all(&dir);
448458
}
459+
460+
#[test]
461+
fn parse_snapshot_id_finds_by_name() {
462+
let ls = "SNAPSHOT ID NAME SOURCE BOX\n\
463+
snap-111 ci-base-aaa-snap box-1\n\
464+
snap-222 ci-base-bbb-snap box-2\n";
465+
assert_eq!(
466+
parse_snapshot_id(ls, "ci-base-bbb-snap").as_deref(),
467+
Some("snap-222")
468+
);
469+
assert_eq!(parse_snapshot_id(ls, "nope"), None);
470+
assert_eq!(parse_snapshot_id("", "x"), None);
471+
// lines with fewer than two columns are skipped, not panicked on
472+
assert_eq!(
473+
parse_snapshot_id("lonely\n\nsnap-9 found x\n", "found").as_deref(),
474+
Some("snap-9")
475+
);
476+
}
477+
478+
#[test]
479+
fn ci_error_display_covers_each_variant() {
480+
let io = CiError::from(std::io::Error::other("boom"));
481+
assert!(format!("{io}").contains("boom"));
482+
let cli = CiError::Cli {
483+
args: "snapshot rm s".into(),
484+
code: Some(1),
485+
stderr: "still used".into(),
486+
};
487+
assert!(format!("{cli}").contains("still used"));
488+
let step = CiError::StepFailed {
489+
name: "test".into(),
490+
code: 7,
491+
logs: "oops".into(),
492+
};
493+
let s = format!("{step}");
494+
assert!(s.contains("test") && s.contains("exit 7"));
495+
assert!(format!("{}", CiError::NotReady("b1".into())).contains("b1"));
496+
}
497+
498+
// A POSIX-sh stub standing in for `a3s-box`: enough to drive warm_base + step
499+
// through their happy paths (run/exec/start/rm succeed, `snapshot create`
500+
// records name->id in a state file that `snapshot ls` echoes, an exec whose
501+
// command contains "boom" exits 7). Lets us cover the orchestration locally,
502+
// without a real box or KVM.
503+
#[cfg(unix)]
504+
const FAKE_BOX: &str = r#"#!/bin/sh
505+
state="$(dirname "$0")/.snaps"
506+
case "$1" in
507+
run|start|rm) exit 0 ;;
508+
exec) last=""; for a in "$@"; do last="$a"; done
509+
case "$last" in *boom*) exit 7 ;; *) exit 0 ;; esac ;;
510+
snapshot)
511+
case "$2" in
512+
create) name=""; while [ "$#" -gt 0 ]; do [ "$1" = "--name" ] && name="$2"; shift; done
513+
printf '%s %s\n' "snap-fake1" "$name" >> "$state"; printf 'snap-fake1\n'; exit 0 ;;
514+
ls) printf 'ID NAME SRC\n'; cat "$state" 2>/dev/null; exit 0 ;;
515+
*) exit 0 ;;
516+
esac ;;
517+
*) exit 0 ;;
518+
esac
519+
"#;
520+
521+
#[cfg(unix)]
522+
#[test]
523+
fn orchestration_drives_the_cli() {
524+
use std::os::unix::fs::PermissionsExt;
525+
// The ONLY test that touches the A3S_BOX env, so it cannot race a concurrent
526+
// box_bin() reader.
527+
let dir = std::env::temp_dir().join(format!("a3s-ci-fakebox-{}", key(&["fakebox"])));
528+
let _ = std::fs::remove_dir_all(&dir);
529+
std::fs::create_dir_all(&dir).unwrap();
530+
let fake = dir.join("a3s-box");
531+
std::fs::write(&fake, FAKE_BOX).unwrap();
532+
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
533+
534+
// Happy path against the stub.
535+
std::env::set_var("A3S_BOX", &fake);
536+
let cache = FileCache::new(dir.join("cache")).unwrap();
537+
let mut base = warm_base(WarmBase::new("img", "echo hi").cache(&cache)).expect("warm_base");
538+
let r = base.step(Step::new("build", "make")).expect("step");
539+
assert_eq!(r.exit_code, 0);
540+
assert!(!r.cached);
541+
assert!(base.step(Step::new("build", "make")).expect("step2").cached); // cache hit
542+
assert!(matches!(
543+
base.step(Step::new("fail", "boom")),
544+
Err(CiError::StepFailed { code: 7, .. })
545+
));
546+
// box_run surfaces a non-zero exit as Cli (the stub exits 7 on a "boom" exec).
547+
assert!(matches!(
548+
box_run(&["exec", "x", "--", "boom"]),
549+
Err(CiError::Cli { code: Some(7), .. })
550+
));
551+
// allow_failure: a non-zero step returns Ok with the code instead of Err.
552+
let allowed = base
553+
.step(Step::new("maybe", "boom").allow_failure())
554+
.expect("allow_failure step");
555+
assert_eq!(allowed.exit_code, 7);
556+
assert!(!allowed.cached);
557+
// A second warm_base exercises the stale-snapshot pre-clean path.
558+
let _ = warm_base(WarmBase::new("img", "echo hi").cache(&cache)).expect("warm_base #2");
559+
base.dispose();
560+
561+
// A cache-less base exercises the no-cache branch in step().
562+
let mut nocache = warm_base(WarmBase::new("img", "echo hi")).expect("warm_base nocache");
563+
assert!(
564+
!nocache
565+
.step(Step::new("x", "make"))
566+
.expect("nocache step")
567+
.cached
568+
);
569+
nocache.dispose();
570+
571+
// Failure path: a missing binary must surface as Err, never panic.
572+
std::env::set_var("A3S_BOX", "/nonexistent/a3s-box-xyzzy");
573+
assert!(matches!(box_run(&["version"]), Err(CiError::Io(_))));
574+
box_cleanup(&["rm", "-f", "x"]); // best-effort: must not panic
575+
assert!(snapshot_id("any").is_err());
576+
assert!(warm_base(WarmBase::new("img", "echo hi")).is_err());
577+
// wait_ready returns NotReady when the box never becomes ready (fast: 2 tries, 0ms).
578+
assert!(matches!(
579+
wait_ready_inner("b", 2, 0),
580+
Err(CiError::NotReady(_))
581+
));
582+
583+
std::env::remove_var("A3S_BOX");
584+
let _ = std::fs::remove_dir_all(&dir);
585+
}
586+
587+
#[test]
588+
fn builders_chain_all_options() {
589+
// Exercise every builder method (fields are private; chaining covers the bodies).
590+
let c = FileCache::new(std::env::temp_dir().join(format!("a3s-ci-bld-{}", key(&["bld"]))))
591+
.unwrap();
592+
let _ = WarmBase::new("img", "setup").env("A", "1").cache(&c);
593+
let _ = Step::new("n", "cmd")
594+
.input("x")
595+
.env("K", "V")
596+
.allow_failure();
597+
}
449598
}

0 commit comments

Comments
 (0)