From fcf3a645dbb2a39083051924b2ff824342f40deb Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 29 Jun 2026 12:14:27 +1000 Subject: [PATCH] ifcviewer-web: add a headless-browser smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives the built web page in a real Chrome (channel:'chrome', so no `playwright install`): waits for wgpu init, then asserts an orbit drag changes the composited canvas — one check that simultaneously proves the scene rendered, mouse input is wired, and the log overlay isn't eating events — and that zero uncaptured WebGPU errors were logged. Every web bring-up bug so far (blank render, error-buffer cascade, overlay swallowing input) is this shape; this would have caught them. serve.mjs statically serves build-web; the config launches headed against the real GPU (--use-angle=vulkan + --ignore-gpu-blocklist are load-bearing for a non-null adapter on Linux Chrome). node_modules and results are gitignored. See README.md to run. Co-Authored-By: Claude Opus 4.8 --- src/ifcviewer-web/tests/.gitignore | 3 + src/ifcviewer-web/tests/README.md | 45 ++++++++++++++ src/ifcviewer-web/tests/package.json | 12 ++++ src/ifcviewer-web/tests/playwright.config.mjs | 38 ++++++++++++ src/ifcviewer-web/tests/serve.mjs | 40 ++++++++++++ src/ifcviewer-web/tests/smoke.spec.mjs | 62 +++++++++++++++++++ 6 files changed, 200 insertions(+) create mode 100644 src/ifcviewer-web/tests/.gitignore create mode 100644 src/ifcviewer-web/tests/README.md create mode 100644 src/ifcviewer-web/tests/package.json create mode 100644 src/ifcviewer-web/tests/playwright.config.mjs create mode 100644 src/ifcviewer-web/tests/serve.mjs create mode 100644 src/ifcviewer-web/tests/smoke.spec.mjs diff --git a/src/ifcviewer-web/tests/.gitignore b/src/ifcviewer-web/tests/.gitignore new file mode 100644 index 0000000000..cd50951f21 --- /dev/null +++ b/src/ifcviewer-web/tests/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +test-results/ +package-lock.json diff --git a/src/ifcviewer-web/tests/README.md b/src/ifcviewer-web/tests/README.md new file mode 100644 index 0000000000..8def5a3d8f --- /dev/null +++ b/src/ifcviewer-web/tests/README.md @@ -0,0 +1,45 @@ +# Web smoke tests + +Headless-browser smoke tests for the WebGPU/Emscripten ifcviewer build. +They load the built page in a real Chrome, wait for wgpu init, and assert +the embedded sample **renders non-blank**, an **orbit drag changes the +framebuffer**, and **no uncaptured WebGPU errors** are logged. Every web +bring-up bug so far (blank render, error-buffer cascade, an overlay +swallowing mouse input) is this shape. + +## Prerequisites + +- The web build must exist at `build-web/` (repo root): + ```sh + source /path/to/emsdk_env.sh + emcmake cmake -S src/ifcviewer-web -B build-web + ninja -C build-web IfcViewerWeb + ``` +- Node + a system Chrome (`google-chrome-stable`). The config uses + `channel: 'chrome'`, so you do **not** need `npx playwright install`. + +## Run + +```sh +cd src/ifcviewer-web/tests +npm install # one-time: pulls @playwright/test +npm test +``` + +`serve.mjs` statically serves `build-web` on :8124 (override with +`WEB_BUILD_DIR=/path PORT=...`). Playwright starts it automatically. + +## Headless / CI + +WebGPU + headless on Linux is finicky, so the default config runs +**headed** against the machine's real GPU. On a headless box: + +```sh +xvfb-run -a npm test +``` + +For a GPU-less runner, flip `headless: true` in +`playwright.config.mjs` and provide a SwiftShader Vulkan ICD +(`VK_ICD_FILENAMES=.../vk_swiftshader_icd.json`) plus +`--use-angle=swiftshader`. Browser WebGPU over SwiftShader is slow but +adequate for a render-non-blank assertion. diff --git a/src/ifcviewer-web/tests/package.json b/src/ifcviewer-web/tests/package.json new file mode 100644 index 0000000000..79c2b00ac6 --- /dev/null +++ b/src/ifcviewer-web/tests/package.json @@ -0,0 +1,12 @@ +{ + "name": "ifcviewer-web-smoke", + "private": true, + "type": "module", + "description": "Headless-browser smoke tests for the WebGPU/Emscripten ifcviewer build.", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.48.0" + } +} diff --git a/src/ifcviewer-web/tests/playwright.config.mjs b/src/ifcviewer-web/tests/playwright.config.mjs new file mode 100644 index 0000000000..241af97881 --- /dev/null +++ b/src/ifcviewer-web/tests/playwright.config.mjs @@ -0,0 +1,38 @@ +import { defineConfig } from '@playwright/test'; + +// Smoke tests for the WebGPU/Emscripten build. They drive a real Chrome +// (channel: 'chrome' — the system google-chrome-stable, which ships a +// working WebGPU stack) rather than Playwright's bundled Chromium, so +// you don't need `npx playwright install`. +// +// WebGPU + headless on Linux is finicky, so the default is HEADED: it +// uses the machine's real GPU via the X display. For a headless CI box, +// run under xvfb-run, or flip headless:true and add a SwiftShader Vulkan +// ICD (see README). +export default defineConfig({ + testDir: '.', + timeout: 60_000, + reporter: [['list']], + webServer: { + command: 'node serve.mjs', + url: 'http://localhost:8124/IfcViewerWeb.html', + reuseExistingServer: true, + timeout: 30_000, + }, + use: { + baseURL: 'http://localhost:8124', + channel: 'chrome', + headless: false, + launchOptions: { + // This exact combo is what makes requestAdapter return non-null on + // a headed Linux Chrome here — --use-angle=vulkan + the blocklist + // override are both load-bearing (probed during scaffold bring-up). + args: [ + '--enable-unsafe-webgpu', + '--enable-features=Vulkan', + '--ignore-gpu-blocklist', + '--use-angle=vulkan', + ], + }, + }, +}); diff --git a/src/ifcviewer-web/tests/serve.mjs b/src/ifcviewer-web/tests/serve.mjs new file mode 100644 index 0000000000..cbbe84f538 --- /dev/null +++ b/src/ifcviewer-web/tests/serve.mjs @@ -0,0 +1,40 @@ +// Minimal static file server for the Emscripten build output. WebGPU +// needs a real http(s) origin (not file://), so the smoke test serves +// the build-web directory over localhost. No COOP/COEP headers yet — +// pthreads are off in the current web build (that's task #47). +// +// Serve dir resolution: $WEB_BUILD_DIR if set, else the repo's build-web. +import http from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = process.env.WEB_BUILD_DIR + ? path.resolve(process.env.WEB_BUILD_DIR) + : path.resolve(__dirname, '../../../build-web'); +const PORT = Number(process.env.PORT || 8124); + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.wasm': 'application/wasm', + '.ifcview': 'application/octet-stream', +}; + +http.createServer(async (req, res) => { + try { + const url = new URL(req.url, `http://localhost:${PORT}`); + let p = decodeURIComponent(url.pathname); + if (p === '/') p = '/IfcViewerWeb.html'; + const file = path.join(ROOT, p); + // Contain to ROOT. + if (!file.startsWith(ROOT)) { res.writeHead(403).end(); return; } + const body = await readFile(file); + res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' }); + res.end(body); + } catch { + res.writeHead(404).end('not found'); + } +}).listen(PORT, () => console.log(`serving ${ROOT} on http://localhost:${PORT}`)); diff --git a/src/ifcviewer-web/tests/smoke.spec.mjs b/src/ifcviewer-web/tests/smoke.spec.mjs new file mode 100644 index 0000000000..3b6529023c --- /dev/null +++ b/src/ifcviewer-web/tests/smoke.spec.mjs @@ -0,0 +1,62 @@ +import { test, expect } from '@playwright/test'; + +// End-to-end smoke test for the WebGPU build. Every bug from the bring-up +// of camera input + file loading was exactly this shape — the page would +// load but render blank, spam uncaptured WebGPU errors, or swallow mouse +// input behind an overlay. This test would have caught all of them. + +// Capture the composited canvas pixels as a PNG buffer. We use +// Playwright's element screenshot rather than an in-page drawImage/ +// toDataURL: reading a WebGPU canvas back through a 2D context returns +// blank (the swap-chain texture isn't persisted for 2D copy), whereas +// the browser's own capture path includes GPU layers. +async function shot(page) { + return page.locator('#viewer-canvas').screenshot(); +} + +test('renders the sample and orbits without WebGPU errors', async ({ page }) => { + const gpuErrors = []; + page.on('console', (msg) => { + const t = msg.text(); + if (/Uncaptured WebGPU error|is invalid|Not enough memory left/i.test(t)) { + gpuErrors.push(t); + } + }); + page.on('pageerror', (e) => gpuErrors.push('pageerror: ' + e.message)); + + await page.goto('/IfcViewerWeb.html'); + + // Init is complete once C publishes the app pointer (set in the wgpu + // device callback). If WebGPU is missing or the device promise stalls, + // this times out — a real failure, not a flake. + await page.waitForFunction( + () => !!(window.Module && window.Module._app_ptr), + null, + { timeout: 30_000 }, + ); + + // Let a few frames render the embedded sample. + await page.waitForTimeout(1000); + + // Drag across the canvas centre to orbit. The composited canvas must + // change — a single assertion that simultaneously proves the scene + // rendered (a blank canvas dragged stays blank), input is wired, and + // the log overlay isn't intercepting mouse events over the viewport. + const box = await page.locator('#viewer-canvas').boundingBox(); + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + const before = await shot(page); + await page.mouse.move(cx, cy); + await page.mouse.down(); + await page.mouse.move(cx + 140, cy + 50, { steps: 10 }); + await page.mouse.up(); + await page.waitForTimeout(500); + const after = await shot(page); + expect( + Buffer.compare(before, after), + 'orbit drag did not change the canvas — blank render or dead input', + ).not.toBe(0); + + // No WebGPU validation/OOM noise at any point. + expect(gpuErrors, gpuErrors.join('\n')).toEqual([]); +});