897 lines
43 KiB
JavaScript
897 lines
43 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.
|
|
(() => { 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);
|
|
});
|