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
|
# Go
|
||||||
/bin/
|
/bin/
|
||||||
|
/sanderling
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
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 }
|
||||||
|
|||||||
Reference in new issue
Block a user