Plugins in data/plugins are loaded at startup and can add a tab with a read-only node board, and the demo plugin shows every asset as a node (#113)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2a390e52ff
commit
57a97ba94f
22 files changed
+1153
-57
No files matched your search
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Plugins (#113): what the loader does with every way a file can fail to be a plugin, and what the
|
||||
* API v1 lets a plugin see and do. The node board itself draws text and is checked in the browser
|
||||
* (e2e step 38); only its reading of plugin input is here.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
CreateAssetCommand,
|
||||
RenameAssetCommand,
|
||||
SetAssetPropertyCommand,
|
||||
} from '../../src/core/commands/assetCommands';
|
||||
import { createEmptyProject } from '../../src/core/model/Project';
|
||||
import { zoomedAbout } from '../../src/editor/graph/graphView';
|
||||
import { activatePlugins, readMeta } from '../../src/editor/plugins/loader';
|
||||
import { boardNodes } from '../../src/editor/plugins/NodeBoard';
|
||||
import { createPluginApi, type PluginHost } from '../../src/editor/plugins/pluginApi';
|
||||
import { EditorState } from '../../src/editor/state';
|
||||
import { Widget } from '../../src/ui';
|
||||
|
||||
const plugin = (id: string, activate: (api: unknown) => unknown = () => {}, api: unknown = 1) => ({
|
||||
meta: { id, name: `Plugin ${id}`, version: '1.0.0', api },
|
||||
activate,
|
||||
});
|
||||
|
||||
function loaderDeps(modules: Record<string, () => unknown>) {
|
||||
const reports: string[] = [];
|
||||
const activated: string[] = [];
|
||||
return {
|
||||
reports,
|
||||
activated,
|
||||
deps: {
|
||||
load: (file: string) => {
|
||||
const make = modules[file];
|
||||
return make ? Promise.resolve(make()) : Promise.reject(new Error('404'));
|
||||
},
|
||||
api: (meta: { id: string }) => {
|
||||
activated.push(meta.id);
|
||||
return { for: meta.id };
|
||||
},
|
||||
report: (message: string) => reports.push(message),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('the plugin loader', () => {
|
||||
it('activates in file-name order, whatever order the list came in', async () => {
|
||||
const order: string[] = [];
|
||||
const { deps } = loaderDeps({
|
||||
'b.js': () => plugin('b', () => order.push('b')),
|
||||
'a.js': () => plugin('a', () => order.push('a')),
|
||||
});
|
||||
const records = await activatePlugins(['b.js', 'a.js'], deps);
|
||||
expect(order).toEqual(['a', 'b']);
|
||||
expect(records.map((r) => [r.file, r.status])).toEqual([
|
||||
['a.js', 'active'],
|
||||
['b.js', 'active'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('hands every plugin its own API and waits for an async activate before the next', async () => {
|
||||
const seen: unknown[] = [];
|
||||
let first = false;
|
||||
const { deps } = loaderDeps({
|
||||
'a.js': () =>
|
||||
plugin('a', async (api) => {
|
||||
seen.push(api);
|
||||
await Promise.resolve();
|
||||
first = true;
|
||||
}),
|
||||
'b.js': () => plugin('b', (api) => seen.push(api, first)),
|
||||
});
|
||||
await activatePlugins(['a.js', 'b.js'], deps);
|
||||
expect(seen).toEqual([{ for: 'a' }, { for: 'b' }, true]);
|
||||
});
|
||||
|
||||
it('reports each broken plugin and goes on with the rest', async () => {
|
||||
const { deps, reports, activated } = loaderDeps({
|
||||
'good.js': () => plugin('good'),
|
||||
'no-meta.js': () => ({ activate: () => {} }),
|
||||
'no-activate.js': () => ({ meta: { id: 'x', api: 1 } }),
|
||||
'future.js': () => plugin('future', () => {}, 2),
|
||||
'throws.js': () =>
|
||||
plugin('throws', () => {
|
||||
throw new Error('boom');
|
||||
}),
|
||||
});
|
||||
const records = await activatePlugins(
|
||||
['missing.js', 'no-meta.js', 'no-activate.js', 'future.js', 'throws.js', 'good.js'],
|
||||
deps,
|
||||
);
|
||||
const byFile = Object.fromEntries(records.map((r) => [r.file, r]));
|
||||
expect(byFile['good.js']?.status).toBe('active');
|
||||
expect(byFile['missing.js']).toMatchObject({ status: 'failed', error: 'does not load (404)' });
|
||||
expect(byFile['no-meta.js']).toMatchObject({ status: 'failed', error: 'exports no meta' });
|
||||
expect(byFile['no-activate.js']).toMatchObject({
|
||||
status: 'failed',
|
||||
error: 'exports no activate function',
|
||||
});
|
||||
expect(byFile['future.js']).toMatchObject({
|
||||
status: 'failed',
|
||||
error: 'needs plugin API 2, this editor offers 1',
|
||||
});
|
||||
expect(byFile['throws.js']).toMatchObject({ status: 'failed', error: 'activate failed (boom)' });
|
||||
expect(reports).toHaveLength(5);
|
||||
expect(reports).toContain('Plugin throws.js not activated: activate failed (boom)');
|
||||
// A plugin of the wrong version is never given an API at all.
|
||||
expect(activated).toEqual(['good', 'throws']);
|
||||
});
|
||||
|
||||
it('turns away a second plugin with an id that is active already', async () => {
|
||||
const { deps, activated } = loaderDeps({ 'a.js': () => plugin('same'), 'b.js': () => plugin('same') });
|
||||
const records = await activatePlugins(['a.js', 'b.js'], deps);
|
||||
expect(records.map((r) => r.status)).toEqual(['active', 'failed']);
|
||||
expect(records[1]?.error).toBe('a plugin with the id "same" is active already');
|
||||
expect(activated).toEqual(['same']);
|
||||
});
|
||||
|
||||
it('lets a failed plugin keep its id free for a later one', async () => {
|
||||
const { deps } = loaderDeps({
|
||||
'a.js': () =>
|
||||
plugin('same', () => {
|
||||
throw new Error('no');
|
||||
}),
|
||||
'b.js': () => plugin('same'),
|
||||
});
|
||||
expect((await activatePlugins(['a.js', 'b.js'], deps)).map((r) => r.status)).toEqual([
|
||||
'failed',
|
||||
'active',
|
||||
]);
|
||||
});
|
||||
|
||||
it('fills in a missing name and version from what is there', () => {
|
||||
expect(readMeta({ meta: { id: 'bare', api: 1 }, activate: () => {} })).toEqual({
|
||||
id: 'bare',
|
||||
name: 'bare',
|
||||
version: '',
|
||||
api: 1,
|
||||
});
|
||||
expect(readMeta(null)).toBe('exports no meta');
|
||||
expect(readMeta({ meta: { id: '', api: 1 }, activate: () => {} })).toBe(
|
||||
'meta.id must be a non-empty string',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
class Pane extends Widget {}
|
||||
|
||||
function editorHost(): {
|
||||
state: EditorState;
|
||||
host: PluginHost;
|
||||
tabs: Map<string, { label: string; content: Widget }>;
|
||||
} {
|
||||
const state = new EditorState();
|
||||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||||
const tabs = new Map<string, { label: string; content: Widget }>();
|
||||
const host: PluginHost = {
|
||||
assets: () => state.project.assets,
|
||||
onProjectChange: (listener) => state.on('project', listener),
|
||||
hasTab: (id) => id === 'scene' || tabs.has(id),
|
||||
addTab: (id, label, content) => tabs.set(id, { label, content }),
|
||||
};
|
||||
return { state, host, tabs };
|
||||
}
|
||||
|
||||
describe('the plugin API', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('lists the assets as id, name and type – copies, so a plugin cannot write the model', () => {
|
||||
const { state, host } = editorHost();
|
||||
const create = new CreateAssetCommand('behavior', 'Walker');
|
||||
state.execute(create);
|
||||
const api = createPluginApi('demo', host);
|
||||
const assets = api.project.assets();
|
||||
expect(assets.at(-1)).toEqual({ id: create.asset.id, name: 'Walker', type: 'behavior' });
|
||||
assets.at(-1)!.name = 'Changed';
|
||||
expect(state.project.assets.find((a) => a.id === create.asset.id)?.name).toBe('Walker');
|
||||
expect(Object.isFrozen(api) && Object.isFrozen(api.project) && Object.isFrozen(api.tabs)).toBe(true);
|
||||
});
|
||||
|
||||
it('calls a change listener when the asset list changes, not on every model change', () => {
|
||||
const { state, host } = editorHost();
|
||||
const api = createPluginApi('demo', host);
|
||||
const calls = vi.fn();
|
||||
const off = api.project.on('change', calls);
|
||||
|
||||
const create = new CreateAssetCommand('sprite', 'Crate');
|
||||
state.execute(create);
|
||||
expect(calls).toHaveBeenCalledTimes(1);
|
||||
state.execute(new SetAssetPropertyCommand(create.asset.id, 'width', 42));
|
||||
state.preview();
|
||||
expect(calls).toHaveBeenCalledTimes(1);
|
||||
state.execute(new RenameAssetCommand(create.asset.id, 'Box'));
|
||||
expect(calls).toHaveBeenCalledTimes(2);
|
||||
// Back through rename, width and create: the width step leaves the list as it was.
|
||||
state.undo();
|
||||
state.undo();
|
||||
state.undo();
|
||||
expect(calls).toHaveBeenCalledTimes(4);
|
||||
|
||||
off();
|
||||
state.redo();
|
||||
expect(calls).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('reports a listener that throws and keeps it subscribed', () => {
|
||||
const { state, host } = editorHost();
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const api = createPluginApi('demo', host);
|
||||
let calls = 0;
|
||||
api.project.on('change', () => {
|
||||
calls++;
|
||||
throw new Error('listener broke');
|
||||
});
|
||||
state.execute(new CreateAssetCommand('sprite', 'A'));
|
||||
state.execute(new CreateAssetCommand('sprite', 'B'));
|
||||
expect(calls).toBe(2);
|
||||
expect(error).toHaveBeenCalledTimes(2);
|
||||
expect(String(error.mock.calls[0]?.[0])).toContain('[plugin demo]');
|
||||
});
|
||||
|
||||
it('knows only the "change" event', () => {
|
||||
const api = createPluginApi('demo', editorHost().host);
|
||||
expect(() => api.project.on('save', () => {})).toThrow('Unknown project event "save"');
|
||||
});
|
||||
|
||||
it('adds a tab with the widget its create returns', () => {
|
||||
const { host, tabs } = editorHost();
|
||||
const api = createPluginApi('demo', host);
|
||||
const pane = new Pane();
|
||||
expect(api.tabs.add({ id: 'assets', label: 'Assets', create: () => pane })).toBe(true);
|
||||
expect(tabs.get('assets')).toEqual({ label: 'Assets', content: pane });
|
||||
});
|
||||
|
||||
it('turns away a taken tab id with a warning, before anything is created', () => {
|
||||
const { host, tabs } = editorHost();
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const api = createPluginApi('demo', host);
|
||||
const create = vi.fn(() => new Pane());
|
||||
expect(api.tabs.add({ id: 'scene', label: 'Mine', create })).toBe(false);
|
||||
api.tabs.add({ id: 'assets', label: 'Assets', create });
|
||||
expect(createPluginApi('other', host).tabs.add({ id: 'assets', label: 'Again', create })).toBe(false);
|
||||
expect(create).toHaveBeenCalledTimes(1);
|
||||
expect(tabs.get('assets')?.label).toBe('Assets');
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(String(warn.mock.calls[1]?.[0])).toBe('[plugin other] a tab "assets" exists already – not added');
|
||||
});
|
||||
|
||||
it('throws on a tab without id, label or create, or whose content is not a widget', () => {
|
||||
const { host, tabs } = editorHost();
|
||||
const api = createPluginApi('demo', host);
|
||||
expect(() => api.tabs.add({ id: '', label: 'X', create: () => new Pane() })).toThrow('id must be');
|
||||
expect(() => api.tabs.add({ id: 'x', label: 3, create: () => new Pane() })).toThrow('label must be');
|
||||
expect(() => api.tabs.add({ id: 'x', label: 'X', create: 'no' })).toThrow('create must be a function');
|
||||
expect(() => api.tabs.add({ id: 'x', label: 'X', create: () => ({ w: 10, h: 10 }) })).toThrow(
|
||||
'widget from api.ui',
|
||||
);
|
||||
expect(tabs.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a node board reading plugin input', () => {
|
||||
it('takes titles and positions as given', () => {
|
||||
expect(boardNodes([{ id: 'a', title: 'Crate', x: 10, y: -20 }])).toEqual([
|
||||
{ id: 'a', title: 'Crate', x: 10, y: -20 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('makes do with what plain JavaScript may hand over', () => {
|
||||
expect(boardNodes([{ id: 7, title: 12 }, { x: NaN, y: '5', title: {} }, null])).toEqual([
|
||||
{ id: '7', title: '12', x: 0, y: 0 },
|
||||
{ id: '1', title: '', x: 0, y: 0 },
|
||||
{ id: '2', title: '', x: 0, y: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws on something that is not a list', () => {
|
||||
expect(() => boardNodes({ id: 'a' })).toThrow('expects an array');
|
||||
});
|
||||
});
|
||||
|
||||
describe('zooming a graph view about a point', () => {
|
||||
it('keeps the graph point under the pointer where it was', () => {
|
||||
const view = { pan: { x: 100, y: 50 }, zoom: 1 };
|
||||
const at = { x: 300, y: 250 };
|
||||
const next = zoomedAbout(view, at, 2);
|
||||
expect(next.zoom).toBe(2);
|
||||
// Graph point (200, 200) sat under the pointer before and has to after.
|
||||
expect(200 * next.zoom + next.pan.x).toBe(at.x);
|
||||
expect(200 * next.zoom + next.pan.y).toBe(at.y);
|
||||
});
|
||||
|
||||
it('stops at the zoom limits', () => {
|
||||
expect(zoomedAbout({ pan: { x: 0, y: 0 }, zoom: 2 }, { x: 0, y: 0 }, 10).zoom).toBe(2.5);
|
||||
expect(zoomedAbout({ pan: { x: 0, y: 0 }, zoom: 0.3 }, { x: 0, y: 0 }, 0.1).zoom).toBe(0.25);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user