package verifier
import (
"encoding/json"
"path/filepath"
"testing"
"github.com/priyanshujain/uatu/internal/bundler"
"github.com/priyanshujain/uatu/internal/hierarchy"
"github.com/priyanshujain/uatu/internal/ltl"
)
const sampleAppHierarchyXML = `
`
const homeHierarchyXML = `
`
const addAccountHierarchyXML = `
`
const ledgerHierarchyXML = `
`
const addTxnHierarchyXML = `
`
// bundleSampleAppSpec bundles examples/sample-app/spec.ts via the real
// @uatu/spec API so the integration test exercises the same path the CLI uses.
func bundleSampleAppSpec(t *testing.T) string {
t.Helper()
specPath, err := filepath.Abs("../../examples/sample-app/spec.ts")
if err != nil {
t.Fatal(err)
}
apiPath, err := filepath.Abs("../../pkg/spec-api/src/index.ts")
if err != nil {
t.Fatal(err)
}
defaultsPath, err := filepath.Abs("../../pkg/spec-api/src/defaults/properties.ts")
if err != nil {
t.Fatal(err)
}
bundle, err := bundler.Bundle(bundler.Options{
EntryFile: specPath,
Aliases: map[string]string{
"@uatu/spec": apiPath,
"@uatu/spec/defaults/properties": defaultsPath,
},
})
if err != nil {
t.Fatal(err)
}
return string(bundle.JavaScript)
}
func loginSnapshots() Snapshots {
return Snapshots{
"route": json.RawMessage(`"login"`),
"logged_in": json.RawMessage(`false`),
"auth_status": json.RawMessage(`"logged-out"`),
"account_count": json.RawMessage(`0`),
"accounts": json.RawMessage(`[]`),
"total_balance": json.RawMessage(`0`),
"active_account_id": json.RawMessage(`null`),
"ledger_rows": json.RawMessage(`[]`),
"ledger_balance": json.RawMessage(`0`),
"focused_input": json.RawMessage(`null`),
"txn_form_type": json.RawMessage(`null`),
"txn_form_account_id": json.RawMessage(`null`),
"login_error": json.RawMessage(`""`),
"add_account_error": json.RawMessage(`""`),
"txn_error": json.RawMessage(`""`),
}
}
// twoAccountsJSON is shared between home, ledger, and add-transaction
// snapshots so invariants that correlate accounts with ledger rows stay
// consistent across routes.
const twoAccountsJSON = `[` +
`{"id":"acc-1","name":"Checking","balance":0,"txnCount":0},` +
`{"id":"acc-2","name":"Savings","balance":0,"txnCount":0}` +
`]`
func homeSnapshots() Snapshots {
return Snapshots{
"route": json.RawMessage(`"home"`),
"logged_in": json.RawMessage(`true`),
"auth_status": json.RawMessage(`"logged-in"`),
"account_count": json.RawMessage(`2`),
"accounts": json.RawMessage(twoAccountsJSON),
"total_balance": json.RawMessage(`0`),
"active_account_id": json.RawMessage(`null`),
"ledger_rows": json.RawMessage(`[]`),
"ledger_balance": json.RawMessage(`0`),
"focused_input": json.RawMessage(`null`),
"txn_form_type": json.RawMessage(`null`),
"txn_form_account_id": json.RawMessage(`null`),
"login_error": json.RawMessage(`""`),
"add_account_error": json.RawMessage(`""`),
"txn_error": json.RawMessage(`""`),
}
}
func addAccountSnapshots() Snapshots {
s := homeSnapshots()
s["route"] = json.RawMessage(`"add-account"`)
return s
}
func ledgerSnapshots() Snapshots {
s := homeSnapshots()
s["route"] = json.RawMessage(`"ledger"`)
s["active_account_id"] = json.RawMessage(`"acc-1"`)
return s
}
func addTxnSnapshots() Snapshots {
s := ledgerSnapshots()
s["route"] = json.RawMessage(`"add-transaction"`)
s["txn_form_type"] = json.RawMessage(`"credit"`)
s["txn_form_account_id"] = json.RawMessage(`"acc-1"`)
return s
}
// TestSampleAppSpecFiresLoginActions verifies the bundled sample-app spec
// emits Tap actions targeting the login screen elements when they are present
// in the hierarchy.
func TestSampleAppSpecFiresLoginActions(t *testing.T) {
v := newVerifier(t)
if err := v.Load(bundleSampleAppSpec(t)); err != nil {
t.Fatal(err)
}
tree, err := hierarchy.Parse(sampleAppHierarchyXML)
if err != nil {
t.Fatal(err)
}
if err := v.PushSnapshot(SnapshotInput{Snapshots: loginSnapshots(), Tree: tree}); err != nil {
t.Fatal(err)
}
typeEmailHits := 0
tapSubmitHits := 0
for range 400 {
action, err := v.NextAction()
if err != nil {
continue
}
switch {
case action.Kind == ActionKindInputText && action.On == "desc:login_email":
typeEmailHits++
case action.Kind == ActionKindTap && action.On == "desc:login_submit":
tapSubmitHits++
}
}
if typeEmailHits == 0 {
t.Fatal("loginHelper never typed into desc:login_email on sample-app hierarchy")
}
if tapSubmitHits == 0 {
t.Fatal("adversarialLogin never tapped desc:login_submit on sample-app hierarchy")
}
}
// TestSampleAppSpecPropertiesEvaluate checks the properties declared in the
// sample-app spec evaluate sensibly across a small snapshot sequence. The
// spec mixes safety and liveness properties; Pending verdicts are expected
// for liveness properties that haven't had time to resolve yet.
func TestSampleAppSpecPropertiesEvaluate(t *testing.T) {
v := newVerifier(t)
if err := v.Load(bundleSampleAppSpec(t)); err != nil {
t.Fatal(err)
}
tree, err := hierarchy.Parse(sampleAppHierarchyXML)
if err != nil {
t.Fatal(err)
}
if err := v.PushSnapshot(SnapshotInput{Snapshots: loginSnapshots(), Tree: tree}); err != nil {
t.Fatal(err)
}
verdicts := v.EvaluateProperties()
if verdicts["accountCountNonNegative"] != ltl.VerdictHolds {
t.Errorf("accountCountNonNegative: got %v, want holds", verdicts["accountCountNonNegative"])
}
if verdicts["noUncaughtExceptions"] != ltl.VerdictHolds {
t.Errorf("noUncaughtExceptions: got %v, want holds", verdicts["noUncaughtExceptions"])
}
if verdicts["authStatusIsKnown"] != ltl.VerdictHolds {
t.Errorf("authStatusIsKnown: got %v, want holds", verdicts["authStatusIsKnown"])
}
if verdicts["routeIsKnown"] != ltl.VerdictHolds {
t.Errorf("routeIsKnown: got %v, want holds", verdicts["routeIsKnown"])
}
// Liveness: loginReachable hasn't resolved yet.
if verdicts["loginReachable"] != ltl.VerdictPending {
t.Errorf("loginReachable: got %v, want pending", verdicts["loginReachable"])
}
}
// TestSampleAppSpecActionsFireOnEachRoute pushes a hierarchy + snapshot pair
// representative of each sample-app route and verifies the expected action
// generators fire against that state. Guards against silent breakage of any
// one route's generators (a regression only e2e would otherwise catch).
func TestSampleAppSpecActionsFireOnEachRoute(t *testing.T) {
cases := []struct {
name string
xml string
snapshots Snapshots
expectKind ActionKind
expectOns []string
}{
{
name: "home",
xml: homeHierarchyXML,
snapshots: homeSnapshots(),
expectKind: ActionKindTap,
expectOns: []string{
"desc:add_account_button",
"desc:logout_button",
"descPrefix:account_card:",
},
},
{
name: "add-account",
xml: addAccountHierarchyXML,
snapshots: addAccountSnapshots(),
expectKind: ActionKindTap,
expectOns: []string{"desc:add_account_submit", "desc:Back"},
},
{
name: "ledger",
xml: ledgerHierarchyXML,
snapshots: ledgerSnapshots(),
expectKind: ActionKindTap,
expectOns: []string{"desc:add_txn_button", "desc:Back"},
},
{
name: "add-transaction",
xml: addTxnHierarchyXML,
snapshots: addTxnSnapshots(),
expectKind: ActionKindTap,
expectOns: []string{
"desc:txn_submit",
"desc:txn_debit",
"desc:Back",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
v := newVerifier(t)
if err := v.Load(bundleSampleAppSpec(t)); err != nil {
t.Fatal(err)
}
tree, err := hierarchy.Parse(tc.xml)
if err != nil {
t.Fatal(err)
}
if err := v.PushSnapshot(SnapshotInput{Snapshots: tc.snapshots, Tree: tree}); err != nil {
t.Fatal(err)
}
sawOn := map[string]bool{}
sawInputText := map[string]bool{}
for range 800 {
action, err := v.NextAction()
if err != nil {
continue
}
if action.Kind == tc.expectKind {
sawOn[action.On] = true
}
if action.Kind == ActionKindInputText {
sawInputText[action.On] = true
}
}
for _, on := range tc.expectOns {
if !sawOn[on] {
t.Errorf("%s: no %s action on %q; saw %v", tc.name, tc.expectKind, on, keysOf(sawOn))
}
}
if tc.name == "add-account" && !sawInputText["desc:account_name_field"] {
t.Errorf("add-account: typeAccountName never fired; saw %v", keysOf(sawInputText))
}
if tc.name == "add-transaction" {
if !sawInputText["desc:txn_amount"] {
t.Errorf("add-transaction: typeAmount never fired; saw %v", keysOf(sawInputText))
}
if !sawInputText["desc:txn_note"] {
t.Errorf("add-transaction: typeNote never fired; saw %v", keysOf(sawInputText))
}
}
})
}
}
func keysOf(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}