diff --git a/static/app.js b/static/app.js index 7e99ce9..5431049 100644 --- a/static/app.js +++ b/static/app.js @@ -12165,3 +12165,52 @@ async function bootstrapPluginsAndUi() { }) .catch(() => {}); })(); + + +// ─── The window contract ──────────────────────────────────────────────────── +// app.js is a classic script today, so every top-level `function foo()` here is +// implicitly a property of `window`. The R3a migration turns this file into an +// ES module, where that stops being true — module scope is not global scope, and +// each of these names would silently vanish from `window`. +// +// Everything below is reached by NAME from outside this file, so each one is +// made explicit BEFORE the flip. While app.js is still classic this whole block +// is a no-op (it just re-assigns what is already there), which is exactly what +// makes it safe to land on its own. +// +// The consumers are: inline on*= handlers in static/v3/index.html; on*= handlers +// this file builds inside template literals; static/v3/*.js; the capabilities; +// bundled plugins; and — easy to forget, since they live in other repos — +// feedback-desktop and the external plugins. Constitution II names +// `window.playSong` / `window.showScreen` / `window.feedBack` as the public +// extension contract. +// +// Guarded by tests/js/window_contract.test.js. Add a name here the moment +// anything outside app.js calls it. +Object.assign(window, { + _confirmDialog, _getArrangementNamingMode, _libraryLocalFilename, _librarySongArtUrl, + _librarySongId, _onHeaderClick, _onNamingModeChange, _trapFocusInModal, + changeArrangement, checkPluginUpdates, clearLibFilters, clearLoop, + deleteSelectedLoop, exportDiagnostics, exportSettings, filterFavorites, + filterLibrary, fullRescanLibrary, goFavPage, handleSliderInput, + hideScanBanner, importSettings, loadPlugins, loadSavedLoop, + loadSettings, onSectionPracticeModeChange, openEditModal, persistSetting, + pickDlcFolder, pinCurrentArrangementDefault, playSong, previewDiagnostics, + previewEditArt, renderGridCards, renderTreeInto, rescanLibrary, + retuneSong, saveCurrentLoop, saveSettings, seekBy, + setAvOffsetMs, setFavView, setInstrumentPathway, setLibView, + setLibraryProvider, setLoopEnd, setLoopStart, setMastery, + setSpeed, setViz, showScreen, sortFavorites, + sortLibrary, syncLibrarySong, toggleAllArtists, toggleAllFavoriteArtists, + toggleLibFilters, togglePlay, toggleSectionPracticePopover, uiPrompt, + updatePlugin, uploadSongs, + + // These four are invisible to every static scan. app.js:2156-2157 picks the + // handler NAME at runtime — + // const letterFn = favoritesOnly ? 'filterFavTreeLetter' : 'filterTreeLetter'; + // — and interpolates it: `onclick="${letterFn}('A')"`. So the names never + // appear as identifiers anywhere, and ESLint / no-undef / a grep for + // `onclick="fn` all miss them. They are the library A-Z rail and its + // pagination; drop one and those buttons throw at click time, nowhere else. + filterFavTreeLetter, filterTreeLetter, goFavTreePage, goTreePage, +}); diff --git a/tests/js/window_contract.test.js b/tests/js/window_contract.test.js new file mode 100644 index 0000000..940dbf7 --- /dev/null +++ b/tests/js/window_contract.test.js @@ -0,0 +1,95 @@ +// Guards app.js's `window` contract ahead of the R3a ES-module flip. +// +// app.js is a classic script, so every top-level `function foo()` is implicitly +// a property of `window`. As an ES module it will not be — module scope is not +// global scope. Any name reached from OUTSIDE app.js must therefore be an +// explicit `window.foo = …` before the flip, or it vanishes silently. +// +// "Silently" is the whole problem. A missing inline handler is a ReferenceError +// only when someone clicks the button; a `typeof window.setViz !== 'function'` +// guard (capabilities/visualization.js) just degrades and says nothing. Neither +// shows up in a test run, so this file is the thing standing between a dropped +// name and a dead button in production. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..', '..'); +const APP_JS = fs.readFileSync(path.join(ROOT, 'static', 'app.js'), 'utf8'); +const V3_HTML = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'index.html'), 'utf8'); + +// Every name app.js publishes: the scattered `window.foo = …` assignments plus +// the consolidated `Object.assign(window, { … })` contract block at the bottom. +function exposedNames() { + const names = new Set( + [...APP_JS.matchAll(/^window\.([A-Za-z_$][\w$]*)\s*=/gm)].map((m) => m[1]), + ); + const block = APP_JS.match(/Object\.assign\(window, \{([\s\S]*?)\n\}\);/); + assert.ok(block, 'the Object.assign(window, …) contract block is missing from app.js'); + // Strip the comments first — the prose inside them is full of words that + // would otherwise scrape as identifiers. + const body = block[1].replace(/\/\/[^\n]*/g, ''); + for (const m of body.matchAll(/([A-Za-z_$][\w$]*)\s*(?=,|$)/gm)) names.add(m[1]); + return names; +} + +// app.js's own top-level `function foo()` declarations — the names that stop +// being global under `type="module"`. +function topLevelFunctions() { + return new Set( + [...APP_JS.matchAll(/^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm)].map((m) => m[1]), + ); +} + +const HANDLER = /on(?:click|change|input|submit|keyup|keydown|mousedown|error|focus|blur)\s*=\s*"([A-Za-z_$][\w$]*)/g; + +test('every inline on*= handler in the v3 shell is on window', () => { + const exposed = exposedNames(); + const owned = topLevelFunctions(); + const missing = [...V3_HTML.matchAll(HANDLER)] + .map((m) => m[1]) + .filter((n) => owned.has(n) && !exposed.has(n)); + assert.deepEqual([...new Set(missing)], [], 'inline handlers that would break under type="module"'); +}); + +test('every on*= handler app.js builds in a template literal is on window', () => { + // e.g. `