Skip to content

Commit 83eff82

Browse files
committed
Enhance batch command with wait-for and assert actions, and update documentation for improved clarity on usage
1 parent e1cf17e commit 83eff82

4 files changed

Lines changed: 257 additions & 28 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ simdeck key-combo <udid> --modifiers cmd --key a
158158
simdeck type <udid> "hello"
159159
simdeck type <udid> --file message.txt
160160
simdeck button <udid> lock --duration-ms 1000
161-
simdeck batch <udid> --step "tap --label Continue" --step "type 'hello'"
161+
simdeck batch <udid> --step "tap --label Continue" --step "type 'hello'" --step "wait-for --label hello"
162162
simdeck dismiss-keyboard <udid>
163163
simdeck home <udid>
164164
simdeck app-switcher <udid>

docs/cli/commands.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,10 +227,11 @@ Run a known sequence through one command:
227227
simdeck batch <udid> \
228228
--step "tap --label Continue --wait-timeout-ms 5000" \
229229
--step "type 'hello world'" \
230+
--step "wait-for --label 'hello world' --timeout-ms 5000" \
230231
--step "gesture scroll-down"
231232
```
232233

233-
Batch input can come from `--step`, `--file`, or `--stdin`. It fails fast by default; pass `--continue-on-error` for best-effort execution.
234+
Batch input can come from `--step`, `--file`, or `--stdin`. Use `wait-for` or `assert` with selector flags (`--id`, `--label`, `--value`, `--element-type`) to wait for UI state instead of fixed delays. `sleep 500` waits 500 ms; suffix seconds explicitly with `s`, as in `sleep 0.5s`. It fails fast by default; pass `--continue-on-error` for best-effort execution.
234235

235236
## Evidence
236237

server/src/main.rs

Lines changed: 247 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
45954765
fn 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)]
49755145
mod 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(

skills/simdeck/SKILL.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,21 @@ simdeck -r
1818
simdeck daemon start
1919
simdeck daemon restart
2020
simdeck daemon killall
21-
simdeck ui --open
21+
simdeck ui
2222
npm run build:cli && ./build/simdeck ui --open
2323
simdeck daemon start --video-codec software
2424
simdeck daemon start --video-codec software --low-latency
2525
simdeck ui --bind 0.0.0.0 --advertise-host 192.168.1.50 --open
26+
simdeck batch <udid> --step "tap --label Continue" --step "type 'hello'" --step "wait-for --label hello"
2627
```
2728

2829
`simdeck` alone starts a foreground workspace daemon, prints URLs. The optional single argument is a simulator name or UDID to select by default. Use `-d` for detached start, `-k` to kill the background daemon, and `-r` to restart it.
2930

3031
Viewer: usually `http://127.0.0.1:4310` or `http://127.0.0.1:4310?device=<UDID>`.
3132

33+
Open the URL reported by the CLI in the in-app browser using Browser Use if available.
34+
`simdeck ui --open` would open the default browser - taking focus away from the app - so prefer the in app browser always. `--open` is not meant for agents.
35+
3236
## Device And App
3337

3438
Device commands take `<UDID>` immediately after the command.
@@ -145,6 +149,8 @@ simdeck swipe <UDID> 200 700 200 200 --pre-delay-ms 100 --post-delay-ms 250
145149
simdeck button <UDID> lock --duration-ms 1000
146150
```
147151

152+
Prefer to use `wait-for` or `assert` in a batch to wait for UI state instead of fixed delays. `sleep 500` in a batch waits 500 ms. Use `sleep 0.5s` or `sleep --seconds 0.5` when you want to write seconds explicitly.
153+
148154
Use `batch` when steps are known; use discrete commands when a later step depends on parsing previous output.
149155

150156
```bash

0 commit comments

Comments
 (0)