diff --git a/.gitignore b/.gitignore index 1839f37..39eab32 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ node_modules/ # Local portal station handoff credentials and optional direct test artifacts /test/portal-station.env /tests/portal-contract/artifacts/ +/artifacts/readme-media/ diff --git a/README.md b/README.md index c58170c..5bf1ea6 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,12 @@ WiFiManager gives ESP8266 and ESP32 firmware a polished, self-hosted Wi-Fi setup ## See it on real hardware -| Brand the built-in portal | Combine Wi-Fi and application setup | -| --- | --- | -| ![Branded WiFiManager portal overview on an ESP32.](docs/assets/portal-esp32-branded-overview.png) | ![WiFiManager network picker and custom MQTT broker field on an ESP8266.](docs/assets/portal-esp8266-custom-wifi.png) | -| Give each product its own title, identity, icon, and color theme without copying portal HTML. | Show live nearby networks and collect application values, such as an MQTT broker, in the same setup flow. | +![A short WiFiManager portal tour showing product branding, nearby networks, +application settings, and scan feedback.](docs/assets/readme/portal-tour.gif) + +WiFiManager provides a self-hosted setup portal for Wi-Fi and application +settings without copying portal HTML into each firmware. See the detailed +[portal UI guide](docs/PORTAL_UI.md) for supported branding and content APIs. ## Start with a working portal diff --git a/docs/PORTAL_UI.md b/docs/PORTAL_UI.md index 6853f51..ee140d0 100644 --- a/docs/PORTAL_UI.md +++ b/docs/PORTAL_UI.md @@ -4,6 +4,16 @@ WiFiManagerPortalConfig is the supported presentation API for the built-in provi Apply presentation before autoConnect(), startConfigPortal(), or startWebPortal(). Configure portal policy during boot as well so each portal session begins consistently. Portal text and SVG assets are non-owning, so their RAM or PROGMEM data must have static firmware lifetime. WiFiManager locks presentation while a portal is active so asynchronous responses cannot observe partial configuration; setPortalConfig() returns false if it cannot accept the configuration. +## Portal views + +These ESP32 captures use the same real-board portal contract described in +[Testing](TESTING.md). The nearby networks shown are the networks visible to +the capture device when the portal scans. + +| Overview | Wi-Fi and application settings | +| --- | --- | +| ![WiFiManager portal overview with branded identity, status, and portal actions.](assets/readme/portal-overview.png) | ![WiFiManager Wi-Fi page with nearby networks and an application setting.](assets/readme/portal-wifi-settings.png) | + ## Standalone branded portal ~~~cpp diff --git a/docs/TESTING.md b/docs/TESTING.md index cf85c07..426604d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -102,4 +102,39 @@ cp test/portal-station.env.example test/portal-station.env ./tools/portal-hardware run ... --station-env test/portal-station.env ``` +## Refresh README media + +README media is an explicit ESP32-only capture, not part of normal testing or +CI. It uses the same real-board portal contract above, but records a short +browser tour and stores all candidate files under the ignored +`artifacts/readme-media/` directory by default: + +```bash +./tools/portal-hardware run \ + --platform esp32 \ + --port /dev/serial/by-id/usb-... \ + --client-interface USB_WIFI_ADAPTER \ + --capture-readme-media +``` + +Review the printed artifact directory. To keep a run somewhere more convenient, +pass `--output DIRECTORY`. After review, promote only the approved PNG/GIF +files into tracked documentation assets: + +```bash +./tools/promote-readme-media \ + --from artifacts/readme-media/TIMESTAMP-esp32 \ + --replace +./scripts/check-docs.sh +``` + +The Docker renderer validates the GIF duration. The promotion tool requires the +successful ESP32 media manifest, checks file types and size limits, and never +copies raw video, browser reports, traces, or arbitrary artifact files. The +renderer preserves the real recording but deliberately presents it at 1.25× +duration and 6 fps so the +README tour is readable; it does not change normal browser-contract timing. +ESP8266 remains covered by the normal hardware and browser contract but does +not produce duplicate README media. + Back to [documentation](README.md) · [project overview](../README.md). diff --git a/docs/assets/portal-esp32-branded-overview.png b/docs/assets/portal-esp32-branded-overview.png deleted file mode 100644 index 1ad9971..0000000 Binary files a/docs/assets/portal-esp32-branded-overview.png and /dev/null differ diff --git a/docs/assets/portal-esp8266-custom-wifi.png b/docs/assets/portal-esp8266-custom-wifi.png deleted file mode 100644 index 60e4be2..0000000 Binary files a/docs/assets/portal-esp8266-custom-wifi.png and /dev/null differ diff --git a/docs/assets/readme/portal-overview.png b/docs/assets/readme/portal-overview.png new file mode 100644 index 0000000..4314fc1 Binary files /dev/null and b/docs/assets/readme/portal-overview.png differ diff --git a/docs/assets/readme/portal-tour.gif b/docs/assets/readme/portal-tour.gif new file mode 100644 index 0000000..97469ce Binary files /dev/null and b/docs/assets/readme/portal-tour.gif differ diff --git a/docs/assets/readme/portal-wifi-settings.png b/docs/assets/readme/portal-wifi-settings.png new file mode 100644 index 0000000..a9327c2 Binary files /dev/null and b/docs/assets/readme/portal-wifi-settings.png differ diff --git a/lib/WiFiManager/include/WiFiManager.h b/lib/WiFiManager/include/WiFiManager.h index 7106a15..22cf83b 100644 --- a/lib/WiFiManager/include/WiFiManager.h +++ b/lib/WiFiManager/include/WiFiManager.h @@ -337,7 +337,16 @@ class WiFiManager unsigned long startedAt = 0; unsigned long finishedAt = 0; unsigned long timeoutMs = 15000; + // ESP32's high-level scan-complete notification can arrive before the + // radio has fully released its previous scan. A second scan started in + // that short window is reported only as WIFI_SCAN_FAILED by Arduino. + // Keep the established short ESP8266 cadence, but give ESP32 its + // documented radio hand-off time before retrying a user refresh. + #ifdef ESP32 + unsigned long minRestartIntervalMs = 5000; + #else unsigned long minRestartIntervalMs = 2000; + #endif uint32_t generation = 0; uint32_t runningGeneration = 0; uint32_t completionGeneration = 0; @@ -1061,6 +1070,7 @@ protected: void wmTestForceScanState(wm_scan_state_t state) { _scan.state = state; } void wmTestSetScanStartedAt(unsigned long startedAt) { _scan.startedAt = startedAt; } void wmTestSetScanTimeoutMs(unsigned long timeoutMs) { _scan.timeoutMs = timeoutMs; } + unsigned long wmTestGetScanRestartIntervalMs() const { return _scan.minRestartIntervalMs; } void wmTestInjectScanResults(const std::vector& results) { _scanResultsCache = results; _numNetworks = static_cast(results.size()); diff --git a/scripts/check-docs.sh b/scripts/check-docs.sh index 279ce33..5435485 100755 --- a/scripts/check-docs.sh +++ b/scripts/check-docs.sh @@ -42,6 +42,35 @@ for required in README.md CHANGELOG.md docs/README.md docs/GETTING_STARTED.md do fi done +check_readme_media() { + local asset="$1" + local expected_type="$2" + local max_bytes="$3" + local path="$root/docs/assets/readme/$asset" + if [[ ! -s "$path" ]]; then + printf 'Missing README media asset: %s\n' "docs/assets/readme/$asset" >&2 + failed=1 + return + fi + if [[ "$(file --brief --mime-type "$path")" != "$expected_type" ]]; then + printf 'Unexpected README media type: %s\n' "docs/assets/readme/$asset" >&2 + failed=1 + fi + if (( $(wc -c < "$path") > max_bytes )); then + printf 'README media exceeds its size limit: %s\n' "docs/assets/readme/$asset" >&2 + failed=1 + fi +} + +check_readme_media portal-tour.gif image/gif $((2 * 1024 * 1024)) +check_readme_media portal-overview.png image/png $((1024 * 1024)) +check_readme_media portal-wifi-settings.png image/png $((1024 * 1024)) + +if [[ -n "$(git -C "$root" ls-files -- 'artifacts/readme-media/**')" ]]; then + printf 'Ignored README media artifacts must not be tracked.\n' >&2 + failed=1 +fi + while IFS= read -r example; do for required in README.md platformio.ini; do if [[ ! -f "$example/$required" ]]; then diff --git a/test/portal-harness/src/main.cpp b/test/portal-harness/src/main.cpp index d942e54..8f1b627 100644 --- a/test/portal-harness/src/main.cpp +++ b/test/portal-harness/src/main.cpp @@ -20,8 +20,13 @@ void setup() { Serial.begin(115200); delay(300); - // The fixture intentionally has no station credentials. A finite window - // exercises timeout reset without leaving a board in a permanent portal. + // This fixture must be independent of whichever sketch was previously + // flashed to the board. Clear saved station credentials before starting + // the portal so browser artifacts always show the unconfigured flow. + wifi.resetSettings(); + + // A finite window exercises timeout reset without leaving a board in a + // permanent portal. wifi.setConfigPortalTimeout(15 * 60); wifi.setAPStaticIPConfig( IPAddress(192, 168, 4, 1), diff --git a/test/test_wifimanager/test_main.cpp b/test/test_wifimanager/test_main.cpp index 9d56534..5ff6d45 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_connect_pending), TEST_ENTRY(test_scan_cancels_when_lifecycle_blocked), TEST_ENTRY(test_scan_generation_invalidated_on_reset), + TEST_ENTRY(test_scan_restart_interval_is_platform_appropriate), TEST_ENTRY(test_real_async_scan_completes), // Template rendering tests diff --git a/test/test_wifimanager/test_main.h b/test/test_wifimanager/test_main.h index ec7b593..19b8d93 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_scan_restart_interval_is_platform_appropriate(); void test_real_async_scan_completes(); // Template rendering tests diff --git a/test/test_wifimanager/tests/test_wifi_scanning.cpp b/test/test_wifimanager/tests/test_wifi_scanning.cpp index 2327560..4fb4e3f 100644 --- a/test/test_wifimanager/tests/test_wifi_scanning.cpp +++ b/test/test_wifimanager/tests/test_wifi_scanning.cpp @@ -150,3 +150,24 @@ void test_scan_generation_invalidated_on_reset() { Serial.println("[TEST] Scan generation invalidation on reset test completed successfully"); } +void test_scan_restart_interval_is_platform_appropriate() { + Serial.println("[TEST] Testing platform scan restart interval..."); + + WiFiManager wm; + +#ifdef UNIT_TEST + #ifdef ESP32 + TEST_ASSERT_EQUAL_UINT32_MESSAGE( + 5000, wm.wmTestGetScanRestartIntervalMs(), + "ESP32 must wait for the radio to settle after a completed scan"); + #else + TEST_ASSERT_EQUAL_UINT32_MESSAGE( + 2000, wm.wmTestGetScanRestartIntervalMs(), + "ESP8266 keeps the established responsive scan restart interval"); + #endif +#else + TEST_IGNORE_MESSAGE("UNIT_TEST helpers unavailable"); +#endif + + Serial.println("[TEST] Platform scan restart interval test completed successfully"); +} diff --git a/tests/portal-contract/Dockerfile b/tests/portal-contract/Dockerfile index d32d7c3..74ec3e7 100644 --- a/tests/portal-contract/Dockerfile +++ b/tests/portal-contract/Dockerfile @@ -1,5 +1,5 @@ ARG PLAYWRIGHT_VERSION=1.63.0 -FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble +FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble AS portal-contract ARG PLAYWRIGHT_VERSION ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 @@ -8,4 +8,12 @@ COPY tests/portal-contract/package.json tests/portal-contract/package-lock.json RUN npm ci --ignore-scripts && \ [ "$(node -p "require('@playwright/test/package.json').version")" = "$PLAYWRIGHT_VERSION" ] COPY tests/portal-contract/ ./ -CMD ["npm", "test"] +RUN chmod 755 /work/run-portal-contract.sh /work/render-readme-media.sh +CMD ["/work/run-portal-contract.sh"] + +FROM portal-contract AS media +USER root +RUN apt-get update && \ + apt-get install -y --no-install-recommends ffmpeg && \ + rm -rf /var/lib/apt/lists/* +USER pwuser diff --git a/tests/portal-contract/compose.yaml b/tests/portal-contract/compose.yaml index 133058f..6cd2787 100644 --- a/tests/portal-contract/compose.yaml +++ b/tests/portal-contract/compose.yaml @@ -3,6 +3,7 @@ services: build: context: ../.. dockerfile: tests/portal-contract/Dockerfile + target: ${PORTAL_CONTRACT_DOCKER_TARGET:-portal-contract} args: PLAYWRIGHT_VERSION: "1.63.0" network_mode: host @@ -13,6 +14,7 @@ services: HOME: /tmp PORTAL_URL: http://192.168.4.1 PORTAL_BROWSER_MODE: ${PORTAL_BROWSER_MODE:-auto} + PORTAL_CAPTURE_README_MEDIA: ${PORTAL_CAPTURE_README_MEDIA:-0} ARTIFACT_DIR: /artifacts volumes: - ${PORTAL_ARTIFACT_DIR:?portal artifact directory is required}:/artifacts diff --git a/tests/portal-contract/render-readme-media.sh b/tests/portal-contract/render-readme-media.sh new file mode 100755 index 0000000..2b590d2 --- /dev/null +++ b/tests/portal-contract/render-readme-media.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +artifact_dir="${ARTIFACT_DIR:?ARTIFACT_DIR is required}" +media_dir="$artifact_dir/readme-media" +source_video="$media_dir/raw/portal-tour.webm" +target_gif="$media_dir/portal-tour.gif" + +[[ -s "$source_video" ]] || { + echo "README media video was not recorded: $source_video" >&2 + exit 1 +} + +ffmpeg -hide_banner -loglevel error -y -i "$source_video" \ + -filter_complex '[0:v]setpts=1.25*PTS,fps=6,scale=720:-2:flags=lanczos,split[a][b];[a]palettegen=max_colors=128[p];[b][p]paletteuse' \ + -loop 0 "$target_gif" + +[[ -s "$target_gif" ]] || { + echo "README media GIF was not rendered: $target_gif" >&2 + exit 1 +} + +duration="$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$target_gif")" +awk -v duration="$duration" 'BEGIN { exit !(duration >= 4 && duration <= 30) }' || { + echo "README media GIF duration is outside the 4–30 second review range: $duration" >&2 + exit 1 +} + +max_bytes=$((2 * 1024 * 1024)) +(( $(wc -c < "$target_gif") <= max_bytes )) || { + echo "README media GIF exceeds its 2 MiB documentation budget: $target_gif" >&2 + exit 1 +} diff --git a/tests/portal-contract/run-portal-contract.sh b/tests/portal-contract/run-portal-contract.sh new file mode 100755 index 0000000..ce408b3 --- /dev/null +++ b/tests/portal-contract/run-portal-contract.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +/work/node_modules/.bin/playwright test --config /work/playwright.config.js + +if [[ "${PORTAL_CAPTURE_README_MEDIA:-0}" == "1" ]]; then + /work/render-readme-media.sh +fi diff --git a/tests/portal-contract/tests/readme-media.spec.js b/tests/portal-contract/tests/readme-media.spec.js new file mode 100644 index 0000000..dd6473f --- /dev/null +++ b/tests/portal-contract/tests/readme-media.spec.js @@ -0,0 +1,68 @@ +const fs = require('fs'); +const path = require('path'); +const { test, expect } = require('@playwright/test'); + +function mediaPath(...parts) { + const root = process.env.ARTIFACT_DIR || '/artifacts'; + const target = path.join(root, 'readme-media', ...parts); + fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 }); + return target; +} + +async function waitForCompletedScan(request) { + await expect.poll(async () => { + try { + const response = await request.get('/api/wifi/scan-status'); + if (!response.ok()) return false; + const result = JSON.parse(await response.text()); + return result.state === 'complete' && result.results_valid && result.count > 0; + } catch { + return false; + } + }, { timeout: 45_000, intervals: [500, 800, 1_000] }).toBe(true); +} + +test.describe('WiFiManager README media', () => { + test.skip(process.env.PORTAL_CAPTURE_README_MEDIA !== '1', 'README capture was not requested.'); + + test('records an approved ESP32 portal tour', async ({ browser, request }) => { + const context = await browser.newContext({ + viewport: { width: 720, height: 900 }, + recordVideo: { + dir: mediaPath('raw'), + size: { width: 720, height: 900 }, + }, + }); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + + const video = page.video(); + await page.goto('/', { waitUntil: 'networkidle' }); + await expect(page.locator('#wm-reset-portal-timeout')).toBeVisible(); + // These pauses exist only in the README recording. The ordinary contract + // remains timing-focused; this tour needs readable stable states. + await page.waitForTimeout(1200); + await page.screenshot({ path: mediaPath('portal-overview.png'), fullPage: true }); + + await page.locator('a[href="#/wifi"]').click(); + await expect(page.locator('#wm-refresh-scan')).toBeVisible(); + await waitForCompletedScan(request); + await expect(page.locator('#wm-scan-results .wm-scan-row').first()).toBeVisible(); + await page.locator('#wm-f-installation_label').fill('Workshop sensor'); + await page.waitForTimeout(1200); + await page.screenshot({ path: mediaPath('portal-wifi-settings.png'), fullPage: true }); + + await page.locator('#wm-refresh-scan').click(); + await expect(page.locator('#wm-wifi-scan-overlay')).toBeVisible(); + await page.waitForTimeout(1200); + await context.close(); + + const source = await video.path(); + fs.renameSync(source, mediaPath('raw', 'portal-tour.webm')); + expect(errors).toEqual([]); + }); +}); diff --git a/tools/portal-hardware b/tools/portal-hardware index c78f716..8f4ab81 100755 --- a/tools/portal-hardware +++ b/tools/portal-hardware @@ -13,7 +13,8 @@ Usage: --client-interface IFACE [--take-over-client-adapter] [--output DIRECTORY] ./tools/portal-hardware run --platform esp8266|esp32 --port /dev/serial/by-id/... \ --client-interface IFACE [--take-over-client-adapter] [--keep] \ - [--browser auto|skip] [--station-env PATH] [--output DIRECTORY] + [--browser auto|skip] [--station-env PATH] [--output DIRECTORY] \ + [--capture-readme-media] ./tools/portal-hardware down Only the named client interface may be disconnected or reconfigured. The tool @@ -35,6 +36,7 @@ keep="no" browser="auto" station_env="" output_dir="" +capture_readme_media="no" while [[ $# -gt 0 ]]; do case "$1" in --platform) [[ $# -ge 2 ]] || usage; platform="$2"; shift 2 ;; @@ -45,6 +47,7 @@ while [[ $# -gt 0 ]]; do --browser) [[ $# -ge 2 ]] || usage; browser="$2"; shift 2 ;; --station-env) [[ $# -ge 2 ]] || usage; station_env="$2"; shift 2 ;; --output) [[ $# -ge 2 ]] || usage; output_dir="$2"; shift 2 ;; + --capture-readme-media) capture_readme_media="yes"; shift ;; *) usage ;; esac done @@ -54,12 +57,17 @@ require_common() { wm_require nmcli wm_require pio wm_require docker + wm_require curl docker compose version >/dev/null } prepare_output_dir() { if [[ -z "$output_dir" ]]; then - output_dir="$(wm_portal_state_root)/runs/$(date -u +%Y%m%dT%H%M%SZ)-$platform" + if [[ "$capture_readme_media" == "yes" ]]; then + output_dir="$root/artifacts/readme-media/$(date -u +%Y%m%dT%H%M%SZ)-$platform" + else + output_dir="$(wm_portal_state_root)/runs/$(date -u +%Y%m%dT%H%M%SZ)-$platform" + fi fi install -d -m 700 "$output_dir" output_dir="$(cd "$output_dir" && pwd)" @@ -77,6 +85,51 @@ validate_run_arguments() { echo "Station environment file is not readable: $station_env" >&2 exit 1 } + if [[ "$capture_readme_media" == "yes" ]]; then + [[ "$command_name" == "run" && "$platform" == "esp32" ]] || { + echo "--capture-readme-media is supported only by run --platform esp32" >&2 + exit 2 + } + [[ "$browser" == "auto" ]] || { + echo "--capture-readme-media requires --browser auto" >&2 + exit 2 + } + fi +} + +write_readme_media_manifest() { + local media_dir="$output_dir/readme-media" + local required + for required in portal-tour.gif portal-overview.png portal-wifi-settings.png; do + [[ -s "$media_dir/$required" ]] || { + echo "README media output is incomplete: $media_dir/$required" >&2 + return 1 + } + done + printf '{"schema":1,"project":"WiFiManager","kind":"readme-media","status":"passed","platform":"esp32","created_at":"%s"}\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$media_dir/manifest.json" + 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 + # contract asks the device to perform a second, user-initiated refresh. + 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)" + if [[ "$response" == *'"state":"complete"'* && "$response" == *'"results_valid":true'* ]]; then + return 0 + fi + if [[ "$response" == *'"state":"failed"'* || "$response" == *'"state":"timeout"'* ]]; then + echo "Portal background scan did not become ready: $response" >&2 + return 1 + fi + sleep 1 + done + echo 'Portal did not complete its initial Wi-Fi scan within 45 seconds.' >&2 + return 1 } start_portal_session() { @@ -97,6 +150,10 @@ start_portal_session() { wm_remove_connection "$WM_PORTAL_CONNECTION_UUID" return 1 fi + if ! wait_for_portal_ready; then + wm_remove_connection "$WM_PORTAL_CONNECTION_UUID" + return 1 + fi if ! wm_write_state "$client_interface" "$platform" "$WM_PORTAL_CONNECTION_UUID" "$WM_PORTAL_CONNECTION_NAME"; then wm_remove_connection "$WM_PORTAL_CONNECTION_UUID" return 1 @@ -139,6 +196,13 @@ case "$command_name" in export LOCAL_UID="$(id -u)" export LOCAL_GID="$(id -g)" export PORTAL_BROWSER_MODE="$browser" + if [[ "$capture_readme_media" == "yes" ]]; then + export PORTAL_CAPTURE_README_MEDIA=1 + export PORTAL_CONTRACT_DOCKER_TARGET=media + else + export PORTAL_CAPTURE_README_MEDIA=0 + export PORTAL_CONTRACT_DOCKER_TARGET=portal-contract + fi compose_files=(-f "$root/tests/portal-contract/compose.yaml") if [[ -n "$station_env" ]]; then export PORTAL_STATION_ENV_HOST="$(cd "$(dirname "$station_env")" && pwd)/$(basename "$station_env")" @@ -148,6 +212,9 @@ case "$command_name" in # this invocation always tests the checked-out files, not a stale image. docker compose "${compose_files[@]}" build portal-contract docker compose "${compose_files[@]}" run --rm portal-contract + if [[ "$capture_readme_media" == "yes" ]]; then + write_readme_media_manifest + fi printf 'Portal contract passed. Artifacts: %s\n' "$output_dir" if [[ "$keep" == "yes" ]]; then printf 'Portal session remains connected; run ./tools/portal-hardware down when finished.\n' diff --git a/tools/promote-readme-media b/tools/promote-readme-media new file mode 100755 index 0000000..b97e0b9 --- /dev/null +++ b/tools/promote-readme-media @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source_dir="" +replace="no" + +usage() { + cat <<'EOF' >&2 +Usage: ./tools/promote-readme-media --from ARTIFACT_DIRECTORY --replace + +Copies approved WiFiManager README media from a successful ESP32 capture. The +source directory is the run directory containing readme-media/manifest.json. +EOF + exit 2 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --from) [[ $# -ge 2 ]] || usage; source_dir="$2"; shift 2 ;; + --replace) replace="yes"; shift ;; + *) usage ;; + esac +done + +[[ -n "$source_dir" && "$replace" == "yes" ]] || usage +source_dir="$(cd "$source_dir" && pwd)" +media_dir="$source_dir/readme-media" +manifest="$media_dir/manifest.json" + +[[ -r "$manifest" ]] || { echo "Missing README media manifest: $manifest" >&2; exit 1; } +grep -Fq '"project":"WiFiManager"' "$manifest" && + grep -Fq '"kind":"readme-media"' "$manifest" && + grep -Fq '"status":"passed"' "$manifest" && + grep -Fq '"platform":"esp32"' "$manifest" || { + echo "The media manifest is not a successful WiFiManager ESP32 capture." >&2 + exit 1 + } + +target_dir="$root/docs/assets/readme" +validate_asset() { + local source_file="$1" expected_type="$2" max_bytes="$3" + [[ -s "$source_file" ]] || { echo "Missing media asset: $source_file" >&2; exit 1; } + [[ "$(file --brief --mime-type "$source_file")" == "$expected_type" ]] || { + echo "Unexpected media type for $source_file" >&2 + exit 1 + } + (( $(wc -c < "$source_file") <= max_bytes )) || { + echo "README media exceeds its size limit: $source_file" >&2 + exit 1 + } +} + +# Verify the complete candidate before changing any tracked README asset. +validate_asset "$media_dir/portal-tour.gif" image/gif $((2 * 1024 * 1024)) +validate_asset "$media_dir/portal-overview.png" image/png $((1024 * 1024)) +validate_asset "$media_dir/portal-wifi-settings.png" image/png $((1024 * 1024)) + +install -d "$target_dir" +for asset in portal-tour.gif portal-overview.png portal-wifi-settings.png; do + source_file="$media_dir/$asset" + cp -- "$source_file" "$target_dir/$asset" + printf 'Promoted %s\n' "docs/assets/readme/$asset" +done diff --git a/tools/tests/test-portal-hardware-cli.sh b/tools/tests/test-portal-hardware-cli.sh index 4a2aa7f..8715b85 100755 --- a/tools/tests/test-portal-hardware-cli.sh +++ b/tools/tests/test-portal-hardware-cli.sh @@ -27,6 +27,8 @@ printf '%s\n' '#!/usr/bin/env bash' \ 'printf "docker %s\n" "$*" >>"$CALL_LOG"' \ 'if [[ "$1" == "compose" && "$2" == "version" ]]; then echo "Docker Compose"; exit 0; fi' \ 'exit 0' >"$stub_bin/docker" +printf '%s\n' '#!/usr/bin/env bash' \ +'printf "{\"state\":\"complete\",\"results_valid\":true}"' >"$stub_bin/curl" chmod 755 "$stub_bin"/* export PATH="$stub_bin:$PATH" @@ -44,6 +46,11 @@ if "$root/tools/portal-hardware" up --platform >/dev/null 2>&1; then echo 'missing option value did not reject the request' >&2 exit 1 fi +if "$root/tools/portal-hardware" run --platform esp8266 --port /dev/null \ + --client-interface wlan-client --capture-readme-media >/dev/null 2>&1; then + echo 'ESP8266 README media capture was accepted' >&2 + exit 1 +fi # A failed association must delete the only connection it just created. source "$root/tools/lib/portal-hardware-session.sh" @@ -82,5 +89,9 @@ run_line="$(grep -n " run --rm portal-contract$" "$CALL_LOG" | tail -1 | cut -d: echo "portal contract was not rebuilt before execution" >&2 exit 1 } +grep -Fq 'wait_for_portal_ready' "$root/tools/portal-hardware" +grep -Fq 'api/wifi/scan-status' "$root/tools/portal-hardware" +grep -Fq 'README media GIF exceeds its 2 MiB documentation budget' \ + "$root/tests/portal-contract/render-readme-media.sh" echo 'portal-hardware CLI safety checks passed'