mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
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:
7 files changed
+43
-413
No files matched your search
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user