From 66852efe18464a323e2730a57524e180d1cbec7e Mon Sep 17 00:00:00 2001
From: Lana Begunova <70295997+lana-20@users.noreply.github.com>
Date: Tue, 13 Oct 2026 14:59:33 -0700
Subject: [PATCH 1/2] A web page's range is a slider: named by its label,
filled by position (CHALLENGES 319)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
map printed MobiumApp's as "50 (input)", named by its
value, and app_fill typed into it: fill 0.2, the position every native
slider takes, was clamped to a step and failed as "the page rewrote or
refused the value". A range is now a slider: map names it by its label and
reads its value as its state, app_fill takes a position from 0 to 1 and sets
the value from the page's own min, max and step — so it lands exactly, where
a native iOS slider cannot (219) — and app_type is refused naming app_fill.
From the native side, Android's WebView draws the range as a SeekBar whose
text is its label and which reports no progress; it was mapped as "SeekBar"
valued by its label. A seek bar inside a WebView keeps its text as its name,
and app_fill on it goes through the page.
docs/checks/slider-ios.sh asserts every kind of slider on MobiumApp's Slider
Demo on iOS (318, 234, 319): 3 of 3 on the simulator, failing at the range
with main's binary. slider.sh gains the web range on Android and a wait for
the screen it maps, a race that had passed by timing (233): 3 of 3 on the
emulator.
---
docs/CHALLENGES.md | 35 ++++++
docs/ROADMAP.md | 2 +-
docs/checks/README.md | 3 +-
docs/checks/slider-ios.sh | 103 ++++++++++++++++++
docs/checks/slider.sh | 31 +++++-
internal/agent/handlers.go | 8 ++
internal/agent/schema.go | 4 +-
internal/agent/webrange_test.go | 65 +++++++++++
internal/agent/webview.go | 45 +++++++-
internal/uitree/parse.go | 16 ++-
.../testdata/mobiumapp-sliders-uia2.xml | 41 +++++++
internal/uitree/webseekbar_test.go | 37 +++++++
internal/webview/actionable.go | 73 ++++++++++++-
internal/webview/actionable_test.go | 22 ++++
internal/webview/map.go | 13 ++-
15 files changed, 486 insertions(+), 12 deletions(-)
create mode 100755 docs/checks/slider-ios.sh
create mode 100644 internal/agent/webrange_test.go
create mode 100644 internal/uitree/testdata/mobiumapp-sliders-uia2.xml
create mode 100644 internal/uitree/webseekbar_test.go
diff --git a/docs/CHALLENGES.md b/docs/CHALLENGES.md
index 2638952c..05ac9e3d 100644
--- a/docs/CHALLENGES.md
+++ b/docs/CHALLENGES.md
@@ -6832,6 +6832,41 @@ since it snaps to steps of 10; Balance, which is continuous, landed at 0.28
for 0.25, because a finger on the thumb is not the thumb's center, the
inset Android's seek bars needed (227). A tap on the track moves nothing.
+### 319. A web page's range was a text field to `map` and to `fill`
+
+**Found by:** giving MobiumApp's Slider Demo a web page's
+` ` (0 to 100 in steps of 10) beside its native sliders,
+to measure every kind of slider on iOS (ROADMAP). `map` printed it as
+`50 (input)` — named by its value, the mistake CHALLENGES 65, 219 and 234
+fixed natively — and `app_fill` typed into it: `fill 20` worked, while
+`fill 0.2`, the position every native slider takes, was clamped by the
+page to a step and failed as "the page rewrote or refused the value", a
+cause that was not the case. `app_type` was accepted too, where a native
+slider refuses it. **Fixed:** a range is a slider. `map` names it by its
+label and reads its value as its state — `Range, 0 to 100 in steps of 10
+(slider, 50)` — and `role=slider` finds it. `app_fill` takes a position from
+0 to 1, as on every other slider, and anything else is refused before
+anything moves; `app_type` is refused naming `app_fill`. Unlike a native
+slider (219), a page gives its range's `min`, `max` and `step`, so the
+position is turned into the value the page would take and set through the
+native setter with the `input` and `change` events a drag causes, and it
+lands exactly: on the iPhone 17 Pro simulator 0.2, 1, 0, 0.35 and 0.5 read
+20, 100, 0, 40 and 50, and the page's own output followed each.
+
+**And from the native side, on Android.** Android's WebView draws the range
+as a `SeekBar` — but where a native seek bar's text is its progress (227),
+Chrome's carries the range's label and no progress at all, so `map` in
+`NATIVE_APP` printed `SeekBar (slider, Range, 0 to 100 in steps of 10 in its
+own steps)`: named by its class and valued by its name. A seek bar inside a
+WebView now keeps its text as its name and has no value, and `app_fill` on
+it goes through the page when the page can be reached, as a field in a page
+does (301) — on the Android 15 emulator, 0.3 read "30", through its page.
+Adding these rows to `slider.sh` found it mapping the screen before the
+Slider Demo had opened, with nothing waited for — CHALLENGES 233's race,
+which had passed by timing; it waits for the screen now. `slider.sh` passed
+three times in three on the emulator and `slider-ios.sh` three in three on
+the simulator, which failed at the range with the binary before this fix.
+
## Findings that were not defects
Worth recording because each one closed off an approach that looked obvious.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 522aa1cb..6ff41bc7 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -948,7 +948,7 @@ this is what is not.
a fill that lands exactly from partway, which WebDriverAgent cannot
support — it reports no thumb position, and its range is real for a
UIKit slider and 0 to 1 for SwiftUI's (CHALLENGES 219, 2026-10-13);
- range sliders; and the phone's text
+ range sliders, two thumbs on one track; and the phone's text
size, which is a slider in Settings. A custom control that does carry the
Adjustable trait — Pocket Casts' scrubber — maps as `adjustable` with its
value since 2026-10-05 (CHALLENGES 234), and `app_fill` refuses it,
diff --git a/docs/checks/README.md b/docs/checks/README.md
index f80f1f11..5abda931 100644
--- a/docs/checks/README.md
+++ b/docs/checks/README.md
@@ -92,7 +92,8 @@ checks do not: `clean-stop.sh`, which stops the caller's daemon, and
| [pocketcasts-ios.sh](pocketcasts-ios.sh) | **A fourth third-party app on iOS**: Pocket Casts, a podcast player, signed out, built from its source for a simulator or from the App Store on a phone, and CHALLENGES 234–239 held against it: onboarding's button mapped disabled and `map --diff` saying when it is enabled, a row's checkmark not a target of its own, Discover's rows and a podcast's header named by their words rather than the buttons and images inside them, Play touched clear of the divider through its center, the player's first-run tip said by `map` and `app_alert` and closed where they say, and the scrubber mapped as adjustable with `app_fill` refused. On a simulator, iPhone or iPad, the app's data is cleared first; a phone's onboarding and tip run only on a fresh install, and the app is first brought back to Discover from wherever it was left. An episode is played for a moment and paused |
| [kiwix-ios.sh](kiwix-ios.sh) | **A fifth third-party app on iOS, and the first whose WebView opens**: Kiwix, the offline Wikipedia reader, built from its source for a simulator or from the App Store on an iPhone, and CHALLENGES 244–248 and 256 held against it with a pinned ZIM: each catalog card one entry, off-screen ambiguity told to name one, an article tile one link named once, the disabled List button refused by its own ref, and a tap in the WebView — taller than its page, under the bars — opening the article it names; a page under the Library neither mapped nor tapped. A simulator starts from cleared data, with the ZIM uploaded and opened through the system document picker; a phone keeps its data, so the ZIM must already be in Kiwix's library from its own download, and 245 is reached only with no page under the Library |
| [feed.sh](feed.sh) | **A list that grows as it is scrolled**, on MobiumApp's Feed Demo: twenty rows a page, the next 2.5 seconds after the end is reached, three pages in all. `scroll-to` reaches Row 55 across two loads instead of stopping at the first page's end, says Row 70 is past the real end at once, and an unknown role is refused rather than waited on (CHALLENGES 222, 223). Passed on the simulator, the iPhone and the Pixel 8 Pro |
-| [slider.sh](slider.sh) | **Sliders on Android**, on MobiumApp's Slider Demo: Volume (0–100 in tens) and Balance (0–1, continuous) map as sliders by name with a value, are filled by position and land exactly — Volume on 30, 100 and 0, Balance on 0.25 — `map --diff` says the value moved, and a non-number and `type` are refused (CHALLENGES 227). Passed on the Pixel 8 Pro. Android only: on iOS the demo's sliders report no slider at all |
+| [slider.sh](slider.sh) | **Sliders on Android**, on MobiumApp's Slider Demo: Volume (0–100 in tens) and Balance (0–1, continuous) map as sliders by name with a value, are filled by position and land exactly — Volume on 30, 100 and 0, Balance on 0.25 — `map --diff` says the value moved, and a non-number and `type` are refused (CHALLENGES 227). Passed on the Pixel 8 Pro. Android only: on iOS the demo is [slider-ios.sh](slider-ios.sh) |
+| [slider-ios.sh](slider-ios.sh) | **Sliders on iOS, three kinds**, on MobiumApp's Slider Demo: Volume, a plain view to iOS (`map` calls it a button), where `fill` is refused as unsupported naming `swipe` and a drag lands it (CHALLENGES 318); Level, the same slider made accessible, mapped as adjustable with its value, `fill` refused naming a drag and the drag landing it (234); and a web page's range, a slider by its label, filled by position and landing exactly from the page's min, max and step, with a value and `type` refused (319). Passed 2026-10-13 three times in three on the iPhone 17 Pro simulator, about 25 seconds; with the binary before 319 it failed at the range |
| [frames.sh](frames.sh) | **Frames inside a WebView**, on MobiumApp's Frames page: in the WebView's context every frame's elements are mapped with their frame — same-origin and nested through the page, cross-origin through its own execution context — each button tapped reaches its frame, the cross-origin card field is filled and added to as the frame reports, and `NATIVE_APP` reaches the cross-origin button too (CHALLENGES 228, 229). Passed on the iPhone simulator and an emulator |
| [compose-app.sh](compose-app.sh) | **A Jetpack Compose app**, Seal from F-Droid, the first Compose app driven here, and CHALLENGES 176–178 held against it. Its icon buttons map once each as buttons. Its first-launch dialog, which the platform's alert endpoint does not know, is read by `alert` and not accepted, a tap on what it covers is refused naming it, and a rule answers it by its Close button. A switch row is set by its words, read back and put back, and a row with no state is refused. An app the check installed is uninstalled after, and on a phone nothing read off it is printed. Emulators and phones |
| [map-diff.sh](map-diff.sh) | **`map --diff`**, on MobiumApp's Form Demo. The first diff of a session says it had nothing to compare with and adds the whole screen. A map with nothing done between says nothing changed, the control that it can come back empty. A box checked is its state and what it was, and on iOS, where its row grows, what is under it moves as one line, not five. A screen left is its elements gone and the next screen's added, and a ref from the diff acts. Emulators and simulators |
diff --git a/docs/checks/slider-ios.sh b/docs/checks/slider-ios.sh
new file mode 100755
index 00000000..eeae717a
--- /dev/null
+++ b/docs/checks/slider-ios.sh
@@ -0,0 +1,103 @@
+#!/bin/sh
+# Sliders on iOS, on MobiumApp's Slider Demo: three kinds, each handled the
+# way the one fill rule says — a slider is filled with a position from 0 to
+# 1, and a control Mobium cannot set to a position is refused, naming a drag.
+#
+# docs/checks/slider-ios.sh
+#
+# - Volume, @react-native-community/slider as shipped: on iOS a plain view
+# with no trait and no value, so map calls it a button. app_fill is
+# refused as unsupported, naming app_swipe, with nothing moved
+# (CHALLENGES 318); a drag on its thumb lands it, in steps of 10.
+# - Level, the same slider made accessible: the Adjustable trait and a
+# value. Mapped as adjustable with its value; app_fill refused naming a
+# drag (234); the drag lands it and map reads the new value.
+# - Range, a web page's , 0 to 100 in steps of 10: a
+# slider by its label, filled by position, landing exactly from the
+# page's own min, max and step; a value and app_type refused (319).
+#
+# Android's sliders are slider.sh. Real UIKit and SwiftUI sliders, which
+# app_fill moves (219), are icecubes-ios.sh.
+set -e
+DEV="$1"
+if [ -z "$DEV" ]; then echo "usage: $0 " >&2; exit 2; fi
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+. "$ROOT/docs/checks/lib.sh"
+check_platform ios "$DEV"
+check_lock "$DEV"
+M="$ROOT/bin/mobium --device $DEV"
+APP=dev.mobium.mobiumapp
+fail() { echo "FAIL: $*" >&2; exit 1; }
+row() { printf ' %-10s %-60s ok\n' "$1" "$2"; }
+
+# refused : refused with that exit status, saying so.
+refused() {
+ want=$1 words=$2; shift 2
+ set +e; out=$("$@" 2>&1); got=$?; set -e
+ [ "$got" = "$want" ] || fail "$*: exit $got, want $want: $out"
+ echo "$out" | grep -q "$words" || fail "$*: refused without saying \"$words\": $out"
+}
+state() { $M text testid=sliderState; }
+# The track's bounds in device pixels, from map: x1 x2 y.
+track() { $M map --json | python3 -c '
+import json, sys
+for e in json.load(sys.stdin)["elements"]:
+ if e["label"] == sys.argv[1]:
+ b = e["bounds"]; print(b["x1"], b["x2"], (b["y1"] + b["y2"]) // 2); break' "$1"; }
+# drag : a finger on the thumb at one fraction of the
+# track, moved to another.
+drag() {
+ set -- "$1" "$2" "$3" $(track "$1")
+ [ -n "$6" ] || fail "no bounds for $1 in map"
+ x() { python3 -c "print(int($2 + $1 * ($3 - $2)))"; }
+ $M swipe "$(x "$2" "$4" "$5")" "$6" "$(x "$3" "$4" "$5")" "$6" --duration 800ms >/dev/null
+}
+
+echo "--- $DEV"
+$M terminate "$APP" >/dev/null 2>&1 || true
+out=$($M launch "$APP" 2>&1) || fail "launch: $out"
+$M scroll-to 'text="Slider Demo"' --direction down >/dev/null || fail "MobiumApp has no Slider Demo — rebuild it"
+$M tap 'text="Slider Demo"' >/dev/null
+$M wait testid=sliderState --timeout 10s >/dev/null || fail "the Slider Demo did not open"
+case "$(state)" in
+ "volume: 50, balance: 0.50, level: 5, changes: 0") ;;
+ *) fail "the Slider Demo has no Level, or did not start fresh: $(state) — rebuild MobiumApp" ;;
+esac
+
+map=$($M map)
+echo "$map" | grep -q " Volume (button)" || fail "Volume is no longer a plain view to map — has the library changed? $map"
+echo "$map" | grep -q " Level (adjustable, 50%)" || fail "Level is not adjustable at 50%: $map"
+row "map" "Volume a plain view (button); Level adjustable, 50%"
+
+refused 5 "not a text field or a slider" $M fill testid=volumeSlider 0.2
+echo "$(state)" | grep -q "^volume: 50," || fail "a refused fill moved Volume: $(state)"
+drag Volume 0.5 0.2
+echo "$(state)" | grep -q "^volume: 20," || fail "a drag from 50% to 20% left $(state)"
+row "volume" "fill refused, naming app_swipe; a drag lands it on 20"
+
+refused 2 "app_swipe" $M fill testid=levelSlider 0.8
+echo "$(state)" | grep -q "level: 5," || fail "a refused fill moved Level: $(state)"
+drag Level 0.5 0.8
+echo "$(state)" | grep -q "level: 8," || fail "a drag from 50% to 80% left $(state)"
+$M map | grep -q " Level (adjustable, 80%)" || fail "map does not read Level at 80%"
+row "level" "fill refused, naming a drag; the drag lands it on 8"
+
+WEB=$($M contexts | awk '/^WEBVIEW_/ {print $1; exit}')
+[ -n "$WEB" ] || fail "the Slider Demo's page is not inspectable — rebuild MobiumApp"
+at_exit "$M context NATIVE_APP >/dev/null 2>&1"
+$M context "$WEB" >/dev/null
+$M map | grep -q " Range, 0 to 100 in steps of 10 (slider, 50)" || fail "the range does not map as a slider by its label: $($M map)"
+for p in "0.2 20" "1 100" "0 0" "0.35 40"; do
+ set -- $p
+ $M fill testid=webRange "$1" >/dev/null || fail "fill the range to $1"
+ $M text | grep -q "^range: $2\$" || fail "the range filled to $1 reads $($M text | tail -1), want $2"
+done
+refused 2 "from 0" $M fill testid=webRange 20
+refused 2 "app_fill" $M type testid=webRange 20
+$M text | grep -q "^range: 40\$" || fail "a refused fill or type moved the range"
+row "range" "a slider by its label; 0.2, 1, 0, 0.35 land on 20, 100, 0, 40"
+row "refusals" "a value and app_type refused before moving"
+
+$M context NATIVE_APP >/dev/null
+$M terminate "$APP" >/dev/null 2>&1 || true
+echo PASS
diff --git a/docs/checks/slider.sh b/docs/checks/slider.sh
index 4b59bee1..1792be9c 100755
--- a/docs/checks/slider.sh
+++ b/docs/checks/slider.sh
@@ -10,7 +10,8 @@
# plain XCUIElementTypeOther with no Adjustable trait and no value, so iOS —
# VoiceOver included — has nothing to call a slider, and WebDriverAgent moves
# only a Slider element. Ice Cubes' native sliders are the iOS control, in
-# icecubes-ios.sh. See ROADMAP, "Sliders".
+# icecubes-ios.sh, and the Slider Demo on iOS — that plain view, an
+# accessible one and a web range — is slider-ios.sh. See ROADMAP, "Sliders".
#
# A slider is filled with a position from 0 (the start of its track) to 1
# (the end), and the app says where it landed (CHALLENGES 219, 227):
@@ -22,7 +23,9 @@
# 0.25 within 0.03: a seek bar is touched on its track and lands
# exactly. Each fill starts from an end anyway, as it must on iOS;
# - map --diff says the value moved;
-# - a position that is not a number from 0 to 1, and app_type, are refused.
+# - a position that is not a number from 0 to 1, and app_type, are refused;
+# - a web page's range, which the WebView draws as a seek bar named by its
+# label, is filled through the page and lands exactly (CHALLENGES 319).
set -e
DEV="$1"
if [ -z "$DEV" ]; then echo "usage: $0 " >&2; exit 2; fi
@@ -34,7 +37,7 @@ row() { printf ' %-12s %-58s ok\n' "$1" "$2"; }
APP=dev.mobium.mobiumapp
case "$DEV" in
*-*-*-*-*|????????-????????????????)
- echo "slider.sh is Android only: on iOS the Slider Demo's sliders report no slider at all — see the top of this file" >&2
+ echo "slider.sh is Android only: on iOS the Slider Demo is slider-ios.sh — see the top of this file" >&2
exit 2 ;;
*) M="$ROOT/bin/mobium --device $DEV" ;;
esac
@@ -46,6 +49,9 @@ $M terminate "$APP" >/dev/null 2>&1 || true
$M launch "$APP" >/dev/null
$M scroll-to "label=Slider Demo" >/dev/null 2>&1 || true
$M tap "label=Slider Demo" >/dev/null || fail "MobiumApp has no Slider Demo — rebuild it"
+# Waited for, or the first map can read the screen being left (CHALLENGES 233).
+$M wait testid=sliderState --timeout 10s >/dev/null || fail "the Slider Demo did not open"
+$M wait 'text="Range, 0 to 100 in steps of 10",role=slider' --timeout 10s >/dev/null || fail "the Slider Demo's page did not load — rebuild MobiumApp"
state() { $M text testid=sliderState; }
volume() { state | sed -n 's/^volume: \([0-9]*\),.*/\1/p'; }
balance() { state | sed -n 's/.*balance: \([0-9.]*\),.*/\1/p'; }
@@ -82,5 +88,24 @@ out=$($M type "$VOL" 5 2>&1) && fail "type on a slider was accepted: $out"
echo "$out" | grep -q "app_fill" || fail "type on a slider was refused without naming app_fill: $out"
row "refusals" "abc refused before moving; type refused, naming app_fill"
+# A web page's range (CHALLENGES 319): Android's WebView draws it as a seek
+# bar whose text is its label and which reports no progress, so natively it
+# is named by that label, and a fill goes through the page, which lands it
+# exactly from its own min, max and step. In the page it is a slider too.
+RANGE='text="Range, 0 to 100 in steps of 10",role=slider'
+$M map | grep -q " Range, 0 to 100 in steps of 10 (slider)" || fail "the page's range is not a slider named by its label natively: $($M map)"
+out=$($M fill "$RANGE" 0.3 2>&1) || fail "fill the page's range natively: $out"
+echo "$out" | grep -q 'reads "30"' || fail "the page's range filled to 0.3 natively: $out"
+WEB=$($M contexts | awk '/^WEBVIEW_/ {print $1; exit}')
+[ -n "$WEB" ] || fail "the Slider Demo's page is not inspectable — rebuild MobiumApp"
+at_exit "$M context NATIVE_APP >/dev/null 2>&1"
+$M context "$WEB" >/dev/null
+$M map | grep -q " Range, 0 to 100 in steps of 10 (slider, 30)" || fail "the range does not map as a slider at 30 in its page: $($M map)"
+$M fill testid=webRange 0.8 >/dev/null || fail "fill the range in its page"
+$M text | grep -q "^range: 80\$" || fail "the range filled to 0.8 reads $($M text | tail -1)"
+out=$($M type testid=webRange 20 2>&1) && fail "type on the page's range was accepted: $out"
+$M context NATIVE_APP >/dev/null
+row "web range" "named by its label natively; 0.3 and 0.8 land on 30 and 80"
+
$M terminate "$APP" >/dev/null 2>&1 || true
echo PASS
diff --git a/internal/agent/handlers.go b/internal/agent/handlers.go
index 287529e5..6c21a308 100644
--- a/internal/agent/handlers.go
+++ b/internal/agent/handlers.go
@@ -2089,6 +2089,14 @@ func (h *Handlers) typeTextOn(ctx context.Context, s *session, args map[string]i
}
// A slider is filled with a position, and not typed into.
if uitree.HasClassRole(node, "slider") {
+ // A page's range, drawn by its WebView, is moved through the page
+ // when the page can be reached, which knows its min, max and step
+ // and lands exactly; natively only when it cannot (CHALLENGES 319).
+ if replace && inWebView(node) {
+ if res, ok, err := h.typeThroughPage(ctx, s, target, node, text, replace); ok {
+ return res, err
+ }
+ }
if !replace {
return nil, failedCheck(mobiumerr.InvalidArgument, target, checkEditable, "it is a slider, not a text field",
"app_fill moves a slider to a position from 0 to 1").
diff --git a/internal/agent/schema.go b/internal/agent/schema.go
index 3a20c16d..d10114dc 100644
--- a/internal/agent/schema.go
+++ b/internal/agent/schema.go
@@ -271,7 +271,9 @@ func GetToolSchemas() []Tool {
"the value the slider then reports, as map shows it. On Android the track is " +
"touched at the position and the slider lands there; on iOS the thumb is moved " +
"as a finger would move it: from either end it lands exactly, from partway it " +
- "can land a step past, so to land exactly there, fill 0 or 1 first.",
+ "can land a step past, so to land exactly there, fill 0 or 1 first. A web page's " +
+ " is a slider too, set from the page's own min, max and step, so " +
+ "it lands exactly.",
InputSchema: map[string]interface{}{
"type": "object",
"properties": withDevice(map[string]interface{}{
diff --git a/internal/agent/webrange_test.go b/internal/agent/webrange_test.go
new file mode 100644
index 00000000..e299f212
--- /dev/null
+++ b/internal/agent/webrange_test.go
@@ -0,0 +1,65 @@
+package agent
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/mobiumdev/mobium/internal/mobiumerr"
+ "github.com/mobiumdev/mobium/internal/webview"
+)
+
+// rangePage answers every script with one canned answer and keeps the last.
+type rangePage struct {
+ layoutPage
+ answer string
+ got string
+}
+
+func (p *rangePage) Evaluate(ctx context.Context, expr string) (string, error) {
+ p.got = expr
+ return p.answer, nil
+}
+
+func fillRange(t *testing.T, p *rangePage, text string, replace bool) (*ToolsCallResult, error) {
+ t.Helper()
+ h := NewHandlers()
+ h.implicitWait = 0
+ s := &session{dev: fakeDevice(), driver: &hostDriver{}, backend: BackendUIA2, web: p, webCtx: "WEBVIEW_test"}
+ el := webview.Element{Tag: "input", Type: "range", Label: "Range", TestID: "webRange", Value: "50"}
+ return h.webFill(context.Background(), s, "testid=webRange", func(context.Context) (webview.Element, error) {
+ return el, nil
+ }, text, replace)
+}
+
+// A web range is a slider: filled with a position from 0 to 1 like a native
+// one, and never typed into. fill 0.2 typed "0.2", the page clamped it to a
+// step, and the failure said the page had refused it (CHALLENGES 319).
+func TestAWebRangeIsFilledByPosition(t *testing.T) {
+ p := &rangePage{answer: `{"status":"ok","value":"20","was":"50","want":"20","matches":true}`}
+ res, err := fillRange(t, p, "0.2", true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(p.got, "const pos = 0.2;") || strings.Contains(p.got, "const text") {
+ t.Errorf("the page was not asked to move the range to 0.2:\n%.300s", p.got)
+ }
+ v := res.StructuredContent.(ActionView)
+ if v.Value != "20" || v.Position == nil || *v.Position != 0.2 || !strings.Contains(textOf(res), `reads "20", was "50"`) {
+ t.Errorf("result %q, %+v", textOf(res), v)
+ }
+
+ if _, err := fillRange(t, &rangePage{}, "20", true); mobiumerr.CodeOf(err) != mobiumerr.InvalidArgument ||
+ !strings.Contains(err.Error(), "from 0") {
+ t.Errorf("a value, not a position: %v", err)
+ }
+ if _, err := fillRange(t, &rangePage{}, "20", false); mobiumerr.CodeOf(err) != mobiumerr.InvalidArgument ||
+ !strings.Contains(err.Error(), "app_fill") {
+ t.Errorf("type on a range: %v", err)
+ }
+ p = &rangePage{answer: `{"status":"ok","value":"40","was":"50","want":"20","matches":false}`}
+ if _, err := fillRange(t, p, "0.2", true); mobiumerr.CodeOf(err) != mobiumerr.NotConfirmed ||
+ !strings.Contains(err.Error(), "reads 40") {
+ t.Errorf("a page that rewrote the value: %v", err)
+ }
+}
diff --git a/internal/agent/webview.go b/internal/agent/webview.go
index 7eda0987..e8c2dd11 100644
--- a/internal/agent/webview.go
+++ b/internal/agent/webview.go
@@ -379,6 +379,9 @@ func (h *Handlers) listWeb(ctx context.Context, s *session, els []webview.Elemen
label = e.Tag
}
line := fmt.Sprintf("%s %s (%s)", ref, label, role)
+ if e.Value != "" {
+ line = fmt.Sprintf("%s %s (%s, %s)", ref, label, role, e.Value)
+ }
if e.Frame != "" {
// Which frame it is in: MobiumApp's three frames each hold a
// "Tap me", and without this the three lines read the same.
@@ -388,7 +391,7 @@ func (h *Handlers) listWeb(ctx context.Context, s *session, els []webview.Elemen
// Bounds are reported in device pixels, like everything else, so a
// web element and a native one are directly comparable.
- item := ElementView{Ref: ref, Label: label, Role: role, Context: s.webCtx}
+ item := ElementView{Ref: ref, Label: label, Role: role, Context: s.webCtx, Value: e.Value}
if frameErr == nil {
item.Bounds = boundsView(frame.ToDevice(e.X, e.Y, e.W, e.H))
}
@@ -820,6 +823,46 @@ func (h *Handlers) webFill(ctx context.Context, s *session, target string,
if err != nil {
return nil, err
}
+ // A range is a slider, filled with a position as a native one is,
+ // and not typed into: fill 0.2 typed "0.2" and the page clamped it
+ // to a step, which read as "the page rewrote or refused the value".
+ // The page gives its min, max and step, so the move lands exactly
+ // (CHALLENGES 319).
+ if el.Type == "range" {
+ if !replace {
+ return nil, failedCheck(mobiumerr.InvalidArgument, target, checkEditable, "it is a slider, not a text field",
+ "app_fill moves a slider to a position from 0 to 1").
+ WithRemedy("app_fill with a position from 0 (the start of its track) to 1 (the end)")
+ }
+ pos, err := sliderPosition(text)
+ if err != nil {
+ return nil, err
+ }
+ r, err := webview.SetRange(ctx, s.web, el, pos)
+ if err != nil {
+ return nil, err
+ }
+ switch {
+ case r.Status == "not_found":
+ return nil, mobiumerr.New(mobiumerr.NoSuchElement, "%s is no longer on the page — the content changed, run app_map again", target)
+ case r.Status == "ok" && !r.Matches:
+ return nil, mobiumerr.New(mobiumerr.NotConfirmed, "moved %s to position %s, which is %s on its "+
+ "track, and the page reads %s — the page rewrote the value", target, formatPosition(pos), r.Want, r.Value)
+ case r.Status == "ok":
+ return Result(fmt.Sprintf("moved %s to position %s in %s — it reads %q, was %q", target,
+ formatPosition(pos), s.webCtx, r.Value, r.Was),
+ ActionView{Action: "fill", Target: target, Context: s.webCtx, Value: r.Value, Position: &pos}), nil
+ }
+ if time.Now().After(deadline) {
+ return nil, webCheckFailed(target, &webview.Actionability{Status: r.Status, Check: r.Check, Reason: r.Reason}, h.implicitWait)
+ }
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-time.After(100 * time.Millisecond):
+ }
+ continue
+ }
f, err := webview.Fill(ctx, s.web, el, text, appendText)
if err != nil {
return nil, err
diff --git a/internal/uitree/parse.go b/internal/uitree/parse.go
index 68e21404..dd26cf21 100644
--- a/internal/uitree/parse.go
+++ b/internal/uitree/parse.go
@@ -372,13 +372,27 @@ func nodeFrom(e xml.StartElement, parent *Node, sibling int) *Node {
// A seek bar's text is its progress in its own units — MobiumApp's
// Volume, 50 to the app, read "5.0" on the Pixel 8 Pro — which is its
// state, not its name: kept as its value, as iOS's is (CHALLENGES 227).
- if HasClassRole(n, "slider") {
+ // Not one a WebView draws for a page's : Chrome
+ // gives its seek bar the range's label as its text and no value, so
+ // the label read as "the value" and the seek bar was named SeekBar
+ // (CHALLENGES 319).
+ if HasClassRole(n, "slider") && !insideWebView(n) {
n.Value, n.Text = n.Text, ""
n.ValueSteps = n.Value != ""
}
return n
}
+// insideWebView reports a node a WebView draws for its page.
+func insideWebView(n *Node) bool {
+ for p := n.Parent; p != nil; p = p.Parent {
+ if strings.HasSuffix(p.Class, "WebView") {
+ return true
+ }
+ }
+ return false
+}
+
// Package reports the app in the foreground, taken from the hierarchy that was
// already fetched rather than from a separate device call.
//
diff --git a/internal/uitree/testdata/mobiumapp-sliders-uia2.xml b/internal/uitree/testdata/mobiumapp-sliders-uia2.xml
new file mode 100644
index 00000000..a98c2c5e
--- /dev/null
+++ b/internal/uitree/testdata/mobiumapp-sliders-uia2.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/internal/uitree/webseekbar_test.go b/internal/uitree/webseekbar_test.go
new file mode 100644
index 00000000..cb914f2f
--- /dev/null
+++ b/internal/uitree/webseekbar_test.go
@@ -0,0 +1,37 @@
+package uitree
+
+import (
+ "os"
+ "testing"
+)
+
+// Android's seek bar keeps its progress as its text (227); the one a WebView
+// draws for a page's keeps the range's label there and
+// reports no progress, and was mapped as "SeekBar" valued by its label.
+// MobiumApp's Slider Demo on the Android 15 emulator. CHALLENGES 319.
+func TestAWebViewsSeekBarIsNamedByItsText(t *testing.T) {
+ raw, err := os.ReadFile("testdata/mobiumapp-sliders-uia2.xml")
+ if err != nil {
+ t.Fatal(err)
+ }
+ tree, err := ParseAndroid(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ seen := map[string]Entry{}
+ for _, e := range tree.Map() {
+ if e.Role == "slider" {
+ seen[e.Label] = e
+ }
+ }
+ if e, ok := seen["Level"]; !ok || e.Node.Value != "5.0" || !e.Node.ValueSteps {
+ t.Errorf("Level, a native seek bar: %+v", seen["Level"])
+ }
+ e, ok := seen["Range, 0 to 100 in steps of 10"]
+ if !ok {
+ t.Fatalf("the page's range is not a slider named by its label: %v", seen)
+ }
+ if e.Node.Value != "" || e.Node.ValueSteps {
+ t.Errorf("the page's range has a value it was never given: %q", e.Node.Value)
+ }
+}
diff --git a/internal/webview/actionable.go b/internal/webview/actionable.go
index 7393e711..e664a27e 100644
--- a/internal/webview/actionable.go
+++ b/internal/webview/actionable.go
@@ -273,9 +273,10 @@ const fillScript = `(() => {
// fillableInputTypesJS is Vibium's list of the input types a fill can set:
// text-like inputs and the value-bearing pickers, not checkboxes, radios,
-// files or buttons.
+// files or buttons — nor a range, which is a slider and moved by position
+// (rangeScript, CHALLENGES 319).
const fillableInputTypesJS = `['text','password','email','number','search','tel','url',` +
- `'range','color','date','time','datetime-local','month','week']`
+ `'color','date','time','datetime-local','month','week']`
// Fill puts text into the index-th element of the map, after the page says it
// can take it, and reports whether it now holds exactly that — the text alone,
@@ -307,6 +308,74 @@ func Fill(ctx context.Context, p Page, el Element, value string, appendText bool
return &f, nil
}
+// rangeScript moves the index-th candidate, a range, to a position from 0
+// to 1 of its track: the value is worked out from the page's own min, max
+// and step — which a native slider does not report (CHALLENGES 219) — set
+// through the native setter, announced with the events a drag causes, and
+// read back. The checks are a fill's: visible and in view, enabled.
+const rangeScript = `(() => {
+ ` + candidatesJS + `
+ const el = __mobiumCandidates()[%d];
+ const pos = %g;
+ if (!el) return JSON.stringify({status:'not_found'});
+ const fail = (check, reason) => JSON.stringify({status:'failed', check, reason});
+ let rect = __mobiumRect(el);
+ if (rect.top < 0 || rect.left < 0 || rect.bottom > window.innerHeight || rect.right > window.innerWidth) {
+ el.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
+ rect = __mobiumRect(el);
+ }
+ if (rect.width === 0 || rect.height === 0) return fail('visible', 'zero size');
+ const win = el.ownerDocument.defaultView;
+ const style = win.getComputedStyle(el);
+ if (style.visibility === 'hidden' || style.display === 'none') return fail('visible', 'hidden');
+ if (el.disabled === true) return fail('enabled', 'disabled attribute');
+ if (el.getAttribute('aria-disabled') === 'true') return fail('enabled', 'aria-disabled');
+ const num = (v, d) => { const n = parseFloat(v); return isNaN(n) ? d : n; };
+ const min = num(el.min, 0), max = num(el.max, 100);
+ const step = el.step === 'any' ? 0 : num(el.step, 1);
+ let want = min + pos * (max - min);
+ if (step > 0) want = Math.min(max, min + Math.round((want - min) / step) * step);
+ want = Number(want.toPrecision(12));
+ const was = el.value;
+ Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, 'value').set.call(el, String(want));
+ el.dispatchEvent(new Event('input', {bubbles: true}));
+ el.dispatchEvent(new Event('change', {bubbles: true}));
+ return JSON.stringify({status: 'ok', value: el.value, was: was, want: String(want),
+ matches: Math.abs(parseFloat(el.value) - want) < 1e-9});
+})()`
+
+// Ranged is what a page says after one of its ranges was moved.
+type Ranged struct {
+ Status string `json:"status"`
+ Check string `json:"check,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ Value string `json:"value"`
+ Was string `json:"was"`
+ Want string `json:"want"`
+ Matches bool `json:"matches"`
+}
+
+// SetRange moves a range to a position from 0 to 1 of its track.
+func SetRange(ctx context.Context, p Page, el Element, pos float64) (*Ranged, error) {
+ var in evaluator = p
+ if el.context != 0 {
+ c, ok := p.(contextual)
+ if !ok {
+ return &Ranged{Status: "not_found"}, nil
+ }
+ in = evalIn{c, el.context}
+ }
+ raw, err := in.Evaluate(ctx, fmt.Sprintf(rangeScript, el.local, pos))
+ if err != nil {
+ return nil, err
+ }
+ var r Ranged
+ if err := json.Unmarshal([]byte(raw), &r); err != nil {
+ return nil, mobiumerr.New(mobiumerr.DeviceServer, "could not read the page's answer to moving a range: %w", err)
+ }
+ return &r, nil
+}
+
// checkedScript is the checked state of the index-th candidate, as the page
// holds it: an input's own `checked` for a checkbox or radio, `aria-checked`
// for an element whose role has one. No state at all for anything else —
diff --git a/internal/webview/actionable_test.go b/internal/webview/actionable_test.go
index 8a059079..ecf751ab 100644
--- a/internal/webview/actionable_test.go
+++ b/internal/webview/actionable_test.go
@@ -112,3 +112,25 @@ func TestCheckedReadsThePagesOwnState(t *testing.T) {
}
}
}
+
+// A range is a slider to map — named by its label, not its value — and not
+// a text field to a fill (CHALLENGES 319).
+func TestARangeIsASlider(t *testing.T) {
+ if r := (Element{Tag: "input", Type: "range"}).NeutralRole(); r != "slider" {
+ t.Errorf("a range's role is %q", r)
+ }
+ if strings.Contains(fillableInputTypesJS, "'range'") {
+ t.Error("a fill would type into a range")
+ }
+ if !strings.Contains(mapScript, "t === 'range' ? '' : e.value") {
+ t.Error("map names a range by its value")
+ }
+ f := &cannedPage{answer: `{"status":"ok","value":"0.25","was":"0.5","want":"0.25","matches":true}`}
+ r, err := SetRange(context.Background(), f, Element{local: 3}, 0.25)
+ if err != nil || !r.Matches || r.Value != "0.25" {
+ t.Fatalf("%+v, %v", r, err)
+ }
+ if !strings.Contains(f.got, "__mobiumCandidates()[3]") || !strings.Contains(f.got, "const pos = 0.25;") {
+ t.Errorf("the script does not move candidate 3 to 0.25:\n%.300s", f.got)
+ }
+}
diff --git a/internal/webview/map.go b/internal/webview/map.go
index 394751a0..8ed0969d 100644
--- a/internal/webview/map.go
+++ b/internal/webview/map.go
@@ -108,8 +108,11 @@ const mapScript = `(() => {
// label a person reads is. Only for those two: every other field keeps
// what it shows.
const t = (e.getAttribute('type') || '').toLowerCase();
- const named = (t === 'checkbox' || t === 'radio') && e.labels && e.labels.length ? e.labels[0].innerText : '';
- const label = (named || e.innerText || e.value || e.getAttribute('aria-label') ||
+ // A range's value is a position, not a name, as a checkbox's is: it
+ // printed as "50 (input)" (CHALLENGES 319).
+ const labeled = t === 'checkbox' || t === 'radio' || t === 'range';
+ const named = labeled && e.labels && e.labels.length ? e.labels[0].innerText : '';
+ const label = (named || e.innerText || (t === 'range' ? '' : e.value) || e.getAttribute('aria-label') ||
e.getAttribute('placeholder') || e.getAttribute('title') ||
e.getAttribute('alt') || '').trim().replace(/\s+/g, ' ').slice(0, 60);
out.push({
@@ -120,6 +123,7 @@ const mapScript = `(() => {
testid: e.getAttribute('data-testid') || '',
label: label,
disabled: !!e.disabled,
+ value: t === 'range' ? String(e.value) : '',
frame: cands.paths[i],
x: r.x, y: r.y, w: r.width, h: r.height
});
@@ -136,6 +140,9 @@ type Element struct {
TestID string `json:"testid"`
Label string `json:"label"`
Disabled bool `json:"disabled"`
+ // Value is a range's value as the page holds it, and empty for anything
+ // else: a slider's state, as a native one's is.
+ Value string `json:"value,omitempty"`
// Frame is the frame the element is in, as a path of frame selectors
// from the top page — "iframe#sameFrame >> iframe#nestedFrame" — or ""
// for the page itself.
@@ -344,6 +351,8 @@ func (e Element) NeutralRole() string {
return "checkbox"
case "radio":
return "radio"
+ case "range":
+ return "slider"
case "button", "submit", "reset", "image":
return "button"
default:
From 8fee7055c37a70b52b5b075aadb53b81ab4cde66 Mon Sep 17 00:00:00 2001
From: Lana Begunova <70295997+lana-20@users.noreply.github.com>
Date: Tue, 13 Oct 2026 15:16:42 -0700
Subject: [PATCH 2/2] Two-thumb sliders in a page: ARIA sliders mapped and
moved by their keys, a range focused before it moves (CHALLENGES 320)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
On MobiumApp's Slider Demo, Hours — two role="slider" thumbs on a drawn
track — was not in map: the page's element list had no [role=slider]. And
Price — two ranges on one track — moved the minimum when the maximum was
filled below it, reporting the maximum landed: the page gives way by which
thumb has focus, and the fill never focused one. A range is now focused
before it moves, and a crossing fails as not_confirmed. An ARIA slider maps
by its label with aria-valuetext or aria-valuenow as its value, and app_fill
takes a position of aria-valuemin to aria-valuemax and moves it with its
arrow keys, reading aria-valuenow back after each; stopped by the page short
of the value, it says so, and one whose keys move nothing is refused naming
a drag. slider-ios.sh and slider.sh assert both: 3 of 3 on the simulator
and the emulator, failing at the ARIA sliders with the binary before.
---
docs/CHALLENGES.md | 30 ++++++++++++
docs/ROADMAP.md | 4 +-
docs/checks/README.md | 4 +-
docs/checks/slider-ios.sh | 18 +++++++
docs/checks/slider.sh | 23 +++++++++
internal/agent/schema.go | 3 +-
internal/agent/webrange_test.go | 29 +++++++++++
internal/agent/webview.go | 28 +++++++++--
internal/webview/actionable.go | 75 ++++++++++++++++++++++++++++-
internal/webview/actionable_test.go | 18 +++++++
internal/webview/map.go | 8 +--
11 files changed, 228 insertions(+), 12 deletions(-)
diff --git a/docs/CHALLENGES.md b/docs/CHALLENGES.md
index 05ac9e3d..6d163564 100644
--- a/docs/CHALLENGES.md
+++ b/docs/CHALLENGES.md
@@ -6867,6 +6867,36 @@ which had passed by timing; it waits for the screen now. `slider.sh` passed
three times in three on the emulator and `slider-ios.sh` three in three on
the simulator, which failed at the range with the binary before this fix.
+### 320. A two-thumb slider built from ARIA was not in `map`, and one built from ranges moved the wrong thumb
+
+**Found by:** measuring two-thumb sliders, as pages build them, on
+MobiumApp's Slider Demo: Price, two ` ` laid over one
+track, the minimum kept at or below the maximum by the page; and Hours, two
+`role="slider"` thumbs on a drawn track, moved by a finger or the arrow
+keys, the way component libraries build one. Hours was not in `map` at
+all: the list of a page's elements had no `[role=slider]`. Price mapped as
+two sliders by their `aria-label`s, as 319 made them, but filling the
+maximum below the minimum moved the **minimum**, and `fill` reported the
+maximum's value as landed: the page decides which thumb gives way by which
+has focus, and the fill set a value without focusing — where a finger on a
+thumb focuses it. **Fixed:** a range is focused before it is moved, so the
+page holds the thumb that was not moved, and the fill fails as
+`not_confirmed`, saying the page rewrote the value. An ARIA slider is in
+`map` by its label, with `aria-valuetext` or `aria-valuenow` as its value
+— `Start hour (slider, 9:00)` — and `app_fill` takes a position from 0 to 1
+of `aria-valuemin` to `aria-valuemax`, the one rule for every slider, and
+moves it with its arrow keys, which WAI-ARIA requires a slider to answer,
+reading `aria-valuenow` back after each: it stops on the value, or between
+two steps at the closer, or — another thumb in the way — fails, saying the
+page stopped it. One whose keys move nothing is refused as unsupported,
+naming a drag. On the iPhone 17 Pro simulator and the Android 15 emulator,
+0.25 and 0.5 of 24 hours landed on 6:00 and 12:00, and the start pushed
+toward 24 stopped at the end's 12:00 and said so; `slider-ios.sh` and
+`slider.sh` passed three times in three on each, and the iOS check failed
+at the missing ARIA sliders with the binary before. Native two-thumb
+sliders — Material's `RangeSlider`, Compose's, an iOS app's own — are not
+measured yet (ROADMAP).
+
## Findings that were not defects
Worth recording because each one closed off an approach that looked obvious.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 6ff41bc7..83476787 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -948,7 +948,9 @@ this is what is not.
a fill that lands exactly from partway, which WebDriverAgent cannot
support — it reports no thumb position, and its range is real for a
UIKit slider and 0 to 1 for SwiftUI's (CHALLENGES 219, 2026-10-13);
- range sliders, two thumbs on one track; and the phone's text
+ two-thumb sliders natively — Material's RangeSlider, Compose's, an iOS
+ app's own (a page's, two ranges or two ARIA sliders, since 2026-10-13,
+ CHALLENGES 320); and the phone's text
size, which is a slider in Settings. A custom control that does carry the
Adjustable trait — Pocket Casts' scrubber — maps as `adjustable` with its
value since 2026-10-05 (CHALLENGES 234), and `app_fill` refuses it,
diff --git a/docs/checks/README.md b/docs/checks/README.md
index 5abda931..fbb1ace0 100644
--- a/docs/checks/README.md
+++ b/docs/checks/README.md
@@ -92,8 +92,8 @@ checks do not: `clean-stop.sh`, which stops the caller's daemon, and
| [pocketcasts-ios.sh](pocketcasts-ios.sh) | **A fourth third-party app on iOS**: Pocket Casts, a podcast player, signed out, built from its source for a simulator or from the App Store on a phone, and CHALLENGES 234–239 held against it: onboarding's button mapped disabled and `map --diff` saying when it is enabled, a row's checkmark not a target of its own, Discover's rows and a podcast's header named by their words rather than the buttons and images inside them, Play touched clear of the divider through its center, the player's first-run tip said by `map` and `app_alert` and closed where they say, and the scrubber mapped as adjustable with `app_fill` refused. On a simulator, iPhone or iPad, the app's data is cleared first; a phone's onboarding and tip run only on a fresh install, and the app is first brought back to Discover from wherever it was left. An episode is played for a moment and paused |
| [kiwix-ios.sh](kiwix-ios.sh) | **A fifth third-party app on iOS, and the first whose WebView opens**: Kiwix, the offline Wikipedia reader, built from its source for a simulator or from the App Store on an iPhone, and CHALLENGES 244–248 and 256 held against it with a pinned ZIM: each catalog card one entry, off-screen ambiguity told to name one, an article tile one link named once, the disabled List button refused by its own ref, and a tap in the WebView — taller than its page, under the bars — opening the article it names; a page under the Library neither mapped nor tapped. A simulator starts from cleared data, with the ZIM uploaded and opened through the system document picker; a phone keeps its data, so the ZIM must already be in Kiwix's library from its own download, and 245 is reached only with no page under the Library |
| [feed.sh](feed.sh) | **A list that grows as it is scrolled**, on MobiumApp's Feed Demo: twenty rows a page, the next 2.5 seconds after the end is reached, three pages in all. `scroll-to` reaches Row 55 across two loads instead of stopping at the first page's end, says Row 70 is past the real end at once, and an unknown role is refused rather than waited on (CHALLENGES 222, 223). Passed on the simulator, the iPhone and the Pixel 8 Pro |
-| [slider.sh](slider.sh) | **Sliders on Android**, on MobiumApp's Slider Demo: Volume (0–100 in tens) and Balance (0–1, continuous) map as sliders by name with a value, are filled by position and land exactly — Volume on 30, 100 and 0, Balance on 0.25 — `map --diff` says the value moved, and a non-number and `type` are refused (CHALLENGES 227). Passed on the Pixel 8 Pro. Android only: on iOS the demo is [slider-ios.sh](slider-ios.sh) |
-| [slider-ios.sh](slider-ios.sh) | **Sliders on iOS, three kinds**, on MobiumApp's Slider Demo: Volume, a plain view to iOS (`map` calls it a button), where `fill` is refused as unsupported naming `swipe` and a drag lands it (CHALLENGES 318); Level, the same slider made accessible, mapped as adjustable with its value, `fill` refused naming a drag and the drag landing it (234); and a web page's range, a slider by its label, filled by position and landing exactly from the page's min, max and step, with a value and `type` refused (319). Passed 2026-10-13 three times in three on the iPhone 17 Pro simulator, about 25 seconds; with the binary before 319 it failed at the range |
+| [slider.sh](slider.sh) | **Sliders on Android**, on MobiumApp's Slider Demo: Volume (0–100 in tens) and Balance (0–1, continuous) map as sliders by name with a value, are filled by position and land exactly — Volume on 30, 100 and 0, Balance on 0.25 — `map --diff` says the value moved, and a non-number and `type` are refused (CHALLENGES 227); a web page's range, named by its label from the native side and filled through its page (319), and the two-thumb Price and Hours (320). Passed on the Pixel 8 Pro; with the web rows, 2026-10-13, three in three on the Android 15 emulator. Android only: on iOS the demo is [slider-ios.sh](slider-ios.sh) |
+| [slider-ios.sh](slider-ios.sh) | **Sliders on iOS, three kinds**, on MobiumApp's Slider Demo: Volume, a plain view to iOS (`map` calls it a button), where `fill` is refused as unsupported naming `swipe` and a drag lands it (CHALLENGES 318); Level, the same slider made accessible, mapped as adjustable with its value, `fill` refused naming a drag and the drag landing it (234); and a web page's range, a slider by its label, filled by position and landing exactly from the page's min, max and step, with a value and `type` refused (319); and two two-thumb sliders, Price (two ranges) and Hours (two ARIA sliders), each thumb a slider, a crossing stopped by the page and said so (320). Passed 2026-10-13 three times in three on the iPhone 17 Pro simulator, about 30 seconds; it failed at the range with the binary before 319 and at the ARIA sliders with the one before 320 |
| [frames.sh](frames.sh) | **Frames inside a WebView**, on MobiumApp's Frames page: in the WebView's context every frame's elements are mapped with their frame — same-origin and nested through the page, cross-origin through its own execution context — each button tapped reaches its frame, the cross-origin card field is filled and added to as the frame reports, and `NATIVE_APP` reaches the cross-origin button too (CHALLENGES 228, 229). Passed on the iPhone simulator and an emulator |
| [compose-app.sh](compose-app.sh) | **A Jetpack Compose app**, Seal from F-Droid, the first Compose app driven here, and CHALLENGES 176–178 held against it. Its icon buttons map once each as buttons. Its first-launch dialog, which the platform's alert endpoint does not know, is read by `alert` and not accepted, a tap on what it covers is refused naming it, and a rule answers it by its Close button. A switch row is set by its words, read back and put back, and a row with no state is refused. An app the check installed is uninstalled after, and on a phone nothing read off it is printed. Emulators and phones |
| [map-diff.sh](map-diff.sh) | **`map --diff`**, on MobiumApp's Form Demo. The first diff of a session says it had nothing to compare with and adds the whole screen. A map with nothing done between says nothing changed, the control that it can come back empty. A box checked is its state and what it was, and on iOS, where its row grows, what is under it moves as one line, not five. A screen left is its elements gone and the next screen's added, and a ref from the diff acts. Emulators and simulators |
diff --git a/docs/checks/slider-ios.sh b/docs/checks/slider-ios.sh
index eeae717a..7a03e5a8 100755
--- a/docs/checks/slider-ios.sh
+++ b/docs/checks/slider-ios.sh
@@ -96,6 +96,24 @@ refused 2 "from 0" $M fill testid=webRange 20
refused 2 "app_fill" $M type testid=webRange 20
$M text | grep -q "^range: 40\$" || fail "a refused fill or type moved the range"
row "range" "a slider by its label; 0.2, 1, 0, 0.35 land on 20, 100, 0, 40"
+
+# Two thumbs on one track, as pages build them (CHALLENGES 320): two ranges
+# laid over each other, and two ARIA sliders, which map did not list. Each
+# thumb is its own slider. A thumb pushed past the other is the page's to
+# stop, and fill says the page stopped it rather than claiming the position.
+$M map | grep -q " Minimum price (slider, 200)" || fail "Price's thumbs are not two sliders: $($M map)"
+$M map | grep -q " Start hour (slider, 9:00)" || fail "the ARIA sliders are not in map: $($M map)"
+$M fill testid=priceMin 0.5 >/dev/null || fail "fill Price's minimum"
+$M fill testid=priceMax 0.9 >/dev/null || fail "fill Price's maximum"
+$M text | grep -q "^price: 500 to 900\$" || fail "Price reads $($M text | grep '^price')"
+refused 7 "rewrote" $M fill testid=priceMax 0.3
+$M text | grep -q "^price: 500 to 500\$" || fail "the maximum pushed below the minimum left $($M text | grep '^price') — the other thumb moved"
+$M fill testid=hoursStart 0.25 >/dev/null || fail "fill the start hour"
+$M fill testid=hoursEnd 0.5 >/dev/null || fail "fill the end hour"
+$M text | grep -q "^hours: 6 to 12\$" || fail "Hours reads $($M text | grep '^hours')"
+refused 7 "stopped it at 12:00" $M fill testid=hoursStart 1
+row "two thumbs" "Price and Hours each two sliders; a crossing is the page's to stop"
+
row "refusals" "a value and app_type refused before moving"
$M context NATIVE_APP >/dev/null
diff --git a/docs/checks/slider.sh b/docs/checks/slider.sh
index 1792be9c..b45a281d 100755
--- a/docs/checks/slider.sh
+++ b/docs/checks/slider.sh
@@ -33,6 +33,13 @@ ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
. "$ROOT/docs/checks/lib.sh"
check_lock "$DEV"
fail() { echo "FAIL: $*" >&2; exit 1; }
+# refused : refused with that exit status, saying so.
+refused() {
+ want=$1 words=$2; shift 2
+ set +e; out=$("$@" 2>&1); got=$?; set -e
+ [ "$got" = "$want" ] || fail "$*: exit $got, want $want: $out"
+ echo "$out" | grep -q "$words" || fail "$*: refused without saying \"$words\": $out"
+}
row() { printf ' %-12s %-58s ok\n' "$1" "$2"; }
APP=dev.mobium.mobiumapp
case "$DEV" in
@@ -104,6 +111,22 @@ $M map | grep -q " Range, 0 to 100 in steps of 10 (slider, 30)" || fail "the ran
$M fill testid=webRange 0.8 >/dev/null || fail "fill the range in its page"
$M text | grep -q "^range: 80\$" || fail "the range filled to 0.8 reads $($M text | tail -1)"
out=$($M type testid=webRange 20 2>&1) && fail "type on the page's range was accepted: $out"
+# Two thumbs on one track, as pages build them (CHALLENGES 320): two ranges
+# laid over each other, and two ARIA sliders, which map did not list. Each
+# thumb is its own slider. A thumb pushed past the other is the page's to
+# stop, and fill says the page stopped it rather than claiming the position.
+$M map | grep -q " Minimum price (slider, 200)" || fail "Price's thumbs are not two sliders: $($M map)"
+$M map | grep -q " Start hour (slider, 9:00)" || fail "the ARIA sliders are not in map: $($M map)"
+$M fill testid=priceMin 0.5 >/dev/null || fail "fill Price's minimum"
+$M fill testid=priceMax 0.9 >/dev/null || fail "fill Price's maximum"
+$M text | grep -q "^price: 500 to 900\$" || fail "Price reads $($M text | grep '^price')"
+refused 7 "rewrote" $M fill testid=priceMax 0.3
+$M text | grep -q "^price: 500 to 500\$" || fail "the maximum pushed below the minimum left $($M text | grep '^price') — the other thumb moved"
+$M fill testid=hoursStart 0.25 >/dev/null || fail "fill the start hour"
+$M fill testid=hoursEnd 0.5 >/dev/null || fail "fill the end hour"
+$M text | grep -q "^hours: 6 to 12\$" || fail "Hours reads $($M text | grep '^hours')"
+refused 7 "stopped it at 12:00" $M fill testid=hoursStart 1
+row "two thumbs" "Price and Hours each two sliders; a crossing is the page's to stop"
$M context NATIVE_APP >/dev/null
row "web range" "named by its label natively; 0.3 and 0.8 land on 30 and 80"
diff --git a/internal/agent/schema.go b/internal/agent/schema.go
index d10114dc..dcb38dd0 100644
--- a/internal/agent/schema.go
+++ b/internal/agent/schema.go
@@ -273,7 +273,8 @@ func GetToolSchemas() []Tool {
"as a finger would move it: from either end it lands exactly, from partway it " +
"can land a step past, so to land exactly there, fill 0 or 1 first. A web page's " +
" is a slider too, set from the page's own min, max and step, so " +
- "it lands exactly.",
+ "it lands exactly; an ARIA slider (role=slider) is moved there with its arrow keys. " +
+ "A thumb the page stops short — another thumb in the way — fails as not_confirmed.",
InputSchema: map[string]interface{}{
"type": "object",
"properties": withDevice(map[string]interface{}{
diff --git a/internal/agent/webrange_test.go b/internal/agent/webrange_test.go
index e299f212..43920ad2 100644
--- a/internal/agent/webrange_test.go
+++ b/internal/agent/webrange_test.go
@@ -63,3 +63,32 @@ func TestAWebRangeIsFilledByPosition(t *testing.T) {
t.Errorf("a page that rewrote the value: %v", err)
}
}
+
+// An ARIA slider is moved by its arrow keys, the only way WAI-ARIA gives,
+// and one stopped by the page short of its value is refused as a range's
+// clamp is, saying the page stopped it (CHALLENGES 320).
+func TestAnARIASliderIsMovedByItsKeys(t *testing.T) {
+ h := NewHandlers()
+ h.implicitWait = 0
+ run := func(answer string) (*ToolsCallResult, *rangePage, error) {
+ p := &rangePage{answer: answer}
+ s := &session{dev: fakeDevice(), driver: &hostDriver{}, backend: BackendUIA2, web: p, webCtx: "WEBVIEW_test"}
+ el := webview.Element{Tag: "div", Role: "slider", Label: "Start hour", TestID: "hoursStart", Value: "9:00"}
+ res, err := h.webFill(context.Background(), s, "testid=hoursStart", func(context.Context) (webview.Element, error) {
+ return el, nil
+ }, "0.25", true)
+ return res, p, err
+ }
+ res, p, err := run(`{"status":"ok","value":"6:00","was":"9:00","want":"6","matches":true}`)
+ if err != nil || !strings.Contains(p.got, "ArrowRight") || !strings.Contains(textOf(res), "by its arrow keys") {
+ t.Errorf("%q, %v", textOf(res), err)
+ }
+ if _, _, err := run(`{"status":"ok","value":"12:00","was":"9:00","want":"24","matches":false}`); mobiumerr.CodeOf(err) != mobiumerr.NotConfirmed ||
+ !strings.Contains(err.Error(), "stopped it at 12:00") {
+ t.Errorf("stopped by the page: %v", err)
+ }
+ _, _, err = run(`{"status":"failed","check":"settable","reason":"the arrow keys do not move it"}`)
+ if e, ok := mobiumerr.As(err); !ok || e.Code != mobiumerr.Unsupported || !strings.Contains(e.Remedy, "app_swipe") {
+ t.Errorf("keys that move nothing: %v", err)
+ }
+}
diff --git a/internal/agent/webview.go b/internal/agent/webview.go
index e8c2dd11..d089d6af 100644
--- a/internal/agent/webview.go
+++ b/internal/agent/webview.go
@@ -828,7 +828,8 @@ func (h *Handlers) webFill(ctx context.Context, s *session, target string,
// to a step, which read as "the page rewrote or refused the value".
// The page gives its min, max and step, so the move lands exactly
// (CHALLENGES 319).
- if el.Type == "range" {
+ aria := el.Type != "range" && el.NeutralRole() == "slider"
+ if el.Type == "range" || aria {
if !replace {
return nil, failedCheck(mobiumerr.InvalidArgument, target, checkEditable, "it is a slider, not a text field",
"app_fill moves a slider to a position from 0 to 1").
@@ -838,19 +839,38 @@ func (h *Handlers) webFill(ctx context.Context, s *session, target string,
if err != nil {
return nil, err
}
- r, err := webview.SetRange(ctx, s.web, el, pos)
+ set := webview.SetRange
+ if aria {
+ set = webview.SetARIASlider
+ }
+ r, err := set(ctx, s.web, el, pos)
if err != nil {
return nil, err
}
switch {
+ case r.Status == "failed" && r.Check == "settable":
+ return nil, failedCheck(mobiumerr.Unsupported, target, "settable", r.Reason,
+ "a slider the page draws itself is moved as a finger moves it").
+ WithRemedy("app_swipe from its thumb along its track, with x1, y1, x2, y2; then app_map to read its value")
case r.Status == "not_found":
return nil, mobiumerr.New(mobiumerr.NoSuchElement, "%s is no longer on the page — the content changed, run app_map again", target)
+ case r.Status == "ok" && !r.Matches && aria:
+ return nil, mobiumerr.New(mobiumerr.NotConfirmed, "moved %s toward position %s, which is %s on its "+
+ "track, by its arrow keys, and the page stopped it at %s — another of its thumbs, or its own "+
+ "rule, holds it there", target, formatPosition(pos), r.Want, r.Value)
case r.Status == "ok" && !r.Matches:
return nil, mobiumerr.New(mobiumerr.NotConfirmed, "moved %s to position %s, which is %s on its "+
"track, and the page reads %s — the page rewrote the value", target, formatPosition(pos), r.Want, r.Value)
case r.Status == "ok":
- return Result(fmt.Sprintf("moved %s to position %s in %s — it reads %q, was %q", target,
- formatPosition(pos), s.webCtx, r.Value, r.Was),
+ how := ""
+ if aria {
+ how = " by its arrow keys"
+ }
+ if r.Nearest {
+ how += fmt.Sprintf(", the closest its keys reach to %s", r.Want)
+ }
+ return Result(fmt.Sprintf("moved %s to position %s in %s%s — it reads %q, was %q", target,
+ formatPosition(pos), s.webCtx, how, r.Value, r.Was),
ActionView{Action: "fill", Target: target, Context: s.webCtx, Value: r.Value, Position: &pos}), nil
}
if time.Now().After(deadline) {
diff --git a/internal/webview/actionable.go b/internal/webview/actionable.go
index e664a27e..757cf512 100644
--- a/internal/webview/actionable.go
+++ b/internal/webview/actionable.go
@@ -336,6 +336,10 @@ const rangeScript = `(() => {
let want = min + pos * (max - min);
if (step > 0) want = Math.min(max, min + Math.round((want - min) / step) * step);
want = Number(want.toPrecision(12));
+ // Focused first, as a finger on its thumb focuses it: a page with two
+ // thumbs decides which one gives way by which has focus, and unfocused,
+ // filling the maximum below the minimum moved the minimum instead.
+ el.focus();
const was = el.value;
Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, 'value').set.call(el, String(want));
el.dispatchEvent(new Event('input', {bubbles: true}));
@@ -344,6 +348,68 @@ const rangeScript = `(() => {
matches: Math.abs(parseFloat(el.value) - want) < 1e-9});
})()`
+// ariaSliderScript moves the index-th candidate, an ARIA slider, toward a
+// position from 0 to 1 the only way the page offers: WAI-ARIA has a slider
+// answer the arrow keys, and nothing sets its value from outside. It is
+// focused, as a finger or a keyboard would focus it, and arrows are pressed
+// toward the value at that position of aria-valuemin to aria-valuemax, each
+// read back from aria-valuenow, stopping at the closest value the keys
+// reach. A key that moves nothing is a slider that does not answer keys.
+const ariaSliderScript = `(() => {
+ ` + candidatesJS + `
+ const el = __mobiumCandidates()[%d];
+ const pos = %g;
+ if (!el) return JSON.stringify({status:'not_found'});
+ const fail = (check, reason) => JSON.stringify({status:'failed', check, reason});
+ let rect = __mobiumRect(el);
+ if (rect.top < 0 || rect.left < 0 || rect.bottom > window.innerHeight || rect.right > window.innerWidth) {
+ el.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
+ rect = __mobiumRect(el);
+ }
+ if (rect.width === 0 || rect.height === 0) return fail('visible', 'zero size');
+ if (el.getAttribute('aria-disabled') === 'true') return fail('enabled', 'aria-disabled');
+ const num = n => parseFloat(el.getAttribute(n));
+ const min = num('aria-valuemin'), max = num('aria-valuemax');
+ if (isNaN(min) || isNaN(max) || isNaN(num('aria-valuenow')))
+ return fail('settable', 'it has no aria-valuemin, aria-valuemax and aria-valuenow to aim by');
+ const want = min + pos * (max - min);
+ const was = el.getAttribute('aria-valuetext') || el.getAttribute('aria-valuenow');
+ el.focus();
+ const press = key => {
+ el.dispatchEvent(new KeyboardEvent('keydown', {key, code: key, bubbles: true, cancelable: true}));
+ el.dispatchEvent(new KeyboardEvent('keyup', {key, code: key, bubbles: true, cancelable: true}));
+ };
+ // Stopped by the page before the value — another thumb in the way — is
+ // the page refusing it, as a range's clamp is; stopped between two steps
+ // is the closest value the keys reach.
+ let blocked = false;
+ for (let i = 0; i < 1000; i++) {
+ const now = num('aria-valuenow');
+ if (now === want) break;
+ const key = now < want ? 'ArrowRight' : 'ArrowLeft';
+ press(key);
+ const next = num('aria-valuenow');
+ if (next === now) {
+ if (i === 0 && (key === 'ArrowRight' ? now < max : now > min)) {
+ const other = Array.from(document.querySelectorAll('[role=slider]')).some(s => s !== el);
+ if (!other) return fail('settable', 'the arrow keys do not move it');
+ }
+ blocked = true;
+ break;
+ }
+ if (Math.abs(next - want) > Math.abs(now - want)) { press(key === 'ArrowRight' ? 'ArrowLeft' : 'ArrowRight'); break; }
+ }
+ const now = num('aria-valuenow');
+ return JSON.stringify({status: 'ok', value: el.getAttribute('aria-valuetext') || String(now), was: was,
+ want: String(Number(want.toPrecision(12))), matches: !blocked, nearest: !blocked && now !== want});
+})()`
+
+// SetARIASlider moves an ARIA slider toward a position from 0 to 1 of its
+// range with the arrow keys.
+func SetARIASlider(ctx context.Context, p Page, el Element, pos float64) (*Ranged, error) {
+ return setRanged(ctx, p, el, ariaSliderScript, pos)
+}
+
// Ranged is what a page says after one of its ranges was moved.
type Ranged struct {
Status string `json:"status"`
@@ -353,10 +419,17 @@ type Ranged struct {
Was string `json:"was"`
Want string `json:"want"`
Matches bool `json:"matches"`
+ // Nearest says the keys could not reach the value at the position, and
+ // it stopped at the closest one they reach.
+ Nearest bool `json:"nearest,omitempty"`
}
// SetRange moves a range to a position from 0 to 1 of its track.
func SetRange(ctx context.Context, p Page, el Element, pos float64) (*Ranged, error) {
+ return setRanged(ctx, p, el, rangeScript, pos)
+}
+
+func setRanged(ctx context.Context, p Page, el Element, script string, pos float64) (*Ranged, error) {
var in evaluator = p
if el.context != 0 {
c, ok := p.(contextual)
@@ -365,7 +438,7 @@ func SetRange(ctx context.Context, p Page, el Element, pos float64) (*Ranged, er
}
in = evalIn{c, el.context}
}
- raw, err := in.Evaluate(ctx, fmt.Sprintf(rangeScript, el.local, pos))
+ raw, err := in.Evaluate(ctx, fmt.Sprintf(script, el.local, pos))
if err != nil {
return nil, err
}
diff --git a/internal/webview/actionable_test.go b/internal/webview/actionable_test.go
index ecf751ab..dcad0d0f 100644
--- a/internal/webview/actionable_test.go
+++ b/internal/webview/actionable_test.go
@@ -130,7 +130,25 @@ func TestARangeIsASlider(t *testing.T) {
if err != nil || !r.Matches || r.Value != "0.25" {
t.Fatalf("%+v, %v", r, err)
}
+ if !strings.Contains(f.got, "el.focus();\n const was") {
+ t.Error("the range is not focused before it is moved, as a finger on its thumb focuses it")
+ }
if !strings.Contains(f.got, "__mobiumCandidates()[3]") || !strings.Contains(f.got, "const pos = 0.25;") {
t.Errorf("the script does not move candidate 3 to 0.25:\n%.300s", f.got)
}
}
+
+// An ARIA slider is a candidate, a slider by role, with its value from
+// aria-valuetext or aria-valuenow: a two-thumb slider built that way was not
+// in map at all (CHALLENGES 320).
+func TestAnARIASliderIsMapped(t *testing.T) {
+ if !strings.Contains(candidatesJS, "[role=slider]") {
+ t.Error("an ARIA slider is not a candidate")
+ }
+ if r := (Element{Tag: "div", Role: "slider"}).NeutralRole(); r != "slider" {
+ t.Errorf("an ARIA slider's role is %q", r)
+ }
+ if !strings.Contains(mapScript, "aria-valuetext") {
+ t.Error("map does not read an ARIA slider's value")
+ }
+}
diff --git a/internal/webview/map.go b/internal/webview/map.go
index 8ed0969d..41927dce 100644
--- a/internal/webview/map.go
+++ b/internal/webview/map.go
@@ -35,7 +35,7 @@ import (
// through shadow roots, as Vibium does, and through frames the page can see
// into; a cross-origin frame is itself the answer.
const candidatesJS = `function __mobiumCandidates() {
- const sel = 'a,button,input,select,textarea,summary,[role=button],[role=link],[role=checkbox],[role=tab],[onclick],[contenteditable=true]';
+ const sel = 'a,button,input,select,textarea,summary,[role=button],[role=link],[role=checkbox],[role=tab],[role=slider],[onclick],[contenteditable=true]';
const out = [];
out.paths = [];
const visit = (doc, path) => {
@@ -123,7 +123,9 @@ const mapScript = `(() => {
testid: e.getAttribute('data-testid') || '',
label: label,
disabled: !!e.disabled,
- value: t === 'range' ? String(e.value) : '',
+ value: t === 'range' ? String(e.value) :
+ (e.getAttribute('role') || '').toLowerCase() === 'slider' ?
+ (e.getAttribute('aria-valuetext') || e.getAttribute('aria-valuenow') || '') : '',
frame: cands.paths[i],
x: r.x, y: r.y, w: r.width, h: r.height
});
@@ -332,7 +334,7 @@ func (f *Frame) ToDevice(x, y, w, h float64) uitree.Rect {
func (e Element) NeutralRole() string {
if e.Role != "" {
switch e.Role {
- case "button", "link", "checkbox", "tab", "radio":
+ case "button", "link", "checkbox", "tab", "radio", "slider":
return e.Role
}
}