@@ -338,19 +338,24 @@ impl Base<'_> {
338338fn 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