From 0de30218859bf5ad2fb335746e4e651faf3571ab Mon Sep 17 00:00:00 2001 From: GenericJam Date: Fri, 4 Sep 2026 20:29:58 -0600 Subject: [PATCH] Let an agent ask what a build can be probed with, instead of finding out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every helper in Mob.Test is a thin :rpc.call into :mob_nif, and which of them an app can serve is a runtime fact rather than a property of the platform. On Android each harness NIF bails with {:error, :not_loaded} when its cached MobBridge method is absent, and MobBridge.kt is app-owned, generated once and never re-rendered — the same drift that crash-looped an app on MobBridge.torch. On iOS the whole harness lives inside #if !MOB_RELEASE, so a release build leaves the Erlang stubs behind. So the answer has to come from the running app. A table in the docs would reproduce exactly the problem this removes, which is why capabilities/0 is a NIF reading the bridge cache on Android and the gate macros on iOS, rather than an Elixir lookup. It found something immediately. On a freshly generated Android app only element_frames, screen_info, screenshot, scroll_info and scroll_to are available: there is no synthetic input at all — no tap_xy, swipe_xy, long_press_xy, type_text, delete_backward or clear_text, because the mob_new template's MobBridge ships none of them. Filed as MOB-160. The support matrix in this module listed tap_xy on Android as "n/a", which reads as "not applicable" rather than "not implemented"; it now says so, and says plainly that the table is a snapshot and the probe is the truth. An adversarial review found the first version reported sample_region: true on Android from Bridge.screenshot, on the theory that the crop is served from a screenshot the way it is on iOS. Android has no sample_region NIF at all — one grep hit, the atom I had just added. An agent would have read `true`, committed to pixel verification, and hit a stub raise mid-run, which is the precise failure this feature exists to prevent, now with a false assurance attached. Worth recording that my device verification did not catch this: I read sample_region: true in the output as a pass. The same review dismantled the iOS rationale. The comment claimed the NIF must sit outside the release gate or it would be missing from the build whose reduced capability it reports — but mob_beam.m drops -name and -setcookie under MOB_RELEASE, so a release build has no distribution and capabilities/1 gets {:badrpc, :nodedown} before reaching it. The placement is still right; the reason was wrong, and the comment now says what is actually true. Also from the review: :rpc.call/4 waits forever, which on the first call an agent makes turns a wedged-but-reachable node into an indefinite hang — worse than the error it replaces. It takes a timeout now, which also makes the existing :timeout guard reachable. And the badrpc shapes are classified rather than lumped: an app predating the NIF is :unknown, a failed load_nif is false with dist_rpc: true (every NIF is down, so :unknown would send an agent off to try probes that cannot work), and anything else is unreachable. The tests were largely vacuous and the review proved it by running the mutations. Deleting the -export line left them green — the highest-consequence mutation in the diff, since load_nif then fails for the whole module and every app crashes at boot — because the regex was dotall-greedy and matched the -nifs block instead. Moving the iOS registration inside the gate left them green, because the assertion checked where the definition sits, not the registration; it now uses the same #if-nesting parser as Mob.ReleaseScreenshotTest. The Android assertions checked that strings were present, not that keys and values were paired, so swapping two lines of a parallel array was invisible. All nine mutations are now caught against an exact baseline. Device-verified on both platforms after the fixes: iOS debug simulator reports all sixteen probes true; the generated Android app reports the five above, with sample_region now correctly false. Refs MOB-151, MOB-160 Co-Authored-By: Claude Opus 5 (1M context) --- android/jni/mob_nif.zig | 67 +++++++++ ios/mob_nif.m | 48 ++++++ lib/mob/test.ex | 106 +++++++++++++ src/mob_nif.erl | 3 + test/mob/capabilities_test.exs | 265 +++++++++++++++++++++++++++++++++ 5 files changed, 489 insertions(+) create mode 100644 test/mob/capabilities_test.exs 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