From 007dcddd69a6a5c428fd5c3e50143c2c1dedb8fb Mon Sep 17 00:00:00 2001 From: pjay Date: Thu, 23 Apr 2026 17:29:53 +0700 Subject: [PATCH] =?UTF-8?q?feat(ios):=20iOS=20e2e=20support=20=E2=80=94=20?= =?UTF-8?q?Kotlin=20Native=20SDK=20+=20simulator=20driver=20+=20test-ios?= =?UTF-8?q?=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)