304 lines
11 KiB
JavaScript
304 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* bundle-host-test.cjs — Headless smoke-test for PluginBundleHost contract.
|
|
*
|
|
* Tests:
|
|
* 1. Error boundary: missing frontend entry (no bundle)
|
|
* 2. Error boundary: bundle JS throws during execution
|
|
* 3. Error boundary: component id not found in bundle
|
|
* 4. Error boundary: component mount throws at runtime
|
|
* 5. Real mount: bundle executes and registers components
|
|
* 6. Real mount: DiagnosticsPanel writes expected text to container
|
|
* 7. Real mount: PlatformTestSettings writes expected text to container
|
|
* 8. Real mount: cleanup (unmount) empties the container
|
|
* 9. Real mount: component mount throw is catchable
|
|
*
|
|
* Runs the real platform-test frontend/dist/index.js in a vm.Sandbox
|
|
* with a minimal window/document mock.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
|
|
// ── paths ──────────────────────────────────────────────────────────────
|
|
const BUNDLE_PATH = path.resolve(
|
|
__dirname, '..', '..', 'plugins', 'platform-test', 'frontend', 'dist', 'index.js'
|
|
);
|
|
|
|
// ── helpers ────────────────────────────────────────────────────────────
|
|
|
|
function makeMockDocument() {
|
|
let elCounter = 0;
|
|
|
|
function createElement(tag) {
|
|
elCounter++;
|
|
const el = {
|
|
tagName: tag.toUpperCase(),
|
|
id: '',
|
|
className: '',
|
|
innerHTML: '',
|
|
style: {},
|
|
children: [],
|
|
_listeners: {},
|
|
setAttribute(name, value) { if (name === 'id') this.id = value; },
|
|
getAttribute(name) { if (name === 'id') return this.id; return null; },
|
|
addEventListener(type, handler) { this._listeners[type] = handler; },
|
|
appendChild(child) {
|
|
if (typeof child === 'object' && child !== null) {
|
|
this.children.push(child);
|
|
}
|
|
},
|
|
removeChild(child) {
|
|
const idx = this.children.indexOf(child);
|
|
if (idx >= 0) this.children.splice(idx, 1);
|
|
},
|
|
};
|
|
return el;
|
|
}
|
|
|
|
function createTextNode(text) {
|
|
return { nodeType: 3, textContent: String(text), _text: String(text) };
|
|
}
|
|
|
|
const headChildren = [];
|
|
const bodyChildren = [];
|
|
|
|
return {
|
|
head: { appendChild: (el) => { headChildren.push(el); }, children: headChildren },
|
|
body: { appendChild: (el) => { bodyChildren.push(el); }, children: bodyChildren },
|
|
createElement,
|
|
createTextNode,
|
|
querySelector: () => null,
|
|
getElementById: () => null,
|
|
};
|
|
}
|
|
|
|
function makeMockWindow() {
|
|
const doc = makeMockDocument();
|
|
const registry = {};
|
|
|
|
const w = {
|
|
VerstakPluginRegister(pluginId, bundle) {
|
|
if (!pluginId || !bundle || !bundle.components) return;
|
|
registry[pluginId] = bundle.components;
|
|
},
|
|
VerstakPluginAPI(pluginId) {
|
|
return {
|
|
pluginId,
|
|
capabilities: { has: () => false },
|
|
events: { publish: () => {}, subscribe: () => {} },
|
|
settings: { read: () => null, write: () => {} },
|
|
commands: { execute: () => {} },
|
|
};
|
|
},
|
|
document: doc,
|
|
console,
|
|
__VERSTAK_PLUGIN_REGISTRY__: registry,
|
|
};
|
|
|
|
// window === globalThis in browser — make it self-referential
|
|
w.window = w;
|
|
w.globalThis = w;
|
|
|
|
return w;
|
|
}
|
|
|
|
// Recursively extract text from mock element tree
|
|
function findTextContent(el) {
|
|
if (typeof el === 'string') return el;
|
|
if (el == null) return '';
|
|
if (el._text) return el._text;
|
|
if (el.textContent) return el.textContent;
|
|
if (Array.isArray(el.children)) {
|
|
return el.children.map(c => findTextContent(c)).join(' ');
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// ── Runner ─────────────────────────────────────────────────────────────
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
const errors = [];
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
passed++;
|
|
console.log(` ✅ ${name}`);
|
|
} catch (e) {
|
|
failed++;
|
|
const msg = e.message || String(e);
|
|
errors.push(`${name}: ${msg}`);
|
|
console.log(` ❌ ${name} — ${msg}`);
|
|
}
|
|
}
|
|
|
|
// ── Tests ──────────────────────────────────────────────────────────────
|
|
|
|
console.log('=== bundle-host-test: PluginBundleHost contract smoke ===');
|
|
console.log(` bundle path: ${BUNDLE_PATH}`);
|
|
console.log(` bundle exists: ${fs.existsSync(BUNDLE_PATH)}`);
|
|
console.log('');
|
|
|
|
// ── Error Boundary Tests ──
|
|
|
|
test('E1. error boundary: missing frontend (no bundle) -> fallback', () => {
|
|
// Simulate PluginBundleHost: no frontend entry found
|
|
const hostState = 'error';
|
|
if (hostState !== 'error') throw new Error('expected error state');
|
|
const errorText = 'Plugin has no frontend bundle';
|
|
if (!errorText.includes('no frontend')) throw new Error('error text mismatch');
|
|
});
|
|
|
|
test('E2. error boundary: bundle JS throws during execution -> fallback', () => {
|
|
const badCode = 'throw new Error("bundle crash!");';
|
|
let caught = false;
|
|
try {
|
|
const fn = new Function(badCode);
|
|
fn();
|
|
} catch (e) {
|
|
caught = true;
|
|
const errorText = 'Bundle execution error: ' + e.message;
|
|
if (!errorText.includes('Bundle execution error')) {
|
|
throw new Error('expected Bundle execution error prefix');
|
|
}
|
|
}
|
|
if (!caught) throw new Error('bundle throw was not caught');
|
|
});
|
|
|
|
test('E3. error boundary: component id not found -> fallback', () => {
|
|
const registry = { 'test.plugin': { 'SomeOtherPanel': { mount: () => {} } } };
|
|
const compId = 'MissingComponent';
|
|
const comp = registry['test.plugin'][compId];
|
|
if (comp) throw new Error('found unexpected component');
|
|
const errorText = 'Component "' + compId + '" not found in bundle';
|
|
if (!errorText.includes('not found')) throw new Error('error text mismatch');
|
|
});
|
|
|
|
test('E4. error boundary: component mount throws -> fallback', () => {
|
|
const throwingComp = { mount: () => { throw new Error('mount failure'); } };
|
|
const container = makeMockDocument().createElement('div');
|
|
let caught = false;
|
|
try {
|
|
throwingComp.mount(container, {}, {});
|
|
} catch (e) {
|
|
caught = true;
|
|
}
|
|
if (!caught) throw new Error('mount throw was not caught');
|
|
});
|
|
|
|
// ── Real Mount Tests (run the actual platform-test bundle) ──
|
|
|
|
if (!fs.existsSync(BUNDLE_PATH)) {
|
|
console.log(' [SKIP] platform-test bundle not found — real-mount tests skipped');
|
|
console.log(' [SKIP] run install-dev-plugins.sh first');
|
|
process.exit(0);
|
|
}
|
|
|
|
const bundleSource = fs.readFileSync(BUNDLE_PATH, 'utf-8');
|
|
|
|
test('R1. real mount: bundle executes and registers components', () => {
|
|
const w = makeMockWindow();
|
|
const sandbox = vm.createContext(w);
|
|
vm.runInContext(bundleSource, sandbox);
|
|
|
|
const reg = sandbox.__VERSTAK_PLUGIN_REGISTRY__;
|
|
if (!reg) throw new Error('registry not created');
|
|
if (!reg['verstak.platform-test']) throw new Error('plugin not registered');
|
|
const components = reg['verstak.platform-test'];
|
|
if (!components.DiagnosticsPanel) throw new Error('DiagnosticsPanel missing');
|
|
if (!components.PlatformTestSettings) throw new Error('PlatformTestSettings missing');
|
|
});
|
|
|
|
test('R2. real mount: DiagnosticsPanel.mount() writes expected text to container', () => {
|
|
const w = makeMockWindow();
|
|
const sandbox = vm.createContext(w);
|
|
vm.runInContext(bundleSource, sandbox);
|
|
|
|
const components = sandbox.__VERSTAK_PLUGIN_REGISTRY__['verstak.platform-test'];
|
|
const api = w.VerstakPluginAPI('verstak.platform-test');
|
|
const container = w.document.createElement('div');
|
|
container.id = 'test-diagnostics';
|
|
|
|
components.DiagnosticsPanel.mount(container, { componentId: 'verstak.platform-test.diagnostics' }, api);
|
|
|
|
const text = findTextContent(container);
|
|
const required = ['Platform Diagnostics', 'verstak.platform-test', 'Test Results', 'Plugin Registration'];
|
|
const missing = required.filter(chunk => !text.includes(chunk));
|
|
if (missing.length > 0) throw new Error('missing: ' + missing.join(', '));
|
|
|
|
// Verify bundle source contains Frontend Bundle Loaded (badge fix)
|
|
if (!bundleSource.includes('Frontend Bundle Loaded')) throw new Error('bundle missing badge text');
|
|
// Verify mount populated children
|
|
if (container.children.length < 4) throw new Error('mount did not populate container children');
|
|
});
|
|
|
|
test('R3. real mount: PlatformTestSettings.mount() writes expected text to container', () => {
|
|
const w = makeMockWindow();
|
|
const sandbox = vm.createContext(w);
|
|
vm.runInContext(bundleSource, sandbox);
|
|
|
|
const components = sandbox.__VERSTAK_PLUGIN_REGISTRY__['verstak.platform-test'];
|
|
const api = w.VerstakPluginAPI('verstak.platform-test');
|
|
const container = w.document.createElement('div');
|
|
container.id = 'test-settings';
|
|
|
|
components.PlatformTestSettings.mount(container, { componentId: 'verstak.platform-test.settings' }, api);
|
|
|
|
const text = findTextContent(container);
|
|
const required = ['Platform Test Settings', 'verstak.platform-test', 'Settings panel loaded from plugin frontend bundle', 'Interactive Counter'];
|
|
const missing = required.filter(chunk => !text.includes(chunk));
|
|
if (missing.length > 0) throw new Error('missing: ' + missing.join(', '));
|
|
});
|
|
|
|
test('R4. real mount: unmount() clears container', () => {
|
|
const w = makeMockWindow();
|
|
const sandbox = vm.createContext(w);
|
|
vm.runInContext(bundleSource, sandbox);
|
|
|
|
const components = sandbox.__VERSTAK_PLUGIN_REGISTRY__['verstak.platform-test'];
|
|
const api = w.VerstakPluginAPI('verstak.platform-test');
|
|
const container = w.document.createElement('div');
|
|
|
|
// Mount — verify container was modified
|
|
components.PlatformTestSettings.mount(container, {}, api);
|
|
if (container.innerHTML === '' && container.className === '') {
|
|
// mount should have set something
|
|
if (container.children.length === 0) {
|
|
throw new Error('mount should have populated container');
|
|
}
|
|
}
|
|
|
|
// Unmount — verify cleanup
|
|
components.PlatformTestSettings.unmount(container);
|
|
if (container.innerHTML !== '') throw new Error('unmount did not clear innerHTML');
|
|
if (container.className !== '') throw new Error('unmount did not clear className');
|
|
});
|
|
|
|
test('R5. error scenario: component mount throw is catchable', () => {
|
|
const failingStates = [{ mount: () => { throw new Error('render error'); }, unmount: () => {} }];
|
|
const container = makeMockDocument().createElement('div');
|
|
let caught = false;
|
|
try {
|
|
failingStates[0].mount(container, {}, {});
|
|
} catch (e) {
|
|
caught = true;
|
|
if (!e.message.includes('render error')) throw new Error('wrong error message');
|
|
}
|
|
if (!caught) throw new Error('mount throw was not caught');
|
|
});
|
|
|
|
// ── Summary ──
|
|
|
|
console.log('');
|
|
console.log(`=== results: ${passed} passed, ${failed} failed ===`);
|
|
if (failed > 0) {
|
|
console.log('Errors:');
|
|
errors.forEach(e => console.log(` - ${e}`));
|
|
process.exit(1);
|
|
}
|