mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
The Context header carried a Radio icon that opened a second, compact Radio player on top of the panel, duplicating the Radio section that already lives inside Context. Remove the icon, the compact player and everything that kept the two in step. Radio stays in Context as its own section with one Enable control; Cockpit's compact Radio is unchanged. Context itself was hard to read: 7-8 px labels at low contrast, a dashed placeholder box, and a glow halo. The two modes are now one segmented control, labels and guidance are 10 px at higher contrast, the standby hint is plain text, section titles use --text-secondary, and the panel draws no glow. Unused translations are pruned, tests and QA harnesses that pinned the compact player are updated or retired, and CURRENT-STATE and the changelog describe the new header.
856 lines
41 KiB
JavaScript
856 lines
41 KiB
JavaScript
#!/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.
|
|
await puppeteer.executablePath().catch(() => 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(
|
|
'ESC closes attribution without dismissing the launcher underneath',
|
|
!lightboxAfterEsc.shown,
|
|
`overlay shown=${lightboxAfterEsc.shown}`,
|
|
);
|
|
|
|
// The attribution keyboard handler closes the overlay and restores focus.
|
|
// A second Escape belongs to the now-uncovered launcher.
|
|
const attributionFocusRestored = await page.evaluate(() => (
|
|
document.activeElement === document.querySelector('#cesium-credits .cesium-credit-expand-link')
|
|
));
|
|
record('closing attribution restores its disclosure focus', attributionFocusRestored);
|
|
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}`,
|
|
);
|
|
|
|
// ── 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',
|
|
// Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init.
|
|
...(process.platform === 'darwin'
|
|
? ['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist']
|
|
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
|
'--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);
|
|
});
|