feat(spec): drive merchant onboarding (English, phone, OTP, multi-device, home)

This commit is contained in:
pj committed 2026-04-18 01:59:51 +07:00
1 parent eea9a0067a
commit 6ce4eabe86
2 files changed
+119 -45

No files matched your search

+34
View File
@@ -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 <dump.xml> [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)
}
}
}
+85 -45
View File
@@ -8,9 +8,8 @@ import {
taps, taps,
swipes, swipes,
} from "@uatu/spec"; } from "@uatu/spec";
import type { State } from "@uatu/spec";
// ── Snapshot extractors ──────────────────────────────────────── // ── Snapshot extractors (fed by UatuExtractors on Android) ─────
const screen = extract<string>((state) => (state.snapshots.screen as string) ?? ""); const screen = extract<string>((state) => (state.snapshots.screen as string) ?? "");
const balance = extract<number>( const balance = extract<number>(
(state) => (state.snapshots["ledger.balance"] as number) ?? 0, (state) => (state.snapshots["ledger.balance"] as number) ?? 0,
@@ -22,27 +21,52 @@ const totalReceived = extract<number>(
(state) => (state.snapshots["ledger.totalReceived"] as number) ?? 0, (state) => (state.snapshots["ledger.totalReceived"] as number) ?? 0,
); );
// ── Screen detection via accessibility tree ──────────────────── // ── Screen detection via uiautomator hierarchy ─────────────────
const onLoginPhone = extract<boolean>((state) => const onLanguageSelect = extract<boolean>((state) =>
Boolean(state.ax.find("id:login_phone_field")), Boolean(state.ax.find("id:select_language")),
); );
const onLoginOtp = extract<boolean>((state) => const englishTile = extract((state) =>
Boolean(state.ax.find("id:login_otp_field")), state.ax.find("id:select_language") ? state.ax.find("text:English") : undefined,
); );
const onEnterMobile = extract<boolean>((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>(() => 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<boolean>( const onHome = extract<boolean>(
(state) => () => customerRows.current.length > 0 || supplierRows.current.length > 0,
state.ax.findAll("id:home_customer_row").length > 0 ||
state.ax.findAll("id:home_supplier_row").length > 0,
); );
const onLedger = extract<boolean>( const onLedger = extract<boolean>(
() => screen.current === "customer_ledger" || screen.current === "supplier_ledger", () => screen.current === "customer_ledger" || screen.current === "supplier_ledger",
); );
// ── Properties ───────────────────────────────────────────────── // ── Properties ─────────────────────────────────────────────────
export const 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( ledgerBalanceMatchesTxns: always(
() => () =>
!onLedger.current || !onLedger.current ||
@@ -50,47 +74,63 @@ export const properties = {
), ),
}; };
// ── Action generators (gated by screen state) ────────────────── // ── Action generators ──────────────────────────────────────────
const enterPhone = actions(() => const selectEnglish = actions(() => {
onLoginPhone.current if (!onLanguageSelect.current) return [];
? [ return englishTile.current ? [Tap({ on: englishTile.current })] : [];
InputText({ });
into: "id:login_phone_field",
text: process.env.UATU_TEST_PHONE ?? "",
}),
Tap({ on: "id:login_continue" }),
]
: [],
);
const enterOtp = actions(() => const enterMobile = actions(() => {
onLoginOtp.current if (!onEnterMobile.current) return [];
? [ const field = mobileField.current;
InputText({ const terms = termsCheckbox.current;
into: "id:login_otp_field", const button = continueButton.current;
text: process.env.UATU_TEST_OTP ?? "", const phone = process.env.UATU_TEST_PHONE ?? "";
}), if (!field || !phone) return [];
] const steps: ReturnType<typeof Tap | typeof InputText>[] = [
: [], InputText({ into: field, text: phone }),
);
const openCustomerOrSupplier = actions((): ReturnType<typeof Tap>[] => {
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" }),
]; ];
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( export const actionsRoot = weighted(
[100, enterPhone], [100, selectEnglish],
[100, enterMobile],
[100, enterOtp], [100, enterOtp],
[100, dismissMultiDevice],
[100, dismissNotifications],
[80, openCustomerOrSupplier], [80, openCustomerOrSupplier],
[10, taps], [10, taps],
[2, swipes], [2, swipes],
); );
// Verifier looks for `globalThis.actions`; re-export the weighted root.
(globalThis as { actions?: unknown }).actions = actionsRoot; (globalThis as { actions?: unknown }).actions = actionsRoot;