Skip to content

Commit dd1ab39

Browse files
author
Roy Lin
committed
test(ci): cover parsing, Display, and orchestration via a fake-box stub
Extracts `snapshot ls` parsing into a pure `parse_snapshot_id` helper and unit-tests it; tests every `CiError` Display arm; and drives warm_base + step end-to-end against a POSIX-sh `a3s-box` stub (plus the missing-binary failure path). Raises a3s-box-ci line coverage 31% -> 91% (cargo-llvm-cov).
1 parent 84f318f commit dd1ab39

1 file changed

Lines changed: 106 additions & 7 deletions

File tree

src/ci/src/lib.rs

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -338,19 +338,24 @@ impl Base<'_> {
338338
fn snapshot_id(name: &str) -> Result<String> {
339339
let out = box_run(&["snapshot", "ls"])?;
340340
let text = String::from_utf8_lossy(&out.stdout);
341-
for line in text.lines() {
341+
parse_snapshot_id(&text, name).ok_or_else(|| CiError::Cli {
342+
args: format!("snapshot ls (locate '{name}')"),
343+
code: None,
344+
stderr: "snapshot not found after create".into(),
345+
})
346+
}
347+
348+
/// Find a snapshot's ID (first column) by name (second column) in `snapshot ls` output.
349+
fn parse_snapshot_id(ls_output: &str, name: &str) -> Option<String> {
350+
for line in ls_output.lines() {
342351
let mut cols = line.split_whitespace();
343352
if let (Some(id), Some(nm)) = (cols.next(), cols.next()) {
344353
if nm == name {
345-
return Ok(id.to_string());
354+
return Some(id.to_string());
346355
}
347356
}
348357
}
349-
Err(CiError::Cli {
350-
args: format!("snapshot ls (locate '{name}')"),
351-
code: None,
352-
stderr: "snapshot not found after create".into(),
353-
})
358+
None
354359
}
355360

356361
/// Warm a base box: run `setup` once, snapshot the result, return a forkable [`Base`].
@@ -446,4 +451,98 @@ mod tests {
446451
);
447452
let _ = std::fs::remove_dir_all(&dir);
448453
}
454+
455+
#[test]
456+
fn parse_snapshot_id_finds_by_name() {
457+
let ls = "SNAPSHOT ID NAME SOURCE BOX\n\
458+
snap-111 ci-base-aaa-snap box-1\n\
459+
snap-222 ci-base-bbb-snap box-2\n";
460+
assert_eq!(
461+
parse_snapshot_id(ls, "ci-base-bbb-snap").as_deref(),
462+
Some("snap-222")
463+
);
464+
assert_eq!(parse_snapshot_id(ls, "nope"), None);
465+
assert_eq!(parse_snapshot_id("", "x"), None);
466+
}
467+
468+
#[test]
469+
fn ci_error_display_covers_each_variant() {
470+
let io = CiError::from(std::io::Error::other("boom"));
471+
assert!(format!("{io}").contains("boom"));
472+
let cli = CiError::Cli {
473+
args: "snapshot rm s".into(),
474+
code: Some(1),
475+
stderr: "still used".into(),
476+
};
477+
assert!(format!("{cli}").contains("still used"));
478+
let step = CiError::StepFailed {
479+
name: "test".into(),
480+
code: 7,
481+
logs: "oops".into(),
482+
};
483+
let s = format!("{step}");
484+
assert!(s.contains("test") && s.contains("exit 7"));
485+
assert!(format!("{}", CiError::NotReady("b1".into())).contains("b1"));
486+
}
487+
488+
// A POSIX-sh stub standing in for `a3s-box`: enough to drive warm_base + step
489+
// through their happy paths (run/exec/start/rm succeed, `snapshot create`
490+
// records name->id in a state file that `snapshot ls` echoes, an exec whose
491+
// command contains "boom" exits 7). Lets us cover the orchestration locally,
492+
// without a real box or KVM.
493+
#[cfg(unix)]
494+
const FAKE_BOX: &str = r#"#!/bin/sh
495+
state="$(dirname "$0")/.snaps"
496+
case "$1" in
497+
run|start|rm) exit 0 ;;
498+
exec) last=""; for a in "$@"; do last="$a"; done
499+
case "$last" in *boom*) exit 7 ;; *) exit 0 ;; esac ;;
500+
snapshot)
501+
case "$2" in
502+
create) name=""; while [ "$#" -gt 0 ]; do [ "$1" = "--name" ] && name="$2"; shift; done
503+
printf '%s %s\n' "snap-fake1" "$name" >> "$state"; printf 'snap-fake1\n'; exit 0 ;;
504+
ls) printf 'ID NAME SRC\n'; cat "$state" 2>/dev/null; exit 0 ;;
505+
*) exit 0 ;;
506+
esac ;;
507+
*) exit 0 ;;
508+
esac
509+
"#;
510+
511+
#[cfg(unix)]
512+
#[test]
513+
fn orchestration_drives_the_cli() {
514+
use std::os::unix::fs::PermissionsExt;
515+
// The ONLY test that touches the A3S_BOX env, so it cannot race a concurrent
516+
// box_bin() reader.
517+
let dir = std::env::temp_dir().join(format!("a3s-ci-fakebox-{}", key(&["fakebox"])));
518+
let _ = std::fs::remove_dir_all(&dir);
519+
std::fs::create_dir_all(&dir).unwrap();
520+
let fake = dir.join("a3s-box");
521+
std::fs::write(&fake, FAKE_BOX).unwrap();
522+
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
523+
524+
// Happy path against the stub.
525+
std::env::set_var("A3S_BOX", &fake);
526+
let cache = FileCache::new(dir.join("cache")).unwrap();
527+
let mut base = warm_base(WarmBase::new("img", "echo hi").cache(&cache)).expect("warm_base");
528+
let r = base.step(Step::new("build", "make")).expect("step");
529+
assert_eq!(r.exit_code, 0);
530+
assert!(!r.cached);
531+
assert!(base.step(Step::new("build", "make")).expect("step2").cached); // cache hit
532+
assert!(matches!(
533+
base.step(Step::new("fail", "boom")),
534+
Err(CiError::StepFailed { code: 7, .. })
535+
));
536+
base.dispose();
537+
538+
// Failure path: a missing binary must surface as Err, never panic.
539+
std::env::set_var("A3S_BOX", "/nonexistent/a3s-box-xyzzy");
540+
assert!(matches!(box_run(&["version"]), Err(CiError::Io(_))));
541+
box_cleanup(&["rm", "-f", "x"]); // best-effort: must not panic
542+
assert!(snapshot_id("any").is_err());
543+
assert!(warm_base(WarmBase::new("img", "echo hi")).is_err());
544+
545+
std::env::remove_var("A3S_BOX");
546+
let _ = std::fs::remove_dir_all(&dir);
547+
}
449548
}

0 commit comments

Comments
 (0)