mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
feat(spec): drive merchant onboarding (English, phone, OTP, multi-device, home)
This commit is contained in:
1 parent
eea9a0067a
commit
6ce4eabe86
2 files changed
+119
-45
No files matched your search
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Reference in new issue
Block a user