diff --git a/Makefile b/Makefile index c61f0e2..a474602 100644 --- a/Makefile +++ b/Makefile @@ -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/_*') diff --git a/internal/testrun/driver.go b/internal/testrun/driver.go index 5fa8f46..79860d3 100644 --- a/internal/testrun/driver.go +++ b/internal/testrun/driver.go @@ -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 diff --git a/internal/testrun/driver_test.go b/internal/testrun/driver_test.go new file mode 100644 index 0000000..658a18a --- /dev/null +++ b/internal/testrun/driver_test.go @@ -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) + } +} diff --git a/sidecar/build.gradle.kts b/sidecar/build.gradle.kts index ecc6d08..022cc14 100644 --- a/sidecar/build.gradle.kts +++ b/sidecar/build.gradle.kts @@ -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("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) diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt index cd00549..a8dece6 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt @@ -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 withReconnect(replay: Boolean = true, block: () -> T): T = - recovery.run(replay, block) - - override fun launch(bundleId: String, clearState: Boolean, env: Map) = 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 = 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 = 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 { - 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, - ) -} diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt index 7cd3c1d..39f9a5e 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt @@ -66,13 +66,9 @@ fun main(arguments: Array) { 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) diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/IosHierarchyTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/IosHierarchyTest.kt deleted file mode 100644 index 12db274..0000000 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/IosHierarchyTest.kt +++ /dev/null @@ -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 = 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 - assertEquals("Button", attributes["class"]) - assertEquals("LoginSubmit", attributes["resource-id"]) - assertEquals("Sign in", attributes["accessibilityText"]) - } - - @Test - fun textFieldIsEditableNotClickable() { - val node = iosAxElementToTreeNode(element(elementType = 49, value = "demo@folio.app")) - assertEquals(false, node["clickable"]) - assertEquals(true, node["editable"]) - @Suppress("UNCHECKED_CAST") - val attributes = node["attributes"] as Map - assertEquals("TextField", attributes["class"]) - assertEquals("demo@folio.app", attributes["text"]) - assertEquals("demo@folio.app", attributes["value"]) - } - - @Test - fun titleWinsOverValueForText() { - val node = iosAxElementToTreeNode(element(elementType = 48, title = "Heading", value = "ignored")) - @Suppress("UNCHECKED_CAST") - val attributes = node["attributes"] as Map - 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 - 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 - 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 - 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 - 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> - assertEquals(1, children.size) - @Suppress("UNCHECKED_CAST") - val childAttributes = children[0]["attributes"] as Map - assertEquals("Inner", childAttributes["text"]) - } - - @Test - fun unknownElementTypeFallsBackToOther() { - assertEquals("Other", iosElementTypeName(999)) - } -}