Current section

Files

Jump to
mob_dev lib mob_dev bench preflight.ex
Raw

lib/mob_dev/bench/preflight.ex

defmodule MobDev.Bench.Preflight do
@moduledoc """
Pre-run checklist for the iOS battery bench.
Walks through the things that have to be right before locking the screen
and running for 30 minutes. Each check returns `:ok | {:error, message}`.
Goals:
- Catch the misconfigurations that would invalidate a run *before* the run
starts (saves ~30 min of wasted bench time).
- Tell the user exactly what's wrong and how to fix it.
- Be testable — each check is a pure function returning a tagged result.
## Checks performed
1. **USB / hardware UDID** — at least one of: USB-connected device
reachable via `idevice_id -l`, or a configured `:hw_udid`.
2. **App installed** — bundle id appears in `xcrun devicectl device info apps`.
3. **BEAM reachable** — Node.connect succeeds.
4. **RPC responsive**`rpc.call(node, :erlang, :node, [])` returns within
2 seconds. Distinguishes "BEAM up but suspended" from "fully alive".
5. **NIF version**`mob_nif:battery_level/0` is exported. (Indicates the
installed app build is recent enough.)
6. **Background NIF**`mob_nif:background_keep_alive/0` is exported.
Required for screen-off bench mode.
Each check is independent — failure in (3) doesn't skip (5); we run them
all and report a complete picture.
"""
alias MobDev.Bench.Probe
@typedoc "Result for a single check."
@type check_result :: {:ok, String.t()} | {:error, String.t()}
@doc """
Run all preflight checks and return a list of `{name, result}` tuples in
the order they were run.
Common options:
- `:platform``:ios` (default) or `:android` — selects platform-specific
`hardware` and `app_installed` checks
- `:node` — node atom (required for BEAM checks)
- `:cookie` — cookie atom
- `:bundle_id` — app bundle id
- `:host` — IP/host for EPMD (default: derive from node)
- `:require_keep_alive` — boolean, default true (set false for screen-on bench)
iOS-specific:
- `:device_id` — devicectl identifier (CoreDevice UUID)
- `:hw_udid` — hardware UDID for USB checks
Android-specific:
- `:adb_serial` — ADB serial / IP:port for `adb` checks
"""
@spec run(keyword()) :: [{atom(), check_result()}]
def run(opts) do
platform = Keyword.get(opts, :platform, :ios)
[
{:hardware, check_hardware(platform, opts)},
{:app_installed, check_app_installed(platform, opts)},
{:beam_reachable, check_beam_reachable(opts)},
{:rpc_responsive, check_rpc_responsive(opts)},
{:nif_version, check_nif_version(opts)},
{:keep_alive_nif,
if(opts[:require_keep_alive] != false,
do: check_keep_alive_nif(opts),
else: {:ok, "skipped"}
)}
]
end
@doc """
Returns true if every result is `{:ok, _}`. Stricter overall check than
examining individual results — useful for deciding whether to abort.
"""
@spec all_ok?([{atom(), check_result()}]) :: boolean()
def all_ok?(results) do
Enum.all?(results, fn
{_name, {:ok, _}} -> true
_ -> false
end)
end
@doc """
Format the results as a multi-line string with ✓/✗ markers.
"""
@spec pretty([{atom(), check_result()}]) :: String.t()
def pretty(results) do
Enum.map_join(results, "\n", fn
{name, {:ok, msg}} -> " ✓ #{format_name(name)}#{msg}"
{name, {:error, msg}} -> " ✗ #{format_name(name)}#{msg}"
end)
end
defp format_name(name) do
name |> Atom.to_string() |> String.replace("_", " ")
end
# ── Individual checks ────────────────────────────────────────────────────
# ── Hardware check (platform-dispatched) ──────────────────────────────
@doc false
@spec check_hardware(:ios | :android, keyword()) :: {:ok, String.t()} | {:error, String.t()}
def check_hardware(platform, opts) do
case platform do
:ios -> check_hardware_ios(opts)
:android -> check_hardware_android(opts)
_ -> {:error, "unknown platform: #{inspect(platform)}"}
end
end
# Backward-compat — old single-arg version defaults to iOS.
@doc false
@spec check_hardware(keyword()) :: {:ok, String.t()} | {:error, String.t()}
def check_hardware(opts), do: check_hardware(:ios, opts)
defp check_hardware_ios(opts) do
cond do
is_binary(opts[:hw_udid]) ->
{:ok, "hardware UDID provided: #{opts[:hw_udid]}"}
System.find_executable("idevice_id") ->
case System.cmd("idevice_id", ["-l"], stderr_to_stdout: true) do
{out, 0} ->
udids =
out
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
case udids do
[] -> {:error, "no USB device detected (idevice_id -l returned empty)"}
[_one] -> {:ok, "USB device connected"}
many -> {:ok, "#{length(many)} USB devices connected"}
end
_ ->
{:error, "idevice_id failed — is the device trusted?"}
end
true ->
{:error,
"no hw_udid given and idevice_id not installed " <>
"(brew install libimobiledevice)"}
end
end
defp check_hardware_android(opts) do
serial = opts[:adb_serial]
cond do
is_nil(System.find_executable("adb")) ->
{:error, "adb not found (install Android platform-tools)"}
not is_binary(serial) ->
# Try `adb devices` to see if any device is reachable.
case System.cmd("adb", ["devices"], stderr_to_stdout: true) do
{out, 0} ->
devices =
out
|> String.split("\n")
|> Enum.drop(1)
|> Enum.flat_map(fn line ->
case String.split(line) do
[s, "device" | _] -> [s]
_ -> []
end
end)
case devices do
[] -> {:error, "no Android device detected (adb devices returned empty)"}
[single] -> {:ok, "device connected: #{single}"}
many -> {:ok, "#{length(many)} devices connected"}
end
_ ->
{:error, "adb devices failed"}
end
true ->
case System.cmd("adb", ["-s", serial, "get-state"], stderr_to_stdout: true) do
{out, 0} ->
state = String.trim(out)
if state == "device",
do: {:ok, "adb device #{serial} (#{state})"},
else: {:error, "adb device #{serial} state: #{state}"}
{out, _} ->
{:error, "adb get-state failed: #{String.trim(out)}"}
end
end
end
# ── App-installed check (platform-dispatched) ─────────────────────────
@doc false
@spec check_app_installed(:ios | :android, keyword()) ::
{:ok, String.t()} | {:error, String.t()}
def check_app_installed(platform, opts) do
case platform do
:ios -> check_app_installed_ios(opts)
:android -> check_app_installed_android(opts)
_ -> {:error, "unknown platform: #{inspect(platform)}"}
end
end
@doc false
@spec check_app_installed(keyword()) :: {:ok, String.t()} | {:error, String.t()}
def check_app_installed(opts), do: check_app_installed(:ios, opts)
defp check_app_installed_ios(opts) do
bundle = opts[:bundle_id]
device = opts[:device_id]
cond do
not is_binary(bundle) ->
{:error, "bundle_id not configured"}
not is_binary(device) ->
# Without a device id, we can't query devicectl. Treat as informational.
{:ok, "skipped (no device_id provided to verify)"}
is_nil(System.find_executable("xcrun")) ->
{:ok, "skipped (xcrun unavailable)"}
true ->
# devicectl prints a noisy "No provider was found" provisioning
# warning to stderr and exits non-zero even when the query
# succeeded. Don't trust the exit code — search the combined
# output for the bundle id and treat that as authoritative.
{out, _exit} =
System.cmd(
"xcrun",
[
"devicectl",
"device",
"info",
"apps",
"--device",
device,
"--bundle-identifier",
bundle
],
stderr_to_stdout: true
)
cond do
String.contains?(out, bundle) ->
{:ok, "#{bundle} found on device"}
String.contains?(out, "ContainerLookupError") or
String.contains?(out, "not installed") ->
{:error, "#{bundle} not installed — run `mix mob.deploy --native`"}
true ->
# Couldn't verify via devicectl — but the other checks (BEAM
# reachability, NIF exports) will catch real "app not installed"
# cases anyway. Don't fail the run on devicectl noise.
{:ok, "couldn't verify via devicectl (BEAM reachability is authoritative)"}
end
end
end
defp check_app_installed_android(opts) do
bundle = opts[:bundle_id]
serial = opts[:adb_serial]
cond do
not is_binary(bundle) ->
{:error, "bundle_id not configured"}
is_nil(System.find_executable("adb")) ->
{:ok, "skipped (adb unavailable)"}
not is_binary(serial) ->
{:ok, "skipped (no adb_serial provided to verify)"}
true ->
case System.cmd("adb", ["-s", serial, "shell", "pm", "list", "packages", bundle],
stderr_to_stdout: true
) do
{out, 0} ->
# `pm list packages com.example.foo` prints "package:com.example.foo"
# if installed; empty if not.
if String.contains?(out, "package:#{bundle}") do
{:ok, "#{bundle} installed on device"}
else
{:error, "#{bundle} not installed — run `mix mob.deploy --native`"}
end
{out, _} ->
{:error, "adb pm list failed: #{String.trim(out)}"}
end
end
end
@doc false
@spec check_beam_reachable(keyword()) :: {:ok, String.t()} | {:error, String.t()}
def check_beam_reachable(opts) do
node = opts[:node]
host = opts[:host] || derive_host(node)
cond do
not is_atom(node) or node == nil ->
{:error, "no node provided"}
not is_binary(host) ->
{:error, "could not derive host from node #{inspect(node)}"}
Probe.tcp_open?(host, 4369, 1500) ->
{:ok, "EPMD reachable at #{host}:4369"}
true ->
{:error, "EPMD not reachable at #{host}:4369 — phone offline or BEAM dead"}
end
end
@doc false
@spec check_rpc_responsive(keyword()) :: {:ok, String.t()} | {:error, String.t()}
def check_rpc_responsive(opts) do
node = opts[:node]
cookie = opts[:cookie]
cond do
not is_atom(node) or node == nil ->
{:error, "no node provided"}
true ->
if is_atom(cookie), do: Node.set_cookie(node, cookie)
case Node.connect(node) do
true ->
if Probe.rpc_responsive?(node, 2_000) do
{:ok, "RPC ping returned in <2 s"}
else
{:error, "RPC ping timed out (BEAM may be suspended)"}
end
false ->
{:error, "Node.connect/1 returned false — wrong cookie or dist down"}
:ignored ->
{:error, "Node.connect/1 returned :ignored — local node not started"}
end
end
end
@doc false
@spec check_nif_version(keyword()) :: {:ok, String.t()} | {:error, String.t()}
def check_nif_version(opts) do
check_nif_export(opts, :battery_level, "battery_level/0")
end
@doc false
@spec check_keep_alive_nif(keyword()) :: {:ok, String.t()} | {:error, String.t()}
def check_keep_alive_nif(opts) do
check_nif_export(opts, :background_keep_alive, "background_keep_alive/0")
end
defp check_nif_export(opts, fun_name, label) do
node = opts[:node]
case :rpc.call(node, :mob_nif, :module_info, [:exports], 2_000) do
list when is_list(list) ->
if Enum.any?(list, fn {f, _arity} -> f == fun_name end) do
{:ok, "#{label} exported"}
else
{:error,
"#{label} not exported on device — installed app is older than mob_dev expects"}
end
{:badrpc, reason} ->
{:error, "could not query exports: #{inspect(reason)}"}
other ->
{:error, "unexpected exports result: #{inspect(other)}"}
end
end
defp derive_host(node), do: MobDev.NodeUtil.host_from_node(node)
end