fix: WDA startup flakiness - warmup + connection drop message (#40)

* 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

* feat(proto): add env map to LaunchRequest

* feat(driver): add env param to Launch interface + all implementations

* feat(testrun): launch iOS app via XCTest with env vars instead of simctl

* feat(sidecar): add IosDriverBackend using Maestro IOSDriver + env pass-through

* feat(sidecar): wire env map in DriverService + IosDriverBackend in Main

* fix(sidecar): use LocalIOSDevice (WDA+simctl) + stop before relaunch

* fix(sidecar): include exception type in gRPC error description

* fix(sidecar): pick free WDA port instead of hardcoded 9100

Use SocketUtils.nextFreePort to pick a free port in the 22000-23000 range
rather than hardcoding 9100, which only worked if a previous WDA session
left a listener there.

* fix(sdk-ios): check semaphore wait result and throw on snapshot timeout

dispatch_semaphore_wait returns nonzero on timeout; ignoring the return
value caused pauseAndSnapshot to silently return an empty map, sending a
garbage empty STATE frame to the host. Now throws so the agent loop
reconnects instead.

* fix(folio-ios): register snapshot extractors before starting agent

SanderlingIos.start() was called before the snapshot objects were
initialized, so a PAUSE message arriving early produced an empty snapshot.
Move start() to after all extractors are registered.

* chore(ios): remove dead LaunchApp function

LaunchApp had no callers since bff3a49 switched iOS launch to go through
the sidecar driver. Remove the dead code and unused os import.

* test(ios): add unit tests for pickSimulator and iOS flag parsing

Tests for all pickSimulator branches (by name, by UDID, unknown, empty
list, iPhone preference, fallback to first). Also tests BootedUDID on a
canceled context and verifies --platform ios and --ios-device flags are
accepted by parseTestArgs.

* fix(ios): propagate error from BootedUDID instead of silently swallowing

* fix(sidecar): IosDriverBackend.healthy() returns true; WDA liveness checked in open()

* fix(sidecar): warm up WDA after health check to absorb startup race

* fix(runner): surface clear message on WDA connection drop

* docs(testrun): note WDA warmup location above WaitForHealth

* fix(sidecar): extract warmup + add one-shot WDA reconnect on IOException

* fix(runner): fatal on permanent WDA drop during hierarchy fetch

* fix(sidecar): walk cause chain in withReconnect to catch Maestro-wrapped IOException

* fix(sidecar): explicit Unit return in pressKey and waitForIdle withReconnect lambdas

* fix(sidecar): serialize WDA reconnect with ReentrantLock to prevent concurrent xcodebuild races

* fix web examples package config

* Revert "fix web examples package config"

This reverts commit 70c10ade27.

* chore: gitignore built sanderling binary

* fix(hierarchy): parse iOS [x1,y1][x2,y2] bounds + match iOS merged desc labels

* refactor(folio-spec): replace bloated spec with two focused properties

Login is opportunistic. Two concrete properties:
1. every new account starts with balance 0
2. every new txn changes ledger balance by exactly its signed amount

Actions: directed login -> addAccount -> addTxn -> back weighted flow.
This commit is contained in:
pj authored and GitHub committed 2026-04-25 17:39:39 +07:00
1 parent 97154cf580
commit 2a1b263b8c
8 files changed
+211 -309

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ local.properties
# Go # Go
/bin/ /bin/
/sanderling
*.test *.test
*.out *.out
+85 -290
View File
@@ -3,353 +3,148 @@ import {
Tap, Tap,
actions, actions,
always, always,
eventually,
extract, extract,
from, from,
next, next,
now, now,
pressKey,
swipes,
taps,
waitOnce,
weighted, weighted,
} from "@sanderling/spec"; } from "@sanderling/spec";
import { noUncaughtExceptions } from "@sanderling/spec/defaults/properties";
interface AccountSnapshot { interface Account {
id: string; id: string;
name: string; name: string;
balance: number; balance: number;
txnCount: number;
} }
interface LedgerRow { interface LedgerRow {
id: string; id: string;
accountId: string;
type: "credit" | "debit";
amount: number;
signed: number; signed: number;
} }
const loggedIn = extract<boolean>( const loggedIn = extract<boolean>(s => (s.snapshots.logged_in as boolean) ?? false);
(state) => (state.snapshots.logged_in as boolean) ?? false, const route = extract<string>(s => (s.snapshots.screen as string) ?? "");
); const accounts = extract<Account[]>(s => (s.snapshots.accounts as Account[]) ?? []);
const route = extract<string>( const ledgerRows = extract<LedgerRow[]>(s => (s.snapshots.ledger_rows as LedgerRow[]) ?? []);
(state) => (state.snapshots.screen as string) ?? "", const ledgerBalance = extract<number>(s => (s.snapshots.ledger_balance as number) ?? 0);
); const activeAccountId = extract<string | null>(s => (s.snapshots.active_account_id as string | null) ?? null);
const accounts = extract<AccountSnapshot[]>( const focusedInput = extract<string | null>(s => (s.snapshots.focused_input as string | null) ?? null);
(state) => (state.snapshots.accounts as AccountSnapshot[]) ?? [],
); const loginEmailField = extract(s => s.ax.find("desc:login_email"));
const totalBalance = extract<number>( const loginPasswordField = extract(s => s.ax.find("desc:login_password"));
(state) => (state.snapshots.total_balance as number) ?? 0, const loginSubmit = extract(s => s.ax.find("desc:login_submit"));
); const addAccountButton = extract(s => s.ax.find("desc:add_account_button"));
const activeAccountId = extract<string | null>( const accountNameField = extract(s => s.ax.find("desc:account_name_field"));
(state) => (state.snapshots.active_account_id as string | null) ?? null, const addAccountSubmit = extract(s => s.ax.find("desc:add_account_submit"));
); const addTxnButton = extract(s => s.ax.find("desc:add_txn_button"));
const ledgerRows = extract<LedgerRow[]>( const txnAmountField = extract(s => s.ax.find("desc:txn_amount"));
(state) => (state.snapshots.ledger_rows as LedgerRow[]) ?? [], const txnSubmit = extract(s => s.ax.find("desc:txn_submit"));
); const accountCards = extract(s => s.ax.findAll("descPrefix:account_card:"));
const ledgerBalance = extract<number>( const backButton = extract(s => s.ax.find("desc:Back"));
(state) => (state.snapshots.ledger_balance as number) ?? 0,
); // Property 1: every new account starts with balance === 0
const focusedInput = extract<string | null>( const newAccountBalanceIsZero = always(
(state) => (state.snapshots.focused_input as string | null) ?? null, next(() => {
); const prevIds = new Set((accounts.previous ?? []).map(a => a.id));
const txnFormType = extract<string | null>( const newAccounts = accounts.current.filter(a => !prevIds.has(a.id));
(state) => (state.snapshots.txn_form_type as string | null) ?? null, return newAccounts.every(a => a.balance === 0);
); })
const loginError = extract<string>(
(state) => (state.snapshots.login_error as string) ?? "",
);
const addAccountError = extract<string>(
(state) => (state.snapshots.add_account_error as string) ?? "",
);
const txnError = extract<string>(
(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 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( now(() => activeAccountId.current !== null).implies(
next(() => { next(() => {
const prevActive = activeAccountId.previous;
if (prevActive === null || prevActive === undefined) return true;
if (prevActive !== activeAccountId.current) return true;
const prevRows = ledgerRows.previous ?? []; const prevRows = ledgerRows.previous ?? [];
const curRows = ledgerRows.current; const curRows = ledgerRows.current;
if (curRows.length !== prevRows.length + 1) return true; if (curRows.length !== prevRows.length + 1) return true;
const prevIds = new Set(prevRows.map((r) => r.id)); const prevIds = new Set(prevRows.map(r => r.id));
const added = curRows.filter((r) => !prevIds.has(r.id)); const added = curRows.find(r => !prevIds.has(r.id));
if (added.length !== 1) return true; if (!added) return true;
const delta = ledgerBalance.current - (ledgerBalance.previous ?? 0); 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<string>();
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 = "[email protected]"; const DEMO_EMAIL = "[email protected]";
const DEMO_PASSWORD = "ledger123"; 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 []; if (loggedIn.current) return [];
const focus = focusedInput.current; const focus = focusedInput.current;
const email = loginEmailField.current;
const password = loginPasswordField.current;
const submit = loginSubmitButton.current;
if (focus === "login_password") { if (focus === "login_password") {
const submit = loginSubmit.current;
return submit ? [Tap({ on: submit })] : []; return submit ? [Tap({ on: submit })] : [];
} }
if (focus === "login_email") { 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 })] : []; return email ? [InputText({ into: email, text: DEMO_EMAIL })] : [];
}); });
const adversarialLogin = actions(() => { const accountNames = from(["Checking", "Savings", "Travel", "Emergency Fund", "Investments"]);
if (loggedIn.current) return [];
if (focusedInput.current !== null) return [];
const submit = loginSubmitButton.current;
if (!submit) return [];
return [Tap({ on: submit })];
});
const accountNameSampler = from([ // Add an account: home -> tap add -> type name -> submit
"Checking", const addAccount = actions(() => {
"Savings", if (!loggedIn.current) return [];
"Travel", if (route.current === "home") {
"Rent", const btn = addAccountButton.current;
"Emergency Fund", return btn ? [Tap({ on: btn })] : [];
"Investments", }
"Groceries", if (route.current === "add-account") {
" ",
"Checking",
"A".repeat(41),
"Petty Cash",
]);
const typeAccountName = actions(() => {
if (route.current !== "add-account") return [];
const field = accountNameField.current; const field = accountNameField.current;
if (!field) return [];
return [InputText({ into: field, text: accountNameSampler.generate() })];
});
const submitAddAccount = actions(() => {
if (route.current !== "add-account") return [];
const submit = addAccountSubmit.current; const submit = addAccountSubmit.current;
return submit ? [Tap({ on: submit })] : []; const opts = [];
if (field) opts.push(InputText({ into: field, text: accountNames.generate() }));
if (submit) opts.push(Tap({ on: submit }));
return opts;
}
return [];
}); });
const openAddAccount = actions(() => { const amounts = from(["10", "50", "25", "100", "5"]);
if (route.current !== "home") return [];
const button = addAccountButton.current;
return button ? [Tap({ on: button })] : [];
});
const openRandomAccount = actions(() => { // Add a transaction: home -> tap account card -> tap add txn -> type amount -> submit
if (route.current !== "home") return []; const addTxn = actions(() => {
const cards = allAccountCards.current; if (!loggedIn.current) return [];
if (route.current === "home") {
const cards = accountCards.current;
if (cards.length === 0) return []; if (cards.length === 0) return [];
const card = cards[Math.floor(Math.random() * cards.length)]; return [Tap({ on: cards[Math.floor(Math.random() * cards.length)] })];
return [Tap({ on: card })]; }
}); if (route.current === "ledger") {
const btn = addTxnButton.current;
const logoutAction = actions(() => { return btn ? [Tap({ on: btn })] : [];
if (route.current !== "home") return []; }
const button = logoutButton.current; if (route.current === "add-transaction") {
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; 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; const submit = txnSubmit.current;
return submit ? [Tap({ on: submit })] : []; const opts = [];
if (field) opts.push(InputText({ into: field, text: amounts.generate() }));
if (submit) opts.push(Tap({ on: submit }));
return opts;
}
return [];
}); });
const openAddTxn = actions(() => { const back = actions(() => {
if (route.current !== "ledger") return []; const btn = backButton.current;
const button = addTxnButton.current; return btn ? [Tap({ on: btn })] : [];
return button ? [Tap({ on: button })] : [];
}); });
export const properties = { export const properties = {
...domainInvariants, newAccountBalanceIsZero,
...authRouting, newTxnChangesBalance,
...liveness,
noUncaughtExceptions,
}; };
export const actionsRoot = weighted( export const actionsRoot = weighted(
[30, loginHelper], [50, login],
[2, adversarialLogin], [30, addAccount],
[18, typeAccountName], [30, addTxn],
[14, submitAddAccount], [5, back],
[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],
); );
(globalThis as { actions?: unknown; properties?: unknown }).actions = actionsRoot; (globalThis as { actions?: unknown; properties?: unknown }).actions = actionsRoot;
+20 -6
View File
@@ -183,7 +183,8 @@ func match(element *Element, kind, value string) bool {
case "text": case "text":
return element.Text == value return element.Text == value
case "desc": 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": case "descPrefix":
return strings.HasPrefix(element.Description, value) return strings.HasPrefix(element.Description, value)
default: default:
@@ -191,14 +192,14 @@ 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+)\]$`) 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) { func parseBounds(text string) (Bounds, error) {
m := boundsPattern.FindStringSubmatch(text) if m := boundsPattern.FindStringSubmatch(text); m != nil {
if m == nil {
return Bounds{}, fmt.Errorf("bounds %q: not in [L,T,R,B] form", text)
}
coords := make([]int, 4) coords := make([]int, 4)
for i := range 4 { for i := range 4 {
v, err := strconv.Atoi(m[i+1]) v, err := strconv.Atoi(m[i+1])
@@ -209,3 +210,16 @@ func parseBounds(text string) (Bounds, error) {
} }
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)
}
+35
View File
@@ -164,3 +164,38 @@ func TestAccessibilityTextFallback(t *testing.T) {
t.Fatal("expected match via accessibilityText fallback") 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)
}
}
+13
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strings"
"time" "time"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
@@ -105,6 +106,9 @@ func Run(ctx context.Context, options Options) (Summary, error) {
g.Wait() g.Wait()
if hierarchyErr != nil { 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) logger.Warn("hierarchy fetch failed", "step", stepIndex, "err", hierarchyErr)
} }
treeSize := 0 treeSize := 0
@@ -192,6 +196,9 @@ func Run(ctx context.Context, options Options) (Summary, error) {
if nextErr == nil { if nextErr == nil {
if err := applyAction(ctx, options.Driver, nextAction, tree); err != 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) return summary, fmt.Errorf("step %d apply: %w", stepIndex, err)
} }
actionCopy := nextAction actionCopy := nextAction
@@ -500,6 +507,12 @@ func encodeResiduals(residuals map[string]ltl.Formula) (map[string]json.RawMessa
return encoded, firstErr 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 { func traceExceptions(exceptions []verifier.Exception) []trace.Exception {
if len(exceptions) == 0 { if len(exceptions) == 0 {
return nil return nil
+3
View File
@@ -59,6 +59,9 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
_ = sidecarCommand.Process.Kill() _ = sidecarCommand.Process.Kill()
return nil, nil, fmt.Errorf("dial sidecar: %w", err) 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) healthCtx, healthCancel := context.WithTimeout(ctx, sidecarStartupTimeout)
if err := driverClient.WaitForHealth(healthCtx, 250e6); err != nil { if err := driverClient.WaitForHealth(healthCtx, 250e6); err != nil {
healthCancel() healthCancel()
BIN
View File
Binary file not shown.
@@ -487,7 +487,8 @@ private fun pngHeight(bytes: ByteArray): Int {
} }
class IosDriverBackend(private val udid: String) : DriverBackend { 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 { init {
val httpClient = xcuitest.api.OkHttpClientInstance.get() 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) val device = ios.LocalIOSDevice(udid, xcTestDevice, simctlDevice, maestro.utils.NoopInsights)
driver = maestro.drivers.IOSDriver(device, maestro.utils.NoopInsights, metrics) driver = maestro.drivers.IOSDriver(device, maestro.utils.NoopInsights, metrics)
driver.open() driver.open()
warmup()
} }
override fun launch(bundleId: String, clearState: Boolean, env: Map<String, String>) { 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 <T> 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<String, String>) = withReconnect {
runCatching { driver.stopApp(bundleId) } runCatching { driver.stopApp(bundleId) }
if (clearState) driver.clearAppState(bundleId) if (clearState) driver.clearAppState(bundleId)
driver.launchApp(bundleId, env, java.util.UUID.randomUUID()) 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 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)) 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)) 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 -> StubDriverBackend.KEY_MAP[key]?.let { keyCode ->
keyCodeToMaestro(keyCode)?.let { driver.pressKey(it) } keyCodeToMaestro(keyCode)?.let { driver.pressKey(it) }
} }
Unit
} }
override fun screenshot(): Triple<ByteArray, Int, Int> { override fun screenshot(): Triple<ByteArray, Int, Int> = withReconnect {
val buf = okio.Buffer() val buf = okio.Buffer()
driver.takeScreenshot(buf, false) driver.takeScreenshot(buf, false)
val bytes = buf.readByteArray() 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() com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
.writeValueAsString(driver.contentDescriptor(false)) .writeValueAsString(driver.contentDescriptor(false))
}
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> = emptyList() override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> = emptyList()
override fun waitForIdle(durationMillis: Long) { override fun waitForIdle(durationMillis: Long) = withReconnect {
driver.waitForAppToSettle(null, null, durationMillis.toInt()) driver.waitForAppToSettle(null, null, durationMillis.toInt())
Unit
} }
override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false } override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false }