From 007dcddd69a6a5c428fd5c3e50143c2c1dedb8fb Mon Sep 17 00:00:00 2001 From: pjay Date: Thu, 23 Apr 2026 17:29:53 +0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(ios):=20iOS=20e2e=20support=20?= =?UTF-8?q?=E2=80=94=20Kotlin=20Native=20SDK=20+=20simulator=20driver=20+?= =?UTF-8?q?=20test-ios=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ios): add simulator management package * feat(testrun): add iOS platform path (simctl launch + direct TCP) * feat(cli): add --ios-device flag and IosDevice option * feat(sdk-ios): add Kotlin Native iOS SDK (TCP agent + POSIX socket + dispatch pauser) * feat(folio-ios): wire SanderlingIos.start() in MainViewController * feat(folio-ios): add test-ios justfile recipe * fix(sdk-ios): remove unavailable C macros; manual byte swap + no-cast warnings * fix(testrun): simctl-first launch order for iOS; Maestro init after SDK connects --- cmd/sanderling/main.go | 20 +-- cmd/sanderling/test_run.go | 15 +- .../kotlin/app/folio/MainViewController.kt | 5 + .../app/folio/sanderling/AccountSnapshots.kt | 18 +++ .../app/folio/sanderling/AuthSnapshots.kt | 10 ++ .../kotlin/app/folio/sanderling/IosAgent.kt | 90 +++++++++++ .../kotlin/app/folio/sanderling/IosPauser.kt | 25 +++ .../app/folio/sanderling/LedgerSnapshots.kt | 41 +++++ .../folio/sanderling/NavigationSnapshots.kt | 16 ++ .../app/folio/sanderling/SanderlingIos.kt | 36 +++++ .../app/folio/sanderling/TcpConnection.kt | 81 ++++++++++ examples/folio/justfile | 18 +++ internal/ios/ios.go | 153 ++++++++++++++++++ internal/testrun/testrun.go | 65 ++++++-- 14 files changed, 567 insertions(+), 26 deletions(-) create mode 100644 examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AccountSnapshots.kt create mode 100644 examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AuthSnapshots.kt create mode 100644 examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosAgent.kt create mode 100644 examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt create mode 100644 examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/LedgerSnapshots.kt create mode 100644 examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/NavigationSnapshots.kt create mode 100644 examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt create mode 100644 examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/TcpConnection.kt create mode 100644 internal/ios/ios.go diff --git a/cmd/sanderling/main.go b/cmd/sanderling/main.go index 7211769..aafc828 100644 --- a/cmd/sanderling/main.go +++ b/cmd/sanderling/main.go @@ -15,13 +15,14 @@ import ( var Version = "dev" type testOptions struct { - spec string - bundleID string - platform string - avd string - duration time.Duration - seed int64 - output string + spec string + bundleID string + platform string + avd string + iosDevice string + duration time.Duration + seed int64 + output string } const topUsage = `sanderling is a property-based UI fuzzer for mobile apps. @@ -44,8 +45,9 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) { var options testOptions flagSet.StringVar(&options.spec, "spec", "", "path to the TypeScript spec (required)") flagSet.StringVar(&options.bundleID, "bundle-id", "", "target app bundle ID (required)") - flagSet.StringVar(&options.platform, "platform", "android", "target platform: android, web") + flagSet.StringVar(&options.platform, "platform", "android", "target platform: android, ios, web") flagSet.StringVar(&options.avd, "avd", "", "Android AVD name to boot if no device is connected") + flagSet.StringVar(&options.iosDevice, "ios-device", "", "iOS simulator name or UDID to boot if none is running") flagSet.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration") flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)") flagSet.StringVar(&options.output, "output", "./runs", "output directory for traces") @@ -61,7 +63,7 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) { switch options.platform { case "android", "ios", "web": default: - return testOptions{}, fmt.Errorf("unsupported platform: %q (android, web)", options.platform) + return testOptions{}, fmt.Errorf("unsupported platform: %q (android, ios, web)", options.platform) } return options, nil } diff --git a/cmd/sanderling/test_run.go b/cmd/sanderling/test_run.go index 2d826d9..a3ffe1c 100644 --- a/cmd/sanderling/test_run.go +++ b/cmd/sanderling/test_run.go @@ -9,12 +9,13 @@ import ( func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) error { return testrun.Execute(ctx, testrun.Options{ - Spec: options.spec, - BundleID: options.bundleID, - Platform: options.platform, - AVD: options.avd, - Duration: options.duration, - Seed: options.seed, - Output: options.output, + Spec: options.spec, + BundleID: options.bundleID, + Platform: options.platform, + AVD: options.avd, + IosDevice: options.iosDevice, + Duration: options.duration, + Seed: options.seed, + Output: options.output, }, stdout) } diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt index bb8a853..1152cea 100644 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt @@ -29,6 +29,11 @@ private val backGestureTarget = BackGestureTarget() @OptIn(ExperimentalForeignApi::class) fun MainViewController(): UIViewController { Repository.init() + app.folio.sanderling.SanderlingIos.start() + app.folio.sanderling.AuthSnapshots + app.folio.sanderling.AccountSnapshots + app.folio.sanderling.LedgerSnapshots + app.folio.sanderling.NavigationSnapshots val vc = ComposeUIViewController { App() } val gesture = UIScreenEdgePanGestureRecognizer( target = backGestureTarget, diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AccountSnapshots.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AccountSnapshots.kt new file mode 100644 index 0000000..87ca22b --- /dev/null +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AccountSnapshots.kt @@ -0,0 +1,18 @@ +package app.folio.sanderling + +import app.folio.data.Repository +import app.folio.feature.account.AddAccountUiState +import app.folio.platform.balanceOf + +object AccountSnapshots { + val accountCount by SanderlingIos.snapshot { Repository.accounts.value.size } + val totalBalance by SanderlingIos.snapshot { balanceOf(Repository.transactions.value) } + val accounts by SanderlingIos.snapshot { + val txns = Repository.transactions.value + Repository.accounts.value.map { a -> + val rows = txns.filter { it.accountId == a.id } + mapOf("id" to a.id, "name" to a.name, "balance" to balanceOf(rows), "txnCount" to rows.size) + } + } + val addAccountError by SanderlingIos.snapshot { AddAccountUiState.addAccountError.value } +} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AuthSnapshots.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AuthSnapshots.kt new file mode 100644 index 0000000..a59b3a2 --- /dev/null +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/AuthSnapshots.kt @@ -0,0 +1,10 @@ +package app.folio.sanderling + +import app.folio.data.Repository +import app.folio.feature.auth.LoginUiState + +object AuthSnapshots { + val loggedIn by SanderlingIos.snapshot { Repository.session.value != null } + val authStatus by SanderlingIos.snapshot { if (Repository.session.value != null) "logged-in" else "logged-out" } + val loginError by SanderlingIos.snapshot { LoginUiState.loginError.value } +} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosAgent.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosAgent.kt new file mode 100644 index 0000000..6b271cc --- /dev/null +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosAgent.kt @@ -0,0 +1,90 @@ +package app.folio.sanderling + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSBundle +import platform.Foundation.NSThread + +@OptIn(ExperimentalForeignApi::class) +internal object IosAgent { + private const val VERSION = "0.0.1" + private const val PROTOCOL_VERSION = 1 + + fun start(host: String, port: Int) { + val thread = NSThread { runLoop(host, port) } + thread.name = "sanderling-agent" + thread.start() + } + + private fun runLoop(host: String, port: Int) { + var delayMs = 0L + while (true) { + if (delayMs > 0) NSThread.sleepForTimeInterval(delayMs / 1000.0) + try { + val conn = TcpConnection.connect(host, port) + delayMs = 0L + try { serve(conn) } finally { conn.close() } + } catch (_: Exception) { + delayMs = if (delayMs <= 0L) 500L else minOf(delayMs * 2, 10_000L) + } + } + } + + private fun serve(conn: TcpConnection) { + val appPackage = (NSBundle.mainBundle.infoDictionary?.get("CFBundleIdentifier") as? String) ?: "unknown" + conn.writeFrame( + """{"type":"HELLO","protocol_version":$PROTOCOL_VERSION,"version":${jsonString(VERSION)},"platform":"ios","app_package":${jsonString(appPackage)}}""".encodeToByteArray() + ) + while (true) { + val frame = conn.readFrame() + val text = frame.decodeToString() + val type = extractJsonField(text, "type") ?: break + val id = extractJsonLong(text, "id") ?: 0L + when (type) { + "PAUSE" -> handlePause(conn, id) + "RESUME" -> IosPauser.release() + "GOODBYE" -> return + } + } + } + + private fun handlePause(conn: TcpConnection, id: Long) { + val snapshots = IosPauser.pauseAndSnapshot { + val snap = SanderlingIos.extractors.toMap() + buildMap { for ((name, extractor) in snap) put(name, runCatching { extractor() }.getOrNull()) } + } + val snapshotsJson = snapshots.entries.joinToString(",") { (k, v) -> "${jsonString(k)}:${jsonValue(v)}" } + conn.writeFrame("""{"type":"STATE","id":$id,"snapshots":{$snapshotsJson}}""".encodeToByteArray()) + } +} + +internal fun extractJsonField(json: String, key: String): String? = + Regex("\"${Regex.escape(key)}\"\\s*:\\s*\"([^\"\\\\]*(?:\\\\.[^\"\\\\]*)*)\"").find(json)?.groupValues?.get(1) + +internal fun extractJsonLong(json: String, key: String): Long? = + Regex("\"${Regex.escape(key)}\"\\s*:\\s*(-?\\d+)").find(json)?.groupValues?.get(1)?.toLongOrNull() + +internal fun jsonString(s: String): String = buildString { + append('"') + for (c in s) when (c) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> if (c.code < 0x20) append("\\u${c.code.toString(16).padStart(4, '0')}") else append(c) + } + append('"') +} + +internal fun jsonValue(value: Any?): String = when (value) { + null -> "null" + is Boolean -> if (value) "true" else "false" + is Int -> value.toString() + is Long -> value.toString() + is Float -> value.toString() + is Double -> value.toString() + is String -> jsonString(value) + is Map<*, *> -> "{${value.entries.joinToString(",") { (k, v) -> "${jsonString(k.toString())}:${jsonValue(v)}" }}}" + is List<*> -> "[${value.joinToString(",") { jsonValue(it) }}]" + else -> jsonString(value.toString()) +} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt new file mode 100644 index 0000000..e680427 --- /dev/null +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt @@ -0,0 +1,25 @@ +package app.folio.sanderling + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.darwin.* + +@OptIn(ExperimentalForeignApi::class) +internal object IosPauser { + private val snapshotReady = dispatch_semaphore_create(0) + private val resumeGate = dispatch_semaphore_create(0) + private var capturedSnapshot: Map = emptyMap() + + fun pauseAndSnapshot(extractors: () -> Map): Map { + dispatch_async(dispatch_get_main_queue()) { + capturedSnapshot = runCatching { extractors() }.getOrElse { emptyMap() } + dispatch_semaphore_signal(snapshotReady) + dispatch_semaphore_wait(resumeGate, dispatch_time(DISPATCH_TIME_NOW, 5_000_000_000L)) + } + dispatch_semaphore_wait(snapshotReady, dispatch_time(DISPATCH_TIME_NOW, 5_000_000_000L)) + return capturedSnapshot + } + + fun release() { + dispatch_semaphore_signal(resumeGate) + } +} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/LedgerSnapshots.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/LedgerSnapshots.kt new file mode 100644 index 0000000..551fd31 --- /dev/null +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/LedgerSnapshots.kt @@ -0,0 +1,41 @@ +package app.folio.sanderling + +import app.folio.FocusTracker +import app.folio.data.Repository +import app.folio.data.TxnType +import app.folio.feature.ledger.AddTransactionUiState +import app.folio.navigation.Navigator +import app.folio.navigation.Route +import app.folio.platform.balanceOf +import app.folio.platform.signedAmount + +object LedgerSnapshots { + private val activeId + get() = when (val r = Navigator.current.value) { + is Route.Ledger -> r.accountId + is Route.AddTransaction -> r.accountId + else -> null + } + + val activeAccountId by SanderlingIos.snapshot { activeId } + val ledgerRows by SanderlingIos.snapshot { + val active = activeId ?: return@snapshot emptyList() + Repository.transactions.value.filter { it.accountId == active }.map { + mapOf( + "id" to it.id, + "accountId" to it.accountId, + "type" to if (it.type == TxnType.credit) "credit" else "debit", + "amount" to it.amount, + "signed" to signedAmount(it), + ) + } + } + val ledgerBalance by SanderlingIos.snapshot { + val active = activeId ?: return@snapshot 0L + balanceOf(Repository.transactions.value.filter { it.accountId == active }) + } + val focusedInput by SanderlingIos.snapshot { FocusTracker.current.value } + val txnFormType by SanderlingIos.snapshot { AddTransactionUiState.txnFormType.value } + val txnFormAccountId by SanderlingIos.snapshot { (Navigator.current.value as? Route.AddTransaction)?.accountId } + val txnError by SanderlingIos.snapshot { AddTransactionUiState.txnError.value } +} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/NavigationSnapshots.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/NavigationSnapshots.kt new file mode 100644 index 0000000..41c570e --- /dev/null +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/NavigationSnapshots.kt @@ -0,0 +1,16 @@ +package app.folio.sanderling + +import app.folio.navigation.Navigator +import app.folio.navigation.Route + +object NavigationSnapshots { + val screen by SanderlingIos.snapshot { + when (Navigator.current.value) { + Route.Login -> "login" + Route.Home -> "home" + Route.AddAccount -> "add-account" + is Route.Ledger -> "ledger" + is Route.AddTransaction -> "add-transaction" + } + } +} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt new file mode 100644 index 0000000..c17fe57 --- /dev/null +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt @@ -0,0 +1,36 @@ +package app.folio.sanderling + +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty +import platform.Foundation.NSProcessInfo + +private fun String.camelToSnakeCase(): String = buildString { + for ((i, c) in this@camelToSnakeCase.withIndex()) { + if (c.isUpperCase() && i > 0) append('_') + append(c.lowercaseChar()) + } +} + +object SanderlingIos { + internal val extractors = mutableMapOf Any?>() + + fun start() { + val env = NSProcessInfo.processInfo.environment + val portStr = env["SANDERLING_PORT"] as? String ?: return + val port = portStr.toIntOrNull() ?: return + IosAgent.start("127.0.0.1", port) + } + + fun extract(name: String, block: () -> Any?) { + extractors[name] = block + } + + fun snapshot(block: () -> T): SnapshotDelegate = SnapshotDelegate(block) +} + +class SnapshotDelegate(private val block: () -> T) { + operator fun provideDelegate(thisRef: Any?, prop: KProperty<*>): ReadOnlyProperty { + SanderlingIos.extract(prop.name.camelToSnakeCase(), block as () -> Any?) + return ReadOnlyProperty { _, _ -> block() } + } +} diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/TcpConnection.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/TcpConnection.kt new file mode 100644 index 0000000..d821388 --- /dev/null +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/TcpConnection.kt @@ -0,0 +1,81 @@ +package app.folio.sanderling + +import kotlinx.cinterop.* +import platform.posix.* + +@OptIn(ExperimentalForeignApi::class) +internal class TcpConnection private constructor(private val fd: Int) { + companion object { + fun connect(host: String, port: Int): TcpConnection { + val sock = socket(AF_INET, SOCK_STREAM, 0) + check(sock >= 0) { "socket() failed" } + memScoped { + val addr = alloc() + addr.sin_family = AF_INET.convert() + addr.sin_port = networkShort(port) + addr.sin_addr.s_addr = networkAddress(host) + val result = platform.posix.connect(sock, addr.ptr.reinterpret(), sizeOf().convert()) + if (result < 0) { + close(sock) + error("connect() to $host:$port failed: errno=$errno") + } + } + return TcpConnection(sock) + } + + // Convert port to network byte order (big-endian bytes in memory on little-endian iOS). + private fun networkShort(value: Int): UShort = + (((value ushr 8) and 0xFF) or ((value and 0xFF) shl 8)).toUShort() + + // Parse "a.b.c.d" → UInt with bytes [a,b,c,d] in memory (little-endian storage). + private fun networkAddress(host: String): UInt { + val parts = host.split(".").map { it.toInt() } + check(parts.size == 4) { "expected IPv4 address, got: $host" } + return (parts[0] or (parts[1] shl 8) or (parts[2] shl 16) or (parts[3] shl 24)).toUInt() + } + } + + fun writeFrame(data: ByteArray) { + val len = data.size + writeAll(byteArrayOf((len ushr 24).toByte(), (len ushr 16).toByte(), (len ushr 8).toByte(), len.toByte())) + writeAll(data) + } + + fun readFrame(): ByteArray { + val header = readAll(4) + val len = ((header[0].toInt() and 0xFF) shl 24) or + ((header[1].toInt() and 0xFF) shl 16) or + ((header[2].toInt() and 0xFF) shl 8) or + (header[3].toInt() and 0xFF) + check(len in 0..16_777_216) { "bad frame length: $len" } + return readAll(len) + } + + private fun writeAll(data: ByteArray) { + data.usePinned { pinned -> + var offset = 0 + while (offset < data.size) { + val n = send(fd, pinned.addressOf(offset), (data.size - offset).convert(), 0).toInt() + check(n > 0) { "send() failed: errno=$errno" } + offset += n + } + } + } + + private fun readAll(count: Int): ByteArray { + val buf = ByteArray(count) + buf.usePinned { pinned -> + var offset = 0 + while (offset < count) { + val n = recv(fd, pinned.addressOf(offset), (count - offset).convert(), 0).toInt() + check(n > 0) { "recv() returned $n" } + offset += n + } + } + return buf + } + + fun close() { + platform.posix.close(fd) + } +} diff --git a/examples/folio/justfile b/examples/folio/justfile index 989e0ff..263e8e0 100644 --- a/examples/folio/justfile +++ b/examples/folio/justfile @@ -97,3 +97,21 @@ test: --duration "{{duration}}" \ --seed "{{seed}}" \ --output "{{output}}" + +# Build + install + run sanderling spec on iOS simulator. +test-ios: + #!/usr/bin/env bash + set -euo pipefail + just ios + ios_device_flag=() + if [[ -n "{{ios_device}}" ]]; then + ios_device_flag=(--ios-device "{{ios_device}}") + fi + "{{sanderling}}" test \ + --platform ios \ + --spec "{{justfile_directory()}}/sanderling/spec.ts" \ + --bundle-id app.folio \ + "${ios_device_flag[@]}" \ + --duration "{{duration}}" \ + --seed "{{seed}}" \ + --output "{{output}}" diff --git a/internal/ios/ios.go b/internal/ios/ios.go new file mode 100644 index 0000000..36522ab --- /dev/null +++ b/internal/ios/ios.go @@ -0,0 +1,153 @@ +package ios + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" +) + +type simDevice struct { + UDID string `json:"udid"` + State string `json:"state"` + Name string `json:"name"` + IsAvailable bool `json:"isAvailable"` +} + +type simctlDeviceList struct { + Devices map[string][]simDevice `json:"devices"` +} + +func EnsureSimulator(ctx context.Context, deviceName string, stdout io.Writer) error { + booted, err := bootedSimulator(ctx) + if err != nil { + return fmt.Errorf("list booted simulators: %w", err) + } + if booted != nil { + fmt.Fprintf(stdout, "using booted simulator: %s (%s)\n", booted.Name, booted.UDID) + return nil + } + + available, err := availableSimulators(ctx) + if err != nil { + return fmt.Errorf("list available simulators: %w", err) + } + + target, err := pickSimulator(deviceName, available) + if err != nil { + return err + } + + fmt.Fprintf(stdout, "booting simulator %q (%s)...\n", target.Name, target.UDID) + if err := bootSimulator(ctx, target.UDID); err != nil { + return fmt.Errorf("boot simulator %q: %w", target.UDID, err) + } + + if err := waitForSimulatorBoot(ctx, target.UDID, 60*time.Second); err != nil { + return fmt.Errorf("wait for simulator boot: %w", err) + } + + fmt.Fprintf(stdout, "simulator %q ready\n", target.Name) + return nil +} + +func LaunchApp(ctx context.Context, bundleID string, env map[string]string) error { + args := []string{"simctl", "launch", "--terminate-running-process", "booted", bundleID} + cmd := exec.CommandContext(ctx, "xcrun", args...) + cmd.Env = os.Environ() + for k, v := range env { + cmd.Env = append(cmd.Env, "SIMCTL_CHILD_"+k+"="+v) + } + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("launch %s: %w\n%s", bundleID, err, strings.TrimSpace(string(out))) + } + return nil +} + +func bootedSimulator(ctx context.Context) (*simDevice, error) { + out, err := exec.CommandContext(ctx, "xcrun", "simctl", "list", "devices", "booted", "--json").Output() + if err != nil { + return nil, err + } + var list simctlDeviceList + if err := json.Unmarshal(out, &list); err != nil { + return nil, err + } + for _, devices := range list.Devices { + for _, d := range devices { + if d.State == "Booted" { + return &d, nil + } + } + } + return nil, nil +} + +func availableSimulators(ctx context.Context) ([]simDevice, error) { + out, err := exec.CommandContext(ctx, "xcrun", "simctl", "list", "devices", "available", "--json").Output() + if err != nil { + return nil, err + } + var list simctlDeviceList + if err := json.Unmarshal(out, &list); err != nil { + return nil, err + } + var result []simDevice + for _, devices := range list.Devices { + for _, d := range devices { + if d.IsAvailable { + result = append(result, d) + } + } + } + return result, nil +} + +func pickSimulator(deviceName string, available []simDevice) (*simDevice, error) { + if deviceName != "" { + for i, d := range available { + if d.Name == deviceName || d.UDID == deviceName { + return &available[i], nil + } + } + return nil, fmt.Errorf("simulator %q not found among available simulators; run `xcrun simctl list devices available` to see options", deviceName) + } + + if len(available) == 0 { + return nil, fmt.Errorf("no available iOS simulators found; create one in Xcode -> Window -> Devices and Simulators") + } + + for i, d := range available { + if strings.HasPrefix(d.Name, "iPhone") { + return &available[i], nil + } + } + return &available[0], nil +} + +func bootSimulator(ctx context.Context, udid string) error { + return exec.CommandContext(ctx, "xcrun", "simctl", "boot", udid).Run() +} + +func waitForSimulatorBoot(ctx context.Context, udid string, timeout time.Duration) error { + deadline, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + booted, _ := bootedSimulator(deadline) + if booted != nil && (booted.UDID == udid || udid == "") { + return nil + } + select { + case <-deadline.Done(): + return deadline.Err() + case <-ticker.C: + } + } +} diff --git a/internal/testrun/testrun.go b/internal/testrun/testrun.go index a605b47..3308b32 100644 --- a/internal/testrun/testrun.go +++ b/internal/testrun/testrun.go @@ -8,11 +8,13 @@ import ( "net" "os" "path/filepath" + "strconv" "time" "github.com/priyanshujain/sanderling/internal/agent" "github.com/priyanshujain/sanderling/internal/android" "github.com/priyanshujain/sanderling/internal/bundler" + "github.com/priyanshujain/sanderling/internal/ios" "github.com/priyanshujain/sanderling/internal/runner" "github.com/priyanshujain/sanderling/internal/trace" "github.com/priyanshujain/sanderling/internal/verifier" @@ -26,21 +28,27 @@ const ( // Options are the parameters for a single test pipeline run. type Options struct { - Spec string - BundleID string - Platform string - AVD string - Duration time.Duration - Seed int64 - Output string + Spec string + BundleID string + Platform string + AVD string + IosDevice string + Duration time.Duration + Seed int64 + Output string } // Execute runs the full test pipeline: bundle, connect SDK, verify properties. func Execute(ctx context.Context, options Options, stdout io.Writer) error { - if options.Platform == "android" || options.Platform == "ios" { + switch options.Platform { + case "android": if err := android.EnsureDevice(ctx, options.AVD, stdout); err != nil { return err } + case "ios": + if err := ios.EnsureSimulator(ctx, options.IosDevice, stdout); err != nil { + return err + } } aliases := map[string]string{} if specAPIPath := resolveSpecAPIPath(options.Spec); specAPIPath != "" { @@ -69,7 +77,8 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error { var connection *agent.Conn - if options.Platform != "web" { + switch options.Platform { + case "android": listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return fmt.Errorf("agent listener: %w", err) @@ -114,7 +123,43 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error { defer connection.Close() hello := connection.Hello() fmt.Fprintf(stdout, "SDK connected: platform=%s app=%s sdk=%s\n", hello.Platform, hello.AppPackage, hello.Version) - } else { + + case "ios": + // iOS simulator shares the Mac's loopback — no port forwarding needed. + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return fmt.Errorf("agent listener: %w", err) + } + defer listener.Close() + agentPort := listener.Addr().(*net.TCPAddr).Port + + // Launch app via simctl with SANDERLING_PORT so the SDK can connect. + if err := ios.LaunchApp(ctx, options.BundleID, map[string]string{ + "SANDERLING_PORT": strconv.Itoa(agentPort), + }); err != nil { + return fmt.Errorf("launch app: %w", err) + } + fmt.Fprintf(stdout, "iOS app launched with SANDERLING_PORT=%d; waiting for SDK (%.0fs timeout)\n", agentPort, sdkAcceptTimeout.Seconds()) + + agentServer := agent.NewServer(listener) + acceptCtx, acceptCancel := context.WithTimeout(ctx, sdkAcceptTimeout) + conn, acceptErr := agentServer.Accept(acceptCtx) + acceptCancel() + if acceptErr != nil { + return fmt.Errorf("accept SDK: %w", acceptErr) + } + connection = conn + defer connection.Close() + hello := connection.Hello() + fmt.Fprintf(stdout, "SDK connected: platform=%s app=%s sdk=%s\n", hello.Platform, hello.AppPackage, hello.Version) + + // Initialize Maestro XCTest session after the app is running. + // clearState=false avoids relaunching the already-connected app. + if err := activeDriver.Launch(ctx, options.BundleID, false); err != nil { + fmt.Fprintf(stdout, "warn: maestro launch: %v\n", err) + } + + case "web": fmt.Fprintln(stdout, "web mode: skipping SDK") if err := activeDriver.Launch(ctx, options.BundleID, false); err != nil { return fmt.Errorf("launch app: %w", err) From 97154cf580c989cb4c1f930e31773d74eea9f67a Mon Sep 17 00:00:00 2001 From: pjay Date: Thu, 23 Apr 2026 17:35:31 +0700 Subject: [PATCH 2/2] feat(ios): launch via XCTest with env vars for hierarchy/tap access (#39) * feat(proto): add env map to LaunchRequest * feat(driver): add env param to Launch interface and all implementations * feat(sidecar): add IosDriverBackend using Maestro IOSDriver + env pass-through * feat(testrun): launch iOS app via XCTest with env vars instead of simctl * fix(ios): replace LaunchApp with BootedUDID; simctl launch moved to XCTest path * test(cli): add tests for ios platform flag and ios-device flag parsing * fix(sdk-ios): check semaphore wait result; resolve port from args and env; register extractors before start --- cmd/sanderling/main_test.go | 29 ++++++ .../kotlin/app/folio/MainViewController.kt | 2 +- .../kotlin/app/folio/sanderling/IosPauser.kt | 3 +- .../app/folio/sanderling/SanderlingIos.kt | 18 +++- internal/driver/chrome/driver.go | 2 +- internal/driver/driver.go | 2 +- internal/driver/mock/mock.go | 2 +- internal/driver/mock/mock_test.go | 2 +- internal/driver/sidecar/client.go | 3 +- internal/driver/sidecar/client_test.go | 2 +- internal/ios/ios.go | 18 ++-- internal/ios/ios_test.go | 89 ++++++++++++++++++ internal/testrun/driver.go | 11 ++- internal/testrun/testrun.go | 39 ++++---- proto/driverpb/driver.pb.go | 90 +++++++++++-------- proto/driverpb/driver.proto | 1 + sidecar/build.gradle.kts | 2 + .../dev/sanderling/sidecar/DriverBackend.kt | 82 ++++++++++++++++- .../dev/sanderling/sidecar/DriverService.kt | 5 +- .../kotlin/dev/sanderling/sidecar/Main.kt | 11 ++- 20 files changed, 323 insertions(+), 90 deletions(-) create mode 100644 internal/ios/ios_test.go diff --git a/cmd/sanderling/main_test.go b/cmd/sanderling/main_test.go index ada500c..470f180 100644 --- a/cmd/sanderling/main_test.go +++ b/cmd/sanderling/main_test.go @@ -146,6 +146,35 @@ func TestRun_Doctor(t *testing.T) { } } +func TestParseTestArgs_AcceptsIosPlatform(t *testing.T) { + options, err := parseTestArgs([]string{ + "--spec", "s.ts", + "--bundle-id", "com.example.app", + "--platform", "ios", + }, io.Discard) + if err != nil { + t.Fatalf("unexpected error for ios platform: %v", err) + } + if options.platform != "ios" { + t.Errorf("expected platform=ios, got %q", options.platform) + } +} + +func TestParseTestArgs_IosDeviceFlag(t *testing.T) { + options, err := parseTestArgs([]string{ + "--spec", "s.ts", + "--bundle-id", "com.example.app", + "--platform", "ios", + "--ios-device", "iPhone 15 Pro", + }, io.Discard) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if options.iosDevice != "iPhone 15 Pro" { + t.Errorf("expected iosDevice=iPhone 15 Pro, got %q", options.iosDevice) + } +} + func TestRun_TestSubcommand_PipelineErrors(t *testing.T) { // Without a real spec, a real device, or a bootable AVD the pipeline // must surface a specific error rather than panicking — proves the flag diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt index 1152cea..04a86e2 100644 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/MainViewController.kt @@ -29,11 +29,11 @@ private val backGestureTarget = BackGestureTarget() @OptIn(ExperimentalForeignApi::class) fun MainViewController(): UIViewController { Repository.init() - app.folio.sanderling.SanderlingIos.start() app.folio.sanderling.AuthSnapshots app.folio.sanderling.AccountSnapshots app.folio.sanderling.LedgerSnapshots app.folio.sanderling.NavigationSnapshots + app.folio.sanderling.SanderlingIos.start() val vc = ComposeUIViewController { App() } val gesture = UIScreenEdgePanGestureRecognizer( target = backGestureTarget, diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt index e680427..e9220cd 100644 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/IosPauser.kt @@ -15,7 +15,8 @@ internal object IosPauser { dispatch_semaphore_signal(snapshotReady) dispatch_semaphore_wait(resumeGate, dispatch_time(DISPATCH_TIME_NOW, 5_000_000_000L)) } - dispatch_semaphore_wait(snapshotReady, dispatch_time(DISPATCH_TIME_NOW, 5_000_000_000L)) + val waited = dispatch_semaphore_wait(snapshotReady, dispatch_time(DISPATCH_TIME_NOW, 5_000_000_000L)) + check(waited == 0L) { "snapshot timed out: main thread did not signal within 5s" } return capturedSnapshot } diff --git a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt index c17fe57..f14dd0f 100644 --- a/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt +++ b/examples/folio/composeApp/src/iosMain/kotlin/app/folio/sanderling/SanderlingIos.kt @@ -15,12 +15,24 @@ object SanderlingIos { internal val extractors = mutableMapOf Any?>() fun start() { - val env = NSProcessInfo.processInfo.environment - val portStr = env["SANDERLING_PORT"] as? String ?: return - val port = portStr.toIntOrNull() ?: return + val port = resolvePort() ?: return IosAgent.start("127.0.0.1", port) } + private fun resolvePort(): Int? { + // Env var set via SIMCTL_CHILD_SANDERLING_PORT (simctl direct launch). + (NSProcessInfo.processInfo.environment["SANDERLING_PORT"] as? String) + ?.toIntOrNull()?.let { return it } + // Launch argument -SANDERLING_PORT (Maestro simctl launch). + @Suppress("UNCHECKED_CAST") + val args = NSProcessInfo.processInfo.arguments as? List ?: return null + val idx = args.indexOfFirst { it == "-SANDERLING_PORT" } + if (idx >= 0 && idx + 1 < args.size) { + return args[idx + 1].toIntOrNull() + } + return null + } + fun extract(name: String, block: () -> Any?) { extractors[name] = block } diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go index d96a8e8..d119bcd 100644 --- a/internal/driver/chrome/driver.go +++ b/internal/driver/chrome/driver.go @@ -77,7 +77,7 @@ func New() *Driver { return d } -func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool) error { +func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool, _ map[string]string) error { if clearState { if err := chromedp.Run(d.tabCtx, network.ClearBrowserCookies()); err != nil { return fmt.Errorf("clear cookies: %w", err) diff --git a/internal/driver/driver.go b/internal/driver/driver.go index d67603a..974ca09 100644 --- a/internal/driver/driver.go +++ b/internal/driver/driver.go @@ -10,7 +10,7 @@ import ( // lives under driver/sidecar; the web implementation under driver/chrome; // tests use driver/mock. type DeviceDriver interface { - Launch(ctx context.Context, bundleID string, clearState bool) error + Launch(ctx context.Context, bundleID string, clearState bool, env map[string]string) error Terminate(ctx context.Context) error Tap(ctx context.Context, x, y int) error diff --git a/internal/driver/mock/mock.go b/internal/driver/mock/mock.go index c2c45a8..07adadc 100644 --- a/internal/driver/mock/mock.go +++ b/internal/driver/mock/mock.go @@ -88,7 +88,7 @@ func (d *Driver) failure(kind ActionKind) error { return d.Failures[kind] } -func (d *Driver) Launch(_ context.Context, bundleID string, clearState bool) error { +func (d *Driver) Launch(_ context.Context, bundleID string, clearState bool, _ map[string]string) error { if err := d.failure(ActionLaunch); err != nil { return err } diff --git a/internal/driver/mock/mock_test.go b/internal/driver/mock/mock_test.go index d16c791..d56da5c 100644 --- a/internal/driver/mock/mock_test.go +++ b/internal/driver/mock/mock_test.go @@ -29,7 +29,7 @@ func TestRecordsAllActionsInOrder(t *testing.T) { mock := New() ctx := context.Background() - if err := mock.Launch(ctx, "com.example", true); err != nil { + if err := mock.Launch(ctx, "com.example", true, nil); err != nil { t.Fatal(err) } if err := mock.Tap(ctx, 100, 200); err != nil { diff --git a/internal/driver/sidecar/client.go b/internal/driver/sidecar/client.go index a002161..5dc2a47 100644 --- a/internal/driver/sidecar/client.go +++ b/internal/driver/sidecar/client.go @@ -48,10 +48,11 @@ func (c *Client) WaitForHealth(ctx context.Context, pollInterval time.Duration) } } -func (c *Client) Launch(ctx context.Context, bundleID string, clearState bool) error { +func (c *Client) Launch(ctx context.Context, bundleID string, clearState bool, env map[string]string) error { _, err := c.stub.Launch(ctx, &driverpb.LaunchRequest{ BundleId: bundleID, ClearState: clearState, + Env: env, }) return err } diff --git a/internal/driver/sidecar/client_test.go b/internal/driver/sidecar/client_test.go index 4ec69a9..00799b7 100644 --- a/internal/driver/sidecar/client_test.go +++ b/internal/driver/sidecar/client_test.go @@ -191,7 +191,7 @@ func TestClient_LaunchAndTerminate(t *testing.T) { client, _ := Dial(state.address) defer client.Close() - if err := client.Launch(context.Background(), "com.example", true); err != nil { + if err := client.Launch(context.Background(), "com.example", true, nil); err != nil { t.Fatal(err) } if state.fake.launchedBundleID != "com.example" || !state.fake.clearState { diff --git a/internal/ios/ios.go b/internal/ios/ios.go index 36522ab..c195b20 100644 --- a/internal/ios/ios.go +++ b/internal/ios/ios.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "io" - "os" "os/exec" "strings" "time" @@ -55,18 +54,13 @@ func EnsureSimulator(ctx context.Context, deviceName string, stdout io.Writer) e return nil } -func LaunchApp(ctx context.Context, bundleID string, env map[string]string) error { - args := []string{"simctl", "launch", "--terminate-running-process", "booted", bundleID} - cmd := exec.CommandContext(ctx, "xcrun", args...) - cmd.Env = os.Environ() - for k, v := range env { - cmd.Env = append(cmd.Env, "SIMCTL_CHILD_"+k+"="+v) +// BootedUDID returns the UDID of the currently booted iOS simulator, or "" if none is booted. +func BootedUDID(ctx context.Context) string { + d, _ := bootedSimulator(ctx) + if d == nil { + return "" } - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("launch %s: %w\n%s", bundleID, err, strings.TrimSpace(string(out))) - } - return nil + return d.UDID } func bootedSimulator(ctx context.Context) (*simDevice, error) { diff --git a/internal/ios/ios_test.go b/internal/ios/ios_test.go new file mode 100644 index 0000000..d957fe6 --- /dev/null +++ b/internal/ios/ios_test.go @@ -0,0 +1,89 @@ +package ios + +import ( + "context" + "testing" +) + +func TestPickSimulator_ByName(t *testing.T) { + available := []simDevice{ + {UDID: "aaa", Name: "iPad Pro", IsAvailable: true}, + {UDID: "bbb", Name: "iPhone 15", IsAvailable: true}, + } + got, err := pickSimulator("iPhone 15", available) + if err != nil { + t.Fatal(err) + } + if got.UDID != "bbb" { + t.Errorf("got %q, want bbb", got.UDID) + } +} + +func TestPickSimulator_ByUDID(t *testing.T) { + available := []simDevice{ + {UDID: "aaa", Name: "iPad Pro", IsAvailable: true}, + {UDID: "bbb", Name: "iPhone 14", IsAvailable: true}, + } + got, err := pickSimulator("aaa", available) + if err != nil { + t.Fatal(err) + } + if got.Name != "iPad Pro" { + t.Errorf("got %q, want iPad Pro", got.Name) + } +} + +func TestPickSimulator_UnknownName(t *testing.T) { + available := []simDevice{ + {UDID: "aaa", Name: "iPad Pro", IsAvailable: true}, + } + _, err := pickSimulator("Pixel 7", available) + if err == nil { + t.Fatal("expected error for unknown simulator name") + } +} + +func TestPickSimulator_EmptyName_PrefersIPhone(t *testing.T) { + available := []simDevice{ + {UDID: "aaa", Name: "iPad mini", IsAvailable: true}, + {UDID: "bbb", Name: "iPhone 16", IsAvailable: true}, + {UDID: "ccc", Name: "Apple Watch", IsAvailable: true}, + } + got, err := pickSimulator("", available) + if err != nil { + t.Fatal(err) + } + if got.UDID != "bbb" { + t.Errorf("got %q, want bbb (iPhone)", got.UDID) + } +} + +func TestPickSimulator_EmptyName_FallsBackToFirst(t *testing.T) { + available := []simDevice{ + {UDID: "aaa", Name: "iPad Air", IsAvailable: true}, + {UDID: "bbb", Name: "Apple TV", IsAvailable: true}, + } + got, err := pickSimulator("", available) + if err != nil { + t.Fatal(err) + } + if got.UDID != "aaa" { + t.Errorf("got %q, want aaa (first available)", got.UDID) + } +} + +func TestPickSimulator_EmptyList(t *testing.T) { + _, err := pickSimulator("", nil) + if err == nil { + t.Fatal("expected error for empty simulator list") + } +} + +func TestBootedUDID_CanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + udid := BootedUDID(ctx) + if udid != "" { + t.Errorf("expected empty UDID on canceled context, got %q", udid) + } +} diff --git a/internal/testrun/driver.go b/internal/testrun/driver.go index ec2e1d7..e7336d3 100644 --- a/internal/testrun/driver.go +++ b/internal/testrun/driver.go @@ -12,6 +12,7 @@ import ( "github.com/priyanshujain/sanderling/internal/driver" "github.com/priyanshujain/sanderling/internal/driver/chrome" driverSidecar "github.com/priyanshujain/sanderling/internal/driver/sidecar" + "github.com/priyanshujain/sanderling/internal/ios" "github.com/priyanshujain/sanderling/internal/sidecar" ) @@ -35,10 +36,16 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver if err != nil { return nil, nil, err } - sidecarCommand := exec.CommandContext(ctx, "java", "-jar", jarPath, + sidecarArgs := []string{"-jar", jarPath, "--port", strconv.Itoa(sidecarPort), "--platform", options.Platform, - ) + } + if options.Platform == "ios" { + if udid := ios.BootedUDID(ctx); udid != "" { + sidecarArgs = append(sidecarArgs, "--udid", udid) + } + } + sidecarCommand := exec.CommandContext(ctx, "java", sidecarArgs...) sidecarCommand.Stdout = stdout sidecarCommand.Stderr = stdout sidecarCommand.Env = android.EnvWithAndroidPlatformTools(os.Environ()) diff --git a/internal/testrun/testrun.go b/internal/testrun/testrun.go index 3308b32..7e6a16a 100644 --- a/internal/testrun/testrun.go +++ b/internal/testrun/testrun.go @@ -110,7 +110,7 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error { acceptChannel <- acceptResult{conn: conn, err: acceptErr} }() - if err := activeDriver.Launch(ctx, options.BundleID, false); err != nil { + if err := activeDriver.Launch(ctx, options.BundleID, false, nil); err != nil { return fmt.Errorf("launch app: %w", err) } fmt.Fprintf(stdout, "launched %s; waiting for SDK to connect (%.0fs timeout)\n", options.BundleID, sdkAcceptTimeout.Seconds()) @@ -133,35 +133,40 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error { defer listener.Close() agentPort := listener.Addr().(*net.TCPAddr).Port - // Launch app via simctl with SANDERLING_PORT so the SDK can connect. - if err := ios.LaunchApp(ctx, options.BundleID, map[string]string{ + agentServer := agent.NewServer(listener) + + type acceptResult struct { + conn *agent.Conn + err error + } + acceptChannel := make(chan acceptResult, 1) + go func() { + acceptCtx, cancel := context.WithTimeout(ctx, sdkAcceptTimeout) + defer cancel() + conn, acceptErr := agentServer.Accept(acceptCtx) + acceptChannel <- acceptResult{conn: conn, err: acceptErr} + }() + + // Launch app via XCTest with SANDERLING_PORT so the SDK can connect. + if err := activeDriver.Launch(ctx, options.BundleID, false, map[string]string{ "SANDERLING_PORT": strconv.Itoa(agentPort), }); err != nil { return fmt.Errorf("launch app: %w", err) } fmt.Fprintf(stdout, "iOS app launched with SANDERLING_PORT=%d; waiting for SDK (%.0fs timeout)\n", agentPort, sdkAcceptTimeout.Seconds()) - agentServer := agent.NewServer(listener) - acceptCtx, acceptCancel := context.WithTimeout(ctx, sdkAcceptTimeout) - conn, acceptErr := agentServer.Accept(acceptCtx) - acceptCancel() - if acceptErr != nil { - return fmt.Errorf("accept SDK: %w", acceptErr) + result := <-acceptChannel + if result.err != nil { + return fmt.Errorf("accept SDK: %w", result.err) } - connection = conn + connection = result.conn defer connection.Close() hello := connection.Hello() fmt.Fprintf(stdout, "SDK connected: platform=%s app=%s sdk=%s\n", hello.Platform, hello.AppPackage, hello.Version) - // Initialize Maestro XCTest session after the app is running. - // clearState=false avoids relaunching the already-connected app. - if err := activeDriver.Launch(ctx, options.BundleID, false); err != nil { - fmt.Fprintf(stdout, "warn: maestro launch: %v\n", err) - } - case "web": fmt.Fprintln(stdout, "web mode: skipping SDK") - if err := activeDriver.Launch(ctx, options.BundleID, false); err != nil { + if err := activeDriver.Launch(ctx, options.BundleID, false, nil); err != nil { return fmt.Errorf("launch app: %w", err) } } diff --git a/proto/driverpb/driver.pb.go b/proto/driverpb/driver.pb.go index a01e920..a1a07f4 100644 --- a/proto/driverpb/driver.pb.go +++ b/proto/driverpb/driver.pb.go @@ -61,6 +61,7 @@ type LaunchRequest struct { state protoimpl.MessageState `protogen:"open.v1"` BundleId string `protobuf:"bytes,1,opt,name=bundle_id,json=bundleId,proto3" json:"bundle_id,omitempty"` ClearState bool `protobuf:"varint,2,opt,name=clear_state,json=clearState,proto3" json:"clear_state,omitempty"` + Env map[string]string `protobuf:"bytes,3,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -109,6 +110,13 @@ func (x *LaunchRequest) GetClearState() bool { return false } +func (x *LaunchRequest) GetEnv() map[string]string { + if x != nil { + return x.Env + } + return nil +} + type Point struct { state protoimpl.MessageState `protogen:"open.v1"` X int32 `protobuf:"varint,1,opt,name=x,proto3" json:"x,omitempty"` @@ -839,11 +847,15 @@ var File_driverpb_driver_proto protoreflect.FileDescriptor const file_driverpb_driver_proto_rawDesc = "" + "\n" + "\x15driverpb/driver.proto\x12\x14sanderling.driver.v1\"\a\n" + - "\x05Empty\"M\n" + + "\x05Empty\"\xc5\x01\n" + "\rLaunchRequest\x12\x1b\n" + "\tbundle_id\x18\x01 \x01(\tR\bbundleId\x12\x1f\n" + "\vclear_state\x18\x02 \x01(\bR\n" + - "clearState\"#\n" + + "clearState\x12>\n" + + "\x03env\x18\x03 \x03(\v2,.sanderling.driver.v1.LaunchRequest.EnvEntryR\x03env\x1a6\n" + + "\bEnvEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"#\n" + "\x05Point\x12\f\n" + "\x01x\x18\x01 \x01(\x05R\x01x\x12\f\n" + "\x01y\x18\x02 \x01(\x05R\x01y\"\x1c\n" + @@ -919,7 +931,7 @@ func file_driverpb_driver_proto_rawDescGZIP() []byte { return file_driverpb_driver_proto_rawDescData } -var file_driverpb_driver_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_driverpb_driver_proto_msgTypes = make([]protoimpl.MessageInfo, 17) var file_driverpb_driver_proto_goTypes = []any{ (*Empty)(nil), // 0: sanderling.driver.v1.Empty (*LaunchRequest)(nil), // 1: sanderling.driver.v1.LaunchRequest @@ -937,42 +949,44 @@ var file_driverpb_driver_proto_goTypes = []any{ (*LogEntry)(nil), // 13: sanderling.driver.v1.LogEntry (*MetricsRequest)(nil), // 14: sanderling.driver.v1.MetricsRequest (*MetricsResponse)(nil), // 15: sanderling.driver.v1.MetricsResponse + nil, // 16: sanderling.driver.v1.LaunchRequest.EnvEntry } var file_driverpb_driver_proto_depIdxs = []int32{ - 2, // 0: sanderling.driver.v1.SwipeRequest.from:type_name -> sanderling.driver.v1.Point - 2, // 1: sanderling.driver.v1.SwipeRequest.to:type_name -> sanderling.driver.v1.Point - 13, // 2: sanderling.driver.v1.LogEntries.entries:type_name -> sanderling.driver.v1.LogEntry - 1, // 3: sanderling.driver.v1.Driver.Launch:input_type -> sanderling.driver.v1.LaunchRequest - 0, // 4: sanderling.driver.v1.Driver.Terminate:input_type -> sanderling.driver.v1.Empty - 2, // 5: sanderling.driver.v1.Driver.Tap:input_type -> sanderling.driver.v1.Point - 4, // 6: sanderling.driver.v1.Driver.TapSelector:input_type -> sanderling.driver.v1.Selector - 3, // 7: sanderling.driver.v1.Driver.InputText:input_type -> sanderling.driver.v1.Text - 9, // 8: sanderling.driver.v1.Driver.Swipe:input_type -> sanderling.driver.v1.SwipeRequest - 10, // 9: sanderling.driver.v1.Driver.PressKey:input_type -> sanderling.driver.v1.PressKeyRequest - 0, // 10: sanderling.driver.v1.Driver.Screenshot:input_type -> sanderling.driver.v1.Empty - 0, // 11: sanderling.driver.v1.Driver.Hierarchy:input_type -> sanderling.driver.v1.Empty - 11, // 12: sanderling.driver.v1.Driver.RecentLogs:input_type -> sanderling.driver.v1.RecentLogsRequest - 7, // 13: sanderling.driver.v1.Driver.WaitForIdle:input_type -> sanderling.driver.v1.Duration - 0, // 14: sanderling.driver.v1.Driver.Health:input_type -> sanderling.driver.v1.Empty - 14, // 15: sanderling.driver.v1.Driver.Metrics:input_type -> sanderling.driver.v1.MetricsRequest - 0, // 16: sanderling.driver.v1.Driver.Launch:output_type -> sanderling.driver.v1.Empty - 0, // 17: sanderling.driver.v1.Driver.Terminate:output_type -> sanderling.driver.v1.Empty - 0, // 18: sanderling.driver.v1.Driver.Tap:output_type -> sanderling.driver.v1.Empty - 0, // 19: sanderling.driver.v1.Driver.TapSelector:output_type -> sanderling.driver.v1.Empty - 0, // 20: sanderling.driver.v1.Driver.InputText:output_type -> sanderling.driver.v1.Empty - 0, // 21: sanderling.driver.v1.Driver.Swipe:output_type -> sanderling.driver.v1.Empty - 0, // 22: sanderling.driver.v1.Driver.PressKey:output_type -> sanderling.driver.v1.Empty - 5, // 23: sanderling.driver.v1.Driver.Screenshot:output_type -> sanderling.driver.v1.Image - 6, // 24: sanderling.driver.v1.Driver.Hierarchy:output_type -> sanderling.driver.v1.HierarchyJSON - 12, // 25: sanderling.driver.v1.Driver.RecentLogs:output_type -> sanderling.driver.v1.LogEntries - 0, // 26: sanderling.driver.v1.Driver.WaitForIdle:output_type -> sanderling.driver.v1.Empty - 8, // 27: sanderling.driver.v1.Driver.Health:output_type -> sanderling.driver.v1.HealthStatus - 15, // 28: sanderling.driver.v1.Driver.Metrics:output_type -> sanderling.driver.v1.MetricsResponse - 16, // [16:29] is the sub-list for method output_type - 3, // [3:16] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 16, // 0: sanderling.driver.v1.LaunchRequest.env:type_name -> sanderling.driver.v1.LaunchRequest.EnvEntry + 2, // 1: sanderling.driver.v1.SwipeRequest.from:type_name -> sanderling.driver.v1.Point + 2, // 2: sanderling.driver.v1.SwipeRequest.to:type_name -> sanderling.driver.v1.Point + 13, // 3: sanderling.driver.v1.LogEntries.entries:type_name -> sanderling.driver.v1.LogEntry + 1, // 4: sanderling.driver.v1.Driver.Launch:input_type -> sanderling.driver.v1.LaunchRequest + 0, // 5: sanderling.driver.v1.Driver.Terminate:input_type -> sanderling.driver.v1.Empty + 2, // 6: sanderling.driver.v1.Driver.Tap:input_type -> sanderling.driver.v1.Point + 4, // 7: sanderling.driver.v1.Driver.TapSelector:input_type -> sanderling.driver.v1.Selector + 3, // 8: sanderling.driver.v1.Driver.InputText:input_type -> sanderling.driver.v1.Text + 9, // 9: sanderling.driver.v1.Driver.Swipe:input_type -> sanderling.driver.v1.SwipeRequest + 10, // 10: sanderling.driver.v1.Driver.PressKey:input_type -> sanderling.driver.v1.PressKeyRequest + 0, // 11: sanderling.driver.v1.Driver.Screenshot:input_type -> sanderling.driver.v1.Empty + 0, // 12: sanderling.driver.v1.Driver.Hierarchy:input_type -> sanderling.driver.v1.Empty + 11, // 13: sanderling.driver.v1.Driver.RecentLogs:input_type -> sanderling.driver.v1.RecentLogsRequest + 7, // 14: sanderling.driver.v1.Driver.WaitForIdle:input_type -> sanderling.driver.v1.Duration + 0, // 15: sanderling.driver.v1.Driver.Health:input_type -> sanderling.driver.v1.Empty + 14, // 16: sanderling.driver.v1.Driver.Metrics:input_type -> sanderling.driver.v1.MetricsRequest + 0, // 17: sanderling.driver.v1.Driver.Launch:output_type -> sanderling.driver.v1.Empty + 0, // 18: sanderling.driver.v1.Driver.Terminate:output_type -> sanderling.driver.v1.Empty + 0, // 19: sanderling.driver.v1.Driver.Tap:output_type -> sanderling.driver.v1.Empty + 0, // 20: sanderling.driver.v1.Driver.TapSelector:output_type -> sanderling.driver.v1.Empty + 0, // 21: sanderling.driver.v1.Driver.InputText:output_type -> sanderling.driver.v1.Empty + 0, // 22: sanderling.driver.v1.Driver.Swipe:output_type -> sanderling.driver.v1.Empty + 0, // 23: sanderling.driver.v1.Driver.PressKey:output_type -> sanderling.driver.v1.Empty + 5, // 24: sanderling.driver.v1.Driver.Screenshot:output_type -> sanderling.driver.v1.Image + 6, // 25: sanderling.driver.v1.Driver.Hierarchy:output_type -> sanderling.driver.v1.HierarchyJSON + 12, // 26: sanderling.driver.v1.Driver.RecentLogs:output_type -> sanderling.driver.v1.LogEntries + 0, // 27: sanderling.driver.v1.Driver.WaitForIdle:output_type -> sanderling.driver.v1.Empty + 8, // 28: sanderling.driver.v1.Driver.Health:output_type -> sanderling.driver.v1.HealthStatus + 15, // 29: sanderling.driver.v1.Driver.Metrics:output_type -> sanderling.driver.v1.MetricsResponse + 17, // [17:30] is the sub-list for method output_type + 4, // [4:17] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name } func init() { file_driverpb_driver_proto_init() } @@ -986,7 +1000,7 @@ func file_driverpb_driver_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_driverpb_driver_proto_rawDesc), len(file_driverpb_driver_proto_rawDesc)), NumEnums: 0, - NumMessages: 16, + NumMessages: 17, NumExtensions: 0, NumServices: 1, }, diff --git a/proto/driverpb/driver.proto b/proto/driverpb/driver.proto index ccbb255..8fae2f9 100644 --- a/proto/driverpb/driver.proto +++ b/proto/driverpb/driver.proto @@ -30,6 +30,7 @@ message Empty {} message LaunchRequest { string bundle_id = 1; bool clear_state = 2; + map env = 3; } message Point { diff --git a/sidecar/build.gradle.kts b/sidecar/build.gradle.kts index 698eeaa..d39585d 100644 --- a/sidecar/build.gradle.kts +++ b/sidecar/build.gradle.kts @@ -26,6 +26,8 @@ val maestroVersion = "1.40.0" 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") diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt index 62a0a21..be84c3b 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt @@ -1,7 +1,7 @@ package dev.sanderling.sidecar interface DriverBackend { - fun launch(bundleId: String, clearState: Boolean) + fun launch(bundleId: String, clearState: Boolean, env: Map = emptyMap()) fun terminate(bundleId: String) fun tap(x: Int, y: Int) fun tapSelector(selector: String) @@ -131,7 +131,7 @@ class StubDriverBackend(private val platform: String) : DriverBackend { @Volatile var lastInputText: String? = null private set - override fun launch(bundleId: String, clearState: Boolean) { + override fun launch(bundleId: String, clearState: Boolean, env: Map) { launchCount++ lastBundleId = bundleId if (clearState) { @@ -386,9 +386,9 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend { driver.open() } - override fun launch(bundleId: String, clearState: Boolean) { + override fun launch(bundleId: String, clearState: Boolean, env: Map) { if (clearState) driver.clearAppState(bundleId) - driver.launchApp(bundleId, emptyMap(), java.util.UUID.randomUUID()) + driver.launchApp(bundleId, env, java.util.UUID.randomUUID()) } override fun terminate(bundleId: String) = driver.stopApp(bundleId) @@ -486,6 +486,80 @@ private fun pngHeight(bytes: ByteArray): Int { (bytes[22].toInt() and 0xFF shl 8) or (bytes[23].toInt() and 0xFF) } +class IosDriverBackend(private val udid: String) : DriverBackend { + private val driver: maestro.drivers.IOSDriver + + init { + val httpClient = xcuitest.api.OkHttpClientInstance.get() + val metrics = maestro.utils.NoOpMetrics() + val wdaPort = maestro.utils.SocketUtils.nextFreePort(22000, 23000) + val installer = xcuitest.installer.LocalXCTestInstaller( + udid, + "localhost", + false, + wdaPort, + metrics, + httpClient, + false, + false, + ) + val xcTestDriverClient = xcuitest.XCTestDriverClient(installer, httpClient, false) + val xcTestDevice = ios.xctest.XCTestIOSDevice(udid, xcTestDriverClient) { emptySet() } + val simctlDevice = ios.simctl.SimctlIOSDevice(udid) + val device = ios.LocalIOSDevice(udid, xcTestDevice, simctlDevice, maestro.utils.NoopInsights) + driver = maestro.drivers.IOSDriver(device, maestro.utils.NoopInsights, metrics) + driver.open() + } + + override fun launch(bundleId: String, clearState: Boolean, env: Map) { + runCatching { driver.stopApp(bundleId) } + if (clearState) driver.clearAppState(bundleId) + driver.launchApp(bundleId, env, java.util.UUID.randomUUID()) + } + + override fun terminate(bundleId: String) = driver.stopApp(bundleId) + + override fun tap(x: Int, y: Int) = driver.tap(maestro.Point(x, y)) + + override fun tapSelector(selector: String) { + val root = driver.contentDescriptor(false) + val bounds = findBoundsBySelector(root, selector) ?: return + driver.tap(maestro.Point((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2)) + } + + override fun inputText(text: String) = driver.inputText(text) + + override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) = + driver.swipe(maestro.Point(fromX, fromY), maestro.Point(toX, toY), maxOf(durationMillis, 250L)) + + override fun pressKey(key: String) { + StubDriverBackend.KEY_MAP[key]?.let { keyCode -> + keyCodeToMaestro(keyCode)?.let { driver.pressKey(it) } + } + } + + override fun screenshot(): Triple { + val buf = okio.Buffer() + driver.takeScreenshot(buf, false) + val bytes = buf.readByteArray() + return Triple(bytes, pngWidth(bytes), pngHeight(bytes)) + } + + override fun hierarchy(): String = + com.fasterxml.jackson.module.kotlin.jacksonObjectMapper() + .writeValueAsString(driver.contentDescriptor(false)) + + override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List = emptyList() + + override fun waitForIdle(durationMillis: Long) { + driver.waitForAppToSettle(null, null, durationMillis.toInt()) + } + + override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false } + + override fun metrics(bundleId: String) = MetricsSample(0.0, 0L, 0L) +} + private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? { return when (adbKeyCode) { "KEYCODE_BACK" -> maestro.KeyCode.BACK diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt index 8a2f19b..30f4989 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverService.kt @@ -30,7 +30,7 @@ class DriverService( override fun launch(request: LaunchRequest, responseObserver: StreamObserver) { runRpc(responseObserver) { - backend.launch(request.bundleId, request.clearState) + backend.launch(request.bundleId, request.clearState, request.envMap) launchedBundleId.set(request.bundleId) Empty.getDefaultInstance() } @@ -150,7 +150,8 @@ class DriverService( observer.onNext(block()) observer.onCompleted() } catch (cause: Exception) { - observer.onError(cause) + observer.onError(io.grpc.Status.INTERNAL.withDescription(cause.toString()) + .withCause(cause).asRuntimeException()) } } diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt index 929ac6d..4aeed6c 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/Main.kt @@ -44,11 +44,14 @@ 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 = if (platform == "android") { - MaestroDriverBackend(serial) - } else { - StubDriverBackend(platform) + 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) val server = SidecarServer(port, service)