diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d40519d..079a66f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.gitignore b/.gitignore index 4c4dd1f..1839f37 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index fe54004..d9080b3 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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). diff --git a/docs/TESTING.md b/docs/TESTING.md index a221ab3..8d74bb5 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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). diff --git a/scripts/test.sh b/scripts/test.sh index 6ef7c78..0f61ea2 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -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 diff --git a/test/portal-harness/src/main.cpp b/test/portal-harness/src/main.cpp index 29dd803..d942e54 100644 --- a/test/portal-harness/src/main.cpp +++ b/test/portal-harness/src/main.cpp @@ -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); } diff --git a/test/portal-station.env.example b/test/portal-station.env.example new file mode 100644 index 0000000..9734395 --- /dev/null +++ b/test/portal-station.env.example @@ -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 diff --git a/tests/portal-contract/Dockerfile b/tests/portal-contract/Dockerfile new file mode 100644 index 0000000..d32d7c3 --- /dev/null +++ b/tests/portal-contract/Dockerfile @@ -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"] diff --git a/tests/portal-contract/README.md b/tests/portal-contract/README.md new file mode 100644 index 0000000..05d6003 --- /dev/null +++ b/tests/portal-contract/README.md @@ -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. diff --git a/tests/portal-contract/compose.station.yaml b/tests/portal-contract/compose.station.yaml new file mode 100644 index 0000000..8b981c9 --- /dev/null +++ b/tests/portal-contract/compose.station.yaml @@ -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 diff --git a/tests/portal-contract/compose.yaml b/tests/portal-contract/compose.yaml new file mode 100644 index 0000000..133058f --- /dev/null +++ b/tests/portal-contract/compose.yaml @@ -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 diff --git a/tests/portal-contract/package-lock.json b/tests/portal-contract/package-lock.json new file mode 100644 index 0000000..d4abf11 --- /dev/null +++ b/tests/portal-contract/package-lock.json @@ -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" + } + } + } +} diff --git a/tests/portal-contract/package.json b/tests/portal-contract/package.json new file mode 100644 index 0000000..50dcd5a --- /dev/null +++ b/tests/portal-contract/package.json @@ -0,0 +1,11 @@ +{ + "name": "wifimanager-portal-contract", + "private": true, + "version": "1.0.0", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.63.0" + } +} diff --git a/tests/portal-contract/playwright.config.js b/tests/portal-contract/playwright.config.js new file mode 100644 index 0000000..7f85f99 --- /dev/null +++ b/tests/portal-contract/playwright.config.js @@ -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', + }, +}); diff --git a/tests/portal-contract/tests/ap-contract.spec.js b/tests/portal-contract/tests/ap-contract.spec.js new file mode 100644 index 0000000..45c48d6 --- /dev/null +++ b/tests/portal-contract/tests/ap-contract.spec.js @@ -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(' { + 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([]); + }); +}); diff --git a/tests/portal-contract/tests/station-handoff.spec.js b/tests/portal-contract/tests/station-handoff.spec.js new file mode 100644 index 0000000..a5654f4 --- /dev/null +++ b/tests/portal-contract/tests/station-handoff.spec.js @@ -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(); +}); diff --git a/tools/lib/portal-hardware-session.sh b/tools/lib/portal-hardware-session.sh new file mode 100755 index 0000000..95a9cd0 --- /dev/null +++ b/tools/lib/portal-hardware-session.sh @@ -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" +} diff --git a/tools/portal-hardware b/tools/portal-hardware new file mode 100755 index 0000000..6dce8a7 --- /dev/null +++ b/tools/portal-hardware @@ -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 diff --git a/tools/test-portal-browser.sh b/tools/test-portal-browser.sh deleted file mode 100755 index 27819f8..0000000 --- a/tools/test-portal-browser.sh +++ /dev/null @@ -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 '"$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 '"$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" diff --git a/tools/tests/test-portal-hardware-cli.sh b/tools/tests/test-portal-hardware-cli.sh new file mode 100755 index 0000000..a836d5f --- /dev/null +++ b/tools/tests/test-portal-hardware-cli.sh @@ -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'