mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 19:29:33 +00:00
A plugin reload silently did nothing for scriptType:"module" plugins. ES modules are evaluated ONCE PER URL PER DOCUMENT, so re-inserting a <script type="module"> whose src the module map has already seen fires `load` without re-running the body — and the loader then recorded the reload as applied. A no-op that reported success. THE ISSUE UNDERSTATES IT. #879 says "upgrades are fine — a new version yields a new URL". That is true of screen.js and FALSE of the plugin. I drove a real browser through install(1.0.0) -> upgrade(1.1.0) -> rollback(1.0.0), counting evaluations of src/main.js: ONE. Not three, not two. The upgrade re-runs the one-line screen.js shim at its new ?v= URL; the shim does `import './src/main.js'`; a relative specifier resolves against the base URL WITH THE QUERY DROPPED; that is the same URL as before; the module map hands back the already-evaluated v1.0.0 module. The plugin's own code never re-ran. Busting the entry point cannot fix this, whatever token you hang off it. So the token goes in the PATH: /api/plugins/<id>/g/<n>/screen.js. From there './src/main.js' resolves to /api/plugins/<id>/g/<n>/src/main.js — every relative import inherits it, at every depth, for free. No import-specifier rewriting (which could never see `import(expr)` anyway). Same browser drive after the fix: THREE evaluations. Keyed on the plugin ID, not id@version: EVERY re-load of a module plugin needs a fresh path, not just a rollback. First load keeps the stable ?v= URL, so the ETag/304 live-edit caching the R0 rails depend on is untouched. Classic-script plugins are not affected and never take a /g/ path. ━━━ A PATH REWRITE, NOT TWO MIRRORED ROUTES ━━━ Codex caught this, and it was right. The token shifts the BASE URL, so EVERYTHING the module graph resolves relatively moves with it — not only imports. `new URL('../assets/worklet.js', import.meta.url)` from /api/plugins/x/g/1/src/main.js resolves to /api/plugins/x/g/1/assets/worklet.js. Mirroring only screen.js and src/ would have fixed imports and 404'd every asset, worklet and wasm file the graph reaches — and would have broken again the next time someone added a plugin route. So the /g/<token> segment is STRIPPED BEFORE ROUTING. Every plugin route, present and future, works under the prefix with no extra wiring. The token is opaque and never joined into a filesystem path, so containment still rests entirely on the same safe_join. Codex then caught a [P3] in that: eagerly re-encoding raw_path with latin-1 raises UnicodeEncodeError on a valid plugin file like src/工具.js, 500ing a request the plain route serves fine. raw_path is informational and Starlette routes on scope["path"], so the mutation is simply gone — and leaving raw_path as the client sent it is more truthful for logs anyway. TESTS. tests/js/plugin_module_rollback.test.js (5) + 8 in test_plugin_src_route.py: identical bytes under the prefix, the whole graph one and two levels deep, ASSETS (the Codex [P2]), every plugin route, non-ASCII filenames (the [P3]), an opaque token, and containment asserted as PARITY with the un-prefixed route rather than a guessed 404 — `../screen.js` legitimately 200s on both, because the URL normalises before routing. All bite-tested: reverting the fix fails the rollback tests, disabling the rewrite fails the asset tests. Two harnesses re-anchored on `script.src = _pluginScriptUrl(` — the URL literal they keyed on now lives in the helper, further down the file, so their slice ran off the end. node 1045, pytest 2404, ESLint 0, Codex 0. Closes #879 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
139 lines
7.8 KiB
JavaScript
139 lines
7.8 KiB
JavaScript
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { loadAudioSession } = require('./audio_session_test_harness');
|
|
|
|
test('active audio domains expose expected legacy shim metadata', () => {
|
|
const window = loadAudioSession();
|
|
const shims = window.feedBack.capabilities.snapshotDiagnostics().compatibilityShims;
|
|
for (const shimId of ['audio-mix.fader-registry', 'audio-mix.song-volume', 'audio-mix.analyser', 'audio-input.legacy-source', 'audio-monitoring.audio-barrier', 'stems.master-volume', 'stems.private-state']) {
|
|
assert.equal(shims.some(shim => shim.shimId === shimId && shim.status === 'active'), true, shimId);
|
|
}
|
|
});
|
|
|
|
test('legacy bridge hit counts are attributed to canonical audio domains', () => {
|
|
const window = loadAudioSession();
|
|
const audioSession = window.feedBack.audioSession;
|
|
audioSession.recordBridgeHit({ domain: 'audio-mix', bridgeId: 'audio-mix.analyser', legacySurface: 'HTMLAudioElement analyser tap', participantId: 'highway_3d' });
|
|
audioSession.recordBridgeHit({ domain: 'audio-input', bridgeId: 'audio-input.legacy-source', legacySurface: 'navigator.mediaDevices.getUserMedia', participantId: 'note_detect' });
|
|
audioSession.recordBridgeHit({ domain: 'audio-monitoring', bridgeId: 'audio-monitoring.audio-barrier', legacySurface: 'window.feedBackAudioBarrier', participantId: 'note_detect' });
|
|
|
|
const shims = window.feedBack.capabilities.snapshotDiagnostics().compatibilityShims;
|
|
assert.equal(shims.find(shim => shim.shimId === 'audio-mix.analyser').hitCount, 1);
|
|
assert.equal(shims.find(shim => shim.shimId === 'audio-input.legacy-source').capability, 'audio-input');
|
|
assert.equal(shims.find(shim => shim.shimId === 'audio-monitoring.audio-barrier').hitCount, 1);
|
|
});
|
|
|
|
test('native audio-mix participant suppresses matching legacy fader and records overshadowed bridge hit', async () => {
|
|
const { runBrowserScript, installMixerDom } = require('./audio_session_test_harness');
|
|
const window = loadAudioSession();
|
|
installMixerDom(window);
|
|
runBrowserScript(window, 'static/audio-mixer.js');
|
|
window.feedBack.audioSession.startSession({ sessionId: 'main:test-song' });
|
|
|
|
window.feedBack.audio.registerFader({
|
|
id: 'delay.wet',
|
|
label: 'Delay Wet Legacy',
|
|
min: 0,
|
|
max: 1,
|
|
step: 0.1,
|
|
defaultValue: 0.2,
|
|
logicalFaderKey: 'delay:wet',
|
|
getValue: () => 0.2,
|
|
setValue: () => {},
|
|
});
|
|
window.feedBack.audioSession.registerMixParticipant({
|
|
participantId: 'plugin.delay.native',
|
|
ownerPluginId: 'delay',
|
|
label: 'Delay Wet',
|
|
kind: 'plugin',
|
|
sourceMode: 'native',
|
|
logicalFaderKey: 'delay:wet',
|
|
fader: { id: 'wet', label: 'Delay Wet', min: 0, max: 1, step: 0.1, defaultValue: 0.4, currentValue: 0.4 },
|
|
operations: ['fader.get-value', 'fader.set-value'],
|
|
});
|
|
|
|
const listed = await window.feedBack.capabilities.dispatch({ capability: 'audio-mix', command: 'list-faders', source: 'test' });
|
|
const snapshot = window.feedBack.audioSession.snapshot();
|
|
const legacy = snapshot.domains['audio-mix'].participants.find(participant => participant.participantId === 'fader.delay.wet');
|
|
|
|
assert.equal(listed.payload.faders.some(fader => fader.participantId === 'plugin.delay.native'), true);
|
|
assert.equal(listed.payload.faders.some(fader => fader.participantId === 'fader.delay.wet'), false);
|
|
assert.equal(legacy.supersededBy, 'plugin.delay.native');
|
|
assert.equal(snapshot.domains['audio-mix'].bridges.some(bridge => bridge.status === 'overshadowed' && bridge.participantId === 'fader.delay.wet'), true);
|
|
});
|
|
|
|
// Source-level guards for PR1 runtime compatibility-shim hit accounting.
|
|
// Broader app/player/audio domains are reserved for follow-up PRs, so this
|
|
// file checks plugin attribution helpers and that library now uses the native
|
|
// capability module instead of legacy shim accounting.
|
|
|
|
const ROOT = path.join(__dirname, '..', '..');
|
|
const APP_JS = path.join(ROOT, 'static', 'app.js');
|
|
// The plugin loader was carved out of app.js into its own module (R3a); the
|
|
// library-provider code below still lives in app.js.
|
|
const PLUGIN_LOADER_JS = path.join(ROOT, 'static', 'js', 'plugin-loader.js');
|
|
// The viz layer was carved out of app.js too (R3a).
|
|
const VIZ_JS = path.join(ROOT, 'static', 'js', 'viz.js');
|
|
const LIBRARY_JS = path.join(ROOT, 'static', 'capabilities', 'library.js');
|
|
// The library itself was carved out of app.js into ./static/js/library.js (R3a). Note the
|
|
// two are DIFFERENT files: LIBRARY_JS above is the capability; this is the UI module.
|
|
// syncLibrarySong deliberately stayed behind in app.js — it reaches showScreen/playSong,
|
|
// and moving it would have dragged the whole playback core into the library module.
|
|
const LIBRARY_MODULE_JS = path.join(ROOT, 'static', 'js', 'library.js');
|
|
|
|
function source(file) {
|
|
// Normalize CRLF: region() slices fixed CHARACTER windows, so on a
|
|
// Windows checkout (autocrlf) every line costs one extra char and the
|
|
// assertion target can fall outside the window.
|
|
return fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
|
|
}
|
|
|
|
function region(src, needle, length = 1200) {
|
|
const start = src.indexOf(needle);
|
|
assert.ok(start !== -1, `missing source needle: ${needle}`);
|
|
return src.slice(start, start + length);
|
|
}
|
|
|
|
test('plugin script hydration exposes the current plugin id for legacy registrations', () => {
|
|
const src = source(PLUGIN_LOADER_JS);
|
|
// Anchored on the ASSIGNMENT, not the URL literal: the URL is built in
|
|
// _pluginScriptUrl() now (#879 — a rollback needs a fresh module URL for the whole
|
|
// import graph), so the old literal no longer appears at the injection site.
|
|
const block = region(src, 'script.src = _pluginScriptUrl(');
|
|
assert.match(block, /window\.feedBack\._loadingPluginId\s*=\s*plugin\.id/);
|
|
assert.match(block, /delete\s+window\.feedBack\._loadingPluginId/);
|
|
});
|
|
|
|
test('library providers route through native library capability', () => {
|
|
const src = source(APP_JS);
|
|
const libModule = source(LIBRARY_MODULE_JS);
|
|
const librarySrc = source(LIBRARY_JS);
|
|
const loader = region(libModule, 'async function loadLibraryProviders', 1800);
|
|
const selector = region(libModule, 'async function setLibraryProvider(providerId, options = {})', 1600);
|
|
const sync = region(src, 'async function syncLibrarySong(providerId, songId', 1600);
|
|
|
|
assert.match(librarySrc, /capabilities\.registerOwner\(['"]library['"]/);
|
|
assert.match(librarySrc, /kind:\s*['"]provider-coordinator['"]/);
|
|
assert.match(librarySrc, /'library\.read': \['query-page', 'query-artists', 'query-stats', 'tuning-names'\]/);
|
|
assert.match(librarySrc, /window\.feedBack\.libraryProviders\s*=\s*providerApi/);
|
|
assert.match(loader, /api\.refresh\(\{ restoreSaved \}\)/);
|
|
assert.match(selector, /capabilityApi\.command\(['"]library['"],\s*['"]select-provider['"]/);
|
|
assert.match(sync, /capabilityApi\.command\(['"]library['"],\s*['"]sync-song['"]/);
|
|
assert.doesNotMatch(src, /_recordLegacyLibraryProviderShim/);
|
|
assert.doesNotMatch(src, /_recordLegacyLibraryCommand/);
|
|
assert.doesNotMatch(librarySrc, /registerCompatibilityShim|recordLegacyHit/);
|
|
});
|
|
|
|
test('visualization renderer installs preserve plugin attribution', () => {
|
|
const src = source(VIZ_JS);
|
|
const tagger = region(src, 'function _tagVizRenderer(renderer, id)', 700);
|
|
const setViz = region(src, 'function setViz(id)', 3600);
|
|
const autoViz = region(src, 'function _autoMatchViz()', 5200);
|
|
|
|
assert.match(tagger, /renderer\.pluginId\s*=\s*id/);
|
|
assert.match(tagger, /renderer\.source\s*=\s*id/);
|
|
assert.match(setViz, /_installVizRenderer\(renderer,\s*id\)|_installVizRenderer\(venueRenderer,\s*'highway_3d'\)/);
|
|
assert.match(autoViz, /_installVizRenderer\(renderer,\s*id,\s*'auto-match'\)/);
|
|
}); |