mirror of
https://github.com/alexhopeoconnor/WiFiManager.git
synced 2026-10-22 10:42:10 +10:00
test: containerize portal hardware contract
This commit is contained in:
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user