From c9d8e9457eaebdf9f182b6bec128ab47c602b41e Mon Sep 17 00:00:00 2001 From: xcaliber Date: Wed, 25 Feb 2026 13:29:15 +0000 Subject: [PATCH] Changeset 0.11.0 (#62) --- .gitea/workflows/ci.yaml | 2 +- ARCHITECTURE.md | 2 +- CHANGELOG.md | 86 +++ Dockerfile | 21 + Dockerfile.frontend | 11 +- EXTENSIONS.md | 4 +- README.md | 43 +- ROADMAP.md | 102 +-- VERSION | 2 +- extensions/builtin/csv-table/manifest.json | 12 + extensions/builtin/csv-table/script.js | 254 +++++++ extensions/builtin/diff-viewer/manifest.json | 12 + extensions/builtin/diff-viewer/script.js | 166 +++++ extensions/builtin/js-sandbox/manifest.json | 27 + extensions/builtin/js-sandbox/script.js | 202 ++++++ .../builtin/katex-renderer/manifest.json | 12 + extensions/builtin/katex-renderer/script.js | 297 ++++++++ .../builtin/mermaid-renderer/manifest.json | 12 + extensions/builtin/mermaid-renderer/script.js | 209 ++++++ extensions/builtin/regex-tester/manifest.json | 36 + extensions/builtin/regex-tester/script.js | 102 +++ extensions/mermaid-renderer/manifest.json | 12 + extensions/mermaid-renderer/script.js | 507 ++++++++++++++ k8s/ingress.yaml | 7 +- server/Dockerfile | 9 +- server/database/migrations/006_extensions.sql | 41 ++ server/events/bus.go | 26 + server/events/bus_test.go | 88 +++ server/events/types.go | 8 + server/events/ws.go | 36 + server/handlers/completion.go | 58 +- server/handlers/extensions.go | 346 ++++++++++ server/handlers/integration_test.go | 2 +- server/handlers/messages.go | 15 +- server/handlers/seed_extensions.go | 178 +++++ server/handlers/stream_loop.go | 91 ++- server/main.go | 26 +- server/models/models.go | 43 ++ server/store/interfaces.go | 24 + server/store/postgres/extension.go | 194 ++++++ server/store/postgres/stores.go | 1 + server/tools/calculator.go | 384 +++++++++++ server/tools/datetime.go | 64 ++ src/css/styles.css | 13 + src/index.html | 30 + src/js/__tests__/extensions-builtin.test.js | 635 ++++++++++++++++++ src/js/__tests__/extensions.test.js | 491 ++++++++++++++ src/js/admin-handlers.js | 167 +++++ src/js/app.js | 10 + src/js/debug.js | 80 +++ src/js/events.js | 4 +- src/js/extensions.js | 404 +++++++++++ src/js/notes.js | 5 +- src/js/ui-admin.js | 52 +- src/js/ui-core.js | 1 + src/js/ui-format.js | 89 ++- 56 files changed, 5664 insertions(+), 91 deletions(-) create mode 100644 extensions/builtin/csv-table/manifest.json create mode 100644 extensions/builtin/csv-table/script.js create mode 100644 extensions/builtin/diff-viewer/manifest.json create mode 100644 extensions/builtin/diff-viewer/script.js create mode 100644 extensions/builtin/js-sandbox/manifest.json create mode 100644 extensions/builtin/js-sandbox/script.js create mode 100644 extensions/builtin/katex-renderer/manifest.json create mode 100644 extensions/builtin/katex-renderer/script.js create mode 100644 extensions/builtin/mermaid-renderer/manifest.json create mode 100644 extensions/builtin/mermaid-renderer/script.js create mode 100644 extensions/builtin/regex-tester/manifest.json create mode 100644 extensions/builtin/regex-tester/script.js create mode 100644 extensions/mermaid-renderer/manifest.json create mode 100644 extensions/mermaid-renderer/script.js create mode 100644 server/database/migrations/006_extensions.sql create mode 100644 server/handlers/extensions.go create mode 100644 server/handlers/seed_extensions.go create mode 100644 server/store/postgres/extension.go create mode 100644 server/tools/calculator.go create mode 100644 server/tools/datetime.go create mode 100644 src/js/__tests__/extensions-builtin.test.js create mode 100644 src/js/__tests__/extensions.test.js create mode 100644 src/js/extensions.js diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 76f516f..7c916ad 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -369,7 +369,7 @@ jobs: run: | docker build -f server/Dockerfile \ -t ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} \ - ./server + . # ── Build Frontend Image ───────────────────── - name: Build frontend image diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e29b038..27fb5f9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,4 +1,4 @@ -# Architecture — Chat Switchboard v0.9 +# Architecture — Chat Switchboard v0.11 ## Deployment Modes diff --git a/CHANGELOG.md b/CHANGELOG.md index fac392d..e001ec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,92 @@ All notable changes to Chat Switchboard. +## [0.11.0] — 2026-02-25 + +### Added +- **Browser extension system.** Full lifecycle: manifest registration, script + injection, scoped context (`ctx.renderers`, `ctx.tools`, `ctx.events`, + `ctx.storage`, `ctx.ui`), permission-aware API proxying. Extensions self- + register via `Extensions.register()` and receive isolated contexts during + `initAll()`. Admin CRUD endpoints, asset serving (public, no auth needed + for ` + diff --git a/src/js/__tests__/extensions-builtin.test.js b/src/js/__tests__/extensions-builtin.test.js new file mode 100644 index 0000000..6ab8cac --- /dev/null +++ b/src/js/__tests__/extensions-builtin.test.js @@ -0,0 +1,635 @@ +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. + */ +function loadExtensions(overrides = {}) { + const sandbox = createBrowserContext(overrides); + loadSource(sandbox, 'events.js'); + loadSource(sandbox, 'extensions.js'); + const ctx = vm.createContext(sandbox); + return { + Extensions: vm.runInContext('Extensions', ctx), + Events: vm.runInContext('Events', ctx), + sandbox, + }; +} + +// ═══════════════════════════════════════════ +// KaTeX Renderer Extension +// ═══════════════════════════════════════════ + +describe('KaTeX renderer extension', () => { + function loadWithKaTeX() { + const ctx = loadExtensions(); + ctx.Extensions.register({ + id: 'katex-renderer', + _katexReady: false, + _katexLoading: null, + _escapeHtml(str) { + return str.replace(/&/g, '&').replace(//g, '>'); + }, + init(extCtx) { + const self = this; + extCtx.renderers.register('katex-block', { + type: 'block', + priority: 10, + match(lang) { + const l = (lang || '').toLowerCase(); + return l === 'latex' || l === 'math' || l === 'tex' || l === 'katex'; + }, + render(lang, code, container) { + container.innerHTML = ` +
+
+
Rendering math…
+
+
+ View source +
${self._escapeHtml(code.trim())}
+
+
+ `; + } + }); + extCtx.renderers.register('katex-post', { + type: 'post', + priority: 10, + render(container) { + container._katexPostRan = true; + } + }); + }, + destroy() {} + }); + return ctx; + } + + it('registers both block and post renderers', async () => { + const ctx = loadWithKaTeX(); + await ctx.Extensions.initAll(); + const info = ctx.Extensions.debug(); + assert.strictEqual(info.extensions['katex-renderer'].active, true); + assert.strictEqual(info.extensions['katex-renderer'].renderers.length, 2); + }); + + it('matches latex, math, tex, and katex languages', async () => { + const ctx = loadWithKaTeX(); + await ctx.Extensions.initAll(); + + for (const lang of ['latex', 'math', 'tex', 'katex']) { + const container = { innerHTML: '' }; + const handled = ctx.Extensions.runBlockRenderers(lang, 'E = mc^2', container); + assert.ok(handled, `should match language: ${lang}`); + assert.ok(container.innerHTML.includes('katex-block'), `should render katex block for: ${lang}`); + } + }); + + it('does not match other languages', async () => { + const ctx = loadWithKaTeX(); + await ctx.Extensions.initAll(); + + for (const lang of ['python', 'javascript', 'mermaid', 'csv']) { + const container = { innerHTML: '' }; + const handled = ctx.Extensions.runBlockRenderers(lang, 'code', container); + assert.strictEqual(handled, false, `should not match language: ${lang}`); + } + }); + + it('includes encoded source in data attribute', async () => { + const ctx = loadWithKaTeX(); + await ctx.Extensions.initAll(); + + const container = { innerHTML: '' }; + ctx.Extensions.runBlockRenderers('latex', '\\frac{a}{b}', container); + assert.ok(container.innerHTML.includes(encodeURIComponent('\\frac{a}{b}'))); + }); + + it('escapes HTML in source view', async () => { + const ctx = loadWithKaTeX(); + await ctx.Extensions.initAll(); + + const container = { innerHTML: '' }; + ctx.Extensions.runBlockRenderers('math', '', container); + assert.ok(container.innerHTML.includes('<script>')); + assert.ok(!container.innerHTML.includes('', container); + assert.ok(container.innerHTML.includes('<script>')); + }); +}); + +// ═══════════════════════════════════════════ +// JS Sandbox Extension (Tool) +// ═══════════════════════════════════════════ + +describe('JS Sandbox extension', () => { + it('registers js_eval tool handler', async () => { + const ctx = loadExtensions(); + ctx.Extensions.register({ + id: 'js-sandbox', + init(extCtx) { + extCtx.tools.handle('js_eval', async (args) => { + return { success: true, result: 'mock' }; + }); + }, + destroy() {} + }); + await ctx.Extensions.initAll(); + assert.ok(ctx.Extensions._toolHandlers.has('js_eval')); + }); + + it('routes tool calls through the bridge', async () => { + const ctx = loadExtensions(); + let receivedCode = null; + + ctx.Extensions.register({ + id: 'js-sandbox', + init(extCtx) { + extCtx.tools.handle('js_eval', async (args) => { + receivedCode = args.code; + return { success: true, result: '42', output: [] }; + }); + }, + destroy() {} + }); + await ctx.Extensions.initAll(); + + let resultEmitted = null; + ctx.Events.on('tool.result.eval-1', (payload) => { + resultEmitted = payload; + }); + + ctx.Events.emit('tool.call.eval-1', { + call_id: 'eval-1', + tool: 'js_eval', + arguments: { code: '21 * 2' }, + }, { localOnly: true }); + + await new Promise(r => setTimeout(r, 10)); + + assert.strictEqual(receivedCode, '21 * 2'); + assert.ok(resultEmitted); + const parsed = JSON.parse(resultEmitted.result); + assert.strictEqual(parsed.success, true); + assert.strictEqual(parsed.result, '42'); + }); +}); + +// ═══════════════════════════════════════════ +// Regex Tester Extension (Tool) +// ═══════════════════════════════════════════ + +describe('Regex Tester extension', () => { + function loadWithRegex() { + const ctx = loadExtensions(); + ctx.Extensions.register({ + id: 'regex-tester', + _formatMatch(match) { + const result = { + fullMatch: match[0], + index: match.index, + length: match[0].length, + }; + if (match.groups) { + result.namedGroups = {}; + for (const [name, value] of Object.entries(match.groups)) { + result.namedGroups[name] = value; + } + } + if (match.length > 1) { + result.groups = []; + for (let i = 1; i < match.length; i++) { + result.groups.push(match[i] !== undefined ? match[i] : null); + } + } + return result; + }, + _test(pattern, flags, inputs) { + let regex; + try { + regex = new RegExp(pattern, flags); + } catch (e) { + return { success: false, error: `Invalid regex: ${e.message}`, pattern, flags }; + } + const results = inputs.map(input => { + regex.lastIndex = 0; + const isGlobal = regex.global; + const matches = []; + if (isGlobal) { + let match; + while ((match = regex.exec(input)) !== null) { + matches.push(this._formatMatch(match)); + if (match[0].length === 0) regex.lastIndex++; + } + } else { + const match = regex.exec(input); + if (match) matches.push(this._formatMatch(match)); + } + return { input, matched: matches.length > 0, matchCount: matches.length, matches }; + }); + return { + success: true, pattern, flags, + summary: `${results.filter(r => r.matched).length}/${inputs.length} inputs matched`, + results, + }; + }, + init(extCtx) { + const self = this; + extCtx.tools.handle('regex_test', async (args) => { + return self._test(args.pattern, args.flags || '', args.inputs || []); + }); + }, + destroy() {} + }); + return ctx; + } + + it('registers regex_test tool handler', async () => { + const ctx = loadWithRegex(); + await ctx.Extensions.initAll(); + assert.ok(ctx.Extensions._toolHandlers.has('regex_test')); + }); + + it('matches simple patterns', async () => { + const ctx = loadWithRegex(); + await ctx.Extensions.initAll(); + const ext = ctx.Extensions._registry.get('regex-tester').def; + + const result = ext._test('^\\d+$', '', ['123', 'abc', '456']); + assert.strictEqual(result.success, true); + assert.strictEqual(result.summary, '2/3 inputs matched'); + assert.strictEqual(result.results[0].matched, true); + assert.strictEqual(result.results[1].matched, false); + assert.strictEqual(result.results[2].matched, true); + }); + + it('captures groups', async () => { + const ctx = loadWithRegex(); + await ctx.Extensions.initAll(); + const ext = ctx.Extensions._registry.get('regex-tester').def; + + const result = ext._test('(\\d{3})-(\\d{4})', '', ['555-1234']); + assert.strictEqual(result.success, true); + assert.strictEqual(result.results[0].matches[0].fullMatch, '555-1234'); + assert.deepStrictEqual(result.results[0].matches[0].groups, ['555', '1234']); + }); + + it('handles global flag with multiple matches', async () => { + const ctx = loadWithRegex(); + await ctx.Extensions.initAll(); + const ext = ctx.Extensions._registry.get('regex-tester').def; + + const result = ext._test('\\d+', 'g', ['a1b2c3']); + assert.strictEqual(result.results[0].matchCount, 3); + assert.strictEqual(result.results[0].matches[0].fullMatch, '1'); + assert.strictEqual(result.results[0].matches[1].fullMatch, '2'); + assert.strictEqual(result.results[0].matches[2].fullMatch, '3'); + }); + + it('returns error for invalid patterns', async () => { + const ctx = loadWithRegex(); + await ctx.Extensions.initAll(); + const ext = ctx.Extensions._registry.get('regex-tester').def; + + const result = ext._test('[invalid', '', ['test']); + assert.strictEqual(result.success, false); + assert.ok(result.error.includes('Invalid regex')); + }); + + it('handles named groups', async () => { + const ctx = loadWithRegex(); + await ctx.Extensions.initAll(); + const ext = ctx.Extensions._registry.get('regex-tester').def; + + const result = ext._test('(?\\d{4})-(?\\d{2})', '', ['2025-02']); + assert.strictEqual(result.results[0].matches[0].namedGroups.year, '2025'); + assert.strictEqual(result.results[0].matches[0].namedGroups.month, '02'); + }); + + it('routes through tool bridge', async () => { + const ctx = loadWithRegex(); + await ctx.Extensions.initAll(); + + let resultEmitted = null; + ctx.Events.on('tool.result.regex-1', (payload) => { + resultEmitted = payload; + }); + + ctx.Events.emit('tool.call.regex-1', { + call_id: 'regex-1', + tool: 'regex_test', + arguments: { pattern: '^hello', flags: 'i', inputs: ['Hello world', 'goodbye'] }, + }, { localOnly: true }); + + await new Promise(r => setTimeout(r, 10)); + + assert.ok(resultEmitted); + const parsed = JSON.parse(resultEmitted.result); + assert.strictEqual(parsed.success, true); + assert.strictEqual(parsed.summary, '1/2 inputs matched'); + }); +}); diff --git a/src/js/__tests__/extensions.test.js b/src/js/__tests__/extensions.test.js new file mode 100644 index 0000000..6f75564 --- /dev/null +++ b/src/js/__tests__/extensions.test.js @@ -0,0 +1,491 @@ +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 = ` +
+
+
Rendering diagram…
+
+
+ View source +
${self._escapeHtml(code.trim())}
+
+
+ `; + } + }); + extCtx.renderers.register('mermaid-post', { + type: 'post', + priority: 10, + render(container) { + // In test, just mark that post-render ran + container._mermaidPostRan = true; + } + }); + }, + destroy() {} + }); + return ctx; + } + + it('registers both block and post renderers', async () => { + const ctx = loadWithMermaid(); + await ctx.Extensions.initAll(); + const info = ctx.Extensions.debug(); + assert.strictEqual(info.extensions['mermaid-renderer'].active, true); + const renderers = info.extensions['mermaid-renderer'].renderers; + assert.strictEqual(renderers.length, 2); + assert.strictEqual(renderers[0], 'mermaid'); + assert.strictEqual(renderers[1], 'mermaid-post'); + assert.strictEqual(info.rendererCount, 2); + }); + + it('block renderer matches mermaid language', async () => { + const ctx = loadWithMermaid(); + await ctx.Extensions.initAll(); + + const container = { innerHTML: '' }; + const handled = ctx.Extensions.runBlockRenderers('mermaid', 'graph LR; A-->B', container); + assert.ok(handled); + assert.ok(container.innerHTML.includes('mermaid-block')); + assert.ok(container.innerHTML.includes('mermaid-diagram')); + assert.ok(container.innerHTML.includes(encodeURIComponent('graph LR; A-->B'))); + }); + + it('block renderer does not match other languages', async () => { + const ctx = loadWithMermaid(); + await ctx.Extensions.initAll(); + + const container = { innerHTML: '' }; + const handled = ctx.Extensions.runBlockRenderers('javascript', 'const x = 1;', container); + assert.strictEqual(handled, false); + assert.strictEqual(container.innerHTML, ''); + }); + + it('block renderer includes source in details element', async () => { + const ctx = loadWithMermaid(); + await ctx.Extensions.initAll(); + + const container = { innerHTML: '' }; + ctx.Extensions.runBlockRenderers('mermaid', 'sequenceDiagram\n A->>B: Hello', container); + assert.ok(container.innerHTML.includes('mermaid-source')); + assert.ok(container.innerHTML.includes('View source')); + assert.ok(container.innerHTML.includes('sequenceDiagram')); + }); + + it('block renderer escapes HTML in source view', async () => { + const ctx = loadWithMermaid(); + await ctx.Extensions.initAll(); + + const container = { innerHTML: '' }; + ctx.Extensions.runBlockRenderers('mermaid', 'graph LR; A[""]-->B', container); + assert.ok(container.innerHTML.includes('<script>')); + assert.ok(!container.innerHTML.includes('