whisker_dev_server/installer.rs
1//! Tier 2 install + relaunch.
2//!
3//! After a successful cold-rebuild, the freshly-built artifact has to
4//! land on the target and start (re-bootstrapping the dev-runtime so
5//! it dials the dev-server back). For Android we shell out to `adb`;
6//! for iOS Simulator to `xcrun simctl`.
7//!
8//! Application identity (bundle id, applicationId, launcher activity,
9//! scheme, …) is **not** baked in here. The cli passes those as
10//! `Config::android` / `Config::ios` after reading the user's
11//! `whisker.rs::configure(&mut Config)`, so this module has zero
12//! knowledge of which example or external user crate is in play.
13
14use anyhow::{Context, Result};
15use std::path::PathBuf;
16use tokio::process::Command;
17
18use crate::{AndroidParams, IosParams, Target};
19use whisker_build::CaptureShims;
20
21pub struct Installer {
22 target: Target,
23 android: Option<AndroidParams>,
24 ios: Option<IosParams>,
25 workspace_root: PathBuf,
26 package: String,
27 /// Tier 1 capture shims for hot-reload. When `Some`, the
28 /// xcodebuild Command in [`ios_install_and_launch`] gets the
29 /// `RUSTC_WORKSPACE_WRAPPER` + `CARGO_TARGET_*_LINKER` +
30 /// `CARGO_TARGET_*_RUSTFLAGS` env vars set so the Step-7
31 /// Build Phase's cargo invocation runs as a fat capture build.
32 /// Pre-Step-7 the dev-server primed capture via a separate
33 /// `build_xcframework_with` call in `builder.rs`; that call now
34 /// produces an artifact xcodebuild's Build Phase rebuilds anyway,
35 /// so the capture wiring moves here.
36 capture: Option<CaptureShims>,
37 /// Cargo features forwarded to the iOS Build Phase via the
38 /// `WHISKER_FEATURES` env var (the pbxproj's shell script expands
39 /// it into `--features <feat>` args). `whisker run` populates this
40 /// with `["whisker/hot-reload"]` so the dev-runtime WebSocket
41 /// client gets compiled into the user dylib — without it the app
42 /// never sends its `aslr_reference` and every change falls back to
43 /// a Tier 2 cold rebuild.
44 features: Vec<String>,
45 /// Host port the dev-server's WebSocket is bound to (= the port of
46 /// `Config::bind_addr`). The device must reach this exact port:
47 /// Android bridges it with `adb reverse tcp:9876 tcp:<dev_port>`
48 /// (the device keeps dialing its default 9876), and the iOS
49 /// Simulator dials `127.0.0.1:<dev_port>` directly via
50 /// `SIMCTL_CHILD_WHISKER_DEV_ADDR`. Without this the `--bind` flag
51 /// silently breaks hot reload (server on a custom port, device
52 /// still on 9876).
53 dev_port: u16,
54 /// Shared dev-session token to deliver to the device so its
55 /// `hello` is accepted by the server's auth gate. iOS gets it via
56 /// `SIMCTL_CHILD_WHISKER_DEV_TOKEN`; Android via
57 /// `adb shell setprop debug.whisker_dev_token`. `None` = token-less.
58 dev_token: Option<String>,
59}
60
61impl Installer {
62 #[allow(clippy::too_many_arguments)]
63 pub fn new(
64 target: Target,
65 android: Option<AndroidParams>,
66 ios: Option<IosParams>,
67 workspace_root: PathBuf,
68 package: String,
69 capture: Option<CaptureShims>,
70 features: Vec<String>,
71 dev_port: u16,
72 dev_token: Option<String>,
73 ) -> Self {
74 Self {
75 target,
76 android,
77 ios,
78 workspace_root,
79 package,
80 capture,
81 features,
82 dev_port,
83 dev_token,
84 }
85 }
86
87 pub async fn install_and_launch(&self) -> Result<()> {
88 match self.target {
89 Target::Android => {
90 let p = self.android.as_ref().context(
91 "target=Android but no AndroidParams — cli must populate Config.android",
92 )?;
93 android_install_and_launch(p, self.dev_port, self.dev_token.as_deref()).await
94 }
95 Target::IosSimulator => {
96 let p = self.ios.as_ref().context(
97 "target=IosSimulator but no IosParams — cli must populate Config.ios",
98 )?;
99 ios_install_and_launch(
100 p,
101 &self.workspace_root,
102 &self.package,
103 self.capture.as_ref(),
104 &self.features,
105 self.dev_port,
106 self.dev_token.as_deref(),
107 )
108 .await
109 }
110 }
111 }
112}
113
114/// Run a `tokio::process::Command` to completion, capture its stderr,
115/// and filter known-benign lines (`already booted`, `found nothing to
116/// terminate`, xcodebuild's IDE noise). The actual exit status is
117/// returned verbatim; the caller decides what counts as failure. Used
118/// for `xcrun simctl ...` invocations where the stderr signal is
119/// ~70 % noise.
120async fn run_filtered(mut cmd: Command, kind: SimctlNoise) -> Result<std::process::ExitStatus> {
121 use tokio::io::AsyncReadExt;
122 cmd.stdout(std::process::Stdio::piped())
123 .stderr(std::process::Stdio::piped());
124 let mut child = cmd.spawn().context("spawn child")?;
125 // Track the PID so `whisker run`'s hard-exit quit path can SIGTERM
126 // an in-flight xcodebuild / simctl instead of orphaning it. The
127 // guard unregisters when this fn returns.
128 let _child_guard = child.id().map(whisker_build::child_guard::track);
129 let mut stdout = child.stdout.take();
130 let mut stderr = child.stderr.take();
131
132 let (out_buf, err_buf) = tokio::join!(
133 async {
134 let mut s = Vec::new();
135 if let Some(mut h) = stdout.take() {
136 let _ = h.read_to_end(&mut s).await;
137 }
138 s
139 },
140 async {
141 let mut s = Vec::new();
142 if let Some(mut h) = stderr.take() {
143 let _ = h.read_to_end(&mut s).await;
144 }
145 s
146 }
147 );
148 let status = child.wait().await.context("wait for child")?;
149
150 let stderr_str = String::from_utf8_lossy(&err_buf);
151 for line in stderr_str.lines() {
152 let trimmed = line.trim();
153 if trimmed.is_empty() {
154 continue;
155 }
156 if kind.is_benign(trimmed) {
157 continue;
158 }
159 // Anything that survived the filter is real output — surface
160 // it as a warning so the user notices but the curated layout
161 // isn't drowned.
162 whisker_build::ui::warn(trimmed);
163 }
164 // Stdout from these tools is usually empty or low-noise (e.g.
165 // `simctl launch` prints `<bundle_id>: <pid>`); echo it through
166 // info() at debug-grade.
167 let stdout_str = String::from_utf8_lossy(&out_buf);
168 for line in stdout_str.lines() {
169 let trimmed = line.trim();
170 if !trimmed.is_empty() && !kind.is_benign_stdout(trimmed) {
171 whisker_build::ui::info(trimmed);
172 }
173 }
174 Ok(status)
175}
176
177/// `xcodebuild`'s `-quiet` flag silences progress chatter but the
178/// underlying compiler still emits diagnostics — which under Xcode
179/// with iOS 26 SDK + Lynx's pre-iOS-26 framework headers means a
180/// hundreds-of-lines deprecation cascade (`'mainScreen' is
181/// deprecated`, `'screens' is deprecated`, …) on every build. None
182/// of it is actionable by Whisker users (the headers ship from
183/// upstream Lynx), so we filter it as benign here.
184///
185/// Approach: drop anything that looks like a clang / xcodebuild
186/// warning chain — the `warning:` line, the `note:` follow-ups,
187/// the source-line listings (` 217 |`, ` | ^`), the
188/// "in file included from" / "N warnings generated." summaries,
189/// and the `[MT] IDERunDestination` IDE chatter.
190///
191/// Real errors are preserved: lines containing `error:` /
192/// `fatal error:` / `** BUILD FAILED **` always fall through.
193fn is_benign_xcodebuild_line(raw: &str) -> bool {
194 // Under `--verbose` / `WHISKER_VERBOSE=1`, let every line
195 // through — that's the explicit "I want to see the full
196 // underlying tool output" mode, including the deprecation
197 // chain we'd otherwise suppress.
198 if whisker_build::ui::is_verbose() {
199 return false;
200 }
201
202 let line = raw.trim_start_matches(|c: char| c.is_ascii_whitespace() || c == '·');
203
204 // Always surface real errors. Check this first so we don't
205 // accidentally suppress a `warning:`-prefixed error message.
206 if line.starts_with("error:")
207 || line.contains(" error:")
208 || line.starts_with("fatal error:")
209 || line.starts_with("** BUILD FAILED")
210 || line.starts_with("** BUILD INTERRUPTED")
211 {
212 return false;
213 }
214
215 // `2026-05-21 18:21:52.770 xcodebuild[54160:34595363] [MT] IDERunDestination …`
216 if raw.starts_with("20") && raw.contains("xcodebuild[") && raw.contains("] [MT] ") {
217 return true;
218 }
219
220 // The xcframework command's success line.
221 if line.starts_with("xcframework successfully written out to:") {
222 return true;
223 }
224
225 // Diagnostic chain: warnings, notes, source line listings,
226 // `N warnings generated.` summary.
227 if line.starts_with("warning:")
228 || line.contains(" warning:")
229 || line.starts_with("note:")
230 || line.contains(" note:")
231 || line.starts_with("In file included from")
232 || line.ends_with(" warnings generated.")
233 || line.ends_with(" warning generated.")
234 {
235 return true;
236 }
237
238 // Source-line listings rendered alongside the warning chain:
239 // `217 | #import "LynxBackgroundInfo.h"`
240 // ` | ^`
241 // `56 |` (empty source line for context)
242 // After trimming leading whitespace + all leading digits, the
243 // remainder always starts with `|` (with or without trailing
244 // content). Multi-digit line numbers were the gap that earlier
245 // single-char `strip_prefix` filters missed.
246 let after_digits = line
247 .trim_start()
248 .trim_start_matches(|c: char| c.is_ascii_digit() || c.is_ascii_whitespace());
249 if after_digits.starts_with('|') {
250 return true;
251 }
252
253 false
254}
255
256/// Classifies which sub-command produced the stderr — we tune the
257/// "benign noise" set per tool because the false-positive shape
258/// differs (simctl emits NSPOSIX error preambles, xcodebuild emits
259/// `IDERunDestination` etc.).
260#[derive(Copy, Clone)]
261enum SimctlNoise {
262 /// `xcrun simctl boot` — "Unable to boot device in current state: Booted"
263 /// fires when the sim is already up, which is the normal case
264 /// after the first `whisker run`.
265 Boot,
266 /// `xcrun simctl install` / `launch` — generally low-noise but
267 /// can emit POSIX prefixes; treat anything matching the known
268 /// boilerplate as suppressed. `simctl launch
269 /// --terminate-running-process` is also routed through here
270 /// (the previous separate `simctl terminate` step was rolled
271 /// into the launch flag — see `ios_install_and_launch`).
272 Other,
273 /// `xcodebuild` — `[MT] IDERunDestination`, the date-time
274 /// preamble lines, and the post-build "xcframework written"
275 /// confirmation all belong here.
276 Xcodebuild,
277 /// `adb install -r` — stdout banners "Performing Streamed
278 /// Install" and "Success" duplicate the `install_step.done()`
279 /// row our UI already prints.
280 AdbInstall,
281 /// `adb shell am start` — stdout banner `Starting: Intent
282 /// { cmp=... }` duplicates what the launch step's label
283 /// already says.
284 AdbAmStart,
285}
286
287impl SimctlNoise {
288 fn is_benign(&self, line: &str) -> bool {
289 // Lines common to several Apple tools.
290 if line.contains("An error was encountered processing the command")
291 || line.contains("Underlying error (domain=")
292 || line.starts_with(" The request to terminate")
293 {
294 return true;
295 }
296 match self {
297 SimctlNoise::Boot => {
298 line.contains("Unable to boot device in current state: Booted")
299 || line.starts_with("(code=405)")
300 }
301 SimctlNoise::Other => false,
302 SimctlNoise::Xcodebuild => is_benign_xcodebuild_line(line),
303 SimctlNoise::AdbInstall | SimctlNoise::AdbAmStart => false,
304 }
305 }
306
307 fn is_benign_stdout(&self, line: &str) -> bool {
308 // For xcodebuild, also fold stdout through the benign filter
309 // — `-quiet` doesn't fully silence it on iOS 26 SDK + Lynx
310 // pre-iOS-26 headers, so `mainScreen` deprecations etc. land
311 // on stdout depending on Xcode version.
312 if matches!(self, SimctlNoise::Xcodebuild) && is_benign_xcodebuild_line(line) {
313 return true;
314 }
315 match self {
316 // `simctl launch` always reports `<bundle_id>: <pid>` on
317 // success; it duplicates info our `step.done(...)`
318 // already covers.
319 SimctlNoise::Other => line.contains(": ") && line.chars().any(|c| c.is_ascii_digit()),
320 // `adb install -r`: two stdout lines on success — both
321 // are subsumed by the `install` step's ✓ row.
322 SimctlNoise::AdbInstall => line == "Performing Streamed Install" || line == "Success",
323 // `adb shell am start -n <component>`: the one stdout
324 // line "Starting: Intent { cmp=… }" duplicates the
325 // launch step's label.
326 SimctlNoise::AdbAmStart => line.starts_with("Starting: Intent {"),
327 _ => false,
328 }
329 }
330}
331
332async fn android_install_and_launch(
333 p: &AndroidParams,
334 dev_port: u16,
335 dev_token: Option<&str>,
336) -> Result<()> {
337 let apk = p
338 .project_dir
339 .join("app/build/outputs/apk/debug/app-debug.apk");
340 if !apk.is_file() {
341 anyhow::bail!("APK missing at {}", apk.display());
342 }
343
344 // adb reverse — bridge device `127.0.0.1:9876` → host `dev_port` so
345 // the on-device dev-runtime can reach our WebSocket without knowing
346 // the emulator-gateway IP (10.0.2.2). The device keeps dialing its
347 // default 9876 (`WHISKER_DEV_ADDR` fallback); only the host side of
348 // the mapping follows `--bind`, so a custom `--bind` port keeps hot
349 // reload working. Best-effort: it might already be set from a
350 // previous run, or the device might be a non-emulator that doesn't
351 // need it. Routed through `run_filtered` rather than `.status()` so
352 // its stdio doesn't bypass the TUI's stderr-capture pipe and overlay
353 // the live region — same reason every other adb call below now uses
354 // it.
355 let mut reverse_cmd = Command::new("adb");
356 reverse_cmd.args(["reverse", "tcp:9876", &format!("tcp:{dev_port}")]);
357 let _ = run_filtered(reverse_cmd, SimctlNoise::Other).await;
358
359 // Deliver the dev-session token. The app process doesn't inherit
360 // adb-set env vars, so we stash it in a `debug.*` system property
361 // (settable over adb) that the device-side `whisker-dev-runtime`
362 // reads via `__system_property_get`. Without a matching token the
363 // dev-server refuses to ship patches to the app.
364 if let Some(token) = dev_token {
365 let mut setprop_cmd = Command::new("adb");
366 setprop_cmd.args(["shell", "setprop", "debug.whisker_dev_token", token]);
367 let _ = run_filtered(setprop_cmd, SimctlNoise::Other).await;
368 }
369
370 let install_step = whisker_build::ui::step(
371 "install",
372 apk.file_name()
373 .map(|n| n.to_string_lossy().into_owned())
374 .unwrap_or_else(|| "app-debug.apk".into()),
375 );
376 let mut install_cmd = Command::new("adb");
377 install_cmd.args(["install", "-r"]).arg(&apk);
378 let install = run_filtered(install_cmd, SimctlNoise::AdbInstall)
379 .await
380 .context("spawn adb install")?;
381 if !install.success() {
382 install_step.fail(format!("{install}"));
383 anyhow::bail!("adb install -r {} failed ({install})", apk.display());
384 }
385 install_step.done("");
386
387 // adb shell am force-stop (so the relaunch actually re-bootstraps).
388 // `force-stop` is silent on success; route through `run_filtered`
389 // anyway so any error preamble lands in scrollback rather than on
390 // top of the live region.
391 let mut stop_cmd = Command::new("adb");
392 stop_cmd.args(["shell", "am", "force-stop", &p.application_id]);
393 let _ = run_filtered(stop_cmd, SimctlNoise::Other).await;
394
395 let component = format!("{}/{}", p.application_id, p.launcher_activity);
396 let launch_step = whisker_build::ui::step("launch", component.clone());
397 let mut launch_cmd = Command::new("adb");
398 launch_cmd.args(["shell", "am", "start", "-n", &component]);
399 let launch = run_filtered(launch_cmd, SimctlNoise::AdbAmStart)
400 .await
401 .context("spawn adb am start")?;
402 if !launch.success() {
403 launch_step.fail(format!("{launch}"));
404 anyhow::bail!("adb am start {component} failed ({launch})");
405 }
406 launch_step.done("");
407 Ok(())
408}
409
410async fn ios_install_and_launch(
411 p: &IosParams,
412 workspace_root: &std::path::Path,
413 package: &str,
414 capture: Option<&CaptureShims>,
415 features: &[String],
416 dev_port: u16,
417 dev_token: Option<&str>,
418) -> Result<()> {
419 let xcode_project = p.project_dir.join(format!("{}.xcodeproj", p.scheme));
420 if !xcode_project.is_dir() {
421 anyhow::bail!(
422 "Xcode project missing at {} — run xcodegen first",
423 xcode_project.display()
424 );
425 }
426 let derived = workspace_root
427 .join("target/.whisker/ios-derived")
428 .join(package);
429
430 let xc_step = whisker_build::ui::step("xcodebuild", p.scheme.clone());
431 let mut xc_cmd = Command::new("xcodebuild");
432 xc_cmd
433 .arg("-project")
434 .arg(&xcode_project)
435 .args(["-scheme", &p.scheme])
436 .args(["-configuration", "Debug"])
437 .args(["-destination", "generic/platform=iOS Simulator"])
438 .arg("-derivedDataPath")
439 .arg(&derived)
440 // The WhiskerModuleCodegenPlugin is a SwiftPM build-tool plugin;
441 // Xcode gates plugins behind an interactive trust prompt a
442 // headless build can't answer, so skip validation (it ships from
443 // Whisker's own `whisker` SPM package).
444 .arg("-skipPackagePluginValidation")
445 .args(["-quiet", "build"]);
446 // NB: WhiskerRuntime + the codegen plugin now resolve from the
447 // remote `whisker` SwiftPM package (see whisker_build::ios::
448 // WHISKER_IOS_SPM_URL), so the old `WHISKER_IOS_RUNTIME` /
449 // `WHISKER_IOS_MACROS` env injection that pointed module manifests at
450 // `platforms/ios` is gone — modules no longer read it.
451 // Tier 1 capture wiring (hot-reload). Pre-Step-7 the dev-server
452 // ran a separate `build_xcframework_with` call to prime the rustc
453 // + linker capture caches before xcodebuild touched the framework.
454 // Step 7's Build Phase produces the framework during xcodebuild
455 // itself, so the capture envs need to ride along here — they
456 // propagate xcodebuild → shell Build Phase → `whisker build-ios`
457 // subprocess → cargo, where the shims actually intercept rustc +
458 // linker. Capture is opt-in (`HotPatchMode::Tier1Subsecond`); when
459 // `None`, xcodebuild runs without the shims and the loop falls
460 // back to Tier 2 cold rebuilds.
461 if let Some(c) = capture {
462 let sim_triple = "aarch64-apple-ios-sim";
463 for (k, v) in whisker_build::capture_env_vars_for_triple(c, Some(sim_triple)) {
464 xc_cmd.env(k, v);
465 }
466 }
467 // Forward cargo features through to the Build Phase's
468 // `whisker build-ios` invocation as a space-separated list. The
469 // pbxproj's shell script expands each entry into `--features <feat>`
470 // before invoking the binary. `whisker run` puts `whisker/hot-reload`
471 // here so the user dylib carries the dev-runtime WebSocket client;
472 // without that the app never reports `aslr_reference` and every
473 // patch falls through to a Tier 2 cold rebuild + relaunch.
474 if !features.is_empty() {
475 xc_cmd.env("WHISKER_FEATURES", features.join(" "));
476 }
477 let xc_status = run_filtered(xc_cmd, SimctlNoise::Xcodebuild)
478 .await
479 .context("spawn xcodebuild")?;
480 if !xc_status.success() {
481 xc_step.fail(format!("{xc_status}"));
482 anyhow::bail!("xcodebuild build failed ({xc_status})");
483 }
484 xc_step.done("");
485
486 let app_path = derived
487 .join("Build/Products/Debug-iphonesimulator")
488 .join(format!("{}.app", p.scheme));
489 if !app_path.is_dir() {
490 anyhow::bail!(
491 "expected {}.app missing under {} after build",
492 p.scheme,
493 derived.display()
494 );
495 }
496
497 // Best-effort boot of either the caller's override or the first
498 // available iPhone simctl knows about. "Already booted" stderr
499 // is filtered as a benign noise pattern.
500 let device = p
501 .device_override
502 .clone()
503 .or_else(pick_available_iphone)
504 .unwrap_or_else(|| "iPhone 17 Pro".into());
505 let boot_step = whisker_build::ui::step("boot", device.clone());
506 let mut boot_cmd = Command::new("xcrun");
507 boot_cmd.args(["simctl", "boot", &device]);
508 let _ = run_filtered(boot_cmd, SimctlNoise::Boot).await;
509 boot_step.done("");
510
511 let install_step = whisker_build::ui::step("install", format!("{}.app", p.scheme));
512 let mut install_cmd = Command::new("xcrun");
513 install_cmd
514 .args(["simctl", "install", "booted"])
515 .arg(&app_path);
516 let install = run_filtered(install_cmd, SimctlNoise::Other)
517 .await
518 .context("spawn simctl install")?;
519 if !install.success() {
520 install_step.fail(format!("{install}"));
521 anyhow::bail!("simctl install {} failed ({install})", app_path.display());
522 }
523 install_step.done("");
524
525 // `SIMCTL_CHILD_<NAME>` shows up as `<NAME>` inside the launched
526 // app's env — that's how the dev-runtime finds us.
527 //
528 // `--terminate-running-process` makes simctl atomically kill the
529 // previous instance (so the runtime re-bootstraps + reconnects
530 // the dev WebSocket) and immediately launch the fresh build. We
531 // used to do this as two steps — `simctl terminate` followed by
532 // `simctl launch` — but the terminate call emits
533 // `Simulator device failed to terminate <bundle>.` to stderr
534 // whenever the app exists on the simulator but isn't actually
535 // running (which is every cold start, and also the "user
536 // backgrounded the app between rebuilds" case). The flag bundles
537 // both operations and handles the not-running case silently.
538 let launch_step = whisker_build::ui::step("launch", p.bundle_id.clone());
539 let mut launch_cmd = Command::new("xcrun");
540 launch_cmd
541 .args([
542 "simctl",
543 "launch",
544 "--terminate-running-process",
545 "booted",
546 &p.bundle_id,
547 ])
548 // The Simulator shares the host loopback, so it dials the
549 // dev-server's bind port directly. Honors `--bind <port>`.
550 .env(
551 "SIMCTL_CHILD_WHISKER_DEV_ADDR",
552 format!("127.0.0.1:{dev_port}"),
553 );
554 // Deliver the dev-session token as an env var the launched app
555 // inherits (`SIMCTL_CHILD_<NAME>` → `<NAME>` in the child).
556 if let Some(token) = dev_token {
557 launch_cmd.env("SIMCTL_CHILD_WHISKER_DEV_TOKEN", token);
558 }
559 let launch = run_filtered(launch_cmd, SimctlNoise::Other)
560 .await
561 .context("spawn simctl launch")?;
562 if !launch.success() {
563 launch_step.fail(format!("{launch}"));
564 anyhow::bail!("simctl launch {} failed ({launch})", p.bundle_id);
565 }
566 launch_step.done("");
567 Ok(())
568}
569
570/// Best-effort pick of an iPhone simulator that's installed on this
571/// machine. `pick_available_iphone()` returns `None` if simctl isn't
572/// available or the output doesn't parse; the caller substitutes a
573/// hard-coded default.
574fn pick_available_iphone() -> Option<String> {
575 let out = std::process::Command::new("xcrun")
576 .args(["simctl", "list", "devices", "available"])
577 .output()
578 .ok()?;
579 if !out.status.success() {
580 return None;
581 }
582 let text = String::from_utf8(out.stdout).ok()?;
583 for line in text.lines() {
584 let trimmed = line.trim();
585 // Lines look like: iPhone 17 Pro (UDID...) (Shutdown)
586 let Some((name, _rest)) = trimmed.split_once(" (") else {
587 continue;
588 };
589 if name.starts_with("iPhone ") {
590 return Some(name.to_string());
591 }
592 }
593 None
594}
595
596// ============================================================================
597// Tests
598// ============================================================================
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603
604 fn android_params() -> AndroidParams {
605 AndroidParams {
606 project_dir: PathBuf::from("/tmp/x"),
607 application_id: "rs.whisker.examples.helloworld".into(),
608 launcher_activity: ".MainActivity".into(),
609 abi: "arm64-v8a".into(),
610 }
611 }
612
613 #[test]
614 fn installer_for_android_without_params_errors() {
615 let inst = Installer::new(
616 Target::Android,
617 None,
618 None,
619 PathBuf::new(),
620 "x".into(),
621 None,
622 Vec::new(),
623 9876,
624 None,
625 );
626 let rt = tokio::runtime::Builder::new_current_thread()
627 .build()
628 .unwrap();
629 let err = rt
630 .block_on(async { inst.install_and_launch().await })
631 .unwrap_err();
632 assert!(err.to_string().contains("AndroidParams"), "got: {err:#}");
633 }
634
635 #[test]
636 fn installer_for_ios_without_params_errors() {
637 let inst = Installer::new(
638 Target::IosSimulator,
639 None,
640 None,
641 PathBuf::new(),
642 "x".into(),
643 None,
644 Vec::new(),
645 9876,
646 None,
647 );
648 let rt = tokio::runtime::Builder::new_current_thread()
649 .build()
650 .unwrap();
651 let err = rt
652 .block_on(async { inst.install_and_launch().await })
653 .unwrap_err();
654 assert!(err.to_string().contains("IosParams"), "got: {err:#}");
655 }
656
657 #[test]
658 fn android_install_errors_when_apk_missing() {
659 let p = android_params();
660 let rt = tokio::runtime::Builder::new_current_thread()
661 .build()
662 .unwrap();
663 let err = rt
664 .block_on(async { android_install_and_launch(&p, 9876, None).await })
665 .unwrap_err();
666 assert!(err.to_string().contains("APK missing"), "got: {err:#}");
667 }
668}