mirror of
https://github.com/alexhopeoconnor/WiFiManager.git
synced 2026-10-04 02:48:13 +10:00
test: containerize portal hardware contract
This commit is contained in:
@@ -20,7 +20,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: bash -n scripts/*.sh
|
||||
- run: bash -n scripts/*.sh tools/portal-hardware tools/lib/*.sh tools/tests/*.sh
|
||||
- run: ./tools/tests/test-portal-hardware-cli.sh
|
||||
- run: ./scripts/check-docs.sh
|
||||
|
||||
compile-tests:
|
||||
|
||||
@@ -33,3 +33,7 @@ Thumbs.db
|
||||
|
||||
# Misc
|
||||
node_modules/
|
||||
|
||||
# Local portal station handoff credentials and optional direct test artifacts
|
||||
/test/portal-station.env
|
||||
/tests/portal-contract/artifacts/
|
||||
|
||||
@@ -32,6 +32,20 @@ When a physical ESP8266 and ESP32 are available, include their local lifecycle t
|
||||
./scripts/test.sh hardware --platform esp32 --port /dev/serial/by-id/usb-...
|
||||
~~~
|
||||
|
||||
When a physical ESP8266 or ESP32 and a spare USB Wi-Fi adapter are available,
|
||||
run the Docker portal contract as an additional release-gate check. It is
|
||||
opt-in because it flashes the selected board and temporarily joins its AP, but
|
||||
it refuses the host default-route adapter and leaves Docker responsible only
|
||||
for browser/API testing:
|
||||
|
||||
```bash
|
||||
./tools/portal-hardware run --platform esp8266 --port /dev/serial/by-id/usb-... \
|
||||
--client-interface wlx74da385d4165
|
||||
```
|
||||
|
||||
See [Testing](TESTING.md#docker-portal-contract) for cleanup, artifacts, and
|
||||
optional station handoff credentials.
|
||||
|
||||
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).
|
||||
|
||||
+71
-20
@@ -1,45 +1,96 @@
|
||||
# Testing
|
||||
|
||||
The clean-consumer check builds a project that declares only WiFiManager. It proves the package manifest resolves DFTE, ESPAsyncWebServer, and the correct ESP8266 or ESP32 TCP dependency without a sibling checkout or attached board. The runner removes a previous local package link before each
|
||||
check, so dependency resolution uses the current manifest rather than a stale
|
||||
`.pio` copy.
|
||||
WiFiManager separates repeatable package checks from opt-in tests that flash a
|
||||
real board or join a captive portal. The normal commands never need a board,
|
||||
local Wi-Fi credentials, browser binary, or sibling checkout.
|
||||
|
||||
## Clean consumer and example builds
|
||||
|
||||
The clean-consumer check builds a project that declares only WiFiManager. It
|
||||
proves the package manifest resolves DFTE, ESPAsyncWebServer, and the correct
|
||||
ESP8266 or ESP32 TCP dependency without a sibling checkout. The runner removes
|
||||
a prior local package link before each check, so dependency resolution uses the
|
||||
current manifest rather than a stale `.pio` copy.
|
||||
|
||||
```bash
|
||||
./scripts/test.sh compile --platform esp8266
|
||||
./scripts/test.sh compile --platform esp32
|
||||
```
|
||||
|
||||
## 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.
|
||||
CI runs these board-free checks for pull requests and pushes to the maintained
|
||||
branch. It intentionally does not require attached hardware, a local network,
|
||||
or Docker.
|
||||
|
||||
## 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.
|
||||
The Unity suite runs on a board without Wi-Fi credentials, MQTT, DeviceFramework,
|
||||
or a local profile. It verifies portal start/stop 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:
|
||||
Use a stable serial-by-id path rather than a changing `/dev/ttyUSB` number:
|
||||
|
||||
~~~bash
|
||||
```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.
|
||||
The runner flashes the selected board, captures normal-boot serial output with
|
||||
the repository Bash helper, requires Unity's `Tests 0 Failures` and `OK`
|
||||
summary, and prints lifecycle metrics. Hardware work shares a lock with the
|
||||
portal contract, so two invocations cannot flash or use the same board at once.
|
||||
|
||||
## Optional portal browser test
|
||||
## Docker portal contract
|
||||
|
||||
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.
|
||||
`test/portal-harness` is deliberately tiny portal-only firmware, not an example
|
||||
or consuming application. `tools/portal-hardware` flashes it to one explicitly
|
||||
selected board, joins its AP through one explicitly selected **secondary**
|
||||
Wi-Fi adapter, then runs its HTTP and browser contract in a pinned Playwright
|
||||
Docker image. Docker uses host networking only to reach the already-routed
|
||||
portal; it never runs NetworkManager or changes host adapters.
|
||||
|
||||
~~~bash
|
||||
./tools/test-portal-browser.sh --platform esp8266 --port /dev/serial/by-id/usb-... --wifi-interface wlx74da385d4165 --output /tmp/wifimanager-browser-results
|
||||
~~~
|
||||
```bash
|
||||
./tools/portal-hardware doctor --client-interface wlx74da385d4165
|
||||
./tools/portal-hardware run \
|
||||
--platform esp8266 \
|
||||
--port /dev/serial/by-id/usb-... \
|
||||
--client-interface wlx74da385d4165
|
||||
```
|
||||
|
||||
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.
|
||||
The command refuses the host default-route adapter. If the chosen secondary
|
||||
adapter is already connected, require an explicit acknowledgement before it is
|
||||
replaced:
|
||||
|
||||
```bash
|
||||
./tools/portal-hardware run ... --take-over-client-adapter
|
||||
```
|
||||
|
||||
The fixture opens a 15-minute portal session, includes one harmless custom
|
||||
parameter, and verifies root/bootstrap/info/status API responses, concurrent
|
||||
low-priority requests, parameter persistence, timeout reset, an actual async
|
||||
scan, a missing-route response, and desktop/mobile portal rendering with no
|
||||
browser page errors. Screenshots, traces on failure, JSON results, and the HTML
|
||||
report are saved under the printed XDG state-directory artifact path.
|
||||
|
||||
For interactive diagnosis, leave the temporary client connection up and remove
|
||||
only that managed connection when finished:
|
||||
|
||||
```bash
|
||||
./tools/portal-hardware up --platform esp32 --port /dev/serial/by-id/usb-... \
|
||||
--client-interface wlx74da385d4165
|
||||
./tools/portal-hardware down
|
||||
```
|
||||
|
||||
An optional station handoff test is deliberately separate because it connects
|
||||
the fixture to a real LAN. Copy the ignored template below, add local
|
||||
credentials, and pass it explicitly; it is mounted read-only into the test
|
||||
container and is never logged by the runner.
|
||||
|
||||
```bash
|
||||
cp test/portal-station.env.example test/portal-station.env
|
||||
./tools/portal-hardware run ... --station-env test/portal-station.env
|
||||
```
|
||||
|
||||
Back to [documentation](README.md) · [project overview](../README.md).
|
||||
|
||||
+9
-2
@@ -2,12 +2,12 @@
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF' >&2
|
||||
cat <<'USAGE' >&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
|
||||
USAGE
|
||||
exit 2
|
||||
}
|
||||
|
||||
@@ -30,6 +30,13 @@ done
|
||||
[[ "$mode" != "hardware" || -e "$port" ]] || { echo "Serial port not found: $port" >&2; exit 1; }
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
if [[ "$mode" == "hardware" ]]; then
|
||||
# Keep serial flashing and portal-adapter work mutually exclusive.
|
||||
# shellcheck source=tools/lib/portal-hardware-session.sh
|
||||
source "$root/tools/lib/portal-hardware-session.sh"
|
||||
wm_acquire_hardware_lock
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
namespace {
|
||||
|
||||
#if defined(ESP8266)
|
||||
constexpr char kPortalSsid[] = "WM Browser ESP8266";
|
||||
constexpr char kPortalSsid[] = "WM Contract ESP8266";
|
||||
#else
|
||||
constexpr char kPortalSsid[] = "WM Browser ESP32";
|
||||
constexpr char kPortalSsid[] = "WM Contract ESP32";
|
||||
#endif
|
||||
constexpr char kPortalPassword[] = "default1";
|
||||
|
||||
WiFiManager wifi;
|
||||
WiFiManagerParameter kInstallationLabel(
|
||||
"installation_label", "Installation label", "Contract fixture", 32);
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -18,13 +20,14 @@ 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);
|
||||
// The fixture intentionally has no station credentials. 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),
|
||||
IPAddress(192, 168, 4, 1),
|
||||
IPAddress(255, 255, 255, 0));
|
||||
wifi.portalAddParameter(&kInstallationLabel);
|
||||
wifi.startConfigPortal(kPortalSsid, kPortalPassword);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Ignored local credentials for the optional station handoff test.
|
||||
# These values are mounted read-only into the Docker test container.
|
||||
WIFI_SSID=replace-me
|
||||
WIFI_PASSWORD=replace-me
|
||||
@@ -0,0 +1,11 @@
|
||||
ARG PLAYWRIGHT_VERSION=1.63.0
|
||||
FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble
|
||||
|
||||
ARG PLAYWRIGHT_VERSION
|
||||
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
WORKDIR /work
|
||||
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"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# Portal contract container
|
||||
|
||||
This directory contains the browser/API half of the real-hardware portal test.
|
||||
Run it through [`../../tools/portal-hardware`](../../tools/portal-hardware), not
|
||||
directly: the host command alone selects the serial board and attaches the
|
||||
explicit secondary Wi-Fi adapter. Docker uses the host network only to reach
|
||||
the already-routed `192.168.4.1` portal; it never manages host Wi-Fi.
|
||||
|
||||
The image pins the Playwright package to the matching official browser image.
|
||||
Artifacts, traces, screenshots, JSON results, and the HTML report are written
|
||||
to the output directory printed by the host command.
|
||||
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
portal-contract:
|
||||
environment:
|
||||
PORTAL_STATION_ENV: /run/secrets/portal-station.env
|
||||
volumes:
|
||||
- ${PORTAL_STATION_ENV_HOST:?station environment path is required}:/run/secrets/portal-station.env:ro
|
||||
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
portal-contract:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: tests/portal-contract/Dockerfile
|
||||
args:
|
||||
PLAYWRIGHT_VERSION: "1.63.0"
|
||||
network_mode: host
|
||||
ipc: host
|
||||
init: true
|
||||
user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
PORTAL_URL: http://192.168.4.1
|
||||
PORTAL_BROWSER_MODE: ${PORTAL_BROWSER_MODE:-auto}
|
||||
ARTIFACT_DIR: /artifacts
|
||||
volumes:
|
||||
- ${PORTAL_ARTIFACT_DIR:?portal artifact directory is required}:/artifacts
|
||||
Generated
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "wifimanager-portal-contract",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wifimanager-portal-contract",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "wifimanager-portal-contract",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const path = require('path');
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
const artifactDir = process.env.ARTIFACT_DIR || path.join(__dirname, 'artifacts');
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: './tests',
|
||||
timeout: 45_000,
|
||||
expect: { timeout: 10_000 },
|
||||
forbidOnly: !!process.env.CI,
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
outputDir: path.join(artifactDir, 'test-results'),
|
||||
reporter: [
|
||||
['list'],
|
||||
['json', { outputFile: path.join(artifactDir, 'report.json') }],
|
||||
['html', { outputFolder: path.join(artifactDir, 'html-report'), open: 'never' }],
|
||||
],
|
||||
use: {
|
||||
baseURL: process.env.PORTAL_URL || 'http://192.168.4.1',
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
async function json(response) {
|
||||
return JSON.parse(await response.text());
|
||||
}
|
||||
|
||||
async function waitForScan(request) {
|
||||
let result;
|
||||
await expect.poll(async () => {
|
||||
const response = await request.get('/api/wifi/scan-status');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
result = await json(response);
|
||||
return result.scanning;
|
||||
}, { timeout: 45_000, intervals: [500, 800, 1_000] }).toBe(false);
|
||||
return result;
|
||||
}
|
||||
|
||||
test.describe('portal AP contract', () => {
|
||||
test('serves API, persists fixture parameters, and completes a real scan', async ({ request }) => {
|
||||
const root = await request.get('/');
|
||||
expect(root.ok()).toBeTruthy();
|
||||
expect(await root.text()).toContain('<html');
|
||||
|
||||
const [bootstrapResponse, concurrentRoot] = await Promise.all([
|
||||
request.get('/api/bootstrap'),
|
||||
request.get('/'),
|
||||
]);
|
||||
expect(concurrentRoot.ok()).toBeTruthy();
|
||||
const bootstrap = await json(bootstrapResponse);
|
||||
expect(bootstrap.contractVersion).toBe(3);
|
||||
expect(bootstrap.context.portalActive).toBe(true);
|
||||
|
||||
const metaResponse = await request.get('/api/wifi/meta');
|
||||
expect(metaResponse.ok()).toBeTruthy();
|
||||
const meta = await json(metaResponse);
|
||||
expect(JSON.stringify(meta)).toContain('installation_label');
|
||||
|
||||
const infoResponse = await request.get('/api/info');
|
||||
expect(infoResponse.ok()).toBeTruthy();
|
||||
expect(JSON.stringify(await json(infoResponse))).toContain('192.168.4.1');
|
||||
|
||||
const statusResponse = await request.get('/api/status');
|
||||
expect(statusResponse.ok()).toBeTruthy();
|
||||
|
||||
const saveResponse = await request.post('/api/params/save', {
|
||||
form: { installation_label: 'Contract verified' },
|
||||
});
|
||||
expect(saveResponse.ok()).toBeTruthy();
|
||||
expect(JSON.stringify(await json(saveResponse))).toContain('saved');
|
||||
|
||||
const paramsResponse = await request.get('/api/params');
|
||||
expect(paramsResponse.ok()).toBeTruthy();
|
||||
expect(JSON.stringify(await json(paramsResponse))).toContain('Contract verified');
|
||||
|
||||
const resetResponse = await request.post('/api/portal/timeout-reset');
|
||||
expect(resetResponse.ok()).toBeTruthy();
|
||||
expect((await json(resetResponse)).timeoutSecondsRemaining).toBeGreaterThan(800);
|
||||
|
||||
const scanStart = await request.post('/api/wifi/scan');
|
||||
expect([200, 202, 409]).toContain(scanStart.status());
|
||||
const completed = await waitForScan(request);
|
||||
expect(completed.state).toBe('complete');
|
||||
expect(completed.results_valid).toBe(true);
|
||||
|
||||
const missing = await request.get('/not-a-portal-route');
|
||||
expect(missing.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('renders stable desktop and mobile portal views without page errors', async ({ browser }) => {
|
||||
test.skip(process.env.PORTAL_BROWSER_MODE === 'skip', 'Browser checks were explicitly skipped.');
|
||||
const desktop = await browser.newContext({ viewport: { width: 1440, height: 1080 } });
|
||||
const page = await desktop.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') errors.push(message.text());
|
||||
});
|
||||
|
||||
await page.goto('/', { waitUntil: 'networkidle' });
|
||||
await expect(page.locator('#wm-reset-portal-timeout')).toBeVisible();
|
||||
await page.screenshot({ path: `${process.env.ARTIFACT_DIR}/portal-overview-desktop.png`, fullPage: true });
|
||||
|
||||
await page.goto('/#/wifi', { waitUntil: 'networkidle' });
|
||||
await expect(page.locator('#wm-refresh-scan')).toBeVisible();
|
||||
await expect(page.locator('#wm-f-installation_label')).toBeVisible();
|
||||
await page.locator('#wm-f-installation_label').fill('Browser verified');
|
||||
await page.screenshot({ path: `${process.env.ARTIFACT_DIR}/portal-wifi-desktop.png`, fullPage: true });
|
||||
|
||||
const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true });
|
||||
const mobilePage = await mobile.newPage();
|
||||
mobilePage.on('pageerror', (error) => errors.push(error.message));
|
||||
mobilePage.on('console', (message) => {
|
||||
if (message.type() === 'error') errors.push(message.text());
|
||||
});
|
||||
await mobilePage.goto('/#/info', { waitUntil: 'networkidle' });
|
||||
await expect(mobilePage.locator('.wm-page-head')).toBeVisible();
|
||||
await mobilePage.screenshot({ path: `${process.env.ARTIFACT_DIR}/portal-device-mobile.png`, fullPage: true });
|
||||
|
||||
await mobile.close();
|
||||
await desktop.close();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
const fs = require('fs');
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
function stationCredentials() {
|
||||
const file = process.env.PORTAL_STATION_ENV;
|
||||
if (!file) return null;
|
||||
const values = {};
|
||||
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
||||
if (!raw || raw.startsWith('#')) continue;
|
||||
const separator = raw.indexOf('=');
|
||||
if (separator > 0) values[raw.slice(0, separator)] = raw.slice(separator + 1);
|
||||
}
|
||||
if (!values.WIFI_SSID || !values.WIFI_PASSWORD) {
|
||||
throw new Error('PORTAL_STATION_ENV must define WIFI_SSID and WIFI_PASSWORD.');
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
test('optionally hands the fixture off to a real station network', async ({ request }) => {
|
||||
const credentials = stationCredentials();
|
||||
test.skip(!credentials, 'No station environment was supplied.');
|
||||
|
||||
const metaResponse = await request.get('/api/wifi/meta');
|
||||
expect(metaResponse.ok()).toBeTruthy();
|
||||
const meta = JSON.parse(await metaResponse.text());
|
||||
const form = Array.isArray(meta.profiles) && meta.profiles.length
|
||||
? { s0: credentials.WIFI_SSID, p0: credentials.WIFI_PASSWORD, stationAction: 'connect' }
|
||||
: { s: credentials.WIFI_SSID, p: credentials.WIFI_PASSWORD, stationAction: 'connect' };
|
||||
const queued = await request.post('/api/wifi/save', { form });
|
||||
expect(queued.status()).toBe(202);
|
||||
|
||||
let state;
|
||||
await expect.poll(async () => {
|
||||
const response = await request.get('/api/wifi/connect-status');
|
||||
state = JSON.parse(await response.text());
|
||||
return state.state;
|
||||
}, { timeout: 45_000, intervals: [500, 700, 1_000] }).toBe('success');
|
||||
expect(state.stationIp).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
|
||||
expect(state.redirectUrl).toContain(state.stationIp);
|
||||
|
||||
const complete = await request.post('/api/wifi/connect-complete');
|
||||
expect(complete.ok()).toBeTruthy();
|
||||
});
|
||||
Executable
+152
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared host-side helpers for the WiFiManager portal hardware contract.
|
||||
# They never modify a network interface other than the explicit client adapter.
|
||||
|
||||
wm_portal_state_root() {
|
||||
printf '%s/wifimanager-portal-hardware' "${XDG_STATE_HOME:-$HOME/.local/state}"
|
||||
}
|
||||
|
||||
wm_require() {
|
||||
command -v "$1" >/dev/null 2>&1 || {
|
||||
echo "Required command not found: $1" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
wm_default_route_interface() {
|
||||
ip route show default 2>/dev/null | awk '/^default/{print $5; exit}'
|
||||
}
|
||||
|
||||
wm_acquire_hardware_lock() {
|
||||
local lock_file="${WM_HARDWARE_LOCK_FILE:-/tmp/wifimanager-hardware.lock}"
|
||||
exec 9>"$lock_file"
|
||||
flock -n 9 || {
|
||||
echo "Another WiFiManager hardware task is already running; wait for it to finish." >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
wm_require_client_adapter() {
|
||||
local interface="$1" allow_takeover="$2" default_interface active_connection
|
||||
ip link show "$interface" >/dev/null 2>&1 || {
|
||||
echo "Wi-Fi interface not found: $interface" >&2
|
||||
return 1
|
||||
}
|
||||
default_interface="$(wm_default_route_interface)"
|
||||
[[ "$interface" != "$default_interface" ]] || {
|
||||
echo "Refusing to use the host default-route interface: $interface" >&2
|
||||
return 1
|
||||
}
|
||||
active_connection="$(nmcli -g GENERAL.CONNECTION device show "$interface" 2>/dev/null || true)"
|
||||
if [[ -n "$active_connection" && "$active_connection" != "--" && "$allow_takeover" != "yes" ]]; then
|
||||
echo "Client adapter $interface already has connection '$active_connection'." >&2
|
||||
echo "Pass --take-over-client-adapter to replace only that adapter's connection." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
wm_portal_ssid() {
|
||||
case "$1" in
|
||||
esp8266) printf '%s\n' 'WM Contract ESP8266' ;;
|
||||
esp32) printf '%s\n' 'WM Contract ESP32' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
wm_wait_for_portal_ssid() {
|
||||
local interface="$1" ssid="$2" attempt
|
||||
nmcli device wifi rescan ifname "$interface" >/dev/null 2>&1 || true
|
||||
for attempt in $(seq 1 45); do
|
||||
if nmcli -t -f SSID device wifi list ifname "$interface" | grep -Fxq "$ssid"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
nmcli device wifi rescan ifname "$interface" >/dev/null 2>&1 || true
|
||||
done
|
||||
echo "Portal SSID not detected on $interface: $ssid" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wm_remove_connection_by_name() {
|
||||
local name="$1"
|
||||
[[ -n "$name" ]] || return 0
|
||||
nmcli connection down "$name" >/dev/null 2>&1 || true
|
||||
nmcli connection delete "$name" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
wm_create_portal_connection() {
|
||||
local interface="$1" ssid="$2" password="$3" name uuid
|
||||
name="wifimanager-portal-${RANDOM}-$(date +%s)"
|
||||
nmcli device disconnect "$interface" >/dev/null 2>&1 || true
|
||||
wm_wait_for_portal_ssid "$interface" "$ssid"
|
||||
if ! nmcli connection add type wifi ifname "$interface" con-name "$name" ssid "$ssid" \
|
||||
ipv4.method auto ipv4.never-default yes ipv6.method ignore connection.autoconnect no >/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
if ! nmcli connection modify "$name" wifi-sec.key-mgmt wpa-psk wifi-sec.psk "$password"; then
|
||||
wm_remove_connection_by_name "$name"
|
||||
return 1
|
||||
fi
|
||||
if ! nmcli connection up "$name" ifname "$interface"; then
|
||||
wm_remove_connection_by_name "$name"
|
||||
return 1
|
||||
fi
|
||||
uuid="$(nmcli -g connection.uuid connection show "$name")"
|
||||
if [[ -z "$uuid" || "$uuid" == "--" ]]; then
|
||||
wm_remove_connection_by_name "$name"
|
||||
echo "NetworkManager did not return a UUID for the portal connection." >&2
|
||||
return 1
|
||||
fi
|
||||
WM_PORTAL_CONNECTION_UUID="$uuid"
|
||||
WM_PORTAL_CONNECTION_NAME="$name"
|
||||
export WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
|
||||
}
|
||||
|
||||
wm_verify_portal_route() {
|
||||
local interface="$1" route
|
||||
route="$(ip route get 192.168.4.1 2>/dev/null || true)"
|
||||
[[ "$route" == *" dev $interface "* ]] || {
|
||||
echo "Portal route does not use the selected adapter: $route" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
wm_remove_connection() {
|
||||
local uuid="$1"
|
||||
[[ -n "$uuid" ]] || return 0
|
||||
nmcli connection down uuid "$uuid" >/dev/null 2>&1 || true
|
||||
nmcli connection delete uuid "$uuid" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
wm_state_file() {
|
||||
printf '%s/session.env\n' "$(wm_portal_state_root)"
|
||||
}
|
||||
|
||||
wm_write_state() {
|
||||
local interface="$1" platform="$2" uuid="$3" name="$4" root file
|
||||
root="$(wm_portal_state_root)"
|
||||
file="$(wm_state_file)"
|
||||
install -d -m 700 "$root"
|
||||
umask 077
|
||||
printf 'WM_PORTAL_INTERFACE=%q\nWM_PORTAL_PLATFORM=%q\nWM_PORTAL_CONNECTION_UUID=%q\nWM_PORTAL_CONNECTION_NAME=%q\n' \
|
||||
"$interface" "$platform" "$uuid" "$name" >"$file"
|
||||
chmod 600 "$file"
|
||||
}
|
||||
|
||||
wm_load_state() {
|
||||
local file
|
||||
file="$(wm_state_file)"
|
||||
[[ -f "$file" ]] || {
|
||||
echo "No active WiFiManager portal session was found." >&2
|
||||
return 1
|
||||
}
|
||||
# The state file is created above using shell-escaped values and mode 0600.
|
||||
# shellcheck disable=SC1090
|
||||
source "$file"
|
||||
}
|
||||
|
||||
wm_clear_state() {
|
||||
local file
|
||||
file="$(wm_state_file)"
|
||||
rm -f "$file"
|
||||
}
|
||||
Executable
+153
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=tools/lib/portal-hardware-session.sh
|
||||
source "$root/tools/lib/portal-hardware-session.sh"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE' >&2
|
||||
Usage:
|
||||
./tools/portal-hardware doctor --client-interface IFACE [--take-over-client-adapter]
|
||||
./tools/portal-hardware up --platform esp8266|esp32 --port /dev/serial/by-id/... \
|
||||
--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|require|skip] [--station-env PATH] [--output DIRECTORY]
|
||||
./tools/portal-hardware down
|
||||
|
||||
Only the named client interface may be disconnected or reconfigured. The tool
|
||||
refuses the host default-route interface and preserves the created connection
|
||||
in a 0600 state file until `down` or normal `run` cleanup.
|
||||
USAGE
|
||||
exit 2
|
||||
}
|
||||
|
||||
command_name="${1:-}"
|
||||
[[ -n "$command_name" ]] || usage
|
||||
shift || true
|
||||
|
||||
platform=""
|
||||
port=""
|
||||
client_interface=""
|
||||
takeover="no"
|
||||
keep="no"
|
||||
browser="auto"
|
||||
station_env=""
|
||||
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 ;;
|
||||
--client-interface) [[ $# -ge 2 ]] || usage; client_interface="$2"; shift 2 ;;
|
||||
--take-over-client-adapter) takeover="yes"; shift ;;
|
||||
--keep) keep="yes"; shift ;;
|
||||
--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 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
require_common() {
|
||||
wm_require ip
|
||||
wm_require nmcli
|
||||
wm_require pio
|
||||
wm_require docker
|
||||
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"
|
||||
fi
|
||||
install -d -m 700 "$output_dir"
|
||||
output_dir="$(cd "$output_dir" && pwd)"
|
||||
}
|
||||
|
||||
validate_run_arguments() {
|
||||
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
|
||||
[[ -n "$client_interface" ]] || usage
|
||||
[[ -n "$port" && -e "$port" ]] || {
|
||||
echo "Serial port not found: $port" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$browser" == "auto" || "$browser" == "require" || "$browser" == "skip" ]] || usage
|
||||
[[ -z "$station_env" || -r "$station_env" ]] || {
|
||||
echo "Station environment file is not readable: $station_env" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
start_portal_session() {
|
||||
local ssid
|
||||
validate_run_arguments
|
||||
require_common
|
||||
wm_acquire_hardware_lock
|
||||
wm_require_client_adapter "$client_interface" "$takeover"
|
||||
prepare_output_dir
|
||||
ssid="$(wm_portal_ssid "$platform")"
|
||||
|
||||
pio run -d "$root/test/portal-harness" -e "$platform" -t upload --upload-port "$port"
|
||||
if ! wm_create_portal_connection "$client_interface" "$ssid" "default1"; then
|
||||
return 1
|
||||
fi
|
||||
if ! wm_verify_portal_route "$client_interface"; 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
|
||||
fi
|
||||
printf 'Portal connected on %s. Artifacts: %s\n' "$client_interface" "$output_dir"
|
||||
}
|
||||
|
||||
finish_portal_session() {
|
||||
wm_load_state
|
||||
wm_remove_connection "$WM_PORTAL_CONNECTION_UUID"
|
||||
wm_clear_state
|
||||
}
|
||||
|
||||
case "$command_name" in
|
||||
doctor)
|
||||
[[ -n "$client_interface" ]] || usage
|
||||
require_common
|
||||
wm_acquire_hardware_lock
|
||||
wm_require_client_adapter "$client_interface" "$takeover"
|
||||
printf 'Portal hardware prerequisites are ready. Main route is untouched; client adapter: %s\n' "$client_interface"
|
||||
;;
|
||||
up)
|
||||
start_portal_session
|
||||
;;
|
||||
down)
|
||||
[[ -z "$platform$port$client_interface$station_env$output_dir" ]] || usage
|
||||
wm_acquire_hardware_lock
|
||||
finish_portal_session
|
||||
echo 'Portal client connection removed.'
|
||||
;;
|
||||
run)
|
||||
start_portal_session
|
||||
cleanup() {
|
||||
if [[ "$keep" != "yes" ]]; then
|
||||
finish_portal_session || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
export PORTAL_ARTIFACT_DIR="$output_dir"
|
||||
export LOCAL_UID="$(id -u)"
|
||||
export LOCAL_GID="$(id -g)"
|
||||
export PORTAL_BROWSER_MODE="$browser"
|
||||
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")"
|
||||
compose_files+=(-f "$root/tests/portal-contract/compose.station.yaml")
|
||||
fi
|
||||
docker compose "${compose_files[@]}" run --rm portal-contract
|
||||
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'
|
||||
fi
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
@@ -1,153 +0,0 @@
|
||||
#!/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"
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
tmp="$(mktemp -d "${TMPDIR:-/tmp}/wifimanager-portal-cli.XXXXXX")"
|
||||
cleanup() { rm -rf "$tmp"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
stub_bin="$tmp/bin"
|
||||
mkdir -p "$stub_bin"
|
||||
export CALL_LOG="$tmp/calls.log"
|
||||
export WM_HARDWARE_LOCK_FILE="$tmp/hardware.lock"
|
||||
|
||||
printf '%s\n' '#!/usr/bin/env bash' \
|
||||
'if [[ "$1" == "link" && "$2" == "show" ]]; then exit 0; fi' \
|
||||
'if [[ "$1" == "route" && "$2" == "show" ]]; then echo "default via 192.0.2.1 dev wlan-main"; exit 0; fi' \
|
||||
'echo "192.168.4.1 dev wlan-client src 192.168.4.2"' >"$stub_bin/ip"
|
||||
printf '%s\n' '#!/usr/bin/env bash' \
|
||||
'printf "%s\\n" "$*" >>"$CALL_LOG"' \
|
||||
'if [[ "${NMCLI_FAIL_UP:-}" == "yes" && "$1" == "connection" && "$2" == "up" ]]; then exit 7; fi' \
|
||||
'if [[ "$1" == "-g" ]]; then echo "--"; fi' >"$stub_bin/nmcli"
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'exit 0' >"$stub_bin/pio"
|
||||
printf '%s\n' '#!/usr/bin/env bash' \
|
||||
'if [[ "$1" == "compose" && "$2" == "version" ]]; then echo "Docker Compose"; exit 0; fi' \
|
||||
'exit 0' >"$stub_bin/docker"
|
||||
chmod 755 "$stub_bin"/*
|
||||
export PATH="$stub_bin:$PATH"
|
||||
|
||||
"$root/tools/portal-hardware" doctor --client-interface wlan-client >/dev/null
|
||||
! grep -Eq 'connection (add|modify|delete)|device disconnect' "$CALL_LOG"
|
||||
|
||||
if "$root/tools/portal-hardware" doctor --client-interface wlan-main >/dev/null 2>&1; then
|
||||
echo 'default-route adapter guard did not reject the request' >&2
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
|
||||
# A failed association must delete the only connection it just created.
|
||||
source "$root/tools/lib/portal-hardware-session.sh"
|
||||
wm_wait_for_portal_ssid() { return 0; }
|
||||
export NMCLI_FAIL_UP=yes
|
||||
if wm_create_portal_connection wlan-client 'fixture portal' placeholder; then
|
||||
echo 'failed association was reported as success' >&2
|
||||
exit 1
|
||||
fi
|
||||
unset NMCLI_FAIL_UP
|
||||
grep -Eq 'connection delete wifimanager-portal-' "$CALL_LOG"
|
||||
! grep -Eq 'connection (add|modify|delete)|device disconnect' "$CALL_LOG"
|
||||
|
||||
echo 'portal-hardware CLI safety checks passed'
|
||||
Reference in New Issue
Block a user