#!/usr/bin/env node /** * qa-firstrun — the mission launcher's contract, proved in the real app. * * The unit suite pins the show policy and the mission table against fakes. This * harness answers the questions only a running app can: does a mission actually * enable those layers, does the camera actually reach the globe, does a keyless * visitor actually get the honest FIRMS state instead of a silent empty layer, * and does any of it disturb the reasonable-defaults startup look. * * It also captures the owner's taste-pass screenshots into qa-shots/firstrun/. * * Usage: * node scripts/qa-firstrun.mjs --url http://localhost:4278 * node scripts/qa-firstrun.mjs --url http://localhost:4278 --teeth * * `--teeth` is the negative control: it suppresses the launcher's own reveal, so * every rendered/behavioral assertion below must go RED. A green --teeth run * means the assertions are not measuring what they claim to. */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import puppeteer from 'puppeteer'; const args = process.argv.slice(2); const getOpt = (flag, fallback) => { const index = args.indexOf(flag); return index >= 0 && args[index + 1] ? args[index + 1] : fallback; }; const APP_URL = getOpt('--url', 'http://localhost:4173').replace(/\/$/, ''); const TEETH = args.includes('--teeth'); const HEADFUL = args.includes('--headful'); const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const SHOT_DIR = path.join(ROOT, 'qa-shots', 'firstrun'); const CHROME_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, // Version-pinned Chrome-for-Testing over the auto-updating system Chrome. (() => { try { return puppeteer.executablePath(); } catch { return null; } })(), '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ].filter(Boolean); const results = []; let currentSection = 'setup'; function record(name, ok, detail) { results.push({ name, ok, detail, section: currentSection }); console.log(` [${ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'}] ${name}${detail ? ` — ${detail}` : ''}`); } /** * Run one independent section. * * Sections are isolated on purpose. The first version of this harness ran as a * single straight line, so the FIRST throw ended the run — under `--teeth` that * meant it stopped at 9 of 38 checks and never exercised the mission, viewport * or console assertions at all, while still claiming the control had teeth. A * throw now fails its own section and the rest still run, which is what makes * the per-section reachability tally below meaningful. */ async function section(name, body) { currentSection = name; console.log(`\n \x1b[2m── ${name} ──\x1b[0m`); try { await body(); } catch (error) { record(`[${name}] section completed without throwing`, false, error.message); } } const LAUNCHER = '#first-run-launcher'; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** * Load the app and wait for the launcher to settle one way or the other. * * `errorSink` is emptied on the way out: navigating away aborts whatever the * previous page had in flight, and Chrome reports those aborts as * "TypeError: Failed to fetch". Those belong to this harness, not to the app, * so the console assertion only ever judges the page it is actually looking at. */ async function open(page, { hash = '', query = '', clearAll = true, clearSession = true, errorSink = null } = {}) { await page.goto(`${APP_URL}/${query}${hash}`, { waitUntil: 'domcontentloaded' }); if (clearAll || clearSession) { await page.evaluate((all) => { if (all) localStorage.clear(); sessionStorage.clear(); }, clearAll); await page.goto(`${APP_URL}/${query}${hash}`, { waitUntil: 'domcontentloaded' }); } // The launcher is revealed after the loading cover yields (~T+1.9 s). await page.waitForFunction( (sel) => !!window.__godsEyeView?.styleManager && (document.querySelector(sel)?.classList.contains('visible') || !document.querySelector(sel)), { timeout: 45000 }, LAUNCHER, ).catch(() => {}); await sleep(400); if (errorSink) errorSink.length = 0; } const launcherVisible = (page) => page.evaluate( (sel) => !!document.querySelector(sel)?.classList.contains('visible'), LAUNCHER, ); const appState = (page) => page.evaluate(() => { const gev = window.__godsEyeView || {}; const sm = gev.styleManager; const dm = gev.dataManager; const carto = sm?.viewer?.camera?.positionCartographic; const layers = {}; const counts = {}; const all = dm?.getAll?.() || []; for (const id of [ 'local-datacenters', 'local-dams', 'telegeography-submarine-cables', 'local-firms', 'earthquakes', 'flights', 'military', 'rocket-launches', 'satellites', ]) { layers[id] = !!dm?.isEnabled?.(id); counts[id] = all.find((entry) => entry.id === id)?.stats?.count ?? null; } const firms = all.find((entry) => entry.id === 'local-firms'); return { heightKm: carto ? Math.round(carto.height / 1000) : null, layers, counts, contextMode: sm?.getContextModeState?.().mode ?? null, firmsError: firms?.stats?.error ?? null, firmsCount: firms?.stats?.count ?? null, detectionOverridden: sm?._detectionUserOverridden ?? null, durable: localStorage.getItem('gev:first-run-mission:v1'), session: sessionStorage.getItem('gev:first-run-mission-session:v1'), layerStateBlob: localStorage.getItem('gev:layer-state:v2'), allocation: localStorage.getItem('gev:detection-allocation:v1'), }; }); /** * Click a mission tile, wait for the launcher to close, then let the layers it * asked for actually settle. Asserting (or screenshotting) before the feeds have * drawn measures the harness's own impatience, not the mission. */ /** * Start recording every state the GLOBAL loading chip passes through. The chip * is transient — sampling it once after a mission would miss a failure banner * that flashed and cleared — so this watches it continuously instead. */ async function watchLoadingChip(page) { await page.evaluate(() => { window.__chipSeen = []; const el = document.getElementById('global-loading-status'); if (!el) return; const sample = () => { const label = document.getElementById('global-loading-label')?.textContent?.trim() || ''; const state = el.dataset.state || ''; const hidden = el.hidden; const last = window.__chipSeen[window.__chipSeen.length - 1]; const entry = `${hidden ? 'hidden' : 'shown'}:${state}:${label}`; if (entry !== last) window.__chipSeen.push(entry); }; sample(); window.__chipObserver?.disconnect(); window.__chipObserver = new MutationObserver(sample); window.__chipObserver.observe(el, { attributes: true, childList: true, subtree: true, characterData: true, }); }).catch(() => {}); } const readLoadingChip = (page) => page.evaluate(() => window.__chipSeen || []); async function pick(page, choice, { timeout = 40000, settle = [] } = {}) { await page.click(`[data-first-run-choice="${choice}"]`); await page.waitForFunction( (sel) => !document.querySelector(sel) || !document.querySelector(sel).classList.contains('visible'), { timeout }, LAUNCHER, ).catch(() => {}); if (settle.length) { await page.evaluate(async (ids) => { const dm = window.__godsEyeView?.dataManager; await Promise.all(ids.map((id) => dm?.waitForLayerSettled?.(id))); }, settle).catch(() => {}); } // Plus a render beat: settled data still has to reach the framebuffer. await sleep(settle.length ? 3500 : 1500); } /** Every section the run is expected to reach; the teeth verdict requires all. */ const EXPECTED_SECTIONS = [ 'show-policy', 'esc-arbitration', 'mission-environmental', 'mission-contacts', 'mission-space', 'mission-explore', 'viewports', 'console', ]; /* * Sections that are NOT expected to go red under --teeth. * * `console` is a hygiene check, not a launcher check: with the launcher removed * the page legitimately still logs nothing, so demanding a red there would be * demanding a false failure. Everything else exists to test the launcher and * must collapse without it. This list is deliberately tiny and named — it is the * one place a section can be excused, so an unjustified entry is visible. */ const HYGIENE_SECTIONS = ['console']; /** * ESC arbitration — the two defects that motivated the yield design, plus the * yield itself. Driven through the real body classes, because that is exactly * what Cockpit and the Scene director set. */ async function runArbitrationSection(page, { shots, consoleErrors }) { await section('esc-arbitration', async () => { const launcherState = () => page.evaluate((sel) => { const node = document.querySelector(sel); const rect = node?.getBoundingClientRect(); // Topmost, not merely present: hit-test the card's own centre the way the // launcher does, so an overlay that leaves the box intact is visible here. const hit = rect && rect.width > 0 && rect.height > 0 ? document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2) : null; return { present: !!node, classVisible: !!node?.classList.contains('visible'), // Real visibility: `display:none` yields zero client rects. onScreen: !!node && node.getClientRects().length > 0, topmost: !!(hit && node && node.contains(hit)), session: sessionStorage.getItem('gev:first-run-mission-session:v1'), }; }, LAUNCHER); // ── Blocker 2 repro: a Scene starts while the launcher is up ──────────── await open(page, { query: "?welcome=1", errorSink: consoleErrors }); const beforeScene = await launcherState(); record('the launcher is up before the scene starts', beforeScene.onScreen); await page.evaluate(() => document.body.classList.add('scene-playback-mode')); await sleep(300); const duringScene = await launcherState(); record( 'a scene taking the screen makes the launcher YIELD, not just hide', beforeScene.onScreen && !duringScene.classVisible, `onScreen=${duringScene.onScreen} classVisible=${duringScene.classVisible} — a hidden card that kept "visible" is the defect`, ); record('yielding is session-scoped, like any other dismissal', duringScene.session === 'dismissed', `session=${duringScene.session}`); // The key that used to dismiss an invisible launcher must now do nothing // to it — proven by the scene class still being set afterwards. await page.keyboard.press('Escape'); await sleep(300); const afterEsc = await page.evaluate(() => ({ sceneStillOn: document.body.classList.contains('scene-playback-mode'), })); record( 'ESC during a scene never lands on the launcher', afterEsc.sceneStillOn, afterEsc.sceneStillOn ? 'scene class untouched by the launcher' : 'something else consumed the key', ); await page.evaluate(() => document.body.classList.remove('scene-playback-mode')); await sleep(200); const afterScene = await launcherState(); record('a yielded launcher does not pop back when the scene ends', !afterScene.classVisible, `classVisible=${afterScene.classVisible}`); // ── Blocker 3 repro: Cockpit engages while the launcher is up ─────────── await open(page, { query: "?welcome=1", errorSink: consoleErrors }); const beforeCockpit = await launcherState(); record('the launcher is up before cockpit engages', beforeCockpit.onScreen); await page.evaluate(() => document.body.classList.add('cockpit-mode')); await sleep(300); const duringCockpit = await launcherState(); record( 'cockpit engaging makes the launcher YIELD before it can contest ESC', beforeCockpit.onScreen && !duringCockpit.classVisible, `classVisible=${duringCockpit.classVisible} — cockpit registers its capture listener first, so stacking must never arise`, ); await page.evaluate(() => document.body.classList.remove('cockpit-mode')); await sleep(200); // ── Blocker repro: a surface that takes the screen with NO class ──────── // The attribution lightbox is full-screen at z-index 200 against the card's // 175 and announces itself with nothing at all. The card keeps its box, so a // getClientRects()-only check called it visible: ESC dismissed a launcher // the visitor could not see, and burned the session flag, behind a lightbox // that binds no ESC of its own and therefore stayed open. const lightboxState = () => page.evaluate(() => { const overlay = document.querySelector('.cesium-credit-lightbox-overlay'); return { present: !!overlay, shown: !!overlay && getComputedStyle(overlay).display !== 'none', z: overlay ? getComputedStyle(overlay).zIndex : null, }; }); await open(page, { query: "?welcome=1", errorSink: consoleErrors }); const beforeLightbox = await launcherState(); record('the launcher is up before the attribution lightbox opens', beforeLightbox.onScreen); const linkClicked = await page.evaluate(() => { const link = document.querySelector('#cesium-credits .cesium-credit-expand-link'); link?.click(); return !!link; }); await sleep(300); const lightboxUp = await lightboxState(); record( 'the real "Data attribution" lightbox opens above the card', linkClicked && lightboxUp.shown && lightboxUp.z === '200', `link=${linkClicked} shown=${lightboxUp.shown} z-index=${lightboxUp.z} vs the launcher's 175`, ); const buried = await launcherState(); record( 'an unclassed overlay leaves the card MEASURABLE but no longer topmost', buried.onScreen && !buried.topmost, `onScreen=${buried.onScreen} topmost=${buried.topmost} — the class watch cannot see this surface at all`, ); await page.keyboard.press('Escape'); await sleep(400); const escUnderLightbox = await launcherState(); const lightboxAfterEsc = await lightboxState(); record( 'ESC under the lightbox never lands on the buried launcher', beforeLightbox.onScreen && escUnderLightbox.classVisible, `classVisible=${escUnderLightbox.classVisible} — the card was up, covered, and must stay up`, ); record( 'that ESC does not burn the session flag either', beforeLightbox.onScreen && escUnderLightbox.session !== 'dismissed', `session=${escUnderLightbox.session}`, ); record( 'the lightbox keeps its OWN ESC semantics (Cesium binds none, so it stays)', lightboxAfterEsc.shown, `overlay shown=${lightboxAfterEsc.shown}`, ); // The guard disarms the launcher; it must never break it. Close the overlay // the way a visitor does and the key comes straight back. await page.evaluate(() => document.querySelector('.cesium-credit-lightbox-close')?.click()); await sleep(300); const uncovered = await launcherState(); record('closing the lightbox hands the card back the key', uncovered.topmost, `topmost=${uncovered.topmost}`); await page.keyboard.press('Escape'); await sleep(600); const afterUncoveredEsc = await launcherState(); record( 'ESC dismisses normally again once nothing is on top', beforeLightbox.onScreen && !afterUncoveredEsc.classVisible && afterUncoveredEsc.session === 'dismissed', `classVisible=${afterUncoveredEsc.classVisible} session=${afterUncoveredEsc.session}`, ); // ── Blocker repro: a control that claims only the KEY ─────────────────── // The compact Radio disclosure closes on ESC from a capture listener bound // long before this module exists. It called stopPropagation(), which does // NOT stop later listeners on the same document — so one key closed the // disclosure AND dismissed the launcher. The card is not hiding behind this // one and must not yield to it: the key is simply already spoken for. await open(page, { query: "?welcome=1", errorSink: consoleErrors }); const beforeRadio = await launcherState(); record('the launcher is up before the radio disclosure opens', beforeRadio.onScreen); const radioOpened = await page.evaluate(() => { document.getElementById('context-radio-toggle-btn')?.click(); return !!document.getElementById('context-radio-dock')?.classList.contains('disclosure-open'); }); await sleep(250); record('the compact Radio disclosure opens', radioOpened); const withRadio = await launcherState(); record( 'a small disclosure is a key contest, not a screen takeover — no yield', withRadio.classVisible && withRadio.topmost, `classVisible=${withRadio.classVisible} topmost=${withRadio.topmost}`, ); await page.keyboard.press('Escape'); await sleep(400); const radioAfterEsc = await page.evaluate(() => ( !!document.getElementById('context-radio-dock')?.classList.contains('disclosure-open') )); const launcherAfterRadioEsc = await launcherState(); record('ESC closes the disclosure', radioOpened && !radioAfterEsc, `disclosure still open=${radioAfterEsc}`); record( 'that SAME ESC does not also dismiss the launcher', beforeRadio.onScreen && launcherAfterRadioEsc.classVisible && launcherAfterRadioEsc.session !== 'dismissed', `classVisible=${launcherAfterRadioEsc.classVisible} session=${launcherAfterRadioEsc.session} — one key, one action`, ); // ...and the NEXT press is the launcher's, so nothing was permanently taken. await page.keyboard.press('Escape'); await sleep(600); const escAfterRadioClosed = await launcherState(); record( 'the next ESC belongs to the launcher again', beforeRadio.onScreen && !escAfterRadioClosed.classVisible && escAfterRadioClosed.session === 'dismissed', `classVisible=${escAfterRadioClosed.classVisible} session=${escAfterRadioClosed.session}`, ); // ── Deferred reveal: a surface already up when the launcher would show ── // The class goes on as soon as the app exists, which is well before the // launcher's reveal (~T+1.9s), so init genuinely sees a screen it does not // own. Cockpit's own exit() strips this class, so it is re-asserted right up // to the check rather than set once and hoped for. await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); }); // Install the synthetic blocker before any application module can run. A // warm Vite cache can otherwise finish first-run initialization between // DOMContentLoaded and the first page.evaluate(), turning this into the // already-covered "surface engages after reveal" case and burning the // session flag exactly as that path is designed to do. const earlyCockpitBlocker = await page.evaluateOnNewDocument(() => { const blockAsSoonAsBodyExists = () => { if (!document.body) return false; document.body.classList.add('cockpit-mode'); return true; }; if (blockAsSoonAsBodyExists()) return; const observer = new MutationObserver(() => { if (!blockAsSoonAsBodyExists()) return; observer.disconnect(); }); observer.observe(document, { childList: true, subtree: true }); }); try { await page.goto(`${APP_URL}/?welcome=1`, { waitUntil: 'domcontentloaded' }); } finally { await page.removeScriptToEvaluateOnNewDocument(earlyCockpitBlocker.identifier); } await page.waitForFunction(() => !!document.body, { timeout: 45000 }).catch(() => {}); const holdCockpit = async (ms) => { const until = Date.now() + ms; while (Date.now() < until) { await page.evaluate(() => document.body?.classList.add('cockpit-mode')).catch(() => {}); await sleep(200); } }; await holdCockpit(5000); const withCockpitUp = await launcherState(); const classHeld = await page.evaluate(() => document.body.classList.contains('cockpit-mode')); record( 'the launcher WAITS rather than appearing over a surface already up', classHeld && withCockpitUp.present && !withCockpitUp.classVisible, `cockpitClassHeld=${classHeld} present=${withCockpitUp.present} classVisible=${withCockpitUp.classVisible}`, ); record('waiting does not burn the session flag', withCockpitUp.session !== 'dismissed', `session=${withCockpitUp.session}`); // ...and appears once that surface clears. await page.evaluate(() => document.body.classList.remove('cockpit-mode')); await sleep(800); const afterCockpitCleared = await launcherState(); record('the launcher appears once the surface clears', afterCockpitCleared.onScreen, `onScreen=${afterCockpitCleared.onScreen}`); if (afterCockpitCleared.onScreen) shots.push(await shoot(page, 'arbitration-revealed-after-cockpit')); }); } async function shoot(page, name) { // The teeth run deliberately has no launcher, so its frames are pictures of // an empty loading screen. Writing them would overwrite the real evidence // this directory exists to hold, so the control captures nothing. if (TEETH) return `${name}.png (skipped: teeth run)`; const file = path.join(SHOT_DIR, `${name}.png`); await page.screenshot({ path: file }); return path.relative(ROOT, file); } async function main() { console.log('\nFirst-Run Mission Launcher QA'); console.log(` App URL : ${APP_URL}`); console.log(` Mode : ${TEETH ? 'TEETH (launcher suppressed — expect RED)' : 'normal'}\n`); try { const response = await fetch(`${APP_URL}/`); if (!response.ok) throw new Error(`HTTP ${response.status}`); } catch (error) { console.error(`Dev server not reachable at ${APP_URL}: ${error.message}`); process.exit(2); } fs.mkdirSync(SHOT_DIR, { recursive: true }); const executablePath = CHROME_CANDIDATES.find((candidate) => { try { return fs.existsSync(candidate); } catch { return false; } }); const browser = await puppeteer.launch({ headless: HEADFUL ? false : 'new', ...(executablePath ? { executablePath } : {}), args: [ '--no-sandbox', '--disable-setuid-sandbox', '--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900', ], }); const shots = []; const consoleErrors = []; try { const page = await browser.newPage(); await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }); page.on('console', (message) => { if (message.type() !== 'error') return; const text = message.text(); if (!/Failed to load resource.*(404|429|503)/i.test(text)) consoleErrors.push(text); }); page.on('pageerror', (error) => consoleErrors.push(`pageerror: ${error.message}`)); if (TEETH) { // Negative control: the markup never arrives, so nothing can be revealed. await page.evaluateOnNewDocument(() => { document.addEventListener('DOMContentLoaded', () => { document.getElementById('first-run-launcher')?.remove(); }); }); } await section('show-policy', async () => { await open(page); const freshVisible = await launcherVisible(page); record('a fresh session gets the launcher', freshVisible); if (freshVisible) shots.push(await shoot(page, 'launcher-desktop')); const focused = await page.evaluate(() => document.activeElement?.dataset?.firstRunChoice ?? null); record('focus lands on the first mission tile', focused === 'contacts', `activeElement=${focused}`); // The card is a flex column (so its list can scroll on short viewports), // and an author `display` on this id outranks the UA's `[hidden]` rule. // Prove the attribute still hides it, or it sits in the a11y tree from // page parse until reveal — and on a share link, until removal. const hiddenHonored = await page.evaluate((sel) => { const node = document.querySelector(sel); if (!node) return null; node.hidden = true; const display = getComputedStyle(node).display; node.hidden = false; return display; }, LAUNCHER); record('the hidden attribute still hides the flex card', hiddenHonored === 'none', `computed display while hidden = ${hiddenHonored}`); const tiles = await page.$$eval('[data-first-run-choice]', (nodes) => nodes.map((n) => n.dataset.firstRunChoice)); record( 'four tiles in the owner\'s order', JSON.stringify(tiles) === JSON.stringify(['contacts', 'space-missions', 'environmental', 'explore']), tiles.join(' · '), ); // ESC dismisses. Stated as a TRANSITION (was up, now gone) — "not visible" // alone passes vacuously whenever the launcher never appeared at all. await page.keyboard.press('Escape'); await sleep(600); const state = await appState(page); record('ESC dismisses the launcher', freshVisible && !(await launcherVisible(page)), freshVisible ? 'visible → dismissed' : 'never appeared, so nothing was dismissed'); record('ESC writes the SESSION flag only', state.session === 'dismissed' && state.durable === null, `session=${state.session} durable=${state.durable}`); // Same session, reload → stays gone. Also non-vacuous: it only means // anything if this session had seen the launcher in the first place. await open(page, { clearAll: false, clearSession: false }); record('a reload in the same session does not re-nag', freshVisible && !(await launcherVisible(page)), freshVisible ? 'seen this session, stayed away on reload' : 'never appeared, so the check is empty'); // New session (sessionStorage cleared, localStorage kept) → back. await open(page, { clearAll: false, clearSession: true }); record('the next fresh session gets it again', await launcherVisible(page)); // Checkbox → durable suppression. await page.click('[data-first-run-suppress]'); await sleep(200); const ticked = await appState(page); record('the checkbox writes durable suppression immediately', ticked.durable === 'suppressed', `durable=${ticked.durable}`); await page.keyboard.press('Escape'); await sleep(400); await open(page, { clearAll: false, clearSession: true }); record('a suppressed profile stays quiet in later sessions', ticked.durable === 'suppressed' && !(await launcherVisible(page)), ticked.durable === 'suppressed' ? 'suppressed and stayed away' : 'nothing was ever suppressed'); // ?welcome=1 outranks the checkbox; ?welcome=0 suppresses a fresh session. await open(page, { clearAll: false, clearSession: true, query: "?welcome=1", errorSink: consoleErrors }); record('?welcome=1 replays past durable suppression', await launcherVisible(page)); await open(page, { query: '?welcome=0' }); record('?welcome=0 suppresses a fresh session', !(await launcherVisible(page))); // Share links bypass entirely. await open(page, { hash: '#lat=30.2672&lon=-97.7431&alt=2500' }); const shareState = await page.evaluate(() => !!window.__godsEyeView?.styleManager?.hasShareState); const shareShowed = await launcherVisible(page); record('a share link bypasses the launcher', shareState && !shareShowed, `hasShareState=${shareState} launcherVisible=${shareShowed}`); }); await runArbitrationSection(page, { shots, consoleErrors }); // ── Mission outcomes ──────────────────────────────────────────────────── // One section PER MISSION: a throw in one (a tile that never rendered under // --teeth, say) must not take the other four down with it, or the teeth // tally reports "reached" for assertions that never ran. let state; let before; await section('mission-environmental', async () => { // Full clear, not just the session: layer enables are DURABLE, so without it // an earlier mission's layers ride along and this stops measuring // Environmental alone (and its screenshot stops showing it alone). await open(page, { query: "?welcome=1", errorSink: consoleErrors }); before = await appState(page); await watchLoadingChip(page); await pick(page, 'environmental', { settle: ['earthquakes', 'local-firms'] }); state = await appState(page); record('ENVIRONMENTAL leaves the detection override untouched', state.detectionOverridden === false, `_detectionUserOverridden=${state.detectionOverridden}`); record('a mission never auto-suppresses itself', state.durable === null, `durable=${state.durable}`); record('a mission never writes the detection-allocation pref', state.allocation === before.allocation, `${before.allocation} → ${state.allocation}`); record('ENVIRONMENTAL enables BOTH of its feeds', state.layers.earthquakes && state.layers['local-firms'], `quakes=${state.layers.earthquakes} fires=${state.layers['local-firms']}`); record('ENVIRONMENTAL loads real quake records', (state.counts.earthquakes ?? 0) > 0, `${state.counts.earthquakes} quakes`); record('ENVIRONMENTAL reaches the full-earth camera', state.heightKm !== null && state.heightKm > 12000, `${state.heightKm} km`); /* * The tile optimizes for the FULLY CONFIGURED app, so the assertion splits * on what this server actually has rather than pretending one answer fits * both. The branch is reported, so a green run always says which one it was. * * KEYED — both datasets must actually arrive, and a failure banner in * that state is a real defect, so the chip IS asserted. * KEYLESS — only the LAYER ROW is asserted: FIRMS reports KEY REQUIRED, * which is the honest surface a keyless visitor is judged on. * The GLOBAL chip is deliberately NOT asserted in either * direction here: it has no key-required terminal state and * folds that row into a misleading LOAD FAILED. That * aggregation is a defect in the shared state machine * (`src/loadingFeedback.js`), LEDGERED post-launch — it is not * a desirable outcome and not this tile's contract. */ const keyless = state.firmsError === 'KEY REQUIRED'; console.log(` \x1b[2m FIRMS key state: ${keyless ? 'KEYLESS' : 'KEYED'} ` + `(row="${state.firmsError ?? 'none'}", count=${state.firmsCount})\x1b[0m`); if (keyless) { record( 'KEYLESS: the FIRMS row tells the visitor the truth (KEY REQUIRED)', state.firmsError === 'KEY REQUIRED', `layer row reports "${state.firmsError}"`, ); record( 'KEYLESS: the quakes half of the tile still delivers in full', (state.counts.earthquakes ?? 0) > 0, `${state.counts.earthquakes} quakes`, ); } else { record( 'KEYED: both datasets actually arrive', (state.counts.earthquakes ?? 0) > 0 && (state.firmsCount ?? 0) > 0, `${state.counts.earthquakes} quakes · ${state.firmsCount} fire detections`, ); const chip = await readLoadingChip(page); const failed = chip.filter((entry) => /LOAD FAILED/i.test(entry)); // A STALE served-from-cache FIRMS payload is degraded, not failed, and it // still carries data — so the failure test is the banner, not any // non-empty error string. record( 'KEYED: no unexpected global failure while the mission runs', failed.length === 0, failed.length ? `chip showed: ${failed.join(' | ')}` : `firms row="${state.firmsError ?? 'none'}"; chip states seen: ${chip.join(' → ') || 'none'}`, ); } record( 'no mission turns on the bundled infrastructure layers any more', !state.layers['local-datacenters'] && !state.layers['local-dams'] && !state.layers['telegeography-submarine-cables'], 'the removed tile left nothing enabling ~5,700 entities at globe scale', ); shots.push(await shoot(page, 'mission-environmental')); }); await section('mission-contacts', async () => { await open(page, { query: "?welcome=1", errorSink: consoleErrors }); await pick(page, 'contacts'); state = await appState(page); record('LIVE CONTACTS activates the Contacts context mode', state.contextMode === 'flights', `contextMode=${state.contextMode}`); const panelOpen = await page.evaluate(() => { const panel = document.getElementById('global-context-panel'); return !!panel && !panel.classList.contains('collapsed'); }); record('LIVE CONTACTS reveals the Context panel', panelOpen); record('LIVE CONTACTS still leaves the detection override untouched', state.detectionOverridden === false, `_detectionUserOverridden=${state.detectionOverridden}`); shots.push(await shoot(page, 'mission-contacts')); }); await section('mission-space', async () => { await open(page, { query: "?welcome=1", errorSink: consoleErrors }); await pick(page, 'space-missions'); state = await appState(page); record('SPACE MISSIONS activates its context mode', state.contextMode === 'space-missions', `contextMode=${state.contextMode}`); shots.push(await shoot(page, 'mission-space-missions')); }); await section('mission-explore', async () => { await open(page, { query: "?welcome=1", errorSink: consoleErrors }); const beforeExplore = await appState(page); await pick(page, 'explore'); state = await appState(page); record('EXPLORE MANUALLY enables no layer and no context mode', Object.values(state.layers).every((on) => !on) && state.contextMode === null, Object.entries(state.layers).filter(([, on]) => on).map(([id]) => id).join(', ') || 'nothing enabled'); // The real claim is that Explore issues no camera command of its own: the // arrival fly-in owns the camera and must still be heading down, not out. record('EXPLORE MANUALLY never commandeers the camera', (state.heightKm ?? 0) < 12000 && (state.heightKm ?? 1e9) <= (beforeExplore.heightKm ?? 1e9), `${beforeExplore.heightKm} → ${state.heightKm} km (globe would be 18000)`); shots.push(await shoot(page, 'mission-explore')); }); // ── Viewports ─────────────────────────────────────────────────────────── await section('viewports', async () => { // Portrait phone, LANDSCAPE phone (the short viewport that clipped the card // at both ends before it grew a max-height), and a small desktop window. const VIEWPORTS = [ { name: 'mobile-375', width: 375, height: 812, dpr: 2, label: '375 x 812 portrait phone' }, { name: 'landscape-667', width: 667, height: 390, dpr: 2, label: '667 x 390 landscape phone' }, { name: 'desktop-800', width: 800, height: 600, dpr: 1, label: '800 x 600 small desktop' }, ]; for (const vp of VIEWPORTS) { await page.setViewport({ width: vp.width, height: vp.height, deviceScaleFactor: vp.dpr }); await open(page, { query: "?welcome=1", errorSink: consoleErrors }); const box = await page.evaluate((sel) => { const node = document.querySelector(sel); if (!node) return null; const rect = node.getBoundingClientRect(); const list = node.querySelector('.first-run-choices'); const checkbox = node.querySelector('[data-first-run-suppress]'); const cb = checkbox?.getBoundingClientRect(); return { top: Math.round(rect.top), bottom: Math.round(rect.bottom), left: Math.round(rect.left), right: Math.round(rect.right), vw: window.innerWidth, vh: window.innerHeight, listScrolls: !!list && list.scrollHeight > list.clientHeight + 1, // The checkbox and the status line live OUTSIDE the scrolling list, so // they must be on screen at every height, scrollable list or not. checkboxOnScreen: !!cb && cb.top >= 0 && cb.bottom <= window.innerHeight, }; }, LAUNCHER); const fits = !!box && box.top >= 0 && box.bottom <= box.vh && box.left >= 0 && box.right <= box.vw; record( `the launcher fits a ${vp.label} with no clipping`, fits, box ? `${box.left},${box.top} → ${box.right},${box.bottom} in ${box.vw}x${box.vh}${box.listScrolls ? ' (list scrolls)' : ''}` : 'absent', ); record(`"Don't show this again" stays on screen at ${vp.width}x${vp.height}`, !!box?.checkboxOnScreen); // Discoverability: if the list scrolls, the LAST tile must still peek // above the fold. A fold that lands exactly between tiles looks like a // complete list of four, and the fifth mission is simply never found. const peek = await page.evaluate((sel) => { const node = document.querySelector(sel); const list = node?.querySelector('.first-run-choices'); if (!list) return null; list.scrollTop = 0; const tiles = [...list.querySelectorAll('[data-first-run-choice]')]; const last = tiles[tiles.length - 1]; if (!last) return null; const listBox = list.getBoundingClientRect(); const lastBox = last.getBoundingClientRect(); const visible = Math.max(0, Math.min(listBox.bottom, lastBox.bottom) - Math.max(listBox.top, lastBox.top)); return { scrolls: list.scrollHeight > list.clientHeight + 1, fraction: lastBox.height ? visible / lastBox.height : 0, label: last.dataset.firstRunChoice, }; }, LAUNCHER); record( `the last mission is discoverable at ${vp.width}x${vp.height}`, !!peek && (!peek.scrolls || peek.fraction >= 0.25), peek ? `${peek.label} ${(peek.fraction * 100).toFixed(0)}% visible${peek.scrolls ? ' (list scrolls)' : ' (no scroll needed)'}` : 'absent', ); // Every tile plus the checkbox must still be reachable by Tab when the // list scrolls — and reaching one must bring it into view. const reach = await page.evaluate(async (sel) => { const node = document.querySelector(sel); if (!node) return null; const targets = [...node.querySelectorAll('[data-first-run-choice], [data-first-run-suppress]')]; const offscreen = []; for (const target of targets) { target.focus(); await new Promise((resolve) => requestAnimationFrame(resolve)); const rect = target.getBoundingClientRect(); const host = node.getBoundingClientRect(); if (document.activeElement !== target || rect.bottom <= host.top || rect.top >= host.bottom) { offscreen.push(target.dataset.firstRunChoice || 'suppress-checkbox'); } } return { count: targets.length, offscreen }; }, LAUNCHER); // Derived, not a magic number: every rendered tile plus the checkbox. // A hardcoded count silently turns into a failure the day the menu // changes, which is exactly what it did when a tile was removed. const expectedFocusable = await page.$$eval( '[data-first-run-choice]', (nodes) => nodes.length, ) + 1; record( `every tile and the checkbox are keyboard-reachable at ${vp.width}x${vp.height}`, !!reach && reach.count === expectedFocusable && reach.offscreen.length === 0, reach ? `${reach.count}/${expectedFocusable} focusable${reach.offscreen.length ? `, unreachable: ${reach.offscreen.join(', ')}` : ''}` : 'absent', ); // The reachability probe above tabs to the last tile and scrolls the list // with it. Put it back to the top so the taste-pass shot shows what a // visitor actually opens on. await page.evaluate((sel) => { const list = document.querySelector(sel)?.querySelector('.first-run-choices'); if (list) list.scrollTop = 0; document.activeElement?.blur?.(); }, LAUNCHER); await sleep(250); shots.push(await shoot(page, `launcher-${vp.name}`)); } await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }); }); await section('console', async () => { record('no console errors across the whole run', consoleErrors.length === 0, consoleErrors.slice(0, 3).join(' | ') || 'clean'); }); } finally { await browser.close(); } const failed = results.filter((entry) => !entry.ok); const sections = [...new Set(results.map((entry) => entry.section))]; const tally = sections.map((name) => { const rows = results.filter((entry) => entry.section === name); return { name, total: rows.length, red: rows.filter((entry) => !entry.ok).length }; }); console.log(`\n Screenshots: ${shots.join(', ')}`); console.log(`\n ${results.length - failed.length}/${results.length} checks passed\n`); if (!TEETH) process.exit(failed.length === 0 ? 0 : 1); /* * NEGATIVE CONTROL VERDICT. * * Two things have to be true for the control to mean anything, and the old * version checked neither properly: it exited ZERO after a single throw ended * the run at 9 of 38 checks, so most assertions were never even reached. * * (a) the harness must REPORT FAILURE under the stub — a run with the * launcher removed is a failing run, and it exits non-zero like one; * (b) EVERY section must produce at least one red — a section that stays * fully green without the launcher is not testing the launcher. * * Because (a) means the process always exits non-zero here, the control's own * verdict is carried in the exit CODE: 1 = healthy, 2 = the control is broken. */ console.log(' TEETH — per-section reachability (each section must go red):'); for (const row of tally) { const hygiene = HYGIENE_SECTIONS.includes(row.name); const ok = row.red > 0 || hygiene; const tag = hygiene ? '\x1b[2mHYGN\x1b[0m' : (row.red > 0 ? '\x1b[32mRED \x1b[0m' : '\x1b[31mNONE\x1b[0m'); void ok; console.log(` [${tag}] ${row.name}: ${row.red}/${row.total} red`); } const toothless = tally.filter((row) => row.red === 0 && !HYGIENE_SECTIONS.includes(row.name)); const unreached = EXPECTED_SECTIONS.filter((name) => !sections.includes(name)); const healthy = failed.length > 0 && toothless.length === 0 && unreached.length === 0; if (unreached.length) console.log(`\n UNREACHED sections: ${unreached.join(', ')}`); if (toothless.length) console.log(` TOOTHLESS sections: ${toothless.map((row) => row.name).join(', ')}`); console.log(healthy ? `\n TEETH HEALTHY: harness failed as required (${failed.length} red) and every one of ${tally.length} sections went red. Exit 1 by design.\n` : '\n TEETH BROKEN: the control did not collapse the way it claims to. Exit 2.\n'); process.exit(healthy ? 1 : 2); } main().catch((error) => { console.error(error); process.exit(1); });