mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
feat(ios): iOS e2e support — Kotlin Native SDK + simulator driver + test-ios (#38)
* 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
This commit is contained in:
14 files changed
+567
-26
No files matched your search
+11
-9
@@ -15,13 +15,14 @@ import (
|
|||||||
var Version = "dev"
|
var Version = "dev"
|
||||||
|
|
||||||
type testOptions struct {
|
type testOptions struct {
|
||||||
spec string
|
spec string
|
||||||
bundleID string
|
bundleID string
|
||||||
platform string
|
platform string
|
||||||
avd string
|
avd string
|
||||||
duration time.Duration
|
iosDevice string
|
||||||
seed int64
|
duration time.Duration
|
||||||
output string
|
seed int64
|
||||||
|
output string
|
||||||
}
|
}
|
||||||
|
|
||||||
const topUsage = `sanderling is a property-based UI fuzzer for mobile apps.
|
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
|
var options testOptions
|
||||||
flagSet.StringVar(&options.spec, "spec", "", "path to the TypeScript spec (required)")
|
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.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.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.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration")
|
||||||
flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)")
|
flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)")
|
||||||
flagSet.StringVar(&options.output, "output", "./runs", "output directory for traces")
|
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 {
|
switch options.platform {
|
||||||
case "android", "ios", "web":
|
case "android", "ios", "web":
|
||||||
default:
|
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
|
return options, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import (
|
|||||||
|
|
||||||
func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) error {
|
func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) error {
|
||||||
return testrun.Execute(ctx, testrun.Options{
|
return testrun.Execute(ctx, testrun.Options{
|
||||||
Spec: options.spec,
|
Spec: options.spec,
|
||||||
BundleID: options.bundleID,
|
BundleID: options.bundleID,
|
||||||
Platform: options.platform,
|
Platform: options.platform,
|
||||||
AVD: options.avd,
|
AVD: options.avd,
|
||||||
Duration: options.duration,
|
IosDevice: options.iosDevice,
|
||||||
Seed: options.seed,
|
Duration: options.duration,
|
||||||
Output: options.output,
|
Seed: options.seed,
|
||||||
|
Output: options.output,
|
||||||
}, stdout)
|
}, stdout)
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,11 @@ private val backGestureTarget = BackGestureTarget()
|
|||||||
@OptIn(ExperimentalForeignApi::class)
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
fun MainViewController(): UIViewController {
|
fun MainViewController(): UIViewController {
|
||||||
Repository.init()
|
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 vc = ComposeUIViewController { App() }
|
||||||
val gesture = UIScreenEdgePanGestureRecognizer(
|
val gesture = UIScreenEdgePanGestureRecognizer(
|
||||||
target = backGestureTarget,
|
target = backGestureTarget,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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<String, Any?> = emptyMap()
|
||||||
|
|
||||||
|
fun pauseAndSnapshot(extractors: () -> Map<String, Any?>): Map<String, Any?> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Any>()
|
||||||
|
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 }
|
||||||
|
}
|
||||||
+16
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, () -> 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 <T> snapshot(block: () -> T): SnapshotDelegate<T> = SnapshotDelegate(block)
|
||||||
|
}
|
||||||
|
|
||||||
|
class SnapshotDelegate<T>(private val block: () -> T) {
|
||||||
|
operator fun provideDelegate(thisRef: Any?, prop: KProperty<*>): ReadOnlyProperty<Any?, T> {
|
||||||
|
SanderlingIos.extract(prop.name.camelToSnakeCase(), block as () -> Any?)
|
||||||
|
return ReadOnlyProperty { _, _ -> block() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<sockaddr_in>()
|
||||||
|
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<sockaddr_in>().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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,3 +97,21 @@ test:
|
|||||||
--duration "{{duration}}" \
|
--duration "{{duration}}" \
|
||||||
--seed "{{seed}}" \
|
--seed "{{seed}}" \
|
||||||
--output "{{output}}"
|
--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}}"
|
||||||
@@ -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:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
-10
@@ -8,11 +8,13 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/priyanshujain/sanderling/internal/agent"
|
"github.com/priyanshujain/sanderling/internal/agent"
|
||||||
"github.com/priyanshujain/sanderling/internal/android"
|
"github.com/priyanshujain/sanderling/internal/android"
|
||||||
"github.com/priyanshujain/sanderling/internal/bundler"
|
"github.com/priyanshujain/sanderling/internal/bundler"
|
||||||
|
"github.com/priyanshujain/sanderling/internal/ios"
|
||||||
"github.com/priyanshujain/sanderling/internal/runner"
|
"github.com/priyanshujain/sanderling/internal/runner"
|
||||||
"github.com/priyanshujain/sanderling/internal/trace"
|
"github.com/priyanshujain/sanderling/internal/trace"
|
||||||
"github.com/priyanshujain/sanderling/internal/verifier"
|
"github.com/priyanshujain/sanderling/internal/verifier"
|
||||||
@@ -26,21 +28,27 @@ const (
|
|||||||
|
|
||||||
// Options are the parameters for a single test pipeline run.
|
// Options are the parameters for a single test pipeline run.
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Spec string
|
Spec string
|
||||||
BundleID string
|
BundleID string
|
||||||
Platform string
|
Platform string
|
||||||
AVD string
|
AVD string
|
||||||
Duration time.Duration
|
IosDevice string
|
||||||
Seed int64
|
Duration time.Duration
|
||||||
Output string
|
Seed int64
|
||||||
|
Output string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute runs the full test pipeline: bundle, connect SDK, verify properties.
|
// Execute runs the full test pipeline: bundle, connect SDK, verify properties.
|
||||||
func Execute(ctx context.Context, options Options, stdout io.Writer) error {
|
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 {
|
if err := android.EnsureDevice(ctx, options.AVD, stdout); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
case "ios":
|
||||||
|
if err := ios.EnsureSimulator(ctx, options.IosDevice, stdout); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
aliases := map[string]string{}
|
aliases := map[string]string{}
|
||||||
if specAPIPath := resolveSpecAPIPath(options.Spec); specAPIPath != "" {
|
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
|
var connection *agent.Conn
|
||||||
|
|
||||||
if options.Platform != "web" {
|
switch options.Platform {
|
||||||
|
case "android":
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("agent listener: %w", err)
|
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()
|
defer connection.Close()
|
||||||
hello := connection.Hello()
|
hello := connection.Hello()
|
||||||
fmt.Fprintf(stdout, "SDK connected: platform=%s app=%s sdk=%s\n", hello.Platform, hello.AppPackage, hello.Version)
|
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")
|
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); err != nil {
|
||||||
return fmt.Errorf("launch app: %w", err)
|
return fmt.Errorf("launch app: %w", err)
|
||||||
|
|||||||
Reference in new issue
Block a user