defmodule DalaDev.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** — `dala_nif:battery_level/0` is exported. (Indicates the installed app build is recent enough.) 6. **Background NIF** — `dala_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 DalaDev.Bench.Probe alias DalaDev.Bench.ADBHelper @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 dala.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"} not is_binary(serial) -> {:ok, "skipped (no adb_serial provided to verify)"} true -> ADBHelper.check_app_installed(serial, bundle) 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, :dala_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 dala_dev expects"} end {:badrpc, reason} -> {:error, "could not query exports: #{inspect(reason)}"} other -> {:error, "unexpected exports result: #{inspect(other)}"} end end defp derive_host(nil), do: nil defp derive_host(node) when is_atom(node) do case Atom.to_string(node) |> String.split("@", parts: 2) do [_, host] -> host _ -> nil end end end