mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 04:41:23 +00:00
* fix(plugins): don't treat transient absence from /api/plugins as uninstall The backend clears its plugin registry at the start of load_plugins() and repopulates it incrementally while HTTP stays up, so every backend restart (desktop: Audio Quality soundfont switch, LAN toggle, update restart) serves a window of partial — even empty — /api/plugins responses. loadPlugins() treated absence from the current response as an uninstall, with three destructive consequences for still-loaded plugins: 1. Their settings-panel and screen DOM were wiped while their _loadedPluginScripts entry survived, so the NEXT refetch failed the DOM-existence check and re-evaluated the plugin's screen.js mid-session. For the desktop audio_engine plugin that re-ran init() against the surviving native audio chain and exactly duplicated every VST/NAM/IR stage (the alpha testers' "chain duplicates after leaving the Audio menu" / blown-out gain reports). 2. _reconcilePluginStyles dropped their stylesheet, leaving them visible but unstyled until they reappeared. 3. The stale-contribution sweep unmounted their UI contributions and unregistered their capability participant with no re-registration path (plugin scripts don't re-run thanks to the loadedScripts guard). Absence is now a non-signal everywhere in loadPlugins: the DOM wipe and style reconcile are scoped to plugins the response actually names, and the absence sweep is removed. Present plugins still fully re-sync via _registerLegacyPluginUiContributions each round; failed plugins are present in the response and still cleaned up; nav is rebuilt from the response so genuinely uninstalled plugins drop out of it, and their (un-unloadable) already-evaluated scripts keep their DOM until reload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: update idempotence contract to the absence-is-not-uninstall invariant The removed-plugin sweep contract pinned the old behavior this branch deletes; pin the new invariant instead (no absence sweep + respondedIds scoping on the DOM/style reconcilers). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
116 lines
4.4 KiB
JavaScript
116 lines
4.4 KiB
JavaScript
// Verify loadPlugins' plugin-DOM wipe loops in static/app.js: a plugin that is
|
|
// merely ABSENT from the current /api/plugins response (transient partial
|
|
// response while the backend's plugin registry is repopulating after a
|
|
// restart) must keep its settings panel and screen DOM. Wiping it while its
|
|
// _loadedPluginScripts entry survives made the next refetch fail the
|
|
// DOM-existence check and re-evaluate the plugin's screen.js mid-session —
|
|
// which duplicated the desktop audio_engine's native signal chain. Plugins
|
|
// the response knows about but that failed hydration are still wiped, as is
|
|
// junk DOM carrying no plugin id.
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
|
|
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
|
|
|
// Slice the wipe block out of loadPlugins by its stable landmarks: from the
|
|
// nav reset that opens it to the comment introducing the next section.
|
|
function extractWipeBlock(src) {
|
|
const start = src.indexOf("navContainer.innerHTML = '';");
|
|
assert.ok(start !== -1, 'wipe block start (nav reset) not found');
|
|
const end = src.indexOf('// Plugin settings area hosts', start);
|
|
assert.ok(end !== -1, 'wipe block end marker not found');
|
|
return src.slice(start, end);
|
|
}
|
|
|
|
function makeEl(pluginId, id) {
|
|
return {
|
|
dataset: pluginId != null ? { pluginId } : {},
|
|
id: id || (pluginId != null ? `plugin-${pluginId}` : ''),
|
|
removed: false,
|
|
remove() {
|
|
this.removed = true;
|
|
const idx = this._parent ? this._parent.indexOf(this) : -1;
|
|
if (idx >= 0) this._parent.splice(idx, 1);
|
|
},
|
|
};
|
|
}
|
|
|
|
function runWipe({ respondedIds, alreadyHydrated, settingsChildren, screens }) {
|
|
const src = fs.readFileSync(APP_JS, 'utf8');
|
|
const block = extractWipeBlock(src);
|
|
settingsChildren.forEach((el) => { el._parent = settingsChildren; });
|
|
const container = { children: settingsChildren };
|
|
const sandbox = {
|
|
navContainer: { innerHTML: 'seed' },
|
|
mobileNavContainer: { innerHTML: 'seed' },
|
|
_pluginSettingsContainers: () => [container],
|
|
respondedIds,
|
|
alreadyHydrated,
|
|
document: {
|
|
querySelectorAll: (sel) => {
|
|
assert.equal(sel, '.screen[id^="plugin-"]');
|
|
return screens.slice();
|
|
},
|
|
},
|
|
};
|
|
vm.runInNewContext(block, sandbox, { filename: 'wipe-block.js' });
|
|
return sandbox;
|
|
}
|
|
|
|
test('plugin absent from the response keeps its settings + screen DOM', () => {
|
|
const settings = makeEl('audio_engine');
|
|
const screen = makeEl('audio_engine');
|
|
runWipe({
|
|
respondedIds: new Set(), // partial response: plugin missing
|
|
alreadyHydrated: new Set(), // scan loop never saw it either
|
|
settingsChildren: [settings],
|
|
screens: [screen],
|
|
});
|
|
assert.equal(settings.removed, false, 'settings panel must survive a partial response');
|
|
assert.equal(screen.removed, false, 'screen must survive a partial response');
|
|
});
|
|
|
|
test('plugin present in the response but not hydrated is wiped', () => {
|
|
const settings = makeEl('stale_plugin');
|
|
const screen = makeEl('stale_plugin');
|
|
runWipe({
|
|
respondedIds: new Set(['stale_plugin']),
|
|
alreadyHydrated: new Set(),
|
|
settingsChildren: [settings],
|
|
screens: [screen],
|
|
});
|
|
assert.equal(settings.removed, true);
|
|
assert.equal(screen.removed, true);
|
|
});
|
|
|
|
test('hydrated plugin present in the response is preserved', () => {
|
|
const settings = makeEl('audio_engine');
|
|
const screen = makeEl('audio_engine');
|
|
runWipe({
|
|
respondedIds: new Set(['audio_engine']),
|
|
alreadyHydrated: new Set(['audio_engine']),
|
|
settingsChildren: [settings],
|
|
screens: [screen],
|
|
});
|
|
assert.equal(settings.removed, false);
|
|
assert.equal(screen.removed, false);
|
|
});
|
|
|
|
test('junk DOM without a plugin id is still removed', () => {
|
|
const junkSettings = makeEl(null);
|
|
// Screen whose id strips to '' (no dataset.pluginId, bare "plugin-" id).
|
|
const junkScreen = makeEl(null, 'plugin-');
|
|
runWipe({
|
|
respondedIds: new Set(['whatever']),
|
|
alreadyHydrated: new Set(),
|
|
settingsChildren: [junkSettings],
|
|
screens: [junkScreen],
|
|
});
|
|
assert.equal(junkSettings.removed, true);
|
|
assert.equal(junkScreen.removed, true);
|
|
});
|