diff --git a/cmd/internal-tools/hier-check/main.go b/cmd/internal-tools/hier-check/main.go new file mode 100644 index 0000000..fc674f3 --- /dev/null +++ b/cmd/internal-tools/hier-check/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + "os" + + "github.com/priyanshujain/uatu/internal/hierarchy" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: hier-check [selector ...]") + os.Exit(2) + } + content, err := os.ReadFile(os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + tree, err := hierarchy.Parse(string(content)) + if err != nil { + fmt.Fprintln(os.Stderr, "parse:", err) + os.Exit(1) + } + fmt.Printf("parsed %d elements\n", len(tree.Elements)) + for _, selector := range os.Args[2:] { + elements := tree.FindAll(selector) + fmt.Printf("%s -> %d matches\n", selector, len(elements)) + for _, element := range elements { + x, y := element.Bounds.Center() + fmt.Printf(" id=%q text=%q center=%d,%d\n", element.ResourceID, element.Text, x, y) + } + } +} diff --git a/examples/specs/merchant-ledger.ts b/examples/specs/merchant-ledger.ts index 6276c87..03cf848 100644 --- a/examples/specs/merchant-ledger.ts +++ b/examples/specs/merchant-ledger.ts @@ -8,9 +8,8 @@ import { taps, swipes, } from "@uatu/spec"; -import type { State } from "@uatu/spec"; -// ── Snapshot extractors ──────────────────────────────────────── +// ── Snapshot extractors (fed by UatuExtractors on Android) ───── const screen = extract((state) => (state.snapshots.screen as string) ?? ""); const balance = extract( (state) => (state.snapshots["ledger.balance"] as number) ?? 0, @@ -22,27 +21,52 @@ const totalReceived = extract( (state) => (state.snapshots["ledger.totalReceived"] as number) ?? 0, ); -// ── Screen detection via accessibility tree ──────────────────── -const onLoginPhone = extract((state) => - Boolean(state.ax.find("id:login_phone_field")), +// ── Screen detection via uiautomator hierarchy ───────────────── +const onLanguageSelect = extract((state) => + Boolean(state.ax.find("id:select_language")), ); -const onLoginOtp = extract((state) => - Boolean(state.ax.find("id:login_otp_field")), +const englishTile = extract((state) => + state.ax.find("id:select_language") ? state.ax.find("text:English") : undefined, ); + +const onEnterMobile = extract((state) => + Boolean(state.ax.find("id:etMobileNumber")), +); +const mobileField = extract((state) => state.ax.find("id:etMobileNumber")); +const termsCheckbox = extract((state) => state.ax.find("id:checkBoxTerms")); +const continueButton = extract((state) => state.ax.find("id:buttonLogin")); + +// OTP screen uses six single-digit EditTexts sharing resource-id "otp". +// Typing into the first one auto-fills the rest. +const otpFirstBox = extract((state) => state.ax.findAll("id:otp")[0]); +const onOtp = extract(() => Boolean(otpFirstBox.current)); + +// Multi-device dialog that gates the Home screen when another device is +// signed in. Spec auto-signs-out the other device. +const signOutOthersButton = extract((state) => state.ax.find("text:Sign Out Other Devices")); +const confirmSignOutButton = extract((state) => { + const cancel = state.ax.find("text:Cancel"); + if (!cancel) return undefined; + return state.ax.find("text:Sign Out"); +}); + +// Notification-permission modal on Home. +const skipNotificationsButton = extract((state) => state.ax.find("text:Skip")); + +// Home screen customer/supplier rows. Compose testTag appears as +// content-desc; rows are suffixed with a stable UUID. +const customerRows = extract((state) => state.ax.findAll("descPrefix:customer_row_")); +const supplierRows = extract((state) => state.ax.findAll("descPrefix:supplier_row_")); const onHome = extract( - (state) => - state.ax.findAll("id:home_customer_row").length > 0 || - state.ax.findAll("id:home_supplier_row").length > 0, + () => customerRows.current.length > 0 || supplierRows.current.length > 0, ); + const onLedger = extract( () => screen.current === "customer_ledger" || screen.current === "supplier_ledger", ); // ── Properties ───────────────────────────────────────────────── export const properties = { - // The displayed balance is server-fed; the totals are local-DB-fed. - // Any divergence — stale cache, partial sync, optimistic-update glitch, - // mishandled deleted txn — is a violation. ledgerBalanceMatchesTxns: always( () => !onLedger.current || @@ -50,47 +74,63 @@ export const properties = { ), }; -// ── Action generators (gated by screen state) ────────────────── -const enterPhone = actions(() => - onLoginPhone.current - ? [ - InputText({ - into: "id:login_phone_field", - text: process.env.UATU_TEST_PHONE ?? "", - }), - Tap({ on: "id:login_continue" }), - ] - : [], -); +// ── Action generators ────────────────────────────────────────── +const selectEnglish = actions(() => { + if (!onLanguageSelect.current) return []; + return englishTile.current ? [Tap({ on: englishTile.current })] : []; +}); -const enterOtp = actions(() => - onLoginOtp.current - ? [ - InputText({ - into: "id:login_otp_field", - text: process.env.UATU_TEST_OTP ?? "", - }), - ] - : [], -); - -const openCustomerOrSupplier = actions((): ReturnType[] => { - if (!onHome.current) return []; - // The verifier resolves the selector; we just hand back a list of - // candidate row taps weighted equally by the runtime's pickFromResult. - return [ - Tap({ on: "id:home_customer_row" }), - Tap({ on: "id:home_supplier_row" }), +const enterMobile = actions(() => { + if (!onEnterMobile.current) return []; + const field = mobileField.current; + const terms = termsCheckbox.current; + const button = continueButton.current; + const phone = process.env.UATU_TEST_PHONE ?? ""; + if (!field || !phone) return []; + const steps: ReturnType[] = [ + InputText({ into: field, text: phone }), ]; + if (terms) steps.push(Tap({ on: terms })); + if (button) steps.push(Tap({ on: button })); + return steps; +}); + +const enterOtp = actions(() => { + if (!onOtp.current) return []; + const field = otpFirstBox.current; + const otp = process.env.UATU_TEST_OTP ?? ""; + if (!field || !otp) return []; + return [InputText({ into: field, text: otp })]; +}); + +const dismissMultiDevice = actions(() => { + if (confirmSignOutButton.current) return [Tap({ on: confirmSignOutButton.current })]; + if (signOutOthersButton.current) return [Tap({ on: signOutOthersButton.current })]; + return []; +}); + +const dismissNotifications = actions(() => { + return skipNotificationsButton.current + ? [Tap({ on: skipNotificationsButton.current })] + : []; +}); + +const openCustomerOrSupplier = actions(() => { + if (!onHome.current) return []; + const rows = [...customerRows.current, ...supplierRows.current]; + if (rows.length === 0) return []; + return rows.map((row) => Tap({ on: row })); }); export const actionsRoot = weighted( - [100, enterPhone], + [100, selectEnglish], + [100, enterMobile], [100, enterOtp], + [100, dismissMultiDevice], + [100, dismissNotifications], [80, openCustomerOrSupplier], [10, taps], [2, swipes], ); -// Verifier looks for `globalThis.actions`; re-export the weighted root. (globalThis as { actions?: unknown }).actions = actionsRoot;