fix: harden portal OTA release

This commit is contained in:
2026-09-20 19:44:05 +10:00
parent 7c5b50499e
commit 360b7cff86
20 changed files with 621 additions and 198 deletions
+2
View File
@@ -21,11 +21,13 @@ jobs:
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: python3 -m py_compile test/portal-harness/tools/ota_fixture_input.py
- run: |
for spec in tests/portal-harness/tests/*.js; do
node --check "$spec"
done
- run: timeout 15s python3 tools/tests/test-capture-serial.py
- run: timeout 15s bash tools/tests/test-ota-fixture-identity.sh
- run: ./tools/check-ota-partitions.sh
- run: ./scripts/check-docs.sh
+4
View File
@@ -6,6 +6,10 @@
SYS callback. The updater now enters asynchronous mode before its first
erase or write, so the real browser upload can complete and restart into
the new firmware image.
- Send the successful portal OTA response before scheduling the restart, so
browsers can observe a completed HTTP exchange on both ESP8266 and ESP32.
- Give ESP32's Wi-Fi radio a five-second hand-off interval before a user scan
retry, avoiding transient scan failures immediately after completion.
## 3.2.4
+1 -1
View File
@@ -106,7 +106,7 @@ The [Branded Portal](examples/BrandedPortal/) example includes a static SVG, acc
```ini
[common]
lib_deps =
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.2.4
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.2.5
```
The suffix after `#` is a Git ref. PlatformIO clones the repository and checks out that release tag; GitHub Release assets are unrelated. Arduino IDE users can install this repository as a library checkout.
+10 -8
View File
@@ -33,17 +33,19 @@ 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` 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
`./scripts/test.sh` and `./tools/portal-hardware ota --platform esp32` use the
PlatformIO Core/cache shared by the maintained framework repositories,
defaulting to `${XDG_CACHE_HOME:-$HOME/.cache}/arduino-framework-platformio/core-3.3.11`.
WiFiManager, DeviceFramework, DFTE, and ArduinoHA pin this same graph, so this
avoids downloading the same Core 3.3.11 inputs for each repository while
keeping pioarduino's package-form `esptool` and generated environment separate
from stale global `tool-esptoolpy` metadata. Override the location with
`WIFIMANAGER_PLATFORMIO_CORE_DIR`,
`WIFIMANAGER_PLATFORMIO_PACKAGES_DIR`, and
`WIFIMANAGER_PLATFORMIO_CACHE_DIR` when space belongs elsewhere. The first
first install is several GiB; reserve at least 4 GiB plus cache headroom. It is
persistent and is never cleared by normal test commands.
`WIFIMANAGER_PLATFORMIO_CACHE_DIR` when space belongs elsewhere or an isolated
diagnosis is needed. The first shared install is several GiB; reserve at least
4 GiB plus cache headroom. It is persistent and is never cleared by normal test
commands.
For a disposable cache investigation, point that variable at an exact temporary
directory, run the affected command, inspect the resolved graph, then remove
+1 -1
View File
@@ -26,7 +26,7 @@ void loop() {
```ini
lib_deps =
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.2.4
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.2.5
```
The package includes the asynchronous web and TCP dependencies required by the selected ESP8266 or ESP32 target. Add WiFiManager as the application’s direct dependency; do not copy its internal dependency list into your project.
+19 -7
View File
@@ -7,7 +7,7 @@ local Wi-Fi credentials, browser binary, or sibling checkout.
| Physical test harness | Transport | Host adapter | Secret source | Required proof |
| --- | --- | --- | --- | --- |
| Portal lifecycle suite | Serial flash + captive-portal HTTP/browser | Named secondary adapter | safe fixture AP password | Unity/lifecycle checks and portal UI/API coverage |
| Portal HTTP OTA | WiFiManager multipart `POST /u` | Named secondary adapter | safe fixture AP password | serial A → updater accepts/completes B → serial B, plus browser automatic-reboot/B-twice proof |
| Portal HTTP OTA | WiFiManager multipart `POST /u` | Named secondary adapter | safe fixture AP password | rendered upload succeeds, portal restarts automatically, and fixture marker changes A → B twice |
The selected secondary adapter is intentionally never used for normal LAN
testing. It is `never-default`, so the host's ordinary route remains intact.
@@ -40,6 +40,16 @@ artifacts, an ESP32 image larger than either 0x1F0000-byte app slot, or a
partition-table edit that breaks the required two-slot/no-filesystem layout. These checks
intentionally do not require attached hardware, a local network, or Docker.
Direct PlatformIO test-harness commands default to two compiler jobs. Set
`PLATFORMIO_RUN_JOBS=3` only for an explicit local run on an otherwise idle
host.
Physical portal commands lock the shared `192.168.4.0/24` portal network, the
selected secondary adapter, and the named serial device. These non-secret
resource locks are shared with DeviceFramework's portal harness, so a collision
fails before either runner changes a board or adapter while unrelated station
tests can use their own resources.
## Local hardware lifecycle tests
The Unity suite runs portal-only firmware with no Wi-Fi credentials, MQTT,
@@ -242,8 +252,10 @@ HTTP route authentication.
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.
1. Builds immutable A and B fixture images from one platform environment. A
generated harness-only header in an ignored, per-run private directory is
the only changed input, so their marker is compiled into the binary rather
than saved in WiFiManager settings or EEPROM.
2. Checks both ESP32 images against the explicit matching `app0`/`app1` slots;
ESP8266 validates B after A has booted against the exact aligned capacity
passed to `Update.begin()`.
@@ -261,10 +273,10 @@ The OTA command additionally requires Python with PySerial (the
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
requires a healthy recorder, but its contents are diagnostic evidence rather
than a pass/fail comparison against product log strings. This preserves
firmware-side portal-start and DHCP evidence without manufacturing a reset.
OTA-only fixture images wait five seconds after their upload reset so
the passive recorder can attach before A/B boot evidence is emitted; ordinary
portal test-harness startup remains fast.
+3 -1
View File
@@ -11,6 +11,8 @@ tag="${1:-}"
[[ "${2:-}" == "" || "${2:-}" == "--tag" ]] || usage
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=tools/lib/platformio.sh
source "$root/tools/lib/platformio.sh"
version="${tag#v}"
manifest_version="$(sed -n 's/.*"version": "\([^"]*\)".*/\1/p' "$root/library.json" | head -n 1)"
@@ -54,7 +56,7 @@ validate_reference docs/GETTING_STARTED.md
git -C "$root" diff --check
package_dir="$(mktemp -d)"
trap 'rm -rf "$package_dir"' EXIT
pio pkg pack "$root" --output "$package_dir/package.tar.gz" >/dev/null
wm_pio pkg pack "$root" --output "$package_dir/package.tar.gz" >/dev/null
echo "Validated release metadata and PlatformIO package for $tag"
if [[ "${2:-}" == "--tag" ]]; then
+60 -20
View File
@@ -1,6 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
# Keep standalone compilation predictable on laptops and shared workstations.
# A developer may explicitly raise this for an isolated local diagnosis.
export PLATFORMIO_RUN_JOBS="${PLATFORMIO_RUN_JOBS:-2}"
usage() {
cat <<'USAGE' >&2
Usage:
@@ -35,30 +39,32 @@ esac
[[ "$mode" != "hardware" || -e "$port" ]] || { echo "Serial port not found: $port" >&2; exit 1; }
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=tools/lib/platformio.sh
source "$root/tools/lib/platformio.sh"
# shellcheck source=tools/lib/ota-fixture-identity.sh
source "$root/tools/lib/ota-fixture-identity.sh"
pio_for_platform() {
if [[ "$platform" != "esp32" ]]; then
pio "$@"
wm_pio "$@"
return
fi
# 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.
# cache shared by the maintained framework repositories. It is never
# cleared by this script and avoids stale global package metadata selecting
# an incompatible uploader without redownloading this same pinned graph.
local core_dir packages_dir cache_dir
core_dir="${WIFIMANAGER_PLATFORMIO_CORE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/wifimanager-platformio/core-3.3.11}"
core_dir="${WIFIMANAGER_PLATFORMIO_CORE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/arduino-framework-platformio/core-3.3.11}"
packages_dir="${WIFIMANAGER_PLATFORMIO_PACKAGES_DIR:-$core_dir/packages}"
cache_dir="${WIFIMANAGER_PLATFORMIO_CACHE_DIR:-$core_dir/cache}"
install -d -m 700 "$core_dir" "$packages_dir" "$cache_dir"
PLATFORMIO_CORE_DIR="$core_dir" PLATFORMIO_PACKAGES_DIR="$packages_dir" \
PLATFORMIO_CACHE_DIR="$cache_dir" pio "$@"
PLATFORMIO_CACHE_DIR="$cache_dir" wm_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"
local firmware_a="$1" firmware_b="$2" firmware size capacity
[[ -s "$firmware_a" && -s "$firmware_b" ]] || {
echo "Portal OTA fixture build did not produce both A and B images." >&2
return 1
@@ -72,7 +78,7 @@ assert_ota_fixture_pair() {
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
echo "ESP32 OTA fixture $(basename "$firmware") is $size bytes; it exceeds the $capacity-byte app slot." >&2
return 1
}
done
@@ -80,10 +86,11 @@ assert_ota_fixture_pair() {
}
if [[ "$mode" == "hardware" ]]; then
# Keep serial flashing and portal-adapter work mutually exclusive.
# shellcheck source=tools/lib/portal-hardware-session.sh
source "$root/tools/lib/portal-hardware-session.sh"
wm_acquire_hardware_lock
# Unity uses only the named serial device; it does not own a portal AP or
# secondary adapter, so it may run beside an unrelated station test.
# shellcheck source=tools/lib/harness-locks.sh
source "$root/tools/lib/harness-locks.sh"
wm_harness_lock_serial_port "$port"
fi
if [[ "$mode" == "examples" ]]; then
@@ -100,14 +107,47 @@ if [[ "$mode" == "examples" ]]; then
fi
if [[ "$mode" == "ota-fixtures" ]]; then
fixture_platform="$platform"
if [[ "$fixture_platform" == "esp32" ]]; then
fixture_environment="${platform}_ota"
if [[ "$platform" == "esp32" ]]; then
"$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"
# The A/B marker is the only changed source input. Capture each resulting
# binary before rebuilding the same platform environment so CI proves the
# update images differ without paying for duplicate dependency builds.
fixture_dir="$(mktemp -d "${TMPDIR:-/tmp}/wifimanager-ota-fixtures.XXXXXX")"
chmod 700 "$fixture_dir"
export WIFIMANAGER_OTA_IDENTITY_DIR="$fixture_dir/identity"
fixture_build_dir="$fixture_dir/build/$fixture_environment"
wm_lock_ota_fixture_environment "$fixture_environment"
wm_write_ota_fixture_identity A
fixture_a="$fixture_dir/ota-a.bin"
fixture_b="$fixture_dir/ota-b.bin"
cleanup_ota_fixture_build() {
wm_remove_ota_fixture_identity
rm -rf -- "$fixture_dir"
}
on_ota_fixture_build_signal() {
local status="$1"
# Remove the generated identity before leaving. EXIT will call the
# same idempotent cleanup once more, which is intentional.
trap - HUP INT TERM
cleanup_ota_fixture_build
exit "$status"
}
trap cleanup_ota_fixture_build EXIT
trap 'on_ota_fixture_build_signal 129' HUP
trap 'on_ota_fixture_build_signal 130' INT
trap 'on_ota_fixture_build_signal 143' TERM
PLATFORMIO_BUILD_DIR="$fixture_dir/build" \
pio_for_platform run -d "$root/test/portal-harness" -e "$fixture_environment" </dev/null
install -m 600 "$fixture_build_dir/firmware.bin" "$fixture_a"
wm_write_ota_fixture_identity B
PLATFORMIO_BUILD_DIR="$fixture_dir/build" \
pio_for_platform run -d "$root/test/portal-harness" -e "$fixture_environment" </dev/null
install -m 600 "$fixture_build_dir/firmware.bin" "$fixture_b"
assert_ota_fixture_pair "$fixture_a" "$fixture_b"
echo "WiFiManager portal OTA fixture compile check passed for $platform"
exit 0
fi
+8 -6
View File
@@ -25,12 +25,14 @@ 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 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.
The physical portal HTTP OTA test harness uses one `*_ota` environment per
platform. It writes a harness-only A/B identity header to an ignored, owner-only
directory unique to that run before each build, so PlatformIO reuses dependency
objects while the fixture-only `/api/test/firmware-marker` endpoint still proves
the newly booted image. The test harness requires the real form's successful response, its automatic
restart, and two fresh B-marker responses. Passive serial capture is retained
for failure diagnosis, but a product log-message wording change cannot turn a
successful A-to-B update into a failed test.
```bash
./tools/portal-hardware ota \
+12 -22
View File
@@ -6,6 +6,9 @@ framework = arduino
lib_ldf_mode = deep+
lib_deps =
WiFiManager=symlink://../..
build_flags =
extra_scripts =
pre:tools/ota_fixture_input.py
[env:esp8266]
extends = common
@@ -27,34 +30,21 @@ build_flags =
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
-DWM_LOG_LEVEL=4
; 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.
[env:esp8266_ota_a]
; The OTA test harness writes its A/B marker into an ignored header included
; only by the portal fixture. One environment per platform keeps the explicit
; update layout while allowing the A-to-B rebuild to reuse every library object.
[env:esp8266_ota]
extends = env:esp8266
board_build.ldscript = eagle.flash.4m1m.ld
build_flags =
${env:esp8266.build_flags}
-DWM_OTA_TEST_IMAGE=\"A\"
${common.build_flags}
-DWM_PORTAL_OTA_TEST=1
[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]
[env:esp32_ota]
extends = env:esp32
board_build.partitions = partitions/esp32_ota_4m_no_fs.csv
build_flags =
${env:esp32.build_flags}
-DWM_OTA_TEST_IMAGE=\"A\"
[env:esp32_ota_b]
extends = env:esp32
board_build.partitions = partitions/esp32_ota_4m_no_fs.csv
build_flags =
${env:esp32.build_flags}
-DWM_OTA_TEST_IMAGE=\"B\"
${common.build_flags}
-DWM_PORTAL_OTA_TEST=1
+11 -9
View File
@@ -3,19 +3,21 @@
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 test-harness builds retain a descriptive fixture
// value.
#if defined(WM_OTA_TEST_IMAGE)
// These markers belong only to the portal OTA fixture. The hardware runner
// writes the ignored header immediately before each A/B build, so the marker
// is compiled into firmware rather than saved in WiFiManager settings.
#if defined(WM_PORTAL_OTA_TEST)
#include <ota_fixture_identity.h>
#ifndef WM_OTA_FIXTURE_IMAGE
#error "Portal OTA fixture identity is missing."
#endif
// PlatformIO releases the serial port only after the upload-triggered reset.
// The physical OTA test harness then attaches passively, so give it the same
// explicit window as the DeviceFramework A/B fixtures before boot evidence or
// portal work begins. Normal portal test-harness builds keep the short delay.
constexpr unsigned long kSerialMonitorAttachDelayMs = 5000UL;
#else
#define WM_OTA_TEST_IMAGE "portal-harness"
#define WM_OTA_FIXTURE_IMAGE "portal-harness"
constexpr unsigned long kSerialMonitorAttachDelayMs = 300UL;
#endif
@@ -87,7 +89,7 @@ void registerOtaTestMarker() {
server->on("/api/test/firmware-marker", HTTP_GET,
[](AsyncWebServerRequest* request) {
String response = F("{\"marker\":\"");
response += WM_OTA_TEST_IMAGE;
response += WM_OTA_FIXTURE_IMAGE;
response += F("\",\"freeSketchSpace\":");
response += String(ESP.getFreeSketchSpace());
response += F("}");
@@ -105,7 +107,7 @@ void setup() {
// 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);
Serial.println(WM_OTA_FIXTURE_IMAGE);
// This fixture must be independent of whichever sketch was previously
// flashed to the board. Clear saved station credentials before starting
@@ -0,0 +1,23 @@
"""Add the run-owned A/B identity directory to this portal test-harness build."""
import os
from SCons.Script import Exit
Import("env")
if env["PIOENV"].endswith("_ota"):
identity_dir = os.environ.get("WIFIMANAGER_OTA_IDENTITY_DIR")
if not identity_dir:
print(
"WIFIMANAGER_OTA_IDENTITY_DIR is required; "
"run this fixture through its named test harness."
)
Exit(1)
identity_header = os.path.join(identity_dir, "ota_fixture_identity.h")
if not os.path.isfile(identity_header):
print(f"OTA fixture identity header is missing: {identity_header}")
Exit(1)
env.Append(CPPPATH=[identity_dir])
+40 -6
View File
@@ -16,6 +16,9 @@ import serial
_stop_requested = False
IMMEDIATE_EMPTY_READ_SECONDS = 0.01
EMPTY_READ_BACKOFF_INITIAL_SECONDS = 0.01
EMPTY_READ_BACKOFF_MAX_SECONDS = 0.25
def request_stop(_signum, _frame):
@@ -50,7 +53,15 @@ def write_ready(path):
os.close(descriptor)
def capture(port, output, ready_file, should_stop=None):
def capture(
port,
output,
ready_file,
should_stop=None,
deadline_seconds=None,
clock=time.monotonic,
sleep=time.sleep,
):
"""Append serial bytes until terminated by the owning hardware runner."""
global _stop_requested
_stop_requested = False
@@ -64,15 +75,34 @@ def capture(port, output, ready_file, should_stop=None):
):
os.fchmod(output_file.fileno(), 0o600)
write_ready(ready_file)
deadline = None
if deadline_seconds is not None:
deadline = clock() + deadline_seconds
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
while not should_stop():
if deadline is not None and clock() >= deadline:
print("Passive serial capture reached its deadline.", file=sys.stderr)
return 2
read_started = clock()
data = serial_port.read(4096)
if data:
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
output_file.write(data)
continue
# A real serial port blocks for its configured timeout. A
# broken or detached backend can return an empty read
# immediately. Back off only in that pathological case, and
# reset after real data, so the recorder cannot become a
# CPU-bound loop without penalising normal serial timeouts.
if clock() - read_started < IMMEDIATE_EMPTY_READ_SECONDS:
sleep(empty_read_backoff)
empty_read_backoff = min(
empty_read_backoff * 2,
EMPTY_READ_BACKOFF_MAX_SECONDS,
)
else:
# 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)
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
except (OSError, serial.SerialException) as error:
print(f"Passive serial capture failed: {error}", file=sys.stderr)
return 1
@@ -84,11 +114,15 @@ def main():
parser.add_argument("--port", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--ready-file", required=True)
parser.add_argument("--deadline-seconds", type=float)
args = parser.parse_args()
if args.deadline_seconds is not None and args.deadline_seconds <= 0:
parser.error("--deadline-seconds must be greater than zero")
signal.signal(signal.SIGTERM, request_stop)
signal.signal(signal.SIGINT, request_stop)
return capture(args.port, args.output, args.ready_file)
return capture(args.port, args.output, args.ready_file, deadline_seconds=args.deadline_seconds)
if __name__ == "__main__":
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Resource locks shared by WiFiManager and DeviceFramework physical test harnesses.
#
# The protocol deliberately uses a stable, non-secret path and hash input so
# standalone checkouts still coordinate when they use the same host resources.
declare -A WM_HARNESS_LOCK_FDS=()
wm_harness_lock_root() {
local runtime_root
if [[ -n "${ARDUINO_TEST_HARNESS_LOCK_DIR:-}" ]]; then
printf '%s\n' "$ARDUINO_TEST_HARNESS_LOCK_DIR"
return 0
fi
runtime_root="${XDG_RUNTIME_DIR:-}"
if [[ -n "$runtime_root" && -d "$runtime_root" && -w "$runtime_root" ]]; then
printf '%s/arduino-framework-test-harness-locks\n' "$runtime_root"
else
printf '%s/arduino-framework-test-harness-locks\n' "${TMPDIR:-/tmp}"
fi
}
wm_harness_lock_resource() {
local label="$1" resource="$2" lock_root digest lock_file lock_fd
[[ -n "$label" && -n "$resource" ]] || {
echo "A test-harness resource lock needs a label and key." >&2
return 2
}
[[ -n "${WM_HARNESS_LOCK_FDS[$resource]:-}" ]] && return 0
command -v flock >/dev/null 2>&1 || {
echo "flock is required to protect test-harness resources." >&2
return 1
}
command -v sha256sum >/dev/null 2>&1 || {
echo "sha256sum is required to name test-harness resource locks." >&2
return 1
}
lock_root="$(wm_harness_lock_root)"
install -d -m 700 "$lock_root"
digest="$(printf '%s' "$resource" | sha256sum | awk '{print $1}')"
lock_file="$lock_root/${digest}.lock"
exec {lock_fd}>"$lock_file"
if ! flock -n "$lock_fd"; then
printf "Cannot start: %s is already in use by another local test-harness process.\n" "$label" >&2
return 1
fi
WM_HARNESS_LOCK_FDS["$resource"]="$lock_fd"
}
wm_harness_lock_serial_port() {
local port="$1" canonical
canonical="$(readlink -f -- "$port" 2>/dev/null || printf '%s' "$port")"
wm_harness_lock_resource "serial port $canonical" "serial-port:$canonical"
}
wm_harness_lock_portal_network() {
wm_harness_lock_resource "the 192.168.4.0/24 portal network" "portal-network:192.168.4.0/24"
}
wm_harness_lock_wifi_adapter() {
local interface="$1"
wm_harness_lock_resource "Wi-Fi adapter $interface" "wifi-adapter:$interface"
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Generated A/B identity used only by WiFiManager's portal OTA test harness.
#
# Keeping the identity in a tiny header lets PlatformIO reuse the one platform
# environment's library objects. The header is ignored and removed on every
# normal test-harness exit; it is never part of a user sketch or release.
declare -A WM_OTA_FIXTURE_LOCK_FDS=()
wm_lock_ota_fixture_environment() {
local environment="$1" lock_root lock_file lock_fd
[[ "$environment" =~ ^[A-Za-z0-9_-]+$ ]] || {
echo "Unsafe OTA fixture environment name: $environment" >&2
return 2
}
[[ -n "${WM_OTA_FIXTURE_LOCK_FDS[$environment]:-}" ]] && return 0
command -v flock >/dev/null 2>&1 || {
echo "flock is required to protect PlatformIO OTA fixture inputs." >&2
return 1
}
lock_root="$root/test/portal-harness/.pio/harness-locks"
install -d -m 700 "$lock_root"
lock_file="$lock_root/${environment}.lock"
exec {lock_fd}>"$lock_file"
if ! flock -n "$lock_fd"; then
echo "OTA fixture environment '$environment' is already in use by another local test-harness run." >&2
return 1
fi
WM_OTA_FIXTURE_LOCK_FDS["$environment"]="$lock_fd"
}
wm_ota_fixture_identity_dir() {
[[ -n "${WIFIMANAGER_OTA_IDENTITY_DIR:-}" ]] || {
echo "WIFIMANAGER_OTA_IDENTITY_DIR must be set before writing an OTA fixture identity." >&2
return 2
}
printf '%s\n' "$WIFIMANAGER_OTA_IDENTITY_DIR"
}
wm_ota_fixture_identity_header() {
printf '%s/ota_fixture_identity.h\n' "$(wm_ota_fixture_identity_dir)"
}
wm_write_ota_fixture_identity() {
local image="$1" directory header temporary
case "$image" in
A|B) ;;
*)
echo "WiFiManager OTA fixture identity must be A or B." >&2
return 2
;;
esac
directory="$(wm_ota_fixture_identity_dir)" || return
header="$(wm_ota_fixture_identity_header)"
install -d -m 700 "$directory"
temporary="$(mktemp "$directory/ota_fixture_identity.XXXXXX")"
chmod 600 "$temporary"
printf '%s\n' \
'#pragma once' \
'// Generated by the WiFiManager OTA test harness. Do not commit.' \
"#define WM_OTA_FIXTURE_IMAGE \"$image\"" \
> "$temporary"
mv -f -- "$temporary" "$header"
}
wm_remove_ota_fixture_identity() {
local directory header
[[ -n "${WIFIMANAGER_OTA_IDENTITY_DIR:-}" ]] || return 0
directory="$(wm_ota_fixture_identity_dir)" || return
header="$(wm_ota_fixture_identity_header)"
rm -f -- "$header"
rmdir -- "$directory" 2>/dev/null || true
}
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# PlatformIO discovery shared by WiFiManager's local test and hardware runners.
# Non-interactive shells, including an SSH hardware lab session, do not always
# include PlatformIO's standard virtual environment in PATH.
wm_pio_executable() {
local executable
if [[ -n "${WIFIMANAGER_PIO_EXECUTABLE:-}" ]]; then
executable="$WIFIMANAGER_PIO_EXECUTABLE"
else
executable="$(command -v pio 2>/dev/null || true)"
if [[ -z "$executable" && -x "$HOME/.platformio/penv/bin/pio" ]]; then
executable="$HOME/.platformio/penv/bin/pio"
fi
fi
[[ -n "$executable" && -x "$executable" ]] || {
echo "PlatformIO is required; install it or set WIFIMANAGER_PIO_EXECUTABLE." >&2
return 1
}
printf '%s\n' "$executable"
}
wm_pio_available() {
wm_pio_executable >/dev/null
}
wm_pio() {
local executable
executable="$(wm_pio_executable)" || return
"$executable" "$@"
}
+6 -16
View File
@@ -1,4 +1,6 @@
#!/usr/bin/env bash
# shellcheck source=tools/lib/harness-locks.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/harness-locks.sh"
# Shared host-side helpers for the WiFiManager portal hardware test harness.
# They never modify a network interface other than the explicit client adapter.
@@ -121,22 +123,9 @@ wm_default_route_interface() {
}
wm_acquire_hardware_lock() {
# First-party runners deliberately share this lock: a WiFiManager portal
# test and a DeviceFramework hardware test can otherwise serial-flash the
# same selected board concurrently. Keep the WiFiManager override for
# isolated tests and let callers redirect the common lock with TMPDIR.
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 test harness rather than
# only the serial flash and adapter connection.
[[ "${WM_HARDWARE_LOCK_HELD:-no}" == "yes" ]] && return 0
exec 9>"$lock_file"
flock -n 9 || {
echo "Another WiFiManager hardware task is already running; wait for it to finish." >&2
return 1
}
WM_HARDWARE_LOCK_HELD=yes
# All ordinary ESP portals use this gateway/subnet. Keep portal commands
# mutually exclusive even when they name different boards or adapters.
wm_harness_lock_portal_network
}
wm_require_client_adapter() {
@@ -162,6 +151,7 @@ wm_require_client_adapter() {
echo "Client adapter is not Wi-Fi: $interface ($device_type)." >&2
return 1
}
wm_harness_lock_wifi_adapter "$interface"
if ! active_connection="$(wm_nmcli -g GENERAL.CONNECTION device show "$interface" 2>/dev/null)"; then
echo "NetworkManager could not inspect the selected portal adapter: $interface" >&2
return 1
+129 -101
View File
@@ -2,8 +2,17 @@
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=tools/lib/platformio.sh
source "$root/tools/lib/platformio.sh"
# shellcheck source=tools/lib/portal-hardware-session.sh
source "$root/tools/lib/portal-hardware-session.sh"
# shellcheck source=tools/lib/ota-fixture-identity.sh
source "$root/tools/lib/ota-fixture-identity.sh"
# Portal tests exclusively own their portal network, selected Wi-Fi adapter,
# and named serial port. Avoid consuming every host core unless the developer
# explicitly opts in.
export PLATFORMIO_RUN_JOBS="${PLATFORMIO_RUN_JOBS:-2}"
usage() {
cat <<'USAGE' >&2
@@ -47,8 +56,7 @@ station_env=""
output_dir=""
capture_readme_media="no"
custom_parameter_stress="no"
ota_environment_a=""
ota_environment_b=""
ota_environment_name=""
ota_firmware_a=""
ota_firmware_b=""
ota_browser_prebuilt="no"
@@ -97,37 +105,6 @@ require_ota_serial_capture() {
}
}
wm_pio_executable() {
# Hardware runners are commonly launched over non-interactive SSH, where
# a normal PlatformIO installation is not necessarily on PATH. Honor an
# explicit override first, then PATH, then PlatformIO's standard venv.
# This mirrors DeviceFramework's resolver without changing a user's PATH.
local executable
if [[ -n "${WIFIMANAGER_PIO_EXECUTABLE:-}" ]]; then
executable="$WIFIMANAGER_PIO_EXECUTABLE"
else
executable="$(command -v pio 2>/dev/null || true)"
if [[ -z "$executable" && -x "$HOME/.platformio/penv/bin/pio" ]]; then
executable="$HOME/.platformio/penv/bin/pio"
fi
fi
[[ -n "$executable" && -x "$executable" ]] || {
echo "PlatformIO is required; install it or set WIFIMANAGER_PIO_EXECUTABLE." >&2
return 1
}
printf '%s\n' "$executable"
}
wm_pio_available() {
wm_pio_executable >/dev/null
}
wm_pio() {
local executable
executable="$(wm_pio_executable)" || return
"$executable" "$@"
}
pio_for_portal_environment() {
local environment="$1"
shift
@@ -135,7 +112,7 @@ pio_for_portal_environment() {
case "$environment" in
# Keep the maintained Core 3.3.11 graph in the same persistent cache
# as the ESP32 A/B fixture. It is never cleared by this test harness.
esp32|esp32_ota_*)
esp32|esp32_ota)
;;
*)
wm_pio "$@"
@@ -144,7 +121,7 @@ pio_for_portal_environment() {
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}"
core_dir="${WIFIMANAGER_PLATFORMIO_CORE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/arduino-framework-platformio/core-3.3.11}"
packages_dir="${WIFIMANAGER_PLATFORMIO_PACKAGES_DIR:-$core_dir/packages}"
cache_dir="${WIFIMANAGER_PLATFORMIO_CACHE_DIR:-$core_dir/cache}"
install -d -m 700 "$core_dir" "$packages_dir" "$cache_dir"
@@ -164,6 +141,13 @@ prepare_output_dir() {
output_dir="$(cd "$output_dir" && pwd)"
}
prepare_ota_identity_input() {
# The generated A/B marker belongs to this run, never to shared fixture
# source. Keeping it with the private artifacts prevents cross-run drift.
export WIFIMANAGER_OTA_IDENTITY_DIR="$output_dir/ota-fixture-input"
install -d -m 700 "$WIFIMANAGER_OTA_IDENTITY_DIR"
}
validate_run_arguments() {
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
[[ -n "$client_interface" ]] || usage
@@ -309,7 +293,7 @@ restore_station_handoff_fixture() {
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
! wait_for_portal_scan_ready; then
echo 'The clean portal-only fixture did not return after station hand-off cleanup.' >&2
return 1
fi
@@ -330,14 +314,33 @@ write_readme_media_manifest() {
chmod 600 "$media_dir/manifest.json"
}
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
# test harness asks the device to perform a second, user-initiated refresh.
portal_scan_status() {
curl --interface "$client_interface" --connect-timeout 3 --max-time 5 \
--silent --show-error --fail http://192.168.4.1/api/wifi/scan-status 2>/dev/null
}
wait_for_portal_http_ready() {
# The AP can be visible before the portal server has completed startup.
# OTA requires the update UI and its HTTP route; it does not require an
# unrelated background scan to have succeeded.
local attempt response
for attempt in $(seq 1 45); do
response="$(curl --interface "$client_interface" --connect-timeout 3 --max-time 5 \
--silent --show-error --fail http://192.168.4.1/api/wifi/scan-status 2>/dev/null || true)"
response="$(portal_scan_status || true)"
if [[ "$response" == *'"state"'* ]]; then
return 0
fi
sleep 1
done
echo 'Portal HTTP API did not become ready within 45 seconds.' >&2
return 1
}
wait_for_portal_scan_ready() {
# The normal portal UI suite exercises the automatic scan and still
# requires a usable result. Keep that coverage separate from HTTP OTA.
local attempt response
for attempt in $(seq 1 45); do
response="$(portal_scan_status || true)"
if [[ "$response" == *'"state":"complete"'* && "$response" == *'"results_valid":true'* ]]; then
return 0
fi
@@ -351,8 +354,18 @@ wait_for_portal_ready() {
return 1
}
report_initial_portal_scan_status() {
local response
response="$(portal_scan_status || true)"
if [[ -n "$response" ]]; then
printf 'Initial portal scan status: %s\n' "$response"
else
echo 'Initial portal scan status was unavailable after portal startup.' >&2
fi
}
start_portal_session() {
local environment="${1:-$platform}" erase_before_upload="${2:-no}" expected_a_artifact="${3:-}" capture_ota_serial="${4:-no}" ssid reconnect_after_drop="no"
local environment="${1:-$platform}" erase_before_upload="${2:-no}" expected_a_artifact="${3:-}" capture_ota_serial="${4:-no}" require_scan="${5:-yes}" ssid reconnect_after_drop="no"
validate_run_arguments
require_common
wm_acquire_hardware_lock
@@ -362,9 +375,17 @@ start_portal_session() {
# for an idle adapter and then replace a connection it does not own.
wm_prepare_networkmanager_authorization
wm_require_client_adapter "$client_interface" "$takeover"
wm_harness_lock_serial_port "$port"
prepare_output_dir
ssid="$(wm_portal_ssid "$platform")"
if [[ -n "$expected_a_artifact" ]]; then
# B was built most recently. Restore the generated A identity before
# PlatformIO verifies and serial-flashes the immutable A artifact.
wm_write_ota_fixture_identity A
pio_for_portal_environment "$environment" run -d "$root/test/portal-harness" -e "$environment"
ota_assert_a_artifact_matches_build "$expected_a_artifact"
fi
if [[ "$erase_before_upload" == "yes" ]]; then
# OTA selection metadata must not survive from a previous fixture run:
# otherwise a bootloader could select a stale app slot instead of A.
@@ -397,26 +418,43 @@ start_portal_session() {
wm_cleanup_created_connection
return 1
fi
if ! wait_for_portal_ready; then
if [[ "$require_scan" == yes ]]; then
wait_for_portal_scan_ready || {
wm_cleanup_created_connection
return 1
}
elif ! wait_for_portal_http_ready; then
wm_cleanup_created_connection
return 1
fi
[[ "$require_scan" == yes ]] || report_initial_portal_scan_status
printf 'Portal connected on %s. Artifacts: %s\n' "$client_interface" "$output_dir"
}
ota_environment() {
local image="$1"
printf '%s_ota_%s\n' "$platform" "$image"
printf '%s_ota\n' "$platform"
}
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"
marker|freeSketchSpace)
# The marker endpoint is deliberately fixture-only JSON. Parse it
# as JSON instead of maintaining a second, fragile copy of its
# wire format in a regular expression.
python3 -c '
import json
import sys
try:
value = json.load(sys.stdin)[sys.argv[1]]
except (json.JSONDecodeError, KeyError, TypeError):
raise SystemExit(1)
if isinstance(value, bool) or not isinstance(value, (str, int)):
raise SystemExit(1)
print(value)
' "$field" <<<"$response" 2>/dev/null || true
;;
*)
echo "Unknown OTA marker field: $field" >&2
@@ -515,31 +553,46 @@ ota_assert_firmware_fits() {
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
build_ota_image() {
local image="$1" source artifact
case "$image" in
A)
artifact="$output_dir/${platform}-portal-ota-a.bin"
ota_firmware_a="$artifact"
;;
B)
artifact="$output_dir/${platform}-portal-ota-b.bin"
ota_firmware_b="$artifact"
;;
*)
echo "Unknown portal OTA fixture image: $image" >&2
return 2
;;
esac
wm_write_ota_fixture_identity "$image"
pio_for_portal_environment "$ota_environment_name" run -d "$root/test/portal-harness" -e "$ota_environment_name"
source="$root/test/portal-harness/.pio/build/$ota_environment_name/firmware.bin"
[[ -s "$source" ]] || {
echo "PlatformIO did not produce portal OTA image $image." >&2
return 1
}
if cmp -s "$source_a" "$source_b"; then
install -m 600 "$source" "$artifact"
}
prepare_ota_firmware() {
local capacity
ota_environment_name="$(ota_environment)"
wm_lock_ota_fixture_environment "$ota_environment_name"
# Capture immutable A before rebuilding the same environment as B. The
# generated header is the only changed input, so PlatformIO reuses library
# objects while the browser still mounts a distinct B artifact read-only.
build_ota_image A
build_ota_image B
if cmp -s "$ota_firmware_a" "$ota_firmware_b"; then
echo "PlatformIO produced identical OTA A and B images." >&2
return 1
fi
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"
@@ -551,7 +604,7 @@ prepare_ota_firmware() {
ota_assert_a_artifact_matches_build() {
local expected_artifact="$1" source
source="$root/test/portal-harness/.pio/build/$ota_environment_a/firmware.bin"
source="$root/test/portal-harness/.pio/build/$ota_environment_name/firmware.bin"
[[ -s "$expected_artifact" && -s "$source" ]] || {
echo "Prepared OTA A artifact or PlatformIO build output is missing." >&2
return 1
@@ -633,6 +686,7 @@ start_ota_serial_capture() {
--port "$port" \
--output "$ota_serial_capture_log" \
--ready-file "$ota_serial_capture_ready" \
--deadline-seconds 600 \
>>"$ota_serial_capture_status" 2>&1 &
ota_serial_capture_pid=$!
@@ -658,30 +712,6 @@ require_ota_serial_capture_running() {
}
}
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
@@ -693,10 +723,6 @@ stop_ota_serial_capture() {
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() {
@@ -717,6 +743,7 @@ cleanup_portal_session() {
# a recursive EXIT trap must not obscure the original status.
trap - EXIT HUP INT TERM
stop_ota_serial_capture || true
wm_remove_ota_fixture_identity
if ! restore_station_handoff_fixture; then
(( status != 0 )) || status=1
fi
@@ -826,9 +853,10 @@ case "$command_name" in
wm_require_client_adapter "$client_interface" "$takeover"
require_ota_serial_capture
prepare_output_dir
prepare_ota_identity_input
prepare_ota_firmware
prepare_ota_harness_image
start_portal_session "$ota_environment_a" yes "$ota_firmware_a" yes
start_portal_session "$ota_environment_name" yes "$ota_firmware_a" yes no
# A must be visible through the real portal before a B upload can be
# meaningful. On ESP8266 the marker also reports the actual active
+71
View File
@@ -73,6 +73,26 @@ class FakeSerial:
self.close()
class FakeClock:
def __init__(self):
self.value = 0.0
def __call__(self):
return self.value
def advance(self, seconds):
self.value += seconds
class BlockingEmptySerial(FakeSerial):
clock = None
def read(self, _size):
type(self).read_calls += 1
type(self).clock.advance(0.25)
return b""
def load_capture_module():
fake_serial_module = types.ModuleType("serial")
fake_serial_module.Serial = FakeSerial
@@ -126,6 +146,57 @@ def main():
assert ("open", False, False, "/dev/fake") in FakeSerial.events
assert ("close",) in FakeSerial.events
assert FakeSerial.read_calls == 2
# An immediate empty-return backend must back off progressively instead of
# spinning. This takes no real sleep because the sleeper is injected.
FakeSerial.read_calls = 0
with tempfile.TemporaryDirectory() as temporary_directory:
output = Path(temporary_directory) / "serial-ota.log"
ready = Path(temporary_directory) / "serial-ota.ready"
empty_sleeps = []
assert module.capture(
"/dev/fake",
output,
ready,
should_stop=lambda: FakeSerial.read_calls >= 5,
sleep=empty_sleeps.append,
) == 0
assert empty_sleeps == [0.01, 0.02, 0.04, 0.08]
# A normal 250 ms serial timeout is already rate-limited by the device
# driver, so it must not receive an additional backoff sleep.
module.serial.Serial = BlockingEmptySerial
BlockingEmptySerial.events = []
BlockingEmptySerial.read_calls = 0
clock = FakeClock()
BlockingEmptySerial.clock = clock
with tempfile.TemporaryDirectory() as temporary_directory:
output = Path(temporary_directory) / "serial-ota.log"
ready = Path(temporary_directory) / "serial-ota.ready"
empty_sleeps = []
assert module.capture(
"/dev/fake",
output,
ready,
should_stop=lambda: BlockingEmptySerial.read_calls >= 2,
clock=clock,
sleep=empty_sleeps.append,
) == 0
assert empty_sleeps == []
module.serial.Serial = FakeSerial
FakeSerial.read_calls = 0
with tempfile.TemporaryDirectory() as temporary_directory:
output = Path(temporary_directory) / "serial-ota.log"
ready = Path(temporary_directory) / "serial-ota.ready"
assert module.capture(
"/dev/fake",
output,
ready,
should_stop=lambda: False,
deadline_seconds=0.0,
) == 2
print("WiFiManager passive OTA serial-capture test-harness check passed")
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Check OTA fixture identity and environment locking without PlatformIO or a board.
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
temporary_root="$(mktemp -d "${TMPDIR:-/tmp}/wifimanager-ota-identity-test.XXXXXX")"
chmod 700 "$temporary_root"
cleanup() {
rm -rf -- "$temporary_root"
}
trap cleanup EXIT HUP INT TERM
export ARDUINO_TEST_HARNESS_LOCK_DIR="$temporary_root/locks"
export WIFIMANAGER_OTA_IDENTITY_DIR="$temporary_root/identity"
# shellcheck source=tools/lib/ota-fixture-identity.sh
source "$root/tools/lib/ota-fixture-identity.sh"
# shellcheck source=tools/lib/harness-locks.sh
source "$root/tools/lib/harness-locks.sh"
wm_write_ota_fixture_identity A
identity_file="$WIFIMANAGER_OTA_IDENTITY_DIR/ota_fixture_identity.h"
[[ "$(stat -c '%a' "$WIFIMANAGER_OTA_IDENTITY_DIR")" == 700 ]]
[[ "$(stat -c '%a' "$identity_file")" == 600 ]]
rg -Fqx '#define WM_OTA_FIXTURE_IMAGE "A"' "$identity_file"
wm_lock_ota_fixture_environment esp8266_ota
collision_output="$temporary_root/lock-collision.log"
if (
root="$root"
export WIFIMANAGER_OTA_IDENTITY_DIR="$temporary_root/other-identity"
# shellcheck source=tools/lib/ota-fixture-identity.sh
source "$root/tools/lib/ota-fixture-identity.sh"
wm_lock_ota_fixture_environment esp8266_ota
) 2>"$collision_output"; then
echo "A second test-harness process unexpectedly acquired the same OTA environment lock." >&2
exit 1
fi
rg -Fqx "OTA fixture environment 'esp8266_ota' is already in use by another local test-harness run." "$collision_output"
wm_harness_lock_resource "test resource" "identity-test:shared-resource"
if (
# shellcheck source=tools/lib/harness-locks.sh
source "$root/tools/lib/harness-locks.sh"
wm_harness_lock_resource "test resource" "identity-test:shared-resource"
) 2>"$collision_output"; then
echo "A second test-harness process unexpectedly acquired the same resource lock." >&2
exit 1
fi
rg -Fqx 'Cannot start: test resource is already in use by another local test-harness process.' "$collision_output"
wm_remove_ota_fixture_identity
[[ ! -e "$identity_file" ]]
echo "WiFiManager OTA fixture identity test-harness check passed"