/** * Plugins (#113, #114): what the loader does with every way a file can fail to be a plugin, what * the API v1 lets a plugin see and do, and what switching one on and off in the Plugins tab does. * 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 { PluginManager, type PluginRecord, PluginScope, readMeta } from '../../src/editor/plugins/loader'; import { boardNodes } from '../../src/editor/plugins/NodeBoard'; import { displayName, statusText } from '../../src/editor/plugins/PluginsPane'; import { createPluginApi, type PluginHost, scopedHost } 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, }); /** * 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. */ function loaderDeps(modules: Record unknown>, off: string[] = []) { const reports: string[] = []; const activated: string[] = []; const stored: [string, boolean][] = []; const loads: [string, number][] = []; const deps = { load: (file: string, updatedAt: number) => { loads.push([file, updatedAt]); 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 }; }, store: (file: string, active: boolean) => { stored.push([file, active]); return Promise.resolve(); }, 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) }))); return { reports, activated, stored, loads, deps, manager, start }; } const byFileOf = (records: readonly PluginRecord[]) => Object.fromEntries(records.map((r) => [r.file, r])) as Record; describe('the plugin loader', () => { it('activates in file-name order, whatever order the list came in', async () => { const order: string[] = []; const { manager, start } = loaderDeps({ 'b.js': () => plugin('b', () => order.push('b')), 'a.js': () => plugin('a', () => order.push('a')), }); await start(['b.js', 'a.js']); expect(order).toEqual(['a', 'b']); expect(manager.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 { start } = 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 start(); expect(seen).toEqual([{ for: 'a' }, { for: 'b' }, true]); }); it('reports each broken plugin and goes on with the rest', async () => { const { manager, start, 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'); }), }); await start(['missing.js', 'no-meta.js', 'no-activate.js', 'future.js', 'throws.js', 'good.js']); const byFile = byFileOf(manager.records); 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 { manager, start, activated } = loaderDeps({ 'a.js': () => plugin('same'), 'b.js': () => plugin('same'), }); await start(); expect(manager.records.map((r) => r.status)).toEqual(['active', 'failed']); expect(manager.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 { manager, start } = loaderDeps({ 'a.js': () => plugin('same', () => { throw new Error('no'); }), 'b.js': () => plugin('same'), }); await start(); expect(manager.records.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({ meta: { id: 'x', api: 1 }, activate: () => {}, deactivate: 'no' })).toBe( 'deactivate must be a function', ); 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; } { const state = new EditorState(); state.loadProject(createEmptyProject('p', 'P'), 1); const tabs = new Map(); 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 }), removeTab: (id) => void tabs.delete(id), }; 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); }); }); /** * 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 * 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. */ function switchable(modules: Record unknown>, off: string[] = []) { const { state, host, tabs } = editorHost(); const reports: string[] = []; const stored: [string, boolean][] = []; const loads: [string, number][] = []; let refuse = false; const manager = new PluginManager({ load: (file, updatedAt) => { loads.push([file, updatedAt]); const make = modules[file]; return make ? Promise.resolve(make()) : Promise.reject(new Error('404')); }, api: (meta, scope) => createPluginApi(meta.id, scopedHost(host, scope)), store: (file, active) => { if (refuse) return Promise.reject(new Error('Server error')); stored.push([file, active]); return Promise.resolve(); }, report: (message) => reports.push(message), }); const start = () => manager.start( Object.keys(modules).map((file, i) => ({ file, updatedAt: i + 1, active: !off.includes(file) })), ); return { state, tabs, manager, start, reports, stored, loads, refuse: (no: boolean) => (refuse = no) }; } /** A plugin that puts a tab and a change listener in place, and says when it was let go. */ const tabPlugin = (id: string, log: string[] = []) => ({ meta: { id, name: `Plugin ${id}`, version: '2.0', api: 1 }, activate: (api: ReturnType) => { api.project.on('change', () => log.push(`${id}: change`)); api.tabs.add({ id, label: id, create: () => new Pane() }); }, deactivate: () => log.push(`${id}: deactivate`), }); it('loads only what is switched on – a plugin that is off is never even fetched', async () => { const { manager, start, loads } = switchable( { 'on.js': () => plugin('on'), 'off.js': () => plugin('off') }, ['off.js'], ); await start(); expect(loads.map(([file]) => file)).toEqual(['on.js']); expect(manager.records.map((r) => [r.file, r.status, r.meta?.name])).toEqual([ ['off.js', 'inactive', undefined], ['on.js', 'active', 'Plugin on'], ]); }); it('stores the switch 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', ]); await start(); expect(tabs.size).toBe(0); await manager.setActive('demo.js', true); expect(stored).toEqual([['demo.js', true]]); // 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('signing out stops every plugin without touching the switches on the server', async () => { const log: string[] = []; const { manager, start, state, tabs, stored } = switchable({ 'a.js': () => tabPlugin('a', log), 'b.js': () => tabPlugin('b', log), 'off.js': () => tabPlugin('off', log), }); await start(); expect(tabs.size).toBe(3); 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). expect(stored).toEqual([]); state.execute(new CreateAssetCommand('sprite', 'Crate')); expect(log).toEqual(['a: deactivate', 'b: deactivate', 'off: deactivate']); }); it('switching off runs deactivate and takes the tab and the listeners with it', async () => { const log: string[] = []; const { manager, start, state, tabs, stored } = switchable({ 'demo.js': () => tabPlugin('demo', log) }); await start(); state.execute(new CreateAssetCommand('sprite', 'Crate')); expect(log).toEqual(['demo: change']); await manager.setActive('demo.js', false); expect(log).toEqual(['demo: change', 'demo: deactivate']); expect(tabs.size).toBe(0); expect(stored).toEqual([['demo.js', false]]); expect(manager.records[0]).toMatchObject({ status: 'inactive' }); // Nothing of it runs any more, while the name it had stays on the row. state.execute(new CreateAssetCommand('sprite', 'Box')); expect(log).toEqual(['demo: change', 'demo: deactivate']); expect(manager.records[0]?.meta?.name).toBe('Plugin demo'); await manager.setActive('demo.js', true); state.execute(new CreateAssetCommand('sprite', 'Barrel')); expect(log.at(-1)).toBe('demo: change'); 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') }); await start(); refuse(true); await manager.setActive('demo.js', false); expect(manager.records[0]?.status).toBe('active'); expect(tabs.has('demo')).toBe(true); expect(reports).toEqual(['Plugin demo.js not switched off: Server error']); }); it('takes back what a plugin registered before its activate threw', async () => { const { manager, start, tabs, reports } = switchable({ 'half.js': () => ({ meta: { id: 'half', name: 'Half', version: '1', api: 1 }, activate: (api: ReturnType) => { api.tabs.add({ id: 'half', label: 'Half', create: () => new Pane() }); throw new Error('boom'); }, }), }); await start(); expect(manager.records[0]).toMatchObject({ status: 'failed', error: 'activate failed (boom)' }); expect(tabs.size).toBe(0); expect(reports).toEqual(['Plugin half.js not activated: activate failed (boom)']); // Switched off it is simply off: nothing left to tidy, nothing to report, and the name it // managed to give stays on its row. await manager.setActive('half.js', false); expect(manager.records[0]).toEqual({ file: 'half.js', status: 'inactive', meta: { id: 'half', name: 'Half', version: '1', api: 1 }, }); }); it('switches off even when the plugin throws on the way out, and says so', async () => { const { manager, start, tabs, reports } = switchable({ 'rude.js': () => ({ meta: { id: 'rude', name: 'Rude', version: '1', api: 1 }, activate: (api: ReturnType) => api.tabs.add({ id: 'rude', label: 'Rude', create: () => new Pane() }), deactivate: () => { throw new Error('not going'); }, }), }); await start(); await manager.setActive('rude.js', false); expect(manager.records[0]).toMatchObject({ status: 'inactive', error: 'deactivate failed (not going)', }); expect(tabs.size).toBe(0); expect(reports).toEqual(['Plugin rude.js switched off, but: deactivate failed (not going)']); }); it('runs switches one at a time, in the order they were asked for', async () => { const log: string[] = []; const { manager, start, stored } = switchable({ 'demo.js': () => tabPlugin('demo', log) }); await start(); await Promise.all([ manager.setActive('demo.js', false), manager.setActive('demo.js', true), manager.setActive('demo.js', false), ]); expect(stored).toEqual([ ['demo.js', false], ['demo.js', true], ['demo.js', false], ]); expect(manager.records[0]?.status).toBe('inactive'); }); it('tells its listeners after every change', async () => { const { manager, start } = switchable({ 'demo.js': () => tabPlugin('demo') }); const seen = vi.fn(); const off = manager.onChange(seen); await start(); expect(seen).toHaveBeenCalledTimes(1); await manager.setActive('demo.js', false); expect(seen).toHaveBeenCalledTimes(2); off(); await manager.setActive('demo.js', true); expect(seen).toHaveBeenCalledTimes(2); }); it('runs a scope’s cleanups newest first, all of them even when one throws', () => { const scope = new PluginScope(); const order: string[] = []; scope.add(() => order.push('first')); scope.add(() => { throw new Error('middle broke'); }); scope.add(() => order.push('last')); expect(scope.dispose()).toEqual(['middle broke']); expect(order).toEqual(['last', 'first']); // Disposed twice (switched off, then the editor tidies up) runs nothing a second time. expect(scope.dispose()).toEqual([]); }); it('says on the row what became of each plugin', () => { const row = (record: Partial): PluginRecord => ({ file: 'x.js', status: 'inactive', meta: null, ...record, }); expect(statusText(row({ status: 'active' }))).toBe('Active'); expect(statusText(row({}))).toBe('Off'); expect(statusText(row({ status: 'failed', error: 'does not load (404)' }))).toBe( 'Failed: does not load (404)', ); expect(statusText(row({ error: 'deactivate failed (x)' }))).toBe( 'Switched off, but: deactivate failed (x)', ); expect(displayName(row({}))).toBe('—'); expect(displayName(row({ meta: { id: 'demo', name: 'Demo plugin', version: '1', api: 1 } }))).toBe( 'Demo plugin', ); }); }); 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); }); });