use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunnerState {
pub pid: u32,
pub udid: String,
pub port: u16,
pub log: PathBuf,
#[serde(default)]
pub bundle: Option<String>,
#[serde(default)]
pub supervisor_pid: Option<u32>,
}
pub fn runner_env(bundle: Option<&str>, record_enabled: bool, port: u16) -> Vec<(String, String)> {
let mut env = Vec::new();
if let Some(b) = bundle {
env.push((
"TEST_RUNNER_SMIX_RUNNER_TARGET_BUNDLE".to_string(),
b.to_string(),
));
}
if record_enabled {
env.push((
"TEST_RUNNER_SMIX_RECORD_ENABLED".to_string(),
"1".to_string(),
));
}
env.push(("TEST_RUNNER_SMIX_RUNNER_PORT".to_string(), port.to_string()));
env.push((
"TEST_RUNNER_SMIX_RUNNER_VERSION".to_string(),
env!("CARGO_PKG_VERSION").to_string(),
));
env
}
pub fn workspace_root(start: &Path) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(d) = dir {
if d.join(".smix").is_dir() {
return Some(d.to_path_buf());
}
dir = d.parent();
}
None
}
pub fn xcodebuild_argv(project: &Path, udid: &str) -> Vec<String> {
let derived = format!(".smix/runner/derived-data-{udid}");
vec![
"test".into(),
"-project".into(),
project.display().to_string(),
"-scheme".into(),
"SmixRunner".into(),
"-destination".into(),
format!("platform=iOS Simulator,id={udid}"),
"-derivedDataPath".into(),
derived,
]
}
pub fn health_ok(port: u16) -> bool {
read_health_bytes(port, 64)
.map(|(status_ok, _body)| status_ok)
.unwrap_or(false)
}
pub fn health_runner_version(port: u16) -> Option<String> {
let (ok, body) = read_health_bytes(port, 4096).ok()?;
if !ok {
return None;
}
let body_start = body
.windows(4)
.position(|w| w == b"\r\n\r\n")
.map(|i| i + 4)?;
let json_bytes = &body[body_start..];
let value: serde_json::Value = serde_json::from_slice(json_bytes).ok()?;
let v = value.get("runnerVersion")?.as_str()?;
if v.is_empty() { None } else { Some(v.to_string()) }
}
fn read_health_bytes(port: u16, cap: usize) -> Result<(bool, Vec<u8>), ()> {
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;
let mut s = TcpStream::connect_timeout(&([127, 0, 0, 1], port).into(), Duration::from_secs(1))
.map_err(|_| ())?;
s.set_read_timeout(Some(Duration::from_secs(2)))
.map_err(|_| ())?;
s.write_all(b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.map_err(|_| ())?;
let mut buf = vec![0u8; cap];
let mut total = 0usize;
while total < cap {
match s.read(&mut buf[total..]) {
Ok(0) => break,
Ok(n) => total += n,
Err(_) => break,
}
}
buf.truncate(total);
let status_ok = std::str::from_utf8(&buf)
.ok()
.map(|s| s.contains(" 200"))
.unwrap_or(false);
Ok((status_ok, buf))
}
fn state_path(root: &Path) -> PathBuf {
root.join(".smix/runner/state.json")
}
fn read_state(root: &Path) -> Option<RunnerState> {
let text = std::fs::read_to_string(state_path(root)).ok()?;
serde_json::from_str(&text).ok()
}
fn pid_command(pid: u32) -> Option<String> {
let out = std::process::Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "command="])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let cmd = String::from_utf8_lossy(&out.stdout).trim().to_string();
if cmd.is_empty() { None } else { Some(cmd) }
}
fn signal(pid: u32, sig: &str) {
let _ = std::process::Command::new("kill")
.args([sig, &pid.to_string()])
.status();
}
fn tail_log(log: &Path, lines: usize) -> String {
let Ok(text) = std::fs::read_to_string(log) else {
return String::new();
};
let all: Vec<&str> = text.lines().collect();
let start = all.len().saturating_sub(lines);
all[start..].join("\n")
}
pub fn resolve_runner_project(
root: &Path,
override_path: Option<&Path>,
) -> Result<PathBuf, String> {
let explicit_flag = override_path.map(Path::to_path_buf);
let explicit_env = std::env::var_os("SMIX_RUNNER_PROJECT").map(PathBuf::from);
if let Some(p) = explicit_flag.or(explicit_env) {
if p.exists() {
return Ok(p);
}
return Err(format!(
"runner project missing: {}\n\
explicit override (--runner-project or $SMIX_RUNNER_PROJECT) \
does not point to an existing SmixRunner.xcodeproj — \
fix the path, or unset the override to fall back to \
install-shipped / repo-local defaults.",
p.display()
));
}
if let Some(installed_dir) = installed_runner_dir() {
match ensure_installed_runner_synced(&installed_dir) {
Ok(SyncOutcome::AlreadyCurrent) => {}
Ok(SyncOutcome::Extracted { previous_version, .. }) => {
let from = previous_version.as_deref().unwrap_or("<none>");
eprintln!(
"smix-runner: synced runner sources → {} (was {}) at {}",
smix_runner_sources::SOURCES_VERSION,
from,
installed_dir.display()
);
}
Err(err) => {
eprintln!(
"smix-runner: auto-sync failed at {}: {err}",
installed_dir.display()
);
}
}
}
let candidates: Vec<PathBuf> = std::iter::empty()
.chain(installed_runner_project())
.chain(std::iter::once(
root.join("swift-bridge/SmixRunner.xcodeproj"),
))
.collect();
for cand in &candidates {
if cand.exists() {
return Ok(cand.clone());
}
}
let last = candidates
.last()
.cloned()
.unwrap_or_else(|| PathBuf::from("<no candidates — this should not happen>"));
let attempted = candidates
.iter()
.map(|p| format!(" - {}", p.display()))
.collect::<Vec<_>>()
.join("\n");
Err(format!(
"runner project missing: {}\n\
tried:\n{attempted}\n\
fix: (a) `smix runner install` to populate ~/.local/share/smix/runner/, \
or (b) pass `--runner-project <path>` on `smix runner up`, \
or (c) set $SMIX_RUNNER_PROJECT",
last.display()
))
}
fn installed_runner_project() -> Option<PathBuf> {
installed_runner_dir().map(|d| d.join("SmixRunner.xcodeproj"))
}
pub(crate) fn installed_runner_dir() -> Option<PathBuf> {
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?;
Some(base.join("smix/runner"))
}
#[derive(Debug)]
pub(crate) enum SyncOutcome {
AlreadyCurrent,
Extracted {
previous_version: Option<String>,
#[allow(dead_code)]
backup: Option<PathBuf>,
},
}
pub(crate) fn ensure_installed_runner_synced(
dir: &Path,
) -> Result<SyncOutcome, smix_runner_sources::ExtractError> {
let previous = smix_runner_sources::read_installed_version(dir)?;
if previous.as_deref() == Some(smix_runner_sources::SOURCES_VERSION) {
return Ok(SyncOutcome::AlreadyCurrent);
}
let report = smix_runner_sources::extract_to(dir, true)?;
Ok(SyncOutcome::Extracted {
previous_version: previous,
backup: report.backup,
})
}
pub fn up(
root: &Path,
udid: &str,
port: u16,
bundle: Option<&str>,
record_enabled: bool,
runner_project: Option<&Path>,
) -> Result<(), String> {
up_with_options(root, udid, port, bundle, record_enabled, runner_project, false)
}
pub fn up_with_options(
root: &Path,
udid: &str,
port: u16,
bundle: Option<&str>,
record_enabled: bool,
runner_project: Option<&Path>,
supervise: bool,
) -> Result<(), String> {
match bundle {
Some(b) => {
println!("[runner] target bundle-id: {b}");
}
None => {
let bypass = std::env::var("SMIX_RUNNER_UP_ALLOW_DEFAULT_BUNDLE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
if !bypass {
return Err(
"no --bundle passed; the runner would latch to the \
built-in default (com.apple.Preferences) and every \
subsequent /tree call would report Preferences as the \
app.\n\n\
fix: pass --bundle <your-app-bundle-id>, e.g.\n\
smix runner up <device> --bundle com.example.app\n\n\
to keep the legacy default (v1.0.3 behavior), export\n\
SMIX_RUNNER_UP_ALLOW_DEFAULT_BUNDLE=1\n\
— but expect empty a11y trees until you re-attach a \
real target."
.to_string(),
);
}
eprintln!(
"[runner] warning: no --bundle passed and \
SMIX_RUNNER_UP_ALLOW_DEFAULT_BUNDLE=1 set; latching to \
default com.apple.Preferences"
);
}
}
if health_ok(port) {
match read_state(root) {
Some(st) if st.udid == udid && st.bundle.as_deref() == bundle => {
println!("runner already up: udid={udid} port={port} pid={}", st.pid);
return Ok(());
}
Some(st) => {
return Err(format!(
"port {port} already serves a runner recorded for udid={} \
bundle={:?} — run `smix runner down` first",
st.udid, st.bundle
));
}
None => {
return Err(format!(
"port {port} already serves /health but no \
.smix/runner/state.json records it — not killing blindly; \
investigate (pgrep -fl xcodebuild), then `smix runner down`"
));
}
}
}
let project = resolve_runner_project(root, runner_project)?;
let runner_dir = root.join(".smix/runner");
std::fs::create_dir_all(&runner_dir).map_err(|e| format!("mkdir .smix/runner: {e}"))?;
let log = runner_dir.join(format!("runner-{udid}.log"));
let log_file =
std::fs::File::create(&log).map_err(|e| format!("create {}: {e}", log.display()))?;
let log_err = log_file
.try_clone()
.map_err(|e| format!("clone log handle: {e}"))?;
let mut cmd = std::process::Command::new("xcodebuild");
cmd.args(xcodebuild_argv(&project, udid))
.envs(runner_env(bundle, record_enabled, port))
.stdin(std::process::Stdio::null())
.stdout(log_file)
.stderr(log_err);
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
let mut child = cmd.spawn().map_err(|e| format!("spawn xcodebuild: {e}"))?;
let pid = child.id();
let st = RunnerState {
pid,
udid: udid.to_string(),
port,
log: log.clone(),
bundle: bundle.map(str::to_string),
supervisor_pid: None,
};
std::fs::write(
state_path(root),
serde_json::to_string_pretty(&st).expect("state serializes"),
)
.map_err(|e| format!("write state.json: {e}"))?;
let timeout_secs: u64 = std::env::var("SMIX_RUNNER_UP_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(300);
let derived_dir = root.join(format!(".smix/runner/derived-data-{udid}"));
let is_cold = !derived_dir.is_dir()
|| std::fs::read_dir(&derived_dir)
.map(|mut d| d.next().is_none())
.unwrap_or(true);
if is_cold {
println!(
"runner starting: udid={udid} port={port} pid={pid} \
— COLD REBUILD expected up to 10 minutes (first run after \
upgrade compiles the XCUITest bundle for smix {}). \
Log: {}. Timeout {timeout_secs}s.",
env!("CARGO_PKG_VERSION"),
log.display()
);
} else {
println!(
"runner starting: udid={udid} port={port} pid={pid} \
(warm rebuild ~3 s expected; log {}, timeout {timeout_secs}s)",
log.display()
);
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
let started_at = std::time::Instant::now();
let mut last_heartbeat = started_at;
while std::time::Instant::now() < deadline {
if is_cold && last_heartbeat.elapsed() >= std::time::Duration::from_secs(30) {
let elapsed_s = started_at.elapsed().as_secs();
println!("runner up: xcodebuild still working ({elapsed_s}s elapsed)");
use std::io::Write;
let _ = std::io::stdout().flush();
last_heartbeat = std::time::Instant::now();
}
if let Ok(Some(status)) = child.try_wait() {
let _ = std::fs::remove_file(state_path(root));
return Err(format!(
"xcodebuild exited early ({status}) — log tail:\n{}",
tail_log(&log, 25)
));
}
if health_ok(port) {
let cli_version = env!("CARGO_PKG_VERSION");
match health_runner_version(port) {
Some(v) if v == cli_version => {
println!(
"runner up: http://localhost:{port}/health = 200 (runner v{v})"
);
}
Some(v) => {
let _ = std::fs::remove_file(state_path(root));
signal(pid, "-TERM");
return Err(format!(
"runner version mismatch: CLI is v{cli_version} but the \
running SmixRunner reports v{v}. This means the on-disk \
runner project used by xcodebuild is out of sync with the \
installed CLI — the v1.0.4-v1.0.9 distribution gap the \
v1.0.10 auto-sync closes. Fix: `smix runner install --force` \
to re-extract the embedded runner sources, then retry \
`smix runner up`. If you're using an explicit \
--runner-project / $SMIX_RUNNER_PROJECT, either update \
that path to a v{cli_version} runner or drop the override."
));
}
None => {
eprintln!(
"runner up: warning — runner /health returned legacy body \
(no `runnerVersion` field). This runner predates v1.0.10 \
and cannot self-report its version. If you see missing \
routes (e.g. `/session/open` 404), run \
`smix runner install --force` to sync sources to v{cli_version}."
);
println!("runner up: http://localhost:{port}/health = 200 (legacy body)");
}
}
if supervise {
match spawn_supervisor(root, runner_project) {
Ok(sup_pid) => {
if let Some(mut current) = read_state(root) {
current.supervisor_pid = Some(sup_pid);
let _ = std::fs::write(
state_path(root),
serde_json::to_string_pretty(¤t)
.expect("state serializes"),
);
println!(
"runner supervise: spawned pid={sup_pid} \
(log: .smix/runner/supervise-{udid}.log)"
);
}
}
Err(e) => {
eprintln!(
"runner supervise: spawn failed: {e} — runner \
is up but no sidecar attached"
);
}
}
}
return Ok(());
}
std::thread::sleep(std::time::Duration::from_secs(2));
}
signal(pid, "-INT");
let _ = std::fs::remove_file(state_path(root));
Err(format!(
"runner did not become healthy within {timeout_secs}s — sent SIGINT; log tail:\n{}",
tail_log(&log, 25)
))
}
pub fn down(root: &Path, port: u16) -> Result<(), String> {
let mut acted = false;
if let Some(st) = read_state(root) {
if let Some(sup_pid) = st.supervisor_pid
&& sup_pid != std::process::id()
&& let Some(cmd) = pid_command(sup_pid)
&& (cmd.contains("smix") || cmd.contains("supervise"))
{
println!("stopping supervisor: pid={sup_pid}");
signal(sup_pid, "-TERM");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while pid_command(sup_pid).is_some() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(250));
}
if pid_command(sup_pid).is_some() {
eprintln!("supervisor pid {sup_pid} ignored SIGTERM for 5s — SIGKILL");
signal(sup_pid, "-9");
}
}
match pid_command(st.pid) {
Some(cmd) if cmd.contains("xcodebuild") => {
println!("stopping runner: pid={} udid={}", st.pid, st.udid);
signal(st.pid, "-INT");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while pid_command(st.pid).is_some() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(500));
}
if pid_command(st.pid).is_some() {
eprintln!(
"warning: pid {} ignored SIGINT for 30s — escalating to \
SIGKILL (expect a macOS crash-report dialog from the \
runner app)",
st.pid
);
signal(st.pid, "-9");
}
acted = true;
}
Some(other) => {
eprintln!(
"stale handle: pid {} is now {:?} (not xcodebuild) — \
dropping state without killing",
st.pid, other
);
}
None => {
println!("runner pid {} already gone — dropping stale handle", st.pid);
}
}
let _ = std::fs::remove_file(state_path(root));
}
let swept = std::process::Command::new("pkill")
.args(["-INT", "-f", "xcodebuild.*SmixRunner"])
.status()
.map(|s| s.success())
.unwrap_or(false);
if swept {
println!("swept unrecorded xcodebuild SmixRunner session(s)");
acted = true;
}
if acted {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while health_ok(port) && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(500));
}
}
if health_ok(port) {
return Err(format!(
"port {port} still answers /health after teardown — inspect \
`pgrep -fl xcodebuild`"
));
}
println!("runner down: port {port} closed");
Ok(())
}
pub fn cycle(
root: &Path,
port: u16,
runner_project: Option<&Path>,
) -> Result<(), String> {
let st = read_state(root).ok_or_else(|| {
"no runner state.json — cycle only cycles a known runner; \
run `smix runner up <device> [--bundle <id>]` for a cold start"
.to_string()
})?;
let udid = st.udid.clone();
let bundle = st.bundle.clone();
let cycle_port = st.port;
let had_supervisor = st.supervisor_pid.is_some();
if cycle_port != port {
eprintln!(
"note: state.json port {cycle_port} differs from --runner-port {port}; \
cycling on state.json's {cycle_port}"
);
}
println!(
"cycling runner: udid={udid} port={cycle_port} bundle={bundle:?}"
);
down(root, cycle_port)?;
up_with_options(
root,
&udid,
cycle_port,
bundle.as_deref(),
false,
runner_project,
had_supervisor,
)
}
fn collect_log_context(
log_path: &Path,
match_line: &str,
context_size: usize,
) -> Vec<String> {
let Ok(text) = std::fs::read_to_string(log_path) else {
return Vec::new();
};
let trimmed = match_line.trim();
let lines: Vec<&str> = text.lines().collect();
let Some(idx) = lines.iter().position(|l| l.contains(trimmed)) else {
return Vec::new();
};
let start = idx.saturating_sub(context_size);
let end = (idx + context_size + 1).min(lines.len());
lines[start..end].iter().map(|l| l.to_string()).collect()
}
fn spawn_supervisor(root: &Path, runner_project: Option<&Path>) -> Result<u32, String> {
let st = read_state(root)
.ok_or_else(|| "internal: no state.json to attach supervisor to".to_string())?;
let udid = st.udid.clone();
let runner_dir = root.join(".smix/runner");
std::fs::create_dir_all(&runner_dir).map_err(|e| format!("mkdir .smix/runner: {e}"))?;
let log = runner_dir.join(format!("supervise-{udid}.log"));
let log_file = std::fs::File::create(&log)
.map_err(|e| format!("create {}: {e}", log.display()))?;
let log_err = log_file
.try_clone()
.map_err(|e| format!("clone supervise log handle: {e}"))?;
let self_exe = std::env::current_exe()
.map_err(|e| format!("current_exe: {e}"))?;
let mut cmd = std::process::Command::new(&self_exe);
cmd.arg("runner").arg("supervise");
if let Some(p) = runner_project {
cmd.arg("--runner-project").arg(p);
}
cmd.stdin(std::process::Stdio::null())
.stdout(log_file)
.stderr(log_err);
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
let child = cmd.spawn().map_err(|e| format!("spawn supervise: {e}"))?;
Ok(child.id())
}
pub fn supervise(
root: &Path,
runner_project: Option<&Path>,
) -> Result<(), String> {
let st = read_state(root).ok_or_else(|| {
"no runner state.json — supervise attaches to a known runner; \
run `smix runner up <device> --bundle <id>` first"
.to_string()
})?;
let log_path = st.log.clone();
let port = st.port;
println!(
"smix runner supervise: attached\n udid={} port={} log={}",
st.udid,
st.port,
log_path.display()
);
let mut position: u64 = std::fs::metadata(&log_path)
.map(|m| m.len())
.unwrap_or(0);
let mut last_cycle_at: Option<std::time::Instant> = None;
let mut cycle_times: Vec<std::time::Instant> = Vec::new();
let interrupt_patterns: &[&str] = &[
"** TEST INTERRUPTED **",
"SchemeActionResultOperation started unexpectedly",
];
let cycle_cooldown = std::time::Duration::from_secs(60);
let storm_window = std::time::Duration::from_secs(600);
let storm_threshold = 5;
let mut carry = String::new();
loop {
std::thread::sleep(std::time::Duration::from_millis(500));
let meta = match std::fs::metadata(&log_path) {
Ok(m) => m,
Err(_) => continue,
};
let current_len = meta.len();
if current_len < position {
position = 0;
carry.clear();
}
if current_len == position {
continue;
}
use std::io::{Read, Seek, SeekFrom};
let mut f = match std::fs::File::open(&log_path) {
Ok(f) => f,
Err(_) => continue,
};
if f.seek(SeekFrom::Start(position)).is_err() {
continue;
}
let mut buf = String::new();
if f.read_to_string(&mut buf).is_err() {
position = current_len;
continue;
}
position = current_len;
carry.push_str(&buf);
let mut lines: Vec<&str> = carry.lines().collect();
let keep_last = !carry.ends_with('\n');
let tail = if keep_last { lines.pop().unwrap_or("").to_string() } else { String::new() };
for line in &lines {
let matched = interrupt_patterns.iter().any(|p| line.contains(p));
if !matched {
continue;
}
let now = std::time::Instant::now();
if let Some(prev) = last_cycle_at {
if now.duration_since(prev) < cycle_cooldown {
eprintln!(
"supervise: interrupt hit within {:?} of last cycle — \
skipping (cooldown)",
cycle_cooldown
);
continue;
}
}
cycle_times.retain(|t| now.duration_since(*t) < storm_window);
if cycle_times.len() >= storm_threshold {
return Err(format!(
"supervise: {} cycles inside {:?} — bailing so a monitoring \
layer can escalate",
cycle_times.len(),
storm_window
));
}
let context: Vec<String> = collect_log_context(&log_path, line, 5);
use std::io::Write;
let mut out = std::io::stdout().lock();
let context_json = context
.iter()
.map(|l| format!("{:?}", l))
.collect::<Vec<_>>()
.join(",");
let _ = writeln!(
out,
r#"{{"event":"RunnerCycled","reasonMatched":{:?},"context":[{}],"atMs":{}}}"#,
line.trim(),
context_json,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
);
let _ = out.flush();
match cycle(root, port, runner_project) {
Ok(()) => {
cycle_times.push(now);
last_cycle_at = Some(now);
position = 0;
carry.clear();
break;
}
Err(e) => {
return Err(format!("supervise: cycle failed: {e}"));
}
}
}
carry = tail;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
const UDID: &str = "5D087114-ECB3-443C-8DDB-40EEF9CFB90C";
#[test]
fn xcodebuild_argv_targets_explicit_udid() {
let argv = xcodebuild_argv(Path::new("/repo/swift-bridge/SmixRunner.xcodeproj"), UDID);
assert_eq!(argv[0], "test");
assert!(argv.contains(&"SmixRunner".to_string()));
assert!(
argv.iter()
.any(|a| a == &format!("platform=iOS Simulator,id={UDID}"))
);
assert!(!argv.iter().any(|a| a.contains("name=")));
let derived_pos = argv
.iter()
.position(|a| a == "-derivedDataPath")
.expect("argv missing -derivedDataPath");
assert_eq!(
argv[derived_pos + 1],
format!(".smix/runner/derived-data-{UDID}")
);
}
#[test]
fn runner_state_round_trips() {
let st = RunnerState {
pid: 4242,
udid: UDID.into(),
port: 22087,
log: PathBuf::from("/tmp/runner.log"),
bundle: Some("com.example.app".into()),
supervisor_pid: None,
};
let json = serde_json::to_string(&st).unwrap();
let back: RunnerState = serde_json::from_str(&json).unwrap();
assert_eq!(back, st);
}
#[test]
fn runner_env_uses_test_runner_prefix() {
let env = runner_env(Some("com.example.app"), false, 22087);
let map: std::collections::HashMap<String, String> = env.iter().cloned().collect();
assert_eq!(
map.get("TEST_RUNNER_SMIX_RUNNER_TARGET_BUNDLE")
.map(String::as_str),
Some("com.example.app")
);
assert_eq!(
map.get("TEST_RUNNER_SMIX_RUNNER_PORT").map(String::as_str),
Some("22087")
);
let env_no_bundle = runner_env(None, false, 22090);
assert_eq!(env_no_bundle.len(), 2);
assert!(
env_no_bundle
.iter()
.any(|(k, v)| k == "TEST_RUNNER_SMIX_RUNNER_PORT" && v == "22090")
);
}
#[test]
fn runner_env_forwards_cli_version_for_health_echo() {
let env = runner_env(None, false, 22087);
let map: std::collections::HashMap<String, String> = env.into_iter().collect();
assert_eq!(
map.get("TEST_RUNNER_SMIX_RUNNER_VERSION").map(String::as_str),
Some(env!("CARGO_PKG_VERSION"))
);
}
#[test]
fn runner_env_with_record_adds_enabled_var() {
let env = runner_env(Some("com.example.app"), true, 22087);
let map: std::collections::HashMap<String, String> = env.into_iter().collect();
assert_eq!(
map.get("TEST_RUNNER_SMIX_RUNNER_TARGET_BUNDLE")
.map(String::as_str),
Some("com.example.app")
);
assert_eq!(
map.get("TEST_RUNNER_SMIX_RECORD_ENABLED")
.map(String::as_str),
Some("1")
);
}
#[test]
fn ensure_installed_runner_synced_extracts_on_missing_version_file() {
let dir = tempfile::tempdir().expect("tempdir");
let outcome = ensure_installed_runner_synced(dir.path()).expect("sync");
matches!(outcome, SyncOutcome::Extracted { previous_version: None, .. })
.then_some(())
.expect("expected first-run extract with no previous version");
assert!(
dir.path().join(".smix-runner-version").exists(),
"version file must be written"
);
assert!(
dir.path().join("SmixRunner.xcodeproj/project.pbxproj").exists(),
"xcodeproj must land on disk after sync"
);
}
#[test]
fn ensure_installed_runner_synced_reextracts_on_stale_version() {
let dir = tempfile::tempdir().expect("tempdir");
fs::write(dir.path().join(".smix-runner-version"), "0.0.0-stale\n")
.expect("seed stale version");
fs::write(dir.path().join("stale-marker.txt"), b"old contents")
.expect("seed stale marker");
let outcome = ensure_installed_runner_synced(dir.path()).expect("sync");
match outcome {
SyncOutcome::Extracted {
previous_version,
backup,
} => {
assert_eq!(previous_version.as_deref(), Some("0.0.0-stale"));
let backup = backup.expect("backup path present");
assert!(backup.exists(), "backup dir must exist");
assert!(
backup.join("stale-marker.txt").exists(),
"backup must preserve prior tree contents"
);
}
SyncOutcome::AlreadyCurrent => panic!("stale must not be treated as current"),
}
assert!(!dir.path().join("stale-marker.txt").exists());
assert_eq!(
std::fs::read_to_string(dir.path().join(".smix-runner-version"))
.unwrap()
.trim(),
smix_runner_sources::SOURCES_VERSION
);
}
#[test]
fn ensure_installed_runner_synced_is_noop_when_current() {
let dir = tempfile::tempdir().expect("tempdir");
ensure_installed_runner_synced(dir.path()).expect("first sync");
let outcome = ensure_installed_runner_synced(dir.path()).expect("second sync");
matches!(outcome, SyncOutcome::AlreadyCurrent)
.then_some(())
.expect("second call must be AlreadyCurrent, not Extracted");
}
#[test]
fn workspace_root_walks_up_to_smix_dir() {
let root = std::env::temp_dir().join(format!("smix-runner-ws-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".smix")).unwrap();
let nested = root.join("crates/smix-cli");
fs::create_dir_all(&nested).unwrap();
assert_eq!(workspace_root(&nested).unwrap(), root);
let outside =
std::env::temp_dir().join(format!("smix-runner-no-ws-{}", std::process::id()));
let _ = fs::remove_dir_all(&outside);
fs::create_dir_all(&outside).unwrap();
assert!(workspace_root(&outside).is_none());
}
}