Files
keyrelay/tests/run_tests.sh
T
ducoterra d629a0ff51 phase: 03_session_takeover
**Phase 03 `session_takeover` — final verification pass. All green.**

All four task files were already in `.agents/phases/complete/03_session_takeover/` with per-task reports, so this was the phase-level close, not new implementation.

**Completion criteria**
1. *2nd controller authenticates + takes the bridge; a 3rd is refused* — met host-side: `test_claim` 102 (whitelist matrix, `acceptsUpgrade` one-claim-at-a-time, both deadline clocks across `millis()` rollover, 64-byte cap through the real codec), `test_relayctl` 308 (`--take-over` = `T`,`X`,`H`; default refusal names the flag and sends no `X`), `test_page` 142 (claim→button→one click→`T` then `X` only). Refusal mechanism stated precisely: a second claim gets `503`/`E3`, a third *socket* gets the slot refusal (`slotrefused`).
2. *Handover releases then adopts, lands disarmed* — pinned: `closeLink("taken over")` before adoption (inverting it fails exactly 1 check, reverted); no `arm`/`setArmed`/`armed = true` in the `X` path (inserting one fails exactly 1 check, reverted); un-authenticated `X` admitted in `claim.h` fails `test_claim`+`test_relayctl` (reverted). Also verified by reading: `serveClaim` never writes `_lastInboundMs`/`_controllerSeen`, so a parked claim cannot hold the board armed past 8 s.
3. *Incumbent page says taken over, disarmed, no auto-reclaim* — `onE12()` + `test_page` (disarms, releases mouse, ends typing run, pill `taken over`, reconnect loop off).
4. *Three source pins fail on inversion* — re-demonstrated in this pass, all reverted (`src/claim.h`, `src/control_server.cpp` byte-identical to pre-inversion).
5. *Gate green + README/AGENTS say takeover cannot arm* — yes.

**Validation** — `bash .agents/validate.sh` → **exit 0**: 12 suites **5026 checks, 0 failures** (normal and `SANITIZE=1`), `bridge`/`console`/`rollback` all SUCCESS, page bytes 59 286, README/AGENTS claims OK, hygiene OK. Extra lint: `ruff --select F,E9` clean, `bash -n` clean, `py_compile` clean. Coverage on new/changed logic: `claim.h` 100 % (11 lines), `status.h` 100 % (29), `proto.h` 94.8 % (229), relayctl take-over lines 14/15 (the miss is a `def` continuation trace never records).

**Defects fixed (docs only — no code defect found)**: bug 6 claimed a 2nd `/ws` upgrade "is still refused"; Measured-behaviour samples lacked `taken=`/`busy`/`tkov`; footprint + TLS-cost rows quoted phase-02 numbers while claiming to be what the gate prints (now 99 744/1 257 933, 67 352/1 178 173, 93 568/1 157 609 → TLS = +6 176 B RAM/+100 324 B flash); page-delivery/handshake rows dated; `AGENTS.md` pure-module list was missing `claim.h`.

**Hardware**: no device run. The board still runs pre-phase-03 firmware, reflash/arming mutates a live device (the target is this machine), and both controllers need the operator's passphrase — guessing feeds the shared 5-fail lockout. The 3 blank rows (third-controller refusal, chord labels, nothing-outlives-the-link) are now recorded `skipped (no device run)` with the exact closing commands in `.agents/HW_VERIFICATION.md`.

**Tree left for the harness**: index untouched (0 staged), no history changes; `todo/03_session_takeover/00_phase.md` moved to `complete/` (matches the phase-02 rename pattern); untracked files the commit must include: `.agents/validate.sh`, `src/claim.h`, `tests/test_claim.cpp`, `.agents/phases/complete/03_session_takeover/`, `.agents/reports/03_session_takeover/`. Next phase: `todo/04_controller_page_responsive/`.
2026-11-09 08:57:35 -05:00

