feat: add reproducible portal README media

This commit is contained in:
2026-09-09 12:56:52 +10:00
parent 5304aad4c8
commit 20e75622ac
23 changed files with 386 additions and 10 deletions
+1
View File
@@ -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/
+6 -4
View File
@@ -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
+10
View File
@@ -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
+35
View File
@@ -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).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

+10
View File
@@ -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<WiFiScanNetwork>& results) {
_scanResultsCache = results;
_numNetworks = static_cast<int>(results.size());
+29
View File
@@ -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
+7 -2
View File
@@ -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),
+1
View File
@@ -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
+1
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_scan_restart_interval_is_platform_appropriate();
void test_real_async_scan_completes();
// Template rendering tests
@@ -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");
}
+10 -2
View File
@@ -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
+2
View File
@@ -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
+33
View File
@@ -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
}
+8
View File
@@ -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
@@ -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([]);
});
});
+69 -2
View File
@@ -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'
+64
View File
@@ -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
+11
View File
@@ -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'