#!/usr/bin/env node /** * Scope and HUD browser acceptance. Requires the development server. * Run: node scripts/qa-scope-hud.mjs * Optional: QA_BASE_URL, QA_SCREENSHOT_DIR (screenshots stay outside the repo). * Proves native checkbox click/Space, feather 0/11/100/200, layout independence, * share/scene restore, and visible text backing including rotated metadata. */ import fs from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; 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 failures = 0; const check = (name, passed, detail) => { console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`); if (!passed) { failures += 1; if (detail) console.log(JSON.stringify(detail)); } }; const readScope = () => page.evaluate(() => { const scope = window.__godsEyeView.styleManager.getVisualState().scope; const canvas = document.getElementById('scope-mask'); const context = canvas.getContext('2d'); const radialX = Math.round( canvas.width / 2 + Math.min(canvas.width, canvas.height) * 0.35, ); return { enabled: scope.enabled, feather: scope.featherPct / 100, cornerAlpha: context.getImageData(1, 1, 1, 1).data[3], radialAlpha: context.getImageData( radialX, Math.round(canvas.height / 2), 1, 1, ).data[3], checked: document.getElementById('scope-toggle').checked, disabled: document.getElementById('scope-feather-slider').disabled, value: Number(document.getElementById('scope-feather-slider').value), readout: document.getElementById('scope-feather-value').textContent, }; }); try { await page.setViewport({ width: 1440, height: 900 }); const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173'); url.searchParams.set('welcome', '0'); url.hash = 'v=2&lat=30.2672&lon=-97.7431&alt=2500&heading=0&pitch=-45&roll=0&map=esri&sc=1&scf=11&l='; await page.goto(url.href, { waitUntil: 'domcontentloaded' }); await page.waitForFunction( () => window.__godsEyeView?.styleManager?._applicationShortcuts, { timeout: 60_000 }, ); await page.waitForFunction( () => { const cover = document.getElementById('loading-screen'); return ( cover?.classList.contains('hidden') && Number(getComputedStyle(cover).opacity) === 0 ); }, { timeout: 60_000 }, ); await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; manager.setPanelCollapsed('pp-toggles', false, { persist: false, syncShare: false, }); manager.hud.setMode('on'); manager.setStyle('normal'); }); const initial = await readScope(); check( 'default Scope checkbox and 11% feather agree with the mask', initial.enabled && initial.checked && !initial.disabled && initial.feather === 0.11 && initial.value === 11 && initial.cornerAlpha > 0, initial, ); const name = await page.$eval('#scope-toggle', (el) => ({ type: el.type, label: el.labels?.[0]?.textContent.trim(), })); check( 'Scope uses a native checkbox with a visible label', name.type === 'checkbox' && name.label?.includes('Scope'), name, ); await page.click('#scope-toggle'); const off = await readScope(); check( 'click disables Scope and its feather slider', !off.enabled && !off.checked && off.disabled && off.feather === 0.11 && off.cornerAlpha === 0, off, ); await page.focus('#scope-toggle'); await page.keyboard.press('Space'); const on = await readScope(); check( 'Space re-enables the native checkbox without losing feather', on.enabled && on.checked && !on.disabled && on.feather === 0.11 && on.cornerAlpha > 0, on, ); const paintedAlpha = []; for (const pct of [0, 11, 100, 200]) { await page.$eval( '#scope-feather-slider', (el, value) => { el.value = String(value); el.dispatchEvent(new Event('input', { bubbles: true })); }, pct, ); const state = await readScope(); paintedAlpha.push(state.radialAlpha); check( `feather ${pct}% reaches the mask and readout`, state.value === pct && state.feather === pct / 100 && state.readout === `${pct}%`, state, ); } check( 'painted gradient widens beyond the old 100% ceiling', paintedAlpha[0] === 0 && paintedAlpha[1] === 0 && paintedAlpha[2] > 0 && paintedAlpha[3] > paintedAlpha[2], paintedAlpha, ); await page.focus('#scope-feather-slider'); await page.keyboard.press('Home'); check('native Home reaches the hard edge', (await readScope()).feather === 0); await page.keyboard.press('End'); check( 'native End reaches the expanded 200% maximum', (await readScope()).feather === 2, ); await page.click('#scope-toggle'); for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) { await page.select('#hud-layout-select', layout); const state = await readScope(); check( `Scope OFF and 200% survive ${layout}`, !state.enabled && !state.checked && state.disabled && state.feather === 2 && state.value === 200 && state.cornerAlpha === 0, state, ); } const restored = await page.evaluate(async () => { const manager = window.__godsEyeView.styleManager; const links = manager.shareLinkManager; manager._syncShareState(); const params = links._buildHashParams(); const encoded = { enabled: params.get('sc'), feather: params.get('scf') }; await manager.applyVisualState({ scope: { enabled: true, featherPct: 0 } }); await manager._visualSettings.restoreShareState({ scopeEnabled: encoded.enabled === '1', scopeFeatherPct: Number(encoded.feather), }); const shared = manager.getVisualState().scope; await manager.applyVisualState({ scope: { enabled: true, featherPct: 200 }, }); const scene = manager.getVisualState().scope; await manager.applyVisualState({ scope: { enabled: false, featherPct: 200 }, }); return { encoded, shared, scene }; }); check( 'share state serializes and restores Scope OFF with 200%', restored.encoded.enabled === '0' && restored.encoded.feather === '200' && restored.shared.enabled === false && restored.shared.featherPct === 200, restored, ); check( 'scene state restores Scope ON with 200%', restored.scene.enabled === true && restored.scene.featherPct === 200, restored, ); for (const layout of ['minimal', 'operator', 'tactical']) { await page.select('#hud-layout-select', layout); const coverage = await page.evaluate(() => { const hud = document.getElementById('intel-hud'); const walker = document.createTreeWalker(hud, NodeFilter.SHOW_TEXT); const missing = []; let visible = 0; let node; while ((node = walker.nextNode())) { if ( !node.textContent.trim() || node.parentElement.closest('.hud-bracket') ) continue; const range = document.createRange(); range.selectNodeContents(node); if ( ![...range.getClientRects()].some( (rect) => rect.width > 0 && rect.height > 0, ) ) continue; visible += 1; const backing = node.parentElement.closest('.hud-readout'); const color = backing && getComputedStyle(backing).backgroundColor; if ( !backing || !color?.includes('0.34') || backing.parentElement.closest('.hud-readout') ) missing.push(node.textContent.trim()); } const wrapped = [...hud.querySelectorAll('.hud-readout')].filter( (el) => el.offsetWidth > 0 && el.offsetHeight > 0, ); const empty = wrapped.filter((el) => !el.innerText.trim()).length; const lineIds = [ 'hud-mgrs', 'hud-latlon', 'hud-gsd', 'hud-alt', 'hud-timestamp', 'hud-bottom-line', ]; const multiline = lineIds.filter((id) => { const el = document.getElementById(id); if (!el.getClientRects().length) return false; const range = document.createRange(); range.selectNodeContents(el); return ( range.getBoundingClientRect().height > parseFloat(getComputedStyle(el).lineHeight) * 1.5 ); }); const edges = [...hud.querySelectorAll('.hud-edge')] .filter((el) => getComputedStyle(el).display !== 'none') .map((el) => { const box = el.querySelector('.hud-readout').getBoundingClientRect(); return ( getComputedStyle(el).transform !== 'none' && box.height > box.width && Number(getComputedStyle(el).opacity) === 1 ); }); const broadBackings = [ ...hud.querySelectorAll('.hud-corner, .hud-top-bar, .hud-bottom-bar'), ].filter( (el) => el.offsetWidth > 0 && getComputedStyle(el).backgroundColor !== 'rgba(0, 0, 0, 0)', ); return { visible, missing, empty, multiline, edges, broadBackings: broadBackings.length, }; }); check( `${layout}: all visible HUD text has one backing`, coverage.visible > 0 && coverage.missing.length === 0 && coverage.empty === 0 && coverage.broadBackings === 0, coverage, ); check( `${layout}: desktop telemetry remains single line; rotated backing follows the labels`, coverage.multiline.length === 0 && coverage.edges.every(Boolean), coverage, ); if (process.env.QA_SCREENSHOT_DIR) { fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true }); await page.screenshot({ path: path.join( process.env.QA_SCREENSHOT_DIR, `scope-off-${layout}.png`, ), }); } } const details = await page.evaluate(() => { const summary = document.getElementById('hud-summary'); summary.textContent = 'LONG TELEMETRY SUMMARY '.repeat(30); const ais = document.getElementById('hud-ais-vessel'); ais.textContent = 'AIS: 4 VESSELS\nLATEST OBSERVATION'; return { summaryClipped: summary.scrollWidth > summary.clientWidth && getComputedStyle(summary).textOverflow === 'ellipsis', aisMultiline: getComputedStyle(ais).whiteSpace === 'pre-line' && ais.textContent.split('\n').length === 2 && ais.getBoundingClientRect().height + 0.5 >= parseFloat(getComputedStyle(ais).lineHeight) * 2, recColor: getComputedStyle(document.getElementById('hud-rec-dot')).color, }; }); check( 'summary clipping, multiline AIS and red REC dot retain their behavior', details.summaryClipped && details.aisMultiline && details.recColor === 'rgb(255, 51, 51)', details, ); await page.setViewport({ width: 390, height: 844 }); await page.select('#hud-layout-select', 'minimal'); const narrow = await page.evaluate(() => [...document.querySelectorAll('#intel-hud .hud-readout')] .filter( (el) => el.getClientRects().length && getComputedStyle(el).display !== 'contents', ) .every((el) => { const box = el.getBoundingClientRect(); return box.left >= 0 && box.right <= innerWidth; }), ); check('narrow HUD text backings stay within the viewport', narrow); check('runtime has no uncaught browser errors', errors.length === 0, errors); } finally { await browser.close(); } console.log(`RESULT: ${failures} failures`); process.exitCode = failures ? 1 : 0;