148 lines
5.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# Host tests for keyrelay's pure logic modules.
#
# tests/run_tests.sh build and run every suite
# tests/run_tests.sh clean remove the build directory
#
# The modules under test (src/keystate.h, src/proto.h, src/status.h, src/text.h,
# src/mouse.h, src/output.h, src/ws_codec.h, src/sha1.h, src/tls_pem.h, and the pure
# policy halves of src/tls_session.h and src/conn.h) have no Arduino or
# ESP-IDF
# dependency by design, so the exact code that runs on the board is compiled and
# exercised here before anything is flashed.
#
# A C++ compiler is not always installed on a workstation, so the runner picks one:
# $CXX if you set it, it wins
# g++ on PATH native, fastest
# a local container image podman + the first image that has g++ (see images
# below), with this repo bind-mounted read-write into
# /work -- no system packages installed for a test run
set -uo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(dirname "$here")"
out="$here/build"
if [[ "${1:-}" == "clean" ]]; then
rm -rf "$out"
echo "cleaned $out"
exit 0
fi
# Candidate images, tried in order, that already exist locally. Add your own here.
images=(
"localhost/ai-sandbox:ubuntu-9a08b1d500b0"
"docker.io/library/gcc:14"
)
suites=(test_keystate test_proto test_text test_mouse test_output test_ws_codec
test_tls_pem test_tls_session test_conn test_claim)
build_and_run() {
local cxx="$1"
local prefix=("$cxx")
local flags=(-std=gnu++17 -O1 -g -Wall -Wextra -Wno-unused-function -Isrc -Itests)
mkdir -p "$out"
if [[ -n "${SANITIZE:-}" ]]; then
flags+=(-fsanitize=address,undefined -fno-omit-frame-pointer)
echo "sanitizers: on"
fi
local failed=0 suite
for suite in "${suites[@]}"; do
if ! "${prefix[@]}" "${flags[@]}" "tests/$suite.cpp" -o "$out/$suite" \
2>"$out/$suite.log"; then
echo "BUILD FAILED: $suite"
cat "$out/$suite.log"
failed=1
continue
fi
[[ -s "$out/$suite.log" ]] && cat "$out/$suite.log"
"$out/$suite" || failed=1
done
# The Python side (the CLI client's WebSocket codec, and its key table against
# the one embedded in the controller page) needs only stdlib.
if command -v python3 >/dev/null 2>&1; then
python3 tests/test_relayctl.py || failed=1
else
echo "python3 not found: skipped tests/test_relayctl.py"
fi
return $failed
}
# The controller page is a C++ raw string, so no compiler looks at its JavaScript and
# a typo there fails in somebody's browser instead of in a build. Node runs it -- on
# the host, deliberately, because the container that supplies g++ need not have node.
run_page_suite() {
if command -v node >/dev/null 2>&1; then
node "$here/test_page.js" || return 1
else
echo "node not found: skipped tests/test_page.js"
fi
return 0
}
if [[ "${1:-}" == "--inside" ]]; then
cd "$root" || exit 2
# The container has g++ but not necessarily node; the page suite skips itself.
build_and_run "${CXX:-g++}"
rc=$?
if [[ -z "${KR_QUIET_RESULT:-}" ]]; then
if ((rc)); then echo "RESULT: FAILED"; else echo "RESULT: all suites passed"; fi
fi
exit $rc
fi
if [[ -n "${CXX:-}" ]]; then
echo "compiler: \$CXX = $CXX"
(cd "$root" && build_and_run "$CXX")
rc=$?
(cd "$root" && run_page_suite) || rc=1
if ((rc)); then echo "RESULT: FAILED"; else echo "RESULT: all suites passed"; fi
exit $rc
fi
if command -v g++ >/dev/null 2>&1; then
echo "compiler: $(command -v g++)"
(cd "$root" && build_and_run g++)
rc=$?
(cd "$root" && run_page_suite) || rc=1
if ((rc)); then echo "RESULT: FAILED"; else echo "RESULT: all suites passed"; fi
exit $rc
fi
if command -v podman >/dev/null 2>&1; then
for img in "${images[@]}"; do
if ! timeout 60 podman image exists "$img" 2>/dev/null; then
continue
fi
if ! timeout 60 podman run --rm --entrypoint sh "$img" -c 'command -v g++' \
>/dev/null 2>&1; then
continue
fi
echo "compiler: g++ inside $img (repo mounted at /work)"
# :z relabels for SELinux; the build output lands in tests/build as usual.
# KR_QUIET_RESULT: the host prints the one summary, after its own page suite.
podman run --rm --entrypoint bash -v "$root:/work:z" \
-e "SANITIZE=${SANITIZE:-}" -e KR_QUIET_RESULT=1 \
"$img" /work/tests/run_tests.sh --inside
rc=$?
(cd "$root" && run_page_suite) || rc=1
if ((rc)); then echo "RESULT: FAILED"; exit 1; fi
echo "RESULT: all suites passed"
exit 0
done
fi
cat >&2 <<EOF
No C++ compiler found.
install one e.g. sudo dnf install gcc-c++ (then re-run)
or set \$CXX e.g. CXX=/path/to/g++ tests/run_tests.sh
or add an image add a local podman image with g++ to images[] above
EOF
exit 2