1. download.add.json had startMode "now" against a real, large (~6 GB)
Ubuntu ISO with saveDir hardcoded to /home/sami/Downloads/Programs.
Against a real veloxd (tests/conformance/run.sh) that's a real
download into the real user's real home, every single run — it had
already happened twice. startMode -> "later" (exercises the add path,
hands nothing to the engine) and saveDir is dropped entirely (resolves
to saveTo.defaultDir instead, checked against allowedRoots the same
way). Documented the rule this fixture was breaking in
contracts/fixtures/README.md so it doesn't happen a third time.
Auditing the rest for the same shape (now real: capture.offer, D7)
found a second, subtler instance: capture.offer.take.json's "take"
admits a real, immediately-started task the same way download.add
does, and the "Programs" category's saveDir is a migration-seeded
builtin (~/Downloads/Programs) that no isolated test setup can
redirect -- so even after pointing the URL at example.org (RFC 2606),
a real ~6 GB sparse .veloxpart still landed in the real home on the
declared Content-Length alone. Shrunk to a plausible-but-small 5 MiB.
Also scoped to "transport": "uds" -- a real "take" persists an active
task, so replaying the same fixture again on the second live transport
against the same shared daemon was hitting capture.offer's own
dedupe-by-URL and failing on a missing taskId, not a bug.
download.add's other real-URL siblings (errors/*.invalid-path,
*.invalid-params, *.disk-full) all fail before admission or are
requires-gated; left alone.
2. ADR 0018: DAEMON can set a nullable field through download.update /
settings.set but never clear it back to null, because the generated
C++ parser collapses "absent" and "explicit null" to the same
std::nullopt for every optional field (contracts/codegen/gen_cpp.py,
on purpose, and correct for create-style params -- just wrong for
patch-style ones, which is the only place the schema documents
"explicit null clears"). Decision: an opt-in x-clearable schema
annotation makes just those fields std::optional<std::optional<T>> in
C++ (TS already round-trips this natively); not a blanket rule
(would retype response fields like TaskSummary.effectiveUrl that have
no clear-vs-absent distinction to make), not an explicit clear-list
field (would redesign a wire contract DAEMON already built against
just to route around a generator gap). Recorded, not implemented here
-- that's its own PROTO PR (schema annotations + gen_cpp.py + gen_ts.py
+ regeneration + a minor VERSION bump per ADR 0015), not bundled into
a fixture-safety pass. Left a pointer to the ADR at the generator
comment it concerns.
3. Re-verified every xfail entry against current deferrals.md rather
than trust the reasons already on file: D7/D8 (capture.offer/
getRules), D3d/e/f/g/h/i (rules, queue.reorder, schedule, limiter,
download.update/refreshUrl) and D9 (settings) have all closed since
the list was last pruned, so most of it was stale. Removed everything
that now cleanly passes; kept and re-reasoned everything that doesn't:
- errors/download.provideAuth.not-found.json stays, as asked: real
bug, on_download_provideAuth never checks the task exists.
- category.list.json (mimeTypes -- documented D3a gap), schedule.set.json
(nextRunAt -- documented D3f gap): unchanged in substance, reason
text was already accurate.
- download.probe/get/list/update.json, session.hello.json,
queue.start/reorder.json, category.remove.json: not bugs -- each
golden depicts a richer lifecycle/config state (a probed download,
real queue or category membership, media/grabber capabilities) than
this harness's fresh, never-started bound tasks and empty isolated
DB can produce.
- limiter.get.json: real fixture bug, not a daemon one -- applyToRunning
is a write-only instruction on limiter.set, on_limiter_get never
returns it; the golden shouldn't have had it either. Fixed the
fixture and tools/mockd's own limiter.get, which had the same field
hardcoded into its in-memory state independent of the fixture file.
- grabber.*/media.*: still genuinely stub (M4 territory).
Only remaining unexpected-pass surfaced while re-verifying
(errors/capture.offer.ignore.json, always "take" instead of "ignore")
traced to capture.minSizeBytes defaulting to 0 on a fresh daemon,
making its below-minimum-size scenario unreachable -- not a bug, so
raised the setting in run.sh's isolated seeding instead of xfailing it.
ctest -L conformance: green, 100% (2/2), ~87s.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SFeUKLbdHizrJjLBeK7ffz
224 lines
11 KiB
Bash
Executable File
224 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# The conformance suite. This is the command CI runs on every lane's PR.
|
|
#
|
|
# ./tests/conformance/run.sh static + C++ + TS against mockd, then veloxd
|
|
# ./tests/conformance/run.sh --uds PATH --ws-port N against an already-running daemon
|
|
#
|
|
# Four runners, one set of fixtures:
|
|
# 1. check_contract.py schemas, fixtures and committed generated code agree
|
|
# 2. cpp/ the generated C++ parses, serialises and dispatches every fixture
|
|
# 3. ts/replay.ts against mockd — mockd always answers every fixture correctly, so
|
|
# this is the TS client and the fixtures agreeing with each other
|
|
# 3b. ts/replay.ts against a real, isolated veloxd it builds and starts — the one
|
|
# runner that can catch veloxd disagreeing with its own contract.
|
|
# Fixtures that hit a still-stubbed handler (daemon/docs/deferrals.md
|
|
# D1-D4b) are excused via veloxd-xfail.json; everything else must
|
|
# pass for real.
|
|
#
|
|
# Plus one scenario that cannot be shown against a healthy server: with the daemon
|
|
# answering slower than capture.offer's 750 ms deadline, the client must give up and let
|
|
# Firefox take the download. That is the fail-open guarantee, and it is checked here.
|
|
|
|
set -euo pipefail
|
|
|
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
HERE="$REPO/tests/conformance"
|
|
WORK="$(mktemp -d)"
|
|
EXTERNAL_UDS=""
|
|
EXTERNAL_WS=""
|
|
MOCKD_PID=""
|
|
SLOW_PID=""
|
|
VELOXD_PID=""
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--uds) EXTERNAL_UDS="$2"; shift 2 ;;
|
|
--ws-port) EXTERNAL_WS="$2"; shift 2 ;;
|
|
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
|
*) echo "run.sh: unknown option $1" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
# Kill the server and anything it spawned. `pkill -P "$pid"` only reaps direct children —
|
|
# tsx's actual listener is often a grandchild, which that missed, leaving it holding the
|
|
# port and breaking the next run (a leaked mockd once did exactly this). Every server
|
|
# below is launched via `setsid`, which makes it the leader of its own new session/process
|
|
# group (pgid == its own pid), so `kill -TERM -"$pid"` (negative: a process-group kill)
|
|
# reaches it and everything it spawned in one shot, however deep.
|
|
stop() {
|
|
local pid="$1"
|
|
[ -n "$pid" ] || return 0
|
|
kill -TERM -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true
|
|
wait "$pid" 2>/dev/null || true
|
|
}
|
|
|
|
# A free loopback TCP port, kernel-assigned (bind :0) rather than a fixed number — a
|
|
# hardcoded port means one leaked process from a previous run makes every future run fail
|
|
# EADDRINUSE instead of just picking a different port.
|
|
free_port() {
|
|
python3 -c "import socket; s=socket.socket(); s.bind(('127.0.0.1',0)); print(s.getsockname()[1]); s.close()"
|
|
}
|
|
|
|
cleanup() {
|
|
stop "$MOCKD_PID"
|
|
stop "$SLOW_PID"
|
|
stop "$VELOXD_PID"
|
|
rm -rf "$WORK"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
step() { printf '\n=== %s ===\n' "$1"; }
|
|
|
|
# ---------------------------------------------------------------- 1. static
|
|
step "static conformance (schemas, fixtures, generated code)"
|
|
# jsonschema/referencing aren't part of tools/bootstrap.sh's apt list (that's PKG's
|
|
# script; these are this suite's own Python deps), so this suite installs them itself
|
|
# rather than assuming a CI image happens to have them. Cheap and idempotent when
|
|
# they're already present, which is every local dev run after the first.
|
|
python3 -c "import jsonschema, referencing" 2>/dev/null \
|
|
|| python3 -m pip install --quiet --disable-pip-version-check --user jsonschema referencing
|
|
python3 "$HERE/check_contract.py"
|
|
|
|
# ------------------------------------------------------------------- 2. C++
|
|
step "generated C++ (parse, serialise, dispatch)"
|
|
CXX="${CXX:-g++}"
|
|
"$CXX" -std=c++23 -Wall -Wextra -Wpedantic -Werror \
|
|
-I"$REPO/core/generated" -I"$HERE/cpp" \
|
|
"$HERE/cpp/conformance_main.cpp" "$REPO/core/generated/velox_proto.cpp" \
|
|
-o "$WORK/conformance_cpp"
|
|
"$WORK/conformance_cpp" "$REPO"
|
|
|
|
# -------------------------------------------------------------------- 3. TS
|
|
step "generated TypeScript against a live server"
|
|
if [ -z "$EXTERNAL_UDS" ] && [ -z "$EXTERNAL_WS" ]; then
|
|
( cd "$REPO/tools/mockd" && npm install --silent --no-audit --no-fund )
|
|
UDS="$WORK/velox.sock"
|
|
WS_PORT="$(free_port)"
|
|
( cd "$REPO/tools/mockd" && exec setsid ./node_modules/.bin/tsx src/index.ts \
|
|
--uds "$UDS" --ws-port "$WS_PORT" --allowed-root "$WORK" ) >"$WORK/mockd.log" 2>&1 &
|
|
MOCKD_PID=$!
|
|
# Wait for the socket rather than sleeping a guessed amount.
|
|
for _ in $(seq 1 50); do
|
|
[ -S "$UDS" ] && node -e "require('net').connect('$UDS').on('connect',function(){this.end();process.exit(0)}).on('error',()=>process.exit(1))" 2>/dev/null && break
|
|
sleep 0.2
|
|
done
|
|
node -e "require('net').connect('$UDS').on('connect',function(){this.end();process.exit(0)}).on('error',()=>process.exit(1))" 2>/dev/null \
|
|
|| { echo "mockd did not start:"; cat "$WORK/mockd.log"; exit 1; }
|
|
else
|
|
UDS="$EXTERNAL_UDS"
|
|
WS_PORT="$EXTERNAL_WS"
|
|
fi
|
|
|
|
( cd "$HERE/ts" && npm install --silent --no-audit --no-fund )
|
|
|
|
TS_ARGS=()
|
|
[ -n "$UDS" ] && TS_ARGS+=(--uds "$UDS")
|
|
[ -n "$WS_PORT" ] && TS_ARGS+=(--ws-port "$WS_PORT")
|
|
( cd "$HERE/ts" && ./node_modules/.bin/tsx replay.ts "${TS_ARGS[@]}" )
|
|
|
|
# ------------------------------------------------------------------ 3b. veloxd
|
|
# The same fixtures against a real, isolated veloxd. mockd (above) always answers every
|
|
# fixture correctly by construction, so it can only prove the TS client and the fixtures
|
|
# agree with each other — it cannot catch veloxd disagreeing with its own contract. This
|
|
# is the runner that closed that gap: it is what would have caught veloxd's download.get
|
|
# returning `segments: 0`, which TaskSummary forbids (minimum 1, required), before it
|
|
# shipped rather than after.
|
|
step "generated TypeScript against a real, isolated veloxd"
|
|
if [ -z "$EXTERNAL_UDS" ] && [ -z "$EXTERNAL_WS" ]; then
|
|
if [ ! -f "$REPO/daemon/CMakeLists.txt" ]; then
|
|
echo "skipped: daemon/CMakeLists.txt not present (lane DAEMON has not landed yet)"
|
|
else
|
|
VBUILD="$REPO/build/dev"
|
|
# cmake --preset dev is idempotent to re-run against an existing build dir; a CI leg
|
|
# that already configured (the `conformance` job does, before ctest) just reuses it.
|
|
if [ ! -f "$VBUILD/CMakeCache.txt" ]; then
|
|
( cd "$REPO" && cmake --preset dev ) >"$WORK/veloxd-configure.log" 2>&1 \
|
|
|| { echo "veloxd: cmake configure failed:"; cat "$WORK/veloxd-configure.log"; exit 1; }
|
|
fi
|
|
cmake --build "$VBUILD" --target veloxd >"$WORK/veloxd-build.log" 2>&1 \
|
|
|| { echo "veloxd: build failed:"; cat "$WORK/veloxd-build.log"; exit 1; }
|
|
VELOXD_BIN="$VBUILD/bin/veloxd"
|
|
|
|
# Isolated: its own runtime dir (socket, ws.port, single-instance lock), data dir
|
|
# (velox.db) and config dir, none of them the real user's. veloxd's single-instance
|
|
# lock is a UID-scoped abstract socket, not namespaced by XDG_RUNTIME_DIR, so this
|
|
# still collides with a veloxd already running for this user outside the sandbox —
|
|
# that shows up below as "another instance is already running" and fails loudly
|
|
# rather than silently testing the wrong daemon.
|
|
VXDG="$WORK/veloxd-xdg"
|
|
mkdir -p "$VXDG/runtime" "$VXDG/data" "$VXDG/config" "$VXDG/downloads"
|
|
|
|
# VELOX_PAIR_AUTO=1: the pairing approver is the D1 dev stub (EnvAutoApprover,
|
|
# daemon/src/rpc/pairing.cpp) and denies every pairing without it — without this,
|
|
# session.pair never issues a token and the WS half of this step can't even connect.
|
|
XDG_RUNTIME_DIR="$VXDG/runtime" XDG_DATA_HOME="$VXDG/data" XDG_CONFIG_HOME="$VXDG/config" \
|
|
VELOX_PAIR_AUTO=1 \
|
|
setsid "$VELOXD_BIN" >"$WORK/veloxd.log" 2>&1 &
|
|
VELOXD_PID=$!
|
|
VUDS="$VXDG/runtime/velox/velox.sock"
|
|
for _ in $(seq 1 50); do [ -S "$VUDS" ] && break; sleep 0.2; done
|
|
[ -S "$VUDS" ] || { echo "veloxd did not start:"; cat "$WORK/veloxd.log"; exit 1; }
|
|
|
|
# saveTo.allowedRoots defaults to ["~/Downloads"]; download.add's own isolated
|
|
# downloads dir needs to be an allowed root too, or every download.add fixture fails
|
|
# -32011 before the point of this runner is even reached. $HOME/Downloads stays in
|
|
# the list alongside it: a few fixtures still set an explicit saveDir there
|
|
# (category.upsert.json, download.update.json) rather than take the default.
|
|
# capture.minSizeBytes defaults to 0 (nothing is ever "too small"), which makes
|
|
# errors/capture.offer.ignore.json's below-minimum-size case impossible to reach
|
|
# against a fresh daemon; raised here so that fixture's scenario is actually
|
|
# reachable. Written straight into the isolated velox.db, before veloxd has any RPC
|
|
# session to write it through: settings.set is real now (D9), but this has to be in
|
|
# place before the very first fixture runs, and setup happens before any connection
|
|
# exists.
|
|
python3 - "$VXDG/data/velox/velox.db" "$VXDG/downloads" "$HOME/Downloads" <<'PY'
|
|
import json, sqlite3, sys
|
|
db_path, isolated_downloads, home_downloads = sys.argv[1:4]
|
|
db = sqlite3.connect(db_path)
|
|
db.execute(
|
|
"INSERT INTO settings(key, value) VALUES(?, ?) "
|
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
("saveTo.allowedRoots", json.dumps([isolated_downloads, home_downloads])),
|
|
)
|
|
db.execute(
|
|
"INSERT INTO settings(key, value) VALUES(?, ?) "
|
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
("capture.minSizeBytes", json.dumps(1000000)),
|
|
)
|
|
db.execute(
|
|
"INSERT INTO settings(key, value) VALUES(?, ?) "
|
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
("saveTo.defaultDir", json.dumps(isolated_downloads)),
|
|
)
|
|
db.commit()
|
|
PY
|
|
|
|
VELOXD_TS_ARGS=(--uds "$VUDS")
|
|
if [ -f "$VXDG/runtime/velox/ws.port" ]; then
|
|
VELOXD_TS_ARGS+=(--ws-port "$(cat "$VXDG/runtime/velox/ws.port")")
|
|
fi
|
|
( cd "$HERE/ts" && ./node_modules/.bin/tsx replay.ts "${VELOXD_TS_ARGS[@]}" \
|
|
--xfail "$HERE/veloxd-xfail.json" )
|
|
fi
|
|
else
|
|
echo "skipped: --uds/--ws-port already points at a live daemon"
|
|
fi
|
|
|
|
# ------------------------------------------------- 4. capture fails open
|
|
step "capture.offer fails open when the daemon is too slow"
|
|
if [ -z "$EXTERNAL_UDS" ]; then
|
|
SLOW_UDS="$WORK/slow.sock"
|
|
( cd "$REPO/tools/mockd" && exec setsid ./node_modules/.bin/tsx src/index.ts \
|
|
--uds "$SLOW_UDS" --no-ws --slow 2000 ) >"$WORK/slow.log" 2>&1 &
|
|
SLOW_PID=$!
|
|
for _ in $(seq 1 50); do [ -S "$SLOW_UDS" ] && break; sleep 0.2; done
|
|
[ -S "$SLOW_UDS" ] || { echo "slow mockd did not start:"; cat "$WORK/slow.log"; exit 1; }
|
|
( cd "$HERE/ts" && ./node_modules/.bin/tsx replay.ts --uds "$SLOW_UDS" \
|
|
--only capture.offer.timeout --include-requires )
|
|
else
|
|
echo "skipped: needs a deliberately slow server, which run.sh only arranges for mockd"
|
|
fi
|
|
|
|
printf '\n=== conformance: all runners passed ===\n'
|