Remove the JVM sidecar's iOS backend (#65)

* refactor(sidecar): drop IosDriverBackend

* refactor(sidecar): route ios platform off the iOS backend

* chore(sidecar): remove maestro ios dependencies

* feat(testrun): reject physical iOS with a clear message

* refactor(sidecar): drop iOS hierarchy helpers and their test

* build(sidecar): strip iOS runner bundles and classes from the fat jar

* test(testrun): cover physical-iOS rejection
This commit is contained in:
pj authored and GitHub committed 2026-06-08 20:29:35 +05:30
1 parent 406b7516b3
commit e04631d4c9
7 files changed
+43 -413

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@ COMPANION_PREPARE := internal/driver/ioscompanion/companionassets/prepare.sh
RUNNER_EMBED := internal/driver/ioscompanion/runnerassets/assets/runner-1.0.0.tar.gz
RUNNER_PREPARE := companion/prepare.sh
RUNNER_SRC := $(shell find companion/Sources -type f -name '*.swift' 2>/dev/null) companion/project.yml
SIDECAR_SRC := $(shell find sidecar/src -type f \( -name '*.kt' -o -name '*.kts' \) 2>/dev/null) build.gradle.kts settings.gradle.kts
SIDECAR_SRC := $(shell find sidecar/src -type f \( -name '*.kt' -o -name '*.kts' \) 2>/dev/null) sidecar/build.gradle.kts build.gradle.kts settings.gradle.kts
SANDERLING_BIN := bin/sanderling
DOCS_SRC := $(shell find docs -type f -name '*.md' -not -path 'docs/_*')
+8 -8
View File
@@ -21,8 +21,9 @@ import (
// buildDriver creates the appropriate DeviceDriver for the platform and returns
// a cleanup function. For web, ChromeDriver is used directly. An iOS simulator
// is driven by the native simulator companion (no JVM). Android and physical
// iOS devices use the JVM sidecar, which is extracted, spawned, and dialed.
// is driven by the native simulator companion (no JVM). Android uses the JVM
// sidecar, which is extracted, spawned, and dialed. Physical iOS devices are
// not yet supported.
func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver.DeviceDriver, func(), error) {
if err := Preflight(ctx, options.Platform); err != nil {
return nil, nil, err
@@ -45,7 +46,11 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
return d, d.Close, nil
}
// Physical iOS devices and Android use the JVM sidecar, which requires java.
if options.Platform == "ios" {
return nil, nil, fmt.Errorf("physical-device iOS is not yet supported; run against a simulator instead")
}
// Android uses the JVM sidecar, which requires java.
if err := preflightDevice(options.Platform); err != nil {
return nil, nil, err
}
@@ -65,11 +70,6 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
"--port", strconv.Itoa(sidecarPort),
"--platform", options.Platform,
}
if options.Platform == "ios" {
if options.iosUDID != "" {
sidecarArgs = append(sidecarArgs, "--udid", options.iosUDID)
}
}
sidecarCommand := exec.CommandContext(ctx, "java", sidecarArgs...)
sidecarCommand.Stdout = stdout
sidecarCommand.Stderr = stdout
+28
View File
@@ -0,0 +1,28 @@
package testrun
import (
"context"
"io"
"os/exec"
"strings"
"testing"
)
func TestBuildDriverRejectsPhysicalIOS(t *testing.T) {
if _, err := exec.LookPath("xcrun"); err != nil {
t.Skip("xcrun not on PATH; physical-iOS rejection is reached only after preflight")
}
options := Options{Platform: "ios"}
options.iosIsSimulator = false
_, cleanup, err := buildDriver(context.Background(), options, io.Discard)
if cleanup != nil {
cleanup()
}
if err == nil {
t.Fatal("expected physical-device iOS to be rejected")
}
if !strings.Contains(err.Error(), "not yet supported") {
t.Fatalf("expected an unsupported-physical-iOS error, got %v", err)
}
}
+6 -2
View File
@@ -44,8 +44,6 @@ configurations.all {
dependencies {
implementation("dev.mobile:maestro-client:$maestroVersion")
implementation("dev.mobile:maestro-utils:$maestroVersion")
implementation("dev.mobile:maestro-ios:$maestroVersion")
implementation("dev.mobile:maestro-ios-driver:$maestroVersion")
implementation("io.grpc:grpc-netty-shaded:$grpcVersion")
implementation("io.grpc:grpc-protobuf:$grpcVersion")
@@ -100,6 +98,12 @@ application {
tasks.named<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar>("shadowJar") {
archiveFileName.set("sidecar-all.jar")
exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA")
// The iOS driving modules arrive transitively through maestro-client but
// the sidecar only drives Android, so strip the iOS UITest runner bundles
// and iOS-only classes from the fat JAR. Compilation keeps the full
// classpath; only the packaged artifact drops these.
exclude("driver-iphoneos/**", "driver-iPhoneSimulator/**")
exclude("ios/**", "xcuitest/**", "hierarchy/**")
}
// JUnit 4 (vintage) for the gRPC GrpcCleanupRule + JUnit 5 (jupiter)
@@ -759,166 +759,6 @@ internal class WdaRecovery(
}
}
class IosDriverBackend(private val udid: String) : DriverBackend {
private lateinit var driver: maestro.drivers.IOSDriver
private lateinit var localDevice: ios.LocalIOSDevice
private lateinit var installer: xcuitest.installer.LocalXCTestInstaller
private val recovery by lazy {
WdaRecovery(
isAlive = { runCatching { installer.isChannelAlive() }.getOrElse { false } },
restart = { driver.open(); warmup() },
)
}
init {
val reaped = reapOrphanIosRunners(udid) { command ->
runCatching { ProcessBuilder(command).start().waitFor() }.getOrDefault(1)
}
if (reaped) {
println("terminated orphaned XCTest runner session for $udid")
// Give the killed session a beat to tear down before installing ours.
Thread.sleep(1000)
}
val wdaPort = maestro.utils.SocketUtils.nextFreePort(22000, 23000)
val tempFileHandler = maestro.utils.TempFileHandler()
val simctlDevice = device.SimctlIOSDevice(
deviceId = udid,
tempFileHandler = tempFileHandler,
)
val driverConfig = xcuitest.installer.LocalXCTestInstaller.IOSDriverConfig(
prebuiltRunner = true,
sourceDirectory = "driver-iPhoneSimulator",
context = xcuitest.installer.Context.CLI,
snapshotKeyHonorModalViews = null,
)
installer = xcuitest.installer.LocalXCTestInstaller(
deviceId = udid,
host = "127.0.0.1",
deviceType = util.IOSDeviceType.SIMULATOR,
defaultPort = wdaPort,
iOSDriverConfig = driverConfig,
deviceController = simctlDevice,
tempFileHandler = tempFileHandler,
)
val xcTestClient = xcuitest.XCTestClient("127.0.0.1", wdaPort)
val xcTestDriverClient = xcuitest.XCTestDriverClient(
installer = installer,
client = xcTestClient,
)
val xcRunnerUtils = util.XCRunnerCLIUtils(tempFileHandler)
val xcTestDevice = ios.xctest.XCTestIOSDevice(
deviceId = udid,
client = xcTestDriverClient,
getInstalledApps = { xcRunnerUtils.listApps(udid) },
)
val device = ios.LocalIOSDevice(
deviceId = udid,
xcTestDevice = xcTestDevice,
deviceController = simctlDevice,
insights = maestro.utils.NoopInsights,
)
localDevice = device
driver = maestro.drivers.IOSDriver(device, maestro.utils.NoopInsights)
driver.open()
warmup()
}
private fun warmup() {
var warmupErr: Exception? = null
repeat(3) { attempt ->
try {
driver.contentDescriptor(false)
warmupErr = null
return@repeat
} catch (e: Exception) {
warmupErr = e
if (attempt < 2) Thread.sleep(500)
}
}
warmupErr?.let { throw IllegalStateException("WDA warmup failed after 3 attempts: $it") }
}
private fun <T> withReconnect(replay: Boolean = true, block: () -> T): T =
recovery.run(replay, block)
override fun launch(bundleId: String, clearState: Boolean, env: Map<String, String>) = withReconnect {
runCatching { driver.stopApp(bundleId) }
if (clearState) driver.clearAppState(bundleId)
driver.launchApp(bundleId, env)
}
override fun terminate(bundleId: String) = withReconnect { driver.stopApp(bundleId) }
override fun tap(x: Int, y: Int) = withReconnect(replay = false) { driver.tap(maestro.Point(x, y)) }
// The second tap request is already queued at the XCTest runner while the
// first executes, so the on-device gap collapses to the runner's
// turnaround instead of a full transport round trip. Sequential requests
// leave a gap wide enough for the app to navigate between the taps.
override fun doubleTap(x: Int, y: Int): Unit = withReconnect(replay = false) {
overlappedDoubleTap { driver.tap(maestro.Point(x, y)) }
}
override fun longPress(x: Int, y: Int) = withReconnect(replay = false) { driver.longPress(maestro.Point(x, y)) }
override fun tapSelector(selector: String) = withReconnect(replay = false) {
val root = driver.contentDescriptor(false)
val bounds = findBoundsBySelector(root, selector) ?: return@withReconnect
driver.tap(maestro.Point((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2))
}
override fun inputText(text: String) = withReconnect(replay = false) { driver.inputText(text) }
override fun eraseText(characterCount: Int) = withReconnect(replay = false) { driver.eraseText(characterCount) }
override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) = withReconnect(replay = false) {
driver.swipe(maestro.Point(fromX, fromY), maestro.Point(toX, toY), maxOf(durationMillis, 250L))
}
override fun pressKey(key: String) = withReconnect(replay = false) {
StubDriverBackend.KEY_MAP[key]?.let { keyCode ->
keyCodeToMaestro(keyCode)?.let { driver.pressKey(it) }
}
Unit
}
override fun screenshot(): Triple<ByteArray, Int, Int> = withReconnect {
val buf = okio.Buffer()
driver.takeScreenshot(buf, false)
val bytes = buf.readByteArray()
Triple(bytes, pngWidth(bytes), pngHeight(bytes))
}
override fun hierarchy(): String = withReconnect {
com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
.writeValueAsString(iosAxElementToTreeNode(localDevice.viewHierarchy(false).axElement))
}
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> = emptyList()
override fun waitForIdle(durationMillis: Long) = withReconnect {
driver.waitForAppToSettle(null, null, durationMillis.toInt())
pollUntilStable(STABILITY_POLL_CAP_MILLIS) {
stabilitySnapshot(
com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
.writeValueAsString(driver.contentDescriptor(false)),
)
}
Unit
}
override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false }
override fun metrics(bundleId: String) = MetricsSample(0.0, 0L, 0L)
// close stops the XCTest runner session (kills the xcodebuild process and
// uninstalls the runner app). Skipping this leaves an orphaned session
// that xcodebuild later restarts, killing the next run's session.
override fun close() {
runCatching { driver.close() }
}
}
private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? {
return when (adbKeyCode) {
"KEYCODE_BACK" -> maestro.KeyCode.BACK
@@ -932,115 +772,3 @@ private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? {
else -> null
}
}
// XCUIElementType raw values that accept a tap. The XCTest hierarchy carries
// no clickable flag, so tappability is derived from the element type that
// XCUITest itself derives from the accessibility traits.
internal val IOS_CLICKABLE_ELEMENT_TYPES = setOf(
9, // button
10, // radioButton
12, // checkBox
14, // popUpButton
15, // comboBox
16, // menuButton
17, // toolbarButton
20, // key
37, // segmentedControl
40, // switch
41, // toggle
42, // link
54, // menuItem
75, // cell
79, // stepper
80, // tab
)
// XCUIElementType raw values that accept text input.
internal val IOS_EDITABLE_ELEMENT_TYPES = setOf(
45, // searchField
49, // textField
50, // secureTextField
52, // textView
)
// XCUIElementType raw values that scroll.
internal val IOS_SCROLLABLE_ELEMENT_TYPES = setOf(
26, // table
32, // collectionView
46, // scrollView
58, // webView
)
// Checkable element types and the value convention ("1" when on) mirror the
// upstream content-descriptor mapping.
internal val IOS_CHECKABLE_ELEMENT_TYPES = setOf(
12, // checkBox
40, // switch
41, // toggle
)
private val IOS_ELEMENT_TYPE_NAMES = arrayOf(
"Any", "Other", "Application", "Group", "Window", "Sheet", "Drawer",
"Alert", "Dialog", "Button", "RadioButton", "RadioGroup", "CheckBox",
"DisclosureTriangle", "PopUpButton", "ComboBox", "MenuButton",
"ToolbarButton", "Popover", "Keyboard", "Key", "NavigationBar", "TabBar",
"TabGroup", "Toolbar", "StatusBar", "Table", "TableRow", "TableColumn",
"Outline", "OutlineRow", "Browser", "CollectionView", "Slider",
"PageIndicator", "ProgressIndicator", "ActivityIndicator",
"SegmentedControl", "Picker", "PickerWheel", "Switch", "Toggle", "Link",
"Image", "Icon", "SearchField", "ScrollView", "ScrollBar", "StaticText",
"TextField", "SecureTextField", "DatePicker", "TextView", "Menu",
"MenuItem", "MenuBar", "MenuBarItem", "Map", "WebView", "IncrementArrow",
"DecrementArrow", "Timeline", "RatingIndicator", "ValueIndicator",
"SplitGroup", "Splitter", "RelevanceIndicator", "ColorWell", "HelpTag",
"Matte", "DockItem", "Ruler", "RulerMarker", "Grid", "LevelIndicator",
"Cell", "LayoutArea", "LayoutItem", "Handle", "Stepper", "Tab",
"TouchBar", "StatusItem",
)
internal fun iosElementTypeName(elementType: Int): String =
IOS_ELEMENT_TYPE_NAMES.getOrElse(elementType) { "Other" }
// iosAxElementToTreeNode maps the raw XCTest accessibility tree to the
// TreeNode JSON shape the runner parses. The upstream content-descriptor
// mapping drops the element type, leaving no way to tell buttons and text
// fields apart, so fuzz verbs found no tap or typing candidates on iOS.
internal fun iosAxElementToTreeNode(element: hierarchy.AXElement): Map<String, Any?> {
val elementType = element.elementType
val title = element.title.orEmpty()
val value = element.value.orEmpty()
val label = element.label.orEmpty()
val editable = elementType in IOS_EDITABLE_ELEMENT_TYPES
val checked = elementType in IOS_CHECKABLE_ELEMENT_TYPES && value == "1"
// text carries the element's visible string, matching what Android
// surfaces in its text attribute. Editable fields expose only their
// typed content: their label is the field caption (e.g. "Email"), and
// leaking it into text would make an empty field look filled. Static
// text and buttons carry the visible string in the label.
val text = if (editable) value else title.ifEmpty { value }.ifEmpty { label }
val attributes = linkedMapOf(
"accessibilityText" to label,
"title" to title,
"value" to value,
"text" to text,
"hintText" to element.placeholderValue.orEmpty(),
"resource-id" to element.identifier.orEmpty(),
"bounds" to element.frame.boundsString,
"class" to iosElementTypeName(elementType),
"enabled" to element.enabled.toString(),
"focused" to element.hasFocus.toString(),
"selected" to element.selected.toString(),
"checked" to checked.toString(),
"scrollable" to (elementType in IOS_SCROLLABLE_ELEMENT_TYPES).toString(),
)
return linkedMapOf(
"attributes" to attributes,
"children" to element.children.map { iosAxElementToTreeNode(it) },
"clickable" to (elementType in IOS_CLICKABLE_ELEMENT_TYPES),
"enabled" to element.enabled,
"focused" to element.hasFocus,
"checked" to checked,
"selected" to element.selected,
"editable" to editable,
)
}
@@ -66,13 +66,9 @@ fun main(arguments: Array<String>) {
val serial = arguments.indexOf("--serial").let { index ->
if (index >= 0 && index + 1 < arguments.size) arguments[index + 1] else null
}
val udid = arguments.indexOf("--udid").let { index ->
if (index >= 0 && index + 1 < arguments.size) arguments[index + 1] else null
}
val backend: DriverBackend = when (platform) {
"android" -> MaestroDriverBackend(serial)
"ios" -> IosDriverBackend(udid ?: error("--udid required for ios platform"))
else -> StubDriverBackend(platform)
}
val service = DriverService(platform = platform, backend = backend)
@@ -1,126 +0,0 @@
package dev.sanderling.sidecar
import hierarchy.AXElement
import hierarchy.AXFrame
import org.junit.Test
import kotlin.test.assertEquals
class IosHierarchyTest {
private fun element(
elementType: Int,
label: String = "",
identifier: String = "",
title: String = "",
value: String = "",
placeholderValue: String = "",
children: ArrayList<AXElement> = arrayListOf(),
) = AXElement(
label,
elementType,
identifier,
0,
0L,
0,
false,
0,
false,
placeholderValue,
value,
AXFrame(10f, 20f, 100f, 50f),
true,
title,
children,
)
@Test
fun buttonIsClickableNotEditable() {
val node = iosAxElementToTreeNode(element(elementType = 9, label = "Sign in", identifier = "LoginSubmit"))
assertEquals(true, node["clickable"])
assertEquals(false, node["editable"])
@Suppress("UNCHECKED_CAST")
val attributes = node["attributes"] as Map<String, String>
assertEquals("Button", attributes["class"])
assertEquals("LoginSubmit", attributes["resource-id"])
assertEquals("Sign in", attributes["accessibilityText"])
}
@Test
fun textFieldIsEditableNotClickable() {
val node = iosAxElementToTreeNode(element(elementType = 49, value = "[email protected]"))
assertEquals(false, node["clickable"])
assertEquals(true, node["editable"])
@Suppress("UNCHECKED_CAST")
val attributes = node["attributes"] as Map<String, String>
assertEquals("TextField", attributes["class"])
assertEquals("[email protected]", attributes["text"])
assertEquals("[email protected]", attributes["value"])
}
@Test
fun titleWinsOverValueForText() {
val node = iosAxElementToTreeNode(element(elementType = 48, title = "Heading", value = "ignored"))
@Suppress("UNCHECKED_CAST")
val attributes = node["attributes"] as Map<String, String>
assertEquals("Heading", attributes["text"])
assertEquals("StaticText", attributes["class"])
}
@Test
fun staticTextFallsBackToLabelForText() {
val node = iosAxElementToTreeNode(element(elementType = 48, label = "$1,234.00"))
@Suppress("UNCHECKED_CAST")
val attributes = node["attributes"] as Map<String, String>
assertEquals("$1,234.00", attributes["text"])
}
@Test
fun buttonFallsBackToLabelForText() {
val node = iosAxElementToTreeNode(element(elementType = 9, label = "Sign in"))
@Suppress("UNCHECKED_CAST")
val attributes = node["attributes"] as Map<String, String>
assertEquals("Sign in", attributes["text"])
}
@Test
fun emptyTextFieldDoesNotLeakLabelIntoText() {
val node = iosAxElementToTreeNode(element(elementType = 49, label = "Email"))
@Suppress("UNCHECKED_CAST")
val attributes = node["attributes"] as Map<String, String>
assertEquals("", attributes["text"])
assertEquals("Email", attributes["accessibilityText"])
}
@Test
fun switchOnIsChecked() {
val node = iosAxElementToTreeNode(element(elementType = 40, value = "1"))
assertEquals(true, node["checked"])
assertEquals(true, node["clickable"])
}
@Test
fun scrollViewIsScrollable() {
val node = iosAxElementToTreeNode(element(elementType = 46))
@Suppress("UNCHECKED_CAST")
val attributes = node["attributes"] as Map<String, String>
assertEquals("true", attributes["scrollable"])
assertEquals("ScrollView", attributes["class"])
}
@Test
fun childrenAreMappedRecursively() {
val child = element(elementType = 48, title = "Inner")
val node = iosAxElementToTreeNode(element(elementType = 1, children = arrayListOf(child)))
@Suppress("UNCHECKED_CAST")
val children = node["children"] as List<Map<String, Any?>>
assertEquals(1, children.size)
@Suppress("UNCHECKED_CAST")
val childAttributes = children[0]["attributes"] as Map<String, String>
assertEquals("Inner", childAttributes["text"])
}
@Test
fun unknownElementTypeFallsBackToOther() {
assertEquals("Other", iosElementTypeName(999))
}
}