mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
The gate proves only that AddTransactionScreen is absent from the trace. Claiming the run never got past login was an inference it cannot make: the run that produced it had logged in and was stuck on the new account screen. Name the routes the trace does record instead.
223 lines
8.9 KiB
Bash
Executable File
223 lines
8.9 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Runs examples/folio/sanderling/spec.ts against one platform and checks the
|
|
# exit code the job expects. Kept out of the workflow YAML so it can be run by
|
|
# hand, which is how it was calibrated:
|
|
#
|
|
# SEED=9 MAX_STEPS=200 .github/scripts/folio-run.sh android
|
|
#
|
|
# web and ios expect a conviction: folio's double-submit bug is still there, and
|
|
# a run that no longer finds it is a regression in the fuzzer, not a pass. The
|
|
# exit code alone does not say that much, so this script reads the trace: see
|
|
# GATED_PROPERTIES below. android is a health gate instead, because it convicts
|
|
# in four runs out of five rather than five; see docs/development/ci.md.
|
|
set -uo pipefail
|
|
|
|
platform="${1:?usage: folio-run.sh android|ios|web}"
|
|
seed="${SEED:-1}"
|
|
max_steps="${MAX_STEPS:-240}"
|
|
duration="${DURATION:-20m}"
|
|
sanderling="${SANDERLING:-./bin/sanderling}"
|
|
output="runs/folio-$platform"
|
|
spec="examples/folio/sanderling/spec.ts"
|
|
summary="${GITHUB_STEP_SUMMARY:-/dev/null}"
|
|
|
|
folio_args=(--bundle-id app.folio)
|
|
case "$platform" in
|
|
android)
|
|
folio_args+=(--android-app-path
|
|
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.
|
|
folio_args+=(--platform ios
|
|
--clear-data=false
|
|
--ios-device "${IOS_DEVICE:-iPhone 16 Pro}")
|
|
;;
|
|
web)
|
|
dist="examples/folio/app/webApp/build/dist/wasmJs/developmentExecutable"
|
|
port="${PORT:-8791}"
|
|
# The stock static servers do not set COOP/COEP, and without cross-origin
|
|
# isolation the app's sqlite worker never starts, so folio loads to a blank
|
|
# canvas and every step observes an empty accessibility tree.
|
|
python3 - "$dist" "$port" <<'PY' &
|
|
import functools, http.server, sys
|
|
|
|
class Isolated(http.server.SimpleHTTPRequestHandler):
|
|
def end_headers(self):
|
|
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
|
|
self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
|
|
self.send_header("Cross-Origin-Resource-Policy", "cross-origin")
|
|
super().end_headers()
|
|
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
directory, port = sys.argv[1], int(sys.argv[2])
|
|
handler = functools.partial(Isolated, directory=directory)
|
|
http.server.HTTPServer(("127.0.0.1", port), handler).serve_forever()
|
|
PY
|
|
server_pid=$!
|
|
trap 'kill "$server_pid" 2>/dev/null' EXIT
|
|
ready=""
|
|
for _ in $(seq 1 30); do
|
|
curl -sf "http://127.0.0.1:$port/index.html" >/dev/null && { ready=1; break; }
|
|
sleep 1
|
|
done
|
|
if [ -z "$ready" ]; then
|
|
echo "folio/web: the app server never served index.html on 127.0.0.1:$port from $dist" >&2
|
|
exit 1
|
|
fi
|
|
folio_args=(--platform web --bundle-id "http://127.0.0.1:$port/index.html")
|
|
;;
|
|
*)
|
|
echo "unknown platform: $platform" >&2
|
|
exit 64
|
|
;;
|
|
esac
|
|
|
|
"$sanderling" test \
|
|
--spec "$spec" \
|
|
"${folio_args[@]}" \
|
|
--duration "$duration" \
|
|
--max-steps "$max_steps" \
|
|
--seed "$seed" \
|
|
--exit-on-violation \
|
|
--output "$output"
|
|
code=$?
|
|
|
|
run_dir="$(ls -d "$output"/*/ 2>/dev/null | tail -1)"
|
|
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
|
|
# the identical path. So does a violation of newAccountBalanceIsZero, a real but
|
|
# unrelated property in the same spec, which one android seed produces. Reading
|
|
# the exit code alone leaves the gate green with detection dead.
|
|
#
|
|
# So sort the trace's violations three ways, by name and by is_error:
|
|
# 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
|
|
classified=$(TRACE="$trace" GATED="$GATED_PROPERTIES" python3 - <<'PY'
|
|
import json, os
|
|
|
|
gated = set(os.environ["GATED"].split(","))
|
|
convictions, thrown, other = [], [], []
|
|
try:
|
|
lines = open(os.environ["TRACE"], encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
lines = []
|
|
for line in lines:
|
|
try:
|
|
step = json.loads(line)
|
|
except ValueError:
|
|
continue
|
|
witnesses = step.get("witnesses") or {}
|
|
for name in step.get("violations") or []:
|
|
witness = witnesses.get(name) or {}
|
|
if witness.get("is_error"):
|
|
reason = " ".join(str(witness.get("reason") or "").split())
|
|
thrown.append("%s: %s" % (name, reason) if reason else name)
|
|
elif name in gated:
|
|
convictions.append(name)
|
|
else:
|
|
other.append(name)
|
|
for names in (convictions, thrown, other):
|
|
print(", ".join(names))
|
|
PY
|
|
) || {
|
|
echo "folio/$platform: could not classify $trace, so exit $code cannot be read as a verdict" >&2
|
|
exit 1
|
|
}
|
|
convicted=$(printf '%s\n' "$classified" | sed -n '1p')
|
|
thrown=$(printf '%s\n' "$classified" | sed -n '2p')
|
|
other=$(printf '%s\n' "$classified" | sed -n '3p')
|
|
|
|
{
|
|
echo "### folio on $platform"
|
|
echo
|
|
echo "- seed \`$seed\`, budget $max_steps steps / $duration"
|
|
echo "- $steps steps recorded, exit $code"
|
|
[ -n "$convicted" ] && echo "- convicted on: $convicted"
|
|
[ -n "$other" ] && echo "- also violated: $other"
|
|
[ -n "$thrown" ] && echo "- **a predicate threw**: $thrown"
|
|
} >> "$summary"
|
|
|
|
# A thrown predicate fails every leg, android included. It is not evidence about
|
|
# folio: the property that threw is violated from that step on whatever the app
|
|
# does, and --exit-on-violation ends the run there, so nothing past it was
|
|
# checked. Reporting that as a conviction, or as android's bonus, is the hole
|
|
# this check exists to close.
|
|
if [ -n "$thrown" ]; then
|
|
echo "folio/$platform: a predicate threw, so exit $code is not a verdict about folio: $thrown" >&2
|
|
echo "folio/$platform: fix the spec (examples/folio/sanderling/) and run again" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Only 0 and 2, the two codes that claim the run completed: any other code is a
|
|
# harness failure, which the branches below already report as one. Without this,
|
|
# a missing trace is judged as an empty trace and reported as a verdict on folio.
|
|
if [ ! -f "$trace" ] && { [ "$code" = 0 ] || [ "$code" = 2 ]; }; then
|
|
echo "folio/$platform: the run exited $code but wrote no trace under $output/, so there is nothing to judge" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$platform" = "android" ]; then
|
|
# A health gate, not a conviction gate: android convicts in four runs out of
|
|
# five, and a gate that fails the fifth would report a regression it had not
|
|
# found. Reaching the transaction screen is what this leg proves: the app
|
|
# built, installed, launched and drove.
|
|
case "$code" in
|
|
0) ;;
|
|
2)
|
|
if [ -n "$convicted" ]; then
|
|
echo "folio/android: found the submit bug in $steps steps (a bonus, not required)"
|
|
else
|
|
echo "folio/android: violated $other, which is not the double-submit; judging health only"
|
|
fi
|
|
;;
|
|
*) echo "folio/android: the harness failed with exit $code" >&2; exit "$code" ;;
|
|
esac
|
|
if ! grep -q '"AddTransactionScreen"' "$trace"; then
|
|
# 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.
|
|
reached=$(grep -oE '"[A-Za-z0-9]+Screen"' "$trace" | tr -d '"' |
|
|
awk '!seen[$0]++' | paste -sd, -) || reached=""
|
|
echo "folio/android: the run never reached AddTransactionScreen over $steps steps" >&2
|
|
echo "folio/android: routes the trace does record: ${reached:-none}" >&2
|
|
exit 1
|
|
fi
|
|
echo "folio/android: healthy run over $steps steps, reached the transaction screen"
|
|
exit 0
|
|
fi
|
|
|
|
case "$code" in
|
|
2)
|
|
if [ -z "$convicted" ]; then
|
|
echo "folio/$platform: exit 2, but the violation was ${other:-nothing this trace records}, not the double-submit this leg gates on" >&2
|
|
exit 1
|
|
fi
|
|
echo "folio/$platform: found the submit bug in $steps steps ($convicted)"
|
|
exit 0
|
|
;;
|
|
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
|
|
exit 1
|
|
;;
|
|
*) echo "folio/$platform: the harness failed with exit $code" >&2; exit "$code" ;;
|
|
esac
|