Feat v0.7.1 surface runner framework (#55)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / test-sqlite (push) Successful in 3m2s
CI/CD / build-and-deploy (push) Successful in 1m26s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #55.
This commit is contained in:
2026-04-01 23:01:38 +00:00
committed by xcaliber
parent e916ed41ea
commit 829caa3b20
59 changed files with 2509 additions and 6525 deletions

View File

@@ -25,6 +25,7 @@ import { createRealtime } from './realtime.js';
import { createRenderers } from './renderers.js';
import { createMarkdown } from './markdown.js';
import { createUsers } from './users.js';
import { createTesting } from './testing.js';
import { confirm } from '../primitives/confirm.js';
import { prompt } from '../primitives/prompt.js';
@@ -121,6 +122,9 @@ export async function boot() {
// Users — identity resolution with local caching
sw.users = createUsers(restClient);
// Testing — structured test framework for surface runners
sw.testing = createTesting(events, restClient);
// Shell helpers — imperative confirm/prompt backed by primitives
sw.confirm = confirm;
sw.prompt = prompt;
@@ -183,7 +187,7 @@ export async function boot() {
};
// Marker for idempotency
sw._sdk = '0.7.0';
sw._sdk = '0.7.1';
// 8. Expose globally
window.sw = sw;

400
src/js/sw/sdk/testing.js Normal file
View File

@@ -0,0 +1,400 @@
// ==========================================
// Armature — SDK Testing Module
// ==========================================
// Structured test framework for surface runners.
// Provides suite/test registration, lifecycle hooks,
// auto-cleanup, assertions, and machine-readable results.
//
// Usage:
// sw.testing.suite('my-suite', async (s) => {
// s.test('example', async (t) => {
// t.assert.ok(true, 'works');
// });
// });
// const results = await sw.testing.run();
//
// Exports: createTesting(events, restClient)
// ==========================================
/** Endpoint map for auto-cleanup by resource type. */
const CLEANUP_ENDPOINTS = {
channel: '/api/v1/channels/',
note: '/api/v1/ext/notes/notes/',
folder: '/api/v1/ext/notes/folders/',
workflow: '/api/v1/workflows/',
schedule: '/api/v1/schedules/',
package: '/api/v1/admin/packages/',
user: '/api/v1/admin/users/',
team: '/api/v1/admin/teams/',
};
/** Sentinel for skip flow control. */
class SkipError extends Error {
constructor(reason) { super(reason); this.name = 'SkipError'; }
}
/** Sentinel for assertion failures. */
class AssertionError extends Error {
constructor(msg) { super(msg); this.name = 'AssertionError'; }
}
// ── Assertions ──────────────────────────────────────────────────
function createAssertions(warnings) {
function fail(msg) { throw new AssertionError(msg || 'assertion failed'); }
return {
ok(val, msg) {
if (!val) fail(msg || 'expected truthy, got ' + JSON.stringify(val));
},
eq(a, b, msg) {
const sa = JSON.stringify(a), sb = JSON.stringify(b);
if (sa !== sb) fail(msg || 'expected ' + sb + ', got ' + sa);
},
neq(a, b, msg) {
if (JSON.stringify(a) === JSON.stringify(b))
fail(msg || 'expected not equal: ' + JSON.stringify(a));
},
gt(a, b, msg) {
if (!(a > b)) fail(msg || 'expected ' + a + ' > ' + b);
},
match(str, re, msg) {
if (!(re instanceof RegExp)) re = new RegExp(re);
if (!re.test(str)) fail(msg || JSON.stringify(str) + ' does not match ' + re);
},
async throws(fn, msg) {
let threw = false;
try { await fn(); } catch (_) { threw = true; }
if (!threw) fail(msg || 'expected function to throw');
},
status(resp, code, msg) {
const actual = resp?._status ?? resp?.status;
if (actual !== code)
fail(msg || 'expected status ' + code + ', got ' + actual);
},
shape(obj, schema, label) {
validateShape(obj, schema, label || 'shape');
},
arrayOf(arr, schema, label) {
const pfx = label || 'arrayOf';
if (!Array.isArray(arr)) fail(pfx + ': expected array, got ' + typeof arr);
if (arr.length > 0) validateShape(arr[0], schema, pfx + '[0]');
},
};
}
/**
* Validate an object against a shape schema.
* Schema keys map to type strings: 'string', 'number', 'bool', 'array', 'object', 'uuid'.
* Append '?' for nullable/optional.
*/
function validateShape(obj, schema, label) {
if (obj == null) throw new AssertionError(label + ': object is null/undefined');
for (const key of Object.keys(schema)) {
let spec = schema[key];
const optional = spec.endsWith('?');
if (optional) spec = spec.slice(0, -1);
const val = obj[key];
if (val === null || val === undefined) {
if (!optional) throw new AssertionError(label + '.' + key + ': missing (expected ' + spec + ')');
continue;
}
let ok = false;
switch (spec) {
case 'string': ok = typeof val === 'string'; break;
case 'number': ok = typeof val === 'number'; break;
case 'bool': ok = typeof val === 'boolean'; break;
case 'array': ok = Array.isArray(val); break;
case 'object': ok = typeof val === 'object' && !Array.isArray(val); break;
case 'uuid': ok = typeof val === 'string' && /^[0-9a-f-]{36}$/.test(val); break;
default: ok = true; // unknown spec — pass
}
if (!ok)
throw new AssertionError(label + '.' + key + ': expected ' + spec + ', got ' + typeof val + ' (' + JSON.stringify(val).slice(0, 60) + ')');
}
}
// ── Suite Execution ─────────────────────────────────────────────
/**
* Run a single registered suite. Returns a suite result object.
*/
async function runSuite(name, suiteFn, restClient) {
const tests = [];
let beforeAllFn = null, afterAllFn = null;
let beforeEachFn = null, afterEachFn = null;
const tracked = []; // suite-level cleanup: [{type, id}]
let suiteSkipped = null;
// Suite context
const s = {
test(tName, tFn) { tests.push({ name: tName, fn: tFn }); },
beforeAll(fn) { beforeAllFn = fn; },
afterAll(fn) { afterAllFn = fn; },
beforeEach(fn) { beforeEachFn = fn; },
afterEach(fn) { afterEachFn = fn; },
track(type, id) { tracked.push({ type, id }); },
skip(reason) { throw new SkipError(reason || 'skipped'); },
};
// Register tests by calling suite function
try {
await suiteFn(s);
} catch (e) {
if (e instanceof SkipError) {
return {
name, status: 'skipped', reason: e.message,
duration_ms: 0, tests: [],
};
}
return {
name, status: 'failed', duration_ms: 0,
tests: [{ name: '(suite setup)', status: 'failed', duration_ms: 0, detail: e.message, warnings: [], cleanup: null }],
};
}
const suiteStart = performance.now();
const results = [];
// beforeAll
try {
if (beforeAllFn) await beforeAllFn();
} catch (e) {
if (e instanceof SkipError) {
suiteSkipped = e.message;
} else {
// beforeAll failure → entire suite fails
results.push({ name: '(beforeAll)', status: 'failed', duration_ms: 0, detail: e.message, warnings: [], cleanup: null });
await doCleanup(tracked, restClient);
return {
name, status: 'failed',
duration_ms: Math.round(performance.now() - suiteStart),
tests: results,
};
}
}
if (suiteSkipped) {
await doCleanup(tracked, restClient);
return {
name, status: 'skipped', reason: suiteSkipped,
duration_ms: Math.round(performance.now() - suiteStart),
tests: [],
};
}
// Run each test
for (const t of tests) {
const tStart = performance.now();
const warnings = [];
const testTracked = [];
let status = 'passed';
let detail = null;
const ctx = {
assert: createAssertions(warnings),
track(type, id) {
testTracked.push({ type, id });
tracked.push({ type, id }); // also in suite queue
},
warn(msg) {
warnings.push(msg);
if (status === 'passed') status = 'warned';
},
browserOnly(fn) {
if (typeof document !== 'undefined' && !window.__HEADLESS__) {
fn();
}
},
skip(reason) { throw new SkipError(reason || 'skipped'); },
};
try {
if (beforeEachFn) await beforeEachFn();
await t.fn(ctx);
if (afterEachFn) await afterEachFn();
} catch (e) {
if (e instanceof SkipError) {
status = 'skipped';
detail = e.message;
} else {
status = 'failed';
detail = e.message || String(e);
}
}
results.push({
name: t.name,
status,
duration_ms: Math.round(performance.now() - tStart),
detail,
warnings,
cleanup: testTracked.length > 0 ? { tracked: testTracked.length, cleaned: 0, failed: 0 } : null,
});
}
// afterAll (always runs)
try {
if (afterAllFn) await afterAllFn();
} catch (e) {
results.push({
name: '(afterAll)', status: 'warned', duration_ms: 0,
detail: 'afterAll error: ' + e.message, warnings: [e.message], cleanup: null,
});
}
// Auto-cleanup
const cleanupResult = await doCleanup(tracked, restClient);
// Annotate cleanup stats on the last result entry if any
if (cleanupResult.total > 0 && results.length > 0) {
const last = results[results.length - 1];
if (!last.cleanup) last.cleanup = { tracked: 0, cleaned: 0, failed: 0 };
last.cleanup.tracked += cleanupResult.total;
last.cleanup.cleaned += cleanupResult.cleaned;
last.cleanup.failed += cleanupResult.failed;
}
// Suite status = worst among tests
let suiteStatus = 'passed';
for (const r of results) {
if (r.status === 'failed') { suiteStatus = 'failed'; break; }
if (r.status === 'warned') suiteStatus = 'warned';
}
return {
name,
status: suiteStatus,
duration_ms: Math.round(performance.now() - suiteStart),
tests: results,
};
}
/**
* Delete tracked resources in LIFO order. Returns cleanup stats.
*/
async function doCleanup(tracked, restClient) {
if (!tracked.length) return { total: 0, cleaned: 0, failed: 0 };
let cleaned = 0, failed = 0;
const total = tracked.length;
// LIFO
for (let i = tracked.length - 1; i >= 0; i--) {
const { type, id } = tracked[i];
const endpoint = CLEANUP_ENDPOINTS[type];
if (!endpoint) {
console.warn('[sw.testing] Unknown cleanup type:', type);
failed++;
continue;
}
try {
await restClient.del(endpoint + id);
cleaned++;
} catch (e) {
console.warn('[sw.testing] Cleanup failed:', type, id, e.message);
failed++;
}
}
tracked.length = 0; // clear
return { total, cleaned, failed };
}
// ── Public Factory ──────────────────────────────────────────────
/**
* Create the sw.testing module.
* @param {object} events — SDK event bus (for 'testing.complete' emission)
* @param {object} restClient — SDK rest client (for auto-cleanup DELETE calls)
*/
export function createTesting(events, restClient) {
const registry = new Map(); // name → suiteFn
let lastResults = null;
return {
/**
* Register a test suite.
* @param {string} name — suite name (e.g. 'icd/smoke')
* @param {function} fn — async function receiving suite context `s`
*/
suite(name, fn) {
registry.set(name, fn);
},
/**
* Run one suite (by name) or all registered suites.
* Returns a structured result object.
*/
async run(name) {
const start = performance.now();
const suitesToRun = [];
if (name) {
const fn = registry.get(name);
if (!fn) throw new Error('Suite not found: ' + name);
suitesToRun.push({ name, fn });
} else {
for (const [n, fn] of registry) {
suitesToRun.push({ name: n, fn });
}
}
const suiteResults = [];
const summary = { total: 0, passed: 0, failed: 0, warned: 0, skipped: 0 };
for (const { name: sName, fn } of suitesToRun) {
const result = await runSuite(sName, fn, restClient);
suiteResults.push(result);
// Aggregate
for (const t of result.tests) {
summary.total++;
if (t.status === 'passed') summary.passed++;
else if (t.status === 'failed') summary.failed++;
else if (t.status === 'warned') summary.warned++;
else if (t.status === 'skipped') summary.skipped++;
}
// Count skipped suites (no tests ran)
if (result.status === 'skipped') summary.skipped++;
}
const results = {
timestamp: new Date().toISOString(),
duration_ms: Math.round(performance.now() - start),
summary,
suites: suiteResults,
};
lastResults = results;
events.emit('testing.complete', results, { localOnly: true });
return results;
},
/**
* Return last run results (or null).
*/
results() {
return lastResults;
},
/**
* Listen for testing events.
* @param {string} event — event name (e.g. 'complete')
* @param {function} fn — handler
*/
on(event, fn) {
events.on('testing.' + event, fn);
},
/**
* List registered suite names.
*/
suites() {
return Array.from(registry.keys());
},
/**
* Clear all registered suites. Used when reloading runner scripts.
*/
clear() {
registry.clear();
lastResults = null;
},
};
}