gods-eye-view/scripts/qa-attribution-b12.mjs

407 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* qa-attribution-b12.mjs — visual + state proof for Batch 12 (data attribution).
*
* Findings H10 + H11 (docs/pre-ship-audit-2026-07-01.md):
* H10 — the Google/Cesium credit MUST stay visible in clean-view AND
* recording modes (those are the modes used to record demos).
* H11 — every data layer's required attribution must surface in the
* expandable bottom-left "Data attribution" lightbox.
*
* This script drives the REAL app headless and proves:
* (i) per-layer credits are registered in viewer.creditDisplay (H11),
* and appear in the "Data attribution" lightbox when opened;
* (ii) enabling datacenters + submarine cables keeps their credits present;
* (iii) toggling clean-view keeps #cesium-credits visible (screenshot);
* (iv) toggling recording-mode keeps #cesium-credits visible (screenshot).
*
* Run: node scripts/qa-attribution-b12.mjs --url http://localhost:4300
*/
import puppeteer from 'puppeteer';
import { existsSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
function getOpt(flag, def) {
const i = argv.indexOf(flag);
return i >= 0 && argv[i + 1] ? argv[i + 1] : def;
}
const APP_URL = getOpt('--url', 'http://localhost:4300');
const APP_ORIGIN = new URL(APP_URL).origin;
const SHOT_DIR = resolve(__dirname, '..', 'qa-shots', 'b12');
mkdirSync(SHOT_DIR, { recursive: true });
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
].filter(Boolean);
function findChromeExecutable() {
return CHROME_EXECUTABLE_CANDIDATES.find((candidate) => {
try { return existsSync(candidate); } catch { return false; }
}) || null;
}
let passed = 0;
let failed = 0;
function check(name, ok, detail) {
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
console.log(` [${tag}] ${name}${detail ? `${detail}` : ''}`);
if (ok) passed++;
else failed++;
}
// Substrings that MUST be present across the registered per-layer credits.
const REQUIRED_CREDIT_SUBSTRINGS = [
'OpenStreetMap contributors', // ODbL — datacenters/dams/roads
'adsb.lol', // ODbL — military traces
'TeleGeography', // CC BY-NC-SA — cables
'NASA FIRMS', // fires
'CelesTrak', // satellites
'U.S. Geological Survey', // earthquakes
'OpenSky Network', // flights
'AISStream', // vessels
'City of Austin', // CCTV
'Radio Browser', // internet-radio directory
];
async function main() {
const chromeExecutable = findChromeExecutable();
const browser = await puppeteer.launch({
headless: 'new',
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
await page.setRequestInterception(true);
page.on('request', (request) => {
const url = new URL(request.url());
if (url.origin === APP_ORIGIN && url.pathname === '/api/openai/hud-summary') {
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ summary: 'QA globe ready' }),
});
return;
}
if (url.origin === APP_ORIGIN && url.pathname === '/api/google/nearby-places') {
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ places: [] }),
});
return;
}
request.continue();
});
const consoleErrors = [];
const failedResponses = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
const sourceUrl = msg.location()?.url || '';
consoleErrors.push(sourceUrl ? `${msg.text()} [${sourceUrl}]` : msg.text());
}
});
page.on('response', (response) => {
if (response.status() >= 500) {
failedResponses.push(`HTTP ${response.status()} ${response.url()}`);
}
});
console.log(`\n qa-attribution-b12 → ${APP_URL}\n`);
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
// Wait for the app + viewer.creditDisplay to be live.
await page.waitForFunction(
() => window.__godsEyeView && window.__godsEyeView.viewer && window.__godsEyeView.viewer.creditDisplay,
{ timeout: 60000 },
);
// Give Cesium a few frames to render the on-screen credit line (logo + link).
await new Promise((r) => setTimeout(r, 2500));
// ── (i) H11: static credits registered ────────────────────────────
console.log('H11 — per-layer credits registered in viewer.creditDisplay');
const registeredHtml = await page.evaluate(() => {
const cd = window.__godsEyeView.viewer.creditDisplay;
const statics = cd._staticCredits || [];
return statics.map((c) => c.html);
});
check(
'creditDisplay has static per-layer credits',
registeredHtml.length >= 10,
`${registeredHtml.length} static credits registered`,
);
for (const sub of REQUIRED_CREDIT_SUBSTRINGS) {
const found = registeredHtml.some((h) => h.includes(sub));
check(`credit present: "${sub}"`, found, found ? '' : 'MISSING');
}
// ── (i) H11: credits render in the "Data attribution" lightbox ─────
console.log('\nH11 — "Data attribution" lightbox surfaces the credits');
// Force a render frame so the credit display flushes, then open the lightbox.
const lightboxState = await page.evaluate(async () => {
const viewer = window.__godsEyeView.viewer;
viewer.scene.requestRender();
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const cd = viewer.creditDisplay;
cd.showLightbox();
viewer.scene.requestRender();
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const box = document.querySelector('.cesium-credit-lightbox');
const list = box?.querySelector(':scope > ul');
const title = box?.querySelector('.cesium-credit-lightbox-title');
const close = box?.querySelector('.cesium-credit-lightbox-close');
const boxRect = box?.getBoundingClientRect();
const listRect = list?.getBoundingClientRect();
const overlay = document.querySelector('.cesium-credit-lightbox-overlay');
const worldOverlay = document.getElementById('world-overlay-root');
document.querySelector('[data-qa-attribution-stack-probe]')?.remove();
const stackProbe = document.createElement('div');
stackProbe.dataset.qaAttributionStackProbe = 'true';
stackProbe.textContent = 'QA WORLD LABEL — STACK PROBE';
Object.assign(stackProbe.style, {
position: 'absolute',
left: `${Math.max(8, (boxRect?.left || 0) - 90)}px`,
top: `${Math.max(8, (boxRect?.top || 0) + 150)}px`,
width: '180px',
padding: '5px 8px',
color: '#8ffcff',
background: 'rgba(4, 18, 28, 0.94)',
borderLeft: '3px solid #19d9e8',
font: '11px monospace',
whiteSpace: 'nowrap',
pointerEvents: 'none',
});
worldOverlay?.appendChild(stackProbe);
const stackProbeRect = stackProbe.getBoundingClientRect();
const lastItem = list?.lastElementChild;
if (list) list.scrollTop = list.scrollHeight;
const lastRect = lastItem?.getBoundingClientRect();
return {
html: box?.innerHTML || '',
itemCount: list?.children.length || 0,
linkCount: list?.querySelectorAll('a[href]').length || 0,
everyLinkHasDestination: [...(list?.querySelectorAll('a') || [])]
.every((link) => Boolean(link.getAttribute('href'))),
boxHeight: boxRect?.height || 0,
viewportHeight: window.innerHeight,
listClientHeight: list?.clientHeight || 0,
listScrollHeight: list?.scrollHeight || 0,
listClientWidth: list?.clientWidth || 0,
listScrollWidth: list?.scrollWidth || 0,
listOverflowY: list ? getComputedStyle(list).overflowY : '',
titleVisible: Boolean(title?.getBoundingClientRect().height),
closeVisible: Boolean(close?.getBoundingClientRect().height),
overlayZIndex: Number(getComputedStyle(overlay).zIndex),
worldOverlayZIndex: Number(getComputedStyle(worldOverlay).zIndex),
stackProbeIntersectsModal: Boolean(boxRect
&& stackProbeRect.left < boxRect.left
&& stackProbeRect.right > boxRect.left
&& stackProbeRect.top < boxRect.bottom
&& stackProbeRect.bottom > boxRect.top),
stackProbeExtendsOutsideModal: Boolean(boxRect && stackProbeRect.left < boxRect.left),
lastItemReachable: Boolean(lastRect && listRect
&& lastRect.bottom <= listRect.bottom + 1
&& lastRect.top >= listRect.top - 1),
};
});
const lightboxHtml = lightboxState.html;
const lightboxHits = REQUIRED_CREDIT_SUBSTRINGS.filter((s) => lightboxHtml.includes(s));
check(
'lightbox renders the per-layer credits',
lightboxHits.length >= REQUIRED_CREDIT_SUBSTRINGS.length - 1,
`${lightboxHits.length}/${REQUIRED_CREDIT_SUBSTRINGS.length} required strings visible in lightbox`,
);
check(
'desktop lightbox stays compact with a fixed title and close control',
lightboxState.boxHeight > 0
&& lightboxState.boxHeight <= lightboxState.viewportHeight * 0.71
&& lightboxState.titleVisible
&& lightboxState.closeVisible,
`box=${Math.round(lightboxState.boxHeight)}px viewport=${lightboxState.viewportHeight}px`,
);
check(
'complete credit list scrolls vertically without horizontal overflow',
lightboxState.itemCount >= registeredHtml.length
&& lightboxState.linkCount > 0
&& lightboxState.everyLinkHasDestination
&& lightboxState.listScrollHeight > lightboxState.listClientHeight
&& /auto|scroll/.test(lightboxState.listOverflowY)
&& lightboxState.listScrollWidth <= lightboxState.listClientWidth + 1
&& lightboxState.lastItemReachable,
`${lightboxState.itemCount} items, ${lightboxState.linkCount} links, list=${lightboxState.listClientHeight}/${lightboxState.listScrollHeight}px`,
);
check(
'attribution modal stacks above shared world labels',
Number.isFinite(lightboxState.overlayZIndex)
&& Number.isFinite(lightboxState.worldOverlayZIndex)
&& lightboxState.overlayZIndex > lightboxState.worldOverlayZIndex
&& lightboxState.stackProbeIntersectsModal
&& lightboxState.stackProbeExtendsOutsideModal,
`modal z=${lightboxState.overlayZIndex}, world labels z=${lightboxState.worldOverlayZIndex}, overlap=${lightboxState.stackProbeIntersectsModal}`,
);
await page.evaluate(() => window.__godsEyeView.viewer.creditDisplay.hideLightbox());
await page.screenshot({ path: resolve(SHOT_DIR, 'attribution-stacking-before-desktop.png') });
await page.evaluate(() => {
window.__godsEyeView.viewer.creditDisplay.showLightbox();
const list = document.querySelector('.cesium-credit-lightbox > ul');
if (list) list.scrollTop = 0;
});
await page.screenshot({ path: resolve(SHOT_DIR, 'attribution-lightbox-desktop.png') });
await page.evaluate(() => {
window.__godsEyeView.viewer.creditDisplay.hideLightbox();
document.querySelector('[data-qa-attribution-stack-probe]')?.remove();
});
await page.setViewport({ width: 560, height: 760, deviceScaleFactor: 1 });
await page.evaluate(async () => {
const viewer = window.__godsEyeView.viewer;
viewer.resize();
viewer.scene.requestRender();
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
const mobileLightbox = await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
viewer.creditDisplay.showLightbox();
const box = document.querySelector('.cesium-credit-lightbox');
const list = box?.querySelector(':scope > ul');
const rect = box?.getBoundingClientRect();
if (list) list.scrollTop = list.scrollHeight;
const listRect = list?.getBoundingClientRect();
const lastRect = list?.lastElementChild?.getBoundingClientRect();
return {
left: rect?.left || 0,
right: rect?.right || 0,
top: rect?.top || 0,
bottom: rect?.bottom || 0,
viewportWidth: innerWidth,
viewportHeight: innerHeight,
lastItemReachable: Boolean(lastRect && listRect
&& lastRect.bottom <= listRect.bottom + 1),
};
});
check(
'mobile lightbox remains full-screen edge-to-edge and reaches the final credit',
Math.abs(mobileLightbox.left) <= 1
&& Math.abs(mobileLightbox.right - mobileLightbox.viewportWidth) <= 1
&& Math.abs(mobileLightbox.top) <= 1
&& Math.abs(mobileLightbox.bottom - mobileLightbox.viewportHeight) <= 1
&& mobileLightbox.lastItemReachable,
`box=${Math.round(mobileLightbox.left)},${Math.round(mobileLightbox.top)}${Math.round(mobileLightbox.right)},${Math.round(mobileLightbox.bottom)}`,
);
await page.screenshot({ path: resolve(SHOT_DIR, 'attribution-lightbox-mobile.png') });
await page.evaluate(() => window.__godsEyeView.viewer.creditDisplay.hideLightbox());
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
// ── (ii) enable datacenters + cables; credits still present ────────
console.log('\nH11 — enabling datacenters + submarine cables');
const layerIds = await page.evaluate(() => {
const dm = window.__godsEyeView.dataManager;
return [...dm.layers.keys()];
});
// Find the datacenter + cable layer ids by fuzzy match on the id string.
const dcId = layerIds.find((id) => /datacenter/i.test(id));
const cableId = layerIds.find((id) => /cable|submarine|telegeo/i.test(id));
check('found datacenter + cable layer ids', !!dcId && !!cableId, `dc=${dcId} cable=${cableId}`);
if (dcId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), dcId);
if (cableId) await page.evaluate((id) => window.__godsEyeView.dataManager.setEnabled(id, true), cableId);
await new Promise((r) => setTimeout(r, 800));
const afterEnableHtml = await page.evaluate(() =>
(window.__godsEyeView.viewer.creditDisplay._staticCredits || []).map((c) => c.html),
);
check(
'datacenter credit (OSM/ODbL) still present after enable',
afterEnableHtml.some((h) => h.includes('OpenStreetMap contributors')),
'',
);
check(
'cable credit (TeleGeography) still present after enable',
afterEnableHtml.some((h) => h.includes('TeleGeography')),
'',
);
// helper: is #cesium-credits visible (line rendered, not display:none)?
const creditVisibility = async () =>
page.evaluate(() => {
const el = document.getElementById('cesium-credits');
if (!el) return { present: false };
const cs = getComputedStyle(el);
const rect = el.getBoundingClientRect();
return {
present: true,
display: cs.display,
visibility: cs.visibility,
opacity: cs.opacity,
width: rect.width,
height: rect.height,
text: (el.textContent || '').trim().slice(0, 120),
hasLogo: !!el.querySelector('.cesium-credit-logoContainer, img'),
};
});
// ── (iii) clean-view: credit line STILL visible ───────────────────
console.log('\nH10 — clean-view keeps the credit line visible');
await page.evaluate(() => document.body.classList.add('ui-clean-view'));
await new Promise((r) => setTimeout(r, 400));
const cleanVis = await creditVisibility();
await page.screenshot({ path: resolve(SHOT_DIR, 'clean-view.png') });
check(
'clean-view: #cesium-credits not display:none',
cleanVis.present && cleanVis.display !== 'none' && cleanVis.visibility !== 'hidden',
`display=${cleanVis.display} visibility=${cleanVis.visibility} w=${Math.round(cleanVis.width)} h=${Math.round(cleanVis.height)}`,
);
check(
'clean-view: "Data attribution" link to per-layer popover still present',
!!cleanVis.text && /Data attribution/i.test(cleanVis.text),
`credit text = "${cleanVis.text}"`,
);
await page.evaluate(() => document.body.classList.remove('ui-clean-view'));
// ── (iv) recording-mode: credit line STILL visible ────────────────
console.log('\nH10 — recording-mode keeps the credit line visible');
await page.evaluate(() => document.body.classList.add('recording-mode'));
await new Promise((r) => setTimeout(r, 400));
const recVis = await creditVisibility();
await page.screenshot({ path: resolve(SHOT_DIR, 'recording-mode.png') });
check(
'recording-mode: #cesium-credits not display:none',
recVis.present && recVis.display !== 'none' && recVis.visibility !== 'hidden',
`display=${recVis.display} visibility=${recVis.visibility} w=${Math.round(recVis.width)} h=${Math.round(recVis.height)}`,
);
await page.evaluate(() => document.body.classList.remove('recording-mode'));
// baseline (normal) screenshot for comparison
await new Promise((r) => setTimeout(r, 300));
await page.screenshot({ path: resolve(SHOT_DIR, 'normal.png') });
check(
'no console errors during QA',
consoleErrors.length === 0,
consoleErrors.slice(0, 3).join(' | ') || 'clean',
);
check(
'no HTTP 5xx responses during QA',
failedResponses.length === 0,
failedResponses.slice(0, 3).join(' | ') || 'clean',
);
await browser.close();
console.log('\n────────────────────────────────────────────────────────────');
console.log(` RESULT: ${passed} passed, ${failed} failed`);
console.log(` Screenshots: ${SHOT_DIR}`);
console.log('────────────────────────────────────────────────────────────\n');
process.exit(failed === 0 ? 0 : 1);
}
main().catch((e) => {
console.error('qa-attribution-b12 crashed:', e);
process.exit(1);
});