diff --git a/.gitignore b/.gitignore index 2afe5ba..3f9bd4f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ local.properties # Go /bin/ +/sanderling *.test *.out diff --git a/examples/folio/sanderling/spec.ts b/examples/folio/sanderling/spec.ts index 07be367..ba9ccd5 100644 --- a/examples/folio/sanderling/spec.ts +++ b/examples/folio/sanderling/spec.ts @@ -3,353 +3,148 @@ import { Tap, actions, always, - eventually, extract, from, next, now, - pressKey, - swipes, - taps, - waitOnce, weighted, } from "@sanderling/spec"; -import { noUncaughtExceptions } from "@sanderling/spec/defaults/properties"; -interface AccountSnapshot { +interface Account { id: string; name: string; balance: number; - txnCount: number; } interface LedgerRow { id: string; - accountId: string; - type: "credit" | "debit"; - amount: number; signed: number; } -const loggedIn = extract( - (state) => (state.snapshots.logged_in as boolean) ?? false, -); -const route = extract( - (state) => (state.snapshots.screen as string) ?? "", -); -const accounts = extract( - (state) => (state.snapshots.accounts as AccountSnapshot[]) ?? [], -); -const totalBalance = extract( - (state) => (state.snapshots.total_balance as number) ?? 0, -); -const activeAccountId = extract( - (state) => (state.snapshots.active_account_id as string | null) ?? null, -); -const ledgerRows = extract( - (state) => (state.snapshots.ledger_rows as LedgerRow[]) ?? [], -); -const ledgerBalance = extract( - (state) => (state.snapshots.ledger_balance as number) ?? 0, -); -const focusedInput = extract( - (state) => (state.snapshots.focused_input as string | null) ?? null, -); -const txnFormType = extract( - (state) => (state.snapshots.txn_form_type as string | null) ?? null, -); -const loginError = extract( - (state) => (state.snapshots.login_error as string) ?? "", -); -const addAccountError = extract( - (state) => (state.snapshots.add_account_error as string) ?? "", -); -const txnError = extract( - (state) => (state.snapshots.txn_error as string) ?? "", -); -const loginEmailField = extract((state) => state.ax.find("desc:login_email")); -const loginPasswordField = extract((state) => state.ax.find("desc:login_password")); -const loginSubmitButton = extract((state) => state.ax.find("desc:login_submit")); -const addAccountButton = extract((state) => state.ax.find("desc:add_account_button")); -const logoutButton = extract((state) => state.ax.find("desc:logout_button")); -const accountNameField = extract((state) => state.ax.find("desc:account_name_field")); -const addAccountSubmit = extract((state) => state.ax.find("desc:add_account_submit")); -const addTxnButton = extract((state) => state.ax.find("desc:add_txn_button")); -const txnAmountField = extract((state) => state.ax.find("desc:txn_amount")); -const txnNoteField = extract((state) => state.ax.find("desc:txn_note")); -const txnCredit = extract((state) => state.ax.find("desc:txn_credit")); -const txnDebit = extract((state) => state.ax.find("desc:txn_debit")); -const txnSubmit = extract((state) => state.ax.find("desc:txn_submit")); -const backButton = extract((state) => state.ax.find("desc:Back")); -const allAccountCards = extract((state) => - state.ax.findAll("descPrefix:account_card:"), +const loggedIn = extract(s => (s.snapshots.logged_in as boolean) ?? false); +const route = extract(s => (s.snapshots.screen as string) ?? ""); +const accounts = extract(s => (s.snapshots.accounts as Account[]) ?? []); +const ledgerRows = extract(s => (s.snapshots.ledger_rows as LedgerRow[]) ?? []); +const ledgerBalance = extract(s => (s.snapshots.ledger_balance as number) ?? 0); +const activeAccountId = extract(s => (s.snapshots.active_account_id as string | null) ?? null); +const focusedInput = extract(s => (s.snapshots.focused_input as string | null) ?? null); + +const loginEmailField = extract(s => s.ax.find("desc:login_email")); +const loginPasswordField = extract(s => s.ax.find("desc:login_password")); +const loginSubmit = extract(s => s.ax.find("desc:login_submit")); +const addAccountButton = extract(s => s.ax.find("desc:add_account_button")); +const accountNameField = extract(s => s.ax.find("desc:account_name_field")); +const addAccountSubmit = extract(s => s.ax.find("desc:add_account_submit")); +const addTxnButton = extract(s => s.ax.find("desc:add_txn_button")); +const txnAmountField = extract(s => s.ax.find("desc:txn_amount")); +const txnSubmit = extract(s => s.ax.find("desc:txn_submit")); +const accountCards = extract(s => s.ax.findAll("descPrefix:account_card:")); +const backButton = extract(s => s.ax.find("desc:Back")); + +// Property 1: every new account starts with balance === 0 +const newAccountBalanceIsZero = always( + next(() => { + const prevIds = new Set((accounts.previous ?? []).map(a => a.id)); + const newAccounts = accounts.current.filter(a => !prevIds.has(a.id)); + return newAccounts.every(a => a.balance === 0); + }) ); -const balanceMatchesTransactionDelta = always( +// Property 2: every new transaction changes the account ledger balance by exactly its signed amount +const newTxnChangesBalance = always( now(() => activeAccountId.current !== null).implies( next(() => { - const prevActive = activeAccountId.previous; - if (prevActive === null || prevActive === undefined) return true; - if (prevActive !== activeAccountId.current) return true; const prevRows = ledgerRows.previous ?? []; const curRows = ledgerRows.current; if (curRows.length !== prevRows.length + 1) return true; - const prevIds = new Set(prevRows.map((r) => r.id)); - const added = curRows.filter((r) => !prevIds.has(r.id)); - if (added.length !== 1) return true; + const prevIds = new Set(prevRows.map(r => r.id)); + const added = curRows.find(r => !prevIds.has(r.id)); + if (!added) return true; const delta = ledgerBalance.current - (ledgerBalance.previous ?? 0); - return delta === added[0].signed; - }), - ), + return delta === added.signed && delta !== 0; + }) + ) ); -const totalEqualsSumOfAccounts = always(() => { - const sum = accounts.current.reduce((acc, a) => acc + a.balance, 0); - return sum === totalBalance.current; -}); - -const balanceChangeRequiresActiveAccount = always( - now(() => true).implies( - next(() => { - const prevAccounts = accounts.previous ?? []; - const prevActive = activeAccountId.previous ?? null; - for (const cur of accounts.current) { - const prev = prevAccounts.find((a) => a.id === cur.id); - if (!prev) continue; - if (cur.balance !== prev.balance && prevActive !== cur.id) return false; - } - return true; - }), - ), -); - -const duplicateAccountNamesRejected = always(() => { - const seen = new Set(); - for (const a of accounts.current) { - const key = a.name.trim().toLowerCase(); - if (seen.has(key)) return false; - seen.add(key); - } - return true; -}); - -const domainInvariants = { - balanceMatchesTransactionDelta, - totalEqualsSumOfAccounts, - balanceChangeRequiresActiveAccount, - duplicateAccountNamesRejected, -}; - -const loggedInLeavesLogin = always( - now(() => loggedIn.current).implies( - eventually(() => route.current !== "login").within(3, "seconds"), - ), -); - -const loggedOutReachesLogin = always( - now(() => !loggedIn.current).implies( - eventually(() => route.current === "login").within(3, "seconds"), - ), -); - -const authRouting = { - loggedInLeavesLogin, - loggedOutReachesLogin, -}; - -const loginReachable = eventually(() => loggedIn.current).within(90, "seconds"); -const accountCreationReachable = eventually( - () => accounts.current.length > 0, -).within(180, "seconds"); -const someTransactionExists = eventually(() => - accounts.current.some((a) => a.txnCount > 0), -).within(300, "seconds"); - -const loginErrorClears = always( - now(() => loginError.current !== "").implies( - eventually(() => loginError.current === "").within(30, "seconds"), - ), -); -const addAccountErrorClears = always( - now(() => addAccountError.current !== "").implies( - eventually(() => addAccountError.current === "").within(30, "seconds"), - ), -); -const txnErrorClears = always( - now(() => txnError.current !== "").implies( - eventually(() => txnError.current === "").within(30, "seconds"), - ), -); - -const liveness = { - loginReachable, - accountCreationReachable, - someTransactionExists, - loginErrorClears, - addAccountErrorClears, - txnErrorClears, -}; - const DEMO_EMAIL = "demo@folio.app"; const DEMO_PASSWORD = "ledger123"; -const loginHelper = actions(() => { +// Login if not already in — step by step based on which field has focus +const login = actions(() => { if (loggedIn.current) return []; const focus = focusedInput.current; - const email = loginEmailField.current; - const password = loginPasswordField.current; - const submit = loginSubmitButton.current; - if (focus === "login_password") { + const submit = loginSubmit.current; return submit ? [Tap({ on: submit })] : []; } if (focus === "login_email") { - return password ? [InputText({ into: password, text: DEMO_PASSWORD })] : []; + const pwd = loginPasswordField.current; + return pwd ? [InputText({ into: pwd, text: DEMO_PASSWORD })] : []; } + const email = loginEmailField.current; return email ? [InputText({ into: email, text: DEMO_EMAIL })] : []; }); -const adversarialLogin = actions(() => { - if (loggedIn.current) return []; - if (focusedInput.current !== null) return []; - const submit = loginSubmitButton.current; - if (!submit) return []; - return [Tap({ on: submit })]; +const accountNames = from(["Checking", "Savings", "Travel", "Emergency Fund", "Investments"]); + +// Add an account: home -> tap add -> type name -> submit +const addAccount = actions(() => { + if (!loggedIn.current) return []; + if (route.current === "home") { + const btn = addAccountButton.current; + return btn ? [Tap({ on: btn })] : []; + } + if (route.current === "add-account") { + const field = accountNameField.current; + const submit = addAccountSubmit.current; + const opts = []; + if (field) opts.push(InputText({ into: field, text: accountNames.generate() })); + if (submit) opts.push(Tap({ on: submit })); + return opts; + } + return []; }); -const accountNameSampler = from([ - "Checking", - "Savings", - "Travel", - "Rent", - "Emergency Fund", - "Investments", - "Groceries", - " ", - "Checking", - "A".repeat(41), - "Petty Cash", -]); +const amounts = from(["10", "50", "25", "100", "5"]); -const typeAccountName = actions(() => { - if (route.current !== "add-account") return []; - const field = accountNameField.current; - if (!field) return []; - return [InputText({ into: field, text: accountNameSampler.generate() })]; +// Add a transaction: home -> tap account card -> tap add txn -> type amount -> submit +const addTxn = actions(() => { + if (!loggedIn.current) return []; + if (route.current === "home") { + const cards = accountCards.current; + if (cards.length === 0) return []; + return [Tap({ on: cards[Math.floor(Math.random() * cards.length)] })]; + } + if (route.current === "ledger") { + const btn = addTxnButton.current; + return btn ? [Tap({ on: btn })] : []; + } + if (route.current === "add-transaction") { + const field = txnAmountField.current; + const submit = txnSubmit.current; + const opts = []; + if (field) opts.push(InputText({ into: field, text: amounts.generate() })); + if (submit) opts.push(Tap({ on: submit })); + return opts; + } + return []; }); -const submitAddAccount = actions(() => { - if (route.current !== "add-account") return []; - const submit = addAccountSubmit.current; - return submit ? [Tap({ on: submit })] : []; -}); - -const openAddAccount = actions(() => { - if (route.current !== "home") return []; - const button = addAccountButton.current; - return button ? [Tap({ on: button })] : []; -}); - -const openRandomAccount = actions(() => { - if (route.current !== "home") return []; - const cards = allAccountCards.current; - if (cards.length === 0) return []; - const card = cards[Math.floor(Math.random() * cards.length)]; - return [Tap({ on: card })]; -}); - -const logoutAction = actions(() => { - if (route.current !== "home") return []; - const button = logoutButton.current; - return button ? [Tap({ on: button })] : []; -}); - -const goBack = actions(() => { - const button = backButton.current; - return button ? [Tap({ on: button })] : []; -}); - -const amountSampler = from([ - "12.34", - "100", - "0.01", - "999.99", - "5.5", - "42", - "0", - "", - "1e4", - "0.001", - "-5", -]); - -const typeAmount = actions(() => { - if (route.current !== "add-transaction") return []; - const field = txnAmountField.current; - if (!field) return []; - return [InputText({ into: field, text: amountSampler.generate() })]; -}); - -const noteSampler = from([ - "Coffee", - "Paycheck", - "Gas", - "Refund", - "", - "Groceries for the week", -]); - -const typeNote = actions(() => { - if (route.current !== "add-transaction") return []; - const field = txnNoteField.current; - if (!field) return []; - return [InputText({ into: field, text: noteSampler.generate() })]; -}); - -const toggleTxnType = actions(() => { - if (route.current !== "add-transaction") return []; - const current = txnFormType.current; - const target = current === "credit" ? txnDebit.current : txnCredit.current; - return target ? [Tap({ on: target })] : []; -}); - -const submitTxn = actions(() => { - if (route.current !== "add-transaction") return []; - const submit = txnSubmit.current; - return submit ? [Tap({ on: submit })] : []; -}); - -const openAddTxn = actions(() => { - if (route.current !== "ledger") return []; - const button = addTxnButton.current; - return button ? [Tap({ on: button })] : []; +const back = actions(() => { + const btn = backButton.current; + return btn ? [Tap({ on: btn })] : []; }); export const properties = { - ...domainInvariants, - ...authRouting, - ...liveness, - noUncaughtExceptions, + newAccountBalanceIsZero, + newTxnChangesBalance, }; export const actionsRoot = weighted( - [30, loginHelper], - [2, adversarialLogin], - [18, typeAccountName], - [14, submitAddAccount], - [18, typeAmount], - [8, typeNote], - [6, toggleTxnType], - [16, submitTxn], - [14, openAddAccount], - [14, openRandomAccount], - [12, openAddTxn], - [6, goBack], - [1, logoutAction], - [4, taps], - [2, swipes], - [2, waitOnce], - [2, pressKey], + [50, login], + [30, addAccount], + [30, addTxn], + [5, back], ); (globalThis as { actions?: unknown; properties?: unknown }).actions = actionsRoot; diff --git a/internal/hierarchy/hierarchy.go b/internal/hierarchy/hierarchy.go index fbadd25..60f5317 100644 --- a/internal/hierarchy/hierarchy.go +++ b/internal/hierarchy/hierarchy.go @@ -183,7 +183,8 @@ func match(element *Element, kind, value string) bool { case "text": return element.Text == value case "desc": - return element.Description == value + // Exact match, or iOS merged label "desc, child text". + return element.Description == value || strings.HasPrefix(element.Description, value+", ") case "descPrefix": return strings.HasPrefix(element.Description, value) default: @@ -191,21 +192,34 @@ func match(element *Element, kind, value string) bool { } } -// boundsPattern matches "[l,t,r,b]" (4-value Maestro format). +// boundsPattern matches "[l,t,r,b]" (4-value Android/Maestro format). var boundsPattern = regexp.MustCompile(`^\[(-?\d+),(-?\d+),(-?\d+),(-?\d+)\]$`) +// boundsPatternTwo matches "[x1,y1][x2,y2]" (iOS XCUITest format). +var boundsPatternTwo = regexp.MustCompile(`^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$`) + func parseBounds(text string) (Bounds, error) { - m := boundsPattern.FindStringSubmatch(text) - if m == nil { - return Bounds{}, fmt.Errorf("bounds %q: not in [L,T,R,B] form", text) - } - coords := make([]int, 4) - for i := range 4 { - v, err := strconv.Atoi(m[i+1]) - if err != nil { - return Bounds{}, err + if m := boundsPattern.FindStringSubmatch(text); m != nil { + coords := make([]int, 4) + for i := range 4 { + v, err := strconv.Atoi(m[i+1]) + if err != nil { + return Bounds{}, err + } + coords[i] = v } - coords[i] = v + return Bounds{Left: coords[0], Top: coords[1], Right: coords[2], Bottom: coords[3]}, nil } - return Bounds{Left: coords[0], Top: coords[1], Right: coords[2], Bottom: coords[3]}, nil + if m := boundsPatternTwo.FindStringSubmatch(text); m != nil { + coords := make([]int, 4) + for i := range 4 { + v, err := strconv.Atoi(m[i+1]) + if err != nil { + return Bounds{}, err + } + coords[i] = v + } + return Bounds{Left: coords[0], Top: coords[1], Right: coords[2], Bottom: coords[3]}, nil + } + return Bounds{}, fmt.Errorf("bounds %q: not in [L,T,R,B] or [x1,y1][x2,y2] form", text) } diff --git a/internal/hierarchy/hierarchy_test.go b/internal/hierarchy/hierarchy_test.go index fa1c595..efd6284 100644 --- a/internal/hierarchy/hierarchy_test.go +++ b/internal/hierarchy/hierarchy_test.go @@ -164,3 +164,38 @@ func TestAccessibilityTextFallback(t *testing.T) { t.Fatal("expected match via accessibilityText fallback") } } + +func TestIOSMergedLabel(t *testing.T) { + // iOS merges contentDescription with child text: "add_account_button, + Add account" + input := `{ + "attributes": {"accessibilityText": "add_account_button, + Add account", "bounds": "[20,777][382,825]"}, + "children": [] + }` + tree, _ := Parse(input) + el := tree.Find("desc:add_account_button") + if el == nil { + t.Fatal("expected desc: to match iOS merged label") + } + if el.Bounds.Left != 20 || el.Bounds.Top != 777 || el.Bounds.Right != 382 || el.Bounds.Bottom != 825 { + t.Errorf("unexpected bounds: %+v", el.Bounds) + } +} + +func TestIOSBoundsFormat(t *testing.T) { + input := `{ + "attributes": {"accessibilityText": "account_card:abc123, Tim, $100", "bounds": "[20,130][382,202]"}, + "children": [] + }` + tree, _ := Parse(input) + el := tree.Find("descPrefix:account_card:") + if el == nil { + t.Fatal("expected descPrefix to match iOS account card") + } + if el.Bounds.Left != 20 || el.Bounds.Top != 130 || el.Bounds.Right != 382 || el.Bounds.Bottom != 202 { + t.Errorf("unexpected bounds: %+v", el.Bounds) + } + cx, cy := el.Bounds.Center() + if cx != 201 || cy != 166 { + t.Errorf("unexpected center: (%d, %d)", cx, cy) + } +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 3d606cc..32087f0 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "strings" "time" "golang.org/x/sync/errgroup" @@ -105,6 +106,9 @@ func Run(ctx context.Context, options Options) (Summary, error) { g.Wait() if hierarchyErr != nil { + if isWDADrop(hierarchyErr) { + return summary, fmt.Errorf("WDA connection permanently lost at step %d - re-run the test: %w", stepIndex, hierarchyErr) + } logger.Warn("hierarchy fetch failed", "step", stepIndex, "err", hierarchyErr) } treeSize := 0 @@ -192,6 +196,9 @@ func Run(ctx context.Context, options Options) (Summary, error) { if nextErr == nil { if err := applyAction(ctx, options.Driver, nextAction, tree); err != nil { + if isWDADrop(err) { + return summary, fmt.Errorf("step %d: iOS XCTest runner lost connection - known WDA startup flake, re-run the test: %w", stepIndex, err) + } return summary, fmt.Errorf("step %d apply: %w", stepIndex, err) } actionCopy := nextAction @@ -500,6 +507,12 @@ func encodeResiduals(residuals map[string]ltl.Formula) (map[string]json.RawMessa return encoded, firstErr } +func isWDADrop(err error) bool { + msg := err.Error() + return strings.Contains(msg, "ConnectException") || + (strings.Contains(msg, "code = Internal") && strings.Contains(msg, "SocketException")) +} + func traceExceptions(exceptions []verifier.Exception) []trace.Exception { if len(exceptions) == 0 { return nil diff --git a/internal/testrun/driver.go b/internal/testrun/driver.go index e7336d3..be3a215 100644 --- a/internal/testrun/driver.go +++ b/internal/testrun/driver.go @@ -59,6 +59,9 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver _ = sidecarCommand.Process.Kill() return nil, nil, fmt.Errorf("dial sidecar: %w", err) } + // WaitForHealth confirms the gRPC sidecar is up. For iOS, the WDA warmup + // (absorbing the XCUITest startup race) runs inside IosDriverBackend.init + // in the sidecar - no additional sleep needed here. healthCtx, healthCancel := context.WithTimeout(ctx, sidecarStartupTimeout) if err := driverClient.WaitForHealth(healthCtx, 250e6); err != nil { healthCancel() diff --git a/sanderling b/sanderling deleted file mode 100755 index 4c38e27..0000000 Binary files a/sanderling and /dev/null differ diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt index be84c3b..c3c6762 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt @@ -487,7 +487,8 @@ private fun pngHeight(bytes: ByteArray): Int { } class IosDriverBackend(private val udid: String) : DriverBackend { - private val driver: maestro.drivers.IOSDriver + private lateinit var driver: maestro.drivers.IOSDriver + private val reconnectLock = java.util.concurrent.locks.ReentrantLock() init { val httpClient = xcuitest.api.OkHttpClientInstance.get() @@ -509,50 +510,90 @@ class IosDriverBackend(private val udid: String) : DriverBackend { val device = ios.LocalIOSDevice(udid, xcTestDevice, simctlDevice, maestro.utils.NoopInsights) driver = maestro.drivers.IOSDriver(device, maestro.utils.NoopInsights, metrics) driver.open() + warmup() } - override fun launch(bundleId: String, clearState: Boolean, env: Map) { + private fun warmup() { + var warmupErr: Exception? = null + repeat(3) { attempt -> + try { + driver.contentDescriptor(false) + warmupErr = null + return@repeat + } catch (e: Exception) { + warmupErr = e + if (attempt < 2) Thread.sleep(500) + } + } + warmupErr?.let { throw IllegalStateException("WDA warmup failed after 3 attempts: $it") } + } + + private fun withReconnect(block: () -> T): T { + return try { + block() + } catch (e: Exception) { + val isIoFailure = generateSequence(e as Throwable) { it.cause } + .any { it is java.io.IOException } + if (!isIoFailure) throw e + reconnectLock.lock() + try { + try { driver.open(); warmup() } + catch (reconnectErr: Exception) { + throw IllegalStateException("WDA reconnect failed: $reconnectErr", e) + } + } finally { + reconnectLock.unlock() + } + block() + } + } + + override fun launch(bundleId: String, clearState: Boolean, env: Map) = withReconnect { 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 terminate(bundleId: String) = withReconnect { driver.stopApp(bundleId) } - override fun tap(x: Int, y: Int) = driver.tap(maestro.Point(x, y)) + override fun tap(x: Int, y: Int) = withReconnect { driver.tap(maestro.Point(x, y)) } - override fun tapSelector(selector: String) { + override fun tapSelector(selector: String) = withReconnect { val root = driver.contentDescriptor(false) - val bounds = findBoundsBySelector(root, selector) ?: return + val bounds = findBoundsBySelector(root, selector) ?: return@withReconnect driver.tap(maestro.Point((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2)) } - override fun inputText(text: String) = driver.inputText(text) + override fun inputText(text: String) = withReconnect { driver.inputText(text) } - override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) = + override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) = withReconnect { driver.swipe(maestro.Point(fromX, fromY), maestro.Point(toX, toY), maxOf(durationMillis, 250L)) + } - override fun pressKey(key: String) { + override fun pressKey(key: String) = withReconnect { StubDriverBackend.KEY_MAP[key]?.let { keyCode -> keyCodeToMaestro(keyCode)?.let { driver.pressKey(it) } } + Unit } - override fun screenshot(): Triple { + override fun screenshot(): Triple = withReconnect { val buf = okio.Buffer() driver.takeScreenshot(buf, false) val bytes = buf.readByteArray() - return Triple(bytes, pngWidth(bytes), pngHeight(bytes)) + Triple(bytes, pngWidth(bytes), pngHeight(bytes)) } - override fun hierarchy(): String = + override fun hierarchy(): String = withReconnect { com.fasterxml.jackson.module.kotlin.jacksonObjectMapper() .writeValueAsString(driver.contentDescriptor(false)) + } override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List = emptyList() - override fun waitForIdle(durationMillis: Long) { + override fun waitForIdle(durationMillis: Long) = withReconnect { driver.waitForAppToSettle(null, null, durationMillis.toInt()) + Unit } override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false }