mirror of
https://github.com/alexhopeoconnor/WiFiManager.git
synced 2026-10-04 02:48:13 +10:00
test: add portal hardware lifecycle coverage
This commit is contained in:
@@ -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
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Executable
+68
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Executable
+153
@@ -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"
|
||||
Reference in New Issue
Block a user