Skip to content
RPRichard PruittTech & Cyber
Navigation
repo://poppyup/viewer/test/run.mjs

viewer/test/run.mjs

9.9 KB · 113 lines · javascript · Download from the repository page

viewer/test/run.mjs
// 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);