Changeset 0.28.5 (#191)
This commit is contained in:
BIN
surfaces/icd-test-runner.surface
Normal file
BIN
surfaces/icd-test-runner.surface
Normal file
Binary file not shown.
@@ -201,8 +201,13 @@
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
entry.status = 'fail';
|
||||
entry.detail = String(e && e.message ? e.message : e);
|
||||
if (e && e._skip) {
|
||||
entry.status = 'skip';
|
||||
entry.detail = String(e.message || 'skipped');
|
||||
} else {
|
||||
entry.status = 'fail';
|
||||
entry.detail = String(e && e.message ? e.message : e);
|
||||
}
|
||||
}
|
||||
entry.duration = Math.round(performance.now() - t0);
|
||||
T.results.push(entry);
|
||||
@@ -210,6 +215,17 @@
|
||||
return entry;
|
||||
};
|
||||
|
||||
/**
|
||||
* Skip a test with a reason. Call inside a T.test() fn body.
|
||||
* Skipped tests appear in results as status='skip' — not pass, not fail.
|
||||
* @param {string} reason — why this test was skipped
|
||||
*/
|
||||
T.skip = function (reason) {
|
||||
var e = new Error(reason || 'skipped');
|
||||
e._skip = true;
|
||||
throw e;
|
||||
};
|
||||
|
||||
// ─── API Wrappers ───────────────────────────────────────────
|
||||
// API._get/_post/_put already prepend __BASE__. No base prefix here.
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
'tier-authz.js',
|
||||
'tier-security.js',
|
||||
'tier-providers.js',
|
||||
'tier-sdk.js',
|
||||
'ui.js'
|
||||
];
|
||||
|
||||
|
||||
501
surfaces/icd-test-runner/js/tier-sdk.js
Normal file
501
surfaces/icd-test-runner/js/tier-sdk.js
Normal file
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
};
|
||||
})();
|
||||
@@ -230,6 +230,14 @@
|
||||
}, 'Providers');
|
||||
T.el.controls.appendChild(btnProv);
|
||||
|
||||
// SDK tier button — tests switchboard-sdk.js contract
|
||||
var btnSdk = $('button', {
|
||||
className: (typeof Switchboard !== 'undefined') ? 'btn-secondary' : 'btn-ghost',
|
||||
style: { opacity: (typeof Switchboard !== 'undefined') ? '1' : '0.5' },
|
||||
onClick: function () { T.runSuite('sdk'); }
|
||||
}, 'SDK');
|
||||
T.el.controls.appendChild(btnSdk);
|
||||
|
||||
T.el.controls.appendChild(btnAll);
|
||||
T.el.controls.appendChild(btnClear);
|
||||
|
||||
@@ -342,6 +350,7 @@
|
||||
var now = new Date().toISOString();
|
||||
var pass = T.results.filter(function (r) { return r.status === 'pass'; }).length;
|
||||
var fail = T.results.filter(function (r) { return r.status === 'fail'; }).length;
|
||||
var skip = T.results.filter(function (r) { return r.status === 'skip'; }).length;
|
||||
var total = T.results.length;
|
||||
var totalMs = T.results.reduce(function (s, r) { return s + r.duration; }, 0);
|
||||
|
||||
@@ -353,7 +362,7 @@
|
||||
lines.push('URL: ' + location.href);
|
||||
lines.push('');
|
||||
lines.push('--- Summary ---');
|
||||
lines.push('Total: ' + total + ' Pass: ' + pass + ' Fail: ' + fail + ' Rate: ' + (total > 0 ? Math.round((pass / total) * 100) : 0) + '% Duration: ' + totalMs + 'ms');
|
||||
lines.push('Total: ' + total + ' Pass: ' + pass + ' Fail: ' + fail + (skip > 0 ? ' Skip: ' + skip : '') + ' Rate: ' + (total > 0 ? Math.round((pass / (total - skip)) * 100) : 0) + '% Duration: ' + totalMs + 'ms');
|
||||
|
||||
// Critical failures callout
|
||||
var criticals = T.results.filter(function (r) { return r.status === 'fail' && r.detail && r.detail.indexOf('CRITICAL') !== -1; });
|
||||
@@ -391,17 +400,18 @@
|
||||
var domains = {};
|
||||
T.results.forEach(function (r) {
|
||||
var k = r.domain;
|
||||
if (!domains[k]) domains[k] = { pass: 0, fail: 0, total: 0, ms: 0 };
|
||||
if (!domains[k]) domains[k] = { pass: 0, fail: 0, skip: 0, total: 0, ms: 0 };
|
||||
domains[k].total++;
|
||||
domains[k].ms += r.duration;
|
||||
if (r.status === 'pass') domains[k].pass++;
|
||||
else if (r.status === 'skip') domains[k].skip++;
|
||||
else domains[k].fail++;
|
||||
});
|
||||
|
||||
lines.push('--- By Domain ---');
|
||||
Object.keys(domains).forEach(function (d) {
|
||||
var s = domains[d];
|
||||
var flag = s.fail > 0 ? 'FAIL' : 'OK';
|
||||
var flag = s.fail > 0 ? 'FAIL' : (s.skip > 0 && s.pass === 0 ? 'SKIP' : 'OK');
|
||||
lines.push(' ' + pad(d, 16) + pad(flag, 6) + pad(s.pass + '/' + s.total, 8) + s.ms + 'ms');
|
||||
});
|
||||
lines.push('');
|
||||
@@ -417,16 +427,30 @@
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Skipped (if any)
|
||||
var skipped = T.results.filter(function (r) { return r.status === 'skip'; });
|
||||
if (skipped.length > 0) {
|
||||
lines.push('--- Skipped (' + skipped.length + ') ---');
|
||||
skipped.forEach(function (r, i) {
|
||||
lines.push(' ' + (i + 1) + '. [' + r.tier + '/' + r.domain + '] ' + r.name);
|
||||
lines.push(' ' + r.detail);
|
||||
});
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Full detail table
|
||||
lines.push('--- Full Results ---');
|
||||
lines.push(pad('STATUS', 8) + pad('TIER', 8) + pad('DOMAIN', 16) + pad('MS', 6) + 'TEST');
|
||||
lines.push(repeat('-', 90));
|
||||
T.results.forEach(function (r) {
|
||||
var statusStr = r.status === 'pass' ? 'PASS' : 'FAIL';
|
||||
var statusStr = r.status === 'pass' ? 'PASS' : (r.status === 'skip' ? 'SKIP' : 'FAIL');
|
||||
var line = pad(statusStr, 8) + pad(r.tier, 8) + pad(r.domain, 16) + pad(String(r.duration), 6) + r.name;
|
||||
if (r.status === 'fail' && r.detail) {
|
||||
line += '\n' + repeat(' ', 38) + '↳ ' + r.detail;
|
||||
}
|
||||
if (r.status === 'skip' && r.detail) {
|
||||
line += '\n' + repeat(' ', 38) + '↳ SKIP: ' + r.detail;
|
||||
}
|
||||
lines.push(line);
|
||||
});
|
||||
lines.push('');
|
||||
@@ -521,6 +545,7 @@
|
||||
if (which === 'authz' || (which === 'all' && T.fixtures.ready)) await T.runAuthz();
|
||||
if (which === 'security' || (which === 'all' && T.fixtures.ready)) await T.runSecurity();
|
||||
if (which === 'provider' || (which === 'all' && T.providerSetup.configured)) await T.runProviders();
|
||||
if (which === 'sdk' || which === 'all') { if (typeof T.runSdk === 'function') await T.runSdk(); }
|
||||
} catch (e) {
|
||||
T.results.push({ tier: '?', domain: 'runner', name: 'FATAL', status: 'fail', duration: 0, detail: String(e) });
|
||||
}
|
||||
@@ -546,8 +571,10 @@
|
||||
if (!T.el.progress) return;
|
||||
var pass = T.results.filter(function (r) { return r.status === 'pass'; }).length;
|
||||
var fail = T.results.filter(function (r) { return r.status === 'fail'; }).length;
|
||||
var skip = T.results.filter(function (r) { return r.status === 'skip'; }).length;
|
||||
var total = T.results.length;
|
||||
var pct = total > 0 ? Math.round((pass / total) * 100) : 0;
|
||||
var countable = total - skip;
|
||||
var pct = countable > 0 ? Math.round((pass / countable) * 100) : 0;
|
||||
|
||||
T.el.progress.innerHTML = '';
|
||||
if (total === 0) return;
|
||||
@@ -571,7 +598,7 @@
|
||||
T.el.progress.appendChild(barOuter);
|
||||
|
||||
var label = $('div', { style: { fontSize: '12px', color: 'var(--text-2)' } },
|
||||
(T.running ? 'Running... ' : '') + total + ' tests · ' + pass + ' pass · ' + fail + ' fail · ' + pct + '%');
|
||||
(T.running ? 'Running... ' : '') + total + ' tests · ' + pass + ' pass · ' + fail + ' fail' + (skip > 0 ? ' · ' + skip + ' skip' : '') + ' · ' + pct + '%');
|
||||
T.el.progress.appendChild(label);
|
||||
}
|
||||
|
||||
@@ -584,10 +611,11 @@
|
||||
var domains = {};
|
||||
T.results.forEach(function (r) {
|
||||
var k = r.domain;
|
||||
if (!domains[k]) domains[k] = { pass: 0, fail: 0, total: 0, ms: 0 };
|
||||
if (!domains[k]) domains[k] = { pass: 0, fail: 0, skip: 0, total: 0, ms: 0 };
|
||||
domains[k].total++;
|
||||
domains[k].ms += r.duration;
|
||||
if (r.status === 'pass') domains[k].pass++;
|
||||
else if (r.status === 'skip') domains[k].skip++;
|
||||
else domains[k].fail++;
|
||||
});
|
||||
|
||||
@@ -600,17 +628,19 @@
|
||||
|
||||
Object.keys(domains).forEach(function (d) {
|
||||
var s = domains[d];
|
||||
var allPass = s.fail === 0;
|
||||
var borderColor = s.fail > 0 ? 'var(--danger)' : (s.skip > 0 ? 'var(--warning)' : 'var(--success)');
|
||||
var label = s.pass + '/' + s.total + ' pass';
|
||||
if (s.skip > 0) label += ' · ' + s.skip + ' skip';
|
||||
label += ' · ' + s.ms + 'ms';
|
||||
var card = $('div', {
|
||||
style: {
|
||||
background: 'var(--bg-surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)', padding: '10px 14px',
|
||||
borderLeft: '3px solid ' + (allPass ? 'var(--success)' : 'var(--danger)')
|
||||
borderLeft: '3px solid ' + borderColor
|
||||
}
|
||||
}, [
|
||||
$('div', { style: { fontSize: '13px', fontWeight: '600', color: 'var(--text)', marginBottom: '4px' } }, d),
|
||||
$('div', { style: { fontSize: '12px', color: 'var(--text-2)' } },
|
||||
s.pass + '/' + s.total + ' pass · ' + s.ms + 'ms')
|
||||
$('div', { style: { fontSize: '12px', color: 'var(--text-2)' } }, label)
|
||||
]);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
@@ -649,10 +679,14 @@
|
||||
var tbody = $('tbody');
|
||||
T.results.forEach(function (r) {
|
||||
var isPass = r.status === 'pass';
|
||||
var isSkip = r.status === 'skip';
|
||||
var rowBg = isPass ? 'transparent' : (isSkip ? 'rgba(234,179,8,0.04)' : 'rgba(239,68,68,0.04)');
|
||||
var dotColor = isPass ? 'var(--success)' : (isSkip ? 'var(--warning)' : 'var(--danger)');
|
||||
var detailColor = isPass ? 'var(--text-3)' : (isSkip ? 'var(--warning)' : 'var(--danger)');
|
||||
var tr = $('tr', {
|
||||
style: {
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: isPass ? 'transparent' : 'rgba(239,68,68,0.04)'
|
||||
background: rowBg
|
||||
}
|
||||
});
|
||||
|
||||
@@ -661,7 +695,7 @@
|
||||
$('span', {
|
||||
style: {
|
||||
display: 'inline-block', width: '8px', height: '8px',
|
||||
borderRadius: '50%', background: isPass ? 'var(--success)' : 'var(--danger)'
|
||||
borderRadius: '50%', background: dotColor
|
||||
}
|
||||
})
|
||||
]));
|
||||
@@ -671,21 +705,22 @@
|
||||
tr.appendChild($('td', { style: { padding: '6px 10px', color: 'var(--text)' } }, r.name));
|
||||
tr.appendChild($('td', { style: { padding: '6px 10px', color: 'var(--text-3)', textAlign: 'right' } }, String(r.duration)));
|
||||
|
||||
var detailText = isSkip ? ('SKIP: ' + (r.detail || '')) : (r.detail || '');
|
||||
var detailTd = $('td', {
|
||||
style: {
|
||||
padding: '6px 10px', color: isPass ? 'var(--text-3)' : 'var(--danger)',
|
||||
padding: '6px 10px', color: detailColor,
|
||||
maxWidth: '320px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
cursor: r.detail ? 'pointer' : 'default'
|
||||
cursor: detailText ? 'pointer' : 'default'
|
||||
},
|
||||
title: r.detail || ''
|
||||
}, r.detail ? r.detail.substring(0, 120) : '✓');
|
||||
title: detailText || ''
|
||||
}, detailText ? detailText.substring(0, 120) : '✓');
|
||||
|
||||
if (r.detail && r.detail.length > 120) {
|
||||
if (detailText && detailText.length > 120) {
|
||||
detailTd.addEventListener('click', function () {
|
||||
if (detailTd.style.whiteSpace === 'nowrap') {
|
||||
detailTd.style.whiteSpace = 'pre-wrap';
|
||||
detailTd.style.wordBreak = 'break-all';
|
||||
detailTd.textContent = r.detail;
|
||||
detailTd.textContent = detailText;
|
||||
} else {
|
||||
detailTd.style.whiteSpace = 'nowrap';
|
||||
detailTd.textContent = r.detail.substring(0, 120);
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"route": "/s/icd-test-runner",
|
||||
"auth": "authenticated",
|
||||
"layout": "single",
|
||||
"version": "0.28.4.0",
|
||||
"description": "Integration test runner — validates ICD endpoint contracts across smoke, CRUD, AuthZ, Security, and provider tiers."
|
||||
"version": "0.28.5.0",
|
||||
"description": "Integration test runner — validates ICD endpoint contracts across smoke, CRUD, AuthZ, Security, Provider, and SDK tiers."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user