Changeset 0.11.0 (#62)
This commit is contained in:
491
src/js/__tests__/extensions.test.js
Normal file
491
src/js/__tests__/extensions.test.js
Normal file
@@ -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, '<').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 = `
|
||||
<div class="mermaid-block" data-mermaid-id="${id}">
|
||||
<div class="mermaid-diagram" data-mermaid-src="${encodeURIComponent(code.trim())}">
|
||||
<div class="mermaid-loading">Rendering diagram…</div>
|
||||
</div>
|
||||
<details class="mermaid-source">
|
||||
<summary>View source</summary>
|
||||
<pre><code>${self._escapeHtml(code.trim())}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
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["<script>alert(1)</script>"]-->B', container);
|
||||
assert.ok(container.innerHTML.includes('<script>'));
|
||||
assert.ok(!container.innerHTML.includes('<script>alert'));
|
||||
});
|
||||
|
||||
it('post renderer runs on container', async () => {
|
||||
const ctx = loadWithMermaid();
|
||||
await ctx.Extensions.initAll();
|
||||
|
||||
const container = {};
|
||||
ctx.Extensions.runPostRenderers(container);
|
||||
assert.strictEqual(container._mermaidPostRan, true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user