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
+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([]);
});
});