test(navigation): add public navigation API contract gate
This commit is contained in:
@@ -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.');
|
||||
Reference in New Issue
Block a user