Merge branch 'runner-uncertain-last-action' into pr-73-followups

This commit is contained in:
pj committed 2026-08-15 14:04:56 +05:30
commit df0cb96386
14 files changed
+428 -60

No files matched your search

+4 -2
View File
@@ -29,7 +29,7 @@ Every extractor callback receives a `State`:
interface State { interface State {
ax: AccessibilityTree; ax: AccessibilityTree;
snapshots: Record<string, unknown>; snapshots: Record<string, unknown>;
lastAction: Action | null; lastAction: (Action & { applied: true | null }) | null;
logs: readonly LogEntry[]; logs: readonly LogEntry[];
exceptions: readonly ExceptionRecord[]; exceptions: readonly ExceptionRecord[];
time: number; // ms since run start time: number; // ms since run start
@@ -40,11 +40,13 @@ interface State {
|---|---| |---|---|
| `ax` | Live UI hierarchy for this step | | `ax` | Live UI hierarchy for this step |
| `snapshots` | Key-value data pushed by the app SDK (empty if SDK not integrated) | | `snapshots` | Key-value data pushed by the app SDK (empty if SDK not integrated) |
| `lastAction` | The action dispatched in the previous step, or `null` on the first step | | `lastAction` | The action dispatched in the previous step, or `null` on the first step and on any step that dispatched nothing |
| `logs` | Log entries collected since the previous step | | `logs` | Log entries collected since the previous step |
| `exceptions` | Uncaught exceptions or `Sanderling.reportError()` calls since the previous step | | `exceptions` | Uncaught exceptions or `Sanderling.reportError()` calls since the previous step |
| `time` | Milliseconds elapsed since the run started | | `time` | Milliseconds elapsed since the run started |
`lastAction.applied` is `true` when the runner saw the dispatch succeed and `null` when the apply call failed with the action possibly already delivered: an RPC deadline can fire after the tap reached the app, and nothing can find out afterwards. So there are three states, not two. `state.lastAction === null` means no action ran; `applied === null` means one ran whose fate is unknown. A property that attributes an effect to the action ("this submit must move the balance by the typed amount") has to decline unless `applied` is `true`, or a timeout convicts a healthy app. A property that counts what the app COULD have done should include it: an unconfirmed submit belongs in an upper bound on how many submits a window holds.
## Selectors ## Selectors
Selectors are passed to `ax.find()`, `ax.findAll()`, and element-scoped `.find()` / `.findAll()`. Selectors are passed to `ax.find()`, `ax.findAll()`, and element-scoped `.find()` / `.findAll()`.
+47 -13
View File
@@ -123,10 +123,22 @@ export function readHomeCards<T>(args: {
return { value: reading, carrier: reading, fresh: true }; return { value: reading, carrier: reading, fresh: true };
} }
function isTapOn( // state.lastAction as the two hosts build it (internal/verifier/marshal.go
lastAction: { kind?: string; on?: string | object } | null, // lastActionFields), read defensively: every field is what a Go struct decided
target: string, // to emit, not something this file can trust a compile-time shape for.
): boolean { //
// `applied` is true when the runner saw the action's dispatch succeed and null
// when the apply call failed with the gesture possibly already delivered: an
// RPC deadline can fire after the tap landed, and nothing can find out
// afterwards. That is unknown, not "it did not happen", which is what
// `lastAction === null` says.
export interface ObservedAction {
kind?: string;
on?: string | object;
applied?: true | null;
}
function isTapOn(lastAction: ObservedAction | null, target: string): boolean {
if (lastAction == null) return false; if (lastAction == null) return false;
if (lastAction.kind !== "Tap" && lastAction.kind !== "DoubleTap") return false; if (lastAction.kind !== "Tap" && lastAction.kind !== "DoubleTap") return false;
const on = lastAction.on; const on = lastAction.on;
@@ -138,19 +150,27 @@ function isTapOn(
// Repository.createTransaction: AddTransactionViewModel.submit() is the only // Repository.createTransaction: AddTransactionViewModel.submit() is the only
// caller, AddTransactionEvent.Submit is the only thing that runs it, and the // caller, AddTransactionEvent.Submit is the only thing that runs it, and the
// TxnSubmit button's onClick is the only thing that sends that event. // TxnSubmit button's onClick is the only thing that sends that event.
export function isTxnSubmitTap( export function isTxnSubmitTap(lastAction: ObservedAction | null): boolean {
lastAction: { kind?: string; on?: string | object } | null,
): boolean {
return isTapOn(lastAction, "TxnSubmit"); return isTapOn(lastAction, "TxnSubmit");
} }
// Likewise the only action that creates an account. // Likewise the only action that creates an account.
export function isAddAccountSubmitTap( export function isAddAccountSubmitTap(lastAction: ObservedAction | null): boolean {
lastAction: { kind?: string; on?: string | object } | null,
): boolean {
return isTapOn(lastAction, "AddAccountSubmit"); return isTapOn(lastAction, "AddAccountSubmit");
} }
// Did the runner see this action's dispatch succeed? `applied` is null when the
// apply call failed with the gesture possibly already delivered (an RPC
// deadline can fire after the tap landed), and the runner has no way to find
// out afterwards. Such an action may have caused anything the next reading
// shows, so it counts toward how many submits a window COULD hold, but it never
// licenses attributing an effect to it: a property that demands the effect of
// an action that may never have run convicts the app of the runner's own
// uncertainty.
export function confirmedApplied(lastAction: ObservedAction | null): boolean {
return lastAction != null && lastAction.applied === true;
}
// Counts the submit actions inside the window the balance property compares // Counts the submit actions inside the window the balance property compares
// over: from the last Home total we read to this step, inclusive of this step's // over: from the last Home total we read to this step, inclusive of this step's
// action. // action.
@@ -164,9 +184,15 @@ export function isAddAccountSubmitTap(
// //
// The reset lands on `fresh`, the same event that advances the carrier, so the // The reset lands on `fresh`, the same event that advances the carrier, so the
// count always describes exactly the interval the two compared totals span. // count always describes exactly the interval the two compared totals span.
//
// A submit whose dispatch the runner could not confirm counts here, because
// this number is an upper bound on the submits the window holds and the tap may
// well have landed. Leaving it out is what convicted a healthy app:
// committedTransactionsExceedSubmits saw a transaction rise of one against a
// window of zero and called it a double submit.
export function countSubmitsInWindow(args: { export function countSubmitsInWindow(args: {
previousCount: number; previousCount: number;
lastAction: { kind?: string; on?: string | object } | null; lastAction: ObservedAction | null;
fresh: boolean; fresh: boolean;
}): { reported: number; next: number } { }): { reported: number; next: number } {
const { previousCount, lastAction, fresh } = args; const { previousCount, lastAction, fresh } = args;
@@ -343,7 +369,7 @@ export function homeTxnCountsOf(cards: readonly CardReading[]): Record<string, T
// same as fine, and both come back false here. // same as fine, and both come back false here.
export function createdAccountHasNonZeroBalance(args: { export function createdAccountHasNonZeroBalance(args: {
route: string | null; route: string | null;
lastAction: { kind?: string; on?: string | object } | null; lastAction: ObservedAction | null;
typedName: string | undefined; typedName: string | undefined;
before: Account[] | null; before: Account[] | null;
after: Account[] | null; after: Account[] | null;
@@ -351,6 +377,10 @@ export function createdAccountHasNonZeroBalance(args: {
const { route, lastAction, before, after } = args; const { route, lastAction, before, after } = args;
if (route !== "home") return false; if (route !== "home") return false;
if (!isAddAccountSubmitTap(lastAction)) return false; if (!isAddAccountSubmitTap(lastAction)) return false;
// A creation nobody can confirm happened is not a creation to attribute a
// card to: the card that turned up may be an older account of the same name
// scrolling into view.
if (!confirmedApplied(lastAction)) return false;
if (before === null || after === null) return false; if (before === null || after === null) return false;
const typed = (args.typedName ?? "").trim(); const typed = (args.typedName ?? "").trim();
if (typed === "") return false; if (typed === "") return false;
@@ -465,7 +495,7 @@ export function parseTypedAmount(text: string | undefined | null): number {
// the bug: the double-tap is a single action. // the bug: the double-tap is a single action.
export function submitChangesBalanceByTypedAmount(args: { export function submitChangesBalanceByTypedAmount(args: {
route: string | null; route: string | null;
lastAction: { kind?: string; on?: string | object } | null; lastAction: ObservedAction | null;
submitsInWindow: number; submitsInWindow: number;
typedAmount: number; typedAmount: number;
prevTotalBalance: number | null; prevTotalBalance: number | null;
@@ -476,6 +506,10 @@ export function submitChangesBalanceByTypedAmount(args: {
if (route !== "home") return true; if (route !== "home") return true;
if (!isTxnSubmitTap(lastAction)) return true; if (!isTxnSubmitTap(lastAction)) return true;
// The whole rule is that the delta belongs to THIS submit. A submit the
// runner could not confirm may have committed nothing, and a balance that
// did not move is then exactly what a healthy app looks like.
if (!confirmedApplied(lastAction)) return true;
if (submitsInWindow !== 1) return true; if (submitsInWindow !== 1) return true;
if (typedAmount === 0) return true; if (typedAmount === 0) return true;
// An unknown total on either side is not evidence of anything. Comparing one // An unknown total on either side is not evidence of anything. Comparing one
+10 -3
View File
@@ -298,11 +298,18 @@ func Run(ctx context.Context, options Options) (Summary, error) {
logger.Warn("apply error; marking step transitional", "step", stepIndex, "err", err) logger.Warn("apply error; marking step transitional", "step", stepIndex, "err", err)
transitional = true transitional = true
applySkipped = true applySkipped = true
lastAction = nil // The error says the call failed, not that the gesture never
// reached the app: a deadline that fires after dispatch leaves
// the effect committed. Reporting no action here would let a
// property convict the app for an effect with no cause, so the
// action is reported with its fate unknown instead.
unconfirmed := nextAction
lastAction = &unconfirmed
} else { } else {
consecutiveApplyFailures = 0 consecutiveApplyFailures = 0
actionCopy := nextAction applied := nextAction
lastAction = &actionCopy applied.Applied = true
lastAction = &applied
} }
} else { } else {
lastAction = nil lastAction = nil
@@ -0,0 +1,150 @@
package runner
import (
"context"
"errors"
"fmt"
"path/filepath"
"sync/atomic"
"testing"
"time"
"github.com/priyanshujain/sanderling/internal/driver"
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
)
// An apply error is not proof that nothing landed. An RPC deadline that fires
// after the tap was dispatched leaves the transaction committed, and a runner
// that reports "no action" for it hands
// submitCommitsOneTransactionPerAction a rise of one transaction against a
// window of zero submits: a conviction manufactured out of the runner's own
// uncertainty, on the property carrying most of the detection on android.
//
// The spec below is the real folio predicate pair, imported from the example,
// so what this asserts is the verdict the shipped property reaches.
const uncertainApplySpecTemplate = `
import { actions, always, extract, next, Tap } from "@sanderling/spec";
import {
committedTransactionsExceedSubmits,
countSubmitsInWindow,
} from "%s";
let submits = 0;
const submitsInWindow = extract("submitsInWindow", state => {
const window = countSubmitsInWindow({
previousCount: submits,
lastAction: state.lastAction,
fresh: true,
});
submits = window.next;
return window.reported;
});
const counts = extract("counts", state => {
const text = state.ax.find("id:TxnCount")?.text;
return text ? { Travel: parseInt(text, 10) } : null;
});
globalThis.properties = {
submitCommitsOneTransactionPerAction: always(
next(() =>
!committedTransactionsExceedSubmits({
countsBefore: counts.previous ?? null,
countsAfter: counts.current,
submitsInWindow: submitsInWindow.current,
}),
),
),
};
globalThis.actions = actions(() => [Tap({ on: "id:TxnSubmit" })]);
`
const homeWithTxnCount = `{"attributes":{"resource-id":"HomeScreen"},"children":[
{"attributes":{"resource-id":"TxnCount","text":"%d"},"children":[]},
{"attributes":{"resource-id":"TxnSubmit","bounds":"[40,80,240,160]"},"children":[],"clickable":true,"enabled":true}
]}`
// dispatchThenFailDriver is the device condition the runner cannot see through:
// the tap reaches the app and commits, then the call the runner is waiting on
// times out. Every later hierarchy read shows the committed transactions.
type dispatchThenFailDriver struct {
*mockdriver.Driver
commitsPerTap int64
committed atomic.Int64
}
func (d *dispatchThenFailDriver) Tap(context.Context, int, int) error {
return d.dispatchThenFail()
}
func (d *dispatchThenFailDriver) TapSelector(context.Context, string) error {
return d.dispatchThenFail()
}
func (d *dispatchThenFailDriver) dispatchThenFail() error {
d.committed.Add(d.commitsPerTap)
return errors.New("rpc error: code = DeadlineExceeded desc = context deadline exceeded")
}
func (d *dispatchThenFailDriver) Snapshot(context.Context) (string, driver.Image, error) {
return fmt.Sprintf(homeWithTxnCount, d.committed.Load()), driver.Image{}, nil
}
func TestRunner_ApplyErrorAfterDispatchDoesNotConvictTheSubmitCountingProperty(t *testing.T) {
predicates, err := filepath.Abs("../../examples/folio/sanderling/predicates.ts")
if err != nil {
t.Fatal(err)
}
spec := fmt.Sprintf(uncertainApplySpecTemplate, predicates)
run := func(t *testing.T, commitsPerTap int64) []ViolationRecord {
t.Helper()
state := newHarnessWithSpec(t, spec)
device := &dispatchThenFailDriver{Driver: state.mock, commitsPerTap: commitsPerTap}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: time.Hour,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 2,
Driver: device,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.Steps != 2 {
t.Fatalf("steps = %d, want 2; the run never reached the step that judges the pair", summary.Steps)
}
if got := device.committed.Load(); got != commitsPerTap*2 {
t.Fatalf("the device committed %d transaction(s), want %d; the taps never reached it",
got, commitsPerTap*2)
}
return summary.Violations
}
t.Run("one transaction per tap is not a double submit", func(t *testing.T) {
if violations := run(t, 1); len(violations) != 0 {
t.Errorf("the counting property convicted a healthy app: %v\n"+
"one transaction rose against a submit the runner dispatched but "+
"could not confirm, and the spec was told no action happened",
violations)
}
})
// The control. Without it a green above proves nothing: a property that
// never sees a comparable pair is silently vacuous and reports the same
// empty violation list.
t.Run("two transactions per tap still convicts", func(t *testing.T) {
violations := run(t, 2)
if len(violations) == 0 {
t.Fatal("the counting property missed a double submit; the harness never " +
"put the property in a position to fire, so the case above proves nothing")
}
if violations[0].Properties[0] != "submitCommitsOneTransactionPerAction" {
t.Errorf("violated %v, want submitCommitsOneTransactionPerAction", violations[0].Properties)
}
})
}
+42 -1
View File
@@ -3,6 +3,7 @@ package runner
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"testing" "testing"
"time" "time"
@@ -71,7 +72,47 @@ func TestRunner_WebInstallsLastActionInThePage(t *testing.T) {
// Every later step carries what the runner actually applied. The shape is // Every later step carries what the runner actually applied. The shape is
// the goja host's (internal/verifier/marshal.go lastActionFields), pinned // the goja host's (internal/verifier/marshal.go lastActionFields), pinned
// against it by TestLastAction_WebJSONMatchesTheGojaObject. // against it by TestLastAction_WebJSONMatchesTheGojaObject.
const want = `{"kind":"Tap","on":"id:TxnSubmit"}` const want = `{"kind":"Tap","applied":true,"on":"id:TxnSubmit"}`
if web.installed[1] != want {
t.Errorf("step 2 installed %s, want %s", web.installed[1], want)
}
}
// failingTapWebDriver dispatches the tap and then fails the call, the shape an
// RPC deadline takes: the page has the click, the runner has an error.
type failingTapWebDriver struct {
*tappingWebDriver
}
func (d *failingTapWebDriver) Tap(context.Context, int, int) error {
return errors.New("rpc error: code = DeadlineExceeded desc = context deadline exceeded")
}
// The web leg of the same three states the goja host reports. "applied":null is
// not "no action": a property gated on the last action still sees the tap and
// decides for itself, which it cannot do if the page is handed a bare null.
func TestRunner_WebInstallsAnUnconfirmedActionWithItsFateUnknown(t *testing.T) {
state := newHarnessWithSpec(t, lastActionSpec)
web := &failingTapWebDriver{tappingWebDriver: &tappingWebDriver{Driver: state.mock}}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := Run(ctx, Options{
Duration: time.Hour,
IdleTimeout: 20 * time.Millisecond,
MaxSteps: 2,
Driver: web,
Verifier: state.verifier,
TraceWriter: state.writer,
}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(web.installed) < 2 {
t.Fatalf("the page was handed lastAction %d time(s); the web path never installed it",
len(web.installed))
}
const want = `{"kind":"Tap","applied":null,"on":"id:TxnSubmit"}`
if web.installed[1] != want { if web.installed[1] != want {
t.Errorf("step 2 installed %s, want %s", web.installed[1], want) t.Errorf("step 2 installed %s, want %s", web.installed[1], want)
} }
+14 -2
View File
@@ -344,7 +344,19 @@ func lastActionFields(action *Action) []actionField {
point := func(x, y int) []actionField { point := func(x, y int) []actionField {
return []actionField{{key: "x", value: x}, {key: "y", value: y}} return []actionField{{key: "x", value: x}, {key: "y", value: y}}
} }
fields := []actionField{{key: "kind", value: string(action.Kind)}} // An action whose apply call failed is not an action that did not happen:
// the dispatch may have landed before the error. That is unknown, and
// unknown is null here for the same reason every other absence in the spec
// surface is, so a property decides for itself instead of being handed a
// "nothing happened" the runner cannot vouch for.
var applied any
if action.Applied {
applied = true
}
fields := []actionField{
{key: "kind", value: string(action.Kind)},
{key: "applied", value: applied},
}
if action.On != "" { if action.On != "" {
fields = append(fields, actionField{key: "on", value: action.On}) fields = append(fields, actionField{key: "on", value: action.On})
} }
@@ -397,7 +409,7 @@ func objectFromFields(runtime *goja.Runtime, fields []actionField) *goja.Object
// has no Go-side state object to read: the runner pushes this JSON into the // has no Go-side state object to read: the runner pushes this JSON into the
// page before each extractor evaluation. A nil action encodes as JSON null, // page before each extractor evaluation. A nil action encodes as JSON null,
// the same value the goja host reports on the first step of a run and after a // the same value the goja host reports on the first step of a run and after a
// step whose action was never applied. // step whose action was never dispatched.
func EncodeLastAction(action *Action) json.RawMessage { func EncodeLastAction(action *Action) json.RawMessage {
if action == nil { if action == nil {
return json.RawMessage("null") return json.RawMessage("null")
+40
View File
@@ -168,6 +168,7 @@ func TestLastAction_WebJSONMatchesTheGojaObject(t *testing.T) {
}{ }{
{"nil", nil}, {"nil", nil},
{"Tap", &Action{Kind: ActionKindTap, On: "id:TxnSubmit", X: 12, Y: 34}}, {"Tap", &Action{Kind: ActionKindTap, On: "id:TxnSubmit", X: 12, Y: 34}},
{"TapApplied", &Action{Kind: ActionKindTap, On: "id:TxnSubmit", Applied: true}},
{"TapWithoutSelector", &Action{Kind: ActionKindTap, X: 12, Y: 34}}, {"TapWithoutSelector", &Action{Kind: ActionKindTap, X: 12, Y: 34}},
{"DoubleTap", &Action{Kind: ActionKindDoubleTap, On: `desc:say "hi" <b>`}}, {"DoubleTap", &Action{Kind: ActionKindDoubleTap, On: `desc:say "hi" <b>`}},
{"InputText", &Action{Kind: ActionKindInputText, On: "id:field", Text: "50"}}, {"InputText", &Action{Kind: ActionKindInputText, On: "id:field", Text: "50"}},
@@ -193,3 +194,42 @@ func TestLastAction_WebJSONMatchesTheGojaObject(t *testing.T) {
}) })
} }
} }
// A spec has to be able to tell three things apart: no action ran, an action
// ran, and an action was dispatched whose fate the runner cannot vouch for.
// The third used to be reported as the first, which is how a property that
// reasons "an effect landed with no action to cause it" convicts an app over
// an RPC deadline.
func TestLastAction_SeparatesNoActionFromAnActionOfUnknownFate(t *testing.T) {
verifier := newVerifier(t)
mustLoad(t, verifier, `
globalThis.fate = __sanderling__.extract(state =>
state.lastAction === null ? "no action"
: state.lastAction.applied === true ? "applied"
: state.lastAction.applied === null ? "unknown"
: "unreadable");
`)
for _, testCase := range []struct {
name string
action *Action
want string
}{
{"nothing ran", nil, "no action"},
{"dispatch confirmed", &Action{Kind: ActionKindTap, On: "id:TxnSubmit", Applied: true}, "applied"},
{"dispatch unconfirmed", &Action{Kind: ActionKindTap, On: "id:TxnSubmit"}, "unknown"},
} {
t.Run(testCase.name, func(t *testing.T) {
if err := verifier.PushSnapshot(SnapshotInput{
Snapshots: Snapshots{},
LastAction: testCase.action,
}); err != nil {
t.Fatal(err)
}
handle := verifier.runtime.GlobalObject().Get("fate").ToObject(verifier.runtime)
if got := handle.Get("current").String(); got != testCase.want {
t.Errorf("the spec read %q, want %q", got, testCase.want)
}
})
}
}
+5
View File
@@ -33,6 +33,11 @@ type Action struct {
// Direction is the scroll direction for ActionKindScroll: one of "up", // Direction is the scroll direction for ActionKindScroll: one of "up",
// "down", "left", "right". Empty for every other kind. // "down", "left", "right". Empty for every other kind.
Direction string Direction string
// Applied is meaningful only on the action a step reports to the spec as
// state.lastAction: true when the runner saw the dispatch succeed, false
// when the apply call failed and nothing can say whether the action
// reached the app. The spec is told which of the two it is.
Applied bool
} }
// LogEntry mirrors a logcat line captured between steps. // LogEntry mirrors a logcat line captured between steps.
+1
View File
@@ -13,6 +13,7 @@ export type {
InputTextAction, InputTextAction,
Key, Key,
KnownAttrSelectors, KnownAttrSelectors,
LastAction,
LogEntry, LogEntry,
LongPressAction, LongPressAction,
Point, Point,
+11 -1
View File
@@ -102,10 +102,20 @@ export interface ExceptionRecord {
unixMillis?: number; unixMillis?: number;
} }
/**
* The previous step's action as the runner reports it. `applied` is true when
* the runner saw the dispatch succeed and null when the apply call failed with
* the gesture possibly already delivered: an RPC deadline can fire after the
* tap landed. Null is unknown, not "it did not happen" (`state.lastAction` is
* itself null for that), so a property attributing an effect to this action
* has to decline unless `applied` is true.
*/
export type LastAction = Action & { applied: true | null };
export interface State { export interface State {
snapshots: Snapshots; snapshots: Snapshots;
ax: AccessibilityTree; ax: AccessibilityTree;
lastAction: Action | null; lastAction: LastAction | null;
time: number; time: number;
logs: readonly LogEntry[]; logs: readonly LogEntry[];
exceptions: readonly ExceptionRecord[]; exceptions: readonly ExceptionRecord[];
+24 -3
View File
@@ -3,8 +3,12 @@ import { test } from "node:test";
import { createdAccountHasNonZeroBalance } from "../../../examples/folio/sanderling/predicates.ts"; import { createdAccountHasNonZeroBalance } from "../../../examples/folio/sanderling/predicates.ts";
const created = { kind: "Tap", on: "testTag:AddAccountScreen > testTag:AddAccountSubmit" }; const created = {
const idle = { kind: "Tap", on: "testTag:HomeScreen > testTag:AccountCard" }; kind: "Tap",
on: "testTag:AddAccountScreen > testTag:AddAccountSubmit",
applied: true as const,
};
const idle = { kind: "Tap", on: "testTag:HomeScreen > testTag:AccountCard", applied: true as const };
const account = (name: string, balance: number | null) => ({ name, balance }); const account = (name: string, balance: number | null) => ({ name, balance });
@@ -27,7 +31,7 @@ test("a double-tapped create is judged the same way", () => {
assert.equal( assert.equal(
createdAccountHasNonZeroBalance({ createdAccountHasNonZeroBalance({
route: "home", route: "home",
lastAction: { kind: "DoubleTap", on: "id:AddAccountSubmit" }, lastAction: { kind: "DoubleTap", on: "id:AddAccountSubmit", applied: true },
typedName: "Travel", typedName: "Travel",
before: [account("Checking", 0)], before: [account("Checking", 0)],
after: [account("Checking", 0), account("Travel", 5000)], after: [account("Checking", 0), account("Travel", 5000)],
@@ -233,3 +237,20 @@ test("a card that was already there is not a card that was just created", () =>
false, false,
); );
}); });
// The apply call failed with the gesture possibly already delivered, so nobody
// knows whether that account was created. The card carrying the typed name may
// be an older one that scrolled into view, and attributing it to a creation
// that may never have happened is a conviction built on a guess.
test("a create the runner could not confirm attributes nothing", () => {
assert.equal(
createdAccountHasNonZeroBalance({
route: "home",
lastAction: { ...created, applied: null },
typedName: "Travel",
before: [account("Checking", 0)],
after: [account("Checking", 0), account("Travel", 5000)],
}),
false,
);
});
@@ -12,7 +12,7 @@ test("single submit: delta matches typed amount", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 500, typedAmount: 500,
prevTotalBalance: 1000, prevTotalBalance: 1000,
@@ -26,7 +26,7 @@ test("double submit: delta is twice the typed amount, fires", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 500, typedAmount: 500,
prevTotalBalance: 1000, prevTotalBalance: 1000,
@@ -40,7 +40,7 @@ test("DoubleTap kind also caught when delta exceeds typed amount", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "DoubleTap", on: submitOn }, lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 500, typedAmount: 500,
prevTotalBalance: 0, prevTotalBalance: 0,
@@ -54,7 +54,7 @@ test("wrong action kind: vacuous true even with mismatch", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "InputText", on: submitOn }, lastAction: { kind: "InputText", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 500, typedAmount: 500,
prevTotalBalance: 1000, prevTotalBalance: 1000,
@@ -68,7 +68,7 @@ test("wrong target: vacuous true even with mismatch", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: "testTag:LoginScreen > testTag:LoginSubmit" }, lastAction: { kind: "Tap", on: "testTag:LoginScreen > testTag:LoginSubmit", applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 500, typedAmount: 500,
prevTotalBalance: 1000, prevTotalBalance: 1000,
@@ -96,7 +96,7 @@ test("zero typedAmount: vacuous true", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 0, typedAmount: 0,
prevTotalBalance: 1000, prevTotalBalance: 1000,
@@ -110,7 +110,7 @@ test("selector as object: coerced safely and TxnSubmit detected", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: { testTag: "TxnSubmit" } }, lastAction: { kind: "Tap", on: { testTag: "TxnSubmit" }, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 500, typedAmount: 500,
prevTotalBalance: 0, prevTotalBalance: 0,
@@ -124,7 +124,7 @@ test("selector as object without TxnSubmit: vacuous true", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: { testTag: "LoginSubmit" } }, lastAction: { kind: "Tap", on: { testTag: "LoginSubmit" }, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 500, typedAmount: 500,
prevTotalBalance: 0, prevTotalBalance: 0,
@@ -138,7 +138,7 @@ test("raw whole-dollar input: single submit clears", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: parseTypedAmount("50"), typedAmount: parseTypedAmount("50"),
prevTotalBalance: 5000, prevTotalBalance: 5000,
@@ -152,7 +152,7 @@ test("raw whole-dollar input: double submit fires", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: parseTypedAmount("50"), typedAmount: parseTypedAmount("50"),
prevTotalBalance: 5000, prevTotalBalance: 5000,
@@ -166,7 +166,7 @@ test("decimal input from empty prior balance clears", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: parseTypedAmount("5.50"), typedAmount: parseTypedAmount("5.50"),
prevTotalBalance: 0, prevTotalBalance: 0,
@@ -180,7 +180,7 @@ test("DoubleTap kind with raw whole-dollar input fires", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "DoubleTap", on: submitOn }, lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: parseTypedAmount("100"), typedAmount: parseTypedAmount("100"),
prevTotalBalance: 0, prevTotalBalance: 0,
@@ -194,7 +194,7 @@ test("route gate: ledger landing with stale carrier is skipped", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "ledger", route: "ledger",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 5000, typedAmount: 5000,
prevTotalBalance: 0, prevTotalBalance: 0,
@@ -208,7 +208,7 @@ test("route gate: add-transaction landing with double-submit delta is skipped",
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "add-transaction", route: "add-transaction",
lastAction: { kind: "DoubleTap", on: submitOn }, lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 5000, typedAmount: 5000,
prevTotalBalance: 0, prevTotalBalance: 0,
@@ -222,7 +222,7 @@ test("route gate: null route is skipped", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: null, route: null,
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 5000, typedAmount: 5000,
prevTotalBalance: 0, prevTotalBalance: 0,
@@ -236,7 +236,7 @@ test("route gate: home landing with matching delta passes", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 5000, typedAmount: 5000,
prevTotalBalance: 0, prevTotalBalance: 0,
@@ -250,7 +250,7 @@ test("route gate: home landing with double-insert delta fires", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 5000, typedAmount: 5000,
prevTotalBalance: 0, prevTotalBalance: 0,
@@ -275,7 +275,7 @@ test("above 2^53 a healthy single submit is not reported", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 1600, typedAmount: 1600,
prevTotalBalance: HUGE_BALANCE, prevTotalBalance: HUGE_BALANCE,
@@ -289,7 +289,7 @@ test("above 2^53 a double-submit delta is not reported either", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 1600, typedAmount: 1600,
prevTotalBalance: HUGE_BALANCE, prevTotalBalance: HUGE_BALANCE,
@@ -303,7 +303,7 @@ test("an unreadable previous balance above 2^53 is not evidence", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 1600, typedAmount: 1600,
prevTotalBalance: HUGE_BALANCE, prevTotalBalance: HUGE_BALANCE,
@@ -320,7 +320,7 @@ test("typed amount above 2^53 is not evidence", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 1e23, typedAmount: 1e23,
prevTotalBalance: 0, prevTotalBalance: 0,
@@ -336,7 +336,7 @@ test("boundary: a double submit landing exactly on MAX_SAFE_INTEGER still fires"
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 4503599627370495, typedAmount: 4503599627370495,
prevTotalBalance: 0, prevTotalBalance: 0,
@@ -350,7 +350,7 @@ test("boundary: a single submit landing exactly on MAX_SAFE_INTEGER passes", ()
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 9007199254740991, typedAmount: 9007199254740991,
prevTotalBalance: 0, prevTotalBalance: 0,
@@ -364,7 +364,7 @@ test("boundary: one cent past MAX_SAFE_INTEGER stops being evidence", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 4503599627370496, typedAmount: 4503599627370496,
prevTotalBalance: 0, prevTotalBalance: 0,
@@ -381,7 +381,7 @@ test("a large but exact difference between safe balances still fires", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 500, typedAmount: 500,
prevTotalBalance: -9007199254740991, prevTotalBalance: -9007199254740991,
@@ -397,7 +397,7 @@ test("21-digit typed amount with an unmoved balance is not a violation", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: parseTypedAmount("999999999999999999999"), typedAmount: parseTypedAmount("999999999999999999999"),
prevTotalBalance: 220900, prevTotalBalance: 220900,
@@ -417,7 +417,7 @@ test("freshness: two submits in the window is vacuous, not a conviction", () =>
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "DoubleTap", on: submitOn }, lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
submitsInWindow: 2, submitsInWindow: 2,
typedAmount: 19600, typedAmount: 19600,
prevTotalBalance: 0, prevTotalBalance: 0,
@@ -431,7 +431,7 @@ test("freshness: two submits cannot convict even on a clean 2x delta", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 2, submitsInWindow: 2,
typedAmount: 500, typedAmount: 500,
prevTotalBalance: 1000, prevTotalBalance: 1000,
@@ -448,7 +448,7 @@ test("freshness boundary: exactly one submit is the window that convicts", () =>
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "DoubleTap", on: submitOn }, lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 19600, typedAmount: 19600,
prevTotalBalance: 0, prevTotalBalance: 0,
@@ -462,7 +462,7 @@ test("freshness boundary: one submit with a healthy 1x delta still passes", () =
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1, submitsInWindow: 1,
typedAmount: 19600, typedAmount: 19600,
prevTotalBalance: 0, prevTotalBalance: 0,
@@ -476,7 +476,7 @@ test("freshness boundary: three submits is vacuous", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 3, submitsInWindow: 3,
typedAmount: 500, typedAmount: 500,
prevTotalBalance: 0, prevTotalBalance: 0,
@@ -493,7 +493,7 @@ test("freshness boundary: a window with no submit in it is vacuous", () => {
assert.equal( assert.equal(
submitChangesBalanceByTypedAmount({ submitChangesBalanceByTypedAmount({
route: "home", route: "home",
lastAction: { kind: "Tap", on: submitOn }, lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 0, submitsInWindow: 0,
typedAmount: 500, typedAmount: 500,
prevTotalBalance: 1000, prevTotalBalance: 1000,
@@ -502,3 +502,21 @@ test("freshness boundary: a window with no submit in it is vacuous", () => {
true, true,
); );
}); });
// applied: null is the runner saying it dispatched the tap and never learned
// whether it landed. A submit that committed nothing leaves the balance where
// it was, so demanding the typed amount of movement for it convicts an app that
// did exactly what it should have.
test("a submit the runner could not confirm demands no balance move", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn, applied: null },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 1000,
}),
true,
);
});
+23
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { import {
committedTransactionsExceedSubmits,
countSubmitsInWindow, countSubmitsInWindow,
isTxnSubmitTap, isTxnSubmitTap,
readHomeTotalBalance, readHomeTotalBalance,
@@ -149,3 +150,25 @@ test("an unreadable Home does not close the window", () => {
assert.equal(trace[2]?.total, null); assert.equal(trace[2]?.total, null);
assert.equal(trace[3]?.submits, 2); assert.equal(trace[3]?.submits, 2);
}); });
// The window is an upper bound on the submits it holds, so a submit whose
// dispatch the runner could not confirm belongs in it: the tap may well have
// landed, and a bound that leaves it out is one the transaction it committed
// exceeds. That is the false conviction, a rise of one against a window of
// zero, on the property carrying most of the detection on android.
test("a submit the runner could not confirm still counts toward the window", () => {
const window = countSubmitsInWindow({
previousCount: 0,
lastAction: { kind: "Tap", on: submitOn, applied: null },
fresh: true,
});
assert.equal(window.reported, 1);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Travel: 3 },
countsAfter: { Travel: 4 },
submitsInWindow: window.reported,
}),
false,
);
});
+7 -3
View File
@@ -94,7 +94,7 @@ test("the measured android transition chain no longer convicts at delta 0", () =
const step = ( const step = (
tags: string[], tags: string[],
totalText: string | undefined, totalText: string | undefined,
lastAction: { kind: string; on: string } | null, lastAction: { kind: string; on: string; applied: true } | null,
) => { ) => {
const route = routeOfFrame(SCREENS, frame(...tags)); const route = routeOfFrame(SCREENS, frame(...tags));
const reading = readHomeTotalBalance({ route, totalText, previousCarrier: carrier }); const reading = readHomeTotalBalance({ route, totalText, previousCarrier: carrier });
@@ -104,8 +104,12 @@ test("the measured android transition chain no longer convicts at delta 0", () =
return { route, total: reading.value, submits: window.reported }; return { route, total: reading.value, submits: window.reported };
}; };
const back = { kind: "DoubleTap", on: "id:BackButton" }; const back = { kind: "DoubleTap", on: "id:BackButton", applied: true as const };
const phantomSubmit = { kind: "Tap", on: "testTag:AddTransactionScreen > testTag:TxnSubmit" }; const phantomSubmit = {
kind: "Tap",
on: "testTag:AddTransactionScreen > testTag:TxnSubmit",
applied: true as const,
};
const transition = step(["AddTransactionScreen", "HomeScreen"], "$86,911.00", back); const transition = step(["AddTransactionScreen", "HomeScreen"], "$86,911.00", back);
assert.equal(transition.route, null); assert.equal(transition.route, null);