mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-17 22:11:36 +00:00
ifcviewer-web: add a headless-browser smoke test
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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
test-results/
|
||||||
|
package-lock.json
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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}`));
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user