diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index fde771f..fe54004 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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). diff --git a/docs/TESTING.md b/docs/TESTING.md index 8026e10..a221ab3 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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). diff --git a/lib/WiFiManager/include/WiFiManager.h b/lib/WiFiManager/include/WiFiManager.h index 2c00050..e82d15a 100644 --- a/lib/WiFiManager/include/WiFiManager.h +++ b/lib/WiFiManager/include/WiFiManager.h @@ -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); diff --git a/lib/WiFiManager/src/WiFiManager.cpp b/lib/WiFiManager/src/WiFiManager.cpp index a665b72..2a14da7 100644 --- a/lib/WiFiManager/src/WiFiManager.cpp +++ b/lib/WiFiManager/src/WiFiManager.cpp @@ -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().swap(_scanResultsCache); +} + void WiFiManager::invalidateScanResults() { _scan.resultsValid = false; diff --git a/lib/WiFiManager/src/WiFiManagerHandlers.cpp b/lib/WiFiManager/src/WiFiManagerHandlers.cpp index 4c1a9cd..a0136ac 100644 --- a/lib/WiFiManager/src/WiFiManagerHandlers.cpp +++ b/lib/WiFiManager/src/WiFiManagerHandlers.cpp @@ -16,10 +16,6 @@ #include #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_removeDuplicateAPs) { - std::vector 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); } } diff --git a/scripts/capture-unity-serial.sh b/scripts/capture-unity-serial.sh new file mode 100755 index 0000000..e0c33d3 --- /dev/null +++ b/scripts/capture-unity-serial.sh @@ -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 diff --git a/scripts/test.sh b/scripts/test.sh index 90d2cd2..6ef7c78 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -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. diff --git a/test/portal-harness/README.md b/test/portal-harness/README.md new file mode 100644 index 0000000..94c0eef --- /dev/null +++ b/test/portal-harness/README.md @@ -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. diff --git a/test/portal-harness/platformio.ini b/test/portal-harness/platformio.ini new file mode 100644 index 0000000..8577977 --- /dev/null +++ b/test/portal-harness/platformio.ini @@ -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 diff --git a/test/portal-harness/src/main.cpp b/test/portal-harness/src/main.cpp new file mode 100644 index 0000000..29dd803 --- /dev/null +++ b/test/portal-harness/src/main.cpp @@ -0,0 +1,33 @@ +#include +#include + +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(); +} diff --git a/test/test_wifimanager/test_main.cpp b/test/test_wifimanager/test_main.cpp index c97b791..44be950 100644 --- a/test/test_wifimanager/test_main.cpp +++ b/test/test_wifimanager/test_main.cpp @@ -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); diff --git a/test/test_wifimanager/test_main.h b/test/test_wifimanager/test_main.h index a3a7dc4..a5194f9 100644 --- a/test/test_wifimanager/test_main.h +++ b/test/test_wifimanager/test_main.h @@ -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 diff --git a/test/test_wifimanager/tests/test_nonblocking.cpp b/test/test_wifimanager/tests/test_nonblocking.cpp index 77aff4c..3d814d4 100644 --- a/test/test_wifimanager/tests/test_nonblocking.cpp +++ b/test/test_wifimanager/tests/test_nonblocking.cpp @@ -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"); } diff --git a/test/test_wifimanager/tests/test_resource_lifecycle.cpp b/test/test_wifimanager/tests/test_resource_lifecycle.cpp new file mode 100644 index 0000000..4dcb314 --- /dev/null +++ b/test/test_wifimanager/tests/test_resource_lifecycle.cpp @@ -0,0 +1,186 @@ +#include +#include +#include +#include + +#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(snapshot.freeHeap), + static_cast(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 results; + for (uint8_t i = 0; i < 24; ++i) { + results.push_back({String("Network-") + String(i), -30 - i, static_cast(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(scan.state), + scan.lastScanResult, + static_cast(wm.getScanResults().size()), + static_cast(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"); +} diff --git a/tools/test-portal-browser.sh b/tools/test-portal-browser.sh new file mode 100755 index 0000000..27819f8 --- /dev/null +++ b/tools/test-portal-browser.sh @@ -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 '"$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 '"$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"