fix: validate portal OTA and simplify test harness

This commit is contained in:
2026-09-18 02:41:18 +10:00
parent 3e09958d11
commit 7c5b50499e
44 changed files with 900 additions and 549 deletions
+2 -12
View File
@@ -22,10 +22,10 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: bash -n scripts/*.sh tools/check-ota-partitions.sh tools/portal-hardware tools/lib/*.sh tools/tests/*.sh - run: bash -n scripts/*.sh tools/check-ota-partitions.sh tools/portal-hardware tools/lib/*.sh tools/tests/*.sh
- run: | - run: |
for spec in tests/portal-contract/tests/*.js; do for spec in tests/portal-harness/tests/*.js; do
node --check "$spec" node --check "$spec"
done 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: ./tools/check-ota-partitions.sh
- run: ./scripts/check-docs.sh - run: ./scripts/check-docs.sh
@@ -41,14 +41,6 @@ jobs:
unity: true unity: true
- platform: esp32 - platform: esp32
examples: true 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 ota_fixtures: true
unity: true unity: true
steps: steps:
@@ -60,8 +52,6 @@ jobs:
- run: ./scripts/test.sh compile --platform ${{ matrix.platform }} - run: ./scripts/test.sh compile --platform ${{ matrix.platform }}
- if: matrix.unity - if: matrix.unity
run: ./scripts/test.sh unity --platform ${{ matrix.platform }} run: ./scripts/test.sh unity --platform ${{ matrix.platform }}
- if: matrix.platform == 'esp32-current'
run: ./scripts/test.sh packages --platform esp32-current
- if: matrix.examples - if: matrix.examples
run: ./scripts/test.sh examples --platform ${{ matrix.platform }} run: ./scripts/test.sh examples --platform ${{ matrix.platform }}
- if: matrix.ota_fixtures - if: matrix.ota_fixtures
+44 -7
View File
@@ -6,22 +6,59 @@ on:
- 'v*' - 'v*'
permissions: permissions:
contents: write contents: read
jobs: jobs:
publish: documentation:
runs-on: ubuntu-latest 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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: '3.11' python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19 - run: python -m pip install --upgrade platformio==6.1.19
- run: ./scripts/test.sh compile --platform esp8266 - run: ./scripts/test.sh compile --platform ${{ matrix.platform }}
- run: ./scripts/test.sh examples --platform esp8266 - if: matrix.unity
- run: ./scripts/test.sh compile --platform esp32 run: ./scripts/test.sh unity --platform ${{ matrix.platform }}
- run: ./scripts/test.sh examples --platform esp32 - if: matrix.examples
- run: ./scripts/check-docs.sh 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/prepare-release.sh "$GITHUB_REF_NAME"
- run: ./scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-notes.md" - run: ./scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-notes.md"
- run: >- - run: >-
+1 -1
View File
@@ -36,5 +36,5 @@ node_modules/
# Local portal station handoff credentials and optional direct test artifacts # Local portal station handoff credentials and optional direct test artifacts
/test/portal-station.env /test/portal-station.env
/tests/portal-contract/artifacts/ /tests/portal-harness/artifacts/
/artifacts/ /artifacts/
+7
View File
@@ -1,5 +1,12 @@
# Changelog # 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 ## 3.2.4
- Correct profile-backed Wi-Fi hand-off from the embedded web portal: the - Correct profile-backed Wi-Fi hand-off from the embedded web portal: the
+16 -24
View File
@@ -9,14 +9,13 @@ lib_deps =
## Target pins ## Target pins
WiFiManager currently has two explicit ESP32 test lanes: WiFiManager uses one maintained ESP32 test lane:
| Lane | pioarduino platform | Purpose | | Lane | pioarduino platform | Purpose |
| --- | --- | --- | | --- | --- | --- |
| `esp32` | `51.03.05` / Arduino-ESP32 3.0.5 | temporary compatibility contract | | `esp32` | `55.03.311` / Arduino-ESP32 3.3.11 | maintained baseline |
| `esp32_core_3_3_11` / CLI `esp32-current` | `55.03.311` / Arduino-ESP32 3.3.11 | maintained current validation lane |
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 application chooses its own `platform` and must validate the complete
framework/toolchain stack. Do not let a shared global PlatformIO cache choose framework/toolchain stack. Do not let a shared global PlatformIO cache choose
framework metadata or a compiler implicitly, and do not override just the 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` 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 settings in this repository's `platformio.ini`; keep those settings together
when adding an ESP32 environment. The portal OTA fixture uses the current when adding an ESP32 environment. The portal OTA fixture and every guided
3.3.11 lane for ESP32 even while the 3.0.5 compatibility lane remains example use this 3.3.11 ESP32 baseline.
available.
ESP8266 test environments pin framework commit `521ae60` for the upstream ESP8266 test environments pin framework commit `521ae60` for the upstream
Postmortem large-jump linker fix. The exact rationale and update rule are in 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 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). [DeviceFramework's toolchain guide](https://github.com/alexhopeoconnor/DeviceFramework/blob/main/docs/TOOLCHAINS.md).
`./scripts/test.sh` automatically places the `esp32-current` lane, and `./scripts/test.sh` and `./tools/portal-hardware ota --platform esp32` place
`./tools/portal-hardware ota --platform esp32` places its current A/B fixture, the ESP32 A/B fixture,
in a dedicated PlatformIO Core/cache directory, defaulting to in a dedicated PlatformIO Core/cache directory, defaulting to
`${XDG_CACHE_HOME:-$HOME/.cache}/wifimanager-platformio/core-3.3.11`. That `${XDG_CACHE_HOME:-$HOME/.cache}/wifimanager-platformio/core-3.3.11`. That
keeps pioarduino's package-form `esptool` and generated environment separate 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_CORE_DIR`,
`WIFIMANAGER_PLATFORMIO_PACKAGES_DIR`, and `WIFIMANAGER_PLATFORMIO_PACKAGES_DIR`, and
`WIFIMANAGER_PLATFORMIO_CACHE_DIR` when space belongs elsewhere. The first `WIFIMANAGER_PLATFORMIO_CACHE_DIR` when space belongs elsewhere. The first
clean install is several GiB; reserve at least 4 GiB plus cache headroom. It first install is several GiB; reserve at least 4 GiB plus cache headroom. It is
is a deliberate quarantine, not a reason to delete or override packages in the persistent and is never cleared by normal test commands.
shared PlatformIO installation.
For a disposable cache investigation, point that variable at an exact temporary For a disposable cache investigation, point that variable at an exact temporary
directory, run the affected command, inspect the resolved graph, then remove directory, run the affected command, inspect the resolved graph, then remove
@@ -55,9 +52,7 @@ only that directory:
```bash ```bash
wm_pio_core="$(mktemp -d /tmp/wifimanager-pio-XXXXXX)" wm_pio_core="$(mktemp -d /tmp/wifimanager-pio-XXXXXX)"
WIFIMANAGER_PLATFORMIO_CORE_DIR="$wm_pio_core" \ WIFIMANAGER_PLATFORMIO_CORE_DIR="$wm_pio_core" \
./scripts/test.sh compile --platform esp32-current ./scripts/test.sh compile --platform esp32
WIFIMANAGER_PLATFORMIO_CORE_DIR="$wm_pio_core" \
./scripts/test.sh packages --platform esp32-current
rm -rf -- "$wm_pio_core" 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/check-docs.sh
./scripts/test.sh compile --platform esp8266 ./scripts/test.sh compile --platform esp8266
./scripts/test.sh compile --platform esp32 ./scripts/test.sh compile --platform esp32
./scripts/test.sh compile --platform esp32-current
./scripts/test.sh unity --platform esp8266 ./scripts/test.sh unity --platform esp8266
./scripts/test.sh unity --platform esp32 ./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 esp8266
./scripts/test.sh examples --platform esp32 ./scripts/test.sh examples --platform esp32
./scripts/test.sh ota-fixtures --platform esp8266 ./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 ./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, 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 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 it refuses the host default-route adapter and leaves Docker responsible only
for browser/API testing: 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) whole runner as root. See [Testing](TESTING.md#networkmanager-authorization)
for the direct/Polkit and scoped-sudo behavior. 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. 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 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 A, and uses the real browser update form to upload B; do not replace its
automatic-reboot assertion with a manual reset: automatic-reboot assertion with a manual reset:
@@ -118,7 +110,7 @@ automatic-reboot assertion with a manual reset:
--client-interface wlx74da385d4165 --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. 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. 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.
+2 -2
View File
@@ -6,7 +6,7 @@ Apply presentation before autoConnect(), startConfigPortal(), or startWebPortal(
## Portal views ## 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 [Testing](TESTING.md). The nearby networks shown are the networks visible to
the capture device when the portal scans. the capture device when the portal scans.
@@ -61,7 +61,7 @@ void setup() {
void loop() { wifi.process(); } 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 ## Presentation reference
+55 -29
View File
@@ -1,13 +1,13 @@
# Testing # Testing
WiFiManager separates repeatable package checks from opt-in tests that flash a WiFiManager separates repeatable board-free builds from opt-in tests that flash
real board or join a captive portal. The normal commands never need a board, a real board or join a captive portal. The normal commands never need a board,
local Wi-Fi credentials, browser binary, or sibling checkout. 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 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 | A → rendered browser upload B → automatic reboot → B twice | | 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 The selected secondary adapter is intentionally never used for normal LAN
testing. It is `never-default`, so the host's ordinary route remains intact. 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 ## Board-free fixture, consumer, and example builds
The Unity compile check builds WiFiManager's own fixture without a board. The The Unity compile check builds WiFiManager's own fixture without a board. The
clean-consumer check builds a project that declares only WiFiManager. Together consumer check builds a project that declares only WiFiManager, proving that a
they prove the package manifest resolves DFTE, ESPAsyncWebServer, and the correct normal PlatformIO dependency resolution can compile DFTE, ESPAsyncWebServer,
ESP8266 or ESP32 TCP dependency without a sibling checkout. The runner removes and the correct ESP8266 or ESP32 TCP dependency. Normal commands reuse the
a prior local package link before each check, so dependency resolution uses the persistent PlatformIO cache; they do not delete, reinstall, or separately
current manifest rather than a stale `.pio` copy. assert the package graph.
```bash ```bash
./scripts/test.sh compile --platform esp8266 ./scripts/test.sh compile --platform esp8266
./scripts/test.sh compile --platform esp32 ./scripts/test.sh compile --platform esp32
./scripts/test.sh compile --platform esp32-current
./scripts/test.sh unity --platform esp8266 ./scripts/test.sh unity --platform esp8266
./scripts/test.sh unity --platform esp32 ./scripts/test.sh unity --platform esp32
./scripts/test.sh unity --platform esp32-current
./scripts/test.sh examples --platform esp8266 ./scripts/test.sh examples --platform esp8266
./scripts/test.sh examples --platform esp32 ./scripts/test.sh examples --platform esp32
./scripts/test.sh ota-fixtures --platform esp8266 ./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 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; branch. `esp32` uses Arduino-ESP32 3.3.11 and compiles the guided examples.
`esp32-current` is the clean-consumer Arduino-ESP32 3.3.11 validation lane. The OTA fixture builds compile both immutable A
The OTA fixture builds also use 3.3.11 for ESP32 and compile both immutable A
and B images against their tracked OTA partition layout. CI rejects equal A/B 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 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 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 runner flashes the selected board, captures normal-boot serial output with
the repository Bash helper, requires Unity's `Tests 0 Failures` and `OK` the repository Bash helper, requires Unity's `Tests 0 Failures` and `OK`
summary, and prints lifecycle metrics. Hardware work shares a lock with the 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. 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 `test/portal-harness` is deliberately tiny portal-only firmware, not an example
or consuming application. `tools/portal-hardware` flashes it to one explicitly or consuming application. `tools/portal-hardware` flashes it to one explicitly
selected board, joins its AP through one explicitly selected **secondary** 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 Docker image. Docker uses host networking only to reach the already-routed
portal; it never runs NetworkManager or changes host adapters. 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. state-directory artifact path.
On ESP8266, an AP+STA scan can briefly move the radio off the AP channel. The 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 that transport interruption and still requires a reachable portal with a
complete, valid scan result. 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 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 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 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 An optional station handoff test is deliberately separate because it connects
the fixture to a real LAN. Copy the ignored template below, add local 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 credentials, and pass it explicitly. The runner parses only `WIFI_SSID` and
container and is never logged by the runner. `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 A retained session is deliberately never overwritten. Before touching
NetworkManager, the runner atomically records its uniquely generated connection 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 ## Refresh README media
README media is an explicit ESP32-only capture, not part of normal testing or 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 browser tour and stores all candidate files under the ignored
`artifacts/readme-media/` directory by default: `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 copies raw video, browser reports, traces, or arbitrary artifact files. The
renderer preserves the real recording but deliberately presents it at 1.25× renderer preserves the real recording but deliberately presents it at 1.25×
duration and 6 fps so the duration and 6 fps so the
README tour is readable; it does not change normal browser-contract timing. README tour is readable; it does not change normal browser-test-harness timing.
ESP8266 remains covered by the normal hardware and browser contract but does ESP8266 remains covered by the normal hardware and browser test harness but does
not produce duplicate README media. 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 `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 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 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 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 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 `default1` WPA password; this is an AP-access test, not a claim that `/u` has
HTTP route authentication. 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 1. Builds immutable A and B fixture images. Their marker is compiled into the
binary, not saved in WiFiManager settings or EEPROM. 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, 6. Requires the real `POST /u` success response, an automatic portal outage,
automatic restart, and two independent B-marker responses. 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 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 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 issue a manual reset. A board which boots B only after intervention is a
+1 -1
View File
@@ -17,7 +17,7 @@ platform_packages =
[env:esp32] [env:esp32]
extends = common 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 board = esp32dev
build_unflags = -std=gnu++11 build_unflags = -std=gnu++11
build_flags = build_flags =
+1 -1
View File
@@ -17,7 +17,7 @@ platform_packages =
[env:esp32] [env:esp32]
extends = common 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 board = esp32dev
build_unflags = -std=gnu++11 build_unflags = -std=gnu++11
build_flags = build_flags =
+1 -1
View File
@@ -17,7 +17,7 @@ platform_packages =
[env:esp32] [env:esp32]
extends = common 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 board = esp32dev
build_unflags = -std=gnu++11 build_unflags = -std=gnu++11
build_flags = build_flags =
+1 -1
View File
@@ -17,7 +17,7 @@ platform_packages =
[env:esp32] [env:esp32]
extends = common 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 board = esp32dev
build_unflags = -std=gnu++11 build_unflags = -std=gnu++11
build_flags = build_flags =
+5 -1
View File
@@ -939,6 +939,11 @@ void WiFiManagerHandlers::handleUpdating(AsyncWebServerRequest *request, String
} }
#ifdef ESP8266 #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(); WiFiUDP::stopAll();
uint32_t maxSketchSpace = (ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000; uint32_t maxSketchSpace = (ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000;
#elif defined(ESP32) #elif defined(ESP32)
@@ -1610,4 +1615,3 @@ void WiFiManagerHandlers::handleApiPortalExit(AsyncWebServerRequest *request) {
} }
#endif // defined(ESP8266) || defined(ESP32) #endif // defined(ESP8266) || defined(ESP32)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "WiFiManager", "name": "WiFiManager",
"version": "3.2.4", "version": "3.2.5",
"keywords": [ "keywords": [
"wifi", "wifi",
"wi-fi", "wi-fi",
+3 -10
View File
@@ -16,11 +16,11 @@ build_flags =
-DUNIT_TEST -DUNIT_TEST
lib_deps = lib_deps =
ESP32Async/ESPAsyncWebServer@3.9.1 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 ESP32Async/ESPAsyncTCP@2.0.0
[env:esp32] [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 board = esp32dev
framework = arduino framework = arduino
monitor_speed = 115200 monitor_speed = 115200
@@ -36,16 +36,9 @@ build_flags =
-DUNIT_TEST -DUNIT_TEST
lib_deps = lib_deps =
ESP32Async/ESPAsyncWebServer@3.9.1 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 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) ; Optional: compile tests with DFTE logs bridged into WiFiManager::log (see README)
[env:esp8266_dfte_log] [env:esp8266_dfte_log]
extends = env:esp8266 extends = env:esp8266
+13 -48
View File
@@ -4,18 +4,17 @@ set -euo pipefail
usage() { usage() {
cat <<'USAGE' >&2 cat <<'USAGE' >&2
Usage: Usage:
./scripts/test.sh compile --platform esp8266|esp32|esp32-current ./scripts/test.sh compile --platform esp8266|esp32
./scripts/test.sh unity --platform esp8266|esp32|esp32-current ./scripts/test.sh unity --platform esp8266|esp32
./scripts/test.sh examples --platform esp8266|esp32 ./scripts/test.sh examples --platform esp8266|esp32
./scripts/test.sh ota-fixtures --platform esp8266|esp32|esp32-current ./scripts/test.sh ota-fixtures --platform esp8266|esp32
./scripts/test.sh packages --platform esp8266|esp32|esp32-current
./scripts/test.sh hardware --platform esp8266|esp32 --port /dev/serial/by-id/... ./scripts/test.sh hardware --platform esp8266|esp32 --port /dev/serial/by-id/...
USAGE USAGE
exit 2 exit 2
} }
mode="${1:-}" 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 shift
platform="" platform=""
@@ -29,41 +28,23 @@ while [[ $# -gt 0 ]]; do
done done
case "$platform" in case "$platform" in
esp8266|esp32) esp8266|esp32) environment="$platform" ;;
environment="$platform"
;;
esp32-current)
environment="esp32_core_3_3_11"
;;
*) usage ;; *) usage ;;
esac 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" || -n "$port" ]] || usage
[[ "$mode" != "hardware" || -e "$port" ]] || { echo "Serial port not found: $port" >&2; exit 1; } [[ "$mode" != "hardware" || -e "$port" ]] || { echo "Serial port not found: $port" >&2; exit 1; }
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
pio_for_platform() { pio_for_platform() {
if [[ "$platform" != "esp32-current" ]]; then if [[ "$platform" != "esp32" ]]; then
pio "$@" pio "$@"
return return
fi fi
# pioarduino Core 3.3.11's esptool package form cannot safely share a # Keep the maintained Core 3.3.11 package form in a persistent project
# PlatformIO Core directory with a legacy Core 3.0.5 tool-esptoolpy # cache. It is never cleared by this script and avoids stale global
# installation. Keep the current validation lane in a project-owned, # package metadata selecting an incompatible uploader.
# user-cache location unless the developer deliberately supplies one.
local core_dir packages_dir cache_dir local core_dir packages_dir cache_dir
core_dir="${WIFIMANAGER_PLATFORMIO_CORE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/wifimanager-platformio/core-3.3.11}" 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}" 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 echo "Portal OTA fixture A and B are identical." >&2
return 1 return 1
fi fi
if [[ "$platform" == "esp32-current" ]]; then if [[ "$platform" == "esp32" ]]; then
capacity=$((0x1F0000)) capacity=$((0x1F0000))
for firmware in "$firmware_a" "$firmware_b"; do for firmware in "$firmware_a" "$firmware_b"; do
size="$(wc -c < "$firmware" | tr -d '[:space:]')" size="$(wc -c < "$firmware" | tr -d '[:space:]')"
@@ -112,7 +93,7 @@ if [[ "$mode" == "examples" ]]; then
exit 1 exit 1
fi fi
for example in "${examples[@]}"; do for example in "${examples[@]}"; do
pio_for_platform run -d "$example" -e "$platform" </dev/null pio_for_platform run -d "$example" -e "$environment" </dev/null
done done
echo "WiFiManager examples compile check passed for $platform" echo "WiFiManager examples compile check passed for $platform"
exit 0 exit 0
@@ -120,11 +101,7 @@ fi
if [[ "$mode" == "ota-fixtures" ]]; then if [[ "$mode" == "ota-fixtures" ]]; then
fixture_platform="$platform" fixture_platform="$platform"
# ESP32 OTA fixtures are deliberately pinned to the current 3.3.11 lane. if [[ "$fixture_platform" == "esp32" ]]; then
# Accept the same public target name as the clean-consumer check so CI
# never mixes legacy and current platform package graphs in one worker.
if [[ "$fixture_platform" == "esp32-current" ]]; then
fixture_platform="esp32"
"$root/tools/check-ota-partitions.sh" "$root/tools/check-ota-partitions.sh"
fi fi
for image in a b; do for image in a b; do
@@ -144,28 +121,16 @@ if [[ "$mode" == "unity" ]]; then
exit 0 exit 0
fi fi
if [[ "$mode" == "packages" ]]; then
pio_for_platform pkg list -d "$root/test/compile-project" -e "$environment"
exit 0
fi
if [[ "$mode" == "hardware" ]]; then if [[ "$mode" == "hardware" ]]; then
# Upload first, then capture from the normal boot reset. The Unity sketch # Upload first, then capture from the normal boot reset. The Unity sketch
# deliberately waits two seconds before it begins its test sequence. # deliberately waits two seconds before it begins its test sequence.
pio test -d "$root" -e "$platform" --filter test_wifimanager \ pio_for_platform test -d "$root" -e "$platform" --filter test_wifimanager \
--upload-port "$port" --without-testing --upload-port "$port" --without-testing
"$root/scripts/capture-unity-serial.sh" --port "$port" --timeout 300 "$root/scripts/capture-unity-serial.sh" --port "$port" --timeout 300
echo "WiFiManager hardware test passed for $platform on $port" echo "WiFiManager hardware test passed for $platform on $port"
exit 0 exit 0
fi fi
cached_library="$root/test/compile-project/.pio/libdeps/${environment}/WiFiManager"
# The fixture intentionally declares only this local package. Remove a prior
# link so each check resolves the current manifest as a fresh consumer would.
if [[ -d "$cached_library" || -e "${cached_library}.pio-link" ]]; then
pio_for_platform pkg uninstall -d "$root/test/compile-project" -e "$environment" \
-l WiFiManager --no-save --skip-dependencies >/dev/null
fi
pio_for_platform run -d "$root/test/compile-project" -e "$environment" pio_for_platform run -d "$root/test/compile-project" -e "$environment"
echo "WiFiManager consumer compile check passed for $platform" echo "WiFiManager consumer compile check passed for $platform"
+1 -7
View File
@@ -20,7 +20,7 @@ lib_deps =
[env:esp32] [env:esp32]
extends = common 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 board = esp32dev
build_unflags = -std=gnu++11 build_unflags = -std=gnu++11
build_flags = build_flags =
@@ -30,9 +30,3 @@ build_flags =
-DWM_LOG_LEVEL=3 -DWM_LOG_LEVEL=3
lib_deps = lib_deps =
${common.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
+9 -5
View File
@@ -11,7 +11,7 @@ Use it through the repository runner so a secondary Wi-Fi adapter is explicitly
--client-interface wlx... --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. 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 ## A/B portal OTA fixture
The same fixture has dedicated `*_ota_a` and `*_ota_b` PlatformIO environments 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 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. That marker served from the fixture-only `/api/test/firmware-marker` endpoint and an
proves a B boot without trusting saved portal values, EEPROM, or a filename. 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 ```bash
./tools/portal-hardware ota \ ./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 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 live ESP8266 updater capacity), then erases the explicitly selected test board
before serial-flashing A. A successful run leaves B installed in the 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.
+6 -13
View File
@@ -18,7 +18,7 @@ build_flags =
[env:esp32] [env:esp32]
extends = common 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 board = esp32dev
build_unflags = -std=gnu++11 build_unflags = -std=gnu++11
build_flags = build_flags =
@@ -27,14 +27,7 @@ build_flags =
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src -I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
-DWM_LOG_LEVEL=4 -DWM_LOG_LEVEL=4
; The maintained ESP32 validation lane. Keep this named rather than relying on ; OTA test-harness images deliberately differ only by their compile-time marker.
; 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.
; ESP8266 needs an OTA-capable linker layout; ESP32 needs a tracked two-slot ; ESP8266 needs an OTA-capable linker layout; ESP32 needs a tracked two-slot
; partition table. The hardware runner always flashes A over serial then ; partition table. The hardware runner always flashes A over serial then
; uploads B through the real portal form. ; uploads B through the real portal form.
@@ -53,15 +46,15 @@ build_flags =
-DWM_OTA_TEST_IMAGE=\"B\" -DWM_OTA_TEST_IMAGE=\"B\"
[env:esp32_ota_a] [env:esp32_ota_a]
extends = env:esp32_core_3_3_11 extends = env:esp32
board_build.partitions = partitions/esp32_ota_4m_no_fs.csv board_build.partitions = partitions/esp32_ota_4m_no_fs.csv
build_flags = build_flags =
${env:esp32_core_3_3_11.build_flags} ${env:esp32.build_flags}
-DWM_OTA_TEST_IMAGE=\"A\" -DWM_OTA_TEST_IMAGE=\"A\"
[env:esp32_ota_b] [env:esp32_ota_b]
extends = env:esp32_core_3_3_11 extends = env:esp32
board_build.partitions = partitions/esp32_ota_4m_no_fs.csv board_build.partitions = partitions/esp32_ota_4m_no_fs.csv
build_flags = build_flags =
${env:esp32_core_3_3_11.build_flags} ${env:esp32.build_flags}
-DWM_OTA_TEST_IMAGE=\"B\" -DWM_OTA_TEST_IMAGE=\"B\"
+25 -11
View File
@@ -6,24 +6,33 @@ namespace {
// These markers belong only to the portal hardware fixture. They are compiled // 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 // 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 // assertion proves that the new firmware booted after the updater restarted
// the board. Normal portal-contract builds retain a descriptive fixture value. // the board. Normal portal test-harness builds retain a descriptive fixture
#ifndef WM_OTA_TEST_IMAGE // value.
#define WM_OTA_TEST_IMAGE "portal-contract" #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 #endif
#if defined(ESP8266) #if defined(ESP8266)
constexpr char kPortalSsid[] = "WM Contract ESP8266"; constexpr char kPortalSsid[] = "WM Test Harness ESP8266";
#else #else
constexpr char kPortalSsid[] = "WM Contract ESP32"; constexpr char kPortalSsid[] = "WM Test Harness ESP32";
#endif #endif
constexpr char kPortalPassword[] = "default1"; constexpr char kPortalPassword[] = "default1";
WiFiManager wifi; WiFiManager wifi;
WiFiManagerParameter kInstallationLabel( 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 // Keep the characters from upstream issue #1863 in the portal fixture. The
// browser contract verifies this value through JSON, DOM rendering, save, and // browser test harness verifies this value through JSON, DOM rendering, save,
// a subsequent reload rather than relying on a string-only serializer check. // and a subsequent reload rather than relying on a string-only serializer
// check.
WiFiManagerParameter kEscapedValue( WiFiManagerParameter kEscapedValue(
"escaped_value", "Escaped value", "7(f+4]2y3fsYTQt'Uhxc\"d\\<>&", 64); "escaped_value", "Escaped value", "7(f+4]2y3fsYTQt'Uhxc\"d\\<>&", 64);
WiFiManagerParameter kMqttHost( WiFiManagerParameter kMqttHost(
@@ -45,7 +54,7 @@ WiFiManagerParameter kLongitude(
WiFiManagerParameter kFirmwareChannel( WiFiManagerParameter kFirmwareChannel(
"firmware_channel", "Firmware channel", "stable", 16); "firmware_channel", "Firmware channel", "stable", 16);
WiFiManagerParameter kOwnerName( WiFiManagerParameter kOwnerName(
"owner_name", "Owner name", "Portal contract", 48); "owner_name", "Owner name", "Portal test harness", 48);
WiFiManagerParameter kNotes( WiFiManagerParameter kNotes(
"notes", "Notes", "Thirteen-field rendering fixture", 64); "notes", "Notes", "Thirteen-field rendering fixture", 64);
@@ -91,7 +100,12 @@ void registerOtaTestMarker() {
void setup() { void setup() {
Serial.begin(115200); 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 // This fixture must be independent of whichever sketch was previously
// flashed to the board. Clear saved station credentials before starting // flashed to the board. Clear saved station credentials before starting
@@ -107,7 +121,7 @@ void setup() {
IPAddress(255, 255, 255, 0)); IPAddress(255, 255, 255, 0));
registerOtaTestMarker(); registerOtaTestMarker();
// Keep custom parameters on their own native Save parameters page. This // 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. // starting a station connection as part of the regression test.
wifi.portalSetLayoutParamsLocation(PortalParamsLocation::SetupPage); wifi.portalSetLayoutParamsLocation(PortalParamsLocation::SetupPage);
for (auto* parameter : kPortalParameters) { for (auto* parameter : kPortalParameters) {
+2 -1
View File
@@ -1,4 +1,5 @@
# Ignored local credentials for the optional station handoff test. # 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_SSID=replace-me
WIFI_PASSWORD=replace-me WIFI_PASSWORD=replace-me
+1 -1
View File
@@ -40,7 +40,7 @@ TestCase tests[] = {
TEST_ENTRY(test_get_config_portal_ssid), TEST_ENTRY(test_get_config_portal_ssid),
TEST_ENTRY(test_bootstrap_json_portal_feature_flags), TEST_ENTRY(test_bootstrap_json_portal_feature_flags),
TEST_ENTRY(test_portal_default_presentation), 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_bootstrap_json_snapshot_consistency),
TEST_ENTRY(test_root_render_interleaved_context_isolation), TEST_ENTRY(test_root_render_interleaved_context_isolation),
TEST_ENTRY(test_portal_presentation_configuration), TEST_ENTRY(test_portal_presentation_configuration),
+1 -1
View File
@@ -39,7 +39,7 @@ void test_config_portal_already_active();
void test_get_config_portal_ssid(); void test_get_config_portal_ssid();
void test_bootstrap_json_portal_feature_flags(); void test_bootstrap_json_portal_feature_flags();
void test_portal_default_presentation(); 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_bootstrap_json_snapshot_consistency();
void test_root_render_interleaved_context_isolation(); void test_root_render_interleaved_context_isolation();
void test_portal_presentation_configuration(); void test_portal_presentation_configuration();
@@ -87,8 +87,8 @@ void test_portal_default_presentation() {
TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"logoAltText\":\"\""))); TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"logoAltText\":\"\"")));
} }
void test_bootstrap_json_contract_v3() { void test_bootstrap_json_schema_v3() {
Serial.println("[TEST] Testing bootstrap JSON v3 contract..."); Serial.println("[TEST] Testing bootstrap JSON v3 schema...");
WiFiManager wm; WiFiManager wm;
WiFiManagerHandlers handlers(&wm); WiFiManagerHandlers handlers(&wm);
@@ -143,7 +143,7 @@ void test_bootstrap_json_contract_v3() {
TEST_ASSERT_EQUAL(-1, j.indexOf(F("\"portalTimeoutSecondsRemaining\":0"))); TEST_ASSERT_EQUAL(-1, j.indexOf(F("\"portalTimeoutSecondsRemaining\":0")));
wm.wmTestSetPortalActive(false); 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() { void test_bootstrap_json_snapshot_consistency() {
+10
View File
@@ -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.*
@@ -1,17 +1,17 @@
ARG PLAYWRIGHT_VERSION=1.63.0 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 ARG PLAYWRIGHT_VERSION
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
WORKDIR /work 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 && \ RUN npm ci --ignore-scripts && \
[ "$(node -p "require('@playwright/test/package.json').version")" = "$PLAYWRIGHT_VERSION" ] [ "$(node -p "require('@playwright/test/package.json').version")" = "$PLAYWRIGHT_VERSION" ]
COPY tests/portal-contract/ ./ COPY . ./
RUN chmod 755 /work/run-portal-contract.sh /work/render-readme-media.sh RUN chmod 755 /work/run-portal-harness.sh /work/render-readme-media.sh
CMD ["/work/run-portal-contract.sh"] CMD ["/work/run-portal-harness.sh"]
FROM portal-contract AS media FROM portal-harness AS media
USER root USER root
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends ffmpeg && \ apt-get install -y --no-install-recommends ffmpeg && \
@@ -1,4 +1,4 @@
# Portal contract container # Portal test-harness container
This directory contains the browser/API half of the real-hardware portal test. This directory contains the browser/API half of the real-hardware portal test.
Run it through [`../../tools/portal-hardware`](../../tools/portal-hardware), not 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. to the output directory printed by the host command.
`compose.ota.yaml` is an overlay used only by `portal-hardware ota`. It mounts `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 already-built B firmware read-only and enables the A/B browser test
The ordinary portal contract never receives a firmware artifact. 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.
@@ -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 # firmware image. portal-hardware supplies this absolute path after it has
# built B and copied it into the run's private artifact directory. # built B and copied it into the run's private artifact directory.
services: services:
portal-contract: portal-harness:
environment: environment:
PORTAL_OTA_FIRMWARE: /firmware/portal-ota-b.bin PORTAL_OTA_FIRMWARE: /firmware/portal-ota-b.bin
PORTAL_OTA_INITIAL_MARKER: ${PORTAL_OTA_INITIAL_MARKER:-A} PORTAL_OTA_INITIAL_MARKER: ${PORTAL_OTA_INITIAL_MARKER:-A}
@@ -1,5 +1,5 @@
services: services:
portal-contract: portal-harness:
environment: environment:
PORTAL_STATION_ENV: /run/secrets/portal-station.env PORTAL_STATION_ENV: /run/secrets/portal-station.env
volumes: volumes:
@@ -1,9 +1,13 @@
services: services:
portal-contract: portal-harness:
build: build:
context: ../.. # Keep Docker's build context limited to the browser test harness. A
dockerfile: tests/portal-contract/Dockerfile # developer may point --station-env or --output anywhere in the repo;
target: ${PORTAL_CONTRACT_DOCKER_TARGET:-portal-contract} # 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: args:
PLAYWRIGHT_VERSION: "1.63.0" PLAYWRIGHT_VERSION: "1.63.0"
network_mode: host network_mode: host
@@ -1,11 +1,11 @@
{ {
"name": "wifimanager-portal-contract", "name": "wifimanager-portal-harness",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wifimanager-portal-contract", "name": "wifimanager-portal-harness",
"version": "1.0.0", "version": "1.0.0",
"devDependencies": { "devDependencies": {
"@playwright/test": "1.63.0" "@playwright/test": "1.63.0"
@@ -1,5 +1,5 @@
{ {
"name": "wifimanager-portal-contract", "name": "wifimanager-portal-harness",
"private": true, "private": true,
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
@@ -1,7 +1,13 @@
// Configuration for the browser half of the portal test harness.
const path = require('path'); const path = require('path');
const { defineConfig } = require('@playwright/test'); const { defineConfig } = require('@playwright/test');
const artifactDir = process.env.ARTIFACT_DIR || path.join(__dirname, 'artifacts'); 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({ module.exports = defineConfig({
testDir: './tests', testDir: './tests',
@@ -18,8 +24,8 @@ module.exports = defineConfig({
], ],
use: { use: {
baseURL: process.env.PORTAL_URL || 'http://192.168.4.1', baseURL: process.env.PORTAL_URL || 'http://192.168.4.1',
screenshot: 'only-on-failure', screenshot: hasStationCredentials ? 'off' : 'only-on-failure',
trace: 'retain-on-failure', trace: hasStationCredentials ? 'off' : 'retain-on-failure',
video: 'retain-on-failure', video: hasStationCredentials ? 'off' : 'retain-on-failure',
}, },
}); });
@@ -1,4 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# README-media renderer used by the portal test harness.
set -euo pipefail set -euo pipefail
artifact_dir="${ARTIFACT_DIR:?ARTIFACT_DIR is required}" artifact_dir="${ARTIFACT_DIR:?ARTIFACT_DIR is required}"
@@ -3,7 +3,7 @@ set -euo pipefail
playwright_args=(test --config /work/playwright.config.js) playwright_args=(test --config /work/playwright.config.js)
if [[ -n "${PORTAL_TEST_FILE:-}" ]]; then 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. # spec instead of allowing ordinary portal tests to consume its AP window.
playwright_args+=("$PORTAL_TEST_FILE") playwright_args+=("$PORTAL_TEST_FILE")
fi fi
@@ -1,7 +1,18 @@
const { test, expect } = require('@playwright/test'); 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 = [ 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: 'escaped_value', value: "7(f+4]2y3fsYTQt'Uhxc\"d\\<>&" },
{ id: 'mqtt_host', value: 'broker.example.local' }, { id: 'mqtt_host', value: 'broker.example.local' },
{ id: 'mqtt_port', value: '1883' }, { id: 'mqtt_port', value: '1883' },
@@ -12,7 +23,7 @@ const fixtureParameters = [
{ id: 'latitude', value: '-27.4698' }, { id: 'latitude', value: '-27.4698' },
{ id: 'longitude', value: '153.0251' }, { id: 'longitude', value: '153.0251' },
{ id: 'firmware_channel', value: 'stable' }, { 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' }, { id: 'notes', value: 'Thirteen-field rendering fixture' },
]; ];
const escapedUpdatedValue = "updated 'quote\" slash\\<>&"; const escapedUpdatedValue = "updated 'quote\" slash\\<>&";
@@ -49,7 +60,7 @@ async function waitForScan(request) {
return result; 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 }) => { test('serves API, retains all thirteen fixture parameters, and completes a real scan', async ({ request }) => {
const root = await request.get('/'); const root = await request.get('/');
expect(root.ok()).toBeTruthy(); expect(root.ok()).toBeTruthy();
@@ -115,7 +126,7 @@ test.describe('portal AP contract', () => {
await page.goto('/', { waitUntil: 'networkidle' }); await page.goto('/', { waitUntil: 'networkidle' });
await expect(page.locator('#wm-reset-portal-timeout')).toBeVisible(); 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 page.goto('/#/wifi', { waitUntil: 'networkidle' });
await expect(page.locator('#wm-refresh-scan')).toBeVisible(); 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-installation_label')).toBeVisible();
await expect(page.locator('#wm-f-escaped_value')).toHaveValue(fixtureParameters[1].value); await expect(page.locator('#wm-f-escaped_value')).toHaveValue(fixtureParameters[1].value);
await page.locator('#wm-f-installation_label').fill('Browser verified'); 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 mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true });
const mobilePage = await mobile.newPage(); const mobilePage = await mobile.newPage();
@@ -134,7 +145,7 @@ test.describe('portal AP contract', () => {
}); });
await mobilePage.goto('/#/info', { waitUntil: 'networkidle' }); await mobilePage.goto('/#/info', { waitUntil: 'networkidle' });
await expect(mobilePage.locator('.wm-page-head')).toBeVisible(); 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 mobile.close();
await desktop.close(); await desktop.close();
@@ -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('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.'); test.skip(!firmware, 'OTA firmware is mounted only for portal-hardware ota.');
// Initial portal availability, an observed outage, and two fresh B // Initial portal availability, an observed outage, and two fresh B
// responses each have their own bounded waits. Keep the overall budget // responses each have their own bounded waits. Keep the overall budget
// larger than their sum so a valid slow reassociation is not killed by // 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); test.setTimeout(300_000);
const initial = await waitForMarker(request, initialMarker); const initial = await waitForMarker(request, initialMarker);
@@ -24,6 +24,8 @@ async function waitForCompletedScan(request) {
test.describe('WiFiManager README media', () => { test.describe('WiFiManager README media', () => {
test.skip(process.env.PORTAL_CAPTURE_README_MEDIA !== '1', 'README capture was not requested.'); 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 }) => { test('records an approved ESP32 portal tour', async ({ browser, request }) => {
const context = await browser.newContext({ const context = await browser.newContext({
@@ -43,7 +45,7 @@ test.describe('WiFiManager README media', () => {
const video = page.video(); const video = page.video();
await page.goto('/', { waitUntil: 'networkidle' }); await page.goto('/', { waitUntil: 'networkidle' });
await expect(page.locator('#wm-reset-portal-timeout')).toBeVisible(); 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. // remains timing-focused; this tour needs readable stable states.
await page.waitForTimeout(1200); await page.waitForTimeout(1200);
await page.screenshot({ path: mediaPath('portal-overview.png'), fullPage: true }); await page.screenshot({ path: mediaPath('portal-overview.png'), fullPage: true });
@@ -1,3 +1,4 @@
// Optional LAN handoff exercised by the portal test harness.
const fs = require('fs'); const fs = require('fs');
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
+95
View File
@@ -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())
+1 -1
View File
@@ -63,4 +63,4 @@ if awk -F, '
exit 1 exit 1
fi fi
echo "ESP32 OTA partition contract passed: $table" echo "ESP32 OTA partition test-harness check passed: $table"
+99 -14
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/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. # They never modify a network interface other than the explicit client adapter.
wm_portal_state_root() { 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}" local lock_file="${WM_HARDWARE_LOCK_FILE:-${TMPDIR:-/tmp}/deviceframework-hardware-test.lock}"
# `ota` deliberately acquires this before it builds firmware, then calls # `ota` deliberately acquires this before it builds firmware, then calls
# the shared portal-start helper which also acquires it. Keep that nested # 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. # only the serial flash and adapter connection.
[[ "${WM_HARDWARE_LOCK_HELD:-no}" == "yes" ]] && return 0 [[ "${WM_HARDWARE_LOCK_HELD:-no}" == "yes" ]] && return 0
exec 9>"$lock_file" exec 9>"$lock_file"
@@ -140,7 +140,7 @@ wm_acquire_hardware_lock() {
} }
wm_require_client_adapter() { 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 || { ip link show "$interface" >/dev/null 2>&1 || {
echo "Wi-Fi interface not found: $interface" >&2 echo "Wi-Fi interface not found: $interface" >&2
return 1 return 1
@@ -150,7 +150,22 @@ wm_require_client_adapter() {
echo "Refusing to use the host default-route interface: $interface" >&2 echo "Refusing to use the host default-route interface: $interface" >&2
return 1 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 if [[ -n "$active_connection" && "$active_connection" != "--" && "$allow_takeover" != "yes" ]]; then
echo "Client adapter $interface already has connection '$active_connection'." >&2 echo "Client adapter $interface already has connection '$active_connection'." >&2
echo "Pass --take-over-client-adapter to replace only that adapter's 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() { wm_portal_ssid() {
case "$1" in case "$1" in
esp8266) printf '%s\n' 'WM Contract ESP8266' ;; esp8266) printf '%s\n' 'WM Test Harness ESP8266' ;;
esp32) printf '%s\n' 'WM Contract ESP32' ;; esp32) printf '%s\n' 'WM Test Harness ESP32' ;;
*) return 1 ;; *) return 1 ;;
esac esac
} }
@@ -194,11 +209,49 @@ wm_remove_connection_by_name() {
local name="$1" local name="$1"
[[ -n "$name" ]] || return 0 [[ -n "$name" ]] || return 0
wm_nmcli connection down "$name" >/dev/null 2>&1 || true 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() { 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)" name="wifimanager-portal-${RANDOM}-$(date +%s)"
# Publish the owned name before the first NetworkManager mutation. The # Publish the owned name before the first NetworkManager mutation. The
# caller's signal trap can then remove it throughout the pending-to-active # caller's signal trap can then remove it throughout the pending-to-active
@@ -217,7 +270,16 @@ wm_create_portal_connection() {
fi fi
wm_nmcli device disconnect "$interface" >/dev/null 2>&1 || true wm_nmcli device disconnect "$interface" >/dev/null 2>&1 || true
if ! wm_wait_for_portal_ssid "$interface" "$ssid"; then 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 return 1
fi fi
if ! wm_nmcli connection add type wifi ifname "$interface" con-name "$name" ssid "$ssid" \ 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 wm_cleanup_created_connection
return 1 return 1
fi 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 if ! wm_nmcli connection up "$name" ifname "$interface"; then
wm_cleanup_created_connection wm_cleanup_created_connection
return 1 return 1
@@ -264,11 +335,22 @@ wm_verify_portal_route() {
wm_remove_connection() { wm_remove_connection() {
local uuid="${1:-}" name="${2:-}" local uuid="${1:-}" name="${2:-}"
[[ -n "$uuid" || -n "$name" ]] || return 0
if [[ -n "$uuid" ]]; then if [[ -n "$uuid" ]]; then
wm_nmcli connection down uuid "$uuid" >/dev/null 2>&1 || true 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 elif [[ -n "$name" ]]; then
wm_remove_connection_by_name "$name" wm_remove_connection_by_name "$name" || return 1
fi fi
} }
@@ -277,8 +359,11 @@ wm_cleanup_created_connection() {
# separate from `finish_portal_session`, which reads a retained state file # separate from `finish_portal_session`, which reads a retained state file
# for an explicit later `down` command. # for an explicit later `down` command.
[[ "${WM_PORTAL_CONNECTION_OWNED:-no}" == "yes" ]] || return 0 [[ "${WM_PORTAL_CONNECTION_OWNED:-no}" == "yes" ]] || return 0
wm_remove_connection "${WM_PORTAL_CONNECTION_UUID:-}" "${WM_PORTAL_CONNECTION_NAME:-}" wm_remove_connection "${WM_PORTAL_CONNECTION_UUID:-}" "${WM_PORTAL_CONNECTION_NAME:-}" || return 1
wm_clear_state 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 WM_PORTAL_CONNECTION_OWNED=no
unset WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME unset WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
} }
@@ -375,5 +460,5 @@ wm_load_state() {
wm_clear_state() { wm_clear_state() {
local file local file
file="$(wm_state_file)" file="$(wm_state_file)"
rm -f "$file" rm -f -- "$file"
} }
+298 -42
View File
@@ -52,6 +52,12 @@ ota_environment_b=""
ota_firmware_a="" ota_firmware_a=""
ota_firmware_b="" ota_firmware_b=""
ota_browser_prebuilt="no" 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 # `wm_create_portal_connection` sets these before NetworkManager mutates the
# secondary adapter. Keeping the ownership record in-process means the # secondary adapter. Keeping the ownership record in-process means the
# signal trap can clean up the exact pending or active connection immediately. # signal trap can clean up the exact pending or active connection immediately.
@@ -83,6 +89,14 @@ require_common() {
docker compose version >/dev/null 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() { wm_pio_executable() {
# Hardware runners are commonly launched over non-interactive SSH, where # Hardware runners are commonly launched over non-interactive SSH, where
# a normal PlatformIO installation is not necessarily on PATH. Honor an # a normal PlatformIO installation is not necessarily on PATH. Honor an
@@ -119,11 +133,9 @@ pio_for_portal_environment() {
shift shift
case "$environment" in case "$environment" in
# The ESP32 A/B fixture extends the 3.3.11 environment. Its package # Keep the maintained Core 3.3.11 graph in the same persistent cache
# graph must not share a Core directory with the legacy 3.0.5 portal # as the ESP32 A/B fixture. It is never cleared by this test harness.
# fixture, whose tool-esptoolpy package name collides with current esp32|esp32_ota_*)
# pioarduino metadata.
esp32_core_3_3_11|esp32_ota_*)
;; ;;
*) *)
wm_pio "$@" wm_pio "$@"
@@ -164,6 +176,20 @@ validate_run_arguments() {
echo "Station environment file is not readable: $station_env" >&2 echo "Station environment file is not readable: $station_env" >&2
exit 1 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 if [[ "$capture_readme_media" == "yes" ]]; then
[[ "$command_name" == "run" && "$platform" == "esp32" ]] || { [[ "$command_name" == "run" && "$platform" == "esp32" ]] || {
echo "--capture-readme-media is supported only by run --platform esp32" >&2 echo "--capture-readme-media is supported only by run --platform esp32" >&2
@@ -200,6 +226,96 @@ validate_run_arguments() {
fi 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() { write_readme_media_manifest() {
local media_dir="$output_dir/readme-media" local media_dir="$output_dir/readme-media"
local required local required
@@ -217,7 +333,7 @@ write_readme_media_manifest() {
wait_for_portal_ready() { wait_for_portal_ready() {
# A portal SSID can be visible before its first background scan has # A portal SSID can be visible before its first background scan has
# completed. Wait for that normal portal-start work before the browser # 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 local attempt response
for attempt in $(seq 1 45); do for attempt in $(seq 1 45); do
response="$(curl --interface "$client_interface" --connect-timeout 3 --max-time 5 \ response="$(curl --interface "$client_interface" --connect-timeout 3 --max-time 5 \
@@ -236,13 +352,16 @@ wait_for_portal_ready() {
} }
start_portal_session() { 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 validate_run_arguments
require_common require_common
wm_acquire_hardware_lock wm_acquire_hardware_lock
wm_require_no_active_session 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_prepare_networkmanager_authorization
wm_require_client_adapter "$client_interface" "$takeover"
prepare_output_dir prepare_output_dir
ssid="$(wm_portal_ssid "$platform")" ssid="$(wm_portal_ssid "$platform")"
@@ -255,10 +374,23 @@ start_portal_session() {
ota_assert_a_artifact_matches_build "$expected_a_artifact" ota_assert_a_artifact_matches_build "$expected_a_artifact"
fi fi
pio_for_portal_environment "$environment" run -d "$root/test/portal-harness" -e "$environment" -t upload --upload-port "$port" 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 if [[ -n "$expected_a_artifact" ]]; then
ota_assert_a_artifact_matches_build "$expected_a_artifact" ota_assert_a_artifact_matches_build "$expected_a_artifact"
fi 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 return 1
fi fi
if ! wm_verify_portal_route "$client_interface"; then 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 ota_assert_firmware_fits "$ota_firmware_b" "$capacity" B
} }
configure_ota_contract_environment() { configure_ota_harness_environment() {
export PORTAL_ARTIFACT_DIR="$output_dir" export PORTAL_ARTIFACT_DIR="$output_dir"
export LOCAL_UID="$(id -u)" export LOCAL_UID="$(id -u)"
export LOCAL_GID="$(id -g)" export LOCAL_GID="$(id -g)"
export PORTAL_BROWSER_MODE=auto 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. # 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_CAPTURE_README_MEDIA=0
export PORTAL_CUSTOM_PARAMETER_STRESS=0 export PORTAL_CUSTOM_PARAMETER_STRESS=0
export PORTAL_TEST_FILE=tests/ota.spec.js export PORTAL_TEST_FILE=tests/ota.spec.js
@@ -464,36 +596,119 @@ configure_ota_contract_environment() {
ota_compose_files() { ota_compose_files() {
printf '%s\n' \ printf '%s\n' \
-f "$root/tests/portal-contract/compose.yaml" \ -f "$root/tests/portal-harness/compose.yaml" \
-f "$root/tests/portal-contract/compose.ota.yaml" -f "$root/tests/portal-harness/compose.ota.yaml"
} }
prepare_ota_contract_image() { prepare_ota_harness_image() {
local -a compose_files local -a compose_files
configure_ota_contract_environment configure_ota_harness_environment
mapfile -t compose_files < <(ota_compose_files) mapfile -t compose_files < <(ota_compose_files)
# Build before A is flashed. A cold Playwright build can otherwise consume # Build before A is flashed. A cold Playwright build can otherwise consume
# the finite portal window before the browser has connected. # 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 ota_browser_prebuilt=yes
} }
run_ota_contract() { run_ota_harness() {
local -a compose_files local -a compose_files
configure_ota_contract_environment configure_ota_harness_environment
mapfile -t compose_files < <(ota_compose_files) mapfile -t compose_files < <(ota_compose_files)
[[ "$ota_browser_prebuilt" == yes ]] || { [[ "$ota_browser_prebuilt" == yes ]] || {
echo "Portal OTA browser image was not prepared before A was flashed." >&2 echo "Portal OTA browser image was not prepared before A was flashed." >&2
return 1 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() { finish_portal_session() {
wm_load_state # A normal run already owns an in-process record. `down` starts fresh, so
wm_prepare_networkmanager_authorization # load its retained exact record and select the scoped authorization path
wm_remove_connection "$WM_PORTAL_CONNECTION_UUID" "$WM_PORTAL_CONNECTION_NAME" # before attempting deletion.
wm_clear_state 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() { 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 signal must never fall through into the rest of the hardware run, and
# a recursive EXIT trap must not obscure the original status. # a recursive EXIT trap must not obscure the original status.
trap - EXIT HUP INT TERM 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" ]] && \ if [[ "${WM_PORTAL_CONNECTION_OWNED:-no}" == "yes" ]] && \
{ (( status != 0 )) || { [[ "$command_name" != "up" && "$keep" != "yes" ]]; }; }; then { (( 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 fi
exit "$status" exit "$status"
} }
@@ -518,6 +745,9 @@ case "$command_name" in
[[ -n "$client_interface" ]] || usage [[ -n "$client_interface" ]] || usage
require_common require_common
wm_acquire_hardware_lock 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_require_client_adapter "$client_interface" "$takeover"
wm_report_networkmanager_authorization wm_report_networkmanager_authorization
printf 'Portal hardware prerequisites are ready. Main route is untouched; client adapter: %s\n' "$client_interface" 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) down)
[[ -z "$platform$port$client_interface$station_env$output_dir" ]] || usage [[ -z "$platform$port$client_interface$station_env$output_dir" ]] || usage
wm_acquire_hardware_lock 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.' echo 'Portal client connection removed.'
;; ;;
run) run)
@@ -547,24 +780,36 @@ case "$command_name" in
export PORTAL_PLATFORM="$platform" export PORTAL_PLATFORM="$platform"
if [[ "$capture_readme_media" == "yes" ]]; then if [[ "$capture_readme_media" == "yes" ]]; then
export PORTAL_CAPTURE_README_MEDIA=1 export PORTAL_CAPTURE_README_MEDIA=1
export PORTAL_CONTRACT_DOCKER_TARGET=media export PORTAL_HARNESS_DOCKER_TARGET=media
else else
export PORTAL_CAPTURE_README_MEDIA=0 export PORTAL_CAPTURE_README_MEDIA=0
export PORTAL_CONTRACT_DOCKER_TARGET=portal-contract export PORTAL_HARNESS_DOCKER_TARGET=portal-harness
fi fi
compose_files=(-f "$root/tests/portal-contract/compose.yaml") compose_files=(-f "$root/tests/portal-harness/compose.yaml")
if [[ -n "$station_env" ]]; then if [[ -n "$station_env" ]]; then
export PORTAL_STATION_ENV_HOST="$(cd "$(dirname "$station_env")" && pwd)/$(basename "$station_env")" prepare_station_handoff_env
compose_files+=(-f "$root/tests/portal-contract/compose.station.yaml") 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 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. # this invocation always tests the checked-out files, not a stale image.
docker compose "${compose_files[@]}" build portal-contract docker compose "${compose_files[@]}" build portal-harness
docker compose "${compose_files[@]}" run --rm portal-contract docker compose "${compose_files[@]}" run --rm portal-harness
restore_station_handoff_fixture
remove_station_handoff_env
if [[ "$capture_readme_media" == "yes" ]]; then if [[ "$capture_readme_media" == "yes" ]]; then
write_readme_media_manifest write_readme_media_manifest
fi 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 if [[ "$keep" == "yes" ]]; then
printf 'Portal session remains connected; run ./tools/portal-hardware down when finished.\n' printf 'Portal session remains connected; run ./tools/portal-hardware down when finished.\n'
fi fi
@@ -577,27 +822,38 @@ case "$command_name" in
require_common require_common
wm_acquire_hardware_lock wm_acquire_hardware_lock
wm_require_no_active_session wm_require_no_active_session
wm_require_client_adapter "$client_interface" "$takeover"
wm_prepare_networkmanager_authorization wm_prepare_networkmanager_authorization
wm_require_client_adapter "$client_interface" "$takeover"
require_ota_serial_capture
prepare_output_dir prepare_output_dir
prepare_ota_firmware prepare_ota_firmware
prepare_ota_contract_image prepare_ota_harness_image
start_portal_session "$ota_environment_a" yes "$ota_firmware_a" 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 # A must be visible through the real portal before a B upload can be
# meaningful. On ESP8266 the marker also reports the actual active # meaningful. On ESP8266 the marker also reports the actual active
# sketch-space limit, which is checked before the browser sees B. # sketch-space limit, which is checked before the browser sees B.
wait_for_ota_marker A >/dev/null wait_for_ota_marker A >/dev/null
validate_running_ota_capacity 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 # 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 wait_for_ota_marker B >/dev/null
require_ota_serial_capture_running
sleep 1 sleep 1
wait_for_ota_marker B >/dev/null 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 if [[ "$keep" == "yes" ]]; then
printf 'Portal session remains connected; run ./tools/portal-hardware down when finished.\n' printf 'Portal session remains connected; run ./tools/portal-hardware down when finished.\n'
fi fi
+133
View File
@@ -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()
-279
View File
@@ -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'