This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/packages/icd-test-runner/js/framework.js
Jeffrey Smith f1c47002aa
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
v0.7.1 Surface Runner Framework
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>
2026-04-01 22:49:55 +00:00

298 lines
12 KiB
JavaScript

/**
* ICD Test Runner — Framework
* Assertion library, ICD shapes, API wrappers, token capture.
* (DOM helpers and test harness removed — runner now uses sw.testing)
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
// ─── Assertion Library ──────────────────────────────────────
function assertType(val, type, path) {
var actual = val === null ? 'null' : Array.isArray(val) ? 'array' : typeof val;
if (type === 'array') {
if (!Array.isArray(val)) return path + ': expected array, got ' + actual;
} else if (type === 'uuid') {
if (typeof val !== 'string' || val.length < 8) return path + ': expected uuid string';
} else if (type === 'string?') {
if (val !== null && val !== undefined && typeof val !== 'string')
return path + ': expected string|null, got ' + actual;
} else if (type === 'number?') {
if (val !== null && val !== undefined && typeof val !== 'number')
return path + ': expected number|null, got ' + actual;
} else if (type === 'bool') {
if (typeof val !== 'boolean') return path + ': expected boolean, got ' + actual;
} else if (type === 'object') {
if (typeof val !== 'object' || val === null || Array.isArray(val))
return path + ': expected object, got ' + actual;
} else if (type === 'any') {
// always passes
} else {
if (typeof val !== type) return path + ': expected ' + type + ', got ' + actual;
}
return null;
}
function validateShape(obj, schema, prefix) {
var errors = [];
prefix = prefix || '$';
if (typeof obj !== 'object' || obj === null) {
errors.push(prefix + ': expected object, got ' + (obj === null ? 'null' : typeof obj));
return errors;
}
Object.keys(schema).forEach(function (key) {
var spec = schema[key];
var val = obj[key];
var path = prefix + '.' + key;
if (typeof spec === 'string') {
if (spec.charAt(spec.length - 1) === '?') {
if (val === undefined || val === null) return;
spec = spec.slice(0, -1);
} else if (val === undefined) {
errors.push(path + ': missing required field');
return;
}
var e = assertType(val, spec, path);
if (e) errors.push(e);
} else if (typeof spec === 'object' && spec._type) {
if (spec._optional && (val === undefined || val === null)) return;
if (val === undefined) { errors.push(path + ': missing required field'); return; }
var e2 = assertType(val, spec._type, path);
if (e2) errors.push(e2);
}
});
return errors;
}
T.assert = function (condition, msg) {
if (!condition) throw new Error(msg || 'assertion failed');
};
T.assertShape = function (obj, schema, label) {
var errs = validateShape(obj, schema, label || '$');
if (errs.length) throw new Error(errs.join('; '));
};
T.assertArrayOf = function (arr, schema, label) {
T.assert(Array.isArray(arr), (label || '$') + ': expected array');
if (arr.length > 0) T.assertShape(arr[0], schema, (label || '$') + '[0]');
};
T.assertHasKey = function (obj, key, label) {
T.assert(obj && obj.hasOwnProperty(key), (label || '$') + ': missing key "' + key + '"');
};
T.assertEnum = function (val, allowed, path) {
if (allowed.indexOf(val) === -1)
throw new Error(path + ': "' + val + '" not in [' + allowed.join(', ') + ']');
};
T.assertStatus = function (data, expected, label) {
T.assert(data._status === expected,
(label || '$') + ': expected HTTP ' + expected + ', got ' + data._status +
(data.error ? ' (' + data.error + ')' : ''));
};
// ─── ICD Shapes ─────────────────────────────────────────────
var S = T.S;
S.notification = { id: 'string', type: 'string', title: 'string', read: 'bool', created_at: 'string' };
S.profile = { id: 'string', username: 'string', email: 'string', role: 'string?', settings: 'object', created_at: 'string' };
S.surface = { id: 'string', title: 'string', source: 'string', enabled: 'bool' };
S.surfaceNav = { id: 'string', title: 'string', route: 'string' };
S.surfaceAdmin = { id: 'string', title: 'string', manifest: 'object', enabled: 'bool', source: 'string', installed_at: 'string', updated_at: 'string' };
S.workflow = { id: 'string', name: 'string', slug: 'string', entry_mode: 'string', is_active: 'bool', created_at: 'string', updated_at: 'string' };
S.workflowStage = { id: 'string', workflow_id: 'string', ordinal: 'number', name: 'string', history_mode: 'string', created_at: 'string', surface_pkg_id: 'string?' };
S.workflowVersion = { id: 'string', workflow_id: 'string', version_number: 'number', created_at: 'string' };
S.workflowStatus = { workflow_id: 'string', workflow_version: 'number', current_stage: 'number', status: 'string' };
S.assignment = { id: 'string', channel_id: 'string', status: 'string', created_at: 'string' };
S.team = { id: 'string', name: 'string', created_at: 'string?' };
S.teamMember = { user_id: 'string', role: 'string' };
S.group = { id: 'string', name: 'string' };
S.extension = { id: 'string', title: 'string', type: 'string', version: 'string', tier: 'string', enabled: 'bool', is_system: 'bool', scope: 'string', source: 'string', installed_at: 'string', updated_at: 'string' };
S.auditEntry = { id: 'string', action: 'string', created_at: 'string' };
S.adminUser = { id: 'string', username: 'string', role: 'string', created_at: 'string' };
S.loginResponse = { access_token: 'string', refresh_token: 'string' };
// ─── API Wrappers ───────────────────────────────────────────
// Raw fetch — kernel provides no global API wrappers; extensions use fetch directly.
async function _fetchJSON(method, path, body) {
var token = await T.getAuthToken();
var opts = {
method: method,
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
credentials: 'same-origin'
};
if (body !== undefined && method !== 'GET') opts.body = JSON.stringify(body);
var resp = await fetch(T.base + '/api/v1' + path, opts);
if (resp.status === 204) return { _status: 204 };
var text = await resp.text();
var data;
try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; }
data._status = resp.status;
if (!resp.ok) {
var err = new Error(method + ' ' + path + ' → ' + resp.status + (data.error ? ': ' + data.error : ''));
err.status = resp.status;
err.data = data;
throw err;
}
return data;
}
T.apiGet = async function (path) { return await _fetchJSON('GET', path); };
T.apiPost = async function (path, body) { return await _fetchJSON('POST', path, body); };
T.apiPut = async function (path, body) { return await _fetchJSON('PUT', path, body); };
T.apiPatch = async function (path, body) { return await _fetchJSON('PATCH', path, body); };
// ─── Token Capture ──────────────────────────────────────────
var _capturedToken = '';
T.captureAuthToken = async function () {
if (_capturedToken) return _capturedToken;
_capturedToken = getAuthTokenFromStorage();
return _capturedToken;
};
function getAuthTokenFromStorage() {
var env = (T.base || '').replace(/^\//, '') || 'default';
var keys = ['arm_auth_' + env, 'arm_auth', 'arm_token'];
for (var i = 0; i < keys.length; i++) {
try {
var raw = localStorage.getItem(keys[i]);
if (!raw) continue;
try {
var parsed = JSON.parse(raw);
if (parsed) {
var tok = parsed.access_token || parsed.token || parsed.accessToken || '';
if (tok) return tok;
}
} catch (e2) { /* not JSON */ }
if (raw.length > 20 && raw.indexOf('{') !== 0) return raw;
} catch (e) { /* skip */ }
}
return '';
}
T.getAuthToken = async function () {
return _capturedToken || (await T.captureAuthToken()) || getAuthTokenFromStorage();
};
// ─── DELETE (needs raw fetch fallback) ──────────────────────
T.apiDelete = async function (path) {
return await _fetchJSON('DELETE', path);
};
T.safeDelete = async function (path, retries) {
retries = retries || 2;
for (var attempt = 0; attempt <= retries; attempt++) {
try {
return await T.apiDelete(path);
} catch (e) {
// On 502/503, wait and retry (proxy may be recovering)
if (attempt < retries && e.message && (e.message.indexOf('502') !== -1 || e.message.indexOf('503') !== -1)) {
await new Promise(function (r) { setTimeout(r, 500 * (attempt + 1)); });
continue;
}
// Last resort: try without auth header (some DELETE endpoints accept this)
try {
var resp = await fetch(T.base + '/api/v1' + path, {
method: 'DELETE', credentials: 'same-origin'
});
if (resp.ok || resp.status === 204) return {};
} catch (e2) { /* give up */ }
// Swallow cleanup errors on final attempt — don't let cleanup failures mask test results
if (attempt >= retries) {
console.warn('[ICD] cleanup failed: DELETE ' + path + ' — ' + e.message);
return {};
}
}
}
return {};
};
// ── File Upload (multipart/form-data) ──────────────────────
T.apiUpload = async function (path, file, filename) {
var token = await T.getAuthToken();
var fd = new FormData();
fd.append('file', file, filename || 'test.txt');
var resp = await fetch(T.base + '/api/v1' + path, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
credentials: 'same-origin',
body: fd
});
var text = await resp.text();
var data;
try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; }
data._status = resp.status;
if (!resp.ok) throw new Error('Upload ' + path + ' → ' + resp.status + (data.error ? ': ' + data.error : ''));
return data;
};
// ─── Auth Fetch (explicit token for test users) ─────────────
T.authFetch = async function (token, method, path, body) {
var opts = {
method: method,
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
credentials: 'same-origin'
};
if (body !== undefined && method !== 'GET') opts.body = JSON.stringify(body);
var resp = await fetch(T.base + '/api/v1' + path, opts);
var text = await resp.text();
var data;
try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; }
data._status = resp.status;
return data;
};
T.publicPost = async function (path, body) {
var resp = await fetch(T.base + '/api/v1' + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
var text = await resp.text();
var data;
try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; }
data._status = resp.status;
return data;
};
// Session-scoped fetch: uses cookies only (no JWT). For visitor workflow tests.
T.sessionGet = async function (path) {
var resp = await fetch(T.base + '/api/v1' + path, {
method: 'GET',
credentials: 'include'
});
var text = await resp.text();
var data;
try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; }
data._status = resp.status;
return data;
};
T.sessionPost = async function (path, body) {
var resp = await fetch(T.base + '/api/v1' + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body)
});
var text = await resp.text();
var data;
try { data = text ? JSON.parse(text) : {}; } catch (e) { data = { _raw: text }; }
data._status = resp.status;
return data;
};
})();