test: containerize portal hardware contract

This commit is contained in:
2026-09-06 20:32:46 +10:00
parent d57f6c91c5
commit daf9f207b2
20 changed files with 758 additions and 181 deletions
+11
View File
@@ -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"]
+11
View File
@@ -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
+18
View File
@@ -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
+60
View File
@@ -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"
}
}
}
}
+11
View File
@@ -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();
});