test: add portal OTA A/B contract coverage

This commit is contained in:
2026-09-17 12:10:48 +10:00
parent 7305ea8827
commit 228e1252f4
21 changed files with 954 additions and 33 deletions
+14
View File
@@ -0,0 +1,14 @@
# Keep local credentials, build output, and test artifacts out of the Docker
# build context. OTA firmware is mounted read-only by compose.ota.yaml instead
# of copied into an image.
.git
.github
.pio
**/.pio
node_modules
**/node_modules
artifacts
test/portal-station.env
test/.env
platformio.local.ini*
**/platformio.local.ini*
+32 -4
View File
@@ -20,8 +20,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: bash -n scripts/*.sh tools/portal-hardware tools/lib/*.sh tools/tests/*.sh - run: bash -n scripts/*.sh tools/check-ota-partitions.sh tools/portal-hardware tools/lib/*.sh tools/tests/*.sh
- run: |
for spec in tests/portal-contract/tests/*.js; do
node --check "$spec"
done
- run: ./tools/tests/test-portal-hardware-cli.sh - run: ./tools/tests/test-portal-hardware-cli.sh
- run: ./tools/check-ota-partitions.sh
- run: ./scripts/check-docs.sh - run: ./scripts/check-docs.sh
compile-tests: compile-tests:
@@ -29,12 +34,35 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
environment: ["esp8266", "esp32"] include:
- platform: esp8266
examples: true
ota_fixtures: true
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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: '3.11' python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19 - run: python -m pip install --upgrade platformio==6.1.19
- run: ./scripts/test.sh compile --platform ${{ matrix.environment }} - run: ./scripts/test.sh compile --platform ${{ matrix.platform }}
- run: ./scripts/test.sh examples --platform ${{ matrix.environment }} - 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
run: ./scripts/test.sh ota-fixtures --platform ${{ matrix.platform }}
+1 -1
View File
@@ -37,4 +37,4 @@ node_modules/
# Local portal station handoff credentials and optional direct test artifacts # Local portal station handoff credentials and optional direct test artifacts
/test/portal-station.env /test/portal-station.env
/tests/portal-contract/artifacts/ /tests/portal-contract/artifacts/
/artifacts/readme-media/ /artifacts/
+67 -9
View File
@@ -9,19 +9,57 @@ lib_deps =
## Target pins ## Target pins
The ESP32 test environments pin the pioarduino `51.03.05` platform package, WiFiManager currently has two explicit ESP32 test lanes:
which selects Arduino-ESP32 3.0.5 / ESP-IDF 5.1.4+. This is a test-target
contract, not a library-manifest dependency: a consuming application chooses | Lane | pioarduino platform | Purpose |
its own `platform` and must validate the complete framework/toolchain stack. | --- | --- | --- |
Core 3 Wi-Fi builds need the C++14, `SOC_WIFI_SUPPORTED`, `Network/src`, and | `esp32` | `51.03.05` / Arduino-ESP32 3.0.5 | temporary compatibility contract |
ESP8266-transport ignore settings in this repository's `platformio.ini`; keep | `esp32_core_3_3_11` / CLI `esp32-current` | `55.03.311` / Arduino-ESP32 3.3.11 | maintained current validation lane |
those settings together when adding an ESP32 environment.
This is a test-target contract, not a library-manifest dependency: a consuming
application chooses its own `platform` and must validate the complete
framework/toolchain stack. Do not let a shared global PlatformIO cache choose
framework metadata or a compiler implicitly, and do not override just the
toolchain to repair a cache mismatch. Each pioarduino platform owns its
matching framework, uploader, and compiler package set.
Core 3 Wi-Fi builds need the C++14, `SOC_WIFI_SUPPORTED`, and `Network/src`
settings in this repository's `platformio.ini`; keep those settings together
when adding an ESP32 environment. The portal OTA fixture uses the current
3.3.11 lane for ESP32 even while the 3.0.5 compatibility lane remains
available.
ESP8266 test environments pin framework commit `521ae60` for the upstream ESP8266 test environments pin framework commit `521ae60` for the upstream
Postmortem large-jump linker fix. The exact rationale and update rule are in Postmortem large-jump linker fix. The exact rationale and update rule are in
the shared [ESP8266 linker-workaround note](https://github.com/alexhopeoconnor/arduino-home-assistant/blob/main/docs/ESP8266-LINKER-WORKAROUND.md). the shared [ESP8266 linker-workaround note](https://github.com/alexhopeoconnor/arduino-home-assistant/blob/main/docs/ESP8266-LINKER-WORKAROUND.md).
For the pioarduino release-to-Core mapping and the scoped repair for a stale For the pioarduino release-to-Core mapping and cache-collision diagnosis, see
global PlatformIO tool package, see [DeviceFramework's toolchain guide](https://github.com/alexhopeoconnor/DeviceFramework/blob/main/docs/TOOLCHAINS.md). [DeviceFramework's toolchain guide](https://github.com/alexhopeoconnor/DeviceFramework/blob/main/docs/TOOLCHAINS.md).
`./scripts/test.sh` automatically places the `esp32-current` lane, and
`./tools/portal-hardware ota --platform esp32` places its current 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
`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.
For a disposable cache investigation, point that variable at an exact temporary
directory, run the affected command, inspect the resolved graph, then remove
only that directory:
```bash
wm_pio_core="$(mktemp -d /tmp/wifimanager-pio-XXXXXX)"
WIFIMANAGER_PLATFORMIO_CORE_DIR="$wm_pio_core" \
./scripts/test.sh compile --platform esp32-current
WIFIMANAGER_PLATFORMIO_CORE_DIR="$wm_pio_core" \
./scripts/test.sh packages --platform esp32-current
rm -rf -- "$wm_pio_core"
```
Start a release with `bump-version.sh`. It updates package metadata and canonical installation snippets, then creates the changelog section. Replace its generated TODO with the release summary and update any behavioural documentation before running: Start a release with `bump-version.sh`. It updates package metadata and canonical installation snippets, then creates the changelog section. Replace its generated TODO with the release summary and update any behavioural documentation before running:
@@ -31,8 +69,15 @@ Start a release with `bump-version.sh`. It updates package metadata and canonica
./scripts/check-docs.sh ./scripts/check-docs.sh
./scripts/test.sh compile --platform esp8266 ./scripts/test.sh compile --platform esp8266
./scripts/test.sh compile --platform esp32 ./scripts/test.sh compile --platform esp32
./scripts/test.sh compile --platform esp32-current
./scripts/test.sh unity --platform esp8266
./scripts/test.sh unity --platform esp32
./scripts/test.sh unity --platform esp32-current
./scripts/test.sh packages --platform esp32-current
./scripts/test.sh examples --platform esp8266 ./scripts/test.sh examples --platform esp8266
./scripts/test.sh examples --platform esp32 ./scripts/test.sh examples --platform esp32
./scripts/test.sh ota-fixtures --platform esp8266
./scripts/test.sh ota-fixtures --platform esp32-current
./scripts/prepare-release.sh vMAJOR.MINOR.PATCH --tag ./scripts/prepare-release.sh vMAJOR.MINOR.PATCH --tag
``` ```
@@ -57,6 +102,19 @@ for browser/API testing:
See [Testing](TESTING.md#docker-portal-contract) for cleanup, artifacts, and See [Testing](TESTING.md#docker-portal-contract) for cleanup, artifacts, and
optional station handoff credentials. optional station handoff credentials.
Run the portal HTTP OTA A/B contract separately when a spare adapter and 4 MB
test board are available. It erases the selected board's flash, serial-flashes
A, and uses the real browser update form to upload B; do not replace its
automatic-reboot assertion with a manual reset:
```bash
./tools/portal-hardware ota --platform esp8266 --port /dev/serial/by-id/usb-... \
--client-interface wlx74da385d4165
```
See [Portal HTTP OTA A/B contract](TESTING.md#portal-http-ota-ab-contract) 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. Push the branch and annotated tag. GitHub Actions repeats the board-free compile checks, validates the package, and creates a GitHub Release using that version’s changelog section. The workflow does not publish to the PlatformIO Registry.
Back to [documentation](README.md) · [project overview](../README.md). Back to [documentation](README.md) · [project overview](../README.md).
+83 -5
View File
@@ -4,10 +4,19 @@ 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, real board or join a captive portal. The normal commands never need a board,
local Wi-Fi credentials, browser binary, or sibling checkout. local Wi-Fi credentials, browser binary, or sibling checkout.
## Clean consumer and example builds | Physical contract | 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 |
The clean-consumer check builds a project that declares only WiFiManager. It The selected secondary adapter is intentionally never used for normal LAN
proves the package manifest resolves DFTE, ESPAsyncWebServer, and the correct 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 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 a prior local package link before each check, so dependency resolution uses the
current manifest rather than a stale `.pio` copy. current manifest rather than a stale `.pio` copy.
@@ -15,13 +24,24 @@ current manifest rather than a stale `.pio` copy.
```bash ```bash
./scripts/test.sh compile --platform esp8266 ./scripts/test.sh compile --platform esp8266
./scripts/test.sh compile --platform esp32 ./scripts/test.sh compile --platform esp32
./scripts/test.sh compile --platform esp32-current
./scripts/test.sh unity --platform esp8266
./scripts/test.sh unity --platform esp32
./scripts/test.sh unity --platform esp32-current
./scripts/test.sh examples --platform esp8266 ./scripts/test.sh examples --platform esp8266
./scripts/test.sh examples --platform esp32 ./scripts/test.sh examples --platform esp32
./scripts/test.sh ota-fixtures --platform esp8266
./scripts/test.sh ota-fixtures --platform esp32-current
``` ```
CI runs these board-free checks for pull requests and pushes to the maintained CI runs these board-free checks for pull requests and pushes to the maintained
branch. It intentionally does not require attached hardware, a local network, branch. `esp32` remains the explicit Arduino-ESP32 3.0.5 compatibility lane;
or Docker. `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
and B images against their tracked OTA partition layout. CI rejects equal A/B
artifacts, an ESP32 image larger than either 0x1F0000-byte app slot, or a
partition-table edit that breaks the required two-slot/no-filesystem layout. These checks
intentionally do not require attached hardware, a local network, or Docker.
## Local hardware lifecycle tests ## Local hardware lifecycle tests
@@ -155,4 +175,62 @@ README tour is readable; it does not change normal browser-contract timing.
ESP8266 remains covered by the normal hardware and browser contract but does ESP8266 remains covered by the normal hardware and browser contract but does
not produce duplicate README media. not produce duplicate README media.
## Portal HTTP OTA A/B contract
`portal-hardware ota` is a separate opt-in physical test for WiFiManager's
built-in HTTP update path. It exercises the rendered firmware-update page and
its real multipart `POST /u` request; it is not an ArduinoOTA/UDP test.
```bash
./tools/portal-hardware ota \
--platform esp8266 \
--port /dev/serial/by-id/usb-... \
--client-interface wlx74da385d4165
```
The selected `--client-interface` has exactly the same safety rules as the
normal portal contract: 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:
1. Builds immutable A and B fixture images. Their marker is compiled into the
binary, not saved in WiFiManager settings or EEPROM.
2. Checks both ESP32 images against the explicit matching `app0`/`app1` slots;
ESP8266 validates B after A has booted against the exact aligned capacity
passed to `Update.begin()`.
3. Erases the explicitly selected test board's flash, then flashes A over
serial and starts its captive portal.
4. Joins that portal only through the named secondary adapter and requires the
A marker at `/api/test/firmware-marker`.
5. Mounts B read-only into the Playwright container, chooses it in the real
`#wm-ota-file` browser input, and submits the rendered form.
6. Requires the real `POST /u` success response, an automatic portal outage,
automatic restart, and two independent B-marker responses.
The fixture marker endpoint exists only in `test/portal-harness`; it is not a
WiFiManager library route or a product-firmware pattern. The test does not
issue a manual reset. A board which boots B only after intervention is a
failure, even if B later appears.
Both fixture images are built with explicit OTA-capable layouts:
| Platform | Fixture layout | Capacity check |
| --- | --- | --- |
| ESP8266 | `eagle.flash.4m1m.ld` | A's live `ESP.getFreeSketchSpace()` response |
| ESP32 | two `0x1F0000` A/B app slots, no filesystem | tracked CSV `app1` size |
These are 4 MB fixture layouts (`d1_mini` for ESP8266 and `esp32dev` for
ESP32). Do not run this command against a board with another flash size unless
its matching explicit A/B layout and capacity checks have been added first.
The final board state is firmware B in the portal-only fixture: it clears
saved station settings on every boot and leaves no developer Wi-Fi credential
on the device. By default the temporary NetworkManager connection is removed
when the test exits. Pass `--keep` only for interactive diagnosis, then run
`./tools/portal-hardware down` to remove that named temporary connection.
Back to [documentation](README.md) · [project overview](../README.md). Back to [documentation](README.md) · [project overview](../README.md).
+7 -1
View File
@@ -39,10 +39,16 @@ lib_deps =
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.2.0 DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.2.0
ESP32Async/AsyncTCP@^3.4.9 ESP32Async/AsyncTCP@^3.4.9
; Current ESP32 validation lane. Keep Core 3.0.5 as a named compatibility
; target until its support policy is explicitly retired; never let a shared
; PlatformIO cache choose a framework/toolchain pair implicitly.
[env:esp32_core_3_3_11]
extends = env:esp32
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
; Optional: compile tests with DFTE logs bridged into WiFiManager::log (see README) ; Optional: compile tests with DFTE logs bridged into WiFiManager::log (see README)
[env:esp8266_dfte_log] [env:esp8266_dfte_log]
extends = env:esp8266 extends = env:esp8266
build_flags = build_flags =
${env:esp8266.build_flags} ${env:esp8266.build_flags}
-DWM_DFTE_LOGGING -DWM_DFTE_LOGGING
+108 -9
View File
@@ -4,15 +4,18 @@ set -euo pipefail
usage() { usage() {
cat <<'USAGE' >&2 cat <<'USAGE' >&2
Usage: Usage:
./scripts/test.sh compile --platform esp8266|esp32 ./scripts/test.sh compile --platform esp8266|esp32|esp32-current
./scripts/test.sh examples --platform esp8266|esp32 ./scripts/test.sh unity --platform esp8266|esp32|esp32-current
./scripts/test.sh hardware --platform esp8266|esp32 --port /dev/serial/by-id/... ./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 hardware --platform esp8266|esp32 --port /dev/serial/by-id/...
USAGE USAGE
exit 2 exit 2
} }
mode="${1:-}" mode="${1:-}"
[[ "$mode" == "compile" || "$mode" == "examples" || "$mode" == "hardware" ]] || usage [[ "$mode" == "compile" || "$mode" == "unity" || "$mode" == "examples" || "$mode" == "ota-fixtures" || "$mode" == "packages" || "$mode" == "hardware" ]] || usage
shift shift
platform="" platform=""
@@ -25,11 +28,76 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage case "$platform" in
esp8266|esp32)
environment="$platform"
;;
esp32-current)
environment="esp32_core_3_3_11"
;;
*) 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" || -n "$port" ]] || usage
[[ "$mode" != "hardware" || -e "$port" ]] || { echo "Serial port not found: $port" >&2; exit 1; } [[ "$mode" != "hardware" || -e "$port" ]] || { echo "Serial port not found: $port" >&2; exit 1; }
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
pio_for_platform() {
if [[ "$platform" != "esp32-current" ]]; 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.
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}"
cache_dir="${WIFIMANAGER_PLATFORMIO_CACHE_DIR:-$core_dir/cache}"
install -d -m 700 "$core_dir" "$packages_dir" "$cache_dir"
PLATFORMIO_CORE_DIR="$core_dir" PLATFORMIO_PACKAGES_DIR="$packages_dir" \
PLATFORMIO_CACHE_DIR="$cache_dir" pio "$@"
}
assert_ota_fixture_pair() {
local fixture_platform="$1"
local firmware_a firmware_b firmware size capacity
firmware_a="$root/test/portal-harness/.pio/build/${fixture_platform}_ota_a/firmware.bin"
firmware_b="$root/test/portal-harness/.pio/build/${fixture_platform}_ota_b/firmware.bin"
[[ -s "$firmware_a" && -s "$firmware_b" ]] || {
echo "Portal OTA fixture build did not produce both A and B images." >&2
return 1
}
if cmp -s "$firmware_a" "$firmware_b"; then
echo "Portal OTA fixture A and B are identical." >&2
return 1
fi
if [[ "$platform" == "esp32-current" ]]; then
capacity=$((0x1F0000))
for firmware in "$firmware_a" "$firmware_b"; do
size="$(wc -c < "$firmware" | tr -d '[:space:]')"
(( size <= capacity )) || {
echo "ESP32 OTA fixture $(basename "$(dirname "$firmware")") is $size bytes; it exceeds the $capacity-byte app slot." >&2
return 1
}
done
fi
}
if [[ "$mode" == "hardware" ]]; then if [[ "$mode" == "hardware" ]]; then
# Keep serial flashing and portal-adapter work mutually exclusive. # Keep serial flashing and portal-adapter work mutually exclusive.
# shellcheck source=tools/lib/portal-hardware-session.sh # shellcheck source=tools/lib/portal-hardware-session.sh
@@ -44,12 +112,43 @@ if [[ "$mode" == "examples" ]]; then
exit 1 exit 1
fi fi
for example in "${examples[@]}"; do for example in "${examples[@]}"; do
pio run -d "$example" -e "$platform" </dev/null pio_for_platform run -d "$example" -e "$platform" </dev/null
done done
echo "WiFiManager examples compile check passed for $platform" echo "WiFiManager examples compile check passed for $platform"
exit 0 exit 0
fi 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"
"$root/tools/check-ota-partitions.sh"
fi
for image in a b; do
pio_for_platform run -d "$root/test/portal-harness" -e "${fixture_platform}_ota_${image}" </dev/null
done
assert_ota_fixture_pair "$fixture_platform"
echo "WiFiManager portal OTA fixture compile check passed for $platform"
exit 0
fi
if [[ "$mode" == "unity" ]]; then
# Compile WiFiManager's own fixtures without a board. This is separate
# from the clean-consumer fixture, which protects manifest resolution.
pio_for_platform test -d "$root" -e "$environment" --filter test_wifimanager \
--without-uploading --without-testing
echo "WiFiManager Unity compile check passed for $platform"
exit 0
fi
if [[ "$mode" == "packages" ]]; then
pio_for_platform pkg list -d "$root/test/compile-project" -e "$environment"
exit 0
fi
if [[ "$mode" == "hardware" ]]; then if [[ "$mode" == "hardware" ]]; then
# Upload first, then capture from the normal boot reset. The Unity sketch # Upload first, then capture from the normal boot reset. The Unity sketch
# deliberately waits two seconds before it begins its test sequence. # deliberately waits two seconds before it begins its test sequence.
@@ -60,13 +159,13 @@ if [[ "$mode" == "hardware" ]]; then
exit 0 exit 0
fi fi
cached_library="$root/test/compile-project/.pio/libdeps/${platform}/WiFiManager" cached_library="$root/test/compile-project/.pio/libdeps/${environment}/WiFiManager"
# The fixture intentionally declares only this local package. Remove a prior # The fixture intentionally declares only this local package. Remove a prior
# link so each check resolves the current manifest as a fresh consumer would. # link so each check resolves the current manifest as a fresh consumer would.
if [[ -d "$cached_library" || -e "${cached_library}.pio-link" ]]; then if [[ -d "$cached_library" || -e "${cached_library}.pio-link" ]]; then
pio pkg uninstall -d "$root/test/compile-project" -e "$platform" \ pio_for_platform pkg uninstall -d "$root/test/compile-project" -e "$environment" \
-l WiFiManager --no-save --skip-dependencies >/dev/null -l WiFiManager --no-save --skip-dependencies >/dev/null
fi fi
pio run -d "$root/test/compile-project" -e "$platform" pio_for_platform run -d "$root/test/compile-project" -e "$environment"
echo "WiFiManager consumer compile check passed for $platform" echo "WiFiManager consumer compile check passed for $platform"
+6
View File
@@ -30,3 +30,9 @@ build_flags =
-DWM_LOG_LEVEL=3 -DWM_LOG_LEVEL=3
lib_deps = lib_deps =
${common.lib_deps} ${common.lib_deps}
; Clean-consumer validation against the maintained ESP32 platform lane. This
; is intentionally separate from the temporary 3.0.5 compatibility target.
[env:esp32_core_3_3_11]
extends = env:esp32
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
+22
View File
@@ -14,3 +14,25 @@ Use it through the repository runner so a secondary Wi-Fi adapter is explicitly
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 Contract ESP8266`; the ESP32 SSID is `WM Contract ESP32`. Both use `default1` exclusively for local development tests.
The runner cleans up only the temporary connection it creates on the named secondary interface. It refuses to run if that interface is the system default route. The runner cleans up only the temporary connection it creates on the named secondary interface. It refuses to run if that interface is the system default route.
## 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.
```bash
./tools/portal-hardware ota \
--platform esp32 \
--port /dev/serial/by-id/... \
--client-interface wlx...
```
ESP8266 explicitly uses `eagle.flash.4m1m.ld`. ESP32 uses the tracked two-slot
`partitions/esp32_ota_4m_no_fs.csv` layout on the maintained Arduino-ESP32
3.3.11 fixture lane. Both are 4 MB layouts. The runner builds and preserves A
and B before it touches the board, validates the matching ESP32 slots (or the
live ESP8266 updater capacity), then erases the explicitly selected test board
before serial-flashing A. A successful run leaves B installed in the
portal-only fixture.
@@ -0,0 +1,12 @@
# 4 MB ESP32 portal-harness OTA test layout.
#
# The fixture compiles portal assets into the application image, so it uses no
# filesystem. app0 and app1 are equal 0x1F0000-byte slots. The portal OTA
# runner validates B against that inactive-slot size before it flashes A or
# submits the browser upload.
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
app0, app, ota_0, 0x10000, 0x1F0000,
app1, app, ota_1, 0x200000, 0x1F0000,
coredump, data, coredump,0x3F0000, 0x10000,
1 # 4 MB ESP32 portal-harness OTA test layout.
2 #
3 # The fixture compiles portal assets into the application image, so it uses no
4 # filesystem. app0 and app1 are equal 0x1F0000-byte slots. The portal OTA
5 # runner validates B against that inactive-slot size before it flashes A or
6 # submits the browser upload.
7 # Name, Type, SubType, Offset, Size, Flags
8 nvs, data, nvs, 0x9000, 0x5000,
9 otadata, data, ota, 0xe000, 0x2000,
10 app0, app, ota_0, 0x10000, 0x1F0000,
11 app1, app, ota_1, 0x200000, 0x1F0000,
12 coredump, data, coredump,0x3F0000, 0x10000,
+39
View File
@@ -26,3 +26,42 @@ build_flags =
-DSOC_WIFI_SUPPORTED=1 -DSOC_WIFI_SUPPORTED=1
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src -I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
-DWM_LOG_LEVEL=4 -DWM_LOG_LEVEL=4
; The maintained ESP32 validation lane. Keep this named rather than relying on
; whatever framework/toolchain pair happens to be installed in PlatformIO's
; global package cache.
[env:esp32_core_3_3_11]
extends = env:esp32
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
; OTA contract images deliberately differ only by their compile-time marker.
; ESP8266 needs an OTA-capable linker layout; ESP32 needs a tracked two-slot
; partition table. The hardware runner always flashes A over serial then
; uploads B through the real portal form.
[env:esp8266_ota_a]
extends = env:esp8266
board_build.ldscript = eagle.flash.4m1m.ld
build_flags =
${env:esp8266.build_flags}
-DWM_OTA_TEST_IMAGE=\"A\"
[env:esp8266_ota_b]
extends = env:esp8266
board_build.ldscript = eagle.flash.4m1m.ld
build_flags =
${env:esp8266.build_flags}
-DWM_OTA_TEST_IMAGE=\"B\"
[env:esp32_ota_a]
extends = env:esp32_core_3_3_11
board_build.partitions = partitions/esp32_ota_4m_no_fs.csv
build_flags =
${env:esp32_core_3_3_11.build_flags}
-DWM_OTA_TEST_IMAGE=\"A\"
[env:esp32_ota_b]
extends = env:esp32_core_3_3_11
board_build.partitions = partitions/esp32_ota_4m_no_fs.csv
build_flags =
${env:esp32_core_3_3_11.build_flags}
-DWM_OTA_TEST_IMAGE=\"B\"
+31
View File
@@ -3,6 +3,14 @@
namespace { 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"
#endif
#if defined(ESP8266) #if defined(ESP8266)
constexpr char kPortalSsid[] = "WM Contract ESP8266"; constexpr char kPortalSsid[] = "WM Contract ESP8266";
#else #else
@@ -57,6 +65,28 @@ WiFiManagerParameter* const kPortalParameters[] = {
&kNotes, &kNotes,
}; };
void registerOtaTestMarker() {
// setWebServerCallback runs after WiFiManager creates its server and
// before it registers built-in routes. This private fixture endpoint is
// intentionally not a WiFiManager product API.
wifi.setWebServerCallback([]() {
AsyncWebServer* const server = wifi.getServer();
if (server == nullptr) {
return;
}
server->on("/api/test/firmware-marker", HTTP_GET,
[](AsyncWebServerRequest* request) {
String response = F("{\"marker\":\"");
response += WM_OTA_TEST_IMAGE;
response += F("\",\"freeSketchSpace\":");
response += String(ESP.getFreeSketchSpace());
response += F("}");
request->send(200, "application/json", response);
});
});
}
} // namespace } // namespace
void setup() { void setup() {
@@ -75,6 +105,7 @@ void setup() {
IPAddress(192, 168, 4, 1), IPAddress(192, 168, 4, 1),
IPAddress(192, 168, 4, 1), IPAddress(192, 168, 4, 1),
IPAddress(255, 255, 255, 0)); IPAddress(255, 255, 255, 0));
registerOtaTestMarker();
// Keep custom parameters on their own native Save parameters page. This // Keep custom parameters on their own native Save parameters page. This
// lets the browser contract exercise a parameter-only submit without // lets the browser contract exercise a parameter-only submit without
// starting a station connection as part of the regression test. // starting a station connection as part of the regression test.
+4
View File
@@ -9,3 +9,7 @@ the already-routed `192.168.4.1` portal; it never manages host Wi-Fi.
The image pins the Playwright package to the matching official browser image. The image pins the Playwright package to the matching official browser image.
Artifacts, traces, screenshots, JSON results, and the HTML report are written Artifacts, traces, screenshots, JSON results, and the HTML report are written
to the output directory printed by the host command. to the output directory printed by the host command.
`compose.ota.yaml` is an overlay used only by `portal-hardware ota`. It mounts
the already-built B firmware read-only and enables the A/B browser contract.
The ordinary portal contract never receives a firmware artifact.
+11
View File
@@ -0,0 +1,11 @@
# OTA-only overlay. The normal portal contract 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:
environment:
PORTAL_OTA_FIRMWARE: /firmware/portal-ota-b.bin
PORTAL_OTA_INITIAL_MARKER: ${PORTAL_OTA_INITIAL_MARKER:-A}
PORTAL_OTA_EXPECTED_MARKER: ${PORTAL_OTA_EXPECTED_MARKER:-B}
volumes:
- ${PORTAL_OTA_FIRMWARE_HOST:?OTA firmware path is required}:/firmware/portal-ota-b.bin:ro
+1
View File
@@ -17,6 +17,7 @@ services:
PORTAL_CUSTOM_PARAMETER_STRESS: ${PORTAL_CUSTOM_PARAMETER_STRESS:-0} PORTAL_CUSTOM_PARAMETER_STRESS: ${PORTAL_CUSTOM_PARAMETER_STRESS:-0}
PORTAL_PLATFORM: ${PORTAL_PLATFORM:-} PORTAL_PLATFORM: ${PORTAL_PLATFORM:-}
PORTAL_CAPTURE_README_MEDIA: ${PORTAL_CAPTURE_README_MEDIA:-0} PORTAL_CAPTURE_README_MEDIA: ${PORTAL_CAPTURE_README_MEDIA:-0}
PORTAL_TEST_FILE: ${PORTAL_TEST_FILE:-}
ARTIFACT_DIR: /artifacts ARTIFACT_DIR: /artifacts
volumes: volumes:
- ${PORTAL_ARTIFACT_DIR:?portal artifact directory is required}:/artifacts - ${PORTAL_ARTIFACT_DIR:?portal artifact directory is required}:/artifacts
+7 -1
View File
@@ -1,7 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
/work/node_modules/.bin/playwright test --config /work/playwright.config.js 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
# spec instead of allowing ordinary portal tests to consume its AP window.
playwright_args+=("$PORTAL_TEST_FILE")
fi
/work/node_modules/.bin/playwright "${playwright_args[@]}"
if [[ "${PORTAL_CAPTURE_README_MEDIA:-0}" == "1" ]]; then if [[ "${PORTAL_CAPTURE_README_MEDIA:-0}" == "1" ]]; then
/work/render-readme-media.sh /work/render-readme-media.sh
+89
View File
@@ -0,0 +1,89 @@
const { test, expect } = require('@playwright/test');
const firmware = process.env.PORTAL_OTA_FIRMWARE;
const initialMarker = process.env.PORTAL_OTA_INITIAL_MARKER || 'A';
const expectedMarker = process.env.PORTAL_OTA_EXPECTED_MARKER || 'B';
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
async function getMarker(request) {
const response = await request.get('/api/test/firmware-marker', { timeout: 4_000 });
if (!response.ok()) {
throw new Error(`marker endpoint returned HTTP ${response.status()}`);
}
return response.json();
}
async function waitForMarker(request, expected, timeout = 75_000) {
let observed;
await expect.poll(async () => {
try {
observed = await getMarker(request);
return observed.marker;
} catch {
return undefined;
}
}, {
timeout,
intervals: [250, 500, 1_000, 1_000],
}).toBe(expected);
return observed;
}
async function requireRestartOutage(request) {
const deadline = Date.now() + 25_000;
while (Date.now() < deadline) {
try {
const response = await request.get('/api/test/firmware-marker', { timeout: 1_000 });
if (!response.ok()) {
return;
}
} catch {
return;
}
await sleep(150);
}
throw new Error('The portal never became unavailable after a successful OTA response.');
}
test.describe('portal HTTP OTA contract', () => {
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.
test.setTimeout(300_000);
const initial = await waitForMarker(request, initialMarker);
expect(initial.freeSketchSpace).toEqual(expect.any(Number));
expect(initial.freeSketchSpace).toBeGreaterThan(0);
// This deliberately uses the rendered UI and its multipart XHR instead of
// posting directly to /u. It therefore covers the real file control,
// submit handling, success JSON, and restart presentation together.
await page.goto('/#/update', { waitUntil: 'networkidle' });
const input = page.locator('#wm-ota-file');
await expect(input).toBeVisible();
await input.setInputFiles(firmware);
const updateResponse = page.waitForResponse((response) => {
const requestPath = new URL(response.url()).pathname;
return requestPath === '/u' && response.request().method() === 'POST';
});
await page.locator('#wm-ota-form button[type="submit"]').click();
const response = await updateResponse;
expect(response.status()).toBe(200);
await expect(response.json()).resolves.toMatchObject({ ok: true });
// A manual reset is never issued here. Observing the outage is what proves
// WiFiManager's Update.end(true) path restarted the board on its own.
await requireRestartOutage(request);
const first = await waitForMarker(request, expectedMarker);
expect(first.freeSketchSpace).toEqual(expect.any(Number));
await sleep(1_000);
const second = await waitForMarker(request, expectedMarker);
expect(second.freeSketchSpace).toEqual(expect.any(Number));
});
});
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Verify the tracked ESP32 OTA table used by the portal A/B fixtures. Keep the
# check independent of PlatformIO so CI rejects a layout regression before it
# downloads a framework or a hardware runner erases a board.
set -euo pipefail
project_dir="$(cd "$(dirname "$0")/.." && pwd)"
table="${1:-$project_dir/test/portal-harness/partitions/esp32_ota_4m_no_fs.csv}"
[[ $# -le 1 && -r "$table" ]] || {
echo "Usage: $0 [partition-table.csv]" >&2
exit 2
}
partition_row() {
local name="$1" type="$2" subtype="$3"
awk -F, -v name="$name" -v type="$type" -v subtype="$subtype" '
function trim(value) {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
return value
}
/^[[:space:]]*#/ || NF < 5 { next }
trim($1) == name && trim($2) == type && trim($3) == subtype {
print trim($1) "," trim($2) "," trim($3) "," trim($4) "," trim($5)
}
' "$table"
}
require_row() {
local name="$1" type="$2" subtype="$3" offset="$4" size="$5" actual expected
expected="$name,$type,$subtype,$offset,$size"
actual="$(partition_row "$name" "$type" "$subtype")"
[[ "$actual" == "$expected" ]] || {
echo "Expected exactly this OTA partition row: $expected" >&2
echo "Found: ${actual:-<none>}" >&2
return 1
}
}
require_row nvs data nvs 0x9000 0x5000
require_row otadata data ota 0xe000 0x2000
require_row app0 app ota_0 0x10000 0x1F0000
require_row app1 app ota_1 0x200000 0x1F0000
app_count="$(awk -F, '
function trim(value) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", value); return value }
/^[[:space:]]*#/ || NF < 5 { next }
trim($2) == "app" { count++ }
END { print count + 0 }
' "$table")"
[[ "$app_count" == 2 ]] || {
echo "OTA layout must contain exactly two application partitions, found $app_count." >&2
exit 1
}
if awk -F, '
function trim(value) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", value); return value }
/^[[:space:]]*#/ || NF < 5 { next }
trim($2) == "data" && trim($3) ~ /^(spiffs|littlefs|fat)$/ { found = 1 }
END { exit found ? 0 : 1 }
' "$table"; then
echo "OTA test layout must not reserve a filesystem partition." >&2
exit 1
fi
echo "ESP32 OTA partition contract passed: $table"
+6
View File
@@ -19,11 +19,17 @@ wm_default_route_interface() {
wm_acquire_hardware_lock() { wm_acquire_hardware_lock() {
local lock_file="${WM_HARDWARE_LOCK_FILE:-/tmp/wifimanager-hardware.lock}" local lock_file="${WM_HARDWARE_LOCK_FILE:-/tmp/wifimanager-hardware.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
# only the serial flash and adapter connection.
[[ "${WM_HARDWARE_LOCK_HELD:-no}" == "yes" ]] && return 0
exec 9>"$lock_file" exec 9>"$lock_file"
flock -n 9 || { flock -n 9 || {
echo "Another WiFiManager hardware task is already running; wait for it to finish." >&2 echo "Another WiFiManager hardware task is already running; wait for it to finish." >&2
return 1 return 1
} }
WM_HARDWARE_LOCK_HELD=yes
} }
wm_require_client_adapter() { wm_require_client_adapter() {
+326 -3
View File
@@ -15,11 +15,20 @@ Usage:
--client-interface IFACE [--take-over-client-adapter] [--keep] \ --client-interface IFACE [--take-over-client-adapter] [--keep] \
[--browser auto|skip] [--station-env PATH] [--output DIRECTORY] \ [--browser auto|skip] [--station-env PATH] [--output DIRECTORY] \
[--capture-readme-media] [--custom-parameter-stress] [--capture-readme-media] [--custom-parameter-stress]
./tools/portal-hardware ota --platform esp8266|esp32 --port /dev/serial/by-id/... \
--client-interface IFACE [--take-over-client-adapter] [--keep] \
[--output DIRECTORY]
./tools/portal-hardware down ./tools/portal-hardware down
Only the named client interface may be disconnected or reconfigured. The tool Only the named client interface may be disconnected or reconfigured. The tool
refuses the host default-route interface and preserves the created connection refuses the host default-route interface and preserves the created connection
in a 0600 state file until `down` or normal `run` cleanup. in a 0600 state file until `down` or normal `run`/`ota` cleanup.
`ota` is an opt-in physical A/B test. It erases the explicitly selected test
board's flash, serial-flashes image A, then uses the real portal browser form
to upload image B. It requires the automatic restart and two independent
B-marker responses; a manual reset never makes the test pass. On success the
selected board remains on B in the portal-only fixture.
USAGE USAGE
exit 2 exit 2
} }
@@ -38,6 +47,11 @@ station_env=""
output_dir="" output_dir=""
capture_readme_media="no" capture_readme_media="no"
custom_parameter_stress="no" custom_parameter_stress="no"
ota_environment_a=""
ota_environment_b=""
ota_firmware_a=""
ota_firmware_b=""
ota_browser_prebuilt="no"
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--platform) [[ $# -ge 2 ]] || usage; platform="$2"; shift 2 ;; --platform) [[ $# -ge 2 ]] || usage; platform="$2"; shift 2 ;;
@@ -63,6 +77,32 @@ require_common() {
docker compose version >/dev/null docker compose version >/dev/null
} }
pio_for_portal_environment() {
local environment="$1"
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_*)
;;
*)
pio "$@"
return
;;
esac
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}"
cache_dir="${WIFIMANAGER_PLATFORMIO_CACHE_DIR:-$core_dir/cache}"
install -d -m 700 "$core_dir" "$packages_dir" "$cache_dir"
PLATFORMIO_CORE_DIR="$core_dir" PLATFORMIO_PACKAGES_DIR="$packages_dir" \
PLATFORMIO_CACHE_DIR="$cache_dir" pio "$@"
}
prepare_output_dir() { prepare_output_dir() {
if [[ -z "$output_dir" ]]; then if [[ -z "$output_dir" ]]; then
if [[ "$capture_readme_media" == "yes" ]]; then if [[ "$capture_readme_media" == "yes" ]]; then
@@ -107,6 +147,20 @@ validate_run_arguments() {
exit 2 exit 2
} }
fi fi
if [[ "$command_name" == "ota" ]]; then
[[ "$browser" == "auto" ]] || {
echo "OTA coverage always uses the browser; --browser skip is not supported." >&2
exit 2
}
[[ -z "$station_env" ]] || {
echo "OTA coverage is portal-only and does not accept --station-env." >&2
exit 2
}
[[ "$capture_readme_media" == "no" && "$custom_parameter_stress" == "no" ]] || {
echo "OTA coverage cannot be combined with README media or parameter-stress modes." >&2
exit 2
}
fi
} }
write_readme_media_manifest() { write_readme_media_manifest() {
@@ -145,7 +199,7 @@ wait_for_portal_ready() {
} }
start_portal_session() { start_portal_session() {
local ssid local environment="${1:-$platform}" erase_before_upload="${2:-no}" expected_a_artifact="${3:-}" ssid
validate_run_arguments validate_run_arguments
require_common require_common
wm_acquire_hardware_lock wm_acquire_hardware_lock
@@ -154,7 +208,18 @@ start_portal_session() {
prepare_output_dir prepare_output_dir
ssid="$(wm_portal_ssid "$platform")" ssid="$(wm_portal_ssid "$platform")"
pio run -d "$root/test/portal-harness" -e "$platform" -t upload --upload-port "$port" if [[ "$erase_before_upload" == "yes" ]]; then
# OTA selection metadata must not survive from a previous fixture run:
# otherwise a bootloader could select a stale app slot instead of A.
pio_for_portal_environment "$environment" run -d "$root/test/portal-harness" -e "$environment" -t erase --upload-port "$port"
fi
if [[ -n "$expected_a_artifact" ]]; then
ota_assert_a_artifact_matches_build "$expected_a_artifact"
fi
pio_for_portal_environment "$environment" run -d "$root/test/portal-harness" -e "$environment" -t upload --upload-port "$port"
if [[ -n "$expected_a_artifact" ]]; then
ota_assert_a_artifact_matches_build "$expected_a_artifact"
fi
if ! wm_create_portal_connection "$client_interface" "$ssid" "default1"; then if ! wm_create_portal_connection "$client_interface" "$ssid" "default1"; then
return 1 return 1
fi fi
@@ -173,6 +238,223 @@ start_portal_session() {
printf 'Portal connected on %s. Artifacts: %s\n' "$client_interface" "$output_dir" printf 'Portal connected on %s. Artifacts: %s\n' "$client_interface" "$output_dir"
} }
ota_environment() {
local image="$1"
printf '%s_ota_%s\n' "$platform" "$image"
}
ota_marker_field() {
local response="$1" field="$2"
case "$field" in
marker)
sed -n 's/.*"marker"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' <<<"$response"
;;
freeSketchSpace)
sed -n 's/.*"freeSketchSpace"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' <<<"$response"
;;
*)
echo "Unknown OTA marker field: $field" >&2
return 2
;;
esac
}
ota_fetch_marker() {
curl --interface "$client_interface" --connect-timeout 1 --max-time 2 \
--silent --show-error http://192.168.4.1/api/test/firmware-marker 2>/dev/null
}
wait_for_ota_marker() {
local expected="$1" deadline response marker
deadline=$((SECONDS + 75))
while (( SECONDS < deadline )); do
response="$(ota_fetch_marker || true)"
marker="$(ota_marker_field "$response" marker)"
if [[ "$marker" == "$expected" ]]; then
printf '%s\n' "$response"
return 0
fi
sleep 0.5
done
echo "Portal OTA fixture did not report marker '$expected' within 75 seconds." >&2
return 1
}
ota_esp32_partition_row() {
local partition="$1" table="$root/test/portal-harness/partitions/esp32_ota_4m_no_fs.csv"
[[ -r "$table" ]] || {
echo "ESP32 OTA partition table is unreadable: $table" >&2
return 1
}
awk -F, -v partition="$partition" '
function trim(value) {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
return value
}
/^[[:space:]]*#/ || NF < 5 { next }
trim($1) == partition {
printf "%s\t%s\t%s\t%s\n", trim($2), trim($3), trim($4), trim($5)
exit
}
' "$table"
}
ota_esp32_require_partition() {
local name="$1" expected_type="$2" expected_subtype="$3" expected_offset="$4" expected_size="$5"
local row type subtype offset size
row="$(ota_esp32_partition_row "$name")"
IFS=$'\t' read -r type subtype offset size <<<"$row"
[[ "$type" == "$expected_type" && "$subtype" == "$expected_subtype" && \
"$offset" == "$expected_offset" && "$size" == "$expected_size" ]] || {
echo "ESP32 OTA partition '$name' must be $expected_type/$expected_subtype at $expected_offset with size $expected_size; found '${row:-missing}'." >&2
return 1
}
}
ota_esp32_slot_size() {
local table="$root/test/portal-harness/partitions/esp32_ota_4m_no_fs.csv"
local value_a value_b
[[ -r "$table" ]] || {
echo "ESP32 OTA partition table is unreadable: $table" >&2
return 1
}
ota_esp32_require_partition nvs data nvs 0x9000 0x5000
ota_esp32_require_partition otadata data ota 0xe000 0x2000
ota_esp32_require_partition app0 app ota_0 0x10000 0x1F0000
ota_esp32_require_partition app1 app ota_1 0x200000 0x1F0000
value_a="$(ota_esp32_partition_row app0 | awk -F '\t' '{ print $4 }')"
value_b="$(ota_esp32_partition_row app1 | awk -F '\t' '{ print $4 }')"
[[ "$value_a" =~ ^0x[0-9A-Fa-f]+$ && "$value_a" == "$value_b" ]] || {
echo "ESP32 OTA app slots must have equal hexadecimal capacities: app0=$value_a app1=$value_b" >&2
return 1
}
printf '%d\n' "$((value_b))"
}
ota_assert_firmware_fits() {
local firmware="$1" capacity="$2" label="$3" image_size
[[ -n "$firmware" && -s "$firmware" ]] || {
echo "OTA $label firmware artifact is missing." >&2
return 1
}
[[ "$capacity" =~ ^[0-9]+$ && "$capacity" -gt 0 ]] || {
echo "OTA capacity is invalid: $capacity" >&2
return 1
}
image_size="$(wc -c < "$firmware")"
if (( image_size > capacity )); then
echo "OTA $label image is too large: ${image_size} bytes exceeds ${capacity} bytes." >&2
return 1
fi
printf 'OTA %s image fits the available update space: %s <= %s bytes\n' "$label" "$image_size" "$capacity"
}
prepare_ota_firmware() {
local source_a source_b capacity
ota_environment_a="$(ota_environment a)"
ota_environment_b="$(ota_environment b)"
# Build and preserve both identities before touching the board. The
# browser mounts B read-only; the A artifact is compared against the
# PlatformIO build used for serial flashing so a later build cannot turn
# the A/B proof into an unrecorded input change.
pio_for_portal_environment "$ota_environment_a" run -d "$root/test/portal-harness" -e "$ota_environment_a"
pio_for_portal_environment "$ota_environment_b" run -d "$root/test/portal-harness" -e "$ota_environment_b"
source_a="$root/test/portal-harness/.pio/build/$ota_environment_a/firmware.bin"
source_b="$root/test/portal-harness/.pio/build/$ota_environment_b/firmware.bin"
[[ -s "$source_a" && -s "$source_b" ]] || {
echo "PlatformIO did not produce both OTA fixture images." >&2
return 1
}
if cmp -s "$source_a" "$source_b"; then
echo "PlatformIO produced identical OTA A and B images." >&2
return 1
fi
ota_firmware_a="$output_dir/${platform}-portal-ota-a.bin"
ota_firmware_b="$output_dir/${platform}-portal-ota-b.bin"
install -m 600 "$source_a" "$ota_firmware_a"
install -m 600 "$source_b" "$ota_firmware_b"
if [[ "$platform" == "esp32" ]]; then
"$root/tools/check-ota-partitions.sh"
capacity="$(ota_esp32_slot_size)"
ota_assert_firmware_fits "$ota_firmware_a" "$capacity" A
ota_assert_firmware_fits "$ota_firmware_b" "$capacity" B
fi
}
ota_assert_a_artifact_matches_build() {
local expected_artifact="$1" source
source="$root/test/portal-harness/.pio/build/$ota_environment_a/firmware.bin"
[[ -s "$expected_artifact" && -s "$source" ]] || {
echo "Prepared OTA A artifact or PlatformIO build output is missing." >&2
return 1
}
cmp -s "$expected_artifact" "$source" || {
echo "PlatformIO's A build no longer matches the immutable artifact prepared before serial flashing." >&2
return 1
}
}
validate_running_ota_capacity() {
local marker_response free_sketch_space capacity
[[ "$platform" == "esp8266" ]] || return 0
marker_response="$(wait_for_ota_marker A)"
free_sketch_space="$(ota_marker_field "$marker_response" freeSketchSpace)"
[[ "$free_sketch_space" =~ ^[0-9]+$ && "$free_sketch_space" -gt 4096 ]] || {
echo "ESP8266 fixture did not report usable free sketch space." >&2
return 1
}
# Match WiFiManagerHandlers::handleUpdating(): Update.begin receives this
# aligned value, not the raw ESP.getFreeSketchSpace() number.
capacity=$(( (free_sketch_space - 0x1000) & 0xFFFFF000 ))
ota_assert_firmware_fits "$ota_firmware_b" "$capacity" B
}
configure_ota_contract_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,
# stress, target, or test-selection setting into a physical firmware run.
export PORTAL_CONTRACT_DOCKER_TARGET=portal-contract
export PORTAL_CAPTURE_README_MEDIA=0
export PORTAL_CUSTOM_PARAMETER_STRESS=0
export PORTAL_TEST_FILE=tests/ota.spec.js
export PORTAL_PLATFORM="$platform"
export PORTAL_OTA_FIRMWARE_HOST="$ota_firmware_b"
export PORTAL_OTA_INITIAL_MARKER=A
export PORTAL_OTA_EXPECTED_MARKER=B
}
ota_compose_files() {
printf '%s\n' \
-f "$root/tests/portal-contract/compose.yaml" \
-f "$root/tests/portal-contract/compose.ota.yaml"
}
prepare_ota_contract_image() {
local -a compose_files
configure_ota_contract_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
ota_browser_prebuilt=yes
}
run_ota_contract() {
local -a compose_files
configure_ota_contract_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
}
finish_portal_session() { finish_portal_session() {
wm_load_state wm_load_state
wm_remove_connection "$WM_PORTAL_CONNECTION_UUID" wm_remove_connection "$WM_PORTAL_CONNECTION_UUID"
@@ -208,6 +490,9 @@ case "$command_name" in
export LOCAL_UID="$(id -u)" export LOCAL_UID="$(id -u)"
export LOCAL_GID="$(id -g)" export LOCAL_GID="$(id -g)"
export PORTAL_BROWSER_MODE="$browser" export PORTAL_BROWSER_MODE="$browser"
# A normal run is the full portal suite; do not let a prior OTA shell
# environment restrict it to one spec.
export PORTAL_TEST_FILE=''
export PORTAL_CUSTOM_PARAMETER_STRESS=0 export PORTAL_CUSTOM_PARAMETER_STRESS=0
if [[ "$custom_parameter_stress" == "yes" ]]; then if [[ "$custom_parameter_stress" == "yes" ]]; then
export PORTAL_CUSTOM_PARAMETER_STRESS=1 export PORTAL_CUSTOM_PARAMETER_STRESS=1
@@ -237,5 +522,43 @@ case "$command_name" in
printf 'Portal session remains connected; run ./tools/portal-hardware down when finished.\n' printf 'Portal session remains connected; run ./tools/portal-hardware down when finished.\n'
fi fi
;; ;;
ota)
# Acquire the same board/adapter lock before *any* A/B preparation.
# Otherwise two OTA invocations can collide in .pio or create a
# same-second artifact directory before either reaches serial flash.
validate_run_arguments
require_common
wm_acquire_hardware_lock
wm_require_no_active_session
wm_require_client_adapter "$client_interface" "$takeover"
prepare_output_dir
prepare_ota_firmware
prepare_ota_contract_image
start_portal_session "$ota_environment_a" yes "$ota_firmware_a"
cleanup() {
if [[ "$keep" != "yes" ]]; then
finish_portal_session || true
fi
}
trap cleanup EXIT INT TERM
# 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
# The browser contract 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.
wait_for_ota_marker B >/dev/null
sleep 1
wait_for_ota_marker B >/dev/null
printf 'Portal HTTP OTA contract passed. Artifacts: %s\n' "$output_dir"
if [[ "$keep" == "yes" ]]; then
printf 'Portal session remains connected; run ./tools/portal-hardware down when finished.\n'
fi
;;
*) usage ;; *) usage ;;
esac esac
+22
View File
@@ -61,6 +61,20 @@ if "$root/tools/portal-hardware" run --platform esp8266 --port /dev/null \
echo 'browser-skipped custom-parameter stress was accepted' >&2 echo 'browser-skipped custom-parameter stress was accepted' >&2
exit 1 exit 1
fi 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. # A failed association must delete the only connection it just created.
source "$root/tools/lib/portal-hardware-session.sh" source "$root/tools/lib/portal-hardware-session.sh"
@@ -102,6 +116,14 @@ run_line="$(grep -n " run --rm portal-contract$" "$CALL_LOG" | tail -1 | cut -d:
grep -Fq 'wait_for_portal_ready' "$root/tools/portal-hardware" grep -Fq 'wait_for_portal_ready' "$root/tools/portal-hardware"
grep -Fq 'api/wifi/scan-status' "$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 '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 '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' \ grep -Fq 'README media GIF exceeds its 2 MiB documentation budget' \
"$root/tests/portal-contract/render-readme-media.sh" "$root/tests/portal-contract/render-readme-media.sh"