mod act;
mod authoring;
mod bench;
mod capsule;
mod down;
mod federation;
#[cfg(test)]
mod guide_gate;
mod init;
mod parallel;
mod readiness;
#[cfg(test)]
mod release_record;
mod runner_android;
mod script;
use clap::{Parser, Subcommand};
use smix_simctl::registry::{self, RegistryError, SimRegistry};
use smix_simctl::{Appearance, DeviceControlError, LaunchResult, SimctlClient};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
#[derive(Parser, Debug)]
#[command(
name = "smix",
about = "AI-native iOS Simulator + Android emulator automation",
version,
long_about = "\
smix — AI-native automation for iOS Simulator + Android emulator.
What smix is:
· A single tool that owns the full sim/emulator lifecycle (boot →
capsule → flow → teardown).
· A pinned-device model: every command takes an explicit DEVICE
(registry alias from `.smix/sims.json` or raw UDID). There is no
`--device booted` fallback; ambiguity is a bug, not a feature.
· A three-layer architecture: sense (tree / find / OCR / popups) and
act (tap / fill / swipe / press-key) are core flat capabilities;
decide lives in driver impls.
· Two yaml dialects:
- smix flows (read maestro-format yaml, plus smix-native extensions:
ocrText / anchorRelative / fallback / cross-platform `app:`).
Run via `smix run flow.yaml`.
- smix-native run-script (shell-friendly sequential subcommand
driver). Run via `smix run-script script.yaml`.
· AI-readable failures: every error carries visibleElements +
suggestions + code, not just a stack trace.
What smix is NOT:
· Not a build tool. smix does not build the app under test; you build,
smix installs + drives.
· Not a maestro wrapper. We read maestro's yaml format because flow
files are portable, not because we are bound to its product surface.
Quick start:
smix sim boot <DEVICE> # boot a registered sim/emulator
smix capsule up <DEVICE> # start runner (XCUITest on iOS,
# Kotlin instrumentation on Android)
smix run flow.yaml --device <DEVICE> # execute a flow
smix find --selector-id <a11y-id> # ad-hoc probe (one-shot)
smix tree --json # inspect current a11y tree
smix capsule down <DEVICE> # teardown
Subcommand categories:
Environment:
doctor, sim, runner, capsule, down
Flow execution:
run (maestro-format yaml flow)
run-script (smix-native sequential subcommand script)
Live probes (require a running runner):
tap, find, wait-for, fill, press-key, scroll, hide-keyboard,
tree, describe, system-popups
Documentation:
- Master AI guide: docs/AI_GUIDE.md
- Quickstart: docs/ai-guide/01-quickstart.md
- CLI reference: docs/ai-guide/05-cli.md
- Cookbook: docs/ai-guide/08-cookbook.md
- Errors + remedies: docs/ai-guide/07-errors.md
Sim safety hook:
Bare `xcrun simctl <verb>` is BLOCKED for mutating verbs (read-only
`simctl list` is allowed). Use typed `smix sim ...` subcommands or
`smix sim exec <DEVICE> ...` for passthrough. The hook requires an
explicit device id — there is no 'booted' / blanket selector.
"
)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[allow(clippy::large_enum_variant)]
#[derive(Subcommand, Debug)]
enum Cmd {
Init {
#[arg(long, default_value = "dev")]
alias: String,
#[arg(long)]
device: Option<String>,
#[arg(long)]
app: Option<PathBuf>,
},
Doctor {
#[arg(long)]
json: bool,
},
Bench {
#[arg(long = "update-baseline", default_value_t = false)]
update_baseline: bool,
#[arg(long = "current-file")]
current_file: Option<std::path::PathBuf>,
#[arg(long = "baseline-file")]
baseline_file: Option<std::path::PathBuf>,
},
Diagnostic {
#[command(subcommand)]
action: DiagnosticAction,
},
Sim {
#[command(subcommand)]
action: SimAction,
},
Runner {
#[command(subcommand)]
action: RunnerAction,
},
Down,
Capsule {
#[command(subcommand)]
action: CapsuleAction,
},
Tap {
selector: String,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
Find {
selector: String,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
WaitFor {
selector: String,
#[arg(long, default_value_t = 5)]
timeout: u64,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
Fill {
selector: String,
#[arg(long)]
text: String,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
PressKey {
key: String,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
Scroll {
selector: String,
#[arg(long)]
direction: String,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
HideKeyboard {
#[arg(long)]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
Tree {
#[arg(long)]
json: bool,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
Describe {
#[arg(long)]
json: bool,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
SystemPopups {
#[arg(long)]
json: bool,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
SystemPopupAction {
popup_id: String,
button_id: String,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
RunScript {
path: PathBuf,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
#[command(long_about = "\
Run a flow file end-to-end on the connected sim/emulator.
A smix flow is a yaml document with two parts: a header (app id / logical \
key) and an ordered list of steps. smix accepts the maestro yaml format \
(40 verbs: assertVisible, tapOn, inputText, scroll, runFlow, ...) plus \
smix-native extensions (ocrText / anchorRelative / fallback selectors, \
cross-platform `app:` resolver via smix-apps.yaml).
Prerequisites:
1. Sim / emulator booted with a known device id (registry alias or UDID)
2. Runner up (`smix capsule up <DEVICE>`)
3. App installed + (optionally) launched
Common invocations:
# iOS (capsule default port 22087)
smix run --device ios-17 flow.yaml
# Android (Kotlin runner on adb-forwarded :28080)
smix run --device emulator-5554 --platform android \\
--apps-config smix-apps.yaml --runner-port 28080 flow.yaml
# Skip auto-foreground (app already on screen)
smix run --device <DEVICE> --no-launch flow.yaml
Exit codes:
0 success
2 yaml parse error
3 runtime SDK failure (sim / app problem mid-flow)
4 unknown verb / direction
5 runFlow cycle / file IO
6 runner unreachable (capsule not up / wrong port)
Documentation: docs/AI_GUIDE.md
")]
Run {
#[arg(required = true, num_args = 1..)]
flows: Vec<PathBuf>,
#[arg(long, env = "SMIX_UDID")]
device: Option<String>,
#[arg(long = "also-device")]
also_device: Vec<String>,
#[arg(long, default_value_t = 1)]
parallel: usize,
#[arg(long, conflicts_with_all = ["device", "also_device", "parallel"])]
nodes: Option<PathBuf>,
#[arg(long)]
bundle_id: Option<String>,
#[arg(long, env = "SMIX_RUNNER_PORT")]
runner_port: Option<u16>,
#[arg(long, default_value_t = false)]
no_launch: bool,
#[arg(long, default_value_t = false)]
animations: bool,
#[arg(long, value_enum, env = "SMIX_PLATFORM", default_value_t = RunPlatform::Ios)]
platform: RunPlatform,
#[arg(long, env = "SMIX_APPS_CONFIG")]
apps_config: Option<PathBuf>,
#[arg(long = "env", value_parser = parse_kv_pair, action = clap::ArgAction::Append)]
env: Vec<(String, String)>,
#[arg(long = "debug-output")]
debug_output: Option<PathBuf>,
#[arg(long, default_value_t = false)]
verbose: bool,
#[arg(long, value_enum, default_value_t = RunOutputFormat::Human)]
format: RunOutputFormat,
#[arg(long, default_value_t = false)]
activate: bool,
#[arg(long, default_value_t = false)]
fail_fast: bool,
#[arg(long = "retry", default_value_t = 1)]
retry: u32,
#[arg(long = "await-signal")]
await_signal: Option<String>,
#[arg(long = "gate-signal")]
gate_signal: Option<String>,
#[arg(long = "gate-signal-timeout", default_value_t = 60_000)]
gate_signal_timeout_ms: u64,
#[arg(long = "expect-log-clean", default_value_t = false)]
expect_log_clean: bool,
#[arg(long = "metro-log-url")]
metro_log_url: Option<String>,
#[arg(long = "fixture-registry")]
fixture_registry: Option<PathBuf>,
#[arg(long = "force-key-events", default_value_t = false)]
force_key_events: bool,
#[arg(long = "no-fail-annotate", default_value_t = false)]
no_fail_annotate: bool,
#[arg(long = "check", alias = "dry-run", default_value_t = false)]
check: bool,
},
Migrate {
#[arg(num_args = 0..)]
paths: Vec<PathBuf>,
#[arg(long, default_value_t = false)]
in_place: bool,
},
Annotate {
input: PathBuf,
output: PathBuf,
#[arg(long = "annotate", num_args = 1..)]
annotations: Vec<String>,
#[arg(long, default_value = "balanced")]
compression: String,
#[arg(long)]
font: Option<PathBuf>,
},
Authoring {
#[command(subcommand)]
action: AuthoringAction,
},
}
#[derive(Subcommand, Debug)]
enum AuthoringAction {
Generate {
input: PathBuf,
#[arg(long, value_enum, default_value_t = authoring::GenFormat::Maestro)]
format: authoring::GenFormat,
#[arg(long, short)]
output: PathBuf,
#[arg(long, default_value = "com.example")]
app_id: String,
#[arg(long, default_value = "recorded")]
test_fn_name: String,
},
TapRecord {
#[arg(long, short)]
output: PathBuf,
#[arg(long, default_value_t = 10)]
duration: u64,
#[arg(long, value_enum, default_value_t = authoring::GenFormat::Maestro)]
format: authoring::GenFormat,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
#[arg(long, default_value = "com.example")]
app_id: String,
#[arg(long, default_value = "recorded")]
test_fn_name: String,
},
Suggest {
partial: String,
#[arg(long, env = "SMIX_RUNNER_PORT")]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
CaptureTree {
output: PathBuf,
#[arg(long, env = "SMIX_RUNNER_PORT")]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
DiffTree {
baseline: PathBuf,
#[arg(long, env = "SMIX_RUNNER_PORT")]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
Propose {
flow: PathBuf,
#[arg(long)]
bundle: PathBuf,
#[arg(long, short)]
output: PathBuf,
},
Record {
output: PathBuf,
#[arg(long, default_value_t = 10)]
duration_secs: u64,
#[arg(long, default_value_t = 500)]
interval_ms: u64,
#[arg(long, env = "SMIX_RUNNER_PORT")]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
#[arg(long)]
app_id: Option<String>,
},
}
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
enum RunOutputFormat {
Human,
Json,
Junit,
}
impl RunOutputFormat {
fn to_adapter(self) -> smix_adapter_maestro::OutputFormat {
match self {
Self::Human => smix_adapter_maestro::OutputFormat::Human,
Self::Json => smix_adapter_maestro::OutputFormat::Json,
Self::Junit => smix_adapter_maestro::OutputFormat::Junit,
}
}
}
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
enum RunPlatform {
Ios,
Android,
}
impl RunPlatform {
fn to_flow(self) -> smix_adapter_maestro::FlowPlatform {
match self {
Self::Ios => smix_adapter_maestro::FlowPlatform::Ios,
Self::Android => smix_adapter_maestro::FlowPlatform::Android,
}
}
}
#[derive(Subcommand, Debug)]
enum CapsuleAction {
Up {
device: String,
#[arg(long)]
bundle: String,
#[arg(long = "no-launch", default_value_t = false)]
no_launch: bool,
#[arg(long, default_value_t = false)]
require_hard: bool,
#[arg(long)]
soft: bool,
#[arg(long)]
no_capture: bool,
},
Down { device: String },
}
#[derive(Subcommand, Debug)]
enum RunnerAction {
Up {
device: String,
#[arg(long, value_enum, default_value_t = RunPlatform::Ios)]
platform: RunPlatform,
#[arg(long)]
bundle: Option<String>,
#[arg(long = "runner-project", env = "SMIX_RUNNER_PROJECT")]
runner_project: Option<PathBuf>,
#[arg(long = "runner-port", env = "SMIX_RUNNER_PORT")]
runner_port: Option<u16>,
#[arg(long = "supervise", default_value_t = false)]
supervise: bool,
#[arg(long = "no-launch", default_value_t = false)]
no_launch: bool,
},
Down {
#[arg(long, value_enum, default_value_t = RunPlatform::Ios)]
platform: RunPlatform,
#[arg(long)]
device: Option<String>,
},
Cycle {
#[arg(long = "runner-project", env = "SMIX_RUNNER_PROJECT")]
runner_project: Option<PathBuf>,
},
Supervise {
#[arg(long = "runner-project", env = "SMIX_RUNNER_PROJECT")]
runner_project: Option<PathBuf>,
},
ListSessions,
Install {
#[arg(long)]
path: Option<PathBuf>,
#[arg(long, default_value_t = false)]
force: bool,
},
}
#[derive(Subcommand, Debug)]
enum SimAction {
List {
#[arg(long)]
json: bool,
},
Resolve { device: String },
Register {
alias: String,
#[arg(long)]
udid: String,
#[arg(long)]
locale: Option<String>,
#[arg(long = "runner-port")]
runner_port: Option<u16>,
},
Boot { device: String },
Shutdown { device: String },
Erase { device: String },
Screenshot { device: String, out: PathBuf },
Launch {
device: String,
bundle_id: String,
#[arg(long = "child-env", value_parser = parse_kv_pair, action = clap::ArgAction::Append)]
child_env: Vec<(String, String)>,
#[arg(last = true)]
launch_args: Vec<String>,
},
Terminate { device: String, bundle_id: String },
Install { device: String, app_path: PathBuf },
Uninstall { device: String, bundle_id: String },
Openurl { device: String, url: String },
Appearance {
device: String,
#[arg(value_parser = parse_appearance)]
mode: Appearance,
},
KeychainReset { device: String },
Locale {
device: String,
lang: String,
#[arg(long)]
reboot: bool,
},
Exec {
device: String,
verb: String,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
}
fn parse_kv_pair(s: &str) -> Result<(String, String), String> {
let (k, v) = s
.split_once('=')
.ok_or_else(|| format!("expected `KEY=VALUE`, got `{s}`"))?;
if k.is_empty() {
return Err(format!("empty KEY in `{s}`"));
}
Ok((k.to_string(), v.to_string()))
}
fn parse_appearance(s: &str) -> Result<Appearance, String> {
match s.to_ascii_lowercase().as_str() {
"light" => Ok(Appearance::Light),
"dark" => Ok(Appearance::Dark),
other => Err(format!("expected 'light' or 'dark', got {:?}", other)),
}
}
fn resolve_device(device_ref: &str) -> Result<String, CliError> {
if registry::is_udid(device_ref) {
return Ok(device_ref.to_ascii_uppercase());
}
let path = registry_path()?;
Ok(SimRegistry::load(&path)?.resolve(device_ref)?)
}
fn registry_path() -> Result<PathBuf, CliError> {
if let Some(p) = std::env::var_os("SMIX_SIMS_JSON") {
return Ok(PathBuf::from(p));
}
let cwd = std::env::current_dir()
.map_err(|e| CliError::Other(format!("cannot determine cwd: {e}")))?;
SimRegistry::discover(&cwd).ok_or_else(|| {
CliError::Other(format!(
"no .smix registry was found upward from {} — pass an explicit \
UDID or set SMIX_SIMS_JSON",
cwd.display()
))
})
}
fn lookup_registered(device_ref: &str) -> Option<smix_simctl::registry::RegisteredSim> {
let path = registry_path().ok()?;
let reg = SimRegistry::load(&path).ok()?;
reg.lookup(device_ref).cloned()
}
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() -> ExitCode {
let cli = Cli::parse();
match run(cli).await {
Ok(code) => code,
Err(e) => {
eprintln!("error: {e}");
ExitCode::from(1)
}
}
}
async fn run(cli: Cli) -> Result<ExitCode, CliError> {
if let Some(dir) = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
{
let diag_root = dir.join("smix");
smix_simctl::set_subprocess_ring_persist_path(diag_root.clone());
smix_simctl::set_reset_app_data_counters_persist_path(diag_root.clone());
smix_simctl::set_flow_attempts_persist_path(diag_root);
}
let simctl = SimctlClient::new();
match cli.cmd {
Cmd::Init { alias, device, app } => {
cmd_init(&simctl, &alias, device.as_deref(), app.as_deref()).await?
}
Cmd::Doctor { json } => cmd_doctor(&simctl, json).await?,
Cmd::Bench {
update_baseline,
current_file,
baseline_file,
} => {
let default_baseline = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("bench")
.join("baseline.json");
let baseline_path = baseline_file.unwrap_or(default_baseline);
bench::run(update_baseline, current_file.as_deref(), &baseline_path)
.map_err(CliError::Other)?;
}
Cmd::Diagnostic { action } => cmd_diagnostic(action).await?,
Cmd::Sim { action } => match action {
SimAction::List { json } => cmd_sim_list(&simctl, json).await?,
SimAction::Resolve { device } => {
println!("{}", resolve_device(&device)?);
}
SimAction::Register {
alias,
udid,
locale,
runner_port,
} => {
let udid = udid.to_ascii_uppercase();
if !registry::is_udid(&udid) {
return Err(CliError::Other(format!(
"--udid {udid:?} is not UDID-form (8-4-4-4-12 hex); \
find it via `smix sim list`"
)));
}
let devices = simctl.list_devices().await?;
let device = devices
.iter()
.find(|d| d.udid.eq_ignore_ascii_case(&udid))
.ok_or_else(|| {
CliError::Other(format!(
"simctl knows no device {udid} — check `smix sim list`"
))
})?;
let path = registry_path().unwrap_or_else(|_| PathBuf::from(".smix/sims.json"));
let outcome = SimRegistry::register(
&path,
&alias,
smix_simctl::registry::RegisteredSim {
device_name: device.name.clone(),
udid: device.udid.to_ascii_uppercase(),
runtime: device.runtime_identifier.clone(),
device_type: device.device_type_identifier.clone(),
locale,
runner_port,
},
)?;
let verb = match outcome {
smix_simctl::registry::RegisterOutcome::Added => "registered",
smix_simctl::registry::RegisterOutcome::Updated => "updated",
};
println!(
"{verb}: {alias} → {} ({}) in {}",
device.udid,
device.name,
smix_simctl::registry::store_dir(&path).display()
);
}
SimAction::Boot { device } => {
let udid = resolve_device(&device)?;
simctl.boot(&udid).await?;
println!("booted: {udid}");
if let Some(spec) = lookup_registered(&device)
&& let Some(desired) = spec.locale.as_ref()
{
let current = simctl.current_locale(&udid).await.ok().flatten();
if current.as_deref() == Some(desired.as_str()) {
println!("locale: {desired} ok");
} else {
eprintln!(
"locale: enforcing {desired} (current {})",
current.as_deref().unwrap_or("<unset>")
);
simctl.set_locale(&udid, desired).await?;
simctl.shutdown(&udid).await?;
simctl
.boot_and_wait(&udid, std::time::Duration::from_secs(60))
.await?;
println!("locale: {desired} enforced + sim re-booted");
}
}
}
SimAction::Shutdown { device } => {
let udid = resolve_device(&device)?;
simctl.shutdown(&udid).await?;
println!("shutdown: {udid}");
}
SimAction::Erase { device } => {
let udid = resolve_device(&device)?;
simctl.erase(&udid).await?;
println!("erased: {udid}");
}
SimAction::Screenshot { device, out } => {
let udid = resolve_device(&device)?;
let png = simctl.screenshot(&udid).await?;
if out.as_os_str() == "-" {
use std::io::Write;
std::io::stdout()
.write_all(&png)
.map_err(|e| CliError::Other(format!("write stdout: {e}")))?;
} else {
std::fs::write(&out, &png)
.map_err(|e| CliError::Other(format!("write {}: {e}", out.display())))?;
println!(
"screenshot: {udid} → {} ({} bytes)",
out.display(),
png.len()
);
}
}
SimAction::Launch {
device,
bundle_id,
child_env,
launch_args,
} => {
let udid = resolve_device(&device)?;
let pairs: Vec<(&str, &str)> = child_env
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let LaunchResult { pid } = simctl
.launch_with_args_and_env(&udid, &bundle_id, &launch_args, &pairs)
.await?;
println!("launched: {bundle_id} on {udid} (pid {pid})");
}
SimAction::Terminate { device, bundle_id } => {
let udid = resolve_device(&device)?;
simctl.terminate(&udid, &bundle_id).await?;
println!("terminated: {bundle_id} on {udid}");
}
SimAction::Install { device, app_path } => {
let udid = resolve_device(&device)?;
simctl
.install(&udid, &app_path.display().to_string())
.await?;
println!("installed: {} on {udid}", app_path.display());
}
SimAction::Uninstall { device, bundle_id } => {
let udid = resolve_device(&device)?;
simctl.uninstall(&udid, &bundle_id).await?;
println!("uninstalled: {bundle_id} on {udid}");
}
SimAction::Openurl { device, url } => {
let udid = resolve_device(&device)?;
simctl.open_url(&udid, &url).await?;
println!("opened: {url} on {udid}");
}
SimAction::Appearance { device, mode } => {
let udid = resolve_device(&device)?;
simctl.set_appearance(&udid, mode).await?;
println!("appearance: {udid} → {}", mode.as_str());
}
SimAction::KeychainReset { device } => {
let udid = resolve_device(&device)?;
simctl.keychain_reset(&udid).await?;
println!("keychain reset: {udid}");
}
SimAction::Locale {
device,
lang,
reboot,
} => {
let udid = resolve_device(&device)?;
let current = simctl.current_locale(&udid).await.ok().flatten();
if current.as_deref() == Some(lang.as_str()) {
println!("locale already: {lang}");
return Ok(ExitCode::SUCCESS);
}
simctl.set_locale(&udid, &lang).await?;
if reboot {
println!("locale: written {lang} — rebooting sim to apply");
simctl.shutdown(&udid).await?;
simctl.boot(&udid).await?;
println!("locale: {lang} enforced (sim rebooted)");
} else {
println!(
"locale: written {lang}\n\
note: running apps cache locale at process-start — \
restart the target app, or re-run with `--reboot` to \
cycle the sim so subsequent launches see the new locale."
);
}
}
SimAction::Exec { device, verb, args } => {
return cmd_sim_exec(&device, &verb, &args).await;
}
},
Cmd::Runner { action } => {
let root = smix_workspace_root()?;
match action {
RunnerAction::Up {
device,
platform,
bundle,
runner_project,
runner_port: port_flag,
supervise,
no_launch,
} => {
if platform == RunPlatform::Android {
reject_ios_only_up_flags(
bundle.is_some(),
runner_project.is_some(),
supervise,
)
.map_err(CliError::Other)?;
let port = port_flag.unwrap_or(runner_android::DEFAULT_ANDROID_PORT);
runner_android::up(&root, &device, port, 180).map_err(CliError::Other)?;
return Ok(std::process::ExitCode::SUCCESS);
}
let sims_port = lookup_registered(&device).and_then(|s| s.runner_port);
let port = port_flag.or(sims_port).unwrap_or(22087);
let udid = resolve_device(&device)?;
smix_capsule::runner::up(
&root,
&udid,
port,
bundle.as_deref(),
runner_project.as_deref(),
smix_capsule::runner::UpOptions {
supervise,
attach_without_relaunch: no_launch,
..Default::default()
},
)
.map_err(CliError::Other)?;
}
RunnerAction::Down { platform, device } => {
if platform == RunPlatform::Android {
let serial = device.ok_or_else(|| {
CliError::Other(
"runner down --platform android needs --device \
<adb-serial>: an adb command without one acts on \
whichever device is attached"
.to_string(),
)
})?;
let port = std::env::var("SMIX_RUNNER_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(runner_android::DEFAULT_ANDROID_PORT);
runner_android::down(&root, &serial, port).map_err(CliError::Other)?;
return Ok(std::process::ExitCode::SUCCESS);
}
let port = runner_port();
smix_capsule::runner::down(&root, port).map_err(CliError::Other)?;
}
RunnerAction::Cycle { runner_project } => {
let port = runner_port();
smix_capsule::runner::cycle(&root, port, runner_project.as_deref())
.map_err(CliError::Other)?;
}
RunnerAction::Supervise { runner_project } => {
smix_capsule::runner::supervise(&root, runner_project.as_deref())
.map_err(CliError::Other)?;
}
RunnerAction::ListSessions => {
let port = runner_port();
let client = smix_runner_client::HttpRunnerClient::new(port);
let resp = client
.list_sessions()
.await
.map_err(|e| CliError::Other(format!("/session/list: {e}")))?;
if resp.sessions.is_empty() {
println!("(no open sessions)");
} else {
println!(
"{:<38} {:<40} openedAtMs lastActivatedAtMs",
"sessionId", "bundleId"
);
for s in &resp.sessions {
println!(
"{:<38} {:<40} {:<17} {}",
s.session_id, s.bundle_id, s.opened_at_ms, s.last_activated_at_ms,
);
}
}
}
RunnerAction::Install { path, force } => {
let target = path.unwrap_or_else(|| {
smix_capsule::runner::installed_runner_dir()
.unwrap_or_else(|| PathBuf::from("~/.local/share/smix/runner"))
});
if !force {
match smix_capsule::runner::ensure_installed_runner_synced(&target) {
Ok(smix_capsule::runner::SyncOutcome::AlreadyCurrent) => {
println!(
"runner install: already at v{} — nothing to do (pass --force to re-extract).",
smix_runner_sources::SOURCES_VERSION
);
}
Ok(smix_capsule::runner::SyncOutcome::Extracted {
previous_version,
..
}) => {
let from = previous_version.as_deref().unwrap_or("<none>");
println!(
"runner install: extracted v{} into {} (was {}).",
smix_runner_sources::SOURCES_VERSION,
target.display(),
from
);
}
Err(e) => {
return Err(CliError::Other(format!(
"runner install: sync failed at {}: {e}",
target.display()
)));
}
}
} else {
match smix_runner_sources::extract_to(&target, true) {
Ok(report) => {
let backup_note = report
.backup
.as_ref()
.map(|b| {
format!(" (previous tree backed up to {})", b.display())
})
.unwrap_or_default();
let pruned_note = if report.pruned_backups.is_empty() {
String::new()
} else {
format!(
" Removed {} older backup tree(s), keeping the newest {}.",
report.pruned_backups.len(),
smix_runner_sources::BACKUPS_KEPT
)
};
println!(
"runner install: extracted {} files at v{} into {}{}.{}",
report.file_count,
report.version_written,
target.display(),
backup_note,
pruned_note
);
}
Err(e) => {
return Err(CliError::Other(format!(
"runner install --force: {e}"
)));
}
}
}
}
}
}
Cmd::Down => {
let root = smix_workspace_root()?;
down::run(&root, runner_port())
.await
.map_err(CliError::Other)?;
}
Cmd::Capsule { action } => {
let root = smix_workspace_root()?;
let port = runner_port();
let capture_endpoint = std::env::var("SMIX_CAPTURE_ENDPOINT")
.unwrap_or_else(|_| "http://127.0.0.1:8787".to_string());
match action {
CapsuleAction::Up {
device,
bundle,
soft,
require_hard,
no_capture,
no_launch,
} => {
let udid = resolve_device(&device)?;
capsule::up(capsule::UpOptions {
root: &root,
udid: &udid,
runner_port: port,
capture_endpoint: &capture_endpoint,
bundle: Some(&bundle),
soft,
require_hard,
no_capture,
no_launch,
})
.await
.map_err(CliError::Other)?;
}
CapsuleAction::Down { device } => {
let udid = resolve_device(&device)?;
capsule::down(&root, &udid).await.map_err(CliError::Other)?;
}
}
}
Cmd::Tap {
selector,
port,
device,
} => {
let p = runner_dial_port(port, device.as_deref());
act::cmd_tap(selector, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::Find {
selector,
port,
device,
} => {
let p = runner_dial_port(port, device.as_deref());
act::cmd_find(selector, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::WaitFor {
selector,
timeout,
port,
device,
} => {
let p = runner_dial_port(port, device.as_deref());
act::cmd_wait_for(selector, timeout, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::Fill {
selector,
text,
port,
device,
} => {
let p = runner_dial_port(port, device.as_deref());
act::cmd_fill(selector, text, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::PressKey { key, port, device } => {
let p = runner_dial_port(port, device.as_deref());
act::cmd_press_key(key, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::Scroll {
selector,
direction,
port,
device,
} => {
let p = runner_dial_port(port, device.as_deref());
act::cmd_scroll(selector, direction, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::HideKeyboard { port, device } => {
let p = runner_dial_port(port, device.as_deref());
act::cmd_hide_keyboard(p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::Tree { json, port, device } => {
let p = runner_dial_port(port, device.as_deref());
act::cmd_tree(json, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::Describe { json, port, device } => {
let p = runner_dial_port(port, device.as_deref());
act::cmd_describe(json, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::SystemPopups { json, port, device } => {
let p = runner_dial_port(port, device.as_deref());
act::cmd_system_popups(json, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::SystemPopupAction {
popup_id,
button_id,
port,
device,
} => {
let p = runner_dial_port(port, device.as_deref());
act::cmd_system_popup_action(&popup_id, &button_id, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::RunScript { path, port, device } => {
let p = runner_dial_port(port, device.as_deref());
script::cmd_run_script(&path, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::Run {
flows,
device,
also_device,
parallel,
nodes,
bundle_id,
animations,
runner_port,
no_launch,
platform,
apps_config,
env,
debug_output,
verbose,
format,
activate,
fail_fast,
retry,
await_signal,
gate_signal,
gate_signal_timeout_ms,
expect_log_clean,
metro_log_url,
fixture_registry,
force_key_events,
no_fail_annotate,
check,
} => {
let switches = smix_capsule::runner::load_switches();
let sw_auto_ocr = smix_capsule::runner::resolve_switch(
switches.auto_ocr_fallback,
"SMIX_AUTO_OCR_FALLBACK",
);
let sw_ai_assertions = smix_capsule::runner::resolve_switch(
switches.enable_ai_assertions,
"SMIX_ENABLE_AI_ASSERTIONS",
);
let sw_assert_no_autorecord = smix_capsule::runner::resolve_switch(
switches.assert_screenshot_no_autorecord,
"SMIX_ASSERT_SCREENSHOT_NO_AUTORECORD",
);
let sw_launch_reinstall = smix_capsule::runner::resolve_switch(
switches.launch_fresh_force_reinstall,
"SMIX_LAUNCH_FRESH_FORCE_REINSTALL",
);
let warn_if_env = |r: &smix_capsule::runner::ResolvedSwitch,
env_name: &str,
key: &str| {
if r.source == smix_capsule::runner::SwitchSource::Env {
eprintln!(
"warning: {env_name} is deprecated; use .smix/config.yaml switches.{key}"
);
}
};
if check {
warn_if_env(&sw_auto_ocr, "SMIX_AUTO_OCR_FALLBACK", "autoOcrFallback");
warn_if_env(
&sw_ai_assertions,
"SMIX_ENABLE_AI_ASSERTIONS",
"enableAiAssertions",
);
smix_adapter_maestro::set_auto_ocr_fallback_override(Some(sw_auto_ocr.value));
smix_adapter_maestro::set_ai_assertions_override(Some(sw_ai_assertions.value));
let mut fail = 0u8;
let mut step_total: usize = 0;
for flow_path in &flows {
match std::fs::read_to_string(flow_path) {
Ok(yaml) => match smix_adapter_maestro::parse_flow_yaml(&yaml) {
Ok(flow) => {
let n = flow.steps.len();
step_total += n;
eprintln!(
"smix run: parse OK {} ({n} step{})",
flow_path.display(),
if n == 1 { "" } else { "s" },
);
}
Err(e) => {
eprintln!("smix run: parse FAIL {}: {e}", flow_path.display());
fail = 2;
}
},
Err(e) => {
eprintln!("smix run: parse FAIL {}: read: {e}", flow_path.display());
fail = 2;
}
}
}
if fail == 0 {
eprintln!(
"smix run: parse OK — {} flow{}, {step_total} total step{}",
flows.len(),
if flows.len() == 1 { "" } else { "s" },
if step_total == 1 { "" } else { "s" },
);
}
return Ok(std::process::ExitCode::from(fail));
}
if let Some(nodes_path) = &nodes {
let yaml = std::fs::read_to_string(nodes_path)
.map_err(|e| CliError::Other(format!("read {}: {e}", nodes_path.display())))?;
let node_specs =
federation::parse_nodes(&yaml).map_err(|e| CliError::Other(e.to_string()))?;
for flow in &flows {
if !flow.is_file() {
return Err(CliError::Other(format!(
"flow not found locally: {} — flow paths are repo-relative \
and must exist on every node (scheduler repo is the authority)",
flow.display()
)));
}
}
let mut passthrough: Vec<String> = Vec::new();
if let Some(b) = &bundle_id {
passthrough.push("--bundle-id".into());
passthrough.push(b.clone());
}
if no_launch {
passthrough.push("--no-launch".into());
}
if animations {
passthrough.push("--animations".into());
}
if activate {
passthrough.push("--activate".into());
}
if verbose {
passthrough.push("--verbose".into());
}
if fail_fast {
passthrough.push("--fail-fast".into());
}
if retry != 1 {
passthrough.push("--retry".into());
passthrough.push(retry.to_string());
}
passthrough.push("--platform".into());
passthrough.push(
match platform {
RunPlatform::Ios => "ios",
RunPlatform::Android => "android",
}
.into(),
);
if let Some(a) = &apps_config {
passthrough.push("--apps-config".into());
passthrough.push(a.display().to_string());
}
if debug_output.is_some() {
passthrough.push("--debug-output".into());
passthrough.push(federation::FED_ARTIFACT_DIR.into());
}
for (k, v) in &env {
passthrough.push("--env".into());
passthrough.push(format!("{k}={v}"));
}
let passthrough: Vec<String> = passthrough
.iter()
.map(|t| federation::shell_quote(t))
.collect();
let flow_strs: Vec<String> =
flows.iter().map(|p| p.display().to_string()).collect();
let slots = federation::expand_slots(&node_specs);
let assignments = federation::assign_flows(flow_strs.len(), &slots);
let merged = federation::run_federation(
&node_specs,
&assignments,
&flow_strs,
&passthrough,
debug_output.as_deref(),
)
.map_err(|e| CliError::Other(e.to_string()))?;
let doc = serde_json::to_string(&merged)
.map_err(|e| CliError::Other(format!("serialize merged report: {e}")))?;
println!("{doc}");
return Ok(std::process::ExitCode::from(merged.aggregate_exit));
}
let all_devices: Vec<String> = device
.iter()
.cloned()
.chain(also_device.iter().cloned())
.collect();
let sim_count = parallel::effective_sim_count(parallel, all_devices.len());
if sim_count > 1 {
let devices = &all_devices[..sim_count];
let flow_strs: Vec<String> =
flows.iter().map(|p| p.display().to_string()).collect();
let buckets = parallel::shard_flows(flow_strs.len(), sim_count);
let shards: Vec<(String, Vec<String>)> = buckets
.iter()
.enumerate()
.map(|(i, idxs)| {
(
devices[i].clone(),
idxs.iter().map(|&j| flow_strs[j].clone()).collect(),
)
})
.collect();
let mut passthrough: Vec<String> = Vec::new();
if let Some(b) = &bundle_id {
passthrough.push("--bundle-id".into());
passthrough.push(b.clone());
}
if no_launch {
passthrough.push("--no-launch".into());
}
if animations {
passthrough.push("--animations".into());
}
if activate {
passthrough.push("--activate".into());
}
if verbose {
passthrough.push("--verbose".into());
}
if fail_fast {
passthrough.push("--fail-fast".into());
}
if retry != 1 {
passthrough.push("--retry".into());
passthrough.push(retry.to_string());
}
passthrough.push("--platform".into());
passthrough.push(
match platform {
RunPlatform::Ios => "ios",
RunPlatform::Android => "android",
}
.into(),
);
if let Some(a) = &apps_config {
passthrough.push("--apps-config".into());
passthrough.push(a.display().to_string());
}
if let Some(d) = &debug_output {
passthrough.push("--debug-output".into());
passthrough.push(d.display().to_string());
}
for (k, v) in &env {
passthrough.push("--env".into());
passthrough.push(format!("{k}={v}"));
}
let exe = std::env::current_exe()
.map_err(|e| CliError::Other(format!("cannot find smix binary: {e}")))?;
let code = parallel::run_parallel(&exe, &shards, &passthrough);
return Ok(std::process::ExitCode::from(code));
}
if verbose && std::env::var_os("SMIX_LOG").is_none() {
unsafe { std::env::set_var("SMIX_LOG", "debug") };
}
let udid = device
.as_deref()
.map(|d| resolve_device(d).unwrap_or_else(|_| d.to_string()));
let bundle = bundle_id.clone();
let port = run_port(runner_port, || {
device
.as_deref()
.and_then(lookup_registered)
.and_then(|sim| sim.runner_port)
});
let plat = platform.to_flow();
let out_fmt = format.to_adapter();
warn_if_env(&sw_auto_ocr, "SMIX_AUTO_OCR_FALLBACK", "autoOcrFallback");
warn_if_env(
&sw_ai_assertions,
"SMIX_ENABLE_AI_ASSERTIONS",
"enableAiAssertions",
);
warn_if_env(
&sw_assert_no_autorecord,
"SMIX_ASSERT_SCREENSHOT_NO_AUTORECORD",
"assertScreenshotNoAutorecord",
);
warn_if_env(
&sw_launch_reinstall,
"SMIX_LAUNCH_FRESH_FORCE_REINSTALL",
"launchFreshForceReinstall",
);
let multi_flow = flows.len() > 1;
let mut worst_exit: u8 = 0;
for (idx, flow_path) in flows.iter().enumerate() {
let per_flow_debug = debug_output.as_ref().map(|d| {
if multi_flow {
let stem = flow_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("flow")
.to_string();
d.join(stem)
} else {
d.clone()
}
});
if multi_flow {
eprintln!(
"smix run: [{}/{}] {}",
idx + 1,
flows.len(),
flow_path.display()
);
}
let attribution_bundle: Option<String> = bundle.clone().or_else(|| {
smix_adapter_maestro::parse_flow_file(flow_path)
.ok()
.map(|f| f.app_id)
.filter(|a| !a.is_empty())
});
let max_attempts = retry.max(1);
let mut attempts: Vec<smix_runner_wire::FlowAttempt> = Vec::new();
let flow_name = flow_path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| flow_path.display().to_string());
let mut final_code: u8 = 1;
for attempt_index in 0..max_attempts {
if attempt_index > 0 {
eprintln!("smix run: retry #{attempt_index} for flow {flow_name}");
}
let ips_before = ips_snapshot(attribution_bundle.as_deref());
let started = std::time::Instant::now();
let code =
smix_adapter_maestro::run_flow_code(smix_adapter_maestro::FlowArgs {
flow: flow_path.clone(),
udid: udid.clone(),
bundle_id: bundle.clone(),
runner_port: port,
animations,
no_launch,
platform: plat,
apps_config: apps_config.clone(),
env_vars: env.clone(),
debug_output: per_flow_debug.clone(),
verbose,
format: out_fmt,
auto_activate: activate,
metro_log_url: metro_log_url.clone(),
await_signal: await_signal.clone(),
gate_signal: gate_signal.clone(),
gate_signal_timeout_ms,
expect_log_clean,
fixture_registry: fixture_registry.clone(),
force_key_events,
no_fail_annotate,
auto_ocr_fallback: Some(sw_auto_ocr.value),
ai_assertions: Some(sw_ai_assertions.value),
assert_screenshot_no_autorecord: Some(sw_assert_no_autorecord.value),
launch_fresh_force_reinstall: Some(sw_launch_reinstall.value),
})
.await;
let wall_ms = started.elapsed().as_millis() as u64;
let ips_after = ips_snapshot(attribution_bundle.as_deref());
let new_ips: Option<String> =
ips_after.iter().find(|p| !ips_before.contains(*p)).cloned();
let (status, error_class) = match code {
0 => ("ok".to_string(), None),
2 => ("error".to_string(), Some("PARSE_ERROR".to_string())),
3 => ("error".to_string(), Some("EXPECTATION_FAILURE".to_string())),
4 => ("error".to_string(), Some("UNKNOWN_VERB".to_string())),
5 => ("error".to_string(), Some("FLOW_IO_ERROR".to_string())),
6 => ("error".to_string(), Some("RUNNER_UNREACHABLE".to_string())),
130 | 143 => ("interrupted".to_string(), Some("SIGNAL".to_string())),
n => ("error".to_string(), Some(format!("EXIT_{n}"))),
};
let mut a = smix_runner_wire::FlowAttempt::default();
a.attempt_index = attempt_index;
a.status = status;
a.error_class = error_class;
a.ips_generated = new_ips;
a.wall_ms = wall_ms;
attempts.push(a);
final_code = code;
if code == 0 {
break;
}
}
struct AttemptView<'a>(&'a smix_runner_wire::FlowAttempt);
impl<'a> smix_simctl::FlowAttemptShape for AttemptView<'a> {
fn attempt_index(&self) -> u32 {
self.0.attempt_index
}
fn status(&self) -> &str {
&self.0.status
}
fn error_class(&self) -> Option<&str> {
self.0.error_class.as_deref()
}
fn ips_generated(&self) -> Option<&str> {
self.0.ips_generated.as_deref()
}
fn wall_ms(&self) -> u64 {
self.0.wall_ms
}
}
let views: Vec<AttemptView> = attempts.iter().map(AttemptView).collect();
smix_simctl::record_flow_attempts(&flow_name, &views);
worst_exit = worst_exit.max(final_code);
if fail_fast && final_code != 0 {
eprintln!(
"smix run: --fail-fast — aborting batch on first failure (exit={final_code})"
);
break;
}
}
return Ok(ExitCode::from(worst_exit));
}
Cmd::Migrate { paths, in_place } => {
return cmd_migrate(paths, in_place).await;
}
Cmd::Annotate {
input,
output,
annotations,
compression,
font,
} => {
return cmd_annotate(input, output, annotations, compression, font).await;
}
Cmd::Authoring { action } => match action {
AuthoringAction::Suggest {
partial,
port,
device,
} => {
return authoring::cmd_suggest(runner_dial_port(port, device.as_deref()), partial)
.await;
}
AuthoringAction::Generate {
input,
format,
output,
app_id,
test_fn_name,
} => {
return authoring::cmd_generate(input, format, output, app_id, test_fn_name).await;
}
AuthoringAction::TapRecord {
output,
duration,
format,
port,
device,
app_id,
test_fn_name,
} => {
let port = port
.or_else(|| {
device
.as_deref()
.and_then(lookup_registered)
.and_then(|sim| sim.runner_port)
})
.unwrap_or(28080);
return authoring::cmd_tap_record(
port,
duration,
format,
output,
app_id,
test_fn_name,
)
.await;
}
AuthoringAction::CaptureTree {
output,
port,
device,
} => {
return authoring::cmd_capture_tree(
runner_dial_port(port, device.as_deref()),
output,
)
.await;
}
AuthoringAction::DiffTree {
baseline,
port,
device,
} => {
return authoring::cmd_diff_tree(
runner_dial_port(port, device.as_deref()),
baseline,
)
.await;
}
AuthoringAction::Propose {
flow,
bundle,
output,
} => {
return authoring::cmd_propose(flow, bundle, output).await;
}
AuthoringAction::Record {
output,
duration_secs,
interval_ms,
port,
device,
app_id,
} => {
return authoring::cmd_record_session(
runner_dial_port(port, device.as_deref()),
duration_secs,
interval_ms,
output,
app_id,
)
.await;
}
},
}
Ok(ExitCode::SUCCESS)
}
async fn cmd_annotate(
input: PathBuf,
output: PathBuf,
annotations: Vec<String>,
compression: String,
font: Option<PathBuf>,
) -> Result<ExitCode, CliError> {
use smix_annotate::{Annotator, Compression};
let png = std::fs::read(&input)
.map_err(|e| CliError::Other(format!("read {}: {e}", input.display())))?;
let mut ann = Annotator::new(&png)
.map_err(|e| CliError::Other(format!("decode {}: {e}", input.display())))?;
if let Some(fp) = &font {
let font_bytes = std::fs::read(fp)
.map_err(|e| CliError::Other(format!("read font {}: {e}", fp.display())))?;
ann = ann.font(font_bytes);
}
for spec in &annotations {
let a = parse_annotation_spec(spec)
.map_err(|e| CliError::Other(format!("annotation `{spec}`: {e}")))?;
ann = ann.add(a);
}
let comp = match compression.to_lowercase().as_str() {
"fast" => Compression::Fast,
"balanced" => Compression::Balanced,
"aggressive" => Compression::Aggressive,
other => {
return Err(CliError::Other(format!(
"unknown compression preset `{other}`"
)));
}
};
ann = ann.compression(comp);
let bytes = ann
.render()
.map_err(|e| CliError::Other(format!("render: {e}")))?;
std::fs::write(&output, bytes)
.map_err(|e| CliError::Other(format!("write {}: {e}", output.display())))?;
eprintln!("smix annotate: wrote {}", output.display());
Ok(ExitCode::SUCCESS)
}
fn parse_annotation_spec(spec: &str) -> Result<smix_annotate::Annotation, String> {
use smix_annotate::{Annotation, Color, Position};
let parts: Vec<&str> = spec.split(',').collect();
let kind = parts
.first()
.ok_or_else(|| "empty spec".to_string())?
.trim();
let mut kv = std::collections::BTreeMap::new();
for part in &parts[1..] {
let (k, v) = part
.split_once(':')
.ok_or_else(|| format!("expected key:value, got `{part}`"))?;
kv.insert(k.trim(), v.trim());
}
let get_color = |default: Color| -> Result<Color, String> {
Ok(match kv.get("color") {
Some(s) => Color::parse(s).map_err(|e| e.to_string())?,
None => default,
})
};
let get_pos = |key_prefix: &str, default_key: &str| -> Result<Position, String> {
let key = if kv.contains_key(key_prefix) {
key_prefix
} else {
default_key
};
let v = kv.get(key).ok_or_else(|| format!("missing `{key}`"))?;
let parts: Vec<&str> = v.split(['_', '|']).collect();
if parts.len() != 2 {
return Err(format!(
"position `{v}` — expected `X_Y` (underscore or pipe separator)"
));
}
let x: i32 = parts[0]
.parse()
.map_err(|_| format!("bad x `{}`", parts[0]))?;
let y: i32 = parts[1]
.parse()
.map_err(|_| format!("bad y `{}`", parts[1]))?;
Ok(Position::pixel(x, y))
};
match kind {
"circle" => {
let at = get_pos("at", "at")?;
let color = get_color(Color::RED)?;
let radius: i32 = kv
.get("radius")
.map(|s| s.parse().unwrap_or(30))
.unwrap_or(30);
let stroke: i32 = kv
.get("stroke")
.map(|s| s.parse().unwrap_or(3))
.unwrap_or(3);
Ok(Annotation::circle(at)
.color(color)
.radius(radius)
.stroke(stroke)
.build())
}
"arrow" => {
let from = get_pos("from", "from")?;
let to = get_pos("to", "to")?;
let color = get_color(Color::BLUE)?;
let stroke: i32 = kv
.get("stroke")
.map(|s| s.parse().unwrap_or(4))
.unwrap_or(4);
Ok(Annotation::arrow(from, to)
.color(color)
.stroke(stroke)
.build())
}
"text" => {
let at = get_pos("at", "at")?;
let content = kv
.get("content")
.ok_or_else(|| "missing `content`".to_string())?
.to_string();
let color = get_color(Color::WHITE)?;
let size: f32 = kv
.get("size")
.map(|s| s.parse().unwrap_or(24.0))
.unwrap_or(24.0);
Ok(Annotation::text(at, content)
.color(color)
.size(size)
.build())
}
"box" => {
let at = get_pos("at", "at")?;
let width: i32 = kv
.get("width")
.and_then(|s| s.parse().ok())
.ok_or_else(|| "missing `width`".to_string())?;
let height: i32 = kv
.get("height")
.and_then(|s| s.parse().ok())
.ok_or_else(|| "missing `height`".to_string())?;
let color = get_color(Color::YELLOW)?;
let stroke: i32 = kv
.get("stroke")
.map(|s| s.parse().unwrap_or(2))
.unwrap_or(2);
Ok(Annotation::box_(at, width, height)
.color(color)
.stroke(stroke)
.build())
}
"line" => {
let from = get_pos("from", "from")?;
let to = get_pos("to", "to")?;
let color = get_color(Color::CYAN)?;
let stroke: i32 = kv
.get("stroke")
.map(|s| s.parse().unwrap_or(2))
.unwrap_or(2);
Ok(Annotation::line(from, to)
.color(color)
.stroke(stroke)
.build())
}
other => Err(format!(
"unknown annotation kind `{other}` (expected circle/arrow/text/box/line)"
)),
}
}
async fn cmd_migrate(paths: Vec<PathBuf>, in_place: bool) -> Result<ExitCode, CliError> {
use std::io::{Read, Write};
let migrator = smix_migrate::Migrator::default();
if paths.is_empty() {
if in_place {
eprintln!("smix migrate: --in-place requires at least one path");
return Ok(ExitCode::from(2));
}
let mut buf = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut buf) {
eprintln!("smix migrate: failed to read stdin: {e}");
return Ok(ExitCode::from(2));
}
match migrator.migrate(&buf) {
Ok((out, report)) => {
warn_unknown(&report.unknown_verbs, "<stdin>");
warn_unknown_selector_keys(&report.unknown_selector_keys, "<stdin>");
print!("{out}");
std::io::stdout().flush().ok();
Ok(ExitCode::SUCCESS)
}
Err(e) => {
eprintln!("smix migrate: <stdin>: {e}");
Ok(ExitCode::from(2))
}
}
} else {
let mut worst: u8 = 0;
let mut totals = Totals::default();
for path in &paths {
let input = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
eprintln!("smix migrate: read {}: {e}", path.display());
worst = worst.max(2);
continue;
}
};
match migrator.migrate(&input) {
Ok((out, report)) => {
warn_unknown(&report.unknown_verbs, &path.display().to_string());
warn_unknown_selector_keys(
&report.unknown_selector_keys,
&path.display().to_string(),
);
if in_place {
let tmp = path.with_extension("smix-migrate.tmp");
if let Err(e) = std::fs::write(&tmp, &out) {
eprintln!("smix migrate: write tmp {}: {e}", tmp.display());
worst = worst.max(3);
continue;
}
if let Err(e) = std::fs::rename(&tmp, path) {
eprintln!("smix migrate: rename {}: {e}", tmp.display());
worst = worst.max(3);
continue;
}
eprintln!(
"smix migrate: rewrote {} — {}",
path.display(),
describe_changes(&report)
);
totals.files += 1;
totals.renames += report.renamed.len();
totals.unknown += report.unknown_verbs.len();
totals.subflows += report.subflow_refs;
} else {
print!("{out}");
std::io::stdout().flush().ok();
}
}
Err(e) => {
eprintln!("smix migrate: {}: {e}", path.display());
worst = worst.max(2);
}
}
}
if in_place {
totals.report(paths.len());
}
Ok(ExitCode::from(worst))
}
}
#[derive(Default)]
struct Totals {
files: usize,
renames: usize,
unknown: usize,
subflows: usize,
}
impl Totals {
fn report(&self, asked_for: usize) {
if asked_for > 1 {
eprintln!(
"smix migrate: {} of {} file(s) rewritten, {} rename(s)",
self.files, asked_for, self.renames
);
}
if self.unknown > 0 {
eprintln!(
"smix migrate: {} verb(s) had no smix equivalent and were left as they were \
— the flow will not parse until you replace them",
self.unknown
);
}
if self.subflows > 0 {
eprintln!(
"smix migrate: {} runFlow reference(s) point at files this run did not open \
— migrate those too",
self.subflows
);
}
}
}
fn describe_changes(report: &smix_migrate::MigrateReport) -> String {
if report.renamed.is_empty() {
return format!("no changes, {} step(s)", report.step_count);
}
let mut counts: Vec<(&str, &str, usize)> = Vec::new();
for rename in &report.renamed {
match counts.iter_mut().find(|c| c.0 == rename.from) {
Some(c) => c.2 += 1,
None => counts.push((rename.from, rename.to, 1)),
}
}
let detail: Vec<String> = counts
.iter()
.map(|(from, to, n)| {
if *n == 1 {
format!("{from} → {to}")
} else {
format!("{from} → {to} ×{n}")
}
})
.collect();
format!(
"{} rename(s) in {} step(s): {}",
report.renamed.len(),
report.step_count,
detail.join(", ")
)
}
fn warn_unknown(unknown: &[String], src: &str) {
if !unknown.is_empty() {
eprintln!(
"smix migrate: WARN {}: unknown verb(s) preserved verbatim: {}",
src,
unknown.join(", ")
);
}
}
fn warn_unknown_selector_keys(keys: &[String], src: &str) {
if !keys.is_empty() {
eprintln!(
"smix migrate: WARN {}: selector key(s) v2 does not accept: {} \
— flows using them fail to parse; remove them or use a \
supported selector",
src,
keys.join(", ")
);
}
}
fn runner_port() -> u16 {
std::env::var("SMIX_RUNNER_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(22087)
}
fn smix_workspace_root() -> Result<PathBuf, CliError> {
if let Some(p) = std::env::var_os("SMIX_WORKSPACE") {
return Ok(PathBuf::from(p));
}
let cwd = std::env::current_dir()
.map_err(|e| CliError::Other(format!("cannot determine cwd: {e}")))?;
smix_capsule::runner::workspace_root(&cwd).ok_or_else(|| {
CliError::Other(format!(
"no .smix/ workspace found upward from {} — cd into the smix \
workspace or set SMIX_WORKSPACE",
cwd.display()
))
})
}
fn exec_arity_complaint(verb: &str, args: &[String]) -> Option<String> {
const DEVICE_IMPLIED: &[&str] = &["openurl", "launch", "terminate", "install", "uninstall"];
if !DEVICE_IMPLIED.contains(&verb) {
return None;
}
let looks_like_udid =
|a: &String| a.len() == 36 && a.split('-').map(str::len).eq([8, 4, 4, 4, 12]);
let stray = args.iter().position(looks_like_udid)?;
Some(format!(
"`{verb}` takes no device argument — `sim exec <ALIAS|UDID>` already named one, \
and simctl would read `{}` as the device and everything after it shifted by one. \
Drop it: `smix sim exec <ALIAS> {verb} {}`",
args[stray],
args.iter()
.enumerate()
.filter(|(i, _)| *i != stray)
.map(|(_, a)| a.as_str())
.collect::<Vec<_>>()
.join(" ")
))
}
fn exec_argv(verb: &str, udid: &str, args: &[String]) -> Vec<String> {
let mut argv = vec![verb.to_string()];
if args.iter().any(|a| a == "{udid}") {
argv.extend(args.iter().map(|a| {
if a == "{udid}" {
udid.to_string()
} else {
a.clone()
}
}));
} else {
argv.push(udid.to_string());
argv.extend(args.iter().cloned());
}
argv
}
async fn cmd_sim_exec(device: &str, verb: &str, args: &[String]) -> Result<ExitCode, CliError> {
let udid = resolve_device(device)?;
if let Some(complaint) = exec_arity_complaint(verb, args) {
return Err(CliError::Other(complaint));
}
let argv = exec_argv(verb, &udid, args);
use std::os::unix::process::CommandExt;
let err = std::process::Command::new("xcrun")
.arg("simctl")
.args(&argv)
.exec();
Err(CliError::Other(format!("exec xcrun simctl: {err}")))
}
#[derive(Subcommand, Debug)]
enum DiagnosticAction {
Store {
#[arg(long, default_value = ".smix")]
root: PathBuf,
},
Dump {
#[arg(long, default_value_t = false)]
json: bool,
#[arg(long = "metro-log")]
metro_log: Option<PathBuf>,
#[arg(long = "metro-log-tail-lines", default_value_t = 200)]
metro_log_tail_lines: usize,
},
}
fn ips_snapshot(bundle_id: Option<&str>) -> std::collections::HashSet<String> {
let mut set: std::collections::HashSet<String> = Default::default();
let Some(home) = std::env::var_os("HOME") else {
return set;
};
let dir = std::path::PathBuf::from(home).join("Library/Logs/DiagnosticReports");
let Ok(read_dir) = std::fs::read_dir(&dir) else {
return set;
};
let bundle_leaf = bundle_id
.and_then(|b| b.rsplit('.').next())
.map(|s| s.to_lowercase());
for entry in read_dir.flatten() {
let name = entry.file_name().to_string_lossy().to_lowercase();
if !name.ends_with(".ips") {
continue;
}
let interesting = match &bundle_leaf {
Some(leaf) => name.contains(leaf),
None => true,
};
if interesting {
set.insert(entry.file_name().to_string_lossy().into_owned());
}
}
set
}
fn tail_lines(path: &Path, n: usize) -> std::io::Result<Vec<String>> {
use std::io::{Read, Seek, SeekFrom};
let mut f = std::fs::File::open(path)?;
let end = f.seek(SeekFrom::End(0))?;
if end == 0 || n == 0 {
return Ok(Vec::new());
}
let chunk_size: u64 = 8192;
let mut pos = end;
let mut buf: Vec<u8> = Vec::new();
let mut line_count = 0usize;
while pos > 0 && line_count <= n {
let read_from = pos.saturating_sub(chunk_size);
let read_len = (pos - read_from) as usize;
pos = read_from;
f.seek(SeekFrom::Start(read_from))?;
let mut chunk = vec![0u8; read_len];
f.read_exact(&mut chunk)?;
chunk.append(&mut buf);
buf = chunk;
line_count = buf.iter().filter(|&&b| b == b'\n').count();
}
let text = String::from_utf8_lossy(&buf).into_owned();
let mut lines: Vec<String> = text.lines().map(|s| s.to_string()).collect();
if lines.len() > n {
lines = lines.split_off(lines.len() - n);
}
Ok(lines)
}
async fn cmd_diagnostic(action: DiagnosticAction) -> Result<(), CliError> {
match action {
DiagnosticAction::Store { root } => {
let store = smix_store::Store::open(&root)
.map_err(|e| CliError::Other(format!("open store at {}: {e}", root.display())))?;
let dumped = store
.dump_json()
.map_err(|e| CliError::Other(format!("dump store: {e}")))?;
println!("{dumped}");
}
DiagnosticAction::Dump {
json,
metro_log,
metro_log_tail_lines,
} => {
let port = runner_port();
let client = smix_runner_client::HttpRunnerClient::new(port);
let mut resp = match client.diagnostic_dump().await {
Ok(r) => r,
Err(e) => {
eprintln!(
"warning: /diagnostic/dump unreachable ({e}); \
showing client-side ring buffer only"
);
smix_runner_wire::DiagnosticDumpResponse::default()
}
};
if let Some(ref path) = metro_log {
match tail_lines(path, metro_log_tail_lines) {
Ok(lines) => resp.metro_log_tail = lines,
Err(e) => {
eprintln!(
"warning: --metro-log {} unreadable ({e}); \
metro log tail will be empty in dump",
path.display()
);
}
}
}
let reset_counters = smix_simctl::reset_app_data_counters_snapshot();
resp.session_counters.reset_app_data_total = reset_counters.reset_app_data_total;
resp.session_counters.reset_app_data_timed_out =
reset_counters.reset_app_data_timed_out;
let recent_flows = smix_simctl::recent_flow_attempts();
resp.recent_flows = recent_flows
.into_iter()
.map(|f| {
let mut rec = smix_runner_wire::FlowAttemptRecord::default();
rec.flow_name = f.flow_name;
rec.attempts = f
.attempts
.into_iter()
.map(|a| {
let mut w = smix_runner_wire::FlowAttempt::default();
w.attempt_index = a.attempt_index;
w.status = a.status;
w.error_class = a.error_class;
w.ips_generated = a.ips_generated;
w.wall_ms = a.wall_ms;
w
})
.collect();
rec
})
.collect();
let client_side = smix_simctl::recent_subprocesses();
if json {
let payload = serde_json::json!({
"runner": resp,
"clientSubprocesses": client_side.iter().map(|r| serde_json::json!({
"argv": r.argv,
"exitCode": r.exit_code,
"wallMs": r.wall_ms,
"stderrHead": r.stderr_head,
"timestampMs": r.timestamp.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64).unwrap_or(0),
})).collect::<Vec<_>>(),
});
println!(
"{}",
serde_json::to_string_pretty(&payload).unwrap_or_default()
);
return Ok(());
}
println!("=== runner runtime snapshot ===");
println!("uptime: {}ms", resp.uptime_ms);
println!("sim health: {}", resp.sim_health);
if let Some(pid) = resp.supervisor_pid {
println!("supervisor pid: {pid}");
} else {
println!("supervisor pid: (none)");
}
println!();
println!("=== open sessions ({}) ===", resp.sessions.len());
for s in &resp.sessions {
println!(
" {:<38} {:<40} openedAtMs={} lastActivatedAtMs={}",
s.session_id, s.bundle_id, s.opened_at_ms, s.last_activated_at_ms
);
}
println!();
let ac = &resp.alive_cache;
println!("=== app-alive cache counters ===");
println!(
" wired={} markDead={} markAlive={} suppressHit={} suppressMiss={}",
ac.wired,
ac.mark_dead_total,
ac.mark_alive_total,
ac.suppress_hit_total,
ac.suppress_miss_total,
);
println!(
" reprobeAttempted={} reprobeSucceeded={} reprobeInvalidatedEarly={} reprobeExhaustedWindow={}",
ac.reprobe_attempted_total,
ac.reprobe_succeeded_total,
ac.reprobe_invalidated_early,
ac.reprobe_exhausted_window,
);
let sc = &resp.session_counters;
println!();
println!("=== session lifecycle counters (cumulative, survive close) ===");
println!(
" opened={} closed={} relaunch={} terminate={} launch={}",
sc.opened_total,
sc.closed_total,
sc.relaunch_app_total,
sc.terminate_app_total,
sc.launch_app_total,
);
println!(
" terminate: viaXCUIApplication={} viaFallback={} # fallback>0 = cooperative terminate failed → potential .ips writes",
sc.terminate_app_via_xcuiapplication, sc.terminate_app_via_fallback,
);
println!(
" launch: reachedForeground={} timedOutBeforeForeground={} # timedOut>0 → next call may fire during launch → bug_type 309",
sc.launch_app_reached_foreground, sc.launch_app_timed_out_before_foreground,
);
println!(
" resetAppData: total={} timedOut={} # timedOut>0 → URL scheme fired but reset-complete log-line never arrived",
sc.reset_app_data_total, sc.reset_app_data_timed_out,
);
println!(
" interactive: reachedInteractive={} timedOutBeforeInteractive={} # timedOut>0 → process foreground but a11y tree unusable (splash / dev-launcher / sparse annotation)",
sc.launch_app_reached_interactive, sc.launch_app_timed_out_before_interactive,
);
if !resp.last_interactive_named_ids.is_empty() {
println!(
" lastInteractiveNamedIds ({}): {}",
resp.last_interactive_named_ids.len(),
resp.last_interactive_named_ids.join(", "),
);
} else {
println!(
" lastInteractiveNamedIds: [] # no launch has completed with a non-empty sample yet",
);
}
println!();
if !resp.metro_log_tail.is_empty() {
println!(
"=== metro log tail (last {} of file) ===",
resp.metro_log_tail.len()
);
for line in &resp.metro_log_tail {
println!(" {}", line);
}
println!();
}
if !resp.recent_flows.is_empty() {
println!("=== recent flows (retry attribution) ===");
for flow in &resp.recent_flows {
println!(" flow: {}", flow.flow_name);
for attempt in &flow.attempts {
let err = attempt
.error_class
.as_deref()
.map(|c| format!(" errorClass={c}"))
.unwrap_or_default();
let ips = attempt
.ips_generated
.as_deref()
.map(|p| format!(" ipsGenerated={p}"))
.unwrap_or_default();
println!(
" attempt #{} status={} wallMs={}{}{}",
attempt.attempt_index, attempt.status, attempt.wall_ms, err, ips,
);
}
}
println!();
}
println!(
"=== runner-side subprocesses (last {} of {}) ===",
resp.recent_subprocesses.len().min(20),
resp.recent_subprocesses.len(),
);
for r in resp.recent_subprocesses.iter().rev().take(20) {
let code = r
.exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "-".into());
let head = if r.stderr_head.is_empty() {
String::new()
} else {
format!(" err={:?}", r.stderr_head)
};
println!(
" {:>13}ms code={:>3} {}{}",
r.wall_ms,
code,
r.argv.join(" "),
head
);
}
println!();
println!(
"=== client-side subprocesses (last {} of {}) ===",
client_side.len().min(20),
client_side.len(),
);
for r in client_side.iter().rev().take(20) {
let code = r
.exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "-".into());
let head = if r.stderr_head.is_empty() {
String::new()
} else {
format!(" err={:?}", r.stderr_head)
};
println!(
" {:>13}ms code={:>3} simctl {}{}",
r.wall_ms,
code,
r.argv.join(" "),
head
);
}
}
}
Ok(())
}
async fn cmd_init(
simctl: &SimctlClient,
alias: &str,
device: Option<&str>,
app: Option<&Path>,
) -> Result<(), CliError> {
let devices = simctl.list_devices().await?;
let candidates: Vec<init::Candidate> = devices
.iter()
.filter(|d| d.is_available)
.map(|d| init::Candidate {
udid: d.udid.clone(),
name: d.name.clone(),
})
.collect();
let path = registry_path().unwrap_or_else(|_| PathBuf::from(".smix/sims.json"));
let existing: Vec<String> = SimRegistry::load(&path)
.map(|reg| reg.sims().keys().cloned().collect())
.unwrap_or_default();
let plan = match init::plan_init(&candidates, alias, device, &existing) {
Ok(plan) => plan,
Err(refusal) => {
return Err(CliError::Other(format!(
"{}\n{}",
refusal.reason, refusal.remedy
)));
}
};
let sim = devices
.iter()
.find(|d| d.udid == plan.udid)
.map(|d| registry::RegisteredSim {
udid: d.udid.to_ascii_uppercase(),
device_name: d.name.clone(),
runtime: d.runtime_identifier.clone(),
device_type: d.device_type_identifier.clone(),
runner_port: None,
locale: None,
})
.ok_or_else(|| CliError::Other(format!("device {} vanished mid-init", plan.udid)))?;
SimRegistry::register(&path, &plan.alias, sim)
.map_err(|e| CliError::Other(format!("register: {e}")))?;
println!(
"registered `{}` -> {} in {}",
plan.alias,
plan.udid,
path.display()
);
let bundle = match app {
Some(app_path) => {
if let Err(e) = simctl.boot(&plan.udid).await
&& !e.to_string().contains("current state: Booted")
{
return Err(CliError::Other(format!("boot {}: {e}", plan.udid)));
}
let id = bundle_id_of(app_path)?;
simctl
.install(&plan.udid, &app_path.display().to_string())
.await
.map_err(|e| CliError::Other(format!("install {}: {e}", app_path.display())))?;
println!("installed {} ({id})", app_path.display());
Some(id)
}
None => None,
};
println!();
match bundle {
Some(id) => println!("next: smix capsule up {} --bundle {id}", plan.alias),
None => println!(
"next: smix capsule up {} --bundle <your.bundle.id>",
plan.alias
),
}
println!(" boots the device and starts the runner that carries every tap and query");
Ok(())
}
fn bundle_id_of(app: &Path) -> Result<String, CliError> {
let plist = app.join("Info.plist");
if !plist.exists() {
return Err(CliError::Other(format!(
"{} has no Info.plist — is it an .app bundle?",
app.display()
)));
}
let out = std::process::Command::new("plutil")
.args(["-extract", "CFBundleIdentifier", "raw", "-o", "-"])
.arg(&plist)
.output()
.map_err(|e| CliError::Other(format!("plutil: {e}")))?;
if !out.status.success() {
return Err(CliError::Other(format!(
"{} declares no CFBundleIdentifier",
plist.display()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn capture_server_reachable() -> bool {
use std::net::{SocketAddr, TcpStream};
use std::time::Duration;
let addr: SocketAddr = ([127, 0, 0, 1], 8787).into();
TcpStream::connect_timeout(&addr, Duration::from_millis(300)).is_ok()
}
async fn cmd_doctor(simctl: &SimctlClient, json: bool) -> Result<(), CliError> {
let simctl_facts = match simctl.list_runtimes().await {
Ok(runtimes) => {
let devices = simctl.list_devices().await.unwrap_or_default();
Some(readiness::SimctlFacts {
available_runtimes: runtimes.iter().filter(|r| r.is_available).count(),
available_devices: devices.iter().filter(|d| d.is_available).count(),
})
}
Err(_) => None,
};
let registry = std::env::current_dir()
.ok()
.and_then(|cwd| SimRegistry::discover(&cwd))
.and_then(|path| SimRegistry::load(&path).ok())
.map(|reg| readiness::RegistryFacts {
aliases: reg.sims().len(),
first_alias: reg.sims().keys().next().cloned(),
});
let port: u16 = std::env::var("SMIX_RUNNER_PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(22087);
let verdict = readiness::assess(&readiness::Facts {
simctl: simctl_facts,
registry,
runner_up: smix_capsule::runner::health_ok(port),
capture_server_up: capture_server_reachable(),
});
if json {
let out = serde_json::to_string_pretty(&verdict)
.map_err(|e| CliError::Other(format!("serialize: {e}")))?;
println!("{out}");
return Ok(());
}
println!("smix doctor");
println!("============");
for check in &verdict.checks {
let mark = match check.status {
readiness::Status::Ok => "\u{2713}",
readiness::Status::Blocked => "\u{2717}",
readiness::Status::Skipped => "\u{2022}",
};
println!("{mark} {}", check.detail);
}
println!("{}", readiness::PLATFORM_NOTE);
match &verdict.next {
Some(next) => {
println!();
println!("next: {}", next.command);
println!(" {}", next.reason);
}
None => println!("\nready — nothing left to set up"),
}
Ok(())
}
async fn cmd_sim_list(simctl: &SimctlClient, json: bool) -> Result<(), CliError> {
let devices = simctl.list_devices().await?;
if json {
let out = serde_json::to_string_pretty(&devices)
.map_err(|e| CliError::Other(format!("serialize: {e}")))?;
println!("{}", out);
return Ok(());
}
println!("{:<40} {:<28} {:<10} RUNTIME", "UDID", "NAME", "STATE");
for d in &devices {
let runtime_short = d
.runtime_identifier
.rsplit('.')
.next()
.unwrap_or(d.runtime_identifier.as_str());
println!(
"{:<40} {:<28} {:<10} {runtime_short}",
d.udid, d.name, d.state
);
}
Ok(())
}
#[derive(Debug)]
enum CliError {
Simctl(DeviceControlError),
Registry(RegistryError),
Other(String),
}
impl From<DeviceControlError> for CliError {
fn from(e: DeviceControlError) -> Self {
CliError::Simctl(e)
}
}
impl From<RegistryError> for CliError {
fn from(e: RegistryError) -> Self {
CliError::Registry(e)
}
}
impl std::fmt::Display for CliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CliError::Simctl(e) => write!(f, "{e}"),
CliError::Registry(e) => write!(f, "{e}"),
CliError::Other(s) => write!(f, "{s}"),
}
}
}
impl std::error::Error for CliError {}
fn run_port(flag: Option<u16>, registered: impl FnOnce() -> Option<u16>) -> u16 {
flag.or_else(registered).unwrap_or(act::DEFAULT_RUNNER_PORT)
}
fn runner_dial_port(flag: Option<u16>, device: Option<&str>) -> u16 {
run_port(flag.or_else(act::runner_port_from_env_opt), || {
device
.and_then(lookup_registered)
.and_then(|sim| sim.runner_port)
})
}
fn reject_ios_only_up_flags(
bundle: bool,
runner_project: bool,
supervise: bool,
) -> Result<(), String> {
let offenders: Vec<&str> = [
(bundle, "--bundle"),
(runner_project, "--runner-project"),
(supervise, "--supervise"),
]
.into_iter()
.filter_map(|(given, name)| given.then_some(name))
.collect();
if offenders.is_empty() {
return Ok(());
}
Err(format!(
"runner up --platform android does not implement {}: {} iOS-only. \
Drop the flag — the Android runner takes its target app from the \
App-Bundle-Id header per request, builds no Xcode project, and has \
no supervise sidecar. Only --runner-port applies on this platform.",
offenders.join(" / "),
if offenders.len() == 1 {
"it is"
} else {
"they are"
},
))
}
#[cfg(test)]
mod tests {
use super::*;
const UDID: &str = "5D087114-ECB3-443C-8DDB-40EEF9CFB90C";
#[test]
fn tail_lines_returns_last_n_when_file_larger_than_chunk() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("big.log");
let content = (0..5000)
.map(|i| format!("line-{i}"))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(&path, format!("{content}\n")).unwrap();
let tail = tail_lines(&path, 3).unwrap();
assert_eq!(tail, vec!["line-4997", "line-4998", "line-4999"]);
}
#[test]
fn tail_lines_returns_all_when_file_smaller_than_n() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("small.log");
std::fs::write(&path, "one\ntwo\nthree\n").unwrap();
let tail = tail_lines(&path, 10).unwrap();
assert_eq!(tail, vec!["one", "two", "three"]);
}
#[test]
fn tail_lines_handles_no_trailing_newline() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("noeol.log");
std::fs::write(&path, "alpha\nbeta").unwrap();
let tail = tail_lines(&path, 5).unwrap();
assert_eq!(tail, vec!["alpha", "beta"]);
}
#[test]
fn tail_lines_returns_empty_for_zero_n() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("any.log");
std::fs::write(&path, "content\n").unwrap();
assert!(tail_lines(&path, 0).unwrap().is_empty());
}
#[test]
fn tail_lines_returns_empty_for_empty_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.log");
std::fs::write(&path, b"").unwrap();
assert!(tail_lines(&path, 100).unwrap().is_empty());
}
#[test]
fn tail_lines_survives_utf8_split_across_chunk_boundary() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("utf8.log");
let prefix = "a".repeat(8189);
let content = format!("{prefix}😀\ntail-line\n");
std::fs::write(&path, content).unwrap();
let tail = tail_lines(&path, 1).unwrap();
assert_eq!(tail, vec!["tail-line"]);
}
#[test]
fn authoring_propose_parses_flow_bundle_out() {
let cli = Cli::try_parse_from([
"smix",
"authoring",
"propose",
"corrupt.yaml",
"--bundle",
"bundle-dir",
"-o",
"amended.yaml",
])
.unwrap();
let Cmd::Authoring {
action:
AuthoringAction::Propose {
flow,
bundle,
output,
},
} = cli.cmd
else {
panic!("expected authoring propose")
};
assert_eq!(flow, std::path::PathBuf::from("corrupt.yaml"));
assert_eq!(bundle, std::path::PathBuf::from("bundle-dir"));
assert_eq!(output, std::path::PathBuf::from("amended.yaml"));
}
#[test]
fn exec_parses_hyphen_args_verbatim() {
let cli = Cli::try_parse_from([
"smix",
"sim",
"exec",
"02",
"status_bar",
"override",
"--time",
"9:41",
])
.unwrap();
let Cmd::Sim {
action: SimAction::Exec { device, verb, args },
} = cli.cmd
else {
panic!("expected sim exec");
};
assert_eq!(device, "02");
assert_eq!(verb, "status_bar");
assert_eq!(args, ["override", "--time", "9:41"]);
}
#[test]
fn a_second_device_argument_is_named_as_the_mistake() {
let complaint = exec_arity_complaint(
"openurl",
&[
"74CAF762-06F0-4687-9D29-E737A435AEAF".to_string(),
"insight://x".to_string(),
],
)
.expect("a udid passed to openurl is always a mistake");
assert!(
complaint.contains("takes no device argument"),
"{complaint}"
);
assert!(
complaint.contains("smix sim exec <ALIAS> openurl insight://x"),
"it has to show the corrected command: {complaint}"
);
}
#[test]
fn an_unknown_verb_is_not_second_guessed() {
assert!(
exec_arity_complaint(
"spawn",
&["74CAF762-06F0-4687-9D29-E737A435AEAF".to_string()]
)
.is_none()
);
}
#[test]
fn an_ordinary_call_is_not_complained_about() {
assert!(exec_arity_complaint("openurl", &["insight://x".to_string()]).is_none());
}
#[test]
fn exec_argv_injects_udid_after_verb() {
let argv = exec_argv(
"push",
UDID,
&["com.example.app".into(), "payload.json".into()],
);
assert_eq!(argv, ["push", UDID, "com.example.app", "payload.json"]);
}
#[test]
fn exec_argv_substitutes_placeholder_instead_of_injecting() {
let argv = exec_argv(
"spawn",
UDID,
&[
"-s".into(),
"{udid}".into(),
"launchctl".into(),
"list".into(),
],
);
assert_eq!(argv, ["spawn", "-s", UDID, "launchctl", "list"]);
}
#[test]
fn sim_launch_parses_repeated_child_env_flags() {
let cli = Cli::try_parse_from([
"smix",
"sim",
"launch",
"02",
"com.example.app",
"--child-env",
"SMIX_PERF_RECEIVER_URL=http://127.0.0.1:9999",
"--child-env",
"LAUNCH_FORCE_PUSH=true",
])
.expect("parse sim launch with --child-env x2");
let Cmd::Sim {
action:
SimAction::Launch {
device,
bundle_id,
child_env,
launch_args,
},
} = cli.cmd
else {
panic!("expected sim launch");
};
assert_eq!(device, "02");
assert_eq!(bundle_id, "com.example.app");
assert_eq!(
child_env,
vec![
(
"SMIX_PERF_RECEIVER_URL".to_string(),
"http://127.0.0.1:9999".to_string(),
),
("LAUNCH_FORCE_PUSH".to_string(), "true".to_string()),
]
);
assert!(launch_args.is_empty());
}
#[test]
fn sim_launch_parses_trailing_launch_args_after_double_dash() {
let cli = Cli::try_parse_from([
"smix",
"sim",
"launch",
"02",
"com.example.app",
"--child-env",
"K=V",
"--",
"-uitestV2Root",
"YES",
])
.expect("parse trailing args");
let Cmd::Sim {
action:
SimAction::Launch {
launch_args,
child_env,
..
},
} = cli.cmd
else {
panic!("expected sim launch");
};
assert_eq!(launch_args, vec!["-uitestV2Root", "YES"]);
assert_eq!(child_env.len(), 1);
}
#[test]
fn sim_launch_without_child_env_yields_empty_vec() {
let cli = Cli::try_parse_from(["smix", "sim", "launch", "02", "com.example.app"])
.expect("parse bare launch");
let Cmd::Sim {
action: SimAction::Launch { child_env, .. },
} = cli.cmd
else {
panic!("expected sim launch");
};
assert!(child_env.is_empty());
}
#[test]
fn sim_launch_rejects_child_env_without_equals() {
let err = Cli::try_parse_from([
"smix",
"sim",
"launch",
"02",
"com.example.app",
"--child-env",
"NOEQUALS",
])
.expect_err("must reject KEY without =");
let msg = format!("{err}");
assert!(
msg.contains("KEY=VALUE") || msg.contains("="),
"expected error to hint KEY=VALUE shape; got: {msg}"
);
}
#[test]
fn sim_launch_rejects_child_env_with_empty_key() {
let err = Cli::try_parse_from([
"smix",
"sim",
"launch",
"02",
"com.example.app",
"--child-env",
"=just_value",
])
.expect_err("must reject empty KEY");
let msg = format!("{err}");
assert!(msg.contains("empty KEY"), "msg: {msg}");
}
#[test]
fn parse_kv_pair_allows_equals_in_value() {
let (k, v) = super::parse_kv_pair("URL=http://h:9999/p=q&r=s").expect("parse");
assert_eq!(k, "URL");
assert_eq!(v, "http://h:9999/p=q&r=s");
}
#[test]
fn every_device_subcommand_accepts_alias_ref() {
for argv in [
vec!["smix", "sim", "boot", "02"],
vec!["smix", "sim", "shutdown", "ios-17"],
vec!["smix", "sim", "erase", "02"],
vec!["smix", "sim", "screenshot", "02", "/tmp/x.png"],
vec!["smix", "sim", "launch", "02", "com.example.app"],
vec!["smix", "sim", "terminate", "02", "com.example.app"],
vec!["smix", "sim", "install", "02", "/tmp/App.app"],
vec!["smix", "sim", "uninstall", "02", "com.example.app"],
vec!["smix", "sim", "openurl", "02", "https://example.com"],
vec!["smix", "sim", "appearance", "02", "dark"],
vec!["smix", "sim", "keychain-reset", "02"],
vec!["smix", "sim", "resolve", "02"],
] {
Cli::try_parse_from(&argv).unwrap_or_else(|e| panic!("{argv:?} failed to parse: {e}"));
}
}
#[test]
fn android_runner_up_takes_only_the_port_flag() {
assert!(reject_ios_only_up_flags(false, false, false).is_ok());
for (bundle, project, supervise, expected) in [
(true, false, false, "--bundle"),
(false, true, false, "--runner-project"),
(false, false, true, "--supervise"),
] {
let err = reject_ios_only_up_flags(bundle, project, supervise)
.expect_err("an iOS-only flag must be refused, not dropped");
assert!(err.contains(expected), "{expected} unnamed in: {err}");
assert!(
err.contains("--runner-port"),
"the refusal must say what DOES work: {err}"
);
}
}
#[test]
fn every_dropped_flag_is_named_at_once() {
let err = reject_ios_only_up_flags(true, true, true).expect_err("all three are iOS-only");
for flag in ["--bundle", "--runner-project", "--supervise"] {
assert!(err.contains(flag), "{flag} unnamed in: {err}");
}
assert!(err.contains("they are"), "plural form expected in: {err}");
}
#[test]
fn the_animations_flag_is_off_by_default() {
use clap::CommandFactory;
let cli = Cli::command();
let run = cli
.get_subcommands()
.find(|c| c.get_name() == "run")
.expect("`smix run` still exists");
let arg = run
.get_arguments()
.find(|a| a.get_id() == "animations")
.expect("`smix run --animations` exists");
assert_eq!(
arg.get_default_values(),
["false"],
"the flag stopped defaulting to false, so a run no longer \
quietens the device unless asked"
);
}
#[test]
fn run_port_flag_wins() {
assert_eq!(run_port(Some(22099), || Some(23000)), 22099);
}
#[test]
fn run_port_falls_back_to_registry() {
assert_eq!(run_port(None, || Some(22099)), 22099);
}
#[test]
fn run_port_defaults_to_22087() {
assert_eq!(run_port(None, || None), 22087);
}
#[test]
fn run_port_skips_registry_lookup_when_flag_present() {
let consulted = std::cell::Cell::new(false);
let port = run_port(Some(22099), || {
consulted.set(true);
Some(23000)
});
assert_eq!(port, 22099);
assert!(
!consulted.get(),
"registry was read despite an explicit port"
);
}
}