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