// Embed tests: the viewer inside a cross-origin iframe on tests/embed-host.html. // Usage: node tests/embed.mjs // viewerBase serves the Space (for example http://localhost:8080/), hostBase serves tests/ on another origin. import { chromium } from 'playwright-core'; const VIEWER = process.argv[2] || 'http://localhost:8080/'; const HOST = process.argv[3] || 'http://localhost:8081/'; const browser = await chromium.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); const page = await browser.newPage({ viewport: { width: 1000, height: 800 } }); page.setDefaultTimeout(180000); // shared CI boxes can be slow; real browsers are far faster const errors = []; page.on('pageerror', (e) => errors.push(String(e))); const results = []; const check = (name, pass, info) => results.push({ name, pass: !!pass, ...(pass ? {} : { info }) }); const poll = async (fn, ms = 15000) => { const t0 = Date.now(); let v; while (Date.now() - t0 < ms) { v = await fn(); if (v) return v; await page.waitForTimeout(150); } return v; }; const call = (method, ...args) => page.evaluate(([m, a]) => window.unrollEmbed.call(m, ...a), [method, args]); const src = new URL('index.html?embed=1', VIEWER).href; await page.goto(`${HOST}embed-host.html?src=${encodeURIComponent(src)}`, { waitUntil: 'load' }); check('host and viewer are cross-origin', new URL(HOST).origin !== new URL(VIEWER).origin); await page.waitForFunction(() => window.unrollEmbed.ready, null, { timeout: 180000 }); const ready = await page.evaluate(() => window.unrollEmbed.ready); check('viewer announces ready with version and methods', ready.version && ready.version.version && ready.methods.includes('setLayer'), ready); const frame = page.frames().find((f) => f.url().startsWith(new URL(VIEWER).origin)); check('the iframe loaded the viewer', !!frame); let st = await call('getState'); check('embed mode is on and waiting for a click', st.embed && st.embed.embedded && st.embed.framed && !st.embed.active, st.embed); check('the gate is shown', await frame.isVisible('[data-testid="embed-activate"]')); const box = await page.locator('#unroll').boundingBox(); const cx = box.x + box.width / 2, cy = box.y + box.height / 2; // before activation the wheel scrolls the host page await page.mouse.move(cx, cy); let y0 = await page.evaluate(() => scrollY); await page.mouse.wheel(0, 300); await poll(async () => (await page.evaluate(() => scrollY)) > y0); let y1 = await page.evaluate(() => scrollY); check('wheel over an inactive embed scrolls the host page', y1 > y0, { y0, y1 }); await page.evaluate(() => scrollTo({ top: 0, behavior: 'instant' })); await poll(async () => (await page.evaluate(() => scrollY)) === 0); await page.waitForTimeout(800); const box2 = await page.locator('#unroll').boundingBox(); const x = box2.x + box2.width / 2, y = box2.y + box2.height * 0.45; // click to activate await page.mouse.click(x, y); await page.waitForTimeout(300); st = await call('getState'); check('a click activates the scene', st.embed.active, st.embed); check('the gate is gone', !(await frame.isVisible('[data-testid="embed-activate"]'))); // drag orbits the camera inside the iframe const c0 = (await call('camera.get')).position; await page.mouse.move(x, y); await page.mouse.down(); await page.mouse.move(x + 120, y + 10, { steps: 8 }); await page.mouse.up(); await page.waitForTimeout(800); const c1 = (await call('camera.get')).position; check('dragging inside the iframe orbits the camera', JSON.stringify(c0) !== JSON.stringify(c1), { c0, c1 }); // the wheel now zooms and does not scroll the host const d0 = (await call('camera.get')).distance; y0 = await page.evaluate(() => scrollY); await page.mouse.move(x, y); await page.mouse.wheel(0, -400); await poll(async () => (await call('camera.get')).distance !== d0); await page.waitForTimeout(800); const d1 = (await call('camera.get')).distance; y1 = await page.evaluate(() => scrollY); check('wheel on an active embed zooms the scene', d1 !== d0, { d0, d1 }); check('and does not scroll the host page', y1 === y0, { y0, y1 }); // controls inside the iframe await frame.click('[data-testid="layer-xray"]'); await page.waitForTimeout(400); check('clicking a layer chip inside the iframe works', (await call('getState')).layer === 'xray'); await frame.locator('[data-testid="unroll-slider"]').fill('700'); await page.waitForTimeout(300); check('the scrubber works inside the iframe', Math.abs((await call('getState')).target - 0.7) < 0.01); await frame.click('[data-testid="destroy-mode"]'); await page.waitForTimeout(300); check('Destroy works inside the iframe', (await call('getState')).destroy.on); await frame.click('[data-testid="destroy-mode"]'); // postMessage API let r = await call('setLayer', 'ink'); check('postMessage call returns the API result', r.ok && r.layer === 'ink', r); await page.waitForTimeout(700); check('subscribed events reach the host', (await page.evaluate(() => window.unrollEmbed.events)).some((e) => e.event === 'layer')); r = await call('setUnroll', 7); check('validation errors come back over postMessage', !r.ok && r.code === 'invalid', r); r = await call('nope.method'); check('unknown methods are refused', !r.ok, r); r = await call('open.removeLocal', 'local-x'); check('host pages cannot touch local scrolls', !r.ok && r.code === 'forbidden', r); r = await call('run', [['setLayer', 'xray'], ['setUnroll', 0.25]]); check('run works over postMessage', r.ok && r.completed === 2, r); r = await call('screenshot'); check('screenshot over postMessage', r.ok && r.dataURL.startsWith('data:image/png'), r.error); await page.click('button[data-call=\'["setLayer","ink"]\']'); await page.waitForTimeout(400); check('host page buttons drive the viewer', (await call('getState')).layer === 'ink'); const full = await frame.getAttribute('[data-testid="embed-open-full"]', 'href'); check('Open full viewer links to the same view without embed', full && !/embed=/.test(full) && /t=/.test(full), full); // fullscreen from inside the frame await frame.click('[data-testid="fullscreen"]'); await page.waitForTimeout(500); const fs = (await call('getState')).fullscreen; check('the fullscreen button works inside the iframe', fs, fs); if (fs) await frame.evaluate(() => document.exitFullscreen()); // responsive: the scene follows the container await page.setViewportSize({ width: 420, height: 800 }); const sz = () => frame.evaluate(() => ({ w: innerWidth, c: document.getElementById('gl').getBoundingClientRect().width })); await poll(async () => { const s = await sz(); return Math.abs(s.w - s.c) < 2 && s.w < 420; }); const sizes = await sz(); check('the scene resizes with its container', Math.abs(sizes.w - sizes.c) < 2 && sizes.w < 420, sizes); // leaving: a click on the host gives the wheel back await page.mouse.click(5, 5); await page.waitForTimeout(300); check('clicking the host page deactivates the embed', !(await call('getState')).embed.active); // autoplay and controls=0 await page.setViewportSize({ width: 1000, height: 800 }); const src2 = new URL('index.html?embed=1&autoplay=1&controls=0&scroll=PHerc0139&t=0.1&layer=xray', VIEWER).href; await page.goto(`${HOST}embed-host.html?src=${encodeURIComponent(src2)}`, { waitUntil: 'load' }); await page.waitForFunction(() => window.unrollEmbed.ready, null, { timeout: 180000 }); const frame2 = page.frames().find((f) => f.url().startsWith(new URL(VIEWER).origin)); st = await call('getState'); check('autoplay plays on load', st.playing || st.target > 0.1, { playing: st.playing, target: st.target }); check('state params apply in embeds', st.layer === 'xray', st.layer); check('controls=0 hides the controls', !(await frame2.isVisible('[data-testid="layer-ink"]')) && !(await frame2.isVisible('[data-testid="unroll-slider"]'))); // the Embed panel in the full viewer await page.goto(VIEWER, { waitUntil: 'load' }); await page.waitForFunction(() => window.unroll && window.unroll.waitFor, null, { timeout: 120000 }); await page.evaluate(() => window.unroll.waitFor('ready', { timeout: 180000 })); await page.click('[data-testid="embed"]'); const code = await page.inputValue('[data-testid="embed-code"]'); check('the Embed panel shows an iframe snippet', /