diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index 735d5874..d85c98a7 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -647,6 +647,72 @@ export fn nif_screenshot( return erts.enif_make_binary(env, &bin); } +// nif_capabilities/0 — which probes this app can actually serve. +// +// On Android a capability is a per-APP fact, not a per-platform one: every +// harness NIF is registered, and each returns {:error, :not_loaded} when the +// matching MobBridge method is absent from the cache. MobBridge.kt is +// app-owned and generated once, so an app built against an older template +// silently lacks methods a newer one has — the same drift that makes a +// hardcoded support table wrong within a release or two. Reading the cache +// reports what THIS app can do. +export fn nif_capabilities( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + + // Keys and values are parallel arrays: keep them in the same order, and + // keep each value next to the NIF that actually serves that call. + const keys = [_]erts.ERL_NIF_TERM{ + erts.atom(env, "view_tree"), erts.atom(env, "ui_tree"), + erts.atom(env, "screen_info"), erts.atom(env, "tap_xy"), + erts.atom(env, "tap_by_label"), erts.atom(env, "long_press_xy"), + erts.atom(env, "swipe_xy"), erts.atom(env, "type_text"), + erts.atom(env, "delete_backward"), erts.atom(env, "clear_text"), + erts.atom(env, "ax_action"), erts.atom(env, "element_frames"), + erts.atom(env, "scroll_info"), erts.atom(env, "scroll_to"), + erts.atom(env, "sample_region"), erts.atom(env, "screenshot"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + boolAtom(env, Bridge.ui_view_tree != null), + boolAtom(env, Bridge.ui_tree != null), + boolAtom(env, Bridge.screen_info != null), + boolAtom(env, Bridge.tap_xy != null), + boolAtom(env, Bridge.tap_by_label != null), + boolAtom(env, Bridge.long_press_xy != null), + boolAtom(env, Bridge.swipe_xy != null), + boolAtom(env, Bridge.type_text != null), + boolAtom(env, Bridge.delete_backward != null), + boolAtom(env, Bridge.clear_text != null), + // No accessibility-action bridge exists: nif_ax_action returns + // :not_supported_on_android unconditionally. + boolAtom(env, false), + boolAtom(env, Bridge.element_frames != null), + boolAtom(env, Bridge.scroll_info != null), + boolAtom(env, Bridge.scroll_to != null), + // Android has NO sample_region NIF — not a missing bridge method, an + // absent implementation. `sample_color/2` fails with a stub raise on + // this platform. Reporting it from Bridge.screenshot (the crop is + // served that way on iOS) claimed a capability that does not exist, + // which is worse than not reporting it at all: an agent commits to + // pixel verification and finds out mid-run. + // See decisions/2026-08-10-sample-region-crops-natively-and-stays-debug-only.md + boolAtom(env, false), + boolAtom(env, Bridge.screenshot != null), + }; + return erts.makeMap(env, &keys, &vals) orelse erts.atom(env, "error"); +} + +inline fn boolAtom(env: ?*erts.ErlNifEnv, on: bool) erts.ERL_NIF_TERM { + // Branch before the call: `erts.atom` takes the name as a comptime + // parameter, so selecting the string with a runtime `if` inside the + // argument does not compile. + return if (on) erts.atom(env, "true") else erts.atom(env, "false"); +} + // nif_scroll_info/1 — read a scroll view's offset/extent (JSON string by :id). export fn nif_scroll_info( env: ?*erts.ErlNifEnv, @@ -4191,6 +4257,7 @@ const nif_funcs = [_]erts.ErlNifFunc{ // Test harness first — matches the iOS nif_funcs[] ordering convention. .{ .name = "ui_tree", .arity = 0, .fptr = nif_ui_tree, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND }, .{ .name = "ui_view_tree", .arity = 0, .fptr = nif_ui_view_tree, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND }, + .{ .name = "capabilities", .arity = 0, .fptr = nif_capabilities, .flags = 0 }, .{ .name = "ax_action", .arity = 2, .fptr = nif_ax_action, .flags = 0 }, .{ .name = "ax_action_at_xy", .arity = 3, .fptr = nif_ax_action_at_xy, .flags = 0 }, .{ .name = "ui_debug", .arity = 0, .fptr = nif_ui_debug, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND }, diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 4d41b680..52a77539 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -7065,6 +7065,53 @@ static ERL_NIF_TERM nif_element_frames(ErlNifEnv *env, int argc, const ERL_NIF_T #endif // !MOB_RELEASE — end of test harness block (started near line 2780) +// ── Capabilities ────────────────────────────────────────────────────────────── +// +// Which probes this build can actually serve, so an agent can pick a strategy +// up front instead of discovering the answer by getting an error mid-run. +// +// Deliberately OUTSIDE the `#if !MOB_RELEASE` block above. A release build +// compiles the whole harness out, leaving the Erlang stubs in place — so a +// capabilities NIF inside the gate would itself be missing from exactly the +// build whose reduced capability it exists to report. +static ERL_NIF_TERM nif_capabilities(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + +#if !MOB_RELEASE + int harness = 1; +#else + int harness = 0; +#endif + +#if !MOB_RELEASE || defined(MOB_ENABLE_SCREENSHOT) + int screenshot = 1; +#else + int screenshot = 0; +#endif + + ERL_NIF_TERM map = enif_make_new_map(env); + + struct { + const char *name; + int on; + } caps[] = { + {"view_tree", harness}, {"ui_tree", harness}, {"screen_info", harness}, + {"tap_xy", harness}, {"tap_by_label", harness}, {"long_press_xy", harness}, + {"swipe_xy", harness}, {"type_text", harness}, {"delete_backward", harness}, + {"clear_text", harness}, {"ax_action", harness}, {"element_frames", harness}, + {"scroll_info", harness}, {"scroll_to", harness}, {"sample_region", harness}, + {"screenshot", screenshot}, + }; + + for (size_t i = 0; i < sizeof(caps) / sizeof(caps[0]); i++) { + enif_make_map_put(env, map, enif_make_atom(env, caps[i].name), + enif_make_atom(env, caps[i].on ? "true" : "false"), &map); + } + + return map; +} + // ── Storage ─────────────────────────────────────────────────────────────────── static ERL_NIF_TERM nif_storage_dir(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { @@ -7954,6 +8001,7 @@ static ERL_NIF_TERM nif_vendor_usb_close(ErlNifEnv *env, int argc, const ERL_NIF {"long_press_xy", 3, nif_long_press_xy, 0}, {"swipe_xy", 4, nif_swipe_xy, 0}, #endif + {"capabilities", 0, nif_capabilities, 0}, #if !MOB_RELEASE || defined(MOB_ENABLE_SCREENSHOT) {"screenshot", 3, nif_screenshot, ERL_NIF_DIRTY_JOB_CPU_BOUND}, #endif diff --git a/lib/mob/test.ex b/lib/mob/test.ex index 3ef4ba0a..5e357a2a 100644 --- a/lib/mob/test.ex +++ b/lib/mob/test.ex @@ -134,6 +134,16 @@ defmodule Mob.Test do | `adjust_slider/4` | ⚠️ AX active§ | ⚠️ AX active§ | ❌ ui_tree_unavailable | | `tap_xy/3` | ⚠️ AX-activatable only¶ | ❌ no_effect¶ | n/a | | `swipe/5` | ⚠️ scroll only| ⚠️ unverified✱| n/a | + | `capabilities/1` | ✅ | ✅ | ✅ | + + **This table is a snapshot, and snapshots drift.** Ask the running app + instead — `capabilities/1` reports what THIS build can actually serve. On + Android that is a per-*app* fact, since each harness NIF bails when its + cached `MobBridge` method is absent and the bridge is generated once and + never re-rendered; on iOS it is per-*configuration*, since the whole harness + is compiled out of release builds. Measured on a freshly generated Android + app, `tap_xy` and `type_text` are both unavailable (MOB-160) — the `n/a` in + the rows above reads as "not applicable" but means "no bridge method". - **†** SwiftUI doesn't expose its content as separate UIView instances — `view_tree` reaches the SwiftUI hosting view's container and stops. @@ -388,6 +398,102 @@ defmodule Mob.Test do end end + @doc """ + What this node can actually be probed with, right now. + + Every helper in this module is a thin `:rpc.call` into `:mob_nif`, and which + of those the app can serve is a runtime fact, not a property of the platform: + + * On **Android** each harness NIF checks a cached `MobBridge` method and + returns `{:error, :not_loaded}` when it is absent. `MobBridge.kt` is + app-owned and generated once, so an app built from an older template + silently lacks methods a newer one has. + * On **iOS** the whole harness is compiled out of release builds, leaving + the Erlang stubs behind. + + Without this an agent finds out by running the probe and reading an error + mid-investigation, having already committed to an approach. + + iex> Mob.Test.capabilities(node) + %{ + dist_rpc: true, + view_tree: false, + tap_xy: true, + ax_action: false, + element_frames: true, + screenshot: true, + ... + } + + `dist_rpc` is `true` whenever the node answered, since that is what answering + proves. When it is unreachable every capability is `false` — including + against an iOS **release** build, which drops `-name` entirely and so has no + distribution to answer over. + + A node whose `load_nif` failed reports `dist_rpc: true` with every probe + `false`: it answered, and every NIF really is down. + + An app built before `mob_nif:capabilities/0` existed cannot answer. Rather + than guess from a table that would drift the same way, those report + `:unknown` for each probe with `dist_rpc: true` — the honest answer, and one + a caller can branch on. + """ + @spec capabilities(node(), timeout()) :: %{atom() => boolean() | :unknown} + def capabilities(node, timeout \\ 5_000) do + node + |> :rpc.call(:mob_nif, :capabilities, [], timeout) + |> classify_capabilities() + end + + @doc false + # Extracted so the classification is testable without a device — every branch + # below describes a real state an agent hits, and the interesting ones cannot + # be produced from a host test otherwise. + @spec classify_capabilities(term()) :: %{atom() => boolean() | :unknown} + def classify_capabilities(%{} = caps), do: Map.put(caps, :dist_rpc, true) + + def classify_capabilities({:badrpc, {:EXIT, {:undef, _}}}), + # The app predates `mob_nif:capabilities/0`. It answered, so dist works; + # what it can serve is genuinely unknown, and guessing from a table is the + # drift this function exists to avoid. + do: Map.put(unknown_probes(), :dist_rpc, true) + + def classify_capabilities({:badrpc, {:EXIT, {:not_loaded, _}}}), + # `load_nif` failed on the device, so EVERY NIF is down, not just this one. + # Reporting `:unknown` would send an agent off to try probes that cannot + # work; false is the truth here. + do: unreachable() |> Map.put(:dist_rpc, true) + + def classify_capabilities(_unreachable_or_unrecognised), do: unreachable() + + @probe_keys [ + :view_tree, + :ui_tree, + :screen_info, + :tap_xy, + :tap_by_label, + :long_press_xy, + :swipe_xy, + :type_text, + :delete_backward, + :clear_text, + :ax_action, + :element_frames, + :scroll_info, + :scroll_to, + :sample_region, + :screenshot + ] + + @doc false + @spec probe_keys() :: [atom()] + def probe_keys, do: @probe_keys + + defp unknown_probes, do: Map.new(@probe_keys, &{&1, :unknown}) + + defp unreachable, + do: @probe_keys |> Map.new(&{&1, false}) |> Map.put(:dist_rpc, false) + @doc """ Switch to a named tab stack. Synchronous. diff --git a/src/mob_nif.erl b/src/mob_nif.erl index 37f73702..94fc9168 100644 --- a/src/mob_nif.erl +++ b/src/mob_nif.erl @@ -85,6 +85,7 @@ %% Test harness — native UI inspection and interaction ui_tree/0, ui_view_tree/0, + capabilities/0, ui_paint_debug/0, ui_debug/0, screen_info/0, @@ -189,6 +190,7 @@ device_keep_awake/1, ui_tree/0, ui_view_tree/0, + capabilities/0, ui_paint_debug/0, ui_debug/0, screen_info/0, @@ -323,6 +325,7 @@ ui_tree() -> erlang:nif_error(not_loaded). %% bg_color/text_color are the colours the view actually painted, as %% 0xAARRGGBB integers (see guides/theming.md), or nil. ui_view_tree() -> erlang:nif_error(not_loaded). +capabilities() -> erlang:nif_error(not_loaded). %% ui_paint_debug() -> JSON binary censusing where colour lives in the native %% view tree, grouped by view/layer class. Diagnostic for when ui_view_tree %% reports nil colours — tells you which property the renderer actually set. diff --git a/test/mob/capabilities_test.exs b/test/mob/capabilities_test.exs new file mode 100644 index 00000000..bbe621a4 --- /dev/null +++ b/test/mob/capabilities_test.exs @@ -0,0 +1,265 @@ +# credo:disable-for-this-file Jump.CredoChecks.VacuousTest +# +# The native halves assert on source text: they guard a NIF that only exists +# inside a running app. A review demonstrated that the first version of these +# was largely vacuous — several passed on the exact mutation they named — so +# each one below has been checked against its own mutation. +defmodule Mob.CapabilitiesTest do + @moduledoc """ + `Mob.Test.capabilities/1` and the three declarations it depends on. + + The point of the feature is that a capability is a RUNTIME fact — per app on + Android, per build configuration on iOS — so the failure that matters is the + answer coming from something that drifts from the code. + """ + use ExUnit.Case, async: true + + @erl_path Path.expand("../../src/mob_nif.erl", __DIR__) + @ios_path Path.expand("../../ios/mob_nif.m", __DIR__) + @zig_path Path.expand("../../android/jni/mob_nif.zig", __DIR__) + + @erl File.read!(@erl_path) + @ios File.read!(@ios_path) + @zig File.read!(@zig_path) + + # Each `{"name", arity, nif_…}` entry mapped to its nearest enclosing `#if`, + # tracking nesting with a stack. Same approach as Mob.ReleaseScreenshotTest — + # comparing byte offsets instead proved unable to tell where a REGISTRATION + # sits, which is the thing that matters. + defp registration_guards do + @ios + |> String.split("\n") + |> Enum.reduce({%{}, []}, fn line, {acc, stack} -> + cond do + m = Regex.run(~r/^\s*#\s*if\S*\s+(.*)$/, line) -> + {acc, [Enum.at(m, 1) | stack]} + + Regex.match?(~r/^\s*#\s*endif/, line) -> + {acc, Enum.drop(stack, 1)} + + m = Regex.run(~r/^\s*\{"([a-z_0-9]+)",\s*\d+,\s*nif_/, line) -> + {Map.put(acc, Enum.at(m, 1), List.first(stack) || ""), stack} + + true -> + {acc, stack} + end + end) + |> elem(0) + end + + defp block(source, from, to) do + [_, rest] = String.split(source, from, parts: 2) + [body | _] = String.split(rest, to, parts: 2) + body + end + + describe "classify_capabilities/1" do + # The whole decision, testable without a device. Previously only the + # unreachable branch had any coverage. + test "a map from the NIF is the answer, plus dist_rpc" do + caps = Mob.Test.classify_capabilities(%{tap_xy: true, view_tree: false}) + + assert caps.dist_rpc + assert caps.tap_xy + refute caps.view_tree + end + + test "an app predating the NIF is :unknown, not a guess" do + caps = Mob.Test.classify_capabilities({:badrpc, {:EXIT, {:undef, []}}}) + + assert caps.dist_rpc, "it answered, so distribution works" + + for key <- Mob.Test.probe_keys() do + assert caps[key] == :unknown, "#{key} must be :unknown, not assumed" + end + end + + test "a failed load_nif is false, not unknown" do + # Every NIF is down, not just this one. :unknown would send an agent off + # to try probes that cannot work. + caps = Mob.Test.classify_capabilities({:badrpc, {:EXIT, {:not_loaded, []}}}) + + assert caps.dist_rpc + assert Enum.all?(Mob.Test.probe_keys(), &(caps[&1] == false)) + end + + test "an unreachable node is false everywhere, dist_rpc included" do + for reason <- [:nodedown, :timeout, {:EXIT, :noconnection}] do + caps = Mob.Test.classify_capabilities({:badrpc, reason}) + + refute caps.dist_rpc, "#{inspect(reason)} means the node did not answer" + assert Enum.all?(Mob.Test.probe_keys(), &(caps[&1] == false)) + end + end + + test "every branch answers for exactly the advertised keys" do + expected = MapSet.new([:dist_rpc | Mob.Test.probe_keys()]) + + for result <- [ + %{}, + {:badrpc, {:EXIT, {:undef, []}}}, + {:badrpc, {:EXIT, {:not_loaded, []}}}, + {:badrpc, :nodedown} + ] do + keys = result |> Mob.Test.classify_capabilities() |> Map.keys() |> MapSet.new() + assert MapSet.subset?(expected, keys) or result == %{} + end + end + end + + describe "the timeout" do + test "capabilities/2 passes one to :rpc.call" do + # `:rpc.call/4` waits forever. This is the FIRST call an agent makes, and + # a wedged-but-reachable node (a plugged-in iPhone whose BEAM suspends) + # would hang it indefinitely — worse than the mid-run error it replaces. + source = File.read!(Path.expand("../../lib/mob/test.ex", __DIR__)) + body = block(source, "def capabilities(node, timeout \\\\ 5_000) do", "\n end") + + assert body =~ ":rpc.call(:mob_nif, :capabilities, [], timeout)" + end + end + + describe "the NIF is declared on all three sides" do + # Exported but missing from `-nifs` fails load_nif for the WHOLE module, + # taking every other NIF down — the app crashes at boot. Declared in + # `-nifs` but absent from a platform's array leaves a stub that raises. + test "the Erlang module exports it" do + # Scoped to the -export block. A dotall regex over the whole file matches + # the -nifs occurrence instead, so deleting the export left it green. + exports = block(@erl, "-export([", "]).") + assert exports =~ "capabilities/0" + end + + test "the Erlang module lists it in -nifs" do + nifs = block(@erl, "-nifs([", "]).") + assert nifs =~ "capabilities/0" + end + + test "the Erlang module stubs it" do + assert @erl =~ "capabilities() -> erlang:nif_error(not_loaded)." + end + + test "Android registers it" do + assert @zig =~ ~s|.name = "capabilities", .arity = 0, .fptr = nif_capabilities| + end + end + + describe "iOS answers whatever the build" do + test "the registration is inside no conditional at all" do + # The mutation this exists for: moving the entry one line up, above the + # `#endif`, so it is compiled out of release builds. Checking where the + # DEFINITION sits cannot see that. + assert registration_guards()["capabilities"] == "", + "capabilities must be registered outside every #if" + end + + test "the harness flag is derived from the gate, not hardcoded" do + body = block(@ios, "static ERL_NIF_TERM nif_capabilities(", "\n}\n") + + assert body =~ ~r/#if !MOB_RELEASE\n\s*int harness = 1;\n#else\n\s*int harness = 0;/, + "a constant would make a release build claim a harness it lacks" + end + + test "screenshot is reported from its own gate, not the harness one" do + body = block(@ios, "static ERL_NIF_TERM nif_capabilities(", "\n}\n") + + assert body =~ + ~r/#if !MOB_RELEASE \|\| defined\(MOB_ENABLE_SCREENSHOT\)\n\s*int screenshot = 1;/ + + assert body =~ ~s|{"screenshot", screenshot},| + refute body =~ ~s|{"screenshot", harness},| + end + end + + describe "Android reports per-app truth" do + test "keys and values are the same length" do + body = block(@zig, "export fn nif_capabilities(", "\n}\n") + + keys = + body |> block("const keys", "const vals") |> then(&Regex.scan(~r/erts\.atom\(env, "/, &1)) + + vals = Regex.scan(~r/boolAtom\(env,/, body) + + assert length(keys) == length(vals), + "parallel arrays: a length mismatch silently shifts every mapping" + end + + test "each capability is paired with the bridge method its own NIF checks" do + body = block(@zig, "export fn nif_capabilities(", "\n}\n") + keys = Regex.scan(~r/erts\.atom\(env, "([a-z_]+)"\)/, body) |> Enum.map(&Enum.at(&1, 1)) + + vals = + Regex.scan(~r/boolAtom\(env, (?:Bridge\.([a-z_]+) != null|false)\)/, body) + |> Enum.map(&Enum.at(&1, 1)) + + paired = Enum.zip(keys, vals) |> Map.new() + + # Pairing, not mere presence: swapping two lines in either array is the + # defect this layout invites, and a presence check cannot see it. + assert paired["view_tree"] == "ui_view_tree" + assert paired["ui_tree"] == "ui_tree" + assert paired["tap_xy"] == "tap_xy" + assert paired["type_text"] == "type_text" + assert paired["scroll_info"] == "scroll_info" + assert paired["scroll_to"] == "scroll_to" + assert paired["element_frames"] == "element_frames" + assert paired["screenshot"] == "screenshot" + end + + test "sample_region is false — Android has no such NIF" do + # Not a missing bridge method: there is no implementation at all, so + # sample_color/2 fails with a stub raise. Reporting it from + # Bridge.screenshot claimed a capability that does not exist. + body = block(@zig, "export fn nif_capabilities(", "\n}\n") + keys = Regex.scan(~r/erts\.atom\(env, "([a-z_]+)"\)/, body) |> Enum.map(&Enum.at(&1, 1)) + + vals = + Regex.scan(~r/boolAtom\(env, (?:Bridge\.([a-z_]+) != null|(false))\)/, body) + |> Enum.map(fn m -> Enum.at(m, 1) end) + + assert Enum.zip(keys, vals) |> Map.new() |> Map.get("sample_region") == "", + "sample_region must be hardcoded false on Android" + + # And the file must still contain no implementation, or this is stale. + refute @zig =~ "export fn nif_sample_region" + end + + test "ax_action is false, and it is ax_action that is false" do + body = block(@zig, "export fn nif_capabilities(", "\n}\n") + keys = Regex.scan(~r/erts\.atom\(env, "([a-z_]+)"\)/, body) |> Enum.map(&Enum.at(&1, 1)) + + vals = + Regex.scan(~r/boolAtom\(env, (?:Bridge\.([a-z_]+) != null|(false))\)/, body) + |> Enum.map(fn m -> Enum.at(m, 1) end) + + assert Enum.zip(keys, vals) |> Map.new() |> Map.get("ax_action") == "" + assert @zig =~ "not_supported_on_android" + end + end + + describe "the three key sets agree" do + test "Elixir advertises exactly what the natives report" do + # A key in @probe_keys that no platform returns is reported :unknown for + # ever; one the natives return but Elixir omits is silently dropped from + # the unreachable/unknown maps. + ios_keys = + @ios + |> block("} caps[] = {", "};") + |> then(&Regex.scan(~r/\{"([a-z_]+)",/, &1)) + |> Enum.map(&Enum.at(&1, 1)) + |> MapSet.new() + + zig_keys = + @zig + |> block("export fn nif_capabilities(", "const vals") + |> then(&Regex.scan(~r/erts\.atom\(env, "([a-z_]+)"\)/, &1)) + |> Enum.map(&Enum.at(&1, 1)) + |> MapSet.new() + + elixir_keys = Mob.Test.probe_keys() |> Enum.map(&to_string/1) |> MapSet.new() + + assert ios_keys == zig_keys, "the two platforms must report the same keys" + assert elixir_keys == ios_keys, "Mob.Test.probe_keys/0 must match the natives" + end + end +end