Feat v0.7.1 surface runner framework (#55)
All checks were successful
All checks were successful
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:
@@ -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
400
src/js/sw/sdk/testing.js
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user