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
+2 -1
View File
@@ -20,7 +20,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: bash -n scripts/*.sh
- run: bash -n scripts/*.sh tools/portal-hardware tools/lib/*.sh tools/tests/*.sh
- run: ./tools/tests/test-portal-hardware-cli.sh
- run: ./scripts/check-docs.sh
compile-tests:
+4
View File
@@ -33,3 +33,7 @@ Thumbs.db
# Misc
node_modules/
# Local portal station handoff credentials and optional direct test artifacts
/test/portal-station.env
/tests/portal-contract/artifacts/
+14
View File
@@ -32,6 +32,20 @@ When a physical ESP8266 and ESP32 are available, include their local lifecycle t
./scripts/test.sh hardware --platform esp32 --port /dev/serial/by-id/usb-...
~~~
When a physical ESP8266 or ESP32 and a spare USB Wi-Fi adapter are available,
run the Docker portal contract as an additional release-gate check. It is
opt-in because it flashes the selected board and temporarily joins its AP, but
it refuses the host default-route adapter and leaves Docker responsible only
for browser/API testing:
```bash
./tools/portal-hardware run --platform esp8266 --port /dev/serial/by-id/usb-... \
--client-interface wlx74da385d4165
```
See [Testing](TESTING.md#docker-portal-contract) for cleanup, artifacts, and
optional station handoff credentials.
Push the branch and annotated tag. GitHub Actions repeats the board-free compile checks, validates the package, and creates a GitHub Release using that version’s changelog section. The workflow does not publish to the PlatformIO Registry.
Back to [documentation](README.md) · [project overview](../README.md).
+71 -20
View File
@@ -1,45 +1,96 @@
# Testing
The clean-consumer check builds a project that declares only WiFiManager. It proves the package manifest resolves DFTE, ESPAsyncWebServer, and the correct ESP8266 or ESP32 TCP dependency without a sibling checkout or attached board. The runner removes a previous local package link before each
check, so dependency resolution uses the current manifest rather than a stale
`.pio` copy.
WiFiManager separates repeatable package checks from opt-in tests that flash a
real board or join a captive portal. The normal commands never need a board,
local Wi-Fi credentials, browser binary, or sibling checkout.
## Clean consumer and example builds
The clean-consumer check builds a project that declares only WiFiManager. It
proves the package manifest resolves DFTE, ESPAsyncWebServer, and the correct
ESP8266 or ESP32 TCP dependency without a sibling checkout. The runner removes
a prior local package link before each check, so dependency resolution uses the
current manifest rather than a stale `.pio` copy.
```bash
./scripts/test.sh compile --platform esp8266
./scripts/test.sh compile --platform esp32
```
## Example builds
```bash
./scripts/test.sh examples --platform esp8266
./scripts/test.sh examples --platform esp32
```
CI runs the clean-consumer and example checks for pushes to the maintained branch and pull requests. It intentionally compiles only: attached boards, local network state, and browser installation are not CI requirements.
CI runs these board-free checks for pull requests and pushes to the maintained
branch. It intentionally does not require attached hardware, a local network,
or Docker.
## Local hardware lifecycle tests
The existing Unity suite runs on a board without Wi-Fi credentials, MQTT, a DeviceFramework checkout, or a local profile. It verifies portal start and stop lifecycle recovery, scan-cache release, and a real asynchronous Wi-Fi scan.
The Unity suite runs on a board without Wi-Fi credentials, MQTT, DeviceFramework,
or a local profile. It verifies portal start/stop recovery, scan-cache release,
and a real asynchronous Wi-Fi scan.
Use a stable serial-by-id path rather than a changing /dev/ttyUSB number:
Use a stable serial-by-id path rather than a changing `/dev/ttyUSB` number:
~~~bash
```bash
pio device list
./scripts/test.sh hardware --platform esp8266 --port /dev/serial/by-id/usb-...
./scripts/test.sh hardware --platform esp32 --port /dev/serial/by-id/usb-...
~~~
```
The runner uploads the test image, captures its normal-boot serial output with the repository Bash helper, requires Unity's Tests 0 Failures and OK summary, and prints WM_METRIC lifecycle measurements. It does not need or read an environment file. A failed capture is preserved under /tmp for inspection; a successful one is removed.
The runner flashes the selected board, captures normal-boot serial output with
the repository Bash helper, requires Unity's `Tests 0 Failures` and `OK`
summary, and prints lifecycle metrics. Hardware work shares a lock with the
portal contract, so two invocations cannot flash or use the same board at once.
## Optional portal browser test
## Docker portal contract
test/portal-harness is a deliberately tiny portal-only firmware. It is not an example or a consuming application. The browser runner flashes it to the selected board, joins the portal with an explicitly named secondary Wi-Fi adapter, checks root HTML and bootstrap JSON, exercises concurrent root/bootstrap responses, and verifies an asynchronous scan. When a compatible local Chromium binary is available, it also captures the portal and records browser-console output.
`test/portal-harness` is deliberately tiny portal-only firmware, not an example
or consuming application. `tools/portal-hardware` flashes it to one explicitly
selected board, joins its AP through one explicitly selected **secondary**
Wi-Fi adapter, then runs its HTTP and browser contract in a pinned Playwright
Docker image. Docker uses host networking only to reach the already-routed
portal; it never runs NetworkManager or changes host adapters.
~~~bash
./tools/test-portal-browser.sh --platform esp8266 --port /dev/serial/by-id/usb-... --wifi-interface wlx74da385d4165 --output /tmp/wifimanager-browser-results
~~~
```bash
./tools/portal-hardware doctor --client-interface wlx74da385d4165
./tools/portal-hardware run \
--platform esp8266 \
--port /dev/serial/by-id/usb-... \
--client-interface wlx74da385d4165
```
The runner refuses an interface that owns the host's default route. It creates a temporary NetworkManager connection with never-default before bringing it up, so portal traffic stays on the specified adapter and does not replace the host Internet route. The temporary connection is removed on exit. Use a non-default secondary adapter only.
The command refuses the host default-route adapter. If the chosen secondary
adapter is already connected, require an explicit acknowledgement before it is
replaced:
```bash
./tools/portal-hardware run ... --take-over-client-adapter
```
The fixture opens a 15-minute portal session, includes one harmless custom
parameter, and verifies root/bootstrap/info/status API responses, concurrent
low-priority requests, parameter persistence, timeout reset, an actual async
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.
For interactive diagnosis, leave the temporary client connection up and remove
only that managed connection when finished:
```bash
./tools/portal-hardware up --platform esp32 --port /dev/serial/by-id/usb-... \
--client-interface wlx74da385d4165
./tools/portal-hardware down
```
An optional station handoff test is deliberately separate because it connects
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.
```bash
cp test/portal-station.env.example test/portal-station.env
./tools/portal-hardware run ... --station-env test/portal-station.env
```
Back to [documentation](README.md) · [project overview](../README.md).
+9 -2
View File
@@ -2,12 +2,12 @@
set -euo pipefail
usage() {
cat <<'EOF' >&2
cat <<'USAGE' >&2
Usage:
./scripts/test.sh compile --platform esp8266|esp32
./scripts/test.sh examples --platform esp8266|esp32
./scripts/test.sh hardware --platform esp8266|esp32 --port /dev/serial/by-id/...
EOF
USAGE
exit 2
}
@@ -30,6 +30,13 @@ done
[[ "$mode" != "hardware" || -e "$port" ]] || { echo "Serial port not found: $port" >&2; exit 1; }
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ "$mode" == "hardware" ]]; then
# Keep serial flashing and portal-adapter work mutually exclusive.
# shellcheck source=tools/lib/portal-hardware-session.sh
source "$root/tools/lib/portal-hardware-session.sh"
wm_acquire_hardware_lock
fi
if [[ "$mode" == "examples" ]]; then
mapfile -t examples < <(find "$root/examples" -mindepth 2 -maxdepth 2 -type f -name platformio.ini -printf '%h\n' | sort)
if (( ${#examples[@]} == 0 )); then
+8 -5
View File
@@ -4,13 +4,15 @@
namespace {
#if defined(ESP8266)
constexpr char kPortalSsid[] = "WM Browser ESP8266";
constexpr char kPortalSsid[] = "WM Contract ESP8266";
#else
constexpr char kPortalSsid[] = "WM Browser ESP32";
constexpr char kPortalSsid[] = "WM Contract ESP32";
#endif
constexpr char kPortalPassword[] = "default1";
WiFiManager wifi;
WiFiManagerParameter kInstallationLabel(
"installation_label", "Installation label", "Contract fixture", 32);
} // namespace
@@ -18,13 +20,14 @@ void setup() {
Serial.begin(115200);
delay(300);
// This harness is deliberately portal-only: it has no saved station
// credentials and never exercises a consuming application.
wifi.setConfigPortalTimeout(0);
// The fixture intentionally has no station credentials. A finite window
// exercises timeout reset without leaving a board in a permanent portal.
wifi.setConfigPortalTimeout(15 * 60);
wifi.setAPStaticIPConfig(
IPAddress(192, 168, 4, 1),
IPAddress(192, 168, 4, 1),
IPAddress(255, 255, 255, 0));
wifi.portalAddParameter(&kInstallationLabel);
wifi.startConfigPortal(kPortalSsid, kPortalPassword);
}
+4
View File
@@ -0,0 +1,4 @@
# Ignored local credentials for the optional station handoff test.
# These values are mounted read-only into the Docker test container.
WIFI_SSID=replace-me
WIFI_PASSWORD=replace-me
+11
View File
@@ -0,0 +1,11 @@
ARG PLAYWRIGHT_VERSION=1.63.0
FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble
ARG PLAYWRIGHT_VERSION
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
WORKDIR /work
COPY tests/portal-contract/package.json tests/portal-contract/package-lock.json ./
RUN npm ci --ignore-scripts && \
[ "$(node -p "require('@playwright/test/package.json').version")" = "$PLAYWRIGHT_VERSION" ]
COPY tests/portal-contract/ ./
CMD ["npm", "test"]
+11
View File
@@ -0,0 +1,11 @@
# Portal contract container
This directory contains the browser/API half of the real-hardware portal test.
Run it through [`../../tools/portal-hardware`](../../tools/portal-hardware), not
directly: the host command alone selects the serial board and attaches the
explicit secondary Wi-Fi adapter. Docker uses the host network only to reach
the already-routed `192.168.4.1` portal; it never manages host Wi-Fi.
The image pins the Playwright package to the matching official browser image.
Artifacts, traces, screenshots, JSON results, and the HTML report are written
to the output directory printed by the host command.
@@ -0,0 +1,6 @@
services:
portal-contract:
environment:
PORTAL_STATION_ENV: /run/secrets/portal-station.env
volumes:
- ${PORTAL_STATION_ENV_HOST:?station environment path is required}:/run/secrets/portal-station.env:ro
+18
View File
@@ -0,0 +1,18 @@
services:
portal-contract:
build:
context: ../..
dockerfile: tests/portal-contract/Dockerfile
args:
PLAYWRIGHT_VERSION: "1.63.0"
network_mode: host
ipc: host
init: true
user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
environment:
HOME: /tmp
PORTAL_URL: http://192.168.4.1
PORTAL_BROWSER_MODE: ${PORTAL_BROWSER_MODE:-auto}
ARTIFACT_DIR: /artifacts
volumes:
- ${PORTAL_ARTIFACT_DIR:?portal artifact directory is required}:/artifacts
+60
View File
@@ -0,0 +1,60 @@
{
"name": "wifimanager-portal-contract",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wifimanager-portal-contract",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "1.63.0"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "wifimanager-portal-contract",
"private": true,
"version": "1.0.0",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "1.63.0"
}
}
@@ -0,0 +1,25 @@
const path = require('path');
const { defineConfig } = require('@playwright/test');
const artifactDir = process.env.ARTIFACT_DIR || path.join(__dirname, 'artifacts');
module.exports = defineConfig({
testDir: './tests',
timeout: 45_000,
expect: { timeout: 10_000 },
forbidOnly: !!process.env.CI,
fullyParallel: false,
workers: 1,
outputDir: path.join(artifactDir, 'test-results'),
reporter: [
['list'],
['json', { outputFile: path.join(artifactDir, 'report.json') }],
['html', { outputFolder: path.join(artifactDir, 'html-report'), open: 'never' }],
],
use: {
baseURL: process.env.PORTAL_URL || 'http://192.168.4.1',
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
video: 'retain-on-failure',
},
});
@@ -0,0 +1,103 @@
const { test, expect } = require('@playwright/test');
async function json(response) {
return JSON.parse(await response.text());
}
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;
}, { timeout: 45_000, intervals: [500, 800, 1_000] }).toBe(false);
return result;
}
test.describe('portal AP contract', () => {
test('serves API, persists fixture parameters, and completes a real scan', async ({ request }) => {
const root = await request.get('/');
expect(root.ok()).toBeTruthy();
expect(await root.text()).toContain('<html');
const [bootstrapResponse, concurrentRoot] = await Promise.all([
request.get('/api/bootstrap'),
request.get('/'),
]);
expect(concurrentRoot.ok()).toBeTruthy();
const bootstrap = await json(bootstrapResponse);
expect(bootstrap.contractVersion).toBe(3);
expect(bootstrap.context.portalActive).toBe(true);
const metaResponse = await request.get('/api/wifi/meta');
expect(metaResponse.ok()).toBeTruthy();
const meta = await json(metaResponse);
expect(JSON.stringify(meta)).toContain('installation_label');
const infoResponse = await request.get('/api/info');
expect(infoResponse.ok()).toBeTruthy();
expect(JSON.stringify(await json(infoResponse))).toContain('192.168.4.1');
const statusResponse = await request.get('/api/status');
expect(statusResponse.ok()).toBeTruthy();
const saveResponse = await request.post('/api/params/save', {
form: { installation_label: 'Contract verified' },
});
expect(saveResponse.ok()).toBeTruthy();
expect(JSON.stringify(await json(saveResponse))).toContain('saved');
const paramsResponse = await request.get('/api/params');
expect(paramsResponse.ok()).toBeTruthy();
expect(JSON.stringify(await json(paramsResponse))).toContain('Contract verified');
const resetResponse = await request.post('/api/portal/timeout-reset');
expect(resetResponse.ok()).toBeTruthy();
expect((await json(resetResponse)).timeoutSecondsRemaining).toBeGreaterThan(800);
const scanStart = await request.post('/api/wifi/scan');
expect([200, 202, 409]).toContain(scanStart.status());
const completed = await waitForScan(request);
expect(completed.state).toBe('complete');
expect(completed.results_valid).toBe(true);
const missing = await request.get('/not-a-portal-route');
expect(missing.status()).toBe(404);
});
test('renders stable desktop and mobile portal views without page errors', async ({ browser }) => {
test.skip(process.env.PORTAL_BROWSER_MODE === 'skip', 'Browser checks were explicitly skipped.');
const desktop = await browser.newContext({ viewport: { width: 1440, height: 1080 } });
const page = await desktop.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
await page.goto('/', { waitUntil: 'networkidle' });
await expect(page.locator('#wm-reset-portal-timeout')).toBeVisible();
await page.screenshot({ path: `${process.env.ARTIFACT_DIR}/portal-overview-desktop.png`, fullPage: true });
await page.goto('/#/wifi', { waitUntil: 'networkidle' });
await expect(page.locator('#wm-refresh-scan')).toBeVisible();
await expect(page.locator('#wm-f-installation_label')).toBeVisible();
await page.locator('#wm-f-installation_label').fill('Browser verified');
await page.screenshot({ path: `${process.env.ARTIFACT_DIR}/portal-wifi-desktop.png`, fullPage: true });
const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true });
const mobilePage = await mobile.newPage();
mobilePage.on('pageerror', (error) => errors.push(error.message));
mobilePage.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
await mobilePage.goto('/#/info', { waitUntil: 'networkidle' });
await expect(mobilePage.locator('.wm-page-head')).toBeVisible();
await mobilePage.screenshot({ path: `${process.env.ARTIFACT_DIR}/portal-device-mobile.png`, fullPage: true });
await mobile.close();
await desktop.close();
expect(errors).toEqual([]);
});
});
@@ -0,0 +1,43 @@
const fs = require('fs');
const { test, expect } = require('@playwright/test');
function stationCredentials() {
const file = process.env.PORTAL_STATION_ENV;
if (!file) return null;
const values = {};
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
if (!raw || raw.startsWith('#')) continue;
const separator = raw.indexOf('=');
if (separator > 0) values[raw.slice(0, separator)] = raw.slice(separator + 1);
}
if (!values.WIFI_SSID || !values.WIFI_PASSWORD) {
throw new Error('PORTAL_STATION_ENV must define WIFI_SSID and WIFI_PASSWORD.');
}
return values;
}
test('optionally hands the fixture off to a real station network', async ({ request }) => {
const credentials = stationCredentials();
test.skip(!credentials, 'No station environment was supplied.');
const metaResponse = await request.get('/api/wifi/meta');
expect(metaResponse.ok()).toBeTruthy();
const meta = JSON.parse(await metaResponse.text());
const form = Array.isArray(meta.profiles) && meta.profiles.length
? { s0: credentials.WIFI_SSID, p0: credentials.WIFI_PASSWORD, stationAction: 'connect' }
: { s: credentials.WIFI_SSID, p: credentials.WIFI_PASSWORD, stationAction: 'connect' };
const queued = await request.post('/api/wifi/save', { form });
expect(queued.status()).toBe(202);
let state;
await expect.poll(async () => {
const response = await request.get('/api/wifi/connect-status');
state = JSON.parse(await response.text());
return state.state;
}, { timeout: 45_000, intervals: [500, 700, 1_000] }).toBe('success');
expect(state.stationIp).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
expect(state.redirectUrl).toContain(state.stationIp);
const complete = await request.post('/api/wifi/connect-complete');
expect(complete.ok()).toBeTruthy();
});
+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'