From 7c5b50499eb30625718999ff05a43ad9589dd5d3 Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Thu, 17 Sep 2026 22:11:24 +1000 Subject: [PATCH] fix: validate portal OTA and simplify test harness --- .github/workflows/ci.yml | 14 +- .github/workflows/release.yml | 51 ++- .gitignore | 2 +- CHANGELOG.md | 7 + docs/DEVELOPMENT.md | 40 +-- docs/PORTAL_UI.md | 4 +- docs/TESTING.md | 84 +++-- examples/BasicPortal/platformio.ini | 2 +- examples/BrandedPortal/platformio.ini | 2 +- examples/CustomPortalContent/platformio.ini | 2 +- examples/StationProfiles/platformio.ini | 2 +- lib/WiFiManager/src/WiFiManagerHandlers.cpp | 6 +- library.json | 2 +- platformio.ini | 13 +- scripts/test.sh | 61 +--- test/compile-project/platformio.ini | 8 +- test/portal-harness/README.md | 14 +- test/portal-harness/platformio.ini | 19 +- test/portal-harness/src/main.cpp | 36 +- test/portal-station.env.example | 3 +- test/test_wifimanager/test_main.cpp | 2 +- test/test_wifimanager/test_main.h | 2 +- .../tests/test_root_render_lifecycle.cpp | 6 +- tests/portal-harness/.dockerignore | 10 + .../Dockerfile | 12 +- .../README.md | 12 +- .../compose.ota.yaml | 4 +- .../compose.station.yaml | 2 +- .../compose.yaml | 12 +- .../package-lock.json | 4 +- .../package.json | 2 +- .../playwright.config.js | 12 +- .../render-readme-media.sh | 1 + .../run-portal-harness.sh} | 2 +- .../tests/ap-harness.spec.js} | 23 +- .../tests/ota.spec.js | 4 +- .../tests/readme-media.spec.js | 4 +- .../tests/station-handoff.spec.js | 1 + tools/capture-serial.py | 95 +++++ tools/check-ota-partitions.sh | 2 +- tools/lib/portal-hardware-session.sh | 113 +++++- tools/portal-hardware | 340 +++++++++++++++--- tools/tests/test-capture-serial.py | 133 +++++++ tools/tests/test-portal-hardware-cli.sh | 279 -------------- 44 files changed, 900 insertions(+), 549 deletions(-) create mode 100644 tests/portal-harness/.dockerignore rename tests/{portal-contract => portal-harness}/Dockerfile (61%) rename tests/{portal-contract => portal-harness}/README.md (61%) rename tests/{portal-contract => portal-harness}/compose.ota.yaml (82%) rename tests/{portal-contract => portal-harness}/compose.station.yaml (91%) rename tests/{portal-contract => portal-harness}/compose.yaml (61%) rename tests/{portal-contract => portal-harness}/package-lock.json (94%) rename tests/{portal-contract => portal-harness}/package.json (79%) rename tests/{portal-contract => portal-harness}/playwright.config.js (52%) rename tests/{portal-contract => portal-harness}/render-readme-media.sh (95%) rename tests/{portal-contract/run-portal-contract.sh => portal-harness/run-portal-harness.sh} (83%) rename tests/{portal-contract/tests/ap-contract.spec.js => portal-harness/tests/ap-harness.spec.js} (90%) rename tests/{portal-contract => portal-harness}/tests/ota.spec.js (97%) rename tests/{portal-contract => portal-harness}/tests/readme-media.spec.js (94%) rename tests/{portal-contract => portal-harness}/tests/station-handoff.spec.js (96%) create mode 100644 tools/capture-serial.py create mode 100644 tools/tests/test-capture-serial.py delete mode 100755 tools/tests/test-portal-hardware-cli.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8dd2b99..0fb7a44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,10 +22,10 @@ jobs: - uses: actions/checkout@v4 - run: bash -n scripts/*.sh tools/check-ota-partitions.sh tools/portal-hardware tools/lib/*.sh tools/tests/*.sh - run: | - for spec in tests/portal-contract/tests/*.js; do + for spec in tests/portal-harness/tests/*.js; do node --check "$spec" done - - run: ./tools/tests/test-portal-hardware-cli.sh + - run: timeout 15s python3 tools/tests/test-capture-serial.py - run: ./tools/check-ota-partitions.sh - run: ./scripts/check-docs.sh @@ -41,14 +41,6 @@ jobs: unity: true - platform: esp32 examples: true - ota_fixtures: false - unity: true - - platform: esp32-current - examples: false - # Keep current A/B OTA builds in their own worker. PlatformIO uses - # one package-name directory for tool-esptoolpy, so mixing legacy - # 3.0.5 and current 3.3.11 graphs in a single cache is not a - # meaningful compatibility test. ota_fixtures: true unity: true steps: @@ -60,8 +52,6 @@ jobs: - run: ./scripts/test.sh compile --platform ${{ matrix.platform }} - if: matrix.unity run: ./scripts/test.sh unity --platform ${{ matrix.platform }} - - if: matrix.platform == 'esp32-current' - run: ./scripts/test.sh packages --platform esp32-current - if: matrix.examples run: ./scripts/test.sh examples --platform ${{ matrix.platform }} - if: matrix.ota_fixtures diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b3186f..aac0fd1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,22 +6,59 @@ on: - 'v*' permissions: - contents: write + contents: read jobs: - publish: + documentation: runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: bash -n scripts/*.sh tools/check-ota-partitions.sh tools/portal-hardware tools/lib/*.sh tools/tests/*.sh + - run: | + for spec in tests/portal-harness/tests/*.js; do + node --check "$spec" + done + - run: timeout 15s python3 tools/tests/test-capture-serial.py + - run: ./tools/check-ota-partitions.sh + - run: ./scripts/check-docs.sh + + compile-tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - platform: esp8266 + examples: true + ota_fixtures: true + unity: true + - platform: esp32 + examples: true + ota_fixtures: true + unity: true steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.11' - run: python -m pip install --upgrade platformio==6.1.19 - - run: ./scripts/test.sh compile --platform esp8266 - - run: ./scripts/test.sh examples --platform esp8266 - - run: ./scripts/test.sh compile --platform esp32 - - run: ./scripts/test.sh examples --platform esp32 - - run: ./scripts/check-docs.sh + - run: ./scripts/test.sh compile --platform ${{ matrix.platform }} + - if: matrix.unity + run: ./scripts/test.sh unity --platform ${{ matrix.platform }} + - if: matrix.examples + run: ./scripts/test.sh examples --platform ${{ matrix.platform }} + - if: matrix.ota_fixtures + run: ./scripts/test.sh ota-fixtures --platform ${{ matrix.platform }} + + publish: + needs: + - documentation + - compile-tests + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 - run: ./scripts/prepare-release.sh "$GITHUB_REF_NAME" - run: ./scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-notes.md" - run: >- diff --git a/.gitignore b/.gitignore index ffd0921..34dae5e 100644 --- a/.gitignore +++ b/.gitignore @@ -36,5 +36,5 @@ node_modules/ # Local portal station handoff credentials and optional direct test artifacts /test/portal-station.env -/tests/portal-contract/artifacts/ +/tests/portal-harness/artifacts/ /artifacts/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 971c17c..f00a032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 3.2.5 + +- Prevent ESP8266 portal firmware uploads from yielding in ESPAsyncWebServer's + SYS callback. The updater now enters asynchronous mode before its first + erase or write, so the real browser upload can complete and restart into + the new firmware image. + ## 3.2.4 - Correct profile-backed Wi-Fi hand-off from the embedded web portal: the diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8506bd4..bd7e4b9 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -9,14 +9,13 @@ lib_deps = ## Target pins -WiFiManager currently has two explicit ESP32 test lanes: +WiFiManager uses one maintained ESP32 test lane: | Lane | pioarduino platform | Purpose | | --- | --- | --- | -| `esp32` | `51.03.05` / Arduino-ESP32 3.0.5 | temporary compatibility contract | -| `esp32_core_3_3_11` / CLI `esp32-current` | `55.03.311` / Arduino-ESP32 3.3.11 | maintained current validation lane | +| `esp32` | `55.03.311` / Arduino-ESP32 3.3.11 | maintained baseline | -This is a test-target contract, not a library-manifest dependency: a consuming +This is a test-target policy, not a library-manifest dependency: a consuming application chooses its own `platform` and must validate the complete framework/toolchain stack. Do not let a shared global PlatformIO cache choose framework metadata or a compiler implicitly, and do not override just the @@ -25,9 +24,8 @@ matching framework, uploader, and compiler package set. Core 3 Wi-Fi builds need the C++14, `SOC_WIFI_SUPPORTED`, and `Network/src` settings in this repository's `platformio.ini`; keep those settings together -when adding an ESP32 environment. The portal OTA fixture uses the current -3.3.11 lane for ESP32 even while the 3.0.5 compatibility lane remains -available. +when adding an ESP32 environment. The portal OTA fixture and every guided +example use this 3.3.11 ESP32 baseline. ESP8266 test environments pin framework commit `521ae60` for the upstream Postmortem large-jump linker fix. The exact rationale and update rule are in @@ -35,18 +33,17 @@ the shared [ESP8266 linker-workaround note](https://github.com/alexhopeoconnor/a For the pioarduino release-to-Core mapping and cache-collision diagnosis, see [DeviceFramework's toolchain guide](https://github.com/alexhopeoconnor/DeviceFramework/blob/main/docs/TOOLCHAINS.md). -`./scripts/test.sh` automatically places the `esp32-current` lane, and -`./tools/portal-hardware ota --platform esp32` places its current A/B fixture, +`./scripts/test.sh` and `./tools/portal-hardware ota --platform esp32` place +the ESP32 A/B fixture, in a dedicated PlatformIO Core/cache directory, defaulting to `${XDG_CACHE_HOME:-$HOME/.cache}/wifimanager-platformio/core-3.3.11`. That keeps pioarduino's package-form `esptool` and generated environment separate -from the legacy 3.0.5 `tool-esptoolpy` graph. Override the location with +from stale global `tool-esptoolpy` metadata. Override the location with `WIFIMANAGER_PLATFORMIO_CORE_DIR`, `WIFIMANAGER_PLATFORMIO_PACKAGES_DIR`, and `WIFIMANAGER_PLATFORMIO_CACHE_DIR` when space belongs elsewhere. The first -clean install is several GiB; reserve at least 4 GiB plus cache headroom. It -is a deliberate quarantine, not a reason to delete or override packages in the -shared PlatformIO installation. +first install is several GiB; reserve at least 4 GiB plus cache headroom. It is +persistent and is never cleared by normal test commands. For a disposable cache investigation, point that variable at an exact temporary directory, run the affected command, inspect the resolved graph, then remove @@ -55,9 +52,7 @@ only that directory: ```bash wm_pio_core="$(mktemp -d /tmp/wifimanager-pio-XXXXXX)" WIFIMANAGER_PLATFORMIO_CORE_DIR="$wm_pio_core" \ - ./scripts/test.sh compile --platform esp32-current -WIFIMANAGER_PLATFORMIO_CORE_DIR="$wm_pio_core" \ - ./scripts/test.sh packages --platform esp32-current + ./scripts/test.sh compile --platform esp32 rm -rf -- "$wm_pio_core" ``` @@ -69,15 +64,12 @@ Start a release with `bump-version.sh`. It updates package metadata and canonica ./scripts/check-docs.sh ./scripts/test.sh compile --platform esp8266 ./scripts/test.sh compile --platform esp32 -./scripts/test.sh compile --platform esp32-current ./scripts/test.sh unity --platform esp8266 ./scripts/test.sh unity --platform esp32 -./scripts/test.sh unity --platform esp32-current -./scripts/test.sh packages --platform esp32-current ./scripts/test.sh examples --platform esp8266 ./scripts/test.sh examples --platform esp32 ./scripts/test.sh ota-fixtures --platform esp8266 -./scripts/test.sh ota-fixtures --platform esp32-current +./scripts/test.sh ota-fixtures --platform esp32 ./scripts/prepare-release.sh vMAJOR.MINOR.PATCH --tag ``` @@ -89,7 +81,7 @@ When a physical ESP8266 and ESP32 are available, include their local lifecycle t ~~~ 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 +run the Docker portal test harness 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: @@ -105,10 +97,10 @@ host setup, not a test secret; never add a sudo value to an env file or run the whole runner as root. See [Testing](TESTING.md#networkmanager-authorization) for the direct/Polkit and scoped-sudo behavior. -See [Testing](TESTING.md#docker-portal-contract) for cleanup, artifacts, and +See [Testing](TESTING.md#docker-portal-test-harness) for cleanup, artifacts, and optional station handoff credentials. -Run the portal HTTP OTA A/B contract separately when a spare adapter and 4 MB +Run the portal HTTP OTA A/B test harness separately when a spare adapter and 4 MB test board are available. It erases the selected board's flash, serial-flashes A, and uses the real browser update form to upload B; do not replace its automatic-reboot assertion with a manual reset: @@ -118,7 +110,7 @@ automatic-reboot assertion with a manual reset: --client-interface wlx74da385d4165 ``` -See [Portal HTTP OTA A/B contract](TESTING.md#portal-http-ota-ab-contract) for +See [Portal HTTP OTA A/B test harness](TESTING.md#portal-http-ota-ab-test-harness) for the partition, artifact, final-board-state, and adapter rules. 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. diff --git a/docs/PORTAL_UI.md b/docs/PORTAL_UI.md index ee140d0..1c58711 100644 --- a/docs/PORTAL_UI.md +++ b/docs/PORTAL_UI.md @@ -6,7 +6,7 @@ Apply presentation before autoConnect(), startConfigPortal(), or startWebPortal( ## Portal views -These ESP32 captures use the same real-board portal contract described in +These ESP32 captures use the same real-board portal test harness described in [Testing](TESTING.md). The nearby networks shown are the networks visible to the capture device when the portal scans. @@ -61,7 +61,7 @@ void setup() { void loop() { wifi.process(); } ~~~ -The complete buildable example is [Branded Portal](../examples/BrandedPortal/BrandedPortal.ino). The compile fixture exercises this API on ESP8266 and ESP32. +The complete buildable example is [Branded Portal](../examples/BrandedPortal/BrandedPortal.ino). The compile fixture exercises this API on ESP8266 and the maintained ESP32 3.3.11 baseline. ## Presentation reference diff --git a/docs/TESTING.md b/docs/TESTING.md index 11303a9..01a97b5 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,13 +1,13 @@ # Testing -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, +WiFiManager separates repeatable board-free builds 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. -| Physical contract | Transport | Host adapter | Secret source | Required proof | +| Physical test harness | Transport | Host adapter | Secret source | Required proof | | --- | --- | --- | --- | --- | -| Portal lifecycle suite | Serial flash + captive-portal HTTP/browser | Named secondary adapter | safe fixture AP password | Unity/lifecycle checks and portal UI/API contract | -| Portal HTTP OTA | WiFiManager multipart `POST /u` | Named secondary adapter | safe fixture AP password | A → rendered browser upload B → automatic reboot → B twice | +| Portal lifecycle suite | Serial flash + captive-portal HTTP/browser | Named secondary adapter | safe fixture AP password | Unity/lifecycle checks and portal UI/API coverage | +| Portal HTTP OTA | WiFiManager multipart `POST /u` | Named secondary adapter | safe fixture AP password | serial A → updater accepts/completes B → serial B, plus browser automatic-reboot/B-twice proof | The selected secondary adapter is intentionally never used for normal LAN testing. It is `never-default`, so the host's ordinary route remains intact. @@ -15,29 +15,26 @@ testing. It is `never-default`, so the host's ordinary route remains intact. ## Board-free fixture, consumer, and example builds The Unity compile check builds WiFiManager's own fixture without a board. The -clean-consumer check builds a project that declares only WiFiManager. Together -they prove 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. +consumer check builds a project that declares only WiFiManager, proving that a +normal PlatformIO dependency resolution can compile DFTE, ESPAsyncWebServer, +and the correct ESP8266 or ESP32 TCP dependency. Normal commands reuse the +persistent PlatformIO cache; they do not delete, reinstall, or separately +assert the package graph. ```bash ./scripts/test.sh compile --platform esp8266 ./scripts/test.sh compile --platform esp32 -./scripts/test.sh compile --platform esp32-current ./scripts/test.sh unity --platform esp8266 ./scripts/test.sh unity --platform esp32 -./scripts/test.sh unity --platform esp32-current ./scripts/test.sh examples --platform esp8266 ./scripts/test.sh examples --platform esp32 ./scripts/test.sh ota-fixtures --platform esp8266 -./scripts/test.sh ota-fixtures --platform esp32-current +./scripts/test.sh ota-fixtures --platform esp32 ``` CI runs these board-free checks for pull requests and pushes to the maintained -branch. `esp32` remains the explicit Arduino-ESP32 3.0.5 compatibility lane; -`esp32-current` is the clean-consumer Arduino-ESP32 3.3.11 validation lane. -The OTA fixture builds also use 3.3.11 for ESP32 and compile both immutable A +branch. `esp32` uses Arduino-ESP32 3.3.11 and compiles the guided examples. +The OTA fixture builds compile both immutable A and B images against their tracked OTA partition layout. CI rejects equal A/B artifacts, an ESP32 image larger than either 0x1F0000-byte app slot, or a partition-table edit that breaks the required two-slot/no-filesystem layout. These checks @@ -60,15 +57,15 @@ pio device list 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 and DeviceFramework's hardware runners on the same host, so +portal test harness and DeviceFramework's hardware runners on the same host, so two first-party invocations cannot flash or use the same board at once. -## Docker portal contract +## Docker portal test harness `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 +Wi-Fi adapter, then runs its HTTP and browser test harness 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. @@ -125,11 +122,11 @@ on failure, JSON results, and the HTML report are saved under the printed XDG state-directory artifact path. On ESP8266, an AP+STA scan can briefly move the radio off the AP channel. The -client may reconnect during that interval; the contract deliberately retries +client may reconnect during that interval; the test harness deliberately retries that transport interruption and still requires a reachable portal with a complete, valid scan result. -The normal browser contract catches the common regression case. When changing +The normal browser test harness catches the common regression case. When changing parameter rendering, run the opt-in ESP8266 soak as well. It performs twelve full browser renders and API fetches while the AP is active, asserting all thirteen fields and their exact values on every pass. This targets the @@ -155,8 +152,25 @@ only that managed connection when finished: 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. +credentials, and pass it explicitly. The runner parses only `WIFI_SSID` and +`WIFI_PASSWORD` into a generated mode-600 two-key file, mounts that file +read-only into the test container, and removes it after the browser run; it +never mounts the complete local environment file or logs either value. Because +browser traces can retain request bodies, this opt-in mode disables Playwright +screenshots, video, and tracing, including explicit diagnostic screenshots. It +cannot be combined with README-media capture. Docker builds from the tracked +`tests/portal-harness` directory only, so neither the source environment file +nor the generated two-key file enters its build context. Keep its private output +directory private and review any remaining report before sharing it. + +After either a passing or failing station-handoff attempt, the runner +serial-flashes the portal-only fixture once more. Its `setup()` clears saved +station settings, so the selected test board returns to the clean no-station +portal state and does not retain the developer's Wi-Fi credentials. A failed +restore or a failure to see the cleaned fixture AP return makes the command +fail. `--keep` affects only the runner's temporary +secondary-adapter connection; it does not retain station credentials on the +board. A retained session is deliberately never overwritten. Before touching NetworkManager, the runner atomically records its uniquely generated connection @@ -174,7 +188,7 @@ cp test/portal-station.env.example test/portal-station.env ## Refresh README media README media is an explicit ESP32-only capture, not part of normal testing or -CI. It uses the same real-board portal contract above, but records a short +CI. It uses the same real-board portal test harness above, but records a short browser tour and stores all candidate files under the ignored `artifacts/readme-media/` directory by default: @@ -202,11 +216,11 @@ successful ESP32 media manifest, checks file types and size limits, and never copies raw video, browser reports, traces, or arbitrary artifact files. The renderer preserves the real recording but deliberately presents it at 1.25× duration and 6 fps so the -README tour is readable; it does not change normal browser-contract timing. -ESP8266 remains covered by the normal hardware and browser contract but does +README tour is readable; it does not change normal browser-test-harness timing. +ESP8266 remains covered by the normal hardware and browser test harness but does not produce duplicate README media. -## Portal HTTP OTA A/B contract +## Portal HTTP OTA A/B test harness `portal-hardware ota` is a separate opt-in physical test for WiFiManager's built-in HTTP update path. It exercises the rendered firmware-update page and @@ -220,13 +234,13 @@ its real multipart `POST /u` request; it is not an ArduinoOTA/UDP test. ``` The selected `--client-interface` has exactly the same safety rules as the -normal portal contract: it must be the explicitly named secondary adapter and +normal portal test harness: it must be the explicitly named secondary adapter and cannot be the host default-route interface. The test never attaches that adapter to a normal station network. The fixture AP uses the safe local `default1` WPA password; this is an AP-access test, not a claim that `/u` has HTTP route authentication. -The runner performs the following complete contract: +The test harness performs the following complete run: 1. Builds immutable A and B fixture images. Their marker is compiled into the binary, not saved in WiFiManager settings or EEPROM. @@ -242,6 +256,18 @@ The runner performs the following complete contract: 6. Requires the real `POST /u` success response, an automatic portal outage, automatic restart, and two independent B-marker responses. +The OTA command additionally requires Python with PySerial (the +`python3-serial` package on Debian/Ubuntu) and retains a passive, +no-reset `serial-ota.log` beside the browser artifacts. It attaches immediately +after serial-flashing A releases the port—before portal association and the A +marker check—and remains attached through the two B checks. A passing run +requires the log's ordered immutable A marker, WiFiManager's update-start and +update-complete lines, then immutable B marker. This preserves firmware-side +portal-start and DHCP evidence as well as OTA evidence, without manufacturing a +reset. OTA-only fixture images wait five seconds after their upload reset so +the passive recorder can attach before A/B boot evidence is emitted; ordinary +portal test-harness startup remains fast. + The fixture marker endpoint exists only in `test/portal-harness`; it is not a WiFiManager library route or a product-firmware pattern. The test does not issue a manual reset. A board which boots B only after intervention is a diff --git a/examples/BasicPortal/platformio.ini b/examples/BasicPortal/platformio.ini index 7e1654e..0daa15f 100644 --- a/examples/BasicPortal/platformio.ini +++ b/examples/BasicPortal/platformio.ini @@ -17,7 +17,7 @@ platform_packages = [env:esp32] extends = common -platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip board = esp32dev build_unflags = -std=gnu++11 build_flags = diff --git a/examples/BrandedPortal/platformio.ini b/examples/BrandedPortal/platformio.ini index 7e1654e..0daa15f 100644 --- a/examples/BrandedPortal/platformio.ini +++ b/examples/BrandedPortal/platformio.ini @@ -17,7 +17,7 @@ platform_packages = [env:esp32] extends = common -platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip board = esp32dev build_unflags = -std=gnu++11 build_flags = diff --git a/examples/CustomPortalContent/platformio.ini b/examples/CustomPortalContent/platformio.ini index 7e1654e..0daa15f 100644 --- a/examples/CustomPortalContent/platformio.ini +++ b/examples/CustomPortalContent/platformio.ini @@ -17,7 +17,7 @@ platform_packages = [env:esp32] extends = common -platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip board = esp32dev build_unflags = -std=gnu++11 build_flags = diff --git a/examples/StationProfiles/platformio.ini b/examples/StationProfiles/platformio.ini index 7e1654e..0daa15f 100644 --- a/examples/StationProfiles/platformio.ini +++ b/examples/StationProfiles/platformio.ini @@ -17,7 +17,7 @@ platform_packages = [env:esp32] extends = common -platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip board = esp32dev build_unflags = -std=gnu++11 build_flags = diff --git a/lib/WiFiManager/src/WiFiManagerHandlers.cpp b/lib/WiFiManager/src/WiFiManagerHandlers.cpp index c015ed5..30833aa 100644 --- a/lib/WiFiManager/src/WiFiManagerHandlers.cpp +++ b/lib/WiFiManager/src/WiFiManagerHandlers.cpp @@ -939,6 +939,11 @@ void WiFiManagerHandlers::handleUpdating(AsyncWebServerRequest *request, String } #ifdef ESP8266 + // ESPAsyncWebServer invokes this upload callback from the ESP8266 SYS + // context. The core's default Updater mode yields around flash erases + // and writes, but yield() panics from that context. Tell the core this + // upload is asynchronous before the first Update call. + Update.runAsync(true); WiFiUDP::stopAll(); uint32_t maxSketchSpace = (ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000; #elif defined(ESP32) @@ -1610,4 +1615,3 @@ void WiFiManagerHandlers::handleApiPortalExit(AsyncWebServerRequest *request) { } #endif // defined(ESP8266) || defined(ESP32) - diff --git a/library.json b/library.json index f4c6a7e..80a454a 100644 --- a/library.json +++ b/library.json @@ -1,6 +1,6 @@ { "name": "WiFiManager", - "version": "3.2.4", + "version": "3.2.5", "keywords": [ "wifi", "wi-fi", diff --git a/platformio.ini b/platformio.ini index c740fe8..7062626 100644 --- a/platformio.ini +++ b/platformio.ini @@ -16,11 +16,11 @@ build_flags = -DUNIT_TEST lib_deps = ESP32Async/ESPAsyncWebServer@3.9.1 - DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.2.0 + DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.2.1 ESP32Async/ESPAsyncTCP@2.0.0 [env:esp32] -platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip board = esp32dev framework = arduino monitor_speed = 115200 @@ -36,16 +36,9 @@ build_flags = -DUNIT_TEST lib_deps = ESP32Async/ESPAsyncWebServer@3.9.1 - DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.2.0 + DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.2.1 ESP32Async/AsyncTCP@^3.4.9 -; Current ESP32 validation lane. Keep Core 3.0.5 as a named compatibility -; target until its support policy is explicitly retired; never let a shared -; PlatformIO cache choose a framework/toolchain pair implicitly. -[env:esp32_core_3_3_11] -extends = env:esp32 -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip - ; Optional: compile tests with DFTE logs bridged into WiFiManager::log (see README) [env:esp8266_dfte_log] extends = env:esp8266 diff --git a/scripts/test.sh b/scripts/test.sh index 40ec3fc..a939a85 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -4,18 +4,17 @@ set -euo pipefail usage() { cat <<'USAGE' >&2 Usage: - ./scripts/test.sh compile --platform esp8266|esp32|esp32-current - ./scripts/test.sh unity --platform esp8266|esp32|esp32-current + ./scripts/test.sh compile --platform esp8266|esp32 + ./scripts/test.sh unity --platform esp8266|esp32 ./scripts/test.sh examples --platform esp8266|esp32 - ./scripts/test.sh ota-fixtures --platform esp8266|esp32|esp32-current - ./scripts/test.sh packages --platform esp8266|esp32|esp32-current + ./scripts/test.sh ota-fixtures --platform esp8266|esp32 ./scripts/test.sh hardware --platform esp8266|esp32 --port /dev/serial/by-id/... USAGE exit 2 } mode="${1:-}" -[[ "$mode" == "compile" || "$mode" == "unity" || "$mode" == "examples" || "$mode" == "ota-fixtures" || "$mode" == "packages" || "$mode" == "hardware" ]] || usage +[[ "$mode" == "compile" || "$mode" == "unity" || "$mode" == "examples" || "$mode" == "ota-fixtures" || "$mode" == "hardware" ]] || usage shift platform="" @@ -29,41 +28,23 @@ while [[ $# -gt 0 ]]; do done case "$platform" in - esp8266|esp32) - environment="$platform" - ;; - esp32-current) - environment="esp32_core_3_3_11" - ;; + esp8266|esp32) environment="$platform" ;; *) usage ;; esac -[[ "$mode" != "examples" || "$platform" != "esp32-current" ]] || { - echo "The current ESP32 lane is a clean-consumer check; examples retain their explicit compatibility environments." >&2 - exit 2 -} -[[ "$mode" != "hardware" || "$platform" != "esp32-current" ]] || { - echo "The current ESP32 lane is a board-free clean-consumer check; use esp32 for the existing Unity hardware suite." >&2 - exit 2 -} -[[ "$mode" != "ota-fixtures" || "$platform" != "esp32" ]] || { - echo "ESP32 OTA fixtures are pinned to the isolated 3.3.11 graph; use --platform esp32-current." >&2 - exit 2 -} [[ "$mode" != "hardware" || -n "$port" ]] || usage [[ "$mode" != "hardware" || -e "$port" ]] || { echo "Serial port not found: $port" >&2; exit 1; } root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" pio_for_platform() { - if [[ "$platform" != "esp32-current" ]]; then + if [[ "$platform" != "esp32" ]]; then pio "$@" return fi - # pioarduino Core 3.3.11's esptool package form cannot safely share a - # PlatformIO Core directory with a legacy Core 3.0.5 tool-esptoolpy - # installation. Keep the current validation lane in a project-owned, - # user-cache location unless the developer deliberately supplies one. + # Keep the maintained Core 3.3.11 package form in a persistent project + # cache. It is never cleared by this script and avoids stale global + # package metadata selecting an incompatible uploader. local core_dir packages_dir cache_dir core_dir="${WIFIMANAGER_PLATFORMIO_CORE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/wifimanager-platformio/core-3.3.11}" packages_dir="${WIFIMANAGER_PLATFORMIO_PACKAGES_DIR:-$core_dir/packages}" @@ -86,7 +67,7 @@ assert_ota_fixture_pair() { echo "Portal OTA fixture A and B are identical." >&2 return 1 fi - if [[ "$platform" == "esp32-current" ]]; then + if [[ "$platform" == "esp32" ]]; then capacity=$((0x1F0000)) for firmware in "$firmware_a" "$firmware_b"; do size="$(wc -c < "$firmware" | tr -d '[:space:]')" @@ -112,7 +93,7 @@ if [[ "$mode" == "examples" ]]; then exit 1 fi for example in "${examples[@]}"; do - pio_for_platform run -d "$example" -e "$platform" /dev/null -fi pio_for_platform run -d "$root/test/compile-project" -e "$environment" echo "WiFiManager consumer compile check passed for $platform" diff --git a/test/compile-project/platformio.ini b/test/compile-project/platformio.ini index c324420..2cdb011 100644 --- a/test/compile-project/platformio.ini +++ b/test/compile-project/platformio.ini @@ -20,7 +20,7 @@ lib_deps = [env:esp32] extends = common -platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip board = esp32dev build_unflags = -std=gnu++11 build_flags = @@ -30,9 +30,3 @@ build_flags = -DWM_LOG_LEVEL=3 lib_deps = ${common.lib_deps} - -; Clean-consumer validation against the maintained ESP32 platform lane. This -; is intentionally separate from the temporary 3.0.5 compatibility target. -[env:esp32_core_3_3_11] -extends = env:esp32 -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip diff --git a/test/portal-harness/README.md b/test/portal-harness/README.md index 5f2676a..5c4d74a 100644 --- a/test/portal-harness/README.md +++ b/test/portal-harness/README.md @@ -11,7 +11,7 @@ Use it through the repository runner so a secondary Wi-Fi adapter is explicitly --client-interface wlx... ``` -The ESP8266 portal SSID is `WM Contract ESP8266`; the ESP32 SSID is `WM Contract ESP32`. Both use `default1` exclusively for local development tests. +The ESP8266 portal SSID is `WM Test Harness ESP8266`; the ESP32 SSID is `WM Test Harness ESP32`. Both use `default1` exclusively for local development tests. The runner cleans up only the temporary connection it creates on the named secondary interface. It refuses to run if that interface is the system default route. @@ -26,9 +26,11 @@ the `WM_NMCLI_AUTH` options. ## A/B portal OTA fixture The same fixture has dedicated `*_ota_a` and `*_ota_b` PlatformIO environments -for the physical portal HTTP OTA contract. A and B differ only by a compiled -marker served from the fixture-only `/api/test/firmware-marker` endpoint. That -proves a B boot without trusting saved portal values, EEPROM, or a filename. +for the physical portal HTTP OTA test harness. A and B differ only by a compiled +marker served from the fixture-only `/api/test/firmware-marker` endpoint and an +immutable serial boot marker. The test harness requires serial A, updater +start/completion, then serial B, so it proves a B boot without trusting saved +portal values, EEPROM, or a filename. ```bash ./tools/portal-hardware ota \ @@ -43,4 +45,6 @@ ESP8266 explicitly uses `eagle.flash.4m1m.ld`. ESP32 uses the tracked two-slot and B before it touches the board, validates the matching ESP32 slots (or the live ESP8266 updater capacity), then erases the explicitly selected test board before serial-flashing A. A successful run leaves B installed in the -portal-only fixture. +portal-only fixture. It requires Python PySerial and retains a passive, +no-reset `serial-ota.log` in the private run artifact directory for both +success and failure diagnosis. diff --git a/test/portal-harness/platformio.ini b/test/portal-harness/platformio.ini index 8ebea3e..dbd5a4d 100644 --- a/test/portal-harness/platformio.ini +++ b/test/portal-harness/platformio.ini @@ -18,7 +18,7 @@ build_flags = [env:esp32] extends = common -platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip board = esp32dev build_unflags = -std=gnu++11 build_flags = @@ -27,14 +27,7 @@ build_flags = -I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src -DWM_LOG_LEVEL=4 -; The maintained ESP32 validation lane. Keep this named rather than relying on -; whatever framework/toolchain pair happens to be installed in PlatformIO's -; global package cache. -[env:esp32_core_3_3_11] -extends = env:esp32 -platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip - -; OTA contract images deliberately differ only by their compile-time marker. +; OTA test-harness images deliberately differ only by their compile-time marker. ; ESP8266 needs an OTA-capable linker layout; ESP32 needs a tracked two-slot ; partition table. The hardware runner always flashes A over serial then ; uploads B through the real portal form. @@ -53,15 +46,15 @@ build_flags = -DWM_OTA_TEST_IMAGE=\"B\" [env:esp32_ota_a] -extends = env:esp32_core_3_3_11 +extends = env:esp32 board_build.partitions = partitions/esp32_ota_4m_no_fs.csv build_flags = - ${env:esp32_core_3_3_11.build_flags} + ${env:esp32.build_flags} -DWM_OTA_TEST_IMAGE=\"A\" [env:esp32_ota_b] -extends = env:esp32_core_3_3_11 +extends = env:esp32 board_build.partitions = partitions/esp32_ota_4m_no_fs.csv build_flags = - ${env:esp32_core_3_3_11.build_flags} + ${env:esp32.build_flags} -DWM_OTA_TEST_IMAGE=\"B\" diff --git a/test/portal-harness/src/main.cpp b/test/portal-harness/src/main.cpp index 0f55b89..253fd26 100644 --- a/test/portal-harness/src/main.cpp +++ b/test/portal-harness/src/main.cpp @@ -6,24 +6,33 @@ namespace { // These markers belong only to the portal hardware fixture. They are compiled // into the image rather than stored in Wi-FiManager settings, so an A -> B // assertion proves that the new firmware booted after the updater restarted -// the board. Normal portal-contract builds retain a descriptive fixture value. -#ifndef WM_OTA_TEST_IMAGE -#define WM_OTA_TEST_IMAGE "portal-contract" +// the board. Normal portal test-harness builds retain a descriptive fixture +// value. +#if defined(WM_OTA_TEST_IMAGE) +// PlatformIO releases the serial port only after the upload-triggered reset. +// The physical OTA test harness then attaches passively, so give it the same +// explicit window as the DeviceFramework A/B fixtures before boot evidence or +// portal work begins. Normal portal test-harness builds keep the short delay. +constexpr unsigned long kSerialMonitorAttachDelayMs = 5000UL; +#else +#define WM_OTA_TEST_IMAGE "portal-harness" +constexpr unsigned long kSerialMonitorAttachDelayMs = 300UL; #endif #if defined(ESP8266) -constexpr char kPortalSsid[] = "WM Contract ESP8266"; +constexpr char kPortalSsid[] = "WM Test Harness ESP8266"; #else -constexpr char kPortalSsid[] = "WM Contract ESP32"; +constexpr char kPortalSsid[] = "WM Test Harness ESP32"; #endif constexpr char kPortalPassword[] = "default1"; WiFiManager wifi; WiFiManagerParameter kInstallationLabel( - "installation_label", "Installation label", "Contract fixture", 32); + "installation_label", "Installation label", "Harness fixture", 32); // Keep the characters from upstream issue #1863 in the portal fixture. The -// browser contract verifies this value through JSON, DOM rendering, save, and -// a subsequent reload rather than relying on a string-only serializer check. +// browser test harness verifies this value through JSON, DOM rendering, save, +// and a subsequent reload rather than relying on a string-only serializer +// check. WiFiManagerParameter kEscapedValue( "escaped_value", "Escaped value", "7(f+4]2y3fsYTQt'Uhxc\"d\\<>&", 64); WiFiManagerParameter kMqttHost( @@ -45,7 +54,7 @@ WiFiManagerParameter kLongitude( WiFiManagerParameter kFirmwareChannel( "firmware_channel", "Firmware channel", "stable", 16); WiFiManagerParameter kOwnerName( - "owner_name", "Owner name", "Portal contract", 48); + "owner_name", "Owner name", "Portal test harness", 48); WiFiManagerParameter kNotes( "notes", "Notes", "Thirteen-field rendering fixture", 64); @@ -91,7 +100,12 @@ void registerOtaTestMarker() { void setup() { Serial.begin(115200); - delay(300); + delay(kSerialMonitorAttachDelayMs); + // The physical HTTP OTA test harness records this immutable marker before + // and after its browser upload. It cannot be faked by saved portal values + // or an HTTP response from a stale image. + Serial.print(F("WiFiManager portal OTA fixture image: ")); + Serial.println(WM_OTA_TEST_IMAGE); // This fixture must be independent of whichever sketch was previously // flashed to the board. Clear saved station credentials before starting @@ -107,7 +121,7 @@ void setup() { IPAddress(255, 255, 255, 0)); registerOtaTestMarker(); // Keep custom parameters on their own native Save parameters page. This - // lets the browser contract exercise a parameter-only submit without + // lets the browser test harness exercise a parameter-only submit without // starting a station connection as part of the regression test. wifi.portalSetLayoutParamsLocation(PortalParamsLocation::SetupPage); for (auto* parameter : kPortalParameters) { diff --git a/test/portal-station.env.example b/test/portal-station.env.example index 9734395..dc9bdd0 100644 --- a/test/portal-station.env.example +++ b/test/portal-station.env.example @@ -1,4 +1,5 @@ # Ignored local credentials for the optional station handoff test. -# These values are mounted read-only into the Docker test container. +# The runner copies only these two values into a private one-run file mounted +# read-only into Docker; any other local env entries are not passed through. WIFI_SSID=replace-me WIFI_PASSWORD=replace-me diff --git a/test/test_wifimanager/test_main.cpp b/test/test_wifimanager/test_main.cpp index fb5d185..865a185 100644 --- a/test/test_wifimanager/test_main.cpp +++ b/test/test_wifimanager/test_main.cpp @@ -40,7 +40,7 @@ TestCase tests[] = { TEST_ENTRY(test_get_config_portal_ssid), TEST_ENTRY(test_bootstrap_json_portal_feature_flags), TEST_ENTRY(test_portal_default_presentation), - TEST_ENTRY(test_bootstrap_json_contract_v3), + TEST_ENTRY(test_bootstrap_json_schema_v3), TEST_ENTRY(test_bootstrap_json_snapshot_consistency), TEST_ENTRY(test_root_render_interleaved_context_isolation), TEST_ENTRY(test_portal_presentation_configuration), diff --git a/test/test_wifimanager/test_main.h b/test/test_wifimanager/test_main.h index 65cb99c..3dabd66 100644 --- a/test/test_wifimanager/test_main.h +++ b/test/test_wifimanager/test_main.h @@ -39,7 +39,7 @@ void test_config_portal_already_active(); void test_get_config_portal_ssid(); void test_bootstrap_json_portal_feature_flags(); void test_portal_default_presentation(); -void test_bootstrap_json_contract_v3(); +void test_bootstrap_json_schema_v3(); void test_bootstrap_json_snapshot_consistency(); void test_root_render_interleaved_context_isolation(); void test_portal_presentation_configuration(); diff --git a/test/test_wifimanager/tests/test_root_render_lifecycle.cpp b/test/test_wifimanager/tests/test_root_render_lifecycle.cpp index fb9a9f9..11a5e65 100644 --- a/test/test_wifimanager/tests/test_root_render_lifecycle.cpp +++ b/test/test_wifimanager/tests/test_root_render_lifecycle.cpp @@ -87,8 +87,8 @@ void test_portal_default_presentation() { TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"logoAltText\":\"\""))); } -void test_bootstrap_json_contract_v3() { - Serial.println("[TEST] Testing bootstrap JSON v3 contract..."); +void test_bootstrap_json_schema_v3() { + Serial.println("[TEST] Testing bootstrap JSON v3 schema..."); WiFiManager wm; WiFiManagerHandlers handlers(&wm); @@ -143,7 +143,7 @@ void test_bootstrap_json_contract_v3() { TEST_ASSERT_EQUAL(-1, j.indexOf(F("\"portalTimeoutSecondsRemaining\":0"))); wm.wmTestSetPortalActive(false); - Serial.println("[TEST] Bootstrap JSON v2 contract test completed successfully"); + Serial.println("[TEST] Bootstrap JSON v3 schema test completed successfully"); } void test_bootstrap_json_snapshot_consistency() { diff --git a/tests/portal-harness/.dockerignore b/tests/portal-harness/.dockerignore new file mode 100644 index 0000000..56ecc7c --- /dev/null +++ b/tests/portal-harness/.dockerignore @@ -0,0 +1,10 @@ +# The harness image needs only checked-in browser-test sources. Keep local +# artifacts and any credential-shaped file out even when a developer chooses +# an output path inside this directory. +node_modules +artifacts +**/artifacts +*.env +**/*.env +.portal-station.* +**/.portal-station.* diff --git a/tests/portal-contract/Dockerfile b/tests/portal-harness/Dockerfile similarity index 61% rename from tests/portal-contract/Dockerfile rename to tests/portal-harness/Dockerfile index 74ec3e7..9701e67 100644 --- a/tests/portal-contract/Dockerfile +++ b/tests/portal-harness/Dockerfile @@ -1,17 +1,17 @@ ARG PLAYWRIGHT_VERSION=1.63.0 -FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble AS portal-contract +FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble AS portal-harness ARG PLAYWRIGHT_VERSION ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 WORKDIR /work -COPY tests/portal-contract/package.json tests/portal-contract/package-lock.json ./ +COPY package.json package-lock.json ./ RUN npm ci --ignore-scripts && \ [ "$(node -p "require('@playwright/test/package.json').version")" = "$PLAYWRIGHT_VERSION" ] -COPY tests/portal-contract/ ./ -RUN chmod 755 /work/run-portal-contract.sh /work/render-readme-media.sh -CMD ["/work/run-portal-contract.sh"] +COPY . ./ +RUN chmod 755 /work/run-portal-harness.sh /work/render-readme-media.sh +CMD ["/work/run-portal-harness.sh"] -FROM portal-contract AS media +FROM portal-harness AS media USER root RUN apt-get update && \ apt-get install -y --no-install-recommends ffmpeg && \ diff --git a/tests/portal-contract/README.md b/tests/portal-harness/README.md similarity index 61% rename from tests/portal-contract/README.md rename to tests/portal-harness/README.md index def6717..b9d1916 100644 --- a/tests/portal-contract/README.md +++ b/tests/portal-harness/README.md @@ -1,4 +1,4 @@ -# Portal contract container +# Portal test-harness container This directory contains the browser/API half of the real-hardware portal test. Run it through [`../../tools/portal-hardware`](../../tools/portal-hardware), not @@ -11,5 +11,11 @@ Artifacts, traces, screenshots, JSON results, and the HTML report are written to the output directory printed by the host command. `compose.ota.yaml` is an overlay used only by `portal-hardware ota`. It mounts -the already-built B firmware read-only and enables the A/B browser contract. -The ordinary portal contract never receives a firmware artifact. +the already-built B firmware read-only and enables the A/B browser test +harness. The ordinary portal test harness never receives a firmware artifact. + +`compose.station.yaml` is used only by the opt-in station-handoff command. The +host runner stages only `WIFI_SSID` and `WIFI_PASSWORD` in a mode-600 temporary +file instead of mounting the developer's complete environment file. That mode +disables Playwright screenshots, video, and tracing because request bodies can +contain the local password. diff --git a/tests/portal-contract/compose.ota.yaml b/tests/portal-harness/compose.ota.yaml similarity index 82% rename from tests/portal-contract/compose.ota.yaml rename to tests/portal-harness/compose.ota.yaml index 7219cd5..b170ac4 100644 --- a/tests/portal-contract/compose.ota.yaml +++ b/tests/portal-harness/compose.ota.yaml @@ -1,8 +1,8 @@ -# OTA-only overlay. The normal portal contract neither builds nor mounts a +# OTA-only overlay. The normal portal test harness neither builds nor mounts a # firmware image. portal-hardware supplies this absolute path after it has # built B and copied it into the run's private artifact directory. services: - portal-contract: + portal-harness: environment: PORTAL_OTA_FIRMWARE: /firmware/portal-ota-b.bin PORTAL_OTA_INITIAL_MARKER: ${PORTAL_OTA_INITIAL_MARKER:-A} diff --git a/tests/portal-contract/compose.station.yaml b/tests/portal-harness/compose.station.yaml similarity index 91% rename from tests/portal-contract/compose.station.yaml rename to tests/portal-harness/compose.station.yaml index 8b981c9..22dac8f 100644 --- a/tests/portal-contract/compose.station.yaml +++ b/tests/portal-harness/compose.station.yaml @@ -1,5 +1,5 @@ services: - portal-contract: + portal-harness: environment: PORTAL_STATION_ENV: /run/secrets/portal-station.env volumes: diff --git a/tests/portal-contract/compose.yaml b/tests/portal-harness/compose.yaml similarity index 61% rename from tests/portal-contract/compose.yaml rename to tests/portal-harness/compose.yaml index ac7411f..111589a 100644 --- a/tests/portal-contract/compose.yaml +++ b/tests/portal-harness/compose.yaml @@ -1,9 +1,13 @@ services: - portal-contract: + portal-harness: build: - context: ../.. - dockerfile: tests/portal-contract/Dockerfile - target: ${PORTAL_CONTRACT_DOCKER_TARGET:-portal-contract} + # Keep Docker's build context limited to the browser test harness. A + # developer may point --station-env or --output anywhere in the repo; + # neither source credentials nor the generated two-key file may cross + # the host-to-Docker boundary during image build. + context: . + dockerfile: Dockerfile + target: ${PORTAL_HARNESS_DOCKER_TARGET:-portal-harness} args: PLAYWRIGHT_VERSION: "1.63.0" network_mode: host diff --git a/tests/portal-contract/package-lock.json b/tests/portal-harness/package-lock.json similarity index 94% rename from tests/portal-contract/package-lock.json rename to tests/portal-harness/package-lock.json index d4abf11..c572ae4 100644 --- a/tests/portal-contract/package-lock.json +++ b/tests/portal-harness/package-lock.json @@ -1,11 +1,11 @@ { - "name": "wifimanager-portal-contract", + "name": "wifimanager-portal-harness", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "wifimanager-portal-contract", + "name": "wifimanager-portal-harness", "version": "1.0.0", "devDependencies": { "@playwright/test": "1.63.0" diff --git a/tests/portal-contract/package.json b/tests/portal-harness/package.json similarity index 79% rename from tests/portal-contract/package.json rename to tests/portal-harness/package.json index 50dcd5a..efab2e8 100644 --- a/tests/portal-contract/package.json +++ b/tests/portal-harness/package.json @@ -1,5 +1,5 @@ { - "name": "wifimanager-portal-contract", + "name": "wifimanager-portal-harness", "private": true, "version": "1.0.0", "scripts": { diff --git a/tests/portal-contract/playwright.config.js b/tests/portal-harness/playwright.config.js similarity index 52% rename from tests/portal-contract/playwright.config.js rename to tests/portal-harness/playwright.config.js index 7f85f99..94832de 100644 --- a/tests/portal-contract/playwright.config.js +++ b/tests/portal-harness/playwright.config.js @@ -1,7 +1,13 @@ +// Configuration for the browser half of the portal test harness. const path = require('path'); const { defineConfig } = require('@playwright/test'); const artifactDir = process.env.ARTIFACT_DIR || path.join(__dirname, 'artifacts'); +// The optional station-handoff test submits real local Wi-Fi credentials. The +// runner mounts only its generated two-key file, but Playwright traces can +// retain request bodies, so leave no screenshots, video, or trace behind for +// that one opt-in credential-bearing mode. +const hasStationCredentials = Boolean(process.env.PORTAL_STATION_ENV); module.exports = defineConfig({ testDir: './tests', @@ -18,8 +24,8 @@ module.exports = defineConfig({ ], use: { baseURL: process.env.PORTAL_URL || 'http://192.168.4.1', - screenshot: 'only-on-failure', - trace: 'retain-on-failure', - video: 'retain-on-failure', + screenshot: hasStationCredentials ? 'off' : 'only-on-failure', + trace: hasStationCredentials ? 'off' : 'retain-on-failure', + video: hasStationCredentials ? 'off' : 'retain-on-failure', }, }); diff --git a/tests/portal-contract/render-readme-media.sh b/tests/portal-harness/render-readme-media.sh similarity index 95% rename from tests/portal-contract/render-readme-media.sh rename to tests/portal-harness/render-readme-media.sh index 2b590d2..9372956 100755 --- a/tests/portal-contract/render-readme-media.sh +++ b/tests/portal-harness/render-readme-media.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# README-media renderer used by the portal test harness. set -euo pipefail artifact_dir="${ARTIFACT_DIR:?ARTIFACT_DIR is required}" diff --git a/tests/portal-contract/run-portal-contract.sh b/tests/portal-harness/run-portal-harness.sh similarity index 83% rename from tests/portal-contract/run-portal-contract.sh rename to tests/portal-harness/run-portal-harness.sh index bbd606c..e2a97ef 100755 --- a/tests/portal-contract/run-portal-contract.sh +++ b/tests/portal-harness/run-portal-harness.sh @@ -3,7 +3,7 @@ set -euo pipefail playwright_args=(test --config /work/playwright.config.js) if [[ -n "${PORTAL_TEST_FILE:-}" ]]; then - # OTA is a destructive, time-bounded board contract. Run only its browser + # OTA is a destructive, time-bounded board test harness. Run only its browser # spec instead of allowing ordinary portal tests to consume its AP window. playwright_args+=("$PORTAL_TEST_FILE") fi diff --git a/tests/portal-contract/tests/ap-contract.spec.js b/tests/portal-harness/tests/ap-harness.spec.js similarity index 90% rename from tests/portal-contract/tests/ap-contract.spec.js rename to tests/portal-harness/tests/ap-harness.spec.js index b6da103..09a355d 100644 --- a/tests/portal-contract/tests/ap-contract.spec.js +++ b/tests/portal-harness/tests/ap-harness.spec.js @@ -1,7 +1,18 @@ const { test, expect } = require('@playwright/test'); +const hasStationCredentials = Boolean(process.env.PORTAL_STATION_ENV); + +async function saveDiagnosticScreenshot(page, target) { + // The optional station hand-off submits real local credentials. Playwright's + // automatic artifacts are disabled in that mode, and explicit screenshots + // must honor the same boundary. + if (!hasStationCredentials) { + await page.screenshot({ path: target, fullPage: true }); + } +} + const fixtureParameters = [ - { id: 'installation_label', value: 'Contract fixture' }, + { id: 'installation_label', value: 'Harness fixture' }, { id: 'escaped_value', value: "7(f+4]2y3fsYTQt'Uhxc\"d\\<>&" }, { id: 'mqtt_host', value: 'broker.example.local' }, { id: 'mqtt_port', value: '1883' }, @@ -12,7 +23,7 @@ const fixtureParameters = [ { id: 'latitude', value: '-27.4698' }, { id: 'longitude', value: '153.0251' }, { id: 'firmware_channel', value: 'stable' }, - { id: 'owner_name', value: 'Portal contract' }, + { id: 'owner_name', value: 'Portal test harness' }, { id: 'notes', value: 'Thirteen-field rendering fixture' }, ]; const escapedUpdatedValue = "updated 'quote\" slash\\<>&"; @@ -49,7 +60,7 @@ async function waitForScan(request) { return result; } -test.describe('portal AP contract', () => { +test.describe('portal AP test harness', () => { test('serves API, retains all thirteen fixture parameters, and completes a real scan', async ({ request }) => { const root = await request.get('/'); expect(root.ok()).toBeTruthy(); @@ -115,7 +126,7 @@ test.describe('portal AP contract', () => { 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 saveDiagnosticScreenshot(page, `${process.env.ARTIFACT_DIR}/portal-overview-desktop.png`); await page.goto('/#/wifi', { waitUntil: 'networkidle' }); await expect(page.locator('#wm-refresh-scan')).toBeVisible(); @@ -124,7 +135,7 @@ test.describe('portal AP contract', () => { await expect(page.locator('#wm-f-installation_label')).toBeVisible(); await expect(page.locator('#wm-f-escaped_value')).toHaveValue(fixtureParameters[1].value); await page.locator('#wm-f-installation_label').fill('Browser verified'); - await page.screenshot({ path: `${process.env.ARTIFACT_DIR}/portal-wifi-desktop.png`, fullPage: true }); + await saveDiagnosticScreenshot(page, `${process.env.ARTIFACT_DIR}/portal-wifi-desktop.png`); const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true }); const mobilePage = await mobile.newPage(); @@ -134,7 +145,7 @@ test.describe('portal AP contract', () => { }); 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 saveDiagnosticScreenshot(mobilePage, `${process.env.ARTIFACT_DIR}/portal-device-mobile.png`); await mobile.close(); await desktop.close(); diff --git a/tests/portal-contract/tests/ota.spec.js b/tests/portal-harness/tests/ota.spec.js similarity index 97% rename from tests/portal-contract/tests/ota.spec.js rename to tests/portal-harness/tests/ota.spec.js index 5ca8dcd..0602e78 100644 --- a/tests/portal-contract/tests/ota.spec.js +++ b/tests/portal-harness/tests/ota.spec.js @@ -87,13 +87,13 @@ function waitForOtaResponse(page) { }); } -test.describe('portal HTTP OTA contract', () => { +test.describe('portal HTTP OTA test harness', () => { test('uploads B through the rendered portal form, requires automatic reboot, and observes B twice', async ({ page, request }) => { test.skip(!firmware, 'OTA firmware is mounted only for portal-hardware ota.'); // Initial portal availability, an observed outage, and two fresh B // responses each have their own bounded waits. Keep the overall budget // larger than their sum so a valid slow reassociation is not killed by - // Playwright before the fixture contract has concluded. + // Playwright before the fixture test harness has concluded. test.setTimeout(300_000); const initial = await waitForMarker(request, initialMarker); diff --git a/tests/portal-contract/tests/readme-media.spec.js b/tests/portal-harness/tests/readme-media.spec.js similarity index 94% rename from tests/portal-contract/tests/readme-media.spec.js rename to tests/portal-harness/tests/readme-media.spec.js index dd6473f..c9b37f2 100644 --- a/tests/portal-contract/tests/readme-media.spec.js +++ b/tests/portal-harness/tests/readme-media.spec.js @@ -24,6 +24,8 @@ async function waitForCompletedScan(request) { test.describe('WiFiManager README media', () => { test.skip(process.env.PORTAL_CAPTURE_README_MEDIA !== '1', 'README capture was not requested.'); + test.skip(Boolean(process.env.PORTAL_STATION_ENV), + 'README recording is unavailable when a station hand-off carries local credentials.'); test('records an approved ESP32 portal tour', async ({ browser, request }) => { const context = await browser.newContext({ @@ -43,7 +45,7 @@ test.describe('WiFiManager README media', () => { const video = page.video(); await page.goto('/', { waitUntil: 'networkidle' }); await expect(page.locator('#wm-reset-portal-timeout')).toBeVisible(); - // These pauses exist only in the README recording. The ordinary contract + // These pauses exist only in the README recording. The ordinary test harness // remains timing-focused; this tour needs readable stable states. await page.waitForTimeout(1200); await page.screenshot({ path: mediaPath('portal-overview.png'), fullPage: true }); diff --git a/tests/portal-contract/tests/station-handoff.spec.js b/tests/portal-harness/tests/station-handoff.spec.js similarity index 96% rename from tests/portal-contract/tests/station-handoff.spec.js rename to tests/portal-harness/tests/station-handoff.spec.js index a5654f4..2ddba6d 100644 --- a/tests/portal-contract/tests/station-handoff.spec.js +++ b/tests/portal-harness/tests/station-handoff.spec.js @@ -1,3 +1,4 @@ +// Optional LAN handoff exercised by the portal test harness. const fs = require('fs'); const { test, expect } = require('@playwright/test'); diff --git a/tools/capture-serial.py b/tools/capture-serial.py new file mode 100644 index 0000000..13a4649 --- /dev/null +++ b/tools/capture-serial.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Passively retain serial evidence for a physical portal OTA upload. + +The recorder deliberately opens the ESP USB-UART with both modem-control lines +inactive. It never resets the target: the calling runner has already flashed +and booted fixture A before this process attaches. +""" + +import argparse +import os +import signal +import sys +import time + +import serial + + +_stop_requested = False + + +def request_stop(_signum, _frame): + """Let the read loop finish promptly after a normal runner cleanup.""" + global _stop_requested + _stop_requested = True + + +def open_capture_port(port): + """Open a UART without PySerial's default reset-causing line assertion.""" + serial_port = serial.Serial( + port=None, + baudrate=115200, + timeout=0.25, + rtscts=False, + dsrdtr=False, + ) + serial_port.dtr = False + serial_port.rts = False + serial_port.port = port + serial_port.open() + return serial_port + + +def write_ready(path): + """Publish readiness only after the passive serial port is open.""" + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.fchmod(descriptor, 0o600) + os.write(descriptor, b"ready\n") + finally: + os.close(descriptor) + + +def capture(port, output, ready_file, should_stop=None): + """Append serial bytes until terminated by the owning hardware runner.""" + global _stop_requested + _stop_requested = False + if should_stop is None: + should_stop = lambda: _stop_requested + + try: + with ( + open_capture_port(port) as serial_port, + open(output, "ab", buffering=0) as output_file, + ): + os.fchmod(output_file.fileno(), 0o600) + write_ready(ready_file) + while not should_stop(): + data = serial_port.read(4096) + if data: + output_file.write(data) + else: + # PySerial normally blocks for its configured timeout. A + # broken backend or test double can return immediately; + # never let that turn a detached recorder into a CPU loop. + time.sleep(0.01) + except (OSError, serial.SerialException) as error: + print(f"Passive serial capture failed: {error}", file=sys.stderr) + return 1 + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", required=True) + parser.add_argument("--output", required=True) + parser.add_argument("--ready-file", required=True) + args = parser.parse_args() + + signal.signal(signal.SIGTERM, request_stop) + signal.signal(signal.SIGINT, request_stop) + return capture(args.port, args.output, args.ready_file) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/check-ota-partitions.sh b/tools/check-ota-partitions.sh index 667784c..6c834c1 100755 --- a/tools/check-ota-partitions.sh +++ b/tools/check-ota-partitions.sh @@ -63,4 +63,4 @@ if awk -F, ' exit 1 fi -echo "ESP32 OTA partition contract passed: $table" +echo "ESP32 OTA partition test-harness check passed: $table" diff --git a/tools/lib/portal-hardware-session.sh b/tools/lib/portal-hardware-session.sh index 0203e75..5d18c08 100755 --- a/tools/lib/portal-hardware-session.sh +++ b/tools/lib/portal-hardware-session.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Shared host-side helpers for the WiFiManager portal hardware contract. +# Shared host-side helpers for the WiFiManager portal hardware test harness. # They never modify a network interface other than the explicit client adapter. wm_portal_state_root() { @@ -128,7 +128,7 @@ wm_acquire_hardware_lock() { local lock_file="${WM_HARDWARE_LOCK_FILE:-${TMPDIR:-/tmp}/deviceframework-hardware-test.lock}" # `ota` deliberately acquires this before it builds firmware, then calls # the shared portal-start helper which also acquires it. Keep that nested - # path idempotent so the lock covers the whole A/B contract rather than + # path idempotent so the lock covers the whole A/B test harness rather than # only the serial flash and adapter connection. [[ "${WM_HARDWARE_LOCK_HELD:-no}" == "yes" ]] && return 0 exec 9>"$lock_file" @@ -140,7 +140,7 @@ wm_acquire_hardware_lock() { } wm_require_client_adapter() { - local interface="$1" allow_takeover="$2" default_interface active_connection + local interface="$1" allow_takeover="$2" default_interface device_type active_connection ip link show "$interface" >/dev/null 2>&1 || { echo "Wi-Fi interface not found: $interface" >&2 return 1 @@ -150,7 +150,22 @@ wm_require_client_adapter() { echo "Refusing to use the host default-route interface: $interface" >&2 return 1 } - active_connection="$(wm_nmcli -g GENERAL.CONNECTION device show "$interface" 2>/dev/null || true)" + # A non-default Ethernet, tunnel, or virtual interface can otherwise look + # harmless here and reach board flashing before the first Wi-Fi scan + # fails. Ask NetworkManager through the already-selected authorization + # path, so a denied inspection cannot be mistaken for a usable adapter. + if ! device_type="$(wm_nmcli -g GENERAL.TYPE device show "$interface" 2>/dev/null)"; then + echo "NetworkManager could not determine the selected portal adapter type: $interface" >&2 + return 1 + fi + [[ "$device_type" == "wifi" ]] || { + echo "Client adapter is not Wi-Fi: $interface ($device_type)." >&2 + return 1 + } + if ! active_connection="$(wm_nmcli -g GENERAL.CONNECTION device show "$interface" 2>/dev/null)"; then + echo "NetworkManager could not inspect the selected portal adapter: $interface" >&2 + return 1 + fi 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 @@ -160,8 +175,8 @@ wm_require_client_adapter() { wm_portal_ssid() { case "$1" in - esp8266) printf '%s\n' 'WM Contract ESP8266' ;; - esp32) printf '%s\n' 'WM Contract ESP32' ;; + esp8266) printf '%s\n' 'WM Test Harness ESP8266' ;; + esp32) printf '%s\n' 'WM Test Harness ESP32' ;; *) return 1 ;; esac } @@ -194,11 +209,49 @@ wm_remove_connection_by_name() { local name="$1" [[ -n "$name" ]] || return 0 wm_nmcli connection down "$name" >/dev/null 2>&1 || true - wm_nmcli connection delete "$name" >/dev/null 2>&1 || true + if ! wm_nmcli connection delete "$name"; then + # A pending recovery record can survive an uncatchable exit before + # `connection add` ran. Only a successful complete listing which does + # not contain this generated name proves that there is nothing left to + # remove; an authorization or NetworkManager query failure must retain + # the record for an explicit later `down` command. + if wm_connection_name_is_absent "$name"; then + return 0 + fi + wm_report_portal_connection_cleanup_failure + return 1 + fi +} + +wm_connection_name_is_absent() { + local name="$1" names + if ! names="$(wm_nmcli -t -f NAME connection show 2>/dev/null)"; then + return 1 + fi + ! grep -Fxq -- "$name" <<<"$names" +} + +wm_connection_uuid_is_absent() { + local uuid="$1" uuids + if ! uuids="$(wm_nmcli -t -f UUID connection show 2>/dev/null)"; then + return 1 + fi + ! grep -Fxq -- "$uuid" <<<"$uuids" +} + +wm_report_portal_connection_cleanup_failure() { + echo 'Could not remove the temporary WiFiManager portal connection. Its recovery state was retained; restore NetworkManager authorization and run ./tools/portal-hardware down.' >&2 } wm_create_portal_connection() { - local interface="$1" ssid="$2" password="$3" platform="${4:-}" name uuid + local interface="$1" ssid="$2" password="$3" platform="${4:-}" reconnect_after_drop="${5:-no}" name uuid + case "$reconnect_after_drop" in + yes|no) ;; + *) + echo "Portal connection reconnect policy must be yes or no." >&2 + return 2 + ;; + esac name="wifimanager-portal-${RANDOM}-$(date +%s)" # Publish the owned name before the first NetworkManager mutation. The # caller's signal trap can then remove it throughout the pending-to-active @@ -217,7 +270,16 @@ wm_create_portal_connection() { fi wm_nmcli device disconnect "$interface" >/dev/null 2>&1 || true if ! wm_wait_for_portal_ssid "$interface" "$ssid"; then - wm_cleanup_created_connection + # No `connection add` has run on this path, so this process knows that + # its pending record has no NetworkManager profile to remove. Clear it + # directly rather than requiring a privileged delete of a profile that + # cannot exist. + if ! wm_clear_state; then + wm_report_portal_connection_cleanup_failure + return 1 + fi + WM_PORTAL_CONNECTION_OWNED=no + unset WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME return 1 fi if ! wm_nmcli connection add type wifi ifname "$interface" con-name "$name" ssid "$ssid" \ @@ -247,6 +309,15 @@ wm_create_portal_connection() { wm_cleanup_created_connection return 1 fi + # The ordinary portal runner must leave its disposable connection inert so + # it cannot surprise a developer later. The OTA runner is different: the + # board intentionally disappears after POST /u, then returns as the same + # AP, so its one owned connection needs to reassociate autonomously for the + # browser and host-side B checks. Cleanup still deletes this exact profile. + if [[ "$reconnect_after_drop" == yes ]] && ! wm_nmcli connection modify "$name" connection.autoconnect yes; then + wm_cleanup_created_connection + return 1 + fi if ! wm_nmcli connection up "$name" ifname "$interface"; then wm_cleanup_created_connection return 1 @@ -264,11 +335,22 @@ wm_verify_portal_route() { wm_remove_connection() { local uuid="${1:-}" name="${2:-}" + [[ -n "$uuid" || -n "$name" ]] || return 0 if [[ -n "$uuid" ]]; then wm_nmcli connection down uuid "$uuid" >/dev/null 2>&1 || true - wm_nmcli connection delete uuid "$uuid" >/dev/null 2>&1 || true + if ! wm_nmcli connection delete uuid "$uuid"; then + # An active UUID means this runner did create a profile. Retain + # the exact recovery state unless the same authorized + # NetworkManager view positively proves another actor already + # removed it. A failed listing (including an expired scoped sudo + # ticket) is never treated as absence. + if ! wm_connection_uuid_is_absent "$uuid"; then + wm_report_portal_connection_cleanup_failure + return 1 + fi + fi elif [[ -n "$name" ]]; then - wm_remove_connection_by_name "$name" + wm_remove_connection_by_name "$name" || return 1 fi } @@ -277,8 +359,11 @@ wm_cleanup_created_connection() { # separate from `finish_portal_session`, which reads a retained state file # for an explicit later `down` command. [[ "${WM_PORTAL_CONNECTION_OWNED:-no}" == "yes" ]] || return 0 - wm_remove_connection "${WM_PORTAL_CONNECTION_UUID:-}" "${WM_PORTAL_CONNECTION_NAME:-}" - wm_clear_state + wm_remove_connection "${WM_PORTAL_CONNECTION_UUID:-}" "${WM_PORTAL_CONNECTION_NAME:-}" || return 1 + if ! wm_clear_state; then + echo 'The temporary WiFiManager portal connection was removed, but its recovery state could not be cleared.' >&2 + return 1 + fi WM_PORTAL_CONNECTION_OWNED=no unset WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME } @@ -375,5 +460,5 @@ wm_load_state() { wm_clear_state() { local file file="$(wm_state_file)" - rm -f "$file" + rm -f -- "$file" } diff --git a/tools/portal-hardware b/tools/portal-hardware index 50ef183..a72c108 100755 --- a/tools/portal-hardware +++ b/tools/portal-hardware @@ -52,6 +52,12 @@ ota_environment_b="" ota_firmware_a="" ota_firmware_b="" ota_browser_prebuilt="no" +ota_serial_capture_pid="" +ota_serial_capture_log="" +ota_serial_capture_ready="" +ota_serial_capture_status="" +station_handoff_env_file="" +station_handoff_fixture_restore_needed="no" # `wm_create_portal_connection` sets these before NetworkManager mutates the # secondary adapter. Keeping the ownership record in-process means the # signal trap can clean up the exact pending or active connection immediately. @@ -83,6 +89,14 @@ require_common() { docker compose version >/dev/null } +require_ota_serial_capture() { + wm_require python3 + python3 -c 'import serial' >/dev/null 2>&1 || { + echo "Portal OTA serial evidence requires Python pyserial (for example, python3-serial)." >&2 + return 1 + } +} + wm_pio_executable() { # Hardware runners are commonly launched over non-interactive SSH, where # a normal PlatformIO installation is not necessarily on PATH. Honor an @@ -119,11 +133,9 @@ pio_for_portal_environment() { shift case "$environment" in - # The ESP32 A/B fixture extends the 3.3.11 environment. Its package - # graph must not share a Core directory with the legacy 3.0.5 portal - # fixture, whose tool-esptoolpy package name collides with current - # pioarduino metadata. - esp32_core_3_3_11|esp32_ota_*) + # Keep the maintained Core 3.3.11 graph in the same persistent cache + # as the ESP32 A/B fixture. It is never cleared by this test harness. + esp32|esp32_ota_*) ;; *) wm_pio "$@" @@ -164,6 +176,20 @@ validate_run_arguments() { echo "Station environment file is not readable: $station_env" >&2 exit 1 } + if [[ -n "$station_env" ]]; then + [[ "$command_name" == "run" ]] || { + echo "--station-env is supported only by the browser-backed run command." >&2 + exit 2 + } + [[ "$browser" == "auto" ]] || { + echo "--station-env requires --browser auto so the real hand-off is exercised." >&2 + exit 2 + } + [[ "$capture_readme_media" == "no" ]] || { + echo "--station-env cannot be combined with README media capture." >&2 + exit 2 + } + fi if [[ "$capture_readme_media" == "yes" ]]; then [[ "$command_name" == "run" && "$platform" == "esp32" ]] || { echo "--capture-readme-media is supported only by run --platform esp32" >&2 @@ -200,6 +226,96 @@ validate_run_arguments() { fi } +read_station_handoff_value() { + local requested_key="$1" line value="" found=no + while IFS= read -r line || [[ -n "$line" ]]; do + # Accept the documented KEY=VALUE file format without evaluating it as + # shell code. A value may contain '=' but not a second declaration of + # the same key, which would make the generated minimal file ambiguous. + line="${line%$'\r'}" + case "$line" in + "$requested_key"=*) + [[ "$found" == no ]] || { + echo "Station environment defines $requested_key more than once." >&2 + return 1 + } + value="${line#*=}" + found=yes + ;; + esac + done <"$station_env" + [[ "$found" == yes && -n "$value" ]] || { + echo "Station environment must define a non-empty $requested_key value." >&2 + return 1 + } + printf '%s' "$value" +} + +prepare_station_handoff_env() { + local ssid password + ssid="$(read_station_handoff_value WIFI_SSID)" || return 1 + password="$(read_station_handoff_value WIFI_PASSWORD)" || return 1 + station_handoff_env_file="$(mktemp "$output_dir/.portal-station.XXXXXX")" || { + echo 'Could not create the private station-handoff environment file.' >&2 + return 1 + } + if ! { + printf 'WIFI_SSID=%s\n' "$ssid" + printf 'WIFI_PASSWORD=%s\n' "$password" + } >"$station_handoff_env_file"; then + rm -f -- "$station_handoff_env_file" + station_handoff_env_file="" + echo 'Could not write the private station-handoff environment file.' >&2 + return 1 + fi + if ! chmod 600 "$station_handoff_env_file"; then + rm -f -- "$station_handoff_env_file" + station_handoff_env_file="" + echo 'Could not protect the private station-handoff environment file.' >&2 + return 1 + fi + export PORTAL_STATION_ENV_HOST="$station_handoff_env_file" +} + +remove_station_handoff_env() { + [[ -n "$station_handoff_env_file" ]] || return 0 + if ! rm -f -- "$station_handoff_env_file"; then + echo 'Could not remove the private station-handoff environment file.' >&2 + return 1 + fi + station_handoff_env_file="" + unset PORTAL_STATION_ENV_HOST +} + +restore_station_handoff_fixture() { + local ssid + [[ "$station_handoff_fixture_restore_needed" == yes ]] || return 0 + # The optional browser hand-off deliberately persists its supplied station + # credentials long enough to exercise WiFiManager's real connect path. + # Reflash the same portal-only fixture afterwards: its setup() calls + # resetSettings(), so the selected test board returns to a no-station + # state without retaining a developer's Wi-Fi credentials. This is normal + # test cleanup, not OTA evidence; the OTA command never accepts a station + # environment and never calls this function. + printf 'Restoring the clean portal-only fixture after station hand-off.\n' + if ! pio_for_portal_environment "$platform" run -d "$root/test/portal-harness" -e "$platform" \ + -t upload --upload-port "$port"; then + echo 'Could not restore the clean portal-only fixture after station hand-off.' >&2 + return 1 + fi + # The board deliberately disappears during the cleanup reflash. Require + # its fixture AP to return and be routed through the owned secondary + # adapter before declaring the developer-supplied credentials scrubbed. + ssid="$(wm_portal_ssid "$platform")" || return 1 + if ! wm_wait_for_portal_ssid "$client_interface" "$ssid" || \ + ! wm_verify_portal_route "$client_interface" || \ + ! wait_for_portal_ready; then + echo 'The clean portal-only fixture did not return after station hand-off cleanup.' >&2 + return 1 + fi + station_handoff_fixture_restore_needed="no" +} + write_readme_media_manifest() { local media_dir="$output_dir/readme-media" local required @@ -217,7 +333,7 @@ write_readme_media_manifest() { wait_for_portal_ready() { # A portal SSID can be visible before its first background scan has # completed. Wait for that normal portal-start work before the browser - # contract asks the device to perform a second, user-initiated refresh. + # test harness asks the device to perform a second, user-initiated refresh. local attempt response for attempt in $(seq 1 45); do response="$(curl --interface "$client_interface" --connect-timeout 3 --max-time 5 \ @@ -236,13 +352,16 @@ wait_for_portal_ready() { } start_portal_session() { - local environment="${1:-$platform}" erase_before_upload="${2:-no}" expected_a_artifact="${3:-}" ssid + local environment="${1:-$platform}" erase_before_upload="${2:-no}" expected_a_artifact="${3:-}" capture_ota_serial="${4:-no}" ssid reconnect_after_drop="no" validate_run_arguments require_common wm_acquire_hardware_lock wm_require_no_active_session - wm_require_client_adapter "$client_interface" "$takeover" + # Choose the real NetworkManager authorization path before inspecting the + # secondary adapter. A headless shell must not mistake a denied inspection + # for an idle adapter and then replace a connection it does not own. wm_prepare_networkmanager_authorization + wm_require_client_adapter "$client_interface" "$takeover" prepare_output_dir ssid="$(wm_portal_ssid "$platform")" @@ -255,10 +374,23 @@ start_portal_session() { ota_assert_a_artifact_matches_build "$expected_a_artifact" fi pio_for_portal_environment "$environment" run -d "$root/test/portal-harness" -e "$environment" -t upload --upload-port "$port" + if [[ "$capture_ota_serial" == yes ]]; then + # Attach only after PlatformIO releases the serial port. Capture the + # complete A boot, including portal start and DHCP, before the adapter + # is asked to associate with the fixture AP. + start_ota_serial_capture + fi if [[ -n "$expected_a_artifact" ]]; then ota_assert_a_artifact_matches_build "$expected_a_artifact" fi - if ! wm_create_portal_connection "$client_interface" "$ssid" "default1" "$platform"; then + # OTA and the optional station hand-off both deliberately reboot the test + # board. Let only those owned disposable profiles reassociate after that + # outage; ordinary portal work keeps the secondary adapter inert after its + # current run. + if [[ "$capture_ota_serial" == yes || -n "$station_env" ]]; then + reconnect_after_drop=yes + fi + if ! wm_create_portal_connection "$client_interface" "$ssid" "default1" "$platform" "$reconnect_after_drop"; then return 1 fi if ! wm_verify_portal_route "$client_interface"; then @@ -445,14 +577,14 @@ validate_running_ota_capacity() { ota_assert_firmware_fits "$ota_firmware_b" "$capacity" B } -configure_ota_contract_environment() { +configure_ota_harness_environment() { export PORTAL_ARTIFACT_DIR="$output_dir" export LOCAL_UID="$(id -u)" export LOCAL_GID="$(id -g)" export PORTAL_BROWSER_MODE=auto - # OTA is its own contract. Do not inherit a developer's unrelated media, + # OTA is its own test harness. Do not inherit a developer's unrelated media, # stress, target, or test-selection setting into a physical firmware run. - export PORTAL_CONTRACT_DOCKER_TARGET=portal-contract + export PORTAL_HARNESS_DOCKER_TARGET=portal-harness export PORTAL_CAPTURE_README_MEDIA=0 export PORTAL_CUSTOM_PARAMETER_STRESS=0 export PORTAL_TEST_FILE=tests/ota.spec.js @@ -464,36 +596,119 @@ configure_ota_contract_environment() { ota_compose_files() { printf '%s\n' \ - -f "$root/tests/portal-contract/compose.yaml" \ - -f "$root/tests/portal-contract/compose.ota.yaml" + -f "$root/tests/portal-harness/compose.yaml" \ + -f "$root/tests/portal-harness/compose.ota.yaml" } -prepare_ota_contract_image() { +prepare_ota_harness_image() { local -a compose_files - configure_ota_contract_environment + configure_ota_harness_environment mapfile -t compose_files < <(ota_compose_files) # Build before A is flashed. A cold Playwright build can otherwise consume # the finite portal window before the browser has connected. - docker compose "${compose_files[@]}" build portal-contract + docker compose "${compose_files[@]}" build portal-harness ota_browser_prebuilt=yes } -run_ota_contract() { +run_ota_harness() { local -a compose_files - configure_ota_contract_environment + configure_ota_harness_environment mapfile -t compose_files < <(ota_compose_files) [[ "$ota_browser_prebuilt" == yes ]] || { echo "Portal OTA browser image was not prepared before A was flashed." >&2 return 1 } - docker compose "${compose_files[@]}" run --rm portal-contract + docker compose "${compose_files[@]}" run --rm portal-harness +} + +start_ota_serial_capture() { + local attempt + ota_serial_capture_log="$output_dir/serial-ota.log" + ota_serial_capture_ready="$output_dir/.serial-ota.ready" + ota_serial_capture_status="$output_dir/serial-ota-capture.log" + rm -f -- "$ota_serial_capture_ready" + install -m 600 /dev/null "$ota_serial_capture_log" + install -m 600 /dev/null "$ota_serial_capture_status" + python3 "$root/tools/capture-serial.py" \ + --port "$port" \ + --output "$ota_serial_capture_log" \ + --ready-file "$ota_serial_capture_ready" \ + >>"$ota_serial_capture_status" 2>&1 & + ota_serial_capture_pid=$! + + for attempt in $(seq 1 50); do + [[ -f "$ota_serial_capture_ready" ]] && return 0 + if ! kill -0 "$ota_serial_capture_pid" >/dev/null 2>&1; then + wait "$ota_serial_capture_pid" || true + ota_serial_capture_pid="" + echo "Portal OTA serial recorder exited before it became ready: $ota_serial_capture_status" >&2 + return 1 + fi + sleep 0.1 + done + echo "Portal OTA serial recorder did not become ready: $ota_serial_capture_status" >&2 + stop_ota_serial_capture || true + return 1 +} + +require_ota_serial_capture_running() { + [[ -n "$ota_serial_capture_pid" ]] && kill -0 "$ota_serial_capture_pid" >/dev/null 2>&1 || { + echo "Portal OTA serial recorder stopped unexpectedly: $ota_serial_capture_status" >&2 + return 1 + } +} + +assert_ota_serial_sequence() { + python3 - "$ota_serial_capture_log" <<'PY' +import sys + +path = sys.argv[1] +with open(path, "r", encoding="utf-8", errors="replace") as source: + lines = source.readlines() + +def first_after(marker, start=0): + for index in range(start, len(lines)): + if marker in lines[index]: + return index + return None + +a = first_after("WiFiManager portal OTA fixture image: A") +upload_started = first_after("[OTA] Update file:", (a or 0) + 1) +upload_completed = first_after("[OTA] OTA FILE END bytes:", (upload_started or 0) + 1) +b = first_after("WiFiManager portal OTA fixture image: B", (upload_completed or 0) + 1) + +if None in (a, upload_started, upload_completed, b): + raise SystemExit("Serial portal-OTA evidence is missing its required A/upload/B ordering.") +PY +} + +stop_ota_serial_capture() { + local status=0 + [[ -n "$ota_serial_capture_pid" ]] || return 0 + if kill -0 "$ota_serial_capture_pid" >/dev/null 2>&1; then + kill -TERM "$ota_serial_capture_pid" >/dev/null 2>&1 || status=1 + fi + if ! wait "$ota_serial_capture_pid"; then + status=1 + fi + ota_serial_capture_pid="" + (( status == 0 )) || return "$status" + if ! assert_ota_serial_sequence; then + echo "Serial portal-OTA evidence did not observe ordered A/upload/B markers: $ota_serial_capture_log" >&2 + return 1 + fi } finish_portal_session() { - wm_load_state - wm_prepare_networkmanager_authorization - wm_remove_connection "$WM_PORTAL_CONNECTION_UUID" "$WM_PORTAL_CONNECTION_NAME" - wm_clear_state + # A normal run already owns an in-process record. `down` starts fresh, so + # load its retained exact record and select the scoped authorization path + # before attempting deletion. + if [[ "${WM_PORTAL_CONNECTION_OWNED:-no}" != "yes" ]]; then + wm_load_state + wm_prepare_networkmanager_authorization + WM_PORTAL_CONNECTION_OWNED=yes + fi + wm_cleanup_created_connection } cleanup_portal_session() { @@ -501,9 +716,21 @@ cleanup_portal_session() { # A signal must never fall through into the rest of the hardware run, and # a recursive EXIT trap must not obscure the original status. trap - EXIT HUP INT TERM + stop_ota_serial_capture || true + if ! restore_station_handoff_fixture; then + (( status != 0 )) || status=1 + fi + if ! remove_station_handoff_env; then + (( status != 0 )) || status=1 + fi if [[ "${WM_PORTAL_CONNECTION_OWNED:-no}" == "yes" ]] && \ { (( status != 0 )) || { [[ "$command_name" != "up" && "$keep" != "yes" ]]; }; }; then - wm_cleanup_created_connection || true + if ! wm_cleanup_created_connection; then + # Preserve an existing run failure, but never turn an otherwise + # successful browser/OTA run into a claimed pass when its owned + # NetworkManager connection could not be removed. + (( status != 0 )) || status=1 + fi fi exit "$status" } @@ -518,6 +745,9 @@ case "$command_name" in [[ -n "$client_interface" ]] || usage require_common wm_acquire_hardware_lock + # Doctor is read-only, but it must validate the same authorization + # path as a real portal command before it calls an adapter safe. + wm_prepare_networkmanager_authorization wm_require_client_adapter "$client_interface" "$takeover" wm_report_networkmanager_authorization printf 'Portal hardware prerequisites are ready. Main route is untouched; client adapter: %s\n' "$client_interface" @@ -528,7 +758,10 @@ case "$command_name" in down) [[ -z "$platform$port$client_interface$station_env$output_dir" ]] || usage wm_acquire_hardware_lock - finish_portal_session + if ! finish_portal_session; then + trap - EXIT HUP INT TERM + exit 1 + fi echo 'Portal client connection removed.' ;; run) @@ -547,24 +780,36 @@ case "$command_name" in export PORTAL_PLATFORM="$platform" if [[ "$capture_readme_media" == "yes" ]]; then export PORTAL_CAPTURE_README_MEDIA=1 - export PORTAL_CONTRACT_DOCKER_TARGET=media + export PORTAL_HARNESS_DOCKER_TARGET=media else export PORTAL_CAPTURE_README_MEDIA=0 - export PORTAL_CONTRACT_DOCKER_TARGET=portal-contract + export PORTAL_HARNESS_DOCKER_TARGET=portal-harness fi - compose_files=(-f "$root/tests/portal-contract/compose.yaml") + compose_files=(-f "$root/tests/portal-harness/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") + prepare_station_handoff_env + compose_files+=(-f "$root/tests/portal-harness/compose.station.yaml") + # Once the browser can receive the private station credentials, + # every exit path must return the board to the fixture's clean + # portal-only state. The EXIT trap covers a browser failure; the + # explicit success-path call below keeps a cleanup failure from + # being announced as a passing test. + station_handoff_fixture_restore_needed=yes fi - # The contract source is copied into the image; rebuild with Docker cache so + # The test-harness source is copied into the image; rebuild with Docker cache so # this invocation always tests the checked-out files, not a stale image. - docker compose "${compose_files[@]}" build portal-contract - docker compose "${compose_files[@]}" run --rm portal-contract + docker compose "${compose_files[@]}" build portal-harness + docker compose "${compose_files[@]}" run --rm portal-harness + restore_station_handoff_fixture + remove_station_handoff_env if [[ "$capture_readme_media" == "yes" ]]; then write_readme_media_manifest fi - printf 'Portal contract passed. Artifacts: %s\n' "$output_dir" + if [[ "$keep" != "yes" ]] && ! finish_portal_session; then + trap - EXIT HUP INT TERM + exit 1 + fi + printf 'Portal test harness passed. Artifacts: %s\n' "$output_dir" if [[ "$keep" == "yes" ]]; then printf 'Portal session remains connected; run ./tools/portal-hardware down when finished.\n' fi @@ -577,27 +822,38 @@ case "$command_name" in require_common wm_acquire_hardware_lock wm_require_no_active_session - wm_require_client_adapter "$client_interface" "$takeover" wm_prepare_networkmanager_authorization + wm_require_client_adapter "$client_interface" "$takeover" + require_ota_serial_capture prepare_output_dir prepare_ota_firmware - prepare_ota_contract_image - start_portal_session "$ota_environment_a" yes "$ota_firmware_a" + prepare_ota_harness_image + start_portal_session "$ota_environment_a" yes "$ota_firmware_a" yes # A must be visible through the real portal before a B upload can be # meaningful. On ESP8266 the marker also reports the actual active # sketch-space limit, which is checked before the browser sees B. wait_for_ota_marker A >/dev/null validate_running_ota_capacity - run_ota_contract + run_ota_harness - # The browser contract has already required an outage and two B + # The browser test harness has already required an outage and two B # observations. Repeat the host-side observation after the container - # exits so the named secondary adapter is also proven to see B. + # exits so the named secondary adapter is also proven to rediscover + # the returned AP and see B. This is AP routing, not a LAN IP shortcut. + wm_wait_for_portal_ssid "$client_interface" "$(wm_portal_ssid "$platform")" + wm_verify_portal_route "$client_interface" wait_for_ota_marker B >/dev/null + require_ota_serial_capture_running sleep 1 wait_for_ota_marker B >/dev/null - printf 'Portal HTTP OTA contract passed. Artifacts: %s\n' "$output_dir" + require_ota_serial_capture_running + stop_ota_serial_capture + if [[ "$keep" != "yes" ]] && ! finish_portal_session; then + trap - EXIT HUP INT TERM + exit 1 + fi + printf 'Portal HTTP OTA test harness passed. Artifacts: %s\n' "$output_dir" if [[ "$keep" == "yes" ]]; then printf 'Portal session remains connected; run ./tools/portal-hardware down when finished.\n' fi diff --git a/tools/tests/test-capture-serial.py b/tools/tests/test-capture-serial.py new file mode 100644 index 0000000..b2982f4 --- /dev/null +++ b/tools/tests/test-capture-serial.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Unit-check the passive portal-OTA serial recorder without real hardware.""" + +import importlib.util +import stat +import sys +import tempfile +import types +from pathlib import Path + + +class FakeSerial: + events = [] + read_calls = 0 + + def __init__(self, port=None, **kwargs): + assert port is None + assert kwargs["baudrate"] == 115200 + assert kwargs["timeout"] == 0.25 + assert kwargs["rtscts"] is False + assert kwargs["dsrdtr"] is False + self._dtr = True + self._rts = True + self._port = None + self.is_open = False + self.events.append(("init", port)) + + @property + def dtr(self): + return self._dtr + + @dtr.setter + def dtr(self, value): + self._dtr = value + self.events.append(("dtr", value)) + + @property + def rts(self): + return self._rts + + @rts.setter + def rts(self, value): + self._rts = value + self.events.append(("rts", value)) + + @property + def port(self): + return self._port + + @port.setter + def port(self, value): + self._port = value + self.events.append(("port", value)) + + def open(self): + assert self._dtr is False + assert self._rts is False + self.is_open = True + self.events.append(("open", self._dtr, self._rts, self._port)) + + def close(self): + self.is_open = False + self.events.append(("close",)) + + def read(self, _size): + type(self).read_calls += 1 + return b"[OTA] serial evidence\n" if type(self).read_calls == 1 else b"" + + def __enter__(self): + return self + + def __exit__(self, _type, _value, _traceback): + self.close() + + +def load_capture_module(): + fake_serial_module = types.ModuleType("serial") + fake_serial_module.Serial = FakeSerial + fake_serial_module.SerialException = OSError + sys.modules["serial"] = fake_serial_module + + source = Path(__file__).resolve().parents[1] / "capture-serial.py" + spec = importlib.util.spec_from_file_location("capture_serial", source) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def main(): + module = load_capture_module() + + serial_port = module.open_capture_port("/dev/fake") + assert FakeSerial.events == [ + ("init", None), + ("dtr", False), + ("rts", False), + ("port", "/dev/fake"), + ("open", False, False, "/dev/fake"), + ] + serial_port.close() + + FakeSerial.events.clear() + FakeSerial.read_calls = 0 + with tempfile.TemporaryDirectory() as temporary_directory: + output = Path(temporary_directory) / "serial-ota.log" + ready = Path(temporary_directory) / "serial-ota.ready" + original_write_ready = module.write_ready + + def checked_write_ready(path): + assert ("open", False, False, "/dev/fake") in FakeSerial.events + original_write_ready(path) + + module.write_ready = checked_write_ready + assert module.capture( + "/dev/fake", + output, + ready, + should_stop=lambda: FakeSerial.read_calls >= 2, + ) == 0 + assert ready.read_text() == "ready\n" + assert output.read_bytes() == b"[OTA] serial evidence\n" + assert stat.S_IMODE(output.stat().st_mode) == 0o600 + assert stat.S_IMODE(ready.stat().st_mode) == 0o600 + + assert ("open", False, False, "/dev/fake") in FakeSerial.events + assert ("close",) in FakeSerial.events + assert FakeSerial.read_calls == 2 + print("WiFiManager passive OTA serial-capture test-harness check passed") + + +if __name__ == "__main__": + main() diff --git a/tools/tests/test-portal-hardware-cli.sh b/tools/tests/test-portal-hardware-cli.sh deleted file mode 100755 index 105a6e2..0000000 --- a/tools/tests/test-portal-hardware-cli.sh +++ /dev/null @@ -1,279 +0,0 @@ -#!/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" -export XDG_STATE_HOME="$tmp/state" -# Most mock cases exercise the direct desktop-Polkit path. Individual cases -# below explicitly select the headless scoped-sudo path. -export WM_NMCLI_AUTH=direct - -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_PERMISSION_MODE:-}" == "auth" && "$1" == "-t" && "$2" == "-f" && "$3" == "PERMISSION,VALUE" ]]; then printf "%s\\n" "org.freedesktop.NetworkManager.wifi.scan:auth" "org.freedesktop.NetworkManager.network-control:auth" "org.freedesktop.NetworkManager.settings.modify.system:auth"; exit 0; fi' \ -'if [[ "${NMCLI_REQUIRE_SUDO:-}" == "yes" && "${RUN_AS_SUDO:-}" != "yes" ]]; then echo "Error: Insufficient privileges" >&2; exit 7; fi' \ -'if [[ "${NMCLI_FAIL_SCAN:-}" == "yes" && "$1" == "device" && "$2" == "wifi" && "$3" == "rescan" ]]; then echo "fixture scan failure" >&2; exit 7; fi' \ -'if [[ "${NMCLI_FAIL_ADD:-}" == "yes" && "$1" == "connection" && "$2" == "add" ]]; then exit 7; fi' \ -'if [[ "${NMCLI_SIGNAL_PARENT:-}" == "yes" && "$1" == "connection" && "$2" == "add" ]]; then kill -TERM "$PPID"; exit 0; fi' \ -'if [[ "${NMCLI_FAIL_UP:-}" == "yes" && "$1" == "connection" && "$2" == "up" ]]; then exit 7; fi' \ -'if [[ "$1" == "-t" && "$2" == "-f" && "$3" == "SSID" ]]; then echo "WM Contract ESP8266"; exit 0; fi' \ -'if [[ "$1" == "-g" && "$2" == "connection.uuid" ]]; then echo "stub-uuid"; exit 0; fi' \ -'if [[ "$1" == "-g" ]]; then echo "--"; fi' >"$stub_bin/nmcli" -printf '%s\n' '#!/usr/bin/env bash' \ -'printf "sudo %s\\n" "$*" >>"$CALL_LOG"' \ -'if [[ "${SUDO_FAIL:-}" == "yes" ]]; then exit 1; fi' \ -'if [[ "$1" == "-v" ]]; then exit 0; fi' \ -'if [[ "$1" == "-n" ]]; then shift; fi' \ -'if [[ "$1" == "true" ]]; then exit 0; fi' \ -'[[ "$1" == "--" ]] && shift' \ -'RUN_AS_SUDO=yes exec "$@"' >"$stub_bin/sudo" -printf '%s\n' '#!/usr/bin/env bash' 'exit 0' >"$stub_bin/pio" -printf '%s\n' '#!/usr/bin/env bash' \ -'printf "docker %s\n" "$*" >>"$CALL_LOG"' \ -'if [[ "$1" == "compose" && "$2" == "version" ]]; then echo "Docker Compose"; exit 0; fi' \ -'exit 0' >"$stub_bin/docker" -printf '%s\n' '#!/usr/bin/env bash' \ -'printf "{\"state\":\"complete\",\"results_valid\":true}"' >"$stub_bin/curl" -chmod 755 "$stub_bin"/* -export PATH="$stub_bin:$PATH" - -"$root/tools/portal-hardware" doctor --client-interface wlan-client >/dev/null -if grep -Eq 'connection (add|modify|delete)|device disconnect' "$CALL_LOG"; then - echo 'doctor unexpectedly changed a NetworkManager connection' >&2 - exit 1 -fi - -if "$root/tools/portal-hardware" doctor --client-interface wlan-main >/dev/null 2>&1; then - echo 'default-route adapter guard did not reject the request' >&2 - 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 -if "$root/tools/portal-hardware" run --platform esp8266 --port /dev/null \ - --client-interface wlan-client --capture-readme-media >/dev/null 2>&1; then - echo 'ESP8266 README media capture was accepted' >&2 - exit 1 -fi -if "$root/tools/portal-hardware" run --platform esp32 --port /dev/null \ - --client-interface wlan-client --custom-parameter-stress >/dev/null 2>&1; then - echo 'ESP32 custom-parameter stress was accepted' >&2 - exit 1 -fi -if "$root/tools/portal-hardware" run --platform esp8266 --port /dev/null \ - --client-interface wlan-client --browser skip --custom-parameter-stress >/dev/null 2>&1; then - echo 'browser-skipped custom-parameter stress was accepted' >&2 - exit 1 -fi -if "$root/tools/portal-hardware" ota --platform esp8266 --port /dev/null \ - --client-interface wlan-client --browser skip >/dev/null 2>&1; then - echo 'browser-skipped OTA was accepted' >&2 - exit 1 -fi -if "$root/tools/portal-hardware" ota --platform esp8266 --port /dev/null \ - --client-interface wlan-client --station-env "$root/test/portal-station.env.example" >/dev/null 2>&1; then - echo 'station handoff inputs were accepted by portal OTA' >&2 - exit 1 -fi -if "$root/scripts/test.sh" ota-fixtures --platform esp32 >/dev/null 2>&1; then - echo 'legacy-cache ESP32 OTA-fixture selector was accepted' >&2 - exit 1 -fi - -# A failed association must delete the only connection it just created. -source "$root/tools/lib/portal-hardware-session.sh" -# A NetworkManager failure must remain distinct from an absent portal SSID. -: >"$CALL_LOG" -export NMCLI_FAIL_SCAN=yes -if scan_output="$(wm_wait_for_portal_ssid wlan-client 'fixture portal' 2>&1)"; then - echo 'failed Wi-Fi scan was reported as an SSID result' >&2 - exit 1 -fi -unset NMCLI_FAIL_SCAN -[[ "$scan_output" == *'NetworkManager could not scan the selected portal adapter'* ]] || { - echo 'failed Wi-Fi scan did not report the NetworkManager error path' >&2 - exit 1 -} -if grep -Fq 'connection add' "$CALL_LOG"; then - echo 'failed Wi-Fi scan continued into connection creation' >&2 - exit 1 -fi - -wm_wait_for_portal_ssid() { return 0; } -export NMCLI_FAIL_UP=yes -if wm_create_portal_connection wlan-client 'fixture portal' placeholder esp8266; then - echo 'failed association was reported as success' >&2 - exit 1 -fi -unset NMCLI_FAIL_UP -grep -Eq 'connection delete (uuid stub-uuid|wifimanager-portal-)' "$CALL_LOG" -[[ ! -e "$(wm_state_file)" ]] || { - echo 'failed association left portal recovery state behind' >&2 - exit 1 -} - -# The pending record must be removed even when NetworkManager rejects the -# connection before it has a UUID. -export NMCLI_FAIL_ADD=yes -if wm_create_portal_connection wlan-client 'fixture portal' placeholder esp8266; then - echo 'failed connection creation was reported as success' >&2 - exit 1 -fi -unset NMCLI_FAIL_ADD -grep -Eq 'connection delete wifimanager-portal-' "$CALL_LOG" -if grep -Fq 'sudo ' "$CALL_LOG"; then - echo 'generic NetworkManager failure unexpectedly retried with sudo' >&2 - exit 1 -fi -[[ ! -e "$(wm_state_file)" ]] || { - echo 'failed connection creation left pending recovery state behind' >&2 - exit 1 -} - -# A non-graphical SSH shell often has no Polkit agent. Preflight the scoped -# sudo path once, then use it for only the named portal adapter actions; never -# require the developer to run the entire runner as root. -: >"$CALL_LOG" -export NMCLI_REQUIRE_SUDO=yes -export WM_NMCLI_AUTH=sudo -unset WM_NMCLI_AUTH_READY WM_NMCLI_MODE WM_NMCLI_PERMISSIONS -wm_prepare_networkmanager_authorization -wm_wait_for_portal_ssid() { return 0; } -if ! wm_create_portal_connection wlan-client 'fixture portal' placeholder esp8266; then - echo 'authorization fallback did not create the portal connection' >&2 - exit 1 -fi -wm_cleanup_created_connection -unset NMCLI_REQUIRE_SUDO -export WM_NMCLI_AUTH=direct -unset WM_NMCLI_AUTH_READY WM_NMCLI_MODE WM_NMCLI_PERMISSIONS -grep -Fq 'sudo -n -- nmcli connection add' "$CALL_LOG" -[[ ! -e "$(wm_state_file)" ]] || { - echo 'authorization fallback left portal recovery state behind' >&2 - exit 1 -} - -# A failed sudo validation must stop before the runner can erase or flash a -# board; it is not an SSID discovery failure. -export WM_NMCLI_AUTH=sudo SUDO_FAIL=yes -unset WM_NMCLI_AUTH_READY WM_NMCLI_MODE WM_NMCLI_PERMISSIONS -if wm_prepare_networkmanager_authorization >/dev/null 2>&1; then - echo 'failed scoped sudo validation was accepted' >&2 - exit 1 -fi -unset SUDO_FAIL -export WM_NMCLI_AUTH=direct -unset WM_NMCLI_AUTH_READY WM_NMCLI_MODE WM_NMCLI_PERMISSIONS - -# A session D-Bus socket alone is not a graphical Polkit agent. This models -# the SSH environment that exposes DBUS_SESSION_BUS_ADDRESS but no display. -: >"$CALL_LOG" -if ! ROOT="$root" PATH="$stub_bin:$PATH" WM_NMCLI_AUTH=auto \ - NMCLI_PERMISSION_MODE=auth NMCLI_REQUIRE_SUDO=yes \ - DBUS_SESSION_BUS_ADDRESS='unix:path=/run/user/1000/bus' DISPLAY='' WAYLAND_DISPLAY='' \ - bash -c ' - source "$ROOT/tools/lib/portal-hardware-session.sh" - wm_prepare_networkmanager_authorization - [[ "$WM_NMCLI_MODE" == sudo ]] - '; then - echo 'headless session D-Bus path did not select scoped sudo' >&2 - exit 1 -fi -grep -Fq 'sudo -v' "$CALL_LOG" - -# A portal scan failure must stop before `connection add` and clear the -# pre-add pending record, even though this helper is invoked in an `if`. -wm_wait_for_portal_ssid() { return 1; } -if wm_create_portal_connection wlan-client 'fixture portal' placeholder esp8266; then - echo 'failed portal scan was reported as success' >&2 - exit 1 -fi -wm_wait_for_portal_ssid() { return 0; } -[[ ! -e "$(wm_state_file)" ]] || { - echo 'failed portal scan left pending recovery state behind' >&2 - exit 1 -} - -# Exercise the real runner trap: a TERM immediately after `connection add` -# must remove its exact pending name and clear the atomically written state. -: >"$CALL_LOG" -export NMCLI_SIGNAL_PARENT=yes -if "$root/tools/portal-hardware" up --platform esp8266 --port /dev/null \ - --client-interface wlan-client >/dev/null 2>&1; then - echo 'runner survived a connection-creation interrupt' >&2 - exit 1 -fi -unset NMCLI_SIGNAL_PARENT -grep -Eq 'connection delete wifimanager-portal-' "$CALL_LOG" -[[ ! -e "$(wm_state_file)" ]] || { - echo 'runner interrupt left a portal state record behind' >&2 - exit 1 -} - -# Model an uncatchable host death after the pre-add atomic write. `down` must -# accept its name-only pending record and remove that one connection. -wm_write_state wlan-client esp8266 '' wifimanager-pending-recovery -grep -Fxq 'WM_PORTAL_STATE=pending' "$(wm_state_file)" -"$root/tools/portal-hardware" down >/dev/null -grep -Fq 'connection delete wifimanager-pending-recovery' "$CALL_LOG" -[[ ! -e "$(wm_state_file)" ]] || { - echo 'pending portal recovery state was not cleared' >&2 - exit 1 -} - -# A retained session must be removed explicitly, never silently overwritten. -wm_write_state wlan-client esp8266 stale-uuid stale-name -wm_load_state -[[ "$WM_PORTAL_INTERFACE" == "wlan-client" && "$WM_PORTAL_PLATFORM" == "esp8266" ]] -[[ "$WM_PORTAL_CONNECTION_UUID" == "stale-uuid" && "$WM_PORTAL_CONNECTION_NAME" == "stale-name" ]] -mutations_before="$(grep -Ec '^(device disconnect|connection (add|modify|delete|down))' "$CALL_LOG" || true)" -if "$root/tools/portal-hardware" up --platform esp8266 --port /dev/null \ - --client-interface wlan-client --take-over-client-adapter >/dev/null 2>&1; then - echo 'stale portal session was silently overwritten' >&2 - exit 1 -fi -mutations_after="$(grep -Ec '^(device disconnect|connection (add|modify|delete|down))' "$CALL_LOG" || true)" -[[ "$mutations_before" == "$mutations_after" ]] || { - echo 'stale portal session mutated the selected adapter' >&2 - exit 1 -} -wm_clear_state - -# The runner must build the copied contract source before it starts the container. -"$root/tools/portal-hardware" run --platform esp8266 --port /dev/null --client-interface wlan-client --browser skip >/dev/null -build_line="$(grep -n " build portal-contract$" "$CALL_LOG" | tail -1 | cut -d: -f1)" -run_line="$(grep -n " run --rm portal-contract$" "$CALL_LOG" | tail -1 | cut -d: -f1)" -[[ -n "$build_line" && -n "$run_line" && "$build_line" -lt "$run_line" ]] || { - echo "portal contract was not rebuilt before execution" >&2 - exit 1 -} -grep -Fq 'wait_for_portal_ready' "$root/tools/portal-hardware" -grep -Fq 'api/wifi/scan-status' "$root/tools/portal-hardware" -grep -Fq 'PORTAL_CUSTOM_PARAMETER_STRESS' "$root/tools/portal-hardware" -grep -Fq 'compose.ota.yaml' "$root/tools/portal-hardware" -grep -Fq 'wait_for_ota_marker B' "$root/tools/portal-hardware" -grep -Fq 'pio_for_portal_environment "$ota_environment_a"' "$root/tools/portal-hardware" -grep -Fq 'WIFIMANAGER_PLATFORMIO_CORE_DIR' "$root/tools/portal-hardware" -grep -Fq 'WIFIMANAGER_PIO_EXECUTABLE' "$root/tools/portal-hardware" -grep -Fq 'deviceframework-hardware-test.lock' "$root/tools/lib/portal-hardware-session.sh" -grep -Fq 'WM_PORTAL_CONNECTION_OWNED' "$root/tools/lib/portal-hardware-session.sh" -grep -Fq 'assert_ota_fixture_pair' "$root/scripts/test.sh" -grep -Fq 'WiFiManager Unity compile check passed' "$root/scripts/test.sh" -grep -Fq 'eagle.flash.4m1m.ld' "$root/test/portal-harness/platformio.ini" -grep -Fq 'esp32_ota_4m_no_fs.csv' "$root/test/portal-harness/platformio.ini" -grep -Fq 'README media GIF exceeds its 2 MiB documentation budget' \ - "$root/tests/portal-contract/render-readme-media.sh" - -echo 'portal-hardware CLI safety checks passed'