mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07: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
+1
-1
@@ -5,7 +5,7 @@
|
||||
# / `release-android-local` / `release-npm-dry` Make targets don't need any
|
||||
# of these. They're snapshot/local-only.
|
||||
#
|
||||
# In CI, these are provided via GitHub Actions secrets (see .github/workflows/release.yml).
|
||||
# In CI, these are provided via GitHub Actions secrets (see .github/workflows/ci.yml).
|
||||
|
||||
# npm automation token (bypasses 2FA).
|
||||
# Create at npmjs.com → Access Tokens → Generate New Token → Automation.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
name: folio app
|
||||
description: Install the toolchain folio needs on one platform, and build the app there.
|
||||
|
||||
inputs:
|
||||
platform:
|
||||
description: android, ios or web
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set up the JDKs
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
# The metro gradle plugin folio builds with needs a 21 runtime; the
|
||||
# sidecar toolchain pins 17. Both are installed so gradle can pick.
|
||||
java-version: |
|
||||
17
|
||||
21
|
||||
|
||||
# The iOS app builds its Kotlin framework through the folio gradle project,
|
||||
# which configures :app:androidApp, so this is needed off Android too.
|
||||
# `make sanderling-android` wants it as well, for the sidecar JAR.
|
||||
- name: Set up Android SDK
|
||||
if: inputs.platform != 'web'
|
||||
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
|
||||
|
||||
- name: Cache Gradle
|
||||
if: inputs.platform != 'ios'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: folio-gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
folio-gradle-${{ runner.os }}-
|
||||
|
||||
# idb-companion is not in homebrew-core, only in facebook/homebrew-fb, so
|
||||
# it has to be named by its full tap path. xcodegen and just are core.
|
||||
- name: Install idb-companion, xcodegen and just
|
||||
if: inputs.platform == 'ios'
|
||||
shell: bash
|
||||
run: brew install facebook/fb/idb-companion xcodegen just
|
||||
|
||||
# Both asset tarballs are built by the prepare scripts, and the runner
|
||||
# bundle is an xcodebuild of companion/Sources. Keyed on the scripts and
|
||||
# the versions the Makefile embeds, so a later run reuses them. This has to
|
||||
# land before `make sanderling-ios`, which is what consumes them.
|
||||
- name: Cache the companion and runner bundles
|
||||
if: inputs.platform == 'ios'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
internal/driver/ioscompanion/companionassets/assets
|
||||
internal/driver/ioscompanion/runnerassets/assets
|
||||
key: ios-assets-${{ runner.os }}-${{ hashFiles('internal/driver/ioscompanion/companionassets/prepare.sh', 'companion/prepare.sh', 'companion/project.yml', 'companion/Sources/**') }}
|
||||
|
||||
# Without this the emulator falls back to software rendering and every
|
||||
# step costs several seconds.
|
||||
- name: Enable KVM
|
||||
if: inputs.platform == 'android'
|
||||
shell: bash
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Build the folio APK
|
||||
if: inputs.platform == 'android'
|
||||
shell: bash
|
||||
working-directory: examples/folio
|
||||
run: ./gradlew :app:androidApp:assembleDebug
|
||||
|
||||
- name: Build the folio wasmJs app
|
||||
if: inputs.platform == 'web'
|
||||
shell: bash
|
||||
working-directory: examples/folio
|
||||
run: ./gradlew :app:webApp:wasmJsBrowserDevelopmentExecutableDistribution
|
||||
@@ -0,0 +1,30 @@
|
||||
name: headless chrome
|
||||
description: Install Chrome and prove it starts headless before a driver depends on it.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
# stable is setup-chrome v2's own default, spelled out so a new release of
|
||||
# the action cannot move the browser these jobs drive. The alternative it
|
||||
# offers is Chrome for Testing latest, which tracks ahead of the channel
|
||||
# users run.
|
||||
- uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd # v2.1.2
|
||||
with:
|
||||
chrome-version: stable
|
||||
|
||||
# Ubuntu 24.04 (current ubuntu-latest) restricts unprivileged user
|
||||
# namespaces via AppArmor, which stops headless Chrome from starting even
|
||||
# with --no-sandbox: the process launches but never opens its DevTools
|
||||
# socket. Re-enable them so the driver's Chrome can come up.
|
||||
- name: Allow Chrome under unprivileged user namespaces
|
||||
shell: bash
|
||||
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
|
||||
# Fail here with Chrome's own stderr if the browser can't launch, instead
|
||||
# of letting the driver report an opaque DevTools timeout downstream.
|
||||
- name: Verify headless Chrome starts
|
||||
shell: bash
|
||||
run: |
|
||||
chrome --version
|
||||
chrome --headless --no-sandbox --disable-gpu --disable-dev-shm-usage \
|
||||
--dump-dom 'data:text/html,<title>ok</title>'
|
||||
Executable
+373
@@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env bash
|
||||
# Drives folio-run.sh through a stubbed `sanderling` binary and checks the
|
||||
# verdict it reaches from each shape of trace. What is under test is the
|
||||
# classification, not the fuzzer: the stub writes the trace the run would have
|
||||
# written and exits the code the run would have exited.
|
||||
#
|
||||
# folio-run.sh is invoked as `bash -eo pipefail -c <script>`, which is what a
|
||||
# `run:` block does: -e is set on the shell that calls the script, and does not
|
||||
# cross the shebang into it. Running the script itself under -e would kill it at
|
||||
# the first non-zero `sanderling test`, which is the exit code it exists to read.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
script="$here/folio-run.sh"
|
||||
spec="$here/../../examples/folio/sanderling/spec.ts"
|
||||
testdata="$here/testdata"
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
failed=0
|
||||
case_dir=""
|
||||
status=0
|
||||
spec_override=""
|
||||
|
||||
run() { # <case> <platform> <stub exit code> [none] ; trace lines on stdin
|
||||
local name="$1" platform="$2" code="$3" trace_mode="${4:-file}"
|
||||
case_dir="$work/$name"
|
||||
mkdir -p "$case_dir"
|
||||
# the web leg serves this directory and waits for index.html before it runs
|
||||
mkdir -p "$case_dir/examples/folio/app/webApp/build/dist/wasmJs/developmentExecutable"
|
||||
echo ok > "$case_dir/examples/folio/app/webApp/build/dist/wasmJs/developmentExecutable/index.html"
|
||||
cat > "$case_dir/trace.jsonl"
|
||||
printf '#!/usr/bin/env bash\n' > "$case_dir/sanderling"
|
||||
cat >> "$case_dir/sanderling" <<'STUB'
|
||||
printf '%s\n' "$@" > "$STUB_ARGV"
|
||||
out="" ; prev=""
|
||||
for arg in "$@"; do
|
||||
[ "$prev" = "--output" ] && out="$arg"
|
||||
prev="$arg"
|
||||
done
|
||||
if [ "$STUB_TRACE_MODE" = file ]; then
|
||||
mkdir -p "$out/20260815-101500"
|
||||
cp "$STUB_TRACE" "$out/20260815-101500/trace.jsonl"
|
||||
fi
|
||||
exit "$STUB_CODE"
|
||||
STUB
|
||||
chmod +x "$case_dir/sanderling"
|
||||
status=0
|
||||
cd "$case_dir"
|
||||
STUB_ARGV="$case_dir/argv" STUB_TRACE="$case_dir/trace.jsonl" \
|
||||
STUB_TRACE_MODE="$trace_mode" STUB_CODE="$code" \
|
||||
SANDERLING="$case_dir/sanderling" SPEC="${spec_override:-$spec}" \
|
||||
GITHUB_STEP_SUMMARY="$case_dir/summary.md" PORT=8796 \
|
||||
SEED=7 MAX_STEPS=240 DURATION=20m \
|
||||
bash -eo pipefail -c "'$script' '$platform'" \
|
||||
> "$case_dir/out" 2> "$case_dir/err" || status=$?
|
||||
cd "$here"
|
||||
spec_override=""
|
||||
}
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; failed=1; }
|
||||
|
||||
expect_status() { # <want> <case>
|
||||
[ "$status" = "$1" ] || fail "$2: exit $status, want $1"
|
||||
}
|
||||
|
||||
# grep reads both files directly: piping `cat` into `grep -q` returns 141 under
|
||||
# pipefail, because grep leaves on the first match and cat takes the SIGPIPE.
|
||||
expect_says() { # <text> <case>
|
||||
grep -qF -- "$1" "$case_dir/out" "$case_dir/err" || fail "$2: nothing said '$1'"
|
||||
}
|
||||
|
||||
expect_silent() { # <text> <case>
|
||||
if grep -qF -- "$1" "$case_dir/out" "$case_dir/err"; then
|
||||
fail "$2: should not have said '$1'"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_summary() { # <text> <case>
|
||||
grep -qF -- "$1" "$case_dir/summary.md" || fail "$2: summary has no '$1'"
|
||||
}
|
||||
|
||||
expect_argv() { # <text> <case>
|
||||
grep -qxF -- "$1" "$case_dir/argv" || fail "$2: '$1' never reached the binary"
|
||||
}
|
||||
|
||||
# is_error is absent, not false: internal/trace/writer.go tags it omitempty, so a
|
||||
# predicate that simply returned false never writes the key. The real traces
|
||||
# pinned under testdata/ carry exactly this shape.
|
||||
convicting='{"violations":["submitCommitsOneTransactionPerAction"],"witnesses":{"submitCommitsOneTransactionPerAction":{"reason":"predicate false"}}}'
|
||||
unrelated='{"violations":["newAccountBalanceIsZero"],"witnesses":{"newAccountBalanceIsZero":{"reason":"predicate false"}}}'
|
||||
threw='{"violations":["submitMovesBalanceByAtMostTypedAmount"],"witnesses":{"submitMovesBalanceByAtMostTypedAmount":{"is_error":true,"reason":"TypeError: cannot read text of undefined"}}}'
|
||||
# The spec's `route` extractor, which is what the health gate reads. Only the
|
||||
# extractors that changed at a step are recorded, hence the prev/curr shape.
|
||||
on_txn='{"extractor_changes":{"route":{"prev":"ledger","curr":"add-transaction"}},"violations":[]}'
|
||||
off_txn='{"extractor_changes":{"route":{"prev":null,"curr":"home"}},"violations":[]}'
|
||||
on_ledger='{"extractor_changes":{"route":{"prev":"home","curr":"ledger"}},"violations":[]}'
|
||||
|
||||
# --- the flags the calibrated runs were measured with reach the binary --------
|
||||
|
||||
run argv-ios ios 2 <<TRACE
|
||||
$on_txn
|
||||
$convicting
|
||||
TRACE
|
||||
expect_status 0 argv-ios
|
||||
expect_argv "--exit-on-violation" argv-ios
|
||||
expect_argv "--platform" argv-ios
|
||||
expect_argv "ios" argv-ios
|
||||
expect_argv "--seed" argv-ios
|
||||
expect_argv "7" argv-ios
|
||||
expect_argv "240" argv-ios
|
||||
expect_argv "20m" argv-ios
|
||||
expect_argv "iPhone 16 Pro" argv-ios
|
||||
|
||||
run argv-android android 2 <<TRACE
|
||||
$on_txn
|
||||
$convicting
|
||||
TRACE
|
||||
expect_status 0 argv-android
|
||||
expect_argv "--exit-on-violation" argv-android
|
||||
expect_argv "app.folio" argv-android
|
||||
expect_argv "examples/folio/app/androidApp/build/outputs/apk/debug/androidApp-debug.apk" argv-android
|
||||
|
||||
# --- ios and web: a conviction is required -----------------------------------
|
||||
|
||||
run convicted ios 2 <<TRACE
|
||||
$on_txn
|
||||
$convicting
|
||||
TRACE
|
||||
expect_status 0 convicted
|
||||
expect_says "found the submit bug in 2 steps (submitCommitsOneTransactionPerAction)" convicted
|
||||
expect_summary "- convicted on: submitCommitsOneTransactionPerAction" convicted
|
||||
|
||||
# Exit 2 with a violation of a real but ungated property is not this leg's bug.
|
||||
run other-violation ios 2 <<TRACE
|
||||
$on_txn
|
||||
$unrelated
|
||||
TRACE
|
||||
expect_status 1 other-violation
|
||||
expect_says "not the double-submit this leg gates on" other-violation
|
||||
expect_summary "- also violated: newAccountBalanceIsZero" other-violation
|
||||
|
||||
# A predicate that threw reaches exit 2 by the identical path, and is not a
|
||||
# verdict about folio at all.
|
||||
run threw-ios ios 2 <<TRACE
|
||||
$on_txn
|
||||
$threw
|
||||
TRACE
|
||||
expect_status 1 threw-ios
|
||||
expect_says "a predicate threw, so exit 2 is not a verdict about folio" threw-ios
|
||||
expect_says "TypeError: cannot read text of undefined" threw-ios
|
||||
expect_silent "found the submit bug" threw-ios
|
||||
expect_summary "**a predicate threw**" threw-ios
|
||||
|
||||
# The bug is still in folio, so a clean run is the fuzzer no longer reaching it.
|
||||
run clean-ios ios 0 <<TRACE
|
||||
$on_txn
|
||||
$on_txn
|
||||
TRACE
|
||||
expect_status 1 clean-ios
|
||||
expect_says "the double-submit bug was NOT found in 2 steps" clean-ios
|
||||
|
||||
run harness-ios ios 3 <<TRACE
|
||||
$on_txn
|
||||
TRACE
|
||||
expect_status 3 harness-ios
|
||||
expect_says "the harness failed with exit 3" harness-ios
|
||||
|
||||
# --- android: a health gate, with a conviction as a bonus --------------------
|
||||
|
||||
run android-convicted android 2 <<TRACE
|
||||
$on_txn
|
||||
$convicting
|
||||
TRACE
|
||||
expect_status 0 android-convicted
|
||||
expect_says "found the submit bug in 2 steps (a bonus, not required)" android-convicted
|
||||
|
||||
run android-other android 2 <<TRACE
|
||||
$on_txn
|
||||
$unrelated
|
||||
TRACE
|
||||
expect_status 0 android-other
|
||||
expect_says "judging health only" android-other
|
||||
|
||||
run android-healthy android 0 <<TRACE
|
||||
$on_txn
|
||||
$on_txn
|
||||
TRACE
|
||||
expect_status 0 android-healthy
|
||||
expect_says "healthy run over 2 steps, reached the transaction screen" android-healthy
|
||||
|
||||
# Health means it got to the screen the double-submit lives on. Anything less is
|
||||
# a leg that proved nothing, however green the exit code.
|
||||
run android-stalled android 0 <<TRACE
|
||||
$off_txn
|
||||
$on_ledger
|
||||
TRACE
|
||||
expect_status 1 android-stalled
|
||||
expect_says "never reached the add-transaction route over 2 steps" android-stalled
|
||||
expect_says "routes the trace does record: home, ledger" android-stalled
|
||||
|
||||
# The gate used to grep the hierarchy dump for the "AddTransactionScreen"
|
||||
# marker, so a trace carrying that marker without ever reporting the route
|
||||
# passed. It reads the route the spec computed now, and routeOf answers null on
|
||||
# a frame showing two screens, which is exactly when the marker is present and
|
||||
# the app is not on that screen.
|
||||
run android-marker-without-route android 0 <<TRACE
|
||||
$off_txn
|
||||
{"hierarchy":{"resourceIds":["AddTransactionScreen"]},"violations":[]}
|
||||
TRACE
|
||||
expect_status 1 android-marker-without-route
|
||||
expect_says "never reached the add-transaction route over 2 steps" android-marker-without-route
|
||||
expect_says "routes the trace does record: home" android-marker-without-route
|
||||
|
||||
# And the converse: the route alone is enough, with no hierarchy in the trace.
|
||||
run android-route-without-hierarchy android 0 <<TRACE
|
||||
$off_txn
|
||||
$on_txn
|
||||
TRACE
|
||||
expect_status 0 android-route-without-hierarchy
|
||||
expect_says "healthy run over 2 steps, reached the transaction screen" android-route-without-hierarchy
|
||||
|
||||
# The thrown-predicate check has to bite on android too: this is the leg whose
|
||||
# gate is loose enough to swallow it.
|
||||
run threw-android android 2 <<TRACE
|
||||
$on_txn
|
||||
$threw
|
||||
TRACE
|
||||
expect_status 1 threw-android
|
||||
expect_says "a predicate threw" threw-android
|
||||
expect_silent "judging health only" threw-android
|
||||
|
||||
run harness-android android 4 <<TRACE
|
||||
$on_txn
|
||||
TRACE
|
||||
expect_status 4 harness-android
|
||||
expect_says "the harness failed with exit 4" harness-android
|
||||
|
||||
# --- traces that are not there, or are there and say nothing -----------------
|
||||
|
||||
# Exit 2 and no run directory at all: the glob matches nothing, and an empty
|
||||
# trace must not be judged as a folio that behaved.
|
||||
run no-trace ios 2 none </dev/null
|
||||
expect_status 1 no-trace
|
||||
expect_says "wrote no trace" no-trace
|
||||
expect_silent "Traceback" no-trace
|
||||
|
||||
run no-trace-android android 0 none </dev/null
|
||||
expect_status 1 no-trace-android
|
||||
expect_says "wrote no trace" no-trace-android
|
||||
|
||||
# A zero-byte trace: the file is there, so the missing-trace check passes and
|
||||
# the classification has to survive reading nothing out of it.
|
||||
run zero-byte ios 0 file </dev/null
|
||||
expect_status 1 zero-byte
|
||||
expect_says "NOT found in 0 steps" zero-byte
|
||||
expect_silent "Traceback" zero-byte
|
||||
|
||||
# A line the recorder truncated is skipped, not fatal, and the violation on the
|
||||
# readable line is still found.
|
||||
run malformed ios 2 <<TRACE
|
||||
$on_txn
|
||||
{"violations":["submitCommitsOne
|
||||
$convicting
|
||||
TRACE
|
||||
expect_status 0 malformed
|
||||
expect_says "found the submit bug" malformed
|
||||
expect_silent "Traceback" malformed
|
||||
|
||||
run bad-platform windows 0 none </dev/null
|
||||
expect_status 64 bad-platform
|
||||
expect_says "unknown platform: windows" bad-platform
|
||||
|
||||
# --- the gate names still exist in the spec they gate ------------------------
|
||||
|
||||
# Nothing but this check ties GATED_PROPERTIES to the spec. Rename a gated
|
||||
# property and the classification matches nothing: ios and web report a
|
||||
# different bug, android reclassifies a conviction as health and stays green.
|
||||
sed 's/submitCommitsOneTransactionPerAction/submitCommitsOneTxnPerAction/g' \
|
||||
"$spec" > "$work/renamed-spec.ts"
|
||||
spec_override="$work/renamed-spec.ts"
|
||||
run drift-renamed ios 2 <<TRACE
|
||||
$on_txn
|
||||
$convicting
|
||||
TRACE
|
||||
expect_status 1 drift-renamed
|
||||
expect_says "no longer declares submitCommitsOneTransactionPerAction" drift-renamed
|
||||
if [ -e "$case_dir/argv" ]; then
|
||||
fail "drift-renamed: the run started before the gate was checked"
|
||||
fi
|
||||
|
||||
spec_override="$work/absent-spec.ts"
|
||||
run drift-missing ios 2 none </dev/null
|
||||
expect_status 1 drift-missing
|
||||
expect_says "cannot read" drift-missing
|
||||
|
||||
echo "export const notProperties = { a };" > "$work/shapeless-spec.ts"
|
||||
spec_override="$work/shapeless-spec.ts"
|
||||
run drift-shapeless ios 2 none </dev/null
|
||||
expect_status 1 drift-shapeless
|
||||
expect_says "declares no \`export const properties" drift-shapeless
|
||||
|
||||
# The health gate reads two more names out of the spec: the `route` extractor
|
||||
# and the SCREENS key naming the transaction screen. Renaming either would make
|
||||
# every android run report a route it never failed to reach.
|
||||
sed 's/extract<Route | null>("route"/extract<Route | null>("screen"/' \
|
||||
"$spec" > "$work/no-route-spec.ts"
|
||||
spec_override="$work/no-route-spec.ts"
|
||||
run drift-route-extractor android 0 none </dev/null
|
||||
expect_status 1 drift-route-extractor
|
||||
expect_says 'no longer declares extract("route"' drift-route-extractor
|
||||
|
||||
sed 's/"add-transaction": "AddTransactionScreen"/"add-txn": "AddTransactionScreen"/' \
|
||||
"$spec" > "$work/renamed-route-spec.ts"
|
||||
spec_override="$work/renamed-route-spec.ts"
|
||||
run drift-route-key android 0 none </dev/null
|
||||
expect_status 1 drift-route-key
|
||||
expect_says "waits for a route the app never reports" drift-route-key
|
||||
expect_says "add-txn" drift-route-key
|
||||
|
||||
# And the same check against the spec as it stands: this is the assertion that
|
||||
# fails at `make test` when someone renames a property without moving the gate.
|
||||
run gate-matches-spec ios 2 <<TRACE
|
||||
$on_txn
|
||||
$convicting
|
||||
TRACE
|
||||
expect_status 0 gate-matches-spec
|
||||
expect_silent "no longer declares" gate-matches-spec
|
||||
|
||||
# --- real traces, from actions run 31902501859 on this branch's code ---------
|
||||
|
||||
# The cases above are hand-written, so they only prove the classifier agrees
|
||||
# with what this file assumes a trace looks like. These are what the binary
|
||||
# actually wrote. Every step is kept; of each step only step, violations,
|
||||
# witnesses and residuals survive, and hierarchy is replaced by the "...Screen"
|
||||
# resource ids it held, in order, because that dump is 95% of the bytes and the
|
||||
# only place the route gate's grep can match. Running the classifier over the
|
||||
# originals and over these produced byte-identical verdicts.
|
||||
|
||||
run ios-real ios 2 file < "$testdata/folio-ios-convicted.jsonl"
|
||||
expect_status 0 ios-real
|
||||
expect_says "folio/ios: found the submit bug in 49 steps (submitCommitsOneTransactionPerAction)" ios-real
|
||||
expect_summary "- 49 steps recorded, exit 2" ios-real
|
||||
# a real conviction carries no is_error, so reading it as a thrown predicate is
|
||||
# the mistake this asserts against
|
||||
expect_silent "a predicate threw" ios-real
|
||||
|
||||
# Both gated properties fired on the same step, and both are named.
|
||||
run web-real web 2 file < "$testdata/folio-web-convicted.jsonl"
|
||||
expect_status 0 web-real
|
||||
expect_says "folio/web: found the submit bug in 184 steps (submitCommitsOneTransactionPerAction, submitMovesBalanceByAtMostTypedAmount)" web-real
|
||||
expect_summary "- convicted on: submitCommitsOneTransactionPerAction, submitMovesBalanceByAtMostTypedAmount" web-real
|
||||
|
||||
# 200 steps, no violation, and it reached the transaction screen: the health
|
||||
# gate's passing case on real data.
|
||||
run android-real android 0 file < "$testdata/folio-android-healthy.jsonl"
|
||||
expect_status 0 android-real
|
||||
expect_says "folio/android: healthy run over 200 steps, reached the transaction screen" android-real
|
||||
expect_silent "never reached" android-real
|
||||
|
||||
# The same real trace cut to before it first reached the transaction screen.
|
||||
# The route gate reads the accessibility hierarchy, so this is the one case that
|
||||
# proves it reads real hierarchy dumps and not just the shape assumed above.
|
||||
head -10 "$testdata/folio-android-healthy.jsonl" > "$work/android-early.jsonl"
|
||||
run android-real-stalled android 0 file < "$work/android-early.jsonl"
|
||||
expect_status 1 android-real-stalled
|
||||
expect_says "never reached the add-transaction route over 10 steps" android-real-stalled
|
||||
expect_says "routes the trace does record: login, home, add-account, ledger" android-real-stalled
|
||||
|
||||
if [ "$failed" = 0 ]; then
|
||||
echo "folio-run.sh: ok"
|
||||
fi
|
||||
exit "$failed"
|
||||
+103
-21
@@ -18,9 +18,77 @@ max_steps="${MAX_STEPS:-240}"
|
||||
duration="${DURATION:-20m}"
|
||||
sanderling="${SANDERLING:-./bin/sanderling}"
|
||||
output="runs/folio-$platform"
|
||||
spec="examples/folio/sanderling/spec.ts"
|
||||
spec="${SPEC:-examples/folio/sanderling/spec.ts}"
|
||||
summary="${GITHUB_STEP_SUMMARY:-/dev/null}"
|
||||
|
||||
# The two properties that state folio's double-submit. Anything else the spec
|
||||
# proves false is a different finding, and this leg has nothing to say about it.
|
||||
GATED_PROPERTIES="submitMovesBalanceByAtMostTypedAmount,submitCommitsOneTransactionPerAction"
|
||||
|
||||
# The route android's health gate needs the run to have reached. This is a key
|
||||
# of SCREENS in the spec, which is also where the spec's `route` extractor gets
|
||||
# its answer, so the gate and the app agree on what "on that screen" means.
|
||||
TRANSACTION_ROUTE="add-transaction"
|
||||
|
||||
# A gate is only as good as these names, and nothing else ties them to the spec.
|
||||
# Rename a property there and the classification below matches nothing: ios and
|
||||
# web blame the spec for finding a different bug, and android reclassifies a
|
||||
# real conviction as "judging health only" and stays green. Checked before the
|
||||
# run so a rename costs seconds rather than the whole budget.
|
||||
SPEC="$spec" GATED="$GATED_PROPERTIES" ROUTE="$TRANSACTION_ROUTE" SELF="$0" \
|
||||
python3 - <<'PY' || exit 1
|
||||
import os, re, sys
|
||||
|
||||
spec_path = os.environ["SPEC"]
|
||||
gated = [name for name in os.environ["GATED"].split(",") if name]
|
||||
route = os.environ["ROUTE"]
|
||||
try:
|
||||
with open(spec_path, encoding="utf-8") as handle:
|
||||
source = handle.read()
|
||||
except OSError as error:
|
||||
sys.exit("folio: cannot read %s to check the gated properties still exist: %s"
|
||||
% (spec_path, error))
|
||||
|
||||
block = re.search(r"export\s+const\s+properties\s*=\s*\{(.*?)\}", source, re.S)
|
||||
if block is None:
|
||||
sys.exit("folio: %s declares no `export const properties = {...}`, so the gated "
|
||||
"properties cannot be checked against it" % spec_path)
|
||||
|
||||
declared = set()
|
||||
for entry in re.sub(r"//[^\n]*", "", block.group(1)).split(","):
|
||||
name = entry.split(":")[0].strip()
|
||||
if re.fullmatch(r"[A-Za-z_$][A-Za-z0-9_$]*", name):
|
||||
declared.add(name)
|
||||
|
||||
missing = [name for name in gated if name not in declared]
|
||||
if missing:
|
||||
sys.exit("folio: %s no longer declares %s, so this leg gates on a property that "
|
||||
"cannot be violated and every real conviction would read as a different "
|
||||
"finding. Update GATED_PROPERTIES in %s."
|
||||
% (spec_path, ", ".join(missing), os.environ["SELF"]))
|
||||
|
||||
# The android health gate reads the `route` extractor and asks whether it ever
|
||||
# reported TRANSACTION_ROUTE. Both names come from the spec, so both are checked
|
||||
# here: a renamed extractor or a renamed SCREENS key would otherwise make every
|
||||
# android run report a transaction screen it never failed to reach.
|
||||
if not re.search(r'extract\s*(?:<[^>]*>)?\s*\(\s*"route"', source):
|
||||
sys.exit("folio: %s no longer declares extract(\"route\", ...), so the android "
|
||||
"health gate has nothing to read. Update %s."
|
||||
% (spec_path, os.environ["SELF"]))
|
||||
|
||||
screens = re.search(r"const\s+SCREENS\s*=\s*\{(.*?)\}", source, re.S)
|
||||
if screens is None:
|
||||
sys.exit("folio: %s declares no `const SCREENS = {...}`, so the route the android "
|
||||
"health gate wants cannot be checked against it" % spec_path)
|
||||
|
||||
routes = set(re.findall(r'["\']?([A-Za-z0-9_-]+)["\']?\s*:',
|
||||
re.sub(r"//[^\n]*", "", screens.group(1))))
|
||||
if route not in routes:
|
||||
sys.exit("folio: %s SCREENS declares %s, not %r, so the android health gate waits "
|
||||
"for a route the app never reports. Update TRANSACTION_ROUTE in %s."
|
||||
% (spec_path, ", ".join(sorted(routes)), route, os.environ["SELF"]))
|
||||
PY
|
||||
|
||||
folio_args=(--bundle-id app.folio)
|
||||
case "$platform" in
|
||||
android)
|
||||
@@ -28,15 +96,12 @@ case "$platform" in
|
||||
examples/folio/app/androidApp/build/outputs/apk/debug/androidApp-debug.apk)
|
||||
;;
|
||||
ios)
|
||||
# --clear-data=false because the caller has just installed a fresh build (a
|
||||
# freshly installed app IS clear state). The in-run reinstall path is worth
|
||||
# avoiding here: `simctl uninstall` + `install` immediately followed by the
|
||||
# XCTest runner's own launch hits "app.folio is unknown to FrontBoard"
|
||||
# perhaps half the time. The launch RPC is bounded now, so that surfaces
|
||||
# as an error rather than an indefinite hang, but a failed leg is still a
|
||||
# failed leg and a fresh install is already clear state.
|
||||
# Clear state is left at its default, and no --ios-app-path is passed, so
|
||||
# the driver wipes the app's data container rather than reinstalling. That
|
||||
# is what the calibrated numbers were measured from, and it keeps the run
|
||||
# away from the `simctl uninstall` + `install` path that races FrontBoard
|
||||
# ("app.folio is unknown to FrontBoard"), which needs an app path to reach.
|
||||
folio_args+=(--platform ios
|
||||
--clear-data=false
|
||||
--ios-device "${IOS_DEVICE:-iPhone 16 Pro}")
|
||||
;;
|
||||
web)
|
||||
@@ -92,14 +157,14 @@ esac
|
||||
code=$?
|
||||
|
||||
run_dir="$(ls -d "$output"/*/ 2>/dev/null | tail -1)"
|
||||
trace="${run_dir:-.}/trace.jsonl"
|
||||
# No run directory means no trace. Defaulting the directory to `.` here reads a
|
||||
# stray ./trace.jsonl and reports it as this run's evidence, which is how a run
|
||||
# that wrote nothing at all reached "found the submit bug" and exit 0.
|
||||
trace=""
|
||||
[ -n "$run_dir" ] && trace="${run_dir}trace.jsonl"
|
||||
steps=0
|
||||
[ -f "$trace" ] && steps=$(wc -l < "$trace" | tr -d ' ')
|
||||
|
||||
# The two properties that state folio's double-submit. Anything else the spec
|
||||
# proves false is a different finding, and this leg has nothing to say about it.
|
||||
GATED_PROPERTIES="submitMovesBalanceByTypedAmount,submitCommitsOneTransactionPerAction"
|
||||
|
||||
# Exit 2 means "the run recorded a violation", and that is NOT the same as "the
|
||||
# run convicted folio". A predicate that THROWS is recorded as a violation too,
|
||||
# with is_error set and the thrown text as its reason, and it reaches exit 2 by
|
||||
@@ -111,11 +176,14 @@ GATED_PROPERTIES="submitMovesBalanceByTypedAmount,submitCommitsOneTransactionPer
|
||||
# line 1 convictions: a gated property that was proved false
|
||||
# line 2 thrown: a predicate that blew up, with the reason it gave
|
||||
# line 3 other: a real violation of some property this leg does not gate on
|
||||
# line 4 routes: every value the spec's `route` extractor reported, in the
|
||||
# order it first reported them, which is what android's health gate
|
||||
# reads instead of grepping the hierarchy dump for a screen marker
|
||||
classified=$(TRACE="$trace" GATED="$GATED_PROPERTIES" python3 - <<'PY'
|
||||
import json, os
|
||||
|
||||
gated = set(os.environ["GATED"].split(","))
|
||||
convictions, thrown, other = [], [], []
|
||||
convictions, thrown, other, routes = [], [], [], []
|
||||
try:
|
||||
lines = open(os.environ["TRACE"], encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
@@ -125,6 +193,13 @@ for line in lines:
|
||||
step = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
# Only the extractors that changed are recorded, so a route appears at the
|
||||
# step it was first reached and never again until it changes.
|
||||
change = (step.get("extractor_changes") or {}).get("route")
|
||||
if isinstance(change, dict):
|
||||
value = change.get("curr")
|
||||
if isinstance(value, str) and value not in routes:
|
||||
routes.append(value)
|
||||
witnesses = step.get("witnesses") or {}
|
||||
for name in step.get("violations") or []:
|
||||
witness = witnesses.get(name) or {}
|
||||
@@ -135,7 +210,7 @@ for line in lines:
|
||||
convictions.append(name)
|
||||
else:
|
||||
other.append(name)
|
||||
for names in (convictions, thrown, other):
|
||||
for names in (convictions, thrown, other, routes):
|
||||
print(", ".join(names))
|
||||
PY
|
||||
) || {
|
||||
@@ -145,6 +220,7 @@ PY
|
||||
convicted=$(printf '%s\n' "$classified" | sed -n '1p')
|
||||
thrown=$(printf '%s\n' "$classified" | sed -n '2p')
|
||||
other=$(printf '%s\n' "$classified" | sed -n '3p')
|
||||
routes=$(printf '%s\n' "$classified" | sed -n '4p')
|
||||
|
||||
{
|
||||
echo "### folio on $platform"
|
||||
@@ -191,10 +267,16 @@ if [ "$platform" = "android" ]; then
|
||||
;;
|
||||
*) echo "folio/android: the harness failed with exit $code" >&2; exit "$code" ;;
|
||||
esac
|
||||
if ! grep -q '"AddTransactionScreen"' "$trace"; then
|
||||
echo "folio/android: the run never reached AddTransactionScreen, so it never got past login" >&2
|
||||
exit 1
|
||||
fi
|
||||
case ", $routes, " in
|
||||
*", $TRANSACTION_ROUTE, "*) ;;
|
||||
*)
|
||||
# Where it stopped is a much longer question than this gate answers, so
|
||||
# name the routes the trace holds and leave the diagnosis to the reader.
|
||||
echo "folio/android: the run never reached the $TRANSACTION_ROUTE route over $steps steps" >&2
|
||||
echo "folio/android: routes the trace does record: ${routes:-none}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "folio/android: healthy run over $steps steps, reached the transaction screen"
|
||||
exit 0
|
||||
fi
|
||||
@@ -210,7 +292,7 @@ case "$code" in
|
||||
;;
|
||||
0)
|
||||
echo "folio/$platform: the run finished clean; the double-submit bug was NOT found in $steps steps (seed $seed)" >&2
|
||||
echo "folio/$platform: the spec ran without throwing, so this is the fuzzer no longer reaching the bug, not a broken spec" >&2
|
||||
echo "folio/$platform: the spec ran without throwing, so this is the run no longer reaching the bug, not a broken spec" >&2
|
||||
exit 1
|
||||
;;
|
||||
*) echo "folio/$platform: the harness failed with exit $code" >&2; exit "$code" ;;
|
||||
|
||||
Executable
+143
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bash
|
||||
# Drives next-version.sh against repositories whose tags are planted by hand.
|
||||
# Run under the flags GitHub Actions uses for a `run:` block, because that is
|
||||
# where a swallowed failure hides.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
script="$here/next-version.sh"
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
failed=0
|
||||
outputs=""
|
||||
status=0
|
||||
stderr=""
|
||||
|
||||
resolve() { # <case> <bump> <tag>...
|
||||
local name="$1" bump="$2"
|
||||
shift 2
|
||||
local repo="$work/$name"
|
||||
rm -rf "$repo"
|
||||
mkdir -p "$repo"
|
||||
git -C "$repo" init -q
|
||||
git -C "$repo" -c user.email=t@t -c user.name=t commit -q --allow-empty -m base
|
||||
local tag
|
||||
for tag in "$@"; do git -C "$repo" tag "$tag"; done
|
||||
outputs="$work/$name.out"
|
||||
stderr="$work/$name.err"
|
||||
: > "$outputs"
|
||||
status=0
|
||||
(cd "$repo" && BUMP="$bump" GITHUB_OUTPUT="$outputs" \
|
||||
bash -eo pipefail "$script") >/dev/null 2>"$stderr" || status=$?
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
failed=1
|
||||
}
|
||||
|
||||
expect_version() { # <want> <case>
|
||||
local want="version=$1"
|
||||
grep -qxF -- "$want" "$outputs" || fail "$2: resolved $(tr '\n' ' ' <"$outputs"), want $want"
|
||||
grep -qxF -- "tag=v$1" "$outputs" || fail "$2: tag does not match the version it resolved"
|
||||
[ "$status" = 0 ] || fail "$2: exit $status, want 0"
|
||||
}
|
||||
|
||||
|
||||
# How far back GoReleaser reaches for the notes. Empty leaves it on its own
|
||||
# default, which is the release immediately before this one.
|
||||
expect_previous_tag() { # <want, empty for none> <case>
|
||||
grep -qxF -- "previous_tag=$1" "$outputs" \
|
||||
|| fail "$2: $(grep '^previous_tag=' "$outputs" || echo 'no previous_tag'), want previous_tag=$1"
|
||||
}
|
||||
|
||||
expect_refused() { # <case> <message fragment>
|
||||
[ "$status" != 0 ] || fail "$1: exit 0, want a refusal"
|
||||
grep -q -- "$2" "$stderr" || fail "$1: refused with '$(cat "$stderr")', want it to mention '$2'"
|
||||
[ ! -s "$outputs" ] || fail "$1: refused but still wrote an output"
|
||||
}
|
||||
|
||||
# A repository with nothing released yet starts the line at 0.0.1 rather than
|
||||
# reissuing 0.0.0, and has no earlier release to write notes against.
|
||||
resolve first patch
|
||||
expect_version 0.0.1 first
|
||||
expect_previous_tag "" first
|
||||
|
||||
# The rc tags this repository carries are candidates for 0.0.1, so the first
|
||||
# stable release is 0.0.1 and not 0.0.2.
|
||||
resolve rcs patch v0.0.1-rc1 v0.0.1-rc4
|
||||
expect_version 0.0.1 rcs
|
||||
|
||||
resolve patch patch v1.2.3
|
||||
expect_version 1.2.4 patch
|
||||
# A patch already follows the release before it, so GoReleaser is left alone.
|
||||
expect_previous_tag "" patch
|
||||
|
||||
resolve minor minor v1.2.3
|
||||
expect_version 1.3.0 minor
|
||||
|
||||
resolve major major v1.2.3
|
||||
expect_version 2.0.0 major
|
||||
|
||||
# Lexically 0.9.0 sorts above 0.10.0, so a version-blind sort would count the
|
||||
# next patch off the wrong release and hand back 0.9.1.
|
||||
resolve ordering patch v0.9.0 v0.10.0
|
||||
expect_version 0.10.1 ordering
|
||||
|
||||
# A tag that is not a release is not a base to count from.
|
||||
resolve noise patch v1.2.3 nightly v2.0.0-rc1 vfoo
|
||||
expect_version 1.2.4 noise
|
||||
|
||||
# A bump counts off the highest release, so releasing twice in a row advances
|
||||
# twice rather than landing on the tag the first one just cut.
|
||||
resolve consecutive patch v1.2.3 v1.2.4
|
||||
expect_version 1.2.5 consecutive
|
||||
|
||||
resolve bad-bump sideways v1.2.3
|
||||
expect_refused bad-bump "is not a bump"
|
||||
|
||||
# --- how far back a milestone's notes reach ----------------------------------
|
||||
# The whole point of consolidating: 0.2.0's notes have to cover every patch
|
||||
# since 0.1.0, not just the merge that happened to be last before it.
|
||||
resolve minor-notes minor v0.1.0 v0.1.1 v0.1.2
|
||||
expect_version 0.2.0 minor-notes
|
||||
expect_previous_tag v0.1.0 minor-notes
|
||||
|
||||
# The last release at this level, not the first one ever seen at it.
|
||||
resolve minor-notes-latest minor v0.1.0 v0.2.0 v0.2.1
|
||||
expect_version 0.3.0 minor-notes-latest
|
||||
expect_previous_tag v0.2.0 minor-notes-latest
|
||||
|
||||
# A major counts as a milestone for a minor's notes: 1.0.0 is where the patches
|
||||
# being consolidated started.
|
||||
resolve minor-notes-major minor v0.9.0 v1.0.0 v1.0.1
|
||||
expect_version 1.1.0 minor-notes-major
|
||||
expect_previous_tag v1.0.0 minor-notes-major
|
||||
|
||||
# The same version-aware ordering the base needs.
|
||||
resolve minor-notes-ordering minor v0.9.0 v0.10.0 v0.10.1
|
||||
expect_version 0.11.0 minor-notes-ordering
|
||||
expect_previous_tag v0.10.0 minor-notes-ordering
|
||||
|
||||
# A major reaches back to the last major, not to the last minor.
|
||||
resolve major-notes major v1.0.0 v1.1.0 v1.1.3
|
||||
expect_version 2.0.0 major-notes
|
||||
expect_previous_tag v1.0.0 major-notes
|
||||
|
||||
# The first milestone of its kind has nothing at its own level to reach back to,
|
||||
# so it reaches back to the first release there has ever been.
|
||||
resolve minor-notes-firstever minor v0.0.1 v0.0.2 v0.0.3
|
||||
expect_version 0.1.0 minor-notes-firstever
|
||||
expect_previous_tag v0.0.1 minor-notes-firstever
|
||||
|
||||
resolve major-notes-firstever major v0.1.0 v0.2.0 v0.2.1
|
||||
expect_version 1.0.0 major-notes-firstever
|
||||
expect_previous_tag v0.1.0 major-notes-firstever
|
||||
|
||||
if [ "$failed" = 0 ]; then
|
||||
echo "next-version-test: ok"
|
||||
else
|
||||
echo "next-version-test: failures above" >&2
|
||||
exit 1
|
||||
fi
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# Resolves the version a release is cutting. The tags this repo carries are the
|
||||
# record of what has been released, so the version is counted off them and
|
||||
# nothing in the tree holds it: no commit has to land on master to advance a
|
||||
# version, and a release cannot disagree with a package.json someone edited.
|
||||
#
|
||||
# BUMP is major, minor or patch. Writes `version`, `tag` and `previous_tag` to
|
||||
# $GITHUB_OUTPUT when it is set. `previous_tag` is how far back the release
|
||||
# notes should reach.
|
||||
set -euo pipefail
|
||||
|
||||
bump="${BUMP:-patch}"
|
||||
|
||||
# Only a stable tag counts as a release. v0.0.1-rc4 is a candidate for 0.0.1, so
|
||||
# counting a patch off it would skip the very version it was a candidate for.
|
||||
# `sort -V` puts 0.10.0 above 0.9.0, which a lexical sort does not, and
|
||||
# `sed -n p` reports no matches as an empty line rather than as the failure
|
||||
# `grep` would return under pipefail.
|
||||
releases() { # <sed script selecting the tags to consider>
|
||||
git tag -l 'v*' | sed -n "$1" | sort -V
|
||||
}
|
||||
|
||||
stable='s/^v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$/\1/p'
|
||||
base="$(releases "$stable" | tail -1)"
|
||||
base="${base:-0.0.0}"
|
||||
IFS=. read -r major minor patch <<<"$base"
|
||||
|
||||
case "$bump" in
|
||||
major)
|
||||
version="$((major + 1)).0.0"
|
||||
level='s/^v\([0-9][0-9]*\.0\.0\)$/\1/p'
|
||||
;;
|
||||
minor)
|
||||
version="$major.$((minor + 1)).0"
|
||||
level='s/^v\([0-9][0-9]*\.[0-9][0-9]*\.0\)$/\1/p'
|
||||
;;
|
||||
patch)
|
||||
version="$major.$minor.$((patch + 1))"
|
||||
level=""
|
||||
;;
|
||||
*)
|
||||
echo "next-version: '$bump' is not a bump; use major, minor or patch" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# A patch follows the release before it, which is what GoReleaser assumes on its
|
||||
# own, so it is left alone to assume it. A milestone consolidates every patch
|
||||
# since the last release at its own level, and its notes have to reach back that
|
||||
# far or they describe the one merge that happened to be last. With nothing at
|
||||
# that level yet, they reach back to the first release there has ever been.
|
||||
previous_tag=""
|
||||
if [ -n "$level" ]; then
|
||||
previous="$(releases "$level" | tail -1)"
|
||||
previous="${previous:-$(releases "$stable" | head -1)}"
|
||||
if [ -n "$previous" ]; then previous_tag="v$previous"; fi
|
||||
fi
|
||||
|
||||
tag="v$version"
|
||||
|
||||
echo "next-version: releasing $version, a $bump off $base"
|
||||
if [ -n "$previous_tag" ]; then
|
||||
echo "next-version: the notes reach back to $previous_tag"
|
||||
fi
|
||||
|
||||
if [ -n "${GITHUB_OUTPUT:-}" ]; then
|
||||
{
|
||||
echo "version=$version"
|
||||
echo "tag=$tag"
|
||||
echo "previous_tag=$previous_tag"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
# Drives replay-ui-summary.sh over the traces in testdata/ and checks the
|
||||
# summary it renders. Run under the flags GitHub Actions uses for a `run:`
|
||||
# block, because that is where a swallowed failure hides.
|
||||
#
|
||||
# testdata/replay-ui-real-run.jsonl is the first 10 steps of the replay-ui run in
|
||||
# actions run 31873049857 on master, with the per-step `hierarchy` dumps and the
|
||||
# rowElements/tabElements extractors removed so the file stays readable. Nothing
|
||||
# else was touched. That run was green, and badgeCountMatchesThePanel judged
|
||||
# nothing in all 80 of its steps. The other two traces are written by hand.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
script="$here/replay-ui-summary.sh"
|
||||
testdata="$here/testdata"
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
failed=0
|
||||
rendered=""
|
||||
status=0
|
||||
|
||||
summarise() { # <case name> <output dir>
|
||||
rendered="$work/$1.md"
|
||||
: > "$rendered"
|
||||
status=0
|
||||
GITHUB_STEP_SUMMARY="$rendered" SEED=3 MAX_STEPS=80 \
|
||||
bash -eo pipefail "$script" "$2" >/dev/null 2>"$work/$1.err" || status=$?
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
failed=1
|
||||
}
|
||||
|
||||
expect_status() { # <want> <case>
|
||||
[ "$status" = "$1" ] || fail "$2: exit $status, want $1"
|
||||
}
|
||||
|
||||
expect_line() { # <line> <case>
|
||||
grep -qxF -- "$1" "$rendered" || fail "$2: summary has no line '$1'"
|
||||
}
|
||||
|
||||
expect_absent() { # <pattern> <case>
|
||||
if grep -q -- "$1" "$rendered"; then fail "$2: summary should not mention '$1'"; fi
|
||||
}
|
||||
|
||||
plant() { # <case name> <fixture> -> echoes the output dir
|
||||
local dir="$work/$1/runs/20260815-075347"
|
||||
mkdir -p "$dir"
|
||||
cp "$testdata/$2" "$dir/trace.jsonl"
|
||||
echo "$work/$1/runs"
|
||||
}
|
||||
|
||||
# A green run of the real thing. Every count here was measured, not chosen.
|
||||
summarise real "$(plant real replay-ui-real-run.jsonl)"
|
||||
expect_status 0 real
|
||||
expect_line "- 10 steps recorded, 10 verified, 0 with violations" real
|
||||
expect_line "| selectedStepIsInRange | 10 | 0 |" real
|
||||
expect_line "| exactlyOneStepIsSelected | 10 | 0 |" real
|
||||
expect_line "| stepCountMatchesTheList | 10 | 0 |" real
|
||||
expect_line "| screenshotShowsTheSelectedStep | 10 | 0 |" real
|
||||
expect_line "| switchingTabsKeepsTheStep | 2 | 8 |" real
|
||||
expect_line "| badgeCountMatchesThePanel | **0** | 10 |" real
|
||||
expect_line "- \`badgeCountMatchesThePanel\` judged nothing on this run: no step had a violations badge on screen" real
|
||||
expect_absent "checked nothing" real
|
||||
|
||||
# Every property judged at least once, including the one the real run never
|
||||
# reached. Without this the counts above are consistent with a guard that can
|
||||
# only ever return zero.
|
||||
summarise every "$(plant every replay-ui-every-property.jsonl)"
|
||||
expect_status 0 every
|
||||
expect_line "- 4 steps recorded, 3 verified, 0 with violations" every
|
||||
expect_line "| noUncaughtExceptions | 3 | 0 |" every
|
||||
expect_line "| screenshotShowsTheSelectedStep | 3 | 0 |" every
|
||||
expect_line "| switchingTabsKeepsTheStep | 2 | 1 |" every
|
||||
expect_line "| badgeCountMatchesThePanel | 1 | 2 |" every
|
||||
|
||||
# The fuzzer sat on the run list: the step page never rendered, so nothing was
|
||||
# ever compared. This is the run that used to pass.
|
||||
summarise blind "$(plant blind replay-ui-nothing-rendered.jsonl)"
|
||||
expect_status 1 blind
|
||||
expect_line "| selectedStepIsInRange | **0** | 4 |" blind
|
||||
expect_line "| badgeCountMatchesThePanel | **0** | 4 |" blind
|
||||
expect_line "- \`exactlyOneStepIsSelected\` judged nothing on this run: no step had a row in the step list" blind
|
||||
grep -q "this run checked nothing" "$rendered" || fail "blind: no checked-nothing verdict"
|
||||
grep -q "the step page never rendered" "$work/blind.err" || fail "blind: nothing on stderr"
|
||||
|
||||
# The run directory exists but the trace does not, and the glob matches nothing
|
||||
# at all. Both are the harness dying before it checked anything.
|
||||
mkdir -p "$work/empty-run/runs/20260815-075347"
|
||||
summarise empty-run "$work/empty-run/runs"
|
||||
expect_status 1 empty-run
|
||||
grep -q "no trace at" "$rendered" || fail "empty-run: no missing-trace line"
|
||||
|
||||
summarise no-glob "$work/no-glob/runs"
|
||||
expect_status 1 no-glob
|
||||
grep -q "nothing under" "$rendered" || fail "no-glob: no missing-directory line"
|
||||
|
||||
# A property this summary does not know about is a summary that silently counts
|
||||
# six of seven properties, which is the bug one level up.
|
||||
drifted="$(plant drift replay-ui-every-property.jsonl)"
|
||||
sed 's/badgeCountMatchesThePanel/badgeAgreesWithThePanel/g' \
|
||||
"$testdata/replay-ui-every-property.jsonl" > "$drifted/20260815-075347/trace.jsonl"
|
||||
summarise drift "$drifted"
|
||||
expect_status 1 drift
|
||||
grep -q "these counts cannot be trusted" "$rendered" || fail "drift: no untrusted verdict"
|
||||
|
||||
# Same for a reading the spec no longer declares: the count would quietly go to
|
||||
# zero and read as a UI that stopped rendering.
|
||||
sed 's/extract("violationBadges"/extract("violationCounters"/' \
|
||||
"$here/../../replay-ui/sanderling/spec.ts" > "$work/renamed-spec.ts"
|
||||
rendered="$work/renamed.md"
|
||||
: > "$rendered"
|
||||
status=0
|
||||
GITHUB_STEP_SUMMARY="$rendered" SPEC="$work/renamed-spec.ts" \
|
||||
bash -eo pipefail "$script" "$(plant renamed replay-ui-real-run.jsonl)" \
|
||||
>/dev/null 2>&1 || status=$?
|
||||
expect_status 1 renamed
|
||||
grep -q "no longer declares violationBadges" "$rendered" || fail "renamed: no drift verdict"
|
||||
|
||||
if [ "$failed" = 0 ]; then
|
||||
echo "replay-ui-summary.sh: ok"
|
||||
fi
|
||||
exit "$failed"
|
||||
Executable
+222
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reads the replay-ui fuzzing trace and reports, per property, how many steps
|
||||
# that property actually judged. Kept out of the workflow YAML so it can be run
|
||||
# by hand against a local run:
|
||||
#
|
||||
# GITHUB_STEP_SUMMARY=/dev/stdout .github/scripts/replay-ui-summary.sh runs/replay-ui
|
||||
#
|
||||
# `sanderling test` exiting 0 says only that no property returned false. Every
|
||||
# property in replay-ui/sanderling/spec.ts declines to judge when a reading it
|
||||
# needs is absent, which is right individually and useless in aggregate: a run
|
||||
# that never rendered the step page returns false nowhere and exits 0, so
|
||||
# checked-and-clean and checked-nothing arrive at the same green tick. Section 8
|
||||
# of docs/development/design-principles.md is the rule this leg was breaking.
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
output="${1:-runs/replay-ui}"
|
||||
spec="${SPEC:-$root/replay-ui/sanderling/spec.ts}"
|
||||
summary="${GITHUB_STEP_SUMMARY:-/dev/null}"
|
||||
|
||||
shopt -s nullglob
|
||||
run_dirs=("$output"/*/)
|
||||
shopt -u nullglob
|
||||
|
||||
{
|
||||
echo "### the replay ui"
|
||||
echo
|
||||
echo "- seed \`${SEED:-unset}\`, budget ${MAX_STEPS:-unset} steps"
|
||||
} >> "$summary"
|
||||
|
||||
if [ ${#run_dirs[@]} -eq 0 ]; then
|
||||
echo "- **nothing under \`$output/\`**: the run never started, so no property was ever evaluated" >> "$summary"
|
||||
echo "replay-ui: no run directory under $output/, so there is nothing to judge" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
trace="${run_dirs[${#run_dirs[@]} - 1]}trace.jsonl"
|
||||
if [ ! -f "$trace" ]; then
|
||||
echo "- **no trace at \`$trace\`**: the run wrote no steps, so no property was ever evaluated" >> "$summary"
|
||||
echo "replay-ui: $trace does not exist, so there is nothing to judge" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TRACE="$trace" SPEC="$spec" python3 - >> "$summary" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
trace_path = os.environ["TRACE"]
|
||||
spec_path = os.environ["SPEC"]
|
||||
|
||||
|
||||
def toolbar(values):
|
||||
reading = values.get("toolbar")
|
||||
return reading if isinstance(reading, dict) else None
|
||||
|
||||
|
||||
def numbered(reading, key):
|
||||
return reading is not None and reading.get(key) is not None
|
||||
|
||||
|
||||
def listing(values, name):
|
||||
reading = values.get(name)
|
||||
return reading if isinstance(reading, list) else []
|
||||
|
||||
|
||||
# One entry per property in replay-ui/sanderling/spec.ts, holding the guard that
|
||||
# property opens with. The trace records extractor values, not verdicts, so
|
||||
# counting the steps a property really judged means restating its guard here,
|
||||
# and that restatement is the risk this file carries: a guard that drifts from
|
||||
# its property would report evidence that does not exist. The two checks at the
|
||||
# bottom make the two drifts that CAN be caught loud rather than silent.
|
||||
#
|
||||
# noUncaughtExceptions carries no guard on purpose: it compares a count, not an
|
||||
# element, so it judges every step the verifier accepted.
|
||||
GUARDS = {
|
||||
"noUncaughtExceptions": [],
|
||||
"selectedStepIsInRange": [
|
||||
("a toolbar reporting a step and a step count",
|
||||
lambda c, p: numbered(toolbar(c), "step") and numbered(toolbar(c), "stepCount")),
|
||||
],
|
||||
"exactlyOneStepIsSelected": [
|
||||
("a row in the step list", lambda c, p: len(listing(c, "stepRows")) > 0),
|
||||
],
|
||||
"stepCountMatchesTheList": [
|
||||
("a toolbar reporting a step count", lambda c, p: numbered(toolbar(c), "stepCount")),
|
||||
("a row in the step list", lambda c, p: len(listing(c, "stepRows")) > 0),
|
||||
],
|
||||
"screenshotShowsTheSelectedStep": [
|
||||
("a toolbar reporting a step", lambda c, p: numbered(toolbar(c), "step")),
|
||||
("a screenshot in the before panel",
|
||||
lambda c, p: c.get("beforeScreenshotStep") is not None),
|
||||
],
|
||||
"switchingTabsKeepsTheStep": [
|
||||
("a tab selection that changed from the step before",
|
||||
lambda c, p: p is not None and p.get("activeTabs") != c.get("activeTabs")),
|
||||
("a toolbar on both steps",
|
||||
lambda c, p: p is not None and toolbar(p) is not None and toolbar(c) is not None),
|
||||
],
|
||||
"badgeCountMatchesThePanel": [
|
||||
("a violations badge on screen",
|
||||
lambda c, p: len(listing(c, "violationBadges")) > 0
|
||||
and listing(c, "violationBadges")[0] is not None),
|
||||
("a violations panel on screen",
|
||||
lambda c, p: len(listing(c, "violationPanelCounts")) > 0),
|
||||
],
|
||||
}
|
||||
|
||||
# A zero here is the run failing to render, not the fuzzer getting unlucky:
|
||||
# every one of these needs only that the step page came up, which it does on the
|
||||
# first step of any working run. The other three are left to be reported. Two of
|
||||
# them are trajectory-dependent - switchingTabsKeepsTheStep needs a tab switch
|
||||
# between consecutive steps, badgeCountMatchesThePanel needs the fuzzer to land
|
||||
# on a violating step AND open the violations tab there - and a gate that
|
||||
# convicts on an unlucky seed reports a regression it has not found.
|
||||
MUST_RENDER = (
|
||||
"selectedStepIsInRange",
|
||||
"exactlyOneStepIsSelected",
|
||||
"stepCountMatchesTheList",
|
||||
"screenshotShowsTheSelectedStep",
|
||||
)
|
||||
|
||||
steps = 0
|
||||
verified = 0
|
||||
violating = 0
|
||||
malformed = 0
|
||||
property_names = set()
|
||||
judged = {name: 0 for name in GUARDS}
|
||||
met_once = {name: [0] * len(conditions) for name, conditions in GUARDS.items()}
|
||||
|
||||
# The trace records only the extractors that changed at a step, so an
|
||||
# extractor's value at any step is the last change recorded for it, starting
|
||||
# from null. Steps the verifier skipped advance nothing and are not evaluations.
|
||||
values = {}
|
||||
previous = None
|
||||
with open(trace_path, encoding="utf-8", errors="replace") as lines:
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
step = json.loads(line)
|
||||
except ValueError:
|
||||
malformed += 1
|
||||
continue
|
||||
steps += 1
|
||||
property_names |= set((step.get("residuals") or {}).keys())
|
||||
if step.get("violations"):
|
||||
violating += 1
|
||||
if step.get("skipped_verification") or step.get("transitional"):
|
||||
continue
|
||||
for name, change in (step.get("extractor_changes") or {}).items():
|
||||
values[name] = change.get("curr")
|
||||
verified += 1
|
||||
for name, conditions in GUARDS.items():
|
||||
met = [condition(values, previous) for _, condition in conditions]
|
||||
if all(met):
|
||||
judged[name] += 1
|
||||
for index, held in enumerate(met):
|
||||
met_once[name][index] += 1 if held else 0
|
||||
previous = dict(values)
|
||||
|
||||
report = []
|
||||
report.append("- %d steps recorded, %d verified, %d with violations"
|
||||
% (steps, verified, violating))
|
||||
if malformed:
|
||||
report.append("- **%d unreadable line(s)** in %s" % (malformed, trace_path))
|
||||
report.append("")
|
||||
report.append("- judged: the property compared real values. declined: a reading it "
|
||||
"needs was absent, so it returned true without checking anything.")
|
||||
report.append("")
|
||||
report.append("| property | judged | declined |")
|
||||
report.append("| --- | --- | --- |")
|
||||
for name in GUARDS:
|
||||
count = judged[name]
|
||||
report.append("| %s | %s | %d |"
|
||||
% (name, count if count else "**0**", verified - count))
|
||||
|
||||
report.append("")
|
||||
for name, conditions in GUARDS.items():
|
||||
if judged[name] or not verified:
|
||||
continue
|
||||
absent = [label for index, (label, _) in enumerate(conditions) if not met_once[name][index]]
|
||||
report.append("- `%s` judged nothing on this run: no step had %s"
|
||||
% (name, ", nor ".join(absent) if absent else "what its guard needs"))
|
||||
|
||||
blind = None
|
||||
if not verified:
|
||||
blind = "%s records %d step(s) and not one of them was verified" % (trace_path, steps)
|
||||
else:
|
||||
silent = [name for name in MUST_RENDER if not judged[name]]
|
||||
if silent:
|
||||
blind = ("%s declined on every step, so the step page never rendered and the "
|
||||
"exit code is not evidence about the replay UI" % ", ".join(silent))
|
||||
|
||||
drift = []
|
||||
if property_names and property_names != set(GUARDS):
|
||||
drift.append("the spec's properties are %s but this summary counts %s"
|
||||
% (", ".join(sorted(property_names)), ", ".join(sorted(GUARDS))))
|
||||
try:
|
||||
with open(spec_path, encoding="utf-8") as handle:
|
||||
declared = set(re.findall(r'extract\(\s*"([^"]+)"', handle.read()))
|
||||
except OSError as error:
|
||||
drift.append("could not read %s to check its readings still exist: %s" % (spec_path, error))
|
||||
declared = None
|
||||
if declared is not None:
|
||||
gone = sorted({"toolbar", "stepRows", "beforeScreenshotStep", "activeTabs",
|
||||
"violationBadges", "violationPanelCounts"} - declared)
|
||||
if gone:
|
||||
drift.append("%s no longer declares %s, so the counts above describe readings "
|
||||
"that do not exist" % (spec_path, ", ".join(gone)))
|
||||
|
||||
if blind:
|
||||
report.append("- **this run checked nothing**: %s" % blind)
|
||||
for reason in drift:
|
||||
report.append("- **these counts cannot be trusted**: %s" % reason)
|
||||
|
||||
print("\n".join(report))
|
||||
for reason in ([blind] if blind else []) + drift:
|
||||
print("replay-ui: %s" % reason, file=sys.stderr)
|
||||
sys.exit(1 if blind or drift else 0)
|
||||
PY
|
||||
@@ -0,0 +1,200 @@
|
||||
{"extractor_changes":{"route":{"curr":"login","prev":null}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":1}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":2}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":3}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"login"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":4}
|
||||
{"extractor_changes":{"route":{"curr":"add-account","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":5}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":6}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":7}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":8}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"add-account"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":9}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":10}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":11}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":12}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":13}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":14}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":15}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":16}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":17}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":18}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":19}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":20}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":21}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":22}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":23}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":24}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":25}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":26}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":27}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":28}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":29}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":30}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":31}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":32}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":33}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":34}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":35}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":36}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":37}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":38}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":39}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":40}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":41}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":42}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":43}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":44}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":45}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":46}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":47}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":48}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":49}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":50}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":51}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":52}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":53}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":54}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":55}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":56}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":57}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":58}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":59}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":60}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":61}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":62}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":63}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":64}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":65}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":66}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":67}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":68}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":69}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":70}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":71}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":72}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":73}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":74}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":75}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":76}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":77}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":78}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":79}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":80}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":81}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":82}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":83}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":84}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":85}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":86}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":87}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":88}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":89}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":90}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":91}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":92}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":93}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":94}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":95}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":96}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":97}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":98}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":99}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":100}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":101}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":102}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":103}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":104}
|
||||
{"extractor_changes":{"route":{"curr":"add-account","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":105}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":106}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":107}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"add-account"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":108}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":109}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":110}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":111}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":112}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":113}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":114}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":115}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":116}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":117}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":118}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":119}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":120}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":121}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":122}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":123}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":124}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":125}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":126}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":127}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":128}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":129}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":130}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":131}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":132}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":133}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":134}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":135}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":136}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":137}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":138}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":139}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":140}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":141}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":142}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":143}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":144}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":145}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":146}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":147}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":148}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":149}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":150}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":151}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":152}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":153}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":154}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":155}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":156}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":157}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":158}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":159}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":160}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":161}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":162}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":163}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":164}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":165}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":166}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":167}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":168}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":169}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":170}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":171}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":172}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":173}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":174}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":175}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":176}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":177}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":178}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":179}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":180}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":181}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":182}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":183}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":184}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":185}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":186}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":187}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":188}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":189}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":190}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":191}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":192}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":193}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":194}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":195}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":196}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":197}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":198}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":199}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":200}
|
||||
@@ -0,0 +1,49 @@
|
||||
{"extractor_changes":{"route":{"curr":"login","prev":null}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":1}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":2}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":3}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"login"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":4}
|
||||
{"extractor_changes":{"route":{"curr":"add-account","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":5}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":6}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":7}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":8}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"add-account"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":9}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":10}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":11}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":12}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":13}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":14}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":15}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":16}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":17}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":18}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":19}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":20}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":21}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":22}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":23}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":24}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":25}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":26}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":27}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":28}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":29}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":30}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":31}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":32}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":33}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":34}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":35}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":36}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":37}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":38}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":39}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":40}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":41}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":42}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":43}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":44}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":45}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":46}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":47}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":48}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"op":"false"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":49,"violations":["submitCommitsOneTransactionPerAction"],"witnesses":{"submitCommitsOneTransactionPerAction":{"detected_step":49,"extractors":{"accountBalance":null,"accountCards":[{"__sanderlingSelector":"testTag:HomeScreen > testTag:AccountCard","attrs":{"accessibilityText":"SA, Savings, $1,888.00, 7 transactions","bounds":"[20,130][382,202]","class":"Button","clickable":"true","editable":"false","enabled":"true","identifier":"AccountCard"},"bounds":{"bottom":202,"left":20,"right":382,"top":130},"checked":false,"class":"Button","clickable":true,"desc":"SA, Savings, $1,888.00, 7 transactions","editable":false,"enabled":true,"focused":false,"id":"AccountCard","selected":false,"text":"","x":201,"y":166}],"accountNameField":null,"accounts":[{"balance":188800,"name":"Savings"}],"addAccountButton":{"__sanderlingSelector":"testTag:HomeScreen > testTag:AddAccountButton","attrs":{"accessibilityText":"+ Add account","bounds":"[20,777][382,825]","class":"Button","clickable":"true","editable":"false","enabled":"true","identifier":"AddAccountButton"},"bounds":{"bottom":825,"left":20,"right":382,"top":777},"checked":false,"class":"Button","clickable":true,"desc":"+ Add account","editable":false,"enabled":true,"focused":false,"id":"AddAccountButton","selected":false,"text":"","x":201,"y":801},"addAccountSubmit":null,"addTxnButton":null,"extractor_0":0,"extractor_1":0,"homeTxnCounts":{"Savings":7},"lastAction":{"applied":true,"kind":"DoubleTap","on":"id:TxnSubmit","relaunched":null},"loggedIn":true,"loginEmailField":null,"loginPasswordField":null,"loginSubmit":null,"route":"home","submitsInWindow":6,"submitsSinceAccountBalance":1,"submitsSinceCounts":6,"totalBalance":188800,"txnAmountField":null,"txnSubmit":null},"reason":"predicate false","step":48}}}
|
||||
@@ -0,0 +1,184 @@
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":1}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":2}
|
||||
{"extractor_changes":{"route":{"curr":"login","prev":null}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":3}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":4}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":5}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"login"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":6}
|
||||
{"extractor_changes":{"route":{"curr":"add-account","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":7}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":8}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":9}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":10}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":11}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":12}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":13}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"add-account"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":14}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":15}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":16}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":17}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":18}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":19}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":20}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":21}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":22}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":23}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":24}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":25}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":26}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":27}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":28}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":29}
|
||||
{"extractor_changes":{"route":{"curr":"add-account","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":30}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":31}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":32}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":33}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":34}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":35}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"add-account"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":36}
|
||||
{"extractor_changes":{"route":{"curr":"login","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":37}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":38}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":39}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"login"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":40}
|
||||
{"extractor_changes":{"route":{"curr":"add-account","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":41}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":42}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":43}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":44}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":45}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":46}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":47}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"add-account"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":48}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":49}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":50}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":51}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":52}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":53}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":54}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":55}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":56}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":57}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":58}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":59}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":60}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":61}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":62}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":63}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":64}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":65}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":66}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":67}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":68}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":69}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":70}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":71}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":72}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":73}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":74}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":75}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":76}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":77}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":78}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":79}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":80}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":81}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":82}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":83}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":84}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":85}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":86}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":87}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":88}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":89}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":90}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":91}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":92}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":93}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":94}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":95}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":96}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":97}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":98}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":99}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":100}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":101}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":102}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":103}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":104}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":105}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":106}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":107}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":108}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":109}
|
||||
{"extractor_changes":{"route":{"curr":"add-account","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":110}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":111}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":112}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":113}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"add-account"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":114}
|
||||
{"extractor_changes":{"route":{"curr":"add-account","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":115}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":116}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":117}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":118}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":119}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":120}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":121}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"add-account"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":122}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":123}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":124}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":125}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":126}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":127}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":128}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":129}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":130}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":131}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":132}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":133}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":134}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":135}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":136}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":137}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":138}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":139}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":140}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":141}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":142}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":143}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":144}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":145}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":146}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":147}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":148}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":149}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":150}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":151}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":152}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":153}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":154}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":155}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":156}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":157}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":158}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":159}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":160}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":161}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":162}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":163}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":164}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":165}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":166}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":167}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":168}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":169}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":170}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":171}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":172}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":173}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":174}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":175}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":176}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":177}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":178}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":179}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":180}
|
||||
{"extractor_changes":{"route":{"curr":"ledger","prev":"home"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":181}
|
||||
{"extractor_changes":{"route":{"curr":"add-transaction","prev":"ledger"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":182}
|
||||
{"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"name":"p4","op":"predicate"},"submitMovesBalanceByAtMostTypedAmount":{"name":"p3","op":"predicate"}},"step":183}
|
||||
{"extractor_changes":{"route":{"curr":"home","prev":"add-transaction"}},"residuals":{"newAccountBalanceIsZero":{"name":"p2","op":"predicate"},"submitCommitsOneTransactionPerAction":{"op":"false"},"submitMovesBalanceByAtMostTypedAmount":{"op":"false"}},"step":184,"violations":["submitCommitsOneTransactionPerAction","submitMovesBalanceByAtMostTypedAmount"],"witnesses":{"submitCommitsOneTransactionPerAction":{"detected_step":184,"extractors":{"accountBalance":null,"accountCards":[{"__sanderlingSelector":"testTag:HomeScreen > testTag:AccountCard","attrs":{"aria-label":"","tag":"div"},"bounds":{"bottom":139,"left":20,"right":760,"top":67},"class":"","clickable":true,"dataset":{},"desc":"","enabled":true,"focused":false,"id":"AccountCard","text":"NANaN14 transactions$4,579.00","x":390,"y":103},{"__sanderlingSelector":"testTag:HomeScreen > testTag:AccountCard","attrs":{"aria-label":"","tag":"div"},"bounds":{"bottom":221,"left":20,"right":760,"top":149},"class":"","clickable":true,"dataset":{},"desc":"","enabled":true,"focused":false,"id":"AccountCard","text":"TRTravel3 transactions$801.00","x":390,"y":185},{"__sanderlingSelector":"testTag:HomeScreen > testTag:AccountCard","attrs":{"aria-label":"","tag":"div"},"bounds":{"bottom":297,"left":20,"right":760,"top":231},"class":"","clickable":true,"dataset":{},"desc":"","enabled":true,"focused":false,"id":"AccountCard","text":"Aa0 transactions$0.00","x":390,"y":264}],"accountNameField":null,"accounts":[{"balance":457900,"name":"NANaN"},{"balance":80100,"name":"TRTravel"},{"balance":0,"name":"Aa"}],"addAccountButton":{"__sanderlingSelector":"testTag:HomeScreen > testTag:AddAccountButton","attrs":{"aria-label":"","tag":"div"},"bounds":{"bottom":421,"left":20,"right":760,"top":375},"class":"","clickable":true,"dataset":{},"desc":"","enabled":true,"focused":false,"id":"AddAccountButton","text":"+ Add account","x":390,"y":398},"addAccountSubmit":null,"addTxnButton":null,"extractor_0":0,"extractor_1":0,"homeTxnCounts":{"Aa":"0","NANaN":"14","TRTravel":"3"},"lastAction":{"applied":true,"kind":"DoubleTap","on":"id:TxnSubmit","relaunched":null},"loggedIn":true,"loginEmailField":null,"loginPasswordField":null,"loginSubmit":null,"route":"home","submitsInWindow":1,"submitsSinceAccountBalance":1,"submitsSinceCounts":1,"totalBalance":538000,"txnAmountField":null,"txnSubmit":null},"reason":"predicate false","step":183},"submitMovesBalanceByAtMostTypedAmount":{"detected_step":184,"extractors":{"accountBalance":null,"accountCards":[{"__sanderlingSelector":"testTag:HomeScreen > testTag:AccountCard","attrs":{"aria-label":"","tag":"div"},"bounds":{"bottom":139,"left":20,"right":760,"top":67},"class":"","clickable":true,"dataset":{},"desc":"","enabled":true,"focused":false,"id":"AccountCard","text":"NANaN14 transactions$4,579.00","x":390,"y":103},{"__sanderlingSelector":"testTag:HomeScreen > testTag:AccountCard","attrs":{"aria-label":"","tag":"div"},"bounds":{"bottom":221,"left":20,"right":760,"top":149},"class":"","clickable":true,"dataset":{},"desc":"","enabled":true,"focused":false,"id":"AccountCard","text":"TRTravel3 transactions$801.00","x":390,"y":185},{"__sanderlingSelector":"testTag:HomeScreen > testTag:AccountCard","attrs":{"aria-label":"","tag":"div"},"bounds":{"bottom":297,"left":20,"right":760,"top":231},"class":"","clickable":true,"dataset":{},"desc":"","enabled":true,"focused":false,"id":"AccountCard","text":"Aa0 transactions$0.00","x":390,"y":264}],"accountNameField":null,"accounts":[{"balance":457900,"name":"NANaN"},{"balance":80100,"name":"TRTravel"},{"balance":0,"name":"Aa"}],"addAccountButton":{"__sanderlingSelector":"testTag:HomeScreen > testTag:AddAccountButton","attrs":{"aria-label":"","tag":"div"},"bounds":{"bottom":421,"left":20,"right":760,"top":375},"class":"","clickable":true,"dataset":{},"desc":"","enabled":true,"focused":false,"id":"AddAccountButton","text":"+ Add account","x":390,"y":398},"addAccountSubmit":null,"addTxnButton":null,"extractor_0":0,"extractor_1":0,"homeTxnCounts":{"Aa":"0","NANaN":"14","TRTravel":"3"},"lastAction":{"applied":true,"kind":"DoubleTap","on":"id:TxnSubmit","relaunched":null},"loggedIn":true,"loginEmailField":null,"loginPasswordField":null,"loginSubmit":null,"route":"home","submitsInWindow":1,"submitsSinceAccountBalance":1,"submitsSinceCounts":1,"totalBalance":538000,"txnAmountField":null,"txnSubmit":null},"reason":"predicate false","step":183}}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"extractor_changes":{"activeTabs":{"curr":"screenshot,screenshot","prev":null},"beforeScreenshotStep":{"curr":1,"prev":null},"extractor_0":{"curr":0,"prev":null},"extractor_1":{"curr":0,"prev":null},"stepRows":{"curr":[{"active":true,"step":1,"violating":false},{"active":false,"step":2,"violating":false},{"active":false,"step":3,"violating":false},{"active":false,"step":4,"violating":true},{"active":false,"step":5,"violating":false}],"prev":null},"toolbar":{"curr":{"step":1,"stepCount":5},"prev":null},"violationBadges":{"curr":[],"prev":null},"violationPanelCounts":{"curr":[],"prev":null}},"residuals":{"badgeCountMatchesThePanel":{"op":"true"},"exactlyOneStepIsSelected":{"op":"true"},"noUncaughtExceptions":{"op":"true"},"screenshotShowsTheSelectedStep":{"op":"true"},"selectedStepIsInRange":{"op":"true"},"stepCountMatchesTheList":{"op":"true"},"switchingTabsKeepsTheStep":{"op":"true"}},"step":1,"timestamp":"2026-08-15T07:53:47Z"}
|
||||
{"extractor_changes":{"activeTabs":{"curr":"violations,screenshot","prev":"screenshot,screenshot"},"beforeScreenshotStep":{"curr":4,"prev":1},"stepRows":{"curr":[{"active":false,"step":1,"violating":false},{"active":false,"step":2,"violating":false},{"active":false,"step":3,"violating":false},{"active":true,"step":4,"violating":true},{"active":false,"step":5,"violating":false}],"prev":[{"active":true,"step":1,"violating":false},{"active":false,"step":2,"violating":false},{"active":false,"step":3,"violating":false},{"active":false,"step":4,"violating":true},{"active":false,"step":5,"violating":false}]},"toolbar":{"curr":{"step":4,"stepCount":5},"prev":{"step":1,"stepCount":5}},"violationBadges":{"curr":[1],"prev":[]},"violationPanelCounts":{"curr":[1],"prev":[]}},"residuals":{"badgeCountMatchesThePanel":{"op":"true"},"exactlyOneStepIsSelected":{"op":"true"},"noUncaughtExceptions":{"op":"true"},"screenshotShowsTheSelectedStep":{"op":"true"},"selectedStepIsInRange":{"op":"true"},"stepCountMatchesTheList":{"op":"true"},"switchingTabsKeepsTheStep":{"op":"true"}},"step":2,"timestamp":"2026-08-15T07:53:47Z"}
|
||||
{"extractor_changes":{"activeTabs":{"curr":"hierarchy,screenshot","prev":"violations,screenshot"},"violationBadges":{"curr":[],"prev":[1]},"violationPanelCounts":{"curr":[],"prev":[1]}},"residuals":{"badgeCountMatchesThePanel":{"op":"true"},"exactlyOneStepIsSelected":{"op":"true"},"noUncaughtExceptions":{"op":"true"},"screenshotShowsTheSelectedStep":{"op":"true"},"selectedStepIsInRange":{"op":"true"},"stepCountMatchesTheList":{"op":"true"},"switchingTabsKeepsTheStep":{"op":"true"}},"step":3,"timestamp":"2026-08-15T07:53:47Z"}
|
||||
{"residuals":{"badgeCountMatchesThePanel":{"op":"true"},"exactlyOneStepIsSelected":{"op":"true"},"noUncaughtExceptions":{"op":"true"},"screenshotShowsTheSelectedStep":{"op":"true"},"selectedStepIsInRange":{"op":"true"},"stepCountMatchesTheList":{"op":"true"},"switchingTabsKeepsTheStep":{"op":"true"}},"skipped_verification":true,"step":4,"timestamp":"2026-08-15T07:53:47Z"}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"extractor_changes":{"activeTabs":{"curr":"","prev":null},"extractor_0":{"curr":0,"prev":null},"extractor_1":{"curr":0,"prev":null},"stepRows":{"curr":[],"prev":null},"violationBadges":{"curr":[],"prev":null},"violationPanelCounts":{"curr":[],"prev":null}},"residuals":{"badgeCountMatchesThePanel":{"op":"true"},"exactlyOneStepIsSelected":{"op":"true"},"noUncaughtExceptions":{"op":"true"},"screenshotShowsTheSelectedStep":{"op":"true"},"selectedStepIsInRange":{"op":"true"},"stepCountMatchesTheList":{"op":"true"},"switchingTabsKeepsTheStep":{"op":"true"}},"step":1,"timestamp":"2026-08-15T07:53:47Z"}
|
||||
{"residuals":{"badgeCountMatchesThePanel":{"op":"true"},"exactlyOneStepIsSelected":{"op":"true"},"noUncaughtExceptions":{"op":"true"},"screenshotShowsTheSelectedStep":{"op":"true"},"selectedStepIsInRange":{"op":"true"},"stepCountMatchesTheList":{"op":"true"},"switchingTabsKeepsTheStep":{"op":"true"}},"step":2,"timestamp":"2026-08-15T07:53:47Z"}
|
||||
{"residuals":{"badgeCountMatchesThePanel":{"op":"true"},"exactlyOneStepIsSelected":{"op":"true"},"noUncaughtExceptions":{"op":"true"},"screenshotShowsTheSelectedStep":{"op":"true"},"selectedStepIsInRange":{"op":"true"},"stepCountMatchesTheList":{"op":"true"},"switchingTabsKeepsTheStep":{"op":"true"}},"step":3,"timestamp":"2026-08-15T07:53:47Z"}
|
||||
{"residuals":{"badgeCountMatchesThePanel":{"op":"true"},"exactlyOneStepIsSelected":{"op":"true"},"noUncaughtExceptions":{"op":"true"},"screenshotShowsTheSelectedStep":{"op":"true"},"selectedStepIsInRange":{"op":"true"},"stepCountMatchesTheList":{"op":"true"},"switchingTabsKeepsTheStep":{"op":"true"}},"step":4,"timestamp":"2026-08-15T07:53:47Z"}
|
||||
@@ -0,0 +1,10 @@
|
||||
{"extractor_changes":{"activeTabs":{"curr":"screenshot,screenshot","prev":null},"beforeScreenshotStep":{"curr":1,"prev":null},"extractor_0":{"curr":0,"prev":null},"extractor_1":{"curr":0,"prev":null},"stepRows":{"curr":[{"active":true,"step":1,"violating":false},{"active":false,"step":2,"violating":false},{"active":false,"step":3,"violating":false},{"active":false,"step":4,"violating":true},{"active":false,"step":5,"violating":false},{"active":false,"step":6,"violating":false},{"active":false,"step":7,"violating":false},{"active":false,"step":8,"violating":false},{"active":false,"step":9,"violating":false},{"active":false,"step":10,"violating":false},{"active":false,"step":11,"violating":false},{"active":false,"step":12,"violating":false},{"active":false,"step":13,"violating":false},{"active":false,"step":14,"violating":false},{"active":false,"step":15,"violating":false},{"active":false,"step":16,"violating":false},{"active":false,"step":17,"violating":false},{"active":false,"step":18,"violating":false},{"active":false,"step":19,"violating":false},{"active":false,"step":20,"violating":false},{"active":false,"step":21,"violating":false},{"active":false,"step":22,"violating":false},{"active":false,"step":23,"violating":false},{"active":false,"step":24,"violating":false},{"active":false,"step":25,"violating":false}],"prev":null},"toolbar":{"curr":{"step":1,"stepCount":25},"prev":null},"violationBadges":{"curr":[],"prev":null},"violationPanelCounts":{"curr":[],"prev":null}},"metrics":{"cpu_percent":0,"heap_bytes":2771019,"total_memory_bytes":3893507},"next_action":{"kind":"Tap","selector":"data-testid:tab","tap_point":{"x":774,"y":69},"x":774,"y":69},"residuals":{"badgeCountMatchesThePanel":{"op":"true"},"exactlyOneStepIsSelected":{"op":"true"},"noUncaughtExceptions":{"op":"true"},"screenshotShowsTheSelectedStep":{"op":"true"},"selectedStepIsInRange":{"op":"true"},"stepCountMatchesTheList":{"op":"true"},"switchingTabsKeepsTheStep":{"name":"p6","op":"predicate"}},"screen":"/runs/20260815-075332/steps/1","step":1,"timestamp":"2026-08-15T07:53:47.883914163Z"}
|
||||
{"extractor_changes":{"activeTabs":{"curr":"screenshot,hierarchy","prev":"screenshot,screenshot"}},"metrics":{"cpu_percent":0,"heap_bytes":3490520,"total_memory_bytes":6061916},"next_action":{"key":"right","kind":"PressKey"},"residuals":{"badgeCountMatchesThePanel":{"op":"true"},"exactlyOneStepIsSelected":{"op":"true"},"noUncaughtExceptions":{"op":"true"},"screenshotShowsTheSelectedStep":{"op":"true"},"selectedStepIsInRange":{"op":"true"},"stepCountMatchesTheList":{"op":"true"},"switchingTabsKeepsTheStep":{"name":"p6","op":"predicate"}},"screen":"/runs/20260815-075332/steps/1","step":2,"timestamp":"2026-08-15T07:53:48.356557191Z"}
|
||||
{"extractor_changes":{"activeTabs":{"curr":"screenshot,properties","prev":"screenshot,hierarchy"},"violationPanelCounts":{"curr":[0],"prev":[]}},"metrics":{"cpu_percent":0,"heap_bytes":3737998,"total_memory_bytes":6595962},"next_action":{"kind":"Tap","resolved_bounds":{"height":109,"width":31,"x":337,"y":308},"selector":"desc:select step 12","tap_point":{"x":352,"y":362},"x":353,"y":363},"residuals":{"badgeCountMatchesThePanel":{"op":"true"},"exactlyOneStepIsSelected":{"op":"true"},"noUncaughtExceptions":{"op":"true"},"screenshotShowsTheSelectedStep":{"op":"true"},"selectedStepIsInRange":{"op":"true"},"stepCountMatchesTheList":{"op":"true"},"switchingTabsKeepsTheStep":{"name":"p6","op":"predicate"}},"screen":"/runs/20260815-075332/steps/1","step":3,"timestamp":"2026-08-15T07:53:48.584625848Z"}
|
||||
{"extractor_changes":{"beforeScreenshotStep":{"curr":12,"prev":1},"stepRows":{"curr":[{"active":false,"step":1,"violating":false},{"active":false,"step":2,"violating":false},{"active":false,"step":3,"violating":false},{"active":false,"step":4,"violating":true},{"active":false,"step":5,"violating":false},{"active":false,"step":6,"violating":false},{"active":false,"step":7,"violating":false},{"active":false,"step":8,"violating":false},{"active":false,"step":9,"violating":false},{"active":false,"step":10,"violating":false},{"active":false,"step":11,"violating":false},{"active":true,"step":12,"violating":false},{"active":false,"step":13,"violating":false},{"active":false,"step":14,"violating":false},{"active":false,"step":15,"violating":false},{"active":false,"step":16,"violating":false},{"active":false,"step":17,"violating":false},{"active":false,"step":18,"violating":false},{"active":false,"step":19,"violating":false},{"active":false,"step":20,"violating":false},{"active":false,"step":21,"violating":false},{"active":false,"step":22,"violating":false},{"active":false,"step":23,"violating":false},{"active":false,"step":24,"violating":false},{"active":false,"step":25,"violating":false}],"prev":[{"active":true,"step":1,"violating":false},{"active":false,"step":2,"violating":false},{"active":false,"step":3,"violating":false},{"active":false,"step":4,"violating":true},{"active":false,"step":5,"violating":false},{"active":false,"step":6,"violating":false},{"active":false,"step":7,"violating":false},{"active":false,"step":8,"violating":false},{"active":false,"step":9,"violating":false},{"active":false,"step":10,"violating":false},{"active":false,"step":11,"violating":false},{"active":false,"step":12,"violating":false},{"active":false,"step":13,"violating":false},{"active":false,"step":14,"violating":false},{"active":false,"step":15,"violating":false},{"active":false,"step":16,"violating":false},{"active":false,"step":17,"violating":false},{"active":false,"step":18,"violating":false},{"active":false,"step":19,"violating":false},{"active":false,"step":20,"violating":false},{"active":false,"step":21,"violating":false},{"active":false,"step":22,"violating":false},{"active":false,"step":23,"violating":false},{"active":false,"step":24,"violating":false},{"active":false,"step":25,"violating":false}]},"toolbar":{"curr":{"step":12,"stepCount":25},"prev":{"step":1,"stepCount":25}}},"metrics":{"cpu_percent":0,"heap_bytes":3535304,"total_memory_bytes":7911280},"next_action":{"key":"right","kind":"PressKey"},"residuals":{"badgeCountMatchesThePanel":{"op":"true"},"exactlyOneStepIsSelected":{"op":"true"},"noUncaughtExceptions":{"op":"true"},"screenshotShowsTheSelectedStep":{"op":"true"},"selectedStepIsInRange":{"op":"true"},"stepCountMatchesTheList":{"op":"true"},"switchingTabsKeepsTheStep":{"name":"p6","op":"predicate"}},"screen":"/runs/20260815-075332/steps/12","step":4,"timestamp":"2026-08-15T07:53:48.820882198Z"}
|
||||
{"extractor_changes":{"beforeScreenshotStep":{"curr":13,"prev":12},"stepRows":{"curr":[{"active":false,"step":1,"violating":false},{"active":false,"step":2,"violating":false},{"active":false,"step":3,"violating":false},{"active":false,"step":4,"violating":true},{"active":false,"step":5,"violating":false},{"active":false,"step":6,"violating":false},{"active":false,"step":7,"violating":false},{"active":false,"step":8,"violating":false},{"active":false,"step":9,"violating":false},{"active":false,"step":10,"violating":false},{"active":false,"step":11,"violating":false},{"active":false,"step":12,"violating":false},{"active":true,"step":13,"violating":false},{"active":false,"step":14,"violating":false},{"active":false,"step":15,"violating":false},{"active":false,"step":16,"violating":false},{"active":false,"step":17,"violating":false},{"active":false,"step":18,"violating":false},{"active":false,"step":19,"violating":false},{"active":false,"step":20,"violating":false},{"active":false,"step":21,"violating":false},{"active":false,"step":22,"violating":false},{"active":false,"step":23,"violating":false},{"active":false,"step":24,"violating":false},{"active":false,"step":25,"violating":false}],"prev":[{"active":false,"step":1,"violating":false},{"active":false,"step":2,"violating":false},{"active":false,"step":3,"violating":false},{"active":false,"step":4,"violating":true},{"active":false,"step":5,"violating":false},{"active":false,"step":6,"violating":false},{"active":false,"step":7,"violating":false},{"active":false,"step":8,"violating":false},{"active":false,"step":9,"violating":false},{"active":false,"step":10,"violating":false},{"active":false,"step":11,"violating":false},{"active":true,"step":12,"violating":false},{"active":false,"step":13,"violating":false},{"active":false,"step":14,"violating":false},{"active":false,"step":15,"violating":false},{"active":false,"step":16,"violating":false},{"active":false,"step":17,"violating":false},{"active":false,"step":18,"violating":false},{"active":false,"step":19,"violating":false},{"active":false,"step":20,"violating":false},{"active":false,"step":21,"violating":false},{"active":false,"step":22,"violating":false},{"active":false,"step":23,"violating":false},{"active":false,"step":24,"violating":false},{"active":false,"step":25,"violating":false}]},"toolbar":{"curr":{"step":13,"stepCount":25},"prev":{"step":12,"stepCount":25}}},"metrics":{"cpu_percent":0,"heap_bytes":4913839,"total_memory_bytes":8552983},"next_action":{"duration_millis":250,"from_x":98,"from_y":149,"kind":"Swipe","to_x":98,"to_y":749},"residuals":{"badgeCountMatchesThePanel":{"op":"true"},"exactlyOneStepIsSelected":{"op":"true"},"noUncaughtExceptions":{"op":"true"},"screenshotShowsTheSelectedStep":{"op":"true"},"selectedStepIsInRange":{"op":"true"},"stepCountMatchesTheList":{"op":"true"},"switchingTabsKeepsTheStep":{"name":"p6","op":"predicate"}},"screen":"/runs/20260815-075332/steps/13","step":5,"timestamp":"2026-08-15T07:53:49.038789589Z"}
|
||||
{"metrics":{"cpu_percent":0,"heap_bytes":4615419,"total_memory_bytes":8970775},"next_action":{"key":"left","kind":"PressKey"},"residuals":{"badgeCountMatchesThePanel":{"op":"true"},"exactlyOneStepIsSelected":{"op":"true"},"noUncaughtExceptions":{"op":"true"},"screenshotShowsTheSelectedStep":{"op":"true"},"selectedStepIsInRange":{"op":"true"},"stepCountMatchesTheList":{"op":"true"},"switchingTabsKeepsTheStep":{"name":"p6","op":"predicate"}},"screen":"/runs/20260815-075332/steps/13","step":6,"timestamp":"2026-08-15T07:53:49.251047338Z"}
|
||||
{"extractor_changes":{"beforeScreenshotStep":{"curr":12,"prev":13},"stepRows":{"curr":[{"active":false,"step":1,"violating":false},{"active":false,"step":2,"violating":false},{"active":false,"step":3,"violating":false},{"active":false,"step":4,"violating":true},{"active":false,"step":5,"violating":false},{"active":false,"step":6,"violating":false},{"active":false,"step":7,"violating":false},{"active":false,"step":8,"violating":false},{"active":false,"step":9,"violating":false},{"active":false,"step":10,"violating":false},{"active":false,"step":11,"violating":false},{"active":true,"step":12,"violating":false},{"active":false,"step":13,"violating":false},{"active":false,"step":14,"violating":false},{"active":false,"step":15,"violating":false},{"active":false,"step":16,"violating":false},{"active":false,"step":17,"violating":false},{"active":false,"step":18,"violating":false},{"active":false,"step":19,"violating":false},{"active":false,"step":20,"violating":false},{"active":false,"step":21,"violating":false},{"active":false,"step":22,"violating":false},{"active":false,"step":23,"violating":false},{"active":false,"step":24,"violating":false},{"active":false,"step":25,"violating":false}],"prev":[{"active":false,"step":1,"violating":false},{"active":false,"step":2,"violating":false},{"active":false,"step":3,"violating":false},{"active":false,"step":4,"violating":true},{"active":false,"step":5,"violating":false},{"active":false,"step":6,"violating":false},{"active":false,"step":7,"violating":false},{"active":false,"step":8,"violating":false},{"active":false,"step":9,"violating":false},{"active":false,"step":10,"violating":false},{"active":false,"step":11,"violating":false},{"active":false,"step":12,"violating":false},{"active":true,"step":13,"violating":false},{"active":false,"step":14,"violating":false},{"active":false,"step":15,"violating":false},{"active":false,"step":16,"violating":false},{"active":false,"step":17,"violating":false},{"active":false,"step":18,"violating":false},{"active":false,"step":19,"violating":false},{"active":false,"step":20,"violating":false},{"active":false,"step":21,"violating":false},{"active":false,"step":22,"violating":false},{"active":false,"step":23,"violating":false},{"active":false,"step":24,"violating":false},{"active":false,"step":25,"violating":false}]},"toolbar":{"curr":{"step":12,"stepCount":25},"prev":{"step":13,"stepCount":25}}},"metrics":{"cpu_percent":0,"heap_bytes":3996016,"total_memory_bytes":8970784},"next_action":{"kind":"Tap","selector":"data-testid:tab","tap_point":{"x":670,"y":69},"x":670,"y":69},"residuals":{"badgeCountMatchesThePanel":{"op":"true"},"exactlyOneStepIsSelected":{"op":"true"},"noUncaughtExceptions":{"op":"true"},"screenshotShowsTheSelectedStep":{"op":"true"},"selectedStepIsInRange":{"op":"true"},"stepCountMatchesTheList":{"op":"true"},"switchingTabsKeepsTheStep":{"name":"p6","op":"predicate"}},"screen":"/runs/20260815-075332/steps/12","step":7,"timestamp":"2026-08-15T07:53:49.451285818Z"}
|
||||
{"metrics":{"cpu_percent":0,"heap_bytes":4830079,"total_memory_bytes":8974523},"next_action":{"kind":"Tap","selector":"data-testid:step-row","tap_point":{"x":170,"y":256},"x":170,"y":256},"residuals":{"badgeCountMatchesThePanel":{"op":"true"},"exactlyOneStepIsSelected":{"op":"true"},"noUncaughtExceptions":{"op":"true"},"screenshotShowsTheSelectedStep":{"op":"true"},"selectedStepIsInRange":{"op":"true"},"stepCountMatchesTheList":{"op":"true"},"switchingTabsKeepsTheStep":{"name":"p6","op":"predicate"}},"screen":"/runs/20260815-075332/steps/12","step":8,"timestamp":"2026-08-15T07:53:49.658417939Z"}
|
||||
{"metrics":{"cpu_percent":0,"heap_bytes":3772499,"total_memory_bytes":8974523},"next_action":{"kind":"Tap","selector":"data-testid:step-row","tap_point":{"x":170,"y":363},"x":170,"y":363},"residuals":{"badgeCountMatchesThePanel":{"op":"true"},"exactlyOneStepIsSelected":{"op":"true"},"noUncaughtExceptions":{"op":"true"},"screenshotShowsTheSelectedStep":{"op":"true"},"selectedStepIsInRange":{"op":"true"},"stepCountMatchesTheList":{"op":"true"},"switchingTabsKeepsTheStep":{"name":"p6","op":"predicate"}},"screen":"/runs/20260815-075332/steps/12","step":9,"timestamp":"2026-08-15T07:53:49.865911674Z"}
|
||||
{"extractor_changes":{"beforeScreenshotStep":{"curr":6,"prev":12},"stepRows":{"curr":[{"active":false,"step":1,"violating":false},{"active":false,"step":2,"violating":false},{"active":false,"step":3,"violating":false},{"active":false,"step":4,"violating":true},{"active":false,"step":5,"violating":false},{"active":true,"step":6,"violating":false},{"active":false,"step":7,"violating":false},{"active":false,"step":8,"violating":false},{"active":false,"step":9,"violating":false},{"active":false,"step":10,"violating":false},{"active":false,"step":11,"violating":false},{"active":false,"step":12,"violating":false},{"active":false,"step":13,"violating":false},{"active":false,"step":14,"violating":false},{"active":false,"step":15,"violating":false},{"active":false,"step":16,"violating":false},{"active":false,"step":17,"violating":false},{"active":false,"step":18,"violating":false},{"active":false,"step":19,"violating":false},{"active":false,"step":20,"violating":false},{"active":false,"step":21,"violating":false},{"active":false,"step":22,"violating":false},{"active":false,"step":23,"violating":false},{"active":false,"step":24,"violating":false},{"active":false,"step":25,"violating":false}],"prev":[{"active":false,"step":1,"violating":false},{"active":false,"step":2,"violating":false},{"active":false,"step":3,"violating":false},{"active":false,"step":4,"violating":true},{"active":false,"step":5,"violating":false},{"active":false,"step":6,"violating":false},{"active":false,"step":7,"violating":false},{"active":false,"step":8,"violating":false},{"active":false,"step":9,"violating":false},{"active":false,"step":10,"violating":false},{"active":false,"step":11,"violating":false},{"active":true,"step":12,"violating":false},{"active":false,"step":13,"violating":false},{"active":false,"step":14,"violating":false},{"active":false,"step":15,"violating":false},{"active":false,"step":16,"violating":false},{"active":false,"step":17,"violating":false},{"active":false,"step":18,"violating":false},{"active":false,"step":19,"violating":false},{"active":false,"step":20,"violating":false},{"active":false,"step":21,"violating":false},{"active":false,"step":22,"violating":false},{"active":false,"step":23,"violating":false},{"active":false,"step":24,"violating":false},{"active":false,"step":25,"violating":false}]},"toolbar":{"curr":{"step":6,"stepCount":25},"prev":{"step":12,"stepCount":25}}},"metrics":{"cpu_percent":0,"heap_bytes":4783792,"total_memory_bytes":8974552},"next_action":{"key":"right","kind":"PressKey"},"residuals":{"badgeCountMatchesThePanel":{"op":"true"},"exactlyOneStepIsSelected":{"op":"true"},"noUncaughtExceptions":{"op":"true"},"screenshotShowsTheSelectedStep":{"op":"true"},"selectedStepIsInRange":{"op":"true"},"stepCountMatchesTheList":{"op":"true"},"switchingTabsKeepsTheStep":{"name":"p6","op":"predicate"}},"screen":"/runs/20260815-075332/steps/6","step":10,"timestamp":"2026-08-15T07:53:50.075785262Z"}
|
||||
Executable
+146
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env bash
|
||||
# Checks that everything the workflow names actually exists: composite actions,
|
||||
# reusable workflows, make targets, and the scripts a run: block invokes. Then
|
||||
# checks that no run: block interpolates a `${{ }}`.
|
||||
#
|
||||
# This is the class actionlint does not cover. `uses: ./.github/actions/typo`
|
||||
# lints clean and fails only when the job runs, and the folio jobs and the
|
||||
# release job never run on a pull request, so that first run is after merge.
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
ROOT="$root" python3 - <<'PY'
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
root = os.environ["ROOT"]
|
||||
problems = []
|
||||
checked = 0
|
||||
|
||||
|
||||
def report(ok, label, detail=""):
|
||||
global checked
|
||||
checked += 1
|
||||
if not ok:
|
||||
problems.append("%s%s" % (label, detail))
|
||||
print(" %-4s %s%s" % ("ok" if ok else "MISS", label, detail))
|
||||
|
||||
|
||||
def workflow_files():
|
||||
return (sorted(glob.glob(os.path.join(root, ".github/workflows/*.yml")))
|
||||
+ sorted(glob.glob(os.path.join(root, ".github/actions/*/action.yml"))))
|
||||
|
||||
|
||||
def rel(path):
|
||||
return os.path.relpath(path, root)
|
||||
|
||||
|
||||
# --- composite actions and reusable workflows --------------------------------
|
||||
print("local action and reusable workflow references:")
|
||||
local_refs = 0
|
||||
for path in workflow_files():
|
||||
# Comments are not references. They mention paths as examples, and a version
|
||||
# comment trails the `uses:` line of every pinned action.
|
||||
body = re.sub(r"#[^\n]*", "", open(path).read())
|
||||
found = re.findall(r"^\s*-?\s*uses:\s*(\./\S+)\s*$", body, re.M)
|
||||
local_refs += len(found)
|
||||
for ref in found:
|
||||
# A reusable workflow is named by its own file. A composite action is
|
||||
# named by the directory holding it, and the file inside is action.yml.
|
||||
target = os.path.join(root, ref[2:])
|
||||
if not target.endswith((".yml", ".yaml")):
|
||||
target = os.path.join(target, "action.yml")
|
||||
report(os.path.isfile(target), ref, " (from %s)" % rel(path))
|
||||
# A checker that silently matches nothing reports a safety it never looked
|
||||
# for. If the file names a local action in a form the pattern above does not
|
||||
# read, that is a broken checker, not a clean file.
|
||||
mentions = len(re.findall(r"\./\.github/(?:actions|workflows)/", body))
|
||||
if mentions > len(found):
|
||||
sys.exit("workflow-refs: %s mentions ./.github/actions/ or ./.github/workflows/ "
|
||||
"%d time(s) but this "
|
||||
"check only parsed %d `uses:` reference(s) out of it, so it is not "
|
||||
"reading the file it claims to read" % (rel(path), mentions, len(found)))
|
||||
|
||||
if local_refs == 0:
|
||||
sys.exit("workflow-refs: found no `uses: ./...` at all, so this check is not "
|
||||
"reading the workflows it claims to read")
|
||||
|
||||
# --- make targets ------------------------------------------------------------
|
||||
print("\nmake targets named by a run: step:")
|
||||
makefile = open(os.path.join(root, "Makefile")).read()
|
||||
targets = set(re.findall(r"^([A-Za-z0-9_.-]+):", makefile, re.M))
|
||||
wanted = set()
|
||||
for path in sorted(glob.glob(os.path.join(root, ".github/workflows/*.yml"))):
|
||||
body = open(path).read()
|
||||
# Comments are not run, and prose in them says things like "make the run
|
||||
# always open the same way", which is not a target.
|
||||
commands = re.sub(r"#[^\n]*", "", body)
|
||||
for name in re.findall(r"\bmake\s+([a-z][a-z0-9-]*)\b", commands):
|
||||
wanted.add(name)
|
||||
for name in sorted(wanted):
|
||||
report(name in targets, "make %s" % name)
|
||||
|
||||
# --- scripts a run: step invokes ---------------------------------------------
|
||||
print("\nscripts a run: step invokes:")
|
||||
scripts = set()
|
||||
for path in workflow_files():
|
||||
scripts |= set(re.findall(r"\.github/scripts/[A-Za-z0-9_.-]+\.sh", open(path).read()))
|
||||
for name in sorted(scripts):
|
||||
full = os.path.join(root, name)
|
||||
report(os.path.isfile(full), name)
|
||||
if os.path.isfile(full):
|
||||
report(os.access(full, os.X_OK), "%s is executable" % name)
|
||||
|
||||
# --- expressions in a run: block ---------------------------------------------
|
||||
# A `${{ }}` is substituted into the script text before bash reads the line, so
|
||||
# an expression carrying text someone else wrote runs as a command. Values reach
|
||||
# a run: block through env instead. actionlint flags only the contexts it knows
|
||||
# are attacker-controlled, and a matrix value or a dispatch input is not on that
|
||||
# list.
|
||||
print("\nrun: blocks free of ${{ }}:")
|
||||
|
||||
|
||||
def run_blocks(text):
|
||||
lines = text.split("\n")
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
head = re.match(r"^(\s*(?:-\s+)?)run:(.*)$", lines[i])
|
||||
if head is None:
|
||||
i += 1
|
||||
continue
|
||||
column, rest = len(head.group(1)), head.group(2).strip()
|
||||
start, body = i + 1, []
|
||||
if rest in ("|", "|-", "|+", ">", ">-", ">+", ""):
|
||||
i += 1
|
||||
while i < len(lines) and (not lines[i].strip()
|
||||
or len(lines[i]) - len(lines[i].lstrip()) > column):
|
||||
body.append(lines[i])
|
||||
i += 1
|
||||
else:
|
||||
body.append(rest)
|
||||
i += 1
|
||||
yield start, "\n".join(body)
|
||||
|
||||
|
||||
blocks = 0
|
||||
for path in workflow_files():
|
||||
hits = []
|
||||
for line, body in run_blocks(open(path).read()):
|
||||
blocks += 1
|
||||
hits += ["line %d: %s" % (line, hit) for hit in re.findall(r"\$\{\{.*?\}\}", body, re.S)]
|
||||
report(not hits, rel(path), " (%s)" % ("; ".join(hits) if hits else "clean"))
|
||||
|
||||
# Same reason as the local action count above: a scanner that reads no run:
|
||||
# block at all would pass every file it never looked at.
|
||||
if blocks == 0:
|
||||
sys.exit("workflow-refs: found no run: block at all, so this check is not "
|
||||
"reading the workflows it claims to read")
|
||||
|
||||
print("\n%d references checked" % checked)
|
||||
if problems:
|
||||
sys.exit("workflow-refs: unresolved: %s" % ", ".join(problems))
|
||||
print("workflow-refs: ok")
|
||||
PY
|
||||
+603
-39
@@ -1,55 +1,70 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
# Runs on PRs (opened / synchronize / reopened, which are the defaults) and
|
||||
# manual dispatch only. We deliberately don't run on direct pushes to master:
|
||||
# master is PR-merge-only, and PR validation already covers the merge
|
||||
# commit via the `synchronize` event on the PR branch.
|
||||
pull_request:
|
||||
push:
|
||||
branches: [master]
|
||||
# `none` is an ordinary ci run. minor and major consolidate every patch
|
||||
# released since the last milestone into one, and run the whole suite first:
|
||||
# a release that skipped the device legs would be the only release nobody
|
||||
# checked. The default is what stops a dispatch meant to re-run the tests
|
||||
# from cutting a release by accident.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
promote:
|
||||
description: Consolidate the released patches into a milestone
|
||||
type: choice
|
||||
options:
|
||||
- none
|
||||
- minor
|
||||
- major
|
||||
default: none
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A superseded pull request run is waste. A run that publishes is not, so only
|
||||
# a pull request cancels.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
check-tests:
|
||||
name: Check (tests)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
|
||||
|
||||
- name: Set up Node 22
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: pkg/spec/package-lock.json
|
||||
|
||||
- name: Set up bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Cache bun store
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ runner.os }}-${{ hashFiles('replay-ui/bun.lock') }}
|
||||
@@ -74,7 +89,7 @@ jobs:
|
||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -95,39 +110,588 @@ jobs:
|
||||
- name: Run tests
|
||||
run: make test
|
||||
|
||||
browser:
|
||||
# folio is its own gradle build, and the metro plugin it compiles with
|
||||
# needs a 21 runtime where the sidecar toolchain pins 17. Switching
|
||||
# JAVA_HOME after `make test` rather than installing both up front
|
||||
# leaves every step above this one on exactly the JDK it ran on before.
|
||||
- name: Set up JDK 21 for folio
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Run folio's unit tests
|
||||
run: make test-folio
|
||||
|
||||
check-browser:
|
||||
name: Check (browser)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# Pin stable: the action's default (latest) pulls a dev Chromium whose
|
||||
# remote-debugging socket is flaky under the driver, even though the
|
||||
# browser otherwise launches headless.
|
||||
- name: Set up Chrome
|
||||
uses: browser-actions/setup-chrome@v1
|
||||
with:
|
||||
chrome-version: stable
|
||||
|
||||
# Ubuntu 24.04 (current ubuntu-latest) restricts unprivileged user
|
||||
# namespaces via AppArmor, which stops headless Chrome from starting even
|
||||
# with --no-sandbox: the process launches but never opens its DevTools
|
||||
# socket. Re-enable them so the driver's Chrome can come up.
|
||||
- name: Allow Chrome under unprivileged user namespaces
|
||||
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
|
||||
# Fail here with Chrome's own stderr if the browser can't launch, instead
|
||||
# of letting the driver report an opaque DevTools timeout downstream.
|
||||
- name: Verify headless Chrome starts
|
||||
run: |
|
||||
chrome --version
|
||||
chrome --headless --no-sandbox --disable-gpu --disable-dev-shm-usage \
|
||||
--dump-dom 'data:text/html,<title>ok</title>'
|
||||
- name: Set up headless Chrome
|
||||
uses: ./.github/actions/headless-chrome
|
||||
|
||||
- name: Drive web fixtures through headless Chrome
|
||||
run: make test-browser
|
||||
|
||||
check-workflows:
|
||||
name: Check (workflows)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# Pinned so a new actionlint release cannot change what CI enforces,
|
||||
# for the same reason the buf version above is spelled out. shellcheck
|
||||
# runs over every run: block by default.
|
||||
- name: Lint the workflow
|
||||
uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0
|
||||
with:
|
||||
version: 1.7.12
|
||||
|
||||
# actionlint reads a local action's inputs but never checks that its path
|
||||
# exists: `uses: ./.github/actions/typo` lints clean and fails only when
|
||||
# the job runs, and the release and docs jobs never run on a pull request.
|
||||
- name: Check that the workflow references resolve
|
||||
run: .github/scripts/workflow-refs.sh
|
||||
|
||||
# The run graph boxes jobs together when they share the same dependencies
|
||||
# and the same dependents, so a group only draws as its own box if one job
|
||||
# depends on exactly that group. That is what these three gates are for.
|
||||
# They also collapse a group to one status to read.
|
||||
checks:
|
||||
name: Checks
|
||||
if: always()
|
||||
needs:
|
||||
- check-tests
|
||||
- check-browser
|
||||
- check-workflows
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check the group passed
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||
run: exit 1
|
||||
|
||||
folio-android:
|
||||
name: Folio (android)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
SEED: "9"
|
||||
MAX_STEPS: "200"
|
||||
DURATION: 20m
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Set up bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Build the folio app
|
||||
uses: ./.github/actions/folio-app
|
||||
with:
|
||||
platform: android
|
||||
|
||||
- name: Build sanderling
|
||||
run: make sanderling-android
|
||||
|
||||
- name: Run the spec on an emulator
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
|
||||
with:
|
||||
api-level: 34
|
||||
target: google_apis
|
||||
arch: x86_64
|
||||
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim
|
||||
disable-animations: true
|
||||
script: .github/scripts/folio-run.sh android
|
||||
|
||||
- name: Upload the run
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: folio-android
|
||||
path: runs/
|
||||
retention-days: 14
|
||||
|
||||
folio-ios:
|
||||
name: Folio (ios)
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
SEED: "7"
|
||||
MAX_STEPS: "240"
|
||||
DURATION: 20m
|
||||
IOS_DEVICE: iPhone 16 Pro
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Set up bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Build the folio app
|
||||
uses: ./.github/actions/folio-app
|
||||
with:
|
||||
platform: ios
|
||||
|
||||
- name: Build sanderling
|
||||
run: make sanderling-ios
|
||||
|
||||
- name: Boot a simulator
|
||||
run: |
|
||||
xcrun simctl boot "$IOS_DEVICE" || true
|
||||
xcrun simctl bootstatus "$IOS_DEVICE" -b
|
||||
|
||||
- name: Build and install folio
|
||||
working-directory: examples/folio
|
||||
run: just ios
|
||||
|
||||
# `just ios` leaves the app running, and the run's first act is to clear
|
||||
# its state. Stopping it here means the run always opens the same way.
|
||||
- name: Stop the app before the run
|
||||
run: xcrun simctl terminate booted app.folio || true
|
||||
|
||||
- name: Run the spec
|
||||
run: .github/scripts/folio-run.sh ios
|
||||
|
||||
- name: Upload the run
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: folio-ios
|
||||
path: runs/
|
||||
retention-days: 14
|
||||
|
||||
folio-web:
|
||||
name: Folio (web)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
SEED: "3"
|
||||
MAX_STEPS: "240"
|
||||
DURATION: 20m
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Set up bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Set up headless Chrome
|
||||
uses: ./.github/actions/headless-chrome
|
||||
|
||||
- name: Build the folio app
|
||||
uses: ./.github/actions/folio-app
|
||||
with:
|
||||
platform: web
|
||||
|
||||
- name: Build sanderling
|
||||
run: make sanderling-web
|
||||
|
||||
- name: Run the spec
|
||||
run: .github/scripts/folio-run.sh web
|
||||
|
||||
- name: Upload the run
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: folio-web
|
||||
path: runs/
|
||||
retention-days: 14
|
||||
|
||||
folio:
|
||||
name: Folio
|
||||
if: always()
|
||||
needs:
|
||||
- folio-android
|
||||
- folio-ios
|
||||
- folio-web
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check the group passed
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||
run: exit 1
|
||||
|
||||
replay-ui:
|
||||
name: Replay UI
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
SEED: "3"
|
||||
MAX_STEPS: "80"
|
||||
DURATION: 10m
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Set up bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Set up headless Chrome
|
||||
uses: ./.github/actions/headless-chrome
|
||||
|
||||
# The UI the spec drives is the one embedded in this binary, so the build
|
||||
# has to come after any change to replay-ui/src.
|
||||
- name: Build sanderling
|
||||
run: make sanderling-web
|
||||
|
||||
# A trace with a violation and uncaught exceptions in it, so the UI has
|
||||
# something to render in every panel the spec looks at. No
|
||||
# --exit-on-violation here: the run is the fixture, and stopping it at the
|
||||
# first violation would leave a four-step trace to run against.
|
||||
- name: Record a fixture trace
|
||||
run: |
|
||||
python3 -m http.server 8792 --bind 127.0.0.1 \
|
||||
--directory test/browser/testdata/throwing &
|
||||
ready=""
|
||||
for _ in $(seq 1 30); do
|
||||
curl -sf http://127.0.0.1:8792/ >/dev/null && { ready=1; break; }
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$ready" ]; then
|
||||
echo "the fixture http server never answered on 127.0.0.1:8792" >&2
|
||||
exit 1
|
||||
fi
|
||||
./bin/sanderling test \
|
||||
--platform web \
|
||||
--spec test/browser/testdata/throwing/spec.ts \
|
||||
--bundle-id http://127.0.0.1:8792/ \
|
||||
--duration 5m --max-steps 25 --seed 7 \
|
||||
--output runs/fixture
|
||||
|
||||
- name: Serve the trace with sanderling replay
|
||||
id: fixture
|
||||
run: |
|
||||
# Flags before the positional argument: Go's flag package stops
|
||||
# parsing at the first non-flag word.
|
||||
./bin/sanderling replay --port 8793 --no-open runs/fixture &
|
||||
ready=""
|
||||
for _ in $(seq 1 30); do
|
||||
curl -sf http://127.0.0.1:8793/api/runs >/dev/null && { ready=1; break; }
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$ready" ]; then
|
||||
echo "sanderling replay never served /api/runs on 127.0.0.1:8793" >&2
|
||||
exit 1
|
||||
fi
|
||||
run_id="$(basename "$(find runs/fixture -mindepth 1 -maxdepth 1 | head -1)")"
|
||||
echo "url=http://127.0.0.1:8793/runs/$run_id/steps/1" >> "$GITHUB_OUTPUT"
|
||||
curl -sf "http://127.0.0.1:8793/runs/$run_id/steps/1" >/dev/null
|
||||
|
||||
# The url goes through env rather than into the script text: a `${{ }}` is
|
||||
# substituted before bash ever sees the line.
|
||||
- name: Run the spec
|
||||
run: |
|
||||
./bin/sanderling test \
|
||||
--platform web \
|
||||
--spec replay-ui/sanderling/spec.ts \
|
||||
--bundle-id "$RUN_URL" \
|
||||
--duration "$DURATION" \
|
||||
--max-steps "$MAX_STEPS" \
|
||||
--seed "$SEED" \
|
||||
--exit-on-violation \
|
||||
--output runs/replay-ui
|
||||
env:
|
||||
RUN_URL: ${{ steps.fixture.outputs.url }}
|
||||
|
||||
# Exit 0 above means no property returned false. It does not mean any
|
||||
# property was ever evaluated against real content: they all decline to
|
||||
# judge when the elements they read are absent, so a run that never
|
||||
# rendered the step page is green and worthless. This step is what tells
|
||||
# the two apart, and it fails the job when nothing was judged. folio
|
||||
# makes the same call inside folio-run.sh, where the exit code it is
|
||||
# judging is in scope.
|
||||
- name: Classify the run
|
||||
if: always()
|
||||
run: .github/scripts/replay-ui-summary.sh runs/replay-ui
|
||||
|
||||
- name: Upload the run
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: replay-ui-runs
|
||||
path: runs/
|
||||
retention-days: 14
|
||||
|
||||
# Every merge to master cuts a patch: 0.1.4 becomes 0.1.5. A dispatch with
|
||||
# `promote` set cuts the milestone that consolidates them instead. Both wait on
|
||||
# the device legs as well as the checks, so nothing reaches a registry that the
|
||||
# emulators and the simulator have not agreed on, and both release the commit
|
||||
# this run tested rather than whatever master drifted to while it ran.
|
||||
#
|
||||
# These jobs live here rather than in a workflow of their own because npm
|
||||
# matches a package's one trusted publisher against the filename of the
|
||||
# workflow that starts the run. See docs/development/ci.md.
|
||||
release-tag:
|
||||
name: Tag
|
||||
needs:
|
||||
- checks
|
||||
- folio
|
||||
- replay-ui
|
||||
if: >-
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/master') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.promote != 'none')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ steps.next.outputs.version }}
|
||||
tag: ${{ steps.next.outputs.tag }}
|
||||
previous_tag: ${{ steps.next.outputs.previous_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# The version is counted off the tags, so the tags have to be here.
|
||||
fetch-depth: 0
|
||||
|
||||
# `inputs` is empty on a push, which leaves the resolver on its default of
|
||||
# a patch: that is the bump a merge cuts.
|
||||
- name: Resolve the version
|
||||
id: next
|
||||
run: .github/scripts/next-version.sh
|
||||
env:
|
||||
BUMP: ${{ inputs.promote }}
|
||||
|
||||
# Nothing is published until this lands, so a version that cannot be
|
||||
# tagged never reaches a registry. npm is the half of a release that
|
||||
# cannot be taken back and a tag is the half that can.
|
||||
- name: Tag the commit
|
||||
run: |
|
||||
git -c user.name='github-actions[bot]' \
|
||||
-c user.email='41898282+github-actions[bot]@users.noreply.github.com' \
|
||||
tag -a "$TAG" -m "$TAG"
|
||||
git push origin "refs/tags/$TAG"
|
||||
env:
|
||||
TAG: ${{ steps.next.outputs.tag }}
|
||||
|
||||
release-npm:
|
||||
name: Release (npm)
|
||||
needs: release-tag
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
# npm authenticates this publish over OIDC against the trusted publisher
|
||||
# configured for @sanderling/spec, so the job holds no token and there is
|
||||
# none to expire. npm revoked every classic token in December 2025 and
|
||||
# caps a granular one at 90 days, so a token here would break quarterly.
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.release-tag.outputs.tag }}
|
||||
# `npm ci` below runs dependency lifecycle scripts, and no step in
|
||||
# this job needs the git credential afterwards.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node 22
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: npm
|
||||
cache-dependency-path: pkg/spec/package-lock.json
|
||||
|
||||
# registry-url above writes an `_authToken=${NODE_AUTH_TOKEN}` line into
|
||||
# .npmrc whether or not a token exists, and an npm older than 11.5.1 reads
|
||||
# that empty line as "auth is configured" and never asks for an OIDC
|
||||
# token, so the publish fails needing auth. Node 22 ships npm 10.
|
||||
- name: Install an npm that can publish over OIDC
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: pkg/spec
|
||||
run: npm ci
|
||||
|
||||
# The repo keeps package.json at 0.0.0-dev. The tags are the record of
|
||||
# what has been released, and a version committed to master would be a
|
||||
# second record to hold in step with them.
|
||||
- name: Stamp the version
|
||||
working-directory: pkg/spec
|
||||
run: npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
env:
|
||||
VERSION: ${{ needs.release-tag.outputs.version }}
|
||||
|
||||
# A publish that landed and then failed on its way out leaves npm holding
|
||||
# the version, and re-running the job must not be red for it. The registry
|
||||
# is asked rather than the tags: only npm knows what npm has. Only stdout
|
||||
# decides, because `npm view` on a version that does not exist is empty on
|
||||
# some npm releases and an error on others, and an unreachable registry
|
||||
# must end in a publish that fails loudly rather than a skip that reads as
|
||||
# success.
|
||||
- name: Ask npm whether this version is already published
|
||||
id: published
|
||||
run: |
|
||||
if [ -n "$(npm view "@sanderling/spec@$VERSION" version 2>/dev/null || true)" ]; then
|
||||
echo "npm already has @sanderling/spec@$VERSION, nothing to publish"
|
||||
echo "publish=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
env:
|
||||
VERSION: ${{ needs.release-tag.outputs.version }}
|
||||
|
||||
- name: Publish @sanderling/spec to npm
|
||||
if: steps.published.outputs.publish == 'true'
|
||||
working-directory: pkg/spec
|
||||
run: npm publish --access public
|
||||
|
||||
release-cli:
|
||||
name: Release (cli)
|
||||
needs: release-tag
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.release-tag.outputs.tag }}
|
||||
# GoReleaser reads the tag history for its changelog.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Build sidecar JAR
|
||||
run: make sidecar
|
||||
|
||||
# GoReleaser reaches back to the release before this one on its own, which
|
||||
# is right for a patch and wrong for a milestone: the notes on a 0.2.0
|
||||
# consolidating six patches would cover the last merge only.
|
||||
# GORELEASER_PREVIOUS_TAG moves that boundary back to the last release at
|
||||
# this one's level, and an empty value leaves GoReleaser on its own
|
||||
# default, which is what a patch passes.
|
||||
- name: Publish the sanderling CLI to GitHub Releases
|
||||
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3
|
||||
with:
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GORELEASER_PREVIOUS_TAG: ${{ needs.release-tag.outputs.previous_tag }}
|
||||
|
||||
release:
|
||||
name: Release
|
||||
if: always()
|
||||
needs:
|
||||
- release-npm
|
||||
- release-cli
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check the group passed
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||
run: exit 1
|
||||
|
||||
# The docs used to build only when docs/ or the Makefile changed. A path
|
||||
# filter here would have to sit on the whole workflow, so the site is rebuilt
|
||||
# on every merge instead: it is pandoc over a few pages, and a deploy of bytes
|
||||
# that did not change is a no-op.
|
||||
docs:
|
||||
name: Docs
|
||||
needs: checks
|
||||
if: github.ref == 'refs/heads/master'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
# Pages takes one deployment at a time.
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install pandoc
|
||||
run: sudo apt-get update && sudo apt-get install -y pandoc
|
||||
|
||||
- name: Build site
|
||||
run: make docs
|
||||
|
||||
# No include-hidden-files: v4 stopped uploading dot-files by default, and
|
||||
# build/site has none. It is pandoc output plus a copy of docs/_assets,
|
||||
# which holds three ordinary files. _assets is underscore-prefixed, not
|
||||
# hidden, and deploy-pages serves the artifact without running Jekyll, so
|
||||
# it needs no .nojekyll either.
|
||||
- uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: build/site
|
||||
|
||||
- uses: actions/deploy-pages@v5
|
||||
id: deployment
|
||||
|
||||
# The one status check to point branch protection at. Without `if: always()`
|
||||
# this would be skipped along with anything that skipped, and a skipped
|
||||
# required check reads as a pass.
|
||||
all-checks-passed:
|
||||
name: All checks passed
|
||||
if: always()
|
||||
needs:
|
||||
- checks
|
||||
- folio
|
||||
- replay-ui
|
||||
- release
|
||||
- docs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check all jobs passed
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
|
||||
run: exit 1
|
||||
@@ -1,45 +0,0 @@
|
||||
name: docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "Makefile"
|
||||
- ".github/workflows/docs.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pandoc
|
||||
run: sudo apt-get update && sudo apt-get install -y pandoc
|
||||
|
||||
- name: Build site
|
||||
run: make docs
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: build/site
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/deploy-pages@v4
|
||||
id: deployment
|
||||
@@ -1,266 +0,0 @@
|
||||
name: folio
|
||||
|
||||
# One spec, three platforms. Dispatch-only: each job boots a device or a
|
||||
# browser, builds the folio app for that platform, and runs
|
||||
# examples/folio/sanderling/spec.ts against it.
|
||||
#
|
||||
# web and ios are expect-the-bug jobs: folio double-submits a transaction on a
|
||||
# double tap, so the run is supposed to end with exit 2. Exit 0 means the fuzzer
|
||||
# stopped finding a bug that is still there; exit 1 means the harness broke. The
|
||||
# two are worth telling apart, which is why --exit-on-violation exits 2 and not
|
||||
# 1.
|
||||
#
|
||||
# android is a health gate. It convicts in four runs out of five, which is real
|
||||
# evidence but not a gate: the fifth would report a regression it had not found.
|
||||
# The budget is set so the conviction it usually gets is reported as a bonus.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platforms:
|
||||
description: which legs to run
|
||||
type: choice
|
||||
options: [all, android, ios, web]
|
||||
default: all
|
||||
seed:
|
||||
description: seed override (0 = each job's calibrated seed)
|
||||
default: "0"
|
||||
duration:
|
||||
description: wall-clock budget per run
|
||||
default: 20m
|
||||
max-steps:
|
||||
description: step budget override (0 = each job's calibrated budget)
|
||||
default: "0"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
android:
|
||||
timeout-minutes: 90
|
||||
if: ${{ inputs.platforms == 'all' || inputs.platforms == 'android' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Set up bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: folio-gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
folio-gradle-${{ runner.os }}-
|
||||
|
||||
# Without this the emulator falls back to software rendering and every
|
||||
# step costs several seconds.
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Build the folio APK
|
||||
run: ./gradlew :app:androidApp:assembleDebug
|
||||
working-directory: examples/folio
|
||||
|
||||
- name: Build sanderling
|
||||
run: make sanderling-android
|
||||
|
||||
- name: Fuzz folio on an emulator
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: 34
|
||||
target: google_apis
|
||||
arch: x86_64
|
||||
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim
|
||||
disable-animations: true
|
||||
script: .github/scripts/folio-run.sh android
|
||||
env:
|
||||
SEED: ${{ inputs.seed != '0' && inputs.seed || '9' }}
|
||||
MAX_STEPS: ${{ inputs.max-steps != '0' && inputs.max-steps || '200' }}
|
||||
DURATION: ${{ inputs.duration }}
|
||||
|
||||
- name: Upload the run
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: folio-android
|
||||
path: runs/
|
||||
retention-days: 14
|
||||
|
||||
ios:
|
||||
timeout-minutes: 90
|
||||
if: ${{ inputs.platforms == 'all' || inputs.platforms == 'ios' }}
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Set up bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
# idb-companion is not in homebrew-core, only in facebook/homebrew-fb, so
|
||||
# it has to be named by its full tap path. xcodegen and just are core.
|
||||
- name: Install idb-companion, xcodegen and just
|
||||
run: brew install facebook/fb/idb-companion xcodegen just
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
# The iOS app builds its Kotlin framework through the folio gradle
|
||||
# project, which configures :app:androidApp and so needs an Android SDK
|
||||
# even on this leg.
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
# Both asset tarballs are built by the prepare scripts, and the runner
|
||||
# bundle is an xcodebuild of companion/Sources. Keyed on the scripts and
|
||||
# the versions the Makefile embeds, so a later run reuses them.
|
||||
- name: Cache the companion and runner bundles
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
internal/driver/ioscompanion/companionassets/assets
|
||||
internal/driver/ioscompanion/runnerassets/assets
|
||||
key: ios-assets-${{ runner.os }}-${{ hashFiles('internal/driver/ioscompanion/companionassets/prepare.sh', 'companion/prepare.sh', 'companion/project.yml', 'companion/Sources/**') }}
|
||||
|
||||
- name: Build sanderling
|
||||
run: make sanderling-ios
|
||||
|
||||
- name: Boot a simulator
|
||||
run: |
|
||||
xcrun simctl boot "$IOS_DEVICE" || true
|
||||
xcrun simctl bootstatus "$IOS_DEVICE" -b
|
||||
env:
|
||||
IOS_DEVICE: iPhone 16 Pro
|
||||
|
||||
- name: Build and install folio
|
||||
run: just ios
|
||||
working-directory: examples/folio
|
||||
env:
|
||||
IOS_DEVICE: iPhone 16 Pro
|
||||
|
||||
# `just ios` leaves the app running, and the run's own clear-data
|
||||
# reinstall on top of a live app has raced FrontBoard into refusing the
|
||||
# launch ("app.folio is unknown to FrontBoard") with the run then hanging.
|
||||
- name: Stop the app before the run
|
||||
run: xcrun simctl terminate booted app.folio || true
|
||||
|
||||
- name: Fuzz folio on the simulator
|
||||
run: .github/scripts/folio-run.sh ios
|
||||
env:
|
||||
SEED: ${{ inputs.seed != '0' && inputs.seed || '7' }}
|
||||
MAX_STEPS: ${{ inputs.max-steps != '0' && inputs.max-steps || '240' }}
|
||||
DURATION: ${{ inputs.duration }}
|
||||
IOS_DEVICE: iPhone 16 Pro
|
||||
|
||||
- name: Upload the run
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: folio-ios
|
||||
path: runs/
|
||||
retention-days: 14
|
||||
|
||||
web:
|
||||
timeout-minutes: 60
|
||||
if: ${{ inputs.platforms == 'all' || inputs.platforms == 'web' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Set up bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: folio-gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
folio-gradle-${{ runner.os }}-
|
||||
|
||||
- name: Set up Chrome
|
||||
uses: browser-actions/setup-chrome@v1
|
||||
with:
|
||||
chrome-version: stable
|
||||
|
||||
- name: Allow Chrome under unprivileged user namespaces
|
||||
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
|
||||
- name: Verify headless Chrome starts
|
||||
run: |
|
||||
chrome --version
|
||||
chrome --headless --no-sandbox --disable-gpu --disable-dev-shm-usage \
|
||||
--dump-dom 'data:text/html,<title>ok</title>'
|
||||
|
||||
- name: Build the folio wasmJs app
|
||||
run: ./gradlew :app:webApp:wasmJsBrowserDevelopmentExecutableDistribution
|
||||
working-directory: examples/folio
|
||||
|
||||
- name: Build sanderling
|
||||
run: make sanderling-web
|
||||
|
||||
- name: Fuzz folio in the browser
|
||||
run: .github/scripts/folio-run.sh web
|
||||
env:
|
||||
SEED: ${{ inputs.seed != '0' && inputs.seed || '3' }}
|
||||
MAX_STEPS: ${{ inputs.max-steps != '0' && inputs.max-steps || '240' }}
|
||||
DURATION: ${{ inputs.duration }}
|
||||
|
||||
- name: Upload the run
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: folio-web
|
||||
path: runs/
|
||||
retention-days: 14
|
||||
@@ -1,107 +0,0 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag to release (e.g. v0.0.1-rc1). Must already exist."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release-npm:
|
||||
name: Publish @sanderling/spec to npm
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: |
|
||||
raw="${{ inputs.tag || github.ref_name }}"
|
||||
echo "version=${raw#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Node 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: npm
|
||||
cache-dependency-path: pkg/spec/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: pkg/spec
|
||||
run: npm ci
|
||||
|
||||
- name: Stamp version
|
||||
working-directory: pkg/spec
|
||||
run: npm version ${{ steps.ver.outputs.version }} --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Publish
|
||||
working-directory: pkg/spec
|
||||
# npm tag pre-releases (e.g. 0.1.0-rc1) as "next" so npm install @sanderling/spec
|
||||
# keeps resolving the latest stable.
|
||||
run: |
|
||||
if [[ "${{ steps.ver.outputs.version }}" == *-* ]]; then
|
||||
npm publish --access public --tag next
|
||||
else
|
||||
npm publish --access public
|
||||
fi
|
||||
|
||||
release-cli:
|
||||
name: Publish sanderling CLI to GitHub Releases
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Build sidecar JAR
|
||||
run: make sidecar
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,148 +0,0 @@
|
||||
name: replay-ui
|
||||
|
||||
# Sanderling fuzzing sanderling's own replay UI. Dispatch-only: it takes minutes
|
||||
# and it is a demo of the product loop, not a merge gate.
|
||||
#
|
||||
# The shape is: produce a real trace, serve it with `sanderling replay`, then run
|
||||
# a spec against that UI. Any violation fails the job. Six of the seven
|
||||
# properties in replay-ui/sanderling/spec.ts are cross-panel agreements that hold
|
||||
# for any trace; the seventh is the stock noUncaughtExceptions. None of them
|
||||
# needs recalibrating when the fixture changes.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
seed:
|
||||
description: seed for the dogfood run
|
||||
default: "3"
|
||||
max-steps:
|
||||
description: step budget for the dogfood run
|
||||
default: "80"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dogfood:
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Set up bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
# Pinned stable plus the AppArmor sysctl: the same setup ci.yml's browser
|
||||
# job needs to get headless Chrome up on ubuntu-latest.
|
||||
- name: Set up Chrome
|
||||
uses: browser-actions/setup-chrome@v1
|
||||
with:
|
||||
chrome-version: stable
|
||||
|
||||
- name: Allow Chrome under unprivileged user namespaces
|
||||
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
|
||||
- name: Verify headless Chrome starts
|
||||
run: |
|
||||
chrome --version
|
||||
chrome --headless --no-sandbox --disable-gpu --disable-dev-shm-usage \
|
||||
--dump-dom 'data:text/html,<title>ok</title>'
|
||||
|
||||
# The UI the spec drives is the one embedded in this binary, so the build
|
||||
# has to come after any change to replay-ui/src.
|
||||
- name: Build sanderling
|
||||
run: make sanderling-web
|
||||
|
||||
# A trace with a violation and uncaught exceptions in it, so the UI has
|
||||
# something to render in every panel the spec looks at. No
|
||||
# --exit-on-violation here: the run is the fixture, and stopping it at the
|
||||
# first violation would leave a four-step trace to fuzz.
|
||||
- name: Record a fixture trace
|
||||
run: |
|
||||
python3 -m http.server 8792 --bind 127.0.0.1 \
|
||||
--directory test/browser/testdata/throwing &
|
||||
ready=""
|
||||
for _ in $(seq 1 30); do
|
||||
curl -sf http://127.0.0.1:8792/ >/dev/null && { ready=1; break; }
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$ready" ]; then
|
||||
echo "the fixture http server never answered on 127.0.0.1:8792" >&2
|
||||
exit 1
|
||||
fi
|
||||
./bin/sanderling test \
|
||||
--platform web \
|
||||
--spec test/browser/testdata/throwing/spec.ts \
|
||||
--bundle-id http://127.0.0.1:8792/ \
|
||||
--duration 5m --max-steps 25 --seed 7 \
|
||||
--output runs/fixture
|
||||
|
||||
- name: Serve the trace with sanderling replay
|
||||
run: |
|
||||
# Flags before the positional argument: Go's flag package stops
|
||||
# parsing at the first non-flag word.
|
||||
./bin/sanderling replay --port 8793 --no-open runs/fixture &
|
||||
ready=""
|
||||
for _ in $(seq 1 30); do
|
||||
curl -sf http://127.0.0.1:8793/api/runs >/dev/null && { ready=1; break; }
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$ready" ]; then
|
||||
echo "sanderling replay never served /api/runs on 127.0.0.1:8793" >&2
|
||||
exit 1
|
||||
fi
|
||||
run_id="$(ls runs/fixture | head -1)"
|
||||
echo "RUN_URL=http://127.0.0.1:8793/runs/$run_id/steps/1" >> "$GITHUB_ENV"
|
||||
curl -sf "http://127.0.0.1:8793/runs/$run_id/steps/1" >/dev/null
|
||||
|
||||
# Inputs go through env rather than into the script text: a `${{ }}` is
|
||||
# substituted before bash ever sees the line, so a seed of `$(id)` would
|
||||
# run as a command.
|
||||
- name: Fuzz the replay UI
|
||||
run: |
|
||||
./bin/sanderling test \
|
||||
--platform web \
|
||||
--spec replay-ui/sanderling/spec.ts \
|
||||
--bundle-id "$RUN_URL" \
|
||||
--duration 10m \
|
||||
--max-steps "$MAX_STEPS" \
|
||||
--seed "$SEED" \
|
||||
--exit-on-violation \
|
||||
--output runs/dogfood
|
||||
env:
|
||||
SEED: ${{ inputs.seed }}
|
||||
MAX_STEPS: ${{ inputs.max-steps }}
|
||||
|
||||
- name: Summarise
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "### replay-ui dogfood"
|
||||
echo
|
||||
echo "- seed \`$SEED\`, budget $MAX_STEPS steps"
|
||||
for dir in runs/dogfood/*/; do
|
||||
[ -f "$dir/trace.jsonl" ] || continue
|
||||
steps=$(wc -l < "$dir/trace.jsonl" | tr -d ' ')
|
||||
violations=$(grep -c '"violations":\[' "$dir/trace.jsonl" || true)
|
||||
echo "- $steps steps recorded, $violations step(s) with violations"
|
||||
done
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
env:
|
||||
SEED: ${{ inputs.seed }}
|
||||
MAX_STEPS: ${{ inputs.max-steps }}
|
||||
|
||||
- name: Upload runs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: replay-ui-runs
|
||||
path: runs/
|
||||
retention-days: 14
|
||||
+1
-1
@@ -50,7 +50,7 @@ internal/driver/ioscompanion/companionassets/assets/companion-*.tar.gz
|
||||
# before `go build -tags withcompanion`. Never commit: it's ~5 MB.
|
||||
internal/driver/ioscompanion/runnerassets/assets/runner-*.tar.gz
|
||||
|
||||
# spec-api compiled output
|
||||
# spec package compiled output
|
||||
pkg/spec/dist/
|
||||
|
||||
# goreleaser local output
|
||||
|
||||
+4
-4
@@ -5,10 +5,10 @@ project_name: sanderling
|
||||
before:
|
||||
hooks:
|
||||
# The Go binary embeds the sidecar fat JAR via //go:embed gated by the
|
||||
# `withsidecar` build tag. Rebuild the JAR and stage it at the embed
|
||||
# path so the `go build` below picks up fresh bytes.
|
||||
- make sidecar
|
||||
- sh -c 'mkdir -p internal/sidecar/assets && cp sidecar/build/libs/sidecar-all.jar internal/sidecar/assets/sidecar-all.jar'
|
||||
# `withsidecar` build tag. The Makefile target rebuilds the JAR and stages
|
||||
# it at the embed path, so the `go build` below picks up fresh bytes and
|
||||
# that path stays spelled out in exactly one place.
|
||||
- make sidecar-embed
|
||||
|
||||
builds:
|
||||
- id: sanderling
|
||||
|
||||
@@ -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.
|
||||
@@ -29,7 +29,7 @@ WEB_DIST := replay-ui/dist
|
||||
|
||||
GOLINES := $(shell $(GO) env GOPATH)/bin/golines
|
||||
|
||||
.PHONY: bootstrap proto sidecar sanderling sanderling-web sanderling-android sanderling-ios install test test-go test-browser test-companion test-kotlin test-spec-api spec-typecheck web-test web-typecheck web-build web-dev replay-dev docs clean release-cli release-npm-dry fmt fmt-go fmt-kotlin fmt-ts fmt-swift
|
||||
.PHONY: bootstrap proto sidecar sidecar-embed sanderling sanderling-web sanderling-android sanderling-ios install test test-go test-browser test-companion test-kotlin test-folio test-spec-api test-ci-scripts spec-typecheck web-test web-typecheck web-build web-dev replay-dev docs clean release-cli release-npm-dry fmt fmt-go fmt-kotlin fmt-ts fmt-swift
|
||||
|
||||
bootstrap:
|
||||
$(GO) mod download
|
||||
@@ -42,6 +42,10 @@ proto:
|
||||
|
||||
sidecar: $(SIDECAR_JAR)
|
||||
|
||||
# Stages the JAR where //go:embed expects it. Release tooling calls this rather
|
||||
# than copying the JAR itself, so the embed path is spelled out in one place.
|
||||
sidecar-embed: $(SIDECAR_EMBED)
|
||||
|
||||
sanderling: $(SANDERLING_BIN)
|
||||
|
||||
$(SANDERLING_BIN): $(SIDECAR_EMBED) $(COMPANION_EMBED) $(RUNNER_EMBED) web-build
|
||||
@@ -117,7 +121,7 @@ fmt-ts:
|
||||
fmt-swift:
|
||||
xcrun swift-format format -i -r companion/Sources
|
||||
|
||||
test: test-go test-kotlin spec-typecheck test-spec-api web-typecheck web-test
|
||||
test: test-go test-kotlin spec-typecheck test-spec-api web-typecheck web-test test-ci-scripts
|
||||
|
||||
test-go:
|
||||
$(GO) test $(GO_PACKAGES)
|
||||
@@ -141,6 +145,21 @@ test-companion: $(COMPANION_EMBED) $(RUNNER_EMBED)
|
||||
test-kotlin:
|
||||
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sidecar:test
|
||||
|
||||
# folio is its own gradle build, so nothing in the root build runs its tests.
|
||||
# Kept out of `test` because the metro plugin folio compiles with needs a 21+
|
||||
# runtime, where the sidecar toolchain pins 17: folding this in would raise the
|
||||
# JDK floor of the target everyone runs constantly. CI runs it as its own step.
|
||||
test-folio:
|
||||
cd examples/folio && ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) \
|
||||
:core:testDebugUnitTest :app:shared:testDebugUnitTest
|
||||
|
||||
# The CI scripts that read a trace and decide whether a green leg is
|
||||
# evidence. bash and python3 only, which is all a runner has.
|
||||
test-ci-scripts:
|
||||
.github/scripts/replay-ui-summary-test.sh
|
||||
.github/scripts/folio-run-test.sh
|
||||
.github/scripts/next-version-test.sh
|
||||
|
||||
test-spec-api:
|
||||
cd pkg/spec && npm test --silent
|
||||
|
||||
@@ -171,7 +190,7 @@ $(PAGE_OUT): build/site/%/index.html: docs/%.md $(DOCS_TEMPLATE)
|
||||
|
||||
clean:
|
||||
$(GO) clean
|
||||
rm -rf bin dist pkg/spec-api/dist build/site
|
||||
rm -rf bin dist pkg/spec/dist build/site
|
||||
$(GRADLE) clean
|
||||
|
||||
# Local release dry-runs. None of these touch remote registries.
|
||||
|
||||
+12
-19
@@ -5,15 +5,14 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/android"
|
||||
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion"
|
||||
"github.com/priyanshujain/sanderling/internal/ios"
|
||||
"github.com/priyanshujain/sanderling/internal/sidecarassets"
|
||||
@@ -52,8 +51,8 @@ func webChecks() []doctorCheck {
|
||||
|
||||
func androidChecks() []doctorCheck {
|
||||
return []doctorCheck{
|
||||
{Name: "adb on PATH", Run: checkExecutableOnPath("adb")},
|
||||
{Name: "emulator on PATH or under ANDROID_HOME", Run: checkEmulator},
|
||||
{Name: "adb on PATH or under the Android SDK", Run: checkAdb},
|
||||
{Name: "emulator on PATH or under the Android SDK", Run: checkEmulator},
|
||||
{Name: "java 17+ on PATH", Run: checkJavaVersion},
|
||||
{Name: "sidecar JAR is real (not placeholder)", Run: checkSidecarJAR},
|
||||
}
|
||||
@@ -235,22 +234,16 @@ func checkExecutableOnPath(name string) func(context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// checkAdb and checkEmulator resolve through the same helpers a run uses, so a
|
||||
// host the doctor passes is a host a run can drive.
|
||||
func checkAdb(_ context.Context) error {
|
||||
_, err := android.AdbBinary()
|
||||
return err
|
||||
}
|
||||
|
||||
func checkEmulator(_ context.Context) error {
|
||||
if _, err := exec.LookPath("emulator"); err == nil {
|
||||
return nil
|
||||
}
|
||||
androidHome := os.Getenv("ANDROID_HOME")
|
||||
if androidHome == "" {
|
||||
androidHome = os.Getenv("ANDROID_SDK_ROOT")
|
||||
}
|
||||
if androidHome == "" {
|
||||
return fmt.Errorf("not on PATH and ANDROID_HOME is unset")
|
||||
}
|
||||
candidate := filepath.Join(androidHome, "emulator", "emulator")
|
||||
if _, err := os.Stat(candidate); err != nil {
|
||||
return fmt.Errorf("not found at %s", candidate)
|
||||
}
|
||||
return nil
|
||||
_, err := android.EmulatorBinary()
|
||||
return err
|
||||
}
|
||||
|
||||
var javaVersionPattern = regexp.MustCompile(`(?:java|openjdk)[^"]*"(\d+)(?:\.(\d+))?`)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -110,6 +112,43 @@ func TestDoctorChecksFor_Android_IncludesADB(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The SDK tools a run invokes are resolved through $ANDROID_HOME and the
|
||||
// standard install locations, never PATH alone, so a doctor that turns away a
|
||||
// host on a PATH lookup condemns a setup every run on it would drive fine.
|
||||
func TestAndroidChecks_AcceptSDKToolsThatAreNotOnPath(t *testing.T) {
|
||||
sdk := t.TempDir()
|
||||
for _, tool := range []string{"platform-tools/adb", "emulator/emulator"} {
|
||||
path := filepath.Join(sdk, filepath.FromSlash(tool))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, nil, 0o755); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
t.Setenv("ANDROID_HOME", sdk)
|
||||
t.Setenv("ANDROID_SDK_ROOT", "")
|
||||
|
||||
var sdkChecks []doctorCheck
|
||||
for _, check := range doctorChecksFor("android") {
|
||||
if strings.Contains(check.Name, "adb") || strings.Contains(check.Name, "emulator") {
|
||||
sdkChecks = append(sdkChecks, check)
|
||||
}
|
||||
}
|
||||
if len(sdkChecks) != 2 {
|
||||
t.Fatalf("expected the adb and emulator checks, got %d", len(sdkChecks))
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
if err := runDoctorChecks(context.Background(), sdkChecks, &stdout); err != nil {
|
||||
t.Fatalf("doctor: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "FAIL") {
|
||||
t.Errorf("doctor rejected an SDK it can resolve:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorChecksFor_iOS_IncludesXcrun(t *testing.T) {
|
||||
checks := doctorChecksFor("ios")
|
||||
found := false
|
||||
@@ -129,7 +168,7 @@ func TestDoctorChecksFor_All_IsUnion(t *testing.T) {
|
||||
for _, c := range all {
|
||||
names[c.Name]++
|
||||
}
|
||||
for _, name := range []string{"adb on PATH", "xcrun on PATH (ios simulator)", "headless chromium can launch"} {
|
||||
for _, name := range []string{"adb on PATH or under the Android SDK", "xcrun on PATH (ios simulator)", "headless chromium can launch"} {
|
||||
if names[name] != 1 {
|
||||
t.Errorf("expected %q in 'all' exactly once, got %d", name, names[name])
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ enum AppLifecycle {
|
||||
}
|
||||
|
||||
static func launch(bundleIdentifier: String, foregroundIfRunning: Bool) throws {
|
||||
var reached = XCUIApplication.State.unknown
|
||||
try onMainCatching {
|
||||
let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
|
||||
if foregroundIfRunning {
|
||||
@@ -18,6 +19,17 @@ enum AppLifecycle {
|
||||
} else {
|
||||
application.launch()
|
||||
}
|
||||
reached = application.state
|
||||
}
|
||||
// A refused launch is recorded as a test issue that never throws, so
|
||||
// without this the runner answers ok for an app that is not running
|
||||
// and the host learns nothing until its own bound expires.
|
||||
switch reached {
|
||||
case .runningForeground, .runningBackground, .runningBackgroundSuspended:
|
||||
return
|
||||
default:
|
||||
throw LifecycleError.failed(
|
||||
"\(bundleIdentifier) is \(name(of: reached)) after launch")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +66,21 @@ enum AppLifecycle {
|
||||
return result
|
||||
}
|
||||
|
||||
private static func name(of state: XCUIApplication.State) -> String {
|
||||
switch state {
|
||||
case .runningForeground:
|
||||
return "foreground"
|
||||
case .runningBackground:
|
||||
return "background"
|
||||
case .runningBackgroundSuspended:
|
||||
return "suspended"
|
||||
case .notRunning:
|
||||
return "not running"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// onMainCatching runs automation work on the main thread and converts a
|
||||
// framework assertion into a thrown error so the server survives it.
|
||||
private static func onMainCatching(_ work: @escaping () -> Void) throws {
|
||||
|
||||
+215
-37
@@ -4,22 +4,18 @@ title: CI
|
||||
|
||||
# CI
|
||||
|
||||
`ci.yml` runs on every pull request: it builds, unit-tests, and drives three
|
||||
small web fixtures through headless Chrome (`test/browser/testdata`). It never
|
||||
runs sanderling against a real app.
|
||||
`ci.yml` runs on every pull request and every push to master. The `Check`
|
||||
jobs build, unit-test, and drive three small web fixtures through headless
|
||||
Chrome (`test/browser/testdata`). The `Folio` and `Replay UI` jobs in the same
|
||||
workflow do run sanderling against real apps, on emulators and simulators, and
|
||||
they are what make a run take the better part of an hour.
|
||||
|
||||
Two other workflows do, and both are `workflow_dispatch` only. They boot devices,
|
||||
build apps and take minutes, which is not what you want on every push, and
|
||||
neither is a merge gate.
|
||||
`All checks passed` is the one status check to point branch protection at, and
|
||||
it is what gates a release: `release.yml` cuts one only after a whole ci run
|
||||
went green. See [Releases](#releases) at the bottom.
|
||||
|
||||
## folio
|
||||
|
||||
Actions -> folio -> Run workflow. Inputs pick the legs (`all`, `android`, `ios`,
|
||||
`web`), and override the seed, the step budget and the wall-clock budget. Seed
|
||||
and step budget take `0` to mean "use the calibrated value in the workflow"; the
|
||||
wall-clock budget has no such sentinel and is passed through as written, so
|
||||
every leg gets whatever you type there.
|
||||
|
||||
Each leg builds `examples/folio` for its platform, builds the CLI with only the
|
||||
tags that platform needs (`make sanderling-android` and friends), and runs
|
||||
`examples/folio/sanderling/spec.ts` through `.github/scripts/folio-run.sh`. That
|
||||
@@ -32,8 +28,8 @@ SEED=9 MAX_STEPS=200 .github/scripts/folio-run.sh android
|
||||
|
||||
**web and ios expect the bug.** Folio double-submits a transaction when the
|
||||
submit button is double-tapped, and two properties catch it:
|
||||
`submitMovesBalanceByTypedAmount`, which demands the total balance move by
|
||||
exactly the amount typed, and `submitCommitsOneTransactionPerAction`, which
|
||||
`submitMovesBalanceByAtMostTypedAmount`, which demands the total balance move by
|
||||
no more than the amount typed, and `submitCommitsOneTransactionPerAction`, which
|
||||
demands no more transactions committed over a window than there were submit
|
||||
actions in it. A double tap is one action committing two transactions, so it
|
||||
breaks both.
|
||||
@@ -96,27 +92,83 @@ The wasmJs app is served with `Cross-Origin-Opener-Policy` and
|
||||
cross-origin isolation. Served without them the app loads a blank canvas and
|
||||
every step observes an empty accessibility tree.
|
||||
|
||||
The seeds are calibrated, not guessed. On an M-series mac, web seed 3 convicts at
|
||||
step 185-187 and ios seed 7 at step 97-101, each 3 runs out of 3 and each with a
|
||||
delta of exactly twice the typed amount. Both run a 240-step budget. Keep them
|
||||
pinned: honest evidence is rare, and across 2261 ios steps only one submit tap
|
||||
landing on Home had a single-submit window.
|
||||
The seeds are calibrated, not guessed, and every number here says which host it
|
||||
was measured on, because the hosts do not agree. On an M3 mac driving iOS 26.1
|
||||
simulators, ios seed 7 convicts at step 97-101, 11 runs out of 11 from a cleared
|
||||
install, each on both properties and each with the balance moving by exactly
|
||||
twice the typed amount: 199 typed, 39800 cents moved, one account's transaction
|
||||
count rising by two against a window holding one submit. Web seed 3 convicts at
|
||||
step 185-187 on that mac and at step 192 on the ubuntu runner. Both legs run a
|
||||
240-step budget.
|
||||
|
||||
Android runs seed 9 over 200 steps: its conviction lands at step 178, so a
|
||||
What those numbers assume is a cleared starting state, and that is the only thing
|
||||
that moved them. Measured four ways on one simulator, seed 7 convicts at step 97
|
||||
from a fresh install with clear-state on, at 100 from a fresh install with it
|
||||
off, and at 97 from a dirty container with it on. It walks 240 steps clean
|
||||
exactly once: dirty container, clear-state off, where the app opens already
|
||||
signed in on the previous run's accounts and the walk diverges at step 1. The leg
|
||||
therefore clears state for itself rather than relying on how it was called.
|
||||
|
||||
Do not read a mac number as a statement about CI, but the ios leg does now
|
||||
convict there. It had never done so before 2026-08-16, through every dispatch,
|
||||
and no seed was ever the reason. `submitCommitsOneTransactionPerAction` counted
|
||||
every tap on TxnSubmit toward its window, including taps the app refuses because
|
||||
the amount field is empty, and more than half of a typical window was those.
|
||||
Measured on recorded android runs: 35 taps against a real budget of 16, and 42
|
||||
against 17. A window that wide cannot attribute anything, which is why seed 28
|
||||
reached the bug at the step it convicts at locally and was still not judged.
|
||||
|
||||
`submitCouldCommit` stopped counting them, and the numbers moved a long way:
|
||||
|
||||
leg before after (run 31898888205)
|
||||
ios 240 steps clean, every time convicts step 59, detected 60
|
||||
web step 192 on the ubuntu runner convicts step 185, detected 186
|
||||
android never reached AddTransaction healthy over 200 steps, reached it
|
||||
|
||||
The ios witness at that conviction reads one account's transaction count rising
|
||||
from 0 to 7 against a window holding 6 submits, with `applied: true` on the
|
||||
action and `is_error` unset. Seven transactions from six submits is one double
|
||||
submit, which is the bug the leg exists to find.
|
||||
|
||||
On the calibration mac the same seed now convicts around step 48, twice in a row,
|
||||
where it used to convict at 97-101. Treat both as approximate: the point is that
|
||||
the window is now tight enough to attribute a submit, not that any particular
|
||||
step number is pinned. A run that fails is worth reading before it is worth
|
||||
recalibrating.
|
||||
|
||||
Android runs seed 9 over 200 steps. Its conviction lands around step 178, and a
|
||||
shorter budget would never see the bonus. A full run costs about five minutes.
|
||||
|
||||
Repeating the ios leg by hand is not the same as running it in CI: with
|
||||
`--clear-data=false` a second local run inherits the first one's accounts, so
|
||||
`simctl uninstall` before each repeat or the numbers drift.
|
||||
That step number was measured on a local emulator with animations ON, and the CI
|
||||
job sets `disable-animations: true`, so it does not describe the CI leg. The
|
||||
worry that follows is that zeroing the 700ms Compose fade would stop the leg
|
||||
exercising the cross-fade wait entirely, and the traces say that worry is
|
||||
largely right. The first dispatch, whose run was stuck on one screen, carried 4
|
||||
`transitional` steps in 200. The healthy runs since carry **zero**. So on CI the
|
||||
wait almost never fires, and a leg that is green there is not evidence the wait
|
||||
works. Local runs with animations on are where that gets exercised. Treat the
|
||||
android number as an order of magnitude, not a pin. It is a health gate, so
|
||||
nothing keys on it.
|
||||
|
||||
The ios leg passes `--clear-data=false`, because the job installs a fresh build
|
||||
immediately before the run and a freshly installed app is already clear state.
|
||||
The in-run reinstall is worth avoiding: `simctl uninstall` + `install` followed
|
||||
straight away by the XCTest runner's own launch fails with `app.folio is unknown
|
||||
to FrontBoard` maybe half the time. That used to hang the run outright; the
|
||||
launch RPC is bounded now, so it fails in about 90 seconds with a real error
|
||||
instead, but a failing leg is still a failing leg. The job timeouts are the
|
||||
backstop if it happens anyway.
|
||||
Repeating the ios leg by hand needs nothing special now, because the run clears
|
||||
the app's state itself. It used to: `just ios` installs over the top without
|
||||
uninstalling and folio's signed-in session survives that, so a repeat under the
|
||||
old `--clear-data=false` opened on the previous run's Home screen and diverged at
|
||||
step 1. That is how the leg came to look dead while the app and the seed were
|
||||
both fine, and it is worth recognising: a leg that reports "the double-submit bug
|
||||
was NOT found" from a machine that has been running the app all day is describing
|
||||
the machine.
|
||||
|
||||
The ios leg clears state and passes no `--ios-app-path`, which is deliberate:
|
||||
without an app path the driver wipes the app's data container instead of
|
||||
reinstalling, and the reinstall is the path that races FrontBoard. `simctl
|
||||
uninstall` + `install` followed straight away by the XCTest runner's own launch
|
||||
has failed with `app.folio is unknown to FrontBoard` about half the time on the
|
||||
host that reported it. That race is untouched and still open; the leg simply
|
||||
does not take that path. It did not reproduce here at all, in 20 consecutive
|
||||
reinstall-and-launch cycles on iOS 26.1, 10 of them reinstalling on top of a
|
||||
live app, so any fix for it has to be developed on a host that can still show it
|
||||
failing.
|
||||
|
||||
Only one sanderling run may drive a given simulator at a time. The driver takes
|
||||
an advisory lock on the target's UDID and a second run is refused with the lock
|
||||
@@ -125,16 +177,36 @@ run's automation session bound to a bundle the simulator no longer knows.
|
||||
|
||||
## replay-ui
|
||||
|
||||
Actions -> replay-ui -> Run workflow. This one is dogfooding: it records a trace
|
||||
This one is dogfooding: it records a trace
|
||||
from `test/browser/testdata/throwing` (violations and uncaught exceptions, so
|
||||
every panel has something to render), serves it with `sanderling replay`, and
|
||||
fuzzes that UI with `replay-ui/sanderling/spec.ts`.
|
||||
|
||||
Six of the seven properties there are cross-panel agreements - two panels
|
||||
deriving the same fact by different paths have to say the same thing - so they
|
||||
hold for any trace and need no recalibrating when the fixture changes. The
|
||||
seventh is the stock `noUncaughtExceptions`, which asks nothing of the panels
|
||||
and only fails if the UI throws. Any violation fails the job.
|
||||
Three of the seven properties there are cross-panel agreements - two panels
|
||||
deriving the same fact by different paths have to say the same thing. The other
|
||||
four are a range invariant on the step in the URL, a count of selected rows
|
||||
inside the list, a no-effect property across a tab switch, and the stock
|
||||
`noUncaughtExceptions`, which asks nothing of the panels and only fails if the
|
||||
UI throws. All seven hold for any trace and need no recalibrating when the
|
||||
fixture changes. Any violation fails the job.
|
||||
|
||||
So does a run that judged nothing. Exit 0 says no property returned false, which
|
||||
is not the same as any property having been evaluated: each one declines to
|
||||
judge when the elements it reads are absent, so a run where the trace failed to
|
||||
serve, or where the fuzzer sat on the run list, renders nothing and passes.
|
||||
`.github/scripts/replay-ui-summary.sh` reads the trace and puts a per-property
|
||||
count of judged against declined steps in the job summary. Run it by hand with
|
||||
`GITHUB_STEP_SUMMARY=/dev/stdout .github/scripts/replay-ui-summary.sh runs/dogfood`.
|
||||
|
||||
It fails the job when any of the four properties that need nothing beyond the
|
||||
step page having rendered - `selectedStepIsInRange`, `exactlyOneStepIsSelected`,
|
||||
`stepCountMatchesTheList`, `screenshotShowsTheSelectedStep` - judged nothing at
|
||||
all. The other three are reported and not gated, because a zero on them is a
|
||||
seed getting unlucky rather than a broken leg: `switchingTabsKeepsTheStep` needs
|
||||
a tab switch between consecutive steps, and `badgeCountMatchesThePanel` needs the
|
||||
fuzzer to land on a violating step and open the violations tab in that same
|
||||
step. On the first run measured this way (seed 3, 80 steps) that last one judged
|
||||
nothing at all, so the fixture reaches it far too rarely to be worth gating on.
|
||||
|
||||
## Reading a failure
|
||||
|
||||
@@ -159,3 +231,109 @@ do not raise the step budget blindly - run a seed sweep with the campaign tool
|
||||
(`cmd/internal-tools/campaign`), which exists for exactly this, and pin a seed
|
||||
that finds the bug with room to spare. A leg failing with "a predicate threw" is
|
||||
a different problem entirely and no seed will fix it.
|
||||
|
||||
Sweep in the leg's own configuration, though. The campaign tool and the ios leg
|
||||
now clear state the same way, so a swept seed means what the leg means, but the
|
||||
starting frame is not a detail you can skip checking: while the leg still passed
|
||||
`--clear-data=false`, seed 14 convicted at step 17 in 2 campaign runs out of 2
|
||||
and in 0 leg-shaped runs out of 3. Prefer the earliest conviction on offer over
|
||||
the first one found, too. A run reproduces its trajectory on another host only
|
||||
for as long as every snapshot agrees, and every step of prefix is another chance
|
||||
for it not to: seeds convicting at steps 33, 60, 114, 187 and 189 all turned up
|
||||
within the first 30, so an early one is usually there to be found.
|
||||
|
||||
A short prefix is necessary and not sufficient, though, and ios is the standing
|
||||
counter-example: seed 28 has the shortest prefix on offer, reproduced its walk on
|
||||
the runner exactly, reached the bug at step 32, and still did not convict,
|
||||
because the window the counting invariant had to judge it in was 117 steps wide.
|
||||
Sweeping selects for a seed that reaches the bug. It cannot select for one whose
|
||||
walk also closes the window, so when a property needs a window, check what the
|
||||
window looked like and not only that the conviction happened.
|
||||
|
||||
|
||||
## Releases
|
||||
|
||||
**Every merge to master cuts a patch.** The `Tag`, `Release (npm)` and
|
||||
`Release (cli)` jobs sit in `ci.yml` alongside everything else, waiting on
|
||||
`Checks`, `Folio` and `Replay UI`, so nothing reaches a registry that the
|
||||
emulators and the simulator have not agreed on. `0.1.4` becomes `0.1.5`:
|
||||
published to npm, and to GitHub Releases with the CLI binaries.
|
||||
|
||||
**A milestone consolidates them.** Actions -> ci -> Run workflow, set `promote`
|
||||
to `minor` or `major`, and the patches you have been shipping become `0.2.0`.
|
||||
Leaving `promote` on `none` is an ordinary ci run that publishes nothing, which
|
||||
is what stops a dispatch meant to re-run the tests from cutting a release.
|
||||
|
||||
A promotion runs the whole suite, device legs included. It is the same pipeline
|
||||
either way, and a release that skipped the checks would be the only release
|
||||
nobody checked. Both paths release the commit the run tested rather than
|
||||
whatever master drifted to while it ran. Afterwards the patch line continues
|
||||
from the milestone: the next merge counts off `0.2.0` and cuts `0.2.1`.
|
||||
|
||||
**The tags are the version.** Nothing in the tree holds it:
|
||||
`pkg/spec/package.json` stays at `0.0.0-dev` and CI stamps the real version in
|
||||
before it publishes. So there is no version-bump commit to land on master,
|
||||
nothing to conflict on, and no second record to hold in step with the tags.
|
||||
`.github/scripts/next-version.sh` is the whole rule, and it counts off stable
|
||||
tags only, because `v0.0.1-rc4` is a candidate for `0.0.1` and a patch counted
|
||||
off it would skip the version it was a candidate for. Run it anywhere to see
|
||||
what the next release would be:
|
||||
|
||||
```
|
||||
BUMP=minor .github/scripts/next-version.sh
|
||||
```
|
||||
|
||||
The tag is pushed before anything is published, because npm is the half of a
|
||||
release that cannot be taken back and a tag is the half that can.
|
||||
|
||||
### How far back the notes reach
|
||||
|
||||
GoReleaser builds its changelog from the commits between the previous tag and
|
||||
this one, and works that previous tag out on its own. For a patch that is
|
||||
exactly right. For a milestone it is not: the notes on a `0.2.0` consolidating
|
||||
six patches would describe the one merge that happened to be last.
|
||||
|
||||
So the resolver also emits `previous_tag`, which the release passes as
|
||||
`GORELEASER_PREVIOUS_TAG`: the last release at the level being cut. A minor
|
||||
reaches back to the last `vX.Y.0`, counting a major as one, and a major reaches
|
||||
back to the last `vX.0.0`. The first milestone of its kind has nothing at its own
|
||||
level, so it reaches back to the first release there has ever been. A patch emits
|
||||
nothing, and an empty value leaves GoReleaser on the default that was already
|
||||
right for it.
|
||||
|
||||
The boundary is exclusive, the way a changelog always is: the notes cover what
|
||||
landed *after* that tag. So the one release this shortchanges is the first
|
||||
milestone of its kind, whose notes start after the first release rather than at
|
||||
it. That is one merge, once, and it is not worth a special case.
|
||||
|
||||
### Why the release is not its own workflow
|
||||
|
||||
It reads like it should be. The reason it is not is npm.
|
||||
|
||||
npm publishes over OIDC here, against a trusted publisher configured for
|
||||
`@sanderling/spec`, so CI holds no npm credential at all. That is not a
|
||||
preference: npm disabled classic token creation in November 2025, revoked every
|
||||
classic token on 9 December 2025, and caps a granular token at 90 days. A token
|
||||
in CI would now expire quarterly, which is exactly the failure this replaced.
|
||||
The August 2026 outage was an expired granular token, and npm answers a publish
|
||||
it will not authorise with `404`, so it read as "package does not exist" while
|
||||
`@sanderling/spec` sat in the registry the whole time.
|
||||
|
||||
A package carries exactly one trusted publisher, and npm matches it against the
|
||||
filename of the workflow that *starts* the run. A reusable workflow does not
|
||||
help, because npm sees the caller's name, not the callee's. So every publish has
|
||||
to enter through one file, and since a merge's release has to run inside ci, that
|
||||
file is `ci.yml`.
|
||||
|
||||
Setting it up again, or moving the package, means npmjs.com -> the package ->
|
||||
trusted publisher: repository `priyanshujain/sanderling`, workflow `ci.yml`. Or
|
||||
from a shell, which needs an interactive 2FA challenge:
|
||||
|
||||
```
|
||||
npm trust github @sanderling/spec --file ci.yml --repo priyanshujain/sanderling --allow-publish
|
||||
npm trust list @sanderling/spec
|
||||
```
|
||||
|
||||
The job installs npm 11.5.1 or newer before publishing, because
|
||||
`actions/setup-node` writes an empty `_authToken` line into `.npmrc` and an older
|
||||
npm reads that as "auth is configured" and never asks for an OIDC token.
|
||||
@@ -24,10 +24,11 @@ Nobody writes this test. A manual tester taps submit once, sees the right number
|
||||
|
||||
You do not script the double tap. You state the invariant and let sanderling find the inputs that break it.
|
||||
|
||||
The amount the user types must equal the amount the balance moves:
|
||||
One submit commits one transaction, so the balance cannot move by more than the
|
||||
amount that submit typed:
|
||||
|
||||
```ts
|
||||
const submitMovesBalanceByTypedAmount = always(
|
||||
const submitMovesBalanceByAtMostTypedAmount = always(
|
||||
next(() => {
|
||||
if (route.current !== "home") return true;
|
||||
const action = lastAction.current;
|
||||
@@ -38,16 +39,18 @@ const submitMovesBalanceByTypedAmount = always(
|
||||
if (typed === 0) return true;
|
||||
const before = totalBalance.previous;
|
||||
if (before === null || totalBalance.current === null) return true;
|
||||
return Math.abs(totalBalance.current - before) === typed;
|
||||
return Math.abs(totalBalance.current - before) <= typed;
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
`always` checks the formula at every step; `next` lets it compare the step before a submit to the step after. The guards narrow it to the one transition that matters, a submit that lands back on home, and the last line states the rule: the balance moved by exactly the typed amount. Double-submit moves it by twice that, and the formula is false.
|
||||
`always` checks the formula at every step; `next` lets it compare the step before a submit to the step after. The guards narrow it to the one transition that matters, a submit that lands back on home, and the last line states the rule: the balance moved by no more than the typed amount. Double-submit moves it by twice that, and the formula is false.
|
||||
|
||||
The window guard is the difference between a property and a false conviction. `totalBalance.previous` is the last total we read, not the total as of the last transaction, so the two numbers being compared can straddle any number of commits: a real run produced a delta of 13000 against a typed 19600, because the window held a double-submit's two 19600 debits and an unrelated 26200 credit. A delta like that is not evidence about the amount typed into any one submit. Exactly one submit action in the window still catches the bug, because the double tap is a single action.
|
||||
The obvious version of that last line is `=== typed`, and it is the version this spec used to ship. It was wrong. Folio's `createTransaction` runs in a coroutine and Home's total re-renders on the store's own schedule, so a total that has not caught up yet is what a healthy app looks like a frame after a submit, and an equality convicts it. So does a submit the app rejected, and so does a tap that never landed. The bound declines on all three without needing a case for any of them, and it still catches the bug, because twice the typed amount is more than the typed amount. What it gives up is worth naming: a transaction committed for less than the amount typed is a real ledger bug this property no longer sees. It cannot be told apart from a total one frame behind, and a check that fires on both is evidence about neither.
|
||||
|
||||
The null guard is not defensive clutter either. Read a balance you could not parse as `0` and the comparison becomes `0 - 0 === typed`, which is false at every healthy submit. A reading you do not have is not evidence, so the property declines to judge. The real spec guards the same way against a balance too large for exact integer arithmetic.
|
||||
The window guard is what keeps the comparison about one submit. `totalBalance.previous` is the last total we read, not the total as of the last transaction, so the two numbers being compared can straddle any number of commits: a real run produced a delta of 13000 against a typed 19600, because the window held a double-submit's two 19600 debits and an unrelated 26200 credit. A delta over a window like that is not evidence about the amount typed into any one submit, whichever side of the bound it falls on. Exactly one submit action in the window still catches the bug, because the double tap is a single action.
|
||||
|
||||
The null guard is not defensive clutter either, and under a bound its failure mode is the quiet one. Read a balance you could not parse as `0` and the comparison becomes `|0 - 0| <= typed`, which is true at every submit: the property stops judging and never says so. A reading you do not have is not evidence, so it has to decline in the open rather than pass by accident. The real spec guards the same way against a balance too large for exact integer arithmetic.
|
||||
|
||||
The values it reads come from extractors, which pull state out of the UI tree once per step:
|
||||
|
||||
@@ -179,7 +182,7 @@ That is the whole input. Three invariants, a way in, and a weighted sense of whe
|
||||
|
||||
## What the run does
|
||||
|
||||
sanderling launches Folio, logs in, and starts exploring. Most steps are unremarkable: open an account, add a transaction, watch the balance move by exactly what was typed, `submitMovesBalanceByTypedAmount` holds.
|
||||
sanderling launches Folio, logs in, and starts exploring. Most steps are unremarkable: open an account, add a transaction, watch the balance move by the amount that was typed, `submitMovesBalanceByAtMostTypedAmount` holds.
|
||||
|
||||
Then a step lands two taps on submit before the first save settles. Two transactions post. The balance jumps by twice the typed amount. At that step the formula evaluates false and the run records a violation: the step, the screenshot, the offending action, and the residual formula that failed.
|
||||
|
||||
|
||||
+4
-2
@@ -16,7 +16,8 @@ Run a spec against an app for a fixed duration.
|
||||
|---|---|---|
|
||||
| `--spec` | required | Path to the TypeScript spec. |
|
||||
| `--bundle-id` | required | Target app bundle ID (Android: applicationId). |
|
||||
| `--launcher-activity` | resolved | Optional `<pkg>/<activity>` to launch. Overrides default resolution. |
|
||||
| `--device` | optional (android) | Android device serial, as `adb devices` reports it. Required when more than one device is attached. |
|
||||
| `--android-app-path` | optional (android) | Path to the APK. Clear-state reinstalls from it instead of running `pm clear`. |
|
||||
| `--platform` | `android` | Target platform: `android`, `ios`, or `web`. |
|
||||
| `--avd` | optional (android) | Android AVD name to boot if no device is connected. Required only when no device is connected and multiple AVDs exist. |
|
||||
| `--ios-device` | optional (ios) | iOS target: a simulator name/UDID to boot, or a connected device's name, UDID, or CoreDevice id. |
|
||||
@@ -24,6 +25,7 @@ Run a spec against an app for a fixed duration.
|
||||
| `--duration` | `5m` | Total test duration (`30s`, `5m`, `2h`, `1d`). |
|
||||
| `--max-steps` | `0` | Stop after this many steps (`0` = no cap, the duration governs). A step budget is what makes two generators comparable. |
|
||||
| `--exit-on-violation` | `false` | Stop the run at the first property violation and exit `2`. |
|
||||
| `--arm` | optional | Experiment label recorded in the run's metadata. Used by the campaign tool to tell one sweep cell from another. |
|
||||
| `--seed` | `0` | PRNG seed. `0` uses a random seed and records it in `meta.json`. |
|
||||
| `--generator` | `seeded` | Who picks each action: `seeded` (the run's PRNG) or `llm` (a vision model). See [the LLM generator](../spec-language/#llm-generator). |
|
||||
| `--output` | `./runs` | Output directory for traces. |
|
||||
@@ -56,7 +58,7 @@ sanderling doctor [--platform web|android|ios|ios-device|all]
|
||||
| Platform | Checks |
|
||||
|---|---|
|
||||
| `web` | headless Chromium can launch (the bundled CDP surface boots a real browser). |
|
||||
| `android` | `adb` on PATH; `emulator` on PATH or under `ANDROID_HOME`; Java 17+; embedded native sidecar JAR is real. |
|
||||
| `android` | `adb` and `emulator` on PATH, or under `$ANDROID_HOME`, `$ANDROID_SDK_ROOT` or a standard SDK install location; Java 17+; embedded native sidecar JAR is real. |
|
||||
| `ios` | `xcrun` on PATH; `simctl` on PATH. The simulator path drives the native companion with no JVM. |
|
||||
| `ios-device` | the `ios` checks plus `devicectl`; the macOS `usbmuxd` socket; a connected, paired device; App Store Connect signing credentials present. |
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ The spec package, in your project:
|
||||
npm install --save-dev @sanderling/spec
|
||||
```
|
||||
|
||||
Both come from the same release tag, and the CLI bundles the package's TypeScript sources when it evaluates your spec, so upgrade them together. Pre-releases are published under npm's `next` tag; `npm install @sanderling/spec` gives you the current stable one.
|
||||
|
||||
## Check your environment
|
||||
|
||||
```sh
|
||||
@@ -28,9 +30,9 @@ sanderling doctor
|
||||
|
||||
`doctor` reports what the target platform needs and what is missing:
|
||||
|
||||
- **Android**: `adb` on your PATH, and an emulator (API 30 or newer) or a connected device.
|
||||
- **iOS**: Xcode 16 or newer, with a simulator. For a connected iPhone, run `sanderling doctor --platform ios-device`.
|
||||
- **Web**: Chrome.
|
||||
- **Android**: `adb` and `emulator`, on your PATH or under the Android SDK; Java 17 or newer; a real (not placeholder) sidecar JAR in the binary.
|
||||
- **iOS**: `xcrun` and `simctl`. For a connected iPhone, run `sanderling doctor --platform ios-device`, which also wants `devicectl`, a paired device, and signing credentials.
|
||||
- **Web**: a Chromium that launches headless.
|
||||
|
||||
## Write a spec
|
||||
|
||||
@@ -44,7 +46,7 @@ export const properties = { noUncaughtExceptions };
|
||||
export const actionsRoot = defaultActions;
|
||||
```
|
||||
|
||||
This taps, types, scrolls, and swipes at random, and fails the moment your app throws an uncaught exception. From here you add extractors to read your screens, properties that state what your app guarantees, and actions that drive its real flows. The [case study](../case-study/) walks a complete spec, and the [spec language reference](../spec-language/) lists every primitive.
|
||||
This taps, types, double-taps, scrolls, and swipes at random. Check the property matches your platform before you trust a green run: `noUncaughtExceptions` reads exceptions the web runtime captures in the page, so it fires on `--platform web` and holds unconditionally on Android and iOS. On Android use `noLogcatErrors` instead, which fails on any error-level log line and so catches an uncaught throwable. iOS has neither today, so an iOS run is only worth as much as the properties you write yourself. From here you add extractors to read your screens, properties that state what your app guarantees, and actions that drive its real flows. The [case study](../case-study/) walks a complete spec, and the [spec language reference](../spec-language/) lists every primitive.
|
||||
|
||||
## Run it
|
||||
|
||||
|
||||
+4
-2
@@ -13,7 +13,7 @@ A run is not a unit test. The closer picture is: boot a fuzzer for an hour and s
|
||||
```
|
||||
sanderling test --spec spec.ts --bundle-id com.example.app --duration 30m
|
||||
│
|
||||
├── launch the app under test (pass --clear-data to wipe app data first)
|
||||
├── launch the app under test (wipes app data first unless --clear-data=false)
|
||||
├── boot the sidecar (or connect to Chrome on web)
|
||||
├── bundle the spec, load it into the JS runtime
|
||||
│
|
||||
@@ -64,4 +64,6 @@ A run ends when:
|
||||
- `--duration` elapses, or
|
||||
- the process is interrupted (Ctrl+C).
|
||||
|
||||
Additional conditions (`--max-steps`, `--exit-on-violation`, hard crash handling) land in the [v0.1.0 milestone](https://github.com/priyanshujain/sanderling/milestone/1).
|
||||
- `--max-steps` is reached, or `--exit-on-violation` was passed and a property was violated.
|
||||
|
||||
Hard crash handling lands in the [v0.1.0 milestone](https://github.com/priyanshujain/sanderling/milestone/1).
|
||||
@@ -29,7 +29,7 @@ Every extractor callback receives a `State`:
|
||||
interface State {
|
||||
ax: AccessibilityTree;
|
||||
snapshots: Record<string, unknown>;
|
||||
lastAction: Action | null;
|
||||
lastAction: (Action & { applied: true | null }) | null;
|
||||
logs: readonly LogEntry[];
|
||||
exceptions: readonly ExceptionRecord[];
|
||||
time: number; // ms since run start
|
||||
@@ -40,11 +40,13 @@ interface State {
|
||||
|---|---|
|
||||
| `ax` | Live UI hierarchy for this step |
|
||||
| `snapshots` | Key-value data pushed by the app SDK (empty if SDK not integrated) |
|
||||
| `lastAction` | The action dispatched in the previous step, or `null` on the first step |
|
||||
| `lastAction` | The action dispatched in the previous step, or `null` on the first step and on any step that dispatched nothing |
|
||||
| `logs` | Log entries collected since the previous step |
|
||||
| `exceptions` | Uncaught exceptions or `Sanderling.reportError()` calls since the previous step |
|
||||
| `exceptions` | Uncaught exceptions and unhandled promise rejections captured in the page. Web only: nothing fills this on Android or iOS, where it is always empty |
|
||||
| `time` | Milliseconds elapsed since the run started |
|
||||
|
||||
`lastAction.applied` is `true` when the runner saw the dispatch succeed and `null` when the apply call failed with the action possibly already delivered: an RPC deadline can fire after the tap reached the app, and nothing can find out afterwards. So there are three states, not two. `state.lastAction === null` means no action ran; `applied === null` means one ran whose fate is unknown. A property that attributes an effect to the action ("this submit must move the balance by the typed amount") has to decline unless `applied` is `true`, or a timeout convicts a healthy app. A property that counts what the app COULD have done should include it: an unconfirmed submit belongs in an upper bound on how many submits a window holds.
|
||||
|
||||
## Selectors
|
||||
|
||||
Selectors are passed to `ax.find()`, `ax.findAll()`, and element-scoped `.find()` / `.findAll()`. A tree-level lookup scans the whole hierarchy, the root element included; an element-scoped one scans that element's descendants. Both selector forms scan the same set, so `ax.find("id:page")` and `ax.find({ id: "page" })` return the same element.
|
||||
@@ -347,9 +349,9 @@ import { defaultActions, doubleTaps } from "@sanderling/spec/defaults";
|
||||
import { noUncaughtExceptions, noLogcatErrors } from "@sanderling/spec/defaults/properties";
|
||||
```
|
||||
|
||||
`defaultActions` is a ready-made weighted tree of the built-in generators: taps and typing at weight 100, scrolls 50, swipes 25, double taps 10. Use it as a baseline pool or as one entry in your own tree.
|
||||
`defaultActions` is a ready-made weighted tree of five of the built-in generators: taps and typing at weight 100, scrolls 50, swipes 25, double taps 10. `longPresses`, `pressKeys` and `waitOnce` are not in it; weight them in yourself if you want them. Use it as a baseline pool or as one entry in your own tree.
|
||||
|
||||
| Property | Fails when |
|
||||
|---|---|
|
||||
| `noUncaughtExceptions` | An uncaught exception or `Sanderling.reportError()` call is captured |
|
||||
| `noUncaughtExceptions` | The page captured an uncaught exception or an unhandled rejection (web only; holds on Android and iOS, where `state.exceptions` is never populated) |
|
||||
| `noLogcatErrors` | Logcat emits any error-level (`E`) lines since the previous step (Android only; holds elsewhere) |
|
||||
@@ -37,7 +37,9 @@ IOS_DEVICE="iPhone 15" just ios # pick a different simulator
|
||||
|
||||
`just ios` regenerates `app/iosApp/iosApp.xcodeproj` from `app/iosApp/project.yml`,
|
||||
builds the KMP framework (`Shared.framework` from `:app:shared`), links it
|
||||
into the SwiftUI host, installs, and launches.
|
||||
into the SwiftUI host, uninstalls any previous copy, installs, and launches.
|
||||
The uninstall matters: folio's signed-in session survives an install over the
|
||||
top, so without it a run opens on the last run's Home screen.
|
||||
|
||||
## Web
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@ kotlin {
|
||||
implementation(libs.lifecycle.viewmodel.compose)
|
||||
implementation(libs.navigation.compose)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.androidx.activity.compose)
|
||||
}
|
||||
|
||||
+5
@@ -3,6 +3,7 @@ package app.folio.feature.ledger
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.folio.core.data.Account
|
||||
import app.folio.core.data.MAX_TRANSACTION_AMOUNT_CENTS
|
||||
import app.folio.core.data.Repository
|
||||
import app.folio.core.data.TxnType
|
||||
import app.folio.navigation.Navigator
|
||||
@@ -87,6 +88,10 @@ class AddTransactionViewModel(
|
||||
form.update { it.copy(error = "Amount must be greater than zero") }
|
||||
return
|
||||
}
|
||||
if (cents > MAX_TRANSACTION_AMOUNT_CENTS) {
|
||||
form.update { it.copy(error = "Amount is too large (max \$1,000,000.00)") }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.createTransaction(accountId, s.type, cents, s.note)
|
||||
|
||||
@@ -54,9 +54,8 @@ fun parseCents(input: String): Long? {
|
||||
val fracPadded = (frac + "00").substring(0, 2)
|
||||
val wholeLong = whole.toLongOrNull() ?: return null
|
||||
val fracLong = fracPadded.toLongOrNull() ?: return null
|
||||
val total = wholeLong * 100 + fracLong
|
||||
if (total < 0) return null
|
||||
return total
|
||||
if (wholeLong > (Long.MAX_VALUE - fracLong) / 100) return null
|
||||
return wholeLong * 100 + fracLong
|
||||
}
|
||||
|
||||
fun signedAmount(t: Transaction): Long = if (t.type == TxnType.credit) t.amount else -t.amount
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package app.folio.util
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class ParseCentsTest {
|
||||
@Test
|
||||
fun parsesEverydayAmountsExactly() {
|
||||
assertEquals(1L, parseCents("0.01"))
|
||||
assertEquals(1234L, parseCents("12.34"))
|
||||
assertEquals(1250L, parseCents("12.5"))
|
||||
assertEquals(1200L, parseCents("12.0"))
|
||||
assertEquals(100000L, parseCents("1000"))
|
||||
assertEquals(123456L, parseCents("1,234.56"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsEighteenDigitWholeThatWrapsToAPositiveLong() {
|
||||
assertNull(parseCents("999999999999999999"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsSeventeenDigitWholeThatWrapsToANegativeLong() {
|
||||
assertNull(parseCents("99999999999999999"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsNineteenDigitWholeThatNoLongerFitsALong() {
|
||||
assertNull(parseCents("9999999999999999999"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsTheLargestRepresentableAmountAndRejectsOneCentMore() {
|
||||
assertEquals(Long.MAX_VALUE, parseCents("92233720368547758.07"))
|
||||
assertNull(parseCents("92233720368547758.08"))
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,10 @@ kotlin {
|
||||
api(libs.sqldelight.coroutines.extensions)
|
||||
api(libs.sqldelight.async.extensions)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.sqldelight.android.driver)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
const val MAX_TRANSACTION_AMOUNT_CENTS = 100_000_000L
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@Inject
|
||||
class Repository(private val store: LedgerStore) {
|
||||
@@ -27,6 +29,7 @@ class Repository(private val store: LedgerStore) {
|
||||
|
||||
suspend fun createTransaction(accountId: String, type: TxnType, amount: Long, note: String): Transaction {
|
||||
require(amount > 0) { "Amount must be greater than zero" }
|
||||
require(amount <= MAX_TRANSACTION_AMOUNT_CENTS) { "Amount is too large (max \$1,000,000.00)" }
|
||||
requireNotNull(getAccount(accountId)) { "Account not found" }
|
||||
val txn = Transaction(
|
||||
id = Platform.makeId(),
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package app.folio.core.data
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
private class FakeLedgerStore : LedgerStore {
|
||||
override val accounts = MutableStateFlow<List<Account>>(emptyList())
|
||||
override val transactions = MutableStateFlow<List<Transaction>>(emptyList())
|
||||
override val session = MutableStateFlow<Session?>(null)
|
||||
|
||||
override suspend fun accountExistsByName(name: String): Boolean =
|
||||
accounts.value.any { it.name == name }
|
||||
|
||||
override suspend fun insertAccount(id: String, name: String, createdAt: Long) {
|
||||
accounts.value = accounts.value + Account(id, name, createdAt)
|
||||
}
|
||||
|
||||
override suspend fun insertTxn(
|
||||
id: String,
|
||||
accountId: String,
|
||||
type: TxnType,
|
||||
amount: Long,
|
||||
note: String,
|
||||
createdAt: Long,
|
||||
) {
|
||||
transactions.value = transactions.value + Transaction(id, accountId, type, amount, note, createdAt)
|
||||
}
|
||||
|
||||
override suspend fun upsertSession(user: String, loggedInAt: Long) {
|
||||
session.value = Session(user, loggedInAt)
|
||||
}
|
||||
|
||||
override suspend fun clearSession() {
|
||||
session.value = null
|
||||
}
|
||||
}
|
||||
|
||||
class RepositoryTest {
|
||||
@Test
|
||||
fun rejectsAmountAboveOneMillionDollars() = runTest {
|
||||
val repository = Repository(FakeLedgerStore())
|
||||
val account = repository.createAccount("Checking")
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
repository.createTransaction(account.id, TxnType.credit, 100_000_001L, "")
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
repository.createTransaction(account.id, TxnType.credit, Long.MAX_VALUE, "")
|
||||
}
|
||||
assertTrue(repository.transactions.value.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsAmountAtOneMillionDollars() = runTest {
|
||||
val repository = Repository(FakeLedgerStore())
|
||||
val account = repository.createAccount("Checking")
|
||||
|
||||
repository.createTransaction(account.id, TxnType.credit, 100_000_000L, "rent")
|
||||
|
||||
assertEquals(listOf(100_000_000L), repository.transactions.value.map { it.amount })
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ sqlite-wasm = "3.53.0-build1"
|
||||
|
||||
[libraries]
|
||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||
|
||||
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
|
||||
|
||||
@@ -78,6 +78,13 @@ _ensure-device:
|
||||
echo "emulator did not finish booting in time (see /tmp/folio-emulator.log)" >&2
|
||||
exit 1
|
||||
|
||||
# Run folio's own unit tests. Named test-unit because `test` is the fuzz run.
|
||||
test-unit:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export ANDROID_HOME="$(just _android-home)"
|
||||
./gradlew :core:testDebugUnitTest :app:shared:testDebugUnitTest
|
||||
|
||||
# Build the folio debug APK without installing it.
|
||||
build:
|
||||
#!/usr/bin/env bash
|
||||
@@ -130,6 +137,10 @@ ios:
|
||||
-destination 'platform=iOS Simulator,name={{ios_device}}' \
|
||||
-derivedDataPath app/iosApp/build \
|
||||
build | tail -5
|
||||
# Installing over the top keeps the data container, and folio's signed-in
|
||||
# session with it, so a run started straight after would open on the last
|
||||
# run's Home screen instead of Login and diverge at step 1.
|
||||
xcrun simctl uninstall booted app.folio || true
|
||||
xcrun simctl install booted "{{ios_app}}"
|
||||
xcrun simctl launch booted app.folio
|
||||
|
||||
|
||||
@@ -106,6 +106,21 @@ export function readHomeTotalBalance(args: {
|
||||
//
|
||||
// Callers pass null for a reading they could not take, so an empty list and an
|
||||
// empty map are one case here rather than two.
|
||||
//
|
||||
// A non-empty list is trusted as current, and that rests on the app's shape
|
||||
// rather than on anything in the frame. `fresh` also resets the submit window,
|
||||
// so a card list drawn before the store caught up with a commit would bank
|
||||
// stale counts, start the next window empty, and leave the rise arriving with
|
||||
// no budget to cover it: a healthy app convicted of a double submit. Folio
|
||||
// cannot serve that frame. AddTransactionViewModel.submit pops ONE entry, so a
|
||||
// commit lands back on the ledger it came from and the first Home reading is a
|
||||
// whole action and settle later. Two pops do reach Home, but two pops means two
|
||||
// Submit events, which is the double submit itself, and a verdict there is
|
||||
// late rather than wrong. Measured over four recorded android runs: 51
|
||||
// commit-capable single taps landed on the ledger or the transaction screen and
|
||||
// none on Home, 49 of 49 commits already showed their new balance in the frame
|
||||
// read at the same step, and no rise ever arrived against an empty budget in
|
||||
// 1303 steps. A submit that navigated straight to Home would reopen this.
|
||||
export interface HomeCardReading<T> {
|
||||
value: T | null;
|
||||
carrier: T | null;
|
||||
@@ -123,10 +138,68 @@ export function readHomeCards<T>(args: {
|
||||
return { value: reading, carrier: reading, fresh: true };
|
||||
}
|
||||
|
||||
function isTapOn(
|
||||
lastAction: { kind?: string; on?: string | object } | null,
|
||||
target: string,
|
||||
): boolean {
|
||||
// The balance of the ONE account the ledger and the add-transaction screen are
|
||||
// showing, and the window it closes.
|
||||
//
|
||||
// It exists because the Home readings above close their window only when the
|
||||
// walk goes back to Home, and a walk inside the transaction flow does not: a
|
||||
// submit pops back to the ledger it came from, so the fuzzer can add
|
||||
// transactions all day without Home ever being redrawn. The iOS run in #78 went
|
||||
// 117 steps between two Home readings and accumulated 37 submits against a rise
|
||||
// of 15 transactions, which is no evidence about any single action. Both
|
||||
// screens here carry the account's own balance (LedgerBalance,
|
||||
// TxnCurrentBalance), so the window between two readings holds one action.
|
||||
//
|
||||
// WHICH account 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's
|
||||
// ledger means passing through Home, and one action reads one frame, so a frame
|
||||
// that is neither of these two routes always sits in between. Dropping the
|
||||
// carrier on every such frame, transition frames included, is what makes the
|
||||
// two compared numbers two readings of one account.
|
||||
export interface AccountBalanceReading {
|
||||
value: number | null;
|
||||
carrier: number | null;
|
||||
fresh: boolean;
|
||||
}
|
||||
|
||||
function showsOneAccount(route: string | null): boolean {
|
||||
return route === "ledger" || route === "add-transaction";
|
||||
}
|
||||
|
||||
export function readAccountBalance(args: {
|
||||
route: string | null;
|
||||
balanceText: string | undefined;
|
||||
previousCarrier: number | null;
|
||||
}): AccountBalanceReading {
|
||||
const { route, balanceText, previousCarrier } = args;
|
||||
if (!showsOneAccount(route)) return { value: null, carrier: null, fresh: false };
|
||||
const balance = parseAccountBalance(balanceText);
|
||||
// Unreadable is unknown, not a new value: the balance node scrolls off the
|
||||
// viewport like anything else. The account still cannot have changed, so the
|
||||
// last number we read is carried across and the window stays open.
|
||||
if (balance === null) return { value: previousCarrier, carrier: previousCarrier, fresh: false };
|
||||
return { value: balance, carrier: balance, fresh: true };
|
||||
}
|
||||
|
||||
// state.lastAction as the two hosts build it (internal/verifier/marshal.go
|
||||
// lastActionFields), read defensively: every field is what a Go struct decided
|
||||
// to emit, not something this file can trust a compile-time shape for.
|
||||
//
|
||||
// `applied` is true when the runner saw the action's dispatch succeed and null
|
||||
// when the apply call failed with the gesture possibly already delivered: an
|
||||
// RPC deadline can fire after the tap landed, and nothing can find out
|
||||
// afterwards. That is unknown, not "it did not happen", which is what
|
||||
// `lastAction === null` says.
|
||||
export interface ObservedAction {
|
||||
kind?: string;
|
||||
on?: string | object;
|
||||
applied?: true | null;
|
||||
relaunched?: true | null;
|
||||
}
|
||||
|
||||
function isTapOn(lastAction: ObservedAction | null, target: string): boolean {
|
||||
if (lastAction == null) return false;
|
||||
if (lastAction.kind !== "Tap" && lastAction.kind !== "DoubleTap") return false;
|
||||
const on = lastAction.on;
|
||||
@@ -138,19 +211,40 @@ function isTapOn(
|
||||
// Repository.createTransaction: AddTransactionViewModel.submit() is the only
|
||||
// caller, AddTransactionEvent.Submit is the only thing that runs it, and the
|
||||
// TxnSubmit button's onClick is the only thing that sends that event.
|
||||
export function isTxnSubmitTap(
|
||||
lastAction: { kind?: string; on?: string | object } | null,
|
||||
): boolean {
|
||||
export function isTxnSubmitTap(lastAction: ObservedAction | null): boolean {
|
||||
return isTapOn(lastAction, "TxnSubmit");
|
||||
}
|
||||
|
||||
// Likewise the only action that creates an account.
|
||||
export function isAddAccountSubmitTap(
|
||||
lastAction: { kind?: string; on?: string | object } | null,
|
||||
): boolean {
|
||||
export function isAddAccountSubmitTap(lastAction: ObservedAction | null): boolean {
|
||||
return isTapOn(lastAction, "AddAccountSubmit");
|
||||
}
|
||||
|
||||
// Did the runner see this action's dispatch succeed? `applied` is null when the
|
||||
// apply call failed with the gesture possibly already delivered (an RPC
|
||||
// deadline can fire after the tap landed), and the runner has no way to find
|
||||
// out afterwards. Such an action may have caused anything the next reading
|
||||
// shows, so it counts toward how many submits a window COULD hold, but it never
|
||||
// licenses attributing an effect to it: a property that demands the effect of
|
||||
// an action that may never have run convicts the app of the runner's own
|
||||
// uncertainty.
|
||||
export function confirmedApplied(lastAction: ObservedAction | null): boolean {
|
||||
return lastAction != null && lastAction.applied === true;
|
||||
}
|
||||
|
||||
// The runner reports this when its foreground guard had to relaunch the app
|
||||
// after the action. The action still happened, so it still counts toward how
|
||||
// many submits a window could hold; what nobody can promise across it is that
|
||||
// the process survived long enough to commit, or that Home is showing the same
|
||||
// slice of the account list it was.
|
||||
//
|
||||
// `true | null` for the same reason `applied` is: web and iOS cannot read the
|
||||
// foreground at all, so "no relaunch reported" is not "the app never
|
||||
// restarted", and only an explicit true licenses declining.
|
||||
export function acrossRelaunch(lastAction: ObservedAction | null): boolean {
|
||||
return lastAction != null && lastAction.relaunched === true;
|
||||
}
|
||||
|
||||
// Counts the submit actions inside the window the balance property compares
|
||||
// over: from the last Home total we read to this step, inclusive of this step's
|
||||
// action.
|
||||
@@ -164,16 +258,68 @@ export function isAddAccountSubmitTap(
|
||||
//
|
||||
// The reset lands on `fresh`, the same event that advances the carrier, so the
|
||||
// count always describes exactly the interval the two compared totals span.
|
||||
//
|
||||
// A submit whose dispatch the runner could not confirm counts here, because
|
||||
// this number is an upper bound on the submits the window holds and the tap may
|
||||
// well have landed. Leaving it out is what convicted a healthy app:
|
||||
// committedTransactionsExceedSubmits saw a transaction rise of one against a
|
||||
// window of zero and called it a double submit.
|
||||
//
|
||||
// A submit the app must have refused does not count, for the mirror reason: it
|
||||
// cannot have committed anything, so the bound it would raise is slack the app
|
||||
// can hide a real double submit behind. See submitCouldCommit for what "must
|
||||
// have refused" is allowed to mean.
|
||||
export function countSubmitsInWindow(args: {
|
||||
previousCount: number;
|
||||
lastAction: { kind?: string; on?: string | object } | null;
|
||||
lastAction: ObservedAction | null;
|
||||
amountText?: string;
|
||||
fresh: boolean;
|
||||
}): { reported: number; next: number } {
|
||||
const { previousCount, lastAction, fresh } = args;
|
||||
const reported = previousCount + (isTxnSubmitTap(lastAction) ? 1 : 0);
|
||||
// A relaunch is the one thing that can put a form state on screen other than
|
||||
// the one the tap read, so the field it draws proves nothing about it.
|
||||
const refused = !acrossRelaunch(lastAction) && !submitCouldCommit(args.amountText);
|
||||
const reported = previousCount + (isTxnSubmitTap(lastAction) && !refused ? 1 : 0);
|
||||
return { reported, next: fresh ? 0 : reported };
|
||||
}
|
||||
|
||||
// Folio's own cap, in cents (core/data/Repository.kt).
|
||||
const MAX_TRANSACTION_AMOUNT_CENTS = 100_000_000;
|
||||
|
||||
// Could the app have committed anything for that submit? The amount field as
|
||||
// the LANDING frame shows it is the form state the tap read: the tap changes
|
||||
// nothing about it, and one action runs per step, so nothing else could have.
|
||||
// Off the transaction screen there is no field to read, and undefined is
|
||||
// unknown, which counts.
|
||||
//
|
||||
// False only where Folio's own code must have refused. parseCents takes
|
||||
// `^\d+(\.\d{1,2})?$` with commas stripped and refuses everything else, and
|
||||
// AddTransactionViewModel refuses a parsed zero on top of that. An empty field
|
||||
// never even reaches the parser: TxnSubmit is
|
||||
// clickable(enabled = amount.isNotBlank()), so the click does not fire.
|
||||
//
|
||||
// This is the difference between a bound and a useless one. The window is an
|
||||
// upper bound on the transactions the interval could hold, and a bound inflated
|
||||
// by taps that commit nothing is a bound the app can never exceed: the iOS run
|
||||
// in #78 read a rise of 15 transactions against a window of 37 submits and had
|
||||
// nothing to say. Measured over four recorded android runs, 19, 11, 25 and 25
|
||||
// of 35, 26, 42 and 42 submit taps landed with the amount field empty.
|
||||
//
|
||||
// An amount over Folio's cap is refused before any coroutine starts
|
||||
// (MAX_TRANSACTION_AMOUNT_CENTS, checked in both AddTransactionViewModel.submit
|
||||
// and Repository.createTransaction), and the fuzzer's corpus reaches the button
|
||||
// with one: "999999999999999999999" passes AMOUNT_REGEX, so the field takes it.
|
||||
// Float is precise enough to say which side of the cap an amount is on. The cap
|
||||
// is 1e8, every integer cent up to 2^53 is exact, and an amount far enough above
|
||||
// it to be inexact is far enough above it to be refused.
|
||||
export function submitCouldCommit(amountText: string | undefined): boolean {
|
||||
if (amountText === undefined) return true;
|
||||
const trimmed = amountText.trim().replace(/,/g, "");
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(trimmed)) return false;
|
||||
if (!/[1-9]/.test(trimmed)) return false;
|
||||
return Number(trimmed) * 100 <= MAX_TRANSACTION_AMOUNT_CENTS;
|
||||
}
|
||||
|
||||
// Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to
|
||||
// integer cents. Anything that is not a complete amount is null, not 0: a
|
||||
// balance we could not read is unknown, and reading it as zero silently moves
|
||||
@@ -214,6 +360,15 @@ export function cardBalanceText(args: {
|
||||
return match ? match[0].trim() : undefined;
|
||||
}
|
||||
|
||||
// One account's own balance, off the node whose whole text it is: bare on the
|
||||
// ledger ("$196.00"), labelled in the add-transaction header
|
||||
// ("Balance: $196.00"). Anchored at the end for the same reason as above, so
|
||||
// the label cannot be read as part of the amount.
|
||||
export function parseAccountBalance(text: string | undefined): number | null {
|
||||
const match = text?.match(TRAILING_BALANCE);
|
||||
return match ? parseDollarCents(match[0].trim()) : null;
|
||||
}
|
||||
|
||||
// The result is an identity key, not a display name: off web it is the
|
||||
// AccountName text, on web it is whatever the merged card text leaves in front
|
||||
// of the count label, initials and all ("T2Travel" for "Travel 2024"). Its only
|
||||
@@ -233,6 +388,24 @@ export function cardAccountName(args: {
|
||||
return head.slice(0, label.index).trim();
|
||||
}
|
||||
|
||||
// The avatar text that opens a merged card's identity key, mirroring Folio's
|
||||
// initialsOf (app/shared/.../util/Format.kt).
|
||||
//
|
||||
// A mirror because the alternative is a suffix test, and a suffix test cannot
|
||||
// say which card a name belongs to. Drift can only cost a detection: the result
|
||||
// is compared whole against a card's key, so initials that stop matching the
|
||||
// app match no card rather than the wrong one.
|
||||
export function initialsOf(name: string): string {
|
||||
// Java's \s, which is what Kotlin's Regex("\\s+") compiles to. JS's \s also
|
||||
// matches the unicode spaces, and would split names the app keeps whole.
|
||||
const parts = name.trim().split(/[ \t\n\v\f\r]+/).filter(part => part !== "");
|
||||
const first = parts[0];
|
||||
const last = parts[parts.length - 1];
|
||||
if (first === undefined || last === undefined) return "?";
|
||||
if (parts.length === 1) return first.slice(0, 2).toUpperCase();
|
||||
return (first.slice(0, 1) + last.slice(0, 1)).toUpperCase();
|
||||
}
|
||||
|
||||
// One card's transaction count, in the strongest form its SOURCE supports. The
|
||||
// two forms are the whole reason this is not just a number:
|
||||
//
|
||||
@@ -305,13 +478,26 @@ export function homeAccountsOf(cards: readonly CardReading[]): Account[] | null
|
||||
//
|
||||
// A name carried by more than one card is left out for the same reason, the
|
||||
// rule createdAccountHasNonZeroBalance applies with `matches.length === 1`:
|
||||
// nothing here can say which of them a count came from. Folio accepts the same
|
||||
// account name twice and Home lists whatever fits the viewport, so a reading
|
||||
// that saw one Travel card and a later one that saw two would otherwise
|
||||
// subtract two DIFFERENT accounts' counts and convict a healthy app of
|
||||
// double-submitting. The twin does not have to be readable to spoil the
|
||||
// identity, so duplicates are counted over every card, not just the usable
|
||||
// ones. Dropping a card can only ever cost a detection.
|
||||
// nothing here can say which of them a count came from. Two accounts never
|
||||
// share a NAME (Accounts.name is UNIQUE and Repository.createAccount rejects
|
||||
// one already taken, NOCASE), but they can share a KEY, because web's key is
|
||||
// the card text in front of the digit run, which is the name with any trailing
|
||||
// digits shaved off it: "Travel1" holding 25 transactions and "Travel12"
|
||||
// holding 6 both key to "TRTravel" and both read a three-digit run, so
|
||||
// subtracting one from the other subtracts two unrelated counting series. The
|
||||
// twin does not have to be readable to spoil the identity, so duplicates are
|
||||
// counted over every card, not just the usable ones. Dropping a card can only
|
||||
// ever cost a detection.
|
||||
//
|
||||
// What this cannot see is a twin that never shares a reading with its pair, and
|
||||
// Home lists only what fits the viewport. Nothing computed from the card text
|
||||
// can: the two cards' text is identical character for character ("TRTravel1"
|
||||
// followed by "25 transactions" and "TRTravel12" followed by "6 transactions"
|
||||
// are one string), so no key derived from it separates them. Refusing every run
|
||||
// that could hide a name's own digits would, at the price of the evidence web
|
||||
// convicts on today, whose measured witness is a count of 12 rising to 14.
|
||||
// Separating them needs something the tree does not carry: the account's id on
|
||||
// the card, or a separator in front of the count.
|
||||
export function homeTxnCountsOf(cards: readonly CardReading[]): Record<string, TxnCount> | null {
|
||||
const cardsPerName = new Map<string, number>();
|
||||
for (const card of cards) cardsPerName.set(card.name, (cardsPerName.get(card.name) ?? 0) + 1);
|
||||
@@ -343,7 +529,7 @@ export function homeTxnCountsOf(cards: readonly CardReading[]): Record<string, T
|
||||
// same as fine, and both come back false here.
|
||||
export function createdAccountHasNonZeroBalance(args: {
|
||||
route: string | null;
|
||||
lastAction: { kind?: string; on?: string | object } | null;
|
||||
lastAction: ObservedAction | null;
|
||||
typedName: string | undefined;
|
||||
before: Account[] | null;
|
||||
after: Account[] | null;
|
||||
@@ -351,15 +537,34 @@ export function createdAccountHasNonZeroBalance(args: {
|
||||
const { route, lastAction, before, after } = args;
|
||||
if (route !== "home") return false;
|
||||
if (!isAddAccountSubmitTap(lastAction)) return false;
|
||||
// A creation nobody can confirm happened is not a creation to attribute a
|
||||
// card to: the card that turned up may be an older account of the same name
|
||||
// scrolling into view.
|
||||
if (!confirmedApplied(lastAction)) return false;
|
||||
// A relaunch draws Home from the top again, so the card that carries the
|
||||
// typed name may be an older account of that name laid out where the new one
|
||||
// used to be, and the create may not have reached sqlite at all.
|
||||
if (acrossRelaunch(lastAction)) return false;
|
||||
if (before === null || after === null) return false;
|
||||
const typed = (args.typedName ?? "").trim();
|
||||
if (typed === "") return false;
|
||||
// Web merges the card into one node whose text opens with the avatar
|
||||
// initials, so the identity key is "INInvestments" where android and iOS give
|
||||
// "Investments"; endsWith covers both. Two cards answering to the same typed
|
||||
// name (a second "Travel", or a card the tree exposed twice) leave the
|
||||
// appearance unattributable, so nothing is judged.
|
||||
const matches = after.filter(account => account.name.endsWith(typed));
|
||||
// "Investments". Both forms are built from the name that was typed and
|
||||
// compared whole. A suffix test covered both too, and it also let any OTHER
|
||||
// account ending in those letters answer for the created one: type "Fund"
|
||||
// next to an existing "Emergency Fund", have the new card clipped out of the
|
||||
// reading the way Home clips any card, and the old account is convicted for
|
||||
// money it has held all along. It cost detections as well, because a typed
|
||||
// name that two cards end with is judged as unattributable rather than as the
|
||||
// one card that carries it.
|
||||
//
|
||||
// Two cards answering to one key stay unattributable: Accounts.name is UNIQUE
|
||||
// and Repository.createAccount rejects a name already taken, so that pair is
|
||||
// a card the tree exposed twice, or two names the merged key cannot tell
|
||||
// apart.
|
||||
const mergedKey = initialsOf(typed) + typed;
|
||||
const matches = after.filter(account => account.name === typed || account.name === mergedKey);
|
||||
const created = matches.length === 1 ? matches[0] : undefined;
|
||||
if (created === undefined) return false;
|
||||
if (before.some(account => account.name === created.name)) return false;
|
||||
@@ -401,6 +606,70 @@ export function committedTransactionsExceedSubmits(args: {
|
||||
return committed > submitsInWindow;
|
||||
}
|
||||
|
||||
// The same rule as above, measured in money over the account's own window: one
|
||||
// submit action can commit one transaction, so the account's balance cannot
|
||||
// move by more than the amount that submit typed.
|
||||
//
|
||||
// An UPPER BOUND, the same one submitChangesBalanceByAtMostTypedAmount applies
|
||||
// to Home's total, and that is what makes a one-action window safe. 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, and none
|
||||
// of those is evidence of anything; an equality would convict all three. Moving
|
||||
// by MORE than one submit's worth is not something a correct app can do: only
|
||||
// createTransaction moves this number, only a TxnSubmit tap reaches it, and the
|
||||
// window holds exactly one such tap.
|
||||
//
|
||||
// What it can convict, and what it cannot. The double tap sends two Submit
|
||||
// events, and where they land decides who judges them. Two commits and two pops
|
||||
// reach Home, where this reads no balance at all and
|
||||
// committedTransactionsExceedSubmits does the convicting: that is the shape the
|
||||
// recorded iOS run at runs/folio-ios/20260815-102711 produced, three double taps
|
||||
// out of three, all on Home. Two commits with the second pop cancelled by the
|
||||
// first stop on the account's own ledger, and only this sees them. So this is
|
||||
// not the check that fixed #78, and widening its route gate would not make it
|
||||
// one: readAccountBalance drops the carrier off these two screens, so a Home
|
||||
// landing has nothing to compare.
|
||||
//
|
||||
// It is not idle either. Over that same run it judged 18 of 240 steps against
|
||||
// real readings, every one a submit landing back on the ledger with the balance
|
||||
// moved by exactly what was typed. A commit for more than the amount typed, on
|
||||
// any of those 18, had nowhere else to be caught: the total-balance form got
|
||||
// past its own gates on one step in the whole run.
|
||||
//
|
||||
// A submit the runner could not confirm needs no case of its own: if it never
|
||||
// landed the balance did not move, which is under the bound.
|
||||
// countSubmitsInWindow counts it either way, so it cannot smuggle a second
|
||||
// commit into a window that looks like one.
|
||||
//
|
||||
// typedAmount is the amount the app parsed for THIS submit (parseTypedAmount
|
||||
// mirrors parseCents), so every rejected amount and every amount too large to
|
||||
// hold exactly arrives here as 0 and is vacuous.
|
||||
//
|
||||
// The float guards are the ones submitChangesBalanceByAtMostTypedAmount explains:
|
||||
// each balance and the typed amount lose precision on their own past
|
||||
// Number.MAX_SAFE_INTEGER. Their difference needs none, because a difference
|
||||
// that is really within a safe typedAmount is itself safe and comes out exact.
|
||||
export function committedAmountExceedsOneSubmit(args: {
|
||||
route: string | null;
|
||||
lastAction: ObservedAction | null;
|
||||
submitsInWindow: number;
|
||||
typedAmount: number;
|
||||
prevAccountBalance: number | null;
|
||||
currAccountBalance: number | null;
|
||||
}): boolean {
|
||||
const { route, lastAction, submitsInWindow, typedAmount } = args;
|
||||
const { prevAccountBalance, currAccountBalance } = args;
|
||||
if (!showsOneAccount(route)) return false;
|
||||
if (!isTxnSubmitTap(lastAction)) return false;
|
||||
if (submitsInWindow !== 1) return false;
|
||||
if (typedAmount <= 0) return false;
|
||||
if (prevAccountBalance === null || currAccountBalance === null) return false;
|
||||
if (!Number.isSafeInteger(prevAccountBalance)) return false;
|
||||
if (!Number.isSafeInteger(currAccountBalance)) return false;
|
||||
if (!Number.isSafeInteger(typedAmount)) return false;
|
||||
return Math.abs(currAccountBalance - prevAccountBalance) > typedAmount;
|
||||
}
|
||||
|
||||
// How far one account's count rose between two readings, or null when the pair
|
||||
// is not comparable. Not comparable is not zero: the account drops out of the
|
||||
// sum entirely, which can only cost a detection.
|
||||
@@ -448,12 +717,27 @@ export function parseTypedAmount(text: string | undefined | null): number {
|
||||
}
|
||||
|
||||
// When the last action is a tap (or double-tap) on the transaction Submit
|
||||
// button, the absolute change in total balance must equal the amount the
|
||||
// user typed. A double-submit lands two transactions and shifts the balance
|
||||
// by 2x the typed amount, tripping this check. The route gate skips steps
|
||||
// whose landing screen is not Home: totalBalance is only freshly read from
|
||||
// Home's own TOTAL BALANCE node, so off-Home comparisons would read a stale
|
||||
// carrier value and false-fire.
|
||||
// button, the absolute change in total balance cannot EXCEED the amount the
|
||||
// user typed. A double-submit lands two transactions and shifts the balance by
|
||||
// 2x the typed amount, tripping this check. The route gate skips steps whose
|
||||
// landing screen is not Home: totalBalance is only freshly read from Home's own
|
||||
// TOTAL BALANCE node, so off-Home comparisons would read a stale carrier value
|
||||
// and false-fire.
|
||||
//
|
||||
// A bound rather than the equality this used to be, and the same bound
|
||||
// committedAmountExceedsOneSubmit applies to the account's own balance. The
|
||||
// write finishes before AddTransactionViewModel navigates, but nothing
|
||||
// establishes that Home's total has re-rendered before the frame is read: the
|
||||
// store's flow re-emits on its own schedule. A total that has not caught up has
|
||||
// not moved at all, and an equality convicts a healthy app for it.
|
||||
//
|
||||
// The cost is real and is not covered anywhere else in this spec: a balance
|
||||
// that moves by LESS than the amount typed, a transaction silently dropped or
|
||||
// committed for the wrong amount, is a bug this no longer judges. It cannot be
|
||||
// told apart from a total one frame behind, and a check that fires on both is
|
||||
// evidence about neither. What it keeps is the bug it exists for: every one of
|
||||
// the four recorded android convictions is a 6400 move against 3200 typed, and
|
||||
// 2x still exceeds x.
|
||||
//
|
||||
// submitsInWindow is what keeps the comparison honest. prevTotalBalance is the
|
||||
// last total we READ, not the total as of the previous transaction, so the two
|
||||
@@ -463,9 +747,9 @@ export function parseTypedAmount(text: string | undefined | null): number {
|
||||
// evidence about the amount typed into any one submit, so anything other than
|
||||
// exactly one submit action in the window is vacuous. Exactly one still catches
|
||||
// the bug: the double-tap is a single action.
|
||||
export function submitChangesBalanceByTypedAmount(args: {
|
||||
export function submitChangesBalanceByAtMostTypedAmount(args: {
|
||||
route: string | null;
|
||||
lastAction: { kind?: string; on?: string | object } | null;
|
||||
lastAction: ObservedAction | null;
|
||||
submitsInWindow: number;
|
||||
typedAmount: number;
|
||||
prevTotalBalance: number | null;
|
||||
@@ -476,6 +760,20 @@ export function submitChangesBalanceByTypedAmount(args: {
|
||||
|
||||
if (route !== "home") return true;
|
||||
if (!isTxnSubmitTap(lastAction)) return true;
|
||||
// The two totals were read from two different processes. SqlLedgerStore
|
||||
// starts each one on stateIn(Eagerly, emptyList()) and HomeScreen composes
|
||||
// formatCents(total) off whatever the flow holds, so a restarted app draws
|
||||
// $0.00 into TotalBalance until sqlite answers, and that number is as far
|
||||
// from the last one as the accounts are rich. There is no submit anywhere
|
||||
// that explains it.
|
||||
//
|
||||
// A submit the runner could not confirm needs no guard of its own: it may
|
||||
// have committed nothing, and a balance that did not move is under any bound.
|
||||
// countSubmitsInWindow counts it exactly like a confirmed one, so a total
|
||||
// that moved by more than one typed amount is the same double commit either
|
||||
// way. Under the equality this used to be, that case had to be excused; a
|
||||
// guard for it here now only drops the convictions it exists to make.
|
||||
if (acrossRelaunch(lastAction)) return true;
|
||||
if (submitsInWindow !== 1) return true;
|
||||
if (typedAmount === 0) return true;
|
||||
// An unknown total on either side is not evidence of anything. Comparing one
|
||||
@@ -486,7 +784,7 @@ export function submitChangesBalanceByTypedAmount(args: {
|
||||
// transaction at whatever fits a Kotlin Long, so a balance of ~1e18 cents is
|
||||
// one accepted amount away, and up there the gap between representable
|
||||
// values is 128 cents: a real 1600-cent move reads back as something else
|
||||
// entirely. The equality below is then false for a healthy single submit
|
||||
// entirely. The comparison below is then false for a healthy single submit
|
||||
// exactly as readily as for a double one, and a check that cannot pass is not
|
||||
// a check that failed.
|
||||
//
|
||||
@@ -501,5 +799,5 @@ export function submitChangesBalanceByTypedAmount(args: {
|
||||
if (!Number.isSafeInteger(prevTotalBalance)) return true;
|
||||
if (!Number.isSafeInteger(currTotalBalance)) return true;
|
||||
if (!Number.isSafeInteger(typedAmount)) return true;
|
||||
return Math.abs(currTotalBalance - prevTotalBalance) === typedAmount;
|
||||
return Math.abs(currTotalBalance - prevTotalBalance) <= typedAmount;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
cardAccountName,
|
||||
cardBalanceText,
|
||||
cardTxnCount,
|
||||
committedAmountExceedsOneSubmit,
|
||||
committedTransactionsExceedSubmits,
|
||||
countSubmitsInWindow,
|
||||
createdAccountHasNonZeroBalance,
|
||||
@@ -23,10 +24,11 @@ import {
|
||||
oncePerFrame,
|
||||
parseDollarCents,
|
||||
parseTypedAmount,
|
||||
readAccountBalance,
|
||||
readHomeCards,
|
||||
readHomeTotalBalance,
|
||||
routeOfFrame,
|
||||
submitChangesBalanceByTypedAmount,
|
||||
submitChangesBalanceByAtMostTypedAmount,
|
||||
} from "./predicates";
|
||||
import type { Account, CardReading, TxnCount } from "./predicates";
|
||||
|
||||
@@ -100,6 +102,11 @@ const homeCards = oncePerFrame((s: State): CardReading[] =>
|
||||
// the last-read Home total so `previous` and `current` stay on the same scale.
|
||||
const homeTotalText = (s: State) => on("home", "TotalBalance")(s)?.text;
|
||||
|
||||
// The amount field as the frame a submit landed on shows it, which is the form
|
||||
// state that submit read. Every window below asks, because a submit the app
|
||||
// must have refused raises no bound: see submitCouldCommit.
|
||||
const txnAmountText = oncePerFrame((s: State) => on("add-transaction", "TxnAmountField")(s)?.text);
|
||||
|
||||
let lastHomeTotal: number | null = null;
|
||||
const totalBalance = extract<number | null>("totalBalance", s => {
|
||||
const reading = readHomeTotalBalance({
|
||||
@@ -125,6 +132,7 @@ const submitsInWindow = extract("submitsInWindow", s => {
|
||||
const window = countSubmitsInWindow({
|
||||
previousCount: submitsSinceHomeTotal,
|
||||
lastAction: s.lastAction,
|
||||
amountText: txnAmountText(s),
|
||||
fresh,
|
||||
});
|
||||
submitsSinceHomeTotal = window.next;
|
||||
@@ -173,12 +181,52 @@ const submitsSinceCounts = extract("submitsSinceCounts", s => {
|
||||
const window = countSubmitsInWindow({
|
||||
previousCount: submitsSinceHomeCards,
|
||||
lastAction: s.lastAction,
|
||||
amountText: txnAmountText(s),
|
||||
fresh,
|
||||
});
|
||||
submitsSinceHomeCards = window.next;
|
||||
return window.reported;
|
||||
});
|
||||
|
||||
// The account's own balance, off whichever of its two screens is up. The routes
|
||||
// are exclusive, so at most one of these resolves and the reading is always one
|
||||
// account's number. Its carrier is dropped on every other route, which is what
|
||||
// keeps two readings from spanning two accounts: see readAccountBalance.
|
||||
const accountBalanceText = (s: State) =>
|
||||
on("ledger", "LedgerBalance")(s)?.text ?? on("add-transaction", "TxnCurrentBalance")(s)?.text;
|
||||
|
||||
let lastAccountBalance: number | null = null;
|
||||
const accountBalance = extract<number | null>("accountBalance", s => {
|
||||
const reading = readAccountBalance({
|
||||
route: routeOf(s),
|
||||
balanceText: accountBalanceText(s),
|
||||
previousCarrier: lastAccountBalance,
|
||||
});
|
||||
lastAccountBalance = reading.carrier;
|
||||
return reading.value;
|
||||
});
|
||||
|
||||
// A third window, for the same reason the counting invariant has its own: it
|
||||
// closes on this reading's freshness, which is a different event again. The
|
||||
// transaction flow redraws this balance on nearly every frame, so this window
|
||||
// is the narrow one, usually a single action wide.
|
||||
let submitsSinceAccountBalance = 0;
|
||||
const submitsSinceBalance = extract("submitsSinceAccountBalance", s => {
|
||||
const fresh = readAccountBalance({
|
||||
route: routeOf(s),
|
||||
balanceText: accountBalanceText(s),
|
||||
previousCarrier: null,
|
||||
}).fresh;
|
||||
const window = countSubmitsInWindow({
|
||||
previousCount: submitsSinceAccountBalance,
|
||||
lastAction: s.lastAction,
|
||||
amountText: txnAmountText(s),
|
||||
fresh,
|
||||
});
|
||||
submitsSinceAccountBalance = window.next;
|
||||
return window.reported;
|
||||
});
|
||||
|
||||
const lastAction = extract("lastAction", s => s.lastAction);
|
||||
|
||||
const loginEmailField = extract("loginEmailField", on("login", "LoginEmail"));
|
||||
@@ -208,13 +256,18 @@ const newAccountBalanceIsZero = always(
|
||||
),
|
||||
);
|
||||
|
||||
// Property 2: a tap on TxnSubmit must move the total balance by exactly the
|
||||
// typed amount. A double-submit lands two transactions, so the balance shifts
|
||||
// by twice the typed amount and the check fires. The route gate inside the
|
||||
// Property 2: a tap on TxnSubmit cannot move the total balance by more than the
|
||||
// typed amount. A double-submit lands two transactions, so the balance shifts by
|
||||
// twice the typed amount and the check fires. The route gate inside the
|
||||
// predicate skips off-Home landings where totalBalance.current is the carrier.
|
||||
const submitMovesBalanceByTypedAmount = always(
|
||||
//
|
||||
// The name is the property's, and the gate keys on it, so it stays; what it
|
||||
// demands is the bound, for the reason submitChangesBalanceByAtMostTypedAmount gives:
|
||||
// a total that has not re-rendered yet has not moved, and an equality convicts a
|
||||
// healthy app for a frame that has not caught up.
|
||||
const submitMovesBalanceByAtMostTypedAmount = always(
|
||||
next(() =>
|
||||
submitChangesBalanceByTypedAmount({
|
||||
submitChangesBalanceByAtMostTypedAmount({
|
||||
route: route.current,
|
||||
lastAction: lastAction.current,
|
||||
submitsInWindow: submitsInWindow.current,
|
||||
@@ -230,13 +283,39 @@ const submitMovesBalanceByTypedAmount = always(
|
||||
// stays sound however wide the window between two Home readings gets, because
|
||||
// both sides of the comparison accumulate over the same window. It is the
|
||||
// double-submit stated directly: one tap, two rows.
|
||||
//
|
||||
// One rule, two windows, and they judge different frames rather than the same
|
||||
// frame twice. The counting form compares two Home readings, which is where a
|
||||
// double tap lands: submit() pops one entry per commit, so two commits walk the
|
||||
// stack back past the ledger to Home. What used to defeat it there was the
|
||||
// width of the window, not the window's screen, and what fixed it is
|
||||
// submitCouldCommit refusing to count submits the app cannot have accepted.
|
||||
// Replaying the iOS run at runs/folio-ios/20260815-102711 through this file
|
||||
// measures it: the three double taps sit in windows of 2, 1 and 2 submits with
|
||||
// that rule and 5, 4 and 7 without, and the run convicts 4 times with it and 0
|
||||
// times without.
|
||||
//
|
||||
// The second form says the same thing in money about the one account whose
|
||||
// screen the walk is on, and that window is usually a single action. It judges
|
||||
// the frames the counting form cannot see between two Home visits, and catches
|
||||
// the double submit whose second pop never ran: see
|
||||
// committedAmountExceedsOneSubmit.
|
||||
const submitCommitsOneTransactionPerAction = always(
|
||||
next(() =>
|
||||
!committedTransactionsExceedSubmits({
|
||||
countsBefore: homeTxnCounts.previous ?? null,
|
||||
countsAfter: homeTxnCounts.current,
|
||||
submitsInWindow: submitsSinceCounts.current,
|
||||
}),
|
||||
next(
|
||||
() =>
|
||||
!committedTransactionsExceedSubmits({
|
||||
countsBefore: homeTxnCounts.previous ?? null,
|
||||
countsAfter: homeTxnCounts.current,
|
||||
submitsInWindow: submitsSinceCounts.current,
|
||||
}) &&
|
||||
!committedAmountExceedsOneSubmit({
|
||||
route: route.current,
|
||||
lastAction: lastAction.current,
|
||||
submitsInWindow: submitsSinceBalance.current,
|
||||
typedAmount: parseTypedAmount(txnAmountField.previous?.text),
|
||||
prevAccountBalance: accountBalance.previous ?? null,
|
||||
currAccountBalance: accountBalance.current,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -303,7 +382,7 @@ const addTxn = whenRoute(route, ["home", "ledger", "add-transaction"], () => {
|
||||
|
||||
export const properties = {
|
||||
newAccountBalanceIsZero,
|
||||
submitMovesBalanceByTypedAmount,
|
||||
submitMovesBalanceByAtMostTypedAmount,
|
||||
submitCommitsOneTransactionPerAction,
|
||||
};
|
||||
|
||||
|
||||
+83
-13
@@ -123,14 +123,17 @@ func antiFreezeCommands() [][]string {
|
||||
// reinstalling it. This replaces `pm clear` for clear-state: ColorOS and other
|
||||
// hardened OEM builds deny CLEAR_APP_USER_DATA even to the adb shell user, so a
|
||||
// clear aborts the launch, whereas uninstall+install is always permitted.
|
||||
// The uninstall is best effort so a not-installed app is not an error.
|
||||
// A failed uninstall is not passed over: `install -r` keeps the app's data, so
|
||||
// the reinstall would report a clear-state that never happened.
|
||||
func ReinstallApp(ctx context.Context, serial, bundleID, apkPath string, stdout io.Writer) error {
|
||||
adb, err := AdbBinary()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if output, err := exec.CommandContext(ctx, adb, adbArgs(serial, "uninstall", bundleID)...).CombinedOutput(); err != nil {
|
||||
fmt.Fprintf(stdout, "clear-state: uninstall %s skipped (%v: %s)\n", bundleID, err, strings.TrimSpace(string(output)))
|
||||
if err := clearDataUninstallLeftBehind(ctx, adb, serial, bundleID, strings.TrimSpace(string(output)), stdout); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if output, err := exec.CommandContext(ctx, adb, adbArgs(serial, "install", "-r", apkPath)...).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("install %s: %w: %s", apkPath, err, strings.TrimSpace(string(output)))
|
||||
@@ -138,6 +141,37 @@ func ReinstallApp(ctx context.Context, serial, bundleID, apkPath string, stdout
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearDataUninstallLeftBehind reaches first-launch state after `adb uninstall`
|
||||
// failed. The failure text cannot say why: an API 34 emulator answers
|
||||
// "Failure [DELETE_FAILED_INTERNAL_ERROR]" both for a package that was never
|
||||
// installed and for one it refuses to remove. So ask the package manager which
|
||||
// happened. Nothing installed means nothing to clear. Still installed
|
||||
// means the data survives the reinstall, and `pm clear` is the one remaining
|
||||
// way to reach first-launch state; when that fails too, so does clear-state.
|
||||
func clearDataUninstallLeftBehind(ctx context.Context, adb, serial, bundleID, uninstallOutput string, stdout io.Writer) error {
|
||||
if !packageInstalled(ctx, adb, serial, bundleID) {
|
||||
return nil
|
||||
}
|
||||
output, err := exec.CommandContext(ctx, adb, adbArgs(serial, "shell", "pm", "clear", bundleID)...).CombinedOutput()
|
||||
cleared := strings.TrimSpace(string(output))
|
||||
if err != nil || !strings.Contains(cleared, "Success") {
|
||||
return fmt.Errorf(
|
||||
"clear-state: %s is still installed after `adb uninstall` said %q, and `pm clear` said %q: its data was not cleared",
|
||||
bundleID, uninstallOutput, cleared,
|
||||
)
|
||||
}
|
||||
fmt.Fprintf(stdout, "clear-state: uninstall %s said %q and left it installed; cleared its data with `pm clear` instead\n", bundleID, uninstallOutput)
|
||||
return nil
|
||||
}
|
||||
|
||||
// packageInstalled reports whether the package manager resolves an APK path for
|
||||
// bundleID. The printed path is the signal rather than the exit status, which
|
||||
// `adb shell` does not forward from devices below API 24.
|
||||
func packageInstalled(ctx context.Context, adb, serial, bundleID string) bool {
|
||||
output, _ := exec.CommandContext(ctx, adb, adbArgs(serial, "shell", "pm", "path", bundleID)...).Output()
|
||||
return strings.HasPrefix(strings.TrimSpace(string(output)), "package:")
|
||||
}
|
||||
|
||||
const threeButtonNavOverlay = "com.android.internal.systemui.navbar.threebutton"
|
||||
|
||||
// navModeOverlays are the system navigation-mode overlays. Only one is active at
|
||||
@@ -221,11 +255,7 @@ func navOverlayCommand(ctx context.Context, adb, serial, overlay string) *exec.C
|
||||
// EnvWithAndroidPlatformTools returns env with the directory containing adb
|
||||
// prepended to PATH, so child processes (the sidecar) can invoke adb even
|
||||
// when the user hasn't set up their shell PATH.
|
||||
func EnvWithAndroidPlatformTools(env []string) []string {
|
||||
adb, err := AdbBinary()
|
||||
if err != nil {
|
||||
return env
|
||||
}
|
||||
func EnvWithAndroidPlatformTools(env []string, adb string) []string {
|
||||
adbDir := filepath.Dir(adb)
|
||||
result := make([]string, 0, len(env))
|
||||
found := false
|
||||
@@ -247,7 +277,9 @@ func EnvWithAndroidPlatformTools(env []string) []string {
|
||||
// AdbBinary locates the adb binary via PATH or known Android SDK locations.
|
||||
func AdbBinary() (string, error) { return findAndroidTool("adb", "platform-tools") }
|
||||
|
||||
func emulatorBinary() (string, error) { return findAndroidTool("emulator", "emulator") }
|
||||
// EmulatorBinary locates the emulator binary via PATH or known Android SDK
|
||||
// locations.
|
||||
func EmulatorBinary() (string, error) { return findAndroidTool("emulator", "emulator") }
|
||||
|
||||
// findAndroidTool locates a binary from the Android SDK. It checks PATH,
|
||||
// then $ANDROID_HOME/<subdir>/<name> and $ANDROID_SDK_ROOT/<subdir>/<name>,
|
||||
@@ -264,7 +296,36 @@ func findAndroidTool(name, subdir string) (string, error) {
|
||||
}
|
||||
tried = append(tried, candidate)
|
||||
}
|
||||
return "", fmt.Errorf("could not locate %q: not on PATH and not under any known Android SDK root (set $ANDROID_HOME to point at your SDK; tried %v)", name, tried)
|
||||
return "", fmt.Errorf(
|
||||
"%s not found: not on PATH, and not at [%s]; %s\nput %s on PATH, or point $ANDROID_HOME at an Android SDK that has %s/%s",
|
||||
name, strings.Join(tried, ", "), sdkRootStatus(), name, subdir, name,
|
||||
)
|
||||
}
|
||||
|
||||
// sdkRootStatus reports what the SDK root variables hold, so a lookup failure
|
||||
// says whether they were unset or pointed somewhere that is not an SDK instead
|
||||
// of leaving the reader to work out which from a list of paths.
|
||||
func sdkRootStatus() string {
|
||||
var reported []string
|
||||
for _, variable := range []string{"ANDROID_HOME", "ANDROID_SDK_ROOT"} {
|
||||
value := os.Getenv(variable)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(value)
|
||||
switch {
|
||||
case err != nil:
|
||||
reported = append(reported, fmt.Sprintf("$%s=%s does not exist", variable, value))
|
||||
case !info.IsDir():
|
||||
reported = append(reported, fmt.Sprintf("$%s=%s is not a directory", variable, value))
|
||||
default:
|
||||
reported = append(reported, fmt.Sprintf("$%s=%s", variable, value))
|
||||
}
|
||||
}
|
||||
if len(reported) == 0 {
|
||||
return "$ANDROID_HOME and $ANDROID_SDK_ROOT are unset"
|
||||
}
|
||||
return strings.Join(reported, ", ")
|
||||
}
|
||||
|
||||
func androidSDKCandidates() []string {
|
||||
@@ -283,11 +344,20 @@ func androidSDKCandidates() []string {
|
||||
addRoot(filepath.Join(home, "Library", "Android", "sdk"))
|
||||
addRoot(filepath.Join(home, "Android", "Sdk"))
|
||||
}
|
||||
addRoot("/opt/homebrew/share/android-commandlinetools")
|
||||
addRoot("/usr/local/share/android-commandlinetools")
|
||||
for _, root := range standardSDKRoots {
|
||||
addRoot(root)
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
// standardSDKRoots are the install locations checked after the environment and
|
||||
// the home directory. A var so a resolution test can point it at a fixture
|
||||
// instead of whatever SDK the host running the test happens to have.
|
||||
var standardSDKRoots = []string{
|
||||
"/opt/homebrew/share/android-commandlinetools",
|
||||
"/usr/local/share/android-commandlinetools",
|
||||
}
|
||||
|
||||
func listAdbDevices(ctx context.Context) ([]string, error) {
|
||||
adb, err := AdbBinary()
|
||||
if err != nil {
|
||||
@@ -317,7 +387,7 @@ func parseAdbDevices(output string) []string {
|
||||
}
|
||||
|
||||
func listAVDs(ctx context.Context) ([]string, error) {
|
||||
emulator, err := emulatorBinary()
|
||||
emulator, err := EmulatorBinary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -382,7 +452,7 @@ func pickAVD(requested string, available []string) (string, error) {
|
||||
}
|
||||
|
||||
func bootAVD(_ context.Context, name string) error {
|
||||
emulator, err := emulatorBinary()
|
||||
emulator, err := EmulatorBinary()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package android
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -136,6 +138,114 @@ func TestPickAVD_NoneAvailable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// fakeSDK writes an SDK layout holding only the named tools ("emulator/emulator"),
|
||||
// so a lookup test never resolves against the host's own SDK.
|
||||
func fakeSDK(t *testing.T, tools ...string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, tool := range tools {
|
||||
path := filepath.Join(root, filepath.FromSlash(tool))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, nil, 0o755); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// isolateSDKLookup closes every route to the host's own SDK: an empty PATH, no
|
||||
// root variables, a home with nothing under it, and the standard install
|
||||
// locations replaced by the given roots. It returns the fake home directory.
|
||||
func isolateSDKLookup(t *testing.T, roots ...string) string {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
t.Setenv("ANDROID_HOME", "")
|
||||
t.Setenv("ANDROID_SDK_ROOT", "")
|
||||
t.Setenv("HOME", home)
|
||||
original := standardSDKRoots
|
||||
t.Cleanup(func() { standardSDKRoots = original })
|
||||
standardSDKRoots = roots
|
||||
return home
|
||||
}
|
||||
|
||||
func TestAdbBinary_ResolvesUnderStandardSDKRootWithAndroidHomeUnset(t *testing.T) {
|
||||
sdk := fakeSDK(t, "platform-tools/adb")
|
||||
isolateSDKLookup(t, sdk)
|
||||
|
||||
adb, err := AdbBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("AdbBinary: %v", err)
|
||||
}
|
||||
if want := filepath.Join(sdk, "platform-tools", "adb"); adb != want {
|
||||
t.Errorf("AdbBinary() = %q, want %q", adb, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmulatorBinary_ResolvesUnderStandardSDKRootWithAndroidHomeUnset(t *testing.T) {
|
||||
sdk := fakeSDK(t, "emulator/emulator")
|
||||
isolateSDKLookup(t, sdk)
|
||||
|
||||
emulator, err := EmulatorBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("EmulatorBinary: %v", err)
|
||||
}
|
||||
if want := filepath.Join(sdk, "emulator", "emulator"); emulator != want {
|
||||
t.Errorf("EmulatorBinary() = %q, want %q", emulator, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdbBinary_UsesAndroidHome(t *testing.T) {
|
||||
sdk := fakeSDK(t, "platform-tools/adb")
|
||||
isolateSDKLookup(t)
|
||||
t.Setenv("ANDROID_HOME", sdk)
|
||||
|
||||
adb, err := AdbBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("AdbBinary: %v", err)
|
||||
}
|
||||
if want := filepath.Join(sdk, "platform-tools", "adb"); adb != want {
|
||||
t.Errorf("AdbBinary() = %q, want %q", adb, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdbBinary_NoSDKAnywhereReportsWhereItLooked(t *testing.T) {
|
||||
empty := t.TempDir()
|
||||
home := isolateSDKLookup(t, empty)
|
||||
|
||||
_, err := AdbBinary()
|
||||
if err == nil {
|
||||
t.Fatal("expected an error with no SDK anywhere")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"adb not found: not on PATH",
|
||||
filepath.Join(home, "Library", "Android", "sdk", "platform-tools", "adb"),
|
||||
filepath.Join(empty, "platform-tools", "adb"),
|
||||
"$ANDROID_HOME and $ANDROID_SDK_ROOT are unset",
|
||||
"put adb on PATH, or point $ANDROID_HOME at an Android SDK that has platform-tools/adb",
|
||||
} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q missing %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdbBinary_AndroidHomePointingNowhereIsNamed(t *testing.T) {
|
||||
isolateSDKLookup(t, t.TempDir())
|
||||
missing := filepath.Join(t.TempDir(), "no-such-sdk")
|
||||
t.Setenv("ANDROID_HOME", missing)
|
||||
|
||||
_, err := AdbBinary()
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when ANDROID_HOME points nowhere")
|
||||
}
|
||||
if want := "$ANDROID_HOME=" + missing + " does not exist"; !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q must say %q rather than leaving it in the tried list", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathContains(t *testing.T) {
|
||||
path := "/usr/bin:/opt/tools:/usr/local/bin"
|
||||
if !pathContains(path, "/opt/tools") {
|
||||
@@ -331,3 +441,134 @@ func TestNavModeToRestore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scriptedAdb puts an adb under a fake SDK root that logs each invocation's
|
||||
// arguments and answers from replies, a `case "$*" in` body. SDK lookup is
|
||||
// isolated onto that root, so ReinstallApp runs its real command sequence
|
||||
// against the script and the log holds what reached adb.
|
||||
func scriptedAdb(t *testing.T, replies string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
log := filepath.Join(root, "adb.log")
|
||||
adb := filepath.Join(root, "platform-tools", "adb")
|
||||
if err := os.MkdirAll(filepath.Dir(adb), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", filepath.Dir(adb), err)
|
||||
}
|
||||
script := "#!/bin/sh\necho \"$*\" >> " + log + "\ncase \"$*\" in\n" + replies + "\nesac\n"
|
||||
if err := os.WriteFile(adb, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write %s: %v", adb, err)
|
||||
}
|
||||
isolateSDKLookup(t, root)
|
||||
return log
|
||||
}
|
||||
|
||||
func adbCalls(t *testing.T, log string) []string {
|
||||
t.Helper()
|
||||
contents, err := os.ReadFile(log)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
t.Fatalf("read %s: %v", log, err)
|
||||
}
|
||||
return strings.Split(strings.TrimSpace(string(contents)), "\n")
|
||||
}
|
||||
|
||||
const (
|
||||
uninstallFails = `"uninstall "*) echo "Failure [DELETE_FAILED_INTERNAL_ERROR]"; exit 1;;`
|
||||
stillInstalled = `"shell pm path "*) echo "package:/data/app/app.example-1/base.apk";;`
|
||||
notInstalled = `"shell pm path "*) exit 1;;`
|
||||
installSucceeds = `"install "*) echo "Success";;`
|
||||
)
|
||||
|
||||
func TestReinstallApp_RefusedUninstallClearsTheDataItLeftBehind(t *testing.T) {
|
||||
log := scriptedAdb(t, strings.Join([]string{
|
||||
uninstallFails,
|
||||
stillInstalled,
|
||||
`"shell pm clear "*) echo "Success";;`,
|
||||
installSucceeds,
|
||||
}, "\n"))
|
||||
output := &strings.Builder{}
|
||||
|
||||
if err := ReinstallApp(t.Context(), "", "app.example", "/tmp/app.apk", output); err != nil {
|
||||
t.Fatalf("ReinstallApp: %v", err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"uninstall app.example",
|
||||
"shell pm path app.example",
|
||||
"shell pm clear app.example",
|
||||
"install -r /tmp/app.apk",
|
||||
}
|
||||
if got := adbCalls(t, log); !slices.Equal(got, want) {
|
||||
t.Fatalf("adb calls = %v, want %v", got, want)
|
||||
}
|
||||
if !strings.Contains(output.String(), "pm clear") {
|
||||
t.Errorf("output %q does not say the data was cleared some other way", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReinstallApp_RefusedUninstallThatCannotBeClearedIsFatal(t *testing.T) {
|
||||
log := scriptedAdb(t, strings.Join([]string{
|
||||
uninstallFails,
|
||||
stillInstalled,
|
||||
`"shell pm clear "*) echo "Failed"; exit 1;;`,
|
||||
installSucceeds,
|
||||
}, "\n"))
|
||||
|
||||
err := ReinstallApp(t.Context(), "", "app.example", "/tmp/app.apk", &strings.Builder{})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("ReinstallApp reported success while app.example kept the data clear-state was asked to remove")
|
||||
}
|
||||
for _, want := range []string{"app.example", "DELETE_FAILED_INTERNAL_ERROR", "Failed"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q does not quote %q", err, want)
|
||||
}
|
||||
}
|
||||
if slices.Contains(adbCalls(t, log), "install -r /tmp/app.apk") {
|
||||
t.Error("installed over an app whose data survived, which is the reinstall reporting a clear it did not perform")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReinstallApp_UninstallFailureWithNothingInstalledIsQuiet(t *testing.T) {
|
||||
log := scriptedAdb(t, strings.Join([]string{
|
||||
uninstallFails,
|
||||
notInstalled,
|
||||
installSucceeds,
|
||||
}, "\n"))
|
||||
output := &strings.Builder{}
|
||||
|
||||
if err := ReinstallApp(t.Context(), "", "app.example", "/tmp/app.apk", output); err != nil {
|
||||
t.Fatalf("ReinstallApp: %v", err)
|
||||
}
|
||||
|
||||
calls := adbCalls(t, log)
|
||||
if !slices.Contains(calls, "install -r /tmp/app.apk") {
|
||||
t.Fatalf("adb calls = %v, want the install to go ahead: a first run has no app to uninstall", calls)
|
||||
}
|
||||
if slices.Contains(calls, "shell pm clear app.example") {
|
||||
t.Errorf("adb calls = %v, want no data clear: there was no app holding data", calls)
|
||||
}
|
||||
if output.String() != "" {
|
||||
t.Errorf("output = %q, want nothing: a first run has no app to uninstall", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReinstallApp_SuccessfulUninstallNeedsNoFallback(t *testing.T) {
|
||||
log := scriptedAdb(t, strings.Join([]string{
|
||||
`"uninstall "*) echo "Success";;`,
|
||||
stillInstalled,
|
||||
`"shell pm clear "*) echo "Success";;`,
|
||||
installSucceeds,
|
||||
}, "\n"))
|
||||
|
||||
if err := ReinstallApp(t.Context(), "", "app.example", "/tmp/app.apk", &strings.Builder{}); err != nil {
|
||||
t.Fatalf("ReinstallApp: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"uninstall app.example", "install -r /tmp/app.apk"}
|
||||
if got := adbCalls(t, log); !slices.Equal(got, want) {
|
||||
t.Fatalf("adb calls = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -79,23 +79,27 @@ func New() *Driver {
|
||||
}
|
||||
var parts []string
|
||||
for _, arg := range e.Args {
|
||||
if arg.Value != nil {
|
||||
var s string
|
||||
if err := json.Unmarshal(arg.Value, &s); err == nil {
|
||||
parts = append(parts, s)
|
||||
} else {
|
||||
parts = append(parts, string(arg.Value))
|
||||
// An object argument, which is what console.error(err) passes,
|
||||
// carries no value at all: CDP sends a description instead. Reading
|
||||
// only the value logged those calls with an empty message, so the
|
||||
// entry named a level and nothing a reader could act on.
|
||||
if arg.Value == nil {
|
||||
if arg.Description != "" {
|
||||
parts = append(parts, arg.Description)
|
||||
}
|
||||
continue
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(arg.Value, &s); err == nil {
|
||||
parts = append(parts, s)
|
||||
} else {
|
||||
parts = append(parts, string(arg.Value))
|
||||
}
|
||||
}
|
||||
level := strings.ToUpper(string(e.Type))
|
||||
if level == "LOG" {
|
||||
level = "I"
|
||||
}
|
||||
d.logsMu.Lock()
|
||||
d.logs = append(d.logs, driver.LogEntry{
|
||||
UnixMillis: int64(e.Timestamp.Time().UnixMilli()),
|
||||
Level: level,
|
||||
Level: consoleLevel(e.Type),
|
||||
Tag: "console",
|
||||
Message: strings.Join(parts, " "),
|
||||
})
|
||||
@@ -940,9 +944,33 @@ func (d *Driver) Metrics(ctx context.Context, _ string) (driver.Metrics, error)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// consoleLevel places a console call on driver.LogEntry's logcat scale. The
|
||||
// verbs a spec acts on are all named here; the rest are info rather than "E"
|
||||
// because promoting them would convict an app of an error it never logged.
|
||||
func consoleLevel(apiType runtime.APIType) string {
|
||||
switch apiType {
|
||||
case runtime.APITypeError, runtime.APITypeAssert:
|
||||
return "E"
|
||||
case runtime.APITypeWarning:
|
||||
return "W"
|
||||
case runtime.APITypeDebug:
|
||||
return "D"
|
||||
default:
|
||||
return "I"
|
||||
}
|
||||
}
|
||||
|
||||
// meetsLevel keeps an entry whose level the scale cannot rank. Ranking an
|
||||
// unknown level below every threshold drops it, and a dropped entry is
|
||||
// indistinguishable from a quiet app: the caller sees silence and reports it as
|
||||
// health.
|
||||
func meetsLevel(level, minLevel string) bool {
|
||||
order := map[string]int{"V": 0, "D": 1, "I": 2, "W": 3, "E": 4, "F": 5}
|
||||
return order[level] >= order[minLevel]
|
||||
rank, ranked := order[level]
|
||||
if !ranked {
|
||||
return true
|
||||
}
|
||||
return rank >= order[minLevel]
|
||||
}
|
||||
|
||||
func pngDimensions(png []byte) (int, int) {
|
||||
@@ -1087,6 +1115,30 @@ func (d *Driver) SetLastAction(ctx context.Context, encoded json.RawMessage) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLogs installs the entries this step's log fetch returned as state.logs
|
||||
// inside the page runtime. The page cannot derive them: console output reaches
|
||||
// the driver over CDP and nothing in the page reads it back. Without this call
|
||||
// every web state.logs is empty, and since the page's reading of an extractor
|
||||
// replaces the host's, the default noLogcatErrors then reports green on a run
|
||||
// whose console was full of errors.
|
||||
//
|
||||
// Unguarded for the same reason as SetLastAction: on a page with no setter,
|
||||
// "the page cannot accept logs" has to fail the run rather than be reported as
|
||||
// a successful install.
|
||||
func (d *Driver) SetLogs(ctx context.Context, encoded json.RawMessage) error {
|
||||
payload := strings.TrimSpace(string(encoded))
|
||||
if payload == "" {
|
||||
payload = "[]"
|
||||
}
|
||||
script := fmt.Sprintf(`window.__sanderlingSetLogs__(%s)`, payload)
|
||||
runCtx, cancel := d.runCtx(ctx)
|
||||
defer cancel()
|
||||
if err := chromedp.Run(runCtx, chromedp.Evaluate(script, nil)); err != nil {
|
||||
return fmt.Errorf("set logs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractorScript resolves the extractor table once the page is not mid route
|
||||
// transition, giving up on that wait after %d ms.
|
||||
//
|
||||
|
||||
@@ -936,6 +936,54 @@ func TestSetLastAction_ReportsAPageThatCannotTakeIt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetLogs_ReportsAPageThatCannotTakeThem is the same install on the channel
|
||||
// the log properties hang off. The driver holding a console error changes
|
||||
// nothing on web: the page's reading of every extractor replaces the host's, so
|
||||
// unless the entries are put back into the page, noLogcatErrors counts an empty
|
||||
// array and stays green through a run full of errors.
|
||||
func TestSetLogs_ReportsAPageThatCannotTakeThem(t *testing.T) {
|
||||
const withSetter = `<body><script>
|
||||
window.__logsSeen = null;
|
||||
window.__sanderlingSetLogs__ = function (value) { window.__logsSeen = value; };
|
||||
</script></body>`
|
||||
const withoutSetter = `<body><div id="app">no sanderling runtime here</div></body>`
|
||||
pages := map[string]string{"/with": withSetter, "/without": withoutSetter}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(pages[r.URL.Path]))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
d := New()
|
||||
defer d.Terminate(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := d.Launch(ctx, server.URL+"/with", false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
logs := json.RawMessage(`[{"unixMillis":1,"level":"E","tag":"console","message":"boom"}]`)
|
||||
if err := d.SetLogs(ctx, logs); err != nil {
|
||||
t.Fatalf("SetLogs on a page that defines the setter: %v", err)
|
||||
}
|
||||
var seen []map[string]any
|
||||
if err := chromedp.Run(d.tabCtx,
|
||||
chromedp.Evaluate(`window.__logsSeen`, &seen)); err != nil {
|
||||
t.Fatalf("read installed logs: %v", err)
|
||||
}
|
||||
if len(seen) != 1 || seen[0]["level"] != "E" || seen[0]["message"] != "boom" {
|
||||
t.Errorf("the page received %v, want the error-level entry the driver captured", seen)
|
||||
}
|
||||
|
||||
if err := d.Launch(ctx, server.URL+"/without", false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
if err := d.SetLogs(ctx, logs); err == nil {
|
||||
t.Error("SetLogs reported success on a page with no setter; " +
|
||||
"a runtime that cannot take the step's logs is indistinguishable from one that did")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateExtractors_ReportsAMissingTable is the same failure on the other
|
||||
// sampler. An empty override map is what a spec with no extractors returns, so
|
||||
// treating a missing table as {} makes "this page has no sanderling runtime"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package chrome
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
)
|
||||
|
||||
// Every console verb has to land on the logcat scale driver.LogEntry declares:
|
||||
// the runner fetches at "E" and the default properties count entries whose
|
||||
// level equals "E", so a level spelled any other way is an error the spec never
|
||||
// sees. A verb with no mapping is info, which is honest about severity without
|
||||
// fabricating an error the page never logged.
|
||||
func TestConsoleLevel(t *testing.T) {
|
||||
cases := map[runtime.APIType]string{
|
||||
runtime.APITypeError: "E",
|
||||
runtime.APITypeAssert: "E",
|
||||
runtime.APITypeWarning: "W",
|
||||
runtime.APITypeDebug: "D",
|
||||
runtime.APITypeLog: "I",
|
||||
runtime.APITypeInfo: "I",
|
||||
runtime.APITypeTable: "I",
|
||||
runtime.APIType("countReset"): "I",
|
||||
}
|
||||
for apiType, want := range cases {
|
||||
if got := consoleLevel(apiType); got != want {
|
||||
t.Errorf("consoleLevel(%q) = %q, want %q", apiType, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A level the scale does not name is unknown, not verbose. Ranking it below
|
||||
// every threshold is what silently emptied the web log channel: the entries
|
||||
// existed, the filter dropped them, and the run reported nothing. Evidence the
|
||||
// filter cannot rank has to reach the caller, who can at least see it.
|
||||
func TestMeetsLevel(t *testing.T) {
|
||||
cases := []struct {
|
||||
level string
|
||||
minLevel string
|
||||
want bool
|
||||
}{
|
||||
{"E", "E", true},
|
||||
{"F", "E", true},
|
||||
{"W", "E", false},
|
||||
{"I", "E", false},
|
||||
{"D", "E", false},
|
||||
{"V", "E", false},
|
||||
{"W", "W", true},
|
||||
{"I", "W", false},
|
||||
{"D", "V", true},
|
||||
{"ERROR", "E", true},
|
||||
{"WARNING", "E", true},
|
||||
{"", "E", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := meetsLevel(tc.level, tc.minLevel); got != tc.want {
|
||||
t.Errorf("meetsLevel(%q, %q) = %v, want %v", tc.level, tc.minLevel, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,12 @@ type FocusedWindowChecker interface {
|
||||
FocusedWindowApp(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// LogEntry is one line of device log. Level is logcat's single-letter scale on
|
||||
// every platform: "V", "D", "I", "W", "E", "F", ordered as written. The runner
|
||||
// fetches at "E" and the default properties count entries whose level equals
|
||||
// "E", so a driver that spells a level any other way empties the channel
|
||||
// without failing anything: the entries never arrive and every property reading
|
||||
// state.logs holds vacuously.
|
||||
type LogEntry struct {
|
||||
UnixMillis int64
|
||||
Level string
|
||||
|
||||
@@ -31,17 +31,22 @@ type DeviceOptions struct {
|
||||
BundleID string
|
||||
// AppPath is the .app bundle installed via devicectl for clear-state.
|
||||
AppPath string
|
||||
// ClearState reinstalls the app while NewDevice runs, before the runner's
|
||||
// test session exists. Clear state is a property of the driver rather than
|
||||
// of a launch: see Launch.
|
||||
ClearState bool
|
||||
// Output receives the runner session log path and driver warnings.
|
||||
Output io.Writer
|
||||
// DoubleTapGapMilliseconds overrides the synthesized double-tap gap.
|
||||
DoubleTapGapMilliseconds float64
|
||||
|
||||
// Test seams. Production leaves them nil and NewDevice wires the real
|
||||
// build/spawn/tunnel/dial.
|
||||
spawnRunner func(ctx context.Context, address string) (*exec.Cmd, error)
|
||||
startTunnel func(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error)
|
||||
dialRunner func(address string) (transport.Companion, error)
|
||||
pickAddress func() (string, error)
|
||||
// build/spawn/tunnel/dial/devicectl.
|
||||
spawnRunner func(ctx context.Context, address string) (*exec.Cmd, error)
|
||||
startTunnel func(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error)
|
||||
dialRunner func(address string) (transport.Companion, error)
|
||||
pickAddress func() (string, error)
|
||||
reinstallApp func(ctx context.Context) error
|
||||
}
|
||||
|
||||
// deviceStartupTimeout bounds the runner's startup once its hosting test
|
||||
@@ -61,6 +66,9 @@ func NewDevice(ctx context.Context, options DeviceOptions) (*Driver, error) {
|
||||
if options.CoreDeviceID == "" {
|
||||
return nil, errors.New("ios device: CoreDeviceID is required")
|
||||
}
|
||||
if options.ClearState && options.BundleID == "" {
|
||||
return nil, errors.New("ios device: clear-state needs BundleID: there is nothing to uninstall without it")
|
||||
}
|
||||
output := options.Output
|
||||
if output == nil {
|
||||
output = io.Discard
|
||||
@@ -95,17 +103,24 @@ func NewDevice(ctx context.Context, options DeviceOptions) (*Driver, error) {
|
||||
}
|
||||
}
|
||||
if options.pickAddress != nil {
|
||||
d.pickDeviceAddress = options.pickAddress
|
||||
d.pickRunnerAddress = options.pickAddress
|
||||
} else {
|
||||
d.pickDeviceAddress = pickLoopbackAddress
|
||||
d.pickRunnerAddress = pickLoopbackAddress
|
||||
}
|
||||
|
||||
// Device seams: clear-state reinstalls via devicectl; the container reset and
|
||||
// paste grant are simulator-only and become no-ops. The runner types
|
||||
// natively, so no paste prompt is ever hit.
|
||||
d.reinstallApp = d.devicectlReinstall
|
||||
// natively, so no paste prompt is ever hit. Stopping the app before the
|
||||
// clear is a no-op too: devicectl addresses processes by pid rather than by
|
||||
// bundle, and the uninstall that is the device's only clear takes the
|
||||
// running app with it, which is what a terminate here would be for.
|
||||
d.reinstallApp = options.reinstallApp
|
||||
if d.reinstallApp == nil {
|
||||
d.reinstallApp = d.devicectlReinstall
|
||||
}
|
||||
d.resetContainer = d.deviceResetContainerUnsupported
|
||||
d.grantPaste = func(context.Context) error { return nil }
|
||||
d.terminateApp = func(context.Context) error { return nil }
|
||||
d.restart = d.respawnDevice
|
||||
d.processContext, d.processCancel = context.WithCancel(ctx)
|
||||
|
||||
@@ -116,6 +131,14 @@ func NewDevice(ctx context.Context, options DeviceOptions) (*Driver, error) {
|
||||
}
|
||||
d.deviceLock = lock
|
||||
|
||||
if options.ClearState {
|
||||
if err := d.clearAppState(ctx); err != nil {
|
||||
d.Close()
|
||||
return nil, err
|
||||
}
|
||||
d.clearedBundleID = options.BundleID
|
||||
}
|
||||
|
||||
if err := d.bringUpDevice(ctx); err != nil {
|
||||
d.Close()
|
||||
return nil, err
|
||||
@@ -136,7 +159,7 @@ func NewDevice(ctx context.Context, options DeviceOptions) (*Driver, error) {
|
||||
// health. The build runs inside spawnRunner under the process context, so the
|
||||
// startup timeout only bounds the post-spawn wait, not the build.
|
||||
func (d *Driver) bringUpDevice(ctx context.Context) error {
|
||||
address, err := d.pickDeviceAddress()
|
||||
address, err := d.pickRunnerAddress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -205,8 +228,14 @@ func (d *Driver) respawnDevice(ctx context.Context) error {
|
||||
// devicectlReinstall uninstalls then installs the app bundle via devicectl,
|
||||
// keyed on the CoreDevice id. App lifecycle stays with devicectl: the runner's
|
||||
// own install path is simulator-specific.
|
||||
// A failed uninstall ends the reinstall: installing over an app keeps its data,
|
||||
// so clear-state would be reported without happening. Uninstalling an app that
|
||||
// is not installed exits 0 ("App uninstalled." on a paired iPhone running iOS
|
||||
// 26.5), so there is no benign failure here to sort out from a real one.
|
||||
func (d *Driver) devicectlReinstall(ctx context.Context) error {
|
||||
_ = exec.CommandContext(ctx, "xcrun", "devicectl", "device", "uninstall", "app", "--device", d.coreDeviceID, d.bundleID).Run()
|
||||
if output, err := exec.CommandContext(ctx, "xcrun", "devicectl", "device", "uninstall", "app", "--device", d.coreDeviceID, d.bundleID).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("devicectl uninstall %s: %w: %s", d.bundleID, err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
output, err := exec.CommandContext(ctx, "xcrun", "devicectl", "device", "install", "app", "--device", d.coreDeviceID, d.appPath).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("devicectl install: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
@@ -214,13 +243,11 @@ func (d *Driver) devicectlReinstall(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// deviceResetContainerUnsupported warns once that device clear-state needs an
|
||||
// app path for a devicectl reinstall: there is no simulator-style data-container
|
||||
// wipe on a physical device.
|
||||
// deviceResetContainerUnsupported ends the run: there is no simulator-style
|
||||
// data-container wipe on a physical device, so a clear-state with no app path
|
||||
// to reinstall from cannot happen. Warning and carrying on hands the run every
|
||||
// previous run's data while the flag says it started clean.
|
||||
func (d *Driver) deviceResetContainerUnsupported(context.Context) error {
|
||||
if !d.clearStateWarned {
|
||||
fmt.Fprintln(d.output, "clear-state on a physical device requires --ios-app-path for a reinstall; skipping (state not cleared)")
|
||||
d.clearStateWarned = true
|
||||
}
|
||||
return nil
|
||||
return errors.New("clear-state on a physical device requires --ios-app-path for a reinstall; " +
|
||||
"there is no data-container wipe on a device, so the run would start on the previous run's state")
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
|
||||
@@ -81,6 +83,25 @@ func TestNewDeviceWiresRunnerOnlyMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewDeviceWiresTheAddressPickerEveryBringUpUses keeps the device driver
|
||||
// whole. bringUpRunner reads its picker from a field rather than calling the
|
||||
// package function, and NewDevice left that field nil, so the only thing
|
||||
// standing between a device run and a nil call was which restart path ran.
|
||||
func TestNewDeviceWiresTheAddressPickerEveryBringUpUses(t *testing.T) {
|
||||
address := startLoopbackListener(t)
|
||||
options := testDeviceOptions(address, newDeviceCompanion())
|
||||
options.HardwareUDID = "00008140-PICKER"
|
||||
d, err := NewDevice(context.Background(), options)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDevice: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
if err := d.bringUpRunner(context.Background()); err != nil {
|
||||
t.Fatalf("bringUpRunner: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDeviceRequiresIdentifiers(t *testing.T) {
|
||||
if _, err := NewDevice(context.Background(), DeviceOptions{CoreDeviceID: "x"}); err == nil {
|
||||
t.Fatal("missing HardwareUDID must error")
|
||||
@@ -152,17 +173,100 @@ func TestDeviceEraseAndPressKeyRouteThroughEditor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceClearStateWithoutAppPathWarnsOnce(t *testing.T) {
|
||||
output := &bytes.Buffer{}
|
||||
d := &Driver{output: output, deviceMode: true}
|
||||
d.resetContainer = d.deviceResetContainerUnsupported
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := d.deviceResetContainerUnsupported(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
func TestNewDeviceReinstallsOnceBeforeTheRunnerSession(t *testing.T) {
|
||||
address := startLoopbackListener(t)
|
||||
probe := &clearStateProbe{}
|
||||
options := testDeviceOptions(address, newDeviceCompanion())
|
||||
options.HardwareUDID = "00008140-CLEAR"
|
||||
options.AppPath = "/tmp/Sample.app"
|
||||
options.ClearState = true
|
||||
options.reinstallApp = func(context.Context) error { probe.record("reinstall"); return nil }
|
||||
spawn := options.spawnRunner
|
||||
options.spawnRunner = func(ctx context.Context, runnerAddress string) (*exec.Cmd, error) {
|
||||
probe.record("runner session")
|
||||
return spawn(ctx, runnerAddress)
|
||||
}
|
||||
|
||||
d, err := NewDevice(context.Background(), options)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDevice: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
if err := d.Launch(context.Background(), "", true, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"reinstall", "runner session"}
|
||||
if got := probe.recorded(); !slices.Equal(got, want) {
|
||||
t.Fatalf("calls = %v, want %v: devicectl must reinstall once, before the runner's test session attaches", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevicectlReinstallStopsWhenTheUninstallFails(t *testing.T) {
|
||||
log := scriptedXcrun(t, `"devicectl device uninstall "*) echo "ERROR: Internal logic error: Connection was invalidated"; exit 1;;
|
||||
"devicectl device install "*) :;;`)
|
||||
d := &Driver{coreDeviceID: "CORE-DEVICE", bundleID: "app.example", appPath: "/tmp/Sample.app"}
|
||||
|
||||
err := d.devicectlReinstall(context.Background())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("devicectlReinstall reported success while app.example kept the data clear-state was asked to remove")
|
||||
}
|
||||
for _, want := range []string{"app.example", "Connection was invalidated"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q does not quote %q", err, want)
|
||||
}
|
||||
}
|
||||
if got := bytes.Count(output.Bytes(), []byte("requires --ios-app-path")); got != 1 {
|
||||
t.Fatalf("warning emitted %d times, want once", got)
|
||||
calls := xcrunCalls(t, log)
|
||||
if slices.ContainsFunc(calls, func(call string) bool { return strings.HasPrefix(call, "devicectl device install") }) {
|
||||
t.Errorf("xcrun calls = %v: installing over the app carries its data into the run", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevicectlReinstallProceedsWhenNothingIsInstalled(t *testing.T) {
|
||||
log := scriptedXcrun(t, `"devicectl device uninstall "*) echo "App uninstalled.";;
|
||||
"devicectl device install "*) :;;`)
|
||||
d := &Driver{coreDeviceID: "CORE-DEVICE", bundleID: "app.example", appPath: "/tmp/Sample.app"}
|
||||
|
||||
if err := d.devicectlReinstall(context.Background()); err != nil {
|
||||
t.Fatalf("devicectlReinstall: %v", err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"devicectl device uninstall app --device CORE-DEVICE app.example",
|
||||
"devicectl device install app --device CORE-DEVICE /tmp/Sample.app",
|
||||
}
|
||||
if got := xcrunCalls(t, log); !slices.Equal(got, want) {
|
||||
t.Fatalf("xcrun calls = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewDeviceRefusesClearStateWithoutAnAppPath keeps the device from starting
|
||||
// a run whose clear-state cannot happen. There is no data-container wipe on a
|
||||
// physical device, so without an app path to reinstall from, carrying on hands
|
||||
// the run every previous run's data under a flag that says otherwise.
|
||||
func TestNewDeviceRefusesClearStateWithoutAnAppPath(t *testing.T) {
|
||||
address := startLoopbackListener(t)
|
||||
options := testDeviceOptions(address, newDeviceCompanion())
|
||||
options.HardwareUDID = "00008140-NO-APP-PATH"
|
||||
options.ClearState = true
|
||||
spawned := false
|
||||
spawn := options.spawnRunner
|
||||
options.spawnRunner = func(ctx context.Context, runnerAddress string) (*exec.Cmd, error) {
|
||||
spawned = true
|
||||
return spawn(ctx, runnerAddress)
|
||||
}
|
||||
|
||||
d, err := NewDevice(context.Background(), options)
|
||||
if err == nil {
|
||||
d.Close()
|
||||
t.Fatal("NewDevice returned a driver whose clear-state never happened")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--ios-app-path") {
|
||||
t.Fatalf("err = %v, want it to name the flag that makes the clear possible", err)
|
||||
}
|
||||
if spawned {
|
||||
t.Fatal("the run started anyway; a clear-state that cannot happen must end the run, not open it")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,14 @@ var shutdownGrace = 15 * time.Second
|
||||
// A variable so the timeout test can shrink it.
|
||||
var launchTimeout = 90 * time.Second
|
||||
|
||||
// launchRecoveryTimeout bounds the whole recovery a blown launch bound
|
||||
// triggers, the session restart and the second attempt together. It keeps the
|
||||
// launch path inside the three minutes testrun allows it, so what a user sees
|
||||
// when the app really cannot be launched stays the driver's error rather than
|
||||
// that backstop firing over the top of it. A variable so the bound test can
|
||||
// shrink it.
|
||||
var launchRecoveryTimeout = 60 * time.Second
|
||||
|
||||
// longPressHoldMilliseconds is how long LongPress holds the finger down.
|
||||
const longPressHoldMilliseconds = 600
|
||||
|
||||
@@ -65,16 +73,25 @@ type Options struct {
|
||||
// AppPath is the .app bundle directory. Required for clear-state reinstall;
|
||||
// when empty, clear state falls back to resetting the data container.
|
||||
AppPath string
|
||||
// ClearState resets the app to first-launch state while New runs, before
|
||||
// any automation session attaches. Clear state is a property of the driver
|
||||
// rather than of a launch: see Launch.
|
||||
ClearState bool
|
||||
// Output receives companion stdout and stderr plus driver warnings.
|
||||
Output io.Writer
|
||||
// DoubleTapGapMilliseconds overrides the synthesized double-tap gap.
|
||||
DoubleTapGapMilliseconds float64
|
||||
|
||||
// spawnChild, dialCompanion, and pickAddress are test seams. Production
|
||||
// leaves them nil and New wires the real extraction, spawn, and dial.
|
||||
spawnChild func(ctx context.Context, address string) (*exec.Cmd, error)
|
||||
dialCompanion func(address string) (transport.Companion, error)
|
||||
pickAddress func() (string, error)
|
||||
// These are test seams. Production leaves them nil and New wires the real
|
||||
// extraction, spawn, dial and simctl calls.
|
||||
spawnChild func(ctx context.Context, address string) (*exec.Cmd, error)
|
||||
dialCompanion func(address string) (transport.Companion, error)
|
||||
pickAddress func() (string, error)
|
||||
spawnRunner func(ctx context.Context, address string) (*exec.Cmd, error)
|
||||
dialRunner func(address string) (transport.Companion, error)
|
||||
reinstallApp func(ctx context.Context) error
|
||||
resetContainer func(ctx context.Context) error
|
||||
terminateApp func(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Driver implements driver.DeviceDriver against an iOS simulator companion.
|
||||
@@ -85,6 +102,13 @@ type Driver struct {
|
||||
appPath string
|
||||
output io.Writer
|
||||
|
||||
// clearedBundleID names the app New (or NewDevice) reset to first-launch
|
||||
// state before attaching, which is the only point in a run where clearing
|
||||
// is safe. Launch refuses a clear-state request for anything else rather
|
||||
// than reinstalling under a live session or reporting a reset that only
|
||||
// ever reached another bundle.
|
||||
clearedBundleID string
|
||||
|
||||
screenWidth int
|
||||
screenHeight int
|
||||
|
||||
@@ -114,6 +138,10 @@ type Driver struct {
|
||||
// A seam so tests skip the simctl shell-outs.
|
||||
reinstallApp func(ctx context.Context) error
|
||||
|
||||
// terminateApp stops the app before its state is cleared. A seam so tests
|
||||
// skip the simctl shell-out.
|
||||
terminateApp func(ctx context.Context) error
|
||||
|
||||
// grantPaste pre-authorizes the app's pasteboard access. A seam so tests
|
||||
// skip the sqlite shell-out.
|
||||
grantPaste func(ctx context.Context) error
|
||||
@@ -136,15 +164,19 @@ type Driver struct {
|
||||
dialRunner func(address string) (transport.Companion, error)
|
||||
hybrid bool
|
||||
|
||||
// pickRunnerAddress hands every bring-up a free loopback port, on the
|
||||
// simulator and the device alike. One field, so no path can be wired
|
||||
// without it.
|
||||
pickRunnerAddress func() (string, error)
|
||||
|
||||
// Device-mode fields. On the physical-device path d.companion is the runner
|
||||
// dialed over a usbmux tunnel, hybrid is false, and runnerClient is nil.
|
||||
// coreDeviceID feeds devicectl; tunnel is the in-process usbmux forwarder
|
||||
// bridging the host loopback port to the runner's device-side port.
|
||||
deviceMode bool
|
||||
coreDeviceID string
|
||||
tunnel io.Closer
|
||||
startTunnel func(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error)
|
||||
pickDeviceAddress func() (string, error)
|
||||
deviceMode bool
|
||||
coreDeviceID string
|
||||
tunnel io.Closer
|
||||
startTunnel func(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error)
|
||||
|
||||
// processContext owns the companion child's lifetime: it is derived from
|
||||
// New's context (so a canceled run still reaps the child) and canceled by
|
||||
@@ -189,6 +221,9 @@ func New(ctx context.Context, options Options) (*Driver, error) {
|
||||
if options.UniqueDeviceIdentifier == "" {
|
||||
return nil, errors.New("ios companion: UniqueDeviceIdentifier is required")
|
||||
}
|
||||
if options.ClearState && options.BundleID == "" {
|
||||
return nil, errors.New("ios companion: clear-state needs BundleID: there is nothing to uninstall or wipe without it")
|
||||
}
|
||||
output := options.Output
|
||||
if output == nil {
|
||||
output = io.Discard
|
||||
@@ -206,6 +241,11 @@ func New(ctx context.Context, options Options) (*Driver, error) {
|
||||
doubleTapGapMilliseconds: gap,
|
||||
spawnChild: options.spawnChild,
|
||||
dial: options.dialCompanion,
|
||||
spawnRunner: options.spawnRunner,
|
||||
dialRunner: options.dialRunner,
|
||||
reinstallApp: options.reinstallApp,
|
||||
resetContainer: options.resetContainer,
|
||||
terminateApp: options.terminateApp,
|
||||
hybrid: hybridCompanionEnabled(),
|
||||
}
|
||||
if driverInstance.spawnChild == nil {
|
||||
@@ -232,9 +272,17 @@ func New(ctx context.Context, options Options) (*Driver, error) {
|
||||
return nil, err
|
||||
}
|
||||
driverInstance.address = address
|
||||
driverInstance.pickRunnerAddress = pickAddress
|
||||
driverInstance.restart = driverInstance.respawnAndRedial
|
||||
driverInstance.resetContainer = driverInstance.resetDataContainer
|
||||
driverInstance.reinstallApp = driverInstance.simctlReinstall
|
||||
if driverInstance.resetContainer == nil {
|
||||
driverInstance.resetContainer = driverInstance.resetDataContainer
|
||||
}
|
||||
if driverInstance.reinstallApp == nil {
|
||||
driverInstance.reinstallApp = driverInstance.simctlReinstall
|
||||
}
|
||||
if driverInstance.terminateApp == nil {
|
||||
driverInstance.terminateApp = driverInstance.simctlTerminate
|
||||
}
|
||||
driverInstance.grantPaste = driverInstance.grantPasteboardAccess
|
||||
driverInstance.processContext, driverInstance.processCancel = context.WithCancel(ctx)
|
||||
|
||||
@@ -245,6 +293,14 @@ func New(ctx context.Context, options Options) (*Driver, error) {
|
||||
}
|
||||
driverInstance.deviceLock = lock
|
||||
|
||||
if options.ClearState {
|
||||
if err := driverInstance.clearAppState(ctx); err != nil {
|
||||
driverInstance.Close()
|
||||
return nil, err
|
||||
}
|
||||
driverInstance.clearedBundleID = options.BundleID
|
||||
}
|
||||
|
||||
if err := driverInstance.bringUp(ctx); err != nil {
|
||||
driverInstance.Close()
|
||||
return nil, err
|
||||
@@ -358,7 +414,7 @@ func (d *Driver) bringUpRunner(ctx context.Context) error {
|
||||
// A fresh port every bring-up: after a restart the dying session's
|
||||
// listener may still answer on the old port and would satisfy the wait
|
||||
// below with a dead server.
|
||||
address, err := pickLoopbackAddress()
|
||||
address, err := d.pickRunnerAddress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -441,6 +497,26 @@ func isConnectionError(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isBudgetExpiry reports whether err is a call that outlived its bound rather
|
||||
// than a failure the transport can name. Each transport says so differently:
|
||||
// the runner wraps the context's error when cancellation has landed and the
|
||||
// connection's i/o timeout when the deadline it armed from that context fires
|
||||
// first, and the legacy companion returns a gRPC status. Only an expiry earns
|
||||
// a session restart; an error the runner reports has already said what a fresh
|
||||
// session would say.
|
||||
func isBudgetExpiry(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
if statusValue, ok := status.FromError(err); ok {
|
||||
return statusValue.Code() == codes.DeadlineExceeded
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool, env map[string]string) error {
|
||||
if bundleID != "" {
|
||||
d.bundleID = bundleID
|
||||
@@ -452,6 +528,14 @@ func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool, e
|
||||
// loudly rather than silently dropping the request.
|
||||
return errors.New("ios companion: launch with environment variables is unsupported on this backend")
|
||||
}
|
||||
if clearState && (d.clearedBundleID == "" || d.clearedBundleID != d.bundleID) {
|
||||
// Clearing here would uninstall and reinstall the app underneath a live
|
||||
// automation session, which is what races FrontBoard's registration and
|
||||
// leaves the session launching a bundle FrontBoard has not registered.
|
||||
return fmt.Errorf("ios companion: clear-state must be requested when the driver is created (Options.ClearState) "+
|
||||
"for the bundle being launched; this backend cleared %q before its automation session existed, not %q",
|
||||
d.clearedBundleID, d.bundleID)
|
||||
}
|
||||
|
||||
// Terminate first so the launch is a clean cold start regardless of the
|
||||
// app's prior state. A not-running app is not an error here.
|
||||
@@ -459,12 +543,6 @@ func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool, e
|
||||
return companion.Terminate(callCtx, d.bundleID)
|
||||
})
|
||||
|
||||
if clearState {
|
||||
if err := d.clearAppState(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Grant the app pasteboard access before it runs so unicode input (which
|
||||
// must go through the pasteboard, since HID cannot express it) never trips
|
||||
// the iOS paste-permission prompt. clearState reinstall resets the grant,
|
||||
@@ -477,14 +555,55 @@ func (d *Driver) Launch(ctx context.Context, bundleID string, clearState bool, e
|
||||
}
|
||||
}
|
||||
|
||||
if err := d.lifecycleCall(ctx, func(callCtx context.Context, companion transport.Companion) error {
|
||||
return companion.Launch(callCtx, d.bundleID, true)
|
||||
}); err != nil {
|
||||
if err := d.launchWithSessionRecovery(ctx); err != nil {
|
||||
return fmt.Errorf("launch %s: %w", d.bundleID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// launchWithSessionRecovery runs the launch RPC and, when it blows its own
|
||||
// bound, replaces the session and launches again.
|
||||
//
|
||||
// A launch the simulator refuses, which is what a clear-state reinstall racing
|
||||
// FrontBoard's registration produces, never comes back as an error: XCTest
|
||||
// records the refusal as a test failure the runner cannot observe, then holds
|
||||
// the session's main thread for about four minutes walking a diagnostic chain
|
||||
// (a 120s accessibility wait, a spindump, an idle wait). So there is no error
|
||||
// text to key a retry on, only the expired bound, and every later call queues
|
||||
// behind the same wedge. Only a session that never served the refused launch
|
||||
// can serve the retry, which is why this restarts rather than calls again.
|
||||
func (d *Driver) launchWithSessionRecovery(ctx context.Context) error {
|
||||
launch := func(callCtx context.Context, companion transport.Companion) error {
|
||||
return companion.Launch(callCtx, d.bundleID, true)
|
||||
}
|
||||
err := d.lifecycleCall(ctx, launch)
|
||||
// A caller whose own budget ran out gets no restart: the bound that expired
|
||||
// was the caller's to spend, and the second attempt would inherit it dead.
|
||||
if !isBudgetExpiry(err) || ctx.Err() != nil || d.restart == nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(d.output, "launch %s blew its %v bound (%v); restarting the session and launching once more\n",
|
||||
d.bundleID, launchTimeout, err)
|
||||
|
||||
// The restart runs under the driver's own lifetime context for the same
|
||||
// reason withRecovery's does, while the second attempt stays on the
|
||||
// caller's. Both end at one deadline, so a launch that already spent
|
||||
// launchTimeout cannot then wait out a session cold start on top of it.
|
||||
recoveryDeadline := time.Now().Add(launchRecoveryTimeout)
|
||||
restartCtx := d.processContext
|
||||
if restartCtx == nil {
|
||||
restartCtx = ctx
|
||||
}
|
||||
restartCtx, cancelRestart := context.WithDeadline(restartCtx, recoveryDeadline)
|
||||
defer cancelRestart()
|
||||
if restartErr := d.restart(restartCtx); restartErr != nil {
|
||||
return fmt.Errorf("session restart failed: %w (original: %v)", restartErr, err)
|
||||
}
|
||||
relaunchCtx, cancelRelaunch := context.WithDeadline(ctx, recoveryDeadline)
|
||||
defer cancelRelaunch()
|
||||
return d.lifecycleCall(relaunchCtx, launch)
|
||||
}
|
||||
|
||||
// lifecycleCall runs an app lifecycle RPC against lifecycleCompanion under a
|
||||
// launchTimeout-bounded context, with the usual one-restart recovery. The
|
||||
// companion is resolved inside the retry so a restart's replacement client
|
||||
@@ -511,8 +630,14 @@ func (d *Driver) lifecycleCompanion() transport.Companion {
|
||||
|
||||
// clearAppState resets the app to a first-launch state. With an app path it
|
||||
// uninstalls and reinstalls; without one it falls back to wiping the app's data
|
||||
// container and warns once that a full reinstall needs the app path.
|
||||
// container and warns once that a full reinstall needs the app path. Called
|
||||
// only from construction, before any automation session is attached to the app.
|
||||
func (d *Driver) clearAppState(ctx context.Context) error {
|
||||
// Nothing may be writing to the state while it goes, which is the ordering
|
||||
// Launch used to hold: uninstall copes with a running app, deleting the
|
||||
// data container out from under one does not. Best effort, because an app
|
||||
// that is not running reports a failure that means nothing here.
|
||||
_ = d.terminateApp(ctx)
|
||||
if d.appPath != "" {
|
||||
if err := d.reinstallApp(ctx); err != nil {
|
||||
return fmt.Errorf("reinstall %s: %w", d.appPath, err)
|
||||
@@ -529,8 +654,14 @@ func (d *Driver) clearAppState(ctx context.Context) error {
|
||||
// simctlReinstall uninstalls and reinstalls the app bundle via simctl. App
|
||||
// lifecycle stays with simctl: the companion's install RPC misreads current
|
||||
// simulator targets' architectures and rejects valid bundles.
|
||||
// A failed uninstall ends the reinstall: `simctl install` over an installed app
|
||||
// carries its data container across, so clear-state would be reported without
|
||||
// happening. Uninstalling an app that is not installed exits 0, so there is no
|
||||
// benign failure here to sort out from a real one.
|
||||
func (d *Driver) simctlReinstall(ctx context.Context) error {
|
||||
_ = exec.CommandContext(ctx, "xcrun", "simctl", "uninstall", d.udid, d.bundleID).Run()
|
||||
if output, err := exec.CommandContext(ctx, "xcrun", "simctl", "uninstall", d.udid, d.bundleID).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("simctl uninstall %s: %w: %s", d.bundleID, err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
output, err := exec.CommandContext(ctx, "xcrun", "simctl", "install", d.udid, d.appPath).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("simctl install: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
@@ -538,6 +669,18 @@ func (d *Driver) simctlReinstall(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// simctlTerminate stops the app under test. Launch used to terminate through
|
||||
// the automation session before clearing; the clear now runs before any session
|
||||
// exists, so simctl is what is left to stop the app with. An app that is not
|
||||
// running reports a failure that means nothing to the caller, which is why
|
||||
// clearAppState treats this as best effort.
|
||||
func (d *Driver) simctlTerminate(ctx context.Context) error {
|
||||
if output, err := exec.CommandContext(ctx, "xcrun", "simctl", "terminate", d.udid, d.bundleID).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("simctl terminate %s: %w: %s", d.bundleID, err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// grantPasteboardAccess authorizes the app to read the pasteboard without the
|
||||
// iOS permission prompt, by writing an allow row into the simulator's privacy
|
||||
// (TCC) database. This is the simulator counterpart to `simctl privacy grant`,
|
||||
@@ -965,7 +1108,10 @@ func (d *Driver) WaitForIdle(ctx context.Context, _ time.Duration) error {
|
||||
}
|
||||
|
||||
// RecentLogs returns no entries: the companion log RPC is a follow-up, so v1
|
||||
// reports an empty slice rather than failing.
|
||||
// reports an empty slice rather than failing. Every property reading state.logs
|
||||
// therefore holds vacuously on iOS and nothing says so; closing it means
|
||||
// tailing idb's streaming log RPC and mapping os_log levels onto the
|
||||
// single-letter scale driver.LogEntry declares.
|
||||
func (d *Driver) RecentLogs(_ context.Context, _ time.Time, _ string) ([]driver.LogEntry, error) {
|
||||
return []driver.LogEntry{}, nil
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/driver"
|
||||
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/companionpb"
|
||||
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
|
||||
)
|
||||
|
||||
@@ -183,47 +185,280 @@ func TestLaunchContinuesWhenGrantFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchClearStateReinstallsWithAppPath(t *testing.T) {
|
||||
// clearStateProbe records, in order, the calls a run makes to reset the app and
|
||||
// to bring the runner's automation session up. A reinstall recorded after the
|
||||
// session is the ordering that races FrontBoard.
|
||||
type clearStateProbe struct {
|
||||
mutex sync.Mutex
|
||||
events []string
|
||||
}
|
||||
|
||||
func (p *clearStateProbe) record(event string) {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
p.events = append(p.events, event)
|
||||
}
|
||||
|
||||
func (p *clearStateProbe) recorded() []string {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
out := make([]string, len(p.events))
|
||||
copy(out, p.events)
|
||||
return out
|
||||
}
|
||||
|
||||
// clearStateOptions wires every seam a hybrid bring-up needs, so New runs its
|
||||
// real sequence against fakes: no simulator, no simctl, no XCTest session.
|
||||
func clearStateOptions(t *testing.T, probe *clearStateProbe, udid string, clearState bool) Options {
|
||||
t.Helper()
|
||||
t.Setenv("SANDERLING_SIMULATOR_COMPANION", "")
|
||||
address := startLoopbackListener(t)
|
||||
return Options{
|
||||
UniqueDeviceIdentifier: udid,
|
||||
BundleID: "com.example.app",
|
||||
ClearState: clearState,
|
||||
Output: &bytes.Buffer{},
|
||||
pickAddress: func() (string, error) { return address, nil },
|
||||
spawnChild: func(context.Context, string) (*exec.Cmd, error) { return &exec.Cmd{}, nil },
|
||||
dialCompanion: func(string) (transport.Companion, error) {
|
||||
return &fakeCompanion{accessibilityJSON: "[]"}, nil
|
||||
},
|
||||
spawnRunner: func(context.Context, string) (*exec.Cmd, error) {
|
||||
probe.record("runner session")
|
||||
return &exec.Cmd{}, nil
|
||||
},
|
||||
dialRunner: func(string) (transport.Companion, error) {
|
||||
return &fakeCompanion{accessibilityJSON: "[]"}, nil
|
||||
},
|
||||
reinstallApp: func(context.Context) error { probe.record("reinstall"); return nil },
|
||||
resetContainer: func(context.Context) error { probe.record("reset container"); return nil },
|
||||
terminateApp: func(context.Context) error { probe.record("stop app"); return nil },
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearStateReinstallsOnceBeforeTheRunnerSession(t *testing.T) {
|
||||
probe := &clearStateProbe{}
|
||||
options := clearStateOptions(t, probe, "CLEAR-REINSTALL-UDID", true)
|
||||
options.AppPath = "/tmp/Sample.app"
|
||||
|
||||
d, err := New(context.Background(), options)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
if err := d.Launch(context.Background(), "", true, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"stop app", "reinstall", "runner session"}
|
||||
if got := probe.recorded(); !slices.Equal(got, want) {
|
||||
t.Fatalf("calls = %v, want %v: the reinstall must run once, on a stopped app, before the automation session attaches", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearStateWithoutAppPathWipesContainerBeforeTheRunnerSession(t *testing.T) {
|
||||
probe := &clearStateProbe{}
|
||||
output := &bytes.Buffer{}
|
||||
options := clearStateOptions(t, probe, "CLEAR-CONTAINER-UDID", true)
|
||||
options.Output = output
|
||||
|
||||
d, err := New(context.Background(), options)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
if err := d.Launch(context.Background(), "", true, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"stop app", "reset container", "runner session"}
|
||||
if got := probe.recorded(); !slices.Equal(got, want) {
|
||||
t.Fatalf("calls = %v, want %v: the fallback must wipe a stopped app's container once, before the session, and never reinstall", got, want)
|
||||
}
|
||||
if warnings := strings.Count(output.String(), "resetting the data container only"); warnings != 1 {
|
||||
t.Fatalf("warning emitted %d times, want once", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutClearStateTheAppIsLeftAlone(t *testing.T) {
|
||||
probe := &clearStateProbe{}
|
||||
options := clearStateOptions(t, probe, "NO-CLEAR-UDID", false)
|
||||
options.AppPath = "/tmp/Sample.app"
|
||||
|
||||
d, err := New(context.Background(), options)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
if err := d.Launch(context.Background(), "", false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"runner session"}
|
||||
if got := probe.recorded(); !slices.Equal(got, want) {
|
||||
t.Fatalf("calls = %v, want %v: a run that did not ask for clear state must not touch the install", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchRefusesClearStateTheDriverWasNotBuiltFor(t *testing.T) {
|
||||
companion := &fakeCompanion{accessibilityJSON: "[]"}
|
||||
d := newTestDriver(companion)
|
||||
d.appPath = "/tmp/Sample.app"
|
||||
reinstalls := 0
|
||||
d.reinstallApp = func(context.Context) error { reinstalls++; return nil }
|
||||
if err := d.Launch(context.Background(), "", true, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
|
||||
err := d.Launch(context.Background(), "", true, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "clear-state") {
|
||||
t.Fatalf("Launch err = %v, want a refusal naming clear-state", err)
|
||||
}
|
||||
if reinstalls != 1 {
|
||||
t.Fatalf("clear-state with app path must reinstall exactly once; got %d", reinstalls)
|
||||
if reinstalls != 0 {
|
||||
t.Fatalf("reinstalls = %d, want 0: a live session must never have the app reinstalled under it", reinstalls)
|
||||
}
|
||||
if indexOf(companion.calls, "launch") < indexOf(companion.calls, "terminate") {
|
||||
t.Fatalf("launch must still follow terminate; got %v", companion.calls)
|
||||
if indexOf(companion.recorded(), "launch") >= 0 {
|
||||
t.Fatalf("a refused launch must not reach the companion; got %v", companion.recorded())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchClearStateFallbackWarnsOnce(t *testing.T) {
|
||||
// TestLaunchRefusesClearStateForABundleItDidNotClear holds the guard to the
|
||||
// fact it is guarding. A driver built to clear one bundle has cleared nothing
|
||||
// for another, so reporting that launch as a clear-state launch is a reset the
|
||||
// caller was told happened and did not.
|
||||
func TestLaunchRefusesClearStateForABundleItDidNotClear(t *testing.T) {
|
||||
companion := &fakeCompanion{accessibilityJSON: "[]"}
|
||||
output := &bytes.Buffer{}
|
||||
d := newTestDriver(companion)
|
||||
d.output = output
|
||||
resets := 0
|
||||
d.resetContainer = func(context.Context) error { resets++; return nil }
|
||||
d.clearedBundleID = "com.example.app"
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := d.Launch(context.Background(), "", true, nil); err != nil {
|
||||
t.Fatalf("Launch %d: %v", i, err)
|
||||
err := d.Launch(context.Background(), "com.other.app", true, nil)
|
||||
|
||||
if err == nil || !strings.Contains(err.Error(), "clear-state") {
|
||||
t.Fatalf("Launch err = %v, want a refusal naming clear-state: com.other.app was never cleared", err)
|
||||
}
|
||||
if indexOf(companion.recorded(), "launch") >= 0 {
|
||||
t.Fatalf("a launch reporting a clear that never happened must not reach the companion; got %v", companion.recorded())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsClearStateWithoutBundleID(t *testing.T) {
|
||||
probe := &clearStateProbe{}
|
||||
options := clearStateOptions(t, probe, "NO-BUNDLE-UDID", true)
|
||||
options.BundleID = ""
|
||||
|
||||
if _, err := New(context.Background(), options); err == nil || !strings.Contains(err.Error(), "BundleID") {
|
||||
t.Fatalf("New err = %v, want a refusal naming BundleID", err)
|
||||
}
|
||||
if got := probe.recorded(); len(got) != 0 {
|
||||
t.Fatalf("calls = %v, want none: clearing an unnamed bundle would reinstall without resetting anything", got)
|
||||
}
|
||||
}
|
||||
|
||||
// scriptedXcrun puts an xcrun on PATH that logs each invocation's arguments and
|
||||
// answers from replies, a `case "$*" in` body, so a reinstall runs its real
|
||||
// command sequence and the log holds what reached the tool.
|
||||
func scriptedXcrun(t *testing.T, replies string) string {
|
||||
t.Helper()
|
||||
directory := t.TempDir()
|
||||
log := filepath.Join(directory, "xcrun.log")
|
||||
script := "#!/bin/sh\necho \"$*\" >> " + log + "\ncase \"$*\" in\n" + replies + "\nesac\n"
|
||||
if err := os.WriteFile(filepath.Join(directory, "xcrun"), []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write xcrun: %v", err)
|
||||
}
|
||||
t.Setenv("PATH", directory)
|
||||
return log
|
||||
}
|
||||
|
||||
func xcrunCalls(t *testing.T, log string) []string {
|
||||
t.Helper()
|
||||
contents, err := os.ReadFile(log)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
t.Fatalf("read %s: %v", log, err)
|
||||
}
|
||||
return strings.Split(strings.TrimSpace(string(contents)), "\n")
|
||||
}
|
||||
|
||||
func TestSimctlReinstallStopsWhenTheUninstallFails(t *testing.T) {
|
||||
log := scriptedXcrun(t, `"simctl uninstall "*) echo "Simulator device failed to uninstall app.example."; echo "Uninstall prohibited."; exit 22;;
|
||||
"simctl install "*) :;;`)
|
||||
d := &Driver{udid: "SIM-UDID", bundleID: "app.example", appPath: "/tmp/Sample.app"}
|
||||
|
||||
err := d.simctlReinstall(context.Background())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("simctlReinstall reported success while app.example kept the data clear-state was asked to remove")
|
||||
}
|
||||
for _, want := range []string{"app.example", "Uninstall prohibited."} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q does not quote %q", err, want)
|
||||
}
|
||||
}
|
||||
if resets != 2 {
|
||||
t.Fatalf("resetContainer called %d times, want 2", resets)
|
||||
if calls := xcrunCalls(t, log); slices.Contains(calls, "simctl install SIM-UDID /tmp/Sample.app") {
|
||||
t.Errorf("xcrun calls = %v: installing over the app carries its data into the run", calls)
|
||||
}
|
||||
warnings := strings.Count(output.String(), "resetting the data container only")
|
||||
if warnings != 1 {
|
||||
t.Fatalf("warning emitted %d times, want once", warnings)
|
||||
}
|
||||
|
||||
func TestSimctlReinstallProceedsWhenNothingIsInstalled(t *testing.T) {
|
||||
log := scriptedXcrun(t, `"simctl uninstall "*) :;;
|
||||
"simctl install "*) :;;`)
|
||||
d := &Driver{udid: "SIM-UDID", bundleID: "app.example", appPath: "/tmp/Sample.app"}
|
||||
|
||||
if err := d.simctlReinstall(context.Background()); err != nil {
|
||||
t.Fatalf("simctlReinstall: %v", err)
|
||||
}
|
||||
for _, call := range companion.calls {
|
||||
if call == "install" || call == "uninstall" {
|
||||
t.Fatalf("fallback path must not install/uninstall; got %v", companion.calls)
|
||||
}
|
||||
|
||||
want := []string{"simctl uninstall SIM-UDID app.example", "simctl install SIM-UDID /tmp/Sample.app"}
|
||||
if got := xcrunCalls(t, log); !slices.Equal(got, want) {
|
||||
t.Fatalf("xcrun calls = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearStateStopsTheAppBeforeWipingItsContainer covers the ordering Launch
|
||||
// used to hold. simctl uninstall copes with a running app; deleting the data
|
||||
// container out from under one does not, and the CI iOS leg passes no app path
|
||||
// so it is the wipe that runs. A run whose previous run was interrupted finds
|
||||
// the app still up.
|
||||
func TestClearStateStopsTheAppBeforeWipingItsContainer(t *testing.T) {
|
||||
container := t.TempDir()
|
||||
stale := filepath.Join(container, "Documents")
|
||||
if err := os.Mkdir(stale, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
log := scriptedXcrun(t, `"simctl terminate "*) :;;
|
||||
"simctl get_app_container "*) echo `+container+`;;`)
|
||||
d := &Driver{udid: "SIM-UDID", bundleID: "app.example", output: &bytes.Buffer{}}
|
||||
d.terminateApp = d.simctlTerminate
|
||||
d.resetContainer = d.resetDataContainer
|
||||
|
||||
if err := d.clearAppState(context.Background()); err != nil {
|
||||
t.Fatalf("clearAppState: %v", err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"simctl terminate SIM-UDID app.example",
|
||||
"simctl get_app_container SIM-UDID app.example data",
|
||||
}
|
||||
if got := xcrunCalls(t, log); !slices.Equal(got, want) {
|
||||
t.Fatalf("xcrun calls = %v, want %v: the app was still writing to the container being deleted", got, want)
|
||||
}
|
||||
if _, err := os.Stat(stale); !os.IsNotExist(err) {
|
||||
t.Fatalf("stat %s = %v, want the previous run's state gone", stale, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearStateSurvivesAnAppThatIsNotRunning holds the terminate to best
|
||||
// effort. simctl exits non-zero when there is nothing to stop, and a first run
|
||||
// on a fresh simulator must not fail on it.
|
||||
func TestClearStateSurvivesAnAppThatIsNotRunning(t *testing.T) {
|
||||
container := t.TempDir()
|
||||
scriptedXcrun(t, `"simctl terminate "*) echo "No matching processes belonging to bundle identifier app.example"; exit 3;;
|
||||
"simctl get_app_container "*) echo `+container+`;;`)
|
||||
d := &Driver{udid: "SIM-UDID", bundleID: "app.example", output: &bytes.Buffer{}}
|
||||
d.terminateApp = d.simctlTerminate
|
||||
d.resetContainer = d.resetDataContainer
|
||||
|
||||
if err := d.clearAppState(context.Background()); err != nil {
|
||||
t.Fatalf("clearAppState: %v: an app that is not running is not a failure to clear", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -952,6 +1187,301 @@ func TestLaunchLeavesATighterCallerDeadlineAlone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// blownBudgetShape is one way a transport the driver launches through reports
|
||||
// a lifecycle call outliving its budget.
|
||||
type blownBudgetShape struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
|
||||
// blownBudgetShapes drives every such transport against a server that never
|
||||
// answers and returns the error each one really produces. The runner transport
|
||||
// has two: wrapTransport wraps the context's own error once cancellation has
|
||||
// landed, and the connection's i/o timeout when the deadline it armed from
|
||||
// that context fires first. The legacy transport reports the same expiry as a
|
||||
// gRPC status. Only the first satisfies errors.Is(err, context.DeadlineExceeded),
|
||||
// so a fake that returns ctx.Err() raw shows the driver a recovery that two
|
||||
// thirds of production can never reach.
|
||||
func blownBudgetShapes(t *testing.T) []blownBudgetShape {
|
||||
t.Helper()
|
||||
return []blownBudgetShape{
|
||||
{"runner context deadline", runnerContextDeadlineError(t)},
|
||||
{"runner connection deadline", silentRunnerLaunchError(t, connectionDeadlineOnly{time.Now().Add(100 * time.Millisecond)})},
|
||||
{"legacy grpc deadline", silentGRPCLaunchError(t)},
|
||||
}
|
||||
}
|
||||
|
||||
// runnerContextDeadlineError is the shape the runner transport produces once
|
||||
// the context's own cancellation has landed.
|
||||
func runnerContextDeadlineError(t *testing.T) error {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
||||
defer cancel()
|
||||
return silentRunnerLaunchError(t, ctx)
|
||||
}
|
||||
|
||||
// connectionDeadlineOnly carries a deadline the runner transport arms the
|
||||
// connection with, while its own cancellation never lands. That is the race
|
||||
// wrapTransport's second branch exists for: the connection's deadline fires
|
||||
// while ctx.Err() is still nil.
|
||||
type connectionDeadlineOnly struct{ deadline time.Time }
|
||||
|
||||
func (c connectionDeadlineOnly) Deadline() (time.Time, bool) { return c.deadline, true }
|
||||
func (c connectionDeadlineOnly) Done() <-chan struct{} { return nil }
|
||||
func (c connectionDeadlineOnly) Err() error { return nil }
|
||||
func (c connectionDeadlineOnly) Value(any) any { return nil }
|
||||
|
||||
// silentRunnerLaunchError returns what the real runner transport produces for a
|
||||
// launch nobody ever answers.
|
||||
func silentRunnerLaunchError(t *testing.T, ctx context.Context) error {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
conn, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
<-closed
|
||||
}()
|
||||
companion, err := transport.DialRunner(listener.Addr().String(), "SIM-UDID", "com.example.app")
|
||||
if err != nil {
|
||||
listener.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
close(closed)
|
||||
_ = companion.Close()
|
||||
listener.Close()
|
||||
})
|
||||
|
||||
launchErr := companion.Launch(ctx, "com.example.app", true)
|
||||
if launchErr == nil {
|
||||
t.Fatal("the runner transport reported a launch no server ever answered")
|
||||
}
|
||||
return launchErr
|
||||
}
|
||||
|
||||
// silentCompanionServer is the legacy companion with a launch that never
|
||||
// answers, so the caller's own deadline is what ends the call.
|
||||
type silentCompanionServer struct {
|
||||
companionpb.UnimplementedCompanionServiceServer
|
||||
}
|
||||
|
||||
func (silentCompanionServer) Launch(stream grpc.BidiStreamingServer[companionpb.LaunchRequest, companionpb.LaunchResponse]) error {
|
||||
<-stream.Context().Done()
|
||||
return stream.Context().Err()
|
||||
}
|
||||
|
||||
// silentGRPCLaunchError returns what the legacy transport produces for the same
|
||||
// launch, which SANDERLING_SIMULATOR_COMPANION=legacy still runs on.
|
||||
func silentGRPCLaunchError(t *testing.T) error {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := grpc.NewServer()
|
||||
companionpb.RegisterCompanionServiceServer(server, silentCompanionServer{})
|
||||
go server.Serve(listener)
|
||||
t.Cleanup(server.Stop)
|
||||
|
||||
companion, err := transport.Dial(listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { companion.Close() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
|
||||
defer cancel()
|
||||
launchErr := companion.Launch(ctx, "com.example.app", true)
|
||||
if launchErr == nil {
|
||||
t.Fatal("the legacy transport reported a launch the companion never answered")
|
||||
}
|
||||
return launchErr
|
||||
}
|
||||
|
||||
// wedgedUntilRestartCompanion models the session a refused launch leaves
|
||||
// behind: the refusal is never reported, no later launch is answered until the
|
||||
// session itself is replaced, and the expired bound reaches the driver in
|
||||
// whatever shape its transport gives it.
|
||||
type wedgedUntilRestartCompanion struct {
|
||||
fakeCompanion
|
||||
blownBudget error
|
||||
mutex sync.Mutex
|
||||
replaced bool
|
||||
attempted int
|
||||
}
|
||||
|
||||
func (w *wedgedUntilRestartCompanion) replaceSession() {
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
w.replaced = true
|
||||
}
|
||||
|
||||
func (w *wedgedUntilRestartCompanion) launchAttempts() int {
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
return w.attempted
|
||||
}
|
||||
|
||||
func (w *wedgedUntilRestartCompanion) Launch(ctx context.Context, _ string, _ bool) error {
|
||||
w.mutex.Lock()
|
||||
w.attempted++
|
||||
replaced := w.replaced
|
||||
w.mutex.Unlock()
|
||||
if replaced {
|
||||
return nil
|
||||
}
|
||||
<-ctx.Done()
|
||||
return w.blownBudget
|
||||
}
|
||||
|
||||
// TestLaunchReplacesTheSessionAfterALaunchBlowsItsBound covers the FrontBoard
|
||||
// race: a clear-state reinstall the simulator has not finished registering
|
||||
// makes the session refuse the launch, and XCTest answers that refusal with
|
||||
// minutes of diagnostics instead of an error, so the bound expires and every
|
||||
// later call queues behind the same wedge. Calling launch again on that session
|
||||
// cannot work; the run only recovers if the session is replaced first. The
|
||||
// recovery has to fire on every shape the driver's transports report that
|
||||
// expiry in, because which one arrives is a race the driver does not control.
|
||||
func TestLaunchReplacesTheSessionAfterALaunchBlowsItsBound(t *testing.T) {
|
||||
for _, shape := range blownBudgetShapes(t) {
|
||||
t.Run(shape.name, func(t *testing.T) {
|
||||
previous := launchTimeout
|
||||
launchTimeout = 100 * time.Millisecond
|
||||
defer func() { launchTimeout = previous }()
|
||||
|
||||
companion := &wedgedUntilRestartCompanion{blownBudget: shape.err}
|
||||
output := &bytes.Buffer{}
|
||||
d := newTestDriver(companion)
|
||||
d.output = output
|
||||
restarts := 0
|
||||
d.restart = func(context.Context) error {
|
||||
restarts++
|
||||
companion.replaceSession()
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := d.Launch(context.Background(), "", false, nil); err != nil {
|
||||
t.Fatalf("Launch: %v", err)
|
||||
}
|
||||
if restarts != 1 {
|
||||
t.Fatalf("session restarts = %d, want exactly 1 (the session reported %v)", restarts, shape.err)
|
||||
}
|
||||
if attempts := companion.launchAttempts(); attempts != 2 {
|
||||
t.Fatalf("launch attempts = %d, want 2: one that wedged and one on the replaced session", attempts)
|
||||
}
|
||||
if !strings.Contains(output.String(), "restarting the session") {
|
||||
t.Fatalf("the recovery was silent, so a run that needed it never says so; output was %q", output.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLaunchBoundsTheSessionRestartItTriggers keeps the recovery inside a
|
||||
// budget of its own. The restart deliberately runs on the driver's lifetime
|
||||
// context rather than the caller's, so without a deadline a session that never
|
||||
// comes back would hang the launch path exactly the way #73 stopped it hanging.
|
||||
func TestLaunchBoundsTheSessionRestartItTriggers(t *testing.T) {
|
||||
previousLaunch, previousRecovery := launchTimeout, launchRecoveryTimeout
|
||||
launchTimeout = 100 * time.Millisecond
|
||||
launchRecoveryTimeout = 200 * time.Millisecond
|
||||
defer func() { launchTimeout, launchRecoveryTimeout = previousLaunch, previousRecovery }()
|
||||
|
||||
d := newTestDriver(&wedgedUntilRestartCompanion{blownBudget: runnerContextDeadlineError(t)})
|
||||
d.restart = func(restartCtx context.Context) error {
|
||||
<-restartCtx.Done()
|
||||
return restartCtx.Err()
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- d.Launch(context.Background(), "", false, nil) }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil || !strings.Contains(err.Error(), "session restart failed") {
|
||||
t.Fatalf("err = %v, want the failed restart named", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Launch never returned: a session that never comes back hangs the launch path")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLaunchKeepsTheSessionWhenTheCallersOwnDeadlineExpires holds the recovery
|
||||
// to the driver's own bound. Spending a session restart on a caller that has
|
||||
// already run out of budget cannot produce a launch, only a later failure.
|
||||
func TestLaunchKeepsTheSessionWhenTheCallersOwnDeadlineExpires(t *testing.T) {
|
||||
previous := launchTimeout
|
||||
launchTimeout = 30 * time.Second
|
||||
defer func() { launchTimeout = previous }()
|
||||
|
||||
companion := &wedgedUntilRestartCompanion{blownBudget: runnerContextDeadlineError(t)}
|
||||
d := newTestDriver(companion)
|
||||
restarts := 0
|
||||
d.restart = func(context.Context) error {
|
||||
restarts++
|
||||
companion.replaceSession()
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := d.Launch(ctx, "", false, nil); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("err = %v, want a deadline-exceeded error", err)
|
||||
}
|
||||
if restarts != 0 {
|
||||
t.Fatalf("session restarts = %d, want 0", restarts)
|
||||
}
|
||||
}
|
||||
|
||||
// refusedLaunchCompanion answers a launch the way the runner does once it
|
||||
// checks the app's state after activating it: promptly, naming the app and the
|
||||
// state it reached, over a session that is still serving.
|
||||
type refusedLaunchCompanion struct {
|
||||
fakeCompanion
|
||||
attempts int
|
||||
}
|
||||
|
||||
func (r *refusedLaunchCompanion) Launch(context.Context, string, bool) error {
|
||||
r.attempts++
|
||||
return errors.New(`runner launch: failed("com.example.app is not running after launch")`)
|
||||
}
|
||||
|
||||
// TestLaunchKeepsTheSessionWhenTheRunnerNamesTheRefusal separates a launch that
|
||||
// answers from a launch that never does. The session restart is the only
|
||||
// recovery from a wedged session, and it costs a cold start; a runner that
|
||||
// reports the app's state has already said what a fresh session would say, so
|
||||
// restarting to hear it again only delays the error and hides the app under it.
|
||||
func TestLaunchKeepsTheSessionWhenTheRunnerNamesTheRefusal(t *testing.T) {
|
||||
companion := &refusedLaunchCompanion{}
|
||||
output := &bytes.Buffer{}
|
||||
d := newTestDriver(companion)
|
||||
d.output = output
|
||||
restarts := 0
|
||||
d.restart = func(context.Context) error {
|
||||
restarts++
|
||||
return nil
|
||||
}
|
||||
|
||||
err := d.Launch(context.Background(), "", false, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "com.example.app is not running after launch") {
|
||||
t.Fatalf("err = %v, want the runner's refusal reaching the caller intact", err)
|
||||
}
|
||||
if restarts != 0 {
|
||||
t.Fatalf("session restarts = %d, want 0: a refusal the runner reported is not a wedged session", restarts)
|
||||
}
|
||||
if companion.attempts != 1 {
|
||||
t.Fatalf("launch attempts = %d, want 1: relaunching an app the runner just refused cannot launch it", companion.attempts)
|
||||
}
|
||||
if strings.Contains(output.String(), "restarting the session") {
|
||||
t.Fatalf("the driver announced a recovery it must not spend here; output was %q", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
// newLockTestOptions builds New options that dial a seamed companion, so the
|
||||
// device-lock tests exercise New without spawning anything.
|
||||
func newLockTestOptions(t *testing.T, udid string) Options {
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
// descPrefix:<prefix> - starts-with on content-desc / accessibilityText
|
||||
//
|
||||
// Object selectors (multi-attribute AND, element-scoped or global):
|
||||
// { attr: value, ... } - all key/value pairs must match; substring / boolean semantics
|
||||
// { attr: value, ... } - all key/value pairs must match, each key resolved by
|
||||
// the same rule its string form above uses
|
||||
//
|
||||
// Path queries (global scan only, string form):
|
||||
// <sel> > <sel> > ... - each segment matched within subtree of previous match
|
||||
@@ -445,7 +446,14 @@ func matchAttr(element *Element, attr, value string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// matchSelector returns true when all filters in sel match the element (AND semantics).
|
||||
// matchSelector returns true when all filters in sel match the element (AND
|
||||
// semantics). Each filter goes through matchAttr, the same rule the string form
|
||||
// resolves a "kind:value" segment by, so {id: "Submit"} and "id:Submit" can
|
||||
// never resolve to different elements. Reaching the attribute map directly here
|
||||
// made the object form skip the kind arms entirely: id, desc and descPrefix
|
||||
// name no attribute any producer writes, so those keys matched NOTHING through
|
||||
// an object selector while the string form matched, and every property over the
|
||||
// missing element passed vacuously.
|
||||
func matchSelector(element *Element, sel Selector) bool {
|
||||
for _, f := range sel.Filters {
|
||||
if !matchAttr(element, f.Attr, f.Value) {
|
||||
|
||||
@@ -1471,3 +1471,83 @@ func TestParseUnreadableFlagKeepsTheRestOfTheTree(t *testing.T) {
|
||||
t.Errorf("stored UnreadableFlags = %d, want 1: the trace has to carry it", decoded.UnreadableFlags)
|
||||
}
|
||||
}
|
||||
|
||||
// selectorFormsDump carries one node per id shape a real dump produces, plus
|
||||
// nodes carrying a description in the ", " form the desc rule knows about and a
|
||||
// text the text rule matches on a substring.
|
||||
const selectorFormsDump = `{
|
||||
"attributes": {"resource-id": "root", "bounds": "[0,0,400,800]"},
|
||||
"children": [
|
||||
{"attributes": {"resource-id": "BareThing", "bounds": "[0,0,100,50]"}, "children": []},
|
||||
{"attributes": {"resource-id": "com.example.app:id/AndroidThing", "bounds": "[0,50,100,100]"},
|
||||
"children": []},
|
||||
{"attributes": {"accessibilityIdentifier": "IosThing", "bounds": "[0,100,100,150]"},
|
||||
"children": []},
|
||||
{"attributes": {"resource-id": "Described", "content-desc": "Save, button", "bounds": "[0,150,100,200]"},
|
||||
"children": []},
|
||||
{"attributes": {"resource-id": "Labelled", "text": "Total balance", "bounds": "[0,200,100,250]"},
|
||||
"children": []}
|
||||
]
|
||||
}`
|
||||
|
||||
// TestSelectorFormsResolveTheSameElement holds the two selector forms a spec can
|
||||
// write to ONE rule per key. A spec reaches these through state.ax.find: a
|
||||
// string goes to FindNode, an object to FindBySelector, and the two ran
|
||||
// different matchers. `id` has a kind arm that knows an Android resource id is
|
||||
// package-qualified (com.example.app:id/Thing) and that a spec names the bare
|
||||
// tail; the object form had no such arm and looked for a literal `id` attribute
|
||||
// no producer writes, so {id: "Thing"} silently matched nothing on every
|
||||
// platform while "id:Thing" matched. `desc` and `descPrefix` had the same
|
||||
// split. A selector that resolves nothing makes every property over it
|
||||
// vacuously true, which is the failure that reports a green run while checking
|
||||
// nothing.
|
||||
func TestSelectorFormsResolveTheSameElement(t *testing.T) {
|
||||
tree, err := Parse(selectorFormsDump)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
key string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{"id", "BareThing", "BareThing"},
|
||||
// A spec names the tail; an Android dump carries the package prefix.
|
||||
{"id", "AndroidThing", "com.example.app:id/AndroidThing"},
|
||||
{"id", "com.example.app:id/AndroidThing", "com.example.app:id/AndroidThing"},
|
||||
{"id", "IosThing", "IosThing"},
|
||||
{"desc", "Save, button", "Described"},
|
||||
// The ", " form an accessibility label takes when a role is appended.
|
||||
{"desc", "Save", "Described"},
|
||||
{"descPrefix", "Sav", "Described"},
|
||||
{"text", "Total", "Labelled"},
|
||||
{"resource-id", "BareThing", "BareThing"},
|
||||
{"testTag", "IosThing", "IosThing"},
|
||||
} {
|
||||
t.Run(test.key+":"+test.value, func(t *testing.T) {
|
||||
stringForm := test.key + ":" + test.value
|
||||
fromString := tree.FindNode(stringForm)
|
||||
if fromString == nil {
|
||||
t.Fatalf("the string form %q resolved nothing", stringForm)
|
||||
}
|
||||
if fromString.ResourceID != test.want {
|
||||
t.Fatalf("the string form resolved %q, want %q", fromString.ResourceID, test.want)
|
||||
}
|
||||
fromObject := tree.Root.FindBySelector(
|
||||
Selector{Filters: []AttrFilter{{Attr: test.key, Value: test.value}}},
|
||||
)
|
||||
if fromObject == nil {
|
||||
t.Fatalf(
|
||||
"the object form {%s: %q} resolved nothing while %q resolved %q",
|
||||
test.key, test.value, stringForm, fromString.ResourceID,
|
||||
)
|
||||
}
|
||||
if fromObject != fromString {
|
||||
t.Errorf(
|
||||
"one selector, two answers: {%s: %q} resolved %q and %q resolved %q",
|
||||
test.key, test.value, fromObject.ResourceID, stringForm, fromString.ResourceID,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -86,8 +86,9 @@ type NextFormula struct {
|
||||
}
|
||||
|
||||
// EventuallyFormula obliges its inner formula to hold at some step within the
|
||||
// given bound. An unbounded eventually never triggers a violation within a
|
||||
// finite run.
|
||||
// given bound. An unbounded eventually that never fires is violated when the
|
||||
// run ends, with the reason "eventually never satisfied", so an eventually over
|
||||
// a state the run may not reach is red on every run that does not reach it.
|
||||
//
|
||||
// When Duration is non-zero and Deadline is the zero time, the evaluator
|
||||
// resolves the absolute deadline on first reduction using the observation
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/driver"
|
||||
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
|
||||
"github.com/priyanshujain/sanderling/internal/trace"
|
||||
)
|
||||
|
||||
// homeWithRows is one settled route whose list holds rows. A row arriving
|
||||
// between two reads is what a Compose lazy list mounting over several frames
|
||||
// looks like from the runner's side.
|
||||
func homeWithRows(rows int) string {
|
||||
var children strings.Builder
|
||||
for row := range rows {
|
||||
fmt.Fprintf(&children,
|
||||
`,{"attributes":{"resource-id":"TxnRow%d","class":"android.view.View"},"children":[]}`, row)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
`{"attributes":{"resource-id":"HomeScreen","class":"android.view.View"},"children":[
|
||||
{"attributes":{"resource-id":"TxnList","class":"android.view.View"},"children":[]}%s
|
||||
]}`, children.String())
|
||||
}
|
||||
|
||||
// composesLateDriver answers the paired Snapshot with the frame the step
|
||||
// records and the hierarchy read that follows with a tree that has grown a row,
|
||||
// for the first composingReads reads of the run. After that both reads describe
|
||||
// the same screen.
|
||||
type composesLateDriver struct {
|
||||
*mockdriver.Driver
|
||||
composingReads int64
|
||||
reads atomic.Int64
|
||||
}
|
||||
|
||||
func (d *composesLateDriver) Snapshot(context.Context) (string, driver.Image, error) {
|
||||
return homeWithRows(1), driver.Image{PNG: []byte("png"), Width: 1, Height: 1}, nil
|
||||
}
|
||||
|
||||
func (d *composesLateDriver) Hierarchy(context.Context) (string, error) {
|
||||
if d.reads.Add(1) <= d.composingReads {
|
||||
return homeWithRows(2), nil
|
||||
}
|
||||
return homeWithRows(1), nil
|
||||
}
|
||||
|
||||
// A route can settle before its content composes, so a tree read the moment the
|
||||
// route arrives can describe a screen that is still filling in. Verifying that
|
||||
// step compares a half-composed frame against a settled one and convicts an app
|
||||
// that did nothing wrong. Two reads a read apart see it happening, and the step
|
||||
// they disagree on is one the verifier must never be handed.
|
||||
//
|
||||
// The always-false property is the witness: it fires on the first step the
|
||||
// verifier evaluates, so the step index of its violation says exactly which
|
||||
// step reached the verifier.
|
||||
func TestRunner_AStepWhoseTreeChangedBetweenReadsIsNotVerified(t *testing.T) {
|
||||
run := func(t *testing.T, composingReads int64) (Summary, string) {
|
||||
t.Helper()
|
||||
state := newHarnessWithSpec(t, violationSpec)
|
||||
device := &composesLateDriver{Driver: state.mock, composingReads: composingReads}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
summary, err := Run(ctx, Options{
|
||||
Duration: time.Hour,
|
||||
IdleTimeout: 20 * time.Millisecond,
|
||||
MaxSteps: 3,
|
||||
Driver: device,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if summary.Steps != 3 {
|
||||
t.Fatalf("steps = %d, want 3", summary.Steps)
|
||||
}
|
||||
return summary, state.writer.Directory()
|
||||
}
|
||||
|
||||
t.Run("the step it changed on is skipped, the next one is judged", func(t *testing.T) {
|
||||
summary, directory := run(t, 1)
|
||||
if len(summary.Violations) != 1 {
|
||||
t.Fatalf("violations = %v, want exactly one", summary.Violations)
|
||||
}
|
||||
violation := summary.Violations[0]
|
||||
if violation.Properties[0] != "balanceNonNegative" {
|
||||
t.Fatalf("violated %v, want balanceNonNegative", violation.Properties)
|
||||
}
|
||||
if violation.StepIndex != 2 {
|
||||
t.Errorf("the property first judged step %d, want 2; the verifier was handed "+
|
||||
"a screen that grew a row while the runner was reading it",
|
||||
violation.StepIndex)
|
||||
}
|
||||
if summary.SkippedVerification != 1 {
|
||||
t.Errorf("the run reports %d step(s) judged by nothing, want 1",
|
||||
summary.SkippedVerification)
|
||||
}
|
||||
// Skipped is not lost: the step is still recorded, screenshot and all,
|
||||
// so the run can be replayed over the frame nothing judged.
|
||||
steps := traceSteps(t, directory)
|
||||
if len(steps) != 3 {
|
||||
t.Fatalf("trace holds %d step(s), want 3", len(steps))
|
||||
}
|
||||
if len(steps[0].Violations) != 0 {
|
||||
t.Errorf("step 1 recorded violations %v; it was never verified", steps[0].Violations)
|
||||
}
|
||||
screenshot := filepath.Join(directory, "screenshots", "step-00001.png")
|
||||
if _, err := os.Stat(screenshot); err != nil {
|
||||
t.Errorf("expected the skipped step's screenshot at %s: %v", screenshot, err)
|
||||
}
|
||||
})
|
||||
|
||||
// The control. Two reads that agree must verify as they always did,
|
||||
// otherwise the case above is just a runner that verifies nothing.
|
||||
t.Run("two reads that agree verify the step", func(t *testing.T) {
|
||||
summary, _ := run(t, 0)
|
||||
if len(summary.Violations) != 1 {
|
||||
t.Fatalf("violations = %v, want exactly one", summary.Violations)
|
||||
}
|
||||
if got := summary.Violations[0].StepIndex; got != 1 {
|
||||
t.Errorf("the property first judged step %d, want 1; a settled screen must be "+
|
||||
"verified on the step it was read", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// submitsOnTapDriver commits commitsPerTap transactions on every tap, shows the
|
||||
// running total in the tree, and grows a row under the hierarchy read that
|
||||
// follows the paired Snapshot: on one chosen step, on the run of steps from
|
||||
// composingRead through composingThrough, or on every one of them.
|
||||
type submitsOnTapDriver struct {
|
||||
*mockdriver.Driver
|
||||
commitsPerTap int64
|
||||
composingRead int64
|
||||
composingThrough int64
|
||||
everyRead bool
|
||||
reads atomic.Int64
|
||||
committed atomic.Int64
|
||||
}
|
||||
|
||||
func (d *submitsOnTapDriver) Tap(context.Context, int, int) error { return d.commit() }
|
||||
func (d *submitsOnTapDriver) TapSelector(context.Context, string) error { return d.commit() }
|
||||
|
||||
func (d *submitsOnTapDriver) commit() error {
|
||||
d.committed.Add(d.commitsPerTap)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *submitsOnTapDriver) Snapshot(context.Context) (string, driver.Image, error) {
|
||||
return fmt.Sprintf(homeWithTxnCount, d.committed.Load()), driver.Image{}, nil
|
||||
}
|
||||
|
||||
func (d *submitsOnTapDriver) Hierarchy(context.Context) (string, error) {
|
||||
read := d.reads.Add(1)
|
||||
composing := d.everyRead || read == d.composingRead ||
|
||||
(read >= d.composingRead && read <= d.composingThrough)
|
||||
if composing {
|
||||
return fmt.Sprintf(homeWithTxnCountComposing, d.committed.Load()), nil
|
||||
}
|
||||
return fmt.Sprintf(homeWithTxnCount, d.committed.Load()), nil
|
||||
}
|
||||
|
||||
// The same tree with one more row in it, which is what the reread sees while
|
||||
// the screen is still filling in.
|
||||
const homeWithTxnCountComposing = `{"attributes":{"resource-id":"HomeScreen"},"children":[
|
||||
{"attributes":{"resource-id":"TxnCount","text":"%d"},"children":[]},
|
||||
{"attributes":{"resource-id":"TxnSubmit","bounds":"[40,80,240,160]"},"children":[],"clickable":true,"enabled":true},
|
||||
{"attributes":{"resource-id":"TxnRowLate"},"children":[]}
|
||||
]}`
|
||||
|
||||
// Skipping a step is only free if nothing the spec needs goes missing with it.
|
||||
// The action a step applies is reported to the spec on the NEXT step the
|
||||
// verifier accepts, so a skipped step in between swallows the action before it:
|
||||
// the transaction it committed still turns up in the next reading, and
|
||||
// submitCommitsOneTransactionPerAction sees a rise nothing in its window
|
||||
// accounts for. That is the conviction #77 and #78 are about, arriving through
|
||||
// the skip rather than through the runner's report.
|
||||
//
|
||||
// So a frame the verifier will not look at is not one to act on either, which
|
||||
// is also what #75 asked for: the fuzzer must not tap into a screen that is
|
||||
// still filling in.
|
||||
func TestRunner_ASkippedStepDoesNotSwallowTheActionBeforeIt(t *testing.T) {
|
||||
spec := specWithFolioPredicates(t)
|
||||
|
||||
run := func(t *testing.T, composingRead int64) (Summary, int64) {
|
||||
t.Helper()
|
||||
state := newHarnessWithSpec(t, spec)
|
||||
device := &submitsOnTapDriver{
|
||||
Driver: state.mock,
|
||||
commitsPerTap: 1,
|
||||
composingRead: composingRead,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
summary, err := Run(ctx, Options{
|
||||
Duration: time.Hour,
|
||||
IdleTimeout: 20 * time.Millisecond,
|
||||
MaxSteps: 3,
|
||||
Driver: device,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if summary.Steps != 3 {
|
||||
t.Fatalf("steps = %d, want 3", summary.Steps)
|
||||
}
|
||||
return summary, device.committed.Load()
|
||||
}
|
||||
|
||||
t.Run("a submit is not lost to the step that follows it", func(t *testing.T) {
|
||||
summary, committed := run(t, 2)
|
||||
if summary.SkippedVerification != 1 {
|
||||
t.Fatalf("the run skipped %d step(s), want 1; the reread never fired, so this "+
|
||||
"proves nothing", summary.SkippedVerification)
|
||||
}
|
||||
if committed == 0 {
|
||||
t.Fatal("the device committed nothing; a runner that never acts passes this " +
|
||||
"test without meaning anything")
|
||||
}
|
||||
if len(summary.Violations) != 0 {
|
||||
t.Errorf("the counting property convicted a healthy app: %v\n"+
|
||||
"one transaction per submit rose, and a submit went unreported because "+
|
||||
"the step after it was skipped", summary.Violations)
|
||||
}
|
||||
})
|
||||
|
||||
// The control: with nothing composing, every step is verified and the same
|
||||
// app is judged clean, so the case above is not just a runner that stopped
|
||||
// judging.
|
||||
t.Run("every step verified, same app, no violation", func(t *testing.T) {
|
||||
summary, committed := run(t, 0)
|
||||
if summary.SkippedVerification != 0 {
|
||||
t.Fatalf("the run skipped %d step(s), want 0", summary.SkippedVerification)
|
||||
}
|
||||
if committed != 3 {
|
||||
t.Fatalf("the device committed %d transaction(s), want 3", committed)
|
||||
}
|
||||
if len(summary.Violations) != 0 {
|
||||
t.Errorf("the counting property convicted a healthy app: %v", summary.Violations)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// One skipped step is held; a run of them has to be held too. A bound that lets
|
||||
// the runner act again while the verifier is still being skipped puts back the
|
||||
// exact swallow the hold exists to prevent, only later: the action drawn on the
|
||||
// step past the bound overwrites the one the hold was carrying, and the carried
|
||||
// action is never reported to any spec.
|
||||
//
|
||||
// The screen composes on steps 3 through 5 of 6 and the device commits one
|
||||
// transaction per tap throughout, so the property has a clean pair to judge
|
||||
// (step 2 to step 6) and nothing in between it can be told about except the
|
||||
// action step 2 applied.
|
||||
func TestRunner_ARunOfSkippedStepsReportsEveryActionItApplied(t *testing.T) {
|
||||
spec := specWithFolioPredicates(t)
|
||||
|
||||
run := func(t *testing.T, commitsPerTap int64) (Summary, int64) {
|
||||
t.Helper()
|
||||
state := newHarnessWithSpec(t, spec)
|
||||
device := &submitsOnTapDriver{
|
||||
Driver: state.mock,
|
||||
commitsPerTap: commitsPerTap,
|
||||
composingRead: 3,
|
||||
composingThrough: 5,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
summary, err := Run(ctx, Options{
|
||||
Duration: time.Hour,
|
||||
IdleTimeout: 20 * time.Millisecond,
|
||||
MaxSteps: 6,
|
||||
Driver: device,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if summary.Steps != 6 {
|
||||
t.Fatalf("steps = %d, want 6", summary.Steps)
|
||||
}
|
||||
if summary.SkippedVerification != 3 {
|
||||
t.Fatalf("the run skipped %d step(s), want 3; the reread never fired across "+
|
||||
"the run this test is about", summary.SkippedVerification)
|
||||
}
|
||||
return summary, device.committed.Load()
|
||||
}
|
||||
|
||||
t.Run("no submit is lost to the run of skipped steps", func(t *testing.T) {
|
||||
summary, committed := run(t, 1)
|
||||
if committed == 0 {
|
||||
t.Fatal("the device committed nothing; a runner that never acts passes this " +
|
||||
"test without meaning anything")
|
||||
}
|
||||
if len(summary.Violations) != 0 {
|
||||
t.Errorf("the counting property convicted a healthy app: %v\n"+
|
||||
"one transaction per submit rose, and a submit went unreported because "+
|
||||
"the runner acted on a step the verifier skipped", summary.Violations)
|
||||
}
|
||||
if committed != 3 {
|
||||
t.Errorf("the device committed %d transaction(s), want 3: one per verified "+
|
||||
"step (1, 2 and 6) and none from a step nothing would judge", committed)
|
||||
}
|
||||
})
|
||||
|
||||
// The control. Without it a green above proves nothing: a property handed
|
||||
// no comparable pair is silently vacuous and reports the same empty list.
|
||||
t.Run("two transactions per tap still convicts across the same run", func(t *testing.T) {
|
||||
summary, _ := run(t, 2)
|
||||
if len(summary.Violations) == 0 {
|
||||
t.Fatal("the counting property missed a double submit; the skipped steps left " +
|
||||
"it with nothing to judge, so the case above proves nothing")
|
||||
}
|
||||
if got := summary.Violations[0].Properties[0]; got != "submitCommitsOneTransactionPerAction" {
|
||||
t.Errorf("violated %v, want submitCommitsOneTransactionPerAction",
|
||||
summary.Violations[0].Properties)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A screen that changes shape under every pair of reads (a live list, a spinner
|
||||
// mounting and unmounting) costs the run its actions: an action applied onto it
|
||||
// would be the one the next verified step never hears about, and there is no
|
||||
// next verified step. What the run must not do is come back green off that,
|
||||
// which is what the "judged by nothing" count and the run's outcome are for.
|
||||
func TestRunner_AScreenThatNeverSettlesActsOnNothingAndSaysSo(t *testing.T) {
|
||||
state := newHarnessWithSpec(t, specWithFolioPredicates(t))
|
||||
device := &submitsOnTapDriver{Driver: state.mock, commitsPerTap: 1, everyRead: true}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
summary, err := Run(ctx, Options{
|
||||
Duration: time.Hour,
|
||||
IdleTimeout: 20 * time.Millisecond,
|
||||
MaxSteps: 5,
|
||||
Driver: device,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if summary.Steps != 5 {
|
||||
t.Fatalf("steps = %d, want 5; the run stalled instead of finishing its budget",
|
||||
summary.Steps)
|
||||
}
|
||||
if summary.SkippedVerification != 5 {
|
||||
t.Fatalf("the run verified some step of a screen that never settled: skipped %d of 5",
|
||||
summary.SkippedVerification)
|
||||
}
|
||||
if got := device.committed.Load(); got != 0 {
|
||||
t.Errorf("the fuzzer applied %d action(s) onto a screen no property would judge; "+
|
||||
"each one is an action no spec will ever be told about", got)
|
||||
}
|
||||
}
|
||||
|
||||
// homeWithTicker is one settled route holding a total that ticks and a button
|
||||
// whose measured bounds shift under it. The nodes, their ids and their classes
|
||||
// are the same in every rendering of it.
|
||||
const homeWithTicker = `{"attributes":{"resource-id":"HomeScreen","class":"android.view.View"},"children":[
|
||||
{"attributes":{"resource-id":"Total","class":"android.widget.TextView","text":"%s"},"children":[]},
|
||||
{"attributes":{"resource-id":"TxnSubmit","class":"android.widget.Button","bounds":"%s"},"children":[]}
|
||||
]}`
|
||||
|
||||
// The same route with a node in it that was not there a read ago.
|
||||
const homeWithTickerAndRow = `{"attributes":{"resource-id":"HomeScreen","class":"android.view.View"},"children":[
|
||||
{"attributes":{"resource-id":"Total","class":"android.widget.TextView","text":"120.00"},"children":[]},
|
||||
{"attributes":{"resource-id":"TxnSubmit","class":"android.widget.Button","bounds":"[40,80,240,160]"},"children":[]},
|
||||
{"attributes":{"resource-id":"TxnRowLate","class":"android.view.View"},"children":[]}
|
||||
]}`
|
||||
|
||||
// rereadsDriver answers the paired Snapshot with one fixed tree and the
|
||||
// hierarchy read that follows with another, so a test can say exactly what
|
||||
// moved between the two reads the detector compares.
|
||||
type rereadsDriver struct {
|
||||
*mockdriver.Driver
|
||||
snapshotTree string
|
||||
rereadTree string
|
||||
}
|
||||
|
||||
func (d *rereadsDriver) Snapshot(context.Context) (string, driver.Image, error) {
|
||||
return d.snapshotTree, driver.Image{}, nil
|
||||
}
|
||||
|
||||
func (d *rereadsDriver) Hierarchy(context.Context) (string, error) {
|
||||
return d.rereadTree, nil
|
||||
}
|
||||
|
||||
// What the two reads are compared ON is the whole feature. Comparing the values
|
||||
// in the tree instead of the nodes in it would fire on every step of a screen
|
||||
// with a total on it or a measure pass in flight, and a runner that skips every
|
||||
// step verifies nothing while reporting no violations: green and vacuous, which
|
||||
// is a worse answer than the composition the comparison set out to catch.
|
||||
//
|
||||
// The always-false property is the witness: it fires on the first step that
|
||||
// reaches the verifier, so its presence and its step index say whether the step
|
||||
// was judged at all.
|
||||
func TestRunner_OnlyAChangeOfShapeCostsAStepItsVerdict(t *testing.T) {
|
||||
settled := fmt.Sprintf(homeWithTicker, "120.00", "[40,80,240,160]")
|
||||
cases := []struct {
|
||||
name string
|
||||
reread string
|
||||
verified bool
|
||||
}{
|
||||
{
|
||||
name: "a total that ticked between the two reads",
|
||||
reread: fmt.Sprintf(homeWithTicker, "121.00", "[40,80,240,160]"),
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
name: "a measure pass that moved the button",
|
||||
reread: fmt.Sprintf(homeWithTicker, "120.00", "[40,84,240,164]"),
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
name: "a node that was not in the tree a read ago",
|
||||
reread: homeWithTickerAndRow,
|
||||
verified: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
state := newHarnessWithSpec(t, violationSpec)
|
||||
device := &rereadsDriver{
|
||||
Driver: state.mock,
|
||||
snapshotTree: settled,
|
||||
rereadTree: testCase.reread,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
summary, err := Run(ctx, Options{
|
||||
Duration: time.Hour,
|
||||
IdleTimeout: 20 * time.Millisecond,
|
||||
MaxSteps: 3,
|
||||
Driver: device,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if summary.Steps != 3 {
|
||||
t.Fatalf("steps = %d, want 3", summary.Steps)
|
||||
}
|
||||
|
||||
if !testCase.verified {
|
||||
if summary.SkippedVerification != 3 {
|
||||
t.Errorf("the run judged %d of 3 steps whose tree grew a node between "+
|
||||
"the two reads; a screen still composing must reach no property",
|
||||
3-summary.SkippedVerification)
|
||||
}
|
||||
if len(summary.Violations) != 0 {
|
||||
t.Errorf("a skipped step reached the verifier anyway: %v",
|
||||
summary.Violations)
|
||||
}
|
||||
return
|
||||
}
|
||||
if summary.SkippedVerification != 0 {
|
||||
t.Fatalf("the run judged nothing: %d of 3 steps were skipped over a tree "+
|
||||
"whose nodes never changed", summary.SkippedVerification)
|
||||
}
|
||||
if len(summary.Violations) != 1 {
|
||||
t.Fatalf("violations = %v, want exactly one", summary.Violations)
|
||||
}
|
||||
if got := summary.Violations[0].StepIndex; got != 1 {
|
||||
t.Errorf("the property first judged step %d, want 1", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type traceLine struct {
|
||||
Step int `json:"step"`
|
||||
Violations []string `json:"violations"`
|
||||
ExtractorChanges map[string]trace.ExtractorChange `json:"extractor_changes"`
|
||||
}
|
||||
|
||||
func traceSteps(t *testing.T, directory string) []traceLine {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(filepath.Join(directory, "trace.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var steps []traceLine
|
||||
for _, raw := range bytes.Split(bytes.TrimSpace(body), []byte("\n")) {
|
||||
var line traceLine
|
||||
if err := json.Unmarshal(raw, &line); err != nil {
|
||||
t.Fatalf("decode trace line: %v", err)
|
||||
}
|
||||
steps = append(steps, line)
|
||||
}
|
||||
return steps
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -12,124 +13,109 @@ import (
|
||||
"github.com/priyanshujain/sanderling/internal/trace"
|
||||
)
|
||||
|
||||
// elementExtractorSpec records accessibility elements directly, the shape an
|
||||
// author reaches for when the question is "what was on screen at this step":
|
||||
// one element and the list a generator would have been offered.
|
||||
// elementExtractorSpec reads a live ax element, the shape every field and
|
||||
// button in examples/folio/sanderling/spec.ts is extracted with. The property
|
||||
// is false the moment the field is on screen, so the run records a witness
|
||||
// whose only interesting content is that element.
|
||||
const elementExtractorSpec = `
|
||||
import { actions, extract } from "@sanderling/spec";
|
||||
extract("field", state => state.ax.find({ testTag: "LoginEmail" }));
|
||||
extract("rows", state => state.ax.findAll({ testTag: "Row" }));
|
||||
globalThis.properties = {};
|
||||
import { actions, always, extract } from "@sanderling/spec";
|
||||
const amountField = extract("amountField", s => s.ax.find({ "resource-id": "TxnAmountField" }));
|
||||
globalThis.properties = {
|
||||
noAmountField: always(() => amountField.current === undefined),
|
||||
};
|
||||
globalThis.actions = actions(() => []);
|
||||
`
|
||||
|
||||
const elementExtractorHierarchy = `{
|
||||
"attributes": {"class": "android.widget.LinearLayout", "bounds": "[0,0,1080,2340]"},
|
||||
const amountFieldTreeJSON = `{
|
||||
"attributes": {"resource-id": "root", "bounds": "[0,0,400,800]"},
|
||||
"enabled": true,
|
||||
"children": [
|
||||
{"attributes": {"resource-id": "LoginEmail", "class": "android.widget.EditText",
|
||||
"text": "[email protected]", "bounds": "[10,20,200,60]"}, "children": []},
|
||||
{"attributes": {"resource-id": "Row", "class": "android.widget.TextView",
|
||||
"text": "first", "bounds": "[0,100,1080,200]"}, "children": []},
|
||||
{"attributes": {"resource-id": "Row", "class": "android.widget.TextView",
|
||||
"text": "second", "bounds": "[0,200,1080,300]"}, "children": []}
|
||||
{"attributes": {"resource-id": "TxnAmountField", "text": "199", "bounds": "[0,100,400,160]"},
|
||||
"editable": true, "enabled": true, "children": []}
|
||||
]
|
||||
}`
|
||||
|
||||
// TestRunner_TraceRecordsElementValuedExtractors pins that an extractor holding
|
||||
// an accessibility element reaches the trace. Elements carry host functions
|
||||
// (find/findAll), which json.Marshal refuses; the encoder used to answer nil
|
||||
// and the diff then emitted no entry, so the run finished clean with the
|
||||
// extractor missing from every step and no error anywhere.
|
||||
// TestRunner_TraceRecordsElementValuedExtractors is the guard on the artifact a
|
||||
// person opens to decide whether a conviction is real. An element-valued
|
||||
// extractor used to reach the trace as null on the goja hosts (ios, android):
|
||||
// its exported value carries the element's find/findAll host functions, which
|
||||
// json.Marshal refuses, so the encoding failed and both the per-step diff and
|
||||
// the witness recorded nothing. A witness that reads null for the field the
|
||||
// property fired on describes a state the property could not have fired in,
|
||||
// which is worse than a blank.
|
||||
func TestRunner_TraceRecordsElementValuedExtractors(t *testing.T) {
|
||||
state := newHarnessWithSpec(t, elementExtractorSpec)
|
||||
state.mock.HierarchyJSON = elementExtractorHierarchy
|
||||
state.mock.HierarchyJSON = amountFieldTreeJSON
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := Run(ctx, Options{
|
||||
Duration: 100 * time.Millisecond,
|
||||
summary, err := Run(ctx, Options{
|
||||
Duration: time.Hour,
|
||||
IdleTimeout: 20 * time.Millisecond,
|
||||
MaxSteps: 2,
|
||||
Driver: state.mock,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
field, rows := elementChangesFromTrace(t, state.directory)
|
||||
if field == nil {
|
||||
t.Fatal("no extractor change for the element-valued extractor reached the trace")
|
||||
}
|
||||
if rows == nil {
|
||||
t.Fatal("no extractor change for the element-list extractor reached the trace")
|
||||
if !containsProperty(summary.Violations, "noAmountField") {
|
||||
t.Fatalf("noAmountField did not violate, so the element never reached a predicate: %v",
|
||||
summary.Violations)
|
||||
}
|
||||
|
||||
var element map[string]any
|
||||
if err := json.Unmarshal(field, &element); err != nil {
|
||||
t.Fatalf("field value is not a JSON object: %v (%s)", err, field)
|
||||
}
|
||||
if got := element["id"]; got != "LoginEmail" {
|
||||
t.Errorf("field.id = %v, want LoginEmail", got)
|
||||
}
|
||||
if got := element["text"]; got != "[email protected]" {
|
||||
t.Errorf("field.text = %v, want [email protected]", got)
|
||||
}
|
||||
if got := element["class"]; got != "android.widget.EditText" {
|
||||
t.Errorf("field.class = %v, want android.widget.EditText", got)
|
||||
}
|
||||
bounds, ok := element["bounds"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("field.bounds missing or not an object: %v", element["bounds"])
|
||||
}
|
||||
if bounds["left"] != float64(10) || bounds["top"] != float64(20) ||
|
||||
bounds["right"] != float64(200) || bounds["bottom"] != float64(60) {
|
||||
t.Errorf("field.bounds = %v, want left/top/right/bottom 10/20/200/60", bounds)
|
||||
}
|
||||
for _, key := range []string{"find", "findAll"} {
|
||||
if _, present := element[key]; present {
|
||||
t.Errorf("field carries the host function %q into the trace", key)
|
||||
}
|
||||
}
|
||||
|
||||
var list []map[string]any
|
||||
if err := json.Unmarshal(rows, &list); err != nil {
|
||||
t.Fatalf("rows value is not a JSON array: %v (%s)", err, rows)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("rows recorded %d elements, want 2", len(list))
|
||||
}
|
||||
if list[0]["text"] != "first" || list[1]["text"] != "second" {
|
||||
t.Errorf("rows recorded %v, want the two Row elements in tree order", list)
|
||||
}
|
||||
}
|
||||
|
||||
// elementChangesFromTrace returns the first recorded value of each extractor.
|
||||
func elementChangesFromTrace(t *testing.T, directory string) (field, rows json.RawMessage) {
|
||||
t.Helper()
|
||||
file, err := os.Open(filepath.Join(directory, "trace.jsonl"))
|
||||
file, err := os.Open(filepath.Join(state.writer.Directory(), "trace.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
type traceLine struct {
|
||||
Step int `json:"step"`
|
||||
ExtractorChanges map[string]trace.ExtractorChange `json:"extractor_changes"`
|
||||
Witnesses map[string]trace.Witness `json:"witnesses"`
|
||||
}
|
||||
changes, witnesses := 0, 0
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
|
||||
for scanner.Scan() {
|
||||
var line struct {
|
||||
ExtractorChanges map[string]trace.ExtractorChange `json:"extractor_changes"`
|
||||
}
|
||||
var line traceLine
|
||||
if err := json.Unmarshal(scanner.Bytes(), &line); err != nil {
|
||||
t.Fatalf("trace line decode: %v", err)
|
||||
}
|
||||
if change, ok := line.ExtractorChanges["field"]; ok && field == nil {
|
||||
field = change.Curr
|
||||
if change, ok := line.ExtractorChanges["amountField"]; ok {
|
||||
changes++
|
||||
assertAmountField(t, fmt.Sprintf("step %d extractor_changes", line.Step), change.Curr)
|
||||
}
|
||||
if change, ok := line.ExtractorChanges["rows"]; ok && rows == nil {
|
||||
rows = change.Curr
|
||||
for name, witness := range line.Witnesses {
|
||||
witnesses++
|
||||
assertAmountField(t, fmt.Sprintf("step %d %s witness", line.Step, name),
|
||||
witness.Extractors["amountField"])
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatalf("scan trace: %v", err)
|
||||
}
|
||||
return field, rows
|
||||
if changes == 0 {
|
||||
t.Error("amountField never appears in extractor_changes; the element the run read is not in the trace")
|
||||
}
|
||||
if witnesses == 0 {
|
||||
t.Error("no witness reached the trace; nothing was compared")
|
||||
}
|
||||
}
|
||||
|
||||
// assertAmountField reads the recorded element the way a person opening the
|
||||
// trace would: the field's text is the number the property was judged on.
|
||||
func assertAmountField(t *testing.T, where string, recorded json.RawMessage) {
|
||||
t.Helper()
|
||||
var element struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(recorded, &element); err != nil {
|
||||
t.Fatalf("%s: decode %s: %v", where, recorded, err)
|
||||
}
|
||||
if element.Text != "199" {
|
||||
t.Errorf("%s: recorded element is %s, want its text to read 199", where, recorded)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/driver"
|
||||
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
|
||||
)
|
||||
|
||||
const guardedBundleID = "app.folio"
|
||||
|
||||
// committingDevice is a device whose submit taps commit transactions the next
|
||||
// hierarchy read shows, and which can say how many it has committed so a test
|
||||
// can prove the taps landed before reading anything into a verdict.
|
||||
type committingDevice interface {
|
||||
driver.DeviceDriver
|
||||
commits() int64
|
||||
}
|
||||
|
||||
// leavesForegroundAfterSubmitDriver is the condition the app-scope guard exists
|
||||
// for: the submit tap lands and commits, and the app is no longer the
|
||||
// foreground app by the time the next step looks. Folio's transactions are in
|
||||
// sqlite, so the commit survives the relaunch and the next reading shows it.
|
||||
type leavesForegroundAfterSubmitDriver struct {
|
||||
*mockdriver.Driver
|
||||
commitsPerTap int64
|
||||
committed atomic.Int64
|
||||
away atomic.Bool
|
||||
}
|
||||
|
||||
func (d *leavesForegroundAfterSubmitDriver) Tap(context.Context, int, int) error {
|
||||
return d.commitThenLeave()
|
||||
}
|
||||
|
||||
func (d *leavesForegroundAfterSubmitDriver) TapSelector(context.Context, string) error {
|
||||
return d.commitThenLeave()
|
||||
}
|
||||
|
||||
func (d *leavesForegroundAfterSubmitDriver) commitThenLeave() error {
|
||||
d.committed.Add(d.commitsPerTap)
|
||||
d.away.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *leavesForegroundAfterSubmitDriver) commits() int64 { return d.committed.Load() }
|
||||
|
||||
func (d *leavesForegroundAfterSubmitDriver) Launch(
|
||||
ctx context.Context,
|
||||
bundleID string,
|
||||
clearState bool,
|
||||
env map[string]string,
|
||||
) error {
|
||||
d.away.Store(false)
|
||||
return d.Driver.Launch(ctx, bundleID, clearState, env)
|
||||
}
|
||||
|
||||
func (d *leavesForegroundAfterSubmitDriver) ForegroundApp(context.Context) (string, error) {
|
||||
if d.away.Load() {
|
||||
return "com.android.launcher", nil
|
||||
}
|
||||
return guardedBundleID, nil
|
||||
}
|
||||
|
||||
func (d *leavesForegroundAfterSubmitDriver) FocusedWindowApp(ctx context.Context) (string, error) {
|
||||
return d.ForegroundApp(ctx)
|
||||
}
|
||||
|
||||
func (d *leavesForegroundAfterSubmitDriver) Snapshot(context.Context) (string, driver.Image, error) {
|
||||
return fmt.Sprintf(homeWithTxnCount, d.committed.Load()), driver.Image{}, nil
|
||||
}
|
||||
|
||||
// The runner reads both per step and compares them, so a device that answered
|
||||
// them off different trees would make every step of this test transitional and
|
||||
// judged by nothing. The sidecar serves both off one read path (snapshotTree)
|
||||
// for the same reason; this one answers them off the same commit count.
|
||||
func (d *leavesForegroundAfterSubmitDriver) Hierarchy(context.Context) (string, error) {
|
||||
return fmt.Sprintf(homeWithTxnCount, d.committed.Load()), nil
|
||||
}
|
||||
|
||||
// obscuredAfterSubmitDriver is the other half of the same guard: the app stays
|
||||
// the resumed activity, but a system window (the notification shade) owns the
|
||||
// focused window when the next step looks, and the guard presses back to
|
||||
// collapse it.
|
||||
type obscuredAfterSubmitDriver struct {
|
||||
*mockdriver.Driver
|
||||
commitsPerTap int64
|
||||
committed atomic.Int64
|
||||
obscured atomic.Bool
|
||||
}
|
||||
|
||||
func (d *obscuredAfterSubmitDriver) Tap(context.Context, int, int) error {
|
||||
return d.commitThenObscure()
|
||||
}
|
||||
|
||||
func (d *obscuredAfterSubmitDriver) TapSelector(context.Context, string) error {
|
||||
return d.commitThenObscure()
|
||||
}
|
||||
|
||||
func (d *obscuredAfterSubmitDriver) commitThenObscure() error {
|
||||
d.committed.Add(d.commitsPerTap)
|
||||
d.obscured.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *obscuredAfterSubmitDriver) commits() int64 { return d.committed.Load() }
|
||||
|
||||
func (d *obscuredAfterSubmitDriver) PressKey(ctx context.Context, key string) error {
|
||||
if key == "back" {
|
||||
d.obscured.Store(false)
|
||||
}
|
||||
return d.Driver.PressKey(ctx, key)
|
||||
}
|
||||
|
||||
func (d *obscuredAfterSubmitDriver) ForegroundApp(context.Context) (string, error) {
|
||||
return guardedBundleID, nil
|
||||
}
|
||||
|
||||
func (d *obscuredAfterSubmitDriver) FocusedWindowApp(context.Context) (string, error) {
|
||||
if d.obscured.Load() {
|
||||
return "com.android.systemui", nil
|
||||
}
|
||||
return guardedBundleID, nil
|
||||
}
|
||||
|
||||
func (d *obscuredAfterSubmitDriver) Snapshot(context.Context) (string, driver.Image, error) {
|
||||
return fmt.Sprintf(homeWithTxnCount, d.committed.Load()), driver.Image{}, nil
|
||||
}
|
||||
|
||||
func (d *obscuredAfterSubmitDriver) Hierarchy(context.Context) (string, error) {
|
||||
return fmt.Sprintf(homeWithTxnCount, d.committed.Load()), nil
|
||||
}
|
||||
|
||||
// runTwoSubmitSteps drives two steps of the shipped folio counting property
|
||||
// against a device that commits on every tap, and hands back what the property
|
||||
// decided. Both steps have to run: the first arms the comparison, the second is
|
||||
// where the guard fires and the pair is judged.
|
||||
func runTwoSubmitSteps(
|
||||
t *testing.T,
|
||||
state *harness,
|
||||
device committingDevice,
|
||||
commitsPerTap int64,
|
||||
) []ViolationRecord {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
summary, err := Run(ctx, Options{
|
||||
Duration: time.Hour,
|
||||
IdleTimeout: 20 * time.Millisecond,
|
||||
MaxSteps: 2,
|
||||
BundleID: guardedBundleID,
|
||||
Driver: device,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if summary.Steps != 2 {
|
||||
t.Fatalf("steps = %d, want 2; the run never reached the step that judges the pair",
|
||||
summary.Steps)
|
||||
}
|
||||
if got := device.commits(); got != commitsPerTap*2 {
|
||||
t.Fatalf("the device committed %d transaction(s), want %d; the taps never reached it",
|
||||
got, commitsPerTap*2)
|
||||
}
|
||||
return summary.Violations
|
||||
}
|
||||
|
||||
func countMockActions(state *harness, kind mockdriver.ActionKind, key string) int {
|
||||
count := 0
|
||||
for _, action := range state.mock.Actions() {
|
||||
if action.Kind != kind {
|
||||
continue
|
||||
}
|
||||
if key != "" && action.Key != key {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func specWithFolioPredicates(t *testing.T) string {
|
||||
t.Helper()
|
||||
predicates, err := filepath.Abs("../../examples/folio/sanderling/predicates.ts")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fmt.Sprintf(submitCountingSpecTemplate, predicates)
|
||||
}
|
||||
|
||||
// A relaunch is not proof that nothing ran before it. The submit was dispatched
|
||||
// and confirmed; what the relaunch changed is that the app restarted between
|
||||
// the two readings the property compares. Reporting "no action" for it hands
|
||||
// submitCommitsOneTransactionPerAction a transaction rise of one against a
|
||||
// window of zero submits, which is the conviction #77 fixed for the apply-error
|
||||
// path, manufactured here out of the scope guard instead.
|
||||
func TestRunner_ARelaunchDoesNotConvictTheSubmitCountingProperty(t *testing.T) {
|
||||
spec := specWithFolioPredicates(t)
|
||||
|
||||
run := func(t *testing.T, commitsPerTap int64) []ViolationRecord {
|
||||
t.Helper()
|
||||
state := newHarnessWithSpec(t, spec)
|
||||
device := &leavesForegroundAfterSubmitDriver{
|
||||
Driver: state.mock,
|
||||
commitsPerTap: commitsPerTap,
|
||||
}
|
||||
violations := runTwoSubmitSteps(t, state, device, commitsPerTap)
|
||||
if countMockActions(state, mockdriver.ActionLaunch, "") == 0 {
|
||||
t.Fatal("the app was never relaunched, so the guard this test is about never ran")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
t.Run("one transaction per tap is not a double submit", func(t *testing.T) {
|
||||
if violations := run(t, 1); len(violations) != 0 {
|
||||
t.Errorf("the counting property convicted a healthy app: %v\n"+
|
||||
"one transaction rose against a submit the runner confirmed, and the "+
|
||||
"spec was told no action happened because the app was relaunched",
|
||||
violations)
|
||||
}
|
||||
})
|
||||
|
||||
// The control. Without it a green above proves nothing: a property that
|
||||
// never sees a comparable pair is silently vacuous and reports the same
|
||||
// empty violation list.
|
||||
t.Run("two transactions per tap still convicts", func(t *testing.T) {
|
||||
violations := run(t, 2)
|
||||
if len(violations) == 0 {
|
||||
t.Fatal("the counting property missed a double submit; the harness never " +
|
||||
"put the property in a position to fire, so the case above proves nothing")
|
||||
}
|
||||
if violations[0].Properties[0] != "submitCommitsOneTransactionPerAction" {
|
||||
t.Errorf("violated %v, want submitCommitsOneTransactionPerAction", violations[0].Properties)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The same hole through the guard's other branch. A system window holding the
|
||||
// focus says nothing about whether the tap under it ran: it was dispatched, and
|
||||
// what nobody can say afterwards is whether the app received it. That is the
|
||||
// unknown `applied` already carries, and it counts toward the submits a window
|
||||
// could hold. Reporting no action instead convicts the app of a transaction
|
||||
// with no cause.
|
||||
func TestRunner_AnOverlayDoesNotConvictTheSubmitCountingProperty(t *testing.T) {
|
||||
spec := specWithFolioPredicates(t)
|
||||
|
||||
run := func(t *testing.T, commitsPerTap int64) []ViolationRecord {
|
||||
t.Helper()
|
||||
state := newHarnessWithSpec(t, spec)
|
||||
device := &obscuredAfterSubmitDriver{
|
||||
Driver: state.mock,
|
||||
commitsPerTap: commitsPerTap,
|
||||
}
|
||||
violations := runTwoSubmitSteps(t, state, device, commitsPerTap)
|
||||
if countMockActions(state, mockdriver.ActionPressKey, "back") == 0 {
|
||||
t.Fatal("the overlay was never dismissed, so the guard this test is about never ran")
|
||||
}
|
||||
if countMockActions(state, mockdriver.ActionLaunch, "") != 0 {
|
||||
t.Fatal("a resumed-but-obscured app must not be relaunched")
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
t.Run("one transaction per tap is not a double submit", func(t *testing.T) {
|
||||
if violations := run(t, 1); len(violations) != 0 {
|
||||
t.Errorf("the counting property convicted a healthy app: %v\n"+
|
||||
"one transaction rose against a submit the runner dispatched, and the "+
|
||||
"spec was told no action happened because a system window took the focus",
|
||||
violations)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("two transactions per tap still convicts", func(t *testing.T) {
|
||||
violations := run(t, 2)
|
||||
if len(violations) == 0 {
|
||||
t.Fatal("the counting property missed a double submit; the harness never " +
|
||||
"put the property in a position to fire, so the case above proves nothing")
|
||||
}
|
||||
if violations[0].Properties[0] != "submitCommitsOneTransactionPerAction" {
|
||||
t.Errorf("violated %v, want submitCommitsOneTransactionPerAction", violations[0].Properties)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// reportedActionSpec puts what the runner told the spec about the last action
|
||||
// into an extractor, so a test can read it out of the trace. `applied` and
|
||||
// `relaunched` have no other producer: the runner's two guard writes are the
|
||||
// only thing that ever sets them, and every spec-side guard built on them (see
|
||||
// acrossRelaunch and confirmedApplied in the folio predicates) reads nothing
|
||||
// else. A regression in either write leaves those guards permanently off with
|
||||
// no property anywhere able to notice.
|
||||
const reportedActionSpec = `
|
||||
import { actions, always, extract, Tap } from "@sanderling/spec";
|
||||
const reportedAction = extract("reportedAction", state => {
|
||||
const last = state.lastAction;
|
||||
if (last == null) return "none";
|
||||
const dispatch = last.applied === true ? "applied" : "unconfirmed";
|
||||
const process = last.relaunched === true ? "relaunched" : "same-process";
|
||||
return dispatch + "/" + process;
|
||||
});
|
||||
globalThis.properties = {
|
||||
theGuardTheRunnerRanReachesTheSpec: always(
|
||||
() => reportedAction.current !== "applied/same-process",
|
||||
),
|
||||
};
|
||||
globalThis.actions = actions(() => [Tap({ on: "id:TxnSubmit" })]);
|
||||
`
|
||||
|
||||
// runReportingTheGuard drives two steps against a device whose submit tap trips
|
||||
// one of the foreground guards, and hands back what the spec read off
|
||||
// state.lastAction on the step the guard fired.
|
||||
func runReportingTheGuard(t *testing.T, device committingDevice, state *harness) string {
|
||||
t.Helper()
|
||||
if violations := runTwoSubmitSteps(t, state, device, 1); len(violations) != 0 {
|
||||
t.Errorf("the spec was told the action ran untouched by any guard: %v", violations)
|
||||
}
|
||||
steps := traceSteps(t, state.writer.Directory())
|
||||
if len(steps) != 2 {
|
||||
t.Fatalf("trace holds %d step(s), want 2", len(steps))
|
||||
}
|
||||
change, ok := steps[1].ExtractorChanges["reportedAction"]
|
||||
if !ok {
|
||||
t.Fatalf("step 2 recorded no reading of the reported action: %+v", steps[1])
|
||||
}
|
||||
return string(change.Curr)
|
||||
}
|
||||
|
||||
func TestRunner_TheSpecIsToldTheAppWasRelaunchedUnderTheAction(t *testing.T) {
|
||||
state := newHarnessWithSpec(t, reportedActionSpec)
|
||||
device := &leavesForegroundAfterSubmitDriver{Driver: state.mock, commitsPerTap: 1}
|
||||
|
||||
reported := runReportingTheGuard(t, device, state)
|
||||
|
||||
if countMockActions(state, mockdriver.ActionLaunch, "") == 0 {
|
||||
t.Fatal("the app was never relaunched, so the write this test is about never ran")
|
||||
}
|
||||
if reported != `"applied/relaunched"` {
|
||||
t.Errorf("the spec read %s off state.lastAction, want \"applied/relaunched\"; "+
|
||||
"a property relaxed across a relaunch cannot fire on a run that never "+
|
||||
"tells it one happened", reported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_TheSpecIsToldAnObscuredActionWasNotConfirmed(t *testing.T) {
|
||||
state := newHarnessWithSpec(t, reportedActionSpec)
|
||||
device := &obscuredAfterSubmitDriver{Driver: state.mock, commitsPerTap: 1}
|
||||
|
||||
reported := runReportingTheGuard(t, device, state)
|
||||
|
||||
if countMockActions(state, mockdriver.ActionPressKey, "back") == 0 {
|
||||
t.Fatal("the overlay was never dismissed, so the write this test is about never ran")
|
||||
}
|
||||
if reported != `"unconfirmed/same-process"` {
|
||||
t.Errorf("the spec read %s off state.lastAction, want "+
|
||||
"\"unconfirmed/same-process\"; a system window held the focused window, "+
|
||||
"so whether the app received the tap is exactly what nobody can say",
|
||||
reported)
|
||||
}
|
||||
}
|
||||
+244
-38
@@ -57,6 +57,11 @@ type Summary struct {
|
||||
EndTime time.Time
|
||||
Steps int
|
||||
Violations []ViolationRecord
|
||||
// SkippedVerification counts the steps whose tree was still moving when it
|
||||
// was read, so no property judged them. A green run that skipped most of
|
||||
// its steps checked almost nothing, and nothing else in the output would
|
||||
// say so.
|
||||
SkippedVerification int
|
||||
// UnsupportedVerbs lists verbs the picker requested that the platform
|
||||
// could not dispatch, deduped, so the report can flag a spec exercising
|
||||
// gestures this target does not support.
|
||||
@@ -105,6 +110,7 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
_, pageExtractors := extractorSource.(webSource)
|
||||
exceptionReporter, _ := options.Driver.(driver.ExceptionReporter)
|
||||
navigationReporter, _ := options.Driver.(driver.NavigationReporter)
|
||||
rereadHierarchy := driverIsAndroid(ctx, options, logger)
|
||||
|
||||
summary := Summary{StartTime: time.Now()}
|
||||
deadline := summary.StartTime.Add(options.Duration)
|
||||
@@ -126,8 +132,23 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
// backed out of (or otherwise left) the app, relaunch it before we
|
||||
// observe or act, so properties never evaluate against a foreign app
|
||||
// and actions never land outside the app.
|
||||
if ensureForeground(ctx, options, logger, stepIndex) {
|
||||
lastAction = nil
|
||||
//
|
||||
// What the guard did is reported to the spec on the action it followed,
|
||||
// because dropping that action says "nothing ran between these two
|
||||
// readings" and the runner has no business saying that: the action ran,
|
||||
// and a property told otherwise convicts the app of an effect with no
|
||||
// cause. See foreground_guard_last_action_test.go.
|
||||
guard := ensureForeground(ctx, options, logger, stepIndex)
|
||||
if lastAction != nil {
|
||||
switch guard {
|
||||
case foregroundRelaunched:
|
||||
lastAction.Relaunched = true
|
||||
case foregroundOverlayDismissed:
|
||||
// A system window owned the focused window, so whether the app
|
||||
// itself ever received this action is exactly the unknown
|
||||
// Applied already has a state for.
|
||||
lastAction.Applied = false
|
||||
}
|
||||
}
|
||||
|
||||
// Hierarchy, metrics, and logs are independent device reads. Run
|
||||
@@ -150,7 +171,8 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
// screenshot describe the same frame, then re-fetches the pair
|
||||
// while the tree still looks transitional.
|
||||
g.Go(func() error {
|
||||
tree, screenshotPNG, transitional, hierarchyErr = fetchSyncedState(gctx, options, logger, si)
|
||||
tree, screenshotPNG, transitional, hierarchyErr = fetchSyncedState(
|
||||
gctx, options, logger, si, rereadHierarchy)
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
@@ -159,7 +181,7 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
})
|
||||
logSince := lastLogTime
|
||||
g.Go(func() error {
|
||||
logs = collectLogs(gctx, options.Driver, logSince)
|
||||
logs = collectLogs(gctx, options.Driver, logger, si, logSince)
|
||||
return nil
|
||||
})
|
||||
// All goroutines write to local variables and return nil, so the Wait
|
||||
@@ -197,8 +219,10 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
screen = tree.Elements[0].Screen
|
||||
}
|
||||
|
||||
// Transitional trees describe a NavHost mid cross-fade. Pushing
|
||||
// one would poison the verifier's previous/current extractor
|
||||
// A transitional tree is one nothing can vouch for: a NavHost mid
|
||||
// cross-fade, a screen that changed shape between two reads, or a
|
||||
// hierarchy that came back empty. Pushing one would poison the
|
||||
// verifier's previous/current extractor
|
||||
// advance, so the next clean step would compare against this
|
||||
// transient state and emit false-positive violations. We still
|
||||
// record the step (hierarchy + screenshot) for replay-side
|
||||
@@ -223,10 +247,11 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
// behind the hierarchy fetch; the fetch is what decides whether this
|
||||
// step counts at all, so it has to go first.
|
||||
//
|
||||
// lastAction is the same value PushSnapshot hands the goja state
|
||||
// below: the two engines evaluate this step against one action.
|
||||
// lastAction and logs are the same values PushSnapshot hands the
|
||||
// goja state below: the two engines evaluate this step against one
|
||||
// action and one set of log entries.
|
||||
overridesCtx, overridesCancel := context.WithTimeout(ctx, observationTimeout)
|
||||
v8Overrides, overridesErr := extractorSource.ExtractorOverrides(overridesCtx, lastAction)
|
||||
v8Overrides, overridesErr := extractorSource.ExtractorOverrides(overridesCtx, lastAction, logs)
|
||||
overridesCancel()
|
||||
if overridesErr != nil {
|
||||
// Not a warning. Without the page's values this step's
|
||||
@@ -281,18 +306,44 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
extractorChanges = encodeExtractorChanges(options.Verifier.ChangedExtractors())
|
||||
} else {
|
||||
skippedVerification = true
|
||||
logger.Warn("transitional tree after retry budget; skipping verifier",
|
||||
summary.SkippedVerification++
|
||||
logger.Warn("unsettled tree; skipping verifier",
|
||||
"step", stepIndex, "screen", screen, "nodes", treeSize)
|
||||
}
|
||||
logger.Info("step", "index", stepIndex, "screen", screen, "nodes", treeSize)
|
||||
|
||||
nextAction, nextErr := actionSource.NextAction(ctx, stepIndex)
|
||||
// A frame the verifier would not look at is not one to act on either.
|
||||
// #75 is the fuzzer tapping into a screen that is still filling in, and
|
||||
// holding the action back is also what keeps the spec's view of the run
|
||||
// continuous: the action a step applies is reported on the NEXT step the
|
||||
// verifier accepts, so acting here would leave the action applied last
|
||||
// step unreported for good, and a property counting actions against
|
||||
// their effects would then see an effect whose cause the runner
|
||||
// swallowed. See TestRunner_ASkippedStepDoesNotSwallowTheActionBeforeIt.
|
||||
//
|
||||
// Unbounded, because lastAction holds exactly one action: any bound that
|
||||
// let the runner act again while the verifier was still being skipped
|
||||
// would overwrite the action the hold was carrying, and that is the same
|
||||
// swallow arriving one step later. A screen that keeps moving therefore
|
||||
// costs the run its actions rather than its soundness, and a run that
|
||||
// verified nothing says so in its outcome (internal/testrun).
|
||||
held := skippedVerification
|
||||
if held {
|
||||
logger.Warn("screen still moving; holding this step's action back",
|
||||
"step", stepIndex)
|
||||
}
|
||||
|
||||
var nextAction verifier.Action
|
||||
nextErr := verifier.ErrNoAction
|
||||
var traceAction *trace.Action
|
||||
if nextErr == nil {
|
||||
traceAction = traceActionFor(nextAction, tree)
|
||||
stampActionSource(traceAction, actionSource)
|
||||
} else if !errors.Is(nextErr, verifier.ErrNoAction) {
|
||||
return summary, fmt.Errorf("step %d next action: %w", stepIndex, nextErr)
|
||||
if !held {
|
||||
nextAction, nextErr = actionSource.NextAction(ctx, stepIndex)
|
||||
if nextErr == nil {
|
||||
traceAction = traceActionFor(nextAction, tree)
|
||||
stampActionSource(traceAction, actionSource)
|
||||
} else if !errors.Is(nextErr, verifier.ErrNoAction) {
|
||||
return summary, fmt.Errorf("step %d next action: %w", stepIndex, nextErr)
|
||||
}
|
||||
}
|
||||
|
||||
residuals, residualErr := encodeResiduals(options.Verifier.Residuals())
|
||||
@@ -300,7 +351,7 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
logger.Warn("residual encode failed", "step", stepIndex, "err", residualErr)
|
||||
}
|
||||
|
||||
applySkipped := false
|
||||
applySkipped := held
|
||||
var actionSkipped actionSkipReason
|
||||
if nextErr == nil && !appIsForeground(ctx, options) {
|
||||
// The app left the foreground between observe and apply (a prior
|
||||
@@ -365,7 +416,13 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
"step", stepIndex, "reason", actionSkipped, "err", err)
|
||||
transitional = true
|
||||
applySkipped = true
|
||||
lastAction = nil
|
||||
// The error says the call failed, not that the gesture never
|
||||
// reached the app: a deadline that fires after dispatch leaves
|
||||
// the effect committed. Reporting no action here would let a
|
||||
// property convict the app for an effect with no cause, so the
|
||||
// action is reported with its fate unknown instead.
|
||||
unconfirmed := nextAction
|
||||
lastAction = &unconfirmed
|
||||
} else if notDispatched != "" {
|
||||
// The action was chosen but nothing reached the driver, so the
|
||||
// screen is exactly the one already verified: the step stays
|
||||
@@ -379,12 +436,16 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
lastAction = nil
|
||||
} else {
|
||||
consecutiveApplyFailures = 0
|
||||
actionCopy := nextAction
|
||||
lastAction = &actionCopy
|
||||
applied := nextAction
|
||||
applied.Applied = true
|
||||
lastAction = &applied
|
||||
}
|
||||
} else {
|
||||
} else if !held {
|
||||
lastAction = nil
|
||||
}
|
||||
// A held step leaves lastAction alone on purpose: nothing ran here, and
|
||||
// the action it points at is still the one the next verified step has to
|
||||
// be told about.
|
||||
|
||||
step := trace.Step{
|
||||
Index: stepIndex,
|
||||
@@ -429,7 +490,16 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
||||
// concurrent fetches observe a stable post-action state. A transient
|
||||
// apply error means nothing landed, so the idle poll has nothing to
|
||||
// settle and may itself hang on the same device condition.
|
||||
if nextErr == nil && !applySkipped && nextAction.Kind != verifier.ActionKindWait {
|
||||
//
|
||||
// A held step settles too, and it is the only case here that waits with
|
||||
// nothing applied. The reread that held it takes its two reads a round
|
||||
// trip apart, which is a tighter window than the one the detector was
|
||||
// measured over (an action and a settle); looping straight back into it
|
||||
// would compare two reads of a composing screen closer together still,
|
||||
// so the screen that most needs to settle is the one given least room.
|
||||
mutated := nextErr == nil && !applySkipped &&
|
||||
nextAction.Kind != verifier.ActionKindWait
|
||||
if held || mutated {
|
||||
idleCtx, idleCancel := context.WithTimeout(ctx, options.IdleTimeout)
|
||||
idleErr := options.Driver.WaitForIdle(idleCtx, options.IdleTimeout)
|
||||
if idleErr != nil && idleCtx.Err() == nil {
|
||||
@@ -493,6 +563,10 @@ func RenderSummary(w io.Writer, summary Summary, platform string) {
|
||||
fmt.Fprintf(w, "%d step(s) observed nothing: the device state could not be read\n",
|
||||
summary.FailedObservations)
|
||||
}
|
||||
if summary.SkippedVerification > 0 {
|
||||
fmt.Fprintf(w, "%d step(s) judged by nothing: the screen was still moving when it was read\n",
|
||||
summary.SkippedVerification)
|
||||
}
|
||||
if len(summary.UnsupportedVerbs) > 0 {
|
||||
fmt.Fprintf(w, "unsupported on %s: %s\n",
|
||||
platform, strings.Join(summary.UnsupportedVerbs, ", "))
|
||||
@@ -542,20 +616,38 @@ func resolveIdleTimeout(options Options) time.Duration {
|
||||
return timeout
|
||||
}
|
||||
|
||||
// foregroundGuard is what ensureForeground had to do to put the app back in
|
||||
// front. The two interventions are separate values because they are separate
|
||||
// facts about the action they follow: a relaunch leaves it confirmed but
|
||||
// straddling a restart, while a system window holding the focus leaves it
|
||||
// dispatched with no way to tell whether the app received it.
|
||||
type foregroundGuard int
|
||||
|
||||
const (
|
||||
foregroundIntact foregroundGuard = iota
|
||||
foregroundOverlayDismissed
|
||||
foregroundRelaunched
|
||||
)
|
||||
|
||||
// ensureForeground keeps the app under test in the foreground. When the driver
|
||||
// can report the foreground app and it no longer matches the bundle under test,
|
||||
// the app is relaunched. Returns true when a relaunch happened so the caller
|
||||
// can drop the now-stale lastAction. Drivers without ForegroundChecker (web,
|
||||
// the app is relaunched. Reports what it did so the caller can pass that on to
|
||||
// the spec through the previous action. Drivers without ForegroundChecker (web,
|
||||
// iOS) are a no-op.
|
||||
func ensureForeground(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) bool {
|
||||
func ensureForeground(
|
||||
ctx context.Context,
|
||||
options Options,
|
||||
logger *slog.Logger,
|
||||
stepIndex int,
|
||||
) foregroundGuard {
|
||||
checker, ok := options.Driver.(driver.ForegroundChecker)
|
||||
if !ok || options.BundleID == "" {
|
||||
return false
|
||||
return foregroundIntact
|
||||
}
|
||||
foreground, err := checker.ForegroundApp(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("foreground check failed", "step", stepIndex, "err", err)
|
||||
return false
|
||||
return foregroundIntact
|
||||
}
|
||||
if foreground != "" && foreground != options.BundleID {
|
||||
logger.Warn("app left foreground; relaunching",
|
||||
@@ -568,7 +660,7 @@ func ensureForeground(ctx context.Context, options Options, logger *slog.Logger,
|
||||
// window, so it never acts outside the app no matter how slow the
|
||||
// relaunch settles.
|
||||
awaitForeground(ctx, options, logger, stepIndex)
|
||||
return true
|
||||
return foregroundRelaunched
|
||||
}
|
||||
// The app is the resumed activity, but a system overlay can still own the
|
||||
// focused window while the app stays resumed: a fuzzer swipe starting in the
|
||||
@@ -578,15 +670,15 @@ func ensureForeground(ctx context.Context, options Options, logger *slog.Logger,
|
||||
// the app again.
|
||||
focusChecker, hasFocus := options.Driver.(driver.FocusedWindowChecker)
|
||||
if !hasFocus {
|
||||
return false
|
||||
return foregroundIntact
|
||||
}
|
||||
focused, err := focusChecker.FocusedWindowApp(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("focus check failed", "step", stepIndex, "err", err)
|
||||
return false
|
||||
return foregroundIntact
|
||||
}
|
||||
if focused == "" || focused == options.BundleID {
|
||||
return false
|
||||
return foregroundIntact
|
||||
}
|
||||
logger.Warn("system window obscuring app; dismissing",
|
||||
"step", stepIndex, "focused", focused, "want", options.BundleID)
|
||||
@@ -594,7 +686,7 @@ func ensureForeground(ctx context.Context, options Options, logger *slog.Logger,
|
||||
logger.Warn("dismiss overlay failed", "step", stepIndex, "err", err)
|
||||
}
|
||||
settleForForeground(ctx, options)
|
||||
return true
|
||||
return foregroundOverlayDismissed
|
||||
}
|
||||
|
||||
// appIsForeground reports whether the app under test currently owns the
|
||||
@@ -840,11 +932,23 @@ func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.A
|
||||
}
|
||||
|
||||
// collectLogs pulls recent error-level log entries from the driver since the
|
||||
// previous fetch. A failure is warned-on but not fatal: log capture is a
|
||||
// best-effort observability channel, not a correctness dependency.
|
||||
func collectLogs(ctx context.Context, drv driver.DeviceDriver, since time.Time) []verifier.LogEntry {
|
||||
// previous fetch. A failure is warned-on but not fatal: one unreadable fetch on
|
||||
// a flaky device should not end a run. It is not free either. This fetch is the
|
||||
// whole evidence base for state.logs, so a step that could not make it leaves
|
||||
// every log property (the default noLogcatErrors included) holding on an empty
|
||||
// slice, and that has to be visible in the run's output rather than read as the
|
||||
// app having logged nothing.
|
||||
func collectLogs(
|
||||
ctx context.Context,
|
||||
drv driver.DeviceDriver,
|
||||
logger *slog.Logger,
|
||||
step int,
|
||||
since time.Time,
|
||||
) []verifier.LogEntry {
|
||||
entries, err := drv.RecentLogs(ctx, since, "E")
|
||||
if err != nil {
|
||||
logger.Warn("log fetch failed; log properties hold vacuously this step",
|
||||
"step", step, "err", err)
|
||||
return nil
|
||||
}
|
||||
result := make([]verifier.LogEntry, 0, len(entries))
|
||||
@@ -1200,10 +1304,17 @@ const (
|
||||
// orthogonal case where the frame itself is transitional.
|
||||
//
|
||||
// The transitional return reports whether the retry budget was exhausted
|
||||
// on a still-transitional tree. Callers use it to skip the verifier for
|
||||
// that step so the previous/current extractor advance does not absorb
|
||||
// on a still-transitional tree, or (when reread is set) whether a second
|
||||
// hierarchy read disagreed with the first. Callers use it to skip the verifier
|
||||
// for that step so the previous/current extractor advance does not absorb
|
||||
// transient state.
|
||||
func fetchSyncedState(ctx context.Context, options Options, logger *slog.Logger, stepIndex int) (tree *hierarchy.Tree, png []byte, transitional bool, err error) {
|
||||
func fetchSyncedState(
|
||||
ctx context.Context,
|
||||
options Options,
|
||||
logger *slog.Logger,
|
||||
stepIndex int,
|
||||
reread bool,
|
||||
) (tree *hierarchy.Tree, png []byte, transitional bool, err error) {
|
||||
var pngBytes []byte
|
||||
var previousJSON string
|
||||
retryLoop:
|
||||
@@ -1239,6 +1350,9 @@ retryLoop:
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
if reread && err == nil && !transitional && changedOnReread(ctx, options, logger, stepIndex, tree) {
|
||||
transitional = true
|
||||
}
|
||||
if len(pngBytes) > 0 {
|
||||
if writeErr := options.TraceWriter.WriteScreenshot(stepIndex, pngBytes); writeErr != nil {
|
||||
logger.Warn("screenshot write failed", "step", stepIndex, "err", writeErr)
|
||||
@@ -1247,6 +1361,98 @@ retryLoop:
|
||||
return tree, pngBytes, transitional, err
|
||||
}
|
||||
|
||||
// changedOnReread reads the hierarchy once more and reports whether the screen
|
||||
// changed shape while we were looking at it. A Compose route can settle before
|
||||
// its content composes (a lazy list mounts over several frames, a query lands a
|
||||
// frame late), and a tree read in that window describes a screen that is still
|
||||
// filling in. Two reads a read apart are the cheapest thing that can see it
|
||||
// happening: the round trip IS the interval, so there is no sleep here.
|
||||
//
|
||||
// The comparison only means anything because the Hierarchy RPC serves the tree
|
||||
// the snapshot's own read produces (see snapshotTree in the sidecar). Off the
|
||||
// bare device read it does not: with an IME standing open, the snapshot answers
|
||||
// with 134 nodes and the bare read with 489, and the pair then differs over
|
||||
// whether the sidecar closed a keyboard between them rather than over anything
|
||||
// the app did.
|
||||
//
|
||||
// Waiting for the change to stop was measured on an API 34 device and refused:
|
||||
// a 750ms-quiet poll capped at 2s cost a median 1434ms against 76ms for one
|
||||
// read, hit its cap on every frame it fired for, and still handed back a frame
|
||||
// that might be filling. Detecting is what the runner can act on, because a
|
||||
// step it declines to verify is at worst a missed conviction, never a false
|
||||
// one.
|
||||
//
|
||||
// A read that fails reports no change. Nothing about a dropped RPC says the
|
||||
// screen was moving, and skipping verification on it would quietly spend the
|
||||
// run's evidence on a flaky link.
|
||||
func changedOnReread(
|
||||
ctx context.Context,
|
||||
options Options,
|
||||
logger *slog.Logger,
|
||||
stepIndex int,
|
||||
first *hierarchy.Tree,
|
||||
) bool {
|
||||
// An empty tree is skipped by the caller anyway, so the read buys nothing.
|
||||
if first == nil || len(first.Elements) == 0 {
|
||||
return false
|
||||
}
|
||||
hierarchyJSON, err := options.Driver.Hierarchy(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("second hierarchy read failed", "step", stepIndex, "err", err)
|
||||
return false
|
||||
}
|
||||
second, err := hierarchy.Parse(hierarchyJSON)
|
||||
if err != nil || second == nil {
|
||||
logger.Warn("second hierarchy parse failed", "step", stepIndex, "err", err)
|
||||
return false
|
||||
}
|
||||
if structuralShape(first) == structuralShape(second) {
|
||||
return false
|
||||
}
|
||||
logger.Warn("screen changed between two reads; skipping verifier",
|
||||
"step", stepIndex, "nodes", len(first.Elements), "then", len(second.Elements))
|
||||
return true
|
||||
}
|
||||
|
||||
// structuralShape renders what is on screen as its nodes' identities in tree
|
||||
// order: how many there are, and which ids and classes they carry.
|
||||
//
|
||||
// Text and bounds are deliberately absent. A measure pass that moves pixels is
|
||||
// not a screen still composing, and neither is a value arriving into a node
|
||||
// that already exists, which this cannot tell apart from a clock ticking. This
|
||||
// decides whether a property gets to judge at all, so it reads only what a
|
||||
// change in what is on screen can move: a detector that fires on every step of
|
||||
// a screen with a timer on it would leave the run green and vacuous, which is
|
||||
// worse than the composition it set out to catch. The trade is measured rather
|
||||
// than assumed: over 100 folio steps on an API 35 emulator, text moved under
|
||||
// an unchanged shape on 1 step, and the shape itself moved on 1 other.
|
||||
//
|
||||
// TestRunner_OnlyAChangeOfShapeCostsAStepItsVerdict is what holds the line:
|
||||
// adding either field back to the shape turns one of its cases red.
|
||||
func structuralShape(tree *hierarchy.Tree) string {
|
||||
var shape strings.Builder
|
||||
for _, element := range tree.Elements {
|
||||
shape.WriteString(element.ResourceID)
|
||||
shape.WriteByte(0x1f)
|
||||
shape.WriteString(element.Class)
|
||||
shape.WriteByte(0x1e)
|
||||
}
|
||||
return shape.String()
|
||||
}
|
||||
|
||||
// driverIsAndroid asks the driver what it is, once per run, so the step loop
|
||||
// never repeats the RPC. It gates the reread: #75 is about Compose composition,
|
||||
// and web and iOS have their own settle paths and no measurement saying an
|
||||
// extra hierarchy read there is cheap. An unreadable answer is not android.
|
||||
func driverIsAndroid(ctx context.Context, options Options, logger *slog.Logger) bool {
|
||||
health, err := options.Driver.Health(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("health read failed; not rereading the hierarchy", "err", err)
|
||||
return false
|
||||
}
|
||||
return health.Platform == "android"
|
||||
}
|
||||
|
||||
func traceActionFor(action verifier.Action, tree *hierarchy.Tree) *trace.Action {
|
||||
traceAction := &trace.Action{Kind: string(action.Kind), X: action.X, Y: action.Y}
|
||||
switch action.Kind {
|
||||
|
||||
@@ -253,6 +253,23 @@ func TestRenderSummary_OmitsUnsupportedLineWhenNone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A step nothing judged is not a step that passed. The run prints its count so
|
||||
// a green summary cannot hide a run that skipped most of its steps, which is
|
||||
// what a screen that keeps moving under the reads would produce.
|
||||
func TestRenderSummary_CountsTheStepsNothingJudged(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
RenderSummary(&out, Summary{Steps: 10, SkippedVerification: 4}, "android")
|
||||
if !strings.Contains(out.String(), "4 step(s) judged by nothing") {
|
||||
t.Errorf("expected the unjudged-step count, got:\n%s", out.String())
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
RenderSummary(&out, Summary{Steps: 10}, "android")
|
||||
if strings.Contains(out.String(), "judged by nothing") {
|
||||
t.Errorf("a run that judged every step must not print the line, got:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunner_ViolationSurfacesInSummary(t *testing.T) {
|
||||
state := newHarnessWithSpec(t, violationSpec)
|
||||
|
||||
@@ -1507,8 +1524,15 @@ func TestRunner_UsesAtomicSnapshot(t *testing.T) {
|
||||
if snapshotCalls == 0 {
|
||||
t.Errorf("expected at least one Snapshot call, got %d", snapshotCalls)
|
||||
}
|
||||
if hierarchyCalls != 0 {
|
||||
t.Errorf("expected zero standalone Hierarchy calls (runner must use Snapshot), got %d", hierarchyCalls)
|
||||
// The recorded pair still comes from Snapshot. The standalone hierarchy
|
||||
// reads are the composition detector (changedOnReread), one per step at
|
||||
// most, and they are never the source of what the step records.
|
||||
if hierarchyCalls > summary.Steps {
|
||||
t.Errorf("expected at most one standalone Hierarchy call per step (runner must observe through Snapshot), got %d over %d steps",
|
||||
hierarchyCalls, summary.Steps)
|
||||
}
|
||||
if snapshotCalls < summary.Steps {
|
||||
t.Errorf("expected a Snapshot per step, got %d over %d steps", snapshotCalls, summary.Steps)
|
||||
}
|
||||
if screenshotCalls != 0 {
|
||||
t.Errorf("expected zero standalone Screenshot calls (runner must use Snapshot), got %d", screenshotCalls)
|
||||
@@ -2339,8 +2363,10 @@ func TestEnsureForeground_DismissesSystemOverlay(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||
options := Options{BundleID: "app.folio", Driver: m, IdleTimeout: 10 * time.Millisecond}
|
||||
|
||||
if !ensureForeground(context.Background(), options, logger, 5) {
|
||||
t.Fatal("expected the guard to act on the focus-stealing overlay")
|
||||
got := ensureForeground(context.Background(), options, logger, 5)
|
||||
if got != foregroundOverlayDismissed {
|
||||
t.Fatalf("the guard reported %v, want foregroundOverlayDismissed; "+
|
||||
"an obscured app is not a relaunched one", got)
|
||||
}
|
||||
backs, relaunches := 0, 0
|
||||
for _, a := range m.Actions() {
|
||||
|
||||
+31
-11
@@ -26,14 +26,15 @@ type ActionSource interface {
|
||||
// PushSnapshot. The mobile path has none (returns nil); the web path returns the
|
||||
// values its extractors computed in V8 against the real DOM.
|
||||
//
|
||||
// lastAction is the action the previous step actually applied, the same value
|
||||
// PushSnapshot hands the goja state. The web path has to install it in the page
|
||||
// before its extractors run: a spec extractor reading state.lastAction runs in
|
||||
// V8 there, and V8 has no way to know what the runner dispatched.
|
||||
// lastAction and logs are what PushSnapshot hands the goja state. The web path
|
||||
// has to install both in the page before its extractors run: a spec extractor
|
||||
// reading state.lastAction or state.logs runs in V8 there, and V8 knows neither
|
||||
// what the runner dispatched nor what the driver's log fetch returned.
|
||||
type ExtractorSource interface {
|
||||
ExtractorOverrides(
|
||||
ctx context.Context,
|
||||
lastAction *verifier.Action,
|
||||
logs []verifier.LogEntry,
|
||||
) (map[int]json.RawMessage, error)
|
||||
}
|
||||
|
||||
@@ -44,6 +45,13 @@ type lastActionInstaller interface {
|
||||
SetLastAction(ctx context.Context, encoded json.RawMessage) error
|
||||
}
|
||||
|
||||
// logInstaller is the same channel for the entries this step's log fetch
|
||||
// returned. Console output reaches the driver over CDP, so the page can only
|
||||
// learn about it from the runner.
|
||||
type logInstaller interface {
|
||||
SetLogs(ctx context.Context, encoded json.RawMessage) error
|
||||
}
|
||||
|
||||
// gojaSource drives both action selection and (trivially) extractor overrides
|
||||
// for the mobile path, where the goja-bundled picker runs in-process and no V8
|
||||
// extractor values exist.
|
||||
@@ -58,6 +66,7 @@ func (s gojaSource) NextAction(context.Context, int) (verifier.Action, error) {
|
||||
func (gojaSource) ExtractorOverrides(
|
||||
context.Context,
|
||||
*verifier.Action,
|
||||
[]verifier.LogEntry,
|
||||
) (map[int]json.RawMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -79,24 +88,35 @@ func (s webSource) NextAction(ctx context.Context, _ int) (verifier.Action, erro
|
||||
return verifier.DecodeAction(raw)
|
||||
}
|
||||
|
||||
// ExtractorOverrides installs the previous step's action in the page, then
|
||||
// reads back what the spec's extractors computed against the live DOM. The
|
||||
// install is not best-effort: a web driver that cannot take it leaves
|
||||
// state.lastAction null in V8, which silently turns every action-gated
|
||||
// property vacuously true, so it is reported as an error instead.
|
||||
// ExtractorOverrides installs the previous step's action and this step's log
|
||||
// entries in the page, then reads back what the spec's extractors computed
|
||||
// against the live DOM. Neither install is best-effort: a web driver that
|
||||
// cannot take them leaves state.lastAction null and state.logs empty in V8,
|
||||
// which silently turns every action-gated property and every log property
|
||||
// vacuously true, so both are reported as errors instead.
|
||||
func (s webSource) ExtractorOverrides(
|
||||
ctx context.Context,
|
||||
lastAction *verifier.Action,
|
||||
logs []verifier.LogEntry,
|
||||
) (map[int]json.RawMessage, error) {
|
||||
installer, ok := s.web.(lastActionInstaller)
|
||||
actions, ok := s.web.(lastActionInstaller)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(
|
||||
"web driver %T cannot install state.lastAction; every property gated "+
|
||||
"on the last action would be vacuously true", s.web)
|
||||
}
|
||||
if err := installer.SetLastAction(ctx, verifier.EncodeLastAction(lastAction)); err != nil {
|
||||
if err := actions.SetLastAction(ctx, verifier.EncodeLastAction(lastAction)); err != nil {
|
||||
return nil, fmt.Errorf("install last action: %w", err)
|
||||
}
|
||||
entries, ok := s.web.(logInstaller)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(
|
||||
"web driver %T cannot install state.logs; every property reading the "+
|
||||
"log stream would be vacuously true", s.web)
|
||||
}
|
||||
if err := entries.SetLogs(ctx, verifier.EncodeLogs(logs)); err != nil {
|
||||
return nil, fmt.Errorf("install logs: %w", err)
|
||||
}
|
||||
return s.web.EvaluateExtractors(ctx)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/driver"
|
||||
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
|
||||
)
|
||||
|
||||
// An apply error is not proof that nothing landed. An RPC deadline that fires
|
||||
// after the tap was dispatched leaves the transaction committed, and a runner
|
||||
// that reports "no action" for it hands
|
||||
// submitCommitsOneTransactionPerAction a rise of one transaction against a
|
||||
// window of zero submits: a conviction manufactured out of the runner's own
|
||||
// uncertainty, on the property carrying most of the detection on android.
|
||||
//
|
||||
// The spec below is the real folio predicate pair, imported from the example,
|
||||
// so what this asserts is the verdict the shipped property reaches.
|
||||
const submitCountingSpecTemplate = `
|
||||
import { actions, always, extract, next, Tap } from "@sanderling/spec";
|
||||
import {
|
||||
committedTransactionsExceedSubmits,
|
||||
countSubmitsInWindow,
|
||||
} from "%s";
|
||||
|
||||
let submits = 0;
|
||||
const submitsInWindow = extract("submitsInWindow", state => {
|
||||
const window = countSubmitsInWindow({
|
||||
previousCount: submits,
|
||||
lastAction: state.lastAction,
|
||||
fresh: true,
|
||||
});
|
||||
submits = window.next;
|
||||
return window.reported;
|
||||
});
|
||||
|
||||
const counts = extract("counts", state => {
|
||||
const text = state.ax.find("id:TxnCount")?.text;
|
||||
return text ? { Travel: parseInt(text, 10) } : null;
|
||||
});
|
||||
|
||||
globalThis.properties = {
|
||||
submitCommitsOneTransactionPerAction: always(
|
||||
next(() =>
|
||||
!committedTransactionsExceedSubmits({
|
||||
countsBefore: counts.previous ?? null,
|
||||
countsAfter: counts.current,
|
||||
submitsInWindow: submitsInWindow.current,
|
||||
}),
|
||||
),
|
||||
),
|
||||
};
|
||||
globalThis.actions = actions(() => [Tap({ on: "id:TxnSubmit" })]);
|
||||
`
|
||||
|
||||
const homeWithTxnCount = `{"attributes":{"resource-id":"HomeScreen"},"children":[
|
||||
{"attributes":{"resource-id":"TxnCount","text":"%d"},"children":[]},
|
||||
{"attributes":{"resource-id":"TxnSubmit","bounds":"[40,80,240,160]"},"children":[],"clickable":true,"enabled":true}
|
||||
]}`
|
||||
|
||||
// dispatchThenFailDriver is the device condition the runner cannot see through:
|
||||
// the tap reaches the app and commits, then the call the runner is waiting on
|
||||
// times out. Every later hierarchy read shows the committed transactions.
|
||||
type dispatchThenFailDriver struct {
|
||||
*mockdriver.Driver
|
||||
commitsPerTap int64
|
||||
committed atomic.Int64
|
||||
}
|
||||
|
||||
func (d *dispatchThenFailDriver) Tap(context.Context, int, int) error {
|
||||
return d.dispatchThenFail()
|
||||
}
|
||||
|
||||
func (d *dispatchThenFailDriver) TapSelector(context.Context, string) error {
|
||||
return d.dispatchThenFail()
|
||||
}
|
||||
|
||||
func (d *dispatchThenFailDriver) dispatchThenFail() error {
|
||||
d.committed.Add(d.commitsPerTap)
|
||||
return errors.New("rpc error: code = DeadlineExceeded desc = context deadline exceeded")
|
||||
}
|
||||
|
||||
func (d *dispatchThenFailDriver) Snapshot(context.Context) (string, driver.Image, error) {
|
||||
return fmt.Sprintf(homeWithTxnCount, d.committed.Load()), driver.Image{}, nil
|
||||
}
|
||||
|
||||
func (d *dispatchThenFailDriver) Hierarchy(context.Context) (string, error) {
|
||||
return fmt.Sprintf(homeWithTxnCount, d.committed.Load()), nil
|
||||
}
|
||||
|
||||
func TestRunner_ApplyErrorAfterDispatchDoesNotConvictTheSubmitCountingProperty(t *testing.T) {
|
||||
predicates, err := filepath.Abs("../../examples/folio/sanderling/predicates.ts")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec := fmt.Sprintf(submitCountingSpecTemplate, predicates)
|
||||
|
||||
run := func(t *testing.T, commitsPerTap int64) []ViolationRecord {
|
||||
t.Helper()
|
||||
state := newHarnessWithSpec(t, spec)
|
||||
device := &dispatchThenFailDriver{Driver: state.mock, commitsPerTap: commitsPerTap}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
summary, err := Run(ctx, Options{
|
||||
Duration: time.Hour,
|
||||
IdleTimeout: 20 * time.Millisecond,
|
||||
MaxSteps: 2,
|
||||
Driver: device,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if summary.Steps != 2 {
|
||||
t.Fatalf("steps = %d, want 2; the run never reached the step that judges the pair", summary.Steps)
|
||||
}
|
||||
if got := device.committed.Load(); got != commitsPerTap*2 {
|
||||
t.Fatalf("the device committed %d transaction(s), want %d; the taps never reached it",
|
||||
got, commitsPerTap*2)
|
||||
}
|
||||
return summary.Violations
|
||||
}
|
||||
|
||||
t.Run("one transaction per tap is not a double submit", func(t *testing.T) {
|
||||
if violations := run(t, 1); len(violations) != 0 {
|
||||
t.Errorf("the counting property convicted a healthy app: %v\n"+
|
||||
"one transaction rose against a submit the runner dispatched but "+
|
||||
"could not confirm, and the spec was told no action happened",
|
||||
violations)
|
||||
}
|
||||
})
|
||||
|
||||
// The control. Without it a green above proves nothing: a property that
|
||||
// never sees a comparable pair is silently vacuous and reports the same
|
||||
// empty violation list.
|
||||
t.Run("two transactions per tap still convicts", func(t *testing.T) {
|
||||
violations := run(t, 2)
|
||||
if len(violations) == 0 {
|
||||
t.Fatal("the counting property missed a double submit; the harness never " +
|
||||
"put the property in a position to fire, so the case above proves nothing")
|
||||
}
|
||||
if violations[0].Properties[0] != "submitCommitsOneTransactionPerAction" {
|
||||
t.Errorf("violated %v, want submitCommitsOneTransactionPerAction", violations[0].Properties)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -55,6 +55,12 @@ func (d *carrierWebDriver) Snapshot(ctx context.Context) (string, driver.Image,
|
||||
|
||||
func (d *carrierWebDriver) InstallBundle(context.Context, []byte) error { return nil }
|
||||
|
||||
// A web target says so. The runner's per-step hierarchy reread is android-only,
|
||||
// and a fake claiming android would take a path no chrome run takes.
|
||||
func (d *carrierWebDriver) Health(context.Context) (driver.Health, error) {
|
||||
return driver.Health{Ready: true, Version: "fake", Platform: "web"}, nil
|
||||
}
|
||||
|
||||
func (d *carrierWebDriver) EvaluateExtractors(context.Context) (map[int]json.RawMessage, error) {
|
||||
d.reads++
|
||||
return map[int]json.RawMessage{0: json.RawMessage(strconv.Itoa(d.reads))}, nil
|
||||
@@ -69,6 +75,8 @@ func (d *carrierWebDriver) NextActionFromV8(context.Context) (json.RawMessage, e
|
||||
|
||||
func (d *carrierWebDriver) SetLastAction(context.Context, json.RawMessage) error { return nil }
|
||||
|
||||
func (d *carrierWebDriver) SetLogs(context.Context, json.RawMessage) error { return nil }
|
||||
|
||||
// TestRunner_TransitionalStepNeverAdvancesThePageCarrier pins the ordering the
|
||||
// web path depends on. The page-side extractors must run only on steps the
|
||||
// verifier accepts: their getters advance spec state every time they evaluate,
|
||||
@@ -166,6 +174,8 @@ func (d *installFailsWebDriver) SetLastAction(context.Context, json.RawMessage)
|
||||
return errors.New("__sanderlingSetLastAction__ is not a function")
|
||||
}
|
||||
|
||||
func (d *installFailsWebDriver) SetLogs(context.Context, json.RawMessage) error { return nil }
|
||||
|
||||
// TestRunner_LastActionInstallFailureFailsTheRun covers the other half of the
|
||||
// same trust boundary. A run that cannot install lastAction in the page cannot
|
||||
// apply the page's extractor values either, so the step keeps goja's
|
||||
@@ -194,3 +204,49 @@ func TestRunner_LastActionInstallFailureFailsTheRun(t *testing.T) {
|
||||
t.Errorf("Run error = %v, want it to name the failed lastAction install", err)
|
||||
}
|
||||
}
|
||||
|
||||
// logInstallFailsWebDriver takes lastAction and refuses the logs, the shape a
|
||||
// page carrying an older published @sanderling/spec runtime has: it knows the
|
||||
// action setter and not the log one.
|
||||
type logInstallFailsWebDriver struct {
|
||||
*installFailsWebDriver
|
||||
}
|
||||
|
||||
func (d *logInstallFailsWebDriver) SetLastAction(context.Context, json.RawMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *logInstallFailsWebDriver) SetLogs(context.Context, json.RawMessage) error {
|
||||
return errors.New("__sanderlingSetLogs__ is not a function")
|
||||
}
|
||||
|
||||
// TestRunner_LogInstallFailureFailsTheRun holds the log channel to the same
|
||||
// standard as the action one. The driver having the console errors decides
|
||||
// nothing on web: the page's reading of every extractor replaces the host's, so
|
||||
// a run that cannot put the entries back into the page evaluates noLogcatErrors
|
||||
// against an empty array and reports green on a console full of errors.
|
||||
// Continuing past this is the vacuity the whole install exists to prevent.
|
||||
func TestRunner_LogInstallFailureFailsTheRun(t *testing.T) {
|
||||
state := newHarnessWithSpec(t, carrierSpec)
|
||||
web := &logInstallFailsWebDriver{
|
||||
installFailsWebDriver: &installFailsWebDriver{Driver: state.mock},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_, err := Run(ctx, Options{
|
||||
Duration: 2 * time.Second,
|
||||
IdleTimeout: 20 * time.Millisecond,
|
||||
MaxSteps: 3,
|
||||
Driver: web,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Run succeeded with a page that cannot take the step's logs; " +
|
||||
"every property reading the log stream ran against an empty array")
|
||||
}
|
||||
if !bytes.Contains([]byte(err.Error()), []byte("install logs")) {
|
||||
t.Errorf("Run error = %v, want it to name the failed log install", err)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@ func (d *webMockDriver) NextActionFromV8(context.Context) (json.RawMessage, erro
|
||||
|
||||
func (d *webMockDriver) SetLastAction(context.Context, json.RawMessage) error { return nil }
|
||||
|
||||
func (d *webMockDriver) SetLogs(context.Context, json.RawMessage) error { return nil }
|
||||
|
||||
// TestRunner_TraceRecordsTheValueTheVerdictUsed fails if the trace and the
|
||||
// verdict disagree about an extractor. A witness is only an explanation of a
|
||||
// violation if it holds the state the violated property was evaluated against.
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/driver"
|
||||
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
|
||||
)
|
||||
|
||||
@@ -25,7 +30,8 @@ globalThis.properties = {};
|
||||
// control, so the runner has a real applied action to report on the next step.
|
||||
type tappingWebDriver struct {
|
||||
*mockdriver.Driver
|
||||
installed []string
|
||||
installed []string
|
||||
installedLogs []string
|
||||
}
|
||||
|
||||
func (d *tappingWebDriver) InstallBundle(context.Context, []byte) error { return nil }
|
||||
@@ -43,6 +49,11 @@ func (d *tappingWebDriver) SetLastAction(_ context.Context, encoded json.RawMess
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *tappingWebDriver) SetLogs(_ context.Context, encoded json.RawMessage) error {
|
||||
d.installedLogs = append(d.installedLogs, string(encoded))
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRunner_WebInstallsLastActionInThePage(t *testing.T) {
|
||||
state := newHarnessWithSpec(t, lastActionSpec)
|
||||
web := &tappingWebDriver{Driver: state.mock}
|
||||
@@ -71,7 +82,114 @@ func TestRunner_WebInstallsLastActionInThePage(t *testing.T) {
|
||||
// Every later step carries what the runner actually applied. The shape is
|
||||
// the goja host's (internal/verifier/marshal.go lastActionFields), pinned
|
||||
// against it by TestLastAction_WebJSONMatchesTheGojaObject.
|
||||
const want = `{"kind":"Tap","on":"id:TxnSubmit"}`
|
||||
const want = `{"kind":"Tap","applied":true,"relaunched":null,"on":"id:TxnSubmit"}`
|
||||
if web.installed[1] != want {
|
||||
t.Errorf("step 2 installed %s, want %s", web.installed[1], want)
|
||||
}
|
||||
}
|
||||
|
||||
// The same hole on the other channel: state.logs was hardcoded [] in
|
||||
// pkg/spec/src/web-runtime.ts, and because the page's reading of an extractor
|
||||
// replaces the host's on web, the driver's error-level entries never reached a
|
||||
// property. The default noLogcatErrors counted an empty array on every run.
|
||||
func TestRunner_WebInstallsTheStepsLogsInThePage(t *testing.T) {
|
||||
state := newHarnessWithSpec(t, lastActionSpec)
|
||||
state.mock.LogEntries = []driver.LogEntry{
|
||||
{UnixMillis: 1700000000123, Level: "E", Tag: "console", Message: "boom from the page"},
|
||||
}
|
||||
web := &tappingWebDriver{Driver: state.mock}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := Run(ctx, Options{
|
||||
Duration: time.Hour,
|
||||
IdleTimeout: 20 * time.Millisecond,
|
||||
MaxSteps: 2,
|
||||
Driver: web,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
if len(web.installedLogs) == 0 {
|
||||
t.Fatal("the page was never handed the step's logs; every property reading " +
|
||||
"state.logs evaluated against the empty array the page starts with")
|
||||
}
|
||||
// The shape is the goja host's (internal/verifier/marshal.go logFields),
|
||||
// pinned against it by TestLogs_WebJSONMatchesTheGojaObject.
|
||||
const want = `[{"unixMillis":1700000000123,"level":"E","tag":"console","message":"boom from the page"}]`
|
||||
if web.installedLogs[0] != want {
|
||||
t.Errorf("step 1 installed %s, want %s", web.installedLogs[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
// A log fetch that fails decides the verdict of every log property: they all
|
||||
// evaluate against an empty slice and hold. That is not a fact about the app,
|
||||
// so the step it happened on has to be visible in the run's output. It used to
|
||||
// be dropped in silence, under a comment claiming it was warned about.
|
||||
func TestRunner_ReportsALogFetchItCouldNotMake(t *testing.T) {
|
||||
state := newHarnessWithSpec(t, lastActionSpec)
|
||||
state.mock.Failures[mockdriver.ActionRecentLogs] = errors.New("adb: device offline")
|
||||
|
||||
var buffer bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buffer, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := Run(ctx, Options{
|
||||
Duration: time.Hour,
|
||||
IdleTimeout: 20 * time.Millisecond,
|
||||
MaxSteps: 2,
|
||||
Driver: state.mock,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
Logger: logger,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(buffer.String(), "adb: device offline") {
|
||||
t.Errorf("the run never reported the failed log fetch, so noLogcatErrors "+
|
||||
"held on evidence nobody collected; log was %q", buffer.String())
|
||||
}
|
||||
}
|
||||
|
||||
// failingTapWebDriver dispatches the tap and then fails the call, the shape an
|
||||
// RPC deadline takes: the page has the click, the runner has an error.
|
||||
type failingTapWebDriver struct {
|
||||
*tappingWebDriver
|
||||
}
|
||||
|
||||
func (d *failingTapWebDriver) Tap(context.Context, int, int) error {
|
||||
return errors.New("rpc error: code = DeadlineExceeded desc = context deadline exceeded")
|
||||
}
|
||||
|
||||
// The web leg of the same three states the goja host reports. "applied":null is
|
||||
// not "no action": a property gated on the last action still sees the tap and
|
||||
// decides for itself, which it cannot do if the page is handed a bare null.
|
||||
func TestRunner_WebInstallsAnUnconfirmedActionWithItsFateUnknown(t *testing.T) {
|
||||
state := newHarnessWithSpec(t, lastActionSpec)
|
||||
web := &failingTapWebDriver{tappingWebDriver: &tappingWebDriver{Driver: state.mock}}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := Run(ctx, Options{
|
||||
Duration: time.Hour,
|
||||
IdleTimeout: 20 * time.Millisecond,
|
||||
MaxSteps: 2,
|
||||
Driver: web,
|
||||
Verifier: state.verifier,
|
||||
TraceWriter: state.writer,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
if len(web.installed) < 2 {
|
||||
t.Fatalf("the page was handed lastAction %d time(s); the web path never installed it",
|
||||
len(web.installed))
|
||||
}
|
||||
const want = `{"kind":"Tap","applied":null,"relaunched":null,"on":"id:TxnSubmit"}`
|
||||
if web.installed[1] != want {
|
||||
t.Errorf("step 2 installed %s, want %s", web.installed[1], want)
|
||||
}
|
||||
|
||||
+96
-22
@@ -84,6 +84,17 @@ var newDeviceDriver = func(ctx context.Context, options ioscompanion.DeviceOptio
|
||||
return d, d.Close, nil
|
||||
}
|
||||
|
||||
// newSimulatorDriver constructs the iOS simulator driver and its cleanup. A
|
||||
// seam so routing tests assert the run's options reach ioscompanion.Options
|
||||
// without spawning a companion.
|
||||
var newSimulatorDriver = func(ctx context.Context, options ioscompanion.Options) (driver.DeviceDriver, func(), error) {
|
||||
d, err := ioscompanion.New(ctx, options)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return d, d.Close, nil
|
||||
}
|
||||
|
||||
// buildDriver creates the appropriate DeviceDriver for the platform and returns
|
||||
// a cleanup function. For web, ChromeDriver is used directly. An iOS simulator
|
||||
// is driven by the native simulator companion (no JVM). A physical iOS device
|
||||
@@ -99,16 +110,17 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
|
||||
}
|
||||
|
||||
if options.Platform == "ios" && options.iosIsSimulator {
|
||||
d, err := ioscompanion.New(ctx, ioscompanion.Options{
|
||||
d, cleanup, err := newSimulatorDriver(ctx, ioscompanion.Options{
|
||||
UniqueDeviceIdentifier: options.iosUDID,
|
||||
BundleID: options.BundleID,
|
||||
AppPath: options.IosAppPath,
|
||||
ClearState: options.ClearData,
|
||||
Output: stdout,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("ios simulator driver: %w", err)
|
||||
}
|
||||
return d, d.Close, nil
|
||||
return d, cleanup, nil
|
||||
}
|
||||
|
||||
if options.Platform == "ios" {
|
||||
@@ -117,6 +129,7 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
|
||||
CoreDeviceID: options.iosCoreDeviceID,
|
||||
BundleID: options.BundleID,
|
||||
AppPath: options.IosAppPath,
|
||||
ClearState: options.ClearData,
|
||||
Output: stdout,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -148,10 +161,14 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
|
||||
if options.Device != "" {
|
||||
sidecarArgs = append(sidecarArgs, "--serial", options.Device)
|
||||
}
|
||||
adbPath, err := android.AdbBinary()
|
||||
if err != nil {
|
||||
return nil, nil, preflightFailure("android", err)
|
||||
}
|
||||
sidecarCommand := exec.CommandContext(ctx, "java", sidecarArgs...)
|
||||
sidecarCommand.Stdout = stdout
|
||||
sidecarCommand.Stderr = stdout
|
||||
sidecarCommand.Env = android.EnvWithAndroidPlatformTools(os.Environ())
|
||||
sidecarCommand.Env = android.EnvWithAndroidPlatformTools(os.Environ(), adbPath)
|
||||
// SIGTERM lets the sidecar's shutdown hook stop the iOS XCTest runner.
|
||||
// SIGKILL skips the hook and orphans an xcodebuild session that later
|
||||
// restarts its runner and hijacks the simulator mid-run.
|
||||
@@ -162,11 +179,13 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
|
||||
if err := sidecarCommand.Start(); err != nil {
|
||||
return nil, nil, fmt.Errorf("spawn sidecar: %w", err)
|
||||
}
|
||||
fmt.Fprintf(stdout, "sidecar pid=%d listening on 127.0.0.1:%d\n", sidecarCommand.Process.Pid, sidecarPort)
|
||||
sidecarExited := watchSidecar(sidecarCommand)
|
||||
address := fmt.Sprintf("127.0.0.1:%d", sidecarPort)
|
||||
fmt.Fprintf(stdout, "sidecar pid=%d listening on %s (adb: %s)\n", sidecarCommand.Process.Pid, address, adbPath)
|
||||
|
||||
driverClient, err := driverSidecar.Dial(fmt.Sprintf("127.0.0.1:%d", sidecarPort))
|
||||
driverClient, err := driverSidecar.Dial(address)
|
||||
if err != nil {
|
||||
stopSidecar(sidecarCommand)
|
||||
stopSidecar(sidecarCommand, sidecarExited)
|
||||
return nil, nil, fmt.Errorf("dial sidecar: %w", err)
|
||||
}
|
||||
driverClient.SetPlatform(options.Platform)
|
||||
@@ -175,22 +194,82 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
|
||||
// (absorbing the XCUITest startup race) runs inside IosDriverBackend.init
|
||||
// in the sidecar - no additional sleep needed here.
|
||||
healthCtx, healthCancel := context.WithTimeout(ctx, sidecarStartupTimeout)
|
||||
if err := driverClient.WaitForHealth(healthCtx, 250e6); err != nil {
|
||||
healthCancel()
|
||||
stopSidecar(sidecarCommand)
|
||||
_ = driverClient.Close()
|
||||
return nil, nil, fmt.Errorf("sidecar health check: %w", err)
|
||||
}
|
||||
healthErr := awaitSidecar(healthCtx, address, sidecarStartupTimeout, func(pollCtx context.Context) error {
|
||||
return driverClient.WaitForHealth(pollCtx, 250e6)
|
||||
}, sidecarExited)
|
||||
healthCancel()
|
||||
if healthErr != nil {
|
||||
stopSidecar(sidecarCommand, sidecarExited)
|
||||
_ = driverClient.Close()
|
||||
return nil, nil, healthErr
|
||||
}
|
||||
fmt.Fprintln(stdout, "sidecar is healthy")
|
||||
|
||||
cleanup := func() {
|
||||
_ = driverClient.Close()
|
||||
stopSidecar(sidecarCommand)
|
||||
stopSidecar(sidecarCommand, sidecarExited)
|
||||
}
|
||||
return driverClient, cleanup, nil
|
||||
}
|
||||
|
||||
// watchSidecar reaps the sidecar and publishes its exit status. The channel is
|
||||
// closed after the send so the shutdown path can still receive once the startup
|
||||
// path has taken the status.
|
||||
func watchSidecar(sidecarCommand *exec.Cmd) <-chan error {
|
||||
exited := make(chan error, 1)
|
||||
go func() {
|
||||
exited <- sidecarCommand.Wait()
|
||||
close(exited)
|
||||
}()
|
||||
return exited
|
||||
}
|
||||
|
||||
// awaitSidecar waits for the sidecar to answer a health check, racing that
|
||||
// against the process exiting so a sidecar that dies during startup is reported
|
||||
// as the exit it was rather than as a deadline half a minute later. Neither
|
||||
// failure knows why the sidecar was unhappy, so both name what to look at
|
||||
// instead of picking a cause.
|
||||
func awaitSidecar(
|
||||
ctx context.Context,
|
||||
address string,
|
||||
timeout time.Duration,
|
||||
health func(context.Context) error,
|
||||
exited <-chan error,
|
||||
) error {
|
||||
healthy := make(chan error, 1)
|
||||
go func() { healthy <- health(ctx) }()
|
||||
select {
|
||||
case exitErr := <-exited:
|
||||
return sidecarExitedError(address, exitErr)
|
||||
case err := <-healthy:
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case exitErr := <-exited:
|
||||
return sidecarExitedError(address, exitErr)
|
||||
default:
|
||||
return fmt.Errorf(
|
||||
"sidecar did not answer a health check on %s within %s and is still running\n%s",
|
||||
address, timeout, sidecarWhatToCheck,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sidecarWhatToCheck = "check the sidecar output above, then `sanderling doctor --platform=android` (java 17+, adb, Android SDK)"
|
||||
|
||||
func sidecarExitedError(address string, exitErr error) error {
|
||||
status := "exit status 0"
|
||||
if exitErr != nil {
|
||||
status = exitErr.Error()
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"sidecar exited before it answered a health check on %s: %s\n%s",
|
||||
address, status, sidecarWhatToCheck,
|
||||
)
|
||||
}
|
||||
|
||||
// sidecarShutdownGrace bounds how long the sidecar gets to run its shutdown
|
||||
// hook (terminate the app, stop the XCTest runner) before being killed.
|
||||
const sidecarShutdownGrace = 15 * time.Second
|
||||
@@ -198,25 +277,20 @@ const sidecarShutdownGrace = 15 * time.Second
|
||||
// stopSidecar terminates the sidecar gracefully so its shutdown hook can stop
|
||||
// the device-side runner processes, escalating to SIGKILL when it does not
|
||||
// exit within the grace window.
|
||||
func stopSidecar(sidecarCommand *exec.Cmd) {
|
||||
func stopSidecar(sidecarCommand *exec.Cmd, exited <-chan error) {
|
||||
if sidecarCommand.Process == nil {
|
||||
return
|
||||
}
|
||||
if err := sidecarCommand.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
_ = sidecarCommand.Process.Kill()
|
||||
_ = sidecarCommand.Wait()
|
||||
<-exited
|
||||
return
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = sidecarCommand.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-exited:
|
||||
case <-time.After(sidecarShutdownGrace):
|
||||
_ = sidecarCommand.Process.Kill()
|
||||
<-done
|
||||
<-exited
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/driver"
|
||||
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion"
|
||||
@@ -27,7 +30,7 @@ func TestBuildDriverRoutesPhysicalIOSToDeviceDriver(t *testing.T) {
|
||||
return stubDeviceDriver{}, func() { closed = true }, nil
|
||||
}
|
||||
|
||||
options := Options{Platform: "ios", BundleID: "app.folio", IosAppPath: "/tmp/iosApp.app"}
|
||||
options := Options{Platform: "ios", BundleID: "app.folio", IosAppPath: "/tmp/iosApp.app", ClearData: true}
|
||||
options.iosIsSimulator = false
|
||||
options.iosUDID = "00008140-HW"
|
||||
options.iosCoreDeviceID = "CORE-1"
|
||||
@@ -45,12 +48,41 @@ func TestBuildDriverRoutesPhysicalIOSToDeviceDriver(t *testing.T) {
|
||||
if got.BundleID != "app.folio" || got.AppPath != "/tmp/iosApp.app" {
|
||||
t.Fatalf("DeviceOptions = %+v, want bundle and app path threaded through", got)
|
||||
}
|
||||
if !got.ClearState {
|
||||
t.Fatalf("DeviceOptions = %+v, want clear-data threaded through: the driver clears before its session, so a launch cannot", got)
|
||||
}
|
||||
cleanup()
|
||||
if !closed {
|
||||
t.Fatal("cleanup must close the device driver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDriverThreadsClearStateToTheSimulatorDriver(t *testing.T) {
|
||||
stubPreflight(t)
|
||||
original := newSimulatorDriver
|
||||
t.Cleanup(func() { newSimulatorDriver = original })
|
||||
|
||||
var got ioscompanion.Options
|
||||
newSimulatorDriver = func(_ context.Context, options ioscompanion.Options) (driver.DeviceDriver, func(), error) {
|
||||
got = options
|
||||
return stubDeviceDriver{}, func() {}, nil
|
||||
}
|
||||
|
||||
options := Options{Platform: "ios", BundleID: "app.folio", IosAppPath: "/tmp/iosApp.app", ClearData: true}
|
||||
options.iosIsSimulator = true
|
||||
options.iosUDID = "SIM-UDID"
|
||||
|
||||
if _, _, err := buildDriver(context.Background(), options, io.Discard); err != nil {
|
||||
t.Fatalf("buildDriver: %v", err)
|
||||
}
|
||||
if got.UniqueDeviceIdentifier != "SIM-UDID" || got.BundleID != "app.folio" || got.AppPath != "/tmp/iosApp.app" {
|
||||
t.Fatalf("Options = %+v, want the resolved target, bundle and app path", got)
|
||||
}
|
||||
if !got.ClearState {
|
||||
t.Fatalf("Options = %+v, want clear-data threaded through: the driver clears before its session, so a launch cannot", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDriverSurfacesDeviceConstructionError(t *testing.T) {
|
||||
stubPreflight(t)
|
||||
original := newDeviceDriver
|
||||
@@ -65,6 +97,130 @@ func TestBuildDriverSurfacesDeviceConstructionError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A sidecar that dies during startup leaves the health poll with nothing to
|
||||
// talk to, and reporting that as a deadline sends the reader after a gRPC
|
||||
// timeout instead of the exit that already happened.
|
||||
func TestAwaitSidecarReportsTheExitItSaw(t *testing.T) {
|
||||
exited := make(chan error, 1)
|
||||
exited <- errors.New("exit status 1")
|
||||
close(exited)
|
||||
|
||||
err := awaitSidecar(
|
||||
context.Background(),
|
||||
"127.0.0.1:54321",
|
||||
30*time.Second,
|
||||
func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() },
|
||||
exited,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the sidecar exits before it is healthy")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"sidecar exited before it answered a health check on 127.0.0.1:54321: exit status 1",
|
||||
"check the sidecar output above",
|
||||
"sanderling doctor --platform=android",
|
||||
} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q missing %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwaitSidecarTimeoutSaysOnlyWhatItObserved(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err := awaitSidecar(
|
||||
ctx,
|
||||
"127.0.0.1:54321",
|
||||
50*time.Millisecond,
|
||||
func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() },
|
||||
make(chan error, 1),
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the sidecar never answers")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"sidecar did not answer a health check on 127.0.0.1:54321 within 50ms and is still running",
|
||||
"check the sidecar output above",
|
||||
"sanderling doctor --platform=android",
|
||||
} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q missing %q", err, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "context deadline exceeded") {
|
||||
t.Errorf("error %q must not hand the reader a bare gRPC deadline", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwaitSidecarHealthyReturnsNil(t *testing.T) {
|
||||
err := awaitSidecar(
|
||||
context.Background(),
|
||||
"127.0.0.1:54321",
|
||||
30*time.Second,
|
||||
func(context.Context) error { return nil },
|
||||
make(chan error, 1),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected a healthy sidecar to pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopSidecarTerminatesARunningSidecar(t *testing.T) {
|
||||
command := exec.Command("sleep", "60")
|
||||
if err := command.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
exited := watchSidecar(command)
|
||||
|
||||
stopped := make(chan struct{})
|
||||
go func() {
|
||||
stopSidecar(command, exited)
|
||||
close(stopped)
|
||||
}()
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("stopSidecar never returned for a running sidecar")
|
||||
}
|
||||
if got := command.ProcessState.String(); got != "signal: terminated" {
|
||||
t.Errorf("sidecar ended as %q, want the SIGTERM its shutdown hook needs", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The startup path takes the exit status to report it, so the shutdown path
|
||||
// that follows must not sit waiting for a status nobody will send again.
|
||||
func TestStopSidecarAfterTheStartupPathTookTheExitStatus(t *testing.T) {
|
||||
command := exec.Command("sh", "-c", "exit 3")
|
||||
if err := command.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
exited := watchSidecar(command)
|
||||
|
||||
err := awaitSidecar(
|
||||
context.Background(),
|
||||
"127.0.0.1:54321",
|
||||
30*time.Second,
|
||||
func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() },
|
||||
exited,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "exit status 3") {
|
||||
t.Fatalf("expected the sidecar's real exit status, got %v", err)
|
||||
}
|
||||
|
||||
stopped := make(chan struct{})
|
||||
go func() {
|
||||
stopSidecar(command, exited)
|
||||
close(stopped)
|
||||
}()
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("stopSidecar blocked on an exit status the startup path had already taken")
|
||||
}
|
||||
}
|
||||
|
||||
// stubPreflight bypasses the host-readiness checks so routing tests exercise
|
||||
// driver construction on a Linux CI runner that lacks xcrun/java.
|
||||
func stubPreflight(t *testing.T) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/android"
|
||||
)
|
||||
|
||||
// Preflight runs platform-specific host checks before sidecar/driver setup.
|
||||
@@ -18,7 +20,15 @@ func Preflight(ctx context.Context, platform string) error {
|
||||
|
||||
type preflightFunc func(name string) error
|
||||
|
||||
// preflightCheck resolves adb through the same helper every adb call in a run
|
||||
// uses, so a host whose SDK is only reachable through $ANDROID_HOME or a
|
||||
// standard install location is not turned away here and then driven fine by
|
||||
// the rest of the pipeline.
|
||||
func preflightCheck(name string) error {
|
||||
if name == "adb" {
|
||||
_, err := android.AdbBinary()
|
||||
return err
|
||||
}
|
||||
if _, err := exec.LookPath(name); err != nil {
|
||||
return fmt.Errorf("%s not found on PATH: %w", name, err)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package testrun
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -49,6 +51,33 @@ func TestPreflight_AndroidNeedsAdbAndJava(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Every adb call in an android run resolves through $ANDROID_HOME and the
|
||||
// standard SDK locations, so a preflight that only looks at PATH turns away a
|
||||
// host the run itself would drive.
|
||||
func TestPreflight_AndroidAcceptsAdbUnderAndroidHome(t *testing.T) {
|
||||
sdk := t.TempDir()
|
||||
writeExecutable(t, filepath.Join(sdk, "platform-tools", "adb"))
|
||||
pathDirectory := t.TempDir()
|
||||
writeExecutable(t, filepath.Join(pathDirectory, "java"))
|
||||
t.Setenv("PATH", pathDirectory)
|
||||
t.Setenv("ANDROID_HOME", sdk)
|
||||
t.Setenv("ANDROID_SDK_ROOT", "")
|
||||
|
||||
if err := Preflight(context.Background(), "android"); err != nil {
|
||||
t.Fatalf("Preflight with adb under $ANDROID_HOME: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeExecutable(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, nil, 0o755); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflight_iOSNeedsXcrun(t *testing.T) {
|
||||
check := func(name string) error {
|
||||
if name == "xcrun" {
|
||||
|
||||
@@ -247,7 +247,15 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
|
||||
// --exit-on-violation a run that found violations is still a successful run
|
||||
// (the summary reports them), which is the behaviour every existing caller
|
||||
// depends on.
|
||||
//
|
||||
// A run none of whose steps reached the verifier fails whatever the flags say,
|
||||
// because it holds no verdict to report. The threshold is every step and not a
|
||||
// fraction of them: a screen that composes now and then costs a healthy android
|
||||
// run a step or two, and a check that fired on those would be red on every run.
|
||||
func runOutcome(options Options, summary runner.Summary) error {
|
||||
if summary.Steps > 0 && summary.SkippedVerification == summary.Steps {
|
||||
return VacuousRunError{Steps: summary.Steps}
|
||||
}
|
||||
if options.ExitOnViolation && len(summary.Violations) > 0 {
|
||||
return ViolationsError{Count: len(summary.Violations)}
|
||||
}
|
||||
@@ -283,6 +291,22 @@ func BundleSpec(specPath string, seed int64) (bundler.Result, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// VacuousRunError reports a run in which no step reached the verifier, so no
|
||||
// property ever judged anything. It is not a clean run and it is not a found
|
||||
// bug: it is a run that produced no evidence either way, and the absence of
|
||||
// violations in it says nothing about the app. It stays untyped to the CLI's
|
||||
// violation path on purpose, so it exits 1 as a broken run rather than 2.
|
||||
type VacuousRunError struct {
|
||||
Steps int
|
||||
}
|
||||
|
||||
func (e VacuousRunError) Error() string {
|
||||
return fmt.Sprintf(
|
||||
"%d step(s) ran and none of them reached the verifier: the screen was "+
|
||||
"still moving every time it was read, so no property judged this run",
|
||||
e.Steps)
|
||||
}
|
||||
|
||||
// bundleInputs holds the pre-driver assembly: alias map, seed, esbuild defines,
|
||||
// and the resolved spec-API/goja-runtime paths the bundler consumes.
|
||||
type bundleInputs struct {
|
||||
@@ -372,16 +396,20 @@ func resolveRuntimeSibling(specAPIPath, userSpecPath, filename string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveSpecAPIPath returns the path to pkg/spec/src/index.ts inside
|
||||
// a sanderling source checkout, searched upward from the spec file and the cwd.
|
||||
// Returns "" when not found, in which case esbuild resolves @sanderling/spec via
|
||||
// node_modules the way a downstream user's project would.
|
||||
// resolveSpecAPIPath returns the path to the spec API's index.ts: a sanderling
|
||||
// source checkout first, searched upward from the spec file and the cwd, then
|
||||
// an installed node_modules/@sanderling/spec. Aliasing the installed copy is
|
||||
// what keeps the spec and the runtime entry on one module graph; resolving the
|
||||
// bare specifier through package.json "exports" would load dist/ alongside the
|
||||
// runtime's src/ and give sampler-rng.ts two instances.
|
||||
func resolveSpecAPIPath(specPath string) string {
|
||||
var candidates []string
|
||||
var checkout, installed []string
|
||||
if absoluteSpec, err := filepath.Abs(specPath); err == nil {
|
||||
directory := filepath.Dir(absoluteSpec)
|
||||
for {
|
||||
candidates = append(candidates, filepath.Join(directory, "pkg/spec/src/index.ts"))
|
||||
checkout = append(checkout, filepath.Join(directory, "pkg/spec/src/index.ts"))
|
||||
installed = append(installed,
|
||||
filepath.Join(directory, "node_modules/@sanderling/spec/src/index.ts"))
|
||||
parent := filepath.Dir(directory)
|
||||
if parent == directory {
|
||||
break
|
||||
@@ -390,9 +418,9 @@ func resolveSpecAPIPath(specPath string) string {
|
||||
}
|
||||
}
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(cwd, "pkg/spec/src/index.ts"))
|
||||
checkout = append(checkout, filepath.Join(cwd, "pkg/spec/src/index.ts"))
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
for _, candidate := range append(checkout, installed...) {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package testrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -287,6 +289,31 @@ func TestRunOutcome_ReportsViolationsOnlyUnderTheFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A step the verifier skipped was judged by nothing, so a run whose every step
|
||||
// was skipped holds no verdict at all: "no violations" there is the absence of
|
||||
// an answer rather than a clean one. Reporting it as a successful run is the
|
||||
// green and vacuous outcome structuralShape's own design notes call worse than
|
||||
// the composition it catches, and the runner's hold is what makes a fully
|
||||
// skipped run reachable.
|
||||
func TestRunOutcome_ARunThatJudgedNothingIsNotASuccess(t *testing.T) {
|
||||
nothingJudged := runner.Summary{Steps: 6, SkippedVerification: 6}
|
||||
err := runOutcome(Options{}, nothingJudged)
|
||||
var vacuous VacuousRunError
|
||||
if !errors.As(err, &vacuous) {
|
||||
t.Fatalf("a run that judged none of its 6 steps came back %v, want a VacuousRunError", err)
|
||||
}
|
||||
if vacuous.Steps != 6 {
|
||||
t.Errorf("steps: got %d, want 6", vacuous.Steps)
|
||||
}
|
||||
|
||||
// A screen that composes now and then costs a run steps, not its verdict. A
|
||||
// check that fired here would turn every healthy android run red.
|
||||
mostlyJudged := runner.Summary{Steps: 6, SkippedVerification: 5}
|
||||
if err := runOutcome(Options{}, mostlyJudged); err != nil {
|
||||
t.Errorf("a run that judged one of its 6 steps must succeed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// wedgedLaunchDriver never returns from Launch, standing in for a driver whose
|
||||
// device-side session is stuck.
|
||||
type wedgedLaunchDriver struct {
|
||||
@@ -327,3 +354,153 @@ func TestLaunchAppBoundsWedgedDriver(t *testing.T) {
|
||||
t.Fatal("launchApp never returned: the pre-run launch is unbounded, so a wedged driver hangs the run forever")
|
||||
}
|
||||
}
|
||||
|
||||
// repoFile walks up from the test's working directory and returns the absolute
|
||||
// path of rel inside the sanderling checkout.
|
||||
func repoFile(t *testing.T, rel string) string {
|
||||
t.Helper()
|
||||
directory, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for {
|
||||
candidate := filepath.Join(directory, rel)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
parent := filepath.Dir(directory)
|
||||
if parent == directory {
|
||||
t.Fatalf("%s not found above the test directory", rel)
|
||||
}
|
||||
directory = parent
|
||||
}
|
||||
}
|
||||
|
||||
// publishedFiles returns the "files" entries of pkg/spec/package.json, the
|
||||
// exact set npm ships in the @sanderling/spec tarball.
|
||||
func publishedFiles(t *testing.T) []string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(repoFile(t, "pkg/spec/package.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var manifest struct {
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return manifest.Files
|
||||
}
|
||||
|
||||
// installPublishedPackage reproduces what `npm install @sanderling/spec`
|
||||
// unpacks into node_modules: only the paths package.json publishes.
|
||||
func installPublishedPackage(t *testing.T, dest string) {
|
||||
t.Helper()
|
||||
specDir := filepath.Dir(repoFile(t, "pkg/spec/package.json"))
|
||||
for _, entry := range publishedFiles(t) {
|
||||
source := filepath.Join(specDir, entry)
|
||||
if _, err := os.Stat(source); err != nil {
|
||||
continue
|
||||
}
|
||||
copyTree(t, source, filepath.Join(dest, entry))
|
||||
}
|
||||
}
|
||||
|
||||
func copyTree(t *testing.T, source, dest string) {
|
||||
t.Helper()
|
||||
err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relative, err := filepath.Rel(source, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dest, relative)
|
||||
if entry.IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target, data, 0o644)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveRuntimeSibling_PublishedPackageShipsTheRuntimes pins npm's "files"
|
||||
// list against the resolver that consumes it. The tarball shipped dist/ alone
|
||||
// while the node_modules fallback looks for src/goja-runtime.ts, so every
|
||||
// `npm install @sanderling/spec` user hit "goja-runtime.ts not found".
|
||||
func TestResolveRuntimeSibling_PublishedPackageShipsTheRuntimes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installPublishedPackage(t, filepath.Join(root, "node_modules", "@sanderling", "spec"))
|
||||
specPath := filepath.Join(root, "spec.ts")
|
||||
if err := os.WriteFile(specPath, []byte(""), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, filename := range []string{"goja-runtime.ts", "web-runtime.ts"} {
|
||||
if resolveRuntimeSibling("", specPath, filename) == "" {
|
||||
t.Errorf("%s unreachable from a published install; package.json publishes %v",
|
||||
filename, publishedFiles(t))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareBundleInputs_InstalledPackageSharesOneModuleGraph pins the
|
||||
// downstream case: with no sanderling checkout above the spec, the aliases and
|
||||
// the runtime entry must name the SAME installed copy. An unset alias let
|
||||
// esbuild resolve @sanderling/spec to dist/ while the runtime came from src/,
|
||||
// which loads sampler-rng.ts twice; from(), strings(), integers() and emails()
|
||||
// then read an rng the picker never set and collapse to a fixed default.
|
||||
func TestPrepareBundleInputs_InstalledPackageSharesOneModuleGraph(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
installed := filepath.Join(root, "node_modules", "@sanderling", "spec")
|
||||
installPublishedPackage(t, installed)
|
||||
specPath := filepath.Join(root, "sanderling", "spec.ts")
|
||||
if err := os.MkdirAll(filepath.Dir(specPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(specPath, []byte(""), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
prep, err := prepareBundleInputs(Options{Spec: specPath})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := filepath.Join(installed, "src")
|
||||
want := map[string]string{
|
||||
"@sanderling/spec": filepath.Join(source, "index.ts"),
|
||||
"@sanderling/spec/defaults": filepath.Join(source, "defaults/index.ts"),
|
||||
"@sanderling/spec/defaults/properties": filepath.Join(source, "defaults/properties.ts"),
|
||||
}
|
||||
for key, wantValue := range want {
|
||||
if prep.aliases[key] != wantValue {
|
||||
t.Errorf("alias %q = %q, want %q", key, prep.aliases[key], wantValue)
|
||||
}
|
||||
}
|
||||
if got := prep.gojaRuntimePath; got != filepath.Join(source, "goja-runtime.ts") {
|
||||
t.Errorf("gojaRuntimePath = %q, want it beside the aliased index.ts", got)
|
||||
}
|
||||
if got := resolveWebRuntimePath(prep.specAPIPath, specPath); got != filepath.Join(source, "web-runtime.ts") {
|
||||
t.Errorf("webRuntimePath = %q, want it beside the aliased index.ts", got)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package verifier
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -176,3 +177,77 @@ func TestStateAxObjectSelectorKeepsCrossPlatformKeysSilent(t *testing.T) {
|
||||
t.Fatalf("probe = %q, want miss", got)
|
||||
}
|
||||
}
|
||||
|
||||
// axSelectorFormsTree carries one node per id shape a dump produces: the bare
|
||||
// tag Compose and the web driver emit, the package-qualified resource id
|
||||
// Android emits, and the iOS accessibility identifier.
|
||||
const axSelectorFormsTree = `{
|
||||
"attributes": {"resource-id": "root", "bounds": "[0,0,400,800]"},
|
||||
"children": [
|
||||
{"attributes": {"resource-id": "BareThing", "text": "bare", "bounds": "[0,0,100,50]"},
|
||||
"children": []},
|
||||
{"attributes": {"resource-id": "com.example.app:id/AndroidThing", "text": "android",
|
||||
"bounds": "[0,50,100,100]"}, "children": []},
|
||||
{"attributes": {"accessibilityIdentifier": "IosThing", "text": "ios",
|
||||
"bounds": "[0,100,100,150]"}, "children": []}
|
||||
]
|
||||
}`
|
||||
|
||||
// TestStateAxSelectorFormsAgree drives both selector forms a spec can write
|
||||
// through state.ax.find and holds them to the same element. The two forms
|
||||
// dispatch to different lookups (findNodeFromJS sends a string to FindNode and
|
||||
// an object to FindBySelector), and the object one used to skip the id rule
|
||||
// that knows an Android resource id is package-qualified, so a spec that wrote
|
||||
// ax.find({id: "AddAccountSubmit"}) got undefined on Android and every property
|
||||
// reading it passed while checking nothing.
|
||||
func TestStateAxSelectorFormsAgree(t *testing.T) {
|
||||
tree, err := hierarchy.Parse(axSelectorFormsTree)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{"BareThing", "bare"},
|
||||
{"AndroidThing", "android"},
|
||||
{"com.example.app:id/AndroidThing", "android"},
|
||||
{"IosThing", "ios"},
|
||||
} {
|
||||
t.Run(test.value, func(t *testing.T) {
|
||||
verifier := newVerifier(t)
|
||||
mustLoad(t, verifier, `
|
||||
globalThis.fromObject = __sanderling__.extract(
|
||||
state => state.ax.find({ id: `+strconv.Quote(test.value)+` })?.text, "fromObject");
|
||||
globalThis.fromString = __sanderling__.extract(
|
||||
state => state.ax.find("id:" + `+strconv.Quote(test.value)+`)?.text, "fromString");
|
||||
globalThis.properties = {};
|
||||
`)
|
||||
if err := verifier.PushSnapshot(SnapshotInput{Tree: tree}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fromObject := readCurrent(t, verifier, "fromObject")
|
||||
fromString := readCurrent(t, verifier, "fromString")
|
||||
if fromString != test.want {
|
||||
t.Fatalf(`ax.find("id:%s") read %v, want %q`, test.value, fromString, test.want)
|
||||
}
|
||||
if fromObject != fromString {
|
||||
t.Errorf(
|
||||
`one selector, two answers: ax.find({id: %q}) read %v and ax.find("id:%s") read %v`,
|
||||
test.value, fromObject, test.value, fromString,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// readCurrent returns a named extractor's current value, or nil when the getter
|
||||
// returned undefined, which is what an unresolved selector produces.
|
||||
func readCurrent(t *testing.T, verifier *Verifier, name string) any {
|
||||
t.Helper()
|
||||
handle := verifier.runtime.GlobalObject().Get(name)
|
||||
if handle == nil {
|
||||
t.Fatalf("%s is not defined", name)
|
||||
}
|
||||
return handle.ToObject(verifier.runtime).Get("current").Export()
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package verifier
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const elementTreeJSON = `{
|
||||
"attributes": {"resource-id": "root", "bounds": "[0,0,400,800]"},
|
||||
"enabled": true,
|
||||
"children": [
|
||||
{"attributes": {"resource-id": "TxnAmountField", "text": "199", "bounds": "[0,100,400,160]"},
|
||||
"editable": true, "enabled": true, "children": []}
|
||||
]
|
||||
}`
|
||||
|
||||
const elementExtractorSpec = `
|
||||
const field = __sanderling__.extract(state => state.ax.find({ "resource-id": "TxnAmountField" }), "field");
|
||||
globalThis.properties = {};
|
||||
`
|
||||
|
||||
// canonicalElement is the trace's record of one ax element, written out in the
|
||||
// key order encoding/json emits. It is the contract both hosts owe the replay
|
||||
// UI: an element the reader can read, with no host-function members and nothing
|
||||
// dropped. Keys the two hosts disagree on (a DOM has no `checked`, a native
|
||||
// tree has no `dataset`) are each host's own business; the ENCODING is not.
|
||||
const canonicalElement = `{
|
||||
"__sanderlingSelector": "resource-id:TxnAmountField",
|
||||
"attrs": {
|
||||
"bounds": "[0,100,400,160]",
|
||||
"editable": "true",
|
||||
"enabled": "true",
|
||||
"resource-id": "TxnAmountField",
|
||||
"text": "199"
|
||||
},
|
||||
"bounds": {"bottom": 160, "left": 0, "right": 400, "top": 100},
|
||||
"checked": false,
|
||||
"class": "",
|
||||
"clickable": false,
|
||||
"desc": "",
|
||||
"editable": true,
|
||||
"enabled": true,
|
||||
"focused": false,
|
||||
"id": "TxnAmountField",
|
||||
"selected": false,
|
||||
"text": "199",
|
||||
"x": 200,
|
||||
"y": 130
|
||||
}`
|
||||
|
||||
// TestExtractorEncoding_ElementIsIdenticalOnBothHosts holds the two extractor
|
||||
// paths to one encoding of one element. The goja hosts (ios, android) run the
|
||||
// getter in-process and encode the value it returned; the web host runs it in
|
||||
// V8 and injects the page's reading through OverrideExtractorValues. A reader
|
||||
// opening a trace does not know which host wrote it, so the same element has to
|
||||
// land as the same bytes either way.
|
||||
//
|
||||
// The goja side used to write null here: an ax element carries find/findAll as
|
||||
// host functions and json.Marshal refuses the whole object over them.
|
||||
func TestExtractorEncoding_ElementIsIdenticalOnBothHosts(t *testing.T) {
|
||||
want := compactJSON(t, canonicalElement)
|
||||
|
||||
native := newVerifier(t)
|
||||
mustLoad(t, native, elementExtractorSpec)
|
||||
pushTree(t, native, elementTreeJSON)
|
||||
fromGoja := string(native.extractors[0].curr)
|
||||
if fromGoja != want {
|
||||
t.Errorf("goja host encoded the element as\n %s\nwant\n %s", fromGoja, want)
|
||||
}
|
||||
|
||||
web := newVerifier(t)
|
||||
mustLoad(t, web, elementExtractorSpec)
|
||||
if err := web.PushSnapshot(SnapshotInput{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := web.OverrideExtractorValues(map[int]json.RawMessage{0: json.RawMessage(want)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fromWeb := string(web.extractors[0].curr)
|
||||
if fromWeb != fromGoja {
|
||||
t.Errorf("the same element reaches the trace as\n %s\non the web host and\n %s\non goja",
|
||||
fromWeb, fromGoja)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractorEncoding_MirrorsTheWebSanitizeRule pins the goja host to the
|
||||
// rule the web host applies before a reading leaves the page (sanitize in
|
||||
// pkg/spec/src/web-runtime.ts, asserted there by the "sanitize ..." tests in
|
||||
// pkg/spec/test/web-runtime.test.ts). Two hosts encoding one value two ways is
|
||||
// the same defect as encoding it not at all: the reader cannot line the traces
|
||||
// up.
|
||||
func TestExtractorEncoding_MirrorsTheWebSanitizeRule(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
expression string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "function-valued properties are dropped",
|
||||
expression: `({ keep: 1, fn: () => 7 })`,
|
||||
want: `{"keep":1}`,
|
||||
},
|
||||
{
|
||||
name: "a top-level function is not a value",
|
||||
expression: `(() => 7)`,
|
||||
want: `null`,
|
||||
},
|
||||
{
|
||||
name: "a self-referential cycle breaks instead of overflowing",
|
||||
expression: `(() => { const a = { name: "root" }; a.self = a; return a; })()`,
|
||||
want: `{"name":"root","self":null}`,
|
||||
},
|
||||
{
|
||||
name: "arrays and nested plain values are preserved",
|
||||
expression: `({ items: [1, "two", { ok: true }] })`,
|
||||
want: `{"items":[1,"two",{"ok":true}]}`,
|
||||
},
|
||||
{
|
||||
name: "a non-finite number is not a value",
|
||||
expression: `Number("nope")`,
|
||||
want: `null`,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := encodeSpecValue(t, test.expression); got != test.want {
|
||||
t.Errorf("encoded as %s, want %s", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractorEncoding_BoundsRecursionPastTheDepthLimit mirrors the web host's
|
||||
// depth cap. state.ax hands out no cyclic element, but a spec returning a value
|
||||
// it built itself can nest without end, and a walk with no bound takes the run
|
||||
// down with a stack overflow.
|
||||
func TestExtractorEncoding_BoundsRecursionPastTheDepthLimit(t *testing.T) {
|
||||
encoded := encodeSpecValue(t, `(() => {
|
||||
let deep = { leaf: true };
|
||||
for (let i = 0; i < 40; i++) deep = { next: deep };
|
||||
return deep;
|
||||
})()`)
|
||||
|
||||
var node any
|
||||
if err := json.Unmarshal([]byte(encoded), &node); err != nil {
|
||||
t.Fatalf("decode %s: %v", encoded, err)
|
||||
}
|
||||
for depth := 0; depth < recordableMaxDepth; depth++ {
|
||||
object, ok := node.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("depth %d: recursion stopped early at %v", depth, node)
|
||||
}
|
||||
node = object["next"]
|
||||
}
|
||||
if node != nil {
|
||||
t.Errorf("depth %d is %v, want null", recordableMaxDepth, node)
|
||||
}
|
||||
}
|
||||
|
||||
// encodeSpecValue returns what the trace records for an extractor whose getter
|
||||
// returned the given expression.
|
||||
func encodeSpecValue(t *testing.T, expression string) string {
|
||||
t.Helper()
|
||||
verifier := newVerifier(t)
|
||||
mustLoad(t, verifier, "__sanderling__.extract(state => "+expression+", \"value\");\nglobalThis.properties = {};")
|
||||
if err := verifier.PushSnapshot(SnapshotInput{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(verifier.extractors[0].curr)
|
||||
}
|
||||
|
||||
func compactJSON(t *testing.T, source string) string {
|
||||
t.Helper()
|
||||
var compact bytes.Buffer
|
||||
if err := json.Compact(&compact, []byte(source)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return compact.String()
|
||||
}
|
||||
|
||||
// TestExtractorEncoding_NestedUndefinedIsNotOnTheWire pins the one reading
|
||||
// shape the two hosts do NOT encode alike, rather than hiding it.
|
||||
//
|
||||
// JSON has no undefined, so the page loses the whole key (asserted in
|
||||
// pkg/spec/test/web-runtime.test.ts) while goja writes null. goja cannot mirror
|
||||
// the drop: Export reports an undefined member and a null member identically as
|
||||
// nil, so dropping those keys here would drop the genuine nulls the page keeps.
|
||||
// Mirroring the other way, by writing null on the page, would break the one
|
||||
// thing that does agree. Carrying the member across takes a wire format that
|
||||
// can express undefined, which is a change to every layer that parses a reading
|
||||
// and to the replay UI that renders one.
|
||||
//
|
||||
// So the guarantee is narrower than "the same object": both hosts answer
|
||||
// undefined when a property READS the member. Key presence (`in`, Object.keys)
|
||||
// is not part of it, and this test says so out loud, so closing the gap has to
|
||||
// be a deliberate change to both hosts at once.
|
||||
func TestExtractorEncoding_NestedUndefinedIsNotOnTheWire(t *testing.T) {
|
||||
const reading = `({ absent: undefined, empty: null, present: 1 })`
|
||||
const fromGoja = `{"absent":null,"empty":null,"present":1}`
|
||||
// What the page sends for the same getter, with the key gone.
|
||||
const fromWeb = `{"empty":null,"present":1}`
|
||||
|
||||
if got := encodeSpecValue(t, reading); got != fromGoja {
|
||||
t.Errorf("goja encoded the reading as %s, want %s", got, fromGoja)
|
||||
}
|
||||
|
||||
native := newVerifier(t)
|
||||
mustLoad(t, native, "__sanderling__.extract(state => "+reading+", \"value\");\nglobalThis.properties = {};")
|
||||
if err := native.PushSnapshot(SnapshotInput{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
web := newVerifier(t)
|
||||
mustLoad(t, web, "__sanderling__.extract(state => null, \"value\");\nglobalThis.properties = {};")
|
||||
if err := web.PushSnapshot(SnapshotInput{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := web.OverrideExtractorValues(map[int]json.RawMessage{0: json.RawMessage(fromWeb)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, probe := range []struct {
|
||||
expression string
|
||||
native bool
|
||||
web bool
|
||||
}{
|
||||
{"reading.absent === undefined", true, true},
|
||||
{"reading.empty === null", true, true},
|
||||
{"reading.present === 1", true, true},
|
||||
// The half that does not survive the wire.
|
||||
{`"absent" in reading`, true, false},
|
||||
} {
|
||||
if got := evaluateAgainstReading(t, native, probe.expression); got != probe.native {
|
||||
t.Errorf("goja host: %s is %v, want %v", probe.expression, got, probe.native)
|
||||
}
|
||||
if got := evaluateAgainstReading(t, web, probe.expression); got != probe.web {
|
||||
t.Errorf("web host: %s is %v, want %v", probe.expression, got, probe.web)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateAgainstReading answers a boolean expression over the value a property
|
||||
// would read out of the first extractor, which is where the two hosts have to
|
||||
// agree.
|
||||
func evaluateAgainstReading(t *testing.T, verifier *Verifier, expression string) bool {
|
||||
t.Helper()
|
||||
if err := verifier.runtime.GlobalObject().Set("reading", verifier.extractors[0].currentValue); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := verifier.runtime.RunString(expression)
|
||||
if err != nil {
|
||||
t.Fatalf("evaluate %s: %v", expression, err)
|
||||
}
|
||||
return value.ToBoolean()
|
||||
}
|
||||
+57
-111
@@ -4,8 +4,6 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -29,7 +27,7 @@ type stateInput struct {
|
||||
}
|
||||
|
||||
// stateObject builds the JS-side `state` object matching the State type from
|
||||
// pkg/spec-api. Fields beyond snapshots/ax are included when the caller
|
||||
// pkg/spec. Fields beyond snapshots/ax are included when the caller
|
||||
// populated them on stateInput.
|
||||
func stateObject(runtime *goja.Runtime, input stateInput) (*goja.Object, error) {
|
||||
state := runtime.NewObject()
|
||||
@@ -400,7 +398,30 @@ func lastActionFields(action *Action) []actionField {
|
||||
point := func(x, y int) []actionField {
|
||||
return []actionField{{key: "x", value: x}, {key: "y", value: y}}
|
||||
}
|
||||
fields := []actionField{{key: "kind", value: string(action.Kind)}}
|
||||
// An action whose apply call failed is not an action that did not happen:
|
||||
// the dispatch may have landed before the error. That is unknown, and
|
||||
// unknown is null here for the same reason every other absence in the spec
|
||||
// surface is, so a property decides for itself instead of being handed a
|
||||
// "nothing happened" the runner cannot vouch for.
|
||||
var applied any
|
||||
if action.Applied {
|
||||
applied = true
|
||||
}
|
||||
// A relaunch between two readings is not "no action happened", which is
|
||||
// what dropping the action reported instead: the app restarted after an
|
||||
// action that did run. Only the positive report is a fact the runner can
|
||||
// vouch for, so the other side is null rather than false: a target whose
|
||||
// foreground the runner cannot read (web, iOS) never relaunches the app and
|
||||
// still cannot promise it never restarted.
|
||||
var relaunched any
|
||||
if action.Relaunched {
|
||||
relaunched = true
|
||||
}
|
||||
fields := []actionField{
|
||||
{key: "kind", value: string(action.Kind)},
|
||||
{key: "applied", value: applied},
|
||||
{key: "relaunched", value: relaunched},
|
||||
}
|
||||
if action.On != "" {
|
||||
fields = append(fields, actionField{key: "on", value: action.On})
|
||||
}
|
||||
@@ -453,7 +474,7 @@ func objectFromFields(runtime *goja.Runtime, fields []actionField) *goja.Object
|
||||
// has no Go-side state object to read: the runner pushes this JSON into the
|
||||
// page before each extractor evaluation. A nil action encodes as JSON null,
|
||||
// the same value the goja host reports on the first step of a run and after a
|
||||
// step whose action was never applied.
|
||||
// step whose action was never dispatched.
|
||||
func EncodeLastAction(action *Action) json.RawMessage {
|
||||
if action == nil {
|
||||
return json.RawMessage("null")
|
||||
@@ -501,19 +522,44 @@ func runtimeMillis(stepTime, runStart time.Time) int64 {
|
||||
return stepTime.Sub(runStart).Milliseconds()
|
||||
}
|
||||
|
||||
// logFields is the ONE description of a state.logs entry, for the same reason
|
||||
// lastActionFields is: the goja host turns it into a JS object (logsArray) and
|
||||
// the web host receives the same fields as JSON (EncodeLogs), so a property
|
||||
// counting error-level lines cannot read one shape on native and another on web.
|
||||
func logFields(entry LogEntry) []actionField {
|
||||
return []actionField{
|
||||
{key: "unixMillis", value: entry.UnixMillis},
|
||||
{key: "level", value: entry.Level},
|
||||
{key: "tag", value: entry.Tag},
|
||||
{key: "message", value: entry.Message},
|
||||
}
|
||||
}
|
||||
|
||||
func logsArray(runtime *goja.Runtime, logs []LogEntry) *goja.Object {
|
||||
array := runtime.NewArray()
|
||||
for index, entry := range logs {
|
||||
item := runtime.NewObject()
|
||||
_ = item.Set("unixMillis", entry.UnixMillis)
|
||||
_ = item.Set("level", entry.Level)
|
||||
_ = item.Set("tag", entry.Tag)
|
||||
_ = item.Set("message", entry.Message)
|
||||
_ = array.Set(fmt.Sprintf("%d", index), item)
|
||||
_ = array.Set(fmt.Sprintf("%d", index), objectFromFields(runtime, logFields(entry)))
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
// EncodeLogs renders this step's log entries for the web host, which has no
|
||||
// Go-side state object to read: the runner pushes this JSON into the page
|
||||
// before each extractor evaluation. No entries encodes as an empty array, the
|
||||
// same value the goja host reports for a step whose log fetch found nothing.
|
||||
func EncodeLogs(logs []LogEntry) json.RawMessage {
|
||||
var buffer bytes.Buffer
|
||||
buffer.WriteByte('[')
|
||||
for index, entry := range logs {
|
||||
if index > 0 {
|
||||
buffer.WriteByte(',')
|
||||
}
|
||||
buffer.Write(encodeFields(logFields(entry)))
|
||||
}
|
||||
buffer.WriteByte(']')
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func exceptionsArray(runtime *goja.Runtime, exceptions []Exception) *goja.Object {
|
||||
array := runtime.NewArray()
|
||||
for index, exception := range exceptions {
|
||||
@@ -529,106 +575,6 @@ func exceptionsArray(runtime *goja.Runtime, exceptions []Exception) *goja.Object
|
||||
return array
|
||||
}
|
||||
|
||||
// traceValueMaxDepth bounds how far recordableValue walks. It mirrors
|
||||
// SANITIZE_MAX_DEPTH in pkg/spec/src/web-runtime.ts, whose sanitize does this
|
||||
// same job for the values the page reports, so both hosts record the same JSON
|
||||
// for the same extractor.
|
||||
const traceValueMaxDepth = 32
|
||||
|
||||
// recordableValue rewrites an exported goja value into one json.Marshal
|
||||
// accepts. An accessibility element is a plain object carrying two host
|
||||
// functions (find/findAll); marshalling it fails on those alone, so the whole
|
||||
// element used to go unrecorded. Dropping them leaves the element's data (id,
|
||||
// text, desc, class, the flags, bounds, attrs), which is what a trace reader
|
||||
// wants and is a subset of the hierarchy the same step already records.
|
||||
//
|
||||
// ok is false for a value with no JSON form at all: callers drop that key from
|
||||
// its object, matching the web host, where a function-valued property is
|
||||
// skipped and a function inside an array stringifies to null.
|
||||
func recordableValue(value any, depth int, seen map[uintptr]bool) (any, bool) {
|
||||
if value == nil {
|
||||
return nil, true
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return finiteOrNull(typed), true
|
||||
case float32:
|
||||
return finiteOrNull(float64(typed)), true
|
||||
}
|
||||
reflected := reflect.ValueOf(value)
|
||||
switch reflected.Kind() {
|
||||
case reflect.Func:
|
||||
return nil, false
|
||||
case reflect.Map:
|
||||
if reflected.Type().Key().Kind() != reflect.String || !holdsAny(reflected.Type().Elem()) {
|
||||
return value, true
|
||||
}
|
||||
if depth >= traceValueMaxDepth || !firstVisit(reflected, seen) {
|
||||
return nil, true
|
||||
}
|
||||
out := make(map[string]any, reflected.Len())
|
||||
iterator := reflected.MapRange()
|
||||
for iterator.Next() {
|
||||
entry, ok := recordableValue(iterator.Value().Interface(), depth+1, seen)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[iterator.Key().String()] = entry
|
||||
}
|
||||
return out, true
|
||||
case reflect.Slice, reflect.Array:
|
||||
if !holdsAny(reflected.Type().Elem()) {
|
||||
return value, true
|
||||
}
|
||||
if depth >= traceValueMaxDepth || !firstVisit(reflected, seen) {
|
||||
return nil, true
|
||||
}
|
||||
out := make([]any, reflected.Len())
|
||||
for index := range out {
|
||||
entry, ok := recordableValue(reflected.Index(index).Interface(), depth+1, seen)
|
||||
if !ok {
|
||||
entry = nil
|
||||
}
|
||||
out[index] = entry
|
||||
}
|
||||
return out, true
|
||||
default:
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
|
||||
// holdsAny reports whether a container's elements can hide a host function or
|
||||
// a cycle. Concretely typed containers ([]string, []byte, map[string]string)
|
||||
// can hold neither, and walking them would rewrite shapes json.Marshal already
|
||||
// handles, such as []byte's base64 form.
|
||||
func holdsAny(elem reflect.Type) bool {
|
||||
return elem.Kind() == reflect.Interface
|
||||
}
|
||||
|
||||
// firstVisit reports whether a container has not been walked yet, so a cyclic
|
||||
// value terminates. Empty containers are never recorded: they cannot close a
|
||||
// cycle, and Go may hand every one of them the same address.
|
||||
func firstVisit(container reflect.Value, seen map[uintptr]bool) bool {
|
||||
if container.Kind() == reflect.Array || container.Len() == 0 {
|
||||
return true
|
||||
}
|
||||
address := container.Pointer()
|
||||
if seen[address] {
|
||||
return false
|
||||
}
|
||||
seen[address] = true
|
||||
return true
|
||||
}
|
||||
|
||||
// finiteOrNull maps NaN and the infinities to JSON null, which is what
|
||||
// JSON.stringify does with them on the web host.
|
||||
func finiteOrNull(value float64) any {
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func jsonToJSValue(runtime *goja.Runtime, raw json.RawMessage) (goja.Value, error) {
|
||||
if len(raw) == 0 {
|
||||
return goja.Undefined(), nil
|
||||
|
||||
@@ -175,6 +175,8 @@ func TestLastAction_WebJSONMatchesTheGojaObject(t *testing.T) {
|
||||
}{
|
||||
{"nil", nil},
|
||||
{"Tap", &Action{Kind: ActionKindTap, On: "id:TxnSubmit", X: 12, Y: 34}},
|
||||
{"TapApplied", &Action{Kind: ActionKindTap, On: "id:TxnSubmit", Applied: true}},
|
||||
{"TapRelaunched", &Action{Kind: ActionKindTap, On: "id:TxnSubmit", Applied: true, Relaunched: true}},
|
||||
{"TapWithoutSelector", &Action{Kind: ActionKindTap, X: 12, Y: 34}},
|
||||
{"DoubleTap", &Action{Kind: ActionKindDoubleTap, On: `desc:say "hi" <b>`}},
|
||||
{"InputText", &Action{Kind: ActionKindInputText, On: "id:field", Text: "50"}},
|
||||
@@ -200,3 +202,137 @@ func TestLastAction_WebJSONMatchesTheGojaObject(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A spec has to be able to tell three things apart: no action ran, an action
|
||||
// ran, and an action was dispatched whose fate the runner cannot vouch for.
|
||||
// The third used to be reported as the first, which is how a property that
|
||||
// reasons "an effect landed with no action to cause it" convicts an app over
|
||||
// an RPC deadline.
|
||||
func TestLastAction_SeparatesNoActionFromAnActionOfUnknownFate(t *testing.T) {
|
||||
verifier := newVerifier(t)
|
||||
mustLoad(t, verifier, `
|
||||
globalThis.fate = __sanderling__.extract(state =>
|
||||
state.lastAction === null ? "no action"
|
||||
: state.lastAction.applied === true ? "applied"
|
||||
: state.lastAction.applied === null ? "unknown"
|
||||
: "unreadable");
|
||||
`)
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
action *Action
|
||||
want string
|
||||
}{
|
||||
{"nothing ran", nil, "no action"},
|
||||
{"dispatch confirmed", &Action{Kind: ActionKindTap, On: "id:TxnSubmit", Applied: true}, "applied"},
|
||||
{"dispatch unconfirmed", &Action{Kind: ActionKindTap, On: "id:TxnSubmit"}, "unknown"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if err := verifier.PushSnapshot(SnapshotInput{
|
||||
Snapshots: Snapshots{},
|
||||
LastAction: testCase.action,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handle := verifier.runtime.GlobalObject().Get("fate").ToObject(verifier.runtime)
|
||||
if got := handle.Get("current").String(); got != testCase.want {
|
||||
t.Errorf("the spec read %q, want %q", got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The runner relaunches the app when it leaves the foreground, which used to be
|
||||
// reported to the spec as "no action ran between these two readings". The
|
||||
// action did run; what a property cannot assume across it is that app state was
|
||||
// continuous, so the relaunch is its own fact on an action that keeps its
|
||||
// confirmed dispatch.
|
||||
func TestLastAction_ReportsARelaunchSeparatelyFromTheDispatch(t *testing.T) {
|
||||
verifier := newVerifier(t)
|
||||
mustLoad(t, verifier, `
|
||||
globalThis.continuity = __sanderling__.extract(state =>
|
||||
state.lastAction === null ? "no action"
|
||||
: state.lastAction.applied !== true ? "unconfirmed"
|
||||
: state.lastAction.relaunched === true ? "applied across a relaunch"
|
||||
: state.lastAction.relaunched === null ? "applied, no relaunch reported"
|
||||
: "unreadable");
|
||||
`)
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
action *Action
|
||||
want string
|
||||
}{
|
||||
{"nothing ran", nil, "no action"},
|
||||
{
|
||||
"confirmed, app stayed",
|
||||
&Action{Kind: ActionKindTap, On: "id:TxnSubmit", Applied: true},
|
||||
"applied, no relaunch reported",
|
||||
},
|
||||
{
|
||||
"confirmed, app relaunched after it",
|
||||
&Action{Kind: ActionKindTap, On: "id:TxnSubmit", Applied: true, Relaunched: true},
|
||||
"applied across a relaunch",
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if err := verifier.PushSnapshot(SnapshotInput{
|
||||
Snapshots: Snapshots{},
|
||||
LastAction: testCase.action,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handle := verifier.runtime.GlobalObject().Get("continuity").ToObject(verifier.runtime)
|
||||
if got := handle.Get("current").String(); got != testCase.want {
|
||||
t.Errorf("the spec read %q, want %q", got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogs_WebJSONMatchesTheGojaObject pins state.logs to ONE shape across the
|
||||
// two hosts, for the same reason lastAction is pinned. On web the page's
|
||||
// reading of every extractor replaces the host's, so state.logs is whatever
|
||||
// EncodeLogs put in the page: a field this side renames or cases differently
|
||||
// leaves the default noLogcatErrors counting nothing on web while it counts on
|
||||
// native, with nothing reporting that it never saw an entry.
|
||||
func TestLogs_WebJSONMatchesTheGojaObject(t *testing.T) {
|
||||
verifier := newVerifier(t)
|
||||
mustLoad(t, verifier, `
|
||||
globalThis.lines = __sanderling__.extract(state => JSON.stringify(state.logs));
|
||||
`)
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
logs []LogEntry
|
||||
}{
|
||||
{"none", nil},
|
||||
{"empty", []LogEntry{}},
|
||||
{
|
||||
"one error",
|
||||
[]LogEntry{{UnixMillis: 1700000000123, Level: "E", Tag: "console", Message: "boom from the page"}},
|
||||
},
|
||||
{
|
||||
"mixed levels",
|
||||
[]LogEntry{
|
||||
{UnixMillis: 1, Level: "E", Tag: "console", Message: `say "hi" <b> & co`},
|
||||
{UnixMillis: 2, Level: "W", Tag: "AndroidRuntime", Message: "a warning"},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if err := verifier.PushSnapshot(SnapshotInput{
|
||||
Snapshots: Snapshots{},
|
||||
Logs: testCase.logs,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handle := verifier.runtime.GlobalObject().Get("lines").ToObject(verifier.runtime)
|
||||
goja := handle.Get("current").String()
|
||||
web := string(EncodeLogs(testCase.logs))
|
||||
if goja != web {
|
||||
t.Errorf("the two hosts disagree on state.logs\n goja: %s\n web: %s", goja, web)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,17 @@ type Action struct {
|
||||
// Direction is the scroll direction for ActionKindScroll: one of "up",
|
||||
// "down", "left", "right". Empty for every other kind.
|
||||
Direction string
|
||||
// Applied is meaningful only on the action a step reports to the spec as
|
||||
// state.lastAction: true when the runner saw the dispatch succeed, false
|
||||
// when the apply call failed and nothing can say whether the action
|
||||
// reached the app. The spec is told which of the two it is.
|
||||
Applied bool
|
||||
// Relaunched, like Applied, is meaningful only on state.lastAction: the
|
||||
// runner brought the app back to the foreground after this action, so the
|
||||
// two readings the spec compares straddle a restart. The action still
|
||||
// happened; what a property cannot assume across it is that app state ran
|
||||
// continuously from one reading to the next.
|
||||
Relaunched bool
|
||||
}
|
||||
|
||||
// LogEntry mirrors a logcat line captured between steps.
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"reflect"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
@@ -383,17 +385,70 @@ func encodeExtractorValue(value goja.Value) ([]byte, error) {
|
||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
recordable, ok := recordableValue(value.Export(), 0, map[uintptr]bool{})
|
||||
if !ok {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
body, err := json.Marshal(recordable)
|
||||
body, err := json.Marshal(recordableValue(value.Export(), 0, map[uintptr]bool{}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// recordableMaxDepth mirrors SANITIZE_MAX_DEPTH in pkg/spec/src/web-runtime.ts.
|
||||
const recordableMaxDepth = 32
|
||||
|
||||
// recordableValue applies the web host's sanitize rule (web-runtime.ts) to an
|
||||
// exported goja value: function members are dropped, a cycle or a branch past
|
||||
// the depth cap becomes null, and a non-finite number becomes null. One rule on
|
||||
// both hosts is what lets the replay UI render a trace without the reader
|
||||
// having to know which host produced it. An ax element carries its find and
|
||||
// findAll host functions, and json.Marshal rejects the whole element over them,
|
||||
// so without this an element-valued extractor reached the trace as null.
|
||||
func recordableValue(value any, depth int, seen map[uintptr]bool) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
address := reflect.ValueOf(typed).Pointer()
|
||||
if depth >= recordableMaxDepth || seen[address] {
|
||||
return nil
|
||||
}
|
||||
seen[address] = true
|
||||
members := make(map[string]any, len(typed))
|
||||
for key, member := range typed {
|
||||
if reflect.ValueOf(member).Kind() == reflect.Func {
|
||||
continue
|
||||
}
|
||||
members[key] = recordableValue(member, depth+1, seen)
|
||||
}
|
||||
return members
|
||||
case []any:
|
||||
if depth >= recordableMaxDepth {
|
||||
return nil
|
||||
}
|
||||
// Every zero-length allocation shares one address, so tracking an empty
|
||||
// array would identify it as every other empty array. It cannot close a
|
||||
// cycle either way.
|
||||
if len(typed) > 0 {
|
||||
address := reflect.ValueOf(typed).Pointer()
|
||||
if seen[address] {
|
||||
return nil
|
||||
}
|
||||
seen[address] = true
|
||||
}
|
||||
members := make([]any, len(typed))
|
||||
for index, member := range typed {
|
||||
members[index] = recordableValue(member, depth+1, seen)
|
||||
}
|
||||
return members
|
||||
case float64:
|
||||
if math.IsNaN(typed) || math.IsInf(typed, 0) {
|
||||
return nil
|
||||
}
|
||||
return typed
|
||||
}
|
||||
if reflect.ValueOf(value).Kind() == reflect.Func {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// ChangedExtractors returns the named extractors whose value changed between
|
||||
// the prior PushSnapshot and the current one. The map is keyed by extractor
|
||||
// name; unnamed extractors (extractor_N fallback) are included so the replay
|
||||
|
||||
@@ -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}}}`);
|
||||
});
|
||||
@@ -170,11 +170,44 @@ const switchATab = actions(() => {
|
||||
return tabs.length === 0 ? [] : [Tap({ on: from(tabs).generate() })];
|
||||
});
|
||||
|
||||
// badgeCountMatchesThePanel needs two readings on ONE step: the badge, which a
|
||||
// tab strip renders only for a step that HAS a violation, and a violations
|
||||
// panel to compare it against, which exists while the properties or violations
|
||||
// tab is selected. Undirected actions put both on the same step 0 times in the
|
||||
// 80 of the first dogfood run: the property was reachable in principle and
|
||||
// judged nothing in practice.
|
||||
//
|
||||
// Both halves have to be aimed at. Aiming at the step alone just moved the
|
||||
// misses to the other side, 0 judged either way. So this selects a step the
|
||||
// list marks as violating, and once standing on one, opens a panel if none is
|
||||
// up. It opens the AFTER panel's, because the before panel's screenshot is what
|
||||
// screenshotShowsTheSelectedStep reads and covering that up trades one
|
||||
// property's evidence for another's.
|
||||
const violatingRows = extract("violatingRows", (s) =>
|
||||
s.ax.findAll({ "data-testid": "step-row" }).filter((row) => dataOf(row, "violations") === "true"),
|
||||
);
|
||||
const afterPropertiesTabs = extract("afterPropertiesTabs", (s) =>
|
||||
s.ax
|
||||
.findAll([{ "data-testid": "state-after" }, { "data-testid": "tab" }])
|
||||
.filter((tab) => dataOf(tab, "tabId") === "properties"),
|
||||
);
|
||||
|
||||
const showAViolatingStepWithItsPanel = actions(() => {
|
||||
const rows = violatingRows.current;
|
||||
if (!rows.some((row) => dataOf(row, "active") === "true")) {
|
||||
return rows.length === 0 ? [] : [Tap({ on: from(rows).generate() })];
|
||||
}
|
||||
if (violationPanelCounts.current.length > 0) return [];
|
||||
const tabs = afterPropertiesTabs.current;
|
||||
return tabs.length === 0 ? [] : [Tap({ on: from(tabs).generate() })];
|
||||
});
|
||||
|
||||
// defaultActions carries the rest: the jump-to-violation button, the theme
|
||||
// toggle, the link back to the run list, and the scrolling.
|
||||
export const actionsRoot = weighted(
|
||||
[30, selectAStep],
|
||||
[20, navigateByKeyboard],
|
||||
[25, switchATab],
|
||||
[20, showAViolatingStepWithItsPanel],
|
||||
[25, defaultActions],
|
||||
);
|
||||
@@ -6,8 +6,15 @@
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Wrapping is what keeps the last tabs reachable. In a narrow column the five
|
||||
tabs are wider than the panel, and the overflow scrolls .detail-panel-body,
|
||||
which carries that tab's own panel out of the column with it. In a 756px
|
||||
viewport (what a headless run gets) Properties and Violations then sit under
|
||||
the neighbouring panel, where neither a person nor the fuzzer can click
|
||||
them. */
|
||||
.tabs-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex: 0 0 auto;
|
||||
|
||||
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user