@@ -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).
151151fn 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<'_> {
338343fn 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 \n snap-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