All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
429 lines
16 KiB
JavaScript
429 lines
16 KiB
JavaScript
/**
|
|
* Test Runners — Registry Surface
|
|
*
|
|
* Discovers installed test-runner packages, loads their suites into
|
|
* sw.testing, and provides a dashboard to run and view results.
|
|
*/
|
|
(async function () {
|
|
'use strict';
|
|
|
|
var mount = document.getElementById('extension-mount');
|
|
if (!mount) return;
|
|
mount.classList.add('ext-test-runners-root');
|
|
|
|
// ─── Boot SDK ─────────────────────────────────────────────
|
|
try {
|
|
var base = window.__BASE__ || '';
|
|
var ver = window.__VERSION__ || '0';
|
|
|
|
if (!window.preact) {
|
|
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
|
|
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
|
|
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
|
|
window.preact = { h, render };
|
|
window.hooks = hooksModule;
|
|
window.html = htmModule.default.bind(h);
|
|
}
|
|
|
|
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
|
|
await sdk.boot();
|
|
} catch (e) {
|
|
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
|
|
return;
|
|
}
|
|
|
|
var { h, render } = preact;
|
|
var { useState, useEffect, useCallback } = hooks;
|
|
|
|
// ─── Shell Topbar ─────────────────────────────────────────
|
|
if (sw.shell && sw.shell.topbar) {
|
|
sw.shell.topbar.setTitle('Test Runners');
|
|
}
|
|
|
|
// ─── Status Icons ─────────────────────────────────────────
|
|
var STATUS_ICON = {
|
|
passed: '\u2713', failed: '\u2717', warned: '\u26A0', skipped: '\u2014'
|
|
};
|
|
|
|
// ─── App Component ────────────────────────────────────────
|
|
|
|
function App() {
|
|
var _s = useState(null); var runners = _s[0], setRunners = _s[1];
|
|
var _s2 = useState(null); var allPkgs = _s2[0], setAllPkgs = _s2[1];
|
|
var _s3 = useState(null); var results = _s3[0], setResults = _s3[1];
|
|
var _s4 = useState(false); var running = _s4[0], setRunning = _s4[1];
|
|
var _s5 = useState(null); var runningSuite = _s5[0], setRunningSuite = _s5[1];
|
|
var _s6 = useState(''); var error = _s6[0], setError = _s6[1];
|
|
var _s7 = useState(false); var loaded = _s7[0], setLoaded = _s7[1];
|
|
|
|
// Discover runners and load their scripts
|
|
useEffect(function () {
|
|
discoverAndLoad().catch(function (e) {
|
|
setError('Failed to discover runners: ' + e.message);
|
|
});
|
|
}, []);
|
|
|
|
async function discoverAndLoad() {
|
|
// Fetch all packages (admin endpoint)
|
|
var pkgsResp = await sw.api.get('/api/v1/admin/packages');
|
|
var pkgs = pkgsResp.data || pkgsResp;
|
|
setAllPkgs(pkgs);
|
|
|
|
// Filter test-runner type
|
|
var testRunners = pkgs.filter(function (p) {
|
|
return p.type === 'test-runner' && p.enabled;
|
|
});
|
|
setRunners(testRunners);
|
|
|
|
// Clear any previously registered suites
|
|
sw.testing.clear();
|
|
|
|
// Load each runner's main.js to register suites.
|
|
// Runner main.js scripts are async IIFEs that load sub-modules sequentially,
|
|
// so the script onload fires before suites are registered. We load all runner
|
|
// scripts, then poll until suite count stabilizes.
|
|
var surfaceBase = (window.__BASE__ || '') + '/surfaces/';
|
|
for (var i = 0; i < testRunners.length; i++) {
|
|
var r = testRunners[i];
|
|
await loadRunnerScript(surfaceBase + r.id + '/js/main.js?v=' + (r.version || '0') + '.' + Date.now());
|
|
}
|
|
|
|
// Wait for suites to stabilize (runners load sub-modules async)
|
|
await waitForSuites();
|
|
|
|
setLoaded(true);
|
|
console.log('[TestRunners] Loaded ' + testRunners.length + ' runners, ' + sw.testing.suites().length + ' suites');
|
|
}
|
|
|
|
function loadRunnerScript(src) {
|
|
return new Promise(function (resolve) {
|
|
var s = document.createElement('script');
|
|
s.src = src;
|
|
s.onload = resolve;
|
|
s.onerror = function () {
|
|
console.warn('[TestRunners] Failed to load: ' + src);
|
|
resolve();
|
|
};
|
|
document.body.appendChild(s);
|
|
});
|
|
}
|
|
|
|
function waitForSuites() {
|
|
return new Promise(function (resolve) {
|
|
var lastCount = 0;
|
|
var stableCount = 0;
|
|
var interval = setInterval(function () {
|
|
var count = sw.testing.suites().length;
|
|
if (count === lastCount && count > 0) {
|
|
stableCount++;
|
|
if (stableCount >= 3) { clearInterval(interval); resolve(); }
|
|
} else {
|
|
stableCount = 0;
|
|
lastCount = count;
|
|
}
|
|
}, 500);
|
|
// Timeout after 30s regardless
|
|
setTimeout(function () { clearInterval(interval); resolve(); }, 30000);
|
|
});
|
|
}
|
|
|
|
// Check requires for a runner
|
|
function checkRequires(runner) {
|
|
var requires = runner.manifest?.requires || runner.requires || [];
|
|
if (!requires.length || !allPkgs) return { met: requires, missing: [] };
|
|
var installedIds = allPkgs.map(function (p) { return p.id; });
|
|
var met = [], missing = [];
|
|
for (var i = 0; i < requires.length; i++) {
|
|
if (installedIds.indexOf(requires[i]) !== -1) met.push(requires[i]);
|
|
else missing.push(requires[i]);
|
|
}
|
|
return { met: met, missing: missing };
|
|
}
|
|
|
|
// Run all suites
|
|
var runAll = useCallback(async function () {
|
|
if (running) return;
|
|
setRunning(true);
|
|
setResults(null);
|
|
setRunningSuite('all');
|
|
try {
|
|
var r = await sw.testing.run();
|
|
setResults(r);
|
|
} catch (e) {
|
|
setError('Run failed: ' + e.message);
|
|
}
|
|
setRunning(false);
|
|
setRunningSuite(null);
|
|
}, [running]);
|
|
|
|
// Run suites for a single runner
|
|
var runRunner = useCallback(async function (runner) {
|
|
if (running) return;
|
|
setRunning(true);
|
|
setRunningSuite(runner.id);
|
|
|
|
var prefix = runner.id === 'icd-test-runner' ? 'icd/' :
|
|
runner.id === 'sdk-test-runner' ? 'sdk/' : runner.id + '/';
|
|
var suiteNames = sw.testing.suites().filter(function (n) { return n.indexOf(prefix) === 0; });
|
|
|
|
var allSuiteResults = [];
|
|
var summary = { total: 0, passed: 0, failed: 0, warned: 0, skipped: 0 };
|
|
var start = performance.now();
|
|
|
|
for (var i = 0; i < suiteNames.length; i++) {
|
|
var r = await sw.testing.run(suiteNames[i]);
|
|
for (var j = 0; j < r.suites.length; j++) {
|
|
allSuiteResults.push(r.suites[j]);
|
|
for (var k = 0; k < r.suites[j].tests.length; k++) {
|
|
var t = r.suites[j].tests[k];
|
|
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++;
|
|
}
|
|
}
|
|
if (r.suites[0] && r.suites[0].status === 'skipped') summary.skipped++;
|
|
}
|
|
|
|
setResults({
|
|
timestamp: new Date().toISOString(),
|
|
duration_ms: Math.round(performance.now() - start),
|
|
summary: summary,
|
|
suites: allSuiteResults
|
|
});
|
|
setRunning(false);
|
|
setRunningSuite(null);
|
|
}, [running]);
|
|
|
|
if (error) {
|
|
return html`<div class="sw-inline-error"><p>${error}</p></div>`;
|
|
}
|
|
|
|
if (!loaded) {
|
|
return html`<div class="ext-test-runners-loading">Loading test runners...</div>`;
|
|
}
|
|
|
|
if (!runners || runners.length === 0) {
|
|
return html`<div class="ext-test-runners-empty">
|
|
<p>No test-runner packages installed.</p>
|
|
<p style="font-size:0.85rem;color:var(--text-muted)">Install packages with <code>"type": "test-runner"</code> to see them here.</p>
|
|
</div>`;
|
|
}
|
|
|
|
var suiteCount = sw.testing.suites().length;
|
|
|
|
return html`
|
|
<div>
|
|
<${RunAllBar}
|
|
suiteCount=${suiteCount}
|
|
results=${results}
|
|
running=${running}
|
|
runningSuite=${runningSuite}
|
|
onRunAll=${runAll}
|
|
/>
|
|
<div class="ext-test-runners-grid">
|
|
${runners.map(function (r) {
|
|
return html`<${RunnerCard}
|
|
key=${r.id}
|
|
runner=${r}
|
|
requires=${checkRequires(r)}
|
|
results=${results}
|
|
running=${running}
|
|
runningSuite=${runningSuite}
|
|
onRun=${function () { runRunner(r); }}
|
|
/>`;
|
|
})}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ─── Export Helpers ─────────────────────────────────────────
|
|
|
|
function exportFailures(results) {
|
|
if (!results) return;
|
|
var failures = [];
|
|
for (var i = 0; i < results.suites.length; i++) {
|
|
var suite = results.suites[i];
|
|
for (var j = 0; j < suite.tests.length; j++) {
|
|
var t = suite.tests[j];
|
|
if (t.status === 'failed' || t.status === 'warned') {
|
|
failures.push({
|
|
suite: suite.name,
|
|
test: t.name,
|
|
status: t.status,
|
|
detail: t.detail || null,
|
|
warnings: t.warnings && t.warnings.length > 0 ? t.warnings : undefined,
|
|
duration_ms: t.duration_ms,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
var report = {
|
|
exported_at: new Date().toISOString(),
|
|
run_at: results.timestamp,
|
|
duration_ms: results.duration_ms,
|
|
summary: results.summary,
|
|
failures: failures,
|
|
};
|
|
var json = JSON.stringify(report, null, 2);
|
|
var blob = new Blob([json], { type: 'application/json' });
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'test-failures-' + new Date().toISOString().slice(0, 19).replace(/:/g, '-') + '.json';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function exportFull(results) {
|
|
if (!results) return;
|
|
var json = JSON.stringify(results, null, 2);
|
|
var blob = new Blob([json], { type: 'application/json' });
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'test-results-' + new Date().toISOString().slice(0, 19).replace(/:/g, '-') + '.json';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// ─── RunAllBar ────────────────────────────────────────────
|
|
|
|
function RunAllBar(props) {
|
|
var s = props.results?.summary;
|
|
var hasFailures = s && (s.failed > 0 || s.warned > 0);
|
|
return html`
|
|
<div class="ext-test-runners-bar">
|
|
<h2>Test Runners</h2>
|
|
${props.running
|
|
? html`<span class="ext-test-runners-running"><span class="ext-test-runners-spinner"></span> Running${props.runningSuite === 'all' ? ' all' : ''}...</span>`
|
|
: html`<button class="sw-btn sw-btn--primary" onClick=${props.onRunAll}>Run All (${props.suiteCount} suites)</button>`
|
|
}
|
|
${s ? html`
|
|
<div class="ext-test-runners-summary">
|
|
<span class="ext-test-runners-stat ext-test-runners-stat--pass">${s.passed} passed</span>
|
|
${s.failed > 0 ? html`<span class="ext-test-runners-stat ext-test-runners-stat--fail">${s.failed} failed</span>` : null}
|
|
${s.warned > 0 ? html`<span class="ext-test-runners-stat ext-test-runners-stat--warn">${s.warned} warned</span>` : null}
|
|
${s.skipped > 0 ? html`<span class="ext-test-runners-stat ext-test-runners-stat--skip">${s.skipped} skipped</span>` : null}
|
|
<span style="color:var(--text-3);font-size:0.8rem">${props.results.duration_ms}ms</span>
|
|
</div>
|
|
` : null}
|
|
</div>
|
|
${s && !props.running ? html`
|
|
<div class="ext-test-runners-export">
|
|
${hasFailures ? html`<button class="sw-btn sw-btn--sm" onClick=${function () { exportFailures(props.results); }}>Export Failures</button>` : null}
|
|
<button class="sw-btn sw-btn--sm" onClick=${function () { exportFull(props.results); }}>Export Full Results</button>
|
|
</div>
|
|
` : null}
|
|
`;
|
|
}
|
|
|
|
// ─── RunnerCard ───────────────────────────────────────────
|
|
|
|
function RunnerCard(props) {
|
|
var r = props.runner;
|
|
var manifest = r.manifest || {};
|
|
var icon = manifest.icon || r.icon || '📦';
|
|
var title = r.title || manifest.title || r.id;
|
|
var desc = manifest.description || r.description || '';
|
|
var req = props.requires;
|
|
|
|
// Filter results for this runner's suites
|
|
var prefix = r.id === 'icd-test-runner' ? 'icd/' :
|
|
r.id === 'sdk-test-runner' ? 'sdk/' : r.id + '/';
|
|
var runnerSuites = props.results?.suites?.filter(function (s) {
|
|
return s.name.indexOf(prefix) === 0;
|
|
}) || [];
|
|
|
|
return html`
|
|
<div class="ext-test-runners-card">
|
|
<div class="ext-test-runners-card__header">
|
|
<span class="ext-test-runners-card__icon">${icon}</span>
|
|
<h3 class="ext-test-runners-card__title">${title}</h3>
|
|
${req.missing.length > 0
|
|
? null
|
|
: props.running
|
|
? (props.runningSuite === r.id
|
|
? html`<span class="ext-test-runners-running"><span class="ext-test-runners-spinner"></span></span>`
|
|
: null)
|
|
: html`<button class="sw-btn sw-btn--sm" onClick=${props.onRun}>Run</button>`
|
|
}
|
|
</div>
|
|
${desc ? html`<p class="ext-test-runners-card__desc">${desc}</p>` : null}
|
|
${req.missing.length > 0
|
|
? html`<p class="ext-test-runners-card__requires ext-test-runners-card__requires--missing">
|
|
Missing required packages: ${req.missing.join(', ')}
|
|
</p>`
|
|
: req.met.length > 0
|
|
? html`<p class="ext-test-runners-card__requires">Requires: ${req.met.join(', ')}</p>`
|
|
: null
|
|
}
|
|
${runnerSuites.length > 0 ? html`<${ResultsPanel} suites=${runnerSuites} />` : null}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ─── ResultsPanel ─────────────────────────────────────────
|
|
|
|
function ResultsPanel(props) {
|
|
return html`
|
|
<div class="ext-test-runners-results">
|
|
${props.suites.map(function (suite) {
|
|
return html`<${SuiteResult} key=${suite.name} suite=${suite} />`;
|
|
})}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function SuiteResult(props) {
|
|
var _s = useState(true); var expanded = _s[0], setExpanded = _s[1];
|
|
var suite = props.suite;
|
|
var statusClass = 'ext-test-runners-status--' + suite.status;
|
|
|
|
var counts = { passed: 0, failed: 0, warned: 0, skipped: 0 };
|
|
for (var i = 0; i < suite.tests.length; i++) {
|
|
var s = suite.tests[i].status;
|
|
if (counts[s] !== undefined) counts[s]++;
|
|
}
|
|
|
|
return html`
|
|
<div class="ext-test-runners-suite">
|
|
<div class="ext-test-runners-suite__name ${statusClass}"
|
|
onClick=${function () { setExpanded(!expanded); }}>
|
|
${expanded ? '▼' : '▶'} ${suite.name}
|
|
${' '}(${counts.passed}/${suite.tests.length} passed${counts.failed ? ', ' + counts.failed + ' failed' : ''}${suite.status === 'skipped' ? ' — skipped' + (suite.reason ? ': ' + suite.reason : '') : ''})
|
|
${' '}${suite.duration_ms}ms
|
|
</div>
|
|
${expanded && suite.tests.length > 0 ? suite.tests.map(function (t) {
|
|
return html`<${TestResult} key=${t.name} test=${t} />`;
|
|
}) : null}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function TestResult(props) {
|
|
var t = props.test;
|
|
var statusClass = 'ext-test-runners-status--' + t.status;
|
|
var icon = STATUS_ICON[t.status] || '?';
|
|
|
|
return html`
|
|
<div>
|
|
<div class="ext-test-runners-test">
|
|
<span class="ext-test-runners-test__status ${statusClass}">${icon}</span>
|
|
<span class="ext-test-runners-test__name">${t.name}</span>
|
|
<span class="ext-test-runners-test__time">${t.duration_ms}ms</span>
|
|
</div>
|
|
${t.detail ? html`<div class="ext-test-runners-test__detail">${t.detail}</div>` : null}
|
|
${t.warnings && t.warnings.length > 0 ? t.warnings.map(function (w) {
|
|
return html`<div class="ext-test-runners-test__detail" style="color:var(--warning,#856404)">${w}</div>`;
|
|
}) : null}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ─── Render ───────────────────────────────────────────────
|
|
render(html`<${App} />`, mount);
|
|
})();
|