diff --git a/docs/TESTING.md b/docs/TESTING.md index 8d74bb5..e71bb46 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -74,6 +74,11 @@ scan, a missing-route response, and desktop/mobile portal rendering with no browser page errors. Screenshots, traces on failure, JSON results, and the HTML report are saved under the printed XDG state-directory artifact path. +On ESP8266, an AP+STA scan can briefly move the radio off the AP channel. The +client may reconnect during that interval; the contract deliberately retries +that transport interruption and still requires a reachable portal with a +complete, valid scan result. + For interactive diagnosis, leave the temporary client connection up and remove only that managed connection when finished: @@ -88,6 +93,10 @@ the fixture to a real LAN. Copy the ignored template below, add local credentials, and pass it explicitly; it is mounted read-only into the test container and is never logged by the runner. +A retained session is deliberately never overwritten. If a previous `up` or an +interrupted `run` left one behind, run `./tools/portal-hardware down` first; +that removes only the named temporary connection recorded by the tool. + ```bash cp test/portal-station.env.example test/portal-station.env ./tools/portal-hardware run ... --station-env test/portal-station.env diff --git a/test/portal-harness/README.md b/test/portal-harness/README.md index 94c0eef..889c4f0 100644 --- a/test/portal-harness/README.md +++ b/test/portal-harness/README.md @@ -5,13 +5,12 @@ This test fixture starts only the WiFiManager captive portal. It intentionally h Use it through the repository runner so a secondary Wi-Fi adapter is explicitly selected and protected from becoming the host default route: ```bash -./tools/test-portal-browser.sh \ +./tools/portal-hardware run \ --platform esp8266 \ --port /dev/serial/by-id/... \ - --wifi-interface wlx... \ - --output /tmp/wifimanager-browser + --client-interface wlx... ``` -The ESP8266 portal SSID is `WM Browser ESP8266`; the ESP32 SSID is `WM Browser ESP32`. Both use `default1` exclusively for local development tests. +The ESP8266 portal SSID is `WM Contract ESP8266`; the ESP32 SSID is `WM Contract ESP32`. Both use `default1` exclusively for local development tests. The runner cleans up only the temporary connection it creates on the named secondary interface. It refuses to run if that interface is the system default route. diff --git a/tests/portal-contract/tests/ap-contract.spec.js b/tests/portal-contract/tests/ap-contract.spec.js index 45c48d6..aaeee3a 100644 --- a/tests/portal-contract/tests/ap-contract.spec.js +++ b/tests/portal-contract/tests/ap-contract.spec.js @@ -7,10 +7,18 @@ async function json(response) { async function waitForScan(request) { let result; await expect.poll(async () => { - const response = await request.get('/api/wifi/scan-status'); - expect(response.ok()).toBeTruthy(); - result = await json(response); - return result.scanning; + try { + const response = await request.get('/api/wifi/scan-status'); + if (!response.ok()) return true; + result = await json(response); + return result.scanning; + } catch { + // ESP8266 AP+STA scans briefly leave the AP channel. A client can lose + // its association while the radio scans, then reconnect before the + // asynchronous scan completes. Keep polling; the assertions below still + // require a reachable portal with a complete, valid result. + return true; + } }, { timeout: 45_000, intervals: [500, 800, 1_000] }).toBe(false); return result; } diff --git a/tools/lib/portal-hardware-session.sh b/tools/lib/portal-hardware-session.sh index 95a9cd0..2d04b88 100755 --- a/tools/lib/portal-hardware-session.sh +++ b/tools/lib/portal-hardware-session.sh @@ -122,27 +122,56 @@ wm_state_file() { printf '%s/session.env\n' "$(wm_portal_state_root)" } +wm_require_no_active_session() { + local file + file="$(wm_state_file)" + [[ ! -e "$file" ]] || { + echo "An existing WiFiManager portal session is recorded; run ./tools/portal-hardware down first." >&2 + return 1 + } +} + wm_write_state() { local interface="$1" platform="$2" uuid="$3" name="$4" root file root="$(wm_portal_state_root)" file="$(wm_state_file)" install -d -m 700 "$root" - umask 077 - printf 'WM_PORTAL_INTERFACE=%q\nWM_PORTAL_PLATFORM=%q\nWM_PORTAL_CONNECTION_UUID=%q\nWM_PORTAL_CONNECTION_NAME=%q\n' \ - "$interface" "$platform" "$uuid" "$name" >"$file" + ( + umask 077 + printf 'WM_PORTAL_INTERFACE=%s\nWM_PORTAL_PLATFORM=%s\nWM_PORTAL_CONNECTION_UUID=%s\nWM_PORTAL_CONNECTION_NAME=%s\n' \ + "$interface" "$platform" "$uuid" "$name" >"$file" + ) chmod 600 "$file" } wm_load_state() { - local file + local file key value file="$(wm_state_file)" [[ -f "$file" ]] || { echo "No active WiFiManager portal session was found." >&2 return 1 } - # The state file is created above using shell-escaped values and mode 0600. - # shellcheck disable=SC1090 - source "$file" + WM_PORTAL_INTERFACE="" + WM_PORTAL_PLATFORM="" + WM_PORTAL_CONNECTION_UUID="" + WM_PORTAL_CONNECTION_NAME="" + while IFS='=' read -r key value; do + case "$key" in + WM_PORTAL_INTERFACE|WM_PORTAL_PLATFORM|WM_PORTAL_CONNECTION_UUID|WM_PORTAL_CONNECTION_NAME) + printf -v "$key" '%s' "$value" + ;; + '') ;; + *) + echo "Invalid WiFiManager portal session state." >&2 + return 1 + ;; + esac + done <"$file" + [[ -n "$WM_PORTAL_INTERFACE" && -n "$WM_PORTAL_PLATFORM" && -n "$WM_PORTAL_CONNECTION_UUID" && -n "$WM_PORTAL_CONNECTION_NAME" ]] || { + echo "Incomplete WiFiManager portal session state." >&2 + return 1 + } + export WM_PORTAL_INTERFACE WM_PORTAL_PLATFORM WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME } wm_clear_state() { diff --git a/tools/portal-hardware b/tools/portal-hardware index 6dce8a7..c78f716 100755 --- a/tools/portal-hardware +++ b/tools/portal-hardware @@ -13,7 +13,7 @@ Usage: --client-interface IFACE [--take-over-client-adapter] [--output DIRECTORY] ./tools/portal-hardware run --platform esp8266|esp32 --port /dev/serial/by-id/... \ --client-interface IFACE [--take-over-client-adapter] [--keep] \ - [--browser auto|require|skip] [--station-env PATH] [--output DIRECTORY] + [--browser auto|skip] [--station-env PATH] [--output DIRECTORY] ./tools/portal-hardware down Only the named client interface may be disconnected or reconfigured. The tool @@ -72,7 +72,7 @@ validate_run_arguments() { echo "Serial port not found: $port" >&2 exit 1 } - [[ "$browser" == "auto" || "$browser" == "require" || "$browser" == "skip" ]] || usage + [[ "$browser" == "auto" || "$browser" == "skip" ]] || usage [[ -z "$station_env" || -r "$station_env" ]] || { echo "Station environment file is not readable: $station_env" >&2 exit 1 @@ -84,6 +84,7 @@ start_portal_session() { validate_run_arguments require_common wm_acquire_hardware_lock + wm_require_no_active_session wm_require_client_adapter "$client_interface" "$takeover" prepare_output_dir ssid="$(wm_portal_ssid "$platform")" @@ -143,6 +144,9 @@ case "$command_name" in export PORTAL_STATION_ENV_HOST="$(cd "$(dirname "$station_env")" && pwd)/$(basename "$station_env")" compose_files+=(-f "$root/tests/portal-contract/compose.station.yaml") fi + # The contract source is copied into the image; rebuild with Docker cache so + # this invocation always tests the checked-out files, not a stale image. + docker compose "${compose_files[@]}" build portal-contract docker compose "${compose_files[@]}" run --rm portal-contract printf 'Portal contract passed. Artifacts: %s\n' "$output_dir" if [[ "$keep" == "yes" ]]; then diff --git a/tools/tests/test-portal-hardware-cli.sh b/tools/tests/test-portal-hardware-cli.sh index a836d5f..4a2aa7f 100755 --- a/tools/tests/test-portal-hardware-cli.sh +++ b/tools/tests/test-portal-hardware-cli.sh @@ -10,6 +10,7 @@ stub_bin="$tmp/bin" mkdir -p "$stub_bin" export CALL_LOG="$tmp/calls.log" export WM_HARDWARE_LOCK_FILE="$tmp/hardware.lock" +export XDG_STATE_HOME="$tmp/state" printf '%s\n' '#!/usr/bin/env bash' \ 'if [[ "$1" == "link" && "$2" == "show" ]]; then exit 0; fi' \ @@ -18,16 +19,22 @@ printf '%s\n' '#!/usr/bin/env bash' \ printf '%s\n' '#!/usr/bin/env bash' \ 'printf "%s\\n" "$*" >>"$CALL_LOG"' \ 'if [[ "${NMCLI_FAIL_UP:-}" == "yes" && "$1" == "connection" && "$2" == "up" ]]; then exit 7; fi' \ +'if [[ "$1" == "-t" && "$2" == "-f" && "$3" == "SSID" ]]; then echo "WM Contract ESP8266"; exit 0; fi' \ +'if [[ "$1" == "-g" && "$2" == "connection.uuid" ]]; then echo "stub-uuid"; exit 0; fi' \ 'if [[ "$1" == "-g" ]]; then echo "--"; fi' >"$stub_bin/nmcli" printf '%s\n' '#!/usr/bin/env bash' 'exit 0' >"$stub_bin/pio" printf '%s\n' '#!/usr/bin/env bash' \ +'printf "docker %s\n" "$*" >>"$CALL_LOG"' \ 'if [[ "$1" == "compose" && "$2" == "version" ]]; then echo "Docker Compose"; exit 0; fi' \ 'exit 0' >"$stub_bin/docker" chmod 755 "$stub_bin"/* export PATH="$stub_bin:$PATH" "$root/tools/portal-hardware" doctor --client-interface wlan-client >/dev/null -! grep -Eq 'connection (add|modify|delete)|device disconnect' "$CALL_LOG" +if grep -Eq 'connection (add|modify|delete)|device disconnect' "$CALL_LOG"; then + echo 'doctor unexpectedly changed a NetworkManager connection' >&2 + exit 1 +fi if "$root/tools/portal-hardware" doctor --client-interface wlan-main >/dev/null 2>&1; then echo 'default-route adapter guard did not reject the request' >&2 @@ -48,6 +55,32 @@ if wm_create_portal_connection wlan-client 'fixture portal' placeholder; then fi unset NMCLI_FAIL_UP grep -Eq 'connection delete wifimanager-portal-' "$CALL_LOG" -! grep -Eq 'connection (add|modify|delete)|device disconnect' "$CALL_LOG" + +# A retained session must be removed explicitly, never silently overwritten. +wm_write_state wlan-client esp8266 stale-uuid stale-name +wm_load_state +[[ "$WM_PORTAL_INTERFACE" == "wlan-client" && "$WM_PORTAL_PLATFORM" == "esp8266" ]] +[[ "$WM_PORTAL_CONNECTION_UUID" == "stale-uuid" && "$WM_PORTAL_CONNECTION_NAME" == "stale-name" ]] +mutations_before="$(grep -Ec '^(device disconnect|connection (add|modify|delete|down))' "$CALL_LOG" || true)" +if "$root/tools/portal-hardware" up --platform esp8266 --port /dev/null \ + --client-interface wlan-client --take-over-client-adapter >/dev/null 2>&1; then + echo 'stale portal session was silently overwritten' >&2 + exit 1 +fi +mutations_after="$(grep -Ec '^(device disconnect|connection (add|modify|delete|down))' "$CALL_LOG" || true)" +[[ "$mutations_before" == "$mutations_after" ]] || { + echo 'stale portal session mutated the selected adapter' >&2 + exit 1 +} +wm_clear_state + +# The runner must build the copied contract source before it starts the container. +"$root/tools/portal-hardware" run --platform esp8266 --port /dev/null --client-interface wlan-client --browser skip >/dev/null +build_line="$(grep -n " build portal-contract$" "$CALL_LOG" | tail -1 | cut -d: -f1)" +run_line="$(grep -n " run --rm portal-contract$" "$CALL_LOG" | tail -1 | cut -d: -f1)" +[[ -n "$build_line" && -n "$run_line" && "$build_line" -lt "$run_line" ]] || { + echo "portal contract was not rebuilt before execution" >&2 + exit 1 +} echo 'portal-hardware CLI safety checks passed'