test: containerize portal hardware contract

This commit is contained in:
2026-09-06 20:32:46 +10:00
parent d57f6c91c5
commit daf9f207b2
20 changed files with 758 additions and 181 deletions
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env bash
# Shared host-side helpers for the WiFiManager portal hardware contract.
# They never modify a network interface other than the explicit client adapter.
wm_portal_state_root() {
printf '%s/wifimanager-portal-hardware' "${XDG_STATE_HOME:-$HOME/.local/state}"
}
wm_require() {
command -v "$1" >/dev/null 2>&1 || {
echo "Required command not found: $1" >&2
return 1
}
}
wm_default_route_interface() {
ip route show default 2>/dev/null | awk '/^default/{print $5; exit}'
}
wm_acquire_hardware_lock() {
local lock_file="${WM_HARDWARE_LOCK_FILE:-/tmp/wifimanager-hardware.lock}"
exec 9>"$lock_file"
flock -n 9 || {
echo "Another WiFiManager hardware task is already running; wait for it to finish." >&2
return 1
}
}
wm_require_client_adapter() {
local interface="$1" allow_takeover="$2" default_interface active_connection
ip link show "$interface" >/dev/null 2>&1 || {
echo "Wi-Fi interface not found: $interface" >&2
return 1
}
default_interface="$(wm_default_route_interface)"
[[ "$interface" != "$default_interface" ]] || {
echo "Refusing to use the host default-route interface: $interface" >&2
return 1
}
active_connection="$(nmcli -g GENERAL.CONNECTION device show "$interface" 2>/dev/null || true)"
if [[ -n "$active_connection" && "$active_connection" != "--" && "$allow_takeover" != "yes" ]]; then
echo "Client adapter $interface already has connection '$active_connection'." >&2
echo "Pass --take-over-client-adapter to replace only that adapter's connection." >&2
return 1
fi
}
wm_portal_ssid() {
case "$1" in
esp8266) printf '%s\n' 'WM Contract ESP8266' ;;
esp32) printf '%s\n' 'WM Contract ESP32' ;;
*) return 1 ;;
esac
}
wm_wait_for_portal_ssid() {
local interface="$1" ssid="$2" attempt
nmcli device wifi rescan ifname "$interface" >/dev/null 2>&1 || true
for attempt in $(seq 1 45); do
if nmcli -t -f SSID device wifi list ifname "$interface" | grep -Fxq "$ssid"; then
return 0
fi
sleep 1
nmcli device wifi rescan ifname "$interface" >/dev/null 2>&1 || true
done
echo "Portal SSID not detected on $interface: $ssid" >&2
return 1
}
wm_remove_connection_by_name() {
local name="$1"
[[ -n "$name" ]] || return 0
nmcli connection down "$name" >/dev/null 2>&1 || true
nmcli connection delete "$name" >/dev/null 2>&1 || true
}
wm_create_portal_connection() {
local interface="$1" ssid="$2" password="$3" name uuid
name="wifimanager-portal-${RANDOM}-$(date +%s)"
nmcli device disconnect "$interface" >/dev/null 2>&1 || true
wm_wait_for_portal_ssid "$interface" "$ssid"
if ! nmcli connection add type wifi ifname "$interface" con-name "$name" ssid "$ssid" \
ipv4.method auto ipv4.never-default yes ipv6.method ignore connection.autoconnect no >/dev/null; then
return 1
fi
if ! nmcli connection modify "$name" wifi-sec.key-mgmt wpa-psk wifi-sec.psk "$password"; then
wm_remove_connection_by_name "$name"
return 1
fi
if ! nmcli connection up "$name" ifname "$interface"; then
wm_remove_connection_by_name "$name"
return 1
fi
uuid="$(nmcli -g connection.uuid connection show "$name")"
if [[ -z "$uuid" || "$uuid" == "--" ]]; then
wm_remove_connection_by_name "$name"
echo "NetworkManager did not return a UUID for the portal connection." >&2
return 1
fi
WM_PORTAL_CONNECTION_UUID="$uuid"
WM_PORTAL_CONNECTION_NAME="$name"
export WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
}
wm_verify_portal_route() {
local interface="$1" route
route="$(ip route get 192.168.4.1 2>/dev/null || true)"
[[ "$route" == *" dev $interface "* ]] || {
echo "Portal route does not use the selected adapter: $route" >&2
return 1
}
}
wm_remove_connection() {
local uuid="$1"
[[ -n "$uuid" ]] || return 0
nmcli connection down uuid "$uuid" >/dev/null 2>&1 || true
nmcli connection delete uuid "$uuid" >/dev/null 2>&1 || true
}
wm_state_file() {
printf '%s/session.env\n' "$(wm_portal_state_root)"
}
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"
chmod 600 "$file"
}
wm_load_state() {
local file
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_clear_state() {
local file
file="$(wm_state_file)"
rm -f "$file"
}
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=tools/lib/portal-hardware-session.sh
source "$root/tools/lib/portal-hardware-session.sh"
usage() {
cat <<'USAGE' >&2
Usage:
./tools/portal-hardware doctor --client-interface IFACE [--take-over-client-adapter]
./tools/portal-hardware up --platform esp8266|esp32 --port /dev/serial/by-id/... \
--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]
./tools/portal-hardware down
Only the named client interface may be disconnected or reconfigured. The tool
refuses the host default-route interface and preserves the created connection
in a 0600 state file until `down` or normal `run` cleanup.
USAGE
exit 2
}
command_name="${1:-}"
[[ -n "$command_name" ]] || usage
shift || true
platform=""
port=""
client_interface=""
takeover="no"
keep="no"
browser="auto"
station_env=""
output_dir=""
while [[ $# -gt 0 ]]; do
case "$1" in
--platform) [[ $# -ge 2 ]] || usage; platform="$2"; shift 2 ;;
--port) [[ $# -ge 2 ]] || usage; port="$2"; shift 2 ;;
--client-interface) [[ $# -ge 2 ]] || usage; client_interface="$2"; shift 2 ;;
--take-over-client-adapter) takeover="yes"; shift ;;
--keep) keep="yes"; shift ;;
--browser) [[ $# -ge 2 ]] || usage; browser="$2"; shift 2 ;;
--station-env) [[ $# -ge 2 ]] || usage; station_env="$2"; shift 2 ;;
--output) [[ $# -ge 2 ]] || usage; output_dir="$2"; shift 2 ;;
*) usage ;;
esac
done
require_common() {
wm_require ip
wm_require nmcli
wm_require pio
wm_require docker
docker compose version >/dev/null
}
prepare_output_dir() {
if [[ -z "$output_dir" ]]; then
output_dir="$(wm_portal_state_root)/runs/$(date -u +%Y%m%dT%H%M%SZ)-$platform"
fi
install -d -m 700 "$output_dir"
output_dir="$(cd "$output_dir" && pwd)"
}
validate_run_arguments() {
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
[[ -n "$client_interface" ]] || usage
[[ -n "$port" && -e "$port" ]] || {
echo "Serial port not found: $port" >&2
exit 1
}
[[ "$browser" == "auto" || "$browser" == "require" || "$browser" == "skip" ]] || usage
[[ -z "$station_env" || -r "$station_env" ]] || {
echo "Station environment file is not readable: $station_env" >&2
exit 1
}
}
start_portal_session() {
local ssid
validate_run_arguments
require_common
wm_acquire_hardware_lock
wm_require_client_adapter "$client_interface" "$takeover"
prepare_output_dir
ssid="$(wm_portal_ssid "$platform")"
pio run -d "$root/test/portal-harness" -e "$platform" -t upload --upload-port "$port"
if ! wm_create_portal_connection "$client_interface" "$ssid" "default1"; then
return 1
fi
if ! wm_verify_portal_route "$client_interface"; then
wm_remove_connection "$WM_PORTAL_CONNECTION_UUID"
return 1
fi
if ! wm_write_state "$client_interface" "$platform" "$WM_PORTAL_CONNECTION_UUID" "$WM_PORTAL_CONNECTION_NAME"; then
wm_remove_connection "$WM_PORTAL_CONNECTION_UUID"
return 1
fi
printf 'Portal connected on %s. Artifacts: %s\n' "$client_interface" "$output_dir"
}
finish_portal_session() {
wm_load_state
wm_remove_connection "$WM_PORTAL_CONNECTION_UUID"
wm_clear_state
}
case "$command_name" in
doctor)
[[ -n "$client_interface" ]] || usage
require_common
wm_acquire_hardware_lock
wm_require_client_adapter "$client_interface" "$takeover"
printf 'Portal hardware prerequisites are ready. Main route is untouched; client adapter: %s\n' "$client_interface"
;;
up)
start_portal_session
;;
down)
[[ -z "$platform$port$client_interface$station_env$output_dir" ]] || usage
wm_acquire_hardware_lock
finish_portal_session
echo 'Portal client connection removed.'
;;
run)
start_portal_session
cleanup() {
if [[ "$keep" != "yes" ]]; then
finish_portal_session || true
fi
}
trap cleanup EXIT INT TERM
export PORTAL_ARTIFACT_DIR="$output_dir"
export LOCAL_UID="$(id -u)"
export LOCAL_GID="$(id -g)"
export PORTAL_BROWSER_MODE="$browser"
compose_files=(-f "$root/tests/portal-contract/compose.yaml")
if [[ -n "$station_env" ]]; then
export PORTAL_STATION_ENV_HOST="$(cd "$(dirname "$station_env")" && pwd)/$(basename "$station_env")"
compose_files+=(-f "$root/tests/portal-contract/compose.station.yaml")
fi
docker compose "${compose_files[@]}" run --rm portal-contract
printf 'Portal contract passed. Artifacts: %s\n' "$output_dir"
if [[ "$keep" == "yes" ]]; then
printf 'Portal session remains connected; run ./tools/portal-hardware down when finished.\n'
fi
;;
*) usage ;;
esac
-153
View File
@@ -1,153 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF' >&2
Usage:
./tools/test-portal-browser.sh \
--platform esp8266|esp32 \
--port /dev/ttyUSB... \
--wifi-interface wlx... \
[--output /absolute/output-directory]
EOF
exit 2
}
platform=""
port=""
wifi_interface=""
output_dir=""
while [[ $# -gt 0 ]]; do
case "$1" in
--platform) [[ $# -ge 2 ]] || usage; platform="$2"; shift 2 ;;
--port) [[ $# -ge 2 ]] || usage; port="$2"; shift 2 ;;
--wifi-interface) [[ $# -ge 2 ]] || usage; wifi_interface="$2"; shift 2 ;;
--output) [[ $# -ge 2 ]] || usage; output_dir="$2"; shift 2 ;;
*) usage ;;
esac
done
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
[[ -e "$port" ]] || { echo "Serial port not found: $port" >&2; exit 1; }
ip link show "$wifi_interface" >/dev/null 2>&1 || {
echo "Wi-Fi interface not found: $wifi_interface" >&2
exit 1
}
command -v nmcli >/dev/null || { echo "nmcli is required" >&2; exit 1; }
command -v curl >/dev/null || { echo "curl is required" >&2; exit 1; }
command -v rg >/dev/null || { echo "rg is required" >&2; exit 1; }
browser_bin=""
for candidate in google-chrome google-chrome-stable chromium chromium-browser; do
if command -v "$candidate" >/dev/null; then
browser_bin="$candidate"
break
fi
done
default_route_interface="$(ip route show default | awk '/^default/{print $5; exit}')"
[[ "$wifi_interface" != "$default_route_interface" ]] || {
echo "Refusing to use the host default-route interface: $wifi_interface" >&2
exit 1
}
case "$platform" in
esp8266) portal_ssid="WM Browser ESP8266" ;;
esp32) portal_ssid="WM Browser ESP32" ;;
esac
portal_password="default1"
if [[ -z "$output_dir" ]]; then
output_dir="$(mktemp -d /tmp/wifimanager-browser.XXXXXX)"
else
mkdir -p "$output_dir"
fi
temporary_connection=""
cleanup() {
if [[ -n "$temporary_connection" ]]; then
nmcli connection down "$temporary_connection" >/dev/null 2>&1 || true
nmcli connection delete "$temporary_connection" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
script_dir="$(cd "$(dirname "$0")" && pwd)"
root="$(cd "$script_dir/.." && pwd)"
# Flash only the explicitly selected idle serial board.
pio run -d "$root/test/portal-harness" \
-e "$platform" -t upload --upload-port "$port"
# The secondary adapter is the only adapter NetworkManager may touch.
nmcli device disconnect "$wifi_interface" >/dev/null 2>&1 || true
nmcli device wifi rescan ifname "$wifi_interface" || true
for attempt in $(seq 1 30); do
if nmcli -t -f SSID device wifi list ifname "$wifi_interface" | grep -Fxq "$portal_ssid"; then
break
fi
sleep 1
nmcli device wifi rescan ifname "$wifi_interface" >/dev/null 2>&1 || true
done
nmcli -t -f SSID device wifi list ifname "$wifi_interface" | grep -Fxq "$portal_ssid" || {
echo "Portal SSID not detected on $wifi_interface: $portal_ssid" >&2
exit 1
}
# Apply never-default before bringing this temporary portal connection up.
temporary_connection="wifimanager-browser-$platform-$$"
nmcli connection add type wifi ifname "$wifi_interface" con-name "$temporary_connection" \
ssid "$portal_ssid" ipv4.method auto ipv4.never-default yes ipv6.method ignore >/dev/null
nmcli connection modify "$temporary_connection" wifi-sec.key-mgmt wpa-psk \
wifi-sec.psk "$portal_password"
nmcli connection up "$temporary_connection" ifname "$wifi_interface" >/dev/null
portal_url="http://192.168.4.1"
curl --silent --show-error --fail --interface "$wifi_interface" --connect-timeout 3 \
"$portal_url/" >"$output_dir/portal.html"
rg -iq '<html' "$output_dir/portal.html"
# Exercise two independent low-priority responses at once before the scan flow.
curl --silent --show-error --fail --interface "$wifi_interface" --connect-timeout 3 \
"$portal_url/api/bootstrap" >"$output_dir/bootstrap-concurrent.json" &
bootstrap_pid="$!"
curl --silent --show-error --fail --interface "$wifi_interface" --connect-timeout 3 \
"$portal_url/" >"$output_dir/portal-concurrent.html" &
portal_pid="$!"
wait "$bootstrap_pid"
wait "$portal_pid"
rg -Fq '"contractVersion":3' "$output_dir/bootstrap-concurrent.json"
rg -iq '<html' "$output_dir/portal-concurrent.html"
for attempt in $(seq 1 30); do
if curl --silent --show-error --fail --interface "$wifi_interface" --connect-timeout 2 \
"$portal_url/api/bootstrap" >"$output_dir/bootstrap.json"; then
break
fi
sleep 1
done
rg -Fq '"contractVersion":3' "$output_dir/bootstrap.json"
curl --silent --show-error --fail --interface "$wifi_interface" --connect-timeout 3 \
-X POST "$portal_url/api/wifi/scan" >"$output_dir/scan-start.json"
for attempt in $(seq 1 30); do
curl --silent --show-error --fail --interface "$wifi_interface" --connect-timeout 3 \
"$portal_url/api/wifi/scan-status" >"$output_dir/scan-status.json"
if ! rg -Fq '"scanning":true' "$output_dir/scan-status.json"; then
break
fi
sleep 1
done
rg -Fq '"state":"complete"' "$output_dir/scan-status.json"
if [[ -n "$browser_bin" ]]; then
"$browser_bin" --headless=new --disable-gpu --no-first-run --enable-logging=stderr \
--window-size=1440,1100 --screenshot="$output_dir/portal.png" "$portal_url" \
>"$output_dir/browser.log" 2>&1
! rg -i "console.*error|uncaught|exception" "$output_dir/browser.log"
else
echo "HTTP portal checks passed; no compatible local Chromium binary for screenshot capture" \
>"$output_dir/browser.log"
fi
echo "WiFiManager portal browser test passed"
echo "Artifacts: $output_dir"
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
tmp="$(mktemp -d "${TMPDIR:-/tmp}/wifimanager-portal-cli.XXXXXX")"
cleanup() { rm -rf "$tmp"; }
trap cleanup EXIT
stub_bin="$tmp/bin"
mkdir -p "$stub_bin"
export CALL_LOG="$tmp/calls.log"
export WM_HARDWARE_LOCK_FILE="$tmp/hardware.lock"
printf '%s\n' '#!/usr/bin/env bash' \
'if [[ "$1" == "link" && "$2" == "show" ]]; then exit 0; fi' \
'if [[ "$1" == "route" && "$2" == "show" ]]; then echo "default via 192.0.2.1 dev wlan-main"; exit 0; fi' \
'echo "192.168.4.1 dev wlan-client src 192.168.4.2"' >"$stub_bin/ip"
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" == "-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' \
'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 "$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
exit 1
fi
if "$root/tools/portal-hardware" up --platform >/dev/null 2>&1; then
echo 'missing option value did not reject the request' >&2
exit 1
fi
# A failed association must delete the only connection it just created.
source "$root/tools/lib/portal-hardware-session.sh"
wm_wait_for_portal_ssid() { return 0; }
export NMCLI_FAIL_UP=yes
if wm_create_portal_connection wlan-client 'fixture portal' placeholder; then
echo 'failed association was reported as success' >&2
exit 1
fi
unset NMCLI_FAIL_UP
grep -Eq 'connection delete wifimanager-portal-' "$CALL_LOG"
! grep -Eq 'connection (add|modify|delete)|device disconnect' "$CALL_LOG"
echo 'portal-hardware CLI safety checks passed'