// The window globals are a THIRD-PARTY CONTRACT. Pin them. // // Out-of-tree plugins load their screen.js as a CLASSIC script and call these // as bare globals. Nothing in core reads most of them, so a call-graph scan, // ESLint's no-undef, and a grep all come back clean while the plugin breaks in // the field. This is the frontend twin of tests/test_plugin_context_contract.py // — same reasoning, same literal-list rule. // // This guard is retroactive: `esc` was an implicit global back when app.js was // a classic script, went module-scoped in a9fce29, and got carved into // js/dom.js in 14b4058. The re-export list at the bottom of app.js was rebuilt // without it, and the MIDI plugin's device list threw "esc is not defined" for // testers — reported as "MIDI Access denied", because the ReferenceError landed // in a try/catch meant for permission failures. // // WHY A LITERAL LIST AND NOT A DERIVED ONE. Deriving the expected set from // app.js would assert the code equals itself. The point is that a human has to // look at a diff and consciously agree to change the contract. import { test, expect } from '@playwright/test'; const PLUGIN_GLOBALS = [ '_confirmDialog', '_getArrangementNamingMode', '_libraryLocalFilename', '_librarySongArtUrl', '_librarySongId', '_onHeaderClick', '_onNamingModeChange', '_trapFocusInModal', 'changeArrangement', 'checkPluginUpdates', 'clearLibFilters', 'clearLoop', 'deleteSelectedLoop', 'esc', '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', 'filterFavTreeLetter', 'filterTreeLetter', 'goFavTreePage', 'goTreePage', ]; test('plugin-facing window globals are all callable', async ({ page }) => { await page.goto('/'); await page.waitForSelector('.screen.active', { timeout: 10000 }); const missing = await page.evaluate( (names) => names.filter((n) => typeof (window as any)[n] !== 'function'), PLUGIN_GLOBALS, ); expect(missing, `window globals plugins depend on are missing or not functions: ${missing.join(', ')}`).toEqual([]); }); // The plugin call site that actually broke: esc() interpolated into a template // string. A global that exists but doesn't escape is its own bug. test('window.esc escapes HTML metacharacters', async ({ page }) => { await page.goto('/'); await page.waitForSelector('.screen.active', { timeout: 10000 }); const escaped = await page.evaluate(() => (window as any).esc('')); expect(escaped).not.toContain('