/** * 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 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; } { 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 }), }; 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); }); });