mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
Merge origin/master into llm-recording-and-analysis
Brings in #73, which landed web fact-parity work overlapping this branch: selector-tagged ax handles, aria-disabled in `enabled`, shadow-DOM traversal and a `scrollable`/`editable` dump, plus a third folio property and two new testTags on HomeScreen. Six files conflicted. The option-carrying ones took the union of both sides' fields, so `--label-source` and `--exit-on-violation` both reach the pipeline. In web-runtime.ts both sides changed how an element is described: master gave `elementHandle` a selector to tag handles with, this branch gave it raw attribute names and a field hint. Both survive, and `enabled` now answers through master's isEnabled while `editable` stays. Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX
This commit is contained in:
commit
d987526e47
73 files changed
+6639
-457
No files matched your search
+211
-27
@@ -282,13 +282,32 @@ function selectorFromString(selector: string): { css?: string; xpath?: string }
|
||||
return { css: cssPart(kind, value) };
|
||||
}
|
||||
|
||||
// deepQueryAll resolves a CSS selector against a root AND every shadow root
|
||||
// beneath it. querySelectorAll stops dead at a shadow boundary, and a canvas app
|
||||
// (Compose for Web mounts its canvas and its whole accessibility tree inside a
|
||||
// shadow root on the mount element) keeps its entire UI on the far side of one:
|
||||
// without this a spec sees four nodes and can neither enumerate a target nor
|
||||
// resolve a testTag. Light-DOM matches come first, then shadow content in walk
|
||||
// order. XPath has no equivalent, so `text:` selectors stop at the boundary.
|
||||
function deepQueryAll(selector: string, root: ParentNode): Element[] {
|
||||
const found: Element[] = [];
|
||||
const visit = (scope: ParentNode): void => {
|
||||
for (const element of Array.from(scope.querySelectorAll(selector))) found.push(element);
|
||||
for (const element of Array.from(scope.querySelectorAll<HTMLElement>("*"))) {
|
||||
if (element.shadowRoot) visit(element.shadowRoot);
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
return found;
|
||||
}
|
||||
|
||||
function queryElement(
|
||||
root: ParentNode,
|
||||
selector: unknown,
|
||||
): Element | null {
|
||||
if (typeof selector === "string") {
|
||||
const { css, xpath } = selectorFromString(selector);
|
||||
if (css) return root.querySelector(css);
|
||||
if (css) return deepQueryAll(css, root)[0] ?? null;
|
||||
if (xpath) {
|
||||
const result = document.evaluate(
|
||||
xpath,
|
||||
@@ -313,7 +332,7 @@ function queryElement(
|
||||
}
|
||||
if (selector && typeof selector === "object") {
|
||||
const { css, xpath } = selectorFromObject(selector as Record<string, string | boolean | undefined>);
|
||||
if (css) return root.querySelector(css);
|
||||
if (css) return deepQueryAll(css, root)[0] ?? null;
|
||||
if (xpath) {
|
||||
const result = document.evaluate(
|
||||
xpath,
|
||||
@@ -331,13 +350,27 @@ function queryElement(
|
||||
function queryAllElements(root: ParentNode, selector: unknown): Element[] {
|
||||
if (typeof selector === "string") {
|
||||
const { css, xpath } = selectorFromString(selector);
|
||||
if (css) return Array.from(root.querySelectorAll(css));
|
||||
if (css) return deepQueryAll(css, root);
|
||||
if (xpath) return evaluateXPathAll(xpath, root as Node);
|
||||
return [];
|
||||
}
|
||||
// A selector path: every match of the first segment is searched for the rest,
|
||||
// concatenated in walk order, mirroring FindAllBySelectorPath in
|
||||
// internal/hierarchy. Falling through to the object branch (as this did)
|
||||
// returned NOTHING for a path on web while native returned matches, so a spec
|
||||
// reading state.ax.findAll([{screen}, {row}]) saw an empty list on web and
|
||||
// every property over it passed by having nothing to check.
|
||||
if (Array.isArray(selector)) {
|
||||
const head = selector[0];
|
||||
if (head === undefined) return [];
|
||||
const heads = queryAllElements(root, head);
|
||||
if (selector.length === 1) return heads;
|
||||
const rest = selector.slice(1);
|
||||
return heads.flatMap((element) => queryAllElements(element, rest));
|
||||
}
|
||||
if (selector && typeof selector === "object" && !Array.isArray(selector)) {
|
||||
const { css, xpath } = selectorFromObject(selector as Record<string, string | boolean | undefined>);
|
||||
if (css) return Array.from(root.querySelectorAll(css));
|
||||
if (css) return deepQueryAll(css, root);
|
||||
if (xpath) return evaluateXPathAll(xpath, root as Node);
|
||||
}
|
||||
return [];
|
||||
@@ -391,7 +424,47 @@ function fieldHint(element: Element): string {
|
||||
return element.getAttribute("name") ?? "";
|
||||
}
|
||||
|
||||
function elementHandle(element: Element): Record<string, unknown> {
|
||||
// SELECTOR_TAG is the key an ax element carries the selector it was found by,
|
||||
// the same key the goja host writes (internal/verifier/bindings.go tagSelector).
|
||||
// The shared serializer (runtime-entry.ts pointOf) reads it off an author
|
||||
// target, so a spec's Tap({ on: state.ax.find(...) }) reaches the runner naming
|
||||
// the control it acted on instead of a bare pair of coordinates. Without it
|
||||
// `lastAction.on` is empty on web for exactly the actions a spec authored.
|
||||
const SELECTOR_TAG = "__sanderlingSelector";
|
||||
|
||||
// selectorTag renders a selector argument in the canonical "k:v" grammar the
|
||||
// hierarchy package parses, chains joined by " > ". It mirrors
|
||||
// selectorStringFromJS in internal/verifier/marshal.go, so an element found by
|
||||
// the same selector is labelled with the SAME string on both hosts.
|
||||
function selectorTag(selector: unknown): string {
|
||||
if (typeof selector === "string") return selector;
|
||||
if (Array.isArray(selector)) {
|
||||
return selector
|
||||
.map(selectorTag)
|
||||
.filter((segment) => segment !== "")
|
||||
.join(" > ");
|
||||
}
|
||||
if (selector && typeof selector === "object") {
|
||||
const source = selector as Record<string, unknown>;
|
||||
return Object.keys(source)
|
||||
.filter((key) => key !== SELECTOR_TAG && source[key] !== undefined && source[key] !== null)
|
||||
.map((key) => `${key}:${String(source[key])}`)
|
||||
.join(" ");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// isEnabled answers the `enabled` fact. `.disabled` is a property only real form
|
||||
// controls have, so it reads undefined on the role-based controls the tappable
|
||||
// set now covers, and every one of them looked enabled however plainly it was
|
||||
// marked otherwise. internal/driver/chrome/driver.go answers the same two ways
|
||||
// for the dump the goja host reads.
|
||||
function isEnabled(element: Element): boolean {
|
||||
if ((element as HTMLButtonElement).disabled) return false;
|
||||
return element.getAttribute("aria-disabled") !== "true";
|
||||
}
|
||||
|
||||
function elementHandle(element: Element, selector: unknown): Record<string, unknown> {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = Math.round(rect.left + rect.width / 2);
|
||||
const y = Math.round(rect.top + rect.height / 2);
|
||||
@@ -416,7 +489,7 @@ function elementHandle(element: Element): Record<string, unknown> {
|
||||
desc: ariaLabel,
|
||||
class: (element as HTMLElement).className ?? "",
|
||||
clickable: true,
|
||||
enabled: !(element as HTMLButtonElement).disabled,
|
||||
enabled: isEnabled(element),
|
||||
editable: isEditableElement(element as HTMLElement),
|
||||
focused: document.activeElement === element,
|
||||
x,
|
||||
@@ -429,12 +502,15 @@ function elementHandle(element: Element): Record<string, unknown> {
|
||||
},
|
||||
attrs,
|
||||
dataset: datasetCopy,
|
||||
find(selector: unknown): unknown {
|
||||
const child = queryElement(element, selector);
|
||||
return child ? elementHandle(child) : undefined;
|
||||
[SELECTOR_TAG]: selectorTag(selector),
|
||||
find(childSelector: unknown): unknown {
|
||||
const child = queryElement(element, childSelector);
|
||||
return child ? elementHandle(child, childSelector) : undefined;
|
||||
},
|
||||
findAll(selector: unknown): unknown[] {
|
||||
return queryAllElements(element, selector).map(elementHandle);
|
||||
findAll(childSelector: unknown): unknown[] {
|
||||
return queryAllElements(element, childSelector).map((child) =>
|
||||
elementHandle(child, childSelector),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -443,10 +519,12 @@ function buildAx(): unknown {
|
||||
return {
|
||||
find(selector: unknown): unknown {
|
||||
const element = queryElement(document, selector);
|
||||
return element ? elementHandle(element) : undefined;
|
||||
return element ? elementHandle(element, selector) : undefined;
|
||||
},
|
||||
findAll(selector: unknown): unknown[] {
|
||||
return queryAllElements(document, selector).map(elementHandle);
|
||||
return queryAllElements(document, selector).map((element) =>
|
||||
elementHandle(element, selector),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -483,13 +561,22 @@ if (typeof globalThis.addEventListener === "function") {
|
||||
});
|
||||
}
|
||||
|
||||
// lastAction is what the previous step actually did, pushed in by the Go runner
|
||||
// (internal/runner, via __sanderlingSetLastAction__) before each extractor
|
||||
// evaluation, in the shape internal/verifier/marshal.go builds for goja. The
|
||||
// page cannot derive it: only the runner knows whether the action it picked was
|
||||
// really applied, and under --generator llm the action is not picked here at
|
||||
// all. Hardcoding null here, as this file used to, makes every spec property
|
||||
// that reads state.lastAction vacuously true on web.
|
||||
let lastAction: unknown = null;
|
||||
|
||||
function buildState(): unknown {
|
||||
return {
|
||||
snapshots: {},
|
||||
ax: buildAx(),
|
||||
document,
|
||||
window,
|
||||
lastAction: null,
|
||||
lastAction,
|
||||
time: 0,
|
||||
logs: [],
|
||||
exceptions: capturedExceptions.slice(),
|
||||
@@ -535,6 +622,11 @@ const runtime = {
|
||||
// the host invoking the extractor/next-action callbacks.
|
||||
defineLockedGlobal("__sanderling__", runtime);
|
||||
|
||||
// The host calls this once per step, before __sanderlingExtractors__.
|
||||
defineLockedGlobal("__sanderlingSetLastAction__", (value: unknown) => {
|
||||
lastAction = value ?? null;
|
||||
});
|
||||
|
||||
// writable:false stops a page script from shadowing the runtime via plain
|
||||
// assignment (the realistic in-page threat). configurable:true is required so
|
||||
// unit tests sharing one process can reinstall a fake via defineProperty; a
|
||||
@@ -549,9 +641,18 @@ function defineLockedGlobal(name: string, value: unknown): void {
|
||||
});
|
||||
}
|
||||
|
||||
function evaluateExtractors(): Record<number, unknown> {
|
||||
// Each reading is wrapped in a {value} envelope because JSON has no undefined.
|
||||
// Written straight into the map, an extractor whose getter returned undefined
|
||||
// (folio's on(route, tag) off its own screen, which is most extractors on most
|
||||
// steps) had its whole INDEX dropped by JSON.stringify, and the host kept goja's
|
||||
// dump-derived reading for it while the rest held the page's. Inside the
|
||||
// envelope the same drop means "this getter returned undefined", which is what
|
||||
// the goja host records for the same getter; a JSON null would instead claim it
|
||||
// returned null, and `x.current === undefined` would answer differently on the
|
||||
// two hosts.
|
||||
function evaluateExtractors(): Record<number, { value?: unknown }> {
|
||||
const state = buildState();
|
||||
const result: Record<number, unknown> = {};
|
||||
const result: Record<number, { value?: unknown }> = {};
|
||||
for (let i = 0; i < extractors.length; i++) {
|
||||
const entry = extractors[i];
|
||||
if (!entry) continue;
|
||||
@@ -568,7 +669,7 @@ function evaluateExtractors(): Record<number, unknown> {
|
||||
extracting = false;
|
||||
}
|
||||
entry.currentValue = value;
|
||||
result[i] = sanitize(value);
|
||||
result[i] = { value: sanitize(value) };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -608,7 +709,22 @@ function sanitizeAt(value: unknown, depth: number, seen: WeakSet<object>): unkno
|
||||
// only how the DOM answers "is this clickable" / "is this editable", the two
|
||||
// facts with no direct DOM equivalent of the accessibility attributes native
|
||||
// platforms expose.
|
||||
const TAPPABLE_SELECTOR = 'a, button, input, select, textarea, [role="button"], [onclick]';
|
||||
//
|
||||
// TAPPABLE_ROLES are the ARIA roles whose whole contract is that a user
|
||||
// activates the element. Covering only role="button" left every other one
|
||||
// invisible to the enumeration, however plain the control looked: the replay UI
|
||||
// builds its step rows as <li role="option">, and the spec dogfooding it had to
|
||||
// hand-write an action to reach them because no default verb could see a single
|
||||
// row. internal/driver/chrome/driver.go resolves the same set for the hierarchy
|
||||
// dump the goja host reads, and the two are compared element by element by
|
||||
// TestHierarchy_DerivesTheSameFactsAsTheWebRuntime.
|
||||
const TAPPABLE_ROLES = [
|
||||
"button", "link", "checkbox", "radio", "switch", "tab", "option",
|
||||
"menuitem", "menuitemcheckbox", "menuitemradio", "treeitem",
|
||||
];
|
||||
const TAPPABLE_SELECTOR = `a, button, input, select, textarea, ${
|
||||
TAPPABLE_ROLES.map((role) => `[role="${role}"]`).join(", ")
|
||||
}, [onclick]`;
|
||||
const EDITABLE_SELECTOR = "input, textarea, [contenteditable]";
|
||||
|
||||
const NON_TEXT_INPUT_TYPES = [
|
||||
@@ -651,12 +767,78 @@ function pointOf(element: Element): Candidate {
|
||||
const HEAD_SELECTOR = "head, head *";
|
||||
|
||||
// targetElements is the walk the target list is built from: the document in
|
||||
// pre-order, minus the head subtree.
|
||||
// pre-order, minus the head subtree, with each shadow host's content spliced in
|
||||
// directly after the host. That is buildTree's order in
|
||||
// internal/driver/chrome/driver.go, and the two producers are compared element
|
||||
// by element in enumeration order.
|
||||
function targetElements(): HTMLElement[] {
|
||||
const inHead = new Set<Element>(Array.from(document.querySelectorAll(HEAD_SELECTOR)));
|
||||
return Array.from(document.querySelectorAll<HTMLElement>("*")).filter(
|
||||
(element) => !inHead.has(element),
|
||||
const walked: HTMLElement[] = [];
|
||||
expandShadowContent(
|
||||
Array.from(document.querySelectorAll<HTMLElement>("*")).filter(
|
||||
(element) => !inHead.has(element),
|
||||
),
|
||||
walked,
|
||||
);
|
||||
return walked;
|
||||
}
|
||||
|
||||
// expandShadowContent copies a tree-ordered element list into `into`, following
|
||||
// each host into its shadow root (and into nested hosts) as it goes.
|
||||
function expandShadowContent(elements: HTMLElement[], into: HTMLElement[]): void {
|
||||
for (const element of elements) {
|
||||
into.push(element);
|
||||
const shadow = element.shadowRoot;
|
||||
if (!shadow) continue;
|
||||
expandShadowContent(Array.from(shadow.querySelectorAll<HTMLElement>("*")), into);
|
||||
}
|
||||
}
|
||||
|
||||
// IDENTITY_KEYS is the ladder a target's selector is built from, mirroring
|
||||
// selectorForElement in internal/verifier/worker.go: the id first (where
|
||||
// Compose for Web lands a testTag), then data-testid, then the description.
|
||||
// Every key here is one the goja host's selector grammar already understands,
|
||||
// so the runner can re-resolve the target it names. `desc` reads the same three
|
||||
// attributes, in the same order, that the hierarchy dump folds into
|
||||
// content-desc (internal/driver/chrome/driver.go); reading fewer of them would
|
||||
// let a selector this side calls unique resolve to a different element on the
|
||||
// Go side, which re-routes the action to whatever the dump matched first.
|
||||
const IDENTITY_KEYS: ReadonlyArray<readonly [string, (element: HTMLElement) => string]> = [
|
||||
["id", (element) => element.id],
|
||||
["data-testid", (element) => element.dataset.testid ?? ""],
|
||||
[
|
||||
"desc",
|
||||
(element) =>
|
||||
element.getAttribute("aria-label") ||
|
||||
element.getAttribute("alt") ||
|
||||
element.getAttribute("title") ||
|
||||
"",
|
||||
],
|
||||
];
|
||||
|
||||
// selectorsFor names each enumerated element, or leaves it unnamed. A value is
|
||||
// only used when it occurs ONCE across the enumeration, so an action carrying
|
||||
// the selector can never be re-resolved onto a sibling that shares the value
|
||||
// (folio's Home screen has many AccountCards under one testTag). Unnamed
|
||||
// elements keep the coordinates-only behaviour the web host always had.
|
||||
function selectorsFor(elements: readonly HTMLElement[]): Array<string | undefined> {
|
||||
const counts = IDENTITY_KEYS.map(() => new Map<string, number>());
|
||||
for (const element of elements) {
|
||||
IDENTITY_KEYS.forEach(([, read], index) => {
|
||||
const value = read(element);
|
||||
if (!value) return;
|
||||
const seen = counts[index]!;
|
||||
seen.set(value, (seen.get(value) ?? 0) + 1);
|
||||
});
|
||||
}
|
||||
return elements.map((element) => {
|
||||
for (let index = 0; index < IDENTITY_KEYS.length; index++) {
|
||||
const [key, read] = IDENTITY_KEYS[index]!;
|
||||
const value = read(element);
|
||||
if (value && counts[index]!.get(value) === 1) return `${key}:${value}`;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
// collectTargets walks the document ONCE and reports every element with the facts
|
||||
@@ -664,16 +846,17 @@ function targetElements(): HTMLElement[] {
|
||||
// resolved by selector first so the DOM's answer to "clickable" and "editable"
|
||||
// stays expressed in CSS, as it always was.
|
||||
function collectTargets(): TargetElement[] {
|
||||
const clickable = new Set<Element>(Array.from(document.querySelectorAll(TAPPABLE_SELECTOR)));
|
||||
const clickable = new Set<Element>(deepQueryAll(TAPPABLE_SELECTOR, document));
|
||||
const editable = new Set<Element>(
|
||||
Array.from(document.querySelectorAll<HTMLElement>(EDITABLE_SELECTOR)).filter(
|
||||
isEditableElement,
|
||||
),
|
||||
(deepQueryAll(EDITABLE_SELECTOR, document) as HTMLElement[]).filter(isEditableElement),
|
||||
);
|
||||
return targetElements().map((element) => ({
|
||||
const elements = targetElements();
|
||||
const selectors = selectorsFor(elements);
|
||||
return elements.map((element, index) => ({
|
||||
...pointOf(element),
|
||||
selector: selectors[index],
|
||||
clickable: clickable.has(element),
|
||||
enabled: !(element as HTMLButtonElement).disabled,
|
||||
enabled: isEnabled(element),
|
||||
editable: editable.has(element),
|
||||
scrollable: isScrollable(element),
|
||||
}));
|
||||
@@ -734,6 +917,7 @@ export const __testing__ = {
|
||||
selectorFromObject,
|
||||
SELECTOR_KEYS,
|
||||
unknownSelectorKeyMessage,
|
||||
selectorTag,
|
||||
xpathStringLiteral,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
cardAccountName,
|
||||
cardBalanceText,
|
||||
parseDollarCents,
|
||||
} from "../../../examples/folio/sanderling/predicates.ts";
|
||||
|
||||
// Android and iOS expose AccountName/AccountBalance as their own nodes. Web
|
||||
// merges the card into one node whose text is initials + name +
|
||||
// "N transaction(s)" + balance, with no separator between the parts. Both
|
||||
// shapes have to land on the same balance, and the name has to stay usable as
|
||||
// an identity key.
|
||||
|
||||
const balanceOf = (cardText: string) =>
|
||||
parseDollarCents(cardBalanceText({ childText: undefined, cardText }));
|
||||
|
||||
// Renders a card the way HomeScreen.kt does, so a test states the account and
|
||||
// lets the fixture do the concatenating.
|
||||
const card = (initials: string, name: string, count: number, balance: string) =>
|
||||
`${initials}${name}${count === 1 ? "1 transaction" : `${count} transactions`}${balance}`;
|
||||
|
||||
test("structured child wins over the card text", () => {
|
||||
assert.equal(
|
||||
cardBalanceText({ childText: "$118.00", cardText: "SASavings1 transaction$118.00" }),
|
||||
"$118.00",
|
||||
);
|
||||
assert.equal(
|
||||
cardAccountName({ childText: "Savings", cardText: "SASavings1 transaction$118.00" }),
|
||||
"Savings",
|
||||
);
|
||||
});
|
||||
|
||||
test("merged card text: balance is the amount at the end, not scraped digits", () => {
|
||||
// The naive reading, text.replace(/[^0-9]/g, ""), absorbs the 12 of
|
||||
// "12 transactions" and returns 12258900.
|
||||
assert.equal(balanceOf("INInvestments12 transactions$2,589.00"), 258900);
|
||||
});
|
||||
|
||||
test("merged card text: singular transaction label", () => {
|
||||
assert.equal(balanceOf("SASavings1 transaction$118.00"), 11800);
|
||||
});
|
||||
|
||||
test("merged card text: negative balance keeps its sign", () => {
|
||||
assert.equal(cardBalanceText({ childText: undefined, cardText: "TRTravel3 transactions-$45.50" }), "-$45.50");
|
||||
assert.equal(balanceOf("TRTravel3 transactions-$45.50"), -4550);
|
||||
});
|
||||
|
||||
test("structured child: negative balance keeps its sign", () => {
|
||||
assert.equal(parseDollarCents(cardBalanceText({ childText: "-$45.50", cardText: undefined })), -4550);
|
||||
});
|
||||
|
||||
test("zero-balance card is 0, not unknown", () => {
|
||||
assert.equal(balanceOf("EFEmergency Fund0 transactions$0.00"), 0);
|
||||
assert.equal(balanceOf("Aa0 transactions$0.00"), 0);
|
||||
});
|
||||
|
||||
// The trap a lazy balance regex falls into: a name ending in digits runs
|
||||
// straight into the transaction count, so only anchoring the amount at the end
|
||||
// of the string gets it right.
|
||||
test("name ending in digits does not leak into the balance", () => {
|
||||
assert.equal(balanceOf(card("T2", "Travel 2024", 12, "$75.00")), 7500);
|
||||
assert.equal(balanceOf(card("T2", "Travel 2024", 0, "$0.00")), 0);
|
||||
assert.equal(balanceOf(card("20", "2024", 3, "-$1,234.56")), -123456);
|
||||
});
|
||||
|
||||
// The account key only has to be stable and per-account. newAccountBalanceIsZero
|
||||
// reads it as a set member: a key that drifted as an account's transaction
|
||||
// count grew would make an existing account look brand new, and the property
|
||||
// would fire on it for holding the balance it just earned.
|
||||
test("account key is stable as the transaction count and balance move", () => {
|
||||
const cards: [string, string][] = [
|
||||
["CH", "Checking"],
|
||||
["T2", "Travel 2024"],
|
||||
["A2", "Account 2"],
|
||||
["Aa", "a"],
|
||||
["?", ""],
|
||||
["5T", "5 transactions"],
|
||||
["X9", "x9"],
|
||||
];
|
||||
for (const [initials, name] of cards) {
|
||||
const keys = new Set<string>();
|
||||
for (let count = 0; count <= 130; count++) {
|
||||
keys.add(cardAccountName({
|
||||
childText: undefined,
|
||||
cardText: card(initials, name, count, `$${count * 7}.50`),
|
||||
}));
|
||||
}
|
||||
assert.equal(keys.size, 1, `key for ${JSON.stringify(name)} drifted: ${[...keys].join(", ")}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("account keys are distinct across the accounts a run creates", () => {
|
||||
const names: [string, string][] = [
|
||||
["CH", "Checking"],
|
||||
["SA", "Savings"],
|
||||
["TR", "Travel"],
|
||||
["EF", "Emergency Fund"],
|
||||
["IN", "Investments"],
|
||||
["T2", "Travel 2024"],
|
||||
["A2", "Account 2"],
|
||||
["A1", "Account 12"],
|
||||
["Aa", "a"],
|
||||
];
|
||||
const keys = names.map(([initials, name]) =>
|
||||
cardAccountName({ childText: undefined, cardText: card(initials, name, 4, "$9.00") }));
|
||||
assert.equal(new Set(keys).size, names.length);
|
||||
});
|
||||
|
||||
// elementHandle in the web runtime truncates node text at 200 characters, so a
|
||||
// long account name (the input corpus types 4096 "a"s) pushes the balance off
|
||||
// the end of the string. That balance is unknown, and unknown must not read as
|
||||
// zero: newAccountBalanceIsZero passes an unknown balance rather than
|
||||
// convicting a card it could not read.
|
||||
test("card text truncated past the balance reads as unknown, not zero", () => {
|
||||
const cardText = "AA" + "a".repeat(198);
|
||||
assert.equal(cardBalanceText({ childText: undefined, cardText }), undefined);
|
||||
assert.equal(balanceOf(cardText), null);
|
||||
});
|
||||
|
||||
test("empty and missing text are unknown, not zero", () => {
|
||||
assert.equal(parseDollarCents(undefined), null);
|
||||
assert.equal(parseDollarCents(""), null);
|
||||
assert.equal(parseDollarCents("no digits here"), null);
|
||||
assert.equal(parseDollarCents("$12"), null);
|
||||
assert.equal(cardBalanceText({ childText: undefined, cardText: undefined }), undefined);
|
||||
assert.equal(cardAccountName({ childText: undefined, cardText: undefined }), "");
|
||||
});
|
||||
|
||||
// The two accessibility shapes have to read the same per-card balance, which is
|
||||
// what the accounts extractor compares. The Home total is no longer a sum of
|
||||
// these: it is the app's own TOTAL BALANCE node (see folio-total-balance.test.ts).
|
||||
test("merged card text and a structured child give the same balance", () => {
|
||||
const merged = ["INInvestments12 transactions$2,589.00", "Aa0 transactions$0.00"].map(cardText =>
|
||||
cardBalanceText({ childText: undefined, cardText }));
|
||||
assert.deepEqual(merged, ["$2,589.00", "$0.00"]);
|
||||
assert.deepEqual(merged.map(parseDollarCents), [258900, 0]);
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
committedTransactionsExceedSubmits,
|
||||
countSubmitsInWindow,
|
||||
homeAccountsOf,
|
||||
homeTxnCountsOf,
|
||||
readHomeCards,
|
||||
} from "../../../examples/folio/sanderling/predicates.ts";
|
||||
import type {
|
||||
CardReading,
|
||||
HomeCardReading,
|
||||
TxnCount,
|
||||
} from "../../../examples/folio/sanderling/predicates.ts";
|
||||
|
||||
const card = (name: string, balance: number | null, count: TxnCount | undefined) => ({
|
||||
name,
|
||||
balance,
|
||||
count,
|
||||
});
|
||||
|
||||
test("a laid-out card list reads as an account list and a count map", () => {
|
||||
const cards = [card("Checking", 0, "0"), card("Travel", 2411200, "1")];
|
||||
assert.deepEqual(homeAccountsOf(cards), [
|
||||
{ name: "Checking", balance: 0 },
|
||||
{ name: "Travel", balance: 2411200 },
|
||||
]);
|
||||
assert.deepEqual(homeTxnCountsOf(cards), { Checking: "0", Travel: "1" });
|
||||
});
|
||||
|
||||
// Android draws Home's own node a frame or two before its list, so `findAll`
|
||||
// over the cards comes back empty while the screen already claims to be Home.
|
||||
// "No cards on screen" is not "no accounts".
|
||||
test("Home with nothing laid out yet is unknown, not empty", () => {
|
||||
assert.equal(homeAccountsOf([]), null);
|
||||
assert.equal(homeTxnCountsOf([]), null);
|
||||
});
|
||||
|
||||
test("a card with no readable name or count is left out of the map", () => {
|
||||
assert.deepEqual(
|
||||
homeTxnCountsOf([card("", 0, "3"), card("Checking", 0, undefined), card("Savings", 0, "2")]),
|
||||
{ Savings: "2" },
|
||||
);
|
||||
});
|
||||
|
||||
test("every card unreadable leaves nothing to compare, which is unknown", () => {
|
||||
assert.equal(homeTxnCountsOf([card("", 0, "3"), card("Checking", 0, undefined)]), null);
|
||||
});
|
||||
|
||||
test("off Home the carrier is reported unchanged", () => {
|
||||
const carried = { Checking: "3" };
|
||||
assert.deepEqual(readHomeCards({ route: "ledger", reading: null, previousCarrier: carried }), {
|
||||
value: carried,
|
||||
carrier: carried,
|
||||
fresh: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("a readable list replaces the carrier and closes the window", () => {
|
||||
assert.deepEqual(
|
||||
readHomeCards({ route: "home", reading: { Checking: "5" }, previousCarrier: { Checking: "3" } }),
|
||||
{ value: { Checking: "5" }, carrier: { Checking: "5" }, fresh: true },
|
||||
);
|
||||
});
|
||||
|
||||
// The poisoned carrier, the same defect readHomeTotalBalance was fixed for and
|
||||
// this reading was not. An empty reading written into the carrier is handed
|
||||
// straight back on every later off-Home step, so one un-laid-out Home turns the
|
||||
// comparison into {} against {} for the rest of the run. Measured on android:
|
||||
// counts_prev was {} at EVERY evaluation point of all 17 runs, which is a
|
||||
// counting invariant that cannot fire at all.
|
||||
test("an un-laid-out Home reports unknown but leaves the carrier intact", () => {
|
||||
const carried = { Checking: "3", Savings: "1" };
|
||||
assert.deepEqual(readHomeCards({ route: "home", reading: null, previousCarrier: carried }), {
|
||||
value: null,
|
||||
carrier: carried,
|
||||
fresh: false,
|
||||
});
|
||||
});
|
||||
|
||||
// The trace the fix has to survive, stepped through the carrier and the window
|
||||
// the spec holds. A double-submit commits two rows against one action, the
|
||||
// Home it lands on has not drawn its list yet, and the counting invariant must
|
||||
// still be able to see the pair once a real Home comes back.
|
||||
function run(steps: { route: string | null; cards: CardReading[]; lastAction: unknown }[]) {
|
||||
let carrier: Record<string, TxnCount> | null = null;
|
||||
let submits = 0;
|
||||
const out: { counts: Record<string, TxnCount> | null; submits: number }[] = [];
|
||||
for (const step of steps) {
|
||||
const reading: HomeCardReading<Record<string, TxnCount>> = readHomeCards({
|
||||
route: step.route,
|
||||
reading: homeTxnCountsOf(step.cards),
|
||||
previousCarrier: carrier,
|
||||
});
|
||||
carrier = reading.carrier;
|
||||
const window = countSubmitsInWindow({
|
||||
previousCount: submits,
|
||||
lastAction: step.lastAction as { kind?: string; on?: string } | null,
|
||||
fresh: reading.fresh,
|
||||
});
|
||||
submits = window.next;
|
||||
out.push({ counts: reading.value, submits: window.reported });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const idle = { kind: "Tap", on: "testTag:AccountCard" };
|
||||
const doubleSubmit = { kind: "DoubleTap", on: "testTag:AddTransactionScreen > testTag:TxnSubmit" };
|
||||
|
||||
test("an un-laid-out Home no longer kills the counting invariant", () => {
|
||||
const trace = run([
|
||||
{ route: "home", cards: [card("Checking", 0, "3")], lastAction: null },
|
||||
{ route: "ledger", cards: [], lastAction: idle },
|
||||
{ route: "home", cards: [], lastAction: doubleSubmit },
|
||||
{ route: "ledger", cards: [], lastAction: idle },
|
||||
{ route: "home", cards: [card("Checking", 0, "5")], lastAction: idle },
|
||||
]);
|
||||
|
||||
// The un-laid-out Home is unknown for its own step, and the two steps after
|
||||
// it get the last list anyone actually read rather than an empty one.
|
||||
assert.deepEqual(trace[2]?.counts, null);
|
||||
assert.deepEqual(trace[3]?.counts, { Checking: "3" });
|
||||
assert.deepEqual(trace[4]?.counts, { Checking: "5" });
|
||||
|
||||
// The window it did not close still holds the double-submit, so one action
|
||||
// against two committed rows is visible at the step that can compare them.
|
||||
assert.equal(trace[4]?.submits, 1);
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: trace[3]?.counts ?? null,
|
||||
countsAfter: trace[4]?.counts ?? null,
|
||||
submitsInWindow: trace[4]?.submits ?? 0,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// The other half of the pairing: the counts window has to close on the counts
|
||||
// reading, not on the total's. A Home frame can render its footer total while
|
||||
// its list is still empty, and a window that reset there would compare a pair of
|
||||
// readings spanning submits it had already forgotten.
|
||||
test("an un-laid-out Home does not close the counting window", () => {
|
||||
const trace = run([
|
||||
{ route: "home", cards: [card("Checking", 0, "3")], lastAction: null },
|
||||
{ route: "ledger", cards: [], lastAction: doubleSubmit },
|
||||
{ route: "home", cards: [], lastAction: doubleSubmit },
|
||||
{ route: "home", cards: [card("Checking", 0, "7")], lastAction: idle },
|
||||
]);
|
||||
assert.equal(trace[3]?.submits, 2);
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { Checking: "3" },
|
||||
countsAfter: trace[3]?.counts ?? null,
|
||||
submitsInWindow: trace[3]?.submits ?? 0,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { createdAccountHasNonZeroBalance } from "../../../examples/folio/sanderling/predicates.ts";
|
||||
|
||||
const created = { kind: "Tap", on: "testTag:AddAccountScreen > testTag:AddAccountSubmit" };
|
||||
const idle = { kind: "Tap", on: "testTag:HomeScreen > testTag:AccountCard" };
|
||||
|
||||
const account = (name: string, balance: number | null) => ({ name, balance });
|
||||
|
||||
// The property still has teeth: the account the fuzzer just asked for, holding
|
||||
// money on the step its creation landed on Home, is a real violation.
|
||||
test("an account created holding money is a violation", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: created,
|
||||
typedName: "Travel",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Travel", 5000)],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("a double-tapped create is judged the same way", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: { kind: "DoubleTap", on: "id:AddAccountSubmit" },
|
||||
typedName: "Travel",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Travel", 5000)],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("an account created empty is what the app is supposed to do", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: created,
|
||||
typedName: "Travel",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Travel", 0)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a balance that could not be read is not evidence", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: created,
|
||||
typedName: "Travel",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Travel", null)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// The false positives this replaces. Home lists the accounts that fit the
|
||||
// viewport, so an account arrives in a later reading for reasons that have
|
||||
// nothing to do with being created: the list scrolled, a clipped card finished
|
||||
// laying out, or (before the route fix) the earlier reading came off a
|
||||
// half-rendered Home mid-transition. Measured on android: an existing Travel
|
||||
// holding $24,112.00 and an existing Savings holding $429,585.00, convicted for
|
||||
// coming into view.
|
||||
test("an account scrolling into view is not an account being created", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: idle,
|
||||
typedName: "Travel",
|
||||
before: [account("Emergency Fund", 461012300), account("Checking", 0)],
|
||||
after: [
|
||||
account("Emergency Fund", 461012300),
|
||||
account("Checking", 0),
|
||||
account("Travel", 2411200),
|
||||
account("Savings", 0),
|
||||
],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("nor is one that appears with no action at all behind it", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: null,
|
||||
typedName: "Travel",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Travel", 2411200)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// Even on the creation step, the only card judged is the one that answers to
|
||||
// the name the fuzzer typed. A card that came into view alongside it is still
|
||||
// just a card that came into view.
|
||||
test("a funded account arriving beside the created one is not judged", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: created,
|
||||
typedName: "Savings",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Savings", 0), account("Travel", 2411200)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("off Home there is no reading to judge", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "ledger",
|
||||
lastAction: created,
|
||||
typedName: "Travel",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Travel", 5000)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a transition frame names no route, so nothing is judged there either", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: null,
|
||||
lastAction: created,
|
||||
typedName: "Travel",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Travel", 5000)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("an unknown reading on either side is not evidence", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: created,
|
||||
typedName: "Travel",
|
||||
before: null,
|
||||
after: [account("Travel", 5000)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: created,
|
||||
typedName: "Travel",
|
||||
before: [account("Checking", 0)],
|
||||
after: null,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// defaultActions types edge-case text into the name field, and an empty name is
|
||||
// not a name we can find a card by.
|
||||
test("an empty typed name attributes nothing", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: created,
|
||||
typedName: " ",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Travel", 5000)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: created,
|
||||
typedName: undefined,
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Travel", 5000)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// Web merges the card into one node whose text opens with the avatar initials,
|
||||
// so the identity key carries them: "TRTravel" is the card for "Travel".
|
||||
test("the merged web key still matches the name that was typed", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: created,
|
||||
typedName: "Travel",
|
||||
before: [account("CHChecking", 0)],
|
||||
after: [account("CHChecking", 0), account("TRTravel", 5000)],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// Two cards answering to one typed name leave the appearance unattributable:
|
||||
// the fuzzer creates duplicates from a five-name list, and the tree has been
|
||||
// seen exposing the same card twice on a transition frame.
|
||||
test("two cards matching the typed name are not attributable to the creation", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: created,
|
||||
typedName: "Travel",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Travel", 5000), account("MyTravel", 900)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a card that was already there is not a card that was just created", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: created,
|
||||
typedName: "Travel",
|
||||
before: [account("Checking", 0), account("Travel", 2411200)],
|
||||
after: [account("Checking", 0), account("Travel", 2411200)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { oncePerFrame, routeOfFrame } from "../../../examples/folio/sanderling/predicates.ts";
|
||||
|
||||
// oncePerFrame is what stops the folio spec re-walking the accessibility tree
|
||||
// once per extractor, and the whole of its safety is that the state object is a
|
||||
// fresh one every step (goja's stateObject, web's buildState). These tests pin
|
||||
// both halves: the same frame is read once, a different frame is a different
|
||||
// answer. A cache that outlived its frame would freeze every reading the spec
|
||||
// takes and the properties over them would go quietly vacuous.
|
||||
const SCREENS = { login: "LoginScreen", home: "HomeScreen" } as const;
|
||||
|
||||
interface Frame {
|
||||
present: readonly string[];
|
||||
finds: number;
|
||||
}
|
||||
|
||||
const frameShowing = (...present: readonly string[]): Frame => ({ present, finds: 0 });
|
||||
|
||||
const routeOf = oncePerFrame((frame: Frame) =>
|
||||
routeOfFrame(SCREENS, tag => {
|
||||
frame.finds++;
|
||||
return frame.present.includes(tag);
|
||||
}),
|
||||
);
|
||||
|
||||
test("one frame is walked once, however many readings ask", () => {
|
||||
const home = frameShowing("HomeScreen");
|
||||
assert.equal(routeOf(home), "home");
|
||||
assert.equal(routeOf(home), "home");
|
||||
assert.equal(routeOf(home), "home");
|
||||
assert.equal(home.finds, 2);
|
||||
});
|
||||
|
||||
test("a new frame is a new answer", () => {
|
||||
const home = frameShowing("HomeScreen");
|
||||
const login = frameShowing("LoginScreen");
|
||||
assert.equal(routeOf(home), "home");
|
||||
assert.equal(routeOf(login), "login");
|
||||
assert.equal(login.finds, 2);
|
||||
});
|
||||
|
||||
test("a transition frame is not answered off the frame before it", () => {
|
||||
assert.equal(routeOf(frameShowing("HomeScreen")), "home");
|
||||
assert.equal(routeOf(frameShowing("HomeScreen", "LoginScreen")), null);
|
||||
});
|
||||
|
||||
test("returning to an earlier frame re-reads it", () => {
|
||||
const home = frameShowing("HomeScreen");
|
||||
routeOf(home);
|
||||
routeOf(frameShowing("LoginScreen"));
|
||||
assert.equal(routeOf(home), "home");
|
||||
assert.equal(home.finds, 4);
|
||||
});
|
||||
|
||||
// What makes memoizing the card list worth more than memoizing the route: the
|
||||
// three readings taken off it share one parse instead of three.
|
||||
test("a frame's reading is handed back by identity", () => {
|
||||
const cardsOf = oncePerFrame((frame: Frame) => frame.present.map(tag => ({ tag })));
|
||||
const home = frameShowing("HomeScreen");
|
||||
assert.equal(cardsOf(home), cardsOf(home));
|
||||
assert.notEqual(cardsOf(home), cardsOf(frameShowing("HomeScreen")));
|
||||
});
|
||||
@@ -13,6 +13,7 @@ test("single submit: delta matches typed amount", () => {
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 1000,
|
||||
currTotalBalance: 1500,
|
||||
@@ -26,6 +27,7 @@ test("double submit: delta is twice the typed amount, fires", () => {
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 1000,
|
||||
currTotalBalance: 2000,
|
||||
@@ -39,6 +41,7 @@ test("DoubleTap kind also caught when delta exceeds typed amount", () => {
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "DoubleTap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 0,
|
||||
currTotalBalance: 1000,
|
||||
@@ -52,6 +55,7 @@ test("wrong action kind: vacuous true even with mismatch", () => {
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "InputText", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 1000,
|
||||
currTotalBalance: 1000,
|
||||
@@ -65,6 +69,7 @@ test("wrong target: vacuous true even with mismatch", () => {
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: "testTag:LoginScreen > testTag:LoginSubmit" },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 1000,
|
||||
currTotalBalance: 1000,
|
||||
@@ -78,6 +83,7 @@ test("null lastAction: vacuous true", () => {
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: null,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 1000,
|
||||
currTotalBalance: 2000,
|
||||
@@ -91,6 +97,7 @@ test("zero typedAmount: vacuous true", () => {
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 0,
|
||||
prevTotalBalance: 1000,
|
||||
currTotalBalance: 1500,
|
||||
@@ -104,6 +111,7 @@ test("selector as object: coerced safely and TxnSubmit detected", () => {
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: { testTag: "TxnSubmit" } },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 0,
|
||||
currTotalBalance: 1000,
|
||||
@@ -117,6 +125,7 @@ test("selector as object without TxnSubmit: vacuous true", () => {
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: { testTag: "LoginSubmit" } },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 0,
|
||||
currTotalBalance: 1000,
|
||||
@@ -130,6 +139,7 @@ test("raw whole-dollar input: single submit clears", () => {
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: parseTypedAmount("50"),
|
||||
prevTotalBalance: 5000,
|
||||
currTotalBalance: 10000,
|
||||
@@ -143,6 +153,7 @@ test("raw whole-dollar input: double submit fires", () => {
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: parseTypedAmount("50"),
|
||||
prevTotalBalance: 5000,
|
||||
currTotalBalance: 15000,
|
||||
@@ -156,6 +167,7 @@ test("decimal input from empty prior balance clears", () => {
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: parseTypedAmount("5.50"),
|
||||
prevTotalBalance: 0,
|
||||
currTotalBalance: 550,
|
||||
@@ -169,6 +181,7 @@ test("DoubleTap kind with raw whole-dollar input fires", () => {
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "DoubleTap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: parseTypedAmount("100"),
|
||||
prevTotalBalance: 0,
|
||||
currTotalBalance: 20000,
|
||||
@@ -182,6 +195,7 @@ test("route gate: ledger landing with stale carrier is skipped", () => {
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "ledger",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 5000,
|
||||
prevTotalBalance: 0,
|
||||
currTotalBalance: 0,
|
||||
@@ -195,6 +209,7 @@ test("route gate: add-transaction landing with double-submit delta is skipped",
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "add-transaction",
|
||||
lastAction: { kind: "DoubleTap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 5000,
|
||||
prevTotalBalance: 0,
|
||||
currTotalBalance: 10000,
|
||||
@@ -208,6 +223,7 @@ test("route gate: null route is skipped", () => {
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: null,
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 5000,
|
||||
prevTotalBalance: 0,
|
||||
currTotalBalance: 0,
|
||||
@@ -221,6 +237,7 @@ test("route gate: home landing with matching delta passes", () => {
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 5000,
|
||||
prevTotalBalance: 0,
|
||||
currTotalBalance: 5000,
|
||||
@@ -234,6 +251,7 @@ test("route gate: home landing with double-insert delta fires", () => {
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 5000,
|
||||
prevTotalBalance: 0,
|
||||
currTotalBalance: 10000,
|
||||
@@ -241,3 +259,246 @@ test("route gate: home landing with double-insert delta fires", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// Precision. Cents are integers in float64 here, so the equality only means
|
||||
// something while every number involved is exactly representable. The app takes
|
||||
// any amount that fits a Kotlin Long, and an iOS run reached a balance around
|
||||
// 1e18 cents, where representable values sit 128 apart: the delta of a
|
||||
// perfectly healthy single submit no longer reads back as the typed amount.
|
||||
const HUGE_BALANCE = 999999999999999900;
|
||||
|
||||
test("above 2^53 the arithmetic itself is wrong, which is why the guard exists", () => {
|
||||
assert.notEqual(Math.abs(HUGE_BALANCE + 1600 - HUGE_BALANCE), 1600);
|
||||
});
|
||||
|
||||
test("above 2^53 a healthy single submit is not reported", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 1600,
|
||||
prevTotalBalance: HUGE_BALANCE,
|
||||
currTotalBalance: HUGE_BALANCE + 1600,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("above 2^53 a double-submit delta is not reported either", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 1600,
|
||||
prevTotalBalance: HUGE_BALANCE,
|
||||
currTotalBalance: HUGE_BALANCE + 3200,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("an unreadable previous balance above 2^53 is not evidence", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 1600,
|
||||
prevTotalBalance: HUGE_BALANCE,
|
||||
currTotalBalance: 5000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// A typed amount past the safe range cannot be compared either. parseTypedAmount
|
||||
// returns 0 for those now, but the predicate takes the number from its caller
|
||||
// and must not convict on one it cannot hold.
|
||||
test("typed amount above 2^53 is not evidence", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 1e23,
|
||||
prevTotalBalance: 0,
|
||||
currTotalBalance: 0,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// The boundary, from both sides. MAX_SAFE_INTEGER still gets judged; one cent
|
||||
// more is where counting stops being exact.
|
||||
test("boundary: a double submit landing exactly on MAX_SAFE_INTEGER still fires", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 4503599627370495,
|
||||
prevTotalBalance: 0,
|
||||
currTotalBalance: 9007199254740990,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("boundary: a single submit landing exactly on MAX_SAFE_INTEGER passes", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 9007199254740991,
|
||||
prevTotalBalance: 0,
|
||||
currTotalBalance: 9007199254740991,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("boundary: one cent past MAX_SAFE_INTEGER stops being evidence", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 4503599627370496,
|
||||
prevTotalBalance: 0,
|
||||
currTotalBalance: 9007199254740992,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// The guard covers the balances and the typed amount, not their difference: two
|
||||
// safe balances subtract exactly whenever the result could have matched a safe
|
||||
// typed amount, so a mismatch here is real and must still be reported.
|
||||
test("a large but exact difference between safe balances still fires", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: -9007199254740991,
|
||||
currTotalBalance: 9007199254740991,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// The 21-digit corpus amount end to end: the app refuses it, so nothing moves,
|
||||
// and the property must stay quiet rather than demand a 1e23-cent move.
|
||||
test("21-digit typed amount with an unmoved balance is not a violation", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: parseTypedAmount("999999999999999999999"),
|
||||
prevTotalBalance: 220900,
|
||||
currTotalBalance: 220900,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// Freshness. prevTotalBalance is the last total we READ, so the window between
|
||||
// it and now can hold more than one submit's transactions. A delta measured
|
||||
// over such a window is not evidence about the amount typed into any one of
|
||||
// them, and the android run that produced a 13000 delta against a typed 19600
|
||||
// is what that looks like: the window held a double-submit's two 19600 debits
|
||||
// and an unrelated 26200 credit.
|
||||
test("freshness: two submits in the window is vacuous, not a conviction", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "DoubleTap", on: submitOn },
|
||||
submitsInWindow: 2,
|
||||
typedAmount: 19600,
|
||||
prevTotalBalance: 0,
|
||||
currTotalBalance: -13000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("freshness: two submits cannot convict even on a clean 2x delta", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 2,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 1000,
|
||||
currTotalBalance: 2000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// The boundary of the rule, from both sides. One submit is the only window the
|
||||
// property judges: zero means the total moved without a submit landing in it
|
||||
// (nothing to attribute the move to), and two or more means the move is shared.
|
||||
test("freshness boundary: exactly one submit is the window that convicts", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "DoubleTap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevTotalBalance: 0,
|
||||
currTotalBalance: -39200,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("freshness boundary: one submit with a healthy 1x delta still passes", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevTotalBalance: 0,
|
||||
currTotalBalance: -19600,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("freshness boundary: three submits is vacuous", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 3,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 0,
|
||||
currTotalBalance: 2500,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// A zero count would mean the step's own action was not counted as a submit,
|
||||
// which contradicts the action gate above it. Guard it anyway: a window with no
|
||||
// submit in it explains no balance move.
|
||||
test("freshness boundary: a window with no submit in it is vacuous", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
submitsInWindow: 0,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 1000,
|
||||
currTotalBalance: 2000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
countSubmitsInWindow,
|
||||
isTxnSubmitTap,
|
||||
readHomeTotalBalance,
|
||||
} from "../../../examples/folio/sanderling/predicates.ts";
|
||||
|
||||
const submitOn = "testTag:AddTransactionScreen > testTag:TxnSubmit";
|
||||
|
||||
test("a tap on TxnSubmit is a commit", () => {
|
||||
assert.equal(isTxnSubmitTap({ kind: "Tap", on: submitOn }), true);
|
||||
});
|
||||
|
||||
test("a double-tap on TxnSubmit is ONE commit action, not two", () => {
|
||||
const window = countSubmitsInWindow({
|
||||
previousCount: 0,
|
||||
lastAction: { kind: "DoubleTap", on: submitOn },
|
||||
fresh: true,
|
||||
});
|
||||
assert.equal(window.reported, 1);
|
||||
});
|
||||
|
||||
test("a selector object naming TxnSubmit is a commit", () => {
|
||||
assert.equal(isTxnSubmitTap({ kind: "Tap", on: { testTag: "TxnSubmit" } }), true);
|
||||
});
|
||||
|
||||
test("typing into the amount field is not a commit", () => {
|
||||
assert.equal(isTxnSubmitTap({ kind: "InputText", on: submitOn }), false);
|
||||
});
|
||||
|
||||
test("tapping some other button is not a commit", () => {
|
||||
assert.equal(isTxnSubmitTap({ kind: "Tap", on: "testTag:AddAccountSubmit" }), false);
|
||||
});
|
||||
|
||||
test("no action at all is not a commit", () => {
|
||||
assert.equal(isTxnSubmitTap(null), false);
|
||||
});
|
||||
|
||||
test("a fresh Home reading closes the window and the next one starts empty", () => {
|
||||
assert.deepEqual(
|
||||
countSubmitsInWindow({ previousCount: 0, lastAction: { kind: "Tap", on: submitOn }, fresh: true }),
|
||||
{ reported: 1, next: 0 },
|
||||
);
|
||||
});
|
||||
|
||||
test("landing off Home keeps the submit in the window for the next step", () => {
|
||||
assert.deepEqual(
|
||||
countSubmitsInWindow({ previousCount: 0, lastAction: { kind: "Tap", on: submitOn }, fresh: false }),
|
||||
{ reported: 1, next: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test("a non-submit step neither adds to nor forgets the window", () => {
|
||||
assert.deepEqual(
|
||||
countSubmitsInWindow({ previousCount: 1, lastAction: { kind: "Tap", on: "testTag:AccountCard" }, fresh: false }),
|
||||
{ reported: 1, next: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test("a second submit with no Home reading between them counts two", () => {
|
||||
assert.deepEqual(
|
||||
countSubmitsInWindow({ previousCount: 1, lastAction: { kind: "DoubleTap", on: submitOn }, fresh: true }),
|
||||
{ reported: 2, next: 0 },
|
||||
);
|
||||
});
|
||||
|
||||
// The two traces the freshness rule exists to tell apart, driven step by step
|
||||
// through the same pair of carriers the spec holds.
|
||||
function run(steps: { route: string | null; totalText?: string; lastAction: unknown }[]) {
|
||||
let carrier: number | null = null;
|
||||
let submits = 0;
|
||||
const out: { total: number | null; submits: number }[] = [];
|
||||
for (const step of steps) {
|
||||
const reading = readHomeTotalBalance({
|
||||
route: step.route,
|
||||
totalText: step.totalText,
|
||||
previousCarrier: carrier,
|
||||
});
|
||||
carrier = reading.carrier;
|
||||
const window = countSubmitsInWindow({
|
||||
previousCount: submits,
|
||||
lastAction: step.lastAction as { kind?: string; on?: string } | null,
|
||||
fresh: reading.fresh,
|
||||
});
|
||||
submits = window.next;
|
||||
out.push({ total: reading.value, submits: window.reported });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const idle = { kind: "Tap", on: "testTag:AccountCard" };
|
||||
const submit = { kind: "Tap", on: submitOn };
|
||||
const doubleSubmit = { kind: "DoubleTap", on: submitOn };
|
||||
|
||||
// A double-submit pops the back stack twice (each Submit calls
|
||||
// navigator.back), so unlike a healthy single submit it lands back on Home,
|
||||
// which is why the property can see it at all.
|
||||
test("clean double submit: one action in the window, delta is 2x", () => {
|
||||
const trace = run([
|
||||
{ route: "home", totalText: "$0.00", lastAction: null },
|
||||
{ route: "ledger", lastAction: idle },
|
||||
{ route: "ledger", lastAction: idle },
|
||||
{ route: "home", totalText: "$100.00", lastAction: doubleSubmit },
|
||||
]);
|
||||
assert.equal(trace[3]?.submits, 1);
|
||||
assert.equal(trace[0]?.total, 0);
|
||||
assert.equal(trace[3]?.total, 10000);
|
||||
});
|
||||
|
||||
// The contaminated window from the android run: an unrelated submit committed
|
||||
// while we were off Home, then the double-submit landed. The delta spans three
|
||||
// transactions, so it is not evidence about either typed amount.
|
||||
test("two submits between Home visits: the window is not evidence", () => {
|
||||
const trace = run([
|
||||
{ route: "home", totalText: "$0.00", lastAction: null },
|
||||
{ route: "ledger", lastAction: idle },
|
||||
{ route: "ledger", lastAction: submit },
|
||||
{ route: "ledger", lastAction: idle },
|
||||
{ route: "home", totalText: "-$130.00", lastAction: doubleSubmit },
|
||||
]);
|
||||
assert.equal(trace[4]?.submits, 2);
|
||||
});
|
||||
|
||||
// Freshness is restored by seeing Home, not by time passing.
|
||||
test("a Home visit between two submits restores a one-action window", () => {
|
||||
const trace = run([
|
||||
{ route: "home", totalText: "$0.00", lastAction: null },
|
||||
{ route: "ledger", lastAction: submit },
|
||||
{ route: "home", totalText: "$262.00", lastAction: idle },
|
||||
{ route: "ledger", lastAction: idle },
|
||||
{ route: "home", totalText: "$66.00", lastAction: doubleSubmit },
|
||||
]);
|
||||
assert.equal(trace[1]?.submits, 1);
|
||||
assert.equal(trace[2]?.submits, 1);
|
||||
assert.equal(trace[4]?.submits, 1);
|
||||
});
|
||||
|
||||
// An unreadable Home is not a Home reading: it must not close the window, or
|
||||
// the count would go back to zero against a total nobody read.
|
||||
test("an unreadable Home does not close the window", () => {
|
||||
const trace = run([
|
||||
{ route: "home", totalText: "$0.00", lastAction: null },
|
||||
{ route: "ledger", lastAction: submit },
|
||||
{ route: "home", totalText: undefined, lastAction: idle },
|
||||
{ route: "home", totalText: "$66.00", lastAction: doubleSubmit },
|
||||
]);
|
||||
assert.equal(trace[2]?.total, null);
|
||||
assert.equal(trace[3]?.submits, 2);
|
||||
});
|
||||
@@ -1,97 +1,88 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { computeHomeTotalBalance } from "../../../examples/folio/sanderling/predicates.ts";
|
||||
import { readHomeTotalBalance } from "../../../examples/folio/sanderling/predicates.ts";
|
||||
|
||||
test("on Home with two cards ($10, $20): returns $30", () => {
|
||||
assert.equal(
|
||||
computeHomeTotalBalance({
|
||||
cardBalanceTexts: ["$10.00", "$20.00"],
|
||||
previousCarrier: 0,
|
||||
}),
|
||||
3000,
|
||||
test("on Home the app's own total is the reading, the carrier and fresh", () => {
|
||||
assert.deepEqual(
|
||||
readHomeTotalBalance({ route: "home", totalText: "$30.00", previousCarrier: 0 }),
|
||||
{ value: 3000, carrier: 3000, fresh: true },
|
||||
);
|
||||
});
|
||||
|
||||
test("off Home (no cards) after a Home visit of $30: returns carrier $30", () => {
|
||||
assert.equal(
|
||||
computeHomeTotalBalance({
|
||||
cardBalanceTexts: [],
|
||||
previousCarrier: 3000,
|
||||
}),
|
||||
3000,
|
||||
test("off Home there is nothing to read, so the carrier is reported unchanged", () => {
|
||||
assert.deepEqual(
|
||||
readHomeTotalBalance({ route: "ledger", totalText: undefined, previousCarrier: 3000 }),
|
||||
{ value: 3000, carrier: 3000, fresh: false },
|
||||
);
|
||||
});
|
||||
|
||||
test("off Home (no cards) with carrier still 0: returns 0", () => {
|
||||
assert.equal(
|
||||
computeHomeTotalBalance({
|
||||
cardBalanceTexts: [],
|
||||
previousCarrier: 0,
|
||||
}),
|
||||
0,
|
||||
test("off Home before any Home visit reports the null carrier, still not fresh", () => {
|
||||
assert.deepEqual(
|
||||
readHomeTotalBalance({ route: "ledger", totalText: undefined, previousCarrier: null }),
|
||||
{ value: null, carrier: null, fresh: false },
|
||||
);
|
||||
});
|
||||
|
||||
test("sequence: Home $30, off-Home, Home $50 tracks new Home totals", () => {
|
||||
let carrier = 0;
|
||||
carrier = computeHomeTotalBalance({
|
||||
cardBalanceTexts: ["$10.00", "$20.00"],
|
||||
previousCarrier: carrier,
|
||||
test("a negative total parses with its sign", () => {
|
||||
assert.deepEqual(
|
||||
readHomeTotalBalance({ route: "home", totalText: "-$1,234.56", previousCarrier: 0 }),
|
||||
{ value: -123456, carrier: -123456, fresh: true },
|
||||
);
|
||||
});
|
||||
|
||||
test("a fresh Home total overrides whatever the carrier held", () => {
|
||||
assert.deepEqual(
|
||||
readHomeTotalBalance({ route: "home", totalText: "$7.50", previousCarrier: 9999 }),
|
||||
{ value: 750, carrier: 750, fresh: true },
|
||||
);
|
||||
});
|
||||
|
||||
// The poisoned carrier. An unreadable Home total is UNKNOWN for that step, so
|
||||
// null is reported and the property goes vacuous, but the carrier must keep the
|
||||
// last total we actually read. Writing null into the carrier is what used to end
|
||||
// the run: off-Home steps hand the carrier straight back, so a single
|
||||
// unreadable Home left every later step null.
|
||||
test("an unreadable Home total reports null but leaves the carrier intact", () => {
|
||||
assert.deepEqual(
|
||||
readHomeTotalBalance({ route: "home", totalText: undefined, previousCarrier: 3000 }),
|
||||
{ value: null, carrier: 3000, fresh: false },
|
||||
);
|
||||
});
|
||||
|
||||
test("a garbled Home total is unknown, not zero", () => {
|
||||
assert.deepEqual(
|
||||
readHomeTotalBalance({ route: "home", totalText: "$", previousCarrier: 3000 }),
|
||||
{ value: null, carrier: 3000, fresh: false },
|
||||
);
|
||||
});
|
||||
|
||||
test("an unreadable Home no longer poisons the steps after it", () => {
|
||||
let carrier: number | null = null;
|
||||
const seen: (number | null)[] = [];
|
||||
const step = (route: string | null, totalText: string | undefined) => {
|
||||
const reading = readHomeTotalBalance({ route, totalText, previousCarrier: carrier });
|
||||
carrier = reading.carrier;
|
||||
seen.push(reading.value);
|
||||
};
|
||||
|
||||
step("home", "$30.00");
|
||||
step("home", undefined);
|
||||
step("ledger", undefined);
|
||||
step("ledger", undefined);
|
||||
step("home", "$50.00");
|
||||
|
||||
assert.deepEqual(seen, [3000, null, 3000, 3000, 5000]);
|
||||
});
|
||||
|
||||
// The clipped fifth account card that started this: it is not a card reading
|
||||
// any more, and the footer total the app renders is unaffected by which cards
|
||||
// the viewport happens to fit.
|
||||
test("Home total is one node, so an off-screen account cannot change it", () => {
|
||||
const withFiveCards = readHomeTotalBalance({
|
||||
route: "home",
|
||||
totalText: "$2,589.00",
|
||||
previousCarrier: 0,
|
||||
});
|
||||
assert.equal(carrier, 3000);
|
||||
carrier = computeHomeTotalBalance({
|
||||
cardBalanceTexts: [],
|
||||
previousCarrier: carrier,
|
||||
});
|
||||
assert.equal(carrier, 3000);
|
||||
carrier = computeHomeTotalBalance({
|
||||
cardBalanceTexts: ["$20.00", "$30.00"],
|
||||
previousCarrier: carrier,
|
||||
});
|
||||
assert.equal(carrier, 5000);
|
||||
});
|
||||
|
||||
test("Ledger step (no Home cards) holds the carrier, ignores Ledger balance", () => {
|
||||
let carrier = 0;
|
||||
carrier = computeHomeTotalBalance({
|
||||
cardBalanceTexts: ["$10.00", "$20.00"],
|
||||
previousCarrier: carrier,
|
||||
});
|
||||
assert.equal(carrier, 3000);
|
||||
carrier = computeHomeTotalBalance({
|
||||
cardBalanceTexts: [],
|
||||
previousCarrier: carrier,
|
||||
});
|
||||
assert.equal(carrier, 3000);
|
||||
});
|
||||
|
||||
test("negative card balance parses with sign and sums correctly", () => {
|
||||
assert.equal(
|
||||
computeHomeTotalBalance({
|
||||
cardBalanceTexts: ["-$5.00", "$10.00"],
|
||||
previousCarrier: 0,
|
||||
}),
|
||||
500,
|
||||
);
|
||||
});
|
||||
|
||||
test("single card on Home overrides any previous carrier", () => {
|
||||
assert.equal(
|
||||
computeHomeTotalBalance({
|
||||
cardBalanceTexts: ["$7.50"],
|
||||
previousCarrier: 9999,
|
||||
}),
|
||||
750,
|
||||
);
|
||||
});
|
||||
|
||||
test("undefined card balance text is treated as 0", () => {
|
||||
assert.equal(
|
||||
computeHomeTotalBalance({
|
||||
cardBalanceTexts: [undefined, "$10.00"],
|
||||
previousCarrier: 0,
|
||||
}),
|
||||
1000,
|
||||
);
|
||||
assert.deepEqual(withFiveCards, { value: 258900, carrier: 258900, fresh: true });
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
countSubmitsInWindow,
|
||||
readHomeCards,
|
||||
readHomeTotalBalance,
|
||||
routeOfFrame,
|
||||
submitChangesBalanceByTypedAmount,
|
||||
} from "../../../examples/folio/sanderling/predicates.ts";
|
||||
|
||||
// The spec's own screen table. A frame is the set of markers its accessibility
|
||||
// tree carries, which is all routeOfFrame is allowed to look at.
|
||||
const SCREENS = {
|
||||
login: "LoginScreen",
|
||||
"add-account": "AddAccountScreen",
|
||||
"add-transaction": "AddTransactionScreen",
|
||||
ledger: "LedgerScreen",
|
||||
home: "HomeScreen",
|
||||
};
|
||||
|
||||
const frame =
|
||||
(...tags: string[]) =>
|
||||
(tag: string) =>
|
||||
tags.includes(tag);
|
||||
|
||||
test("a frame showing one screen names its route", () => {
|
||||
assert.equal(routeOfFrame(SCREENS, frame("LoginScreen")), "login");
|
||||
assert.equal(routeOfFrame(SCREENS, frame("AddAccountScreen")), "add-account");
|
||||
assert.equal(routeOfFrame(SCREENS, frame("AddTransactionScreen")), "add-transaction");
|
||||
assert.equal(routeOfFrame(SCREENS, frame("LedgerScreen")), "ledger");
|
||||
assert.equal(routeOfFrame(SCREENS, frame("HomeScreen")), "home");
|
||||
});
|
||||
|
||||
test("a frame showing no screen at all is unknown", () => {
|
||||
assert.equal(routeOfFrame(SCREENS, frame()), null);
|
||||
assert.equal(routeOfFrame(SCREENS, frame("SomethingElse")), null);
|
||||
});
|
||||
|
||||
// The android transition frame: 425 of 1879 steps across 17 measured runs carry
|
||||
// two screens, in every combination the navigation graph allows. Ranking the
|
||||
// markers and taking the first answers add-transaction for the first of these
|
||||
// while a second, unscoped look answers "on Home" -- and two answers for one
|
||||
// frame is the defect. There is one answer now, and on a transition frame it is
|
||||
// "I do not know".
|
||||
test("a transition frame showing two screens names neither", () => {
|
||||
assert.equal(routeOfFrame(SCREENS, frame("AddTransactionScreen", "HomeScreen")), null);
|
||||
assert.equal(routeOfFrame(SCREENS, frame("HomeScreen", "LedgerScreen")), null);
|
||||
assert.equal(routeOfFrame(SCREENS, frame("AddAccountScreen", "HomeScreen")), null);
|
||||
assert.equal(routeOfFrame(SCREENS, frame("HomeScreen", "LoginScreen")), null);
|
||||
assert.equal(routeOfFrame(SCREENS, frame("AddTransactionScreen", "LedgerScreen")), null);
|
||||
});
|
||||
|
||||
test("the three-screen frames android also emits name nothing", () => {
|
||||
assert.equal(
|
||||
routeOfFrame(SCREENS, frame("AddTransactionScreen", "HomeScreen", "LedgerScreen")),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
// Everything read off Home takes the route as its only input, so a frame that
|
||||
// is not Home cannot be read as Home by anything.
|
||||
test("a transition frame's half-drawn Home total is not a reading", () => {
|
||||
const route = routeOfFrame(SCREENS, frame("AddTransactionScreen", "HomeScreen"));
|
||||
assert.deepEqual(
|
||||
readHomeTotalBalance({ route, totalText: "$86,911.00", previousCarrier: 8681600 }),
|
||||
{ value: 8681600, carrier: 8681600, fresh: false },
|
||||
);
|
||||
});
|
||||
|
||||
test("nor is its half-drawn card list", () => {
|
||||
const route = routeOfFrame(SCREENS, frame("AddAccountScreen", "HomeScreen"));
|
||||
const carried = { Travel: "8", Checking: "0" };
|
||||
const partial = { Travel: "8" };
|
||||
assert.deepEqual(readHomeCards<Record<string, string>>({ route, reading: partial, previousCarrier: carried }), {
|
||||
value: carried,
|
||||
carrier: carried,
|
||||
fresh: false,
|
||||
});
|
||||
});
|
||||
|
||||
// The false conviction itself, android seed 3, steps 98-102 of the recorded
|
||||
// trace. Five submits deep into the window the app sits on AddTransaction with
|
||||
// "339" typed; a double-tap on Back starts the trip Home; the frame that comes
|
||||
// back carries BOTH screens with Home's total already drawn behind the outgoing
|
||||
// one. The old spec read that total as a fresh Home reading, reset the window to
|
||||
// zero, and aimed the next tap at a TxnSubmit button that had stopped existing.
|
||||
// The tap landed on Home, committed nothing, and the property demanded 33900 of
|
||||
// movement for it. Nine of the eleven android convictions were this, all at
|
||||
// delta 0.0x, all a single Tap where the real bug is a DoubleTap.
|
||||
test("the measured android transition chain no longer convicts at delta 0", () => {
|
||||
let carrier: number | null = 8681600;
|
||||
let submits = 5;
|
||||
const step = (
|
||||
tags: string[],
|
||||
totalText: string | undefined,
|
||||
lastAction: { kind: string; on: string } | null,
|
||||
) => {
|
||||
const route = routeOfFrame(SCREENS, frame(...tags));
|
||||
const reading = readHomeTotalBalance({ route, totalText, previousCarrier: carrier });
|
||||
const window = countSubmitsInWindow({ previousCount: submits, lastAction, fresh: reading.fresh });
|
||||
carrier = reading.carrier;
|
||||
submits = window.next;
|
||||
return { route, total: reading.value, submits: window.reported };
|
||||
};
|
||||
|
||||
const back = { kind: "DoubleTap", on: "id:BackButton" };
|
||||
const phantomSubmit = { kind: "Tap", on: "testTag:AddTransactionScreen > testTag:TxnSubmit" };
|
||||
|
||||
const transition = step(["AddTransactionScreen", "HomeScreen"], "$86,911.00", back);
|
||||
assert.equal(transition.route, null);
|
||||
assert.equal(transition.total, 8681600);
|
||||
assert.equal(transition.submits, 5);
|
||||
|
||||
const landing = step(["HomeScreen"], "$86,911.00", phantomSubmit);
|
||||
assert.equal(landing.submits, 6);
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: landing.route,
|
||||
lastAction: phantomSubmit,
|
||||
submitsInWindow: landing.submits,
|
||||
typedAmount: 33900,
|
||||
prevTotalBalance: transition.total,
|
||||
currTotalBalance: landing.total,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// What the reset bought the old spec: the same landing, judged against a
|
||||
// window of one and a total the transition frame had already banked.
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
route: "home",
|
||||
lastAction: phantomSubmit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 33900,
|
||||
prevTotalBalance: 8691100,
|
||||
currTotalBalance: 8691100,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,452 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
cardTxnCount,
|
||||
committedTransactionsExceedSubmits,
|
||||
homeTxnCountsOf,
|
||||
} from "../../../examples/folio/sanderling/predicates.ts";
|
||||
|
||||
// Android and iOS give the count its own node; web merges the card into one
|
||||
// string, where the count sits between the name and the balance. The reading
|
||||
// carries which of the two it came from: a number is a count nothing else could
|
||||
// have leaked into, a string is a digit run that may have.
|
||||
test("a dedicated count node reads as a number, not a digit run", () => {
|
||||
assert.equal(
|
||||
cardTxnCount({ childText: "12 transactions", cardText: "INInvestments12 transactions$2,589.00" }),
|
||||
12,
|
||||
);
|
||||
});
|
||||
|
||||
test("merged card text: the count is taken from in front of the balance", () => {
|
||||
assert.equal(
|
||||
cardTxnCount({ childText: undefined, cardText: "INInvestments12 transactions$2,589.00" }),
|
||||
"12",
|
||||
);
|
||||
});
|
||||
|
||||
test("merged card text: the singular label parses too", () => {
|
||||
assert.equal(
|
||||
cardTxnCount({ childText: undefined, cardText: "SASavings1 transaction$118.00" }),
|
||||
"1",
|
||||
);
|
||||
});
|
||||
|
||||
// The balance has to come off first, or a name ending in digits would be read
|
||||
// as the count.
|
||||
test("a card with no readable count is unknown, not zero", () => {
|
||||
assert.equal(cardTxnCount({ childText: undefined, cardText: undefined }), undefined);
|
||||
assert.equal(cardTxnCount({ childText: undefined, cardText: "no digits here" }), undefined);
|
||||
assert.equal(cardTxnCount({ childText: "", cardText: "AA" + "a".repeat(198) }), undefined);
|
||||
});
|
||||
|
||||
// Measured on a real web run: the account named "-1" holding 2 transactions
|
||||
// merges to "-1-12 transactions-$119.00", and the maximal digit run reads 12.
|
||||
test("merged text runs a digit-ending name into the count", () => {
|
||||
assert.equal(
|
||||
cardTxnCount({ childText: undefined, cardText: "-1-12 transactions-$119.00" }),
|
||||
"12",
|
||||
);
|
||||
});
|
||||
|
||||
const before = { Checking: "3", Savings: "1" };
|
||||
|
||||
test("healthy window: three submits, three transactions", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: before,
|
||||
countsAfter: { Checking: "5", Savings: "2" },
|
||||
submitsInWindow: 3,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejected submits commit nothing, which is under the bound", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: before,
|
||||
countsAfter: { Checking: "3", Savings: "1" },
|
||||
submitsInWindow: 4,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// The bug, stated directly: one tap, two rows.
|
||||
test("double submit: one action commits two transactions", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: before,
|
||||
countsAfter: { Checking: "5", Savings: "1" },
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// The point of counting actions against transactions rather than gating on a
|
||||
// one-submit window: a wide window is still a sound comparison.
|
||||
test("wide window: five submits committing six transactions still fires", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: before,
|
||||
countsAfter: { Checking: "8", Savings: "4" },
|
||||
submitsInWindow: 5,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("boundary: committed equal to the submit count is not a violation", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: before,
|
||||
countsAfter: { Checking: "4", Savings: "1" },
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("boundary: one transaction past the submit count is", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: before,
|
||||
countsAfter: { Checking: "4", Savings: "2" },
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// Only accounts in both readings count. A card that scrolled out of the
|
||||
// viewport, or one whose count was unreadable, drops out of the sum, so the
|
||||
// result is a lower bound on what committed. Losing a card can only cost a
|
||||
// detection; it must never manufacture one.
|
||||
test("an account missing from the later reading is not counted", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { Checking: "3", Savings: "1" },
|
||||
countsAfter: { Checking: "3" },
|
||||
submitsInWindow: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("an account appearing only in the later reading is not counted", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { Checking: "3" },
|
||||
countsAfter: { Checking: "3", Travel: "9" },
|
||||
submitsInWindow: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a card that scrolled away and back is not double counted", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { Checking: "3" },
|
||||
countsAfter: { Checking: "4", Savings: "40" },
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("an unknown reading on either side is not evidence", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: null,
|
||||
countsAfter: { Checking: "99" },
|
||||
submitsInWindow: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { Checking: "0" },
|
||||
countsAfter: null,
|
||||
submitsInWindow: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// The real trace this came from: at the violating step of seeds 3 and 5 the
|
||||
// window held exactly one submit action and the account's count moved by two.
|
||||
test("the measured web witness: submits 1, count delta 2", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { INInvestments: "12", Aa: "0" },
|
||||
countsAfter: { INInvestments: "14", Aa: "0" },
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// The length rule, which is what keeps the merged-text prefix honest. The
|
||||
// account named "-1" reads 19 at nine transactions and 110 at ten: same account,
|
||||
// a delta of 91 out of a true delta of 1. Different run lengths are dropped.
|
||||
test("a count crossing a digit boundary is dropped, not convicted on", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { "-1-": "19" },
|
||||
countsAfter: { "-1-": "110" },
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("same run length keeps the prefixed delta exact", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { "-1-": "110" },
|
||||
countsAfter: { "-1-": "112" },
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { "-1-": "110" },
|
||||
countsAfter: { "-1-": "111" },
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("an unreadably long run is not evidence", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { Checking: "1".repeat(21) },
|
||||
countsAfter: { Checking: "9".repeat(21) },
|
||||
submitsInWindow: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// The other side of that rule, and the reason it is scoped to merged text: a
|
||||
// count read off its own node has no account name in front of it, so its digits
|
||||
// ARE the count and a decade crossing is just a number getting longer. Both
|
||||
// windows below are real android seed-9 readings that the unscoped length rule
|
||||
// threw away, in runs that then finished clean.
|
||||
test("a dedicated node's count crossing a decade is usable evidence", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { Checking: 7 },
|
||||
countsAfter: { Checking: 12 },
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { Savings: 4 },
|
||||
countsAfter: { Savings: 10 },
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// Recovering the window is only worth anything if it still acquits the healthy
|
||||
// case, so the same crossing under a submit that earned it must not fire.
|
||||
test("a dedicated node's healthy decade crossing does not convict", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { Checking: 9 },
|
||||
countsAfter: { Checking: 10 },
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { Checking: 9 },
|
||||
countsAfter: { Checking: 11 },
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// The same numbers off merged text, where the digits may not be the count at
|
||||
// all: still dropped.
|
||||
test("the merged-text equivalent of that crossing is still dropped", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { Checking: "9" },
|
||||
countsAfter: { Checking: "10" },
|
||||
submitsInWindow: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { Checking: "7" },
|
||||
countsAfter: { Checking: "12" },
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// The boundary itself. A pair whose two readings came from different sources is
|
||||
// vouched for by neither rule: the string may carry a name prefix the number
|
||||
// does not, so subtracting them is not a transaction count.
|
||||
test("a pair straddling the two sources is not comparable", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { Checking: 7 },
|
||||
countsAfter: { Checking: "12" },
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { Checking: "7" },
|
||||
countsAfter: { Checking: 12 },
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// End to end from the two accessibility shapes, which is where the distinction
|
||||
// is actually made: the same account, the same true counts, read once off a
|
||||
// dedicated node and once off merged card text.
|
||||
const dedicated = (name: string, count: number) => ({
|
||||
name,
|
||||
balance: 0,
|
||||
count: cardTxnCount({ childText: `${count} transactions`, cardText: undefined }),
|
||||
});
|
||||
|
||||
const merged = (initials: string, name: string, count: number) => ({
|
||||
name,
|
||||
balance: 0,
|
||||
count: cardTxnCount({
|
||||
childText: undefined,
|
||||
cardText: `${initials}${name}${count} transactions$0.00`,
|
||||
}),
|
||||
});
|
||||
|
||||
test("a dedicated-node card list convicts across a decade", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: homeTxnCountsOf([dedicated("Checking", 9)]),
|
||||
countsAfter: homeTxnCountsOf([dedicated("Checking", 11)]),
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("the merged-text card list drops the same pair", () => {
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: homeTxnCountsOf([merged("CH", "Checking", 9)]),
|
||||
countsAfter: homeTxnCountsOf([merged("CH", "Checking", 11)]),
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// Home lists whatever fits the viewport, and Folio lets two accounts share a
|
||||
// name, so one name can arrive on two cards. Keying counts by name collapsed
|
||||
// them onto the last card, and the two readings a window compares then came off
|
||||
// DIFFERENT cards: the probe below is a healthy app, one submit, and a scroll.
|
||||
test("two cards sharing a name do not become one count", () => {
|
||||
const before = homeTxnCountsOf([{ name: "Travel", balance: 0, count: 0 }]);
|
||||
const after = homeTxnCountsOf([
|
||||
{ name: "Travel", balance: 0, count: 0 },
|
||||
{ name: "Travel", balance: 500, count: 8 },
|
||||
]);
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: before,
|
||||
countsAfter: after,
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(after, null);
|
||||
});
|
||||
|
||||
test("a name on two cards is dropped from both readings", () => {
|
||||
const before = homeTxnCountsOf([
|
||||
{ name: "Travel", balance: 0, count: "3" },
|
||||
{ name: "Travel", balance: 0, count: "9" },
|
||||
{ name: "Checking", balance: 0, count: "2" },
|
||||
]);
|
||||
const after = homeTxnCountsOf([
|
||||
{ name: "Travel", balance: 0, count: "3" },
|
||||
{ name: "Travel", balance: 0, count: "11" },
|
||||
{ name: "Checking", balance: 0, count: "2" },
|
||||
]);
|
||||
assert.deepEqual(before, { Checking: "2" });
|
||||
assert.deepEqual(after, { Checking: "2" });
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: before,
|
||||
countsAfter: after,
|
||||
submitsInWindow: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// The other card need not be readable to spoil the identity: an unreadable
|
||||
// count still means the name on the map may not be the card that was read.
|
||||
test("a duplicate name is dropped even when the twin has no count", () => {
|
||||
assert.deepEqual(
|
||||
homeTxnCountsOf([
|
||||
{ name: "Travel", balance: 0, count: 4 },
|
||||
{ name: "Travel", balance: 0, count: undefined },
|
||||
{ name: "Savings", balance: 0, count: 1 },
|
||||
]),
|
||||
{ Savings: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
// Dropping the ambiguous name must not disable the property for the rest.
|
||||
test("a unique name is still counted beside a dropped duplicate", () => {
|
||||
const before = homeTxnCountsOf([
|
||||
{ name: "Travel", balance: 0, count: 3 },
|
||||
{ name: "Travel", balance: 0, count: 1 },
|
||||
{ name: "Checking", balance: 0, count: 4 },
|
||||
]);
|
||||
const after = homeTxnCountsOf([
|
||||
{ name: "Travel", balance: 0, count: 3 },
|
||||
{ name: "Travel", balance: 0, count: 1 },
|
||||
{ name: "Checking", balance: 0, count: 6 },
|
||||
]);
|
||||
assert.deepEqual(before, { Checking: 4 });
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: before,
|
||||
countsAfter: after,
|
||||
submitsInWindow: 1,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("distinct names are all counted", () => {
|
||||
assert.deepEqual(
|
||||
homeTxnCountsOf([
|
||||
{ name: "Checking", balance: 0, count: "3" },
|
||||
{ name: "Savings", balance: 0, count: "1" },
|
||||
]),
|
||||
{ Checking: "3", Savings: "1" },
|
||||
);
|
||||
});
|
||||
@@ -43,14 +43,39 @@ test("zero returns 0", () => {
|
||||
assert.equal(parseTypedAmount("0"), 0);
|
||||
});
|
||||
|
||||
test("leading plus sign tolerated as positive", () => {
|
||||
assert.equal(parseTypedAmount("+50"), 5000);
|
||||
// The app's parseCents matches ^\d+(\.\d{1,2})?$ against the trimmed input, so
|
||||
// a sign is rejected and no transaction is created. Reading "-50" as 5000 cents
|
||||
// would make the balance property demand a move the app never made.
|
||||
test("leading plus sign rejected, like the app", () => {
|
||||
assert.equal(parseTypedAmount("+50"), 0);
|
||||
});
|
||||
|
||||
test("leading minus sign tolerated as positive", () => {
|
||||
assert.equal(parseTypedAmount("-50"), 5000);
|
||||
test("leading minus sign rejected, like the app", () => {
|
||||
assert.equal(parseTypedAmount("-50"), 0);
|
||||
});
|
||||
|
||||
test("comma-separated thousands accepted", () => {
|
||||
assert.equal(parseTypedAmount("1,234.56"), 123456);
|
||||
});
|
||||
|
||||
// The input corpus types this 21-digit run into every field. parseCents calls
|
||||
// toLongOrNull on the whole part, which is null past Long.MAX, so the app
|
||||
// refuses the submit; float64 would have read it as 1e23 and asked the property
|
||||
// to find a balance move of 1e23 cents that never happened.
|
||||
test("21-digit corpus amount returns 0: the app rejects it", () => {
|
||||
assert.equal(parseTypedAmount("999999999999999999999"), 0);
|
||||
});
|
||||
|
||||
test("amount too large for exact cents returns 0", () => {
|
||||
assert.equal(parseTypedAmount("100000000000000"), 0);
|
||||
});
|
||||
|
||||
// 9007199254740991 cents is Number.MAX_SAFE_INTEGER: the last amount whose
|
||||
// cents survive the multiply intact.
|
||||
test("largest exactly representable amount is kept", () => {
|
||||
assert.equal(parseTypedAmount("90071992547409.91"), 9007199254740991);
|
||||
});
|
||||
|
||||
test("one cent past the safe range returns 0", () => {
|
||||
assert.equal(parseTypedAmount("90071992547409.92"), 0);
|
||||
});
|
||||
@@ -14,6 +14,15 @@ export interface FakeElementSpec {
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
// id/testid/label/alt/title are how the host names a target: it builds the
|
||||
// selector an action carries from them, so a fake needs them to exercise that
|
||||
// naming. alt and title are the fallbacks the hierarchy dump folds into
|
||||
// content-desc, which the host has to fall back to in the same order.
|
||||
id?: string;
|
||||
testid?: string;
|
||||
label?: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
// clickable/editable place the element in the selector sets the host queries;
|
||||
// the fake answers those queries directly rather than matching CSS.
|
||||
clickable?: boolean;
|
||||
@@ -28,6 +37,9 @@ export interface FakeElement extends FakeElementSpec {
|
||||
tagName: string;
|
||||
type: string;
|
||||
isContentEditable: boolean;
|
||||
id: string;
|
||||
dataset: Record<string, string | undefined>;
|
||||
getAttribute(name: string): string | null;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
scrollWidth: number;
|
||||
@@ -49,6 +61,10 @@ export function fakeElement(spec: FakeElementSpec): FakeElement {
|
||||
tagName: spec.tag.toUpperCase(),
|
||||
type: spec.tag === "input" ? "text" : "",
|
||||
isContentEditable: editable && spec.tag !== "input" && spec.tag !== "textarea",
|
||||
id: spec.id ?? "",
|
||||
dataset: { testid: spec.testid },
|
||||
getAttribute: (name: string) =>
|
||||
({ "aria-label": spec.label, alt: spec.alt, title: spec.title })[name] ?? null,
|
||||
scrollHeight: spec.overflows ? spec.height * 2 : spec.height,
|
||||
clientHeight: spec.height,
|
||||
scrollWidth: spec.width,
|
||||
|
||||
@@ -128,6 +128,53 @@ test("queryTargets reports a disabled control rather than dropping it", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A target with no selector is a target no property can name. The action the
|
||||
// picker builds from it carries coordinates only, so `lastAction.on` is empty
|
||||
// and any property matching on WHICH control was acted upon cannot fire.
|
||||
test("queryTargets names a uniquely identified target", () => {
|
||||
const submit = fakeElement({
|
||||
tag: "button", x: 0, y: 0, width: 40, height: 20, clickable: true, id: "TxnSubmit",
|
||||
});
|
||||
const byTestid = fakeElement({
|
||||
tag: "button", x: 0, y: 40, width: 40, height: 20, clickable: true, testid: "cancel",
|
||||
});
|
||||
const byLabel = fakeElement({
|
||||
tag: "button", x: 0, y: 80, width: 40, height: 20, clickable: true, label: "Close",
|
||||
});
|
||||
// alt and title are the fallbacks the hierarchy dump folds into content-desc,
|
||||
// so the host has to fall back to them in the same order or a name it calls
|
||||
// unique resolves to a different element on the Go side.
|
||||
const byAlt = fakeElement({ tag: "img", x: 0, y: 120, width: 40, height: 20, alt: "Logo" });
|
||||
const byTitle = fakeElement({ tag: "div", x: 0, y: 160, width: 40, height: 20, title: "Help" });
|
||||
const anonymous = fakeElement({ tag: "div", x: 0, y: 200, width: 10, height: 10 });
|
||||
withFakeDocument([submit, byTestid, byLabel, byAlt, byTitle, anonymous], () => {
|
||||
const targets = host.queryTargets();
|
||||
assert.equal(targets[0]!.selector, "id:TxnSubmit");
|
||||
assert.equal(targets[1]!.selector, "data-testid:cancel");
|
||||
assert.equal(targets[2]!.selector, "desc:Close");
|
||||
assert.equal(targets[3]!.selector, "desc:Logo");
|
||||
assert.equal(targets[4]!.selector, "desc:Help");
|
||||
assert.equal(targets[5]!.selector, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
// A repeated id (folio's Home screen renders one AccountCard testTag per
|
||||
// account) names no single element, so the runner would re-resolve the action
|
||||
// onto whichever sibling it found first. Better unnamed than mis-aimed.
|
||||
test("queryTargets leaves duplicated identities unnamed", () => {
|
||||
const first = fakeElement({
|
||||
tag: "div", x: 0, y: 0, width: 40, height: 20, clickable: true, id: "AccountCard",
|
||||
});
|
||||
const second = fakeElement({
|
||||
tag: "div", x: 0, y: 40, width: 40, height: 20, clickable: true, id: "AccountCard",
|
||||
});
|
||||
withFakeDocument([first, second], () => {
|
||||
const targets = host.queryTargets();
|
||||
assert.equal(targets[0]!.selector, undefined);
|
||||
assert.equal(targets[1]!.selector, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test("queryTargets caches within a tick until reset", () => {
|
||||
const button = fakeElement({ tag: "button", x: 0, y: 0, width: 10, height: 10, clickable: true });
|
||||
withFakeDocument([button], () => {
|
||||
@@ -159,6 +206,13 @@ function withState(run: () => void) {
|
||||
}
|
||||
}
|
||||
|
||||
// Every reading leaves the runtime inside a {value} envelope, so an extractor
|
||||
// whose getter returned undefined keeps its index instead of being dropped by
|
||||
// JSON.stringify.
|
||||
function readingOf(values: Record<number, { value?: unknown }>, index: number): unknown {
|
||||
return values[index]!.value;
|
||||
}
|
||||
|
||||
test("named() sets the extractor's display name", () => {
|
||||
const handle = __testing__.runtime.extract(() => "home").named("route");
|
||||
const entry = __testing__.extractors.find((e) => e.handle === handle);
|
||||
@@ -204,6 +258,63 @@ test("an uncaught cross-extractor read aborts evaluateExtractors", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// JSON.stringify drops an undefined-valued key, so a reading written straight
|
||||
// into the table took the extractor's whole INDEX with it when the getter
|
||||
// returned undefined - folio's on(route, tag) off its own screen, which is most
|
||||
// of its extractors on most steps. The host then kept goja's dump-derived value
|
||||
// for those and the page's for the rest, and a property comparing previous to
|
||||
// current across that split convicts an app that did nothing wrong.
|
||||
test("an extractor that returned undefined keeps its index through JSON", () => {
|
||||
__testing__.extractors.length = 0;
|
||||
__testing__.runtime.extract(() => undefined);
|
||||
__testing__.runtime.extract(() => null);
|
||||
__testing__.runtime.extract(() => 5);
|
||||
let table: Record<number, { value?: unknown }> = {};
|
||||
withState(() => {
|
||||
table = __testing__.evaluateExtractors();
|
||||
});
|
||||
|
||||
const overTheWire = JSON.parse(JSON.stringify(table)) as Record<string, { value?: unknown }>;
|
||||
assert.deepEqual(Object.keys(overTheWire), ["0", "1", "2"]);
|
||||
// undefined and null have to stay distinguishable across the wire: the goja
|
||||
// host records undefined for a getter that returned undefined, so reporting
|
||||
// null instead would make `x.current === undefined` answer one thing on
|
||||
// native and another on web.
|
||||
assert.equal("value" in overTheWire["0"]!, false);
|
||||
assert.equal(overTheWire["1"]!.value, null);
|
||||
assert.equal(overTheWire["2"]!.value, 5);
|
||||
});
|
||||
|
||||
// state.lastAction is the one piece of state the page cannot observe for
|
||||
// itself: only the runner knows which action it actually applied. While the web
|
||||
// runtime hardcoded null there, a spec property gated on the last action (e.g.
|
||||
// folio's submitMovesBalanceByTypedAmount, which only looks at taps on
|
||||
// TxnSubmit) was vacuously true on web forever, and the run went green having
|
||||
// checked nothing.
|
||||
function lastActionSeenByASpec(pushed: unknown): unknown {
|
||||
const setLastAction = (globalThis as Record<string, unknown>)
|
||||
.__sanderlingSetLastAction__ as (value: unknown) => void;
|
||||
__testing__.extractors.length = 0;
|
||||
__testing__.runtime.extract((state) => (state as { lastAction: unknown }).lastAction);
|
||||
let out: Record<number, { value?: unknown }> = {};
|
||||
withState(() => {
|
||||
setLastAction(pushed);
|
||||
out = __testing__.evaluateExtractors();
|
||||
});
|
||||
return readingOf(out, 0);
|
||||
}
|
||||
|
||||
test("state.lastAction carries the action the host pushed", () => {
|
||||
const action = { kind: "Tap", on: "id:TxnSubmit" };
|
||||
assert.deepEqual(lastActionSeenByASpec(action), action);
|
||||
});
|
||||
|
||||
test("state.lastAction is null when the host pushed nothing", () => {
|
||||
// The first step of a run, and any step whose action was never applied: the
|
||||
// goja host reports null there, so the web host must too.
|
||||
assert.equal(lastActionSeenByASpec(null), null);
|
||||
});
|
||||
|
||||
// sanitize runs over every extractor's return value before it leaves the
|
||||
// runtime. A user extractor that returns a page object reachable from
|
||||
// document/window can be self-referential, carry functions, or nest deeply;
|
||||
@@ -212,11 +323,11 @@ test("an uncaught cross-extractor read aborts evaluateExtractors", () => {
|
||||
function sanitizeViaExtract(value: unknown): unknown {
|
||||
__testing__.extractors.length = 0;
|
||||
__testing__.runtime.extract(() => value);
|
||||
let out: Record<number, unknown> = {};
|
||||
let out: Record<number, { value?: unknown }> = {};
|
||||
withState(() => {
|
||||
out = __testing__.evaluateExtractors();
|
||||
});
|
||||
return out[0];
|
||||
return readingOf(out, 0);
|
||||
}
|
||||
|
||||
test("sanitize breaks a self-referential cycle instead of overflowing", () => {
|
||||
@@ -521,3 +632,105 @@ test("a non-editable element carries no hintText", () => {
|
||||
assert.equal(attrsOf(element).hintText, undefined);
|
||||
assert.equal(handleOf(element).editable, false);
|
||||
});
|
||||
|
||||
// An ax element is labelled with the selector it was found by, in the same
|
||||
// canonical grammar selectorStringFromJS emits in internal/verifier/marshal.go.
|
||||
// The label is what a spec's own Tap({ on: state.ax.find(...) }) carries to the
|
||||
// runner: with no label the action is coordinates only, `lastAction.on` is
|
||||
// empty, and a property matching on WHICH control was tapped cannot fire.
|
||||
const { selectorTag } = __testing__;
|
||||
|
||||
test("selectorTag renders the selector shapes the goja host renders", () => {
|
||||
assert.equal(selectorTag("testTag:TxnSubmit"), "testTag:TxnSubmit");
|
||||
assert.equal(selectorTag({ testTag: "TxnSubmit" }), "testTag:TxnSubmit");
|
||||
assert.equal(
|
||||
selectorTag([{ testTag: "AddTransactionScreen" }, { testTag: "TxnSubmit" }]),
|
||||
"testTag:AddTransactionScreen > testTag:TxnSubmit",
|
||||
);
|
||||
assert.equal(selectorTag({ testTag: "Row", "aria-label": "first" }), "testTag:Row aria-label:first");
|
||||
assert.equal(selectorTag(undefined), "");
|
||||
});
|
||||
|
||||
// A selector path scopes the second segment to each match of the first. It
|
||||
// returned nothing at all on web while returning matches on native, so folio's
|
||||
// accounts/totalBalance extractors (findAll([{HomeScreen}, {AccountCard}]))
|
||||
// were empty on every web step and the properties over them checked nothing.
|
||||
test("ax.findAll resolves a selector path segment by segment", () => {
|
||||
const rect = { left: 0, top: 0, right: 10, bottom: 10, width: 10, height: 10 };
|
||||
const node = (id: string, answers: Record<string, unknown[]> = {}) => ({
|
||||
id,
|
||||
tagName: "DIV",
|
||||
className: "",
|
||||
textContent: id,
|
||||
dataset: {},
|
||||
getAttribute: () => null,
|
||||
getBoundingClientRect: () => rect,
|
||||
querySelectorAll: (selector: string) => answers[selector] ?? [],
|
||||
});
|
||||
const cardCss = `:is([data-testid="AccountCard"], [id="AccountCard"])`;
|
||||
const screenCss = `:is([data-testid="HomeScreen"], [id="HomeScreen"])`;
|
||||
const cards = [node("first"), node("second")];
|
||||
const home = node("HomeScreen", { [cardCss]: cards });
|
||||
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
const originalDocument = g.document;
|
||||
const originalWindow = g.window;
|
||||
g.document = { querySelectorAll: (selector: string) => (selector === screenCss ? [home] : []) };
|
||||
g.window = {};
|
||||
try {
|
||||
__testing__.extractors.length = 0;
|
||||
__testing__.runtime.extract((state) => {
|
||||
const ax = (state as { ax: { findAll(s: unknown): Record<string, unknown>[] } }).ax;
|
||||
return ax
|
||||
.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }])
|
||||
.map((card) => card.text);
|
||||
});
|
||||
const values = __testing__.evaluateExtractors();
|
||||
// Scoped to the head match: the cards come from the HomeScreen node, not
|
||||
// from a document-wide sweep for AccountCard.
|
||||
assert.deepEqual(readingOf(values, 0), ["first", "second"]);
|
||||
} finally {
|
||||
g.document = originalDocument;
|
||||
g.window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
test("ax.find and ax.findAll label the element with its selector", () => {
|
||||
const rect = { left: 0, top: 0, right: 10, bottom: 10, width: 10, height: 10 };
|
||||
const submit = {
|
||||
id: "TxnSubmit",
|
||||
tagName: "DIV",
|
||||
className: "",
|
||||
textContent: "Submit",
|
||||
dataset: {},
|
||||
getAttribute: () => null,
|
||||
getBoundingClientRect: () => rect,
|
||||
};
|
||||
const matches = `:is([data-testid="TxnSubmit"], [id="TxnSubmit"])`;
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
const originalDocument = g.document;
|
||||
const originalWindow = g.window;
|
||||
g.document = { querySelectorAll: (selector: string) => (selector === matches ? [submit] : []) };
|
||||
g.window = {};
|
||||
try {
|
||||
__testing__.extractors.length = 0;
|
||||
__testing__.runtime.extract((state) => {
|
||||
const ax = (state as { ax: { find(s: unknown): Record<string, unknown> | undefined } }).ax;
|
||||
return ax.find({ testTag: "TxnSubmit" });
|
||||
});
|
||||
__testing__.runtime.extract((state) => {
|
||||
const ax = (state as { ax: { findAll(s: unknown): Record<string, unknown>[] } }).ax;
|
||||
return ax.findAll({ testTag: "TxnSubmit" });
|
||||
});
|
||||
const values = __testing__.evaluateExtractors();
|
||||
const found = readingOf(values, 0) as Record<string, unknown>;
|
||||
assert.equal(found.__sanderlingSelector, "testTag:TxnSubmit");
|
||||
// findAll passes each element through map(); passing the callback by
|
||||
// reference would hand the array INDEX to the runtime as the selector.
|
||||
const all = readingOf(values, 1) as Record<string, unknown>[];
|
||||
assert.equal(all[0]!.__sanderlingSelector, "testTag:TxnSubmit");
|
||||
} finally {
|
||||
g.document = originalDocument;
|
||||
g.window = originalWindow;
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user