macro_rules! print {
($($arg:tt)*) => { $crate::write_stdout(format_args!($($arg)*)) };
}
macro_rules! println {
() => { $crate::write_stdout(format_args!("\n")) };
($($arg:tt)*) => { $crate::write_stdout(format_args!("{}\n", format_args!($($arg)*))) };
}
mod act;
mod authoring;
mod bench;
mod boot_cold;
mod boot_record;
mod capsule;
mod departures;
mod down;
mod federation;
#[cfg(test)]
mod guide_gate;
mod init;
mod lease_cmd;
mod parallel;
mod readiness;
mod record_cmd;
mod runner_list;
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
(an alias from this machine's registry — `smix sim list` — or a 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)]
alias: Option<String>,
#[arg(long)]
device: Option<String>,
#[arg(long)]
app: Option<PathBuf>,
},
Doctor {
#[arg(long)]
json: bool,
#[arg(long = "runner-port", env = "SMIX_RUNNER_PORT")]
runner_port: Option<u16>,
},
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,
},
Session {
#[command(subcommand)]
action: SessionAction,
},
Sim {
#[command(subcommand)]
action: SimAction,
},
Runner {
#[command(subcommand)]
action: RunnerAction,
},
Down {
#[arg(long = "runner-port", env = "SMIX_RUNNER_PORT")]
runner_port: Option<u16>,
},
Lease {
#[command(subcommand)]
action: LeaseAction,
},
Record {
#[command(subcommand)]
action: RecordAction,
},
Capsule {
#[command(subcommand)]
action: CapsuleAction,
},
Tap {
selector: String,
#[arg(long = "ocr-locale")]
ocr_locale: Vec<String>,
#[arg(long, alias = "runner-port")]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
#[arg(long = "then-screenshot", value_name = "OUT")]
then_screenshot: Option<PathBuf>,
},
Find {
selector: String,
#[arg(long = "ocr-locale")]
ocr_locale: Vec<String>,
#[arg(long, alias = "runner-port")]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
WaitFor {
selector: String,
#[arg(long, default_value_t = 5)]
timeout: u64,
#[arg(long = "ocr-locale")]
ocr_locale: Vec<String>,
#[arg(long, alias = "runner-port")]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
#[arg(long)]
absent: bool,
},
Fill {
selector: String,
#[arg(long)]
text: String,
#[arg(long, alias = "runner-port")]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
PressKey {
key: String,
#[arg(long, alias = "runner-port")]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
Swipe {
direction: Option<String>,
#[arg(long = "from")]
from_point: Option<String>,
#[arg(long = "to")]
to_point: Option<String>,
#[arg(long, alias = "runner-port")]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
Scroll {
selector: String,
#[arg(long)]
direction: String,
#[arg(long, alias = "runner-port")]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
HideKeyboard {
#[arg(long, alias = "runner-port")]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
Tree {
#[arg(long)]
json: bool,
#[arg(long, alias = "runner-port")]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
#[arg(long)]
keyboard: bool,
#[arg(long, default_value = "auto")]
reader: TreeReader,
},
Describe {
#[arg(long)]
json: bool,
#[arg(long, alias = "runner-port")]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
SystemPopups {
#[arg(long)]
json: bool,
#[arg(long, alias = "runner-port")]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
SystemPopupAction {
popup_id: String,
button_id: String,
#[arg(long, alias = "runner-port")]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
RunScript {
path: PathBuf,
#[arg(long, alias = "runner-port")]
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, alias = "port", 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")]
platform: Option<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 TreeReader {
Auto,
Probe,
A11y,
}
impl TreeReader {
fn to_act(self) -> act::TreeReader {
match self {
Self::Auto => act::TreeReader::Auto,
Self::Probe => act::TreeReader::Probe,
Self::A11y => act::TreeReader::A11y,
}
}
}
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
enum RunPlatform {
Ios,
Android,
}
fn resolve_run_platform(
explicit: Option<RunPlatform>,
device_kind: Option<smix_simctl::registry::DeviceKind>,
) -> Result<RunPlatform, String> {
use smix_simctl::registry::DeviceKind;
if let Some(p) = explicit {
return Ok(p);
}
match device_kind {
Some(DeviceKind::Emulator) | Some(DeviceKind::PhysicalAndroid) => Ok(RunPlatform::Android),
Some(DeviceKind::Simulator) | Some(DeviceKind::PhysicalIos) => Ok(RunPlatform::Ios),
None => Err(
"cannot tell which platform this device is — it is not in the registry, \
so its kind is unknown. Register it (`smix sim register <alias> --udid \
<id> --kind emulator|simulator`) so the platform is read from the \
device, or pass --platform to say it once."
.to_string(),
),
}
}
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,
}
}
}
impl RunPlatform {
fn to_driver(self) -> smix_driver::Platform {
match self {
Self::Ios => smix_driver::Platform::Ios,
Self::Android => smix_driver::Platform::Android,
}
}
}
enum DeviceDial {
Registered(smix_simctl::registry::DeviceKind),
Unregistered,
}
fn dial_platform(
device: Option<DeviceDial>,
port_holder_kinds: &[smix_simctl::registry::DeviceKind],
) -> Result<RunPlatform, String> {
match device {
Some(DeviceDial::Registered(k)) => resolve_run_platform(None, Some(k)),
Some(DeviceDial::Unregistered) => Err(
"cannot tell which platform this --device is — it is not in the registry, so its kind is unknown. Register it (`smix sim register <alias> --udid <id> --kind emulator|simulator`) so the platform is read from the device."
.to_string(),
),
None => {
let mut platforms: Vec<RunPlatform> = Vec::new();
for k in port_holder_kinds {
let p = resolve_run_platform(None, Some(*k))
.expect("a known device kind always classifies");
if !platforms.contains(&p) {
platforms.push(p);
}
}
match platforms.as_slice() {
[] => Ok(RunPlatform::Ios),
[one] => Ok(*one),
_ => Err(
"this runner port has devices of more than one platform registered on it (both an iOS and an Android device name it), so which one a verb without --device should drive is a coin toss. Pass --device to say which."
.to_string(),
),
}
}
}
}
#[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,
#[arg(long = "runner-port", env = "SMIX_RUNNER_PORT")]
runner_port: Option<u16>,
},
Down {
device: String,
#[arg(long = "runner-port", env = "SMIX_RUNNER_PORT")]
runner_port: Option<u16>,
},
}
#[derive(Subcommand, Debug)]
enum RecordAction {
Start {
device: String,
#[arg(long)]
output: PathBuf,
},
Stop { device: String },
Status { device: String },
}
#[derive(Subcommand, Debug)]
enum LeaseAction {
List,
Status {
device: String,
#[arg(long)]
json: bool,
},
Reconcile { device: String },
Claim { device: String },
Release { device: String },
Owner { device: String },
Migrate {
#[arg(long = "from", value_name = "DIR")]
from: Vec<PathBuf>,
#[arg(long)]
dry_run: bool,
},
Prune {
#[arg(long)]
dry_run: bool,
#[arg(long, value_name = "DEVICE")]
device: Option<String>,
},
History {
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand, Debug)]
enum RunnerAction {
Up {
#[arg(value_name = "DEVICE", required_unless_present = "device_flag")]
device: Option<String>,
#[arg(long = "device", conflicts_with = "device")]
device_flag: Option<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)]
team: Option<String>,
#[arg(long = "no-launch", default_value_t = false)]
no_launch: bool,
#[arg(long = "force", default_value_t = false)]
force: bool,
#[arg(long = "take-over", default_value_t = false)]
take_over: bool,
},
Down {
#[arg(long, value_enum, default_value_t = RunPlatform::Ios)]
platform: RunPlatform,
#[arg(long)]
device: Option<String>,
#[arg(long = "include-unrecorded", default_value_t = false)]
include_unrecorded: bool,
#[arg(long = "take-over", default_value_t = false)]
take_over: bool,
#[arg(long)]
runner_port: Option<u16>,
},
Forward {
device: String,
#[arg(long, default_value_t = 22087)]
port: u16,
},
Uninstall {
#[arg(long, value_enum, default_value_t = RunPlatform::Android)]
platform: RunPlatform,
#[arg(long)]
device: String,
},
Cycle {
#[arg(long, value_enum, default_value_t = RunPlatform::Ios)]
platform: RunPlatform,
#[arg(long = "runner-project", env = "SMIX_RUNNER_PROJECT")]
runner_project: Option<PathBuf>,
#[arg(long = "runner-port", env = "SMIX_RUNNER_PORT")]
runner_port: Option<u16>,
},
Supervise {
#[arg(long = "runner-project", env = "SMIX_RUNNER_PROJECT")]
runner_project: Option<PathBuf>,
#[arg(long = "runner-port", env = "SMIX_RUNNER_PORT")]
runner_port: Option<u16>,
},
ListSessions {
#[arg(long = "runner-port", env = "SMIX_RUNNER_PORT")]
runner_port: Option<u16>,
},
List {
#[arg(long, default_value_t = false)]
prune: bool,
},
Install {
#[arg(long)]
path: Option<PathBuf>,
#[arg(long, default_value_t = false)]
force: bool,
},
}
#[derive(Subcommand, Debug)]
enum SimAction {
List {
#[arg(long)]
json: bool,
#[arg(long)]
registered: bool,
},
Resolve {
device: String,
#[arg(long)]
json: bool,
},
Register {
alias: String,
#[arg(long)]
udid: String,
#[arg(long)]
locale: Option<String>,
#[arg(long = "runner-port")]
runner_port: Option<u16>,
#[arg(long, value_enum, default_value_t = DeviceKindArg::Simulator)]
kind: DeviceKindArg,
#[arg(long)]
name: Option<String>,
},
Boot {
device: String,
#[arg(long)]
cold: bool,
},
Shutdown {
device: String,
},
Erase {
device: String,
},
Screenshot {
device: String,
out: PathBuf,
#[arg(long = "runner-port", env = "SMIX_RUNNER_PORT")]
runner_port: Option<u16>,
},
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,
},
Reverse {
device: String,
port: u16,
#[arg(long, conflicts_with = "remove")]
to: Option<u16>,
#[arg(long)]
remove: bool,
},
Wake {
device: String,
},
StayAwake {
device: String,
#[arg(value_parser = parse_on_off, action = clap::ArgAction::Set)]
state: bool,
},
Permission {
device: String,
bundle_id: String,
permission: String,
#[arg(value_parser = parse_permission_action)]
action: String,
},
Frontmost {
device: String,
#[arg(long)]
json: bool,
},
Crashes {
device: String,
#[arg(long)]
app: Option<String>,
#[arg(long)]
json: bool,
},
Openurl {
device: String,
url: String,
},
Appearance {
device: String,
#[arg(value_parser = parse_appearance)]
mode: Appearance,
},
AllowDestructive {
device: String,
},
Unregister {
alias: String,
},
Migrate {
#[arg(long = "from", value_name = "DIR")]
from: Vec<PathBuf>,
#[arg(long)]
dry_run: bool,
},
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_on_off(s: &str) -> Result<bool, String> {
match s.trim().to_ascii_lowercase().as_str() {
"on" => Ok(true),
"off" => Ok(false),
other => Err(format!("expected `on` or `off`, got `{other}`")),
}
}
fn parse_permission_action(s: &str) -> Result<String, String> {
match s.trim().to_ascii_lowercase().as_str() {
a @ ("grant" | "revoke" | "reset") => Ok(a.to_string()),
other => Err(format!(
"expected `grant`, `revoke` or `reset`, got `{other}`"
)),
}
}
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 alias_needs_identity_check(device_ref: &str) -> bool {
!registry::is_emulator_serial(device_ref) && !registry::is_udid(device_ref)
}
fn emulator_console_path(avd: &str) -> Result<std::path::PathBuf, CliError> {
let root = smix_lease::store::machine_root().ok_or_else(|| {
CliError::Other(
"no machine-level directory to keep the emulator's console in — neither \
HOME nor XDG_DATA_HOME is set"
.to_string(),
)
})?;
let stamp = smix_lease::store::now_rfc3339().replace(':', "-");
Ok(root
.join("emulator-console")
.join(format!("{avd}-{stamp}.log")))
}
fn free_console_serial() -> Result<String, CliError> {
let taken: Vec<u16> = smix_adb::AdbClient::new()
.live_devices()
.unwrap_or_default()
.iter()
.filter_map(|(serial, _)| serial.strip_prefix("emulator-")?.parse().ok())
.flat_map(|p: u16| [p, p + 1])
.collect();
smix_adb::AdbClient::console_port_candidates(&taken)
.into_iter()
.find(|p| {
std::net::TcpListener::bind(("127.0.0.1", *p)).is_ok()
&& std::net::TcpListener::bind(("127.0.0.1", p + 1)).is_ok()
})
.map(|p| format!("emulator-{p}"))
.ok_or_else(|| {
CliError::Other(
"every emulator console port adb scans (5554–5682) is in use".to_string(),
)
})
}
fn emulator_alias_address(
sim: &smix_simctl::registry::RegisteredSim,
) -> smix_simctl::registry::EmulatorAddress {
let live = smix_adb::AdbClient::new().live_emulators();
registry::emulator_address(sim.avd_name(), &sim.udid, &live)
}
fn address_emulator_alias(
device_ref: &str,
sim: &smix_simctl::registry::RegisteredSim,
) -> Result<String, CliError> {
use smix_simctl::registry::EmulatorAddress;
match emulator_alias_address(sim) {
EmulatorAddress::At { serial } => Ok(serial),
EmulatorAddress::MovedTo { serial, recorded } => {
eprintln!(
"note: {device_ref} is running as {serial}, not the {recorded} it was \
registered on — an emulator serial is a port, and this one took a \
different one this time"
);
Ok(serial)
}
addr => Err(emulator_address_refusal(device_ref, sim, addr)),
}
}
fn emulator_address_refusal(
device_ref: &str,
sim: &smix_simctl::registry::RegisteredSim,
addr: smix_simctl::registry::EmulatorAddress,
) -> CliError {
use smix_simctl::registry::EmulatorAddress;
CliError::Other(match addr {
EmulatorAddress::NotRunning {
avd,
slot_now: Some(other),
} => format!(
"{device_ref} names the AVD `{avd}`, which is not running. {} is running \
`{other}` — a different device that happens to hold the port this alias \
was registered on. Start yours with `smix sim boot {device_ref}`.",
sim.udid
),
EmulatorAddress::NotRunning {
avd,
slot_now: None,
} => format!(
"{device_ref} names the AVD `{avd}`, which is not running. \
Start it with `smix sim boot {device_ref}`."
),
EmulatorAddress::NoIdentityRecorded { serial } => format!(
"{device_ref} records the port {serial} and nothing else, and a port is \
whichever emulator booted into it first — it may be somebody else's \
today. Register it once while yours is running and the AVD name is \
kept: `smix sim register {device_ref} --udid {serial} --kind emulator`."
),
EmulatorAddress::At { serial } | EmulatorAddress::MovedTo { serial, .. } => format!(
"{device_ref} resolves to {serial}; this is not a refusal and nothing \
should have asked for one"
),
})
}
fn resolve_device(device_ref: &str) -> Result<String, CliError> {
if registry::is_udid(device_ref) {
let udid = device_ref.to_ascii_uppercase();
let known = if lookup_registered(&udid).is_some() {
smix_lease::Known::Registered(smix_lease::DeviceClass {
physical: false,
destructive_opt_in: false,
})
} else if simctl_knows(&udid) {
smix_lease::Known::UnregisteredVirtual
} else {
smix_lease::Known::Unknown
};
smix_lease::may_address(&udid, known).map_err(|e| CliError::Other(e.to_string()))?;
return Ok(udid);
}
if registry::is_emulator_serial(device_ref) {
smix_lease::may_address(device_ref, smix_lease::Known::UnregisteredVirtual)
.map_err(|e| CliError::Other(e.to_string()))?;
return Ok(device_ref.to_string());
}
let view = load_registry();
let resolved = view.resolve_ref(device_ref)?.id;
note_if_unmigrated(&view, device_ref);
if alias_needs_identity_check(device_ref)
&& let Some(sim) = view.registry.lookup(device_ref)
&& sim.kind == smix_simctl::registry::DeviceKind::Emulator
{
return address_emulator_alias(device_ref, sim);
}
Ok(resolved)
}
fn resolve_android_serial(device_ref: &str) -> Result<String, CliError> {
if alias_needs_identity_check(device_ref)
&& let Err(e @ smix_simctl::registry::RegistryError::Diverged { .. }) =
load_registry().resolve_ref(device_ref)
{
return Err(e.into());
}
let registered = lookup_registered(device_ref);
let known = match ®istered {
Some(s) => smix_lease::Known::Registered(smix_lease::DeviceClass {
physical: s.kind.is_physical(),
destructive_opt_in: s.destructive_opt_in,
}),
None if device_ref.starts_with("emulator-") => smix_lease::Known::UnregisteredVirtual,
None => smix_lease::Known::Unknown,
};
smix_lease::may_address(device_ref, known).map_err(|e| CliError::Other(e.to_string()))?;
match ®istered {
Some(sim)
if sim.kind == smix_simctl::registry::DeviceKind::Emulator
&& alias_needs_identity_check(device_ref) =>
{
address_emulator_alias(device_ref, sim)
}
_ => Ok(registered.map_or_else(|| device_ref.to_string(), |s| s.udid)),
}
}
fn device_kind_of(device_ref: &str) -> smix_simctl::registry::DeviceKind {
classify_device(&load_registry().registry, device_ref)
}
fn classify_device(reg: &SimRegistry, device_ref: &str) -> smix_simctl::registry::DeviceKind {
use smix_simctl::registry::DeviceKind;
if let Some(sim) = reg.lookup(device_ref) {
return sim.kind;
}
if registry::is_emulator_serial(device_ref) {
return DeviceKind::Emulator;
}
DeviceKind::Simulator
}
fn simctl_knows(udid: &str) -> bool {
std::process::Command::new("xcrun")
.args(["simctl", "list", "devices", "-j"])
.output()
.is_ok_and(|out| {
out.status.success()
&& String::from_utf8_lossy(&out.stdout)
.to_ascii_uppercase()
.contains(udid)
})
}
fn adb_knows(serial: &str) -> bool {
std::process::Command::new("adb")
.arg("devices")
.output()
.is_ok_and(|out| {
out.status.success()
&& String::from_utf8_lossy(&out.stdout)
.lines()
.any(|l| {
l.split('\t').next().is_some_and(|s| s == serial)
&& l.split('\t').nth(1).is_some_and(|s| s.trim() == "device")
})
})
}
fn sim_action_device(action: &SimAction) -> Option<&str> {
match action {
SimAction::List { .. } | SimAction::Migrate { .. } => None,
SimAction::Unregister { .. } => None,
SimAction::Register { udid, .. } => Some(udid),
SimAction::Resolve { device, .. }
| SimAction::Boot { device, .. }
| SimAction::Shutdown { device }
| SimAction::Erase { device }
| SimAction::Screenshot { device, .. }
| SimAction::Launch { device, .. }
| SimAction::Terminate { device, .. }
| SimAction::Install { device, .. }
| SimAction::Uninstall { device, .. }
| SimAction::Reverse { device, .. }
| SimAction::Wake { device }
| SimAction::StayAwake { device, .. }
| SimAction::Permission { device, .. }
| SimAction::Frontmost { device, .. }
| SimAction::Crashes { device, .. }
| SimAction::Openurl { device, .. }
| SimAction::Appearance { device, .. }
| SimAction::AllowDestructive { device }
| SimAction::KeychainReset { device }
| SimAction::Locale { device, .. }
| SimAction::Exec { device, .. } => Some(device),
}
}
fn sim_verb_supports(action: &SimAction) -> Option<Vec<smix_simctl::registry::DeviceKind>> {
use DeviceKind::{Emulator, Simulator};
use smix_simctl::registry::DeviceKind;
if let Some(table_action) = table_action_of(action) {
return Some(
DeviceKind::ALL
.iter()
.copied()
.filter(|kind| {
smix_sdk::device_control::availability(table_action, *kind)
== Some(smix_sdk::device_control::Availability::Works)
})
.collect(),
);
}
const POWERABLE: &[DeviceKind] = &[Simulator, Emulator];
const SIMCTL: &[DeviceKind] = &[Simulator];
Some(
match action {
SimAction::List { .. }
| SimAction::Register { .. }
| SimAction::AllowDestructive { .. }
| SimAction::Migrate { .. }
| SimAction::Unregister { .. }
| SimAction::Resolve { .. } => return None,
SimAction::Boot { .. } | SimAction::Shutdown { .. } => POWERABLE,
SimAction::Erase { .. }
| SimAction::Appearance { .. }
| SimAction::Locale { .. }
| SimAction::Exec { .. } => SIMCTL,
SimAction::Screenshot { .. }
| SimAction::KeychainReset { .. }
| SimAction::Uninstall { .. }
| SimAction::Install { .. }
| SimAction::Reverse { .. }
| SimAction::Wake { .. }
| SimAction::StayAwake { .. }
| SimAction::Frontmost { .. }
| SimAction::Crashes { .. }
| SimAction::Permission { .. }
| SimAction::Launch { .. }
| SimAction::Terminate { .. }
| SimAction::Openurl { .. } => unreachable!("answered by the platform table"),
}
.to_vec(),
)
}
fn table_action_of(action: &SimAction) -> Option<&'static str> {
match action {
SimAction::Reverse { .. } => Some("reverse_port"),
SimAction::Wake { .. } => Some("wake"),
SimAction::StayAwake { .. } => Some("set_stay_awake"),
SimAction::Permission { .. } => Some("set_permission"),
SimAction::Frontmost { .. } => Some("frontmost_app"),
SimAction::Crashes { .. } => Some("crash_reports"),
SimAction::Launch { .. } => Some("launch"),
SimAction::Terminate { .. } => Some("terminate"),
SimAction::Install { .. } => Some("install"),
SimAction::Uninstall { .. } => Some("uninstall"),
SimAction::Openurl { .. } => Some("open_url"),
SimAction::Screenshot { .. } => Some("screenshot"),
SimAction::KeychainReset { .. } => Some("keychain_reset"),
SimAction::List { .. }
| SimAction::Register { .. }
| SimAction::Resolve { .. }
| SimAction::Migrate { .. }
| SimAction::Unregister { .. }
| SimAction::AllowDestructive { .. }
| SimAction::Boot { .. }
| SimAction::Shutdown { .. }
| SimAction::Erase { .. }
| SimAction::Appearance { .. }
| SimAction::Locale { .. }
| SimAction::Exec { .. } => None,
}
}
fn guard_sim_verb(action: &SimAction, device: &str) -> Result<(), CliError> {
let Some(kinds) = sim_verb_supports(action) else {
return Ok(());
};
let kind = device_kind_of(device);
if kinds.contains(&kind) {
return Ok(());
}
Err(CliError::Other(refusal_for(action, device, kind)))
}
fn device_control_for(
kind: smix_simctl::registry::DeviceKind,
udid: &str,
) -> Box<dyn smix_sdk::device_control::DeviceControl> {
use smix_simctl::registry::DeviceKind;
match kind {
DeviceKind::Simulator => Box::new(smix_sdk::ios_device::IosDeviceControl::new()),
DeviceKind::Emulator | DeviceKind::PhysicalAndroid => {
Box::new(smix_sdk::android_device::AndroidDeviceControl::new())
}
DeviceKind::PhysicalIos => Box::new(smix_sdk::devicectl_device::DevicectlClient::new(udid)),
}
}
fn refusal_for(
action: &SimAction,
device: &str,
kind: smix_simctl::registry::DeviceKind,
) -> String {
use smix_simctl::registry::DeviceKind;
if let Some(table_action) = table_action_of(action)
&& let Some(smix_sdk::device_control::Availability::RefusedByName { why, instead }) =
smix_sdk::device_control::availability(table_action, kind)
{
return format!("{device} cannot do this: {why}\nInstead: {instead}");
}
let name_of = |kind| match kind {
DeviceKind::Simulator => "an iOS Simulator",
DeviceKind::Emulator => "an Android emulator",
DeviceKind::PhysicalIos => "a physical iPhone or iPad",
DeviceKind::PhysicalAndroid => "a physical Android device",
};
let works_on = sim_verb_supports(action)
.unwrap_or_default()
.into_iter()
.map(name_of)
.collect::<Vec<_>>()
.join(", ");
format!(
"{device} is {}, and this command has no path to it.\nIt works on: {works_on}.",
name_of(kind)
)
}
fn registry_path() -> Result<PathBuf, CliError> {
if let Some(p) = std::env::var_os("SMIX_SIMS_JSON") {
return Ok(PathBuf::from(p));
}
SimRegistry::machine_dir().ok_or_else(|| {
CliError::Other(
"no machine-level place to keep device records — neither HOME nor \
XDG_DATA_HOME is set. Set SMIX_MACHINE_DIR, or point \
SMIX_SIMS_JSON at a registry."
.into(),
)
})
}
fn load_registry() -> smix_simctl::registry::MergedRegistry {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
SimRegistry::open_all(&cwd)
}
fn note_if_unmigrated(view: &smix_simctl::registry::MergedRegistry, device_ref: &str) {
let Some(sim) = view.registry.lookup(device_ref) else {
return;
};
let Some((alias, from)) = view.unmigrated.iter().find(|(alias, _)| {
view.registry
.lookup(alias)
.is_some_and(|s| s.udid.eq_ignore_ascii_case(&sim.udid))
}) else {
return;
};
eprintln!(
"note: `{alias}` is recorded in {} and not on this machine — another \
checkout cannot see it. `smix sim migrate` moves it.",
from.display()
);
}
fn resolution_line(
device_ref: &str,
sim: Option<&smix_simctl::registry::RegisteredSim>,
source: &smix_simctl::registry::Source,
) -> String {
use smix_simctl::registry::Source;
let book = match source {
Source::Machine(p) => format!("this machine's registry ({})", p.display()),
Source::Checkout(p) => format!(
"{} — a checkout's legacy book, read and never written; `smix sim migrate` \
records it on this machine",
p.display()
),
Source::Named(p) => format!("the registry SMIX_SIMS_JSON names ({})", p.display()),
Source::Literal => {
return format!("{device_ref} is an identifier itself; no registry was consulted");
}
};
match sim.and_then(|s| s.avd_name()) {
Some(avd) => format!("resolved `{device_ref}` (AVD `{avd}`) from {book}"),
None => format!("resolved `{device_ref}` from {book}"),
}
}
fn resolution_json(
device_ref: &str,
id: &str,
resolved: Option<&smix_simctl::registry::Resolved>,
sim: Option<&smix_simctl::registry::RegisteredSim>,
source: &smix_simctl::registry::Source,
) -> serde_json::Value {
use smix_simctl::registry::Source;
let (kind, path) = match source {
Source::Machine(p) => ("machine", Some(p)),
Source::Checkout(p) => ("checkout", Some(p)),
Source::Named(p) => ("named", Some(p)),
Source::Literal => ("literal", None),
};
let mut v = serde_json::json!({
"ref": device_ref,
"id": id,
"alias": resolved.map_or(device_ref, |r| r.alias.as_str()),
"source": { "kind": kind, "path": path.map(|p| p.display().to_string()) },
"deviceKind": sim.map(|s| {
serde_json::to_value(s.kind).expect("a unit enum with derived Serialize always serialises")
}),
});
if let Some(avd) = sim.and_then(|s| s.avd_name()) {
v["avd"] = serde_json::Value::String(avd.to_string());
}
v
}
fn lookup_registered(device_ref: &str) -> Option<smix_simctl::registry::RegisteredSim> {
load_registry().registry.lookup(device_ref).cloned()
}
static STDOUT_READER_LEFT: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
fn write_stdout(args: std::fmt::Arguments<'_>) {
use std::io::Write;
use std::sync::atomic::Ordering;
if STDOUT_READER_LEFT.load(Ordering::Relaxed) {
return;
}
if let Err(e) = std::io::stdout().lock().write_fmt(args) {
if e.kind() == std::io::ErrorKind::BrokenPipe {
STDOUT_READER_LEFT.store(true, Ordering::Relaxed);
return;
}
panic!("failed printing to stdout: {e}");
}
}
#[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)
}
}
}
fn record_emulator_boot(udid: &str, claim: BootClaim, identity: Option<smix_lease::Resource>) {
let Ok(leases) = smix_capsule::runner::machine_leases() else {
return;
};
if let Err(e) = smix_lease::store::record_boot(&leases, udid, claim == BootClaim::ClaimAsOurs) {
eprintln!("warning: boot not recorded in the device ledger: {e}");
}
if let Some(row) = identity
&& let Err(e) = smix_lease::store::add_resource(&leases, udid, row)
{
eprintln!("warning: which AVD {udid} is was not recorded in the device ledger: {e}");
}
}
fn withdraw_emulator_boot(udid: &str) {
let Ok(leases) = smix_capsule::runner::machine_leases() else {
return;
};
if let Err(e) = smix_lease::store::drop_resource_kind(
&leases,
udid,
&smix_lease::Resource::Booted { by_us: true },
) {
eprintln!("warning: the boot record for {udid} was not withdrawn: {e}");
}
}
fn invoked_as() -> String {
let args: Vec<String> = std::env::args().skip(1).take(2).collect();
format!("smix {}", args.join(" "))
}
async fn run(cli: Cli) -> Result<ExitCode, CliError> {
if let Some(diag_root) = smix_lease::store::machine_root() {
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);
}
if matches!(
cli.cmd,
Cmd::Run { .. } | Cmd::Runner { .. } | Cmd::Sim { .. } | Cmd::Lease { .. }
) {
departures::notice(&invoked_as()).await;
}
let simctl = SimctlClient::new();
match cli.cmd {
Cmd::Init { alias, device, app } => {
cmd_init(&simctl, alias.as_deref(), device.as_deref(), app.as_deref()).await?
}
Cmd::Doctor {
json,
runner_port: port,
} => cmd_doctor(&simctl, json, port.unwrap_or_else(runner_port)).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::Session { action } => cmd_session(action).await?,
Cmd::Sim { action } => {
guard_destructive_action(&action)?;
if let Some(device) = sim_action_device(&action) {
guard_sim_verb(&action, device)?;
}
match action {
SimAction::List { json, registered } => {
if registered {
cmd_sim_list_registered(json)?;
} else {
cmd_sim_list(&simctl, json).await?;
}
}
SimAction::Migrate { from, dry_run } => cmd_sim_migrate(from, dry_run)?,
SimAction::Unregister { alias } => {
let path = registry_path()?;
let sim = SimRegistry::unregister(&path, &alias)
.map_err(|e| CliError::Other(e.to_string()))?;
println!("forgot `{alias}` -> {} in {}", sim.udid, path.display());
}
SimAction::Resolve { device, json } => {
let id = resolve_device(&device)?;
let view = load_registry();
let resolved = view.resolve_ref(&device).ok();
let sim = view.registry.lookup(&device);
let source = resolved
.as_ref()
.map_or(smix_simctl::registry::Source::Literal, |r| r.source.clone());
if json {
println!(
"{}",
resolution_json(&device, &id, resolved.as_ref(), sim, &source)
);
} else {
eprintln!("{}", resolution_line(&device, sim, &source));
println!("{id}");
}
}
SimAction::Register {
alias,
udid,
locale,
runner_port,
kind,
name,
} => {
let kind = kind.to_registry();
let udid = registry::canonical_identifier(kind, &udid);
registry::identifier_fits(kind, &udid)
.map_err(|e| CliError::Other(e.to_string()))?;
if kind == smix_simctl::registry::DeviceKind::Emulator {
if !adb_knows(&udid) {
return Err(CliError::Other(format!(
"adb lists no running device {udid} — check `adb devices`.\n\
An emulator is checked against adb the way a simulator is \
checked against simctl; only a physical device is taken as \
given, because nothing here can enumerate the world's phones."
)));
}
let avd = smix_adb::AdbClient::new()
.avd_name(&udid)
.await
.ok()
.filter(|a| !a.is_empty());
let path = registry_path()?;
let outcome = SimRegistry::register(
&path,
&alias,
smix_simctl::registry::RegisteredSim {
device_name: name.unwrap_or_else(|| alias.clone()),
udid: udid.clone(),
runtime: String::new(),
device_type: String::new(),
avd_name: avd,
locale,
runner_port,
kind,
destructive_opt_in: false,
},
)?;
let verb = match outcome {
smix_simctl::registry::RegisterOutcome::Added => "registered",
smix_simctl::registry::RegisterOutcome::Updated => "updated",
};
println!(
"{verb}: {alias} → {udid} (Android emulator) in {}",
smix_simctl::registry::store_dir(&path).display()
);
return Ok(std::process::ExitCode::SUCCESS);
}
if kind.is_physical() {
let path = registry_path()?;
let outcome = SimRegistry::register(
&path,
&alias,
smix_simctl::registry::RegisteredSim {
device_name: name.unwrap_or_else(|| alias.clone()),
udid: udid.clone(),
runtime: String::new(),
device_type: String::new(),
avd_name: None,
locale,
runner_port,
kind,
destructive_opt_in: false,
},
)?;
let verb = match outcome {
smix_simctl::registry::RegisterOutcome::Added => "registered",
smix_simctl::registry::RegisterOutcome::Updated => "updated",
};
println!(
"{verb}: {alias} → {udid} (physical device) in {}",
smix_simctl::registry::store_dir(&path).display()
);
println!(
"destructive actions are refused on it until \
`smix sim allow-destructive {alias}`"
);
return Ok(std::process::ExitCode::SUCCESS);
}
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()?;
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(),
avd_name: None,
locale,
runner_port,
kind: smix_simctl::registry::DeviceKind::Simulator,
destructive_opt_in: false,
},
)?;
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, cold } => {
let registered = lookup_registered(&device);
let booting_emulator = registered
.as_ref()
.is_some_and(|s| s.kind == smix_simctl::registry::DeviceKind::Emulator)
&& alias_needs_identity_check(&device);
let udid = if booting_emulator {
let sim = registered.as_ref().expect("checked above");
match emulator_alias_address(sim) {
smix_simctl::registry::EmulatorAddress::At { serial }
| smix_simctl::registry::EmulatorAddress::MovedTo { serial, .. } => {
serial
}
smix_simctl::registry::EmulatorAddress::NotRunning {
avd,
slot_now: Some(other),
} => {
let serial = free_console_serial()?;
eprintln!(
"note: {device} was registered on {}, which is \
running `{other}` today; starting `{avd}` on \
{serial} instead",
sim.udid
);
serial
}
smix_simctl::registry::EmulatorAddress::NotRunning { .. } => {
sim.udid.clone()
}
addr @ smix_simctl::registry::EmulatorAddress::NoIdentityRecorded {
..
} => return Err(emulator_address_refusal(&device, sim, addr)),
}
} else {
resolve_device(&device)?
};
let kind = device_kind_of(&device);
let running =
kind == smix_simctl::registry::DeviceKind::Emulator && adb_knows(&udid);
if let Some(why) = cold
.then(|| boot_cold::cold_boot_refusal(&device, kind, running))
.flatten()
{
return Err(CliError::Other(why));
}
if kind == smix_simctl::registry::DeviceKind::Emulator {
let already = running;
let claim = boot_claim(if already {
EmulatorState::AlreadyRunning
} else {
EmulatorState::WasOff
});
if !already {
let avd = lookup_registered(&device)
.and_then(|s| s.avd_name().map(str::to_string))
.ok_or_else(|| {
CliError::Other(format!(
"{udid} has no AVD name on record, so there is \
nothing to start. Register it once while it is \
running — `smix sim register <alias> --udid \
{udid} --kind emulator` — and the name is kept."
))
})?;
let adb = smix_adb::AdbClient::new();
let console = emulator_console_path(&avd)?;
let from = if cold {
smix_adb::BootFrom::Cold
} else {
smix_adb::BootFrom::Snapshot
};
adb.start_emulator_on_with(&avd, &udid, &console, from)
.map_err(|e| CliError::Other(format!("{e}")))?;
record_emulator_boot(
&udid,
claim,
Some(smix_lease::Resource::Emulator {
avd: avd.clone(),
console_log: Some(console.display().to_string()),
}),
);
let ready = adb
.wait_for_boot(&udid, std::time::Duration::from_secs(180))
.await;
if let Err(e) = ready {
let stopped = match adb.stop_emulator(&udid).await {
Ok(()) => {
withdraw_emulator_boot(&udid);
"it has been stopped and the ledger no longer says \
smix booted it"
.to_string()
}
Err(stop) => format!(
"stopping it failed ({stop}), so the ledger still \
says smix booted it — `smix sim shutdown {udid}` \
once it answers"
),
};
return Err(CliError::Other(format!(
"{avd} was started and {udid} did not come up \
within 180s: {e}\n\
{stopped}. What the emulator printed is in {}",
console.display()
)));
}
} else {
let identity = match smix_adb::AdbClient::new().avd_name(&udid).await {
Ok(avd) if !avd.is_empty() => {
Some(smix_lease::Resource::Emulator {
avd,
console_log: None,
})
}
_ => None,
};
record_emulator_boot(&udid, claim, identity);
}
println!("booted: {udid}");
return Ok(std::process::ExitCode::SUCCESS);
}
let claim = boot_record::claim_before_boot(&simctl, &udid).await;
simctl
.boot_and_wait(&udid, std::time::Duration::from_secs(120))
.await?;
println!("booted: {udid}");
boot_record::record_simulator_boot(&udid, claim);
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)?;
match shutdown_verdict(ownership_of(&udid), &udid) {
ShutdownVerdict::Proceed => {}
ShutdownVerdict::Refuse { message } => {
return Err(CliError::Other(message));
}
}
if device_kind_of(&device) == smix_simctl::registry::DeviceKind::Emulator {
let adb = smix_adb::AdbClient::new();
if let Err(e) = adb.stop_emulator(&udid).await {
if !format!("{e}").contains("device") {
return Err(CliError::Other(format!("could not stop {udid}: {e}")));
}
}
} else {
match simctl.shutdown(&udid).await {
Ok(()) => {}
Err(smix_simctl::DeviceControlError::NonZeroExit {
ref stderr,
..
}) if stderr.contains("current state: Shutdown") => {}
Err(e) => return Err(e.into()),
}
}
if let Ok(leases) = smix_capsule::runner::machine_leases()
&& let Err(e) = smix_lease::store::drop_resource_kind(
&leases,
&udid,
&smix_lease::Resource::Booted { by_us: true },
)
{
eprintln!("warning: boot row not cleared from the device ledger: {e}");
}
println!("shutdown: {udid}");
}
SimAction::Erase { device } => {
let udid = resolve_device(&device)?;
simctl.erase(&udid).await?;
println!("erased: {udid}");
}
SimAction::Screenshot {
device,
out,
runner_port: port_flag,
} => {
use smix_simctl::registry::DeviceKind;
let udid = resolve_device(&device)?;
let png = match device_kind_of(&device) {
DeviceKind::Simulator => simctl.screenshot(&udid).await?,
DeviceKind::Emulator | DeviceKind::PhysicalAndroid => {
use smix_sdk::device_control::DeviceControl;
smix_sdk::android_device::AndroidDeviceControl::new()
.screenshot(&udid)
.await
.map_err(|e| CliError::Other(e.to_string()))?
}
DeviceKind::PhysicalIos => {
use smix_sdk::device_control::DeviceControl;
let phone = smix_sdk::devicectl_device::DevicectlClient::new(&udid);
let offers = phone
.offers(smix_sdk::devicectl_device::CAPABILITY_SCREENSHOT)
.await
.ok();
match phone_screenshot_route(offers) {
PhoneScreenshotRoute::Devicectl => phone
.screenshot(&udid)
.await
.map_err(|e| CliError::Other(e.to_string()))?,
PhoneScreenshotRoute::Runner => smix_capsule::runner::screenshot(
port_flag.unwrap_or_else(runner_port),
)
.map_err(|e| CliError::Other(e.to_string()))?,
}
}
};
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 kind = device_kind_of(&device);
let pairs: Vec<(&str, &str)> = child_env
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
if !pairs.is_empty() && kind != smix_simctl::registry::DeviceKind::Simulator {
return Err(CliError::Other(format!(
"--child-env sets the launched process's environment, and only \
a simulator's launchd can do that; {udid} is driven by \
{}.\nInstead: pass the values as launch arguments, which \
every backend carries.",
if kind == smix_simctl::registry::DeviceKind::PhysicalIos {
"devicectl"
} else {
"adb"
}
)));
}
let pid = if kind == smix_simctl::registry::DeviceKind::Simulator {
let LaunchResult { pid } = simctl
.launch_with_args_and_env(&udid, &bundle_id, &launch_args, &pairs)
.await?;
pid
} else {
device_control_for(kind, &udid)
.launch_with_args(&udid, &bundle_id, &launch_args, None)
.await
.map_err(|e| CliError::Other(e.to_string()))?
};
if pid == 0 {
println!("launched: {bundle_id} on {udid}");
} else {
println!("launched: {bundle_id} on {udid} (pid {pid})");
}
}
SimAction::Terminate { device, bundle_id } => {
let udid = resolve_device(&device)?;
device_control_for(device_kind_of(&device), &udid)
.terminate(&udid, &bundle_id)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
println!("terminated: {bundle_id} on {udid}");
}
SimAction::Install { device, app_path } => {
let udid = resolve_device(&device)?;
let kind = device_kind_of(&device);
let control = device_control_for(kind, &udid);
let before = control.frontmost_app(&udid).await.ok().flatten();
control
.install(&udid, &app_path.display().to_string())
.await
.map_err(|e| CliError::Other(e.to_string()))?;
println!("installed: {} on {udid}", app_path.display());
if let Some(was) = before {
let after = control.frontmost_app(&udid).await.ok().flatten();
if after.as_ref().map(|f| &f.package) != Some(&was.package) {
println!(
" {} was running and this reinstall stopped it — \
bring it back with `smix sim launch {device} {}`",
was.package, was.package
);
}
}
}
SimAction::Uninstall { device, bundle_id } => {
let udid = resolve_device(&device)?;
let bundle = bundle_id.clone();
let control = device_control_for(device_kind_of(&device), &udid);
with_device_lease(control.as_ref(), &udid, |leased| async move {
leased.uninstall(&bundle).await?;
Ok(((), leased))
})
.await?;
println!("uninstalled: {bundle_id} on {udid}");
}
SimAction::Reverse {
device,
port,
to,
remove,
} => {
use smix_sdk::device_control::DeviceControl;
let serial = resolve_android_serial(&device)?;
let control = smix_sdk::android_device::AndroidDeviceControl::new();
let leases = smix_capsule::runner::machine_leases().map_err(CliError::Other)?;
if remove {
control
.reverse_port_remove(&serial, port)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
smix_lease::store::drop_reverse(&leases, &serial, port)
.map_err(|e| CliError::Other(e.to_string()))?;
println!("reverse: {serial} tcp:{port} closed");
} else {
let host_port = to.unwrap_or(port);
control
.reverse_port(&serial, port, host_port)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
smix_lease::store::record_reverse(
&leases, &serial, &serial, port, host_port,
)
.map_err(|e| CliError::Other(e.to_string()))?;
println!(
"reverse: {serial} tcp:{port} -> 127.0.0.1:{host_port} \
(stays open until `smix sim reverse {device} {port} --remove`, \
or until the device goes away)"
);
}
}
SimAction::Wake { device } => {
let serial = resolve_android_serial(&device)?;
let control = smix_sdk::android_device::AndroidDeviceControl::new();
with_device_lease(&control, &serial, |leased| async move {
leased.wake().await?;
Ok(((), leased))
})
.await?;
println!(
"wake: {serial} screen is on \
(a passcode lock is untouched — this lights the screen, it does not unlock)"
);
}
SimAction::StayAwake { device, state } => {
let serial = resolve_android_serial(&device)?;
let control = smix_sdk::android_device::AndroidDeviceControl::new();
with_device_lease(&control, &serial, |leased| async move {
leased.set_stay_awake(state).await?;
Ok(((), leased))
})
.await?;
let worded = if state { "on" } else { "off" };
println!(
"stay-awake: {serial} {worded} \
(a device setting — it outlives this run until set back)"
);
}
SimAction::Permission {
device,
bundle_id,
permission,
action,
} => {
use smix_sdk::device_control::{DeviceControl, Permission};
let parsed = Permission::from_name(&permission).map_err(CliError::Other)?;
let what = match action.as_str() {
"grant" => smix_sdk::PermissionAction::Grant,
"revoke" => smix_sdk::PermissionAction::Revoke,
_ => smix_sdk::PermissionAction::Reset,
};
let kind = device_kind_of(&device);
if kind == smix_simctl::registry::DeviceKind::Simulator {
let udid = resolve_device(&device)?;
let control = smix_sdk::ios_device::IosDeviceControl::new();
control
.set_permission(&udid, &bundle_id, parsed, what)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
println!("permission: {udid} {bundle_id} {} {action}", parsed.name());
} else {
let serial = resolve_android_serial(&device)?;
let control = smix_sdk::android_device::AndroidDeviceControl::new();
control
.set_permission(&serial, &bundle_id, parsed, what)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
println!(
"permission: {serial} {bundle_id} {} {action}",
parsed.name()
);
}
}
SimAction::Frontmost { device, json } => {
use smix_sdk::device_control::DeviceControl;
let serial = resolve_android_serial(&device)?;
let control = smix_sdk::android_device::AndroidDeviceControl::new();
let front = control
.frontmost_app(&serial)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
match (front, json) {
(Some(f), true) => println!(
"{}",
serde_json::json!({ "package": f.package, "activity": f.activity })
),
(Some(f), false) => println!("frontmost: {} {}", f.package, f.activity),
(None, true) => println!("null"),
(None, false) => {
println!("frontmost: nothing is resumed on {serial}");
}
}
}
SimAction::Crashes { device, app, json } => {
use smix_sdk::device_control::DeviceControl;
let serial = resolve_android_serial(&device)?;
let control = smix_sdk::android_device::AndroidDeviceControl::new();
let all = control
.crash_reports(&serial)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
let shown: Vec<_> = match &app {
Some(pkg) => all.iter().filter(|r| r.process.contains(pkg)).collect(),
None => all.iter().collect(),
};
if json {
let rows: Vec<_> = shown
.iter()
.map(|r| {
serde_json::json!({
"when": r.when,
"process": r.process,
"summary": r.summary,
"lines": r.lines,
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&rows)
.map_err(|e| CliError::Other(e.to_string()))?
);
} else {
for report in &shown {
println!("{} {} — {}", report.when, report.process, report.summary);
for line in &report.lines {
println!(" {line}");
}
}
match &app {
Some(pkg) => println!(
"crashes: {} of {} report(s) on {serial} name {pkg}",
shown.len(),
all.len()
),
None => {
println!("crashes: {} report(s) on {serial}", all.len());
}
}
}
}
SimAction::Openurl { device, url } => {
let udid = resolve_device(&device)?;
device_control_for(device_kind_of(&device), &udid)
.open_url(&udid, &url)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
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::AllowDestructive { device } => {
let path = registry_path()?;
match smix_simctl::registry::SimRegistry::allow_destructive(&path, &device) {
Ok((alias, already)) => {
if already {
println!("{alias}: destructive actions were already allowed");
} else {
println!(
"{alias}: destructive actions allowed — \
erase / uninstall / keychain-reset will now run on it"
);
}
}
Err(e) => return Err(CliError::Other(e.to_string())),
}
}
SimAction::KeychainReset { device } => {
let udid = resolve_device(&device)?;
let control = smix_sdk::ios_device::IosDeviceControl::new();
with_device_lease(&control, &udid, |leased| async move {
leased.keychain_reset().await?;
Ok(((), leased))
})
.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,
device_flag,
platform,
bundle,
runner_project,
runner_port: port_flag,
supervise,
team,
no_launch,
force,
take_over,
} => {
let device = device
.or(device_flag)
.expect("clap requires one of the two forms");
if platform == RunPlatform::Android {
reject_ios_only_up_flags(runner_project.is_some(), supervise)
.map_err(CliError::Other)?;
let port =
port_flag.unwrap_or(smix_capsule::runner_android::DEFAULT_ANDROID_PORT);
let serial = resolve_android_serial(&device)?;
let opts = smix_capsule::runner_android::UpOptions {
timeout_secs: 180,
force,
take_over,
app: bundle,
relaunch: !no_launch,
};
smix_capsule::runner_android::up_with_options(&root, &serial, port, &opts)
.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)?;
let physical_team = match lookup_registered(&device) {
Some(sim) if sim.kind.is_physical() => {
let facts = smix_capsule::signing::collect_facts(&udid);
Some(
smix_capsule::signing::resolve_team(team.as_deref(), &facts)
.map_err(|e| CliError::Other(e.to_string()))?,
)
}
_ => None,
};
let target = match physical_team.as_deref() {
Some(team) => smix_capsule::runner::RunnerTarget::Physical { team },
None => smix_capsule::runner::RunnerTarget::Simulator,
};
if physical_team.is_none() {
let simctl = SimctlClient::new();
let claim = boot_record::claim_before_boot(&simctl, &udid).await;
simctl
.boot_and_wait(&udid, std::time::Duration::from_secs(120))
.await?;
boot_record::record_simulator_boot(&udid, claim);
}
smix_capsule::runner::up_on(
&root,
&udid,
port,
bundle.as_deref(),
runner_project.as_deref(),
smix_capsule::runner::UpOptions {
supervise,
attach_without_relaunch: no_launch,
force_recover: force,
..Default::default()
},
target,
)
.map_err(CliError::Other)?;
}
RunnerAction::Forward { device, port } => {
let udid = resolve_device(&device)?;
let Some(found) = smix_usbmux::find_by_serial(&udid)
.map_err(|e| CliError::Other(e.to_string()))?
else {
return Err(CliError::Other(format!(
"usbmux does not see device {udid}. It is the transport smix \
drives through, so its view is the one that counts — a device \
`devicectl` calls available can still be off the USB bus.\n\
Check the cable, then `smix sim list`."
)));
};
let forward = smix_usbmux::forward(found.device_id, port, port)
.map_err(|e| CliError::Other(format!("bind 127.0.0.1:{port}: {e}")))?;
println!(
"forwarding 127.0.0.1:{} -> {udid}:{port}",
forward.local_port()
);
loop {
std::thread::sleep(std::time::Duration::from_secs(3600));
}
}
RunnerAction::Uninstall { platform, device } => {
if platform != RunPlatform::Android {
return Err(CliError::Other(
"runner uninstall is Android-only: the iOS runner is launched by \
xcodebuild and installs no standalone package"
.to_string(),
));
}
let port = smix_capsule::runner_android::DEFAULT_ANDROID_PORT;
let serial = resolve_android_serial(&device)?;
smix_capsule::runner_android::uninstall(&serial, port)
.map_err(CliError::Other)?;
}
RunnerAction::Down {
platform,
device,
include_unrecorded,
take_over,
runner_port: port_flag,
} => {
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 = port_flag.unwrap_or_else(|| {
std::env::var("SMIX_RUNNER_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(smix_capsule::runner_android::DEFAULT_ANDROID_PORT)
});
let serial = resolve_android_serial(&serial)?;
smix_capsule::runner_android::down_with(&serial, port, take_over)
.map_err(CliError::Other)?;
return Ok(std::process::ExitCode::SUCCESS);
}
let port = port_flag.unwrap_or_else(runner_port);
if include_unrecorded {
smix_capsule::runner::down_including_unrecorded(port)
} else {
smix_capsule::runner::down(port)
}
.map_err(CliError::Other)?;
}
RunnerAction::Cycle {
platform,
runner_project,
runner_port: port_flag,
} => {
if platform == RunPlatform::Android {
return Err(CliError::Other(
"runner cycle has no Android path. Cycling restarts the \
xcodebuild test host, and the Android runner is an \
instrumentation with no counterpart to restart. Use \
`smix runner down --platform android --device <serial>` \
then `smix runner up --platform android --device \
<serial>`. Until 6.6 this read iOS's records and told \
you no runner was recorded."
.to_string(),
));
}
let port = port_flag.unwrap_or_else(runner_port);
smix_capsule::runner::cycle(&root, port, runner_project.as_deref())
.map_err(CliError::Other)?;
}
RunnerAction::Supervise {
runner_project,
runner_port: port,
} => {
let port = port.unwrap_or_else(runner_port);
smix_capsule::runner::supervise(&root, port, runner_project.as_deref())
.map_err(CliError::Other)?;
}
RunnerAction::List { prune } => {
let leases = smix_capsule::runner::machine_leases().map_err(CliError::Other)?;
return Ok(std::process::ExitCode::from(runner_list::run(
&leases, prune,
)?));
}
RunnerAction::ListSessions { runner_port: port } => {
let port = port.unwrap_or_else(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 } => {
if let Some(target) = path {
runner_install_into(&target, force)?;
} else {
runner_install_default(force)?;
}
}
}
}
Cmd::Down { runner_port: port } => {
let root = smix_workspace_root()?;
down::run(&root, port.unwrap_or_else(runner_port))
.await
.map_err(CliError::Other)?;
}
Cmd::Lease { action } => {
let leases = smix_capsule::runner::machine_leases().map_err(CliError::Other)?;
return Ok(std::process::ExitCode::from(
lease_cmd::run(&leases, action).await?,
));
}
Cmd::Record { action } => {
record_cmd::run(action).await?;
}
Cmd::Capsule { action } => {
let root = smix_workspace_root()?;
let port = match &action {
CapsuleAction::Up { runner_port: p, .. }
| CapsuleAction::Down { runner_port: p, .. } => p.unwrap_or_else(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::capsule_supports(device_kind_of(&device), &device)
.map_err(CliError::Other)?;
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::capsule_supports(device_kind_of(&device), &device)
.map_err(CliError::Other)?;
capsule::down(&root, &udid).await.map_err(CliError::Other)?;
}
}
}
Cmd::Tap {
selector,
ocr_locale,
port,
device,
then_screenshot,
} => {
let (p, plat) = runner_dial_target(port, device.as_deref())?;
match then_screenshot {
Some(out) => act::cmd_tap_then_screenshot(selector, p, plat, &out)
.await
.map_err(|e| CliError::Other(e.to_string()))?,
None => act::cmd_tap(selector, p, plat, ocr_locale)
.await
.map_err(|e| CliError::Other(e.to_string()))?,
}
}
Cmd::Find {
selector,
ocr_locale,
port,
device,
} => {
let (p, plat) = runner_dial_target(port, device.as_deref())?;
act::cmd_find(selector, p, plat, ocr_locale)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::WaitFor {
selector,
ocr_locale,
timeout,
port,
device,
absent,
} => {
let (p, plat) = runner_dial_target(port, device.as_deref())?;
act::cmd_wait_for(selector, timeout, p, plat, absent, ocr_locale)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::Fill {
selector,
text,
port,
device,
} => {
let (p, plat) = runner_dial_target(port, device.as_deref())?;
act::cmd_fill(selector, text, p, plat)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::PressKey { key, port, device } => {
let (p, plat) = runner_dial_target(port, device.as_deref())?;
act::cmd_press_key(key, p, plat)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::Scroll {
selector,
direction,
port,
device,
} => {
let (p, plat) = runner_dial_target(port, device.as_deref())?;
act::cmd_scroll(selector, direction, p, plat)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::Swipe {
direction,
from_point,
to_point,
port,
device,
} => {
let (p, plat) = runner_dial_target(port, device.as_deref())?;
match (direction, from_point, to_point) {
(Some(d), None, None) => act::cmd_swipe(d, p, plat)
.await
.map_err(|e| CliError::Other(e.to_string()))?,
(None, Some(from), Some(to)) => act::cmd_swipe_between(&from, &to, p, plat)
.await
.map_err(|e| CliError::Other(e.to_string()))?,
(None, None, None) => {
return Err(CliError::Other(
"swipe needs either a direction (`up` / `down` / `left` / \
`right`) or both `--from` and `--to`"
.to_string(),
));
}
(None, Some(_), None) | (None, None, Some(_)) => {
return Err(CliError::Other(
"a coordinate swipe needs both ends: give `--from` and `--to`".to_string(),
));
}
(Some(_), _, _) => {
return Err(CliError::Other(
"swipe takes a direction or a `--from`/`--to` pair, not both".to_string(),
));
}
}
}
Cmd::HideKeyboard { port, device } => {
let (p, plat) = runner_dial_target(port, device.as_deref())?;
act::cmd_hide_keyboard(p, plat)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::Tree {
json,
port,
device,
keyboard,
reader,
} => {
let p = runner_dial_port(port, device.as_deref());
act::cmd_tree(json, p, keyboard, reader.to_act())
.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, plat) = runner_dial_target(port, device.as_deref())?;
script::cmd_run_script(&path, p, plat)
.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());
}
if let Some(p) = platform {
passthrough.push("--platform".into());
passthrough.push(
match p {
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());
}
if let Some(p) = platform {
passthrough.push("--platform".into());
passthrough.push(
match p {
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 device = run_device_ref(
device.as_deref(),
registry_path().ok().and_then(|p| {
SimRegistry::project_alias(&p, &project_key())
.ok()
.flatten()
}),
);
let udid = device
.as_deref()
.map(|d| resolve_device(d).unwrap_or_else(|_| d.to_string()));
let _run_lease = hold_run_lease(udid.as_deref())?;
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 = match device.as_deref() {
None => platform.unwrap_or(RunPlatform::Ios),
Some(d) => {
let kind = lookup_registered(d).map(|s| s.kind);
match resolve_run_platform(platform, kind) {
Ok(p) => p,
Err(e) => return Err(CliError::Other(e)),
}
}
}
.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(", ")
);
}
}
async fn booted_udids(simctl: &smix_simctl::SimctlClient) -> Vec<String> {
simctl
.list_devices()
.await
.map(|ds| {
ds.into_iter()
.filter(|d| d.state == "Booted")
.map(|d| d.udid.to_uppercase())
.collect()
})
.unwrap_or_default()
}
async fn with_device_lease<'a, F, Fut, T>(
control: &'a dyn smix_sdk::device_control::DeviceControl,
udid: &str,
body: F,
) -> Result<T, CliError>
where
F: FnOnce(smix_sdk::leased::Leased<'a>) -> Fut,
Fut: std::future::Future<Output = Result<(T, smix_sdk::leased::Leased<'a>), CliError>>,
{
let leases = smix_capsule::runner::machine_leases().map_err(CliError::Other)?;
let leased = smix_sdk::leased::Leased::acquire(
control,
&leases,
udid,
&smix_capsule::reconcile::Reconciler,
)
.map_err(|e| CliError::Other(e.to_string()))?;
for report in leased.settled() {
println!("settled first: {}", report.line);
}
let (out, leased) = body(leased).await?;
leased
.release()
.map_err(|e| CliError::Other(e.to_string()))?;
Ok(out)
}
struct RunLease {
leases: smix_lease::store::LeaseDir,
device_id: String,
inherited: Vec<smix_lease::Resource>,
}
impl Drop for RunLease {
fn drop(&mut self) {
if let Err(e) = smix_lease::store::drop_process_rows_except(
&self.leases,
&self.device_id,
&self.inherited,
) {
eprintln!(
"warning: device lease not released for {}: {e}",
self.device_id
);
}
}
}
fn hold_run_lease(udid: Option<&str>) -> Result<Option<RunLease>, CliError> {
let Some(udid) = udid else {
return Ok(None);
};
let Ok(leases) = smix_capsule::runner::machine_leases() else {
return Ok(None);
};
let control = smix_sdk::ios_device::IosDeviceControl::new();
let leased = smix_sdk::leased::Leased::acquire(
&control,
&leases,
udid,
&smix_capsule::reconcile::Reconciler,
)
.map_err(|e| CliError::Other(e.to_string()))?;
for report in leased.settled() {
println!("settled first: {}", report.line);
}
let inherited = leased.inherited().to_vec();
Ok(Some(RunLease {
leases,
device_id: udid.to_string(),
inherited,
}))
}
fn is_destructive(action: &SimAction) -> bool {
match action {
SimAction::Erase { .. }
| SimAction::Uninstall { .. }
| SimAction::KeychainReset { .. }
| SimAction::Exec { .. } => true,
SimAction::List { .. }
| SimAction::Register { .. }
| SimAction::Resolve { .. }
| SimAction::Migrate { .. }
| SimAction::Unregister { .. }
| SimAction::AllowDestructive { .. }
| SimAction::Boot { .. }
| SimAction::Shutdown { .. }
| SimAction::Screenshot { .. }
| SimAction::Launch { .. }
| SimAction::Terminate { .. }
| SimAction::Install { .. }
| SimAction::Openurl { .. }
| SimAction::Reverse { .. }
| SimAction::Wake { .. }
| SimAction::StayAwake { .. }
| SimAction::Frontmost { .. }
| SimAction::Crashes { .. }
| SimAction::Permission { .. }
| SimAction::Appearance { .. }
| SimAction::Locale { .. } => false,
}
}
fn guard_destructive_action(action: &SimAction) -> Result<(), CliError> {
if !is_destructive(action) {
return Ok(());
}
let Some(device) = sim_action_device(action) else {
return Ok(());
};
guard_destructive(device)
}
fn guard_destructive(device_ref: &str) -> Result<(), CliError> {
let reg = load_registry().registry;
let Some(sim) = reg.lookup(device_ref) else {
return Ok(());
};
smix_lease::may_destroy(
device_ref,
smix_lease::DeviceClass {
physical: sim.kind.is_physical(),
destructive_opt_in: sim.destructive_opt_in,
},
)
.map_err(|e| CliError::Other(e.to_string()))
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
enum DeviceKindArg {
Simulator,
Emulator,
PhysicalIos,
PhysicalAndroid,
}
impl DeviceKindArg {
fn to_registry(self) -> smix_simctl::registry::DeviceKind {
use smix_simctl::registry::DeviceKind as K;
match self {
DeviceKindArg::Simulator => K::Simulator,
DeviceKindArg::Emulator => K::Emulator,
DeviceKindArg::PhysicalIos => K::PhysicalIos,
DeviceKindArg::PhysicalAndroid => K::PhysicalAndroid,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PhoneScreenshotRoute {
Devicectl,
Runner,
}
fn phone_screenshot_route(offers_capture: Option<bool>) -> PhoneScreenshotRoute {
match offers_capture {
Some(true) => PhoneScreenshotRoute::Devicectl,
Some(false) | None => PhoneScreenshotRoute::Runner,
}
}
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 SessionAction {
State {
#[arg(long)]
port: Option<u16>,
#[arg(long)]
device: Option<String>,
},
}
#[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,
#[arg(long = "runner-port", env = "SMIX_RUNNER_PORT")]
runner_port: Option<u16>,
},
}
fn runner_install_into(target: &Path, force: bool) -> Result<(), CliError> {
if !force
&& smix_runner_sources::stamp_is_current(
smix_runner_sources::read_installed_version(target)
.map_err(|e| CliError::Other(format!("runner install: {e}")))?
.as_deref(),
)
{
println!(
"runner install: {} already holds v{} — nothing to do (pass --force to re-extract).",
target.display(),
smix_runner_sources::SOURCES_VERSION
);
return Ok(());
}
let report = smix_runner_sources::extract_to(target, true)
.map_err(|e| CliError::Other(format!("runner install: {e}")))?;
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
);
Ok(())
}
fn runner_install_default(force: bool) -> Result<(), CliError> {
let machine = smix_lease::store::machine_root().ok_or_else(|| {
CliError::Other(
"runner install: no HOME or XDG_DATA_HOME, so there is no machine directory".into(),
)
})?;
let dir = smix_runner_sources::tree_dir(&machine, smix_runner_sources::RunnerPlatform::Ios);
if force && dir.exists() {
std::fs::remove_dir_all(&dir).map_err(|e| {
CliError::Other(format!(
"runner install --force: removing {}: {e}",
dir.display()
))
})?;
}
let ensured =
smix_runner_sources::ensure_tree(&machine, smix_runner_sources::RunnerPlatform::Ios)
.map_err(|e| CliError::Other(format!("runner install: {e}")))?;
let pruned_note = if ensured.pruned.is_empty() {
String::new()
} else {
format!(
" Removed {} tree(s) of older sources, keeping the {} most recently used.",
ensured.pruned.len(),
smix_runner_sources::BACKUPS_KEPT
)
};
if ensured.extracted {
println!(
"runner install: extracted v{} into {}.{pruned_note}",
smix_runner_sources::SOURCES_VERSION,
ensured.dir.display()
);
} else {
println!(
"runner install: {} already holds v{} — nothing to do (pass --force to re-extract).{pruned_note}",
ensured.dir.display(),
smix_runner_sources::SOURCES_VERSION
);
}
Ok(())
}
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_session(action: SessionAction) -> Result<(), CliError> {
match action {
SessionAction::State { port, device } => {
let p = runner_dial_port(port, device.as_deref());
let client = smix_runner_client::HttpRunnerClient::new(p);
let sessions = match client.list_sessions().await {
Ok(resp) => serde_json::to_value(&resp.sessions).unwrap_or(serde_json::Value::Null),
Err(e) => serde_json::json!({ "unreachable": e.to_string() }),
};
let report = serde_json::json!({
"port": p,
"device": device,
"sessions": sessions,
});
println!(
"{}",
serde_json::to_string_pretty(&report).unwrap_or_default()
);
Ok(())
}
}
}
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,
runner_port: port,
} => {
let port = port.unwrap_or_else(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: Option<&str>,
device: Option<&str>,
app: Option<&Path>,
) -> Result<(), CliError> {
let cwd = std::env::current_dir()
.map_err(|e| CliError::Other(format!("cannot determine cwd: {e}")))?;
let workspace = cwd.join(".smix");
std::fs::create_dir_all(&workspace)
.map_err(|e| CliError::Other(format!("create {}: {e}", workspace.display())))?;
let alias = alias
.map(str::to_string)
.unwrap_or_else(|| init::default_project_alias(&cwd));
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()?;
let existing: Vec<String> = load_registry().registry.sims().keys().cloned().collect();
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(),
avd_name: None,
runner_port: None,
locale: None,
kind: registry::DeviceKind::Simulator,
destructive_opt_in: false,
})
.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}")))?;
SimRegistry::set_project_alias(&path, &project_key(), &plan.alias)
.map_err(|e| CliError::Other(format!("record project device: {e}")))?;
println!(
"registered `{}` -> {} in {} (this project's default device)",
plan.alias,
plan.udid,
path.display()
);
let bundle = match app {
Some(app_path) => {
let claim = boot_record::claim_before_boot(simctl, &plan.udid).await;
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)));
}
boot_record::record_simulator_boot(&plan.udid, claim);
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, port: u16) -> 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 reg = load_registry().registry;
let registry = (!reg.sims().is_empty()).then(|| readiness::RegistryFacts {
aliases: reg.sims().len(),
first_alias: reg.sims().keys().next().cloned(),
});
let downgradeable = std::env::current_dir()
.ok()
.map(|cwd| cwd.join(".smix"))
.filter(|dir| dir.join("kv").is_dir())
.and_then(|dir| smix_store::Store::open(&dir).ok())
.and_then(|store| store.downgradeable_to_kevy3());
let verdict = readiness::assess(&readiness::Facts {
simctl: simctl_facts,
registry,
runner_up: smix_capsule::runner::health_ok(port),
capture_server_up: capture_server_reachable(),
downgradeable,
});
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(())
}
struct AndroidDevice {
serial: String,
state: String,
model: String,
release: String,
registered: bool,
}
fn parse_adb_devices(list: &str) -> Vec<(String, String, String)> {
list.lines()
.skip_while(|l| l.starts_with("List of devices"))
.filter(|l| !l.trim().is_empty())
.filter_map(|line| {
let mut fields = line.split_whitespace();
let serial = fields.next()?.to_string();
let state = fields.next()?.to_string();
let model = fields
.find_map(|f| f.strip_prefix("model:"))
.unwrap_or("")
.to_string();
Some((serial, state, model))
})
.collect()
}
fn may_ask_release(serial: &str, registered: bool) -> bool {
registered || smix_simctl::registry::is_emulator_serial(serial)
}
fn android_devices() -> Vec<AndroidDevice> {
let registry = load_registry().registry;
let Ok(out) = std::process::Command::new("adb")
.args(["devices", "-l"])
.output()
else {
return Vec::new();
};
if !out.status.success() {
return Vec::new();
}
parse_adb_devices(&String::from_utf8_lossy(&out.stdout))
.into_iter()
.map(|(serial, state, model)| {
let registered = registry.lookup(&serial).is_some();
if !may_ask_release(&serial, registered) {
return AndroidDevice {
serial,
state,
model,
release: String::new(),
registered,
};
}
let release = std::process::Command::new("adb")
.args([
"-s",
&serial,
"shell",
"getprop",
"ro.build.version.release",
])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
AndroidDevice {
serial,
state,
model,
release,
registered,
}
})
.collect()
}
fn cmd_sim_list_registered(json: bool) -> Result<(), CliError> {
let view = load_registry();
let machine = SimRegistry::machine_dir();
if json {
let rows: Vec<serde_json::Value> = view
.registry
.all()
.map(|(alias, sim)| {
serde_json::json!({
"alias": alias,
"udid": sim.udid,
"name": sim.device_name,
"kind": sim.kind,
"runtime": sim.runtime,
"destructiveOptIn": sim.destructive_opt_in,
"scope": match view.unmigrated.get(alias) {
Some(p) => serde_json::json!({"checkout": p.display().to_string()}),
None => serde_json::json!({"machine": machine.as_ref().map(|m| m.display().to_string())}),
},
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&rows)
.map_err(|e| CliError::Other(format!("serialize: {e}")))?
);
return Ok(());
}
if view.registry.sims().is_empty() {
println!("no devices recorded — `smix sim register <alias> --udid <udid>`");
return Ok(());
}
println!("{:<20} {:<40} {:<10} SCOPE", "ALIAS", "UDID", "KIND");
for (alias, sim) in view.registry.all() {
let scope = match view.unmigrated.get(alias) {
Some(p) => p.display().to_string(),
None => "machine".to_string(),
};
println!(
"{:<20} {:<40} {:<10} {scope}",
alias,
sim.udid,
format!("{:?}", sim.kind).to_lowercase()
);
}
if !view.unmigrated.is_empty() {
println!();
println!(
"{} record(s) live in a checkout and are invisible from anywhere \
else — `smix sim migrate` folds them in",
view.unmigrated.len()
);
}
Ok(())
}
fn cmd_sim_migrate(from: Vec<PathBuf>, dry_run: bool) -> Result<(), CliError> {
let into = registry_path()?;
let sources: Vec<PathBuf> = if from.is_empty() {
let cwd = std::env::current_dir()
.map_err(|e| CliError::Other(format!("cannot determine cwd: {e}")))?;
SimRegistry::discover(&cwd).into_iter().collect()
} else {
from.iter()
.map(|p| {
if p.ends_with(".smix") {
p.clone()
} else {
p.join(".smix")
}
})
.collect()
};
if sources.is_empty() {
println!(
"nothing to migrate — no .smix registry above {}. \
Name the checkouts to read with --from <dir>.",
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.display()
);
return Ok(());
}
let sources: Vec<PathBuf> = sources
.into_iter()
.filter(|p| {
if p == &into {
println!("{} is already the machine registry — skipped", p.display());
false
} else {
true
}
})
.collect();
let report = if dry_run {
SimRegistry::migrate_dry_run(&into, &sources)
} else {
SimRegistry::migrate(&into, &sources)
}
.map_err(|e| CliError::Other(format!("migrate: {e}")))?;
for (path, why) in &report.unreadable {
eprintln!(" {} could not be read: {why}", path.display());
}
for path in &report.empty {
println!(" {} held no devices", path.display());
}
for alias in &report.added {
println!(" + {alias}");
}
for alias in &report.narrowed {
println!(" ~ {alias} — destructive consent narrowed to the stricter answer");
}
println!(
"{} device(s) {} in {} ({} already there)",
report.added.len(),
if dry_run {
"would be recorded"
} else {
"now recorded"
},
into.display(),
report.unchanged.len()
);
if !report.added.is_empty() && !dry_run {
println!(
"the source registries are untouched — delete them yourself once \
every tree you use has been migrated"
);
}
Ok(())
}
async fn cmd_sim_list(simctl: &SimctlClient, json: bool) -> Result<(), CliError> {
let devices = simctl.list_devices().await?;
let android = android_devices();
if json {
let mut all: Vec<serde_json::Value> = Vec::new();
for d in &devices {
let mut v =
serde_json::to_value(d).map_err(|e| CliError::Other(format!("serialize: {e}")))?;
if let Some(obj) = v.as_object_mut() {
obj.insert("platform".into(), "ios".into());
}
all.push(v);
}
for a in &android {
all.push(serde_json::json!({
"platform": "android",
"udid": a.serial,
"name": if a.model.is_empty() { a.serial.clone() } else { a.model.clone() },
"state": a.state,
"release": a.release,
"registered": a.registered,
}));
}
let out = serde_json::to_string_pretty(&all)
.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
);
}
for a in &android {
let name = if a.model.is_empty() {
a.serial.as_str()
} else {
a.model.as_str()
};
let release = if !may_ask_release(&a.serial, a.registered) {
"Android (not registered; smix does not address it)".to_string()
} else if a.release.is_empty() {
"Android".to_string()
} else {
format!("Android-{}", a.release)
};
println!("{:<40} {:<28} {:<10} {release}", a.serial, name, a.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 run_device_ref(explicit: Option<&str>, pointer: Option<String>) -> Option<String> {
match explicit {
Some(d) => Some(d.to_string()),
None => pointer,
}
}
fn project_key() -> String {
let cwd = std::env::current_dir().unwrap_or_default();
let root = smix_capsule::runner::workspace_root(&cwd).unwrap_or_else(|| cwd.clone());
std::fs::canonicalize(&root)
.unwrap_or(root)
.to_string_lossy()
.into_owned()
}
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 runner_dial_target(
flag: Option<u16>,
device: Option<&str>,
) -> Result<(u16, smix_driver::Platform), CliError> {
let port = runner_dial_port(flag, device);
let dial = device.map(|d| match lookup_registered(d) {
Some(sim) => DeviceDial::Registered(sim.kind),
None => DeviceDial::Unregistered,
});
let holders: Vec<smix_simctl::registry::DeviceKind> = if device.is_none() {
load_registry()
.registry
.all()
.filter(|(_, sim)| sim.runner_port == Some(port))
.map(|(_, sim)| sim.kind)
.collect()
} else {
Vec::new()
};
let plat = dial_platform(dial, &holders).map_err(CliError::Other)?;
Ok((port, plat.to_driver()))
}
fn reject_ios_only_up_flags(runner_project: bool, supervise: bool) -> Result<(), String> {
let offenders: Vec<&str> = [
(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 builds no Xcode project and has no \
supervise sidecar. What applies on this platform: --runner-port, \
--bundle (the app to leave in front), --no-launch, --force, --take-over.",
offenders.join(" / "),
if offenders.len() == 1 {
"it is"
} else {
"they are"
},
))
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum EmulatorState {
WasOff,
AlreadyRunning,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum BootClaim {
ClaimAsOurs,
LeaveUnclaimed,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Ownership {
BootedByUs,
HeldByOther { pid: u32 },
NoLedgerRow,
}
#[derive(Clone, Debug, PartialEq)]
enum ShutdownVerdict {
Proceed,
Refuse { message: String },
}
fn boot_claim(state: EmulatorState) -> BootClaim {
match state {
EmulatorState::WasOff => BootClaim::ClaimAsOurs,
EmulatorState::AlreadyRunning => BootClaim::LeaveUnclaimed,
}
}
fn ownership_of(serial: &str) -> Ownership {
let Ok(leases) = smix_capsule::runner::machine_leases() else {
return Ownership::NoLedgerRow;
};
let Ok(facts) = smix_lease::store::collect_facts(&leases, serial) else {
return Ownership::NoLedgerRow;
};
let booted_by_us = facts.existing.as_ref().is_some_and(|h| {
h.lease
.known_resources()
.any(|r| matches!(r, smix_lease::Resource::Booted { by_us: true }))
});
match smix_lease::assess(&facts) {
smix_lease::Admission::Denied(c) if c.holder_alive => {
Ownership::HeldByOther { pid: c.holder.pid }
}
_ if booted_by_us => Ownership::BootedByUs,
_ => Ownership::NoLedgerRow,
}
}
fn shutdown_verdict(owner: Ownership, serial: &str) -> ShutdownVerdict {
match owner {
Ownership::BootedByUs => ShutdownVerdict::Proceed,
Ownership::NoLedgerRow => ShutdownVerdict::Refuse {
message: format!(
"{serial} is running and this machine's ledger has no record of smix \
booting it — whoever booted it stops it.\n\
\x20 if it is yours and you started it by hand, stop it the same way\n\
\x20 if it should be smix's, start it with `smix sim boot {serial}` \
and the ledger will say so"
),
},
Ownership::HeldByOther { pid } => ShutdownVerdict::Refuse {
message: format!(
"{serial} is held by pid {pid}, which is still alive — stopping it now \
takes a device out from under work that is running.\n\
\x20 `smix lease status {serial}` says what it has open\n\
\x20 `smix lease reconcile {serial}` closes it gracefully once that \
process is gone"
),
},
}
}
#[cfg(test)]
mod emulator_ownership {
use super::*;
#[test]
fn a_device_already_running_is_not_claimed() {
assert_eq!(
boot_claim(EmulatorState::AlreadyRunning),
BootClaim::LeaveUnclaimed,
"smix did not turn this one on, and saying otherwise is how a \
teardown later stops somebody else's work"
);
}
#[test]
fn a_device_this_process_started_is_claimed() {
assert_eq!(boot_claim(EmulatorState::WasOff), BootClaim::ClaimAsOurs);
}
#[test]
fn an_emulator_we_did_not_boot_is_not_ours_to_stop() {
match shutdown_verdict(Ownership::NoLedgerRow, "emulator-5560") {
ShutdownVerdict::Refuse { message } => {
assert!(
message.contains("emulator-5560"),
"a refusal that does not name the device cannot be acted on: {message}"
);
assert!(
message.to_lowercase().contains("boot"),
"the refusal must say the rule it is applying — whoever booted it \
stops it: {message}"
);
}
other => panic!("stopping a device smix never started was allowed: {other:?}"),
}
}
#[test]
fn an_emulator_we_booted_is_ours_to_stop() {
assert!(matches!(
shutdown_verdict(Ownership::BootedByUs, "emulator-5560"),
ShutdownVerdict::Proceed
));
}
#[test]
fn an_emulator_held_by_someone_else_names_the_holder() {
match shutdown_verdict(Ownership::HeldByOther { pid: 4242 }, "emulator-5560") {
ShutdownVerdict::Refuse { message } => assert!(
message.contains("4242"),
"the refusal must name who is holding it, or the reader has nothing \
to go and ask: {message}"
),
other => panic!("stopping a device another process holds was allowed: {other:?}"),
}
}
}
#[cfg(test)]
mod platform_from_device {
use super::*;
use smix_simctl::registry::DeviceKind;
#[test]
fn an_explicit_platform_wins() {
assert_eq!(
resolve_run_platform(Some(RunPlatform::Android), Some(DeviceKind::Simulator)).unwrap(),
RunPlatform::Android,
"--platform is the override for a device the registry cannot classify"
);
}
#[test]
fn an_emulator_is_android_without_being_told() {
for k in [DeviceKind::Emulator, DeviceKind::PhysicalAndroid] {
assert_eq!(
resolve_run_platform(None, Some(k)).unwrap(),
RunPlatform::Android,
"{k:?}"
);
}
}
#[test]
fn a_simulator_is_ios_without_being_told() {
for k in [DeviceKind::Simulator, DeviceKind::PhysicalIos] {
assert_eq!(
resolve_run_platform(None, Some(k)).unwrap(),
RunPlatform::Ios,
"{k:?}"
);
}
}
#[test]
fn an_unclassifiable_device_refuses_rather_than_guessing() {
let err = resolve_run_platform(None, None).unwrap_err();
assert!(
err.contains("--platform"),
"the error must name the override: {err}"
);
assert!(
err.to_lowercase().contains("register"),
"and the durable fix: {err}"
);
}
}
#[cfg(test)]
mod phone_screenshot_routing {
use super::{PhoneScreenshotRoute, phone_screenshot_route};
#[test]
fn a_phone_that_offers_capture_is_asked_itself() {
assert_eq!(
phone_screenshot_route(Some(true)),
PhoneScreenshotRoute::Devicectl
);
}
#[test]
fn a_phone_that_does_not_is_seen_through_the_runner() {
assert_eq!(
phone_screenshot_route(Some(false)),
PhoneScreenshotRoute::Runner
);
}
#[test]
fn a_phone_devicectl_cannot_speak_for_is_seen_through_the_runner() {
assert_eq!(phone_screenshot_route(None), PhoneScreenshotRoute::Runner);
}
}
#[cfg(test)]
mod dial_platform {
use super::*;
use smix_simctl::registry::DeviceKind;
#[test]
fn run_device_ref_prefers_explicit_then_pointer_then_none() {
assert_eq!(
run_device_ref(Some("emulator-5560"), Some("proj-alias".into())),
Some("emulator-5560".to_string()),
"explicit --device wins over the project pointer"
);
assert_eq!(
run_device_ref(None, Some("proj-alias".into())),
Some("proj-alias".to_string()),
"no --device falls back to the project pointer"
);
assert_eq!(
run_device_ref(None, None),
None,
"neither → None, the old iOS-default behaviour"
);
}
#[test]
fn a_registered_device_decides_by_its_kind() {
assert_eq!(
dial_platform(Some(DeviceDial::Registered(DeviceKind::Emulator)), &[]).unwrap(),
RunPlatform::Android,
"an emulator names the Android driver"
);
assert_eq!(
dial_platform(Some(DeviceDial::Registered(DeviceKind::Simulator)), &[]).unwrap(),
RunPlatform::Ios,
"a simulator names the iOS driver"
);
assert_eq!(
dial_platform(
Some(DeviceDial::Registered(DeviceKind::PhysicalAndroid)),
&[]
)
.unwrap(),
RunPlatform::Android,
);
}
#[test]
fn an_unregistered_device_refuses_and_names_register() {
let err = dial_platform(Some(DeviceDial::Unregistered), &[]).unwrap_err();
assert!(
err.to_lowercase().contains("register"),
"the error must name the durable fix: {err}"
);
}
#[test]
fn a_ports_single_holder_decides_when_no_device() {
assert_eq!(
dial_platform(None, &[DeviceKind::Emulator]).unwrap(),
RunPlatform::Android,
);
assert_eq!(
dial_platform(None, &[DeviceKind::Simulator, DeviceKind::Simulator]).unwrap(),
RunPlatform::Ios,
"two holders of the same platform is not a conflict",
);
}
#[test]
fn a_bare_port_keeps_the_ios_default() {
assert_eq!(dial_platform(None, &[]).unwrap(), RunPlatform::Ios);
}
#[test]
fn two_platforms_on_one_port_refuses() {
let err = dial_platform(None, &[DeviceKind::Emulator, DeviceKind::Simulator]).unwrap_err();
assert!(
err.to_lowercase().contains("--device"),
"the error must name the way out: {err}"
);
}
}
#[cfg(test)]
mod device_power {
use super::*;
use smix_simctl::registry::DeviceKind;
fn kinds(action: &SimAction) -> Vec<DeviceKind> {
sim_verb_supports(action).expect("this verb acts on a device")
}
fn boot() -> SimAction {
SimAction::Boot {
device: String::new(),
cold: false,
}
}
fn shutdown() -> SimAction {
SimAction::Shutdown {
device: String::new(),
}
}
#[test]
fn booting_and_stopping_reach_emulators() {
for action in [boot(), shutdown()] {
let k = kinds(&action);
assert!(
k.contains(&DeviceKind::Emulator),
"an emulator cannot be powered by smix, so nothing can record who did: {k:?}"
);
assert!(
k.contains(&DeviceKind::Simulator),
"the iOS path must survive the widening: {k:?}"
);
}
}
#[test]
fn no_physical_device_is_powered_here() {
for action in [boot(), shutdown()] {
let k = kinds(&action);
for forbidden in [DeviceKind::PhysicalIos, DeviceKind::PhysicalAndroid] {
assert!(
!k.contains(&forbidden),
"this path claims to reach {forbidden:?} — section 9 #1 does not put \
a physical device's power state on it"
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const UDID: &str = "5D087114-ECB3-443C-8DDB-40EEF9CFB90C";
#[test]
fn a_device_ref_is_classed_by_the_registry_then_by_shape() {
use smix_simctl::registry::{DeviceKind, RegisteredSim};
let mut reg = SimRegistry::default();
reg.insert(
"phone",
RegisteredSim {
device_name: "a phone".into(),
kind: DeviceKind::PhysicalIos,
destructive_opt_in: false,
udid: "00008120-000000000000000E".into(),
runtime: String::new(),
device_type: String::new(),
avd_name: None,
locale: None,
runner_port: None,
},
);
assert_eq!(
classify_device(®, "00008120-000000000000000E"),
DeviceKind::PhysicalIos
);
assert_eq!(classify_device(®, "emulator-5554"), DeviceKind::Emulator);
assert_eq!(classify_device(®, UDID), DeviceKind::Simulator);
assert_eq!(
classify_device(®, "EMULATOR-5554"),
DeviceKind::Simulator
);
}
#[test]
fn payload_verbs_reach_android() {
use smix_simctl::registry::DeviceKind::{
Emulator, PhysicalAndroid, PhysicalIos, Simulator,
};
let install = sim_verb_supports(&SimAction::Install {
device: String::new(),
app_path: std::path::PathBuf::new(),
})
.expect("install takes a device");
assert!(install.contains(&Emulator), "install: {install:?}");
assert!(install.contains(&PhysicalAndroid), "install: {install:?}");
assert!(install.contains(&Simulator), "install: {install:?}");
assert!(
install.contains(&PhysicalIos),
"install refuses a registered iPhone and devicectl installs on one: {install:?}"
);
let uninstall = sim_verb_supports(&SimAction::Uninstall {
device: String::new(),
bundle_id: String::new(),
})
.expect("uninstall takes a device");
for kind in [Simulator, PhysicalIos, Emulator, PhysicalAndroid] {
assert!(
uninstall.contains(&kind),
"uninstall dropped {kind:?}: {uninstall:?}"
);
}
}
#[test]
fn arranging_verbs_reach_the_kinds_that_have_a_path() {
use smix_simctl::registry::DeviceKind;
use smix_simctl::registry::DeviceKind::{
Emulator, PhysicalAndroid, PhysicalIos, Simulator,
};
let android_only: Vec<(&str, Option<Vec<DeviceKind>>)> = vec![
(
"wake",
sim_verb_supports(&SimAction::Wake {
device: String::new(),
}),
),
(
"stay-awake",
sim_verb_supports(&SimAction::StayAwake {
device: String::new(),
state: true,
}),
),
(
"frontmost",
sim_verb_supports(&SimAction::Frontmost {
device: String::new(),
json: false,
}),
),
(
"crashes",
sim_verb_supports(&SimAction::Crashes {
device: String::new(),
app: None,
json: false,
}),
),
];
for (name, kinds) in android_only {
let kinds = kinds.unwrap_or_else(|| panic!("{name} takes a device"));
assert!(kinds.contains(&Emulator), "{name}: {kinds:?}");
assert!(kinds.contains(&PhysicalAndroid), "{name}: {kinds:?}");
assert!(
!kinds.contains(&Simulator),
"{name} claims a simulator: {kinds:?}"
);
assert!(
!kinds.contains(&PhysicalIos),
"{name} claims a physical iPhone: {kinds:?}"
);
}
let permission = sim_verb_supports(&SimAction::Permission {
device: String::new(),
bundle_id: String::new(),
permission: String::new(),
action: String::new(),
})
.expect("permission takes a device");
for kind in [Simulator, Emulator, PhysicalAndroid] {
assert!(permission.contains(&kind), "permission dropped {kind:?}");
}
assert!(
!permission.contains(&PhysicalIos),
"a device's TCC grants are its owner's: {permission:?}"
);
}
#[test]
fn arranging_verbs_take_their_refusal_from_the_table() {
use smix_sdk::device_control::availability;
use smix_simctl::registry::DeviceKind::Simulator;
let pairs = [
(
table_action_of(&SimAction::Wake {
device: String::new(),
}),
"wake",
),
(
table_action_of(&SimAction::StayAwake {
device: String::new(),
state: false,
}),
"set_stay_awake",
),
(
table_action_of(&SimAction::Permission {
device: String::new(),
bundle_id: String::new(),
permission: String::new(),
action: String::new(),
}),
"set_permission",
),
(
table_action_of(&SimAction::Frontmost {
device: String::new(),
json: false,
}),
"frontmost_app",
),
(
table_action_of(&SimAction::Crashes {
device: String::new(),
app: None,
json: false,
}),
"crash_reports",
),
];
for (mapped, expected) in pairs {
assert_eq!(mapped, Some(expected), "verb does not name its table row");
assert!(
availability(expected, Simulator).is_some(),
"{expected} is not a row in the platform table"
);
}
}
fn every_sim_action() -> Vec<(&'static str, SimAction)> {
let d = String::new;
vec![
(
"list",
SimAction::List {
json: false,
registered: false,
},
),
(
"register",
SimAction::Register {
alias: d(),
udid: d(),
locale: None,
runner_port: None,
kind: DeviceKindArg::Simulator,
name: None,
},
),
(
"resolve",
SimAction::Resolve {
device: d(),
json: false,
},
),
(
"migrate",
SimAction::Migrate {
dry_run: false,
from: Vec::new(),
},
),
("unregister", SimAction::Unregister { alias: d() }),
(
"allow-destructive",
SimAction::AllowDestructive { device: d() },
),
(
"boot",
SimAction::Boot {
device: d(),
cold: false,
},
),
("shutdown", SimAction::Shutdown { device: d() }),
("erase", SimAction::Erase { device: d() }),
(
"screenshot",
SimAction::Screenshot {
device: d(),
out: std::path::PathBuf::new(),
runner_port: None,
},
),
(
"launch",
SimAction::Launch {
device: d(),
bundle_id: d(),
child_env: Vec::new(),
launch_args: Vec::new(),
},
),
(
"terminate",
SimAction::Terminate {
device: d(),
bundle_id: d(),
},
),
(
"install",
SimAction::Install {
device: d(),
app_path: std::path::PathBuf::new(),
},
),
(
"uninstall",
SimAction::Uninstall {
device: d(),
bundle_id: d(),
},
),
(
"openurl",
SimAction::Openurl {
device: d(),
url: d(),
},
),
(
"appearance",
SimAction::Appearance {
device: d(),
mode: Appearance::Light,
},
),
("keychain-reset", SimAction::KeychainReset { device: d() }),
(
"locale",
SimAction::Locale {
device: d(),
lang: d(),
reboot: false,
},
),
(
"exec",
SimAction::Exec {
device: d(),
verb: d(),
args: Vec::new(),
},
),
(
"reverse",
SimAction::Reverse {
device: d(),
port: 0,
to: None,
remove: false,
},
),
("wake", SimAction::Wake { device: d() }),
(
"stay-awake",
SimAction::StayAwake {
device: d(),
state: false,
},
),
(
"permission",
SimAction::Permission {
device: d(),
bundle_id: d(),
permission: d(),
action: d(),
},
),
(
"frontmost",
SimAction::Frontmost {
device: d(),
json: false,
},
),
(
"crashes",
SimAction::Crashes {
device: d(),
app: None,
json: false,
},
),
]
}
fn sim_verb(want: &str) -> SimAction {
every_sim_action()
.into_iter()
.find(|(n, _)| *n == want)
.map(|(_, a)| a)
.unwrap_or_else(|| panic!("no `smix sim {want}`"))
}
fn verb_kinds(action: &SimAction) -> Vec<smix_simctl::registry::DeviceKind> {
sim_verb_supports(action)
.expect("this verb acts on a device")
.to_vec()
}
#[test]
fn the_enumeration_of_sim_verbs_matches_the_command_tree() {
use clap::CommandFactory;
use std::collections::BTreeSet;
let cmd = Cli::command();
let sim = cmd
.get_subcommands()
.find(|c| c.get_name() == "sim")
.expect("smix sim exists");
let from_clap: BTreeSet<&str> =
sim.get_subcommands().map(clap::Command::get_name).collect();
let listed: BTreeSet<&str> = every_sim_action().iter().map(|(n, _)| *n).collect();
assert_eq!(
listed, from_clap,
"the hand-written verb list and the command tree disagree"
);
}
#[test]
fn a_verb_the_table_answers_for_supports_exactly_what_the_table_says() {
use smix_sdk::device_control::{Availability, availability};
use smix_simctl::registry::DeviceKind;
let mut reconciled = 0;
for (name, action) in every_sim_action() {
let Some(table_action) = table_action_of(&action) else {
continue;
};
reconciled += 1;
let claimed = sim_verb_supports(&action)
.unwrap_or_else(|| panic!("{name} names a table row but takes no device"));
let supported: Vec<_> = DeviceKind::ALL
.iter()
.copied()
.filter(|k| claimed.contains(k))
.collect();
let works: Vec<_> = DeviceKind::ALL
.iter()
.copied()
.filter(|k| availability(table_action, *k) == Some(Availability::Works))
.collect();
assert_eq!(
supported, works,
"`smix sim {name}` and the platform table disagree about {table_action}"
);
}
assert_eq!(
reconciled, 13,
"the number of verbs answered by the platform table changed"
);
}
#[test]
fn the_verbs_the_backend_carries_out_are_not_refused() {
use smix_simctl::registry::DeviceKind::{Emulator, PhysicalAndroid, PhysicalIos};
for verb in ["launch", "openurl"] {
let kinds = verb_kinds(&sim_verb(verb));
for kind in [Emulator, PhysicalAndroid, PhysicalIos] {
assert!(
kinds.contains(&kind),
"`smix sim {verb}` refuses {kind:?} and the backend does it: {kinds:?}"
);
}
}
let terminate = verb_kinds(&sim_verb("terminate"));
for kind in [Emulator, PhysicalAndroid] {
assert!(
terminate.contains(&kind),
"terminate refuses {kind:?} and `am force-stop` does it: {terminate:?}"
);
}
assert!(
!terminate.contains(&PhysicalIos),
"devicectl stops by pid and cannot find one from a bundle id: {terminate:?}"
);
assert!(
verb_kinds(&sim_verb("install")).contains(&PhysicalIos),
"install refuses a registered iPhone, and DevicectlClient::install is wired"
);
}
#[test]
fn a_verb_the_table_cannot_answer_for_refuses_without_claiming_simctl() {
for verb in ["erase", "appearance", "locale", "exec"] {
let action = sim_verb(verb);
assert!(
table_action_of(&action).is_none(),
"{verb} has a table row now — reconcile it instead"
);
let said = refusal_for(
&action,
"emulator-5554",
smix_simctl::registry::DeviceKind::Emulator,
);
assert!(
!said.contains("runs through simctl"),
"`smix sim {verb}` still claims simctl: {said}"
);
assert!(
said.contains("iOS Simulator"),
"`smix sim {verb}` does not say where it does work: {said}"
);
assert!(
!said.contains("Android lifecycle goes through adb"),
"a refusal carrying advice about another verb entirely: {said}"
);
}
}
#[test]
fn stay_awake_takes_on_or_off_and_says_so() {
assert_eq!(parse_on_off("on"), Ok(true));
assert_eq!(parse_on_off("OFF"), Ok(false));
let err = parse_on_off("maybe").expect_err("maybe is not a state");
assert!(err.contains("on"), "{err}");
assert!(err.contains("off"), "{err}");
assert!(err.contains("maybe"), "says what it could not read: {err}");
}
#[test]
fn a_permission_action_is_one_of_three() {
assert_eq!(parse_permission_action("grant"), Ok("grant".to_string()));
assert_eq!(
parse_permission_action(" Revoke "),
Ok("revoke".to_string())
);
let err = parse_permission_action("allow").expect_err("allow is not one of them");
assert!(
err.contains("grant") && err.contains("revoke") && err.contains("reset"),
"{err}"
);
}
#[test]
fn sim_list_asks_an_emulator_and_a_registered_phone_and_no_other_device() {
assert!(
may_ask_release("emulator-5554", false),
"an emulator is ours to read"
);
assert!(
may_ask_release("R5CT10ABCDE", true),
"a registered phone was offered to smix"
);
assert!(
!may_ask_release("R5CT10ABCDE", false),
"an attached phone nobody registered is listed from adb's own line and not addressed (§9#1)"
);
}
#[test]
fn adb_devices_output_parses_to_serial_state_and_model() {
let out = "List of devices attached\n\
emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1\n\
R5CT10ABCDE unauthorized usb:337641472X transport_id:2\n\
\n";
let got = parse_adb_devices(out);
assert_eq!(
got,
vec![
(
"emulator-5554".to_string(),
"device".to_string(),
"sdk_gphone64_arm64".to_string()
),
(
"R5CT10ABCDE".to_string(),
"unauthorized".to_string(),
String::new()
),
]
);
}
#[test]
fn a_literal_slot_is_not_asked_who_it_is() {
assert!(alias_needs_identity_check("sim-smix-android-01"));
assert!(alias_needs_identity_check("phone-s22"));
assert!(!alias_needs_identity_check("emulator-5556"));
assert!(!alias_needs_identity_check(
"5D087114-ECB3-443C-8DDB-40EEF9CFB90C"
));
}
#[test]
fn adb_knows_answers_from_adbs_own_list() {
assert!(!adb_knows("NOSUCHSERIAL0001"));
let Ok(out) = std::process::Command::new("adb").arg("devices").output() else {
return;
};
if !out.status.success() {
return;
}
let listed: Vec<String> = String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| l.split_once('\t'))
.filter(|(_, state)| state.trim() == "device")
.map(|(serial, _)| serial.to_string())
.collect();
for serial in listed {
assert!(
adb_knows(&serial),
"adb lists {serial} as a device but adb_knows says no"
);
}
}
#[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_refuses_what_it_cannot_do() {
assert!(reject_ios_only_up_flags(false, false).is_ok());
for (project, supervise, expected) in [
(true, false, "--runner-project"),
(false, true, "--supervise"),
] {
let err = reject_ios_only_up_flags(project, supervise)
.expect_err("an iOS-only flag must be refused, not dropped");
assert!(err.contains(expected), "{expected} unnamed in: {err}");
assert!(
err.contains("--bundle"),
"the refusal must say what DOES work, and --bundle does now: {err}"
);
}
}
#[test]
fn android_runner_up_takes_the_app_under_test() {
let cli = Cli::try_parse_from([
"smix",
"runner",
"up",
"emulator-5554",
"--platform",
"android",
"--bundle",
"dev.smix.fixture",
"--no-launch",
])
.expect("--bundle and --no-launch parse for Android runner up");
let Cmd::Runner {
action: RunnerAction::Up {
bundle, no_launch, ..
},
} = cli.cmd
else {
panic!("parsed as something other than runner up");
};
assert_eq!(bundle.as_deref(), Some("dev.smix.fixture"));
assert!(no_launch);
}
#[test]
fn every_dropped_flag_is_named_at_once() {
let err = reject_ios_only_up_flags(true, true).expect_err("both are iOS-only");
for flag in ["--runner-project", "--supervise"] {
assert!(err.contains(flag), "{flag} unnamed in: {err}");
}
assert!(err.contains("they are"), "plural form expected in: {err}");
}
#[test]
fn the_command_tree_is_one_clap_can_build() {
use clap::CommandFactory;
Cli::command().debug_assert();
}
#[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"
);
}
}
#[cfg(test)]
mod resolution_shape_tests {
use super::*;
use smix_simctl::registry::{DeviceKind, RegisteredSim, Resolved, Source};
fn emulator() -> RegisteredSim {
RegisteredSim {
device_name: "sim-smix-android-01".into(),
kind: DeviceKind::Emulator,
destructive_opt_in: false,
udid: "emulator-5554".into(),
runtime: String::new(),
device_type: String::new(),
avd_name: Some("sim-smix-android-01".into()),
locale: None,
runner_port: None,
}
}
#[test]
fn resolve_json_names_the_book_and_the_device() {
let path = std::path::PathBuf::from("machine-dir/devices");
let r = Resolved {
id: "emulator-5556".into(),
alias: "phone".into(),
source: Source::Machine(path.clone()),
};
let sim = emulator();
let v = resolution_json("phone", "emulator-5556", Some(&r), Some(&sim), &r.source);
assert_eq!(v["ref"], "phone");
assert_eq!(v["id"], "emulator-5556");
assert_eq!(v["alias"], "phone");
assert_eq!(v["source"]["kind"], "machine");
assert_eq!(v["source"]["path"], "machine-dir/devices");
assert_eq!(v["deviceKind"], "emulator");
assert_eq!(v["avd"], "sim-smix-android-01");
}
#[test]
fn the_stderr_line_says_which_book_answered() {
let sim = emulator();
let line = resolution_line(
"phone",
Some(&sim),
&Source::Checkout("a-checkout/.smix/sims.json".into()),
);
assert!(
line.contains("a-checkout/.smix/sims.json") && line.contains("legacy book"),
"{line}"
);
assert!(line.contains("AVD `sim-smix-android-01`"), "{line}");
let literal = resolution_line("emulator-5554", None, &Source::Literal);
assert!(literal.contains("no registry was consulted"), "{literal}");
}
}