Compare commits

...
16 Commits
73 changed files with 3276 additions and 441 deletions
+14
View File
@@ -0,0 +1,14 @@
# Keep local credentials, build output, and test artifacts out of the Docker
# build context. OTA firmware is mounted read-only by compose.ota.yaml instead
# of copied into an image.
.git
.github
.pio
**/.pio
node_modules
**/node_modules
artifacts
test/portal-station.env
test/.env
platformio.local.ini*
**/platformio.local.ini*
+25 -5
View File
@@ -20,8 +20,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: bash -n scripts/*.sh tools/portal-hardware tools/lib/*.sh tools/tests/*.sh
- run: ./tools/tests/test-portal-hardware-cli.sh
- run: bash -n scripts/*.sh tools/check-ota-partitions.sh tools/portal-hardware tools/lib/*.sh tools/tests/*.sh
- run: python3 -m py_compile test/portal-harness/tools/ota_fixture_input.py
- 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: timeout 15s bash tools/tests/test-ota-fixture-identity.sh
- run: ./tools/check-ota-partitions.sh
- run: ./scripts/check-docs.sh
compile-tests:
@@ -29,12 +36,25 @@ jobs:
strategy:
fail-fast: false
matrix:
environment: ["esp8266", "esp32"]
include:
- platform: esp8266
examples: true
ota_fixtures: true
unity: true
- platform: esp32
examples: true
ota_fixtures: true
unity: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19
- run: ./scripts/test.sh compile --platform ${{ matrix.environment }}
- run: ./scripts/test.sh examples --platform ${{ matrix.environment }}
- run: ./scripts/test.sh compile --platform ${{ matrix.platform }}
- if: matrix.unity
run: ./scripts/test.sh unity --platform ${{ matrix.platform }}
- if: matrix.examples
run: ./scripts/test.sh examples --platform ${{ matrix.platform }}
- if: matrix.ota_fixtures
run: ./scripts/test.sh ota-fixtures --platform ${{ matrix.platform }}
+48 -7
View File
@@ -6,22 +6,63 @@ on:
- 'v*'
permissions:
contents: write
contents: read
jobs:
publish:
documentation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: bash -n scripts/*.sh tools/check-ota-partitions.sh tools/portal-hardware tools/lib/*.sh tools/tests/*.sh
- run: |
for spec in tests/portal-harness/tests/*.js; do
node --check "$spec"
done
- run: timeout 15s python3 tools/tests/test-capture-serial.py
- run: ./tools/check-ota-partitions.sh
- run: ./scripts/check-docs.sh
compile-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- platform: esp8266
examples: true
ota_fixtures: true
unity: true
- platform: esp32
examples: true
ota_fixtures: true
unity: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19
- run: ./scripts/test.sh compile --platform ${{ matrix.platform }}
- if: matrix.unity
run: ./scripts/test.sh unity --platform ${{ matrix.platform }}
- if: matrix.examples
run: ./scripts/test.sh examples --platform ${{ matrix.platform }}
- if: matrix.ota_fixtures
run: ./scripts/test.sh ota-fixtures --platform ${{ matrix.platform }}
publish:
needs:
- documentation
- compile-tests
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19
- run: ./scripts/test.sh compile --platform esp8266
- run: ./scripts/test.sh examples --platform esp8266
- run: ./scripts/test.sh compile --platform esp32
- run: ./scripts/test.sh examples --platform esp32
- run: ./scripts/check-docs.sh
- run: ./scripts/prepare-release.sh "$GITHUB_REF_NAME"
- run: ./scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-notes.md"
- run: >-
+2 -1
View File
@@ -36,4 +36,5 @@ node_modules/
# Local portal station handoff credentials and optional direct test artifacts
/test/portal-station.env
/tests/portal-contract/artifacts/
/tests/portal-harness/artifacts/
/artifacts/
+11
View File
@@ -1,5 +1,16 @@
# 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.
- Send the successful portal OTA response before scheduling the restart, so
browsers can observe a completed HTTP exchange on both ESP8266 and ESP32.
- Give ESP32's Wi-Fi radio a five-second hand-off interval before a user scan
retry, avoiding transient scan failures immediately after completion.
## 3.2.4
- Correct profile-backed Wi-Fi hand-off from the embedded web portal: the
+20 -5
View File
@@ -4,10 +4,12 @@ WiFiManager gives ESP8266 and ESP32 firmware a polished, self-hosted Wi-Fi setup
## See it on real hardware
| Brand the built-in portal | Combine Wi-Fi and application setup |
| --- | --- |
| ![Branded WiFiManager portal overview on an ESP32.](docs/assets/portal-esp32-branded-overview.png) | ![WiFiManager network picker and custom MQTT broker field on an ESP8266.](docs/assets/portal-esp8266-custom-wifi.png) |
| Give each product its own title, identity, icon, and color theme without copying portal HTML. | Show live nearby networks and collect application values, such as an MQTT broker, in the same setup flow. |
![A short WiFiManager portal tour showing product branding, nearby networks,
application settings, and scan feedback.](docs/assets/readme/portal-tour.gif)
WiFiManager provides a self-hosted setup portal for Wi-Fi and application
settings without copying portal HTML into each firmware. See the detailed
[portal UI guide](docs/PORTAL_UI.md) for supported branding and content APIs.
## Start with a working portal
@@ -19,17 +21,30 @@ WiFiManager wifi;
void setup() {
Serial.begin(115200);
// Leave the temporary setup portal available for three minutes.
wifi.setConfigPortalTimeout(180);
// Reconnect to saved Wi-Fi, or open the setup portal when none works.
wifi.autoConnect("Device Setup", "change-me");
}
void loop() {
// Service portal requests and connection state without blocking firmware work.
wifi.process();
}
```
When saved Wi-Fi is unavailable, `autoConnect()` starts the portal asynchronously. Call `process()` from every `loop()` iteration while it may be open. Flash [Basic Portal](examples/BasicPortal/) to try this exact flow; its README gives the network name, password, portal address, and expected result after saving Wi-Fi.
## Building a Home Assistant device?
WiFiManager remains a standalone provisioning library. If a device also needs
persistent configuration, MQTT, Home Assistant discovery, OTA, mDNS, and an
optional local web UI, see
[DeviceFramework](https://github.com/alexhopeoconnor/DeviceFramework), which
integrates this portal as part of that larger device lifecycle.
## Make it yours
```cpp
@@ -91,7 +106,7 @@ The [Branded Portal](examples/BrandedPortal/) example includes a static SVG, acc
```ini
[common]
lib_deps =
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.2.4
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.2.5
```
The suffix after `#` is a Git ref. PlatformIO clones the repository and checks out that release tag; GitHub Release assets are unrelated. Arduino IDE users can install this repository as a library checkout.
+77 -8
View File
@@ -7,12 +7,56 @@ lib_deps =
WiFiManager=symlink:///path/to/WiFiManager
```
The ESP32 environments pin the PlatformIO-compatible pioarduino 51.03.05
platform package, which packages official Arduino-ESP32 3.0.5. This avoids the
known six-second asynchronous scan failure in the older 2.0.17 framework. Core
3 also requires the `SOC_WIFI_SUPPORTED`, `Network/src`, and ESP8266-transport
ignore settings shown in this repository `platformio.ini`; keep those settings
when adding an ESP32 environment.
## Target pins
WiFiManager uses one maintained ESP32 test lane:
| Lane | pioarduino platform | Purpose |
| --- | --- | --- |
| `esp32` | `55.03.311` / Arduino-ESP32 3.3.11 | maintained baseline |
This is a test-target policy, not a library-manifest dependency: a consuming
application chooses its own `platform` and must validate the complete
framework/toolchain stack. Do not let a shared global PlatformIO cache choose
framework metadata or a compiler implicitly, and do not override just the
toolchain to repair a cache mismatch. Each pioarduino platform owns its
matching framework, uploader, and compiler package set.
Core 3 Wi-Fi builds need the C++14, `SOC_WIFI_SUPPORTED`, and `Network/src`
settings in this repository's `platformio.ini`; keep those settings together
when adding an ESP32 environment. The portal OTA fixture and every guided
example use this 3.3.11 ESP32 baseline.
ESP8266 test environments pin framework commit `521ae60` for the upstream
Postmortem large-jump linker fix. The exact rationale and update rule are in
the shared [ESP8266 linker-workaround note](https://github.com/alexhopeoconnor/arduino-home-assistant/blob/main/docs/ESP8266-LINKER-WORKAROUND.md).
For the pioarduino release-to-Core mapping and cache-collision diagnosis, see
[DeviceFramework's toolchain guide](https://github.com/alexhopeoconnor/DeviceFramework/blob/main/docs/TOOLCHAINS.md).
`./scripts/test.sh` and `./tools/portal-hardware ota --platform esp32` use the
PlatformIO Core/cache shared by the maintained framework repositories,
defaulting to `${XDG_CACHE_HOME:-$HOME/.cache}/arduino-framework-platformio/core-3.3.11`.
WiFiManager, DeviceFramework, DFTE, and ArduinoHA pin this same graph, so this
avoids downloading the same Core 3.3.11 inputs for each repository while
keeping pioarduino's package-form `esptool` and generated environment separate
from stale global `tool-esptoolpy` metadata. Override the location with
`WIFIMANAGER_PLATFORMIO_CORE_DIR`,
`WIFIMANAGER_PLATFORMIO_PACKAGES_DIR`, and
`WIFIMANAGER_PLATFORMIO_CACHE_DIR` when space belongs elsewhere or an isolated
diagnosis is needed. The first shared install is several GiB; reserve at least
4 GiB plus cache headroom. It is persistent and is never cleared by normal test
commands.
For a disposable cache investigation, point that variable at an exact temporary
directory, run the affected command, inspect the resolved graph, then remove
only that directory:
```bash
wm_pio_core="$(mktemp -d /tmp/wifimanager-pio-XXXXXX)"
WIFIMANAGER_PLATFORMIO_CORE_DIR="$wm_pio_core" \
./scripts/test.sh compile --platform esp32
rm -rf -- "$wm_pio_core"
```
Start a release with `bump-version.sh`. It updates package metadata and canonical installation snippets, then creates the changelog section. Replace its generated TODO with the release summary and update any behavioural documentation before running:
@@ -22,6 +66,12 @@ Start a release with `bump-version.sh`. It updates package metadata and canonica
./scripts/check-docs.sh
./scripts/test.sh compile --platform esp8266
./scripts/test.sh compile --platform esp32
./scripts/test.sh unity --platform esp8266
./scripts/test.sh unity --platform esp32
./scripts/test.sh examples --platform esp8266
./scripts/test.sh examples --platform esp32
./scripts/test.sh ota-fixtures --platform esp8266
./scripts/test.sh ota-fixtures --platform esp32
./scripts/prepare-release.sh vMAJOR.MINOR.PATCH --tag
```
@@ -33,7 +83,7 @@ When a physical ESP8266 and ESP32 are available, include their local lifecycle t
~~~
When a physical ESP8266 or ESP32 and a spare USB Wi-Fi adapter are available,
run the Docker portal contract as an additional release-gate check. It is
run the Docker portal test harness as an additional release-gate check. It is
opt-in because it flashes the selected board and temporarily joins its AP, but
it refuses the host default-route adapter and leaves Docker responsible only
for browser/API testing:
@@ -43,9 +93,28 @@ for browser/API testing:
--client-interface wlx74da385d4165
```
See [Testing](TESTING.md#docker-portal-contract) for cleanup, artifacts, and
From an SSH/headless shell, the portal command may ask once for scoped
NetworkManager sudo authorization before the selected board is erased. This is
host setup, not a test secret; never add a sudo value to an env file or run the
whole runner as root. See [Testing](TESTING.md#networkmanager-authorization)
for the direct/Polkit and scoped-sudo behavior.
See [Testing](TESTING.md#docker-portal-test-harness) for cleanup, artifacts, and
optional station handoff credentials.
Run the portal HTTP OTA A/B test harness separately when a spare adapter and 4 MB
test board are available. It erases the selected board's flash, serial-flashes
A, and uses the real browser update form to upload B; do not replace its
automatic-reboot assertion with a manual reset:
```bash
./tools/portal-hardware ota --platform esp8266 --port /dev/serial/by-id/usb-... \
--client-interface wlx74da385d4165
```
See [Portal HTTP OTA A/B test harness](TESTING.md#portal-http-ota-ab-test-harness) for
the partition, artifact, final-board-state, and adapter rules.
Push the branch and annotated tag. GitHub Actions repeats the board-free compile checks, validates the package, and creates a GitHub Release using that version’s changelog section. The workflow does not publish to the PlatformIO Registry.
Back to [documentation](README.md) · [project overview](../README.md).
+1 -1
View File
@@ -26,7 +26,7 @@ void loop() {
```ini
lib_deps =
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.2.4
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.2.5
```
The package includes the asynchronous web and TCP dependencies required by the selected ESP8266 or ESP32 target. Add WiFiManager as the application’s direct dependency; do not copy its internal dependency list into your project.
+6 -2
View File
@@ -65,7 +65,7 @@ A normal accepted save returns 202 and directs the portal to poll /api/wifi/conn
~~~json
{
"state": "idle | waiting | success | failed",
"state": "success",
"message": "human readable status",
"wifiStatus": "WL_CONNECTED",
"stationIp": "192.168.1.42",
@@ -73,7 +73,11 @@ A normal accepted save returns 202 and directs the portal to poll /api/wifi/conn
}
~~~
stationIp and redirectUrl are present only after success. If WiFiManager uses a non-default HTTP port, redirectUrl includes it.
`state` is one of `idle`, `waiting`, `success`, or `failed`. The example shows
a successful join; `stationIp` and `redirectUrl` are present only in that state.
If the portal server is not on port 80, `redirectUrl` includes that port. When
connect-on-save is disabled, a saved configuration has no station address and
the built-in portal remains open.
After observing success, the built-in portal POSTs /api/wifi/connect-complete. A 409 response means successful handoff is not ready; otherwise WiFiManager keeps the portal alive briefly, receives the acknowledgement, and then closes after a grace delay. Browser captive redirects can still fail, so the portal keeps the station address visible.
+11 -1
View File
@@ -4,6 +4,16 @@ WiFiManagerPortalConfig is the supported presentation API for the built-in provi
Apply presentation before autoConnect(), startConfigPortal(), or startWebPortal(). Configure portal policy during boot as well so each portal session begins consistently. Portal text and SVG assets are non-owning, so their RAM or PROGMEM data must have static firmware lifetime. WiFiManager locks presentation while a portal is active so asynchronous responses cannot observe partial configuration; setPortalConfig() returns false if it cannot accept the configuration.
## Portal views
These ESP32 captures use the same real-board portal test harness described in
[Testing](TESTING.md). The nearby networks shown are the networks visible to
the capture device when the portal scans.
| Overview | Wi-Fi and application settings |
| --- | --- |
| ![WiFiManager portal overview with branded identity, status, and portal actions.](assets/readme/portal-overview.png) | ![WiFiManager Wi-Fi page with nearby networks and an application setting.](assets/readme/portal-wifi-settings.png) |
## Standalone branded portal
~~~cpp
@@ -51,7 +61,7 @@ void setup() {
void loop() { wifi.process(); }
~~~
The complete buildable example is [Branded Portal](../examples/BrandedPortal/BrandedPortal.ino). The compile fixture exercises this API on ESP8266 and ESP32.
The complete buildable example is [Branded Portal](../examples/BrandedPortal/BrandedPortal.ino). The compile fixture exercises this API on ESP8266 and the maintained ESP32 3.3.11 baseline.
## Presentation reference
+28 -10
View File
@@ -18,18 +18,36 @@ A candidate submitted by the portal or another application subsystem is only com
## Direct WiFiManager use
Implement a small store appropriate to the application. WiFiManager neither allocates nor owns it:
Implement a small store appropriate to the application. The manager neither allocates nor owns it. This complete in-memory version makes the ownership and return contract visible; use the buildable EEPROM example when the profiles must survive a restart:
~~~cpp
class MyProfileStore final : public WiFiManagerStationProfileStore {
```cpp
class MemoryProfileStore final : public WiFiManagerStationProfileStore {
public:
bool load(WiFiManagerStationProfiles& profiles) override;
bool save(const WiFiManagerStationProfiles& profiles) override;
bool clear() override;
bool load(WiFiManagerStationProfiles& profiles) override {
if (!hasProfiles_) return false; // No saved primary profile: open the portal.
profiles = profiles_;
return true;
}
bool save(const WiFiManagerStationProfiles& profiles) override {
profiles_ = profiles;
hasProfiles_ = true;
return true; // A durable store must return false when its write fails.
}
bool clear() override {
profiles_ = {};
hasProfiles_ = false;
return true;
}
private:
WiFiManagerStationProfiles profiles_{};
bool hasProfiles_ = false;
};
WiFiManager wifi;
MyProfileStore profiles;
MemoryProfileStore profiles; // Must outlive WiFiManager's asynchronous connection work.
void setup() {
wifi.setStationProfileStore(&profiles);
@@ -38,11 +56,11 @@ void setup() {
}
void loop() {
wifi.process();
wifi.process(); // Advances profile retries and serves the fallback portal.
}
~~~
```
The store must return a complete WiFiManagerStationProfiles value. Each enabled profile has a NUL-terminated SSID of at most 32 characters and an optional NUL-terminated password of at most 64 characters. Keep slot 0 enabled; set hasPassword to false for an open network.
This memory-only store intentionally loses profiles on restart. The store must return a complete `WiFiManagerStationProfiles` value. Each enabled profile has a NUL-terminated SSID of at most 32 characters and an optional NUL-terminated password of at most 64 characters. Keep slot 0 enabled; set `hasPassword = false` for an open network.
When load() returns false, WiFiManager treats the profile set as unavailable and opens the normal configuration portal. When save() or clear() returns false, getStationStatus().storageSaveFailed is set and the status message explains the failure.
+225 -25
View File
@@ -1,27 +1,54 @@
# Testing
WiFiManager separates repeatable package checks from opt-in tests that flash a
real board or join a captive portal. The normal commands never need a board,
WiFiManager separates repeatable board-free builds from opt-in tests that flash
a real board or join a captive portal. The normal commands never need a board,
local Wi-Fi credentials, browser binary, or sibling checkout.
## Clean consumer and example builds
| 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 coverage |
| Portal HTTP OTA | WiFiManager multipart `POST /u` | Named secondary adapter | safe fixture AP password | rendered upload succeeds, portal restarts automatically, and fixture marker changes A → B twice |
The clean-consumer check builds a project that declares only WiFiManager. It
proves the package manifest resolves DFTE, ESPAsyncWebServer, and the correct
ESP8266 or ESP32 TCP dependency without a sibling checkout. The runner removes
a prior local package link before each check, so dependency resolution uses the
current manifest rather than a stale `.pio` copy.
The selected secondary adapter is intentionally never used for normal LAN
testing. It is `never-default`, so the host's ordinary route remains intact.
## Board-free fixture, consumer, and example builds
The Unity compile check builds WiFiManager's own fixture without a board. The
consumer check builds a project that declares only WiFiManager, proving that a
normal PlatformIO dependency resolution can compile DFTE, ESPAsyncWebServer,
and the correct ESP8266 or ESP32 TCP dependency. Normal commands reuse the
persistent PlatformIO cache; they do not delete, reinstall, or separately
assert the package graph.
```bash
./scripts/test.sh compile --platform esp8266
./scripts/test.sh compile --platform esp32
./scripts/test.sh unity --platform esp8266
./scripts/test.sh unity --platform esp32
./scripts/test.sh examples --platform esp8266
./scripts/test.sh examples --platform esp32
./scripts/test.sh ota-fixtures --platform esp8266
./scripts/test.sh ota-fixtures --platform esp32
```
CI runs these board-free checks for pull requests and pushes to the maintained
branch. It intentionally does not require attached hardware, a local network,
or Docker.
branch. `esp32` uses Arduino-ESP32 3.3.11 and compiles the guided examples.
The OTA fixture builds compile both immutable A
and B images against their tracked OTA partition layout. CI rejects equal A/B
artifacts, an ESP32 image larger than either 0x1F0000-byte app slot, or a
partition-table edit that breaks the required two-slot/no-filesystem layout. These checks
intentionally do not require attached hardware, a local network, or Docker.
Direct PlatformIO test-harness commands default to two compiler jobs. Set
`PLATFORMIO_RUN_JOBS=3` only for an explicit local run on an otherwise idle
host.
Physical portal commands lock the shared `192.168.4.0/24` portal network, the
selected secondary adapter, and the named serial device. These non-secret
resource locks are shared with DeviceFramework's portal harness, so a collision
fails before either runner changes a board or adapter while unrelated station
tests can use their own resources.
## Local hardware lifecycle tests
@@ -40,17 +67,44 @@ pio device list
The runner flashes the selected board, captures normal-boot serial output with
the repository Bash helper, requires Unity's `Tests 0 Failures` and `OK`
summary, and prints lifecycle metrics. Hardware work shares a lock with the
portal contract, so two invocations cannot flash or use the same board at once.
portal test harness and DeviceFramework's hardware runners on the same host, so
two first-party invocations cannot flash or use the same board at once.
## Docker portal contract
## Docker portal test harness
`test/portal-harness` is deliberately tiny portal-only firmware, not an example
or consuming application. `tools/portal-hardware` flashes it to one explicitly
selected board, joins its AP through one explicitly selected **secondary**
Wi-Fi adapter, then runs its HTTP and browser contract in a pinned Playwright
Wi-Fi adapter, then runs its HTTP and browser test harness in a pinned Playwright
Docker image. Docker uses host networking only to reach the already-routed
portal; it never runs NetworkManager or changes host adapters.
The runner resolves PlatformIO from `WIFIMANAGER_PIO_EXECUTABLE`, then `PATH`,
then PlatformIO's standard `~/.platformio/penv/bin/pio` installation. That
makes the same command work from a non-interactive SSH shell without modifying
the user's `PATH`.
### NetworkManager authorization
The portal adapter is a host-side resource, separate from fixture credentials.
The runner never reads a sudo password from `test/.env`, an environment file,
or source control. `doctor` reports whether the current session can use
NetworkManager directly or will need scoped sudo. In a graphical desktop,
Polkit normally authorizes the selected adapter directly. In an SSH or other
headless session with no Polkit agent, the runner visibly validates `sudo -v`
before it erases or flashes the board, then uses `sudo -n nmcli` only to scan,
disconnect, join, and remove its generated connection on the named secondary
adapter.
The normal setting is `WM_NMCLI_AUTH=auto`. Use `WM_NMCLI_AUTH=sudo` to choose
the same scoped path deliberately, or `WM_NMCLI_AUTH=direct` only when a
working Polkit policy already grants the required actions. Do not run the whole
runner under `sudo`: its state files and browser artifacts intentionally remain
owned by the invoking developer. If the sudo ticket expires during a long run,
the runner stops with an actionable message rather than silently treating an
unauthorized rescan as a missing portal SSID. `down` uses the same scoped path
to remove a retained connection.
```bash
./tools/portal-hardware doctor --client-interface wlx74da385d4165
./tools/portal-hardware run \
@@ -67,18 +121,36 @@ replaced:
./tools/portal-hardware run ... --take-over-client-adapter
```
The fixture opens a 15-minute portal session, includes one harmless custom
parameter, and verifies root/bootstrap/info/status API responses, concurrent
low-priority requests, parameter persistence, timeout reset, an actual async
scan, a missing-route response, and desktop/mobile portal rendering with no
browser page errors. Screenshots, traces on failure, JSON results, and the HTML
report are saved under the printed XDG state-directory artifact path.
The fixture opens a 15-minute portal session and includes thirteen harmless
custom parameters. It verifies root/bootstrap/info/status API responses,
concurrent low-priority requests, every custom field and value across repeated
API fetches, timeout reset, an actual async scan, a missing-route response, and
desktop/mobile portal rendering with no browser page errors. It also round-trips
a value containing apostrophes, quotes, backslashes, angle brackets, and an
ampersand through the rendered form and parameter-save API. Screenshots, traces
on failure, JSON results, and the HTML report are saved under the printed XDG
state-directory artifact path.
On ESP8266, an AP+STA scan can briefly move the radio off the AP channel. The
client may reconnect during that interval; the contract deliberately retries
client may reconnect during that interval; the test harness deliberately retries
that transport interruption and still requires a reachable portal with a
complete, valid scan result.
The normal browser test harness catches the common regression case. When changing
parameter rendering, run the opt-in ESP8266 soak as well. It performs twelve
full browser renders and API fetches while the AP is active, asserting all
thirteen fields and their exact values on every pass. This targets the
memory-sensitive rendering failure reported upstream in issue #1787 without
making every ordinary hardware run unnecessarily long:
```bash
./tools/portal-hardware run \
--platform esp8266 \
--port /dev/serial/by-id/usb-... \
--client-interface wlx74da385d4165 \
--custom-parameter-stress
```
For interactive diagnosis, leave the temporary client connection up and remove
only that managed connection when finished:
@@ -90,16 +162,144 @@ only that managed connection when finished:
An optional station handoff test is deliberately separate because it connects
the fixture to a real LAN. Copy the ignored template below, add local
credentials, and pass it explicitly; it is mounted read-only into the test
container and is never logged by the runner.
credentials, and pass it explicitly. The runner parses only `WIFI_SSID` and
`WIFI_PASSWORD` into a generated mode-600 two-key file, mounts that file
read-only into the test container, and removes it after the browser run; it
never mounts the complete local environment file or logs either value. Because
browser traces can retain request bodies, this opt-in mode disables Playwright
screenshots, video, and tracing, including explicit diagnostic screenshots. It
cannot be combined with README-media capture. Docker builds from the tracked
`tests/portal-harness` directory only, so neither the source environment file
nor the generated two-key file enters its build context. Keep its private output
directory private and review any remaining report before sharing it.
A retained session is deliberately never overwritten. If a previous `up` or an
interrupted `run` left one behind, run `./tools/portal-hardware down` first;
that removes only the named temporary connection recorded by the tool.
After either a passing or failing station-handoff attempt, the runner
serial-flashes the portal-only fixture once more. Its `setup()` clears saved
station settings, so the selected test board returns to the clean no-station
portal state and does not retain the developer's Wi-Fi credentials. A failed
restore or a failure to see the cleaned fixture AP return makes the command
fail. `--keep` affects only the runner's temporary
secondary-adapter connection; it does not retain station credentials on the
board.
A retained session is deliberately never overwritten. Before touching
NetworkManager, the runner atomically records its uniquely generated connection
name; after creation it atomically replaces that pending record with the exact
UUID. Ordinary failures and interrupts remove that connection immediately, and
`down` accepts either record after an uncatchable host termination or an
intentional `up`/`--keep` session. It never removes another NetworkManager
connection.
```bash
cp test/portal-station.env.example test/portal-station.env
./tools/portal-hardware run ... --station-env test/portal-station.env
```
## Refresh README media
README media is an explicit ESP32-only capture, not part of normal testing or
CI. It uses the same real-board portal test harness above, but records a short
browser tour and stores all candidate files under the ignored
`artifacts/readme-media/` directory by default:
```bash
./tools/portal-hardware run \
--platform esp32 \
--port /dev/serial/by-id/usb-... \
--client-interface USB_WIFI_ADAPTER \
--capture-readme-media
```
Review the printed artifact directory. To keep a run somewhere more convenient,
pass `--output DIRECTORY`. After review, promote only the approved PNG/GIF
files into tracked documentation assets:
```bash
./tools/promote-readme-media \
--from artifacts/readme-media/TIMESTAMP-esp32 \
--replace
./scripts/check-docs.sh
```
The Docker renderer validates the GIF duration. The promotion tool requires the
successful ESP32 media manifest, checks file types and size limits, and never
copies raw video, browser reports, traces, or arbitrary artifact files. The
renderer preserves the real recording but deliberately presents it at 1.25×
duration and 6 fps so the
README tour is readable; it does not change normal browser-test-harness timing.
ESP8266 remains covered by the normal hardware and browser test harness but does
not produce duplicate README media.
## Portal HTTP OTA A/B test harness
`portal-hardware ota` is a separate opt-in physical test for WiFiManager's
built-in HTTP update path. It exercises the rendered firmware-update page and
its real multipart `POST /u` request; it is not an ArduinoOTA/UDP test.
```bash
./tools/portal-hardware ota \
--platform esp8266 \
--port /dev/serial/by-id/usb-... \
--client-interface wlx74da385d4165
```
The selected `--client-interface` has exactly the same safety rules as the
normal portal test harness: it must be the explicitly named secondary adapter and
cannot be the host default-route interface. The test never attaches that
adapter to a normal station network. The fixture AP uses the safe local
`default1` WPA password; this is an AP-access test, not a claim that `/u` has
HTTP route authentication.
The test harness performs the following complete run:
1. Builds immutable A and B fixture images from one platform environment. A
generated harness-only header in an ignored, per-run private directory is
the only changed input, so their marker is compiled into the binary rather
than saved in WiFiManager settings or EEPROM.
2. Checks both ESP32 images against the explicit matching `app0`/`app1` slots;
ESP8266 validates B after A has booted against the exact aligned capacity
passed to `Update.begin()`.
3. Erases the explicitly selected test board's flash, then flashes A over
serial and starts its captive portal.
4. Joins that portal only through the named secondary adapter and requires the
A marker at `/api/test/firmware-marker`.
5. Mounts B read-only into the Playwright container, chooses it in the real
`#wm-ota-file` browser input, and submits the rendered form.
6. Requires the real `POST /u` success response, an automatic portal outage,
automatic restart, and two independent B-marker responses.
The OTA command additionally requires Python with PySerial (the
`python3-serial` package on Debian/Ubuntu) and retains a passive,
no-reset `serial-ota.log` beside the browser artifacts. It attaches immediately
after serial-flashing A releases the port—before portal association and the A
marker check—and remains attached through the two B checks. A passing run
requires a healthy recorder, but its contents are diagnostic evidence rather
than a pass/fail comparison against product log strings. This preserves
firmware-side portal-start and DHCP evidence without manufacturing a reset.
OTA-only fixture images wait five seconds after their upload reset so
the passive recorder can attach before A/B boot evidence is emitted; ordinary
portal test-harness startup remains fast.
The fixture marker endpoint exists only in `test/portal-harness`; it is not a
WiFiManager library route or a product-firmware pattern. The test does not
issue a manual reset. A board which boots B only after intervention is a
failure, even if B later appears.
Both fixture images are built with explicit OTA-capable layouts:
| Platform | Fixture layout | Capacity check |
| --- | --- | --- |
| ESP8266 | `eagle.flash.4m1m.ld` | A's live `ESP.getFreeSketchSpace()` response |
| ESP32 | two `0x1F0000` A/B app slots, no filesystem | tracked CSV `app1` size |
These are 4 MB fixture layouts (`d1_mini` for ESP8266 and `esp32dev` for
ESP32). Do not run this command against a board with another flash size unless
its matching explicit A/B layout and capacity checks have been added first.
The final board state is firmware B in the portal-only fixture: it clears
saved station settings on every boot and leaves no developer Wi-Fi credential
on the device. By default the temporary NetworkManager connection is removed
when the test exits. Pass `--keep` only for interactive diagnosis, then run
`./tools/portal-hardware down` to remove that named temporary connection.
Back to [documentation](README.md) · [project overview](../README.md).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+5 -2
View File
@@ -5,8 +5,9 @@ WiFiManager portal;
void setup() {
Serial.begin(115200);
portal.setConfigPortalTimeout(180);
portal.setConfigPortalTimeout(180); // Do not leave a first-boot setup AP open forever.
// Returns true when saved station credentials connect; otherwise opens the portal.
if (portal.autoConnect("WiFiManager Basic", "example-pass")) {
Serial.println("Connected. Run your normal application here.");
} else {
@@ -14,4 +15,6 @@ void setup() {
}
}
void loop() { portal.process(); }
void loop() {
portal.process(); // Keeps DNS, HTTP, and station-recovery work responsive.
}
+1 -1
View File
@@ -17,7 +17,7 @@ platform_packages =
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
+1 -1
View File
@@ -17,7 +17,7 @@ platform_packages =
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
@@ -2,12 +2,13 @@
#include <WiFiManager.h>
WiFiManager portal;
// WiFiManager reads this object while the portal is open, so it must outlive setup().
WiFiManagerParameter brokerHost("broker_host", "MQTT broker", "mqtt.local", 40);
void setup() {
Serial.begin(115200);
portal.portalAddParameter(&brokerHost);
portal.portalAddParameter(&brokerHost); // Adds an application-owned setting to the built-in form.
// WiFiManager copies this read-only status section when it is registered.
PortalInfoSection deviceInfo;
@@ -28,7 +29,7 @@ void setup() {
portal.portalAddHomeCard(hint);
portal.setSaveParamsCallback([](WiFiManager::WiFiManagerRequestArgs) {
// A product validates and persists this value here; this demo only prints it.
// Validate and persist a copy in the application; this example only reports it.
Serial.print("MQTT broker selected: ");
Serial.println(brokerHost.getValue());
});
@@ -37,4 +38,6 @@ void setup() {
portal.autoConnect("WiFiManager Content", "example-pass");
}
void loop() { portal.process(); }
void loop() {
portal.process(); // Serves portal requests until provisioning completes or times out.
}
+1 -1
View File
@@ -17,7 +17,7 @@ platform_packages =
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
+6 -1
View File
@@ -10,10 +10,12 @@ struct StoredProfiles {
WiFiManagerStationProfiles profiles;
};
// The application owns persistence; WiFiManager only chooses and verifies profiles.
class EepromProfileStore final : public WiFiManagerStationProfileStore {
public:
bool begin() {
#if defined(ESP32)
// ESP32 EEPROM emulation can fail to reserve its backing region.
return EEPROM.begin(sizeof(StoredProfiles));
#else
EEPROM.begin(sizeof(StoredProfiles));
@@ -25,6 +27,7 @@ public:
StoredProfiles stored{};
EEPROM.get(0, stored);
if (stored.magic != kStoreMagic) {
// Treat erased or unrelated EEPROM as having no profiles.
return false;
}
profiles = stored.profiles;
@@ -32,6 +35,7 @@ public:
}
bool save(const WiFiManagerStationProfiles& profiles) override {
// WiFiManager calls this only after it has verified the submitted candidate.
EEPROM.put(0, StoredProfiles{kStoreMagic, profiles});
return EEPROM.commit();
}
@@ -53,11 +57,12 @@ void setup() {
return;
}
// Both objects are global because station retries continue after setup() returns.
portal.setStationProfileStore(&profileStore);
portal.setStationRecoveryInterval(30000);
portal.startStationConnection("WiFiManager Profiles", "example-pass");
}
void loop() {
portal.process();
portal.process(); // Advances connection attempts and serves provisioning when needed.
}
+1 -1
View File
@@ -17,7 +17,7 @@ platform_packages =
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
+10
View File
@@ -337,7 +337,16 @@ class WiFiManager
unsigned long startedAt = 0;
unsigned long finishedAt = 0;
unsigned long timeoutMs = 15000;
// ESP32's high-level scan-complete notification can arrive before the
// radio has fully released its previous scan. A second scan started in
// that short window is reported only as WIFI_SCAN_FAILED by Arduino.
// Keep the established short ESP8266 cadence, but give ESP32 its
// documented radio hand-off time before retrying a user refresh.
#ifdef ESP32
unsigned long minRestartIntervalMs = 5000;
#else
unsigned long minRestartIntervalMs = 2000;
#endif
uint32_t generation = 0;
uint32_t runningGeneration = 0;
uint32_t completionGeneration = 0;
@@ -1061,6 +1070,7 @@ protected:
void wmTestForceScanState(wm_scan_state_t state) { _scan.state = state; }
void wmTestSetScanStartedAt(unsigned long startedAt) { _scan.startedAt = startedAt; }
void wmTestSetScanTimeoutMs(unsigned long timeoutMs) { _scan.timeoutMs = timeoutMs; }
unsigned long wmTestGetScanRestartIntervalMs() const { return _scan.minRestartIntervalMs; }
void wmTestInjectScanResults(const std::vector<WiFiScanNetwork>& results) {
_scanResultsCache = results;
_numNetworks = static_cast<int>(results.size());
+13 -3
View File
@@ -939,6 +939,11 @@ void WiFiManagerHandlers::handleUpdating(AsyncWebServerRequest *request, String
}
#ifdef ESP8266
// ESPAsyncWebServer invokes this upload callback from the ESP8266 SYS
// context. The core's default Updater mode yields around flash erases
// and writes, but yield() panics from that context. Tell the core this
// upload is asynchronous before the first Update call.
Update.runAsync(true);
WiFiUDP::stopAll();
uint32_t maxSketchSpace = (ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000;
#elif defined(ESP32)
@@ -1003,9 +1008,15 @@ void WiFiManagerHandlers::handleUpdateDone(AsyncWebServerRequest *request) {
return;
}
// AsyncWebServer queues this response and closes the connection only after
// it has completed the response. Restarting from this request callback—or
// merely waiting a guessed interval—can still tear down that TCP exchange.
// Schedule through process() after this particular response disconnects.
request->onDisconnect([this]() {
_wm->_rebootScheduled = true;
_wm->_rebootTime = millis() + _wm->REBOOT_DELAY_MS;
});
sendApiJson(request, 200, jsonApiOtaUpdateSuccess());
delay(1000);
ESP.restart();
}
void WiFiManagerHandlers::sendApiJson(AsyncWebServerRequest *request, int code, const String& json) {
@@ -1604,4 +1615,3 @@ void WiFiManagerHandlers::handleApiPortalExit(AsyncWebServerRequest *request) {
}
#endif // defined(ESP8266) || defined(ESP32)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "WiFiManager",
"version": "3.2.4",
"version": "3.2.5",
"keywords": [
"wifi",
"wi-fi",
+3 -4
View File
@@ -16,11 +16,11 @@ build_flags =
-DUNIT_TEST
lib_deps =
ESP32Async/ESPAsyncWebServer@3.9.1
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.2.0
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.2.1
ESP32Async/ESPAsyncTCP@2.0.0
[env:esp32]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
framework = arduino
monitor_speed = 115200
@@ -36,7 +36,7 @@ build_flags =
-DUNIT_TEST
lib_deps =
ESP32Async/ESPAsyncWebServer@3.9.1
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.2.0
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.2.1
ESP32Async/AsyncTCP@^3.4.9
; Optional: compile tests with DFTE logs bridged into WiFiManager::log (see README)
@@ -45,4 +45,3 @@ extends = env:esp8266
build_flags =
${env:esp8266.build_flags}
-DWM_DFTE_LOGGING
+56
View File
@@ -4,6 +4,32 @@ set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
failed=0
check_cpp_fence_scope() {
local markdown="$1"
awk '
function brace_delta(line, copy) {
copy = line
return gsub(/\{/, "{", copy) - gsub(/\}/, "}", copy)
}
/^```cpp[[:space:]]*$/ { in_cpp = 1; depth = 0; next }
in_cpp && /^```[[:space:]]*$/ { in_cpp = 0; next }
in_cpp {
line = $0
sub(/^[[:space:]]+/, "", line)
if (depth == 0 &&
(line ~ /^(if|for|while|switch)[[:space:]]*\(/ ||
line ~ /^[A-Za-z_][A-Za-z0-9_:]*::[A-Za-z0-9_]+[[:space:]]*\(/ ||
line ~ /^[A-Za-z_][A-Za-z0-9_]*\./ ||
line ~ /^[A-Za-z_][A-Za-z0-9_]*[[:space:]]*\(/)) {
printf "%s:%d: C++ expression appears at namespace scope; wrap it in a function.\n", FILENAME, FNR > "/dev/stderr"
failed = 1
}
depth += brace_delta($0)
}
END { exit failed }
' "$markdown"
}
link_pattern='\]\(([^ )]+)'
while IFS= read -r file; do
in_fence=false
@@ -33,6 +59,7 @@ while IFS= read -r file; do
fi
done
done < "$file"
check_cpp_fence_scope "$file" || failed=1
done < <(find "$root" -path "$root/.git" -prune -o -path '*/.pio' -prune -o -type f -name '*.md' -print)
for required in README.md CHANGELOG.md docs/README.md docs/GETTING_STARTED.md docs/PORTAL_UI.md docs/PORTAL_API.md docs/TESTING.md docs/DEVELOPMENT.md; do
@@ -42,6 +69,35 @@ for required in README.md CHANGELOG.md docs/README.md docs/GETTING_STARTED.md do
fi
done
check_readme_media() {
local asset="$1"
local expected_type="$2"
local max_bytes="$3"
local path="$root/docs/assets/readme/$asset"
if [[ ! -s "$path" ]]; then
printf 'Missing README media asset: %s\n' "docs/assets/readme/$asset" >&2
failed=1
return
fi
if [[ "$(file --brief --mime-type "$path")" != "$expected_type" ]]; then
printf 'Unexpected README media type: %s\n' "docs/assets/readme/$asset" >&2
failed=1
fi
if (( $(wc -c < "$path") > max_bytes )); then
printf 'README media exceeds its size limit: %s\n' "docs/assets/readme/$asset" >&2
failed=1
fi
}
check_readme_media portal-tour.gif image/gif $((2 * 1024 * 1024))
check_readme_media portal-overview.png image/png $((1024 * 1024))
check_readme_media portal-wifi-settings.png image/png $((1024 * 1024))
if [[ -n "$(git -C "$root" ls-files -- 'artifacts/readme-media/**')" ]]; then
printf 'Ignored README media artifacts must not be tracked.\n' >&2
failed=1
fi
while IFS= read -r example; do
for required in README.md platformio.ini; do
if [[ ! -f "$example/$required" ]]; then
+3 -1
View File
@@ -11,6 +11,8 @@ tag="${1:-}"
[[ "${2:-}" == "" || "${2:-}" == "--tag" ]] || usage
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=tools/lib/platformio.sh
source "$root/tools/lib/platformio.sh"
version="${tag#v}"
manifest_version="$(sed -n 's/.*"version": "\([^"]*\)".*/\1/p' "$root/library.json" | head -n 1)"
@@ -54,7 +56,7 @@ validate_reference docs/GETTING_STARTED.md
git -C "$root" diff --check
package_dir="$(mktemp -d)"
trap 'rm -rf "$package_dir"' EXIT
pio pkg pack "$root" --output "$package_dir/package.tar.gz" >/dev/null
wm_pio pkg pack "$root" --output "$package_dir/package.tar.gz" >/dev/null
echo "Validated release metadata and PlatformIO package for $tag"
if [[ "${2:-}" == "--tag" ]]; then
+123 -19
View File
@@ -1,18 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
# Keep standalone compilation predictable on laptops and shared workstations.
# A developer may explicitly raise this for an isolated local diagnosis.
export PLATFORMIO_RUN_JOBS="${PLATFORMIO_RUN_JOBS:-2}"
usage() {
cat <<'USAGE' >&2
Usage:
./scripts/test.sh compile --platform esp8266|esp32
./scripts/test.sh examples --platform esp8266|esp32
./scripts/test.sh hardware --platform esp8266|esp32 --port /dev/serial/by-id/...
./scripts/test.sh compile --platform esp8266|esp32
./scripts/test.sh unity --platform esp8266|esp32
./scripts/test.sh examples --platform esp8266|esp32
./scripts/test.sh ota-fixtures --platform esp8266|esp32
./scripts/test.sh hardware --platform esp8266|esp32 --port /dev/serial/by-id/...
USAGE
exit 2
}
mode="${1:-}"
[[ "$mode" == "compile" || "$mode" == "examples" || "$mode" == "hardware" ]] || usage
[[ "$mode" == "compile" || "$mode" == "unity" || "$mode" == "examples" || "$mode" == "ota-fixtures" || "$mode" == "hardware" ]] || usage
shift
platform=""
@@ -25,16 +31,66 @@ while [[ $# -gt 0 ]]; do
esac
done
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
case "$platform" in
esp8266|esp32) environment="$platform" ;;
*) usage ;;
esac
[[ "$mode" != "hardware" || -n "$port" ]] || usage
[[ "$mode" != "hardware" || -e "$port" ]] || { echo "Serial port not found: $port" >&2; exit 1; }
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=tools/lib/platformio.sh
source "$root/tools/lib/platformio.sh"
# shellcheck source=tools/lib/ota-fixture-identity.sh
source "$root/tools/lib/ota-fixture-identity.sh"
pio_for_platform() {
if [[ "$platform" != "esp32" ]]; then
wm_pio "$@"
return
fi
# Keep the maintained Core 3.3.11 package form in a persistent project
# cache shared by the maintained framework repositories. It is never
# cleared by this script and avoids stale global package metadata selecting
# an incompatible uploader without redownloading this same pinned graph.
local core_dir packages_dir cache_dir
core_dir="${WIFIMANAGER_PLATFORMIO_CORE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/arduino-framework-platformio/core-3.3.11}"
packages_dir="${WIFIMANAGER_PLATFORMIO_PACKAGES_DIR:-$core_dir/packages}"
cache_dir="${WIFIMANAGER_PLATFORMIO_CACHE_DIR:-$core_dir/cache}"
install -d -m 700 "$core_dir" "$packages_dir" "$cache_dir"
PLATFORMIO_CORE_DIR="$core_dir" PLATFORMIO_PACKAGES_DIR="$packages_dir" \
PLATFORMIO_CACHE_DIR="$cache_dir" wm_pio "$@"
}
assert_ota_fixture_pair() {
local firmware_a="$1" firmware_b="$2" firmware size capacity
[[ -s "$firmware_a" && -s "$firmware_b" ]] || {
echo "Portal OTA fixture build did not produce both A and B images." >&2
return 1
}
if cmp -s "$firmware_a" "$firmware_b"; then
echo "Portal OTA fixture A and B are identical." >&2
return 1
fi
if [[ "$platform" == "esp32" ]]; then
capacity=$((0x1F0000))
for firmware in "$firmware_a" "$firmware_b"; do
size="$(wc -c < "$firmware" | tr -d '[:space:]')"
(( size <= capacity )) || {
echo "ESP32 OTA fixture $(basename "$firmware") is $size bytes; it exceeds the $capacity-byte app slot." >&2
return 1
}
done
fi
}
if [[ "$mode" == "hardware" ]]; then
# Keep serial flashing and portal-adapter work mutually exclusive.
# shellcheck source=tools/lib/portal-hardware-session.sh
source "$root/tools/lib/portal-hardware-session.sh"
wm_acquire_hardware_lock
# Unity uses only the named serial device; it does not own a portal AP or
# secondary adapter, so it may run beside an unrelated station test.
# shellcheck source=tools/lib/harness-locks.sh
source "$root/tools/lib/harness-locks.sh"
wm_harness_lock_serial_port "$port"
fi
if [[ "$mode" == "examples" ]]; then
@@ -44,29 +100,77 @@ if [[ "$mode" == "examples" ]]; then
exit 1
fi
for example in "${examples[@]}"; do
pio run -d "$example" -e "$platform" </dev/null
pio_for_platform run -d "$example" -e "$environment" </dev/null
done
echo "WiFiManager examples compile check passed for $platform"
exit 0
fi
if [[ "$mode" == "ota-fixtures" ]]; then
fixture_environment="${platform}_ota"
if [[ "$platform" == "esp32" ]]; then
"$root/tools/check-ota-partitions.sh"
fi
# The A/B marker is the only changed source input. Capture each resulting
# binary before rebuilding the same platform environment so CI proves the
# update images differ without paying for duplicate dependency builds.
fixture_dir="$(mktemp -d "${TMPDIR:-/tmp}/wifimanager-ota-fixtures.XXXXXX")"
chmod 700 "$fixture_dir"
export WIFIMANAGER_OTA_IDENTITY_DIR="$fixture_dir/identity"
fixture_build_dir="$fixture_dir/build/$fixture_environment"
wm_lock_ota_fixture_environment "$fixture_environment"
wm_write_ota_fixture_identity A
fixture_a="$fixture_dir/ota-a.bin"
fixture_b="$fixture_dir/ota-b.bin"
cleanup_ota_fixture_build() {
wm_remove_ota_fixture_identity
rm -rf -- "$fixture_dir"
}
on_ota_fixture_build_signal() {
local status="$1"
# Remove the generated identity before leaving. EXIT will call the
# same idempotent cleanup once more, which is intentional.
trap - HUP INT TERM
cleanup_ota_fixture_build
exit "$status"
}
trap cleanup_ota_fixture_build EXIT
trap 'on_ota_fixture_build_signal 129' HUP
trap 'on_ota_fixture_build_signal 130' INT
trap 'on_ota_fixture_build_signal 143' TERM
PLATFORMIO_BUILD_DIR="$fixture_dir/build" \
pio_for_platform run -d "$root/test/portal-harness" -e "$fixture_environment" </dev/null
install -m 600 "$fixture_build_dir/firmware.bin" "$fixture_a"
wm_write_ota_fixture_identity B
PLATFORMIO_BUILD_DIR="$fixture_dir/build" \
pio_for_platform run -d "$root/test/portal-harness" -e "$fixture_environment" </dev/null
install -m 600 "$fixture_build_dir/firmware.bin" "$fixture_b"
assert_ota_fixture_pair "$fixture_a" "$fixture_b"
echo "WiFiManager portal OTA fixture compile check passed for $platform"
exit 0
fi
if [[ "$mode" == "unity" ]]; then
# Compile WiFiManager's own fixtures without a board. This is separate
# from the clean-consumer fixture, which protects manifest resolution.
pio_for_platform test -d "$root" -e "$environment" --filter test_wifimanager \
--without-uploading --without-testing
echo "WiFiManager Unity compile check passed for $platform"
exit 0
fi
if [[ "$mode" == "hardware" ]]; then
# Upload first, then capture from the normal boot reset. The Unity sketch
# 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
"$root/scripts/capture-unity-serial.sh" --port "$port" --timeout 300
echo "WiFiManager hardware test passed for $platform on $port"
exit 0
fi
cached_library="$root/test/compile-project/.pio/libdeps/${platform}/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 pkg uninstall -d "$root/test/compile-project" -e "$platform" \
-l WiFiManager --no-save --skip-dependencies >/dev/null
fi
pio run -d "$root/test/compile-project" -e "$platform"
pio_for_platform run -d "$root/test/compile-project" -e "$environment"
echo "WiFiManager consumer compile check passed for $platform"
+1 -1
View File
@@ -20,7 +20,7 @@ lib_deps =
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
+37 -1
View File
@@ -11,6 +11,42 @@ Use it through the repository runner so a secondary Wi-Fi adapter is explicitly
--client-interface wlx...
```
The ESP8266 portal SSID is `WM Contract ESP8266`; the ESP32 SSID is `WM Contract ESP32`. Both use `default1` exclusively for local development tests.
The ESP8266 portal SSID is `WM Test Harness ESP8266`; the ESP32 SSID is `WM Test Harness ESP32`. Both use `default1` exclusively for local development tests.
The runner cleans up only the temporary connection it creates on the named secondary interface. It refuses to run if that interface is the system default route.
NetworkManager authority is a host prerequisite, not a fixture secret. A GUI
Polkit session may authorize the adapter directly; a headless/SSH invocation
validates sudo before flashing, then elevates only the generated portal
connection actions. Leave the runner itself unprivileged so its private state
and browser artifacts remain owned by the developer. See
[`docs/TESTING.md`](../../docs/TESTING.md#networkmanager-authorization) for
the `WM_NMCLI_AUTH` options.
## A/B portal OTA fixture
The physical portal HTTP OTA test harness uses one `*_ota` environment per
platform. It writes a harness-only A/B identity header to an ignored, owner-only
directory unique to that run before each build, so PlatformIO reuses dependency
objects while the fixture-only `/api/test/firmware-marker` endpoint still proves
the newly booted image. The test harness requires the real form's successful response, its automatic
restart, and two fresh B-marker responses. Passive serial capture is retained
for failure diagnosis, but a product log-message wording change cannot turn a
successful A-to-B update into a failed test.
```bash
./tools/portal-hardware ota \
--platform esp32 \
--port /dev/serial/by-id/... \
--client-interface wlx...
```
ESP8266 explicitly uses `eagle.flash.4m1m.ld`. ESP32 uses the tracked two-slot
`partitions/esp32_ota_4m_no_fs.csv` layout on the maintained Arduino-ESP32
3.3.11 fixture lane. Both are 4 MB layouts. The runner builds and preserves A
and B before it touches the board, validates the matching ESP32 slots (or the
live ESP8266 updater capacity), then erases the explicitly selected test board
before serial-flashing A. A successful run leaves B installed in the
portal-only fixture. 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.
@@ -0,0 +1,12 @@
# 4 MB ESP32 portal-harness OTA test layout.
#
# The fixture compiles portal assets into the application image, so it uses no
# filesystem. app0 and app1 are equal 0x1F0000-byte slots. The portal OTA
# runner validates B against that inactive-slot size before it flashes A or
# submits the browser upload.
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
app0, app, ota_0, 0x10000, 0x1F0000,
app1, app, ota_1, 0x200000, 0x1F0000,
coredump, data, coredump,0x3F0000, 0x10000,
1 # 4 MB ESP32 portal-harness OTA test layout.
2 #
3 # The fixture compiles portal assets into the application image, so it uses no
4 # filesystem. app0 and app1 are equal 0x1F0000-byte slots. The portal OTA
5 # runner validates B against that inactive-slot size before it flashes A or
6 # submits the browser upload.
7 # Name, Type, SubType, Offset, Size, Flags
8 nvs, data, nvs, 0x9000, 0x5000,
9 otadata, data, ota, 0xe000, 0x2000,
10 app0, app, ota_0, 0x10000, 0x1F0000,
11 app1, app, ota_1, 0x200000, 0x1F0000,
12 coredump, data, coredump,0x3F0000, 0x10000,
+23 -1
View File
@@ -6,6 +6,9 @@ framework = arduino
lib_ldf_mode = deep+
lib_deps =
WiFiManager=symlink://../..
build_flags =
extra_scripts =
pre:tools/ota_fixture_input.py
[env:esp8266]
extends = common
@@ -18,7 +21,7 @@ build_flags =
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
@@ -26,3 +29,22 @@ build_flags =
-DSOC_WIFI_SUPPORTED=1
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
-DWM_LOG_LEVEL=4
; The OTA test harness writes its A/B marker into an ignored header included
; only by the portal fixture. One environment per platform keeps the explicit
; update layout while allowing the A-to-B rebuild to reuse every library object.
[env:esp8266_ota]
extends = env:esp8266
board_build.ldscript = eagle.flash.4m1m.ld
build_flags =
${env:esp8266.build_flags}
${common.build_flags}
-DWM_PORTAL_OTA_TEST=1
[env:esp32_ota]
extends = env:esp32
board_build.partitions = partitions/esp32_ota_4m_no_fs.csv
build_flags =
${env:esp32.build_flags}
${common.build_flags}
-DWM_PORTAL_OTA_TEST=1
+109 -8
View File
@@ -3,31 +3,132 @@
namespace {
#if defined(ESP8266)
constexpr char kPortalSsid[] = "WM Contract ESP8266";
// These markers belong only to the portal OTA fixture. The hardware runner
// writes the ignored header immediately before each A/B build, so the marker
// is compiled into firmware rather than saved in WiFiManager settings.
#if defined(WM_PORTAL_OTA_TEST)
#include <ota_fixture_identity.h>
#ifndef WM_OTA_FIXTURE_IMAGE
#error "Portal OTA fixture identity is missing."
#endif
// 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
constexpr char kPortalSsid[] = "WM Contract ESP32";
#define WM_OTA_FIXTURE_IMAGE "portal-harness"
constexpr unsigned long kSerialMonitorAttachDelayMs = 300UL;
#endif
#if defined(ESP8266)
constexpr char kPortalSsid[] = "WM Test Harness ESP8266";
#else
constexpr char kPortalSsid[] = "WM Test Harness ESP32";
#endif
constexpr char kPortalPassword[] = "default1";
WiFiManager wifi;
WiFiManagerParameter kInstallationLabel(
"installation_label", "Installation label", "Contract fixture", 32);
"installation_label", "Installation label", "Harness fixture", 32);
// Keep the characters from upstream issue #1863 in the portal fixture. The
// browser test harness verifies this value through JSON, DOM rendering, save,
// and a subsequent reload rather than relying on a string-only serializer
// check.
WiFiManagerParameter kEscapedValue(
"escaped_value", "Escaped value", "7(f+4]2y3fsYTQt'Uhxc\"d\\<>&", 64);
WiFiManagerParameter kMqttHost(
"mqtt_host", "MQTT host", "broker.example.local", 64);
WiFiManagerParameter kMqttPort(
"mqtt_port", "MQTT port", "1883", 8);
WiFiManagerParameter kDeviceRoom(
"device_room", "Device room", "Workshop", 32);
WiFiManagerParameter kSensorName(
"sensor_name", "Sensor name", "Ambient temperature", 48);
WiFiManagerParameter kTelemetryTopic(
"telemetry_topic", "Telemetry topic", "sensors/ambient/temperature", 64);
WiFiManagerParameter kTimezone(
"timezone", "Timezone", "Australia/Brisbane", 48);
WiFiManagerParameter kLatitude(
"latitude", "Latitude", "-27.4698", 16);
WiFiManagerParameter kLongitude(
"longitude", "Longitude", "153.0251", 16);
WiFiManagerParameter kFirmwareChannel(
"firmware_channel", "Firmware channel", "stable", 16);
WiFiManagerParameter kOwnerName(
"owner_name", "Owner name", "Portal test harness", 48);
WiFiManagerParameter kNotes(
"notes", "Notes", "Thirteen-field rendering fixture", 64);
WiFiManagerParameter* const kPortalParameters[] = {
&kInstallationLabel,
&kEscapedValue,
&kMqttHost,
&kMqttPort,
&kDeviceRoom,
&kSensorName,
&kTelemetryTopic,
&kTimezone,
&kLatitude,
&kLongitude,
&kFirmwareChannel,
&kOwnerName,
&kNotes,
};
void registerOtaTestMarker() {
// setWebServerCallback runs after WiFiManager creates its server and
// before it registers built-in routes. This private fixture endpoint is
// intentionally not a WiFiManager product API.
wifi.setWebServerCallback([]() {
AsyncWebServer* const server = wifi.getServer();
if (server == nullptr) {
return;
}
server->on("/api/test/firmware-marker", HTTP_GET,
[](AsyncWebServerRequest* request) {
String response = F("{\"marker\":\"");
response += WM_OTA_FIXTURE_IMAGE;
response += F("\",\"freeSketchSpace\":");
response += String(ESP.getFreeSketchSpace());
response += F("}");
request->send(200, "application/json", response);
});
});
}
} // namespace
void setup() {
Serial.begin(115200);
delay(300);
delay(kSerialMonitorAttachDelayMs);
// The physical HTTP OTA test harness records this immutable marker before
// and after its browser upload. It cannot be faked by saved portal values
// or an HTTP response from a stale image.
Serial.print(F("WiFiManager portal OTA fixture image: "));
Serial.println(WM_OTA_FIXTURE_IMAGE);
// The fixture intentionally has no station credentials. A finite window
// exercises timeout reset without leaving a board in a permanent portal.
// This fixture must be independent of whichever sketch was previously
// flashed to the board. Clear saved station credentials before starting
// the portal so browser artifacts always show the unconfigured flow.
wifi.resetSettings();
// A finite window exercises timeout reset without leaving a board in a
// permanent portal.
wifi.setConfigPortalTimeout(15 * 60);
wifi.setAPStaticIPConfig(
IPAddress(192, 168, 4, 1),
IPAddress(192, 168, 4, 1),
IPAddress(255, 255, 255, 0));
wifi.portalAddParameter(&kInstallationLabel);
registerOtaTestMarker();
// Keep custom parameters on their own native Save parameters page. This
// lets the browser test harness exercise a parameter-only submit without
// starting a station connection as part of the regression test.
wifi.portalSetLayoutParamsLocation(PortalParamsLocation::SetupPage);
for (auto* parameter : kPortalParameters) {
wifi.portalAddParameter(parameter);
}
wifi.startConfigPortal(kPortalSsid, kPortalPassword);
}
@@ -0,0 +1,23 @@
"""Add the run-owned A/B identity directory to this portal test-harness build."""
import os
from SCons.Script import Exit
Import("env")
if env["PIOENV"].endswith("_ota"):
identity_dir = os.environ.get("WIFIMANAGER_OTA_IDENTITY_DIR")
if not identity_dir:
print(
"WIFIMANAGER_OTA_IDENTITY_DIR is required; "
"run this fixture through its named test harness."
)
Exit(1)
identity_header = os.path.join(identity_dir, "ota_fixture_identity.h")
if not os.path.isfile(identity_header):
print(f"OTA fixture identity header is missing: {identity_header}")
Exit(1)
env.Append(CPPPATH=[identity_dir])
+2 -1
View File
@@ -1,4 +1,5 @@
# Ignored local credentials for the optional station handoff test.
# These values are mounted read-only into the Docker test container.
# The runner copies only these two values into a private one-run file mounted
# read-only into Docker; any other local env entries are not passed through.
WIFI_SSID=replace-me
WIFI_PASSWORD=replace-me
+3 -1
View File
@@ -17,6 +17,7 @@ TestCase tests[] = {
TEST_ENTRY(test_profile_portal_candidate_does_not_take_legacy_empty_ssid_path),
TEST_ENTRY(test_api_info_json_shape),
TEST_ENTRY(test_api_params_json_shape),
TEST_ENTRY(test_api_params_json_escapes_custom_parameter_value),
TEST_ENTRY(test_api_status_json_shape),
// Configuration tests
@@ -39,7 +40,7 @@ TestCase tests[] = {
TEST_ENTRY(test_get_config_portal_ssid),
TEST_ENTRY(test_bootstrap_json_portal_feature_flags),
TEST_ENTRY(test_portal_default_presentation),
TEST_ENTRY(test_bootstrap_json_contract_v3),
TEST_ENTRY(test_bootstrap_json_schema_v3),
TEST_ENTRY(test_bootstrap_json_snapshot_consistency),
TEST_ENTRY(test_root_render_interleaved_context_isolation),
TEST_ENTRY(test_portal_presentation_configuration),
@@ -105,6 +106,7 @@ TestCase tests[] = {
TEST_ENTRY(test_scan_cancels_when_connect_pending),
TEST_ENTRY(test_scan_cancels_when_lifecycle_blocked),
TEST_ENTRY(test_scan_generation_invalidated_on_reset),
TEST_ENTRY(test_scan_restart_interval_is_platform_appropriate),
TEST_ENTRY(test_real_async_scan_completes),
// Template rendering tests
+3 -1
View File
@@ -39,7 +39,7 @@ void test_config_portal_already_active();
void test_get_config_portal_ssid();
void test_bootstrap_json_portal_feature_flags();
void test_portal_default_presentation();
void test_bootstrap_json_contract_v3();
void test_bootstrap_json_schema_v3();
void test_bootstrap_json_snapshot_consistency();
void test_root_render_interleaved_context_isolation();
void test_portal_presentation_configuration();
@@ -107,6 +107,7 @@ void test_scan_completion_wait();
void test_scan_cancels_when_connect_pending();
void test_scan_cancels_when_lifecycle_blocked();
void test_scan_generation_invalidated_on_reset();
void test_scan_restart_interval_is_platform_appropriate();
void test_real_async_scan_completes();
// Template rendering tests
@@ -122,6 +123,7 @@ void test_profile_portal_success_keeps_handoff_alive();
void test_profile_portal_candidate_does_not_take_legacy_empty_ssid_path();
void test_api_info_json_shape();
void test_api_params_json_shape();
void test_api_params_json_escapes_custom_parameter_value();
void test_api_status_json_shape();
// State transition tests
@@ -167,6 +167,24 @@ void test_api_params_json_shape() {
Serial.println("[TEST] API params JSON shape test completed successfully");
}
void test_api_params_json_escapes_custom_parameter_value() {
Serial.println("[TEST] Testing escaped custom parameter JSON value...");
constexpr char value[] = "7(f+4]2y3fsYTQt'Uhxc\"d\\<>&";
WiFiManager wm;
WiFiManagerHandlers handlers(&wm);
WiFiManagerParameter field("escaped_value", "Escaped value", value,
static_cast<int>(sizeof(value) - 1));
wm.portalAddParameter(&field);
const String json = handlers.buildApiParamsGetJson();
// JSON must retain apostrophes, angle brackets and ampersands as data,
// while escaping the quote and backslash that delimit a JSON string.
TEST_ASSERT_NOT_NULL(strstr(json.c_str(), "7(f+4]2y3fsYTQt'Uhxc\\\"d\\\\<>&"));
Serial.println("[TEST] Escaped custom parameter JSON value test completed successfully");
}
void test_api_status_json_shape() {
Serial.println("[TEST] Testing /api/status JSON shape...");
@@ -87,8 +87,8 @@ void test_portal_default_presentation() {
TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"logoAltText\":\"\"")));
}
void test_bootstrap_json_contract_v3() {
Serial.println("[TEST] Testing bootstrap JSON v3 contract...");
void test_bootstrap_json_schema_v3() {
Serial.println("[TEST] Testing bootstrap JSON v3 schema...");
WiFiManager wm;
WiFiManagerHandlers handlers(&wm);
@@ -143,7 +143,7 @@ void test_bootstrap_json_contract_v3() {
TEST_ASSERT_EQUAL(-1, j.indexOf(F("\"portalTimeoutSecondsRemaining\":0")));
wm.wmTestSetPortalActive(false);
Serial.println("[TEST] Bootstrap JSON v2 contract test completed successfully");
Serial.println("[TEST] Bootstrap JSON v3 schema test completed successfully");
}
void test_bootstrap_json_snapshot_consistency() {
@@ -150,3 +150,24 @@ void test_scan_generation_invalidated_on_reset() {
Serial.println("[TEST] Scan generation invalidation on reset test completed successfully");
}
void test_scan_restart_interval_is_platform_appropriate() {
Serial.println("[TEST] Testing platform scan restart interval...");
WiFiManager wm;
#ifdef UNIT_TEST
#ifdef ESP32
TEST_ASSERT_EQUAL_UINT32_MESSAGE(
5000, wm.wmTestGetScanRestartIntervalMs(),
"ESP32 must wait for the radio to settle after a completed scan");
#else
TEST_ASSERT_EQUAL_UINT32_MESSAGE(
2000, wm.wmTestGetScanRestartIntervalMs(),
"ESP8266 keeps the established responsive scan restart interval");
#endif
#else
TEST_IGNORE_MESSAGE("UNIT_TEST helpers unavailable");
#endif
Serial.println("[TEST] Platform scan restart interval test completed successfully");
}
-11
View File
@@ -1,11 +0,0 @@
ARG PLAYWRIGHT_VERSION=1.63.0
FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble
ARG PLAYWRIGHT_VERSION
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
WORKDIR /work
COPY tests/portal-contract/package.json tests/portal-contract/package-lock.json ./
RUN npm ci --ignore-scripts && \
[ "$(node -p "require('@playwright/test/package.json').version")" = "$PLAYWRIGHT_VERSION" ]
COPY tests/portal-contract/ ./
CMD ["npm", "test"]
-11
View File
@@ -1,11 +0,0 @@
# Portal contract container
This directory contains the browser/API half of the real-hardware portal test.
Run it through [`../../tools/portal-hardware`](../../tools/portal-hardware), not
directly: the host command alone selects the serial board and attaches the
explicit secondary Wi-Fi adapter. Docker uses the host network only to reach
the already-routed `192.168.4.1` portal; it never manages host Wi-Fi.
The image pins the Playwright package to the matching official browser image.
Artifacts, traces, screenshots, JSON results, and the HTML report are written
to the output directory printed by the host command.
-18
View File
@@ -1,18 +0,0 @@
services:
portal-contract:
build:
context: ../..
dockerfile: tests/portal-contract/Dockerfile
args:
PLAYWRIGHT_VERSION: "1.63.0"
network_mode: host
ipc: host
init: true
user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
environment:
HOME: /tmp
PORTAL_URL: http://192.168.4.1
PORTAL_BROWSER_MODE: ${PORTAL_BROWSER_MODE:-auto}
ARTIFACT_DIR: /artifacts
volumes:
- ${PORTAL_ARTIFACT_DIR:?portal artifact directory is required}:/artifacts
@@ -1,111 +0,0 @@
const { test, expect } = require('@playwright/test');
async function json(response) {
return JSON.parse(await response.text());
}
async function waitForScan(request) {
let result;
await expect.poll(async () => {
try {
const response = await request.get('/api/wifi/scan-status');
if (!response.ok()) return true;
result = await json(response);
return result.scanning;
} catch {
// ESP8266 AP+STA scans briefly leave the AP channel. A client can lose
// its association while the radio scans, then reconnect before the
// asynchronous scan completes. Keep polling; the assertions below still
// require a reachable portal with a complete, valid result.
return true;
}
}, { timeout: 45_000, intervals: [500, 800, 1_000] }).toBe(false);
return result;
}
test.describe('portal AP contract', () => {
test('serves API, persists fixture parameters, and completes a real scan', async ({ request }) => {
const root = await request.get('/');
expect(root.ok()).toBeTruthy();
expect(await root.text()).toContain('<html');
const [bootstrapResponse, concurrentRoot] = await Promise.all([
request.get('/api/bootstrap'),
request.get('/'),
]);
expect(concurrentRoot.ok()).toBeTruthy();
const bootstrap = await json(bootstrapResponse);
expect(bootstrap.contractVersion).toBe(3);
expect(bootstrap.context.portalActive).toBe(true);
const metaResponse = await request.get('/api/wifi/meta');
expect(metaResponse.ok()).toBeTruthy();
const meta = await json(metaResponse);
expect(JSON.stringify(meta)).toContain('installation_label');
const infoResponse = await request.get('/api/info');
expect(infoResponse.ok()).toBeTruthy();
expect(JSON.stringify(await json(infoResponse))).toContain('192.168.4.1');
const statusResponse = await request.get('/api/status');
expect(statusResponse.ok()).toBeTruthy();
const saveResponse = await request.post('/api/params/save', {
form: { installation_label: 'Contract verified' },
});
expect(saveResponse.ok()).toBeTruthy();
expect(JSON.stringify(await json(saveResponse))).toContain('saved');
const paramsResponse = await request.get('/api/params');
expect(paramsResponse.ok()).toBeTruthy();
expect(JSON.stringify(await json(paramsResponse))).toContain('Contract verified');
const resetResponse = await request.post('/api/portal/timeout-reset');
expect(resetResponse.ok()).toBeTruthy();
expect((await json(resetResponse)).timeoutSecondsRemaining).toBeGreaterThan(800);
const scanStart = await request.post('/api/wifi/scan');
expect([200, 202, 409]).toContain(scanStart.status());
const completed = await waitForScan(request);
expect(completed.state).toBe('complete');
expect(completed.results_valid).toBe(true);
const missing = await request.get('/not-a-portal-route');
expect(missing.status()).toBe(404);
});
test('renders stable desktop and mobile portal views without page errors', async ({ browser }) => {
test.skip(process.env.PORTAL_BROWSER_MODE === 'skip', 'Browser checks were explicitly skipped.');
const desktop = await browser.newContext({ viewport: { width: 1440, height: 1080 } });
const page = await desktop.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
await page.goto('/', { waitUntil: 'networkidle' });
await expect(page.locator('#wm-reset-portal-timeout')).toBeVisible();
await page.screenshot({ path: `${process.env.ARTIFACT_DIR}/portal-overview-desktop.png`, fullPage: true });
await page.goto('/#/wifi', { waitUntil: 'networkidle' });
await expect(page.locator('#wm-refresh-scan')).toBeVisible();
await expect(page.locator('#wm-f-installation_label')).toBeVisible();
await page.locator('#wm-f-installation_label').fill('Browser verified');
await page.screenshot({ path: `${process.env.ARTIFACT_DIR}/portal-wifi-desktop.png`, fullPage: true });
const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true });
const mobilePage = await mobile.newPage();
mobilePage.on('pageerror', (error) => errors.push(error.message));
mobilePage.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
await mobilePage.goto('/#/info', { waitUntil: 'networkidle' });
await expect(mobilePage.locator('.wm-page-head')).toBeVisible();
await mobilePage.screenshot({ path: `${process.env.ARTIFACT_DIR}/portal-device-mobile.png`, fullPage: true });
await mobile.close();
await desktop.close();
expect(errors).toEqual([]);
});
});
+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.*
+19
View File
@@ -0,0 +1,19 @@
ARG PLAYWRIGHT_VERSION=1.63.0
FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble AS portal-harness
ARG PLAYWRIGHT_VERSION
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
WORKDIR /work
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts && \
[ "$(node -p "require('@playwright/test/package.json').version")" = "$PLAYWRIGHT_VERSION" ]
COPY . ./
RUN chmod 755 /work/run-portal-harness.sh /work/render-readme-media.sh
CMD ["/work/run-portal-harness.sh"]
FROM portal-harness AS media
USER root
RUN apt-get update && \
apt-get install -y --no-install-recommends ffmpeg && \
rm -rf /var/lib/apt/lists/*
USER pwuser
+21
View File
@@ -0,0 +1,21 @@
# Portal test-harness container
This directory contains the browser/API half of the real-hardware portal test.
Run it through [`../../tools/portal-hardware`](../../tools/portal-hardware), not
directly: the host command alone selects the serial board and attaches the
explicit secondary Wi-Fi adapter. Docker uses the host network only to reach
the already-routed `192.168.4.1` portal; it never manages host Wi-Fi.
The image pins the Playwright package to the matching official browser image.
Artifacts, traces, screenshots, JSON results, and the HTML report are written
to the output directory printed by the host command.
`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 test
harness. The ordinary portal test harness never receives a firmware artifact.
`compose.station.yaml` is used only by the opt-in station-handoff command. The
host runner stages only `WIFI_SSID` and `WIFI_PASSWORD` in a mode-600 temporary
file instead of mounting the developer's complete environment file. That mode
disables Playwright screenshots, video, and tracing because request bodies can
contain the local password.
+11
View File
@@ -0,0 +1,11 @@
# OTA-only overlay. The normal portal test harness neither builds nor mounts a
# firmware image. portal-hardware supplies this absolute path after it has
# built B and copied it into the run's private artifact directory.
services:
portal-harness:
environment:
PORTAL_OTA_FIRMWARE: /firmware/portal-ota-b.bin
PORTAL_OTA_INITIAL_MARKER: ${PORTAL_OTA_INITIAL_MARKER:-A}
PORTAL_OTA_EXPECTED_MARKER: ${PORTAL_OTA_EXPECTED_MARKER:-B}
volumes:
- ${PORTAL_OTA_FIRMWARE_HOST:?OTA firmware path is required}:/firmware/portal-ota-b.bin:ro
@@ -1,5 +1,5 @@
services:
portal-contract:
portal-harness:
environment:
PORTAL_STATION_ENV: /run/secrets/portal-station.env
volumes:
+27
View File
@@ -0,0 +1,27 @@
services:
portal-harness:
build:
# Keep Docker's build context limited to the browser test harness. A
# developer may point --station-env or --output anywhere in the repo;
# neither source credentials nor the generated two-key file may cross
# the host-to-Docker boundary during image build.
context: .
dockerfile: Dockerfile
target: ${PORTAL_HARNESS_DOCKER_TARGET:-portal-harness}
args:
PLAYWRIGHT_VERSION: "1.63.0"
network_mode: host
ipc: host
init: true
user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
environment:
HOME: /tmp
PORTAL_URL: http://192.168.4.1
PORTAL_BROWSER_MODE: ${PORTAL_BROWSER_MODE:-auto}
PORTAL_CUSTOM_PARAMETER_STRESS: ${PORTAL_CUSTOM_PARAMETER_STRESS:-0}
PORTAL_PLATFORM: ${PORTAL_PLATFORM:-}
PORTAL_CAPTURE_README_MEDIA: ${PORTAL_CAPTURE_README_MEDIA:-0}
PORTAL_TEST_FILE: ${PORTAL_TEST_FILE:-}
ARTIFACT_DIR: /artifacts
volumes:
- ${PORTAL_ARTIFACT_DIR:?portal artifact directory is required}:/artifacts
@@ -1,11 +1,11 @@
{
"name": "wifimanager-portal-contract",
"name": "wifimanager-portal-harness",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wifimanager-portal-contract",
"name": "wifimanager-portal-harness",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "1.63.0"
@@ -1,5 +1,5 @@
{
"name": "wifimanager-portal-contract",
"name": "wifimanager-portal-harness",
"private": true,
"version": "1.0.0",
"scripts": {
@@ -1,7 +1,13 @@
// Configuration for the browser half of the portal test harness.
const path = require('path');
const { defineConfig } = require('@playwright/test');
const artifactDir = process.env.ARTIFACT_DIR || path.join(__dirname, 'artifacts');
// The optional station-handoff test submits real local Wi-Fi credentials. The
// runner mounts only its generated two-key file, but Playwright traces can
// retain request bodies, so leave no screenshots, video, or trace behind for
// that one opt-in credential-bearing mode.
const hasStationCredentials = Boolean(process.env.PORTAL_STATION_ENV);
module.exports = defineConfig({
testDir: './tests',
@@ -18,8 +24,8 @@ module.exports = defineConfig({
],
use: {
baseURL: process.env.PORTAL_URL || 'http://192.168.4.1',
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
video: 'retain-on-failure',
screenshot: hasStationCredentials ? 'off' : 'only-on-failure',
trace: hasStationCredentials ? 'off' : 'retain-on-failure',
video: hasStationCredentials ? 'off' : 'retain-on-failure',
},
});
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# README-media renderer used by the portal test harness.
set -euo pipefail
artifact_dir="${ARTIFACT_DIR:?ARTIFACT_DIR is required}"
media_dir="$artifact_dir/readme-media"
source_video="$media_dir/raw/portal-tour.webm"
target_gif="$media_dir/portal-tour.gif"
[[ -s "$source_video" ]] || {
echo "README media video was not recorded: $source_video" >&2
exit 1
}
ffmpeg -hide_banner -loglevel error -y -i "$source_video" \
-filter_complex '[0:v]setpts=1.25*PTS,fps=6,scale=720:-2:flags=lanczos,split[a][b];[a]palettegen=max_colors=128[p];[b][p]paletteuse' \
-loop 0 "$target_gif"
[[ -s "$target_gif" ]] || {
echo "README media GIF was not rendered: $target_gif" >&2
exit 1
}
duration="$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$target_gif")"
awk -v duration="$duration" 'BEGIN { exit !(duration >= 4 && duration <= 30) }' || {
echo "README media GIF duration is outside the 4–30 second review range: $duration" >&2
exit 1
}
max_bytes=$((2 * 1024 * 1024))
(( $(wc -c < "$target_gif") <= max_bytes )) || {
echo "README media GIF exceeds its 2 MiB documentation budget: $target_gif" >&2
exit 1
}
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
playwright_args=(test --config /work/playwright.config.js)
if [[ -n "${PORTAL_TEST_FILE:-}" ]]; then
# OTA is a destructive, time-bounded board test harness. Run only its browser
# spec instead of allowing ordinary portal tests to consume its AP window.
playwright_args+=("$PORTAL_TEST_FILE")
fi
/work/node_modules/.bin/playwright "${playwright_args[@]}"
if [[ "${PORTAL_CAPTURE_README_MEDIA:-0}" == "1" ]]; then
/work/render-readme-media.sh
fi
@@ -0,0 +1,209 @@
const { test, expect } = require('@playwright/test');
const hasStationCredentials = Boolean(process.env.PORTAL_STATION_ENV);
async function saveDiagnosticScreenshot(page, target) {
// The optional station hand-off submits real local credentials. Playwright's
// automatic artifacts are disabled in that mode, and explicit screenshots
// must honor the same boundary.
if (!hasStationCredentials) {
await page.screenshot({ path: target, fullPage: true });
}
}
const fixtureParameters = [
{ id: 'installation_label', value: 'Harness fixture' },
{ id: 'escaped_value', value: "7(f+4]2y3fsYTQt'Uhxc\"d\\<>&" },
{ id: 'mqtt_host', value: 'broker.example.local' },
{ id: 'mqtt_port', value: '1883' },
{ id: 'device_room', value: 'Workshop' },
{ id: 'sensor_name', value: 'Ambient temperature' },
{ id: 'telemetry_topic', value: 'sensors/ambient/temperature' },
{ id: 'timezone', value: 'Australia/Brisbane' },
{ id: 'latitude', value: '-27.4698' },
{ id: 'longitude', value: '153.0251' },
{ id: 'firmware_channel', value: 'stable' },
{ id: 'owner_name', value: 'Portal test harness' },
{ id: 'notes', value: 'Thirteen-field rendering fixture' },
];
const escapedUpdatedValue = "updated 'quote\" slash\\<>&";
async function json(response) {
return JSON.parse(await response.text());
}
function expectFixtureParameters(payload, expected = fixtureParameters) {
expect(payload.params).toHaveLength(expected.length);
const byId = new Map(payload.params.map((field) => [field.id, field]));
for (const { id, value } of expected) {
expect(byId.get(id), `missing fixture field ${id}`).toBeDefined();
expect(byId.get(id).value, `unexpected value for ${id}`).toBe(value);
}
}
async function waitForScan(request) {
let result;
await expect.poll(async () => {
try {
const response = await request.get('/api/wifi/scan-status');
if (!response.ok()) return true;
result = await json(response);
return result.scanning;
} catch {
// ESP8266 AP+STA scans briefly leave the AP channel. A client can lose
// its association while the radio scans, then reconnect before the
// asynchronous scan completes. Keep polling; the assertions below still
// require a reachable portal with a complete, valid result.
return true;
}
}, { timeout: 45_000, intervals: [500, 800, 1_000] }).toBe(false);
return result;
}
test.describe('portal AP test harness', () => {
test('serves API, retains all thirteen fixture parameters, and completes a real scan', async ({ request }) => {
const root = await request.get('/');
expect(root.ok()).toBeTruthy();
expect(await root.text()).toContain('<html');
const [bootstrapResponse, concurrentRoot] = await Promise.all([
request.get('/api/bootstrap'),
request.get('/'),
]);
expect(concurrentRoot.ok()).toBeTruthy();
const bootstrap = await json(bootstrapResponse);
expect(bootstrap.contractVersion).toBe(3);
expect(bootstrap.context.portalActive).toBe(true);
expect(bootstrap.layout.paramsLocation).toBe('setup');
const metaResponse = await request.get('/api/wifi/meta');
expect(metaResponse.ok()).toBeTruthy();
const meta = await json(metaResponse);
// The fixture intentionally uses a separate Settings page. Its custom
// parameters are therefore served by /api/params rather than duplicated
// in the Wi-Fi form metadata.
expect(meta.params).toHaveLength(0);
const infoResponse = await request.get('/api/info');
expect(infoResponse.ok()).toBeTruthy();
expect(JSON.stringify(await json(infoResponse))).toContain('192.168.4.1');
const statusResponse = await request.get('/api/status');
expect(statusResponse.ok()).toBeTruthy();
// Issue #1787 was intermittent and memory-sensitive on ESP8266. Fetch
// the complete API response repeatedly so ordinary portal runs verify
// every field and value without opting into the longer browser soak.
for (let attempt = 0; attempt < 4; attempt += 1) {
const paramsResponse = await request.get('/api/params');
expect(paramsResponse.ok(), `parameter fetch ${attempt + 1}`).toBeTruthy();
expectFixtureParameters(await json(paramsResponse));
}
const resetResponse = await request.post('/api/portal/timeout-reset');
expect(resetResponse.ok()).toBeTruthy();
expect((await json(resetResponse)).timeoutSecondsRemaining).toBeGreaterThan(800);
const scanStart = await request.post('/api/wifi/scan');
expect([200, 202, 409]).toContain(scanStart.status());
const completed = await waitForScan(request);
expect(completed.state).toBe('complete');
expect(completed.results_valid).toBe(true);
const missing = await request.get('/not-a-portal-route');
expect(missing.status()).toBe(404);
});
test('renders stable desktop and mobile portal views without page errors', async ({ browser }) => {
test.skip(process.env.PORTAL_BROWSER_MODE === 'skip', 'Browser checks were explicitly skipped.');
const desktop = await browser.newContext({ viewport: { width: 1440, height: 1080 } });
const page = await desktop.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
await page.goto('/', { waitUntil: 'networkidle' });
await expect(page.locator('#wm-reset-portal-timeout')).toBeVisible();
await saveDiagnosticScreenshot(page, `${process.env.ARTIFACT_DIR}/portal-overview-desktop.png`);
await page.goto('/#/wifi', { waitUntil: 'networkidle' });
await expect(page.locator('#wm-refresh-scan')).toBeVisible();
await page.goto('/#/setup', { waitUntil: 'networkidle' });
await expect(page.locator('#wm-param-form input')).toHaveCount(fixtureParameters.length);
await expect(page.locator('#wm-f-installation_label')).toBeVisible();
await expect(page.locator('#wm-f-escaped_value')).toHaveValue(fixtureParameters[1].value);
await page.locator('#wm-f-installation_label').fill('Browser verified');
await saveDiagnosticScreenshot(page, `${process.env.ARTIFACT_DIR}/portal-wifi-desktop.png`);
const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true });
const mobilePage = await mobile.newPage();
mobilePage.on('pageerror', (error) => errors.push(error.message));
mobilePage.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
await mobilePage.goto('/#/info', { waitUntil: 'networkidle' });
await expect(mobilePage.locator('.wm-page-head')).toBeVisible();
await saveDiagnosticScreenshot(mobilePage, `${process.env.ARTIFACT_DIR}/portal-device-mobile.png`);
await mobile.close();
await desktop.close();
expect(errors).toEqual([]);
});
test('round-trips quotes, apostrophes, backslashes, and HTML-sensitive values', async ({ page, request }) => {
test.skip(process.env.PORTAL_BROWSER_MODE === 'skip', 'Browser checks were explicitly skipped.');
await page.goto('/#/setup', { waitUntil: 'networkidle' });
await expect(page.locator('#wm-param-form input')).toHaveCount(fixtureParameters.length);
const escapedInput = page.locator('#wm-f-escaped_value');
await expect(escapedInput).toHaveValue(fixtureParameters[1].value);
await escapedInput.fill(escapedUpdatedValue);
const saveResponse = page.waitForResponse((response) => (
response.url().endsWith('/api/params/save')
&& response.request().method() === 'POST'
));
await page.locator('#wm-param-form button[type="submit"]').click();
expect((await saveResponse).ok()).toBeTruthy();
const paramsResponse = await request.get('/api/params');
expect(paramsResponse.ok()).toBeTruthy();
const expected = fixtureParameters.map((field) => (
field.id === 'escaped_value' ? { ...field, value: escapedUpdatedValue } : field
));
expectFixtureParameters(await json(paramsResponse), expected);
await page.reload({ waitUntil: 'networkidle' });
await expect(page.locator('#wm-f-escaped_value')).toHaveValue(escapedUpdatedValue);
});
test('ESP8266 repeatedly renders all thirteen custom parameters', async ({ page, request }) => {
test.skip(process.env.PORTAL_CUSTOM_PARAMETER_STRESS !== '1',
'ESP8266 custom-parameter browser stress was not requested.');
test.skip(process.env.PORTAL_PLATFORM !== 'esp8266',
'Custom-parameter browser stress is scoped to ESP8266.');
const expected = fixtureParameters.map((field) => (
field.id === 'escaped_value' ? { ...field, value: escapedUpdatedValue } : field
));
const resetResponse = await request.post('/api/params/save', {
form: Object.fromEntries(expected.map(({ id, value }) => [id, value])),
});
expect(resetResponse.ok()).toBeTruthy();
for (let attempt = 0; attempt < 12; attempt += 1) {
const paramsResponse = await request.get('/api/params');
expect(paramsResponse.ok(), `API fetch ${attempt + 1}`).toBeTruthy();
const params = await json(paramsResponse);
expectFixtureParameters(params, expected);
await page.goto('/#/setup', { waitUntil: 'networkidle' });
await expect(page.locator('#wm-param-form input')).toHaveCount(expected.length);
for (const { id, value } of expected) {
await expect(page.locator(`#wm-f-${id}`), `${id}, render ${attempt + 1}`).toHaveValue(value);
}
}
});
});
+127
View File
@@ -0,0 +1,127 @@
const { test, expect } = require('@playwright/test');
const firmware = process.env.PORTAL_OTA_FIRMWARE;
const initialMarker = process.env.PORTAL_OTA_INITIAL_MARKER || 'A';
const expectedMarker = process.env.PORTAL_OTA_EXPECTED_MARKER || 'B';
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
async function getMarker(request) {
const response = await request.get('/api/test/firmware-marker', { timeout: 4_000 });
if (!response.ok()) {
throw new Error(`marker endpoint returned HTTP ${response.status()}`);
}
return response.json();
}
async function waitForMarker(request, expected, timeout = 75_000) {
let observed;
await expect.poll(async () => {
try {
observed = await getMarker(request);
return observed.marker;
} catch {
return undefined;
}
}, {
timeout,
intervals: [250, 500, 1_000, 1_000],
}).toBe(expected);
return observed;
}
async function requireRestartOutage(request) {
const deadline = Date.now() + 25_000;
while (Date.now() < deadline) {
try {
const response = await request.get('/api/test/firmware-marker', { timeout: 1_000 });
if (!response.ok()) {
return;
}
} catch {
return;
}
await sleep(150);
}
throw new Error('The portal never became unavailable after a successful OTA response.');
}
function waitForOtaResponse(page) {
// `waitForResponse()` alone waits until the enclosing test timeout when an
// embedded server resets the upload connection. Treat that as an immediate
// transport failure so a hardware artifact names the real fault instead of
// implying that the rendered form never submitted.
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error('Timed out waiting for the portal OTA POST /u response.'));
}, 90_000);
const isOtaRequest = (request) => {
const requestPath = new URL(request.url()).pathname;
return requestPath === '/u' && request.method() === 'POST';
};
const cleanup = () => {
clearTimeout(timeout);
page.off('response', onResponse);
page.off('requestfailed', onRequestFailed);
};
const onResponse = (response) => {
if (!isOtaRequest(response.request())) {
return;
}
cleanup();
resolve(response);
};
const onRequestFailed = (request) => {
if (!isOtaRequest(request)) {
return;
}
cleanup();
const failure = request.failure();
reject(new Error(`Portal OTA POST /u failed before a response: ${failure ? failure.errorText : 'unknown error'}`));
};
page.on('response', onResponse);
page.on('requestfailed', onRequestFailed);
});
}
test.describe('portal HTTP OTA test harness', () => {
test('uploads B through the rendered portal form, requires automatic reboot, and observes B twice', async ({ page, request }) => {
test.skip(!firmware, 'OTA firmware is mounted only for portal-hardware ota.');
// Initial portal availability, an observed outage, and two fresh B
// responses each have their own bounded waits. Keep the overall budget
// larger than their sum so a valid slow reassociation is not killed by
// Playwright before the fixture test harness has concluded.
test.setTimeout(300_000);
const initial = await waitForMarker(request, initialMarker);
expect(initial.freeSketchSpace).toEqual(expect.any(Number));
expect(initial.freeSketchSpace).toBeGreaterThan(0);
// This deliberately uses the rendered UI and its multipart XHR instead of
// posting directly to /u. It therefore covers the real file control,
// submit handling, success JSON, and restart presentation together.
await page.goto('/#/update', { waitUntil: 'networkidle' });
const input = page.locator('#wm-ota-file');
await expect(input).toBeVisible();
await input.setInputFiles(firmware);
const updateResponse = waitForOtaResponse(page);
await page.locator('#wm-ota-form button[type="submit"]').click();
const response = await updateResponse;
expect(response.status()).toBe(200);
await expect(response.json()).resolves.toMatchObject({ ok: true });
// A manual reset is never issued here. Observing the outage is what proves
// WiFiManager's Update.end(true) path restarted the board on its own.
await requireRestartOutage(request);
const first = await waitForMarker(request, expectedMarker);
expect(first.freeSketchSpace).toEqual(expect.any(Number));
await sleep(1_000);
const second = await waitForMarker(request, expectedMarker);
expect(second.freeSketchSpace).toEqual(expect.any(Number));
});
});
@@ -0,0 +1,70 @@
const fs = require('fs');
const path = require('path');
const { test, expect } = require('@playwright/test');
function mediaPath(...parts) {
const root = process.env.ARTIFACT_DIR || '/artifacts';
const target = path.join(root, 'readme-media', ...parts);
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
return target;
}
async function waitForCompletedScan(request) {
await expect.poll(async () => {
try {
const response = await request.get('/api/wifi/scan-status');
if (!response.ok()) return false;
const result = JSON.parse(await response.text());
return result.state === 'complete' && result.results_valid && result.count > 0;
} catch {
return false;
}
}, { timeout: 45_000, intervals: [500, 800, 1_000] }).toBe(true);
}
test.describe('WiFiManager README media', () => {
test.skip(process.env.PORTAL_CAPTURE_README_MEDIA !== '1', 'README capture was not requested.');
test.skip(Boolean(process.env.PORTAL_STATION_ENV),
'README recording is unavailable when a station hand-off carries local credentials.');
test('records an approved ESP32 portal tour', async ({ browser, request }) => {
const context = await browser.newContext({
viewport: { width: 720, height: 900 },
recordVideo: {
dir: mediaPath('raw'),
size: { width: 720, height: 900 },
},
});
const page = await context.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
const video = page.video();
await page.goto('/', { waitUntil: 'networkidle' });
await expect(page.locator('#wm-reset-portal-timeout')).toBeVisible();
// These pauses exist only in the README recording. The ordinary test harness
// remains timing-focused; this tour needs readable stable states.
await page.waitForTimeout(1200);
await page.screenshot({ path: mediaPath('portal-overview.png'), fullPage: true });
await page.locator('a[href="#/wifi"]').click();
await expect(page.locator('#wm-refresh-scan')).toBeVisible();
await waitForCompletedScan(request);
await expect(page.locator('#wm-scan-results .wm-scan-row').first()).toBeVisible();
await page.locator('#wm-f-installation_label').fill('Workshop sensor');
await page.waitForTimeout(1200);
await page.screenshot({ path: mediaPath('portal-wifi-settings.png'), fullPage: true });
await page.locator('#wm-refresh-scan').click();
await expect(page.locator('#wm-wifi-scan-overlay')).toBeVisible();
await page.waitForTimeout(1200);
await context.close();
const source = await video.path();
fs.renameSync(source, mediaPath('raw', 'portal-tour.webm'));
expect(errors).toEqual([]);
});
});
@@ -1,3 +1,4 @@
// Optional LAN handoff exercised by the portal test harness.
const fs = require('fs');
const { test, expect } = require('@playwright/test');
+129
View File
@@ -0,0 +1,129 @@
#!/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
IMMEDIATE_EMPTY_READ_SECONDS = 0.01
EMPTY_READ_BACKOFF_INITIAL_SECONDS = 0.01
EMPTY_READ_BACKOFF_MAX_SECONDS = 0.25
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,
deadline_seconds=None,
clock=time.monotonic,
sleep=time.sleep,
):
"""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)
deadline = None
if deadline_seconds is not None:
deadline = clock() + deadline_seconds
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
while not should_stop():
if deadline is not None and clock() >= deadline:
print("Passive serial capture reached its deadline.", file=sys.stderr)
return 2
read_started = clock()
data = serial_port.read(4096)
if data:
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
output_file.write(data)
continue
# A real serial port blocks for its configured timeout. A
# broken or detached backend can return an empty read
# immediately. Back off only in that pathological case, and
# reset after real data, so the recorder cannot become a
# CPU-bound loop without penalising normal serial timeouts.
if clock() - read_started < IMMEDIATE_EMPTY_READ_SECONDS:
sleep(empty_read_backoff)
empty_read_backoff = min(
empty_read_backoff * 2,
EMPTY_READ_BACKOFF_MAX_SECONDS,
)
else:
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
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)
parser.add_argument("--deadline-seconds", type=float)
args = parser.parse_args()
if args.deadline_seconds is not None and args.deadline_seconds <= 0:
parser.error("--deadline-seconds must be greater than zero")
signal.signal(signal.SIGTERM, request_stop)
signal.signal(signal.SIGINT, request_stop)
return capture(args.port, args.output, args.ready_file, deadline_seconds=args.deadline_seconds)
if __name__ == "__main__":
sys.exit(main())
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Verify the tracked ESP32 OTA table used by the portal A/B fixtures. Keep the
# check independent of PlatformIO so CI rejects a layout regression before it
# downloads a framework or a hardware runner erases a board.
set -euo pipefail
project_dir="$(cd "$(dirname "$0")/.." && pwd)"
table="${1:-$project_dir/test/portal-harness/partitions/esp32_ota_4m_no_fs.csv}"
[[ $# -le 1 && -r "$table" ]] || {
echo "Usage: $0 [partition-table.csv]" >&2
exit 2
}
partition_row() {
local name="$1" type="$2" subtype="$3"
awk -F, -v name="$name" -v type="$type" -v subtype="$subtype" '
function trim(value) {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
return value
}
/^[[:space:]]*#/ || NF < 5 { next }
trim($1) == name && trim($2) == type && trim($3) == subtype {
print trim($1) "," trim($2) "," trim($3) "," trim($4) "," trim($5)
}
' "$table"
}
require_row() {
local name="$1" type="$2" subtype="$3" offset="$4" size="$5" actual expected
expected="$name,$type,$subtype,$offset,$size"
actual="$(partition_row "$name" "$type" "$subtype")"
[[ "$actual" == "$expected" ]] || {
echo "Expected exactly this OTA partition row: $expected" >&2
echo "Found: ${actual:-<none>}" >&2
return 1
}
}
require_row nvs data nvs 0x9000 0x5000
require_row otadata data ota 0xe000 0x2000
require_row app0 app ota_0 0x10000 0x1F0000
require_row app1 app ota_1 0x200000 0x1F0000
app_count="$(awk -F, '
function trim(value) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", value); return value }
/^[[:space:]]*#/ || NF < 5 { next }
trim($2) == "app" { count++ }
END { print count + 0 }
' "$table")"
[[ "$app_count" == 2 ]] || {
echo "OTA layout must contain exactly two application partitions, found $app_count." >&2
exit 1
}
if awk -F, '
function trim(value) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", value); return value }
/^[[:space:]]*#/ || NF < 5 { next }
trim($2) == "data" && trim($3) ~ /^(spiffs|littlefs|fat)$/ { found = 1 }
END { exit found ? 0 : 1 }
' "$table"; then
echo "OTA test layout must not reserve a filesystem partition." >&2
exit 1
fi
echo "ESP32 OTA partition test-harness check passed: $table"
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Resource locks shared by WiFiManager and DeviceFramework physical test harnesses.
#
# The protocol deliberately uses a stable, non-secret path and hash input so
# standalone checkouts still coordinate when they use the same host resources.
declare -A WM_HARNESS_LOCK_FDS=()
wm_harness_lock_root() {
local runtime_root
if [[ -n "${ARDUINO_TEST_HARNESS_LOCK_DIR:-}" ]]; then
printf '%s\n' "$ARDUINO_TEST_HARNESS_LOCK_DIR"
return 0
fi
runtime_root="${XDG_RUNTIME_DIR:-}"
if [[ -n "$runtime_root" && -d "$runtime_root" && -w "$runtime_root" ]]; then
printf '%s/arduino-framework-test-harness-locks\n' "$runtime_root"
else
printf '%s/arduino-framework-test-harness-locks\n' "${TMPDIR:-/tmp}"
fi
}
wm_harness_lock_resource() {
local label="$1" resource="$2" lock_root digest lock_file lock_fd
[[ -n "$label" && -n "$resource" ]] || {
echo "A test-harness resource lock needs a label and key." >&2
return 2
}
[[ -n "${WM_HARNESS_LOCK_FDS[$resource]:-}" ]] && return 0
command -v flock >/dev/null 2>&1 || {
echo "flock is required to protect test-harness resources." >&2
return 1
}
command -v sha256sum >/dev/null 2>&1 || {
echo "sha256sum is required to name test-harness resource locks." >&2
return 1
}
lock_root="$(wm_harness_lock_root)"
install -d -m 700 "$lock_root"
digest="$(printf '%s' "$resource" | sha256sum | awk '{print $1}')"
lock_file="$lock_root/${digest}.lock"
exec {lock_fd}>"$lock_file"
if ! flock -n "$lock_fd"; then
printf "Cannot start: %s is already in use by another local test-harness process.\n" "$label" >&2
return 1
fi
WM_HARNESS_LOCK_FDS["$resource"]="$lock_fd"
}
wm_harness_lock_serial_port() {
local port="$1" canonical
canonical="$(readlink -f -- "$port" 2>/dev/null || printf '%s' "$port")"
wm_harness_lock_resource "serial port $canonical" "serial-port:$canonical"
}
wm_harness_lock_portal_network() {
wm_harness_lock_resource "the 192.168.4.0/24 portal network" "portal-network:192.168.4.0/24"
}
wm_harness_lock_wifi_adapter() {
local interface="$1"
wm_harness_lock_resource "Wi-Fi adapter $interface" "wifi-adapter:$interface"
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Generated A/B identity used only by WiFiManager's portal OTA test harness.
#
# Keeping the identity in a tiny header lets PlatformIO reuse the one platform
# environment's library objects. The header is ignored and removed on every
# normal test-harness exit; it is never part of a user sketch or release.
declare -A WM_OTA_FIXTURE_LOCK_FDS=()
wm_lock_ota_fixture_environment() {
local environment="$1" lock_root lock_file lock_fd
[[ "$environment" =~ ^[A-Za-z0-9_-]+$ ]] || {
echo "Unsafe OTA fixture environment name: $environment" >&2
return 2
}
[[ -n "${WM_OTA_FIXTURE_LOCK_FDS[$environment]:-}" ]] && return 0
command -v flock >/dev/null 2>&1 || {
echo "flock is required to protect PlatformIO OTA fixture inputs." >&2
return 1
}
lock_root="$root/test/portal-harness/.pio/harness-locks"
install -d -m 700 "$lock_root"
lock_file="$lock_root/${environment}.lock"
exec {lock_fd}>"$lock_file"
if ! flock -n "$lock_fd"; then
echo "OTA fixture environment '$environment' is already in use by another local test-harness run." >&2
return 1
fi
WM_OTA_FIXTURE_LOCK_FDS["$environment"]="$lock_fd"
}
wm_ota_fixture_identity_dir() {
[[ -n "${WIFIMANAGER_OTA_IDENTITY_DIR:-}" ]] || {
echo "WIFIMANAGER_OTA_IDENTITY_DIR must be set before writing an OTA fixture identity." >&2
return 2
}
printf '%s\n' "$WIFIMANAGER_OTA_IDENTITY_DIR"
}
wm_ota_fixture_identity_header() {
printf '%s/ota_fixture_identity.h\n' "$(wm_ota_fixture_identity_dir)"
}
wm_write_ota_fixture_identity() {
local image="$1" directory header temporary
case "$image" in
A|B) ;;
*)
echo "WiFiManager OTA fixture identity must be A or B." >&2
return 2
;;
esac
directory="$(wm_ota_fixture_identity_dir)" || return
header="$(wm_ota_fixture_identity_header)"
install -d -m 700 "$directory"
temporary="$(mktemp "$directory/ota_fixture_identity.XXXXXX")"
chmod 600 "$temporary"
printf '%s\n' \
'#pragma once' \
'// Generated by the WiFiManager OTA test harness. Do not commit.' \
"#define WM_OTA_FIXTURE_IMAGE \"$image\"" \
> "$temporary"
mv -f -- "$temporary" "$header"
}
wm_remove_ota_fixture_identity() {
local directory header
[[ -n "${WIFIMANAGER_OTA_IDENTITY_DIR:-}" ]] || return 0
directory="$(wm_ota_fixture_identity_dir)" || return
header="$(wm_ota_fixture_identity_header)"
rm -f -- "$header"
rmdir -- "$directory" 2>/dev/null || true
}
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# PlatformIO discovery shared by WiFiManager's local test and hardware runners.
# Non-interactive shells, including an SSH hardware lab session, do not always
# include PlatformIO's standard virtual environment in PATH.
wm_pio_executable() {
local executable
if [[ -n "${WIFIMANAGER_PIO_EXECUTABLE:-}" ]]; then
executable="$WIFIMANAGER_PIO_EXECUTABLE"
else
executable="$(command -v pio 2>/dev/null || true)"
if [[ -z "$executable" && -x "$HOME/.platformio/penv/bin/pio" ]]; then
executable="$HOME/.platformio/penv/bin/pio"
fi
fi
[[ -n "$executable" && -x "$executable" ]] || {
echo "PlatformIO is required; install it or set WIFIMANAGER_PIO_EXECUTABLE." >&2
return 1
}
printf '%s\n' "$executable"
}
wm_pio_available() {
wm_pio_executable >/dev/null
}
wm_pio() {
local executable
executable="$(wm_pio_executable)" || return
"$executable" "$@"
}
+321 -48
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env bash
# Shared host-side helpers for the WiFiManager portal hardware contract.
# shellcheck source=tools/lib/harness-locks.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/harness-locks.sh"
# Shared host-side helpers for the WiFiManager portal hardware test harness.
# They never modify a network interface other than the explicit client adapter.
wm_portal_state_root() {
@@ -13,21 +15,121 @@ wm_require() {
}
}
wm_nmcli_permission() {
local permission="$1"
awk -F: -v permission="$permission" '$1 == permission { print $2; exit }' \
<<<"${WM_NMCLI_PERMISSIONS:-}"
}
wm_prepare_networkmanager_authorization() {
# A GUI Polkit agent can authorize direct nmcli actions. An SSH/headless
# shell has no such agent on many Linux hosts, even for a sudo-capable
# developer. Resolve that host-side boundary before an erase/upload, then
# use one fixed command path rather than suppressing an unauthorized scan
# and later misreporting an SSID timeout.
local permission value direct=yes
case "${WM_NMCLI_AUTH:-auto}" in
auto|direct|sudo) ;;
*)
echo 'WM_NMCLI_AUTH must be auto, direct, or sudo.' >&2
return 2
;;
esac
[[ "${WM_NMCLI_AUTH_READY:-no}" == yes ]] && return 0
WM_NMCLI_PERMISSIONS="$(nmcli -t -f PERMISSION,VALUE general permissions 2>/dev/null || true)"
for permission in \
org.freedesktop.NetworkManager.wifi.scan \
org.freedesktop.NetworkManager.network-control \
org.freedesktop.NetworkManager.settings.modify.system; do
value="$(wm_nmcli_permission "$permission")"
[[ "$value" == yes ]] || direct=no
done
case "${WM_NMCLI_AUTH:-auto}" in
direct)
WM_NMCLI_MODE=direct
;;
sudo)
WM_NMCLI_MODE=sudo
;;
auto)
if [[ "$direct" == yes ]]; then
WM_NMCLI_MODE=direct
# A session D-Bus socket is common over SSH but does not itself
# provide a graphical Polkit agent, so require an actual display.
elif [[ -z "${DISPLAY:-}" && -z "${WAYLAND_DISPLAY:-}" ]]; then
WM_NMCLI_MODE=sudo
else
# Give a graphical Polkit agent the chance to authorize the
# action. A failure is reported verbatim by wm_nmcli.
WM_NMCLI_MODE=direct
fi
;;
esac
if [[ "$WM_NMCLI_MODE" == sudo ]]; then
command -v sudo >/dev/null 2>&1 || {
echo 'NetworkManager requires authorization, but sudo is unavailable. Use a graphical Polkit session or install/configure sudo.' >&2
return 1
}
echo 'NetworkManager requires scoped authorization for the named portal adapter; validating sudo before the board is flashed.' >&2
sudo -v || {
echo 'Could not validate sudo for the scoped NetworkManager portal actions.' >&2
return 1
}
fi
WM_NMCLI_AUTH_READY=yes
export WM_NMCLI_MODE WM_NMCLI_AUTH_READY WM_NMCLI_PERMISSIONS
}
wm_report_networkmanager_authorization() {
local permission value direct=yes
WM_NMCLI_PERMISSIONS="$(nmcli -t -f PERMISSION,VALUE general permissions 2>/dev/null || true)"
for permission in \
org.freedesktop.NetworkManager.wifi.scan \
org.freedesktop.NetworkManager.network-control \
org.freedesktop.NetworkManager.settings.modify.system; do
value="$(wm_nmcli_permission "$permission")"
[[ "$value" == yes ]] || direct=no
done
if [[ "$direct" == yes ]]; then
echo 'NetworkManager portal authorization: direct.'
elif [[ -z "${DISPLAY:-}" && -z "${WAYLAND_DISPLAY:-}" ]]; then
echo 'NetworkManager portal authorization: scoped sudo will be requested before a portal command flashes the board.'
else
echo 'NetworkManager portal authorization: graphical Polkit may authorize actions; set WM_NMCLI_AUTH=sudo to use scoped sudo instead.'
fi
}
wm_nmcli() {
# Only this small set of nmcli calls is elevated when the preflight selects
# sudo. The runner, artifacts, and session record remain owned by the
# invoking developer; every mutating call still names the guarded adapter
# or a generated temporary connection.
if [[ "${WM_NMCLI_MODE:-direct}" == sudo ]]; then
sudo -n true || {
echo 'The sudo authorization for scoped NetworkManager actions expired; run sudo -v and retry the portal command.' >&2
return 1
}
sudo -n -- nmcli "$@"
else
nmcli "$@"
fi
}
wm_default_route_interface() {
ip route show default 2>/dev/null | awk '/^default/{print $5; exit}'
}
wm_acquire_hardware_lock() {
local lock_file="${WM_HARDWARE_LOCK_FILE:-/tmp/wifimanager-hardware.lock}"
exec 9>"$lock_file"
flock -n 9 || {
echo "Another WiFiManager hardware task is already running; wait for it to finish." >&2
return 1
}
# All ordinary ESP portals use this gateway/subnet. Keep portal commands
# mutually exclusive even when they name different boards or adapters.
wm_harness_lock_portal_network
}
wm_require_client_adapter() {
local interface="$1" allow_takeover="$2" default_interface active_connection
local interface="$1" allow_takeover="$2" default_interface device_type active_connection
ip link show "$interface" >/dev/null 2>&1 || {
echo "Wi-Fi interface not found: $interface" >&2
return 1
@@ -37,7 +139,23 @@ wm_require_client_adapter() {
echo "Refusing to use the host default-route interface: $interface" >&2
return 1
}
active_connection="$(nmcli -g GENERAL.CONNECTION device show "$interface" 2>/dev/null || true)"
# 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
}
wm_harness_lock_wifi_adapter "$interface"
if ! active_connection="$(wm_nmcli -g GENERAL.CONNECTION device show "$interface" 2>/dev/null)"; then
echo "NetworkManager could not inspect the selected portal adapter: $interface" >&2
return 1
fi
if [[ -n "$active_connection" && "$active_connection" != "--" && "$allow_takeover" != "yes" ]]; then
echo "Client adapter $interface already has connection '$active_connection'." >&2
echo "Pass --take-over-client-adapter to replace only that adapter's connection." >&2
@@ -47,21 +165,31 @@ wm_require_client_adapter() {
wm_portal_ssid() {
case "$1" in
esp8266) printf '%s\n' 'WM Contract ESP8266' ;;
esp32) printf '%s\n' 'WM Contract ESP32' ;;
esp8266) printf '%s\n' 'WM Test Harness ESP8266' ;;
esp32) printf '%s\n' 'WM Test Harness ESP32' ;;
*) return 1 ;;
esac
}
wm_wait_for_portal_ssid() {
local interface="$1" ssid="$2" attempt
nmcli device wifi rescan ifname "$interface" >/dev/null 2>&1 || true
local interface="$1" ssid="$2" attempt advertised
if ! wm_nmcli device wifi rescan ifname "$interface"; then
echo "NetworkManager could not scan the selected portal adapter: $interface" >&2
return 1
fi
for attempt in $(seq 1 45); do
if nmcli -t -f SSID device wifi list ifname "$interface" | grep -Fxq "$ssid"; then
if ! advertised="$(wm_nmcli -t -f SSID device wifi list ifname "$interface")"; then
echo "NetworkManager could not read Wi-Fi scan results from $interface." >&2
return 1
fi
if grep -Fxq "$ssid" <<<"$advertised"; then
return 0
fi
sleep 1
nmcli device wifi rescan ifname "$interface" >/dev/null 2>&1 || true
if ! wm_nmcli device wifi rescan ifname "$interface"; then
echo "NetworkManager could not refresh Wi-Fi scan results from $interface." >&2
return 1
fi
done
echo "Portal SSID not detected on $interface: $ssid" >&2
return 1
@@ -70,36 +198,120 @@ wm_wait_for_portal_ssid() {
wm_remove_connection_by_name() {
local name="$1"
[[ -n "$name" ]] || return 0
nmcli connection down "$name" >/dev/null 2>&1 || true
nmcli connection delete "$name" >/dev/null 2>&1 || true
wm_nmcli connection down "$name" >/dev/null 2>&1 || true
if ! wm_nmcli connection delete "$name"; then
# A pending recovery record can survive an uncatchable exit before
# `connection add` ran. Only a successful complete listing which does
# not contain this generated name proves that there is nothing left to
# remove; an authorization or NetworkManager query failure must retain
# the record for an explicit later `down` command.
if wm_connection_name_is_absent "$name"; then
return 0
fi
wm_report_portal_connection_cleanup_failure
return 1
fi
}
wm_connection_name_is_absent() {
local name="$1" names
if ! names="$(wm_nmcli -t -f NAME connection show 2>/dev/null)"; then
return 1
fi
! grep -Fxq -- "$name" <<<"$names"
}
wm_connection_uuid_is_absent() {
local uuid="$1" uuids
if ! uuids="$(wm_nmcli -t -f UUID connection show 2>/dev/null)"; then
return 1
fi
! grep -Fxq -- "$uuid" <<<"$uuids"
}
wm_report_portal_connection_cleanup_failure() {
echo 'Could not remove the temporary WiFiManager portal connection. Its recovery state was retained; restore NetworkManager authorization and run ./tools/portal-hardware down.' >&2
}
wm_create_portal_connection() {
local interface="$1" ssid="$2" password="$3" 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)"
nmcli device disconnect "$interface" >/dev/null 2>&1 || true
wm_wait_for_portal_ssid "$interface" "$ssid"
if ! nmcli connection add type wifi ifname "$interface" con-name "$name" ssid "$ssid" \
# Publish the owned name before the first NetworkManager mutation. The
# caller's signal trap can then remove it throughout the pending-to-active
# state transition.
WM_PORTAL_CONNECTION_NAME="$name"
WM_PORTAL_CONNECTION_UUID=""
WM_PORTAL_CONNECTION_OWNED=yes
export WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME WM_PORTAL_CONNECTION_OWNED
# A pending record is atomically committed before the first NetworkManager
# mutation. If the host dies after that point, `down` can remove this
# exact name even before NetworkManager's UUID has been obtained.
if [[ -n "$platform" ]] && ! wm_write_state "$interface" "$platform" "" "$name"; then
WM_PORTAL_CONNECTION_OWNED=no
unset WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
return 1
fi
wm_nmcli device disconnect "$interface" >/dev/null 2>&1 || true
if ! wm_wait_for_portal_ssid "$interface" "$ssid"; then
# No `connection add` has run on this path, so this process knows that
# its pending record has no NetworkManager profile to remove. Clear it
# directly rather than requiring a privileged delete of a profile that
# cannot exist.
if ! wm_clear_state; then
wm_report_portal_connection_cleanup_failure
return 1
fi
WM_PORTAL_CONNECTION_OWNED=no
unset WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
return 1
fi
if ! wm_nmcli connection add type wifi ifname "$interface" con-name "$name" ssid "$ssid" \
ipv4.method auto ipv4.never-default yes ipv6.method ignore connection.autoconnect no >/dev/null; then
wm_cleanup_created_connection
return 1
fi
if ! nmcli connection modify "$name" wifi-sec.key-mgmt wpa-psk wifi-sec.psk "$password"; then
wm_remove_connection_by_name "$name"
return 1
fi
if ! nmcli connection up "$name" ifname "$interface"; then
wm_remove_connection_by_name "$name"
return 1
fi
uuid="$(nmcli -g connection.uuid connection show "$name")"
# NetworkManager creates the UUID at `connection add`, before any later
# configuration or association step. Capture it immediately so cleanup
# has an exact identifier throughout the remaining critical section.
uuid="$(wm_nmcli -g connection.uuid connection show "$name")"
if [[ -z "$uuid" || "$uuid" == "--" ]]; then
wm_remove_connection_by_name "$name"
wm_cleanup_created_connection
echo "NetworkManager did not return a UUID for the portal connection." >&2
return 1
fi
WM_PORTAL_CONNECTION_UUID="$uuid"
WM_PORTAL_CONNECTION_NAME="$name"
export WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
export WM_PORTAL_CONNECTION_UUID
# Atomically replace the pending record with the exact UUID before
# association. A normal signal trap removes it immediately; the durable
# record also makes `down` useful after an uncatchable host termination.
if [[ -n "$platform" ]] && ! wm_write_state "$interface" "$platform" "$uuid" "$name"; then
wm_cleanup_created_connection
return 1
fi
if ! wm_nmcli connection modify "$name" wifi-sec.key-mgmt wpa-psk wifi-sec.psk "$password"; then
wm_cleanup_created_connection
return 1
fi
# The ordinary portal runner must leave its disposable connection inert so
# it cannot surprise a developer later. The OTA runner is different: the
# board intentionally disappears after POST /u, then returns as the same
# AP, so its one owned connection needs to reassociate autonomously for the
# browser and host-side B checks. Cleanup still deletes this exact profile.
if [[ "$reconnect_after_drop" == yes ]] && ! wm_nmcli connection modify "$name" connection.autoconnect yes; then
wm_cleanup_created_connection
return 1
fi
if ! wm_nmcli connection up "$name" ifname "$interface"; then
wm_cleanup_created_connection
return 1
fi
}
wm_verify_portal_route() {
@@ -112,10 +324,38 @@ wm_verify_portal_route() {
}
wm_remove_connection() {
local uuid="$1"
[[ -n "$uuid" ]] || return 0
nmcli connection down uuid "$uuid" >/dev/null 2>&1 || true
nmcli connection delete uuid "$uuid" >/dev/null 2>&1 || true
local uuid="${1:-}" name="${2:-}"
[[ -n "$uuid" || -n "$name" ]] || return 0
if [[ -n "$uuid" ]]; then
wm_nmcli connection down uuid "$uuid" >/dev/null 2>&1 || true
if ! wm_nmcli connection delete uuid "$uuid"; then
# An active UUID means this runner did create a profile. Retain
# the exact recovery state unless the same authorized
# NetworkManager view positively proves another actor already
# removed it. A failed listing (including an expired scoped sudo
# ticket) is never treated as absence.
if ! wm_connection_uuid_is_absent "$uuid"; then
wm_report_portal_connection_cleanup_failure
return 1
fi
fi
elif [[ -n "$name" ]]; then
wm_remove_connection_by_name "$name" || return 1
fi
}
wm_cleanup_created_connection() {
# Only remove the connection this process named. This is intentionally
# separate from `finish_portal_session`, which reads a retained state file
# for an explicit later `down` command.
[[ "${WM_PORTAL_CONNECTION_OWNED:-no}" == "yes" ]] || return 0
wm_remove_connection "${WM_PORTAL_CONNECTION_UUID:-}" "${WM_PORTAL_CONNECTION_NAME:-}" || return 1
if ! wm_clear_state; then
echo 'The temporary WiFiManager portal connection was removed, but its recovery state could not be cleared.' >&2
return 1
fi
WM_PORTAL_CONNECTION_OWNED=no
unset WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
}
wm_state_file() {
@@ -132,16 +372,25 @@ wm_require_no_active_session() {
}
wm_write_state() {
local interface="$1" platform="$2" uuid="$3" name="$4" root file
local interface="$1" platform="$2" uuid="$3" name="$4" root file temporary_file state
root="$(wm_portal_state_root)"
file="$(wm_state_file)"
install -d -m 700 "$root"
(
umask 077
printf 'WM_PORTAL_INTERFACE=%s\nWM_PORTAL_PLATFORM=%s\nWM_PORTAL_CONNECTION_UUID=%s\nWM_PORTAL_CONNECTION_NAME=%s\n' \
"$interface" "$platform" "$uuid" "$name" >"$file"
)
chmod 600 "$file"
if [[ -n "$uuid" ]]; then
state="active"
else
state="pending"
fi
temporary_file="$(mktemp "$root/.session.env.XXXXXX")" || return 1
if ! {
printf 'WM_PORTAL_STATE=%s\nWM_PORTAL_INTERFACE=%s\nWM_PORTAL_PLATFORM=%s\nWM_PORTAL_CONNECTION_UUID=%s\nWM_PORTAL_CONNECTION_NAME=%s\n' \
"$state" "$interface" "$platform" "$uuid" "$name"
} >"$temporary_file"; then
rm -f "$temporary_file"
return 1
fi
chmod 600 "$temporary_file"
mv -f "$temporary_file" "$file"
}
wm_load_state() {
@@ -155,9 +404,10 @@ wm_load_state() {
WM_PORTAL_PLATFORM=""
WM_PORTAL_CONNECTION_UUID=""
WM_PORTAL_CONNECTION_NAME=""
WM_PORTAL_STATE=""
while IFS='=' read -r key value; do
case "$key" in
WM_PORTAL_INTERFACE|WM_PORTAL_PLATFORM|WM_PORTAL_CONNECTION_UUID|WM_PORTAL_CONNECTION_NAME)
WM_PORTAL_STATE|WM_PORTAL_INTERFACE|WM_PORTAL_PLATFORM|WM_PORTAL_CONNECTION_UUID|WM_PORTAL_CONNECTION_NAME)
printf -v "$key" '%s' "$value"
;;
'') ;;
@@ -167,15 +417,38 @@ wm_load_state() {
;;
esac
done <"$file"
[[ -n "$WM_PORTAL_INTERFACE" && -n "$WM_PORTAL_PLATFORM" && -n "$WM_PORTAL_CONNECTION_UUID" && -n "$WM_PORTAL_CONNECTION_NAME" ]] || {
[[ -n "$WM_PORTAL_INTERFACE" && -n "$WM_PORTAL_PLATFORM" && -n "$WM_PORTAL_CONNECTION_NAME" ]] || {
echo "Incomplete WiFiManager portal session state." >&2
return 1
}
export WM_PORTAL_INTERFACE WM_PORTAL_PLATFORM WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
# State files written before this recovery format carried an exact UUID
# but no phase. Continue to accept those retained sessions as active.
if [[ -z "$WM_PORTAL_STATE" && -n "$WM_PORTAL_CONNECTION_UUID" ]]; then
WM_PORTAL_STATE="active"
fi
case "$WM_PORTAL_STATE" in
pending)
[[ -z "$WM_PORTAL_CONNECTION_UUID" ]] || {
echo "Invalid WiFiManager pending portal session state." >&2
return 1
}
;;
active)
[[ -n "$WM_PORTAL_CONNECTION_UUID" ]] || {
echo "Incomplete WiFiManager active portal session state." >&2
return 1
}
;;
*)
echo "Invalid WiFiManager portal session state." >&2
return 1
;;
esac
export WM_PORTAL_STATE WM_PORTAL_INTERFACE WM_PORTAL_PLATFORM WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
}
wm_clear_state() {
local file
file="$(wm_state_file)"
rm -f "$file"
rm -f -- "$file"
}
+762 -29
View File
@@ -2,8 +2,17 @@
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=tools/lib/platformio.sh
source "$root/tools/lib/platformio.sh"
# shellcheck source=tools/lib/portal-hardware-session.sh
source "$root/tools/lib/portal-hardware-session.sh"
# shellcheck source=tools/lib/ota-fixture-identity.sh
source "$root/tools/lib/ota-fixture-identity.sh"
# Portal tests exclusively own their portal network, selected Wi-Fi adapter,
# and named serial port. Avoid consuming every host core unless the developer
# explicitly opts in.
export PLATFORMIO_RUN_JOBS="${PLATFORMIO_RUN_JOBS:-2}"
usage() {
cat <<'USAGE' >&2
@@ -13,12 +22,22 @@ Usage:
--client-interface IFACE [--take-over-client-adapter] [--output DIRECTORY]
./tools/portal-hardware run --platform esp8266|esp32 --port /dev/serial/by-id/... \
--client-interface IFACE [--take-over-client-adapter] [--keep] \
[--browser auto|skip] [--station-env PATH] [--output DIRECTORY]
[--browser auto|skip] [--station-env PATH] [--output DIRECTORY] \
[--capture-readme-media] [--custom-parameter-stress]
./tools/portal-hardware ota --platform esp8266|esp32 --port /dev/serial/by-id/... \
--client-interface IFACE [--take-over-client-adapter] [--keep] \
[--output DIRECTORY]
./tools/portal-hardware down
Only the named client interface may be disconnected or reconfigured. The tool
refuses the host default-route interface and preserves the created connection
in a 0600 state file until `down` or normal `run` cleanup.
in a 0600 state file until `down` or normal `run`/`ota` cleanup.
`ota` is an opt-in physical A/B test. It erases the explicitly selected test
board's flash, serial-flashes image A, then uses the real portal browser form
to upload image B. It requires the automatic restart and two independent
B-marker responses; a manual reset never makes the test pass. On success the
selected board remains on B in the portal-only fixture.
USAGE
exit 2
}
@@ -35,6 +54,24 @@ keep="no"
browser="auto"
station_env=""
output_dir=""
capture_readme_media="no"
custom_parameter_stress="no"
ota_environment_name=""
ota_firmware_a=""
ota_firmware_b=""
ota_browser_prebuilt="no"
ota_serial_capture_pid=""
ota_serial_capture_log=""
ota_serial_capture_ready=""
ota_serial_capture_status=""
station_handoff_env_file=""
station_handoff_fixture_restore_needed="no"
# `wm_create_portal_connection` sets these before NetworkManager mutates the
# secondary adapter. Keeping the ownership record in-process means the
# signal trap can clean up the exact pending or active connection immediately.
WM_PORTAL_CONNECTION_UUID=""
WM_PORTAL_CONNECTION_NAME=""
WM_PORTAL_CONNECTION_OWNED="no"
while [[ $# -gt 0 ]]; do
case "$1" in
--platform) [[ $# -ge 2 ]] || usage; platform="$2"; shift 2 ;;
@@ -45,6 +82,8 @@ while [[ $# -gt 0 ]]; do
--browser) [[ $# -ge 2 ]] || usage; browser="$2"; shift 2 ;;
--station-env) [[ $# -ge 2 ]] || usage; station_env="$2"; shift 2 ;;
--output) [[ $# -ge 2 ]] || usage; output_dir="$2"; shift 2 ;;
--capture-readme-media) capture_readme_media="yes"; shift ;;
--custom-parameter-stress) custom_parameter_stress="yes"; shift ;;
*) usage ;;
esac
done
@@ -52,19 +91,63 @@ done
require_common() {
wm_require ip
wm_require nmcli
wm_require pio
wm_pio_available
wm_require docker
wm_require curl
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
}
}
pio_for_portal_environment() {
local environment="$1"
shift
case "$environment" in
# Keep the maintained Core 3.3.11 graph in the same persistent cache
# as the ESP32 A/B fixture. It is never cleared by this test harness.
esp32|esp32_ota)
;;
*)
wm_pio "$@"
return
;;
esac
local core_dir packages_dir cache_dir
core_dir="${WIFIMANAGER_PLATFORMIO_CORE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/arduino-framework-platformio/core-3.3.11}"
packages_dir="${WIFIMANAGER_PLATFORMIO_PACKAGES_DIR:-$core_dir/packages}"
cache_dir="${WIFIMANAGER_PLATFORMIO_CACHE_DIR:-$core_dir/cache}"
install -d -m 700 "$core_dir" "$packages_dir" "$cache_dir"
PLATFORMIO_CORE_DIR="$core_dir" PLATFORMIO_PACKAGES_DIR="$packages_dir" \
PLATFORMIO_CACHE_DIR="$cache_dir" wm_pio "$@"
}
prepare_output_dir() {
if [[ -z "$output_dir" ]]; then
output_dir="$(wm_portal_state_root)/runs/$(date -u +%Y%m%dT%H%M%SZ)-$platform"
if [[ "$capture_readme_media" == "yes" ]]; then
output_dir="$root/artifacts/readme-media/$(date -u +%Y%m%dT%H%M%SZ)-$platform"
else
output_dir="$(wm_portal_state_root)/runs/$(date -u +%Y%m%dT%H%M%SZ)-$platform"
fi
fi
install -d -m 700 "$output_dir"
output_dir="$(cd "$output_dir" && pwd)"
}
prepare_ota_identity_input() {
# The generated A/B marker belongs to this run, never to shared fixture
# source. Keeping it with the private artifacts prevents cross-run drift.
export WIFIMANAGER_OTA_IDENTITY_DIR="$output_dir/ota-fixture-input"
install -d -m 700 "$WIFIMANAGER_OTA_IDENTITY_DIR"
}
validate_run_arguments() {
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
[[ -n "$client_interface" ]] || usage
@@ -77,45 +160,623 @@ validate_run_arguments() {
echo "Station environment file is not readable: $station_env" >&2
exit 1
}
if [[ -n "$station_env" ]]; then
[[ "$command_name" == "run" ]] || {
echo "--station-env is supported only by the browser-backed run command." >&2
exit 2
}
[[ "$browser" == "auto" ]] || {
echo "--station-env requires --browser auto so the real hand-off is exercised." >&2
exit 2
}
[[ "$capture_readme_media" == "no" ]] || {
echo "--station-env cannot be combined with README media capture." >&2
exit 2
}
fi
if [[ "$capture_readme_media" == "yes" ]]; then
[[ "$command_name" == "run" && "$platform" == "esp32" ]] || {
echo "--capture-readme-media is supported only by run --platform esp32" >&2
exit 2
}
[[ "$browser" == "auto" ]] || {
echo "--capture-readme-media requires --browser auto" >&2
exit 2
}
fi
if [[ "$custom_parameter_stress" == "yes" ]]; then
[[ "$command_name" == "run" && "$platform" == "esp8266" ]] || {
echo "--custom-parameter-stress is supported only by run --platform esp8266" >&2
exit 2
}
[[ "$browser" == "auto" ]] || {
echo "--custom-parameter-stress requires --browser auto" >&2
exit 2
}
fi
if [[ "$command_name" == "ota" ]]; then
[[ "$browser" == "auto" ]] || {
echo "OTA coverage always uses the browser; --browser skip is not supported." >&2
exit 2
}
[[ -z "$station_env" ]] || {
echo "OTA coverage is portal-only and does not accept --station-env." >&2
exit 2
}
[[ "$capture_readme_media" == "no" && "$custom_parameter_stress" == "no" ]] || {
echo "OTA coverage cannot be combined with README media or parameter-stress modes." >&2
exit 2
}
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_scan_ready; then
echo 'The clean portal-only fixture did not return after station hand-off cleanup.' >&2
return 1
fi
station_handoff_fixture_restore_needed="no"
}
write_readme_media_manifest() {
local media_dir="$output_dir/readme-media"
local required
for required in portal-tour.gif portal-overview.png portal-wifi-settings.png; do
[[ -s "$media_dir/$required" ]] || {
echo "README media output is incomplete: $media_dir/$required" >&2
return 1
}
done
printf '{"schema":1,"project":"WiFiManager","kind":"readme-media","status":"passed","platform":"esp32","created_at":"%s"}\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$media_dir/manifest.json"
chmod 600 "$media_dir/manifest.json"
}
portal_scan_status() {
curl --interface "$client_interface" --connect-timeout 3 --max-time 5 \
--silent --show-error --fail http://192.168.4.1/api/wifi/scan-status 2>/dev/null
}
wait_for_portal_http_ready() {
# The AP can be visible before the portal server has completed startup.
# OTA requires the update UI and its HTTP route; it does not require an
# unrelated background scan to have succeeded.
local attempt response
for attempt in $(seq 1 45); do
response="$(portal_scan_status || true)"
if [[ "$response" == *'"state"'* ]]; then
return 0
fi
sleep 1
done
echo 'Portal HTTP API did not become ready within 45 seconds.' >&2
return 1
}
wait_for_portal_scan_ready() {
# The normal portal UI suite exercises the automatic scan and still
# requires a usable result. Keep that coverage separate from HTTP OTA.
local attempt response
for attempt in $(seq 1 45); do
response="$(portal_scan_status || true)"
if [[ "$response" == *'"state":"complete"'* && "$response" == *'"results_valid":true'* ]]; then
return 0
fi
if [[ "$response" == *'"state":"failed"'* || "$response" == *'"state":"timeout"'* ]]; then
echo "Portal background scan did not become ready: $response" >&2
return 1
fi
sleep 1
done
echo 'Portal did not complete its initial Wi-Fi scan within 45 seconds.' >&2
return 1
}
report_initial_portal_scan_status() {
local response
response="$(portal_scan_status || true)"
if [[ -n "$response" ]]; then
printf 'Initial portal scan status: %s\n' "$response"
else
echo 'Initial portal scan status was unavailable after portal startup.' >&2
fi
}
start_portal_session() {
local ssid
local environment="${1:-$platform}" erase_before_upload="${2:-no}" expected_a_artifact="${3:-}" capture_ota_serial="${4:-no}" require_scan="${5:-yes}" ssid reconnect_after_drop="no"
validate_run_arguments
require_common
wm_acquire_hardware_lock
wm_require_no_active_session
# Choose the real NetworkManager authorization path before inspecting the
# secondary adapter. A headless shell must not mistake a denied inspection
# for an idle adapter and then replace a connection it does not own.
wm_prepare_networkmanager_authorization
wm_require_client_adapter "$client_interface" "$takeover"
wm_harness_lock_serial_port "$port"
prepare_output_dir
ssid="$(wm_portal_ssid "$platform")"
pio run -d "$root/test/portal-harness" -e "$platform" -t upload --upload-port "$port"
if ! wm_create_portal_connection "$client_interface" "$ssid" "default1"; then
if [[ -n "$expected_a_artifact" ]]; then
# B was built most recently. Restore the generated A identity before
# PlatformIO verifies and serial-flashes the immutable A artifact.
wm_write_ota_fixture_identity A
pio_for_portal_environment "$environment" run -d "$root/test/portal-harness" -e "$environment"
ota_assert_a_artifact_matches_build "$expected_a_artifact"
fi
if [[ "$erase_before_upload" == "yes" ]]; then
# OTA selection metadata must not survive from a previous fixture run:
# otherwise a bootloader could select a stale app slot instead of A.
pio_for_portal_environment "$environment" run -d "$root/test/portal-harness" -e "$environment" -t erase --upload-port "$port"
fi
if [[ -n "$expected_a_artifact" ]]; then
ota_assert_a_artifact_matches_build "$expected_a_artifact"
fi
pio_for_portal_environment "$environment" run -d "$root/test/portal-harness" -e "$environment" -t upload --upload-port "$port"
if [[ "$capture_ota_serial" == yes ]]; then
# Attach only after PlatformIO releases the serial port. Capture the
# complete A boot, including portal start and DHCP, before the adapter
# is asked to associate with the fixture AP.
start_ota_serial_capture
fi
if [[ -n "$expected_a_artifact" ]]; then
ota_assert_a_artifact_matches_build "$expected_a_artifact"
fi
# OTA and the optional station hand-off both deliberately reboot the test
# board. Let only those owned disposable profiles reassociate after that
# outage; ordinary portal work keeps the secondary adapter inert after its
# current run.
if [[ "$capture_ota_serial" == yes || -n "$station_env" ]]; then
reconnect_after_drop=yes
fi
if ! wm_create_portal_connection "$client_interface" "$ssid" "default1" "$platform" "$reconnect_after_drop"; then
return 1
fi
if ! wm_verify_portal_route "$client_interface"; then
wm_remove_connection "$WM_PORTAL_CONNECTION_UUID"
wm_cleanup_created_connection
return 1
fi
if ! wm_write_state "$client_interface" "$platform" "$WM_PORTAL_CONNECTION_UUID" "$WM_PORTAL_CONNECTION_NAME"; then
wm_remove_connection "$WM_PORTAL_CONNECTION_UUID"
if [[ "$require_scan" == yes ]]; then
wait_for_portal_scan_ready || {
wm_cleanup_created_connection
return 1
}
elif ! wait_for_portal_http_ready; then
wm_cleanup_created_connection
return 1
fi
[[ "$require_scan" == yes ]] || report_initial_portal_scan_status
printf 'Portal connected on %s. Artifacts: %s\n' "$client_interface" "$output_dir"
}
finish_portal_session() {
wm_load_state
wm_remove_connection "$WM_PORTAL_CONNECTION_UUID"
wm_clear_state
ota_environment() {
printf '%s_ota\n' "$platform"
}
ota_marker_field() {
local response="$1" field="$2"
case "$field" in
marker|freeSketchSpace)
# The marker endpoint is deliberately fixture-only JSON. Parse it
# as JSON instead of maintaining a second, fragile copy of its
# wire format in a regular expression.
python3 -c '
import json
import sys
try:
value = json.load(sys.stdin)[sys.argv[1]]
except (json.JSONDecodeError, KeyError, TypeError):
raise SystemExit(1)
if isinstance(value, bool) or not isinstance(value, (str, int)):
raise SystemExit(1)
print(value)
' "$field" <<<"$response" 2>/dev/null || true
;;
*)
echo "Unknown OTA marker field: $field" >&2
return 2
;;
esac
}
ota_fetch_marker() {
curl --interface "$client_interface" --connect-timeout 1 --max-time 2 \
--silent --show-error http://192.168.4.1/api/test/firmware-marker 2>/dev/null
}
wait_for_ota_marker() {
local expected="$1" deadline response marker
deadline=$((SECONDS + 75))
while (( SECONDS < deadline )); do
response="$(ota_fetch_marker || true)"
marker="$(ota_marker_field "$response" marker)"
if [[ "$marker" == "$expected" ]]; then
printf '%s\n' "$response"
return 0
fi
sleep 0.5
done
echo "Portal OTA fixture did not report marker '$expected' within 75 seconds." >&2
return 1
}
ota_esp32_partition_row() {
local partition="$1" table="$root/test/portal-harness/partitions/esp32_ota_4m_no_fs.csv"
[[ -r "$table" ]] || {
echo "ESP32 OTA partition table is unreadable: $table" >&2
return 1
}
awk -F, -v partition="$partition" '
function trim(value) {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
return value
}
/^[[:space:]]*#/ || NF < 5 { next }
trim($1) == partition {
printf "%s\t%s\t%s\t%s\n", trim($2), trim($3), trim($4), trim($5)
exit
}
' "$table"
}
ota_esp32_require_partition() {
local name="$1" expected_type="$2" expected_subtype="$3" expected_offset="$4" expected_size="$5"
local row type subtype offset size
row="$(ota_esp32_partition_row "$name")"
IFS=$'\t' read -r type subtype offset size <<<"$row"
[[ "$type" == "$expected_type" && "$subtype" == "$expected_subtype" && \
"$offset" == "$expected_offset" && "$size" == "$expected_size" ]] || {
echo "ESP32 OTA partition '$name' must be $expected_type/$expected_subtype at $expected_offset with size $expected_size; found '${row:-missing}'." >&2
return 1
}
}
ota_esp32_slot_size() {
local table="$root/test/portal-harness/partitions/esp32_ota_4m_no_fs.csv"
local value_a value_b
[[ -r "$table" ]] || {
echo "ESP32 OTA partition table is unreadable: $table" >&2
return 1
}
ota_esp32_require_partition nvs data nvs 0x9000 0x5000
ota_esp32_require_partition otadata data ota 0xe000 0x2000
ota_esp32_require_partition app0 app ota_0 0x10000 0x1F0000
ota_esp32_require_partition app1 app ota_1 0x200000 0x1F0000
value_a="$(ota_esp32_partition_row app0 | awk -F '\t' '{ print $4 }')"
value_b="$(ota_esp32_partition_row app1 | awk -F '\t' '{ print $4 }')"
[[ "$value_a" =~ ^0x[0-9A-Fa-f]+$ && "$value_a" == "$value_b" ]] || {
echo "ESP32 OTA app slots must have equal hexadecimal capacities: app0=$value_a app1=$value_b" >&2
return 1
}
printf '%d\n' "$((value_b))"
}
ota_assert_firmware_fits() {
local firmware="$1" capacity="$2" label="$3" image_size
[[ -n "$firmware" && -s "$firmware" ]] || {
echo "OTA $label firmware artifact is missing." >&2
return 1
}
[[ "$capacity" =~ ^[0-9]+$ && "$capacity" -gt 0 ]] || {
echo "OTA capacity is invalid: $capacity" >&2
return 1
}
image_size="$(wc -c < "$firmware")"
if (( image_size > capacity )); then
echo "OTA $label image is too large: ${image_size} bytes exceeds ${capacity} bytes." >&2
return 1
fi
printf 'OTA %s image fits the available update space: %s <= %s bytes\n' "$label" "$image_size" "$capacity"
}
build_ota_image() {
local image="$1" source artifact
case "$image" in
A)
artifact="$output_dir/${platform}-portal-ota-a.bin"
ota_firmware_a="$artifact"
;;
B)
artifact="$output_dir/${platform}-portal-ota-b.bin"
ota_firmware_b="$artifact"
;;
*)
echo "Unknown portal OTA fixture image: $image" >&2
return 2
;;
esac
wm_write_ota_fixture_identity "$image"
pio_for_portal_environment "$ota_environment_name" run -d "$root/test/portal-harness" -e "$ota_environment_name"
source="$root/test/portal-harness/.pio/build/$ota_environment_name/firmware.bin"
[[ -s "$source" ]] || {
echo "PlatformIO did not produce portal OTA image $image." >&2
return 1
}
install -m 600 "$source" "$artifact"
}
prepare_ota_firmware() {
local capacity
ota_environment_name="$(ota_environment)"
wm_lock_ota_fixture_environment "$ota_environment_name"
# Capture immutable A before rebuilding the same environment as B. The
# generated header is the only changed input, so PlatformIO reuses library
# objects while the browser still mounts a distinct B artifact read-only.
build_ota_image A
build_ota_image B
if cmp -s "$ota_firmware_a" "$ota_firmware_b"; then
echo "PlatformIO produced identical OTA A and B images." >&2
return 1
fi
if [[ "$platform" == "esp32" ]]; then
"$root/tools/check-ota-partitions.sh"
capacity="$(ota_esp32_slot_size)"
ota_assert_firmware_fits "$ota_firmware_a" "$capacity" A
ota_assert_firmware_fits "$ota_firmware_b" "$capacity" B
fi
}
ota_assert_a_artifact_matches_build() {
local expected_artifact="$1" source
source="$root/test/portal-harness/.pio/build/$ota_environment_name/firmware.bin"
[[ -s "$expected_artifact" && -s "$source" ]] || {
echo "Prepared OTA A artifact or PlatformIO build output is missing." >&2
return 1
}
cmp -s "$expected_artifact" "$source" || {
echo "PlatformIO's A build no longer matches the immutable artifact prepared before serial flashing." >&2
return 1
}
}
validate_running_ota_capacity() {
local marker_response free_sketch_space capacity
[[ "$platform" == "esp8266" ]] || return 0
marker_response="$(wait_for_ota_marker A)"
free_sketch_space="$(ota_marker_field "$marker_response" freeSketchSpace)"
[[ "$free_sketch_space" =~ ^[0-9]+$ && "$free_sketch_space" -gt 4096 ]] || {
echo "ESP8266 fixture did not report usable free sketch space." >&2
return 1
}
# Match WiFiManagerHandlers::handleUpdating(): Update.begin receives this
# aligned value, not the raw ESP.getFreeSketchSpace() number.
capacity=$(( (free_sketch_space - 0x1000) & 0xFFFFF000 ))
ota_assert_firmware_fits "$ota_firmware_b" "$capacity" B
}
configure_ota_harness_environment() {
export PORTAL_ARTIFACT_DIR="$output_dir"
export LOCAL_UID="$(id -u)"
export LOCAL_GID="$(id -g)"
export PORTAL_BROWSER_MODE=auto
# OTA is its own test harness. Do not inherit a developer's unrelated media,
# stress, target, or test-selection setting into a physical firmware run.
export PORTAL_HARNESS_DOCKER_TARGET=portal-harness
export PORTAL_CAPTURE_README_MEDIA=0
export PORTAL_CUSTOM_PARAMETER_STRESS=0
export PORTAL_TEST_FILE=tests/ota.spec.js
export PORTAL_PLATFORM="$platform"
export PORTAL_OTA_FIRMWARE_HOST="$ota_firmware_b"
export PORTAL_OTA_INITIAL_MARKER=A
export PORTAL_OTA_EXPECTED_MARKER=B
}
ota_compose_files() {
printf '%s\n' \
-f "$root/tests/portal-harness/compose.yaml" \
-f "$root/tests/portal-harness/compose.ota.yaml"
}
prepare_ota_harness_image() {
local -a compose_files
configure_ota_harness_environment
mapfile -t compose_files < <(ota_compose_files)
# Build before A is flashed. A cold Playwright build can otherwise consume
# the finite portal window before the browser has connected.
docker compose "${compose_files[@]}" build portal-harness
ota_browser_prebuilt=yes
}
run_ota_harness() {
local -a compose_files
configure_ota_harness_environment
mapfile -t compose_files < <(ota_compose_files)
[[ "$ota_browser_prebuilt" == yes ]] || {
echo "Portal OTA browser image was not prepared before A was flashed." >&2
return 1
}
docker compose "${compose_files[@]}" run --rm portal-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" \
--deadline-seconds 600 \
>>"$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
}
}
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"
}
finish_portal_session() {
# A normal run already owns an in-process record. `down` starts fresh, so
# load its retained exact record and select the scoped authorization path
# before attempting deletion.
if [[ "${WM_PORTAL_CONNECTION_OWNED:-no}" != "yes" ]]; then
wm_load_state
wm_prepare_networkmanager_authorization
WM_PORTAL_CONNECTION_OWNED=yes
fi
wm_cleanup_created_connection
}
cleanup_portal_session() {
local status="${1:-$?}"
# A signal must never fall through into the rest of the hardware run, and
# a recursive EXIT trap must not obscure the original status.
trap - EXIT HUP INT TERM
stop_ota_serial_capture || true
wm_remove_ota_fixture_identity
if ! restore_station_handoff_fixture; then
(( status != 0 )) || status=1
fi
if ! remove_station_handoff_env; then
(( status != 0 )) || status=1
fi
if [[ "${WM_PORTAL_CONNECTION_OWNED:-no}" == "yes" ]] && \
{ (( status != 0 )) || { [[ "$command_name" != "up" && "$keep" != "yes" ]]; }; }; then
if ! wm_cleanup_created_connection; then
# Preserve an existing run failure, but never turn an otherwise
# successful browser/OTA run into a claimed pass when its owned
# NetworkManager connection could not be removed.
(( status != 0 )) || status=1
fi
fi
exit "$status"
}
trap 'cleanup_portal_session "$?"' EXIT
trap 'cleanup_portal_session 129' HUP
trap 'cleanup_portal_session 130' INT
trap 'cleanup_portal_session 143' TERM
case "$command_name" in
doctor)
[[ -n "$client_interface" ]] || usage
require_common
wm_acquire_hardware_lock
# Doctor is read-only, but it must validate the same authorization
# path as a real portal command before it calls an adapter safe.
wm_prepare_networkmanager_authorization
wm_require_client_adapter "$client_interface" "$takeover"
wm_report_networkmanager_authorization
printf 'Portal hardware prerequisites are ready. Main route is untouched; client adapter: %s\n' "$client_interface"
;;
up)
@@ -124,31 +785,103 @@ case "$command_name" in
down)
[[ -z "$platform$port$client_interface$station_env$output_dir" ]] || usage
wm_acquire_hardware_lock
finish_portal_session
if ! finish_portal_session; then
trap - EXIT HUP INT TERM
exit 1
fi
echo 'Portal client connection removed.'
;;
run)
start_portal_session
cleanup() {
if [[ "$keep" != "yes" ]]; then
finish_portal_session || true
fi
}
trap cleanup EXIT INT TERM
export PORTAL_ARTIFACT_DIR="$output_dir"
export LOCAL_UID="$(id -u)"
export LOCAL_GID="$(id -g)"
export PORTAL_BROWSER_MODE="$browser"
compose_files=(-f "$root/tests/portal-contract/compose.yaml")
if [[ -n "$station_env" ]]; then
export PORTAL_STATION_ENV_HOST="$(cd "$(dirname "$station_env")" && pwd)/$(basename "$station_env")"
compose_files+=(-f "$root/tests/portal-contract/compose.station.yaml")
# A normal run is the full portal suite; do not let a prior OTA shell
# environment restrict it to one spec.
export PORTAL_TEST_FILE=''
export PORTAL_CUSTOM_PARAMETER_STRESS=0
if [[ "$custom_parameter_stress" == "yes" ]]; then
export PORTAL_CUSTOM_PARAMETER_STRESS=1
fi
# The contract source is copied into the image; rebuild with Docker cache so
export PORTAL_PLATFORM="$platform"
if [[ "$capture_readme_media" == "yes" ]]; then
export PORTAL_CAPTURE_README_MEDIA=1
export PORTAL_HARNESS_DOCKER_TARGET=media
else
export PORTAL_CAPTURE_README_MEDIA=0
export PORTAL_HARNESS_DOCKER_TARGET=portal-harness
fi
compose_files=(-f "$root/tests/portal-harness/compose.yaml")
if [[ -n "$station_env" ]]; then
prepare_station_handoff_env
compose_files+=(-f "$root/tests/portal-harness/compose.station.yaml")
# Once the browser can receive the private station credentials,
# every exit path must return the board to the fixture's clean
# portal-only state. The EXIT trap covers a browser failure; the
# explicit success-path call below keeps a cleanup failure from
# being announced as a passing test.
station_handoff_fixture_restore_needed=yes
fi
# The test-harness source is copied into the image; rebuild with Docker cache so
# this invocation always tests the checked-out files, not a stale image.
docker compose "${compose_files[@]}" build portal-contract
docker compose "${compose_files[@]}" run --rm portal-contract
printf 'Portal contract passed. Artifacts: %s\n' "$output_dir"
docker compose "${compose_files[@]}" build portal-harness
docker compose "${compose_files[@]}" run --rm portal-harness
restore_station_handoff_fixture
remove_station_handoff_env
if [[ "$capture_readme_media" == "yes" ]]; then
write_readme_media_manifest
fi
if [[ "$keep" != "yes" ]] && ! finish_portal_session; then
trap - EXIT HUP INT TERM
exit 1
fi
printf 'Portal test harness passed. Artifacts: %s\n' "$output_dir"
if [[ "$keep" == "yes" ]]; then
printf 'Portal session remains connected; run ./tools/portal-hardware down when finished.\n'
fi
;;
ota)
# Acquire the same board/adapter lock before *any* A/B preparation.
# Otherwise two OTA invocations can collide in .pio or create a
# same-second artifact directory before either reaches serial flash.
validate_run_arguments
require_common
wm_acquire_hardware_lock
wm_require_no_active_session
wm_prepare_networkmanager_authorization
wm_require_client_adapter "$client_interface" "$takeover"
require_ota_serial_capture
prepare_output_dir
prepare_ota_identity_input
prepare_ota_firmware
prepare_ota_harness_image
start_portal_session "$ota_environment_name" yes "$ota_firmware_a" yes no
# A must be visible through the real portal before a B upload can be
# meaningful. On ESP8266 the marker also reports the actual active
# sketch-space limit, which is checked before the browser sees B.
wait_for_ota_marker A >/dev/null
validate_running_ota_capacity
run_ota_harness
# The browser test harness has already required an outage and two B
# observations. Repeat the host-side observation after the container
# exits so the named secondary adapter is also proven to rediscover
# the returned AP and see B. This is AP routing, not a LAN IP shortcut.
wm_wait_for_portal_ssid "$client_interface" "$(wm_portal_ssid "$platform")"
wm_verify_portal_route "$client_interface"
wait_for_ota_marker B >/dev/null
require_ota_serial_capture_running
sleep 1
wait_for_ota_marker B >/dev/null
require_ota_serial_capture_running
stop_ota_serial_capture
if [[ "$keep" != "yes" ]] && ! finish_portal_session; then
trap - EXIT HUP INT TERM
exit 1
fi
printf 'Portal HTTP OTA test harness passed. Artifacts: %s\n' "$output_dir"
if [[ "$keep" == "yes" ]]; then
printf 'Portal session remains connected; run ./tools/portal-hardware down when finished.\n'
fi
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source_dir=""
replace="no"
usage() {
cat <<'EOF' >&2
Usage: ./tools/promote-readme-media --from ARTIFACT_DIRECTORY --replace
Copies approved WiFiManager README media from a successful ESP32 capture. The
source directory is the run directory containing readme-media/manifest.json.
EOF
exit 2
}
while [[ $# -gt 0 ]]; do
case "$1" in
--from) [[ $# -ge 2 ]] || usage; source_dir="$2"; shift 2 ;;
--replace) replace="yes"; shift ;;
*) usage ;;
esac
done
[[ -n "$source_dir" && "$replace" == "yes" ]] || usage
source_dir="$(cd "$source_dir" && pwd)"
media_dir="$source_dir/readme-media"
manifest="$media_dir/manifest.json"
[[ -r "$manifest" ]] || { echo "Missing README media manifest: $manifest" >&2; exit 1; }
grep -Fq '"project":"WiFiManager"' "$manifest" &&
grep -Fq '"kind":"readme-media"' "$manifest" &&
grep -Fq '"status":"passed"' "$manifest" &&
grep -Fq '"platform":"esp32"' "$manifest" || {
echo "The media manifest is not a successful WiFiManager ESP32 capture." >&2
exit 1
}
target_dir="$root/docs/assets/readme"
validate_asset() {
local source_file="$1" expected_type="$2" max_bytes="$3"
[[ -s "$source_file" ]] || { echo "Missing media asset: $source_file" >&2; exit 1; }
[[ "$(file --brief --mime-type "$source_file")" == "$expected_type" ]] || {
echo "Unexpected media type for $source_file" >&2
exit 1
}
(( $(wc -c < "$source_file") <= max_bytes )) || {
echo "README media exceeds its size limit: $source_file" >&2
exit 1
}
}
# Verify the complete candidate before changing any tracked README asset.
validate_asset "$media_dir/portal-tour.gif" image/gif $((2 * 1024 * 1024))
validate_asset "$media_dir/portal-overview.png" image/png $((1024 * 1024))
validate_asset "$media_dir/portal-wifi-settings.png" image/png $((1024 * 1024))
install -d "$target_dir"
for asset in portal-tour.gif portal-overview.png portal-wifi-settings.png; do
source_file="$media_dir/$asset"
cp -- "$source_file" "$target_dir/$asset"
printf 'Promoted %s\n' "docs/assets/readme/$asset"
done
+204
View File
@@ -0,0 +1,204 @@
#!/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()
class FakeClock:
def __init__(self):
self.value = 0.0
def __call__(self):
return self.value
def advance(self, seconds):
self.value += seconds
class BlockingEmptySerial(FakeSerial):
clock = None
def read(self, _size):
type(self).read_calls += 1
type(self).clock.advance(0.25)
return b""
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
# An immediate empty-return backend must back off progressively instead of
# spinning. This takes no real sleep because the sleeper is injected.
FakeSerial.read_calls = 0
with tempfile.TemporaryDirectory() as temporary_directory:
output = Path(temporary_directory) / "serial-ota.log"
ready = Path(temporary_directory) / "serial-ota.ready"
empty_sleeps = []
assert module.capture(
"/dev/fake",
output,
ready,
should_stop=lambda: FakeSerial.read_calls >= 5,
sleep=empty_sleeps.append,
) == 0
assert empty_sleeps == [0.01, 0.02, 0.04, 0.08]
# A normal 250 ms serial timeout is already rate-limited by the device
# driver, so it must not receive an additional backoff sleep.
module.serial.Serial = BlockingEmptySerial
BlockingEmptySerial.events = []
BlockingEmptySerial.read_calls = 0
clock = FakeClock()
BlockingEmptySerial.clock = clock
with tempfile.TemporaryDirectory() as temporary_directory:
output = Path(temporary_directory) / "serial-ota.log"
ready = Path(temporary_directory) / "serial-ota.ready"
empty_sleeps = []
assert module.capture(
"/dev/fake",
output,
ready,
should_stop=lambda: BlockingEmptySerial.read_calls >= 2,
clock=clock,
sleep=empty_sleeps.append,
) == 0
assert empty_sleeps == []
module.serial.Serial = FakeSerial
FakeSerial.read_calls = 0
with tempfile.TemporaryDirectory() as temporary_directory:
output = Path(temporary_directory) / "serial-ota.log"
ready = Path(temporary_directory) / "serial-ota.ready"
assert module.capture(
"/dev/fake",
output,
ready,
should_stop=lambda: False,
deadline_seconds=0.0,
) == 2
print("WiFiManager passive OTA serial-capture test-harness check passed")
if __name__ == "__main__":
main()
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Check OTA fixture identity and environment locking without PlatformIO or a board.
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
temporary_root="$(mktemp -d "${TMPDIR:-/tmp}/wifimanager-ota-identity-test.XXXXXX")"
chmod 700 "$temporary_root"
cleanup() {
rm -rf -- "$temporary_root"
}
trap cleanup EXIT HUP INT TERM
export ARDUINO_TEST_HARNESS_LOCK_DIR="$temporary_root/locks"
export WIFIMANAGER_OTA_IDENTITY_DIR="$temporary_root/identity"
# shellcheck source=tools/lib/ota-fixture-identity.sh
source "$root/tools/lib/ota-fixture-identity.sh"
# shellcheck source=tools/lib/harness-locks.sh
source "$root/tools/lib/harness-locks.sh"
wm_write_ota_fixture_identity A
identity_file="$WIFIMANAGER_OTA_IDENTITY_DIR/ota_fixture_identity.h"
[[ "$(stat -c '%a' "$WIFIMANAGER_OTA_IDENTITY_DIR")" == 700 ]]
[[ "$(stat -c '%a' "$identity_file")" == 600 ]]
grep -Fqx '#define WM_OTA_FIXTURE_IMAGE "A"' "$identity_file"
wm_lock_ota_fixture_environment esp8266_ota
collision_output="$temporary_root/lock-collision.log"
if (
root="$root"
export WIFIMANAGER_OTA_IDENTITY_DIR="$temporary_root/other-identity"
# shellcheck source=tools/lib/ota-fixture-identity.sh
source "$root/tools/lib/ota-fixture-identity.sh"
wm_lock_ota_fixture_environment esp8266_ota
) 2>"$collision_output"; then
echo "A second test-harness process unexpectedly acquired the same OTA environment lock." >&2
exit 1
fi
grep -Fqx "OTA fixture environment 'esp8266_ota' is already in use by another local test-harness run." "$collision_output"
wm_harness_lock_resource "test resource" "identity-test:shared-resource"
if (
# shellcheck source=tools/lib/harness-locks.sh
source "$root/tools/lib/harness-locks.sh"
wm_harness_lock_resource "test resource" "identity-test:shared-resource"
) 2>"$collision_output"; then
echo "A second test-harness process unexpectedly acquired the same resource lock." >&2
exit 1
fi
grep -Fqx 'Cannot start: test resource is already in use by another local test-harness process.' "$collision_output"
wm_remove_ota_fixture_identity
[[ ! -e "$identity_file" ]]
echo "WiFiManager OTA fixture identity test-harness check passed"
-86
View File
@@ -1,86 +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"
printf '%s\n' '#!/usr/bin/env bash' \
'if [[ "$1" == "link" && "$2" == "show" ]]; then exit 0; fi' \
'if [[ "$1" == "route" && "$2" == "show" ]]; then echo "default via 192.0.2.1 dev wlan-main"; exit 0; fi' \
'echo "192.168.4.1 dev wlan-client src 192.168.4.2"' >"$stub_bin/ip"
printf '%s\n' '#!/usr/bin/env bash' \
'printf "%s\\n" "$*" >>"$CALL_LOG"' \
'if [[ "${NMCLI_FAIL_UP:-}" == "yes" && "$1" == "connection" && "$2" == "up" ]]; then exit 7; fi' \
'if [[ "$1" == "-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' '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"
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
# A failed association must delete the only connection it just created.
source "$root/tools/lib/portal-hardware-session.sh"
wm_wait_for_portal_ssid() { return 0; }
export NMCLI_FAIL_UP=yes
if wm_create_portal_connection wlan-client 'fixture portal' placeholder; then
echo 'failed association was reported as success' >&2
exit 1
fi
unset NMCLI_FAIL_UP
grep -Eq 'connection delete wifimanager-portal-' "$CALL_LOG"
# 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
}
echo 'portal-hardware CLI safety checks passed'