#!/usr/bin/env node /** Exercise installed Scene controls and real project/playback operations. */ import fs from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; const shots = path.resolve('qa-shots/scene-controls'); fs.mkdirSync(shots, { recursive: true }); const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', ...(process.platform === 'darwin' ? ['--use-angle=metal', '--enable-gpu'] : ['--use-gl=angle', '--use-angle=swiftshader'])] }); const page = await browser.newPage(); const errors = []; page.on('pageerror', (error) => errors.push(error.message)); let promptValue = 'QA Scene'; page.on('dialog', async (dialog) => { await dialog.accept(dialog.type() === 'prompt' ? promptValue : undefined); }); let failures = 0; function check(name, ok) { console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); if (!ok) failures++; } try { await page.setViewport({ width: 1440, height: 900 }); await page.goto(`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => window.__godsEyeView?.sceneDirector?._controls && document.getElementById('loading-screen')?.classList.contains('hidden'), { timeout: 60000 }); await page.click('[data-collapse-target="scene-panel"]'); await page.click('#scene-new-btn'); await page.waitForFunction(() => document.querySelector('#scene-select option:checked')?.textContent === 'QA Scene'); check('New creates and selects a persisted empty scene', await page.evaluate(() => { const director = window.__godsEyeView.sceneDirector; const saved = JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')); return director._getSelectedScene().shots.length === 0 && !!document.querySelector('.scene-shot-empty') && saved.scenes.some((scene) => scene.title === 'QA Scene'); })); await page.click('#scene-capture-btn'); await page.waitForFunction(() => document.querySelectorAll('.scene-shot-row').length === 1); check('Capture stores the actual camera and visual state', await page.evaluate(() => { const director = window.__godsEyeView.sceneDirector; const shot = director._getSelectedScene().shots[0]; const current = director.styleManager.getCameraState(); return Number.isFinite(shot.camera.lat) && Math.abs(shot.camera.lat - current.lat) < 0.01 && shot.visual.style === director.styleManager.getVisualState().style; })); promptValue = 'QA shot'; await page.click('.scene-shot-label', { clickCount: 2 }); await page.waitForFunction(() => document.querySelector('.scene-shot-label')?.textContent === 'QA shot'); check('Rename persists literal text without interpreting markup', await page.evaluate(() => !document.querySelector('.scene-shot-label b') && JSON.parse(localStorage.getItem('godsEyeView.sceneProject.v2')).scenes.some((scene) => scene.shots.some((shot) => shot.title === 'QA shot')))); await page.click('#scene-capture-btn'); await page.waitForFunction(() => document.querySelectorAll('.scene-shot-row').length === 2); await page.click('.scene-shot-label'); check('Shot selection and Update use the selected shot', await page.evaluate(() => { const director = window.__godsEyeView.sceneDirector; const scene = director._getSelectedScene(); const count = scene.shots.length; document.getElementById('scene-update-shot-btn').click(); return director._selectedShotId === scene.shots[0].id && scene.shots.length === count && document.querySelector('.scene-shot-row.active .scene-shot-label').textContent === scene.shots[0].title; })); await page.evaluate(() => { window.__sceneExports = []; const create = URL.createObjectURL.bind(URL); URL.createObjectURL = (blob) => { if (blob.type === 'application/json') window.__sceneExports.push(blob.text()); return create(blob); }; }); await page.click('#scene-export-btn'); const exported = await page.evaluate(async () => JSON.parse(await window.__sceneExports[0])); check('Export produces the current project JSON', exported.scenes.some((scene) => scene.title === 'QA Scene' && scene.shots.length === 2)); // Keep real camera/style playback while isolating this control check from // live provider availability. Layer transactions are covered by their suites. const scene = exported.scenes.find((item) => item.title === 'QA Scene'); scene.shots = scene.shots.slice(0, 1).map((shot) => ({ ...shot, durationSec: 0.4, holdSec: 10, layers: {} })); const fixture = { ...exported, scenes: [scene] }; const projectFile = path.join(shots, 'project.json'); fs.writeFileSync(projectFile, JSON.stringify(fixture)); const input = await page.$('#scene-import-file'); await input.uploadFile(projectFile); await page.waitForFunction(() => document.getElementById('scene-status').textContent === 'Imported project.json'); check('Import replaces the project and clears the file input', await page.evaluate(() => window.__godsEyeView.sceneDirector._project.scenes.length === 1 && document.querySelectorAll('.scene-shot-row').length === 1 && document.getElementById('scene-import-file').value === '')); await page.click('.scene-shot-btn'); await page.waitForFunction(() => document.getElementById('scene-status').textContent.startsWith('Loaded:'), { timeout: 30000 }); check('LOAD completes the real visual and camera operation', await page.evaluate(() => !window.__godsEyeView.sceneDirector.running && document.getElementById('scene-status').textContent.includes('QA shot'))); await page.click('#scene-start-btn'); await page.waitForFunction(() => window.__godsEyeView.sceneDirector.running && document.body.classList.contains('scene-playback-mode')); check('Playback enters recording presentation and disables editing', await page.evaluate(() => document.getElementById('scene-capture-btn').disabled && document.getElementById('scene-start-btn').disabled && !document.getElementById('scene-stop-btn').disabled && window.__godsEyeView.styleManager.getViewState().recording)); await page.keyboard.press('Escape'); await page.waitForFunction(() => !window.__godsEyeView.sceneDirector.running && !document.body.classList.contains('scene-playback-mode')); check('Escape ends playback and restores idle/recording controls', await page.evaluate(() => !document.getElementById('scene-start-btn').disabled && document.getElementById('scene-stop-btn').disabled && !document.getElementById('scene-download-btn').disabled && !window.__godsEyeView.styleManager.getViewState().recording)); await page.click('#scene-download-btn'); const metadata = await page.evaluate(async () => JSON.parse(await window.__sceneExports.at(-1))); check('Run download contains the completed cancelled run', Boolean(metadata.endedAt && metadata.wasCancelled && Array.isArray(metadata.events))); await page.click('#scene-start-btn'); await page.waitForFunction(() => window.__godsEyeView.sceneDirector.running); await page.$eval('#scene-stop-btn', (button) => button.click()); await page.waitForFunction(() => !window.__godsEyeView.sceneDirector.running); check('The installed Stop control also completes playback cleanup', await page.evaluate(() => !document.body.classList.contains('scene-playback-mode') && !document.getElementById('scene-start-btn').disabled)); const badFile = path.join(shots, 'invalid.json'); fs.writeFileSync(badFile, '{invalid'); await input.uploadFile(badFile); await page.waitForFunction(() => document.getElementById('scene-status').textContent.includes('invalid JSON')); check('Invalid import reports failure and preserves the current project', await page.evaluate(() => window.__godsEyeView.sceneDirector._project.scenes.length === 1 && window.__godsEyeView.sceneDirector._getSelectedScene().title === 'QA Scene')); await page.screenshot({ path: path.join(shots, 'desktop.png') }); await page.setViewport({ width: 390, height: 844 }); await page.screenshot({ path: path.join(shots, 'narrow.png') }); check('Scene controls stay inside the narrow viewport', await page.$eval('#scene-select', (element) => { const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.left >= 0 && rect.right <= innerWidth; })); await page.setViewport({ width: 1440, height: 900 }); await page.click('.scene-shot-danger'); await page.waitForFunction(() => !!document.querySelector('.scene-shot-empty')); check('Delete shot leaves the empty-state presentation', await page.evaluate(() => window.__godsEyeView.sceneDirector._getSelectedScene().shots.length === 0)); await page.click('#scene-delete-btn'); await page.waitForFunction(() => !window.__godsEyeView.sceneDirector._project.scenes.some((item) => item.title === 'QA Scene')); check('Deleting the final scene restores the built-in recipes', await page.evaluate(() => window.__godsEyeView.sceneDirector._project.scenes.length > 0 && document.querySelectorAll('#scene-select option').length > 0)); const teardown = await page.evaluate(async () => { const director = window.__godsEyeView.sceneDirector; const controls = director._controls; const previousStatus = document.getElementById('scene-status').textContent; const project = director._project; let finish; const gate = new Promise((resolve) => { finish = resolve; }); const pending = controls.run('import', { name: 'late.json', text: async () => { await gate; return '{"scenes":[]}'; } }); const stopping = director.destroy(); const stoppedSynchronously = controls.destroyed && controls.removers.length === 0 && controls.rowRemovers.length === 0; document.getElementById('scene-new-btn').click(); controls.run('capture'); finish(); await pending; await stopping; await director.destroy(); return { stoppedSynchronously, sameProject: director._project === project, noLateStatus: document.getElementById('scene-status').textContent === previousStatus, rowsGone: !document.querySelector('.scene-shot-row') }; }); check('Disposal revokes fixed and row actions before waiting for scene work', teardown.stoppedSynchronously && teardown.rowsGone); check('Late import completion cannot replace the disposed project or status', teardown.sameProject && teardown.noLateStatus); check('Scene control interaction produces no uncaught browser errors', errors.length === 0); } finally { await browser.close(); } if (failures) process.exitCode = 1;