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
both sides independently fixed the same three bugs, so each one had to pick a winner rather than keep both implementations. extractor encoding: master's recordableValue in worker.go wins over ours in marshal.go, since master's is pinned by extractor_encoding_test.go and ours had no tests. our error semantics stay: encodeExtractorValue still returns an error instead of nil, so an extractor cannot vanish from the trace silently. apply errors: only the residual generic branch takes master's unconfirmed copy, where the device may have committed the action before the call failed. the finer branches that know nothing was dispatched keep lastAction = nil, and our actionSkipReason taxonomy stays alongside master's held/skippedVerification. selector matching: our matchAttr with matchSelectorKind wins over master's match, since ours also handles idPrefix. matchSelector now calls it, which git did not flag as a conflict and left calling a function our side had deleted. the ltl doc comment takes master's correction: an unbounded eventually that never fires IS violated at run end.
This commit is contained in:
commit
6e85cac8b3
130 files changed
+12772
-1617
No files matched your search
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Priyanshu Jain
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+4
-58
@@ -1,67 +1,13 @@
|
||||
# @sanderling/spec
|
||||
|
||||
TypeScript spec API for [sanderling](https://github.com/priyanshujain/sanderling), a property-based UI fuzzer for mobile and web apps.
|
||||
TypeScript spec API for [sanderling](https://github.com/priyanshujain/sanderling), a property-based UI fuzzer for Android, iOS and web apps.
|
||||
|
||||
Spec authors write properties (what the app must always or eventually do), extractors (structured state from the UI), and action generators (what sanderling is allowed to do). The `sanderling` CLI evaluates the spec in a loop against a running app.
|
||||
|
||||
## Install
|
||||
A spec exports properties (what the app must always or eventually do), extractors (structured state read off the UI), and action generators (what sanderling is allowed to do). The `sanderling` CLI evaluates the spec against a running app once per step.
|
||||
|
||||
```sh
|
||||
npm install --save-dev @sanderling/spec
|
||||
```
|
||||
|
||||
## Usage
|
||||
[Getting started](https://priyanshujain.github.io/sanderling/manual/getting-started/) installs the CLI and runs a first spec. The [spec language reference](https://priyanshujain.github.io/sanderling/manual/spec-language/) lists every primitive, and the [case study](https://priyanshujain.github.io/sanderling/manual/case-study/) walks a complete spec end to end.
|
||||
|
||||
```ts
|
||||
import { extract, always, eventually, actions, weighted, taps, swipes, InputText, Tap } from "@sanderling/spec";
|
||||
|
||||
const loggedIn = extract((s) => !!s.ax.find("id:home-tab-bar"));
|
||||
const balance = extract<number>((s) => (s.snapshots.balance as number) ?? 0);
|
||||
const emailField = extract((s) => s.ax.find("id:email-field"));
|
||||
const submitButton = extract((s) => s.ax.find("id:sign-in-button"));
|
||||
|
||||
export const properties = {
|
||||
balanceNeverNegative: always(() => balance.current >= 0),
|
||||
loginSucceeds: eventually(() => loggedIn.current).within(30, "seconds"),
|
||||
};
|
||||
|
||||
const doLogin = actions(() => {
|
||||
if (loggedIn.current) return [];
|
||||
const email = emailField.current;
|
||||
const submit = submitButton.current;
|
||||
if (!email || !submit) return [];
|
||||
return [InputText({ into: email, text: "[email protected]" }), Tap({ on: submit })];
|
||||
});
|
||||
|
||||
export const actionsRoot = weighted(
|
||||
[50, doLogin],
|
||||
[10, taps],
|
||||
[2, swipes],
|
||||
);
|
||||
```
|
||||
|
||||
## Setup actions
|
||||
|
||||
Some action generators are not fuzz targets but preconditions: they drive the
|
||||
app from a fresh state into the surface you actually want to fuzz (login,
|
||||
onboarding, permission grants, seed data). Export them as `setup` instead of
|
||||
mixing them into `actionsRoot`. The runner tries `setup` first; if it yields
|
||||
no action, it falls through to `actionsRoot`. State regressing back across the
|
||||
precondition (e.g. logout under fuzz) automatically re-engages setup.
|
||||
|
||||
```ts
|
||||
const login = actions(() => {
|
||||
if (loggedIn.current) return [];
|
||||
return [InputText({ into: emailField.current!, text: "[email protected]" }), Tap({ on: submitButton.current! })];
|
||||
});
|
||||
|
||||
export const setup = login;
|
||||
export const actionsRoot = weighted([60, browse], [40, edit]);
|
||||
|
||||
(globalThis as { setup?: unknown }).setup = setup;
|
||||
```
|
||||
|
||||
Setup is just an `ActionGenerator`; compose with `actions`, `weighted`, or
|
||||
`whenRoute` exactly like the main pool.
|
||||
|
||||
Works identically across Android, iOS, and web targets.
|
||||
The CLI bundles this package's TypeScript sources at run time, so keep the CLI and the package on the same release.
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"repository": {
|
||||
|
||||
@@ -4,6 +4,7 @@ export type {
|
||||
Action,
|
||||
ActionGenerator,
|
||||
AttrSelector,
|
||||
Direction,
|
||||
DoubleTapAction,
|
||||
EventuallyFormula,
|
||||
ExceptionRecord,
|
||||
@@ -12,11 +13,14 @@ export type {
|
||||
InputTextAction,
|
||||
Key,
|
||||
KnownAttrSelectors,
|
||||
LastAction,
|
||||
LogEntry,
|
||||
LongPressAction,
|
||||
Point,
|
||||
PressKeyAction,
|
||||
RawAttrs,
|
||||
Sampler,
|
||||
ScrollAction,
|
||||
SelectorPath,
|
||||
Snapshots,
|
||||
State,
|
||||
|
||||
+6
-5
@@ -12,11 +12,12 @@ export function next(predicate: () => boolean): Formula {
|
||||
return globalThis.__sanderling__.next(predicate);
|
||||
}
|
||||
|
||||
// An unbounded `eventually` never forces a violation within a finite run.
|
||||
// Prefer `.within(n, unit)` when you want the verifier to fail a property
|
||||
// that stalls. `"steps"` counts observed steps rather than wall-clock time,
|
||||
// which is what keeps the window the same size across runs of different
|
||||
// speeds.
|
||||
// An unbounded `eventually` that never fires is violated when the run ends,
|
||||
// with the reason "eventually never satisfied", so a goal the run does not
|
||||
// reach is a violation every time. `.within(n, unit)` convicts at the step the
|
||||
// window closes instead of at run end. `"steps"` counts observed steps rather
|
||||
// than wall-clock time, which is what keeps the window the same size across
|
||||
// runs of different speeds.
|
||||
export function eventually(predicate: () => boolean): EventuallyFormula {
|
||||
return globalThis.__sanderling__.eventually(predicate);
|
||||
}
|
||||
+20
-1
@@ -115,10 +115,29 @@ export interface ExceptionRecord {
|
||||
unixMillis?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The previous step's action as the runner reports it. `applied` is true when
|
||||
* the runner saw the dispatch succeed and null when the apply call failed with
|
||||
* the gesture possibly already delivered: an RPC deadline can fire after the
|
||||
* tap landed. Null is unknown, not "it did not happen" (`state.lastAction` is
|
||||
* itself null for that), so a property attributing an effect to this action
|
||||
* has to decline unless `applied` is true.
|
||||
*
|
||||
* `relaunched` is true when the runner had to bring the app back to the
|
||||
* foreground after this action, so the previous reading and the current one
|
||||
* straddle a restart. The action itself still happened; what a property cannot
|
||||
* assume across it is that app state ran continuously between the two readings,
|
||||
* and one demanding an effect of this action has to decline. Null is "not
|
||||
* reported", which is weaker than "the app never restarted": a target whose
|
||||
* foreground the runner cannot read never relaunches the app and cannot promise
|
||||
* that either.
|
||||
*/
|
||||
export type LastAction = Action & { applied: true | null; relaunched: true | null };
|
||||
|
||||
export interface State {
|
||||
snapshots: Snapshots;
|
||||
ax: AccessibilityTree;
|
||||
lastAction: Action | null;
|
||||
lastAction: LastAction | null;
|
||||
time: number;
|
||||
logs: readonly LogEntry[];
|
||||
exceptions: readonly ExceptionRecord[];
|
||||
|
||||
@@ -306,13 +306,18 @@ function selectorFromString(selector: string): { css?: string; xpath?: string }
|
||||
// (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.
|
||||
// resolve a testTag. Matches come back in the order expandShadowContent walks
|
||||
// and buildTree (internal/driver/chrome/driver.go) emits: a host, then that
|
||||
// host's shadow content, then the host's light children. Sweeping the light DOM
|
||||
// first and descending afterwards put a shadow-hosted match behind a later
|
||||
// light-DOM one, so find() answered with a different element on each host.
|
||||
// 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);
|
||||
const matched = new Set<Element>(Array.from(scope.querySelectorAll(selector)));
|
||||
for (const element of Array.from(scope.querySelectorAll<HTMLElement>("*"))) {
|
||||
if (matched.has(element)) found.push(element);
|
||||
if (element.shadowRoot) visit(element.shadowRoot);
|
||||
}
|
||||
};
|
||||
@@ -615,6 +620,15 @@ if (typeof globalThis.addEventListener === "function") {
|
||||
// that reads state.lastAction vacuously true on web.
|
||||
let lastAction: unknown = null;
|
||||
|
||||
// logs is what the driver captured between the previous step and this one,
|
||||
// pushed in by the Go runner (via __sanderlingSetLogs__) before each extractor
|
||||
// evaluation, in the shape internal/verifier/marshal.go builds for goja. The
|
||||
// page cannot derive it: console output reaches the runner over CDP and nothing
|
||||
// in the page reads it back. Hardcoding [] here, as this file used to, makes
|
||||
// every spec property that reads state.logs vacuously true on web, the default
|
||||
// noLogcatErrors included, because the page's reading is the one that wins.
|
||||
let logs: unknown[] = [];
|
||||
|
||||
function buildState(): unknown {
|
||||
return {
|
||||
snapshots: {},
|
||||
@@ -623,7 +637,7 @@ function buildState(): unknown {
|
||||
window,
|
||||
lastAction,
|
||||
time: 0,
|
||||
logs: [],
|
||||
logs,
|
||||
exceptions: capturedExceptions.slice(),
|
||||
};
|
||||
}
|
||||
@@ -672,6 +686,11 @@ defineLockedGlobal("__sanderlingSetLastAction__", (value: unknown) => {
|
||||
lastAction = value ?? null;
|
||||
});
|
||||
|
||||
// The host calls this once per step too, alongside __sanderlingSetLastAction__.
|
||||
defineLockedGlobal("__sanderlingSetLogs__", (value: unknown) => {
|
||||
logs = Array.isArray(value) ? value : [];
|
||||
});
|
||||
|
||||
// The host reads the same buffer buildState puts behind state.exceptions, so
|
||||
// the goja-side state.exceptions is the page's list rather than the empty one
|
||||
// it held before, and the trace records an error surface an offline oracle can
|
||||
|
||||
@@ -29,6 +29,12 @@ import {
|
||||
weighted,
|
||||
whenRoute,
|
||||
} from "../src/index.ts";
|
||||
import type {
|
||||
Action,
|
||||
Direction,
|
||||
LongPressAction,
|
||||
ScrollAction,
|
||||
} from "../src/index.ts";
|
||||
import { setSamplerRng } from "../src/actions.ts";
|
||||
import { SAMPLER_REFUSAL_NAME, setEnumeratingCandidates } from "../src/sampler-rng.ts";
|
||||
import { Pcg } from "../src/pcg.ts";
|
||||
@@ -446,3 +452,18 @@ test("whenRoute body is skipped for a null route", () => {
|
||||
const node = whenRoute(route, ["home"], () => [Tap({ on: "id:x" })]);
|
||||
assert.deepEqual((node as { generate: () => unknown }).generate(), []);
|
||||
});
|
||||
|
||||
// The package entry is the only module a spec author can import from, so every
|
||||
// member of the exported Action union, and the Direction needed to build a
|
||||
// Scroll, has to be reachable there rather than only from src/types.ts.
|
||||
test("index exports every action type a spec author annotates with", () => {
|
||||
const direction: Direction = "down";
|
||||
const scroll: ScrollAction = Scroll({ direction, in: "id:list" });
|
||||
const longPress: LongPressAction = LongPress({ on: "id:row" });
|
||||
const built: Action[] = [scroll, longPress];
|
||||
|
||||
assert.deepEqual(
|
||||
built.map(action => action.kind),
|
||||
["Scroll", "LongPress"],
|
||||
);
|
||||
});
|
||||
@@ -65,6 +65,18 @@ test("name ending in digits does not leak into the balance", () => {
|
||||
assert.equal(balanceOf(card("20", "2024", 3, "-$1,234.56")), -123456);
|
||||
});
|
||||
|
||||
// The limit of a key read off merged text, and the reason homeTxnCountsOf
|
||||
// guards the ambiguity rather than resolving it: two DIFFERENT accounts render
|
||||
// the same card, character for character. Names are unique (Accounts.name is
|
||||
// UNIQUE, checked NOCASE) but the count runs straight into a name that ends in
|
||||
// digits, so nothing computed from this string can say which account it is.
|
||||
test("two accounts can render one card, so no key off it can be injective", () => {
|
||||
const travel1 = card("TR", "Travel1", 25, "$120.00");
|
||||
const travel12 = card("TR", "Travel12", 5, "$120.00");
|
||||
assert.equal(travel1, travel12);
|
||||
assert.equal(cardAccountName({ childText: undefined, cardText: travel1 }), "TRTravel");
|
||||
});
|
||||
|
||||
// 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
|
||||
|
||||
@@ -107,6 +107,7 @@ function run(steps: { route: string | null; cards: CardReading[]; lastAction: un
|
||||
|
||||
const idle = { kind: "Tap", on: "testTag:AccountCard" };
|
||||
const doubleSubmit = { kind: "DoubleTap", on: "testTag:AddTransactionScreen > testTag:TxnSubmit" };
|
||||
const submit = { kind: "Tap", on: "testTag:AddTransactionScreen > testTag:TxnSubmit" };
|
||||
|
||||
test("an un-laid-out Home no longer kills the counting invariant", () => {
|
||||
const trace = run([
|
||||
@@ -157,3 +158,45 @@ test("an un-laid-out Home does not close the counting window", () => {
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// What keeps a healthy submit from ever arriving as a rise nobody paid for. The
|
||||
// reading banked here also resets the submit window, so a Home card list drawn
|
||||
// before the store caught up with a commit would bank stale counts, start the
|
||||
// next window empty, and leave the rise turning up with no budget to cover it.
|
||||
// The app cannot put that frame in front of the spec: submit() pops one entry,
|
||||
// so a commit lands back on the ledger it came from and the first Home reading
|
||||
// is a whole action later, with the submit still in the window when the rise
|
||||
// does show up.
|
||||
test("a submit landing on the ledger is still in the window when Home reads it", () => {
|
||||
const trace = run([
|
||||
{ route: "home", cards: [card("Checking", 0, "3")], lastAction: idle },
|
||||
{ route: "ledger", cards: [], lastAction: submit },
|
||||
{ route: "home", cards: [card("Checking", 5000, "4")], lastAction: idle },
|
||||
]);
|
||||
assert.equal(trace[2]?.submits, 1);
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: trace[1]?.counts ?? null,
|
||||
countsAfter: trace[2]?.counts ?? null,
|
||||
submitsInWindow: trace[2]?.submits ?? 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("and a double submit down that same path still convicts", () => {
|
||||
const trace = run([
|
||||
{ route: "home", cards: [card("Checking", 0, "3")], lastAction: idle },
|
||||
{ route: "ledger", cards: [], lastAction: doubleSubmit },
|
||||
{ route: "home", cards: [card("Checking", 10000, "5")], lastAction: idle },
|
||||
]);
|
||||
assert.equal(trace[2]?.submits, 1);
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: trace[1]?.counts ?? null,
|
||||
countsAfter: trace[2]?.counts ?? null,
|
||||
submitsInWindow: trace[2]?.submits ?? 0,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,501 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
committedAmountExceedsOneSubmit,
|
||||
committedTransactionsExceedSubmits,
|
||||
countSubmitsInWindow,
|
||||
homeTxnCountsOf,
|
||||
parseAccountBalance,
|
||||
parseTypedAmount,
|
||||
readAccountBalance,
|
||||
readHomeCards,
|
||||
} from "../../../examples/folio/sanderling/predicates.ts";
|
||||
import type {
|
||||
ObservedAction,
|
||||
TxnCount,
|
||||
} from "../../../examples/folio/sanderling/predicates.ts";
|
||||
|
||||
// The per-account balance the ledger and the add-transaction screen both show.
|
||||
// Its window closes on every frame of the transaction flow, where the Home
|
||||
// total's closes only when the walk goes back to Home: the iOS run in #78 went
|
||||
// 117 steps between two Home readings and accumulated 37 submits against a rise
|
||||
// of 15, so the double tap at step 32 sat in a window far too wide to judge.
|
||||
|
||||
const submit: ObservedAction = {
|
||||
kind: "Tap",
|
||||
on: "testTag:AddTransactionScreen > testTag:TxnSubmit",
|
||||
applied: true,
|
||||
};
|
||||
const doubleSubmit: ObservedAction = { ...submit, kind: "DoubleTap" };
|
||||
const openLedger: ObservedAction = {
|
||||
kind: "Tap",
|
||||
on: "testTag:HomeScreen > testTag:AccountCard",
|
||||
applied: true,
|
||||
};
|
||||
const openAddTxn: ObservedAction = {
|
||||
kind: "Tap",
|
||||
on: "testTag:LedgerScreen > testTag:AddTransactionButton",
|
||||
applied: true,
|
||||
};
|
||||
const typeAmount: ObservedAction = {
|
||||
kind: "InputText",
|
||||
on: "testTag:AddTransactionScreen > testTag:TxnAmountField",
|
||||
applied: true,
|
||||
};
|
||||
const goBack: ObservedAction = { kind: "Tap", on: "testTag:BackButton", applied: true };
|
||||
|
||||
test("the ledger writes the balance bare and the add-transaction header labels it", () => {
|
||||
assert.equal(parseAccountBalance("$196.00"), 19600);
|
||||
assert.equal(parseAccountBalance("Balance: $196.00"), 19600);
|
||||
assert.equal(parseAccountBalance("-$1,234.56"), -123456);
|
||||
assert.equal(parseAccountBalance("Balance: -$1,234.56"), -123456);
|
||||
assert.equal(parseAccountBalance("$0.00"), 0);
|
||||
});
|
||||
|
||||
test("a balance that is not a complete amount is unknown, not zero", () => {
|
||||
assert.equal(parseAccountBalance(undefined), null);
|
||||
assert.equal(parseAccountBalance(""), null);
|
||||
assert.equal(parseAccountBalance("Balance:"), null);
|
||||
assert.equal(parseAccountBalance("$1,23.00"), null);
|
||||
});
|
||||
|
||||
// Which account these numbers belong to is never asked, because inside a run of
|
||||
// these two routes it cannot change: Route.Ledger is pushed only by tapping a
|
||||
// card on Home, Route.AddTransaction only by the ledger's own button for its
|
||||
// own account, and an accepted submit pops back to that same ledger. Reaching
|
||||
// another account means passing through Home, so every frame that is not one of
|
||||
// the two routes drops the carrier.
|
||||
test("a frame off the account's own screens drops the carrier", () => {
|
||||
for (const route of ["home", "login", "add-account", null]) {
|
||||
assert.deepEqual(
|
||||
readAccountBalance({ route, balanceText: "$196.00", previousCarrier: 10000 }),
|
||||
{ value: null, carrier: null, fresh: false },
|
||||
`route ${route} kept a carrier that may belong to another account`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("a readable balance on either of the two screens closes the window", () => {
|
||||
assert.deepEqual(
|
||||
readAccountBalance({ route: "ledger", balanceText: "$196.00", previousCarrier: 10000 }),
|
||||
{ value: 19600, carrier: 19600, fresh: true },
|
||||
);
|
||||
assert.deepEqual(
|
||||
readAccountBalance({
|
||||
route: "add-transaction",
|
||||
balanceText: "Balance: $196.00",
|
||||
previousCarrier: 10000,
|
||||
}),
|
||||
{ value: 19600, carrier: 19600, fresh: true },
|
||||
);
|
||||
});
|
||||
|
||||
// The balance node scrolled out of the viewport is unknown, not a new value.
|
||||
// The account still cannot have changed, so the carrier survives and the window
|
||||
// stays open across the frame.
|
||||
test("an unreadable balance keeps the carrier and does not close the window", () => {
|
||||
assert.deepEqual(
|
||||
readAccountBalance({ route: "ledger", balanceText: undefined, previousCarrier: 10000 }),
|
||||
{ value: 10000, carrier: 10000, fresh: false },
|
||||
);
|
||||
});
|
||||
|
||||
test("a double submit moves the account balance by twice what was typed", () => {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: doubleSubmit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: 49200,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("a double-submitted debit is caught by the same bound", () => {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: doubleSubmit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: -29200,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("one submit moving the balance by exactly the typed amount is the app working", () => {
|
||||
for (const after of [29600, -9600]) {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: after,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The frames this bound is actually driven down, replayed off the recorded iOS
|
||||
// run at runs/folio-ios/20260815-102711 (seed 7, 240 steps). It judged 18 of
|
||||
// them and fired on none: every one was a single Tap on TxnSubmit landing back
|
||||
// on the account's own ledger with the balance moved by exactly what was typed,
|
||||
// which is the app working. Three of those readings are below, with the same
|
||||
// frame as it looks when the one action commits twice.
|
||||
//
|
||||
// A double tap is nowhere in that list, and the run took three of them: all
|
||||
// three landed on Home, where this conjunct has no balance to read and
|
||||
// committedTransactionsExceedSubmits convicted instead. What reaches here is
|
||||
// the interleaving where the second commit's pop does not run.
|
||||
test("the ledger landings a real run produces are judged, and a doubled one fires", () => {
|
||||
for (const [prev, typed] of [
|
||||
[357900, 25100],
|
||||
[455800, 7900],
|
||||
[682500, 19300],
|
||||
]) {
|
||||
const judge = (currAccountBalance: number) =>
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: typed!,
|
||||
prevAccountBalance: prev!,
|
||||
currAccountBalance,
|
||||
});
|
||||
assert.equal(judge(prev! + typed!), false, `the recorded ${prev} -> ${prev! + typed!} was convicted`);
|
||||
assert.equal(judge(prev! + 2 * typed!), true, `a second commit on ${prev} went unjudged`);
|
||||
}
|
||||
});
|
||||
|
||||
// A balance that has not moved is a commit still in flight (createTransaction
|
||||
// runs in a coroutine), a submit the app rejected, or a tap that never landed.
|
||||
// None of those is evidence, and an equality would convict all three.
|
||||
test("a balance that has not moved yet is not evidence", () => {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: 10000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a window holding anything other than one submit is not attributable", () => {
|
||||
for (const submitsInWindow of [0, 2, 37]) {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: 49200,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("an amount this reading cannot represent is vacuous, not a violation", () => {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: parseTypedAmount("not an amount"),
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: 49200,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: Number.MAX_SAFE_INTEGER + 2,
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: 49200,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a balance too large to hold exactly is not compared", () => {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: Number.MAX_SAFE_INTEGER + 2,
|
||||
currAccountBalance: 0,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: 0,
|
||||
currAccountBalance: Number.MAX_SAFE_INTEGER + 2,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("an unknown balance on either side is not evidence", () => {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: null,
|
||||
currAccountBalance: 49200,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction: submit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: null,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a step whose action was not a submit attributes nothing", () => {
|
||||
for (const lastAction of [openLedger, openAddTxn, typeAmount, goBack, null]) {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route: "ledger",
|
||||
lastAction,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: 49200,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Home shows every account's money, so it is not this comparison's scale", () => {
|
||||
for (const route of ["home", "login", "add-account", null]) {
|
||||
assert.equal(
|
||||
committedAmountExceedsOneSubmit({
|
||||
route,
|
||||
lastAction: doubleSubmit,
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevAccountBalance: 10000,
|
||||
currAccountBalance: 49200,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// A step of the walk: the frame it landed on, the balance node that frame
|
||||
// carried, the amount field as that frame shows it, and the action that got
|
||||
// there. Driven through the same carrier and window the spec holds, `typed`
|
||||
// included: the spec hands the landing frame's field to countSubmitsInWindow
|
||||
// and the previous frame's to the property, and a walk that skips the first
|
||||
// half drives a composition the spec never runs.
|
||||
interface Frame {
|
||||
route: string | null;
|
||||
balanceText?: string;
|
||||
typed?: string;
|
||||
lastAction: ObservedAction | null;
|
||||
}
|
||||
|
||||
function walk(frames: readonly Frame[]) {
|
||||
let carrier: number | null = null;
|
||||
let submits = 0;
|
||||
const verdicts: { violated: boolean; balance: number | null; submits: number }[] = [];
|
||||
let previous: number | null = null;
|
||||
let typedBefore = "";
|
||||
for (const frame of frames) {
|
||||
const reading = readAccountBalance({
|
||||
route: frame.route,
|
||||
balanceText: frame.balanceText,
|
||||
previousCarrier: carrier,
|
||||
});
|
||||
carrier = reading.carrier;
|
||||
const window = countSubmitsInWindow({
|
||||
previousCount: submits,
|
||||
lastAction: frame.lastAction,
|
||||
amountText: frame.route === "add-transaction" ? (frame.typed ?? "") : undefined,
|
||||
fresh: reading.fresh,
|
||||
});
|
||||
submits = window.next;
|
||||
verdicts.push({
|
||||
violated: committedAmountExceedsOneSubmit({
|
||||
route: frame.route,
|
||||
lastAction: frame.lastAction,
|
||||
submitsInWindow: window.reported,
|
||||
typedAmount: parseTypedAmount(typedBefore),
|
||||
prevAccountBalance: previous,
|
||||
currAccountBalance: reading.value,
|
||||
}),
|
||||
balance: reading.value,
|
||||
submits: window.reported,
|
||||
});
|
||||
previous = reading.value;
|
||||
typedBefore = frame.typed ?? "";
|
||||
}
|
||||
return verdicts;
|
||||
}
|
||||
|
||||
// The trajectory of #78: open an account, open the transaction form, type,
|
||||
// double tap. Not one frame of it is Home, so the Home readings the counting
|
||||
// invariant compares never advance and it has nothing to say about any of it.
|
||||
// This is what a 117-step stretch of that run looked like, and it is why the
|
||||
// double tap at step 32 went unconvicted.
|
||||
test("the Home window cannot judge a walk that never goes Home", () => {
|
||||
const cards = [{ name: "Checking", balance: 10000, count: 3 as TxnCount }];
|
||||
let carrier: Record<string, TxnCount> | null = homeTxnCountsOf(cards);
|
||||
let submits = 0;
|
||||
for (const lastAction of [openLedger, openAddTxn, typeAmount, doubleSubmit]) {
|
||||
const reading = readHomeCards({ route: "ledger", reading: null, previousCarrier: carrier });
|
||||
const previous = carrier;
|
||||
carrier = reading.carrier;
|
||||
const window = countSubmitsInWindow({ previousCount: submits, lastAction, fresh: reading.fresh });
|
||||
submits = window.next;
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: previous,
|
||||
countsAfter: reading.value,
|
||||
submitsInWindow: window.reported,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The trajectory the recorded iOS run took to the frames this property judges,
|
||||
// with the taps that reach TxnSubmit over an empty field: 40 of its 61 submit
|
||||
// taps landed back on the transaction screen, and the field they read is the
|
||||
// one the landing frame shows. Counting those as submits is what the run
|
||||
// measures as the difference between 4 convictions and 0. Here the balance node
|
||||
// is off the viewport while they happen, so nothing resets the window and the
|
||||
// slack survives to the frame that matters.
|
||||
test("submits the app must have refused do not buy a double tap an alibi", () => {
|
||||
const verdicts = walk([
|
||||
{ route: "ledger", balanceText: "$100.00", lastAction: openLedger },
|
||||
{ route: "add-transaction", lastAction: openAddTxn },
|
||||
{ route: "add-transaction", lastAction: submit },
|
||||
{ route: "add-transaction", lastAction: submit },
|
||||
{ route: "add-transaction", typed: "196", lastAction: typeAmount },
|
||||
{ route: "ledger", balanceText: "$492.00", lastAction: doubleSubmit },
|
||||
]);
|
||||
assert.equal(verdicts[5]?.submits, 1);
|
||||
assert.equal(verdicts[5]?.violated, true);
|
||||
});
|
||||
|
||||
// The same trajectory, judged where the app actually is. The double tap sends
|
||||
// two Submit events, and the frame it lands on says which of the two shapes
|
||||
// they took: two commits and two pops reach Home, where the counting invariant
|
||||
// judges them, and two commits with the second pop cancelled by the first stop
|
||||
// on the account's own ledger, which is this one. The recorded iOS run took
|
||||
// three double taps and all three landed on Home, so this frame is reasoned
|
||||
// from the app's code (AddTransactionViewModel.submit commits inside
|
||||
// viewModelScope, then pops) rather than measured.
|
||||
test("the double tap is convicted on the frame it lands on", () => {
|
||||
const verdicts = walk([
|
||||
{ route: "ledger", balanceText: "$100.00", lastAction: openLedger },
|
||||
{ route: "add-transaction", balanceText: "Balance: $100.00", lastAction: openAddTxn },
|
||||
{ route: "add-transaction", balanceText: "Balance: $100.00", typed: "196", lastAction: typeAmount },
|
||||
{ route: "ledger", balanceText: "$492.00", lastAction: doubleSubmit },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
verdicts.map(v => v.violated),
|
||||
[false, false, false, true],
|
||||
);
|
||||
assert.equal(verdicts[3]?.submits, 1);
|
||||
});
|
||||
|
||||
test("the same walk with one transaction committed is silent throughout", () => {
|
||||
const verdicts = walk([
|
||||
{ route: "ledger", balanceText: "$100.00", lastAction: openLedger },
|
||||
{ route: "add-transaction", balanceText: "Balance: $100.00", lastAction: openAddTxn },
|
||||
{ route: "add-transaction", balanceText: "Balance: $100.00", typed: "196", lastAction: typeAmount },
|
||||
{ route: "ledger", balanceText: "$296.00", lastAction: submit },
|
||||
{ route: "add-transaction", balanceText: "Balance: $296.00", lastAction: openAddTxn },
|
||||
{ route: "add-transaction", balanceText: "Balance: $296.00", typed: "50", lastAction: typeAmount },
|
||||
{ route: "ledger", balanceText: "$346.00", lastAction: submit },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
verdicts.map(v => v.violated),
|
||||
[false, false, false, false, false, false, false],
|
||||
);
|
||||
});
|
||||
|
||||
// The reading a healthy app must survive: transactions arriving between two
|
||||
// readings that the window can no longer attribute to one action. The balance
|
||||
// node is off the viewport for a stretch, so the two numbers the property would
|
||||
// compare straddle two commits, and the balance moves by 296.00 against a typed
|
||||
// 50.00. Two submits in the window is not one, so there is nothing to judge.
|
||||
test("transactions arriving between two readings do not convict a healthy app", () => {
|
||||
const verdicts = walk([
|
||||
{ route: "ledger", balanceText: "$100.00", lastAction: openLedger },
|
||||
{ route: "add-transaction", lastAction: openAddTxn },
|
||||
{ route: "add-transaction", typed: "196", lastAction: typeAmount },
|
||||
{ route: "ledger", lastAction: submit },
|
||||
{ route: "add-transaction", lastAction: openAddTxn },
|
||||
{ route: "add-transaction", typed: "100", lastAction: typeAmount },
|
||||
{ route: "ledger", balanceText: "$396.00", lastAction: submit },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
verdicts.map(v => v.violated),
|
||||
[false, false, false, false, false, false, false],
|
||||
);
|
||||
assert.equal(verdicts[6]?.submits, 2);
|
||||
assert.equal(verdicts[6]?.balance, 39600);
|
||||
});
|
||||
|
||||
// Attribution across accounts, which is the whole reason the carrier is dropped
|
||||
// rather than carried. A $500.00 account is left behind for an empty one whose
|
||||
// screens have not drawn their balance yet, and the submit into the new account
|
||||
// lands with exactly one submit in the window: every gate this property has is
|
||||
// open, and only the dropped carrier keeps it quiet. Carrying $500.00 across
|
||||
// that frame reads as 30400 committed against 19600 typed, on an app that did
|
||||
// nothing wrong.
|
||||
test("a ledger opened for another account never inherits the old balance", () => {
|
||||
for (const between of ["home", null]) {
|
||||
const verdicts = walk([
|
||||
{ route: "ledger", balanceText: "$500.00", lastAction: openAddTxn },
|
||||
{ route: between, lastAction: goBack },
|
||||
{ route: "ledger", lastAction: openLedger },
|
||||
{ route: "add-transaction", typed: "196", lastAction: openAddTxn },
|
||||
{ route: "ledger", balanceText: "$196.00", lastAction: submit },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
verdicts.map(v => v.violated),
|
||||
[false, false, false, false, false],
|
||||
`an account switch through ${between} was compared across accounts`,
|
||||
);
|
||||
assert.equal(verdicts[4]?.submits, 1);
|
||||
assert.equal(verdicts[4]?.balance, 19600);
|
||||
}
|
||||
});
|
||||
@@ -1,10 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { createdAccountHasNonZeroBalance } from "../../../examples/folio/sanderling/predicates.ts";
|
||||
import {
|
||||
createdAccountHasNonZeroBalance,
|
||||
initialsOf,
|
||||
} 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 created = {
|
||||
kind: "Tap",
|
||||
on: "testTag:AddAccountScreen > testTag:AddAccountSubmit",
|
||||
applied: true as const,
|
||||
};
|
||||
const idle = { kind: "Tap", on: "testTag:HomeScreen > testTag:AccountCard", applied: true as const };
|
||||
|
||||
const account = (name: string, balance: number | null) => ({ name, balance });
|
||||
|
||||
@@ -27,7 +34,7 @@ test("a double-tapped create is judged the same way", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: { kind: "DoubleTap", on: "id:AddAccountSubmit" },
|
||||
lastAction: { kind: "DoubleTap", on: "id:AddAccountSubmit", applied: true },
|
||||
typedName: "Travel",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Travel", 5000)],
|
||||
@@ -205,9 +212,90 @@ test("the merged web key still matches the name that was typed", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// 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.
|
||||
// The avatar the merged web key opens with, hand-computed off Format.kt rather
|
||||
// than off the mirror, because a mirror checked against itself checks nothing.
|
||||
// A single word gives its first two characters, several give the first letter
|
||||
// of the first and of the last, and an empty name gives "?".
|
||||
test("the initials a merged key opens with are the app's", () => {
|
||||
const named: [string, string][] = [
|
||||
["CH", "Checking"],
|
||||
["SA", "Savings"],
|
||||
["TR", "Travel"],
|
||||
["EF", "Emergency Fund"],
|
||||
["IN", "Investments"],
|
||||
["FU", "Fund"],
|
||||
["T2", "Travel 2024"],
|
||||
["A", "a"],
|
||||
["X9", "x9"],
|
||||
["-1", "-1"],
|
||||
["?", ""],
|
||||
["?", " "],
|
||||
];
|
||||
for (const [initials, name] of named) {
|
||||
assert.equal(initialsOf(name), initials, `initials for ${JSON.stringify(name)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// The attribution used to be a suffix test, and a suffix test hands the verdict
|
||||
// to whichever OTHER account happens to end with the typed name. Home lists
|
||||
// what fits the viewport, so the card that was just created is clipped out of
|
||||
// the reading exactly as easily as any other, and the older account left in it
|
||||
// is then judged for money it has held all along.
|
||||
test("an older account whose name ends with the typed one is not the created one", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: created,
|
||||
typedName: "Fund",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Emergency Fund", 461012300)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("the merged web key is matched whole too, not by its ending", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: created,
|
||||
typedName: "Fund",
|
||||
before: [account("CHChecking", 0)],
|
||||
after: [account("CHChecking", 0), account("EFEmergency Fund", 461012300)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// The card that was actually asked for is still judged, standing next to the
|
||||
// account that merely ends with its name.
|
||||
test("the created card is judged beside an account whose name ends with it", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: created,
|
||||
typedName: "Fund",
|
||||
before: [account("Emergency Fund", 461012300)],
|
||||
after: [account("Emergency Fund", 461012300), account("Fund", 5000)],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: created,
|
||||
typedName: "Fund",
|
||||
before: [account("EFEmergency Fund", 461012300)],
|
||||
after: [account("EFEmergency Fund", 461012300), account("FUFund", 5000)],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// Two cards answering to one typed name leave the appearance unattributable.
|
||||
// Accounts.name is UNIQUE and Repository.createAccount rejects a name already
|
||||
// taken, so the pair is one card the tree exposed twice on a transition frame,
|
||||
// or two names the merged web key cannot tell apart.
|
||||
test("two cards matching the typed name are not attributable to the creation", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
@@ -215,7 +303,7 @@ test("two cards matching the typed name are not attributable to the creation", (
|
||||
lastAction: created,
|
||||
typedName: "Travel",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Travel", 5000), account("MyTravel", 900)],
|
||||
after: [account("Checking", 0), account("Travel", 5000), account("Travel", 900)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
@@ -233,3 +321,54 @@ test("a card that was already there is not a card that was just created", () =>
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// The runner's foreground guard restarted the app after the create. A fresh
|
||||
// launch draws Home from the top, so the visible set is whatever the new layout
|
||||
// fits rather than what was there a step ago, and "appeared in the reading" is
|
||||
// even less like "was created" than usual. The process may also have died
|
||||
// before the write landed, which makes the card that carries the typed name an
|
||||
// older account of that name coming into view.
|
||||
test("a create the runner relaunched across attributes nothing", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: { ...created, relaunched: true },
|
||||
typedName: "Travel",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Travel", 5000)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("no relaunch reported still judges the account that was created", () => {
|
||||
for (const relaunched of [null, undefined]) {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: { ...created, relaunched },
|
||||
typedName: "Travel",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Travel", 5000)],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The apply call failed with the gesture possibly already delivered, so nobody
|
||||
// knows whether that account was created. The card carrying the typed name may
|
||||
// be an older one that scrolled into view, and attributing it to a creation
|
||||
// that may never have happened is a conviction built on a guess.
|
||||
test("a create the runner could not confirm attributes nothing", () => {
|
||||
assert.equal(
|
||||
createdAccountHasNonZeroBalance({
|
||||
route: "home",
|
||||
lastAction: { ...created, applied: null },
|
||||
typedName: "Travel",
|
||||
before: [account("Checking", 0)],
|
||||
after: [account("Checking", 0), account("Travel", 5000)],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -3,16 +3,16 @@ import { test } from "node:test";
|
||||
|
||||
import {
|
||||
parseTypedAmount,
|
||||
submitChangesBalanceByTypedAmount,
|
||||
submitChangesBalanceByAtMostTypedAmount,
|
||||
} from "../../../examples/folio/sanderling/predicates.ts";
|
||||
|
||||
const submitOn = "testTag:LedgerScreen > testTag:TxnSubmit";
|
||||
|
||||
test("single submit: delta matches typed amount", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 1000,
|
||||
@@ -24,9 +24,9 @@ test("single submit: delta matches typed amount", () => {
|
||||
|
||||
test("double submit: delta is twice the typed amount, fires", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 1000,
|
||||
@@ -38,9 +38,9 @@ test("double submit: delta is twice the typed amount, fires", () => {
|
||||
|
||||
test("DoubleTap kind also caught when delta exceeds typed amount", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "DoubleTap", on: submitOn },
|
||||
lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 0,
|
||||
@@ -52,9 +52,9 @@ test("DoubleTap kind also caught when delta exceeds typed amount", () => {
|
||||
|
||||
test("wrong action kind: vacuous true even with mismatch", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "InputText", on: submitOn },
|
||||
lastAction: { kind: "InputText", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 1000,
|
||||
@@ -66,9 +66,9 @@ test("wrong action kind: vacuous true even with mismatch", () => {
|
||||
|
||||
test("wrong target: vacuous true even with mismatch", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: "testTag:LoginScreen > testTag:LoginSubmit" },
|
||||
lastAction: { kind: "Tap", on: "testTag:LoginScreen > testTag:LoginSubmit", applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 1000,
|
||||
@@ -80,7 +80,7 @@ test("wrong target: vacuous true even with mismatch", () => {
|
||||
|
||||
test("null lastAction: vacuous true", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: null,
|
||||
submitsInWindow: 1,
|
||||
@@ -94,9 +94,9 @@ test("null lastAction: vacuous true", () => {
|
||||
|
||||
test("zero typedAmount: vacuous true", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 0,
|
||||
prevTotalBalance: 1000,
|
||||
@@ -108,9 +108,9 @@ test("zero typedAmount: vacuous true", () => {
|
||||
|
||||
test("selector as object: coerced safely and TxnSubmit detected", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: { testTag: "TxnSubmit" } },
|
||||
lastAction: { kind: "Tap", on: { testTag: "TxnSubmit" }, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 0,
|
||||
@@ -122,9 +122,9 @@ test("selector as object: coerced safely and TxnSubmit detected", () => {
|
||||
|
||||
test("selector as object without TxnSubmit: vacuous true", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: { testTag: "LoginSubmit" } },
|
||||
lastAction: { kind: "Tap", on: { testTag: "LoginSubmit" }, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 0,
|
||||
@@ -136,9 +136,9 @@ test("selector as object without TxnSubmit: vacuous true", () => {
|
||||
|
||||
test("raw whole-dollar input: single submit clears", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: parseTypedAmount("50"),
|
||||
prevTotalBalance: 5000,
|
||||
@@ -150,9 +150,9 @@ test("raw whole-dollar input: single submit clears", () => {
|
||||
|
||||
test("raw whole-dollar input: double submit fires", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: parseTypedAmount("50"),
|
||||
prevTotalBalance: 5000,
|
||||
@@ -164,9 +164,9 @@ test("raw whole-dollar input: double submit fires", () => {
|
||||
|
||||
test("decimal input from empty prior balance clears", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: parseTypedAmount("5.50"),
|
||||
prevTotalBalance: 0,
|
||||
@@ -178,9 +178,9 @@ test("decimal input from empty prior balance clears", () => {
|
||||
|
||||
test("DoubleTap kind with raw whole-dollar input fires", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "DoubleTap", on: submitOn },
|
||||
lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: parseTypedAmount("100"),
|
||||
prevTotalBalance: 0,
|
||||
@@ -192,9 +192,9 @@ test("DoubleTap kind with raw whole-dollar input fires", () => {
|
||||
|
||||
test("route gate: ledger landing with stale carrier is skipped", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "ledger",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 5000,
|
||||
prevTotalBalance: 0,
|
||||
@@ -206,9 +206,9 @@ test("route gate: ledger landing with stale carrier is skipped", () => {
|
||||
|
||||
test("route gate: add-transaction landing with double-submit delta is skipped", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "add-transaction",
|
||||
lastAction: { kind: "DoubleTap", on: submitOn },
|
||||
lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 5000,
|
||||
prevTotalBalance: 0,
|
||||
@@ -220,9 +220,9 @@ test("route gate: add-transaction landing with double-submit delta is skipped",
|
||||
|
||||
test("route gate: null route is skipped", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: null,
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 5000,
|
||||
prevTotalBalance: 0,
|
||||
@@ -234,9 +234,9 @@ test("route gate: null route is skipped", () => {
|
||||
|
||||
test("route gate: home landing with matching delta passes", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 5000,
|
||||
prevTotalBalance: 0,
|
||||
@@ -248,9 +248,9 @@ test("route gate: home landing with matching delta passes", () => {
|
||||
|
||||
test("route gate: home landing with double-insert delta fires", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 5000,
|
||||
prevTotalBalance: 0,
|
||||
@@ -273,9 +273,9 @@ test("above 2^53 the arithmetic itself is wrong, which is why the guard exists",
|
||||
|
||||
test("above 2^53 a healthy single submit is not reported", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 1600,
|
||||
prevTotalBalance: HUGE_BALANCE,
|
||||
@@ -287,9 +287,9 @@ test("above 2^53 a healthy single submit is not reported", () => {
|
||||
|
||||
test("above 2^53 a double-submit delta is not reported either", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 1600,
|
||||
prevTotalBalance: HUGE_BALANCE,
|
||||
@@ -301,9 +301,9 @@ test("above 2^53 a double-submit delta is not reported either", () => {
|
||||
|
||||
test("an unreadable previous balance above 2^53 is not evidence", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 1600,
|
||||
prevTotalBalance: HUGE_BALANCE,
|
||||
@@ -318,9 +318,9 @@ test("an unreadable previous balance above 2^53 is not evidence", () => {
|
||||
// and must not convict on one it cannot hold.
|
||||
test("typed amount above 2^53 is not evidence", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 1e23,
|
||||
prevTotalBalance: 0,
|
||||
@@ -334,9 +334,9 @@ test("typed amount above 2^53 is not evidence", () => {
|
||||
// more is where counting stops being exact.
|
||||
test("boundary: a double submit landing exactly on MAX_SAFE_INTEGER still fires", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 4503599627370495,
|
||||
prevTotalBalance: 0,
|
||||
@@ -348,9 +348,9 @@ test("boundary: a double submit landing exactly on MAX_SAFE_INTEGER still fires"
|
||||
|
||||
test("boundary: a single submit landing exactly on MAX_SAFE_INTEGER passes", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 9007199254740991,
|
||||
prevTotalBalance: 0,
|
||||
@@ -362,9 +362,9 @@ test("boundary: a single submit landing exactly on MAX_SAFE_INTEGER passes", ()
|
||||
|
||||
test("boundary: one cent past MAX_SAFE_INTEGER stops being evidence", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 4503599627370496,
|
||||
prevTotalBalance: 0,
|
||||
@@ -379,9 +379,9 @@ test("boundary: one cent past MAX_SAFE_INTEGER stops being evidence", () => {
|
||||
// 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({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: -9007199254740991,
|
||||
@@ -395,9 +395,9 @@ test("a large but exact difference between safe balances still fires", () => {
|
||||
// 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({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: parseTypedAmount("999999999999999999999"),
|
||||
prevTotalBalance: 220900,
|
||||
@@ -415,9 +415,9 @@ test("21-digit typed amount with an unmoved balance is not a violation", () => {
|
||||
// and an unrelated 26200 credit.
|
||||
test("freshness: two submits in the window is vacuous, not a conviction", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "DoubleTap", on: submitOn },
|
||||
lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
|
||||
submitsInWindow: 2,
|
||||
typedAmount: 19600,
|
||||
prevTotalBalance: 0,
|
||||
@@ -429,9 +429,9 @@ test("freshness: two submits in the window is vacuous, not a conviction", () =>
|
||||
|
||||
test("freshness: two submits cannot convict even on a clean 2x delta", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 2,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 1000,
|
||||
@@ -446,9 +446,9 @@ test("freshness: two submits cannot convict even on a clean 2x delta", () => {
|
||||
// (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({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "DoubleTap", on: submitOn },
|
||||
lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevTotalBalance: 0,
|
||||
@@ -460,9 +460,9 @@ test("freshness boundary: exactly one submit is the window that convicts", () =>
|
||||
|
||||
test("freshness boundary: one submit with a healthy 1x delta still passes", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevTotalBalance: 0,
|
||||
@@ -474,9 +474,9 @@ test("freshness boundary: one submit with a healthy 1x delta still passes", () =
|
||||
|
||||
test("freshness boundary: three submits is vacuous", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 3,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 0,
|
||||
@@ -491,9 +491,9 @@ test("freshness boundary: three submits is vacuous", () => {
|
||||
// submit in it explains no balance move.
|
||||
test("freshness boundary: a window with no submit in it is vacuous", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 0,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 1000,
|
||||
@@ -502,3 +502,124 @@ test("freshness boundary: a window with no submit in it is vacuous", () => {
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// applied: null is the runner saying it dispatched the tap and never learned
|
||||
// whether it landed. Under the bound that buys the app nothing it did not
|
||||
// already have: a submit that committed nothing leaves the balance where it
|
||||
// was, and a balance that has not moved is under any bound. What the window
|
||||
// still promises is that no OTHER submit action could have moved it, because
|
||||
// countSubmitsInWindow counts an unconfirmed tap exactly like a confirmed one.
|
||||
// So a move of twice the typed amount is the same double commit either way.
|
||||
test("a submit the runner could not confirm is still held to the bound", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "DoubleTap", on: submitOn, applied: null },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 1000,
|
||||
currTotalBalance: 2000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// The write finishes before AddTransactionViewModel navigates, but nothing
|
||||
// establishes that Home's total has re-rendered by the time the frame is read:
|
||||
// the store's flow re-emits on its own schedule and the destination composes off
|
||||
// whatever value it has. A total that has not caught up has not moved at all,
|
||||
// and an equality reads that as the app having ignored the amount.
|
||||
test("a commit the Home total has not caught up with is not a violation", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevTotalBalance: 220900,
|
||||
currTotalBalance: 220900,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// What the bound gives up, and it is a real bug class: an app that moves the
|
||||
// balance by LESS than the amount typed. Nothing in this spec judges that any
|
||||
// more. It cannot be told apart from a total that has not caught up, and a check
|
||||
// that fires on both is not evidence about either.
|
||||
test("an under-move is no longer judged, which is the trade", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 19600,
|
||||
prevTotalBalance: 0,
|
||||
currTotalBalance: 10000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// The witness measured on four recorded android runs, all four of which convict
|
||||
// here and nowhere else: the double tap moved the total by 6400 against 3200
|
||||
// typed. The bound has to keep every one of them.
|
||||
test("the measured double submit still fires under the bound", () => {
|
||||
for (const [prev, curr] of [
|
||||
[17952800, 17959200],
|
||||
[19796100, 19802500],
|
||||
[200000032904800, 200000032911200],
|
||||
]) {
|
||||
assert.equal(
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 3200,
|
||||
prevTotalBalance: prev ?? null,
|
||||
currTotalBalance: curr ?? null,
|
||||
}),
|
||||
false,
|
||||
`the double submit at ${prev} -> ${curr} stopped firing`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// relaunched: true is the runner saying its foreground guard restarted the app
|
||||
// after this action, so the two totals being compared were read from two
|
||||
// different processes. SqlLedgerStore starts every one of them on
|
||||
// stateIn(Eagerly, emptyList()) and HomeScreen composes formatCents(total) off
|
||||
// whatever the flow holds, so the restarted app draws $0.00 into TotalBalance
|
||||
// until sqlite answers. That reading is not a total this tap moved, and it is
|
||||
// as far from the last one as the account is rich.
|
||||
test("a total drawn by a restarted process is not compared with the old one", () => {
|
||||
assert.equal(
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true, relaunched: true },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 455800,
|
||||
currTotalBalance: 0,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// The guard must not become a way of switching the property off. No relaunch
|
||||
// reported is the ordinary case, and web and iOS cannot report one at all.
|
||||
test("no relaunch reported still convicts a double submit", () => {
|
||||
for (const relaunched of [null, undefined]) {
|
||||
assert.equal(
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { kind: "DoubleTap", on: submitOn, applied: true, relaunched },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 500,
|
||||
prevTotalBalance: 1000,
|
||||
currTotalBalance: 2000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
committedTransactionsExceedSubmits,
|
||||
countSubmitsInWindow,
|
||||
isTxnSubmitTap,
|
||||
readHomeTotalBalance,
|
||||
@@ -66,6 +67,117 @@ test("a second submit with no Home reading between them counts two", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// The window is a budget: an upper bound on the transactions the interval could
|
||||
// hold. A tap the app's own parser must have refused spends none of it, and on
|
||||
// android it does not even reach the parser, because TxnSubmit is
|
||||
// clickable(enabled = amount.isNotBlank()). Measured over four recorded android
|
||||
// runs, 19, 11, 25 and 25 of 35, 26, 42 and 42 submit taps landed on the
|
||||
// transaction screen with the amount field empty, so more than half the budget
|
||||
// was being spent on taps that cannot commit anything.
|
||||
test("a submit the app must have refused does not spend the window's budget", () => {
|
||||
for (const amountText of ["", " ", "0", "0.00", "00", "5.", "abc"]) {
|
||||
assert.deepEqual(
|
||||
countSubmitsInWindow({
|
||||
previousCount: 0,
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
amountText,
|
||||
fresh: false,
|
||||
}),
|
||||
{ reported: 0, next: 0 },
|
||||
`amount ${JSON.stringify(amountText)} was counted as a possible commit`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Folio caps a transaction at $1,000,000.00 (MAX_TRANSACTION_AMOUNT_CENTS, in
|
||||
// core/data/Repository.kt), and AddTransactionViewModel.submit refuses anything
|
||||
// over it before a coroutine starts. The fuzzer's corpus carries
|
||||
// "999999999999999999999", AMOUNT_REGEX lets it into the field and it reaches
|
||||
// the button, so this is a refusal the window used to pay for.
|
||||
test("an amount over the app's cap cannot commit", () => {
|
||||
for (const amountText of ["1000000.01", "1,000,001", "999999999999999999999"]) {
|
||||
assert.deepEqual(
|
||||
countSubmitsInWindow({
|
||||
previousCount: 0,
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
amountText,
|
||||
fresh: false,
|
||||
}),
|
||||
{ reported: 0, next: 0 },
|
||||
`amount ${JSON.stringify(amountText)} was counted as a possible commit`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The field as the landing frame shows it, which is the form state the tap read:
|
||||
// nothing between the two changes it. Anywhere but the transaction screen there
|
||||
// is no field to read, and unknown has to count. The cap itself is an amount the
|
||||
// app takes, so it counts too.
|
||||
test("an amount that could commit, or that nobody could read, spends the budget", () => {
|
||||
for (const amountText of ["5", "0.01", "1,000", "1000000.00", "999999.99", undefined]) {
|
||||
assert.deepEqual(
|
||||
countSubmitsInWindow({
|
||||
previousCount: 0,
|
||||
lastAction: { kind: "Tap", on: submitOn },
|
||||
amountText,
|
||||
fresh: false,
|
||||
}),
|
||||
{ reported: 1, next: 1 },
|
||||
`amount ${JSON.stringify(amountText)} was dropped from the budget`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The one thing that can put a different form state on screen than the one the
|
||||
// tap read: the runner restarting the app, which the tap survives and the typed
|
||||
// amount does not. The field a fresh process draws is empty whatever was
|
||||
// submitted, so it proves nothing and the submit keeps its place in the budget.
|
||||
test("a submit across a relaunch spends the budget whatever the field shows", () => {
|
||||
assert.deepEqual(
|
||||
countSubmitsInWindow({
|
||||
previousCount: 0,
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: true, relaunched: true },
|
||||
amountText: "",
|
||||
fresh: false,
|
||||
}),
|
||||
{ reported: 1, next: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
// What the budget costs the counting invariant, in the shape of the iOS run in
|
||||
// #78: a stretch of the walk that never went Home, most of it taps on a submit
|
||||
// button with nothing typed into the form, and one double tap that committed
|
||||
// twice. Counting the refused taps hands the app five transactions of slack it
|
||||
// never used, and two rows against six actions is no violation.
|
||||
test("refused submits used to hide a double submit behind their own budget", () => {
|
||||
const frames = [
|
||||
{ amountText: "", lastAction: { kind: "Tap", on: submitOn } },
|
||||
{ amountText: "", lastAction: { kind: "Tap", on: submitOn } },
|
||||
{ amountText: "", lastAction: { kind: "Tap", on: submitOn } },
|
||||
{ amountText: "", lastAction: { kind: "Tap", on: submitOn } },
|
||||
{ amountText: "", lastAction: { kind: "Tap", on: submitOn } },
|
||||
{ amountText: undefined, lastAction: { kind: "DoubleTap", on: submitOn } },
|
||||
];
|
||||
let budget = 0;
|
||||
for (const frame of frames) {
|
||||
budget = countSubmitsInWindow({
|
||||
previousCount: budget,
|
||||
lastAction: frame.lastAction,
|
||||
amountText: frame.amountText,
|
||||
fresh: false,
|
||||
}).next;
|
||||
}
|
||||
assert.equal(budget, 1);
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { Checking: 3 },
|
||||
countsAfter: { Checking: 5 },
|
||||
submitsInWindow: budget,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// 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 }[]) {
|
||||
@@ -149,3 +261,25 @@ test("an unreadable Home does not close the window", () => {
|
||||
assert.equal(trace[2]?.total, null);
|
||||
assert.equal(trace[3]?.submits, 2);
|
||||
});
|
||||
|
||||
// The window is an upper bound on the submits it holds, so a submit whose
|
||||
// dispatch the runner could not confirm belongs in it: the tap may well have
|
||||
// landed, and a bound that leaves it out is one the transaction it committed
|
||||
// exceeds. That is the false conviction, a rise of one against a window of
|
||||
// zero, on the property carrying most of the detection on android.
|
||||
test("a submit the runner could not confirm still counts toward the window", () => {
|
||||
const window = countSubmitsInWindow({
|
||||
previousCount: 0,
|
||||
lastAction: { kind: "Tap", on: submitOn, applied: null },
|
||||
fresh: true,
|
||||
});
|
||||
assert.equal(window.reported, 1);
|
||||
assert.equal(
|
||||
committedTransactionsExceedSubmits({
|
||||
countsBefore: { Travel: 3 },
|
||||
countsAfter: { Travel: 4 },
|
||||
submitsInWindow: window.reported,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
readHomeCards,
|
||||
readHomeTotalBalance,
|
||||
routeOfFrame,
|
||||
submitChangesBalanceByTypedAmount,
|
||||
submitChangesBalanceByAtMostTypedAmount,
|
||||
} from "../../../examples/folio/sanderling/predicates.ts";
|
||||
|
||||
// The spec's own screen table. A frame is the set of markers its accessibility
|
||||
@@ -94,7 +94,7 @@ test("the measured android transition chain no longer convicts at delta 0", () =
|
||||
const step = (
|
||||
tags: string[],
|
||||
totalText: string | undefined,
|
||||
lastAction: { kind: string; on: string } | null,
|
||||
lastAction: { kind: string; on: string; applied: true } | null,
|
||||
) => {
|
||||
const route = routeOfFrame(SCREENS, frame(...tags));
|
||||
const reading = readHomeTotalBalance({ route, totalText, previousCarrier: carrier });
|
||||
@@ -104,8 +104,12 @@ test("the measured android transition chain no longer convicts at delta 0", () =
|
||||
return { route, total: reading.value, submits: window.reported };
|
||||
};
|
||||
|
||||
const back = { kind: "DoubleTap", on: "id:BackButton" };
|
||||
const phantomSubmit = { kind: "Tap", on: "testTag:AddTransactionScreen > testTag:TxnSubmit" };
|
||||
const back = { kind: "DoubleTap", on: "id:BackButton", applied: true as const };
|
||||
const phantomSubmit = {
|
||||
kind: "Tap",
|
||||
on: "testTag:AddTransactionScreen > testTag:TxnSubmit",
|
||||
applied: true as const,
|
||||
};
|
||||
|
||||
const transition = step(["AddTransactionScreen", "HomeScreen"], "$86,911.00", back);
|
||||
assert.equal(transition.route, null);
|
||||
@@ -115,7 +119,7 @@ test("the measured android transition chain no longer convicts at delta 0", () =
|
||||
const landing = step(["HomeScreen"], "$86,911.00", phantomSubmit);
|
||||
assert.equal(landing.submits, 6);
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: landing.route,
|
||||
lastAction: phantomSubmit,
|
||||
submitsInWindow: landing.submits,
|
||||
@@ -127,9 +131,13 @@ test("the measured android transition chain no longer convicts at delta 0", () =
|
||||
);
|
||||
|
||||
// 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.
|
||||
// window of one and a total the transition frame had already banked. It
|
||||
// convicted on a delta of zero, and that shape cannot convict any more even
|
||||
// with the window reset back to one, because the property is a bound rather
|
||||
// than an equality. A balance that did not move is under any typed amount,
|
||||
// whether nothing was submitted or the total has not caught up yet.
|
||||
assert.equal(
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: phantomSubmit,
|
||||
submitsInWindow: 1,
|
||||
@@ -137,6 +145,20 @@ test("the measured android transition chain no longer convicts at delta 0", () =
|
||||
prevTotalBalance: 8691100,
|
||||
currTotalBalance: 8691100,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// The double tap it was always meant to catch is untouched by that: two
|
||||
// 33900 debits against one action still exceed the amount typed for it.
|
||||
assert.equal(
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: "home",
|
||||
lastAction: { ...phantomSubmit, kind: "DoubleTap" },
|
||||
submitsInWindow: 1,
|
||||
typedAmount: 33900,
|
||||
prevTotalBalance: 8691100,
|
||||
currTotalBalance: 8691100 - 67800,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -1,8 +1,21 @@
|
||||
// A minimal stand-in for the DOM surface the web host reads, shared by the web
|
||||
// runtime's own tests and the cross-host eligibility test. The host asks the
|
||||
// document for three things -- every element, the tappable set, the editable set
|
||||
// -- and reads geometry, `disabled` and the scroll extents off each element, so
|
||||
// that is all a fake has to answer.
|
||||
// A small DOM the web runtime can be driven over, shared by the web runtime's
|
||||
// own tests and the cross-host eligibility test.
|
||||
//
|
||||
// It is a fake, but the structure is real: elements nest, a host owns a shadow
|
||||
// root, and querySelectorAll WALKS the tree and stops at a shadow boundary
|
||||
// exactly as the browser's does. That is what makes the shadow descent in
|
||||
// deepQueryAll and expandShadowContent (src/web-runtime.ts) observable here at
|
||||
// all; the previous harness answered three fixed selectors from a flat list, so
|
||||
// deleting either descent changed no test result.
|
||||
//
|
||||
// What it fabricates is layout: getBoundingClientRect, scrollHeight and
|
||||
// clientHeight are handed over from the spec. No headless DOM computes those,
|
||||
// and they are precisely the facts collectTargets reads, so a real DOM
|
||||
// implementation would have to be stubbed for them anyway.
|
||||
//
|
||||
// An unsupported selector throws rather than matching nothing, so a test whose
|
||||
// selector this cannot parse fails loudly instead of quietly asserting over an
|
||||
// empty list.
|
||||
|
||||
import { __testing__ } from "../src/web-runtime.ts";
|
||||
|
||||
@@ -23,23 +36,46 @@ export interface FakeElementSpec {
|
||||
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.
|
||||
// text is what an ax element handle reports as `text`, the same field the
|
||||
// goja host reads off a hierarchy node, so a test can name WHICH of two
|
||||
// same-id elements a lookup resolved to.
|
||||
text?: string;
|
||||
attrs?: Record<string, string>;
|
||||
// clickable/editable place the element in the two fact sets the host queries
|
||||
// by selector. They are answered from these flags rather than by matching
|
||||
// their CSS: the cross-host golden (fixtures/host-parity-golden.json, built
|
||||
// row for row in internal/verifier/host_parity_test.go) pins fact
|
||||
// combinations no CSS can produce, such as an <input> that is editable and
|
||||
// not clickable. A test states the facts there; this harness reports them.
|
||||
clickable?: boolean;
|
||||
editable?: boolean;
|
||||
disabled?: boolean;
|
||||
// overflows makes the element's content taller than its box, which is how the
|
||||
// host decides an element is scrollable.
|
||||
overflows?: boolean;
|
||||
children?: FakeElementSpec[];
|
||||
shadow?: FakeElementSpec[];
|
||||
}
|
||||
|
||||
export interface FakeElement extends FakeElementSpec {
|
||||
export interface FakeRoot {
|
||||
children: FakeElement[];
|
||||
querySelectorAll(selector: string): FakeElement[];
|
||||
}
|
||||
|
||||
export interface FakeElement extends Omit<FakeElementSpec, "children" | "shadow"> {
|
||||
tagName: string;
|
||||
type: string;
|
||||
isContentEditable: boolean;
|
||||
id: string;
|
||||
className: string;
|
||||
textContent: string;
|
||||
dataset: Record<string, string | undefined>;
|
||||
parentElement: FakeElement | null;
|
||||
children: FakeElement[];
|
||||
shadowRoot: FakeRoot | null;
|
||||
getAttribute(name: string): string | null;
|
||||
matches(selector: string): boolean;
|
||||
querySelectorAll(selector: string): FakeElement[];
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
scrollWidth: number;
|
||||
@@ -56,15 +92,30 @@ export interface FakeElement extends FakeElementSpec {
|
||||
|
||||
export function fakeElement(spec: FakeElementSpec): FakeElement {
|
||||
const editable = spec.editable ?? false;
|
||||
return {
|
||||
const attributes: Record<string, string> = { ...spec.attrs };
|
||||
if (spec.id !== undefined) attributes.id = spec.id;
|
||||
if (spec.testid !== undefined) attributes["data-testid"] = spec.testid;
|
||||
if (spec.label !== undefined) attributes["aria-label"] = spec.label;
|
||||
if (spec.alt !== undefined) attributes.alt = spec.alt;
|
||||
if (spec.title !== undefined) attributes.title = spec.title;
|
||||
const element: FakeElement = {
|
||||
...spec,
|
||||
tagName: spec.tag.toUpperCase(),
|
||||
type: spec.tag === "input" ? "text" : "",
|
||||
isContentEditable: editable && spec.tag !== "input" && spec.tag !== "textarea",
|
||||
id: spec.id ?? "",
|
||||
className: attributes.class ?? "",
|
||||
textContent: spec.text ?? "",
|
||||
dataset: { testid: spec.testid },
|
||||
getAttribute: (name: string) =>
|
||||
({ "aria-label": spec.label, alt: spec.alt, title: spec.title })[name] ?? null,
|
||||
parentElement: null,
|
||||
children: (spec.children ?? []).map(fakeElement),
|
||||
shadowRoot: null,
|
||||
getAttribute: (name: string) => attributes[name] ?? null,
|
||||
// elementHandle asks an element about itself rather than sweeping the
|
||||
// document for it, so a fake that only answers querySelectorAll reports
|
||||
// every element as untappable.
|
||||
matches: (selector: string) => matchesQuery(element, selector),
|
||||
querySelectorAll: (selector: string) => queryScope(element, selector),
|
||||
scrollHeight: spec.overflows ? spec.height * 2 : spec.height,
|
||||
clientHeight: spec.height,
|
||||
scrollWidth: spec.width,
|
||||
@@ -78,27 +129,170 @@ export function fakeElement(spec: FakeElementSpec): FakeElement {
|
||||
bottom: spec.y + spec.height,
|
||||
}),
|
||||
};
|
||||
for (const child of element.children) child.parentElement = element;
|
||||
if (spec.shadow) element.shadowRoot = fakeRoot(spec.shadow.map(fakeElement));
|
||||
return element;
|
||||
}
|
||||
|
||||
// withFakeDocument installs a document answering the host's three queries over
|
||||
// `elements`, resets the host's per-tick cache, and restores the real document
|
||||
// afterwards.
|
||||
// A shadow root's children have no parentElement, as in a real DOM, so a
|
||||
// descendant selector cannot reach across the boundary from either side.
|
||||
function fakeRoot(children: FakeElement[]): FakeRoot {
|
||||
const root: FakeRoot = {
|
||||
children,
|
||||
querySelectorAll: (selector: string) => queryScope(root, selector),
|
||||
};
|
||||
return root;
|
||||
}
|
||||
|
||||
function queryScope(scope: { children: FakeElement[] }, selector: string): FakeElement[] {
|
||||
const found: FakeElement[] = [];
|
||||
const walk = (nodes: FakeElement[]): void => {
|
||||
for (const node of nodes) {
|
||||
if (matchesQuery(node, selector)) found.push(node);
|
||||
walk(node.children);
|
||||
}
|
||||
};
|
||||
walk(scope.children);
|
||||
return found;
|
||||
}
|
||||
|
||||
function matchesQuery(element: FakeElement, selector: string): boolean {
|
||||
if (selector === TAPPABLE_SELECTOR) return element.clickable === true;
|
||||
if (selector === EDITABLE_SELECTOR) return element.editable === true;
|
||||
return matchesSelectorList(element, selector);
|
||||
}
|
||||
|
||||
function matchesSelectorList(element: FakeElement, selector: string): boolean {
|
||||
return splitTopLevel(selector, ",").some((complex) => matchesComplex(element, complex));
|
||||
}
|
||||
|
||||
function matchesComplex(element: FakeElement, complex: string): boolean {
|
||||
const compounds = splitTopLevel(complex, " ");
|
||||
const subject = compounds.pop();
|
||||
if (subject === undefined) return false;
|
||||
if (!matchesCompound(element, subject)) return false;
|
||||
let ancestor = element.parentElement;
|
||||
for (const compound of compounds.reverse()) {
|
||||
while (ancestor && !matchesCompound(ancestor, compound)) ancestor = ancestor.parentElement;
|
||||
if (!ancestor) return false;
|
||||
ancestor = ancestor.parentElement;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const TAG_NAME = /^[a-zA-Z][a-zA-Z0-9-]*/;
|
||||
const ATTRIBUTE = /^([a-zA-Z][\w-]*)(?:([~^]?)=(.+))?$/;
|
||||
|
||||
function matchesCompound(element: FakeElement, compound: string): boolean {
|
||||
let rest = compound;
|
||||
while (rest.length > 0) {
|
||||
if (rest.startsWith("*")) {
|
||||
rest = rest.slice(1);
|
||||
continue;
|
||||
}
|
||||
if (rest.startsWith("[")) {
|
||||
const end = closingIndex(rest, "[", "]");
|
||||
if (!matchesAttribute(element, rest.slice(1, end))) return false;
|
||||
rest = rest.slice(end + 1);
|
||||
continue;
|
||||
}
|
||||
if (rest.startsWith(":is(") || rest.startsWith(":not(")) {
|
||||
const end = closingIndex(rest, "(", ")");
|
||||
const inner = rest.slice(rest.indexOf("(") + 1, end);
|
||||
const anyMatched = splitTopLevel(inner, ",").some((part) =>
|
||||
matchesSelectorList(element, part),
|
||||
);
|
||||
if (rest.startsWith(":is(") ? !anyMatched : anyMatched) return false;
|
||||
rest = rest.slice(end + 1);
|
||||
continue;
|
||||
}
|
||||
const tag = TAG_NAME.exec(rest);
|
||||
if (!tag) throw new Error(`web-dom-harness cannot parse selector ${JSON.stringify(compound)}`);
|
||||
if (element.tagName !== tag[0].toUpperCase()) return false;
|
||||
rest = rest.slice(tag[0].length);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function matchesAttribute(element: FakeElement, body: string): boolean {
|
||||
const parsed = ATTRIBUTE.exec(body);
|
||||
if (!parsed) throw new Error(`web-dom-harness cannot parse attribute [${body}]`);
|
||||
const [, name, operator, quoted] = parsed;
|
||||
const actual = element.getAttribute(name!);
|
||||
if (actual === null) return false;
|
||||
if (quoted === undefined) return true;
|
||||
const value = unescapeCss(quoted.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1"));
|
||||
if (operator === "~") return actual.split(/\s+/).includes(value);
|
||||
if (operator === "^") return actual.startsWith(value);
|
||||
return actual === value;
|
||||
}
|
||||
|
||||
// Selector values reach the harness escaped by CSS.escape, so `[id="1a"]`
|
||||
// arrives as `[id="\31 a"]` and comparing it raw would never match.
|
||||
function unescapeCss(value: string): string {
|
||||
return value.replace(/\\([0-9a-fA-F]{1,6}) ?|\\(.)/g, (_, hex: string, literal: string) =>
|
||||
hex ? String.fromCodePoint(parseInt(hex, 16)) : literal,
|
||||
);
|
||||
}
|
||||
|
||||
function closingIndex(input: string, open: string, close: string): number {
|
||||
let depth = 0;
|
||||
let quote = "";
|
||||
for (let index = input.indexOf(open); index < input.length; index++) {
|
||||
const character = input[index]!;
|
||||
if (quote) {
|
||||
if (character === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (character === '"' || character === "'") quote = character;
|
||||
else if (character === open) depth++;
|
||||
else if (character === close && --depth === 0) return index;
|
||||
}
|
||||
throw new Error(`web-dom-harness cannot parse selector ${JSON.stringify(input)}`);
|
||||
}
|
||||
|
||||
function splitTopLevel(input: string, separator: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
let depth = 0;
|
||||
let quote = "";
|
||||
for (const character of input) {
|
||||
if (quote) {
|
||||
current += character;
|
||||
if (character === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (character === '"' || character === "'") quote = character;
|
||||
else if (character === "(" || character === "[") depth++;
|
||||
else if (character === ")" || character === "]") depth--;
|
||||
else if (depth === 0 && (character === separator || (separator === " " && /\s/.test(character)))) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += character;
|
||||
}
|
||||
parts.push(current);
|
||||
return parts.map((part) => part.trim()).filter((part) => part.length > 0);
|
||||
}
|
||||
|
||||
// withFakeDocument installs a document whose top-level children are `elements`,
|
||||
// resets the host's per-tick cache, and restores the real globals afterwards.
|
||||
// window goes in alongside document because buildState reads both, so an
|
||||
// extractor reaching state.ax needs it.
|
||||
export function withFakeDocument(elements: FakeElement[], run: () => void): void {
|
||||
const global = globalThis as Record<string, unknown>;
|
||||
const original = global.document;
|
||||
const answers: Record<string, FakeElement[]> = {
|
||||
"*": elements,
|
||||
[TAPPABLE_SELECTOR]: elements.filter((element) => element.clickable),
|
||||
[EDITABLE_SELECTOR]: elements.filter((element) => element.editable),
|
||||
};
|
||||
global.document = {
|
||||
querySelectorAll: (selector: string) => answers[selector] ?? [],
|
||||
};
|
||||
const originalDocument = global.document;
|
||||
const originalWindow = global.window;
|
||||
const document: FakeRoot = fakeRoot(elements);
|
||||
global.document = document;
|
||||
global.window = {};
|
||||
__testing__.resetTargetCache();
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
__testing__.resetTargetCache();
|
||||
global.document = original;
|
||||
global.document = originalDocument;
|
||||
global.window = originalWindow;
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,7 @@ test("installRuntime defined the host-invoked globals", () => {
|
||||
});
|
||||
|
||||
const { fakeElement, withFakeDocument } = await import("./web-dom-harness.ts");
|
||||
type FakeElementSpec = Parameters<typeof fakeElement>[0];
|
||||
|
||||
// The host reports facts and never routes verbs: which of these a verb may act
|
||||
// on is decided by the shared rule in src/targets.ts, exercised across both
|
||||
@@ -175,6 +176,55 @@ test("queryTargets leaves duplicated identities unnamed", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The enumeration ORDER is the parity contract. buildTree in
|
||||
// internal/driver/chrome/driver.go emits a host's shadow children before its
|
||||
// light ones, and TestHierarchy_DerivesTheSameFactsAsTheWebRuntime compares the
|
||||
// two enumerations position by position.
|
||||
test("queryTargets splices shadow content in before the host's light children", () => {
|
||||
const page = fakeElement({
|
||||
tag: "div", x: 0, y: 0, width: 400, height: 800, id: "page",
|
||||
children: [
|
||||
{
|
||||
tag: "div", x: 0, y: 0, width: 400, height: 100, id: "mount",
|
||||
shadow: [
|
||||
{ tag: "button", x: 0, y: 0, width: 40, height: 20, id: "shadow-save", clickable: true },
|
||||
],
|
||||
children: [{ tag: "div", x: 0, y: 20, width: 40, height: 20, id: "mount-light-child" }],
|
||||
},
|
||||
{ tag: "div", x: 0, y: 100, width: 400, height: 100, id: "after" },
|
||||
],
|
||||
});
|
||||
withFakeDocument([page], () => {
|
||||
assert.deepEqual(
|
||||
host.queryTargets().map((target) => target.selector),
|
||||
["id:page", "id:mount", "id:shadow-save", "id:mount-light-child", "id:after"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The tappable set is resolved by selector, and querySelectorAll stops dead at
|
||||
// a shadow boundary, so a control inside a shadow root carries the clickable
|
||||
// fact only if the selector sweep descends. A Compose for Web app keeps every
|
||||
// control it has on the far side of one boundary.
|
||||
test("queryTargets reports a shadow-hosted control as clickable", () => {
|
||||
const mount = fakeElement({
|
||||
tag: "div", x: 0, y: 0, width: 400, height: 100, id: "mount",
|
||||
shadow: [
|
||||
{ tag: "button", x: 0, y: 0, width: 40, height: 20, id: "shadow-save", clickable: true },
|
||||
{ tag: "input", x: 0, y: 20, width: 40, height: 20, id: "shadow-amount", editable: true },
|
||||
],
|
||||
});
|
||||
withFakeDocument([mount], () => {
|
||||
const targets = host.queryTargets();
|
||||
assert.deepEqual(
|
||||
targets.map((target) => target.selector),
|
||||
["id:mount", "id:shadow-save", "id:shadow-amount"],
|
||||
);
|
||||
assert.equal(targets[1]!.clickable, true);
|
||||
assert.equal(targets[2]!.editable, true);
|
||||
});
|
||||
});
|
||||
|
||||
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], () => {
|
||||
@@ -288,7 +338,7 @@ test("an extractor that returned undefined keeps its index through JSON", () =>
|
||||
// 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
|
||||
// folio's submitMovesBalanceByAtMostTypedAmount, 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 {
|
||||
@@ -315,6 +365,35 @@ test("state.lastAction is null when the host pushed nothing", () => {
|
||||
assert.equal(lastActionSeenByASpec(null), null);
|
||||
});
|
||||
|
||||
// state.logs is the same kind of hole. Console output reaches the runner over
|
||||
// CDP, so the page cannot read it back, and while the web runtime hardcoded []
|
||||
// there the default noLogcatErrors counted an empty array on every web run: a
|
||||
// page whose console was full of errors went green having checked nothing.
|
||||
function logsSeenByASpec(pushed: unknown): unknown {
|
||||
const setLogs = (globalThis as Record<string, unknown>).__sanderlingSetLogs__ as (
|
||||
value: unknown,
|
||||
) => void;
|
||||
__testing__.extractors.length = 0;
|
||||
__testing__.runtime.extract((state) => (state as { logs: unknown }).logs);
|
||||
let out: Record<number, { value?: unknown }> = {};
|
||||
withState(() => {
|
||||
setLogs(pushed);
|
||||
out = __testing__.evaluateExtractors();
|
||||
});
|
||||
return readingOf(out, 0);
|
||||
}
|
||||
|
||||
test("state.logs carries the entries the host pushed", () => {
|
||||
const entries = [
|
||||
{ unixMillis: 1700000000123, level: "E", tag: "console", message: "boom from the page" },
|
||||
];
|
||||
assert.deepEqual(logsSeenByASpec(entries), entries);
|
||||
});
|
||||
|
||||
test("state.logs is empty when the host pushed no entries", () => {
|
||||
assert.deepEqual(logsSeenByASpec([]), []);
|
||||
});
|
||||
|
||||
// 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;
|
||||
@@ -727,29 +806,23 @@ test("selectorTag renders the selector shapes the goja host renders", () => {
|
||||
// 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,
|
||||
matches: (selector: string) => matchesAnyPart(selector, "div", {}),
|
||||
getBoundingClientRect: () => rect,
|
||||
querySelectorAll: (selector: string) => answers[selector] ?? [],
|
||||
const card = (id: string, y: number): FakeElementSpec => ({
|
||||
tag: "div", x: 0, y, width: 10, height: 10, testid: "AccountCard", text: id,
|
||||
});
|
||||
// The stray card is outside HomeScreen, so a document-wide sweep for the
|
||||
// second segment picks it up and the scoping assertion below fails.
|
||||
const page = fakeElement({
|
||||
tag: "div", x: 0, y: 0, width: 100, height: 100,
|
||||
children: [
|
||||
{
|
||||
tag: "div", x: 0, y: 0, width: 100, height: 50, testid: "HomeScreen",
|
||||
children: [card("first", 0), card("second", 10)],
|
||||
},
|
||||
card("stray", 60),
|
||||
],
|
||||
});
|
||||
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 {
|
||||
withFakeDocument([page], () => {
|
||||
__testing__.extractors.length = 0;
|
||||
__testing__.runtime.extract((state) => {
|
||||
const ax = (state as { ax: { findAll(s: unknown): Record<string, unknown>[] } }).ax;
|
||||
@@ -770,10 +843,7 @@ test("ax.findAll resolves a selector path segment by segment", () => {
|
||||
// Both cards answer to the same path, so neither may carry it: the runner
|
||||
// re-resolves a named target and would send both taps to the first card.
|
||||
assert.deepEqual(readingOf(values, 1), ["", ""]);
|
||||
} finally {
|
||||
g.document = originalDocument;
|
||||
g.window = originalWindow;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// A selector is a name only while ONE element answers to it. The runner prefers
|
||||
@@ -782,33 +852,19 @@ test("ax.findAll resolves a selector path segment by segment", () => {
|
||||
// testTag sends every one of their taps to the first sibling: on folio's Home
|
||||
// screen no account but the first could ever be opened.
|
||||
test("ax.find and ax.findAll label the element with the selector only when it names that element alone", () => {
|
||||
const rect = (top: number) => ({ left: 0, top, right: 10, bottom: top + 10, width: 10, height: 10 });
|
||||
const node = (id: string, top: number) => ({
|
||||
id,
|
||||
tagName: "DIV",
|
||||
className: "",
|
||||
textContent: id,
|
||||
dataset: {},
|
||||
getAttribute: () => null,
|
||||
matches: (selector: string) => matchesAnyPart(selector, "div", {}),
|
||||
getBoundingClientRect: () => rect(top),
|
||||
const sibling = (text: string, y: number): FakeElementSpec => ({
|
||||
tag: "div", x: 0, y, width: 10, height: 10, testid: "AccountCard", text,
|
||||
});
|
||||
const submit = node("TxnSubmit", 0);
|
||||
const cards = [node("Alpha", 20), node("Beta", 40), node("Gamma", 60)];
|
||||
const matches = `:is([data-testid="TxnSubmit"], [id="TxnSubmit"])`;
|
||||
const cardMatches = `:is([data-testid="AccountCard"], [id="AccountCard"])`;
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
const originalDocument = g.document;
|
||||
const originalWindow = g.window;
|
||||
g.document = {
|
||||
querySelectorAll: (selector: string) => {
|
||||
if (selector === matches) return [submit];
|
||||
if (selector === cardMatches) return cards;
|
||||
return [];
|
||||
},
|
||||
};
|
||||
g.window = {};
|
||||
try {
|
||||
const page = fakeElement({
|
||||
tag: "div", x: 0, y: 0, width: 100, height: 100,
|
||||
children: [
|
||||
{ tag: "div", x: 0, y: 0, width: 10, height: 10, id: "TxnSubmit", text: "Submit" },
|
||||
sibling("Alpha", 20),
|
||||
sibling("Beta", 40),
|
||||
sibling("Gamma", 60),
|
||||
],
|
||||
});
|
||||
withFakeDocument([page], () => {
|
||||
__testing__.extractors.length = 0;
|
||||
__testing__.runtime.extract((state) => {
|
||||
const ax = (state as { ax: { find(s: unknown): Record<string, unknown> | undefined } }).ax;
|
||||
@@ -838,10 +894,7 @@ test("ax.find and ax.findAll label the element with the selector only when it na
|
||||
siblings.map((card) => card.y),
|
||||
[25, 45, 65],
|
||||
);
|
||||
} finally {
|
||||
g.document = originalDocument;
|
||||
g.window = originalWindow;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// The same rule for a child lookup, which is the shape a spec reaches a row
|
||||
@@ -849,38 +902,15 @@ test("ax.find and ax.findAll label the element with the selector only when it na
|
||||
// the whole dump, not the parent's subtree, so scoping does not make a shared
|
||||
// name safe.
|
||||
test("element.find and element.findAll label a child only when the selector names it alone", () => {
|
||||
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,
|
||||
matches: (selector: string) => matchesAnyPart(selector, "div", {}),
|
||||
getBoundingClientRect: () => rect,
|
||||
querySelectorAll: (selector: string) => answers[selector] ?? [],
|
||||
const home = fakeElement({
|
||||
tag: "div", x: 0, y: 0, width: 100, height: 100, testid: "HomeScreen",
|
||||
children: [
|
||||
{ tag: "div", x: 0, y: 0, width: 10, height: 10, testid: "AccountCard", text: "first" },
|
||||
{ tag: "div", x: 0, y: 20, width: 10, height: 10, testid: "AccountCard", text: "second" },
|
||||
{ tag: "div", x: 0, y: 40, width: 10, height: 10, testid: "Total", text: "Total" },
|
||||
],
|
||||
});
|
||||
const cardCss = `:is([data-testid="AccountCard"], [id="AccountCard"])`;
|
||||
const totalCss = `:is([data-testid="Total"], [id="Total"])`;
|
||||
const screenCss = `:is([data-testid="HomeScreen"], [id="HomeScreen"])`;
|
||||
const cards = [node("first"), node("second")];
|
||||
const total = node("Total");
|
||||
const home = node("HomeScreen", { [cardCss]: cards, [totalCss]: [total] });
|
||||
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
const originalDocument = g.document;
|
||||
const originalWindow = g.window;
|
||||
g.document = {
|
||||
querySelectorAll: (selector: string) => {
|
||||
if (selector === screenCss) return [home];
|
||||
if (selector === cardCss) return cards;
|
||||
if (selector === totalCss) return [total];
|
||||
return [];
|
||||
},
|
||||
};
|
||||
g.window = {};
|
||||
try {
|
||||
withFakeDocument([home], () => {
|
||||
__testing__.extractors.length = 0;
|
||||
__testing__.runtime.extract((state) => {
|
||||
const ax = (state as {
|
||||
@@ -900,8 +930,63 @@ test("element.find and element.findAll label a child only when the selector name
|
||||
const values = __testing__.evaluateExtractors();
|
||||
assert.deepEqual(readingOf(values, 0), ["", ""]);
|
||||
assert.equal(readingOf(values, 1), "testTag:Total");
|
||||
} finally {
|
||||
g.document = originalDocument;
|
||||
g.window = originalWindow;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// One page, one selector, two hosts. The goja host resolves a selector against
|
||||
// the hierarchy dump, whose buildTree (internal/driver/chrome/driver.go) emits
|
||||
// a host's shadow children BEFORE its light ones, so a pre-order search there
|
||||
// reaches a shadow-hosted match first. deepQueryAll swept the whole light DOM
|
||||
// first and only then descended, so this page answered find({id:"x"}) with the
|
||||
// light node in V8 and the shadow node in goja, and on web V8's answer is the
|
||||
// one that reaches the properties.
|
||||
test("ax.find resolves the shadow-hosted match the hierarchy dump reaches first", () => {
|
||||
const page = fakeElement({
|
||||
tag: "div", x: 0, y: 0, width: 400, height: 800, id: "page",
|
||||
children: [
|
||||
{
|
||||
tag: "div", x: 0, y: 0, width: 400, height: 100, id: "mount",
|
||||
shadow: [{ tag: "span", x: 0, y: 0, width: 40, height: 20, id: "x", text: "shadow" }],
|
||||
},
|
||||
{ tag: "span", x: 0, y: 100, width: 40, height: 20, id: "x", text: "light" },
|
||||
],
|
||||
});
|
||||
withFakeDocument([page], () => {
|
||||
__testing__.extractors.length = 0;
|
||||
__testing__.runtime.extract((state) => {
|
||||
const ax = (state as { ax: { find(s: unknown): Record<string, unknown> | undefined } }).ax;
|
||||
return ax.find("id:x")?.text;
|
||||
});
|
||||
__testing__.runtime.extract((state) => {
|
||||
const ax = (state as { ax: { findAll(s: unknown): Record<string, unknown>[] } }).ax;
|
||||
return ax.findAll("id:x").map((element) => element.text);
|
||||
});
|
||||
const values = __testing__.evaluateExtractors();
|
||||
assert.equal(readingOf(values, 0), "shadow");
|
||||
assert.deepEqual(readingOf(values, 1), ["shadow", "light"]);
|
||||
});
|
||||
});
|
||||
|
||||
// A nested undefined is the one reading shape the two hosts do NOT encode
|
||||
// alike, and this pins the split instead of hiding it. JSON has no undefined,
|
||||
// so the key goes with the value here; goja marshals the same member as null,
|
||||
// and it cannot do otherwise, because an exported goja object reports undefined
|
||||
// and null identically, so dropping those keys there would drop the genuine
|
||||
// nulls this host keeps. Carrying the member across would take a wire format
|
||||
// that can express undefined.
|
||||
//
|
||||
// What both hosts DO agree on is the member's value: reading it answers
|
||||
// undefined either way, and that is the guarantee a property may rely on. Key
|
||||
// presence (`in`, Object.keys) is not.
|
||||
// TestExtractorEncoding_NestedUndefinedIsNotOnTheWire in
|
||||
// internal/verifier/extractor_encoding_test.go pins the other half.
|
||||
test("a nested undefined leaves the page as a dropped key, a nested null does not", () => {
|
||||
__testing__.extractors.length = 0;
|
||||
__testing__.runtime.extract(() => ({ absent: undefined, empty: null, present: 1 }));
|
||||
let wire = "";
|
||||
withState(() => {
|
||||
// Exactly what extractorScript in internal/driver/chrome/driver.go sends.
|
||||
wire = JSON.stringify(__testing__.evaluateExtractors());
|
||||
});
|
||||
assert.equal(wire, `{"0":{"value":{"empty":null,"present":1}}}`);
|
||||
});
|
||||
Reference in new issue
Block a user