#!/usr/bin/env node /** * tools/dnd-smoke.mjs — CSR (Path B / drag&drop) load smoke. * * The per-format perf-smoke only exercised Path A (?model=). This drives the * REAL local-file path: fetch each sample into a File, hand it to * window.__viewer.loadLocalFile(file) (exactly what the dropzone does), and wait * for hmw:ready. Catches the blob:-URL-has-no-extension class of bug. * * USAGE: node tools/dnd-smoke.mjs [--url URL] [--asset /samples/x ...] * (run `npm run build && npm run preview` first) */ import puppeteer from 'puppeteer-core'; import { existsSync } from 'node:fs'; const CHROME_CANDIDATES = [ 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', ]; function findBrowser() { if (process.env.PUPPETEER_EXECUTABLE_PATH && existsSync(process.env.PUPPETEER_EXECUTABLE_PATH)) return process.env.PUPPETEER_EXECUTABLE_PATH; for (const p of CHROME_CANDIDATES) if (existsSync(p)) return p; return null; } function parseArgs(argv) { const a = argv.slice(2); let url = 'http://127.0.0.1:4173'; const assets = []; for (let i = 0; i < a.length; i++) { if (a[i] === '--url') url = a[++i]; else if (a[i] === '--asset') assets.push(a[++i]); } if (!assets.length) assets.push('/samples/Box.glb', '/samples/Duck.glb', '/samples/Avocado.glb', '/samples/Cube.obj', '/samples/Cube.fbx', '/samples/Cube.dae', '/samples/Cube.ifc'); return { url, assets }; } async function dropOne(page, base, asset) { await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.waitForFunction('window.__viewer != null', { timeout: 15000 }); try { const res = await page.evaluate(async (assetPath) => { const name = assetPath.split('/').pop(); const resp = await fetch(assetPath); if (!resp.ok) return { ok: false, err: 'fetch ' + resp.status }; const blob = await resp.blob(); const file = new File([blob], name, { type: blob.type || 'application/octet-stream' }); const ready = new Promise((resolve) => { const onErr = (e) => resolve({ ok: false, err: 'viewer error: ' + (e.detail || 'unknown') }); window.addEventListener('hmw:ready', () => resolve({ ok: true }), { once: true }); // surface our own onError via console; also time out below window.__dndTimeout = setTimeout(() => resolve({ ok: false, err: 'timeout (no hmw:ready)' }), 20000); }); window.__viewer.loadLocalFile(file); return ready; }, asset); return { asset, ...res }; } catch (e) { return { asset, ok: false, err: (e && e.message) || String(e) }; } } async function main() { const o = parseArgs(process.argv); const exe = findBrowser(); if (!exe) { console.error('[dnd-smoke] No Chrome/Edge found.'); process.exit(1); } const browser = await puppeteer.launch({ executablePath: exe, headless: 'new', args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] }); const page = await browser.newPage(); const errs = []; page.on('console', (m) => { const t = m.text(); if (t.includes('load failed') || t.includes('Failed to load')) errs.push(t); }); console.log('[dnd-smoke] CSR drag&drop path, base=' + o.url); const results = []; for (const asset of o.assets) { process.stdout.write(' drop ' + asset + ' ... '); const r = await dropOne(page, o.url, asset); results.push(r); console.log(r.ok ? 'OK' : 'FAIL (' + r.err + ')'); } await browser.close(); const failed = results.filter((r) => !r.ok); console.log('\n[dnd-smoke] ' + (results.length - failed.length) + '/' + results.length + ' passed'); if (failed.length) { console.error('[dnd-smoke] FAILURES: ' + failed.map((f) => f.asset).join(', ')); process.exit(1); } console.log('[dnd-smoke] all local drops load.'); } main().catch((e) => { console.error('[dnd-smoke] Fatal: ' + (e && e.stack ? e.stack : e)); process.exit(1); });