feat(spec): merchant-ledger property + login/navigation generators

Property ledgerBalanceMatchesTxns asserts displayed balance equals
sum(Given) - sum(Received) on either ledger screen. Catches the
class of bug where the server-fed CustomerModel.balance diverges
from the local-DB-fed CoreDatabaseDao sums (stale cache, partial
sync, deleted-txn handling glitch, etc.).

Generators are gated by screen state and weighted to push the run
through login -> home -> ledger quickly:
  enterPhone (100), enterOtp (100), openCustomerOrSupplier (80),
  taps (10), swipes (2).

Phone/OTP read from process.env via esbuild defines so credentials
never land in source. cmd/internal-tools/bundle-check is a quick
sanity tool to confirm the spec bundles before running uatu test.
This commit is contained in:
pj committed 2026-04-18 00:08:40 +07:00
1 parent 7aa75f3d9f
commit 4dbad9073e
2 files changed
+130

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
package main
import (
"fmt"
"os"
"path/filepath"
"github.com/priyanshujain/uatu/internal/bundler"
)
func main() {
repoRoot, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "getwd: %v\n", err)
os.Exit(1)
}
specApiPath := filepath.Join(repoRoot, "pkg/spec-api/src/index.ts")
result, err := bundler.Bundle(bundler.Options{
EntryFile: "examples/specs/merchant-ledger.ts",
Defines: map[string]string{
"UATU_TEST_PHONE": "+910000000000",
"UATU_TEST_OTP": "000000",
},
Aliases: map[string]string{
"@uatu/spec": specApiPath,
},
})
if err != nil {
fmt.Fprintf(os.Stderr, "bundle: %v\n", err)
os.Exit(1)
}
fmt.Printf("bundled: %d bytes, sha256=%s\n", len(result.JavaScript), result.SHA256)
}
+96
View File
@@ -0,0 +1,96 @@
import {
extract,
always,
actions,
weighted,
Tap,
InputText,
taps,
swipes,
} from "@uatu/spec";
import type { State } from "@uatu/spec";
// ── Snapshot extractors ────────────────────────────────────────
const screen = extract<string>((state) => (state.snapshots.screen as string) ?? "");
const balance = extract<number>(
(state) => (state.snapshots["ledger.balance"] as number) ?? 0,
);
const totalGiven = extract<number>(
(state) => (state.snapshots["ledger.totalGiven"] as number) ?? 0,
);
const totalReceived = extract<number>(
(state) => (state.snapshots["ledger.totalReceived"] as number) ?? 0,
);
// ── Screen detection via accessibility tree ────────────────────
const onLoginPhone = extract<boolean>((state) =>
Boolean(state.ax.find("id:login_phone_field")),
);
const onLoginOtp = extract<boolean>((state) =>
Boolean(state.ax.find("id:login_otp_field")),
);
const onHome = extract<boolean>(
(state) =>
state.ax.findAll("id:home_customer_row").length > 0 ||
state.ax.findAll("id:home_supplier_row").length > 0,
);
const onLedger = extract<boolean>(
() => 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 ||
balance.current === totalGiven.current - totalReceived.current,
),
};
// ── 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" }),
]
: [],
);
const enterOtp = actions(() =>
onLoginOtp.current
? [
InputText({
into: "id:login_otp_field",
text: process.env.UATU_TEST_OTP ?? "",
}),
]
: [],
);
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" }),
];
});
export const actionsRoot = weighted(
[100, enterPhone],
[100, enterOtp],
[80, openCustomerOrSupplier],
[10, taps],
[2, swipes],
);
// Verifier looks for `globalThis.actions`; re-export the weighted root.
(globalThis as { actions?: unknown }).actions = actionsRoot;