Plugins belong to the project, and they load before it: the project file gained the field plugins: ["demo-plugin.js"] (default empty, no formatVersion bump), the Plugins tab's switch writes into it as an ordinary undo step (SetPluginActiveCommand) instead of into a server-wide plugins.json, and the start order is turned around - startPlugins only fetches the list of files, then editor.boot() runs and ProjectStore.load(id, prepare) hands the raw JSON through before it is deserialized, where pluginsOf(raw) reads the list and PluginManager.syncTo() activates exactly those; that is the point of the issue, because what a plugin registers has to stand there before normalizeGraph would throw it away as unknown (#126, #127), a project switch first stops what the new project does not name and then starts what it does, a file the server does not have is passed over silently, and after signing out the manager is dead so a late event cannot start anything in a torn-down session; on the server plugins.json, PluginStore::setActive, isActive, the route PUT ?r=plugins/{file} and the 403 for inactive modules are gone and GET ?r=plugins only answers {file,bytes,updatedAt}, which makes every plugin file fetchable - the consequence accepted in #117, written down in the wiki for #115 and #116; publishPlugins(root) only copies files now, the seeder switches the demo plugin on in the fixture project, and the Plugins tab carries a puzzle piece as its icon while only a plugin's own tab still goes without one (the knob and the socket are cubic curves, not arcs, which Pixi's parser draws as a spike after an L); 3 new e2e checks in module 38 and 5 new unit tests cover the project as the owner of the switch, its undo, the order, pluginsOf against crude input and an unknown file name (#119)
game-maker / gate (push) Successful in 3m32s
game-maker / suite (push) Failing after 8m21s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-20 14:24:27 +02:00
1 parent c830c7a3aa
commit 5ba796a2b6
28 files changed
+425 -269

No files matched your search

+97 -29
View File
@@ -9,6 +9,7 @@ import {
CreateAssetCommand,
RenameAssetCommand,
SetAssetPropertyCommand,
SetPluginActiveCommand,
} from '../../src/core/commands/assetCommands';
import { createEmptyProject } from '../../src/core/model/Project';
import { zoomedAbout } from '../../src/editor/graph/graphView';
@@ -25,8 +26,8 @@ const plugin = (id: string, activate: (api: unknown) => unknown = () => {}, api:
});
/**
* A manager on made-up modules. Every file is switched on unless `off` names it, which is what
* the server's `plugins.json` says in the real thing.
* A manager on made-up modules. `start` hands it the files the server has and then the list the
* open project switches on – everything but `off`, which stands for what the project leaves out.
*/
function loaderDeps(modules: Record<string, () => unknown>, off: string[] = []) {
const reports: string[] = [];
@@ -43,15 +44,14 @@ function loaderDeps(modules: Record<string, () => unknown>, off: string[] = [])
activated.push(meta.id);
return { for: meta.id };
},
store: (file: string, active: boolean) => {
stored.push([file, active]);
return Promise.resolve();
},
store: (file: string, active: boolean) => void stored.push([file, active]),
report: (message: string) => reports.push(message),
};
const manager = new PluginManager(deps);
const start = (files = Object.keys(modules)) =>
manager.start(files.map((file, i) => ({ file, updatedAt: i + 1, active: !off.includes(file) })));
const start = async (files = Object.keys(modules)) => {
await manager.start(files.map((file, i) => ({ file, updatedAt: i + 1 })));
await manager.syncTo(files.filter((file) => !off.includes(file)));
};
return { reports, activated, stored, loads, deps, manager, start };
}
@@ -277,12 +277,16 @@ describe('the plugin API', () => {
});
/**
* Switching plugins on and off (#114). The switch is the server's, so it is stored before it is
* applied; applying it means loading and activating, or running `deactivate` and taking back
* Switching plugins on and off (#114, #119). The switch belongs to the open project: it is written
* there first and applied afterwards, and the editor follows the project – so undo takes the switch
* back as well. Applying it means loading and activating, or running `deactivate` and taking back
* everything the plugin registered through its API.
*/
describe('switching a plugin on and off', () => {
/** A manager over a real API and host, so what a plugin registers is really taken away again. */
/**
* A manager over a real API, host and project, wired the way `startPlugins` wires it: the switch
* runs through a command, and what runs follows the project's list.
*/
function switchable(modules: Record<string, () => unknown>, off: string[] = []) {
const { state, host, tabs } = editorHost();
const reports: string[] = [];
@@ -297,16 +301,19 @@ describe('switching a plugin on and off', () => {
},
api: (meta, scope) => createPluginApi(meta.id, scopedHost(host, scope)),
store: (file, active) => {
if (refuse) return Promise.reject(new Error('Server error'));
if (refuse) throw new Error('Project is read-only');
stored.push([file, active]);
return Promise.resolve();
state.execute(new SetPluginActiveCommand(file, active));
},
report: (message) => reports.push(message),
});
const start = () =>
manager.start(
Object.keys(modules).map((file, i) => ({ file, updatedAt: i + 1, active: !off.includes(file) })),
);
state.on('project', () => void manager.syncTo(state.project.plugins));
const on = Object.keys(modules).filter((file) => !off.includes(file));
const start = async () => {
state.project.plugins = [...on];
await manager.start(Object.keys(modules).map((file, i) => ({ file, updatedAt: i + 1 })));
await manager.syncTo(state.project.plugins);
};
return { state, tabs, manager, start, reports, stored, loads, refuse: (no: boolean) => (refuse = no) };
}
@@ -320,7 +327,7 @@ describe('switching a plugin on and off', () => {
deactivate: () => log.push(`${id}: deactivate`),
});
it('loads only what is switched on – a plugin that is off is never even fetched', async () => {
it('loads only what the project asks for – a plugin it leaves out is never even fetched', async () => {
const { manager, start, loads } = switchable(
{ 'on.js': () => plugin('on'), 'off.js': () => plugin('off') },
['off.js'],
@@ -333,22 +340,78 @@ describe('switching a plugin on and off', () => {
]);
});
it('stores the switch first, then loads and activates the plugin', async () => {
it('writes the switch into the project first, then loads and activates the plugin', async () => {
const log: string[] = [];
const { manager, start, stored, loads, tabs } = switchable({ 'demo.js': () => tabPlugin('demo', log) }, [
'demo.js',
]);
const { manager, start, state, stored, loads, tabs } = switchable(
{ 'demo.js': () => tabPlugin('demo', log) },
['demo.js'],
);
await start();
expect(tabs.size).toBe(0);
await manager.setActive('demo.js', true);
expect(stored).toEqual([['demo.js', true]]);
expect(state.project.plugins).toEqual(['demo.js']);
// The file's own time goes into the load, so a replaced file is not taken from the module cache.
expect(loads).toEqual([['demo.js', 1]]);
expect(tabs.has('demo')).toBe(true);
expect(manager.records[0]).toMatchObject({ status: 'active', meta: { name: 'Plugin demo' } });
});
it('is an undo step: undoing the switch stops the plugin again', async () => {
const log: string[] = [];
const { manager, start, state, tabs } = switchable({ 'demo.js': () => tabPlugin('demo', log) });
await start();
expect(tabs.has('demo')).toBe(true);
await manager.setActive('demo.js', false);
expect(state.project.plugins).toEqual([]);
expect(tabs.size).toBe(0);
state.undo();
await manager.syncTo(state.project.plugins);
expect(state.project.plugins).toEqual(['demo.js']);
expect(tabs.has('demo')).toBe(true);
expect(manager.records[0]?.status).toBe('active');
state.redo();
await manager.syncTo(state.project.plugins);
expect(tabs.size).toBe(0);
expect(manager.records[0]?.status).toBe('inactive');
});
it('follows a project change: the one it leaves goes before the one it opens arrives', async () => {
const log: string[] = [];
const { manager, start, tabs } = switchable(
{ 'a.js': () => tabPlugin('a', log), 'b.js': () => tabPlugin('b', log) },
['b.js'],
);
await start();
expect([...tabs.keys()]).toEqual(['a']);
// What a different project switches on, as the raw file says it before it is deserialized.
await manager.syncTo(['b.js']);
expect(log).toEqual(['a: deactivate']);
expect([...tabs.keys()]).toEqual(['b']);
expect(manager.records.map((r) => [r.file, r.status])).toEqual([
['a.js', 'inactive'],
['b.js', 'active'],
]);
// Asking for what is running already does nothing at all.
await manager.syncTo(['b.js']);
expect(log).toEqual(['a: deactivate']);
});
it('passes over a file the server does not have, and says nothing about it', async () => {
const { manager, start, reports, loads } = switchable({ 'a.js': () => plugin('a') }, ['a.js']);
await start();
await manager.syncTo(['gone.js']);
expect(loads).toEqual([]);
expect(reports).toEqual([]);
expect(manager.records.map((r) => r.file)).toEqual(['a.js']);
});
it('signing out stops every plugin without touching the switches on the server', async () => {
const log: string[] = [];
const { manager, start, state, tabs, stored } = switchable({
@@ -362,8 +425,9 @@ describe('switching a plugin on and off', () => {
await manager.stopAll();
expect(log).toEqual(['a: deactivate', 'b: deactivate', 'off: deactivate']);
expect(tabs.size).toBe(0);
// The switches are the account's, not this editor's: nothing was written (#177).
// The project keeps its list: signing out stops the plugins, it does not switch them off (#177).
expect(stored).toEqual([]);
expect(state.project.plugins).toEqual(['a.js', 'b.js', 'off.js']);
state.execute(new CreateAssetCommand('sprite', 'Crate'));
expect(log).toEqual(['a: deactivate', 'b: deactivate', 'off: deactivate']);
});
@@ -391,14 +455,17 @@ describe('switching a plugin on and off', () => {
expect(tabs.has('demo')).toBe(true);
});
it('leaves the plugin as it was when the server does not store the switch', async () => {
const { manager, start, reports, tabs, refuse } = switchable({ 'demo.js': () => tabPlugin('demo') });
it('leaves the plugin as it was when the project does not take the switch', async () => {
const { manager, start, state, reports, tabs, refuse } = switchable({
'demo.js': () => tabPlugin('demo'),
});
await start();
refuse(true);
await manager.setActive('demo.js', false);
expect(manager.records[0]?.status).toBe('active');
expect(state.project.plugins).toEqual(['demo.js']);
expect(tabs.has('demo')).toBe(true);
expect(reports).toEqual(['Plugin demo.js not switched off: Server error']);
expect(reports).toEqual(['Plugin demo.js not switched off: Project is read-only']);
});
it('takes back what a plugin registered before its activate threw', async () => {
@@ -467,13 +534,14 @@ describe('switching a plugin on and off', () => {
const { manager, start } = switchable({ 'demo.js': () => tabPlugin('demo') });
const seen = vi.fn();
const off = manager.onChange(seen);
// The list of files and the first sync against the project are two changes.
await start();
expect(seen).toHaveBeenCalledTimes(1);
await manager.setActive('demo.js', false);
expect(seen).toHaveBeenCalledTimes(2);
await manager.setActive('demo.js', false);
expect(seen).toHaveBeenCalledTimes(3);
off();
await manager.setActive('demo.js', true);
expect(seen).toHaveBeenCalledTimes(2);
expect(seen).toHaveBeenCalledTimes(3);
});
it('runs a scope’s cleanups newest first, all of them even when one throws', () => {