@@ -4161,23 +4161,36 @@ fn batch_line_to_json_step(line: &str) -> anyhow::Result<Value> {
41614161 let value = match command {
41624162 "sleep" => serde_json:: json!( {
41634163 "action" : "sleep" ,
4164- "seconds " : tokens. get( 1 ) . and_then ( |value| value . parse :: < f64 > ( ) . ok ( ) ) . unwrap_or ( 0.0 ) ,
4164+ "ms " : parse_batch_sleep_duration_ms ( & args , tokens. get( 1 ) . map ( String :: as_str ) ) ? ,
41654165 } ) ,
41664166 "tap" => serde_json:: json!( {
41674167 "action" : "tap" ,
41684168 "x" : args. value( "x" ) . and_then( |value| value. parse:: <f64 >( ) . ok( ) ) ,
41694169 "y" : args. value( "y" ) . and_then( |value| value. parse:: <f64 >( ) . ok( ) ) ,
41704170 "normalized" : args. flag( "normalized" ) ,
4171- "selector" : {
4172- "id" : args. value( "id" ) ,
4173- "label" : args. value( "label" ) ,
4174- "value" : args. value( "value" ) ,
4175- "elementType" : args. value( "element-type" ) ,
4176- } ,
4171+ "selector" : batch_selector_json( & args) ,
41774172 "durationMs" : args. value( "duration-ms" ) . and_then( |value| value. parse:: <u64 >( ) . ok( ) ) . unwrap_or( 60 ) ,
41784173 "waitTimeoutMs" : args. value( "wait-timeout-ms" ) . and_then( |value| value. parse:: <u64 >( ) . ok( ) ) . unwrap_or( 0 ) ,
41794174 "pollMs" : args. value( "poll-interval-ms" ) . and_then( |value| value. parse:: <u64 >( ) . ok( ) ) . unwrap_or( 100 ) ,
41804175 } ) ,
4176+ "wait-for" | "waitFor" => serde_json:: json!( {
4177+ "action" : "waitFor" ,
4178+ "selector" : batch_selector_json( & args) ,
4179+ "source" : args. value( "source" ) ,
4180+ "maxDepth" : args. value( "max-depth" ) . and_then( |value| value. parse:: <usize >( ) . ok( ) ) ,
4181+ "includeHidden" : args. flag( "include-hidden" ) ,
4182+ "timeoutMs" : args. value( "timeout-ms" ) . or_else( || args. value( "wait-timeout-ms" ) ) . and_then( |value| value. parse:: <u64 >( ) . ok( ) ) . unwrap_or( 5_000 ) ,
4183+ "pollMs" : args. value( "poll-interval-ms" ) . and_then( |value| value. parse:: <u64 >( ) . ok( ) ) . unwrap_or( 100 ) ,
4184+ } ) ,
4185+ "assert" => serde_json:: json!( {
4186+ "action" : "assert" ,
4187+ "selector" : batch_selector_json( & args) ,
4188+ "source" : args. value( "source" ) ,
4189+ "maxDepth" : args. value( "max-depth" ) . and_then( |value| value. parse:: <usize >( ) . ok( ) ) ,
4190+ "includeHidden" : args. flag( "include-hidden" ) ,
4191+ "timeoutMs" : args. value( "timeout-ms" ) . or_else( || args. value( "wait-timeout-ms" ) ) . and_then( |value| value. parse:: <u64 >( ) . ok( ) ) . unwrap_or( 5_000 ) ,
4192+ "pollMs" : args. value( "poll-interval-ms" ) . and_then( |value| value. parse:: <u64 >( ) . ok( ) ) . unwrap_or( 100 ) ,
4193+ } ) ,
41814194 "key" => serde_json:: json!( {
41824195 "action" : "key" ,
41834196 "keyCode" : parse_hid_key( tokens. get( 1 ) . map( String :: as_str) . unwrap_or( "" ) ) ?,
@@ -4280,14 +4293,16 @@ fn run_batch_step(
42804293 } ;
42814294 match command {
42824295 "sleep" => {
4283- let seconds = tokens
4284- . get ( 1 )
4285- . ok_or_else ( || crate :: error:: AppError :: bad_request ( "sleep requires seconds." ) ) ?
4286- . parse :: < f64 > ( )
4287- . map_err ( |_| {
4288- crate :: error:: AppError :: bad_request ( "sleep seconds must be numeric." )
4289- } ) ?;
4290- sleep_ms ( ( seconds * 1000.0 ) . max ( 0.0 ) as u64 ) ;
4296+ let args = parse_step_options ( & tokens[ 1 ..] ) ;
4297+ let duration_ms = parse_batch_sleep_duration_ms (
4298+ & args,
4299+ tokens
4300+ . iter ( )
4301+ . skip ( 1 )
4302+ . find ( |token| !token. starts_with ( '-' ) )
4303+ . map ( String :: as_str) ,
4304+ ) ?;
4305+ sleep_ms ( duration_ms) ;
42914306 Ok ( "sleep" )
42924307 }
42934308 "tap" => {
@@ -4306,12 +4321,7 @@ fn run_batch_step(
43064321 x,
43074322 y,
43084323 normalized,
4309- selector : ElementSelector {
4310- id : args. value ( "id" ) . map ( str:: to_owned) ,
4311- label : args. value ( "label" ) . map ( str:: to_owned) ,
4312- value : args. value ( "value" ) . map ( str:: to_owned) ,
4313- element_type : args. value ( "element-type" ) . map ( str:: to_owned) ,
4314- } ,
4324+ selector : batch_selector_from_args ( & args) ,
43154325 wait_timeout_ms : args
43164326 . value ( "wait-timeout-ms" )
43174327 . and_then ( |value| value. parse ( ) . ok ( ) )
@@ -4329,6 +4339,16 @@ fn run_batch_step(
43294339 }
43304340 Ok ( "tap" )
43314341 }
4342+ "wait-for" | "waitFor" => {
4343+ let args = parse_step_options ( & tokens[ 1 ..] ) ;
4344+ wait_for_batch_selector ( bridge, udid, & args) ?;
4345+ Ok ( "wait-for" )
4346+ }
4347+ "assert" => {
4348+ let args = parse_step_options ( & tokens[ 1 ..] ) ;
4349+ wait_for_batch_selector ( bridge, udid, & args) ?;
4350+ Ok ( "assert" )
4351+ }
43324352 "swipe" => {
43334353 let args = parse_step_options ( & tokens[ 1 ..] ) ;
43344354 let start_x = required_f64 ( & args, "start-x" ) ?;
@@ -4592,6 +4612,156 @@ fn required_f64(args: &StepOptions, key: &str) -> Result<f64, crate::error::AppE
45924612 . map_err ( |_| crate :: error:: AppError :: bad_request ( format ! ( "--{key} must be numeric." ) ) )
45934613}
45944614
4615+ fn batch_selector_json ( args : & StepOptions ) -> Value {
4616+ serde_json:: json!( {
4617+ "id" : args. value( "id" ) ,
4618+ "label" : args. value( "label" ) ,
4619+ "value" : args. value( "value" ) ,
4620+ "elementType" : args. value( "element-type" ) ,
4621+ } )
4622+ }
4623+
4624+ fn batch_selector_from_args ( args : & StepOptions ) -> ElementSelector {
4625+ ElementSelector {
4626+ id : args. value ( "id" ) . map ( str:: to_owned) ,
4627+ label : args. value ( "label" ) . map ( str:: to_owned) ,
4628+ value : args. value ( "value" ) . map ( str:: to_owned) ,
4629+ element_type : args. value ( "element-type" ) . map ( str:: to_owned) ,
4630+ }
4631+ }
4632+
4633+ fn wait_for_batch_selector (
4634+ bridge : & NativeBridge ,
4635+ udid : & str ,
4636+ args : & StepOptions ,
4637+ ) -> Result < ( ) , crate :: error:: AppError > {
4638+ let selector = batch_selector_from_args ( args) ;
4639+ if selector. id . is_none ( )
4640+ && selector. label . is_none ( )
4641+ && selector. value . is_none ( )
4642+ && selector. element_type . is_none ( )
4643+ {
4644+ return Err ( crate :: error:: AppError :: bad_request (
4645+ "wait-for/assert requires a selector flag." ,
4646+ ) ) ;
4647+ }
4648+
4649+ let timeout_ms = args
4650+ . value ( "timeout-ms" )
4651+ . or_else ( || args. value ( "wait-timeout-ms" ) )
4652+ . and_then ( |value| value. parse :: < u64 > ( ) . ok ( ) )
4653+ . unwrap_or ( 5_000 ) ;
4654+ let poll_interval_ms = args
4655+ . value ( "poll-interval-ms" )
4656+ . and_then ( |value| value. parse :: < u64 > ( ) . ok ( ) )
4657+ . unwrap_or ( 100 )
4658+ . max ( 10 ) ;
4659+ let deadline = std:: time:: Instant :: now ( ) + Duration :: from_millis ( timeout_ms) ;
4660+
4661+ loop {
4662+ let snapshot = bridge. accessibility_snapshot ( udid, None ) ?;
4663+ if snapshot_contains_element ( & snapshot, & selector) {
4664+ return Ok ( ( ) ) ;
4665+ }
4666+ if timeout_ms == 0 || std:: time:: Instant :: now ( ) >= deadline {
4667+ return Err ( crate :: error:: AppError :: not_found (
4668+ "No accessibility element matched the selector." ,
4669+ ) ) ;
4670+ }
4671+ sleep_ms ( poll_interval_ms) ;
4672+ }
4673+ }
4674+
4675+ fn snapshot_contains_element ( snapshot : & Value , selector : & ElementSelector ) -> bool {
4676+ snapshot
4677+ . get ( "roots" )
4678+ . and_then ( Value :: as_array)
4679+ . map ( |roots| {
4680+ roots
4681+ . iter ( )
4682+ . any ( |root| node_contains_matching_element ( root, selector) )
4683+ } )
4684+ . unwrap_or ( false )
4685+ }
4686+
4687+ fn node_contains_matching_element ( node : & Value , selector : & ElementSelector ) -> bool {
4688+ element_matches ( node, selector)
4689+ || node
4690+ . get ( "children" )
4691+ . and_then ( Value :: as_array)
4692+ . map ( |children| {
4693+ children
4694+ . iter ( )
4695+ . any ( |child| node_contains_matching_element ( child, selector) )
4696+ } )
4697+ . unwrap_or ( false )
4698+ }
4699+
4700+ fn parse_batch_sleep_duration_ms (
4701+ args : & StepOptions ,
4702+ positional : Option < & str > ,
4703+ ) -> Result < u64 , crate :: error:: AppError > {
4704+ if let Some ( value) = args
4705+ . value ( "ms" )
4706+ . or_else ( || args. value ( "milliseconds" ) )
4707+ . or_else ( || args. value ( "duration-ms" ) )
4708+ {
4709+ return parse_duration_ms_value ( value, "sleep --ms" ) ;
4710+ }
4711+
4712+ if let Some ( value) = args. value ( "seconds" ) . or_else ( || args. value ( "s" ) ) {
4713+ return parse_duration_seconds_value ( value, "sleep --seconds" ) ;
4714+ }
4715+
4716+ let Some ( value) = positional else {
4717+ return Err ( crate :: error:: AppError :: bad_request (
4718+ "sleep requires a duration, for example `sleep 500`, `sleep 500ms`, or `sleep 0.5s`." ,
4719+ ) ) ;
4720+ } ;
4721+
4722+ parse_duration_literal_ms ( value)
4723+ }
4724+
4725+ fn parse_duration_literal_ms ( value : & str ) -> Result < u64 , crate :: error:: AppError > {
4726+ let value = value. trim ( ) ;
4727+ if let Some ( ms) = value. strip_suffix ( "ms" ) {
4728+ return parse_duration_ms_value ( ms, "sleep duration" ) ;
4729+ }
4730+ if let Some ( seconds) = value. strip_suffix ( 's' ) {
4731+ return parse_duration_seconds_value ( seconds, "sleep duration" ) ;
4732+ }
4733+ parse_duration_ms_value ( value, "sleep duration" )
4734+ }
4735+
4736+ fn parse_duration_ms_value ( value : & str , context : & str ) -> Result < u64 , crate :: error:: AppError > {
4737+ let duration = value
4738+ . trim ( )
4739+ . parse :: < f64 > ( )
4740+ . map_err ( |_| crate :: error:: AppError :: bad_request ( format ! ( "{context} must be numeric." ) ) ) ?;
4741+ finite_non_negative_duration_ms ( duration, 1.0 , context)
4742+ }
4743+
4744+ fn parse_duration_seconds_value ( value : & str , context : & str ) -> Result < u64 , crate :: error:: AppError > {
4745+ let duration = value
4746+ . trim ( )
4747+ . parse :: < f64 > ( )
4748+ . map_err ( |_| crate :: error:: AppError :: bad_request ( format ! ( "{context} must be numeric." ) ) ) ?;
4749+ finite_non_negative_duration_ms ( duration, 1000.0 , context)
4750+ }
4751+
4752+ fn finite_non_negative_duration_ms (
4753+ value : f64 ,
4754+ multiplier : f64 ,
4755+ context : & str ,
4756+ ) -> Result < u64 , crate :: error:: AppError > {
4757+ if !value. is_finite ( ) || value < 0.0 {
4758+ return Err ( crate :: error:: AppError :: bad_request ( format ! (
4759+ "{context} must be finite and non-negative."
4760+ ) ) ) ;
4761+ }
4762+ Ok ( ( value * multiplier) . round ( ) as u64 )
4763+ }
4764+
45954765fn tokenize_step ( line : & str ) -> Result < Vec < String > , crate :: error:: AppError > {
45964766 enum State {
45974767 Normal ,
@@ -4974,10 +5144,11 @@ fn default_client_root() -> anyhow::Result<PathBuf> {
49745144#[ cfg( test) ]
49755145mod tests {
49765146 use super :: {
4977- normalize_accessibility_point_for_display, server_health_watchdog_should_restart,
4978- service_post_error_is_retryable, studio_daemon_restart_args, Cli , Command , DaemonCommand ,
4979- StreamQualityProfileArg , StudioExposeOptions , VideoCodecMode ,
4980- SERVER_HEALTH_WATCHDOG_FAILURE_THRESHOLD , SERVER_HEALTH_WATCHDOG_HTTP_FAILURE_THRESHOLD ,
5147+ batch_line_to_json_step, normalize_accessibility_point_for_display,
5148+ server_health_watchdog_should_restart, service_post_error_is_retryable,
5149+ studio_daemon_restart_args, Cli , Command , DaemonCommand , StreamQualityProfileArg ,
5150+ StudioExposeOptions , VideoCodecMode , SERVER_HEALTH_WATCHDOG_FAILURE_THRESHOLD ,
5151+ SERVER_HEALTH_WATCHDOG_HTTP_FAILURE_THRESHOLD ,
49815152 } ;
49825153 use clap:: Parser ;
49835154
@@ -5054,6 +5225,57 @@ mod tests {
50545225 ) ) ;
50555226 }
50565227
5228+ #[ test]
5229+ fn batch_sleep_positional_duration_defaults_to_milliseconds ( ) {
5230+ let step = batch_line_to_json_step ( "sleep 500" ) . unwrap ( ) ;
5231+
5232+ assert_eq ! ( step[ "action" ] , "sleep" ) ;
5233+ assert_eq ! ( step[ "ms" ] , 500 ) ;
5234+ assert ! ( step. get( "seconds" ) . is_none( ) ) ;
5235+ }
5236+
5237+ #[ test]
5238+ fn batch_sleep_accepts_explicit_seconds_and_milliseconds ( ) {
5239+ assert_eq ! ( batch_line_to_json_step( "sleep 0.5s" ) . unwrap( ) [ "ms" ] , 500 ) ;
5240+ assert_eq ! (
5241+ batch_line_to_json_step( "sleep --seconds 0.25" ) . unwrap( ) [ "ms" ] ,
5242+ 250
5243+ ) ;
5244+ assert_eq ! (
5245+ batch_line_to_json_step( "sleep --ms 125" ) . unwrap( ) [ "ms" ] ,
5246+ 125
5247+ ) ;
5248+ assert_eq ! (
5249+ batch_line_to_json_step( "sleep --duration-ms 75" ) . unwrap( ) [ "ms" ] ,
5250+ 75
5251+ ) ;
5252+ }
5253+
5254+ #[ test]
5255+ fn batch_wait_for_maps_selector_and_timeout_options ( ) {
5256+ let step = batch_line_to_json_step (
5257+ "wait-for --id todo-title-1 --label Done --timeout-ms 750 --poll-interval-ms 25 --source native-ax --max-depth 4" ,
5258+ )
5259+ . unwrap ( ) ;
5260+
5261+ assert_eq ! ( step[ "action" ] , "waitFor" ) ;
5262+ assert_eq ! ( step[ "selector" ] [ "id" ] , "todo-title-1" ) ;
5263+ assert_eq ! ( step[ "selector" ] [ "label" ] , "Done" ) ;
5264+ assert_eq ! ( step[ "timeoutMs" ] , 750 ) ;
5265+ assert_eq ! ( step[ "pollMs" ] , 25 ) ;
5266+ assert_eq ! ( step[ "source" ] , "native-ax" ) ;
5267+ assert_eq ! ( step[ "maxDepth" ] , 4 ) ;
5268+ }
5269+
5270+ #[ test]
5271+ fn batch_assert_maps_to_assert_action ( ) {
5272+ let step = batch_line_to_json_step ( "assert --value Ready" ) . unwrap ( ) ;
5273+
5274+ assert_eq ! ( step[ "action" ] , "assert" ) ;
5275+ assert_eq ! ( step[ "selector" ] [ "value" ] , "Ready" ) ;
5276+ assert_eq ! ( step[ "timeoutMs" ] , 5000 ) ;
5277+ }
5278+
50575279 #[ test]
50585280 fn server_health_watchdog_restarts_when_http_listener_is_unhealthy ( ) {
50595281 assert ! ( server_health_watchdog_should_restart(
0 commit comments