Changeset 0.37.17 (#229)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
357
packages/sdk-test-runner/js/framework.js
Normal file
357
packages/sdk-test-runner/js/framework.js
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* SDK Test Runner — Framework
|
||||
*
|
||||
* Dual-path validation: every test runs through the SDK first. If it
|
||||
* fails, a raw fetch against the ICD-specified endpoint isolates the
|
||||
* fault:
|
||||
*
|
||||
* SDK pass → PASS (both layers good)
|
||||
* SDK fail + ICD pass → SDK_BUG (wrong method/path/unwrap)
|
||||
* SDK fail + ICD fail → ICD_BUG (backend broken)
|
||||
* Validate fail → SHAPE_BUG (response doesn't match ICD contract)
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.SDKR;
|
||||
if (!T) return;
|
||||
|
||||
// ─── Results ───────────────────────────────────────────────
|
||||
|
||||
T.results = [];
|
||||
T.cleanup = [];
|
||||
T.stats = { pass: 0, sdk_bug: 0, icd_bug: 0, shape_bug: 0, skip: 0 };
|
||||
T.running = false;
|
||||
T.aborted = false;
|
||||
|
||||
// ─── 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'; }
|
||||
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 bool, got ' + actual; }
|
||||
else if (type === 'object') { if (typeof val !== 'object' || val === null || Array.isArray(val)) return path + ': expected object, got ' + actual; }
|
||||
else if (type === 'object?') { if (val !== null && val !== undefined && (typeof val !== 'object' || Array.isArray(val))) return path + ': expected object|null, 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) {
|
||||
return [prefix + ': expected object, got ' + (obj === null ? 'null' : typeof obj)];
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
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, got ' + (typeof arr));
|
||||
if (arr.length > 0) T.assertShape(arr[0], schema, (label || '$') + '[0]');
|
||||
};
|
||||
|
||||
/**
|
||||
* Unwrap SDK list response — handles both bare array and paginated envelope.
|
||||
* SDK _unwrap returns { data: [...], total, ... } when pagination metadata
|
||||
* exists, or a bare array when the envelope has only { data: [...] }.
|
||||
*/
|
||||
T.unwrapList = function (r) {
|
||||
if (Array.isArray(r)) return r;
|
||||
if (r && Array.isArray(r.data)) return r.data;
|
||||
throw new Error('expected array or { data: [...] } envelope, got ' + (typeof r));
|
||||
};
|
||||
|
||||
T.assertEnum = function (val, allowed, path) {
|
||||
if (allowed.indexOf(val) === -1)
|
||||
throw new Error(path + ': "' + val + '" not in [' + allowed.join(', ') + ']');
|
||||
};
|
||||
|
||||
// ─── Raw Fetch (ICD path) ─────────────────────────────────
|
||||
|
||||
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' && method !== 'DELETE')
|
||||
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.raw = {
|
||||
get: function (path) { return _fetchJSON('GET', path); },
|
||||
post: function (path, body) { return _fetchJSON('POST', path, body); },
|
||||
put: function (path, body) { return _fetchJSON('PUT', path, body); },
|
||||
patch: function (path, body) { return _fetchJSON('PATCH', path, body); },
|
||||
del: function (path) { return _fetchJSON('DELETE', path); },
|
||||
};
|
||||
|
||||
T.safeDelete = async function (path) {
|
||||
try { await T.raw.del(path); } catch (e) {
|
||||
console.warn('[SDKR] cleanup failed: DELETE ' + path + ' — ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Auth Token ────────────────────────────────────────────
|
||||
|
||||
var _token = '';
|
||||
|
||||
function _readToken() {
|
||||
var env = (T.base || '').replace(/^\//, '') || 'default';
|
||||
var keys = ['sb_auth_' + env, 'sb_auth', 'sb_token'];
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
try {
|
||||
var raw = localStorage.getItem(keys[i]);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
var p = JSON.parse(raw);
|
||||
if (p) { var t = p.access_token || p.token || p.accessToken || ''; if (t) return t; }
|
||||
} catch (_) { /* not JSON */ }
|
||||
if (raw.length > 20 && raw.indexOf('{') !== 0) return raw;
|
||||
} catch (_) { /* skip */ }
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
T.getAuthToken = async function () {
|
||||
if (!_token) _token = _readToken();
|
||||
return _token;
|
||||
};
|
||||
|
||||
// ─── Cleanup ───────────────────────────────────────────────
|
||||
|
||||
T.registerCleanup = function (fn) { T.cleanup.push(fn); };
|
||||
|
||||
T.runCleanup = async function () {
|
||||
var fns = T.cleanup.splice(0);
|
||||
for (var i = fns.length - 1; i >= 0; i--) {
|
||||
try { await fns[i](); } catch (_) { /* swallow */ }
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Dual-Path Test ────────────────────────────────────────
|
||||
//
|
||||
// sdkFn: async () => result — calls sw.api.*
|
||||
// rawSpec: { method, path, body? } — ICD-correct HTTP call
|
||||
// validate: (result) => void — throws if shape is wrong
|
||||
//
|
||||
// Returns: { verdict, sdkErr?, rawErr?, result? }
|
||||
|
||||
T.dualTest = async function (sdkFn, rawSpec, validate) {
|
||||
// 1. Try SDK
|
||||
var sdkResult, sdkErr;
|
||||
try {
|
||||
sdkResult = await sdkFn();
|
||||
} catch (e) {
|
||||
sdkErr = e;
|
||||
}
|
||||
|
||||
if (!sdkErr) {
|
||||
// SDK call succeeded — validate shape
|
||||
try {
|
||||
if (validate) validate(sdkResult);
|
||||
return { verdict: 'PASS', result: sdkResult };
|
||||
} catch (e) {
|
||||
// Shape mismatch — might be _unwrap bug. Confirm with raw.
|
||||
var rawResult2;
|
||||
try {
|
||||
rawResult2 = await _fetchJSON(rawSpec.method, rawSpec.path, rawSpec.body);
|
||||
} catch (_) { /* ignore raw errors in this branch */ }
|
||||
return { verdict: 'SHAPE_BUG', sdkErr: e.message, sdkResult: sdkResult, rawResult: rawResult2 };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. SDK failed — try raw ICD fetch
|
||||
if (!rawSpec) {
|
||||
return { verdict: 'SDK_BUG', sdkErr: sdkErr.message, note: 'no raw spec provided' };
|
||||
}
|
||||
|
||||
var rawResult, rawErr;
|
||||
try {
|
||||
rawResult = await _fetchJSON(rawSpec.method, rawSpec.path, rawSpec.body);
|
||||
} catch (e) {
|
||||
rawErr = e;
|
||||
}
|
||||
|
||||
if (rawErr) {
|
||||
// Both failed → ICD/backend issue
|
||||
return { verdict: 'ICD_BUG', sdkErr: sdkErr.message, rawErr: rawErr.message };
|
||||
}
|
||||
|
||||
// Raw succeeded, SDK failed → SDK has a bug
|
||||
// Optionally validate the raw result too
|
||||
var rawValid = true;
|
||||
if (validate) {
|
||||
try { validate(rawResult); } catch (e) { rawValid = false; }
|
||||
}
|
||||
|
||||
return {
|
||||
verdict: 'SDK_BUG',
|
||||
sdkErr: sdkErr.message,
|
||||
rawResult: rawResult,
|
||||
rawValid: rawValid
|
||||
};
|
||||
};
|
||||
|
||||
// ─── Test Harness ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register and run a single test.
|
||||
*
|
||||
* @param {string} domain — e.g. 'channels', 'workflows'
|
||||
* @param {string} group — e.g. 'crud', 'list', 'update'
|
||||
* @param {string} name — human-readable test name
|
||||
* @param {object} spec — { sdk, raw, validate }
|
||||
* sdk: async () => result
|
||||
* raw: { method: 'GET'|'POST'|..., path: '/channels', body?: {...} }
|
||||
* validate: (result) => void (throw on failure)
|
||||
*
|
||||
* For tests that don't use dual-path (e.g. setup/cleanup), pass a
|
||||
* plain async function as the 4th argument instead of a spec object.
|
||||
*/
|
||||
T.test = async function (domain, group, name, specOrFn) {
|
||||
if (T.aborted) return;
|
||||
|
||||
var entry = {
|
||||
domain: domain, group: group, name: name,
|
||||
verdict: null, sdkErr: null, rawErr: null, note: null,
|
||||
durationMs: 0
|
||||
};
|
||||
|
||||
var t0 = performance.now();
|
||||
|
||||
try {
|
||||
// Skip support — mark as SKIP with reason
|
||||
if (typeof specOrFn === 'object' && specOrFn.skip) {
|
||||
entry.verdict = 'SKIP';
|
||||
entry.note = specOrFn.skip;
|
||||
} else if (typeof specOrFn === 'function') {
|
||||
// Simple test — no dual-path
|
||||
await specOrFn();
|
||||
entry.verdict = 'PASS';
|
||||
} else {
|
||||
// Dual-path test
|
||||
var r = await T.dualTest(specOrFn.sdk, specOrFn.raw, specOrFn.validate);
|
||||
entry.verdict = r.verdict;
|
||||
entry.sdkErr = r.sdkErr || null;
|
||||
entry.rawErr = r.rawErr || null;
|
||||
entry.note = r.note || null;
|
||||
}
|
||||
} catch (e) {
|
||||
entry.verdict = 'ERROR';
|
||||
entry.sdkErr = e.message;
|
||||
}
|
||||
|
||||
entry.durationMs = Math.round(performance.now() - t0);
|
||||
|
||||
// Update stats
|
||||
var v = entry.verdict.toLowerCase().replace('error', 'icd_bug');
|
||||
if (T.stats.hasOwnProperty(v)) T.stats[v]++;
|
||||
|
||||
T.results.push(entry);
|
||||
|
||||
// Live update
|
||||
if (typeof T.onResult === 'function') T.onResult(entry);
|
||||
};
|
||||
|
||||
// ─── Domain Registry ───────────────────────────────────────
|
||||
|
||||
T.domains = {};
|
||||
|
||||
T.registerDomain = function (name, fn) {
|
||||
T.domains[name] = fn;
|
||||
};
|
||||
|
||||
// ─── Run All ───────────────────────────────────────────────
|
||||
|
||||
T.runAll = async function (domainFilter) {
|
||||
T.running = true;
|
||||
T.aborted = false;
|
||||
T.results = [];
|
||||
T.cleanup = [];
|
||||
T.stats = { pass: 0, sdk_bug: 0, icd_bug: 0, shape_bug: 0, skip: 0 };
|
||||
|
||||
if (typeof T.onStart === 'function') T.onStart();
|
||||
|
||||
// Provision fixtures (policies, provider, team) before domains
|
||||
if (typeof T.provisionFixtures === 'function') {
|
||||
try { await T.provisionFixtures(); } catch (e) {
|
||||
console.warn('[SDKR] Fixture provisioning failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
var names = Object.keys(T.domains).sort();
|
||||
if (domainFilter) {
|
||||
names = names.filter(function (n) { return domainFilter.indexOf(n) !== -1; });
|
||||
}
|
||||
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
if (T.aborted) break;
|
||||
try {
|
||||
await T.domains[names[i]]();
|
||||
} catch (e) {
|
||||
T.results.push({
|
||||
domain: names[i], group: 'setup', name: 'domain crashed',
|
||||
verdict: 'ERROR', sdkErr: e.message, rawErr: null, note: null,
|
||||
durationMs: 0
|
||||
});
|
||||
}
|
||||
// Run cleanup between domains
|
||||
await T.runCleanup();
|
||||
}
|
||||
|
||||
// Teardown fixtures (revert policies, etc.)
|
||||
if (typeof T.teardownFixtures === 'function') {
|
||||
try { await T.teardownFixtures(); } catch (_) {}
|
||||
}
|
||||
|
||||
T.running = false;
|
||||
if (typeof T.onComplete === 'function') T.onComplete();
|
||||
};
|
||||
|
||||
T.abort = function () { T.aborted = true; };
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user