mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
* ci: replace the archived buf-setup-action with buf-action
buf-setup-action is archived and runs on node20, which the runners now
warn about. buf-action is its supported replacement and runs on node24.
setup_only keeps it an install, since buf lint is its own step.
* ci: bump bun to 1.3.14
* ci: bump setup-chrome to v2.2.0
* ci: move to node 24 and drop the npm oidc workaround
node 22 is in maintenance and ships npm 10, which is why the publish job
had to install npm@latest over it. node 24 is the active lts and bundles
npm 11.17.0, above the 11.5.1 oidc floor, so the extra step goes.
* ci: pin the protoc plugins instead of installing @latest
these generate the committed stubs, so @latest makes codegen depend on
whatever released most recently. pinned to the versions proto/ records:
protoc-gen-go v1.36.11, protoc-gen-go-grpc v1.6.0.
* ci: run the ios leg on macos-26, pinned to a device and a runtime
macos-26 carries no iPhone 16 Pro at all, and on macos-15 that name spanned
iOS 18.5 through 26.2, so the leg could boot a two-major-old runtime. the
pair is now iPhone 17 Pro on iOS 26.2, resolved to a udid before boot, and
an image that drops it fails naming what it does carry.
iPhone 17 Pro is what examples/folio/justfile already defaulted to.
* ci: keep IOS_DEVICE a device name, not the resolved udid
the boot step exported the udid as IOS_DEVICE, and just ios spends that as
xcodebuild's -destination name=, which matches the display name and
rejected it: 'unable to find a device matching { name:6F69910C-... }'.
nothing downstream needed it. install, launch and terminate all address
booted, and sanderling resolves --ios-device against booted simulators
first, so the simulator this step boots is the one they all get.
300 lines
13 KiB
Bash
Executable File
300 lines
13 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="${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)
|
|
folio_args+=(--android-app-path
|
|
examples/folio/app/androidApp/build/outputs/apk/debug/androidApp-debug.apk)
|
|
;;
|
|
ios)
|
|
# 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
|
|
--ios-device "${IOS_DEVICE:-iPhone 17 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)"
|
|
# 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 ' ')
|
|
|
|
# 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
|
|
# 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, routes = [], [], [], []
|
|
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
|
|
# 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 {}
|
|
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, routes):
|
|
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')
|
|
routes=$(printf '%s\n' "$classified" | sed -n '4p')
|
|
|
|
{
|
|
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
|
|
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
|
|
|
|
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 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" ;;
|
|
esac
|