mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
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 sincebff3a49switched 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 commit70c10ade27. * 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:
8 files changed
+211
-309
No files matched your search
@@ -14,6 +14,7 @@ local.properties
|
||||
|
||||
# Go
|
||||
/bin/
|
||||
/sanderling
|
||||
*.test
|
||||
*.out
|
||||
|
||||
|
||||
@@ -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<boolean>(
|
||||
(state) => (state.snapshots.logged_in as boolean) ?? false,
|
||||
);
|
||||
const route = extract<string>(
|
||||
(state) => (state.snapshots.screen as string) ?? "",
|
||||
);
|
||||
const accounts = extract<AccountSnapshot[]>(
|
||||
(state) => (state.snapshots.accounts as AccountSnapshot[]) ?? [],
|
||||
);
|
||||
const totalBalance = extract<number>(
|
||||
(state) => (state.snapshots.total_balance as number) ?? 0,
|
||||
);
|
||||
const activeAccountId = extract<string | null>(
|
||||
(state) => (state.snapshots.active_account_id as string | null) ?? null,
|
||||
);
|
||||
const ledgerRows = extract<LedgerRow[]>(
|
||||
(state) => (state.snapshots.ledger_rows as LedgerRow[]) ?? [],
|
||||
);
|
||||
const ledgerBalance = extract<number>(
|
||||
(state) => (state.snapshots.ledger_balance as number) ?? 0,
|
||||
);
|
||||
const focusedInput = extract<string | null>(
|
||||
(state) => (state.snapshots.focused_input as string | null) ?? null,
|
||||
);
|
||||
const txnFormType = extract<string | null>(
|
||||
(state) => (state.snapshots.txn_form_type as string | null) ?? null,
|
||||
);
|
||||
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 loggedIn = extract<boolean>(s => (s.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 ledgerRows = extract<LedgerRow[]>(s => (s.snapshots.ledger_rows as LedgerRow[]) ?? []);
|
||||
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 focusedInput = extract<string | null>(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<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_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"]);
|
||||
|
||||
const accountNameSampler = from([
|
||||
"Checking",
|
||||
"Savings",
|
||||
"Travel",
|
||||
"Rent",
|
||||
"Emergency Fund",
|
||||
"Investments",
|
||||
"Groceries",
|
||||
" ",
|
||||
"Checking",
|
||||
"A".repeat(41),
|
||||
"Petty Cash",
|
||||
]);
|
||||
|
||||
const typeAccountName = actions(() => {
|
||||
if (route.current !== "add-account") return [];
|
||||
// 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;
|
||||
if (!field) return [];
|
||||
return [InputText({ into: field, text: accountNameSampler.generate() })];
|
||||
});
|
||||
|
||||
const submitAddAccount = actions(() => {
|
||||
if (route.current !== "add-account") return [];
|
||||
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(() => {
|
||||
if (route.current !== "home") return [];
|
||||
const button = addAccountButton.current;
|
||||
return button ? [Tap({ on: button })] : [];
|
||||
});
|
||||
const amounts = from(["10", "50", "25", "100", "5"]);
|
||||
|
||||
const openRandomAccount = actions(() => {
|
||||
if (route.current !== "home") return [];
|
||||
const cards = allAccountCards.current;
|
||||
// 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 [];
|
||||
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 [];
|
||||
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;
|
||||
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 opts = [];
|
||||
if (field) opts.push(InputText({ into: field, text: amounts.generate() }));
|
||||
if (submit) opts.push(Tap({ on: submit }));
|
||||
return opts;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -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,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+)\]$`)
|
||||
|
||||
// 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)
|
||||
}
|
||||
if m := boundsPattern.FindStringSubmatch(text); m != nil {
|
||||
coords := make([]int, 4)
|
||||
for i := range 4 {
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
BIN
Binary file not shown.
@@ -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<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) }
|
||||
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<ByteArray, Int, Int> {
|
||||
override fun screenshot(): Triple<ByteArray, Int, Int> = 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<LogLine> = 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 }
|
||||
|
||||
Reference in new issue
Block a user