All checks were successful
CI/CD / detect-changes (pull_request) Successful in 23s
CI/CD / test-frontend (pull_request) Successful in 26s
CI/CD / test-go-pg (pull_request) Successful in 2m35s
CI/CD / test-sqlite (pull_request) Successful in 3m19s
CI/CD / build-and-deploy (pull_request) Successful in 1m46s
sw.testing SDK module — structured test framework for surface runners: - suite/test registration, lifecycle hooks (beforeAll/afterAll/beforeEach/afterEach) - Assertion library (ok/eq/neq/gt/match/throws/status/shape/arrayOf) - Auto-cleanup via track(type, id) with LIFO deletion - Three result statuses: passed/failed/warned - Structured JSON results with timing, warnings, cleanup stats test-runner manifest type: - ValidateManifest accepts "test-runner" packages - Excluded from sidebar nav (extensionNavItems filters surface/full only) - DB migrations: SQLite 013, Postgres 014 (CHECK constraint) ICD runner migration (kernel-only): - Migrated from T.test()/T.assert() to sw.testing.suite() - Stripped extension-dependent tests (channels, notes, personas, etc.) - Kernel suites: smoke, crud, authz, security, providers, packaging, sdk - Deleted ui.js + css (rendering delegated to registry surface) SDK runner migration (kernel-only): - Migrated from T.dualTest()/T.domains to sw.testing.suite() - Stripped extension domains (belong in v0.7.2 package runners) - Kernel suites: misc, workflows, admin, packages, connections, deps, composition Runner registry surface (/s/test-runners): - Admin-only dashboard discovering test-runner packages - Run All / per-runner Run buttons, real-time results - Export Failures / Export Full Results (JSON download) - Dark mode styling using kernel CSS variables - Suite count polling for async runner script loading 141 passed, 0 failed on fresh minimal install. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
446 lines
21 KiB
JavaScript
446 lines
21 KiB
JavaScript
/**
|
|
* ICD Test Runner — SDK Tier
|
|
* Validates Preact SDK (sw/sdk/index.js) contract: boot, identity,
|
|
* REST client, events, theme, pipe registration, execution ordering,
|
|
* scoping, halt semantics, error isolation, introspection.
|
|
* v0.37.14: Updated from Armature.init() to boot() API.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
var T = window.ICD;
|
|
if (!T) return;
|
|
if (!window.sw || !window.sw.testing) return;
|
|
|
|
sw.testing.suite('icd/sdk', async function (s) {
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// BOOT
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
s.test('boot: window.sw exists after boot()', async function (t) {
|
|
T.assert(window.sw !== null && typeof window.sw === 'object', 'window.sw should be an object');
|
|
T.assert(window.sw._sdk, 'window.sw._sdk version marker should exist');
|
|
});
|
|
|
|
s.test('boot: boot() is idempotent', async function (t) {
|
|
var a = window.sw;
|
|
// Import and call boot() again — should return same instance
|
|
var mod = await import(window.__BASE__ + '/js/sw/sdk/index.js');
|
|
var b = await mod.boot();
|
|
T.assert(a === b, 'idempotent: must return same object');
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// IDENTITY
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
s.test('identity: sw.auth.user populated', async function (t) {
|
|
T.assert(sw.auth.user !== null, 'sw.auth.user should not be null');
|
|
T.assert(typeof sw.auth.user.id === 'string' && sw.auth.user.id.length > 0, 'user.id');
|
|
T.assert(typeof sw.auth.user.username === 'string', 'user.username');
|
|
T.assert(typeof sw.auth.user.role === 'string', 'user.role');
|
|
});
|
|
|
|
s.test('identity: sw.isAdmin reflects role', async function (t) {
|
|
T.assert(typeof sw.isAdmin === 'boolean', 'isAdmin should be boolean');
|
|
// sw.isAdmin checks permissions, not just the role string
|
|
// On a fresh install the test runner user is admin — verify isAdmin is truthy
|
|
T.assert(sw.isAdmin === true, 'isAdmin should be true when running as admin user');
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// REST CLIENT
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
s.test('api: sw.api.get /health', async function (t) {
|
|
var d = await sw.api.get('/api/v1/health');
|
|
T.assert(d.status === 'ok', 'health status should be ok');
|
|
});
|
|
|
|
s.test('api: sw.api.get /extensions returns array', async function (t) {
|
|
var d = await sw.api.get('/api/v1/extensions');
|
|
// SDK auto-unwraps { data: [...] } to bare array
|
|
var arr = Array.isArray(d) ? d : (d && d.data);
|
|
T.assert(Array.isArray(arr), 'extensions should be array (auto-unwrapped)');
|
|
});
|
|
|
|
s.test('api: sw.api.get /workflows returns array', async function (t) {
|
|
var d = await sw.api.get('/api/v1/workflows');
|
|
var arr = Array.isArray(d) ? d : (d && d.data);
|
|
T.assert(Array.isArray(arr), 'workflows should be array (auto-unwrapped)');
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// EVENTS
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
s.test('events: sw.on receives sw.emit', async function (t) {
|
|
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();
|
|
});
|
|
|
|
s.test('events: sw.once fires exactly once', async function (t) {
|
|
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);
|
|
});
|
|
|
|
s.test('events: sw.off stops delivery', async function (t) {
|
|
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
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
s.test('theme: sw.theme.current returns dark or light', async function (t) {
|
|
var c = sw.theme.current;
|
|
T.assert(c === 'dark' || c === 'light', 'expected dark|light, got ' + c);
|
|
});
|
|
|
|
s.test('theme: sw.theme.mode returns preference', async function (t) {
|
|
var m = sw.theme.mode;
|
|
T.assert(m === 'dark' || m === 'light' || m === 'system', 'expected dark|light|system, got ' + m);
|
|
});
|
|
|
|
s.test('theme: sw.theme.on change fires', async function (t) {
|
|
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 via sw.theme.set() which calls _notify() internally
|
|
var currentMode = sw.theme.mode;
|
|
sw.theme.set(currentMode);
|
|
T.assert(fired, 'change handler should have fired');
|
|
unsub();
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// PIPE REGISTRATION
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
s.test('pipe: sw.pipe.pre registers', async function (t) {
|
|
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');
|
|
});
|
|
|
|
s.test('pipe: sw.pipe.stream registers', async function (t) {
|
|
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');
|
|
});
|
|
|
|
s.test('pipe: sw.pipe.render registers', async function (t) {
|
|
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');
|
|
});
|
|
|
|
s.test('pipe: Scoped filter registers with scope', async function (t) {
|
|
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');
|
|
});
|
|
|
|
s.test('pipe: Priority ordering', async function (t) {
|
|
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
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
s.test('pipe-pre: Filter mutates context', async function (t) {
|
|
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');
|
|
});
|
|
|
|
s.test('pipe-pre: Halt filter returns null', async function (t) {
|
|
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');
|
|
});
|
|
|
|
s.test('pipe-pre: Error isolation — filter throws, chain continues', async function (t) {
|
|
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
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
s.test('pipe-stream: Stream filter counts chunks', async function (t) {
|
|
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);
|
|
});
|
|
|
|
s.test('pipe-stream: Stream filter modifies accumulated', async function (t) {
|
|
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');
|
|
});
|
|
|
|
s.test('pipe-stream: Null drops chunk', async function (t) {
|
|
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
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
s.test('pipe-render: Render filter modifies DOM', async function (t) {
|
|
sw.pipe.render(1, function (ctx) {
|
|
if (ctx.element.dataset.sdkRenderTest) {
|
|
ctx.element.classList.add('ext-icd-test-runner-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('ext-icd-test-runner-sdk-render-modified'), 'element should have class');
|
|
});
|
|
|
|
s.test('pipe-render: Priority ordering in render chain', async function (t) {
|
|
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
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
s.test('pipe-scope: Scoped filter skips non-matching channel', async function (t) {
|
|
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');
|
|
});
|
|
|
|
s.test('pipe-scope: Scoped filter runs on matching channel', async function (t) {
|
|
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');
|
|
});
|
|
|
|
s.test('pipe-scope: Unscoped filter runs on all channels', async function (t) {
|
|
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');
|
|
});
|
|
|
|
// v0.37.14: Extension compat shim test removed (Extensions global deleted).
|
|
// Extension rendering now goes through Preact SDK pipe directly.
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// INTROSPECTION
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
s.test('introspection: sw.pipe.list() shape', async function (t) {
|
|
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');
|
|
});
|
|
|
|
s.test('introspection: Stats populated after execution', async function (t) {
|
|
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');
|
|
});
|
|
|
|
s.test('introspection: Stats include avgMs and errors', async function (t) {
|
|
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');
|
|
});
|
|
|
|
s.test('introspection: Scoped filter shows scope in list', async function (t) {
|
|
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');
|
|
});
|
|
|
|
s.test('introspection: Unscoped filter shows null scope', async function (t) {
|
|
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');
|
|
});
|
|
});
|
|
})();
|