test(navigation): add public navigation API contract gate

This commit is contained in:
Masterluke77
2026-07-14 14:42:26 +02:00
parent 7ff428fbba
commit f6b0ae2a41
+138
View File
@@ -0,0 +1,138 @@
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
const root = process.cwd();
const read = relative => fs.readFileSync(path.join(root, relative), 'utf8');
const source = read('public/navigation-api.js');
const loader = read('public/module-extension-workspace.js');
const pkg = JSON.parse(read('package.json'));
const failures = [];
const expect = (condition, message) => { if (!condition) failures.push(message); };
const includesAll = (text, values, label) => values.forEach(value => expect(text.includes(value), `${label}: ${value}`));
includesAll(source, [
'uxNavigationApiReady',
'window.VendooNavigation = Object.freeze(api)',
'currentView',
'canOpen',
'open',
'vendoo:navigation-changed',
'vendoo:navigation-api-ready',
"version: '1.44.0'",
"attributeFilter: ['class', 'hidden', 'aria-hidden', 'aria-disabled']",
], 'Navigation-API-Vertrag fehlt');
includesAll(loader, [
'loadGlobalNavigationAssets',
'loadNavigationApi',
'navigation-api.js?v=1.44.0',
'data-ux-navigation-api',
'loadSafeActionAdapters',
], 'Navigation-API-Loader fehlt');
expect(loader.indexOf('loadGlobalNavigationAssets();') < loader.indexOf('loadNavigationApi();'), 'Global Navigation muss vor Navigation API geladen werden');
expect(loader.indexOf('loadNavigationApi();') < loader.indexOf('loadSafeActionAdapters();'), 'Navigation API muss vor Safe Action Adapters geladen werden');
expect(pkg.scripts?.['verify:navigation-api'] === 'node tools/verify-navigation-api-1.44.0.mjs', 'verify:navigation-api fehlt');
expect(pkg.scripts?.['verify:all']?.includes('npm run verify:navigation-api'), 'verify:all enthält Navigation-API-Gate nicht');
expect(!/\.innerHTML\s*=/.test(source), 'Navigation API darf innerHTML nicht setzen');
expect(!/\bfetch\s*\(/.test(source), 'Navigation API darf keine Netzwerkaufrufe ausführen');
expect(!/setInterval\s*\(/.test(source), 'Navigation API darf kein Polling starten');
expect(!/history\.(?:pushState|replaceState|back|forward)\s*\(/.test(source), 'Navigation API darf keine eigene History-Logik einführen');
expect(!/location\.(?:assign|replace)|window\.open\s*\(/.test(source), 'Navigation API darf keine externe Navigation ausführen');
function verifyRuntime() {
const events = [];
const listeners = new Map();
let observerConfig = null;
let observerDisconnected = false;
const classes = values => ({
values: new Set(values),
contains(value) { return this.values.has(value); },
add(value) { this.values.add(value); },
remove(value) { this.values.delete(value); },
});
const nav = { id: 'sidebar', querySelectorAll: () => items };
const item = (view, active = false) => ({
dataset: { tab: view },
hidden: false,
classList: classes(active ? ['nav-item', 'active'] : ['nav-item']),
attributes: new Map(),
clicks: 0,
getAttribute(name) { return this.attributes.get(name) ?? null; },
click() {
this.clicks += 1;
items.forEach(candidate => candidate.classList.remove('active'));
this.classList.add('active');
},
});
const dashboard = item('dashboard', true);
const history = item('history');
const hidden = item('hidden-view');
hidden.classList.add('hidden');
const items = [dashboard, history, hidden];
const documentObject = {
documentElement: { dataset: {} },
querySelector(selector) {
if (selector === '.sidebar-nav') return nav;
if (selector === '.sidebar-nav .nav-item.active[data-tab]') return items.find(candidate => candidate.classList.contains('active')) || null;
if (selector === '.tab-content.active') return null;
return null;
},
querySelectorAll(selector) {
return selector === '.sidebar-nav .nav-item[data-tab]' ? items : [];
},
};
class MutationObserverMock {
constructor(callback) { this.callback = callback; }
observe(target, config) { observerConfig = { target, config, observer: this }; }
disconnect() { observerDisconnected = true; }
}
const windowObject = {
addEventListener(type, listener) { listeners.set(type, listener); },
dispatchEvent(event) { events.push(event); return true; },
};
const context = vm.createContext({
document: documentObject,
window: windowObject,
MutationObserver: MutationObserverMock,
CustomEvent: class CustomEvent { constructor(type, init = {}) { this.type = type; this.detail = init.detail; } },
console,
});
vm.runInContext(source, context, { filename: 'public/navigation-api.js' });
const api = windowObject.VendooNavigation;
expect(Object.isFrozen(api), 'Runtime: Navigation API ist nicht schreibgeschützt');
expect(api.currentView === 'dashboard', 'Runtime: currentView erkennt die aktive Ansicht nicht');
expect(api.canOpen('history') === true, 'Runtime: erlaubte Ansicht wird nicht erkannt');
expect(api.canOpen('hidden-view') === false, 'Runtime: versteckte Ansicht wird als verfügbar gemeldet');
expect(api.canOpen('missing') === false, 'Runtime: unbekannte Ansicht wird als verfügbar gemeldet');
expect(api.open('hidden-view') === false && hidden.clicks === 0, 'Runtime: versteckte Ansicht wurde geöffnet');
expect(api.open('history') === true && history.clicks === 1, 'Runtime: Navigation delegiert nicht an den bestehenden Nav-Handler');
expect(api.currentView === 'history', 'Runtime: currentView folgt der bestehenden Navigation nicht');
observerConfig?.observer.callback();
expect(events.some(event => event.type === 'vendoo:navigation-api-ready' && event.detail?.version === '1.44.0'), 'Runtime: Ready-Event fehlt');
expect(events.some(event => event.type === 'vendoo:navigation-changed' && event.detail?.currentView === 'history' && event.detail?.previousView === 'dashboard'), 'Runtime: Change-Event fehlt');
expect(observerConfig?.target === nav, 'Runtime: Navigation wird nicht beobachtet');
expect(observerConfig?.config?.attributeFilter?.includes('aria-disabled'), 'Runtime: Observer-Vertrag ist unvollständig');
listeners.get('beforeunload')?.();
expect(observerDisconnected, 'Runtime: Navigation-Observer wird nicht getrennt');
}
verifyRuntime();
if (failures.length) {
console.error('Navigation API Gate fehlgeschlagen:');
failures.forEach(failure => console.error(`- ${failure}`));
process.exit(1);
}
console.log('Navigation API Gate bestanden.');