Publish curated ITWorx-Pulse source from 0e0633c8ac91cb71312a26837f791035a78d81b8
Public source validation / validate (push) Failing after 2m10s

This commit is contained in:
Public Source Publisher committed 2026-09-30 01:14:48 +00:00
1 parent a4956c2af9
commit db8093b608
88 files changed
+3685 -469

No files matched your search

+21 -8
View File
@@ -19,6 +19,14 @@ async function mockAPI(page: Page): Promise<void> {
});
return;
}
if (path === '/api/v1/session') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ subject: 'operator@example.test', role: 'operator', environment: 'test', permissions: { view: true, operate: true, edit: false, admin: false } }) });
return;
}
if (path === '/api/v1/alerts') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) });
return;
}
if (path === '/api/v1/dashboards') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) });
return;
@@ -70,7 +78,8 @@ test('overview exposes degraded/unknown state and passes axe', async ({ page },
await expect(page.locator('.desktop-navigation')).toBeHidden();
await expect(page.locator('.mobile-navigation')).toBeVisible();
await expect(page.locator('.mobile-navigation')).toHaveCSS('position', 'fixed');
await expect(page.locator('.mobile-primary-list .nav-link')).toHaveCount(4);
await expect(page.locator('.mobile-primary-list .nav-link')).toHaveCount(5);
await expect(page.locator('.mobile-primary-list .nav-link span:last-child')).toHaveText(['Overzicht', 'Incidenten', 'Containers', 'Opslag', 'Services']);
const mobileTargets = await page.locator('.mobile-primary-list .nav-link').evaluateAll((items) => items.map((item) => item.getBoundingClientRect().height));
expect(mobileTargets.every((height) => height >= 44)).toBe(true);
await expect(page.locator('.signal-path-stage')).toHaveCount(6);
@@ -82,9 +91,9 @@ test('overview exposes degraded/unknown state and passes axe', async ({ page },
} else {
await expect(page.locator('.desktop-navigation')).toBeVisible();
const rail = await page.locator('.sidebar').boundingBox();
const commandHeader = await page.locator('.context-bar').boundingBox();
const commandHeader = await page.locator('.operator-command-bar').boundingBox();
expect(rail?.width).toBeLessThanOrEqual(72);
expect(commandHeader?.height).toBe(56);
expect(commandHeader?.height).toBeGreaterThanOrEqual(48);
await expect(page.locator('.overview-kpi')).toHaveCount(4);
await expect(page.locator('.source-health-strip')).toHaveAttribute('tabindex', '0');
await expect(page.locator('.nav-group')).toHaveCount(6);
@@ -101,18 +110,22 @@ test('overview exposes degraded/unknown state and passes axe', async ({ page },
}
});
test('grouped navigation reaches infrastructure in two actions', async ({ page }, testInfo) => {
test('navigation reaches the storage domain in at most two actions', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'Wallboard heeft geen productnavigatie.');
await page.goto('/');
if (testInfo.project.name === 'mobile-chromium') {
await page.getByText('Meer', { exact: true }).click();
await page.getByRole('link', { name: /Disks/ }).click();
await page.getByRole('link', { name: 'Opslag' }).click();
} else {
await page.locator('.nav-group').filter({ hasText: 'Infrastructuur' }).locator('summary').click();
await page.getByRole('link', { name: /Disks/ }).click();
await page.getByRole('link', { name: /Opslagkaart/ }).click();
}
await expect(page).toHaveURL(/\/disks$/);
await expect(page).toHaveURL(/\/storage$/);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
if (testInfo.project.name === 'mobile-chromium') {
await page.getByText('Meer', { exact: true }).click();
await page.locator('.mobile-more').getByRole('link', { name: /Disks/ }).click();
await expect(page).toHaveURL(/\/disks$/);
}
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
});
@@ -57,6 +57,12 @@ test('alertwerkruimte prioriteert operatie en opent configuratie doelgericht', a
await expect(rows.first()).toContainText('Kritiek');
await expect(page.getByRole('heading', { name: 'Geregistreerde regels' })).toHaveCount(0);
if (testInfo.project.name === 'mobile-chromium') {
const detailBox = await rows.first().locator('.alert-operation-button').boundingBox();
const actionBox = await rows.first().locator('.alert-operation-action').boundingBox();
expect((detailBox?.y ?? 0) + (detailBox?.height ?? 0)).toBeLessThanOrEqual(actionBox?.y ?? 0);
}
const acknowledge = page.getByRole('button', { name: 'Erkennen' }).first();
page.once('dialog', (dialog) => dialog.dismiss());
await acknowledge.click();
@@ -21,7 +21,9 @@ const widgets = [
async function mockDashboard(page: Page): Promise<void> {
await page.route('**/api/v1/**', async (route) => {
const requestPath = new URL(route.request().url()).pathname;
const body = requestPath === '/api/v1/dashboards/polish-dashboard'
const body = requestPath === '/api/v1/session'
? { subject: 'editor-1', role: 'editor', environment: 'test', permissions: { view: true, operate: true, edit: true, admin: false } }
: requestPath === '/api/v1/dashboards/polish-dashboard'
? { dashboard, version: { document: { schemaVersion: 2, widgets, variables: [], settings: { defaultTimeRange: '1h' } } } }
: requestPath === '/api/v1/metrics/query-range'
? { status: 'success', data: { result: [{ metric: { __name__: 'host_network_receive', host: 'tower', interface: 'eth0' }, values: [[1786420800, '1200'], [1786420815, '1500']] }] }, provenance: { source: 'prometheus', metric: 'host.network.receive', catalogVersion: '1', cacheKey: 'test' }, sourceObservedAt: '2026-08-11T04:01:00Z', receivedAt: '2026-08-11T04:01:01Z', freshness: 'fresh', cacheHit: false }
@@ -75,7 +77,8 @@ test('dashboard and editor use human labels, safe modes and accessible keyboard
await page.mouse.down();
await page.mouse.move(firstWidget!.x + 30, firstWidget!.y + 75, { steps: 4 });
await page.mouse.up();
await expect(editorWidgets.locator('h3')).toHaveText(['Recente wijzigingen', 'Netwerkbelasting']);
await expect(editorWidgets.locator('h3')).toHaveText(['Netwerkbelasting', 'Recente wijzigingen']);
await expect(editorWidgets.first()).toHaveCSS('grid-row-start', '2');
const resize = page.getByRole('slider', { name: 'Breedte aanpassen: Netwerkbelasting' });
await expect(resize).toHaveAttribute('aria-valuenow', '9');
@@ -89,3 +92,21 @@ test('dashboard and editor use human labels, safe modes and accessible keyboard
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-07/editor-advanced-open.png'), fullPage: true });
});
test('mobile composer uses reorder and per-viewport visibility without horizontal overflow', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile-chromium', 'De mobiele composer gebruikt de ondersteunde 390px-viewport.');
await mockDashboard(page);
await page.goto('/dashboards/polish-dashboard');
await page.getByRole('button', { name: 'Bewerken' }).click();
await expect(page.getByRole('heading', { level: 1, name: 'Dashboard aanpassen' })).toBeVisible();
await expect(page.getByLabel('Layout')).toHaveValue('mobile');
await expect(page.getByLabel('Breedte in kolommen')).toHaveValue('1');
const widgets = page.locator('.editor-widget');
await widgets.nth(1).getByRole('button', { name: 'Widget omhoog: Recente wijzigingen' }).click();
await expect(widgets.locator('h3')).toHaveText(['Recente wijzigingen', 'Netwerkbelasting']);
await widgets.first().getByRole('button', { name: 'Verberg in Mobiel' }).click();
await expect(widgets.first()).toHaveClass(/editor-widget--hidden/);
await expect(page.getByRole('button', { name: /Widget naar links/ })).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await expectNoSeriousAxeViolations(page);
});
@@ -0,0 +1,94 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test, type Page, type Route } from '@playwright/test';
type Dashboard = { id: string; slug: string; name: string; description: string; ownerId: string; scope: string; revision: number; currentVersion: number; archivedAt?: string };
const observedAt = new Date().toISOString();
const initial: Dashboard = { id: 'dashboard-1', slug: 'operations', name: 'Operations', description: 'Dagelijkse operatie', ownerId: 'editor-1', scope: 'shared', revision: 1, currentVersion: 2 };
async function fulfill(route: Route, body: unknown, status = 200): Promise<void> {
await route.fulfill({ status, contentType: status >= 400 ? 'application/problem+json' : 'application/json', body: JSON.stringify(body) });
}
async function mockShell(page: Page, session: unknown): Promise<void> {
await page.route('**/api/v1/system/status', (route) => fulfill(route, { version: '1.5.0', generatedAt: observedAt, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'test', state: 'healthy', reason: 'ok' }] }));
await page.route('**/api/v1/session', (route) => fulfill(route, session));
await page.route('**/api/v1/host', (route) => fulfill(route, { identity: { name: 'lifecycle-fixture' } }));
await page.route('**/api/v1/alerts**', (route) => fulfill(route, { items: [] }));
await page.route('**/api/v1/incidents**', (route) => fulfill(route, { items: [] }));
}
test('editor completes the dashboard lifecycle with revision locks', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'Dashboardbeheer is geen wallboardtaak.');
const dashboards: Dashboard[] = [{ ...initial }];
const revisionHeaders: string[] = [];
await mockShell(page, { subject: 'editor-1', role: 'editor', environment: 'test', permissions: { view: true, operate: true, edit: true, admin: false } });
await page.route('**/api/v1/dashboards**', async (route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname;
const method = request.method();
if (path === '/api/v1/dashboards' && method === 'GET') return fulfill(route, { items: dashboards });
if (path === '/api/v1/dashboards' && method === 'POST') {
const document = request.postDataJSON() as { slug: string; name: string; description: string; scope: string };
const created = { id: 'dashboard-created', slug: document.slug, name: document.name, description: document.description, ownerId: 'editor-1', scope: document.scope, revision: 1, currentVersion: 1 };
dashboards.push(created); return fulfill(route, { dashboard: created }, 201);
}
const item = dashboards.find((candidate) => path.includes(candidate.id));
if (!item) return fulfill(route, { code: 'NOT_FOUND' }, 404);
if (path.endsWith('/versions') && method === 'GET') return fulfill(route, { items: [
{ id: 'v2', dashboardId: item.id, number: 2, schemaVersion: 2, document: {}, changeSummary: 'metadata', createdBy: 'editor-1', createdAt: observedAt },
{ id: 'v1', dashboardId: item.id, number: 1, schemaVersion: 2, document: {}, changeSummary: 'initial', createdBy: 'editor-1', createdAt: observedAt },
] });
if (path.endsWith('/restore/1') && method === 'POST') { revisionHeaders.push(request.headers()['if-match'] ?? ''); item.revision += 1; item.currentVersion += 1; return fulfill(route, { dashboard: item }); }
if (path.endsWith('/clone') && method === 'POST') {
const body = request.postDataJSON() as { slug: string; name: string };
const clone = { ...item, id: 'dashboard-clone', slug: body.slug, name: body.name, revision: 1, currentVersion: 1 };
dashboards.push(clone); return fulfill(route, { dashboard: clone }, 201);
}
if (method === 'PATCH') { revisionHeaders.push(request.headers()['if-match'] ?? ''); const body = request.postDataJSON() as { name: string; description: string }; Object.assign(item, body, { revision: item.revision + 1 }); return fulfill(route, { dashboard: item }); }
if (method === 'DELETE') { revisionHeaders.push(request.headers()['if-match'] ?? ''); item.revision += 1; item.archivedAt = observedAt; return fulfill(route, { dashboard: item }); }
return fulfill(route, { code: 'NOT_FOUND' }, 404);
});
await page.goto('/dashboards');
await expect(page.getByRole('heading', { level: 1, name: 'Dashboardbeheer' })).toBeVisible();
await page.getByRole('button', { name: 'Nieuw dashboard' }).click();
await page.getByLabel('Naam').fill('Nachtzicht');
await page.getByLabel('Template').selectOption('blank');
await page.getByRole('button', { name: 'Dashboard maken' }).click();
await expect(page.locator('.dashboard-manager-row').filter({ has: page.getByText('Nachtzicht', { exact: true }) })).toBeVisible();
let row = page.locator('.dashboard-manager-row').filter({ hasText: 'Operations' });
await row.getByRole('button', { name: 'Beheren' }).click();
await row.getByLabel('Naam').fill('Operations EU');
await row.getByRole('button', { name: 'Naam en beschrijving opslaan' }).click();
row = page.locator('.dashboard-manager-row').filter({ has: page.getByText('Operations EU', { exact: true }) });
await row.getByRole('button', { name: 'Beheren' }).click();
await row.getByRole('button', { name: 'Dupliceren' }).click();
await expect(page.getByText('Operations EU (kopie)', { exact: true })).toBeVisible();
await row.getByRole('button', { name: 'Versies bekijken' }).click();
await row.getByRole('button', { name: 'Herstellen als huidige versie' }).click();
await row.getByRole('button', { name: 'Archiveren' }).click();
await row.getByRole('button', { name: 'Ja, archiveren' }).click();
await expect(page.getByText('Dashboard gearchiveerd.')).toBeVisible();
expect(revisionHeaders).toEqual(['1', '2', '3']);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
const serious = (await new AxeBuilder({ page }).analyze()).violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious');
expect(serious).toEqual([]);
});
test('viewer gets a read-only dashboard manager', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'Dashboardbeheer is geen wallboardtaak.');
await mockShell(page, { subject: 'viewer-1', role: 'viewer', environment: 'test', permissions: { view: true, operate: false, edit: false, admin: false } });
await page.route('**/api/v1/dashboards**', (route) => new URL(route.request().url()).pathname === '/api/v1/dashboards/dashboard-1'
? fulfill(route, { dashboard: initial, version: { document: { schemaVersion: 2, id: '00000000-0000-4000-8000-000000000001', slug: 'operations', name: 'Operations', scope: 'shared', variables: [], widgets: [], settings: { defaultTimeRange: '1h' } } } })
: fulfill(route, { items: [initial] }));
await page.goto('/dashboards');
await expect(page.getByText('Alleen bekijken')).toBeVisible();
await expect(page.getByRole('button', { name: 'Nieuw dashboard' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Beheren' })).toHaveCount(0);
await page.locator('.dashboard-manager-open').click();
await expect(page.getByRole('heading', { level: 1, name: 'Operations' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Bewerken' })).toHaveCount(0);
});
@@ -0,0 +1,40 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test, type Page, type Route } from '@playwright/test';
const observedAt = '2026-09-01T10:00:00Z';
const layout = { desktop: { x: 0, y: 0, w: 6, h: 4, visible: true }, tablet: { x: 0, y: 0, w: 6, h: 4, visible: true }, mobile: { x: 0, y: 0, w: 1, h: 4, visible: true }, wallboard: { x: 0, y: 0, w: 6, h: 4, visible: true } };
async function fulfill(route: Route, body: unknown): Promise<void> { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); }
async function shell(page: Page): Promise<void> {
await page.route('**/api/v1/system/status', (route) => fulfill(route, { version: '1.5.0', generatedAt: observedAt, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }));
await page.route('**/api/v1/session', (route) => fulfill(route, { subject: 'viewer-1', role: 'viewer', environment: 'test', permissions: { view: true, operate: false, edit: false, admin: false } }));
await page.route('**/api/v1/host', (route) => fulfill(route, { identity: { name: 'runtime-fixture' } }));
}
test('dashboard renders bounded alerts, incidents, text and contains an unknown type', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'De runtimeflow wordt apart als wallboard gevalideerd.');
await shell(page);
await page.route('**/api/v1/alerts**', (route) => fulfill(route, { items: [{ id: 'alert-1', ruleName: 'Disk warm', state: 'firing', severity: 'critical', entityName: 'disk1', reason: 'Temperatuur hoog', updatedAt: observedAt }] }));
await page.route('**/api/v1/incidents**', (route) => fulfill(route, { items: [{ id: 'incident-1', title: 'Opslagincident', status: 'open', severity: 'warning', summary: 'Controle nodig', updatedAt: observedAt }] }));
await page.route('**/api/v1/dashboards/runtime', (route) => fulfill(route, {
dashboard: { id: 'runtime', slug: 'runtime', name: 'Runtimebewijs', description: 'Widgetadapters', scope: 'shared', revision: 1, currentVersion: 1 },
version: { document: { variables: [], widgets: [
{ id: 'alert-widget', type: 'alert-summary', title: 'Actieve meldingen', data: { sourceType: 'alerts', limit: 1000 }, behavior: { liveIntervalSeconds: 1 }, layouts: layout },
{ id: 'incident-widget', type: 'table', title: 'Open incidenten', data: { sourceType: 'incidents', limit: 1000 }, behavior: { liveIntervalSeconds: 1 }, layouts: { ...layout, desktop: { ...layout.desktop, x: 6 } } },
{ id: 'text-widget', type: 'text', title: 'Runbook', data: { sourceType: 'text', text: '# Start\n[Open incidenten](/incidents)\n<script>alert(1)</script>' }, behavior: {}, layouts: { ...layout, desktop: { ...layout.desktop, y: 4 } } },
{ id: 'unknown-widget', type: 'made-up', title: 'Onbekend', data: { sourceType: 'alerts', limit: 5 }, behavior: {}, layouts: { ...layout, desktop: { ...layout.desktop, x: 6, y: 4 } } },
] } },
}));
await page.goto('/dashboards/runtime');
await expect(page.getByRole('heading', { level: 1, name: 'Runtimebewijs' })).toBeVisible();
await expect(page.getByText('Disk warm')).toBeVisible();
await expect(page.getByText('Opslagincident')).toBeVisible();
await expect(page.getByRole('link', { name: 'Open incidenten' })).toHaveAttribute('href', '/incidents');
await expect(page.getByText('<script>alert(1)</script>')).toBeVisible();
await expect(page.getByText('Onbekend widgettype')).toBeVisible();
expect(await page.evaluate(() => document.querySelectorAll('script').length)).toBeLessThan(10);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
const serious = (await new AxeBuilder({ page }).analyze()).violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious');
expect(serious).toEqual([]);
});
+3 -1
View File
@@ -47,12 +47,14 @@ test('100 events blijven compact, filterbaar en toetsenbordnavigeerbaar', async
await expect(rows).toHaveCount(10);
await page.getByLabel('Onderdeel').selectOption('entity-00');
await expect(rows).toHaveCount(10);
await page.getByLabel('Zoeken').fill('Gebeurtenis 091');
await page.getByRole('searchbox', { name: 'Zoeken' }).fill('Gebeurtenis 091');
await expect(rows).toHaveCount(1);
expect(eventRequests).toBe(baselineRequests);
await page.getByRole('button', { name: 'Filters wissen' }).click();
const next = page.getByRole('button', { name: 'Volgende pagina' });
// Keep keyboard focus reliable when animation frames are throttled.
await page.evaluate(() => { window.requestAnimationFrame = () => 0; });
await next.focus();
await page.keyboard.press('Enter');
await expect(page.locator('.list-pager [role="status"]')).toBeFocused();
@@ -0,0 +1,47 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test, type Page, type Route } from '@playwright/test';
const nowSeconds = Math.floor(Date.parse('2026-09-01T10:00:00Z') / 1000);
const metrics = [
{ semanticName: 'host.cpu.utilization', description: 'CPU-gebruik van de host', unit: 'percent', valueKind: 'gauge', defaultAggregation: 'avg', allowedVisualizations: ['timeseries'], limits: { maxRangeSeconds: 2_592_000, maxSeries: 20, maxPoints: 4000, timeoutSeconds: 5 } },
{ semanticName: 'host.memory.utilization', description: 'Geheugengebruik van de host', unit: 'percent', valueKind: 'gauge', defaultAggregation: 'avg', allowedVisualizations: ['timeseries'], limits: { maxRangeSeconds: 2_592_000, maxSeries: 20, maxPoints: 4000, timeoutSeconds: 5 } },
];
async function fulfill(route: Route, body: unknown): Promise<void> { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); }
async function shell(page: Page): Promise<void> {
await page.route('**/api/v1/system/status', (route) => fulfill(route, { version: '1.5.0', generatedAt: '2026-09-01T10:00:00Z', overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }));
await page.route('**/api/v1/session', (route) => fulfill(route, { subject: 'viewer', role: 'viewer', environment: 'test', permissions: { view: true, operate: false, edit: false, admin: false } }));
await page.route('**/api/v1/host', (route) => fulfill(route, { identity: { name: 'explore-fixture' } }));
await page.route('**/api/v1/incidents**', (route) => fulfill(route, { items: [{ id: 'incident-1', title: 'Opslagincident', severity: 'warning', updatedAt: '2026-09-01T09:55:00Z' }] }));
await page.route('**/api/v1/alerts**', (route) => fulfill(route, { items: [] }));
}
test('operator finds, compares and queries a local custom range as UTC', async ({ page }, testInfo) => {
test.skip(testInfo.project.name === 'wallboard-chromium', 'Explore is een interactieve operatorroute.');
const requests: Array<Record<string, unknown>> = [];
await shell(page);
await page.route('**/api/v1/metrics/catalog', (route) => fulfill(route, { version: 'v1', metrics, bindings: metrics.map((metric) => ({ semanticName: metric.semanticName, state: 'supported' })) }));
await page.route('**/api/v1/metrics/query-range', async (route) => {
const body = route.request().postDataJSON() as Record<string, unknown>; requests.push(body);
await fulfill(route, { status: 'success', data: { result: [{ metric: { host: 'tower' }, values: [[nowSeconds - 60, '41'], [nowSeconds, '42']] }] }, provenance: { source: 'fixture', metric: body.metric, catalogVersion: 'v1', cacheKey: 'bounded' }, sourceObservedAt: '2026-09-01T10:00:00Z', receivedAt: '2026-09-01T10:00:01Z', freshness: 'fresh', cacheHit: false });
});
await page.goto('/explore');
await expect(page.getByRole('heading', { level: 1, name: 'Metrics verkennen' })).toBeVisible();
await page.getByPlaceholder('CPU, opslag, responstijd…').fill('cpu');
await page.getByRole('button', { name: /host.cpu.utilization/ }).click();
await expect(page.getByRole('img', { name: /Tijdreeks/ })).toBeVisible();
await expect(page.getByRole('list', { name: 'Incident- en meldingsannotaties' })).toContainText('Opslagincident');
await page.getByRole('tab', { name: 'Vergelijken' }).click();
await page.getByPlaceholder('CPU, opslag, responstijd…').fill('memory');
await page.getByRole('button', { name: /host.memory.utilization/ }).click();
await expect(page.getByRole('heading', { name: 'host.memory.utilization' })).toBeVisible();
await page.getByLabel('Periode').last().selectOption('custom');
await page.getByLabel('Vanaf').last().fill('2026-09-01T08:00');
await page.getByLabel('Tot').last().fill('2026-09-01T10:00');
await expect.poll(() => requests.filter((request) => (request.range as { from?: string })?.from === new Date('2026-09-01T08:00').toISOString()).length).toBeGreaterThan(0);
const latest = requests.at(-1)!;
expect(latest).toMatchObject({ maxSeries: 20, maxPoints: 4000, range: { from: new Date('2026-09-01T08:00').toISOString(), to: new Date('2026-09-01T10:00').toISOString() } });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
const serious = (await new AxeBuilder({ page }).analyze()).violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious');
expect(serious).toEqual([]);
});
+5 -4
View File
@@ -46,7 +46,7 @@ test('process and inventory filters survive navigation with mobile-first cards',
test.skip(testInfo.project.name === 'wallboard-chromium', 'Large lists target desktop and mobile routes.');
await page.goto('/processes?q=worker&container=pulse&sort=memory');
await expect(page.getByRole('heading', { name: 'Topprocessen' })).toBeVisible();
await expect(page.getByLabel('Zoeken')).toHaveValue('worker');
await expect(page.getByRole('searchbox', { name: 'Zoeken' })).toHaveValue('worker');
await expect(page.getByLabel('Container')).toHaveValue('pulse');
await expect(page).toHaveURL(/sort=memory/);
if (testInfo.project.name === 'mobile-chromium') await expect(page.locator('.mobile-data-list > li:visible')).toHaveCount(25);
@@ -55,7 +55,7 @@ test('process and inventory filters survive navigation with mobile-first cards',
await page.goto('/inventory?q=Entity&type=container&status=operational&order=desc');
await expect(page.getByRole('heading', { name: 'Wat Pulse kan zien' })).toBeVisible();
await expect(page.getByLabel('Zoeken')).toHaveValue('Entity');
await expect(page.getByRole('searchbox', { name: 'Zoeken' })).toHaveValue('Entity');
await expect(page.getByLabel('Type')).toHaveValue('container');
await expect(page.getByRole('textbox', { name: 'Status' })).toHaveValue('operational');
await expect(page.locator('.inventory-entity-list > li')).toHaveCount(25);
@@ -72,7 +72,8 @@ test('all 150 containers remain reachable with shareable filters and bounded mob
await expect(page.getByRole('heading', { name: 'Containers' })).toBeVisible();
const visibleRows = testInfo.project.name === 'mobile-chromium' ? page.locator('.mobile-data-list > li:visible') : page.locator('.desktop-data-view tbody tr:visible');
await expect(visibleRows).toHaveCount(25);
expect(Date.now() - started).toBeLessThan(3000);
const firstPageBudgetMs = process.env.CI ? 8_000 : 3_000;
expect(Date.now() - started).toBeLessThan(firstPageBudgetMs);
const seen = new Set<string>();
for (let pageNumber = 1; pageNumber <= 6; pageNumber += 1) {
await expect(visibleRows).toHaveCount(25);
@@ -91,7 +92,7 @@ test('all 150 containers remain reachable with shareable filters and bounded mob
expect(controls.every((height) => height >= 44)).toBe(true);
}
await page.getByLabel('Zoeken').fill('container-150');
await page.getByRole('searchbox', { name: 'Zoeken' }).fill('container-150');
await expect(visibleRows).toHaveCount(1);
await expect(page).toHaveURL(/q=container-150/);
await page.reload();
@@ -39,19 +39,28 @@ test('beheerhub, afgeronde onboarding en backupouderdom blijven taakgericht en w
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(onboarding) });
return;
}
if (pathname === '/api/v1/host') {
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ hardware: { status: { state: 'healthy' }, capabilities: [], temperatures: [], fans: [], gpus: [] } }) });
return;
}
await route.fulfill({ status: 404, contentType: 'application/problem+json', body: '{}' });
});
await page.goto('/settings');
await expect(page.getByRole('heading', { name: 'Pulse configureren' })).toBeVisible();
const hub = page.getByRole('region', { name: 'Beheerfuncties' });
await expect(hub.getByRole('link')).toHaveCount(6);
await expect(hub.getByRole('link')).toHaveCount(8);
await expect(hub.getByRole('link', { name: /Systeemstatus en backup/ })).toHaveAttribute('href', '/status');
await expect(hub.getByRole('link', { name: /Eerste configuratie/ })).toHaveAttribute('href', '/onboarding');
await expect(hub.getByRole('link', { name: /Alertregels/ })).toHaveAttribute('href', '/alerts?section=rules');
await expect(hub.getByRole('link', { name: /Stiltes en onderhoud/ })).toHaveAttribute('href', '/alerts?section=controls');
await expect(hub.getByText('Backupactie: beheerder')).toBeVisible();
await expect(hub.getByText('Wijzigen: beheerder')).toBeVisible();
await expect(hub.getByText('Wijzigen: operator')).toBeVisible();
const capabilities = page.getByRole('region', { name: 'Capabilitystatus' });
await expect(capabilities).toContainText('Unraid');
await expect(capabilities).toContainText('HardwaretelemetrieBeschikbaar');
await expect(capabilities).toContainText('UPSNiet geconfigureerd');
await expect(capabilities.getByRole('link', { name: 'Open systeemstatus en back-up' })).toHaveAttribute('href', '/status');
if (process.env.PULSE_CAPTURE_VISUALS && (testInfo.project.name === 'desktop-chromium' || testInfo.project.name === 'mobile-chromium')) {
await page.screenshot({ path: path.resolve('../../artifacts/evidence/M14-03', `settings-${testInfo.project.name}.png`), fullPage: true });
}
@@ -70,7 +79,19 @@ test('beheerhub, afgeronde onboarding en backupouderdom blijven taakgericht en w
await page.getByRole('region', { name: 'Beheerfuncties' }).locator('a[href="/onboarding"]').click();
await expect(page.getByRole('heading', { name: 'Pulse is geconfigureerd' })).toBeVisible();
await expect(page.getByRole('radio')).toHaveCount(0);
await page.getByRole('button', { name: 'Herconfiguratie openen' }).click();
const reconfigure = page.getByRole('button', { name: 'Herconfiguratie openen' });
if (testInfo.project.name === 'mobile-chromium') {
await reconfigure.scrollIntoViewIfNeeded();
expect(await reconfigure.evaluate((button) => {
const box = button.getBoundingClientRect();
const target = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2);
return target === button || button.contains(target);
})).toBe(true);
await reconfigure.focus();
await page.keyboard.press('Enter');
} else {
await reconfigure.click();
}
await expect(page.getByRole('radio')).toHaveCount(4);
await page.getByRole('button', { name: 'Annuleren' }).click();
await expect(page.getByRole('radio')).toHaveCount(0);
+25 -5
View File
@@ -5,9 +5,14 @@ const now = new Date().toISOString();
test('mobile incident command mode is prioritized, bounded and accessible', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile-chromium', 'Mobile incident mode uses the supported 390x844 viewport.');
const mutations: Array<{ path: string; method: string; ifMatch: string | null; idempotencyKey: string | null; body: unknown }> = [];
let canOperate = true;
await page.route('**/api/v1/**', async (route) => {
const path = new URL(route.request().url()).pathname;
const body = path === '/api/v1/system/status' ? {
if (route.request().method() !== 'GET') mutations.push({ path, method: route.request().method(), ifMatch: route.request().headers()['if-match'] ?? null, idempotencyKey: route.request().headers()['idempotency-key'] ?? null, body: route.request().postDataJSON() });
const body = path === '/api/v1/session' ? {
subject: canOperate ? 'operator-1' : 'viewer-1', role: canOperate ? 'operator' : 'viewer', environment: 'test', permissions: { view: true, operate: canOperate, edit: false, admin: false },
} : path === '/api/v1/system/status' ? {
version: '1', generatedAt: now, overallState: 'degraded', components: [],
backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [],
} : path === '/api/v1/incidents/incident-1' ? {
@@ -19,6 +24,10 @@ test('mobile incident command mode is prioritized, bounded and accessible', asyn
{ alertId: 'alert-1', rationale: 'Capaciteitsdrempel van 90% overschreden', confidence: .84, correlationMethod: 'temporal-window', manual: false, createdAt: now },
], notes: [{ id: 'note-1', incidentId: 'incident-1', author: 'Pulse', body: 'Mover is niet actief.', createdAt: now }],
},
} : path === '/api/v1/alerts/alert-1' ? {
alert: { id: 'alert-1', state: 'firing', retainedState: 'firing', ruleName: 'Cachepoolcapaciteit', severity: 'critical', reason: 'Capaciteitsdrempel overschreden', revision: 7, updatedAt: now },
} : path === '/api/v1/alerts/alert-1/acknowledge' ? {
alert: { id: 'alert-1', state: 'acknowledged', revision: 8 },
} : {};
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
});
@@ -28,12 +37,23 @@ test('mobile incident command mode is prioritized, bounded and accessible', asyn
await expect(page.locator('.incident-command-strip')).toContainText('Kritiek');
await expect(page.locator('.incident-command-strip')).toContainText('84%');
await expect(page.locator('.incident-timeline')).toContainText('Capaciteitsdrempel van 90% overschreden');
await page.getByRole('button', { name: 'Melding erkennen' }).click();
await expect(page.getByRole('status')).toContainText('lost de infrastructuur niet automatisch op');
expect(mutations).toHaveLength(1);
expect(mutations[0]).toMatchObject({ path: '/api/v1/alerts/alert-1/acknowledge', method: 'POST', ifMatch: '7', body: { evaluationKey: 'incident-incident-1-alert-alert-1-ack-7' } });
expect(mutations[0].idempotencyKey).toBe('incident-incident-1-alert-alert-1-ack-7');
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
const targets = await page.locator('button, .mobile-navigation a, .mobile-more summary').evaluateAll((items) => items.filter((item) => {
const style = getComputedStyle(item); return style.display !== 'none' && style.visibility !== 'hidden';
}).map((item) => item.getBoundingClientRect().height));
expect(targets.every((height) => height >= 44)).toBe(true);
const undersizedTargets = await page.locator('button, .mobile-navigation a, .mobile-more summary').evaluateAll((items) => items.filter((item) => {
const style = getComputedStyle(item); return style.display !== 'none' && style.visibility !== 'hidden' && item.getClientRects().length > 0;
}).map((item) => ({ label: item.textContent?.trim(), height: item.getBoundingClientRect().height })).filter((item) => item.height < 44));
expect(undersizedTargets).toEqual([]);
const axe = await new AxeBuilder({ page }).analyze();
expect(axe.violations.filter((item) => item.impact === 'critical' || item.impact === 'serious')).toEqual([]);
canOperate = false;
await page.reload();
await expect(page.getByRole('button', { name: 'Melding erkennen' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Eigenaar opslaan' })).toBeDisabled();
await expect(page.getByText('Je rol kan de opvolgcontext alleen bekijken.')).toBeVisible();
expect(mutations).toHaveLength(1);
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: '../../artifacts/evidence/M14-04/mobile-incident-command.png', fullPage: true });
});
+4 -2
View File
@@ -33,7 +33,9 @@ test('mobile primary navigation and operational reasons remain readable at 390px
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
const labels = page.locator('.mobile-primary-list .nav-link span:last-child');
await expect(labels).toHaveCount(4);
await expect(labels).toHaveCount(5);
const navigation = await page.locator('.mobile-navigation').boundingBox();
expect(navigation?.height).toBeLessThanOrEqual(65);
const metrics = await labels.evaluateAll((nodes) => nodes.map((node) => {
const box = node.getBoundingClientRect();
return { left: box.left, right: box.right, top: box.top, bottom: box.bottom, font: Number.parseFloat(getComputedStyle(node).fontSize) };
@@ -45,7 +47,7 @@ test('mobile primary navigation and operational reasons remain readable at 390px
await expect(reason).toHaveCSS('white-space', 'normal');
const overflow = await page.evaluate(() => [...document.querySelectorAll<HTMLElement>('body *')]
.map((element) => ({ tag: element.tagName, className: element.className, right: Math.round(element.getBoundingClientRect().right), scrollWidth: element.scrollWidth, clientWidth: element.clientWidth }))
.filter((item) => item.right > innerWidth + 1 || item.scrollWidth > item.clientWidth + 1)
.filter((item) => item.clientWidth > 1 && (item.right > innerWidth + 1 || item.scrollWidth > item.clientWidth + 1))
.slice(0, 12));
expect(overflow).toEqual([]);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
+11 -6
View File
@@ -18,6 +18,11 @@ function json(value: unknown, status = 200): Response {
const healthySource = { state: 'healthy', freshness: 'fresh' };
function signalStage(name: RegExp): HTMLElement {
const heading = screen.getByRole('heading', { name: 'Signaalpad' });
return within(heading.closest('article')!).getByRole('button', { name });
}
describe('Stitch command overview', () => {
it('shows real source values while failed sources remain explicitly unavailable', async () => {
window.history.replaceState({}, '', '/');
@@ -51,13 +56,13 @@ describe('Stitch command overview', () => {
expect(document.querySelector('.focus-panel')).toBeInTheDocument();
expect(document.querySelector('.context-inspector')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Signaalpad' })).toBeVisible();
const unavailableStorage = screen.getByRole('button', { name: /Opslag: Niet beschikbaar/ });
const unavailableStorage = signalStage(/Opslag: Niet beschikbaar/);
expect(unavailableStorage.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'unknown');
const staleSource = screen.getByRole('button', { name: /Bronnen: Aandacht.*0\/1/ });
const staleSource = signalStage(/Bronnen: Aandacht.*0\/1/);
expect(staleSource.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'attention');
expect(screen.getAllByText('62,3%')[0]).toBeVisible();
expect(screen.getByText('tower-lab')).toBeVisible();
expect(screen.getByRole('button', { name: /Workloads: Kritiek.*1\/2/ })).toBeVisible();
expect(signalStage(/Workloads: Kritiek.*1\/2/)).toBeVisible();
expect(screen.queryByText('0 pools')).not.toBeInTheDocument();
const storageCard = screen.getByRole('heading', { name: 'Capaciteit en toestand' }).closest('article');
expect(storageCard).not.toBeNull();
@@ -128,7 +133,7 @@ describe('Stitch command overview', () => {
render(<App />);
expect(await screen.findByRole('button', { name: /Incidenten: Niet beschikbaar/ })).toBeVisible();
await waitFor(() => expect(signalStage(/Incidenten: Niet beschikbaar/)).toBeVisible());
const incidentCard = screen.getByRole('heading', { name: 'Incidenten' }).closest('article');
expect(incidentCard).not.toBeNull();
expect(within(incidentCard!).getByText(/Deze overzichtsbron kon niet worden geladen/)).toBeVisible();
@@ -150,8 +155,8 @@ describe('Stitch command overview', () => {
render(<App />);
expect(await screen.findByRole('button', { name: /Bronnen: Geen toegang/ })).toBeVisible();
expect(await screen.findByRole('button', { name: /Opslag: Geen toegang/ })).toBeVisible();
await waitFor(() => expect(signalStage(/Bronnen: Geen toegang/)).toBeVisible());
expect(signalStage(/Opslag: Geen toegang/)).toBeVisible();
expect(screen.getByText('Opslag: Geen toegang')).toBeVisible();
expect(screen.getAllByText(/account heeft geen toegang tot deze overzichtsbron/).length).toBeGreaterThan(0);
expect(screen.queryByRole('link', { name: /Aanmelden/ })).not.toBeInTheDocument();
+3 -3
View File
@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import App from '../../src/App';
import { DashboardManagerPage } from '../../src/DashboardManagerPage';
afterEach(() => {
vi.unstubAllGlobals();
@@ -21,9 +21,9 @@ describe('dashboard request caching', () => {
return new Response(JSON.stringify({ error: 'not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' } });
}));
render(<App />);
render(<DashboardManagerPage />);
expect(await screen.findByRole('heading', { level: 1, name: 'Jouw dashboards' })).toBeVisible();
expect(await screen.findByRole('heading', { level: 1, name: 'Dashboardbeheer' })).toBeVisible();
const dashboardRequest = requests.find((request) => request.url.startsWith('/api/v1/dashboards'));
expect(dashboardRequest?.init?.cache).toBe('no-store');
});
@@ -0,0 +1,71 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DashboardEditor, validateDashboardLayouts, validateWidget, type EditorWidget } from '../../src/DashboardEditor';
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
function layouts(x: number, y: number, w = 6, h = 4) {
return {
desktop: { x, y, w, h, visible: true }, tablet: { x: Math.min(x, 2), y, w: Math.min(w, 8), h, visible: true },
mobile: { x: 0, y, w: 1, h, visible: true }, wallboard: { x, y, w, h, visible: true },
};
}
function widget(id: string, title: string, x: number, y: number, locked = false): EditorWidget {
return { id, type: 'stat', title, data: { sourceType: 'inventory', limit: 10 }, visualization: { decimals: 0, thresholds: [] }, behavior: { locked, hidden: false, liveIntervalSeconds: 30 }, layouts: layouts(x, y) };
}
const document = { schemaVersion: 2, id: '00000000-0000-4000-8000-000000000001', slug: 'test', name: 'Test', scope: 'personal', variables: [], widgets: [widget('00000000-0000-4000-8000-000000000011', 'CPU', 0, 0), widget('00000000-0000-4000-8000-000000000012', 'Geheugen', 10, 8)], settings: { defaultTimeRange: '1h' } };
describe('DashboardEditor spatial composition', () => {
it('keeps viewport layouts independent and saves x/y/w/h, thresholds and an internal link', async () => {
let savedBody: Record<string, unknown> | undefined;
vi.stubGlobal('fetch', vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
savedBody = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ dashboard: { revision: 2 } }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}));
const onSaved = vi.fn();
const user = userEvent.setup();
render(<DashboardEditor dashboardId="dashboard-1" revision={1} document={document} onExit={() => undefined} onSaved={onSaved} />);
await user.click(screen.getByRole('button', { name: 'Widget naar rechts: CPU' }));
expect(screen.getByLabelText('Kolom (x) (Desktop)')).toHaveValue(1);
fireEvent.change(screen.getByLabelText('Hoogte in rijen'), { target: { value: '6' } });
await user.selectOptions(screen.getByLabelText('Layout'), 'tablet');
expect(screen.getByLabelText('Kolom (x) (Tablet)')).toHaveValue(0);
fireEvent.change(screen.getByLabelText('Kolom (x) (Tablet)'), { target: { value: '2' } });
await user.selectOptions(screen.getByLabelText('Layout'), 'desktop');
expect(screen.getByLabelText('Kolom (x) (Desktop)')).toHaveValue(1);
await user.click(screen.getByRole('button', { name: 'Drempel toevoegen' }));
const threshold = screen.getByLabelText('Drempels').querySelector<HTMLElement>('.config-threshold-row')!;
fireEvent.change(within(threshold).getByLabelText('Waarde'), { target: { value: '85' } });
fireEvent.change(within(threshold).getByLabelText('Kleur'), { target: { value: '#ff8800' } });
await user.type(screen.getByLabelText('Interne drilldownlink'), '/incidents');
await user.click(screen.getByRole('button', { name: 'Opslaan' }));
await waitFor(() => expect(onSaved).toHaveBeenCalled());
const widgets = savedBody?.widgets as EditorWidget[];
expect((widgets[0].layouts?.desktop as Record<string, unknown>)).toMatchObject({ x: 1, y: 0, w: 6, h: 6 });
expect((widgets[0].layouts?.tablet as Record<string, unknown>)).toMatchObject({ x: 2, y: 0, w: 6, h: 4 });
expect(widgets[0].visualization?.thresholds).toEqual([{ value: 85, color: '#ff8800', label: '' }]);
expect(widgets[0].behavior?.link).toBe('/incidents');
}, 15_000);
it('prevents every spatial control from mutating a locked widget', () => {
const lockedDocument = { ...document, widgets: [widget('00000000-0000-4000-8000-000000000021', 'Vergrendeld', 2, 3, true)] };
render(<DashboardEditor dashboardId="dashboard-1" revision={1} document={lockedDocument} onExit={() => undefined} onSaved={() => undefined} />);
expect(screen.getByRole('button', { name: 'Widget naar links: Vergrendeld' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Widget omhoog: Vergrendeld' })).toBeDisabled();
expect(screen.getByRole('slider', { name: 'Breedte aanpassen: Vergrendeld' })).toBeDisabled();
expect(screen.getByLabelText('Kolom (x) (Desktop)')).toBeDisabled();
expect(screen.getByLabelText('Hoogte in rijen')).toBeDisabled();
});
it('rejects unsafe links, malformed thresholds and colliding persisted layouts', () => {
expect(validateWidget({ ...widget('x', 'Onveilig', 0, 0), visualization: { thresholds: [{ value: 80, color: 'red' }] }, behavior: { link: 'https://example.test', liveIntervalSeconds: 30 } })).toMatchObject({ 'visualization.thresholds': expect.any(String), 'behavior.link': expect.any(String) });
expect(validateDashboardLayouts([widget('a', 'A', 0, 0), widget('b', 'B', 0, 0)])).toBe(false);
});
});
@@ -0,0 +1,104 @@
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DashboardManagerPage } from '../../src/DashboardManagerPage';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function json(value: unknown, status = 200): Response {
return new Response(JSON.stringify(value), { status, headers: { 'Content-Type': status >= 400 ? 'application/problem+json' : 'application/json' } });
}
const editorSession = { subject: 'editor-1', role: 'editor', environment: 'test', permissions: { view: true, operate: true, edit: true, admin: false } };
const viewerSession = { subject: 'viewer-1', role: 'viewer', environment: 'test', permissions: { view: true, operate: false, edit: false, admin: false } };
const dashboard = { id: 'dashboard-1', slug: 'operations', name: 'Operations', description: 'Dagelijkse operatie', ownerId: 'editor-1', scope: 'shared', revision: 1, currentVersion: 2 };
describe('DashboardManagerPage', () => {
it('creates a validated dashboard from a selected template', async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input); requests.push({ url, init });
if (url === '/api/v1/session') return json(editorSession);
if (url.startsWith('/api/v1/dashboards?')) return json({ items: [] });
if (url === '/api/v1/dashboards' && init?.method === 'POST') return json({ dashboard: { ...dashboard, id: 'created-1', slug: 'nachtzicht', name: 'Nachtzicht', description: '', revision: 1, currentVersion: 1 } }, 201);
return json({ code: 'NOT_FOUND' }, 404);
}));
const user = userEvent.setup();
render(<DashboardManagerPage />);
await user.click(await screen.findByRole('button', { name: 'Nieuw dashboard' }));
await user.type(screen.getByLabelText('Naam'), 'Nachtzicht');
await user.selectOptions(screen.getByLabelText('Template'), 'blank');
await user.click(screen.getByRole('button', { name: 'Dashboard maken' }));
expect(await screen.findByText('Nachtzicht')).toBeVisible();
const create = requests.find((request) => request.url === '/api/v1/dashboards' && request.init?.method === 'POST');
expect(create).toBeDefined();
expect(JSON.parse(String(create?.init?.body))).toMatchObject({ schemaVersion: 2, name: 'Nachtzicht', widgets: [] });
});
it('renames, restores and archives with the current revision', async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input); requests.push({ url, init });
if (url === '/api/v1/session') return json(editorSession);
if (url.startsWith('/api/v1/dashboards?')) return json({ items: [dashboard] });
if (url === '/api/v1/dashboards/dashboard-1' && init?.method === 'PATCH') return json({ dashboard: { ...dashboard, name: 'Operations EU', revision: 2 } });
if (url.endsWith('/versions')) return json({ items: [
{ id: 'version-2', dashboardId: 'dashboard-1', number: 2, schemaVersion: 2, document: {}, changeSummary: 'metadata', createdBy: 'editor-1', createdAt: '2026-09-01T10:00:00Z' },
{ id: 'version-1', dashboardId: 'dashboard-1', number: 1, schemaVersion: 2, document: {}, changeSummary: 'initial', createdBy: 'editor-1', createdAt: '2026-09-01T09:00:00Z' },
] });
if (url.endsWith('/restore/1') && init?.method === 'POST') return json({ dashboard: { ...dashboard, name: 'Operations EU', revision: 3, currentVersion: 3 } });
if (url === '/api/v1/dashboards/dashboard-1' && init?.method === 'DELETE') return json({ dashboard: { ...dashboard, name: 'Operations EU', revision: 4, currentVersion: 3, archivedAt: '2026-09-02T00:00:00Z' } });
return json({ code: 'NOT_FOUND' }, 404);
}));
const user = userEvent.setup();
render(<DashboardManagerPage />);
let row = (await screen.findByText('Operations')).closest('li');
expect(row).not.toBeNull();
await user.click(within(row!).getByRole('button', { name: 'Beheren' }));
const name = within(row!).getByLabelText('Naam');
await user.clear(name); await user.type(name, 'Operations EU');
await user.click(within(row!).getByRole('button', { name: 'Naam en beschrijving opslaan' }));
row = (await screen.findByText('Operations EU')).closest('li');
await user.click(within(row!).getByRole('button', { name: 'Beheren' }));
await user.click(within(row!).getByRole('button', { name: 'Versies bekijken' }));
await user.click(await within(row!).findByRole('button', { name: 'Herstellen als huidige versie' }));
await waitFor(() => expect(requests.some((request) => request.url.endsWith('/restore/1') && new Headers(request.init?.headers).get('If-Match') === '2')).toBe(true));
await user.click(within(row!).getByRole('button', { name: 'Archiveren' }));
await user.click(within(row!).getByRole('button', { name: 'Ja, archiveren' }));
await waitFor(() => expect(requests.some((request) => request.init?.method === 'DELETE' && new Headers(request.init.headers).get('If-Match') === '3')).toBe(true));
expect(await screen.findByText('Geen actieve dashboards beschikbaar.')).toBeVisible();
});
it('hides write controls for viewers and explains the boundary', async () => {
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => String(input) === '/api/v1/session' ? json(viewerSession) : json({ items: [dashboard] })));
render(<DashboardManagerPage />);
expect(await screen.findByText('Alleen bekijken')).toBeVisible();
expect(screen.queryByRole('button', { name: 'Nieuw dashboard' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Beheren' })).not.toBeInTheDocument();
});
it('reloads and gives actionable feedback after a revision conflict', async () => {
let lists = 0;
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === '/api/v1/session') return json(editorSession);
if (url.startsWith('/api/v1/dashboards?')) { lists += 1; return json({ items: [{ ...dashboard, revision: lists }] }); }
if (url === '/api/v1/dashboards/dashboard-1' && init?.method === 'PATCH') return json({ code: 'REVISION_CONFLICT', detail: 'conflict' }, 409);
return json({}, 404);
}));
const user = userEvent.setup();
render(<DashboardManagerPage />);
const row = (await screen.findByText('Operations')).closest('li')!;
await user.click(within(row).getByRole('button', { name: 'Beheren' }));
await user.click(within(row).getByRole('button', { name: 'Naam en beschrijving opslaan' }));
expect(await screen.findByText(/intussen gewijzigd/)).toBeVisible();
await waitFor(() => expect(lists).toBe(2));
});
});
@@ -0,0 +1,54 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DashboardRuntimeWidget, type RuntimeWidget } from '../../src/DashboardRuntimeWidget';
afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); });
const layout = { desktop: { x: 0, y: 0, w: 6, h: 4, visible: true } };
function widget(type: string, sourceType: string, extra: Record<string, unknown> = {}): RuntimeWidget {
return { id: type + '-1', type, title: 'Test ' + type, data: { sourceType, limit: 1000, ...extra }, behavior: { liveIntervalSeconds: 1 }, layouts: layout };
}
function mount(value: RuntimeWidget) {
return render(<DashboardRuntimeWidget widget={value} viewport="desktop" document={{}} timeRange="1h" onState={() => undefined} onFilter={() => undefined} />);
}
describe('DashboardRuntimeWidget adapters', () => {
it('renders bounded alerts and clamps the URL to one hundred rows', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.startsWith('/api/v1/alerts')) return new Response(JSON.stringify({ items: [{ id: 'a1', ruleName: 'Disk warm', state: 'firing', severity: 'critical', entityName: 'disk1', reason: 'temperatuur', updatedAt: '2026-09-01T10:00:00Z' }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
return new Response(JSON.stringify({ components: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
});
vi.stubGlobal('fetch', fetchMock);
mount(widget('alert-summary', 'alerts'));
expect(await screen.findByText('Disk warm')).toBeInTheDocument();
expect(fetchMock.mock.calls.some(([input]) => String(input) === '/api/v1/alerts?limit=100')).toBe(true);
expect(screen.getAllByText('1', { selector: 'dd' })).toHaveLength(2);
});
it('renders incident rows and safe text without interpreting HTML', async () => {
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => String(input).startsWith('/api/v1/incidents')
? new Response(JSON.stringify({ items: [{ id: 'i1', title: 'Opslagincident', status: 'open', severity: 'warning', summary: 'Controle nodig', updatedAt: '2026-09-01T10:00:00Z' }] }), { status: 200, headers: { 'Content-Type': 'application/json' } })
: new Response(JSON.stringify({ components: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
const view = mount(widget('table', 'incidents'));
expect(await screen.findByText('Opslagincident')).toBeInTheDocument();
view.unmount();
mount(widget('text', 'text', { text: '# Runbook\n[Open incident](/incidents)\n<script>alert(1)</script>' }));
expect(screen.getByRole('link', { name: 'Open incident' })).toHaveAttribute('href', '/incidents');
expect(screen.queryByRole('script')).not.toBeInTheDocument();
expect(screen.getByText('<script>alert(1)</script>')).toBeInTheDocument();
});
it('contains an unknown type locally and clears its bounded polling timer', async () => {
vi.useFakeTimers();
const clear = vi.spyOn(window, 'clearInterval');
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => String(input).startsWith('/api/v1/events')
? new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })
: new Response(JSON.stringify({ components: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
const view = mount(widget('made-up', 'events'));
expect(screen.getByText('Onbekend widgettype')).toBeInTheDocument();
expect(screen.getByText('made-up')).toBeInTheDocument();
view.unmount();
expect(clear).toHaveBeenCalled();
});
});
@@ -0,0 +1,49 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ExploreMetricsPage } from '../../src/ExploreMetricsPage';
import { resetOperatorContextForTests, setOperatorCustomRange } from '../../src/operatorContext';
const metric = (name: string, maxRangeSeconds = 2_592_000) => ({ semanticName: name, description: 'Goedgekeurde metric voor operators', unit: 'percent', valueKind: 'gauge', defaultAggregation: 'avg', allowedVisualizations: ['timeseries'], limits: { maxRangeSeconds, maxSeries: 20, maxPoints: 4000, timeoutSeconds: 5 } });
const catalog = { version: 'v1', metrics: [metric('host.cpu.utilization'), metric('host.memory.utilization', 3600), metric('storage.pool.utilization'), metric('service.availability'), metric('service.response_time')], bindings: ['host.cpu.utilization', 'host.memory.utilization', 'storage.pool.utilization', 'service.availability', 'service.response_time'].map((semanticName) => ({ semanticName, state: 'supported' })) };
const response = { status: 'success', data: { result: [{ metric: { host: 'tower' }, values: [[1_777_000_000, '42'], [1_777_000_060, '43']] }] }, provenance: { source: 'fixture', metric: 'test', catalogVersion: 'v1', cacheKey: 'safe' }, sourceObservedAt: '2026-09-01T10:00:00Z', receivedAt: '2026-09-01T10:00:01Z', freshness: 'fresh', cacheHit: false };
afterEach(() => { cleanup(); vi.unstubAllGlobals(); resetOperatorContextForTests(); window.localStorage.clear(); window.history.replaceState({}, '', '/explore'); });
function mockAPI() {
const calls: Array<Record<string, unknown>> = [];
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/api/v1/metrics/catalog')) return new Response(JSON.stringify(catalog), { status: 200, headers: { 'Content-Type': 'application/json' } });
if (url.includes('/api/v1/incidents') || url.includes('/api/v1/alerts')) return new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
if (url.endsWith('/api/v1/metrics/query-range')) { calls.push(JSON.parse(String(init?.body))); return new Response(JSON.stringify(response), { status: 200, headers: { 'Content-Type': 'application/json' } }); }
return new Response('', { status: 404 });
}));
return calls;
}
describe('ExploreMetricsPage', () => {
it('finds and opens a semantic metric without exposing PromQL', async () => {
const calls = mockAPI(); const user = userEvent.setup();
render(<ExploreMetricsPage />);
await user.type(await screen.findByPlaceholderText('CPU, opslag, responstijd…'), 'cpu');
await user.click(screen.getByRole('button', { name: /host.cpu.utilization/ }));
await waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0]).toMatchObject({ metric: 'host.cpu.utilization', maxSeries: 20, maxPoints: 4000 });
expect(document.body).not.toHaveTextContent('rate(');
});
it('caps compare at four metrics and blocks an over-budget range locally', async () => {
const calls = mockAPI(); const user = userEvent.setup();
render(<ExploreMetricsPage />);
await user.click(await screen.findByRole('tab', { name: 'Vergelijken' }));
for (const name of ['host.cpu.utilization', 'storage.pool.utilization', 'service.availability', 'service.response_time']) await user.click(screen.getByRole('button', { name: new RegExp(name.replaceAll('.', '\\.')) }));
expect(screen.getByText('Maximaal vier metrics kunnen tegelijk worden vergeleken.')).toBeVisible();
expect(screen.getByRole('button', { name: /host.memory.utilization/ })).not.toHaveAttribute('aria-pressed', 'true');
await user.click(screen.getByRole('button', { name: /host.cpu.utilization/ }));
setOperatorCustomRange('2026-09-01T00:00', '2026-09-02T00:00');
await user.click(screen.getByRole('button', { name: /host.memory.utilization/ }));
await waitFor(() => expect(screen.getByText('Serverbudget overschreden')).toBeVisible());
expect(calls.some((call) => call.metric === 'host.memory.utilization')).toBe(false);
});
});
+17 -1
View File
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
@@ -64,6 +64,22 @@ describe('interactive widgets', () => {
expect(document.body).not.toHaveTextContent(rawKey);
});
it('supports series toggles, bounded zoom, a pointer crosshair and annotations', async () => {
const user = userEvent.setup();
const view = render(<MetricWidget {...props({ kind: 'timeseries', series: [
{ key: 'tower', points: [{ ...freshPoint, timestamp: now - 30_000 }, freshPoint] },
{ key: 'backup', points: [{ ...freshPoint, timestamp: now - 30_000, labels: { host: 'backup' } }, { ...freshPoint, labels: { host: 'backup' } }] },
], annotations: [{ id: 'incident-1', label: 'Opslagincident', timestamp: now, tone: 'critical' }] })} />);
const widget = within(view.container);
const backup = widget.getByRole('button', { name: 'backup' });
expect(backup).toHaveAttribute('aria-pressed', 'true');
await user.click(backup);
expect(backup).toHaveAttribute('aria-pressed', 'false');
await user.click(widget.getByRole('button', { name: 'Zoom op recente helft' }));
expect(widget.getByRole('button', { name: 'Volledige periode' })).toHaveAttribute('aria-pressed', 'true');
expect(widget.getByRole('list', { name: 'Incident- en meldingsannotaties' })).toHaveTextContent('Opslagincident');
});
it('supports keyboard activation of ranked rows', async () => {
const onSelect = vi.fn();
render(<RankedListWidget items={[{ id: 'disk-1', label: 'Disk 1', value: '78%' }]} onSelect={onSelect} />);
@@ -0,0 +1,15 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { ApiProblemError } from '../../src/apiClient';
import { RouteErrorBoundary } from '../../src/RouteErrorBoundary';
function BrokenRoute(): never { throw new ApiProblemError(500, { title: 'Fout' }, 'corr-route-7'); }
describe('RouteErrorBoundary', () => {
it('keeps the shell alive and exposes correlation context', () => {
render(<><nav aria-label="Applicatieshell">Navigatie</nav><RouteErrorBoundary resetKey="/broken"><BrokenRoute /></RouteErrorBoundary></>);
expect(screen.getByRole('navigation', { name: 'Applicatieshell' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Deze pagina kon niet worden weergegeven' })).toBeInTheDocument();
expect(screen.getByText(/corr-route-7/)).toBeInTheDocument();
});
});
+39
View File
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ApiProblemError, requestJSON } from '../../src/apiClient';
afterEach(() => vi.unstubAllGlobals());
describe('requestJSON', () => {
it('preserves safe problem and correlation context', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ title: 'Begrensd', detail: 'Probeer een kortere periode.' }), { status: 422, headers: { 'content-type': 'application/problem+json', 'x-correlation-id': 'corr-123' } })));
await expect(requestJSON('/api/v1/example')).rejects.toMatchObject({ status: 422, correlationId: 'corr-123', message: 'Probeer een kortere periode.' } satisfies Partial<ApiProblemError>);
});
it('retries safe GET requests only within the configured bound', async () => {
const fetcher = vi.fn().mockResolvedValueOnce(new Response('{}', { status: 503 })).mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } }));
vi.stubGlobal('fetch', fetcher);
await expect(requestJSON<{ ok: boolean }>('/api/v1/example', { retries: 1 })).resolves.toEqual({ ok: true });
expect(fetcher).toHaveBeenCalledTimes(2);
});
it('never retries mutations', async () => {
const fetcher = vi.fn().mockResolvedValue(new Response('{}', { status: 503, headers: { 'content-type': 'application/problem+json' } }));
vi.stubGlobal('fetch', fetcher);
await expect(requestJSON('/api/v1/example', { method: 'POST', retries: 2 })).rejects.toBeInstanceOf(ApiProblemError);
expect(fetcher).toHaveBeenCalledTimes(1);
});
it.each([401, 403])('preserves authorization status %s without retry', async (status) => {
const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({ title: 'Geen toegang', correlationId: 'corr-auth' }), { status, headers: { 'content-type': 'application/problem+json' } }));
vi.stubGlobal('fetch', fetcher);
await expect(requestJSON('/api/v1/protected', { retries: 2 })).rejects.toMatchObject({ status, correlationId: 'corr-auth' });
expect(fetcher).toHaveBeenCalledTimes(1);
});
it('never retries an aborted request', async () => {
const fetcher = vi.fn().mockRejectedValue(new DOMException('aborted', 'AbortError'));
vi.stubGlobal('fetch', fetcher);
await expect(requestJSON('/api/v1/example', { retries: 2 })).rejects.toMatchObject({ name: 'AbortError' });
expect(fetcher).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { movePlacement, normalizePlacement, overlaps, resizePlacement } from '../../src/dashboardSpatial';
describe('dashboard spatial layout', () => {
it('clamps placements to the active column budget', () => {
expect(normalizePlacement({ x: 17, y: -3, w: 6, h: 0 }, 'desktop')).toEqual({ x: 12, y: 0, w: 6, h: 1, visible: true });
expect(normalizePlacement({ x: 4, y: 3, w: 4, h: 5 }, 'mobile')).toEqual({ x: 0, y: 3, w: 1, h: 5, visible: true });
});
it('detects overlap and pushes a moved widget below collisions', () => {
const occupied = [{ id: 'a', placement: { x: 0, y: 0, w: 6, h: 4, visible: true } }];
expect(overlaps({ x: 0, y: 0, w: 2, h: 2 }, occupied[0].placement)).toBe(true);
expect(movePlacement({ x: 6, y: 0, w: 6, h: 4 }, -6, 0, occupied, 'desktop')).toMatchObject({ x: 0, y: 4, w: 6, h: 4 });
});
it('keeps resize within the viewport and resolves collisions', () => {
const occupied = [{ id: 'a', placement: { x: 8, y: 0, w: 4, h: 4, visible: true } }];
const resized = resizePlacement({ x: 0, y: 0, w: 6, h: 4 }, 6, 2, occupied, 'desktop');
expect(resized.w).toBe(12);
expect(resized.y).toBe(4);
expect(resized.h).toBe(6);
});
});
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { buildDashboardDocument, dashboardSlug } from '../../src/dashboardTemplates';
describe('dashboard templates', () => {
it('creates a schema v2 blank document with required fields', () => {
let index = 0;
const document = buildDashboardDocument({ name: 'Mijn Test', scope: 'personal', template: 'blank', idFactory: () => `00000000-0000-4000-8000-${String(++index).padStart(12, '0')}` });
expect(document.schemaVersion).toBe(2); expect(document.scope).toBe('personal'); expect(document.widgets).toEqual([]); expect(document.slug).toMatch(/^mijn-test-/);
});
it('only uses proven runtime sources in the operations template', () => {
let index = 0;
const document = buildDashboardDocument({ name: 'Operations', scope: 'shared', template: 'operations', idFactory: () => `10000000-0000-4000-8000-${String(++index).padStart(12, '0')}` });
const widgets = document.widgets as Array<{ data?: { sourceType?: string } }>;
expect(widgets.length).toBeGreaterThan(0); expect(new Set(widgets.map((widget) => widget.data?.sourceType))).toEqual(new Set(['inventory', 'semantic-metric', 'events']));
});
it('normalizes a unicode name to a valid bounded slug', () => { const slug = dashboardSlug('Opslag & Café / Test', 'ABC123'); expect(slug).toBe('opslag-cafe-test-abc123'); expect(slug.length).toBeLessThanOrEqual(80); });
});
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { historicalSamplesFromData } from '../../src/liveBuffer';
describe('historical metric boundary', () => {
it('caps oversized upstream payloads before they enter chart state', () => {
const values = Array.from({ length: 12_000 }, (_, index) => [1_700_000_000 + index, String(index)]);
const result = historicalSamplesFromData({ result: Array.from({ length: 120 }, (_, index) => ({ metric: { host: `host-${index}` }, values })) });
expect(result).toHaveLength(10_000);
expect(new Set(result.map((sample) => sample.series)).size).toBe(1);
expect(result[0].value).toBe(2000);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { rangeForPreset, rangeForSelection } from '../../src/metricClient';
describe('metric range selection', () => {
const now = new Date('2026-08-30T18:00:00.000Z');
it('supports the 30 day operator preset within the existing point budget', () => {
const range = rangeForPreset('30d', now);
expect(range.to).toBe(now.toISOString());
expect(Date.parse(range.to) - Date.parse(range.from)).toBe(30 * 24 * 60 * 60 * 1000);
expect(range.stepSeconds).toBeGreaterThanOrEqual(15);
});
it('turns a valid custom selection into an exact UTC range', () => {
const range = rangeForSelection('custom|2026-08-01T10:00:00.000Z|2026-08-02T10:00:00.000Z', now);
expect(range.from).toBe('2026-08-01T10:00:00.000Z');
expect(range.to).toBe('2026-08-02T10:00:00.000Z');
});
it('fails closed to one hour when a custom range is invalid', () => {
expect(rangeForSelection('custom|broken|2026-08-02T10:00:00.000Z', now)).toEqual(rangeForPreset('1h', now));
});
it('uses bounded recent history while live transport is paused', () => {
expect(rangeForSelection('paused', now)).toEqual(rangeForPreset('live', now));
});
});
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
operatorCustomRangeValid,
operatorRangeSelection,
resetOperatorContextForTests,
setOperatorCustomRange,
setOperatorPaused,
setOperatorRangePreset,
} from '../../src/operatorContext';
afterEach(() => {
resetOperatorContextForTests();
window.localStorage.clear();
window.history.replaceState({}, '', '/');
});
describe('global operator context', () => {
it('encodes a valid custom range in the shareable URL', () => {
setOperatorCustomRange('2026-09-01T10:00', '2026-09-01T11:00');
const params = new URLSearchParams(window.location.search);
expect(params.get('range')).toBe('custom');
expect(params.get('from')).toBe(new Date('2026-09-01T10:00').toISOString());
expect(params.get('to')).toBe(new Date('2026-09-01T11:00').toISOString());
});
it('permits pause only for the live range', () => {
setOperatorRangePreset('live');
setOperatorPaused(true);
expect(window.localStorage.getItem('pulse.operator.paused')).toBe('true');
setOperatorRangePreset('1h');
expect(window.localStorage.getItem('pulse.operator.paused')).toBe('false');
});
it('fails closed to one hour when a custom range is invalid', () => {
const value = { range: 'custom' as const, customFrom: '2026-09-01T11:00', customTo: '2026-09-01T10:00', paused: false, refreshVersion: 0 };
expect(operatorCustomRangeValid(value)).toBe(false);
expect(operatorRangeSelection(value)).toBe('1h');
});
});
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
isFavorite,
readWorkspaceState,
recordRecent,
removeSavedView,
saveCurrentView,
toggleFavorite,
WORKSPACE_EVENT,
} from '../../src/operatorWorkspace';
afterEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
describe('operator workspace preferences', () => {
it('toggles favorites without duplicating the same path', () => {
expect(toggleFavorite('Cache pool', '/pools/cache?range=24h')).toBe(true);
expect(isFavorite('/pools/cache?range=24h')).toBe(true);
expect(readWorkspaceState().favorites).toHaveLength(1);
expect(toggleFavorite('Cache pool', '/pools/cache?range=24h')).toBe(false);
expect(isFavorite('/pools/cache?range=24h')).toBe(false);
expect(readWorkspaceState().favorites).toHaveLength(0);
});
it('persists a saved view and removes it by exact route context', () => {
saveCurrentView('Degraded services', '/services?state=degraded&range=24h');
expect(readWorkspaceState().views).toEqual([
expect.objectContaining({ label: 'Degraded services', path: '/services?state=degraded&range=24h', kind: 'view' }),
]);
removeSavedView('/services?state=degraded&range=24h');
expect(readWorkspaceState().views).toHaveLength(0);
});
it('keeps recent routes bounded, unique and excludes the 404 surface', () => {
recordRecent('Missing', '/404');
for (let index = 0; index < 15; index += 1) recordRecent('Item ' + index, '/inventory/' + index);
recordRecent('Item 14 updated', '/inventory/14');
const recent = readWorkspaceState().recent;
expect(recent).toHaveLength(12);
expect(recent[0]).toMatchObject({ label: 'Item 14 updated', path: '/inventory/14', kind: 'recent' });
expect(recent.filter((item) => item.path === '/inventory/14')).toHaveLength(1);
expect(recent.some((item) => item.path === '/404')).toBe(false);
});
it('fails closed on malformed storage and emits a workspace change event on writes', () => {
window.localStorage.setItem('pulse.operator.workspace.v1', '{bad json');
expect(readWorkspaceState()).toEqual({ favorites: [], views: [], recent: [] });
const listener = vi.fn();
window.addEventListener(WORKSPACE_EVENT, listener);
saveCurrentView('Overview', '/');
window.removeEventListener(WORKSPACE_EVENT, listener);
expect(listener).toHaveBeenCalledTimes(1);
});
});
+38
View File
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { readSessionCapabilities } from '../../src/sessionClient';
afterEach(() => vi.unstubAllGlobals());
function json(value: unknown, status = 200): Response {
return new Response(JSON.stringify(value), { status, headers: { 'Content-Type': 'application/json' } });
}
describe('session capability client', () => {
it('reads bounded identity, environment and effective permissions', async () => {
vi.stubGlobal('fetch', vi.fn(async () => json({
subject: 'operator-42', role: 'editor', environment: 'development',
permissions: { view: true, operate: true, edit: true, admin: false },
})));
await expect(readSessionCapabilities()).resolves.toEqual({
subject: 'operator-42', role: 'editor', environment: 'development',
permissions: { view: true, operate: true, edit: true, admin: false }, source: 'session',
});
});
it('falls back to the legacy capability probe when the endpoint is absent', async () => {
const fetch = vi.fn(async () => fetch.mock.calls.length === 1 ? json({}, 404) : json({}, 405));
vi.stubGlobal('fetch', fetch);
const result = await readSessionCapabilities();
expect(result).toMatchObject({ role: 'editor+', environment: '', permissions: { view: true, operate: true, edit: true, admin: false }, source: 'fallback' });
expect(fetch).toHaveBeenNthCalledWith(2, '/api/v1/widgets/preview', expect.objectContaining({ method: 'GET', cache: 'no-store' }));
});
it('preserves an explicit authentication failure', async () => {
vi.stubGlobal('fetch', vi.fn(async () => json({}, 401)));
await expect(readSessionCapabilities()).rejects.toMatchObject({ status: 401 });
});
});
+25
View File
@@ -0,0 +1,25 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fallbackWidgetCatalog, isWidgetType, readWidgetCatalog, widgetLabel, widgetTypes } from '../../src/widgetCatalog';
afterEach(() => vi.unstubAllGlobals());
describe('widget catalog client', () => {
it('keeps one bounded fourteen-type catalog including query inspector', () => {
expect(widgetTypes).toHaveLength(14);
expect(new Set(widgetTypes).size).toBe(14);
expect(fallbackWidgetCatalog).toHaveLength(14);
expect(widgetLabel('query-inspector')).toBe('Query-inspecteur');
expect(isWidgetType('made-up')).toBe(false);
});
it('normalizes the existing Go catalog shape and ignores unknown entries', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ items: [
{ Type: 'stat', Capabilities: ['metrics'] }, { Type: 'made-up' }, { type: 'text', capabilities: ['safe-markdown'] },
] }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
const catalog = await readWidgetCatalog();
expect(catalog).toHaveLength(14);
expect(catalog.find((item) => item.type === 'stat')?.capabilities).toEqual(['metrics']);
expect(catalog.find((item) => item.type === 'text')?.capabilities).toEqual(['safe-markdown']);
expect(catalog.some((item) => item.type === ('made-up' as never))).toBe(false);
});
});