const { describe, it } = require('node:test'); const assert = require('node:assert'); const { createBrowserContext, loadSource } = require('./helpers'); const vm = require('vm'); /** * Load Events + Extensions into a browser context. * Returns an object with Events, Extensions, and the sandbox. * Note: const declarations in vm contexts aren't sandbox properties, * so we extract them via vm.runInContext. */ function loadExtensions(overrides = {}) { const sandbox = createBrowserContext(overrides); loadSource(sandbox, 'events.js'); loadSource(sandbox, 'extensions.js'); // Extract const globals from the vm context const ctx = vm.createContext(sandbox); // idempotent on already-contextified sandbox return { Extensions: vm.runInContext('Extensions', ctx), Events: vm.runInContext('Events', ctx), sandbox, }; } // ── Registration ───────────────────────────── describe('Extensions.register', () => { it('registers an extension by id', () => { const ctx = loadExtensions(); ctx.Extensions.register({ id: 'test-ext', init() {} }); assert.ok(ctx.Extensions._registry.has('test-ext')); }); it('rejects duplicate registrations', () => { const ctx = loadExtensions(); ctx.Extensions.register({ id: 'dup', init() {} }); ctx.Extensions.register({ id: 'dup', init() {} }); assert.strictEqual(ctx.Extensions._registry.size, 1); }); it('rejects registration without id', () => { const ctx = loadExtensions(); ctx.Extensions.register({ init() {} }); assert.strictEqual(ctx.Extensions._registry.size, 0); }); }); // ── Lifecycle ──────────────────────────────── describe('Extensions.initAll', () => { it('calls init on registered extensions', async () => { const ctx = loadExtensions(); let initialized = false; ctx.Extensions.register({ id: 'lifecycle-test', init(extCtx) { initialized = true; }, }); await ctx.Extensions.initAll(); assert.ok(initialized); }); it('marks extensions as active after init', async () => { const ctx = loadExtensions(); ctx.Extensions.register({ id: 'active-test', init() {} }); await ctx.Extensions.initAll(); const entry = ctx.Extensions._registry.get('active-test'); assert.strictEqual(entry.active, true); }); it('survives init errors without crashing', async () => { const ctx = loadExtensions(); ctx.Extensions.register({ id: 'bad-ext', init() { throw new Error('init failed'); }, }); ctx.Extensions.register({ id: 'good-ext', init() {}, }); await ctx.Extensions.initAll(); assert.strictEqual(ctx.Extensions._registry.get('bad-ext').active, false); assert.strictEqual(ctx.Extensions._registry.get('good-ext').active, true); }); }); describe('Extensions.destroyAll', () => { it('calls destroy and deactivates extensions', async () => { const ctx = loadExtensions(); let destroyed = false; ctx.Extensions.register({ id: 'destroy-test', init() {}, destroy() { destroyed = true; }, }); await ctx.Extensions.initAll(); ctx.Extensions.destroyAll(); assert.ok(destroyed); assert.strictEqual(ctx.Extensions._registry.get('destroy-test').active, false); }); }); // ── Context Object ────────────────────────── describe('Extension context', () => { it('provides scoped events', async () => { const ctx = loadExtensions(); let receivedPayload = null; ctx.Extensions.register({ id: 'events-test', init(extCtx) { extCtx.events.on('test.event', (payload) => { receivedPayload = payload; }); }, }); await ctx.Extensions.initAll(); ctx.Events.emit('test.event', { data: 42 }, { localOnly: true }); assert.deepStrictEqual(receivedPayload, { data: 42 }); }); it('provides scoped storage', async () => { const ctx = loadExtensions(); let storage = null; ctx.Extensions.register({ id: 'storage-test', init(extCtx) { extCtx.storage.set('key1', { foo: 'bar' }); storage = extCtx.storage.get('key1'); }, }); await ctx.Extensions.initAll(); assert.strictEqual(storage.foo, 'bar'); // Verify namespacing assert.ok(ctx.sandbox.localStorage._data['ext::storage-test::key1']); }); it('provides frozen settings from defaults', async () => { const ctx = loadExtensions(); let settings = null; ctx.Extensions.register({ id: 'settings-test', manifest: { settings: { showInline: { type: 'boolean', default: true }, limit: { type: 'number', default: 100 }, }, }, init(extCtx) { settings = extCtx.settings; }, }); await ctx.Extensions.initAll(); assert.strictEqual(settings.showInline, true); assert.strictEqual(settings.limit, 100); // Frozen — assignment silently ignored in sloppy mode settings.showInline = false; assert.strictEqual(settings.showInline, true, 'frozen settings should not change'); }); }); // ── Tool Registration ─────────────────────── describe('ctx.tools.handle', () => { it('registers a tool handler', async () => { const ctx = loadExtensions(); ctx.Extensions.register({ id: 'tool-test', init(extCtx) { extCtx.tools.handle('my_tool', async (args) => { return { result: args.input * 2 }; }); }, }); await ctx.Extensions.initAll(); assert.ok(ctx.Extensions._toolHandlers.has('my_tool')); }); }); // ── Tool Bridge ───────────────────────────── describe('Tool bridge', () => { it('routes tool.call.* to registered handler and emits result', async () => { const ctx = loadExtensions(); let resultEmitted = null; ctx.Extensions.register({ id: 'bridge-test', init(extCtx) { extCtx.tools.handle('double', async (args) => { return { value: args.n * 2 }; }); }, }); await ctx.Extensions.initAll(); // Listen for the result event ctx.Events.on('tool.result.call-123', (payload) => { resultEmitted = payload; }); // Simulate server sending tool.call ctx.Events.emit('tool.call.call-123', { call_id: 'call-123', tool: 'double', arguments: { n: 21 }, }, { localOnly: true }); // Allow async handler to complete await new Promise(r => setTimeout(r, 10)); assert.ok(resultEmitted, 'result event should have been emitted'); assert.strictEqual(resultEmitted.call_id, 'call-123'); const parsed = JSON.parse(resultEmitted.result); assert.strictEqual(parsed.value, 42); }); it('returns error for unknown tool', async () => { const ctx = loadExtensions(); let resultEmitted = null; await ctx.Extensions.initAll(); ctx.Events.on('tool.result.call-unknown', (payload) => { resultEmitted = payload; }); ctx.Events.emit('tool.call.call-unknown', { call_id: 'call-unknown', tool: 'nonexistent_tool', arguments: {}, }, { localOnly: true }); await new Promise(r => setTimeout(r, 10)); assert.ok(resultEmitted); assert.ok(resultEmitted.error, 'should have error field'); }); it('returns error when handler throws', async () => { const ctx = loadExtensions(); let resultEmitted = null; ctx.Extensions.register({ id: 'error-tool', init(extCtx) { extCtx.tools.handle('fail_tool', async () => { throw new Error('something broke'); }); }, }); await ctx.Extensions.initAll(); ctx.Events.on('tool.result.call-fail', (payload) => { resultEmitted = payload; }); ctx.Events.emit('tool.call.call-fail', { call_id: 'call-fail', tool: 'fail_tool', arguments: {}, }, { localOnly: true }); await new Promise(r => setTimeout(r, 10)); assert.ok(resultEmitted); assert.ok(resultEmitted.error); const parsed = JSON.parse(resultEmitted.error); assert.strictEqual(parsed.error, 'something broke'); }); }); // ── Renderer Pipeline ─────────────────────── describe('Renderer pipeline', () => { it('registers and runs block renderers', async () => { const ctx = loadExtensions(); let rendered = false; ctx.Extensions.register({ id: 'renderer-test', init(extCtx) { extCtx.renderers.register('test-renderer', { type: 'block', pattern: 'mermaid', render(lang, code, container) { rendered = true; }, }); }, }); await ctx.Extensions.initAll(); const handled = ctx.Extensions.runBlockRenderers('mermaid', 'graph LR; A-->B', {}); assert.ok(handled); assert.ok(rendered); }); it('skips non-matching block renderers', async () => { const ctx = loadExtensions(); ctx.Extensions.register({ id: 'skip-test', init(extCtx) { extCtx.renderers.register('python-only', { type: 'block', pattern: 'python', render() {}, }); }, }); await ctx.Extensions.initAll(); const handled = ctx.Extensions.runBlockRenderers('javascript', 'code', {}); assert.strictEqual(handled, false); }); it('runs post renderers on container', async () => { const ctx = loadExtensions(); let postRan = false; ctx.Extensions.register({ id: 'post-test', init(extCtx) { extCtx.renderers.register('post-proc', { type: 'post', render(container) { postRan = true; }, }); }, }); await ctx.Extensions.initAll(); ctx.Extensions.runPostRenderers({}); assert.ok(postRan); }); it('respects renderer priority ordering', async () => { const ctx = loadExtensions(); const order = []; ctx.Extensions.register({ id: 'priority-test', init(extCtx) { extCtx.renderers.register('low', { type: 'post', priority: 90, render() { order.push('low'); }, }); extCtx.renderers.register('high', { type: 'post', priority: 10, render() { order.push('high'); }, }); }, }); await ctx.Extensions.initAll(); ctx.Extensions.runPostRenderers({}); assert.deepStrictEqual(order, ['high', 'low']); }); }); // ── Debug ──────────────────────────────────── describe('Extensions.debug', () => { it('returns extension info and renderer count', async () => { const ctx = loadExtensions(); ctx.Extensions.register({ id: 'debug-ext', init(extCtx) { extCtx.renderers.register('r1', { type: 'post', render() {} }); }, }); await ctx.Extensions.initAll(); const info = ctx.Extensions.debug(); assert.ok(info.extensions['debug-ext']); assert.strictEqual(info.extensions['debug-ext'].active, true); assert.strictEqual(info.rendererCount, 1); }); }); // ── Mermaid Extension ───────────────────────────── describe('Mermaid renderer extension', () => { function loadWithMermaid() { const ctx = loadExtensions(); // Load the mermaid extension script into the same VM context const fs = require('fs'); const path = require('path'); // The script lives alongside the test as a fixture, or in the repo root // For CI, we inline the registration to avoid path issues ctx.Extensions.register({ id: 'mermaid-renderer', _mermaidReady: false, _mermaidLoading: null, _escapeHtml(str) { return str.replace(/&/g, '&').replace(//g, '>'); }, init(extCtx) { const self = this; extCtx.renderers.register('mermaid', { type: 'block', pattern: 'mermaid', priority: 10, render(lang, code, container) { const id = 'mmd-test'; container.innerHTML = `
${self._escapeHtml(code.trim())}