mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
initFirstRunExperience no longer writes the launcher card's classes, attributes and text, wires its listeners or removes it: it presents the card's visibility, busy state, status line, copy and scroll fade to the gev-first-run store and connects the controls the welcome view's choices, DISMISS and "Don't show this again" call. A refused save hands the tick back to the view to revert. The launcher still reads the rendered card for its hit test, keyboard trap, focus and overflow measurement. The owner-chosen copy moves to src/firstRunCopy.js, shared by both. The source-text tests give way to component tests, and the first-run mutation QA follows the new code (67/67 caught).
567 lines
22 KiB
JavaScript
567 lines
22 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 the server voice tools/instructions, and a running dev server watches those
|
|
* files 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',
|
|
'src/standalone/startupChrome.test.mjs',
|
|
'src/firstRunLauncherView.test.mjs',
|
|
];
|
|
|
|
const FILES = {
|
|
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
|
|
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
|
|
copy: path.join(ROOT, 'src', 'firstRunCopy.js'),
|
|
surfaces: path.join(ROOT, 'src', 'exclusiveSurfaces.js'),
|
|
html: path.join(ROOT, 'src', 'ui', 'views', 'WelcomeView.vue'),
|
|
css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'),
|
|
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
|
|
voiceDescriptions: path.join(
|
|
ROOT,
|
|
'server/providers/openai/toolDescriptions.js',
|
|
),
|
|
main: path.join(ROOT, 'src', 'app', 'startupChrome.js'),
|
|
ui: path.join(ROOT, 'src', 'ui', 'panelPositionControls.js'),
|
|
radio: path.join(ROOT, 'src', 'ui', 'radioBindings.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 (\n readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) ===\n 'dismissed'\n )\n 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')\n 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'\n ? globalThis.sessionStorage\n : 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: 'isActive: () => !closing && isTopmost(),',
|
|
to: 'isActive: () => !closing,',
|
|
},
|
|
{
|
|
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, {\n attributes: true,\n attributeFilter: ['class'],\n });",
|
|
to: ' /* observer never attached */',
|
|
},
|
|
{
|
|
defect:
|
|
'cockpit drops off the exclusive list and can stack with the launcher',
|
|
file: 'surfaces',
|
|
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: 'keyboard',
|
|
from: ' || event.defaultPrevented',
|
|
to: ' /* belt removed */',
|
|
},
|
|
{
|
|
// Anchored past the call so it lands on the Cockpit utility disclosure's
|
|
// Escape claim and nowhere else.
|
|
defect:
|
|
'a Cockpit utility disclosure returns to stopPropagation, so later document listeners also act on its ESC',
|
|
file: 'radio',
|
|
from: " event.stopImmediatePropagation();\n const kind = displayOpen ? 'display' : 'radio';",
|
|
to: " event.stopPropagation();\n const kind = displayOpen ? 'display' : 'radio';",
|
|
},
|
|
{
|
|
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(Boolean(wanted), storage)) return true;',
|
|
to: ' setFirstRunSuppressed(Boolean(wanted), storage);\n return true;',
|
|
},
|
|
{
|
|
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: 'present({ scrollable: String(overflows) });',
|
|
to: "present({ 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: " 'Live earthquakes and active fires, from USGS and NASA',",
|
|
to: " 'Live earthquakes worldwide, straight from USGS',",
|
|
},
|
|
{
|
|
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: 'copy',
|
|
from: 'ENVIRONMENTAL_LABEL_KEYS[choice] || ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL;',
|
|
to: 'ENVIRONMENTAL_LABEL_KEYS.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, {\n explicit: true,\n });",
|
|
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: 'html',
|
|
from: ':aria-disabled="launcher?.busy ?? undefined" @click="controls?.choose(\'contacts\')"',
|
|
to: ':disabled="launcher?.busy === \'true\'" @click="controls?.choose(\'contacts\')"',
|
|
},
|
|
{
|
|
defect: 'Tab escapes into an app the visitor has not seen yet',
|
|
file: 'keyboard',
|
|
from: "if (event.key !== 'Tab') return;",
|
|
to: 'return;',
|
|
},
|
|
{
|
|
defect: 'ESC no longer dismisses the launcher',
|
|
file: 'keyboard',
|
|
from: "if (event.key === 'Escape') {",
|
|
to: 'if (false) {',
|
|
},
|
|
{
|
|
defect: 'focus is never returned to where the visitor left it',
|
|
file: 'keyboard',
|
|
from: "if (typeof target?.focus === 'function' && target.isConnected) {",
|
|
to: 'if (false) {',
|
|
},
|
|
{
|
|
defect: 'app hotkeys eat the launcher keys (bubble instead of capture)',
|
|
file: 'keyboard',
|
|
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:\n opacity 220ms ease,\n 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:\n opacity 220ms ease,\n 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 {\n transition: none;\n }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices {\n scroll-behavior: auto;\n }',
|
|
to: ' .first-run-nothing {\n transition: none;\n }',
|
|
},
|
|
|
|
// ── 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 @change="onSuppress" />',
|
|
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: 'id="first-run-launcher" role="dialog"',
|
|
to: 'id="first-run-launcher"',
|
|
},
|
|
{
|
|
defect: 'the launcher is revealed before the loading cover yields',
|
|
file: 'main',
|
|
from: " loadingScreen.classList.add('hidden');",
|
|
to: " revealFirstRun();\n loadingScreen.classList.add('hidden');",
|
|
},
|
|
{
|
|
defect:
|
|
'the globe missions lose their DataManager and cannot enable layers',
|
|
file: 'main',
|
|
from: 'firstRun = initializeWelcome?.({ styleManager, dataManager });',
|
|
to: 'firstRun = initializeWelcome?.({ styleManager });',
|
|
},
|
|
|
|
// ── Voice: schema must not drift ──────────────────────────────────────────
|
|
{
|
|
defect: 'the voice TOOL SCHEMA is edited (a Realtime prompt-cache bust)',
|
|
file: 'voiceDescriptions',
|
|
from: "'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers",
|
|
to: "'Counts, lists, superlatives, and attribute filters over records loaded by ENABLED layers",
|
|
},
|
|
{
|
|
defect:
|
|
'the instruction mapping is dropped, so voice cannot reach the modes',
|
|
file: 'voiceInstructions',
|
|
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',
|
|
['--import=./build/registerVue.js', '--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;
|