viewer/test/run.mjs
9.9 KB · 113 lines · javascript · Download from the repository page
// Headless-Chrome interaction test + screenshots. Run from repo root: node viewer/test/run.mjs// Requires: local Google Chrome, puppeteer-core (npm i puppeteer-core in a scratch dir; set PUPPETEER_CORE path via NODE_PATH), python3.import { spawn } from 'node:child_process';import { mkdirSync, existsSync } from 'node:fs';import { createRequire } from 'node:module';import { createServer } from 'node:net';const require = createRequire(process.env.PUPPETEER_DIR ? process.env.PUPPETEER_DIR + '/' : import.meta.url);const puppeteer = require('puppeteer-core'); const CHROME = process.env.CHROME || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';const ROOT = new URL('../', import.meta.url).pathname, OUT = ROOT + 'screenshots/';// pick a free loopback port (a fixed port collided with an unrelated local server during development)const PORT = await new Promise((r) => { const srv = createServer(); srv.listen(0, '127.0.0.1', () => { const p = srv.address().port; srv.close(() => r(p)); }); });mkdirSync(OUT, { recursive: true });const server = spawn('python3', ['-m', 'http.server', String(PORT), '--bind', '127.0.0.1', '-d', ROOT], { stdio: 'ignore' });await new Promise((r) => setTimeout(r, 900));const results = []; const check = (name, ok, detail = '') => { results.push([ok ? 'PASS' : 'FAIL', name, detail]); };const close = (a, b, eps = 1e-6) => a.every((v, i) => Math.abs(v - b[i]) < eps);let browser; const errors = [];try { browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--no-sandbox', '--window-size=1500,950'] }); const page = await browser.newPage(); await page.setViewport({ width: 1500, height: 950, deviceScaleFactor: 1 }); page.on('pageerror', (e) => errors.push('pageerror: ' + e.message)); page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); page.on('requestfailed', (r) => errors.push('requestfailed: ' + r.url())); const t0 = Date.now(); await page.goto(`http://127.0.0.1:${PORT}/index.html`, { waitUntil: 'networkidle0' }); await page.waitForFunction('window.__poppyViewer && window.__poppyViewer.ready', { timeout: 20000, polling: 200 }); const tLoad = Date.now() - t0; const V = (expr) => page.evaluate(expr); const shot = async (name) => { await V('__poppyViewer.renderOnce()'); await new Promise((r) => setTimeout(r, 250)); await page.screenshot({ path: OUT + name + '.png' }); }; await new Promise((r) => setTimeout(r, 600)); check('viewer loads without page/console errors', errors.length === 0, errors.join(' | ') || `load ${tLoad} ms`); const info = await V('__poppyViewer.rendererInfo()'); check('renders geometry', info.triangles > 1000, `draw calls ${info.calls}, triangles ${info.triangles}, geometries ${info.geometries}`); await shot('01-assembled-iso'); // selection sync scene ↔ tree ↔ details await V('__poppyViewer.select("battery-pack")'); check('tree selection follows API/scene selection', (await V('__poppyViewer.treeSelected()')) === 'battery-pack'); check('details panel shows the selected component', (await V('__poppyViewer.detailsTitle()')) === 'Battery pack 4 × 21700, 2S2P, 72 Wh'); // click-pick on a rendered part: centre of the viewport should hit something in front view await V('__poppyViewer.select(null); __poppyViewer.setView("front")'); await new Promise((r) => setTimeout(r, 500)); await V('__poppyViewer.renderOnce()'); const box = await page.$eval('#viewport', (c) => { const r = c.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }); const picked = await page.evaluate(({ x, y }) => __poppyViewer.pickAt(x, y), box); check('picking at viewport centre in front view hits a front-face part', ['battery-door', 'front-frame'].includes(picked), `picked ${picked}`); await page.mouse.move(box.x, box.y); await page.mouse.down(); await page.mouse.up(); await new Promise((r) => setTimeout(r, 100)); check('click selects the picked part', (await V('__poppyViewer.state().selected')) === picked, `selected ${await V('__poppyViewer.state().selected')}`); await page.mouse.move(box.x, box.y); await page.mouse.down(); await page.mouse.move(box.x + 120, box.y + 40, { steps: 6 }); await page.mouse.up(); check('drag does not change the selection', (await V('__poppyViewer.state().selected')) === picked); await V('__poppyViewer.select(null)'); // explosion: 0% matches assembled, 100% = offset, back to 0 exact, labels follow const asm = await V('__poppyViewer.assembledPosition("chassis")'); await V('__poppyViewer.setExplode(1)'); await V('__poppyViewer.renderOnce()'); const p1 = await V('__poppyViewer.localPosition("chassis")'); check('100 % explosion moves the chassis by its authored offset (+48 mm rearward → −Z)', close(p1, [asm[0], asm[1], asm[2] - 48]), `pos ${p1}`); const childLocal = await V('__poppyViewer.localPosition("rear-skin")'); check('children stay assembled inside an exploded subassembly (offset applied once)', close(childLocal, [0, 0, 0]), `rear-skin local ${childLocal}`); await V('__poppyViewer.setView("iso")'); await new Promise((r) => setTimeout(r, 500)); await shot('02-exploded-100'); await V('__poppyViewer.setLabelMode("major")'); await V('__poppyViewer.renderOnce()'); const labels = await V('__poppyViewer.labelsShown()'); const ls = await V('__poppyViewer.labelStats()'); check('labels shown in major mode while exploded', labels.length >= 5, `${labels.length} labels shown of ${ls.candidates} candidates (${ls.occluded} occluded, ${ls.decluttered} decluttered): ${labels.slice(0, 8).join(', ')}`); for (let i = 0; i < 20; i++) { await V('__poppyViewer.setExplode(1)'); await V('__poppyViewer.setExplode(0.5)'); await V('__poppyViewer.setExplode(0)'); } const p0 = await V('__poppyViewer.localPosition("chassis")'); check('after 20 explode cycles 0 % equals assembled exactly', p0.every((v, i) => v === asm[i]), `pos ${p0} vs ${asm}`); // scoped explosion await V('__poppyViewer.setScope("battery-pack"); __poppyViewer.setExplode(1)'); const cell = await V('__poppyViewer.localPosition("cell-1")'), chassisScoped = await V('__poppyViewer.localPosition("chassis")'); check('scoped explosion moves only the scope\'s children', !close(cell, [0, 0, 0]) && close(chassisScoped, asm), `cell-1 ${cell}, chassis ${chassisScoped}`); await V('__poppyViewer.isolate("battery-pack")'); await new Promise((r) => setTimeout(r, 500)); await shot('03-isolated-battery-pack-scoped-explode'); check('isolation hides everything outside the subassembly', !(await V('__poppyViewer.isVisible("chassis")')) && (await V('__poppyViewer.isVisible("cell-2")'))); await V('__poppyViewer.setExplode(0); __poppyViewer.setScope(null); __poppyViewer.exitIsolation()'); check('exit isolation restores visibility', await V('__poppyViewer.isVisible("chassis")')); // hide / show all / selected-hidden behaviour await V('__poppyViewer.select("slat-1"); __poppyViewer.hide("shutter")'); check('hiding an assembly hides its parts', !(await V('__poppyViewer.isVisible("slat-1")'))); check('selection persists when the selected part is hidden', (await V('__poppyViewer.state().selected')) === 'slat-1'); await V('__poppyViewer.renderOnce()'); check('hidden components get no label', !(await V('__poppyViewer.labelsShown()')).includes('slat-1')); await V('__poppyViewer.showAll()'); check('show all restores hidden components', await V('__poppyViewer.isVisible("slat-1")')); // search await V('__poppyViewer.search("ASM2812")'); const vis = await V('__poppyViewer.treeVisibleIds()'); check('search reveals matching parts with their ancestors', vis.includes('asm2812') && vis.includes('main-board-asm') && vis.includes('pad-asm2812') && !vis.includes('cell-1'), vis.join(',')); await V('__poppyViewer.search("")'); // SKU await V('__poppyViewer.setSku("base")'); check('Base SKU hides Pro-only parts and shows the empty NVMe socket', !(await V('__poppyViewer.isVisible("m2-nvme")')) && !(await V('__poppyViewer.isVisible("asm2464pd")')) && (await V('__poppyViewer.isVisible("m2-nvme-socket")'))); await V('__poppyViewer.setSku("pro")'); // section await V('__poppyViewer.select(null); __poppyViewer.setSection({ enabled: true, axis: "x", pos: 0.79 }); __poppyViewer.setView("right")'); await new Promise((r) => setTimeout(r, 500)); await shot('04-section-x-battery-band'); check('section plane enabled state reported', (await V('__poppyViewer.state().section.enabled')) === true); await V('__poppyViewer.setSection({ enabled: false })'); // reset viewer await V('__poppyViewer.hide("chassis"); __poppyViewer.setExplode(0.6); __poppyViewer.select("som"); __poppyViewer.resetViewer()'); const st = await V('__poppyViewer.state()'); check('reset viewer restores camera, visibility, explosion, selection', st.hidden.length === 0 && st.explode === 0 && st.selected === null && st.isolated === null && st.section.enabled === false, JSON.stringify(st)); // camera clipping sanity: near plane small relative to distance const cam = await V('__poppyViewer.cameraInfo()'); check('near/far planes sized to the model', cam.near < 5 && cam.far > 1000, `near ${cam.near} far ${cam.far}`); // resize await page.setViewport({ width: 900, height: 700 }); await new Promise((r) => setTimeout(r, 300)); await V('__poppyViewer.renderOnce()'); check('no errors after resize to a small layout', errors.length === 0, errors.join(' | ')); await shot('05-small-layout'); check('no page/console errors during the whole run', errors.length === 0, errors.join(' | '));} catch (e) { results.push(['FAIL', 'test run', String(e.stack || e) + (typeof errors !== 'undefined' ? ' | page errors: ' + errors.join(' | ') : '')]); }finally { await browser?.close(); server.kill(); }const fails = results.filter((r) => r[0] === 'FAIL').length;for (const [s, n, d] of results) console.log(`${s.padEnd(5)} ${n}${d ? ' — ' + d : ''}`);console.log(`\n${results.length - fails} PASS, ${fails} FAIL. Screenshots in viewer/screenshots/`);process.exit(fails ? 1 : 0);