Files
sanderling/.github/scripts/folio-run.sh
pj b2f9335f5d ci(folio): a missing trace is not a verdict
with no trace the android gate ran its grep against ./trace.jsonl and
reported "never reached AddTransactionScreen, so it never got past
login", which is not what happened. the web and ios branches had the
same misdiagnosis on exit 0.

same class, one line up: the classifier's own failure was swallowed, so
with the evidence reader dead the gate printed a healthy run and exited
0.
2026-08-15 12:47:23 +05:30

218 lines
8.6 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
echo "folio/android: the run never reached AddTransactionScreen, so it never got past login" >&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