/** * ICD Test Runner — SDK Tier * Validates switchboard-sdk.js contract: boot, identity, REST client, * events, theme, pipe registration, execution ordering, scoping, * halt semantics, error isolation, extension compat shim, introspection. */ (function () { 'use strict'; var T = window.ICD; if (!T) return; // ── Helpers ───────────────────────────────────────────────── /** Create a temporary direct channel for pipe tests. Returns channel object. */ async function _createTestChannel(tag) { var title = 'sdk-test-' + tag + '-' + Date.now(); var ch = await T.apiPost('/channels', { title: title, type: 'direct' }); T.cleanup.push(function () { return T.apiDelete('/channels/' + ch.id).catch(function () {}); }); return ch; } /** * Clear all SDK pipe filters between tests. * The SDK stores filters in closure-scoped arrays; we reach them * through sw.pipe.list() and re-init by registering a clear flag. * In practice we just note that tests accumulate filters — ordering * tests carefully so later tests expect earlier filters to exist. */ // ── Tier ──────────────────────────────────────────────────── T.runSdk = async function () { // ═══════════════════════════════════════════════════════════ // BOOT // ═══════════════════════════════════════════════════════════ await T.test('sdk', 'boot', 'Switchboard.init() returns sw object', async function () { T.assert(typeof Switchboard !== 'undefined', 'Switchboard global missing'); var inst = Switchboard.init(); T.assert(inst !== null && typeof inst === 'object', 'init() should return object'); T.assert(inst === window.sw, 'window.sw should be the same instance'); }); await T.test('sdk', 'boot', 'Double init returns same instance', async function () { var a = Switchboard.init(); var b = Switchboard.init(); T.assert(a === b, 'idempotent: must return same object'); }); // ═══════════════════════════════════════════════════════════ // IDENTITY // ═══════════════════════════════════════════════════════════ await T.test('sdk', 'identity', 'sw.user populated', async function () { T.assert(sw.user !== null, 'sw.user should not be null'); T.assert(typeof sw.user.id === 'string' && sw.user.id.length > 0, 'user.id'); T.assert(typeof sw.user.username === 'string', 'user.username'); T.assert(typeof sw.user.role === 'string', 'user.role'); }); await T.test('sdk', 'identity', 'sw.isAdmin reflects role', async function () { T.assert(typeof sw.isAdmin === 'boolean', 'isAdmin should be boolean'); T.assert(sw.isAdmin === (sw.user.role === 'admin'), 'isAdmin should match role'); }); // ═══════════════════════════════════════════════════════════ // REST CLIENT // ═══════════════════════════════════════════════════════════ await T.test('sdk', 'api', 'sw.api.get /health', async function () { var d = await sw.api.get('/api/v1/health'); T.assert(d.status === 'ok', 'health status should be ok'); }); await T.test('sdk', 'api', 'sw.api.get /channels returns envelope', async function () { var d = await sw.api.get('/api/v1/channels'); T.assertHasKey(d, 'data', '/channels'); T.assert(Array.isArray(d.data), 'data should be array'); }); var _sdkTestChannel = null; await T.test('sdk', 'api', 'sw.api.post creates channel', async function () { var d = await sw.api.post('/api/v1/channels', { title: 'sdk-api-test-' + Date.now(), type: 'direct' }); T.assert(typeof d.id === 'string', 'created channel should have id'); _sdkTestChannel = d; T.cleanup.push(function () { return T.apiDelete('/channels/' + d.id).catch(function () {}); }); }); await T.test('sdk', 'api', 'sw.api.del removes channel', async function () { if (!_sdkTestChannel) throw new Error('no channel from previous test'); // Create a throwaway channel to delete var d = await sw.api.post('/api/v1/channels', { title: 'sdk-del-test-' + Date.now(), type: 'direct' }); await sw.api.del('/api/v1/channels/' + d.id); // Verify it's gone try { await sw.api.get('/api/v1/channels/' + d.id); throw new Error('channel should be deleted'); } catch (e) { if (e.message === 'channel should be deleted') throw e; // Expected: 404 or error } }); // ═══════════════════════════════════════════════════════════ // EVENTS // ═══════════════════════════════════════════════════════════ await T.test('sdk', 'events', 'sw.on receives sw.emit', async function () { var received = null; var unsub = sw.on('sdk.test.ping', function (payload) { received = payload; }); sw.emit('sdk.test.ping', { v: 42 }, { localOnly: true }); T.assert(received !== null, 'handler should have fired'); T.assert(received.v === 42, 'payload should match'); unsub(); }); await T.test('sdk', 'events', 'sw.once fires exactly once', async function () { var count = 0; sw.once('sdk.test.once', function () { count++; }); sw.emit('sdk.test.once', {}, { localOnly: true }); sw.emit('sdk.test.once', {}, { localOnly: true }); T.assert(count === 1, 'expected 1, got ' + count); }); await T.test('sdk', 'events', 'sw.off stops delivery', async function () { var count = 0; var fn = function () { count++; }; sw.on('sdk.test.off', fn); sw.emit('sdk.test.off', {}, { localOnly: true }); T.assert(count === 1, 'should fire once before off'); sw.off('sdk.test.off', fn); sw.emit('sdk.test.off', {}, { localOnly: true }); T.assert(count === 1, 'should not fire after off'); }); // ═══════════════════════════════════════════════════════════ // THEME // ═══════════════════════════════════════════════════════════ await T.test('sdk', 'theme', 'sw.theme.current returns dark or light', async function () { var c = sw.theme.current; T.assert(c === 'dark' || c === 'light', 'expected dark|light, got ' + c); }); await T.test('sdk', 'theme', 'sw.theme.mode returns preference', async function () { var m = sw.theme.mode; T.assert(m === 'dark' || m === 'light' || m === 'system', 'expected dark|light|system, got ' + m); }); await T.test('sdk', 'theme', 'sw.theme.on change fires', async function () { var fired = false; var unsub = sw.theme.on('change', function (resolved) { fired = true; T.assert(resolved === 'dark' || resolved === 'light', 'resolved should be dark|light'); }); // Trigger a theme change event Events.emit('theme.changed', {}, { localOnly: true }); T.assert(fired, 'change handler should have fired'); unsub(); }); // ═══════════════════════════════════════════════════════════ // PIPE REGISTRATION // ═══════════════════════════════════════════════════════════ await T.test('sdk', 'pipe', 'sw.pipe.pre registers', async function () { sw.pipe.pre(100, function (ctx) { return ctx; }, { source: '_test-pre' }); var list = sw.pipe.list(); var found = list.pre.some(function (f) { return f.source === '_test-pre'; }); T.assert(found, '_test-pre should appear in pipe.list().pre'); }); await T.test('sdk', 'pipe', 'sw.pipe.stream registers', async function () { sw.pipe.stream(100, function (ctx) { return ctx; }, { source: '_test-stream' }); var list = sw.pipe.list(); var found = list.stream.some(function (f) { return f.source === '_test-stream'; }); T.assert(found, '_test-stream should appear in pipe.list().stream'); }); await T.test('sdk', 'pipe', 'sw.pipe.render registers', async function () { sw.pipe.render(100, function (ctx) { return ctx; }, { source: '_test-render' }); var list = sw.pipe.list(); var found = list.render.some(function (f) { return f.source === '_test-render'; }); T.assert(found, '_test-render should appear in pipe.list().render'); }); await T.test('sdk', 'pipe', 'Scoped filter registers with scope', async function () { sw.pipe.pre(101, function (ctx) { return ctx; }, { source: '_test-scoped', scope: { channelType: ['workflow'] } }); var list = sw.pipe.list(); var entry = list.pre.find(function (f) { return f.source === '_test-scoped'; }); T.assert(entry !== undefined, 'scoped filter should be in list'); T.assert(entry.scope !== null, 'scope should be set'); T.assert(Array.isArray(entry.scope.channelType), 'scope.channelType should be array'); T.assert(entry.scope.channelType[0] === 'workflow', 'should be scoped to workflow'); }); await T.test('sdk', 'pipe', 'Priority ordering', async function () { sw.pipe.render(5, function (ctx) { return ctx; }, { source: '_test-priority-5' }); sw.pipe.render(99, function (ctx) { return ctx; }, { source: '_test-priority-99' }); var list = sw.pipe.list(); var idx5 = -1, idx99 = -1; for (var i = 0; i < list.render.length; i++) { if (list.render[i].source === '_test-priority-5') idx5 = i; if (list.render[i].source === '_test-priority-99') idx99 = i; } T.assert(idx5 >= 0 && idx99 >= 0, 'both filters should exist'); T.assert(idx5 < idx99, 'priority 5 should come before priority 99'); }); // ═══════════════════════════════════════════════════════════ // PIPE EXECUTION — PRE-SEND // ═══════════════════════════════════════════════════════════ await T.test('sdk', 'pipe-pre', 'Filter mutates context', async function () { sw.pipe.pre(1, function (ctx) { ctx.metadata.sdk_test = 'injected'; return ctx; }, { source: '_test-mutate' }); // Simulate _runPre with a fake context var ctx = { message: 'hello', channel: { id: 'fake', type: 'direct', title: '' }, attachments: [], model: 'test', persona: null, metadata: {}, regenerate: false, regenerateMessageId: null }; var result = sw.pipe._runPre(ctx); T.assert(result !== null, 'should not halt'); T.assert(result.metadata.sdk_test === 'injected', 'metadata should be mutated'); }); await T.test('sdk', 'pipe-pre', 'Halt filter returns null', async function () { sw.pipe.pre(0, function (ctx) { if (ctx.metadata._halt_test) return null; return ctx; }, { source: '_test-halt' }); var ctx = { message: 'halt me', channel: { id: 'fake', type: 'direct', title: '' }, attachments: [], model: 'test', persona: null, metadata: { _halt_test: true }, regenerate: false, regenerateMessageId: null }; var result = sw.pipe._runPre(ctx); T.assert(result === null, 'chain should be halted'); }); await T.test('sdk', 'pipe-pre', 'Error isolation — filter throws, chain continues', async function () { sw.pipe.pre(2, function (ctx) { if (ctx.metadata._throw_test) throw new Error('intentional'); return ctx; }, { source: '_test-throw' }); sw.pipe.pre(3, function (ctx) { ctx.metadata.after_throw = true; return ctx; }, { source: '_test-after-throw' }); var ctx = { message: 'test', channel: { id: 'fake', type: 'direct', title: '' }, attachments: [], model: 'test', persona: null, metadata: { _throw_test: true }, regenerate: false, regenerateMessageId: null }; var result = sw.pipe._runPre(ctx); T.assert(result !== null, 'chain should not halt on throw'); T.assert(result.metadata.after_throw === true, 'subsequent filter should have run'); // Verify error was counted var list = sw.pipe.list(); var entry = list.pre.find(function (f) { return f.source === '_test-throw'; }); T.assert(entry && entry.errors > 0, 'error count should be > 0'); }); // ═══════════════════════════════════════════════════════════ // PIPE EXECUTION — STREAM // ═══════════════════════════════════════════════════════════ await T.test('sdk', 'pipe-stream', 'Stream filter counts chunks', async function () { var counter = 0; sw.pipe.stream(1, function (ctx) { if (ctx.channel.id === '_stream_test') counter++; return ctx; }, { source: '_test-stream-counter' }); // Simulate 3 chunks for (var i = 0; i < 3; i++) { sw.pipe._runStream({ chunk: 'word' + i + ' ', accumulated: '', channel: { id: '_stream_test', type: 'direct' }, model: 'test', event: 'content', tokens: { input: 0, output: 0 } }); } T.assert(counter === 3, 'expected 3 chunk calls, got ' + counter); }); await T.test('sdk', 'pipe-stream', 'Stream filter modifies accumulated', async function () { sw.pipe.stream(2, function (ctx) { if (ctx.channel.id === '_accum_test') { ctx.accumulated = ctx.accumulated.toUpperCase(); } return ctx; }, { source: '_test-stream-accum' }); var result = sw.pipe._runStream({ chunk: 'hello', accumulated: 'hello world', channel: { id: '_accum_test', type: 'direct' }, model: 'test', event: 'content', tokens: { input: 0, output: 0 } }); T.assert(result.accumulated === 'HELLO WORLD', 'accumulated should be uppercased'); }); await T.test('sdk', 'pipe-stream', 'Null drops chunk', async function () { sw.pipe.stream(0, function (ctx) { if (ctx.channel.id === '_drop_test') return null; return ctx; }, { source: '_test-stream-drop' }); var result = sw.pipe._runStream({ chunk: 'x', accumulated: 'x', channel: { id: '_drop_test', type: 'direct' }, model: 'test', event: 'content', tokens: { input: 0, output: 0 } }); T.assert(result === null, 'should return null for dropped chunk'); }); // ═══════════════════════════════════════════════════════════ // PIPE EXECUTION — RENDER // ═══════════════════════════════════════════════════════════ await T.test('sdk', 'pipe-render', 'Render filter modifies DOM', async function () { sw.pipe.render(1, function (ctx) { if (ctx.element.dataset.sdkRenderTest) { ctx.element.classList.add('sdk-render-modified'); } return ctx; }, { source: '_test-render-dom' }); var el = document.createElement('div'); el.dataset.sdkRenderTest = 'true'; sw.pipe._runRender({ element: el, message: null, channel: { id: 'fake', type: 'direct' } }); T.assert(el.classList.contains('sdk-render-modified'), 'element should have class'); }); await T.test('sdk', 'pipe-render', 'Priority ordering in render chain', async function () { var order = []; sw.pipe.render(10, function (ctx) { if (ctx.element.dataset.sdkOrderTest) order.push('A'); return ctx; }, { source: '_test-render-order-A' }); sw.pipe.render(20, function (ctx) { if (ctx.element.dataset.sdkOrderTest) order.push('B'); return ctx; }, { source: '_test-render-order-B' }); var el = document.createElement('div'); el.dataset.sdkOrderTest = 'true'; sw.pipe._runRender({ element: el, message: null, channel: { id: 'fake', type: 'direct' } }); T.assert(order.length >= 2, 'both filters should run'); var idxA = order.indexOf('A'); var idxB = order.indexOf('B'); T.assert(idxA < idxB, 'A (p=10) should run before B (p=20)'); }); // ═══════════════════════════════════════════════════════════ // PIPE SCOPING // ═══════════════════════════════════════════════════════════ await T.test('sdk', 'pipe-scope', 'Scoped filter skips non-matching channel', async function () { var ran = false; sw.pipe.pre(200, function (ctx) { ran = true; return ctx; }, { source: '_test-scope-skip', scope: { channelType: ['workflow'] } }); sw.pipe._runPre({ message: 'test', channel: { id: 'x', type: 'direct', title: '' }, attachments: [], model: 'test', persona: null, metadata: {}, regenerate: false, regenerateMessageId: null }); T.assert(!ran, 'scoped-to-workflow filter should NOT run on direct channel'); }); await T.test('sdk', 'pipe-scope', 'Scoped filter runs on matching channel', async function () { var ran = false; sw.pipe.pre(201, function (ctx) { ran = true; return ctx; }, { source: '_test-scope-match', scope: { channelType: ['dm', 'direct'] } }); sw.pipe._runPre({ message: 'test', channel: { id: 'x', type: 'direct', title: '' }, attachments: [], model: 'test', persona: null, metadata: {}, regenerate: false, regenerateMessageId: null }); T.assert(ran, 'scoped-to-direct filter should run on direct channel'); }); await T.test('sdk', 'pipe-scope', 'Unscoped filter runs on all channels', async function () { var count = 0; sw.pipe.pre(202, function (ctx) { count++; return ctx; }, { source: '_test-scope-all' }); sw.pipe._runPre({ message: 'a', channel: { id: 'x', type: 'direct', title: '' }, attachments: [], model: 'test', persona: null, metadata: {}, regenerate: false, regenerateMessageId: null }); sw.pipe._runPre({ message: 'b', channel: { id: 'x', type: 'workflow', title: '' }, attachments: [], model: 'test', persona: null, metadata: {}, regenerate: false, regenerateMessageId: null }); T.assert(count >= 2, 'unscoped filter should run on both channel types'); }); // ═══════════════════════════════════════════════════════════ // EXTENSION COMPAT SHIM // ═══════════════════════════════════════════════════════════ await T.test('sdk', 'compat', 'ctx.renderers.register post → pipe.list() [chat-only]', async function () { // This tests the compat shim in extensions.js. // Extensions only loads on the chat surface (scripts-chat template). if (typeof Extensions === 'undefined') { T.skip('Extensions only loads on chat surface — run SDK tier from /chat to test compat shim'); } var shimName = '_sdk-compat-test-' + Date.now(); Extensions._registerRenderer('sdk-test', shimName, { type: 'post', priority: 77, render: function (container) { /* noop */ } }); var list = sw.pipe.list(); var found = list.render.some(function (f) { return f.source === 'sdk-test:' + shimName; }); T.assert(found, 'shimmed post-renderer should appear in pipe.list().render'); }); // ═══════════════════════════════════════════════════════════ // INTROSPECTION // ═══════════════════════════════════════════════════════════ await T.test('sdk', 'introspection', 'sw.pipe.list() shape', async function () { var list = sw.pipe.list(); T.assertHasKey(list, 'pre', 'pipe.list'); T.assertHasKey(list, 'stream', 'pipe.list'); T.assertHasKey(list, 'render', 'pipe.list'); T.assert(Array.isArray(list.pre), 'pre should be array'); T.assert(Array.isArray(list.stream), 'stream should be array'); T.assert(Array.isArray(list.render), 'render should be array'); }); await T.test('sdk', 'introspection', 'Stats populated after execution', async function () { var list = sw.pipe.list(); // Find any filter with calls > 0 (previous tests should have run some) var anyRun = false; ['pre', 'stream', 'render'].forEach(function (stage) { list[stage].forEach(function (f) { if (f.calls > 0) anyRun = true; }); }); T.assert(anyRun, 'at least one filter should have calls > 0 from previous tests'); }); await T.test('sdk', 'introspection', 'Stats include avgMs and errors', async function () { var list = sw.pipe.list(); var entry = list.pre.find(function (f) { return f.calls > 0; }); if (!entry) throw new Error('no filter with calls > 0'); T.assert(typeof entry.avgMs === 'number', 'avgMs should be number'); T.assert(typeof entry.errors === 'number', 'errors should be number'); T.assert(typeof entry.source === 'string', 'source should be string'); T.assert(typeof entry.priority === 'number', 'priority should be number'); }); await T.test('sdk', 'introspection', 'Scoped filter shows scope in list', async function () { var list = sw.pipe.list(); var scoped = list.pre.find(function (f) { return f.source === '_test-scoped'; }); T.assert(scoped, '_test-scoped filter should exist'); T.assert(scoped.scope !== null && scoped.scope !== undefined, 'scope should be set'); }); await T.test('sdk', 'introspection', 'Unscoped filter shows null scope', async function () { var list = sw.pipe.list(); var unscoped = list.pre.find(function (f) { return f.source === '_test-pre'; }); T.assert(unscoped, '_test-pre filter should exist'); T.assert(unscoped.scope === null, 'scope should be null for unscoped filter'); }); }; })();