test: add portal hardware lifecycle coverage

This commit is contained in:
2026-09-06 20:03:38 +10:00
parent fcd991c5a2
commit d57f6c91c5
15 changed files with 595 additions and 20 deletions
+7
View File
@@ -25,6 +25,13 @@ Start a release with `bump-version.sh`. It updates package metadata and canonica
./scripts/prepare-release.sh vMAJOR.MINOR.PATCH --tag
```
When a physical ESP8266 and ESP32 are available, include their local lifecycle tests in the release gate. These tests remain opt-in because they flash the selected board and use its actual radio:
~~~bash
./scripts/test.sh hardware --platform esp8266 --port /dev/serial/by-id/usb-...
./scripts/test.sh hardware --platform esp32 --port /dev/serial/by-id/usb-...
~~~
Push the branch and annotated tag. GitHub Actions repeats the board-free compile checks, validates the package, and creates a GitHub Release using that version’s changelog section. The workflow does not publish to the PlatformIO Registry.
Back to [documentation](README.md) · [project overview](../README.md).
+32 -1
View File
@@ -9,6 +9,37 @@ check, so dependency resolution uses the current manifest rather than a stale
./scripts/test.sh compile --platform esp32
```
CI runs both checks for pushes to the maintained branch and pull requests. They compile only; hardware portals remain a local integration concern for the consuming application.
## Example builds
```bash
./scripts/test.sh examples --platform esp8266
./scripts/test.sh examples --platform esp32
```
CI runs the clean-consumer and example checks for pushes to the maintained branch and pull requests. It intentionally compiles only: attached boards, local network state, and browser installation are not CI requirements.
## Local hardware lifecycle tests
The existing Unity suite runs on a board without Wi-Fi credentials, MQTT, a DeviceFramework checkout, or a local profile. It verifies portal start and stop lifecycle recovery, scan-cache release, and a real asynchronous Wi-Fi scan.
Use a stable serial-by-id path rather than a changing /dev/ttyUSB number:
~~~bash
pio device list
./scripts/test.sh hardware --platform esp8266 --port /dev/serial/by-id/usb-...
./scripts/test.sh hardware --platform esp32 --port /dev/serial/by-id/usb-...
~~~
The runner uploads the test image, captures its normal-boot serial output with the repository Bash helper, requires Unity's Tests 0 Failures and OK summary, and prints WM_METRIC lifecycle measurements. It does not need or read an environment file. A failed capture is preserved under /tmp for inspection; a successful one is removed.
## Optional portal browser test
test/portal-harness is a deliberately tiny portal-only firmware. It is not an example or a consuming application. The browser runner flashes it to the selected board, joins the portal with an explicitly named secondary Wi-Fi adapter, checks root HTML and bootstrap JSON, exercises concurrent root/bootstrap responses, and verifies an asynchronous scan. When a compatible local Chromium binary is available, it also captures the portal and records browser-console output.
~~~bash
./tools/test-portal-browser.sh --platform esp8266 --port /dev/serial/by-id/usb-... --wifi-interface wlx74da385d4165 --output /tmp/wifimanager-browser-results
~~~
The runner refuses an interface that owns the host's default route. It creates a temporary NetworkManager connection with never-default before bringing it up, so portal traffic stays on the specified adapter and does not replace the host Internet route. The temporary connection is removed on exit. Use a non-default secondary adapter only.
Back to [documentation](README.md) · [project overview](../README.md).
+4
View File
@@ -1012,6 +1012,10 @@ protected:
void failAsyncScan(wm_scan_state_t state, int scanResult = WIFI_SCAN_FAILED);
void resetAsyncScan(bool clearResults);
void invalidateScanResults();
// Release cached scan-result vector capacity after a portal fully closes.
// Do not use this during normal refreshes: the cache is intentionally kept
// while the portal is active so the UI can render nearby networks.
void releaseScanResultStorage();
bool hasFreshScanResults(unsigned int cachetime) const;
bool canRunAsyncScan() const;
void cacheScanResults(int networksFound);
+10
View File
@@ -1373,6 +1373,7 @@ bool WiFiManager::shutdownConfigPortal(){
}
resetAsyncScan(true);
releaseScanResultStorage();
if(!configPortalActive) return false;
@@ -1954,6 +1955,15 @@ void WiFiManager::resetAsyncScan(bool clearResults) {
_scan.visibleNetworkCount = _numNetworks;
}
}
void WiFiManager::releaseScanResultStorage() {
// clear() releases String payloads but deliberately retains the vector's
// backing allocation. At portal shutdown that cache has no remaining value,
// so release it with the server/DNS objects rather than carrying it into the
// normal connected runtime. Keep this out of the refresh path to avoid
// needless allocation churn while a user is looking at nearby networks.
std::vector<WiFiScanNetwork>().swap(_scanResultsCache);
}
void WiFiManager::invalidateScanResults() {
_scan.resultsValid = false;
+9 -11
View File
@@ -16,10 +16,6 @@
#include <cstring>
#if defined(ESP8266) || defined(ESP32)
#ifndef WM_PAGE_RESERVE_BYTES
#define WM_PAGE_RESERVE_BYTES 8192
#endif
static void jsonAppendEscaped(String& out, const String& s) {
for (size_t i = 0; i < s.length(); i++) {
const char c = s[i];
@@ -56,7 +52,7 @@ namespace {
const char kEmptyPortalPlaceholder[] PROGMEM = "";
inline void reservePage(String& page, size_t extraBytes = WM_PAGE_RESERVE_BYTES) {
inline void reservePage(String& page, size_t extraBytes) {
if (extraBytes == 0) return;
const size_t targetLen = page.length() + extraBytes;
(void)page.reserve(targetLen);
@@ -259,21 +255,23 @@ void WiFiManagerHandlers::collectVisibleScanResults(std::vector<const WiFiManage
}
if (_wm->_removeDuplicateAPs) {
std::vector<const WiFiManager::WiFiScanNetwork*> deduped;
deduped.reserve(networks.size());
// Reuse the pointer storage that was already reserved above. Building a
// second vector briefly doubles this request's scan-list allocation on
// constrained ESP8266 heaps.
size_t kept = 0;
for (const auto* network : networks) {
bool duplicate = false;
for (const auto* existing : deduped) {
if (existing->ssid == network->ssid) {
for (size_t i = 0; i < kept; ++i) {
if (networks[i]->ssid == network->ssid) {
duplicate = true;
break;
}
}
if (!duplicate) {
deduped.push_back(network);
networks[kept++] = network;
}
}
networks.swap(deduped);
networks.resize(kept);
}
}
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 --port /dev/serial/by-id/... [--timeout seconds]" >&2
exit 2
}
port=""
timeout_seconds=300
while [[ $# -gt 0 ]]; do
case "$1" in
--port) [[ $# -ge 2 ]] || usage; port="${2:-}"; shift 2 ;;
--timeout) [[ $# -ge 2 ]] || usage; timeout_seconds="${2:-}"; shift 2 ;;
*) usage ;;
esac
done
[[ -n "$port" && -e "$port" ]] || usage
[[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || usage
capture_file="$(mktemp -p /tmp wifimanager-unity.XXXXXX)"
monitor_pid=""
preserve_capture=false
cleanup() {
if [[ -n "$monitor_pid" ]] && kill -0 "$monitor_pid" 2>/dev/null; then
kill "$monitor_pid" 2>/dev/null || true
wait "$monitor_pid" 2>/dev/null || true
fi
if [[ "$preserve_capture" == "false" ]]; then
rm -f "$capture_file"
fi
}
trap cleanup EXIT
# Start immediately after upload. PlatformIO's interactive monitor cannot run
# without a TTY; socat opens only this port and streams its configured 115200
# baud output into the capture file without touching another board.
timeout --foreground "$timeout_seconds" socat -u "FILE:$port,raw,echo=0,b115200" STDOUT \
>"$capture_file" 2>&1 &
monitor_pid="$!"
while kill -0 "$monitor_pid" 2>/dev/null; do
if grep -aqE '[0-9]+ Tests [0-9]+ Failures' "$capture_file"; then
kill "$monitor_pid" 2>/dev/null || true
wait "$monitor_pid" 2>/dev/null || true
monitor_pid=""
if grep -aq "Tests 0 Failures" "$capture_file" && grep -aq "^OK" "$capture_file"; then
grep -aE '\[METRIC\]|Tests [0-9]+ Failures|^OK$' "$capture_file" || true
exit 0
fi
echo "Unity reported a test failure:" >&2
grep -anE ':FAIL|FAIL$|\[METRIC\]|Tests [0-9]+ Failures' "$capture_file" >&2 || true
tail -n 80 "$capture_file" >&2 || true
preserve_capture=true
echo "Full serial capture retained at $capture_file" >&2
exit 1
fi
sleep 0.25
done
wait "$monitor_pid" || true
monitor_pid=""
preserve_capture=true
echo "Serial monitoring ended before Unity produced a summary; capture retained at $capture_file" >&2
exit 1
+33 -4
View File
@@ -2,16 +2,35 @@
set -euo pipefail
usage() {
echo "Usage: $0 compile|examples --platform esp8266|esp32" >&2
cat <<'EOF' >&2
Usage:
./scripts/test.sh compile --platform esp8266|esp32
./scripts/test.sh examples --platform esp8266|esp32
./scripts/test.sh hardware --platform esp8266|esp32 --port /dev/serial/by-id/...
EOF
exit 2
}
[[ $# -eq 3 && ( "${1:-}" == "compile" || "${1:-}" == "examples" ) && "${2:-}" == "--platform" ]] || usage
platform="${3:-}"
mode="${1:-}"
[[ "$mode" == "compile" || "$mode" == "examples" || "$mode" == "hardware" ]] || usage
shift
platform=""
port=""
while [[ $# -gt 0 ]]; do
case "$1" in
--platform) [[ $# -ge 2 ]] || usage; platform="${2:-}"; shift 2 ;;
--port) [[ $# -ge 2 ]] || usage; port="${2:-}"; shift 2 ;;
*) usage ;;
esac
done
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
[[ "$mode" != "hardware" || -n "$port" ]] || usage
[[ "$mode" != "hardware" || -e "$port" ]] || { echo "Serial port not found: $port" >&2; exit 1; }
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ "$1" == "examples" ]]; then
if [[ "$mode" == "examples" ]]; then
mapfile -t examples < <(find "$root/examples" -mindepth 2 -maxdepth 2 -type f -name platformio.ini -printf '%h\n' | sort)
if (( ${#examples[@]} == 0 )); then
echo "No example projects found" >&2
@@ -24,6 +43,16 @@ if [[ "$1" == "examples" ]]; then
exit 0
fi
if [[ "$mode" == "hardware" ]]; then
# Upload first, then capture from the normal boot reset. The Unity sketch
# deliberately waits two seconds before it begins its test sequence.
pio test -d "$root" -e "$platform" --filter test_wifimanager \
--upload-port "$port" --without-testing
"$root/scripts/capture-unity-serial.sh" --port "$port" --timeout 300
echo "WiFiManager hardware test passed for $platform on $port"
exit 0
fi
cached_library="$root/test/compile-project/.pio/libdeps/${platform}/WiFiManager"
# The fixture intentionally declares only this local package. Remove a prior
# link so each check resolves the current manifest as a fresh consumer would.
+17
View File
@@ -0,0 +1,17 @@
# Portal browser harness
This test fixture starts only the WiFiManager captive portal. It intentionally has no station credentials, MQTT configuration, DeviceFramework dependency, or application logic.
Use it through the repository runner so a secondary Wi-Fi adapter is explicitly selected and protected from becoming the host default route:
```bash
./tools/test-portal-browser.sh \
--platform esp8266 \
--port /dev/serial/by-id/... \
--wifi-interface wlx... \
--output /tmp/wifimanager-browser
```
The ESP8266 portal SSID is `WM Browser ESP8266`; the ESP32 SSID is `WM Browser ESP32`. Both use `default1` exclusively for local development tests.
The runner cleans up only the temporary connection it creates on the named secondary interface. It refuses to run if that interface is the system default route.
+28
View File
@@ -0,0 +1,28 @@
[platformio]
default_envs = esp8266
[common]
framework = arduino
lib_ldf_mode = deep+
lib_deps =
WiFiManager=symlink://../..
[env:esp8266]
extends = common
platform = espressif8266
board = d1_mini
platform_packages =
platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3
build_flags =
-DWM_LOG_LEVEL=4
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
-std=gnu++14
-DSOC_WIFI_SUPPORTED=1
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
-DWM_LOG_LEVEL=4
+33
View File
@@ -0,0 +1,33 @@
#include <Arduino.h>
#include <WiFiManager.h>
namespace {
#if defined(ESP8266)
constexpr char kPortalSsid[] = "WM Browser ESP8266";
#else
constexpr char kPortalSsid[] = "WM Browser ESP32";
#endif
constexpr char kPortalPassword[] = "default1";
WiFiManager wifi;
} // namespace
void setup() {
Serial.begin(115200);
delay(300);
// This harness is deliberately portal-only: it has no saved station
// credentials and never exercises a consuming application.
wifi.setConfigPortalTimeout(0);
wifi.setAPStaticIPConfig(
IPAddress(192, 168, 4, 1),
IPAddress(192, 168, 4, 1),
IPAddress(255, 255, 255, 0));
wifi.startConfigPortal(kPortalSsid, kPortalPassword);
}
void loop() {
wifi.process();
}
+3
View File
@@ -105,6 +105,7 @@ TestCase tests[] = {
TEST_ENTRY(test_scan_cancels_when_lifecycle_blocked),
TEST_ENTRY(test_scan_generation_invalidated_on_reset),
TEST_ENTRY(test_real_async_scan_completes),
// Template rendering tests
TEST_ENTRY(test_shell_template_renders_core_placeholders),
TEST_ENTRY(test_shell_template_renders_dynamic_theme_with_percent_values),
@@ -128,6 +129,8 @@ TestCase tests[] = {
TEST_ENTRY(test_rapid_portal_start_stop),
TEST_ENTRY(test_multiple_parameters_stress),
TEST_ENTRY(test_portal_with_timeout_stress),
TEST_ENTRY(test_portal_resource_recovery),
TEST_ENTRY(test_scan_result_storage_released_when_portal_closes),
};
const size_t TEST_COUNT = sizeof(tests) / sizeof(TestCase);
+3
View File
@@ -107,6 +107,7 @@ void test_scan_completion_wait();
void test_scan_cancels_when_connect_pending();
void test_scan_cancels_when_lifecycle_blocked();
void test_scan_generation_invalidated_on_reset();
void test_real_async_scan_completes();
// Template rendering tests
void test_shell_template_renders_core_placeholders();
@@ -141,6 +142,8 @@ void test_long_running_portal();
void test_rapid_portal_start_stop();
void test_multiple_parameters_stress();
void test_portal_with_timeout_stress();
void test_portal_resource_recovery();
void test_scan_result_storage_released_when_portal_closes();
#endif // TEST_MAIN_H
@@ -4,7 +4,8 @@
// Test process() doesn't block - verify it must be called periodically
void test_nonblocking_process() {
Serial.println("[TEST] Testing process() calls (non-blocking behavior)...");
constexpr unsigned long kMaximumProcessDurationMs = 250;
unsigned long maximumObservedDurationMs = 0;
WiFiManager wm;
@@ -18,10 +19,13 @@ void test_nonblocking_process() {
for (int i = 0; i < 10; i++) {
unsigned long start = millis();
wm.process();
unsigned long elapsed = millis() - start;
const unsigned long elapsed = millis() - start;
maximumObservedDurationMs = max(maximumObservedDurationMs, elapsed);
// Each call should be fast (< 100ms) - verifies non-blocking behavior
TEST_ASSERT_LESS_THAN(100, elapsed);
// A normal cooperative call is quick, but DNS and Wi-Fi service may
// briefly run on ESP8266. This still catches a truly blocking portal.
TEST_ASSERT_LESS_THAN_MESSAGE(kMaximumProcessDurationMs, elapsed,
"process() exceeded its bounded service time");
delay(10);
}
@@ -31,6 +35,7 @@ void test_nonblocking_process() {
wm.stopConfigPortal();
Serial.printf("[METRIC] WM_PROCESS max_elapsed_ms=%lu\n", maximumObservedDurationMs);
Serial.println("[TEST] Non-blocking process() test completed successfully");
}
@@ -0,0 +1,186 @@
#include <Arduino.h>
#include <WiFiManager.h>
#include <unity.h>
#include <vector>
#include "../test_main.h"
namespace {
struct HeapSnapshot {
uint32_t freeHeap;
uint32_t largestBlock;
uint8_t fragmentation;
};
HeapSnapshot captureHeap(const char* label) {
HeapSnapshot snapshot{
ESP.getFreeHeap(),
#if defined(ESP8266)
ESP.getMaxFreeBlockSize(),
ESP.getHeapFragmentation(),
#else
ESP.getMaxAllocHeap(),
0,
#endif
};
Serial.printf("[METRIC] WM_HEAP label=%s free=%lu largest=%lu fragmentation=%u\n",
label,
static_cast<unsigned long>(snapshot.freeHeap),
static_cast<unsigned long>(snapshot.largestBlock),
snapshot.fragmentation);
return snapshot;
}
uint32_t allowedHeapDrift() {
#if defined(ESP8266)
return 1024;
#else
return 4096;
#endif
}
uint32_t allowedLargestBlockDrift() {
#if defined(ESP8266)
return 1024;
#else
return 4096;
#endif
}
void assertRecovered(const HeapSnapshot& settled, const HeapSnapshot& final) {
const uint32_t freeFloor = settled.freeHeap > allowedHeapDrift()
? settled.freeHeap - allowedHeapDrift()
: 0;
const uint32_t blockFloor = settled.largestBlock > allowedLargestBlockDrift()
? settled.largestBlock - allowedLargestBlockDrift()
: 0;
TEST_ASSERT_GREATER_OR_EQUAL_UINT32_MESSAGE(
freeFloor, final.freeHeap,
"Portal start/stop cycles retained too much heap after warm-up");
TEST_ASSERT_GREATER_OR_EQUAL_UINT32_MESSAGE(
blockFloor, final.largestBlock,
"Portal start/stop cycles degraded the largest contiguous allocation");
}
void startAndStopPortal(WiFiManager& wm, const char* ssid) {
wm.startConfigPortal(ssid);
wm.process();
TEST_ASSERT_TRUE_MESSAGE(wm.getConfigPortalActive(), "Portal should start");
TEST_ASSERT_NOT_NULL_MESSAGE(wm.getServer(), "Portal server should exist");
TEST_ASSERT_NOT_NULL_MESSAGE(wm.getDNSServer(), "Portal DNS server should exist");
wm.stopConfigPortal();
wm.process();
TEST_ASSERT_FALSE_MESSAGE(wm.getConfigPortalActive(), "Portal should stop");
TEST_ASSERT_NULL_MESSAGE(wm.getServer(), "Portal server should be released");
TEST_ASSERT_NULL_MESSAGE(wm.getDNSServer(), "Portal DNS server should be released");
}
} // namespace
void test_portal_resource_recovery() {
Serial.println("[TEST] Measuring portal resource recovery...");
WiFiManager wm;
wm.setConfigPortalTimeout(20);
// Warm the Wi-Fi core once before establishing the comparison point. The
// SDK is allowed to retain its own one-time allocations; the test detects
// repeated decline after that settled point instead.
startAndStopPortal(wm, "WM-Memory-Warmup");
delay(200);
const HeapSnapshot settled = captureHeap("settled");
for (uint8_t cycle = 0; cycle < 20; ++cycle) {
const String ssid = String("WM-Memory-") + String(cycle);
wm.startConfigPortal(ssid.c_str());
wm.process();
TEST_ASSERT_TRUE_MESSAGE(wm.getConfigPortalActive(), "Portal should start during cycle");
TEST_ASSERT_NOT_NULL_MESSAGE(wm.getServer(), "Server should exist during cycle");
TEST_ASSERT_NOT_NULL_MESSAGE(wm.getDNSServer(), "DNS should exist during cycle");
if (cycle == 0) {
captureHeap("portal-active");
}
wm.stopConfigPortal();
wm.process();
TEST_ASSERT_FALSE_MESSAGE(wm.getConfigPortalActive(), "Portal should stop during cycle");
TEST_ASSERT_NULL_MESSAGE(wm.getServer(), "Server should be released during cycle");
TEST_ASSERT_NULL_MESSAGE(wm.getDNSServer(), "DNS should be released during cycle");
captureHeap("cycle-stopped");
}
delay(200);
const HeapSnapshot final = captureHeap("final");
assertRecovered(settled, final);
Serial.println("[TEST] Portal resource recovery test completed successfully");
}
void test_scan_result_storage_released_when_portal_closes() {
Serial.println("[TEST] Testing scan-result storage release on portal close...");
WiFiManager wm;
wm.setConfigPortalTimeout(20);
wm.startConfigPortal("WM-Scan-Storage");
wm.process();
#ifdef UNIT_TEST
std::vector<WiFiManager::WiFiScanNetwork> results;
for (uint8_t i = 0; i < 24; ++i) {
results.push_back({String("Network-") + String(i), -30 - i, static_cast<uint8_t>(i % 2)});
}
wm.wmTestInjectScanResults(results);
captureHeap("scan-cache-held");
TEST_ASSERT_GREATER_THAN_UINT32(0, wm.getScanResults().capacity());
#endif
wm.stopConfigPortal();
wm.process();
TEST_ASSERT_EQUAL_UINT32(0, wm.getScanResults().size());
TEST_ASSERT_EQUAL_UINT32(0, wm.getScanResults().capacity());
captureHeap("scan-storage-cleared");
Serial.println("[TEST] Scan-result storage release test completed successfully");
}
void test_real_async_scan_completes() {
Serial.println("[TEST] Testing real asynchronous Wi-Fi scan completion...");
WiFiManager wm;
wm.setConfigPortalTimeout(30);
wm.startConfigPortal("WM-Real-Scan");
wm.process();
TEST_ASSERT_TRUE_MESSAGE(wm.getConfigPortalActive(), "Portal should be active for scanning");
captureHeap("scan-portal-active");
wm.requestAsyncScan(true);
captureHeap("scan-requested");
const uint32_t deadline = millis() + 25000UL;
while (wm.isScanRunning() && millis() < deadline) {
wm.process();
delay(20);
}
const WiFiManager::WiFiScanRuntimeState scan = wm.getScanSnapshot();
Serial.printf("[METRIC] WM_SCAN state=%u result=%d count=%d elapsed=%lu\n",
static_cast<unsigned>(scan.state),
scan.lastScanResult,
static_cast<int>(wm.getScanResults().size()),
static_cast<unsigned long>(millis() - scan.startedAt));
captureHeap("scan-complete-cache");
const bool stillRunning = wm.isScanRunning();
const WiFiManager::wm_scan_state_t state = wm.getScanState();
wm.stopConfigPortal();
captureHeap("scan-storage-cleared");
TEST_ASSERT_FALSE_MESSAGE(stillRunning, "Async scan exceeded its completion deadline");
TEST_ASSERT_EQUAL_MESSAGE(WiFiManager::WM_SCAN_COMPLETE, state,
"Real scan must complete rather than enter failed/timeout state");
Serial.println("[TEST] Real asynchronous Wi-Fi scan test completed successfully");
}
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF' >&2
Usage:
./tools/test-portal-browser.sh \
--platform esp8266|esp32 \
--port /dev/ttyUSB... \
--wifi-interface wlx... \
[--output /absolute/output-directory]
EOF
exit 2
}
platform=""
port=""
wifi_interface=""
output_dir=""
while [[ $# -gt 0 ]]; do
case "$1" in
--platform) [[ $# -ge 2 ]] || usage; platform="$2"; shift 2 ;;
--port) [[ $# -ge 2 ]] || usage; port="$2"; shift 2 ;;
--wifi-interface) [[ $# -ge 2 ]] || usage; wifi_interface="$2"; shift 2 ;;
--output) [[ $# -ge 2 ]] || usage; output_dir="$2"; shift 2 ;;
*) usage ;;
esac
done
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
[[ -e "$port" ]] || { echo "Serial port not found: $port" >&2; exit 1; }
ip link show "$wifi_interface" >/dev/null 2>&1 || {
echo "Wi-Fi interface not found: $wifi_interface" >&2
exit 1
}
command -v nmcli >/dev/null || { echo "nmcli is required" >&2; exit 1; }
command -v curl >/dev/null || { echo "curl is required" >&2; exit 1; }
command -v rg >/dev/null || { echo "rg is required" >&2; exit 1; }
browser_bin=""
for candidate in google-chrome google-chrome-stable chromium chromium-browser; do
if command -v "$candidate" >/dev/null; then
browser_bin="$candidate"
break
fi
done
default_route_interface="$(ip route show default | awk '/^default/{print $5; exit}')"
[[ "$wifi_interface" != "$default_route_interface" ]] || {
echo "Refusing to use the host default-route interface: $wifi_interface" >&2
exit 1
}
case "$platform" in
esp8266) portal_ssid="WM Browser ESP8266" ;;
esp32) portal_ssid="WM Browser ESP32" ;;
esac
portal_password="default1"
if [[ -z "$output_dir" ]]; then
output_dir="$(mktemp -d /tmp/wifimanager-browser.XXXXXX)"
else
mkdir -p "$output_dir"
fi
temporary_connection=""
cleanup() {
if [[ -n "$temporary_connection" ]]; then
nmcli connection down "$temporary_connection" >/dev/null 2>&1 || true
nmcli connection delete "$temporary_connection" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
script_dir="$(cd "$(dirname "$0")" && pwd)"
root="$(cd "$script_dir/.." && pwd)"
# Flash only the explicitly selected idle serial board.
pio run -d "$root/test/portal-harness" \
-e "$platform" -t upload --upload-port "$port"
# The secondary adapter is the only adapter NetworkManager may touch.
nmcli device disconnect "$wifi_interface" >/dev/null 2>&1 || true
nmcli device wifi rescan ifname "$wifi_interface" || true
for attempt in $(seq 1 30); do
if nmcli -t -f SSID device wifi list ifname "$wifi_interface" | grep -Fxq "$portal_ssid"; then
break
fi
sleep 1
nmcli device wifi rescan ifname "$wifi_interface" >/dev/null 2>&1 || true
done
nmcli -t -f SSID device wifi list ifname "$wifi_interface" | grep -Fxq "$portal_ssid" || {
echo "Portal SSID not detected on $wifi_interface: $portal_ssid" >&2
exit 1
}
# Apply never-default before bringing this temporary portal connection up.
temporary_connection="wifimanager-browser-$platform-$$"
nmcli connection add type wifi ifname "$wifi_interface" con-name "$temporary_connection" \
ssid "$portal_ssid" ipv4.method auto ipv4.never-default yes ipv6.method ignore >/dev/null
nmcli connection modify "$temporary_connection" wifi-sec.key-mgmt wpa-psk \
wifi-sec.psk "$portal_password"
nmcli connection up "$temporary_connection" ifname "$wifi_interface" >/dev/null
portal_url="http://192.168.4.1"
curl --silent --show-error --fail --interface "$wifi_interface" --connect-timeout 3 \
"$portal_url/" >"$output_dir/portal.html"
rg -iq '<html' "$output_dir/portal.html"
# Exercise two independent low-priority responses at once before the scan flow.
curl --silent --show-error --fail --interface "$wifi_interface" --connect-timeout 3 \
"$portal_url/api/bootstrap" >"$output_dir/bootstrap-concurrent.json" &
bootstrap_pid="$!"
curl --silent --show-error --fail --interface "$wifi_interface" --connect-timeout 3 \
"$portal_url/" >"$output_dir/portal-concurrent.html" &
portal_pid="$!"
wait "$bootstrap_pid"
wait "$portal_pid"
rg -Fq '"contractVersion":3' "$output_dir/bootstrap-concurrent.json"
rg -iq '<html' "$output_dir/portal-concurrent.html"
for attempt in $(seq 1 30); do
if curl --silent --show-error --fail --interface "$wifi_interface" --connect-timeout 2 \
"$portal_url/api/bootstrap" >"$output_dir/bootstrap.json"; then
break
fi
sleep 1
done
rg -Fq '"contractVersion":3' "$output_dir/bootstrap.json"
curl --silent --show-error --fail --interface "$wifi_interface" --connect-timeout 3 \
-X POST "$portal_url/api/wifi/scan" >"$output_dir/scan-start.json"
for attempt in $(seq 1 30); do
curl --silent --show-error --fail --interface "$wifi_interface" --connect-timeout 3 \
"$portal_url/api/wifi/scan-status" >"$output_dir/scan-status.json"
if ! rg -Fq '"scanning":true' "$output_dir/scan-status.json"; then
break
fi
sleep 1
done
rg -Fq '"state":"complete"' "$output_dir/scan-status.json"
if [[ -n "$browser_bin" ]]; then
"$browser_bin" --headless=new --disable-gpu --no-first-run --enable-logging=stderr \
--window-size=1440,1100 --screenshot="$output_dir/portal.png" "$portal_url" \
>"$output_dir/browser.log" 2>&1
! rg -i "console.*error|uncaught|exception" "$output_dir/browser.log"
else
echo "HTTP portal checks passed; no compatible local Chromium binary for screenshot capture" \
>"$output_dir/browser.log"
fi
echo "WiFiManager portal browser test passed"
echo "Artifacts: $output_dir"