From 360b7cff862b17dc28bafad669656faf9a30724e Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Sun, 20 Sep 2026 19:44:05 +1000 Subject: [PATCH] fix: harden portal OTA release --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 4 + README.md | 2 +- docs/DEVELOPMENT.md | 18 +- docs/GETTING_STARTED.md | 2 +- docs/TESTING.md | 26 +- scripts/prepare-release.sh | 4 +- scripts/test.sh | 80 ++++-- test/portal-harness/README.md | 14 +- test/portal-harness/platformio.ini | 34 +-- test/portal-harness/src/main.cpp | 20 +- .../portal-harness/tools/ota_fixture_input.py | 23 ++ tools/capture-serial.py | 46 +++- tools/lib/harness-locks.sh | 63 +++++ tools/lib/ota-fixture-identity.sh | 74 ++++++ tools/lib/platformio.sh | 31 +++ tools/lib/portal-hardware-session.sh | 22 +- tools/portal-hardware | 230 ++++++++++-------- tools/tests/test-capture-serial.py | 71 ++++++ tools/tests/test-ota-fixture-identity.sh | 53 ++++ 20 files changed, 621 insertions(+), 198 deletions(-) create mode 100644 test/portal-harness/tools/ota_fixture_input.py create mode 100644 tools/lib/harness-locks.sh create mode 100644 tools/lib/ota-fixture-identity.sh create mode 100644 tools/lib/platformio.sh create mode 100644 tools/tests/test-ota-fixture-identity.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fb7a44..b7746c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index f00a032..af19d6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 4021a16..519ba87 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index bd7e4b9..2c20a18 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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 diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 35ab345..4d7918f 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -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. diff --git a/docs/TESTING.md b/docs/TESTING.md index 01a97b5..898937c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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. diff --git a/scripts/prepare-release.sh b/scripts/prepare-release.sh index 02ced1a..a540e18 100755 --- a/scripts/prepare-release.sh +++ b/scripts/prepare-release.sh @@ -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 diff --git a/scripts/test.sh b/scripts/test.sh index a939a85..feb37a1 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -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}" 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 + #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 diff --git a/test/portal-harness/tools/ota_fixture_input.py b/test/portal-harness/tools/ota_fixture_input.py new file mode 100644 index 0000000..ad44653 --- /dev/null +++ b/test/portal-harness/tools/ota_fixture_input.py @@ -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]) diff --git a/tools/capture-serial.py b/tools/capture-serial.py index 13a4649..7e7029e 100644 --- a/tools/capture-serial.py +++ b/tools/capture-serial.py @@ -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__": diff --git a/tools/lib/harness-locks.sh b/tools/lib/harness-locks.sh new file mode 100644 index 0000000..4c43ea8 --- /dev/null +++ b/tools/lib/harness-locks.sh @@ -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" +} diff --git a/tools/lib/ota-fixture-identity.sh b/tools/lib/ota-fixture-identity.sh new file mode 100644 index 0000000..be0ed65 --- /dev/null +++ b/tools/lib/ota-fixture-identity.sh @@ -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 +} diff --git a/tools/lib/platformio.sh b/tools/lib/platformio.sh new file mode 100644 index 0000000..7a9b3d3 --- /dev/null +++ b/tools/lib/platformio.sh @@ -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" "$@" +} diff --git a/tools/lib/portal-hardware-session.sh b/tools/lib/portal-hardware-session.sh index 5d18c08..7e3e566 100755 --- a/tools/lib/portal-hardware-session.sh +++ b/tools/lib/portal-hardware-session.sh @@ -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 diff --git a/tools/portal-hardware b/tools/portal-hardware index a72c108..576c039 100755 --- a/tools/portal-hardware +++ b/tools/portal-hardware @@ -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 diff --git a/tools/tests/test-capture-serial.py b/tools/tests/test-capture-serial.py index b2982f4..6b303ba 100644 --- a/tools/tests/test-capture-serial.py +++ b/tools/tests/test-capture-serial.py @@ -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") diff --git a/tools/tests/test-ota-fixture-identity.sh b/tools/tests/test-ota-fixture-identity.sh new file mode 100644 index 0000000..9e2ef6a --- /dev/null +++ b/tools/tests/test-ota-fixture-identity.sh @@ -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"