gods-eye-view/scripts/qa-firstrun-mutations.mjs

520 lines
21 KiB
JavaScript

#!/usr/bin/env node
/**
* Are the first-run launcher's pins actually load-bearing?
*
* A pin that only goes red when you delete the whole feature proves very little.
* This reverts each decision INDIVIDUALLY — the smallest edit that reintroduces
* the original defect or contradicts the owner's ruling — and requires
* src/firstRunExperience.test.mjs to go red for it. Every entry names what it
* restores, so the count is reproducible rather than asserted in a commit
* message.
*
* node scripts/qa-firstrun-mutations.mjs
*
* Every touched file is restored on exit, including on failure.
*
* NOTE: two mutations edit vite.config.js, and a running dev server watches that
* file and restarts on every write. Writes are therefore content-guarded below
* so the file is touched exactly twice per mutation instead of on every
* iteration — enough that a dev server survives, but expect it to restart. If
* you are mid-QA on a live server, run this before or after, not during.
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const TESTS = 'src/firstRunExperience.test.mjs';
const FILES = {
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
html: path.join(ROOT, 'index.html'),
css: path.join(ROOT, 'style.css'),
vite: path.join(ROOT, 'vite.config.js'),
main: path.join(ROOT, 'src', 'main.js'),
ui: path.join(ROOT, 'src', 'ui.js'),
docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'),
};
/** @type {Array<{defect: string, file: keyof FILES, from: string, to: string}>} */
const MUTATIONS = [
// ── Show policy (owner ruling: session-scoped dismiss vs durable checkbox) ──
{
defect: 'dismissing writes the DURABLE key, so the launcher never returns',
file: 'module',
from: "writeStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY, 'dismissed');",
to: "writeStored('local', sessionStorageRef, FIRST_RUN_STORAGE_KEY, 'suppressed');",
},
{
defect: 'the checkbox is ignored, so nothing can ever suppress the launcher',
file: 'module',
from: " ? writeStored('local', storage, FIRST_RUN_STORAGE_KEY, 'suppressed')",
to: ' ? false',
},
{
defect: 'unticking the box cannot take the suppression back',
file: 'module',
from: " : removeStored('local', storage, FIRST_RUN_STORAGE_KEY);",
to: ' : true;',
},
{
defect: 'the session flag is never read, so it re-nags on every reload',
file: 'module',
from: "if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false;",
to: '// session check removed',
},
{
defect: 'the durable flag is never read, so the checkbox does nothing',
file: 'module',
from: "if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false;",
to: '// durable check removed',
},
{
defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it',
file: 'module',
from: "if (params.get('welcome') === '1') return true;",
to: '// replay hatch removed',
},
{
defect: '?welcome=0 stops suppressing',
file: 'module',
from: "if (params.get('welcome') === '0') return false;",
to: '// suppression param removed',
},
{
defect: 'a share link gets the launcher over the view its author chose',
file: 'module',
from: 'if (hasShareState) return false;',
to: '// share bypass removed',
},
{
defect: 'storage is read from a DEFAULT PARAMETER, outside every guard',
file: 'module',
from: 'export function rememberFirstRunSessionDismissed(sessionStorageRef) {',
to: 'export function rememberFirstRunSessionDismissed(sessionStorageRef = globalThis.sessionStorage) {',
},
{
defect: 'shouldShowFirstRun resolves storage in its parameter list again',
file: 'module',
from: 'export function shouldShowFirstRun({\n hasShareState = false,\n storage,\n sessionStorageRef,',
to: 'export function shouldShowFirstRun({\n hasShareState = false,\n storage = globalThis.localStorage,\n sessionStorageRef = globalThis.sessionStorage,',
},
{
defect: 'the guarded resolver stops catching, so a hostile getter escapes',
file: 'module',
from: " try {\n return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }",
to: " return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;",
},
{
defect: 'a blocked getItem throws instead of reading as "nothing stored"',
file: 'module',
from: ' return resolveStore(kind, injected)?.getItem?.(key) ?? null;\n } catch {\n return null;\n }',
to: ' return resolveStore(kind, injected)?.getItem?.(key) ?? null;\n } finally {\n // no catch\n }',
},
// ── ESC arbitration ───────────────────────────────────────────────────────
{
defect: 'the key handler trusts the CLASS, so an invisible card eats ESC',
file: 'module',
from: 'if (closing || !isTopmost()) return;',
to: 'if (closing) return;',
},
{
defect: 'the launcher stops yielding and fights Cockpit/Scenes for ESC',
file: 'module',
from: 'if (revealed && blocked) yieldToExclusiveSurface();\n else if (!revealed && !blocked) reveal();',
to: 'if (!revealed) reveal();',
},
{
defect: 'nothing watches for another surface taking the screen',
file: 'module',
from: "surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });",
to: '/* observer never attached */',
},
{
defect: 'cockpit drops off the exclusive list and can stack with the launcher',
file: 'module',
from: "export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([\n 'cockpit-mode',",
to: 'export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([',
},
{
defect: 'a scene hides the launcher in CSS but the JS never learns of it',
file: 'css',
from: 'body.scene-playback-mode #first-run-launcher {',
to: 'body.scene-playback-mode-typo #first-run-launcher {',
},
{
defect: 'yielding steals focus back from the surface that just took over',
file: 'module',
from: 'dismiss({ restoreFocus: false });',
to: 'dismiss();',
},
{
defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
file: 'module',
from: ' && root.getClientRects().length > 0\n && !coveredByOverlay();',
to: ' && root.getClientRects().length > 0;',
},
{
defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
file: 'module',
from: ' return Boolean(hit) && !root.contains(hit);',
to: ' return !root.contains(hit);',
},
{
defect: 'the launcher stops honouring a key another surface already claimed',
file: 'module',
from: ' if (event.defaultPrevented) return;',
to: ' /* belt removed */',
},
{
// Anchored past the call so it cannot land on the cockpit disclosure's own
// stopImmediatePropagation() a few lines below and prove nothing about this.
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things',
file: 'ui',
from: ' event.stopImmediatePropagation();\n setRadioDisclosure(false, { returnFocus: true });',
to: ' event.stopPropagation();\n setRadioDisclosure(false, { returnFocus: true });',
},
{
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
file: 'module',
from: ' * ACCEPTED, DELIBERATELY NOT TIMED OUT: a surface class that never clears',
to: ' * (note removed)',
},
{
defect: 'a bounded reveal timer punches the card through a recording in progress',
file: 'module',
from: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;',
to: ' const syncToExclusiveSurfaces = () => {\n if (closing) return;\n globalThis.setTimeout?.(reveal, 45000);',
},
{
defect: 'the accepted no-show vanishes from CURRENT-STATE and reads as a bug',
file: 'docs',
from: 'a surface class that never clears means no launcher for that page',
to: 'the launcher always turns up eventually for that page',
},
// ── The checkbox may not promise what storage refused ─────────────────────
{
defect: 'a refused write leaves the box ticked, promising a suppression nobody stored',
file: 'module',
from: ' if (setFirstRunSuppressed(wanted, storage)) return;',
to: ' setFirstRunSuppressed(wanted, storage);\n return;',
},
{
defect: 'a missing storage area reports the write as saved',
file: 'module',
from: " if (typeof store?.setItem !== 'function') return false;\n store.setItem(key, value);\n return true;",
to: ' store?.setItem?.(key, value);\n return true;',
},
{
defect: 'the scroll fade promises more list on a card where everything fits',
file: 'module',
from: 'choiceList.dataset.scrollable = String(overflows);',
to: "choiceList.dataset.scrollable = 'true'; void overflows;",
},
{
defect: 'the DISPLAY rail opens on first run again, stealing the impression',
file: 'ui',
from: "if (panelId === 'pp-toggles' && stored === null) collapsed = true;",
to: "if (panelId === 'pp-toggles' && stored === null) collapsed = false;",
},
{
defect: 'the first-run rail default overrides a stored user choice',
file: 'ui',
from: "if (panelId === 'pp-toggles' && stored === null) collapsed = true;",
to: "if (panelId === 'pp-toggles') collapsed = true;",
},
// ── Mission definitions ────────────────────────────────────────────────────
{
defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
file: 'module',
from: " environmental: Object.freeze({",
to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({",
},
{
defect: 'ENVIRONMENTAL drops the keyless earthquakes that carry it without a key',
file: 'module',
from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),",
to: "layerIds: Object.freeze(['local-firms']),",
},
{
defect: 'FIRMS is dropped again from the tile whose subcopy promises it',
file: 'module',
from: "layerIds: Object.freeze(['earthquakes', 'local-firms']),",
to: "layerIds: Object.freeze(['earthquakes']),",
},
{
defect: 'the tile stops promising the fires it actually turns on',
file: 'html',
from: '<small>Live earthquakes and active fires, from USGS and NASA</small>',
to: '<small>Live earthquakes worldwide, straight from USGS</small>',
},
{
defect: "the owner-authored first-run line is quietly rewritten",
file: 'html',
from: 'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.',
to: "It feels like a forbidden cockpit. It isn't — every feed is public, and every contact is live.",
},
{
defect: 'the ledgered global-chip defect loses its pointer to the real fix',
file: 'module',
from: ' // means a KEY REQUIRED terminal state in src/loadingFeedback.js, a state',
to: ' // (note removed)',
},
{
defect: 'the globe missions never pull the camera out',
file: 'module',
from: ' .then(() => flyToGlobe())',
to: ' .then(() => null)',
},
{
defect: 'a refused layer is reported as a successful mission',
file: 'module',
from: 'return { ok: failedLayerIds.length === 0, choice, failedLayerIds };',
to: 'return { ok: true, choice, failedLayerIds };',
},
{
defect: 'a cancelled globe flight fails the whole mission',
file: 'module',
from: ' .catch(() => null);',
to: ' .catch(() => { throw new Error("flight failed"); });',
},
{
defect: 'Explore manually quietly runs a mission instead of nothing',
file: 'module',
from: "if (mission.kind === 'none') return { ok: true, choice };",
to: "if (mission.kind === 'none') return { ok: true, choice, ran: await setContextMode('contacts') };",
},
{
defect: 'an unknown choice is treated as a success',
file: 'module',
from: 'if (!mission) return { ok: false, choice };',
to: 'if (!mission) return { ok: true, choice };',
},
{
defect: 'the fires/quakes tile name stops being switchable from one constant',
file: 'module',
from: 'return ENVIRONMENTAL_LABELS[choice] || ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
to: 'return ENVIRONMENTAL_LABELS.ENVIRONMENTAL;',
},
// ── Defaults interplay (the ruling this whole integration turns on) ────────
{
defect: "mission layer enables stop persisting, unlike the clicks they stand for",
file: 'module',
from: "dataManager.setEnabled(layerId, true, { origin: 'user' })",
to: 'dataManager.setEnabled(layerId, true)',
},
{
defect: 'a mission hand-edits detection and kills the style auto-preset contract',
file: 'module',
from: ' flyToGlobe: () => styleManager.resetToGlobeView(),',
to: ' flyToGlobe: () => { styleManager._detectionUserOverridden = true; return styleManager.resetToGlobeView(); },',
},
{
defect: 'a mission persists a 3D-models choice nobody made',
file: 'module',
from: ' const result = await styleManager.setContextMode(mode);',
to: ' styleManager._setModels3dEnabled?.(true);\n const result = await styleManager.setContextMode(mode);',
},
{
defect: 'the Context panel is left collapsed, hiding the mode that just started',
file: 'module',
from: "styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true });",
to: '// panel reveal removed',
},
{
defect: 'the decision table is deleted, so the next editor re-litigates it blind',
file: 'module',
from: ' * MISSION → APP STATE, AND WHAT IT IS ALLOWED TO PERSIST',
to: ' * (notes removed)',
},
// ── Accessibility and the transplant defects ──────────────────────────────
{
defect: 'disabling the focused tile drops the keyboard to <body> mid-flight',
file: 'module',
from: "for (const button of buttons) button.setAttribute('aria-disabled', String(next));",
to: 'for (const button of buttons) button.disabled = next;',
},
{
defect: 'Tab escapes into an app the visitor has not seen yet',
file: 'module',
from: "if (event.key !== 'Tab') return;",
to: 'return;',
},
{
defect: 'ESC no longer dismisses the launcher',
file: 'module',
from: "if (event.key === 'Escape') {",
to: 'if (false) {',
},
{
defect: 'focus is never returned to where the visitor left it',
file: 'module',
from: "if (typeof previouslyFocused?.focus === 'function' && previouslyFocused.isConnected) {",
to: 'if (false) {',
},
{
defect: 'app hotkeys eat the launcher keys (bubble instead of capture)',
file: 'module',
from: "documentRef.addEventListener('keydown', onKeyDown, true);",
to: "documentRef.addEventListener('keydown', onKeyDown);",
},
{
defect: 'the card stays clickable through its fade-out (double-launch)',
file: 'css',
from: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;',
to: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);',
},
{
defect: 'the launcher survives Clean-UI and recording mode',
file: 'css',
from: 'body.ui-clean-view #first-run-launcher,',
to: 'body.never-matches-anything #first-run-launcher,',
},
{
defect: 'the flex card defeats [hidden] and sits in the a11y tree pre-reveal',
file: 'css',
from: '#first-run-launcher[hidden] {\n display: none;\n}',
to: '/* [hidden] rule removed */',
},
{
defect: 'the card loses its viewport cap and clips on a short landscape phone',
file: 'css',
from: ' max-height: calc(100dvh - 1.5rem);',
to: ' /* cap removed */',
},
{
defect: 'the mission list stops scrolling, so tiles overflow the capped card',
file: 'css',
from: ' min-height: 0;\n overflow-y: auto;',
to: ' /* scroll removed */',
},
{
// Anchored to the launcher's OWN selectors: style.css has four other
// reduced-motion blocks, and a bare `@media` anchor would mutate one of
// those instead and prove nothing about this feature.
defect: "the launcher's reduced-motion opt-out is dropped",
file: 'css',
from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow { transition: none; }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices { scroll-behavior: auto; }',
to: ' .first-run-nothing { transition: none; }',
},
// ── Markup and startup ordering ───────────────────────────────────────────
{
defect: 'a mission tile is dropped from the menu',
file: 'html',
from: ' <button type="button" data-first-run-choice="space-missions">',
to: ' <button type="button" data-first-run-choice-disabled="space-missions">',
},
{
defect: 'the menu order stops matching the owner\'s',
file: 'html',
from: '<button type="button" data-first-run-choice="environmental">',
to: '<button type="button" data-first-run-choice="zzz-environmental">',
},
{
defect: 'the "don\'t show again" checkbox is removed',
file: 'html',
from: '<input type="checkbox" data-first-run-suppress />',
to: '<span data-first-run-suppress-removed></span>',
},
{
defect: 'the status line stops being a polite live region',
file: 'html',
from: 'data-first-run-status role="status" aria-live="polite"',
to: 'data-first-run-status',
},
{
defect: 'the launcher is no longer announced as a dialog',
file: 'html',
from: '<aside id="first-run-launcher" role="dialog"',
to: '<aside id="first-run-launcher"',
},
{
defect: 'the launcher is revealed before the loading cover yields',
file: 'main',
from: " loadingScreen.classList.add('hidden');",
to: ' initFirstRunExperience({ styleManager, dataManager });\n loadingScreen.classList.add(\'hidden\');',
},
{
defect: 'the globe missions lose their DataManager and cannot enable layers',
file: 'main',
from: 'initFirstRunExperience({ styleManager, dataManager });',
to: 'initFirstRunExperience({ styleManager });',
},
// ── Voice: schema must not drift ──────────────────────────────────────────
{
defect: 'the voice TOOL SCHEMA is edited (a Realtime prompt-cache bust)',
file: 'vite',
from: " 'earthquakes',\n 'satellites',",
to: " 'earthquakes',\n 'infrastructure-mode',\n 'satellites',",
},
{
defect: 'the instruction mapping is dropped, so voice cannot reach the modes',
file: 'vite',
from: " 'NAMED VIEWS are shorthand",
to: " // 'NAMED VIEWS are shorthand",
},
];
const originals = new Map();
for (const [key, file] of Object.entries(FILES)) originals.set(key, fs.readFileSync(file, 'utf8'));
/** Write only on a real change: a no-op write still wakes every file watcher. */
const write = (file, next) => {
if (fs.readFileSync(file, 'utf8') !== next) fs.writeFileSync(file, next);
};
const restoreAll = () => {
for (const [key, file] of Object.entries(FILES)) write(file, originals.get(key));
};
process.on('exit', restoreAll);
let caught = 0;
const missed = [];
console.log(`\nFirst-run launcher pin strength — ${MUTATIONS.length} individual reverts\n`);
for (const { defect, file, from, to } of MUTATIONS) {
const original = originals.get(file);
if (!original.includes(from)) {
missed.push(`${defect} (ANCHOR MISSING — the mutation no longer applies)`);
console.log(` \x1b[31mSTALE\x1b[0m ${defect}`);
continue;
}
write(FILES[file], original.replace(from, to));
let red = false;
let by = '';
try {
execFileSync('node', ['--test', TESTS], { cwd: ROOT, encoding: 'utf8', stdio: 'pipe' });
} catch (error) {
red = true;
const failed = String(error.stdout || '').split('\n')
.filter((line) => line.trim().startsWith('✖') && line.includes('('))
.map((line) => line.trim().slice(2).split(' (')[0]);
by = [...new Set(failed)].slice(0, 2).join('; ');
}
write(FILES[file], original);
if (red) {
caught += 1;
console.log(` \x1b[32mRED \x1b[0m ${defect}\n caught by: ${by}`);
} else {
missed.push(defect);
console.log(` \x1b[31mGREEN\x1b[0m ${defect} <-- no pin covers this`);
}
}
restoreAll();
console.log(`\n ${caught}/${MUTATIONS.length} defects caught by the pins`);
if (missed.length) {
console.log('\n uncovered:');
for (const item of missed) console.log(` - ${item}`);
}
console.log('');
process.exitCode = missed.length ? 1 : 0;