This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
const generatedAt = new Date().toISOString();
|
||||
|
||||
async function mockAPI(page: Page): Promise<void> {
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path === '/api/v1/system/status') {
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
version: '1', generatedAt, overallState: 'degraded',
|
||||
components: [{ id: 'database', state: 'healthy', reason: 'ok' }, { id: 'prometheus', state: 'unknown', reason: 'source_stale' }],
|
||||
backup: { state: 'disabled', reason: 'not_configured' },
|
||||
sourceLag: [{ sourceId: 'reverse-proxy', state: 'unknown', reason: 'source_unavailable', ageSeconds: 180 }],
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (path === '/api/v1/dashboards') {
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
return;
|
||||
}
|
||||
if (path === '/api/v1/host') {
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({
|
||||
identity: { name: 'mobile-fixture' },
|
||||
cpu: { totalPercent: 42, perCore: Array.from({ length: 16 }, (_, index) => index + 1) },
|
||||
memory: { utilizationPercent: 61 }, source: { state: 'healthy', freshness: 'fresh' },
|
||||
}) });
|
||||
return;
|
||||
}
|
||||
if (path === '/api/v1/containers') {
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ source: { state: 'healthy', freshness: 'fresh' }, containers: [{ id: 'proxy', state: 'running', health: 'healthy' }], total: 1 }) });
|
||||
return;
|
||||
}
|
||||
if (path === '/api/v1/pools') {
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ source: { state: 'healthy', freshness: 'fresh' }, pools: [{ id: 'cache', name: 'Cache', state: 'healthy', utilizationPercent: 63 }], total: 1 }) });
|
||||
return;
|
||||
}
|
||||
if (path === '/api/v1/services') {
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ capabilityState: 'available', configurationState: 'configured', services: [{ id: 'proxy', name: 'Proxy', state: 'up' }], total: 1 }) });
|
||||
return;
|
||||
}
|
||||
if (path === '/api/v1/incidents') {
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [{ id: 'incident-1', title: 'Bronvertraging', severity: 'warning', startedAt: generatedAt }] }) });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 404, contentType: 'application/problem+json', body: JSON.stringify({ code: 'NOT_FOUND' }) });
|
||||
});
|
||||
}
|
||||
|
||||
async function expectNoCriticalA11yViolations(page: Page): Promise<void> {
|
||||
const results = await new AxeBuilder({ page }).analyze();
|
||||
const severe = results.violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious');
|
||||
expect(severe, severe.map((violation) => `${violation.id}: ${violation.help}`).join('\n')).toEqual([]);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockAPI(page);
|
||||
});
|
||||
|
||||
test('overview exposes degraded/unknown state and passes axe', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'wallboard-chromium', 'Wallboard has a dedicated route test.');
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
await expect(page.getByText(/Onbekend|Verminderd/).first()).toBeVisible();
|
||||
if (testInfo.project.name === 'mobile-chromium') {
|
||||
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);
|
||||
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);
|
||||
await expect(page.locator('.signal-path-inspector')).toBeVisible();
|
||||
const incidentBox = await page.locator('.incident-queue').boundingBox();
|
||||
const signalBox = await page.locator('.signal-path-panel').boundingBox();
|
||||
expect(incidentBox?.y).toBeLessThan(signalBox?.y ?? 0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
} else {
|
||||
await expect(page.locator('.desktop-navigation')).toBeVisible();
|
||||
const rail = await page.locator('.sidebar').boundingBox();
|
||||
const commandHeader = await page.locator('.context-bar').boundingBox();
|
||||
expect(rail?.width).toBeLessThanOrEqual(72);
|
||||
expect(commandHeader?.height).toBe(56);
|
||||
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);
|
||||
await expect(page.locator('.nav-group').first()).toHaveAttribute('open', '');
|
||||
await expect(page.locator('.nav-group').filter({ hasText: 'Infrastructuur' })).not.toHaveAttribute('open', '');
|
||||
}
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator(':focus')).toBeVisible();
|
||||
await expectNoCriticalA11yViolations(page);
|
||||
if (process.env.PULSE_E2E_REAL_BASE_URL || process.env.PULSE_CAPTURE_VISUALS) {
|
||||
const evidenceDirectory = process.env.PULSE_CAPTURE_VISUALS ? 'M14-02' : 'M11-10';
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
|
||||
await page.screenshot({ path: path.resolve('../../artifacts/evidence', evidenceDirectory, `overview-${testInfo.project.name}.png`), fullPage: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('grouped navigation reaches infrastructure in 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();
|
||||
} else {
|
||||
await page.locator('.nav-group').filter({ hasText: 'Infrastructuur' }).locator('summary').click();
|
||||
await page.getByRole('link', { name: /Disks/ }).click();
|
||||
}
|
||||
await expect(page).toHaveURL(/\/disks$/);
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
});
|
||||
|
||||
test('wallboard remains read-only, bounded and accessible', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'wallboard-chromium', 'Wallboard is verified at 1920x1080.');
|
||||
await page.goto('/wallboard');
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
await expect(page.getByText(/Geen dashboards|Wallboard/).first()).toBeVisible();
|
||||
await expect(page.locator('.sidebar')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /Bewerken|Exporteren/ })).toHaveCount(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight + 1)).toBe(true);
|
||||
await expectNoCriticalA11yViolations(page);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
const alerts = Array.from({ length: 25 }, (_, index) => ({
|
||||
id: `alert-${String(index + 1).padStart(2, '0')}`,
|
||||
state: index === 1 ? 'acknowledged' : 'firing',
|
||||
retainedState: 'firing',
|
||||
ruleName: `Melding ${String(index + 1).padStart(2, '0')}`,
|
||||
severity: index % 5 === 0 ? 'critical' : 'attention',
|
||||
entityName: index % 2 === 0 ? 'Tower' : 'Database',
|
||||
reason: 'threshold_exceeded',
|
||||
revision: index + 1,
|
||||
updatedAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 0) - index * 60_000).toISOString(),
|
||||
}));
|
||||
|
||||
test('alertwerkruimte prioriteert operatie en opent configuratie doelgericht', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'wallboard-chromium', 'De alertwerkruimte gebruikt de desktop-, tablet- en mobiele shell.');
|
||||
let operationHeaders: Record<string, string> | undefined;
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
if (pathname === '/api/v1/system/status') {
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ version: 'test', generatedAt: new Date().toISOString(), overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }) });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/alert-rules') {
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/metrics/catalog') {
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ metrics: [{ semanticName: 'host.cpu.utilization', unit: 'percent', defaultAggregation: 'avg' }] }) });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/alerts' && request.method() === 'GET') {
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: alerts }) });
|
||||
return;
|
||||
}
|
||||
if (pathname.startsWith('/api/v1/alerts/') && request.method() === 'POST') {
|
||||
operationHeaders = request.headers();
|
||||
await route.fulfill({ contentType: 'application/json', body: '{}' });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/alert-silences' || pathname === '/api/v1/maintenance-windows') {
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 404, contentType: 'application/problem+json', body: '{}' });
|
||||
});
|
||||
|
||||
await page.goto('/alerts');
|
||||
await expect(page.getByRole('heading', { name: 'Meldingen en incidenten' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Actief 25/ })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.getByRole('button', { name: /Kritiek actief 5/ })).toBeVisible();
|
||||
const rows = page.locator('.alert-operation-list-items > li');
|
||||
await expect(rows).toHaveCount(20);
|
||||
await expect(rows.first()).toContainText('Kritiek');
|
||||
await expect(page.getByRole('heading', { name: 'Geregistreerde regels' })).toHaveCount(0);
|
||||
|
||||
const acknowledge = page.getByRole('button', { name: 'Erkennen' }).first();
|
||||
page.once('dialog', (dialog) => dialog.dismiss());
|
||||
await acknowledge.click();
|
||||
expect(operationHeaders).toBeUndefined();
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await acknowledge.click();
|
||||
await expect.poll(() => operationHeaders?.['if-match']).toBe('1');
|
||||
expect(operationHeaders?.['idempotency-key']).toBeTruthy();
|
||||
|
||||
await page.getByRole('button', { name: /Alertregels Detectie en drempels/ }).click();
|
||||
await expect(page).toHaveURL(/section=rules/);
|
||||
await expect(page.getByRole('heading', { name: 'Geregistreerde regels' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Actieve en recente meldingen' })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: /Stiltes en onderhoud Tijdelijke uitzonderingen/ }).click();
|
||||
await expect(page).toHaveURL(/section=controls/);
|
||||
await expect(page.getByRole('heading', { name: 'Tijdelijke onderdrukking en onderhoud' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Geregistreerde regels' })).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: 'Tijdelijke onderdrukking en onderhoud' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Stiltes en onderhoud Tijdelijke uitzonderingen/ })).toHaveAttribute('aria-current', 'page');
|
||||
await page.getByRole('button', { name: /Actieve meldingen Prioriteiten en erkenning/ }).click();
|
||||
await expect(page).not.toHaveURL(/section=/);
|
||||
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollHeight / window.innerHeight)).toBeLessThan(10);
|
||||
const axe = await new AxeBuilder({ page }).analyze();
|
||||
expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
|
||||
if (testInfo.project.name === 'mobile-chromium') {
|
||||
const heights = await page.locator('.alert-section-nav button').evaluateAll((buttons) => buttons.map((button) => button.getBoundingClientRect().height));
|
||||
expect(heights.every((height) => height >= 44)).toBe(true);
|
||||
}
|
||||
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', `alerts-${testInfo.project.name}.png`), fullPage: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
|
||||
|
||||
test('persisted capacity history produces one qualified explainable forecast', async ({ page }) => {
|
||||
test.skip(!enabled, 'Requires an isolated real Pulse stack.');
|
||||
expect((await page.request.get('/auth/test-login')).ok()).toBeTruthy();
|
||||
const response = await page.request.get('/api/v1/forecasts');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const snapshot = await response.json() as { qualifiedCount: number; items: Array<{ entityId: string; dataPoints: number; confidence: string; projectedAt?: string }> };
|
||||
expect(snapshot.qualifiedCount).toBe(1);
|
||||
expect(snapshot.items[0]).toMatchObject({ dataPoints: 3, confidence: 'medium' });
|
||||
expect(snapshot.items[0].projectedAt).toBeTruthy();
|
||||
|
||||
await page.goto('/capacity');
|
||||
await expect(page.getByText(/1 gekwalificeerde prognoses/)).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Media forecast' })).toBeVisible();
|
||||
await expect(page.getByText('Mediane dagelijkse groei', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Gemiddelde betrouwbaarheid')).toBeVisible();
|
||||
await expect(page.getByText('42 dagen', { exact: false })).toBeVisible();
|
||||
await expect(page.getByText('0 B / 0 B')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
const axe = await new AxeBuilder({ page }).analyze();
|
||||
expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
|
||||
const coreRoutes = ['/', '/host', '/containers', '/storage', '/services', '/alerts', '/incidents', '/inventory'];
|
||||
|
||||
test('core routes remain accessible, bounded and visually stable', async ({ page }, testInfo) => {
|
||||
test.skip(!enabled, 'Requires the isolated server-built Pulse stack.');
|
||||
const errors: string[] = [];
|
||||
page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); });
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
expect((await page.request.get('/auth/test-login')).ok()).toBeTruthy();
|
||||
|
||||
const routes = testInfo.project.name === 'wallboard-chromium' ? ['/wallboard'] : coreRoutes;
|
||||
for (const route of routes) {
|
||||
await page.goto(route);
|
||||
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), `${route} has document overflow`).toBe(true);
|
||||
if (route === '/wallboard') expect(await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight + 1), `${route} has vertical overflow`).toBe(true);
|
||||
const axe = await new AxeBuilder({ page }).analyze();
|
||||
expect(axe.violations.filter((item) => item.impact === 'critical' || item.impact === 'serious'), `${route} axe findings`).toEqual([]);
|
||||
if (process.env.PULSE_CAPTURE_VISUALS && (route === '/' || route === '/services' || route === '/wallboard')) {
|
||||
const name = route === '/' ? 'overview' : route.slice(1);
|
||||
await page.screenshot({ path: path.resolve('../../artifacts/evidence/M11-10', `${name}-${testInfo.project.name}.png`), fullPage: true });
|
||||
}
|
||||
}
|
||||
expect(errors, errors.join('\n')).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
const dashboard = { id: 'polish-dashboard', slug: 'operations', name: 'Netwerkoperaties', description: 'Actuele netwerkbelasting en operationele wijzigingen.', scope: 'system', revision: 4, currentVersion: 7 };
|
||||
const widgets = [
|
||||
{
|
||||
id: 'network', title: 'Netwerkbelasting', type: 'timeseries',
|
||||
data: { sourceType: 'semantic-metric', metric: 'host.network.receive', aggregation: 'avg' },
|
||||
visualization: { unit: 'bytesPerSecond', decimals: 0, legend: true },
|
||||
behavior: { locked: false, hidden: false, liveIntervalSeconds: 30 },
|
||||
layouts: { desktop: { x: 0, y: 0, w: 9, h: 5, visible: true } },
|
||||
},
|
||||
{
|
||||
id: 'events', title: 'Recente wijzigingen', type: 'event-timeline',
|
||||
data: { sourceType: 'events', limit: 12 }, behavior: { locked: false, hidden: false, liveIntervalSeconds: 30 },
|
||||
layouts: { desktop: { x: 9, y: 0, w: 9, h: 5, visible: true } },
|
||||
},
|
||||
];
|
||||
|
||||
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'
|
||||
? { 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 }
|
||||
: requestPath === '/api/v1/events'
|
||||
? { items: [{ id: 'event-1', type: 'container.restart', severity: 'warning', summary: 'container.restart', occurredAt: '2026-08-11T04:00:00Z' }] }
|
||||
: requestPath === '/api/v1/system/status'
|
||||
? { version: '1', generatedAt: '2026-08-11T04:01:00Z', overallState: 'healthy', components: [{ id: 'database', state: 'healthy', reason: 'database_ready' }], backup: { state: 'healthy', reason: 'backup_verified' }, sourceLag: [] }
|
||||
: {};
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
});
|
||||
}
|
||||
|
||||
async function expectNoSeriousAxeViolations(page: Page): Promise<void> {
|
||||
const results = await new AxeBuilder({ page }).analyze();
|
||||
const violations = results.violations.filter((item) => item.impact === 'critical' || item.impact === 'serious');
|
||||
expect(violations, violations.map((item) => `${item.id}: ${item.help}`).join('\n')).toEqual([]);
|
||||
}
|
||||
|
||||
test('dashboard and editor use human labels, safe modes and accessible keyboard controls', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'desktop-chromium', 'The editor acceptance proof uses the desktop canvas.');
|
||||
await mockDashboard(page);
|
||||
await page.goto('/dashboards/polish-dashboard');
|
||||
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Netwerkoperaties' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { level: 2, name: 'Dashboardwidgets' })).toBeAttached();
|
||||
await expect(page.getByRole('heading', { level: 3, name: 'Netwerkbelasting' })).toBeVisible();
|
||||
await expect(page.getByRole('list', { name: 'Legenda' })).toContainText('tower · eth0');
|
||||
await expect(page.locator('.metric-chart-line')).toHaveAttribute('d', 'M 28.000 192.000 L 628.000 12.000');
|
||||
await expect(page.getByText('Container herstart', { exact: false })).toBeVisible();
|
||||
await expect(page.getByText('container.restart', { exact: true })).toHaveCount(0);
|
||||
await expect(page.locator('body')).not.toContainText('{"__name__"');
|
||||
await expectNoSeriousAxeViolations(page);
|
||||
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-07/dashboard-human-labels.png'), fullPage: true });
|
||||
|
||||
await page.getByRole('button', { name: 'Bewerken' }).click();
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Dashboard aanpassen' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { level: 2, name: 'Dashboardindeling' })).toBeAttached();
|
||||
const advanced = page.getByText('Dashboardvariabelen, sjablonen en gegevensoverdracht', { exact: true });
|
||||
await expect(advanced).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Import, export en templates' })).toBeHidden();
|
||||
await expect(page.locator('.editor-widget-actions').first()).toContainText('Omhoog');
|
||||
await expect(page.locator('.editor-widget-actions').first()).toContainText('Omlaag');
|
||||
await expect(page.locator('.editor-widget-actions').first()).toContainText('Breedte');
|
||||
await expect(page.getByText('Weergavemodus')).toHaveCount(0);
|
||||
|
||||
const editorWidgets = page.locator('.editor-widget');
|
||||
await expect(editorWidgets.locator('h3')).toHaveText(['Netwerkbelasting', 'Recente wijzigingen']);
|
||||
const firstWidget = await editorWidgets.first().boundingBox();
|
||||
expect(firstWidget).not.toBeNull();
|
||||
await page.mouse.move(firstWidget!.x + 30, firstWidget!.y + 30);
|
||||
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']);
|
||||
|
||||
const resize = page.getByRole('slider', { name: 'Breedte aanpassen: Netwerkbelasting' });
|
||||
await expect(resize).toHaveAttribute('aria-valuenow', '9');
|
||||
await resize.focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(resize).toHaveAttribute('aria-valuenow', '10');
|
||||
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-07/editor-keyboard-and-disclosure.png'), fullPage: true });
|
||||
await advanced.click();
|
||||
await expect(page.getByRole('heading', { level: 2, name: 'Import, export en templates' })).toBeVisible();
|
||||
await expectNoSeriousAxeViolations(page);
|
||||
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 });
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
|
||||
|
||||
test('default dashboard and wallboard expose usable real sources', async ({ page }) => {
|
||||
test.skip(!enabled, 'Run against an isolated server smoke stack.');
|
||||
test.setTimeout(90_000);
|
||||
expect((await page.request.get('/auth/test-login')).ok()).toBeTruthy();
|
||||
const failures: string[] = [];
|
||||
page.on('pageerror', (error) => failures.push(error.message));
|
||||
page.on('response', (response) => {
|
||||
const path = new URL(response.url()).pathname;
|
||||
if (path.startsWith('/api/') && response.status() >= 400) failures.push(`${response.status()} ${path}`);
|
||||
});
|
||||
|
||||
await page.goto('/dashboards/11111111-1111-4111-8111-111111111111');
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Overzicht' })).toBeVisible();
|
||||
await expect(page.locator('.widget-card--runtime')).toHaveCount(5);
|
||||
await expect(page.locator('.widget-placeholder')).toHaveCount(0);
|
||||
await expect.poll(() => failures, { timeout: 5_000 }).toEqual([]);
|
||||
await expect(page.locator('.metric-chart')).toBeVisible();
|
||||
await expect(page.getByText('Disk 1').first()).toBeVisible();
|
||||
await expect(page.getByText('pulse', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText('Inventaris succesvol bijgewerkt')).toBeVisible();
|
||||
await expect(page.locator('.widget-card--runtime[data-runtime-state="usable"]')).toHaveCount(5);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||||
expect((await new AxeBuilder({ page }).analyze()).violations.filter((item) => item.impact === 'critical' || item.impact === 'serious')).toEqual([]);
|
||||
|
||||
await page.goto('/wallboard?refresh=300&interval=300');
|
||||
await expect(page.getByRole('heading', { name: 'Operationeel wallboard' })).toBeVisible();
|
||||
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Transport' })).toContainText('Verbonden');
|
||||
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Data' })).toContainText('Bruikbaar');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight + 1)).toBe(true);
|
||||
await expect(page.locator('.metric-chart')).toBeVisible();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||||
expect((await new AxeBuilder({ page }).analyze()).violations.filter((item) => item.impact === 'critical' || item.impact === 'serious')).toEqual([]);
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
const items = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: `event-${String(index + 1).padStart(3, '0')}`,
|
||||
type: index % 2 === 0 ? 'service.down' : 'container.restart',
|
||||
severity: index % 10 === 0 ? 'critical' : index % 3 === 0 ? 'warning' : 'info',
|
||||
entityId: `entity-${String(index % 5).padStart(2, '0')}`,
|
||||
sourceId: 'source-unraid',
|
||||
occurredAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 0) - index * 60_000).toISOString(),
|
||||
receivedAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 5) - index * 60_000).toISOString(),
|
||||
summary: `Gebeurtenis ${String(index + 1).padStart(3, '0')}`,
|
||||
}));
|
||||
|
||||
test('100 events blijven compact, filterbaar en toetsenbordnavigeerbaar', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'wallboard-chromium', 'De eventwerkruimte gebruikt de desktop-, tablet- en mobiele shell.');
|
||||
let eventRequests = 0;
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (pathname === '/api/v1/events') {
|
||||
eventRequests += 1;
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items }) });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/system/status') {
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ version: '1', generatedAt: new Date().toISOString(), overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }) });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 404, contentType: 'application/problem+json', body: '{}' });
|
||||
});
|
||||
|
||||
await page.goto('/events');
|
||||
await expect(page.getByRole('heading', { name: 'Gebeurtenissen' })).toBeVisible();
|
||||
const rows = page.locator('.event-list > li');
|
||||
await expect(rows).toHaveCount(20);
|
||||
const baselineRequests = eventRequests;
|
||||
expect(baselineRequests).toBeLessThanOrEqual(2);
|
||||
const criticalSummary = page.getByRole('button', { name: /Kritieke gebeurtenissen/i });
|
||||
await expect(criticalSummary).toBeVisible();
|
||||
await expect(criticalSummary).toContainText('10');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollHeight / window.innerHeight)).toBeLessThan(10);
|
||||
|
||||
await page.getByLabel('Ernst').selectOption('critical');
|
||||
await expect(rows).toHaveCount(10);
|
||||
await page.getByLabel('Soort').selectOption('service.down');
|
||||
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 expect(rows).toHaveCount(1);
|
||||
expect(eventRequests).toBe(baselineRequests);
|
||||
|
||||
await page.getByRole('button', { name: 'Filters wissen' }).click();
|
||||
const next = page.getByRole('button', { name: 'Volgende pagina' });
|
||||
await next.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('.list-pager [role="status"]')).toBeFocused();
|
||||
await expect(page).toHaveURL(/page=2/);
|
||||
await expect(rows).toHaveCount(20);
|
||||
await expect(rows.first()).toContainText('event-021');
|
||||
expect(eventRequests).toBe(baselineRequests);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
|
||||
const axe = await new AxeBuilder({ page }).analyze();
|
||||
expect(axe.violations.filter((item) => item.impact === 'critical' || item.impact === 'serious')).toEqual([]);
|
||||
if (testInfo.project.name === 'mobile-chromium') {
|
||||
const pagerHeights = await page.locator('.list-pager .button').evaluateAll((buttons) => buttons.map((button) => button.getBoundingClientRect().height));
|
||||
expect(pagerHeights.every((height) => height >= 44)).toBe(true);
|
||||
}
|
||||
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', `events-${testInfo.project.name}.png`), fullPage: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
|
||||
|
||||
test('real inventory exposes effective overrides, provenance and relations', async ({ page }, testInfo) => {
|
||||
test.skip(!enabled, 'Requires the isolated real PostgreSQL stack and inventory fixture.');
|
||||
const login = await page.request.get('/auth/test-login');
|
||||
expect(login.ok()).toBeTruthy();
|
||||
|
||||
await page.goto('/inventory');
|
||||
await page.getByRole('searchbox', { name: 'Zoeken' }).fill('pulse-api');
|
||||
const entity = page.getByRole('link', { name: /Pulse API · handmatig/ });
|
||||
await expect(entity).toBeVisible();
|
||||
await expect(entity).toContainText('1 bronnen · 2 feiten · 1 relaties · 2 correcties');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||||
|
||||
await entity.click();
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Pulse API · handmatig' })).toBeVisible();
|
||||
await expect(page.getByText('itworx/pulse:pinned', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Handmatige correctie').first()).toBeVisible();
|
||||
await expect(page.getByText('Verouderd', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /PostgreSQL.*depends_on.*bevestigd/ })).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||||
|
||||
const violations = await new AxeBuilder({ page }).analyze();
|
||||
expect(violations.violations.filter((item) => ['serious', 'critical'].includes(item.impact ?? ''))).toEqual([]);
|
||||
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M11-05', `inventory-${testInfo.project.name}.png`), fullPage: true });
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
const containers = Array.from({ length: 150 }, (_, index) => ({
|
||||
id: `container-${String(index + 1).padStart(3, '0')}`,
|
||||
name: `container-${String(index + 1).padStart(3, '0')}`,
|
||||
image: 'example/pulse:read-only', state: index % 17 === 0 ? 'exited' : 'running', health: index % 13 === 0 ? 'unhealthy' : 'healthy',
|
||||
intentionalStop: false, metricsAvailable: true, lifecycleAvailable: true, uptimeSeconds: 3600, restartCount: 0, exitCode: 0,
|
||||
cpuPercent: index / 10, memoryBytes: 1024 * (index + 1), memoryLimitBytes: 1024 * 1024,
|
||||
networkRxBytes: 0, networkTxBytes: 0, blockReadBytes: 0, blockWriteBytes: 0,
|
||||
}));
|
||||
const processes = Array.from({ length: 60 }, (_, index) => ({ pid: index + 1, name: `worker-${String(index + 1).padStart(2, '0')}`, state: 'running', runtimeSeconds: 300, cpuPercent: index, memoryBytes: 2048 + index, containerName: index % 2 ? 'pulse' : 'database' }));
|
||||
const entities = Array.from({ length: 60 }, (_, index) => ({ id: `entity-${index + 1}`, entityType: 'container', canonicalName: `container.${index + 1}`, displayName: `Entity ${String(index + 1).padStart(2, '0')}`, status: 'operational', factCount: 2, overrideCount: 0, relationCount: 1, sourceCount: 1, staleFactCount: 0 }));
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/v1/containers?**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const query = (url.searchParams.get('q') ?? '').toLowerCase();
|
||||
const state = url.searchParams.get('state') ?? '';
|
||||
const health = url.searchParams.get('health') ?? '';
|
||||
const after = Number(url.searchParams.get('after') ?? '0');
|
||||
const limit = Number(url.searchParams.get('limit') ?? '25');
|
||||
const filtered = containers.filter((item) => (!query || item.name.includes(query)) && (!state || item.state === state) && (!health || item.health === health));
|
||||
const items = filtered.slice(after, after + limit);
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ source: { id: 'target-scale', state: 'healthy' }, total: filtered.length, containers: items, nextCursor: after + limit < filtered.length ? String(after + limit) : '' }) });
|
||||
});
|
||||
await page.route('**/api/v1/processes?**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const query = (url.searchParams.get('q') ?? '').toLowerCase();
|
||||
const container = (url.searchParams.get('container') ?? '').toLowerCase();
|
||||
const after = Number(url.searchParams.get('after') ?? '0');
|
||||
const filtered = processes.filter((item) => (!query || item.name.includes(query)) && (!container || item.containerName.includes(container)));
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ source: { id: 'target-scale', state: 'healthy' }, total: filtered.length, processes: filtered.slice(after, after + 25), nextCursor: after + 25 < filtered.length ? String(after + 25) : '' }) });
|
||||
});
|
||||
await page.route('**/api/v1/entities?**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const query = (url.searchParams.get('q') ?? '').toLowerCase();
|
||||
const after = Number(url.searchParams.get('after') ?? '0');
|
||||
const filtered = entities.filter((item) => !query || (item.displayName + item.canonicalName).toLowerCase().includes(query));
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: filtered.slice(after, after + 25), hasMore: after + 25 < filtered.length, nextCursor: after + 25 < filtered.length ? String(after + 25) : '' }) });
|
||||
});
|
||||
});
|
||||
|
||||
test('process and inventory filters survive navigation with mobile-first cards', async ({ page }, testInfo) => {
|
||||
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.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);
|
||||
else await expect(page.locator('.desktop-data-view tbody tr:visible')).toHaveCount(25);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
|
||||
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.getByLabel('Type')).toHaveValue('container');
|
||||
await expect(page.getByRole('textbox', { name: 'Status' })).toHaveValue('operational');
|
||||
await expect(page.locator('.inventory-entity-list > li')).toHaveCount(25);
|
||||
await page.getByRole('button', { name: 'Volgende pagina' }).click();
|
||||
await expect(page.locator('.list-pager [role="status"]')).toBeFocused();
|
||||
await expect(page).toHaveURL(/after=25/);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
});
|
||||
|
||||
test('all 150 containers remain reachable with shareable filters and bounded mobile cards', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'wallboard-chromium', 'Large lists target desktop and mobile routes.');
|
||||
const started = Date.now();
|
||||
await page.goto('/containers');
|
||||
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 seen = new Set<string>();
|
||||
for (let pageNumber = 1; pageNumber <= 6; pageNumber += 1) {
|
||||
await expect(visibleRows).toHaveCount(25);
|
||||
for (const value of await visibleRows.locator('a').allTextContents()) seen.add(value.trim());
|
||||
if (pageNumber < 6) {
|
||||
const next = page.getByRole('button', { name: 'Volgende pagina' });
|
||||
await next.click();
|
||||
await expect(page.locator('.list-pager [role="status"]')).toBeFocused();
|
||||
}
|
||||
}
|
||||
expect(seen.size).toBe(150);
|
||||
expect(seen.has('container-150')).toBe(true);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
if (testInfo.project.name === 'mobile-chromium') {
|
||||
const controls = await page.locator('.list-pager button').evaluateAll((buttons) => buttons.map((button) => button.getBoundingClientRect().height));
|
||||
expect(controls.every((height) => height >= 44)).toBe(true);
|
||||
}
|
||||
|
||||
await page.getByLabel('Zoeken').fill('container-150');
|
||||
await expect(visibleRows).toHaveCount(1);
|
||||
await expect(page).toHaveURL(/q=container-150/);
|
||||
await page.reload();
|
||||
await expect(visibleRows).toHaveCount(1);
|
||||
await expect(visibleRows.getByText('container-150')).toBeVisible();
|
||||
|
||||
const axe = await new AxeBuilder({ page }).analyze();
|
||||
expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
|
||||
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M11-08', `large-containers-${testInfo.project.name}.png`), fullPage: true });
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
const rule = {
|
||||
id: '20000000-0000-4000-8000-000000000001', schemaVersion: 1, name: 'Hoge hostbelasting', enabled: true, severity: 'critical', scope: {},
|
||||
condition: { inputType: 'metric', metric: 'host.cpu.utilization', operator: '>', threshold: 90, recoveryThreshold: 80, aggregation: 'avg', windowSeconds: 60 },
|
||||
evaluationIntervalSeconds: 30, pendingSeconds: 60, resolveSeconds: 120, cooldownSeconds: 300,
|
||||
unknownBehavior: 'retain-firing-as-unknown', groupBy: [], suppressWhen: ['host.unreachable'],
|
||||
message: { titleKey: 'alerts.rule.title', bodyKey: 'alerts.rule.body' }, revision: 1, currentVersion: 1,
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/v1/system/status', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ version: 'test', generatedAt: new Date().toISOString(), overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }) }));
|
||||
await page.route('**/api/v1/alert-rules?**', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [rule] }) }));
|
||||
await page.route('**/api/v1/metrics/catalog', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ metrics: [{ semanticName: 'host.cpu.utilization', unit: 'percent', defaultAggregation: 'avg' }] }) }));
|
||||
await page.route('**/api/v1/alert-silences**', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) }));
|
||||
await page.route('**/api/v1/maintenance-windows**', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) }));
|
||||
await page.route('**/api/v1/alerts?**', (route) => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ items: [] }) }));
|
||||
});
|
||||
|
||||
test('alert editor uses Dutch guided choices and rejects an invalid draft', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'wallboard-chromium', 'De alert-editor is geen wallboardroute.');
|
||||
await page.goto('/alerts');
|
||||
await expect(page.getByRole('heading', { name: 'Meldingen en incidenten' })).toBeVisible();
|
||||
await page.getByRole('button', { name: /Alertregels Detectie en drempels/ }).click();
|
||||
await expect(page.getByText('Kritiek · v1')).toBeVisible();
|
||||
await expect(page.getByRole('combobox', { name: /Meting/ })).toHaveValue('host.cpu.utilization');
|
||||
await expect(page.getByRole('option', { name: 'CPU-gebruik van de host (%)' })).toBeAttached();
|
||||
await expect(page.getByText('host.cpu.utilization')).toHaveCount(0);
|
||||
await expect(page.getByText('host.unreachable')).not.toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Nieuwe regel' }).click();
|
||||
const save = page.getByRole('button', { name: 'Regel opslaan' });
|
||||
await expect(save).toBeDisabled();
|
||||
await page.locator('#alert-rule-name').fill('CPU-waarschuwing');
|
||||
await page.getByRole('combobox', { name: /Meting/ }).selectOption('host.cpu.utilization');
|
||||
await expect(save).toBeEnabled();
|
||||
await page.locator('#alert-rule-recovery-threshold').fill('90');
|
||||
await expect(save).toBeDisabled();
|
||||
await page.getByRole('combobox', { name: /Signaalbron/ }).selectOption('event');
|
||||
await expect(page.getByRole('combobox', { name: /Meting/ })).toHaveCount(0);
|
||||
await expect(page.locator('#alert-rule-threshold')).toHaveValue('3');
|
||||
await expect(save).toBeEnabled();
|
||||
|
||||
await page.getByRole('button', { name: /Stiltes en onderhoud Tijdelijke uitzonderingen/ }).click();
|
||||
await expect(page.locator('#silence-matcher')).toHaveValue('critical');
|
||||
await expect(page.locator('#silence-matcher')).toContainText('Kritiek');
|
||||
await expect(page.locator('#maintenance-selector')).toContainText('Host');
|
||||
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
const axe = await new AxeBuilder({ page }).analyze();
|
||||
expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
|
||||
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M11-09', `alert-editor-${testInfo.project.name}.png`), fullPage: true });
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
const systemStatus = {
|
||||
version: '1.3.0',
|
||||
release: { version: '1.3.0', commit: 'abc1234', builtAt: '2026-08-21T12:00:00Z', migrationVersion: '0024' },
|
||||
generatedAt: new Date().toISOString(),
|
||||
overallState: 'healthy',
|
||||
components: [],
|
||||
backup: { state: 'healthy', reason: 'backup_verified', ageSeconds: 30 * 60 * 60, verifiedAt: '2026-08-20T06:00:00Z' },
|
||||
sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'fresh', ageSeconds: 5 }],
|
||||
};
|
||||
|
||||
const onboarding = {
|
||||
state: { completed: true, step: 'completed', dashboardChoice: 'default', rulesChoice: 'default', dashboardId: 'overview', rulesReady: true },
|
||||
capabilities: [
|
||||
{ id: 'auth', state: 'ready', detail: 'Aanmelding geconfigureerd.' },
|
||||
{ id: 'database', state: 'ready', detail: 'Database beschikbaar.' },
|
||||
{ id: 'prometheus', state: 'ready', detail: 'Meetgegevens beschikbaar.' },
|
||||
{ id: 'unraid', state: 'ready', detail: 'Unraid-bron beschikbaar.' },
|
||||
],
|
||||
resume: false,
|
||||
};
|
||||
|
||||
test('beheerhub, afgeronde onboarding en backupouderdom blijven taakgericht en waarheidsgetrouw', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'wallboard-chromium', 'Beheerflows gebruiken de desktop-, tablet- en mobiele shell.');
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
if (pathname === '/api/v1/system/status') {
|
||||
if (route.request().method() === 'POST') {
|
||||
await route.fulfill({ status: 403, contentType: 'application/problem+json', body: '{}' });
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ...systemStatus, generatedAt: new Date().toISOString() }) });
|
||||
return;
|
||||
}
|
||||
if (pathname === '/api/v1/onboarding') {
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(onboarding) });
|
||||
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', { 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: operator')).toBeVisible();
|
||||
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 });
|
||||
}
|
||||
|
||||
await hub.getByRole('link', { name: /Systeemstatus en backup/ }).click();
|
||||
const backup = page.getByRole('article').filter({ has: page.getByText('Backupstatus', { exact: true }) });
|
||||
await expect(page.getByRole('heading', { name: 'Pulse-systeemstatus' })).toBeVisible();
|
||||
await expect(backup.locator('.status-badge')).toContainText('Aandacht');
|
||||
await expect(backup.getByText(/backup is verlopen/i)).toBeVisible();
|
||||
await expect(backup.getByText(/1 dag geleden/)).toBeVisible();
|
||||
await expect(backup.getByText(/ouder dan 24 uur/)).toBeVisible();
|
||||
await backup.getByRole('button', { name: 'Maak geverifieerde backup' }).click();
|
||||
await expect(backup.getByRole('alert')).toContainText('Alleen beheerders');
|
||||
|
||||
await page.goto('/settings');
|
||||
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();
|
||||
await expect(page.getByRole('radio')).toHaveCount(4);
|
||||
await page.getByRole('button', { name: 'Annuleren' }).click();
|
||||
await expect(page.getByRole('radio')).toHaveCount(0);
|
||||
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
const axe = await new AxeBuilder({ page }).analyze();
|
||||
expect(axe.violations.filter((item) => item.impact === 'serious' || item.impact === 'critical')).toEqual([]);
|
||||
if (testInfo.project.name === 'mobile-chromium') {
|
||||
const linkHeights = await page.locator('.settings-hub-card a').evaluateAll((links) => links.map((link) => link.getBoundingClientRect().height));
|
||||
expect(linkHeights.every((height) => height >= 44)).toBe(true);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
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.');
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
const body = 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' ? {
|
||||
incident: {
|
||||
id: 'incident-1', correlationKey: 'cachepool', title: 'Cachepool bijna vol',
|
||||
summary: 'Cachepool is 92% gebruikt en groeit sneller dan verwacht.', severity: 'critical',
|
||||
status: 'open', startedAt: now, correlationMethod: 'temporal-window', confidence: .84,
|
||||
revision: 2, updatedAt: now, ownerUserId: '', alerts: [
|
||||
{ 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 }],
|
||||
},
|
||||
} : {};
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
});
|
||||
|
||||
await page.goto('/incidents/incident-1');
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Cachepool bijna vol' })).toBeVisible();
|
||||
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');
|
||||
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 axe = await new AxeBuilder({ page }).analyze();
|
||||
expect(axe.violations.filter((item) => item.impact === 'critical' || item.impact === 'serious')).toEqual([]);
|
||||
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: '../../artifacts/evidence/M14-04/mobile-incident-command.png', fullPage: true });
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
|
||||
|
||||
test('real stack serves collector data through database, API and UI', async ({ page }) => {
|
||||
test.skip(!enabled, 'Run through scripts/integration-smoke.ps1 with an isolated real stack.');
|
||||
test.setTimeout(210_000);
|
||||
const login = await page.request.get('/auth/test-login');
|
||||
expect(login.ok()).toBeTruthy();
|
||||
const issuedSession = (await page.context().cookies()).find((cookie) => cookie.name === 'pulse_session');
|
||||
expect(issuedSession, 'mock login issues the same HttpOnly session contract as OIDC').toBeDefined();
|
||||
expect(issuedSession?.httpOnly).toBe(true);
|
||||
|
||||
for (const endpoint of ['/healthz', '/readyz']) {
|
||||
const response = await page.request.get(endpoint);
|
||||
expect(response.status(), endpoint).toBe(200);
|
||||
expect(response.headers()['content-type'], endpoint).toContain('text/plain');
|
||||
expect((await response.text()).toLowerCase(), endpoint).not.toContain('<!doctype html>');
|
||||
}
|
||||
for (const endpoint of ['/metrics', '/debug/pprof/']) {
|
||||
const response = await page.request.get(endpoint);
|
||||
expect(response.status(), endpoint).toBe(404);
|
||||
expect((await response.text()).toLowerCase(), endpoint).not.toContain('<!doctype html>');
|
||||
}
|
||||
|
||||
const endpointChecks = [
|
||||
'/api/v1/system/status', '/api/v1/host', '/api/v1/processes?limit=10',
|
||||
'/api/v1/containers?limit=10', '/api/v1/array', '/api/v1/disks?limit=10',
|
||||
'/api/v1/pools?limit=10', '/api/v1/shares?limit=10', '/api/v1/services?limit=10',
|
||||
'/api/v1/network', '/api/v1/topology?limit=10', '/api/v1/applications?limit=10', '/api/v1/events?limit=10',
|
||||
'/api/v1/entities?limit=10', '/api/v1/dashboards?limit=10', '/api/v1/alert-rules?limit=10',
|
||||
'/api/v1/alerts?limit=10', '/api/v1/incidents?limit=10', '/api/v1/onboarding',
|
||||
];
|
||||
for (const endpoint of endpointChecks) {
|
||||
const response = await page.request.get(endpoint);
|
||||
expect(response.status(), endpoint).toBe(200);
|
||||
}
|
||||
const hostResponse = await page.request.get('/api/v1/host');
|
||||
const host = await hostResponse.json() as Record<string, unknown>;
|
||||
expect(JSON.stringify(host)).toContain('smoke-host');
|
||||
expect(JSON.stringify(host)).toContain('fresh');
|
||||
|
||||
const containers = await (await page.request.get('/api/v1/containers?limit=10')).json() as { containers?: Array<{ name: string; state: string; health: string; metricsAvailable?: boolean; lifecycleAvailable?: boolean }> };
|
||||
const smokeContainer = containers.containers?.find((item) => item.name === 'smoke-api');
|
||||
expect(smokeContainer).toMatchObject({ state: 'running', health: 'healthy', metricsAvailable: false, lifecycleAvailable: false });
|
||||
const applications = await (await page.request.get('/api/v1/applications?limit=10')).json() as { applications?: Array<{ name: string; status: string }> };
|
||||
expect(applications.applications?.find((item) => item.name === 'smoke')).toMatchObject({ status: 'healthy' });
|
||||
await page.goto('/containers');
|
||||
await expect(page.getByText(/metingen niet beschikbaar/).first()).toBeVisible();
|
||||
|
||||
const systemStatus = await (await page.request.get('/api/v1/system/status')).json() as {
|
||||
components?: Array<{ id: string; state: string; reason: string }>;
|
||||
sourceLag?: Array<{ sourceId: string; state: string }>;
|
||||
};
|
||||
const unraidComponent = systemStatus.components?.find((item) => item.id === 'unraid');
|
||||
expect(unraidComponent, 'unraid runtime component').toBeDefined();
|
||||
expect(unraidComponent?.state, 'unraid is derived from every fresh bounded agent capability').toBe('healthy');
|
||||
expect(unraidComponent?.reason, 'unraid no longer uses API-local configuration').toBe('source_sampled');
|
||||
const storageComponent = systemStatus.components?.find((item) => item.id === 'storage');
|
||||
expect(storageComponent, 'storage runtime component').toMatchObject({ state: 'healthy', reason: 'source_sampled' });
|
||||
expect(systemStatus.sourceLag?.find((source) => source.sourceId === 'unraid')?.state).toBe('healthy');
|
||||
|
||||
const onboarding = await (await page.request.get('/api/v1/onboarding')).json() as { capabilities?: Array<{ id: string; state: string }> };
|
||||
expect(onboarding.capabilities?.find((capability) => capability.id === 'unraid')?.state).toBe('ready');
|
||||
|
||||
const dashboardId = '61000000-0000-4000-8000-000000000001';
|
||||
const dashboard = {
|
||||
schemaVersion: 2, id: dashboardId, slug: 'real-stack-metric', name: 'Real-stack CPU', description: 'Metric planner browser gate.', scope: 'system',
|
||||
variables: [{ name: 'server', type: 'server', label: 'Server', default: 'smoke-host' }],
|
||||
widgets: [{
|
||||
id: '62000000-0000-4000-8000-000000000001', type: 'timeseries', title: 'CPU live', description: 'Catalog-bounded metric.',
|
||||
data: { sourceType: 'semantic-metric', metric: 'host.cpu.utilization', scope: { serverId: '$server' }, aggregation: 'avg', transformations: [] },
|
||||
visualization: { unit: 'percent', decimals: 1, legend: true, showSparkline: false, min: 0, max: 100, thresholds: [] },
|
||||
behavior: { locked: true, hidden: false, hideWhenEmpty: false, showOnlyOnProblem: false, liveIntervalSeconds: 2, independentTimeRange: null },
|
||||
layouts: { desktop: { x: 0, y: 0, w: 18, h: 8, visible: true }, tablet: { x: 0, y: 0, w: 8, h: 8, visible: true }, mobile: { x: 0, y: 0, w: 1, h: 8, visible: true }, wallboard: { x: 0, y: 0, w: 24, h: 12, visible: true } },
|
||||
}],
|
||||
settings: { defaultTimeRange: 'live', live: true, refreshSeconds: 10, rotationSeconds: 30 },
|
||||
};
|
||||
const dashboardResponse = await page.request.post('/api/v1/dashboards', { data: dashboard });
|
||||
expect([201, 409], await dashboardResponse.text()).toContain(dashboardResponse.status());
|
||||
const peerDashboard = {
|
||||
...dashboard,
|
||||
id: '61000000-0000-4000-8000-000000000002',
|
||||
slug: 'real-stack-memory',
|
||||
name: 'Real-stack geheugen',
|
||||
widgets: dashboard.widgets.map((widget) => ({
|
||||
...widget,
|
||||
id: '62000000-0000-4000-8000-000000000002',
|
||||
title: 'Geheugen live',
|
||||
data: { ...widget.data, metric: 'host.memory.utilization' },
|
||||
})),
|
||||
};
|
||||
const peerDashboardResponse = await page.request.post('/api/v1/dashboards', { data: peerDashboard });
|
||||
expect([201, 409], await peerDashboardResponse.text()).toContain(peerDashboardResponse.status());
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
const websocketOpened = await page.evaluate(() => new Promise<boolean>((resolve) => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}/api/v1/live`);
|
||||
const timeout = window.setTimeout(() => { socket.close(); resolve(false); }, 5_000);
|
||||
socket.addEventListener('open', () => { window.clearTimeout(timeout); socket.close(); resolve(true); }, { once: true });
|
||||
socket.addEventListener('error', () => { window.clearTimeout(timeout); resolve(false); }, { once: true });
|
||||
}));
|
||||
expect(websocketOpened, 'same-origin WebSocket upgrade through nginx').toBe(true);
|
||||
|
||||
const runtimeFailures: string[] = [];
|
||||
page.on('pageerror', (error) => runtimeFailures.push(error.message));
|
||||
page.on('response', (response) => {
|
||||
if (new URL(response.url()).pathname.startsWith('/api/') && response.status() >= 400) {
|
||||
runtimeFailures.push(`${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
const routes = ['/', '/host', '/pools', '/shares', '/storage', '/capacity', '/processes', '/containers', '/services', '/topology', '/network', '/applications', '/inventory', '/dashboards', '/alerts', '/incidents', '/settings', '/status', '/onboarding'];
|
||||
for (const route of routes) {
|
||||
await page.goto(route);
|
||||
await expect(page.getByRole('heading', { level: 1 }).first(), route).toBeVisible();
|
||||
await expect(page.locator('.state-page[role="alert"]'), route).toHaveCount(0);
|
||||
}
|
||||
expect(runtimeFailures).toEqual([]);
|
||||
await page.addInitScript((id) => window.localStorage.setItem(`pulse.dashboard.view.${id}.range`, 'live'), dashboardId);
|
||||
const metricResponse = page.waitForResponse((response) => new URL(response.url()).pathname === '/api/v1/metrics/query-range');
|
||||
await page.goto('/dashboards/' + dashboardId);
|
||||
await expect(page.getByRole('heading', { name: 'Real-stack CPU' })).toBeVisible();
|
||||
expect((await metricResponse).status(), 'catalog-bounded query through deployed UI').toBe(200);
|
||||
await expect(page.getByRole('heading', { name: 'CPU live' })).toBeVisible();
|
||||
expect(runtimeFailures).toEqual([]);
|
||||
|
||||
// A rotating wallboard fetches the next dashboard document before its live
|
||||
// subscription is ready. Exercise a response well beyond the 250 ms
|
||||
// subscription-release grace and prove the bounded idle transport is reused
|
||||
// across multiple rotations instead of closing and reopening.
|
||||
const dashboardList = await (await page.request.get('/api/v1/dashboards?limit=100')).json() as { items?: Record<string, unknown>[] };
|
||||
const wallboardDashboardIds = (dashboardList.items ?? []).map((item) => String(item.id ?? item.ID ?? '')).filter(Boolean);
|
||||
expect(wallboardDashboardIds.length).toBeGreaterThanOrEqual(2);
|
||||
await page.addInitScript(() => {
|
||||
const NativeWebSocket = window.WebSocket;
|
||||
const counters = { opened: 0, closed: 0 };
|
||||
Object.defineProperty(window, '__pulseSocketLifecycle', { value: counters, configurable: true });
|
||||
class TrackedWebSocket extends NativeWebSocket {
|
||||
constructor(url: string | URL, protocols?: string | string[]) {
|
||||
super(url, protocols);
|
||||
counters.opened += 1;
|
||||
this.addEventListener('close', () => { counters.closed += 1; }, { once: true });
|
||||
}
|
||||
}
|
||||
window.WebSocket = TrackedWebSocket;
|
||||
});
|
||||
await page.addInitScript((ids) => {
|
||||
ids.forEach((id) => window.localStorage.setItem(`pulse.dashboard.view.${id}.range`, 'live'));
|
||||
}, wallboardDashboardIds);
|
||||
let delayedDashboardLoads = 0;
|
||||
await page.route('**/api/v1/dashboards/**', async (route) => {
|
||||
delayedDashboardLoads += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||||
try {
|
||||
await route.continue();
|
||||
} catch (error) {
|
||||
// Going offline can settle an intentionally delayed request before the
|
||||
// handler resumes. That is the failure mode under test, not a harness
|
||||
// failure; every other routing error must still fail the flow.
|
||||
if (!(error instanceof Error) || !error.message.includes('Route is already handled')) throw error;
|
||||
}
|
||||
});
|
||||
await page.goto('/wallboard?interval=10&refresh=300');
|
||||
await expect(page.getByRole('heading', { name: 'Operationeel wallboard' })).toBeVisible();
|
||||
const visibleDashboard = page.locator('#dashboard-view-title');
|
||||
await expect(visibleDashboard).toBeVisible();
|
||||
await page.context().setOffline(true);
|
||||
// Cross a rotation while both HTTP and WebSocket transport are unavailable.
|
||||
// The wallboard must retain its last verified document instead of replacing
|
||||
// operational context with a blank loading/error page.
|
||||
await page.waitForTimeout(12_000);
|
||||
await expect(visibleDashboard).toBeVisible();
|
||||
await expect(visibleDashboard).not.toHaveText('');
|
||||
await page.context().setOffline(false);
|
||||
// The integration API has a 30-second idle session TTL. Staying on this page
|
||||
// for 65 seconds after recovery crosses it more than twice. Dashboard refreshes must renew
|
||||
// the HttpOnly cookie server-side without reopening the live transport.
|
||||
await page.waitForTimeout(65_000);
|
||||
expect(delayedDashboardLoads, 'initial document plus at least five rotations').toBeGreaterThanOrEqual(6);
|
||||
const renewedSession = (await page.context().cookies()).find((cookie) => cookie.name === 'pulse_session');
|
||||
expect(renewedSession, 'active wallboard retains its server session').toBeDefined();
|
||||
expect(renewedSession?.value).toBe(issuedSession?.value);
|
||||
expect(renewedSession?.expires ?? 0, 'idle deadline was renewed beyond its initial expiry').toBeGreaterThan(issuedSession?.expires ?? 0);
|
||||
const statusAfterMultipleTTLs = await page.request.get('/api/v1/system/status');
|
||||
expect(statusAfterMultipleTTLs.status(), 'authenticated API after multiple idle TTLs').toBe(200);
|
||||
const socketLifecycle = await page.evaluate(() => (window as unknown as { __pulseSocketLifecycle: { opened: number; closed: number } }).__pulseSocketLifecycle);
|
||||
expect(socketLifecycle).toEqual({ opened: 1, closed: 0 });
|
||||
expect(runtimeFailures).toEqual([]);
|
||||
await page.unroute('**/api/v1/dashboards/**');
|
||||
|
||||
await page.goto('/host');
|
||||
await expect(page.getByText('smoke-host').first()).toBeVisible();
|
||||
const axe = await new AxeBuilder({ page }).analyze();
|
||||
expect(axe.violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious')).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
test('release metadata and verified backup are production truthful', async ({ page }, testInfo) => {
|
||||
test.skip(!process.env.PULSE_E2E_REAL_BASE_URL, 'Requires the isolated server-built Pulse stack.');
|
||||
expect((await page.request.get('/auth/test-login')).ok()).toBeTruthy();
|
||||
const created = await page.request.post('/api/v1/system/backups');
|
||||
expect(created.ok()).toBeTruthy();
|
||||
|
||||
const response = await page.request.get('/api/v1/system/status');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const status = await response.json() as {
|
||||
version: string;
|
||||
release: { version: string; commit: string; builtAt?: string; migrationVersion: string };
|
||||
backup: { state: string; reason: string; verifiedAt?: string; ageSeconds?: number };
|
||||
};
|
||||
expect(status.version).not.toBe('development');
|
||||
expect(status.release).toMatchObject({ version: 'm11.11-test', commit: 'm1111testcommit' });
|
||||
expect(status.release.builtAt).toBe('2026-08-12T03:00:00Z');
|
||||
expect(status.release.migrationVersion).toMatch(/^\d{4}_.+/);
|
||||
expect(status.backup).toMatchObject({ state: 'healthy', reason: 'backup_verified' });
|
||||
expect(status.backup.verifiedAt).toBeTruthy();
|
||||
expect(status.backup.ageSeconds).toBeGreaterThanOrEqual(0);
|
||||
|
||||
await page.goto('/status');
|
||||
await expect(page.getByText(/m11\.11-test/)).toBeVisible();
|
||||
await expect(page.getByText(/De backup is geverifieerd/)).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({
|
||||
path: path.resolve('../../artifacts/evidence/M11-11', `release-backup-${testInfo.project.name}.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
const observedAt = new Date().toISOString();
|
||||
|
||||
async function mockResponsiveData(page: Page): Promise<void> {
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
const body = pathname === '/api/v1/system/status' ? {
|
||||
version: '1', generatedAt: observedAt, overallState: 'degraded',
|
||||
components: [
|
||||
{ id: 'database', state: 'healthy', reason: 'ok' },
|
||||
{ id: 'prometheus', state: 'unknown', reason: 'source_stale' },
|
||||
],
|
||||
backup: { state: 'disabled', reason: 'not_configured' },
|
||||
sourceLag: [{ sourceId: 'prometheus', state: 'unknown', reason: 'source_stale', ageSeconds: 900 }],
|
||||
} : pathname === '/api/v1/host' ? {
|
||||
identity: { name: 'responsive-fixture' }, cpu: { totalPercent: 42, perCore: [42, 38] },
|
||||
memory: { utilizationPercent: 61 }, source: { state: 'healthy', freshness: 'fresh' },
|
||||
} : pathname === '/api/v1/containers' ? { source: { state: 'healthy', freshness: 'fresh' }, containers: [], total: 0 }
|
||||
: pathname === '/api/v1/pools' ? { source: { state: 'healthy', freshness: 'fresh' }, pools: [], total: 0 }
|
||||
: pathname === '/api/v1/services' ? { capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 }
|
||||
: pathname === '/api/v1/incidents' ? { items: [] }
|
||||
: {};
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => mockResponsiveData(page));
|
||||
|
||||
test('mobile primary navigation and operational reasons remain readable at 390px', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'mobile-chromium', 'Mobile evidence uses the supported 390px viewport.');
|
||||
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);
|
||||
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) };
|
||||
}));
|
||||
for (let index = 1; index < metrics.length; index += 1) expect(metrics[index - 1].right).toBeLessThanOrEqual(metrics[index].left);
|
||||
expect(metrics.every((metric) => metric.font >= 12 && metric.bottom - metric.top <= 16)).toBe(true);
|
||||
const reason = page.locator('.action-queue small').first();
|
||||
await expect(reason).toBeVisible();
|
||||
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)
|
||||
.slice(0, 12));
|
||||
expect(overflow).toEqual([]);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(390);
|
||||
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-06/responsive-mobile-390.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('tablet sidebar stays compact and content uses the remaining viewport', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'tablet-chromium', 'Tablet evidence uses the supported 1024px viewport.');
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
const sidebar = await page.locator('.sidebar').boundingBox();
|
||||
const workspace = await page.locator('.app-workspace').boundingBox();
|
||||
const queue = await page.locator('.action-queue').boundingBox();
|
||||
const dataPlane = await page.locator('.signal-path-panel').boundingBox();
|
||||
expect(sidebar?.width).toBeLessThanOrEqual(208);
|
||||
expect(workspace?.width).toBeGreaterThanOrEqual(816);
|
||||
expect(queue?.width).toBeGreaterThan(300);
|
||||
expect(dataPlane?.width).toBeGreaterThan(500);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1024);
|
||||
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: path.resolve('../../artifacts/evidence/M12-06/responsive-tablet-1024.png'), fullPage: true });
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.skip(!enabled, 'Requires an isolated real Pulse stack.');
|
||||
const login = await page.request.get('/auth/test-login');
|
||||
expect(login.ok()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('real services and inventory topology retain explicit production states', async ({ page }) => {
|
||||
await page.goto('/services');
|
||||
await expect(page.getByRole('heading', { name: 'Bereikbaarheid en historie' })).toBeVisible();
|
||||
await expect(page.getByText('Voor deze service is nog geen probe geconfigureerd.').first()).toBeVisible();
|
||||
|
||||
await page.goto('/topology');
|
||||
await expect(page.getByRole('heading', { name: 'Relaties en services' })).toBeVisible();
|
||||
await expect(page.getByRole('region', { name: 'Relaties', exact: true }).getByText('Ondersteunt')).toBeVisible();
|
||||
await expect(page.getByText(/dependency-test-a-/).first()).toBeVisible();
|
||||
|
||||
await page.goto('/network');
|
||||
const dns = page.getByRole('heading', { name: 'DNS' }).locator('../..');
|
||||
await expect(dns.getByText('Niet geconfigureerd')).toBeVisible();
|
||||
await expect(dns.getByText('Voor dit signaal is nog geen veilige probe geconfigureerd.')).toBeVisible();
|
||||
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
const accessibility = await new AxeBuilder({ page }).analyze();
|
||||
expect(accessibility.violations.filter((violation) => ['serious', 'critical'].includes(violation.impact ?? ''))).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('expired wallboard session is revoked once without periodic 401 churn', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'wallboard-chromium', 'The long-running wallboard owns this session boundary.');
|
||||
let apiRequests = 0;
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
apiRequests += 1;
|
||||
await route.fulfill({ status: 401, contentType: 'application/problem+json', body: JSON.stringify({ code: 'UNAUTHENTICATED' }) });
|
||||
});
|
||||
|
||||
await page.goto('/wallboard?interval=10&refresh=10');
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Geen toegang' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Aanmelden' })).toBeVisible();
|
||||
await expect.poll(() => apiRequests).toBeGreaterThan(0);
|
||||
const boundaryRequests = apiRequests;
|
||||
// Development StrictMode mounts the four bootstrap readers twice. The
|
||||
// production bundle issues one batch; neither mode may start a second one.
|
||||
expect(boundaryRequests).toBeLessThanOrEqual(8);
|
||||
|
||||
await page.waitForTimeout(12_000);
|
||||
expect(apiRequests).toBe(boundaryRequests);
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Geen toegang' })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
const observedAt = new Date().toISOString();
|
||||
|
||||
async function mockSignalFlow(page: Page): Promise<void> {
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
const body = pathname === '/api/v1/system/status' ? {
|
||||
version: '1', generatedAt: observedAt, overallState: 'degraded',
|
||||
components: [
|
||||
{ id: 'database', state: 'healthy', reason: 'ok' },
|
||||
{ id: 'worker', state: 'healthy', reason: 'ok' },
|
||||
{ id: 'prometheus', state: 'degraded', reason: 'source_stale' },
|
||||
{ id: 'unraid', state: 'healthy', reason: 'ok' },
|
||||
{ id: 'storage', state: 'healthy', reason: 'ok' },
|
||||
],
|
||||
backup: { state: 'healthy', reason: 'ok', ageSeconds: 3600 },
|
||||
sourceLag: [{ sourceId: 'prometheus', state: 'degraded', reason: 'source_stale', ageSeconds: 420 }],
|
||||
} : pathname === '/api/v1/host' ? {
|
||||
identity: { name: 'tower' }, cpu: { totalPercent: 68.4, perCore: [72, 64, 66, 71] },
|
||||
memory: { utilizationPercent: 71.2 }, source: { state: 'healthy', freshness: 'fresh' },
|
||||
} : pathname === '/api/v1/containers' ? {
|
||||
source: { state: 'healthy', freshness: 'fresh' }, total: 6,
|
||||
containers: [
|
||||
{ id: 'one', state: 'running', health: 'healthy' }, { id: 'two', state: 'running', health: 'healthy' },
|
||||
{ id: 'three', state: 'running', health: 'healthy' }, { id: 'four', state: 'running', health: 'healthy' },
|
||||
{ id: 'five', state: 'running', health: 'healthy' }, { id: 'six', state: 'restarting', health: 'unknown' },
|
||||
],
|
||||
} : pathname === '/api/v1/pools' ? {
|
||||
source: { state: 'healthy', freshness: 'fresh' }, total: 2,
|
||||
pools: [
|
||||
{ id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 63.8 },
|
||||
{ id: 'array', name: 'Array', state: 'healthy', capacitySeverity: 'attention', utilizationPercent: 86.1 },
|
||||
],
|
||||
} : pathname === '/api/v1/services' ? {
|
||||
capabilityState: 'available', configurationState: 'configured', total: 3,
|
||||
services: [
|
||||
{ id: 'proxy', name: 'Reverse proxy', state: 'up' },
|
||||
{ id: 'auth', name: 'Authentik', state: 'up' },
|
||||
{ id: 'media', name: 'Media', state: 'degraded' },
|
||||
],
|
||||
} : pathname === '/api/v1/incidents' ? {
|
||||
items: [{ id: 'incident-1', title: 'Prometheus-bron loopt achter', severity: 'warning', startedAt: observedAt }],
|
||||
} : pathname === '/api/v1/dashboards' ? { items: [] } : {};
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => mockSignalFlow(page));
|
||||
|
||||
test('operational signal path is diagnostic, keyboard reachable and responsive', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === 'wallboard-chromium', 'The wallboard keeps its dedicated bounded composition.');
|
||||
const runtimeErrors: string[] = [];
|
||||
page.on('pageerror', (error) => runtimeErrors.push(error.message));
|
||||
page.on('console', (message) => { if (message.type() === 'error') runtimeErrors.push(message.text()); });
|
||||
await page.goto('/');
|
||||
const panel = page.locator('.signal-path-panel');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel.getByRole('heading', { name: 'Signaalpad' })).toBeVisible();
|
||||
const stages = panel.locator('.signal-path-stage button');
|
||||
await expect(stages).toHaveCount(6);
|
||||
await expect(stages.first()).toHaveAttribute('aria-pressed', 'true');
|
||||
await stages.nth(1).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(stages.nth(1)).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(panel.locator('.signal-path-inspector')).toContainText('68,4%');
|
||||
await expect(panel.locator('.signal-path-inspector')).toContainText('71,2%');
|
||||
await expect(panel.getByRole('button', { name: 'Open host' })).toBeVisible();
|
||||
|
||||
const stageHeights = await stages.evaluateAll((items) => items.map((item) => item.getBoundingClientRect().height));
|
||||
expect(stageHeights.every((height) => height >= 44)).toBe(true);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
if (testInfo.project.name === 'desktop-chromium') {
|
||||
const severe = (await new AxeBuilder({ page }).analyze()).violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious');
|
||||
expect(severe, severe.map((violation) => `${violation.id}: ${violation.help}`).join('\n')).toEqual([]);
|
||||
}
|
||||
|
||||
if (process.env.PULSE_SOL_ULTRA_CAPTURE) {
|
||||
await page.evaluate(() => { window.scrollTo(0, 0); (document.activeElement as HTMLElement | null)?.blur(); });
|
||||
const mobile = testInfo.project.name === 'mobile-chromium';
|
||||
await page.screenshot({ path: path.resolve('../../artifacts/evidence/SOL-ULTRA/visual', `overview-${testInfo.project.name}.png`), fullPage: !mobile });
|
||||
if (mobile) {
|
||||
await page.addStyleTag({ content: '.context-bar, .mobile-navigation { display: none !important; }' });
|
||||
await panel.screenshot({ path: path.resolve('../../artifacts/evidence/SOL-ULTRA/visual/overview-mobile-chromium-signal.png') });
|
||||
}
|
||||
}
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
if (testInfo.project.name === 'desktop-chromium') {
|
||||
await panel.getByRole('button', { name: 'Open host' }).click();
|
||||
await expect(page).toHaveURL(/\/host$/);
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('failed overview resources remain unknown and recover through the shared retry', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'desktop-chromium', 'The data-state contract is viewport independent.');
|
||||
let poolCalls = 0;
|
||||
let allowPoolRecovery = false;
|
||||
await page.route('**/api/v1/pools?**', async (route) => {
|
||||
poolCalls += 1;
|
||||
if (!allowPoolRecovery) {
|
||||
await route.fulfill({ status: 503, contentType: 'application/problem+json', body: JSON.stringify({ code: 'SOURCE_UNAVAILABLE' }) });
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
await page.goto('/');
|
||||
const storage = page.locator('.signal-path-stage').filter({ hasText: 'Opslag' });
|
||||
await expect(storage).toHaveAttribute('data-tone', 'unknown');
|
||||
await expect(storage).toContainText('Niet beschikbaar');
|
||||
await expect(page.locator('.overview-kpi').filter({ hasText: 'Hoogste poolgebruik' }).locator('strong')).toHaveText('—');
|
||||
await expect(page.locator('.capacity-plane')).toContainText('Deze overzichtsbron kon niet worden geladen');
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Aandacht vereist' })).toBeVisible();
|
||||
|
||||
allowPoolRecovery = true;
|
||||
await page.getByRole('button', { name: 'Opnieuw laden' }).click();
|
||||
await expect(storage).toContainText('Aandacht');
|
||||
await expect(storage).not.toContainText('Niet beschikbaar');
|
||||
expect(poolCalls).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('signal animation yields to reduced-motion preference', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'desktop-chromium', 'Reduced-motion CSS is viewport independent.');
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto('/');
|
||||
const animation = await page.locator('.signal-path-stage--healthy').first().evaluate((element) => getComputedStyle(element, '::before').animationDuration);
|
||||
expect(Number.parseFloat(animation)).toBeLessThanOrEqual(0.001);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
const zeroTimestamp = '0001-01-01T00:00:00Z';
|
||||
|
||||
async function mockSourceStatusData(page: Page): Promise<void> {
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
const pathname = new URL(route.request().url()).pathname;
|
||||
const source = { id: 'unraid', state: 'healthy', freshness: 'stale', observedAt: zeroTimestamp, reason: 'source_stale' };
|
||||
const body = pathname === '/api/v1/host' ? {
|
||||
source,
|
||||
identity: { name: 'Tower', version: '7.2.0' },
|
||||
uptimeSeconds: 3600,
|
||||
cpu: { totalPercent: 12, perCore: [12], iowaitPercent: 0 },
|
||||
load: { one: 0.1, five: 0.2, fifteen: 0.3 },
|
||||
memory: { totalBytes: 1024, availableBytes: 512, usedBytes: 512, utilizationPercent: 50, swapTotalBytes: 0, swapUsedBytes: 0, swapUtilizationPercent: 0 },
|
||||
filesystems: [], network: [], time: { synchronized: true, offsetSeconds: 0, state: 'healthy' },
|
||||
status: { state: 'unknown', reasons: [{ code: 'filesystem_root_not_configured', message: 'technical' }] },
|
||||
observedAt: zeroTimestamp, receivedAt: zeroTimestamp,
|
||||
warnings: ['filesystem_root_not_configured'],
|
||||
} : pathname === '/api/v1/array' ? {
|
||||
source, state: 'unknown', parity: { present: false, state: 'unknown', errors: 0 }, members: [],
|
||||
} : pathname === '/api/v1/disks' ? {
|
||||
source: { ...source, id: 'unraid-disks', reason: 'filesystem_root_not_configured' }, total: 0, disks: [],
|
||||
} : pathname === '/api/v1/pools' ? {
|
||||
source: { ...source, id: 'unraid-pools' }, total: 0, pools: [],
|
||||
} : pathname === '/api/v1/applications' ? {
|
||||
source, total: 0, applications: [],
|
||||
} : pathname === '/api/v1/system/status' ? {
|
||||
version: '1', generatedAt: zeroTimestamp, overallState: 'unknown', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [],
|
||||
} : {};
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => mockSourceStatusData(page));
|
||||
|
||||
test('source status is human-readable and technically progressive on core routes', async ({ page }) => {
|
||||
for (const route of ['/host', '/storage', '/applications']) {
|
||||
await page.goto(route);
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
await expect(page.getByText('Nooit ontvangen').first()).toBeVisible();
|
||||
await expect(page.getByText(/laatste meting is verouderd/i).first()).toBeVisible();
|
||||
const visibleText = await page.locator('body').innerText();
|
||||
expect(visibleText).not.toContain('source_stale');
|
||||
expect(visibleText).not.toContain('filesystem_root_not_configured');
|
||||
expect(visibleText).not.toMatch(/1 jan(?:uari)? 1/i);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
}
|
||||
|
||||
await page.goto('/host');
|
||||
const technical = page.locator('.source-status-technical').first();
|
||||
await expect(technical).not.toHaveAttribute('open');
|
||||
await technical.getByText('Technische broninformatie').click();
|
||||
await expect(technical.getByText('source_stale')).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const enabled = Boolean(process.env.PULSE_E2E_REAL_BASE_URL);
|
||||
|
||||
test('storage map keeps physical identity and signal severities truthful', async ({ page }) => {
|
||||
test.skip(!enabled, 'Run against an isolated real stack with the M11-03 storage fixture.');
|
||||
const login = await page.request.get('/auth/test-login');
|
||||
expect(login.ok()).toBeTruthy();
|
||||
|
||||
const disks = await (await page.request.get('/api/v1/disks?limit=100')).json() as { disks: Array<{ id: string; state: string; capacitySeverity: string; thermalSeverity: string }> };
|
||||
expect(disks.disks.find((disk) => disk.id === 'disk-10')).toMatchObject({ state: 'online', capacitySeverity: 'critical', thermalSeverity: 'normal' });
|
||||
expect(disks.disks.find((disk) => disk.id === 'cache')).toMatchObject({ state: 'online', capacitySeverity: 'attention', thermalSeverity: 'critical' });
|
||||
|
||||
const pools = await (await page.request.get('/api/v1/pools?limit=100')).json() as { pools: Array<{ id: string; state: string; capacitySeverity: string }> };
|
||||
expect(pools.pools.find((pool) => pool.id === 'cache')).toMatchObject({ state: 'healthy', capacitySeverity: 'attention' });
|
||||
|
||||
await page.goto('/storage');
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Opslagoverzicht' })).toBeVisible();
|
||||
const visualNodes = page.locator('.storage-map-node');
|
||||
await expect(visualNodes).toHaveCount(3);
|
||||
await expect(visualNodes.filter({ hasText: 'disk10' })).toHaveCount(1);
|
||||
await expect(visualNodes.filter({ hasText: 'disk10' })).toContainText('capaciteit kritiek');
|
||||
await expect(visualNodes.filter({ hasText: 'cache' })).toHaveCount(2);
|
||||
await expect(page.locator('.storage-heatmap-cell')).toHaveCount(2);
|
||||
await expect(page.locator('.storage-heatmap-cell--critical')).toHaveCount(1);
|
||||
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
const accessibility = await new AxeBuilder({ page }).analyze();
|
||||
expect(accessibility.violations.filter((violation) => ['serious', 'critical'].includes(violation.impact ?? ''))).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const dashboard = { id: 'wallboard-dashboard', slug: 'operations', name: 'Operaties', description: 'Kritieke infrastructuur en recente gebeurtenissen.', scope: 'system', revision: 1, currentVersion: 1 };
|
||||
const widgets = [
|
||||
['system', 'Serverstatus', 0, 0, 6, 4, 'inventory', { entityType: 'server' }],
|
||||
['cpu', 'CPU en belasting', 6, 0, 10, 6, 'text', {}],
|
||||
['storage', 'Array en pools', 0, 6, 12, 7, 'text', {}],
|
||||
['apps', 'Applicaties', 12, 6, 12, 7, 'text', {}],
|
||||
['events', 'Recente gebeurtenissen', 0, 13, 24, 6, 'events', {}],
|
||||
].map(([id, title, x, y, w, h, sourceType, scope]) => ({
|
||||
id, title, type: id === 'events' ? 'event-timeline' : 'stat',
|
||||
data: { sourceType, scope, limit: 12 }, behavior: { liveIntervalSeconds: 30 },
|
||||
layouts: { wallboard: { x, y, w, h, visible: true }, desktop: { x: 0, y: 0, w: 6, h: 4, visible: true } },
|
||||
}));
|
||||
|
||||
test('wallboard rotates bounded 1080p slides with truthful transport and data status', async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'wallboard-chromium', 'Requires the 1920x1080 wallboard viewport.');
|
||||
let dashboardReads = 0;
|
||||
await page.route('**/api/v1/**', async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path === '/api/v1/dashboards') {
|
||||
dashboardReads += 1;
|
||||
// React development mode performs an initial StrictMode re-read. Fail the
|
||||
// first scheduled refresh, not either of the initial bootstrap reads.
|
||||
if (dashboardReads === 3) {
|
||||
await route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'temporary_unavailable' }) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const body = path === '/api/v1/dashboards' ? { items: [dashboard] }
|
||||
: path === '/api/v1/dashboards/wallboard-dashboard' ? { dashboard, version: { document: { widgets, variables: [] } } }
|
||||
: path === '/api/v1/system/status' ? { version: '1', generatedAt: new Date().toISOString(), overallState: 'degraded', components: [{ id: 'storage', state: 'degraded', reason: 'capacity_critical' }], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'fresh' }] }
|
||||
: path === '/api/v1/services' ? { services: [{ id: 'up', state: 'up' }, { id: 'down', state: 'down' }] }
|
||||
: path === '/api/v1/incidents' ? { items: [{ id: 'incident-1' }] }
|
||||
: path === '/api/v1/events' ? { items: [{ id: 'event-1', type: 'service.down', severity: 'critical', summary: 'Service niet beschikbaar', occurredAt: new Date().toISOString() }] }
|
||||
: {};
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
});
|
||||
|
||||
const startedAt = Date.now();
|
||||
await page.goto('/wallboard?interval=15&refresh=10');
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Operationeel wallboard' })).toBeVisible();
|
||||
expect(Date.now() - startedAt).toBeLessThan(3_000);
|
||||
await expect(page.getByText('Slide 1 / 2')).toBeVisible();
|
||||
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Transport' })).toContainText('Verbonden');
|
||||
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Data' })).toContainText('Bruikbaar');
|
||||
await expect(page.locator('.wallboard-priority')).toContainText('OpslagVerstoord');
|
||||
await expect(page.locator('.wallboard-priority')).toContainText('Services1 problemen');
|
||||
await expect(page.locator('.wallboard-priority')).toContainText('Incidenten1 open');
|
||||
await expect(page.getByRole('button', { name: /Bewerken|Exporteren/ })).toHaveCount(0);
|
||||
expect(await page.evaluate(() => ({ horizontal: document.documentElement.scrollWidth - innerWidth, vertical: document.documentElement.scrollHeight - innerHeight }))).toEqual({ horizontal: 0, vertical: 0 });
|
||||
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: `../../artifacts/evidence/M14-04/wallboard-slide-1-${testInfo.project.name}.png` });
|
||||
|
||||
await expect(page.getByText('Slide 2 / 2')).toBeVisible({ timeout: 18_000 });
|
||||
await expect(page.getByRole('heading', { level: 3, name: 'Recente gebeurtenissen' })).toBeVisible();
|
||||
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Transport' })).toContainText('Bron niet beschikbaar', { timeout: 12_000 });
|
||||
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Data' })).toContainText('Bruikbaar');
|
||||
await expect(page.locator('.wallboard-connection').filter({ hasText: 'Transport' })).toContainText('Verbonden', { timeout: 12_000 });
|
||||
await expect(page.getByText('Slide 2 / 2')).toBeVisible();
|
||||
expect(await page.evaluate(() => ({ horizontal: document.documentElement.scrollWidth - innerWidth, vertical: document.documentElement.scrollHeight - innerHeight }))).toEqual({ horizontal: 0, vertical: 0 });
|
||||
if (process.env.PULSE_CAPTURE_VISUALS) await page.screenshot({ path: `../../artifacts/evidence/M14-04/wallboard-slide-2-${testInfo.project.name}.png` });
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
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 { AlertRulesPage } from '../../src/AlertRulesPage';
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); window.history.replaceState({}, '', '/alerts'); });
|
||||
|
||||
describe('begeleide alertregelbewerking', () => {
|
||||
it('houdt de werkruimte gesloten wanneer het regelscontract toegang weigert', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
|
||||
if (String(input).includes('/alert-rules?')) return Promise.resolve(new Response('{}', { status: 403 }));
|
||||
return Promise.resolve(new Response(JSON.stringify({ metrics: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
}));
|
||||
|
||||
render(<AlertRulesPage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Geen toegang tot alertregels' })).toBeVisible();
|
||||
expect(screen.queryByRole('navigation', { name: 'Werkruimte voor meldingen' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading', { name: 'Actieve en recente meldingen' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('laadt de metriccatalogus en blokkeert een ongeldige regel', async () => {
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/metrics/catalog')) return Promise.resolve(new Response(JSON.stringify({ metrics: [
|
||||
{ semanticName: 'host.cpu.utilization', unit: 'percent', defaultAggregation: 'avg' },
|
||||
] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
if (url.includes('/alert-silences') || url.includes('/maintenance-windows') || url.includes('/alerts?')) return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<AlertRulesPage />);
|
||||
|
||||
await user.click((await screen.findByText('Alertregels')).closest('button')!);
|
||||
const save = await screen.findByRole('button', { name: 'Regel opslaan' });
|
||||
expect(save).toBeDisabled();
|
||||
const metric = screen.getByRole('combobox', { name: /Meting/ }) as HTMLSelectElement;
|
||||
expect(metric).toHaveTextContent('CPU-gebruik van de host (%)');
|
||||
expect(screen.queryByText('host.cpu.utilization')).not.toBeInTheDocument();
|
||||
|
||||
await user.type(document.querySelector<HTMLInputElement>('#alert-rule-name')!, 'Hoge hostbelasting');
|
||||
await user.selectOptions(metric, 'host.cpu.utilization');
|
||||
expect(save).toBeEnabled();
|
||||
|
||||
const technical = screen.getByText('Technische regelgegevens').closest('details');
|
||||
expect(technical).not.toHaveAttribute('open');
|
||||
expect(screen.getByLabelText('Host niet bereikbaar')).not.toBeChecked();
|
||||
await user.click(screen.getByLabelText('Host niet bereikbaar'));
|
||||
expect(screen.getByLabelText('Host niet bereikbaar')).toBeChecked();
|
||||
await user.click(screen.getByText('Stiltes en onderhoud').closest('button')!);
|
||||
expect(document.querySelector('#silence-matcher')).toHaveTextContent('Kritiek');
|
||||
expect(document.querySelector('#maintenance-selector')).toHaveTextContent('Host');
|
||||
expect(window.location.search).toBe('?section=controls');
|
||||
});
|
||||
|
||||
it('laat bestaande niet-metrische regels met typeafhankelijke validatie bewerken', async () => {
|
||||
const eventRule = {
|
||||
id: '81111111-1111-4111-8111-111111111111', schemaVersion: 1, name: 'Container bevindt zich in een herstartlus', enabled: true, severity: 'degraded', scope: {},
|
||||
condition: { inputType: 'event', operator: '>=', threshold: 3, aggregation: 'count', windowSeconds: 900 }, evaluationIntervalSeconds: 30, pendingSeconds: 0, resolveSeconds: 900, cooldownSeconds: 0,
|
||||
unknownBehavior: 'become-unknown', groupBy: [], suppressWhen: ['host.unreachable'], message: { titleKey: 'alerts.restart.title', bodyKey: 'alerts.restart.body' }, revision: 1, currentVersion: 1,
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/metrics/catalog')) return Promise.resolve(new Response(JSON.stringify({ metrics: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
if (url.includes('/alert-rules?')) return Promise.resolve(new Response(JSON.stringify({ items: [eventRule] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
}));
|
||||
|
||||
render(<AlertRulesPage />);
|
||||
|
||||
await userEvent.setup().click((await screen.findByText('Alertregels')).closest('button')!);
|
||||
expect(await screen.findByDisplayValue('Container bevindt zich in een herstartlus')).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox', { name: /Signaalbron/ })).toHaveValue('event');
|
||||
expect(screen.queryByRole('combobox', { name: /Meting/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Regel opslaan' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('zet kritieke actieve meldingen vooraan en bewaart confirmatie, revisie en idempotentie', async () => {
|
||||
const alerts = Array.from({ length: 25 }, (_, index) => ({
|
||||
id: `alert-${String(index + 1).padStart(2, '0')}`,
|
||||
state: index === 1 ? 'acknowledged' : 'firing',
|
||||
retainedState: 'firing',
|
||||
ruleName: `Melding ${String(index + 1).padStart(2, '0')}`,
|
||||
severity: index % 5 === 0 ? 'critical' : 'attention',
|
||||
entityName: 'Tower',
|
||||
reason: 'threshold_exceeded',
|
||||
revision: index + 1,
|
||||
updatedAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 0) - index * 60_000).toISOString(),
|
||||
}));
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/metrics/catalog')) return Promise.resolve(new Response(JSON.stringify({ metrics: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
if (url.includes('/alert-rules?')) return Promise.resolve(new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
if (url.includes('/alerts?')) return Promise.resolve(new Response(JSON.stringify({ items: alerts }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
if (init?.method === 'POST') return Promise.resolve(new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
return Promise.resolve(new Response(JSON.stringify({ alert: alerts[0] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const confirm = vi.fn(() => false);
|
||||
vi.stubGlobal('confirm', confirm);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<AlertRulesPage />);
|
||||
|
||||
const critical = (await screen.findByText('Kritiek actief')).closest('button')!;
|
||||
expect(screen.getByText('Actief').closest('button')).toHaveAttribute('aria-pressed', 'true');
|
||||
await waitFor(() => expect(document.querySelectorAll('.alert-operation-list-items li')).toHaveLength(20));
|
||||
expect(document.querySelector('.alert-operation-list-items li')).toHaveTextContent('Kritiek');
|
||||
await user.click(critical);
|
||||
await waitFor(() => expect(document.querySelectorAll('.alert-operation-list-items li')).toHaveLength(5));
|
||||
|
||||
const acknowledge = screen.getAllByRole('button', { name: 'Erkennen' })[0];
|
||||
await user.click(acknowledge);
|
||||
expect(confirm).toHaveBeenCalledWith('Deze melding erkennen? De evaluatie en geschiedenis blijven behouden.');
|
||||
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false);
|
||||
|
||||
confirm.mockReturnValue(true);
|
||||
await user.click(acknowledge);
|
||||
const operation = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
|
||||
expect(operation?.[1]?.headers).toMatchObject({ 'If-Match': '1' });
|
||||
expect((operation?.[1]?.headers as Record<string, string>)['Idempotency-Key']).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
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 App from '../../src/App';
|
||||
import { resetSystemStatusForTests } from '../../src/systemStatus';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetSystemStatusForTests();
|
||||
vi.unstubAllGlobals();
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
function json(value: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(value), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
const healthySource = { state: 'healthy', freshness: 'fresh' };
|
||||
|
||||
describe('Stitch command overview', () => {
|
||||
it('shows real source values while failed sources remain explicitly unavailable', async () => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
const now = new Date().toISOString();
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/v1/system/status') return json({
|
||||
version: '1', generatedAt: now, overallState: 'healthy', components: [
|
||||
{ id: 'database', state: 'healthy', reason: 'ok' },
|
||||
{ id: 'prometheus', state: 'healthy', reason: 'ok' },
|
||||
], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'prometheus', state: 'degraded', reason: 'source_stale', ageSeconds: 420 }],
|
||||
});
|
||||
if (url === '/api/v1/host') return json({
|
||||
identity: { name: 'tower-lab' },
|
||||
cpu: { totalPercent: 37.5, perCore: [25, 50] },
|
||||
memory: { utilizationPercent: 62.25 },
|
||||
source: healthySource,
|
||||
});
|
||||
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [{ id: 'running', state: 'RUNNING', health: 'healthy' }, { id: 'stopped', state: 'stopped', health: 'unknown' }], total: 2 });
|
||||
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'configured', services: [{ id: 'svc-1', name: 'API', state: 'up' }], total: 1 });
|
||||
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
|
||||
if (url.startsWith('/api/v1/pools')) return json({ error: 'source unavailable' }, 503);
|
||||
return json({ error: 'unexpected request' }, 404);
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect((await screen.findAllByText('37,5%'))[0]).toBeVisible();
|
||||
expect(document.querySelector('.instrument-band')).toBeInTheDocument();
|
||||
expect(document.querySelector('.data-plane')).toBeInTheDocument();
|
||||
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/ });
|
||||
expect(unavailableStorage.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'unknown');
|
||||
const staleSource = screen.getByRole('button', { name: /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(screen.queryByText('0 pools')).not.toBeInTheDocument();
|
||||
const storageCard = screen.getByRole('heading', { name: 'Capaciteit en toestand' }).closest('article');
|
||||
expect(storageCard).not.toBeNull();
|
||||
expect(within(storageCard!).getByText(/Deze overzichtsbron kon niet worden geladen/)).toBeVisible();
|
||||
expect(screen.getByText('Opslag: Niet beschikbaar')).toBeVisible();
|
||||
expect(screen.getByRole('heading', { name: 'Aandacht vereist' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('suppresses current workload claims when retained container data is stale', async () => {
|
||||
const now = new Date().toISOString();
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
|
||||
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
|
||||
if (url.startsWith('/api/v1/containers')) return json({ source: { state: 'unknown', freshness: 'stale' }, containers: [{ id: 'retained', state: 'running', health: 'healthy' }], total: 1 });
|
||||
if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
|
||||
return json({}, 404);
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Workloads: Verouderd.*actieve containers: —/ })).toBeVisible();
|
||||
const workloadCard = screen.getByRole('heading', { name: 'Containers' }).closest('article');
|
||||
expect(workloadCard).not.toBeNull();
|
||||
expect(within(workloadCard!).getByText(/laatst bekende waarden worden niet als actueel getoond/i)).toBeVisible();
|
||||
expect(within(workloadCard!).queryByText(/1 van 1 containers zijn actief/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('settles independent resources while a single endpoint is still pending', async () => {
|
||||
const now = new Date().toISOString();
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
|
||||
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
|
||||
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [{ id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 50 }], total: 1 });
|
||||
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'configured', services: [{ id: 'api', name: 'API', state: 'up' }], total: 1 });
|
||||
if (url.startsWith('/api/v1/incidents')) return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true });
|
||||
});
|
||||
return json({}, 404);
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Host: Gezond.*30%.*40%/ })).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: /Incidenten: Wordt geladen/ })).toBeVisible();
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Status nog niet bevestigd' })).toBeVisible();
|
||||
const incidentCard = screen.getByRole('heading', { name: 'Incidenten' }).closest('article');
|
||||
expect(incidentCard).not.toBeNull();
|
||||
expect(within(incidentCard!).getByText(/wordt geladen; er wordt nog geen toestand verondersteld/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('does not present an unavailable incident feed as an empty healthy feed', async () => {
|
||||
const now = new Date().toISOString();
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
|
||||
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
|
||||
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/incidents')) return json({ code: 'UNAVAILABLE' }, 503);
|
||||
return json({}, 404);
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /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();
|
||||
expect(within(incidentCard!).queryByText('Er zijn geen open incidenten geregistreerd.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('distinguishes forbidden resources from an expired session', async () => {
|
||||
const now = new Date().toISOString();
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/v1/system/status') return json({ code: 'FORBIDDEN' }, 403);
|
||||
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
|
||||
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/pools')) return json({ code: 'FORBIDDEN' }, 403);
|
||||
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
|
||||
return json({}, 404);
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Bronnen: Geen toegang/ })).toBeVisible();
|
||||
expect(await screen.findByRole('button', { name: /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();
|
||||
});
|
||||
|
||||
it('prioriteert kritieke poolcapaciteit boven gezonde device-health', async () => {
|
||||
const now = new Date().toISOString();
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] });
|
||||
if (url.startsWith('/api/v1/pools')) return json({ source: healthySource, pools: [{ id: 'ssd', name: 'ssd', state: 'healthy', capacitySeverity: 'critical', utilizationPercent: 98.8 }], total: 1 });
|
||||
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
|
||||
if (url === '/api/v1/host') return json({ source: healthySource });
|
||||
return json({}, 404);
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('ssd: Kritiek')).toBeVisible();
|
||||
expect(screen.getByText(/kritieke capaciteitsgrens is overschreden/)).toBeVisible();
|
||||
expect(screen.getByText('Kritiek · device-health gezond')).toBeVisible();
|
||||
expect(await screen.findByRole('heading', { name: 'Aandacht vereist' })).toBeVisible();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Opslag: Kritiek/ })).toHaveAttribute('aria-pressed', 'true'));
|
||||
});
|
||||
|
||||
it('retries every overview resource from the shared retry action', async () => {
|
||||
const user = userEvent.setup();
|
||||
const now = new Date().toISOString();
|
||||
let poolCalls = 0;
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [{ id: 'database', state: 'healthy', reason: 'ok' }], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] });
|
||||
if (url === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
|
||||
if (url.startsWith('/api/v1/containers')) return json({ source: healthySource, containers: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/services')) return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
|
||||
if (url.startsWith('/api/v1/incidents')) return json({ items: [] });
|
||||
if (url.startsWith('/api/v1/pools')) {
|
||||
poolCalls += 1;
|
||||
if (poolCalls === 1) return json({ code: 'POOLS_UNAVAILABLE' }, 503);
|
||||
return json({ source: healthySource, pools: [{ id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 50 }], total: 1 });
|
||||
}
|
||||
return json({}, 404);
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
expect(await screen.findByText('Opslag: Niet beschikbaar')).toBeVisible();
|
||||
await user.click(screen.getByRole('button', { name: 'Opnieuw laden' }));
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Opslag: Gezond/ })).toBeVisible();
|
||||
expect(poolCalls).toBe(2);
|
||||
expect(screen.queryByText('Opslag: Niet beschikbaar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reads the bounded container target scale and marks truncated services partial', async () => {
|
||||
const now = new Date().toISOString();
|
||||
const containers = Array.from({ length: 150 }, (_, index) => ({ id: `container-${String(index).padStart(3, '0')}`, state: 'running', health: 'healthy' }));
|
||||
const services = Array.from({ length: 100 }, (_, index) => ({ id: `service-${String(index).padStart(3, '0')}`, name: `Service ${index}`, state: 'up' }));
|
||||
let containerCalls = 0;
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const raw = String(input);
|
||||
const url = new URL(raw, 'http://pulse.test');
|
||||
if (raw === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [{ id: 'database', state: 'healthy', reason: 'ok' }], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] });
|
||||
if (url.pathname === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
|
||||
if (url.pathname === '/api/v1/containers') {
|
||||
containerCalls += 1;
|
||||
const offset = Number(url.searchParams.get('after') ?? 0);
|
||||
const page = containers.slice(offset, offset + 100);
|
||||
return json({ source: healthySource, containers: page, total: containers.length, nextCursor: offset + page.length < containers.length ? String(offset + page.length) : undefined });
|
||||
}
|
||||
if (url.pathname === '/api/v1/pools') return json({ source: healthySource, pools: [{ id: 'cache', name: 'Cache', state: 'healthy', capacitySeverity: 'normal', utilizationPercent: 50 }], total: 1 });
|
||||
if (url.pathname === '/api/v1/services') return json({ capabilityState: 'available', configurationState: 'configured', services, total: 150 });
|
||||
if (url.pathname === '/api/v1/incidents') return json({ items: [] });
|
||||
return json({}, 404);
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /Workloads: Gezond.*150\/150/ })).toBeVisible();
|
||||
const serviceStage = screen.getByRole('button', { name: /Services: Gedeeltelijke data.*≥100\/150/ });
|
||||
expect(serviceStage.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'unknown');
|
||||
expect(screen.getByText('Services: Gedeeltelijke data')).toBeVisible();
|
||||
expect(containerCalls).toBe(2);
|
||||
});
|
||||
|
||||
it('bounds container paging and does not inflate totals with overlapping rows', async () => {
|
||||
const now = new Date().toISOString();
|
||||
let containerCalls = 0;
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const raw = String(input);
|
||||
const url = new URL(raw, 'http://pulse.test');
|
||||
if (raw === '/api/v1/system/status') return json({ version: '1', generatedAt: now, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'ok' }] });
|
||||
if (url.pathname === '/api/v1/host') return json({ source: healthySource, cpu: { totalPercent: 30 }, memory: { utilizationPercent: 40 } });
|
||||
if (url.pathname === '/api/v1/containers') {
|
||||
containerCalls += 1;
|
||||
const start = url.searchParams.get('after') === '100' ? 99 : url.searchParams.get('after') === '200' ? 199 : 0;
|
||||
const items = Array.from({ length: 100 }, (_, index) => ({ id: `container-${start + index}`, state: 'running', health: 'healthy' }));
|
||||
const nextCursor = containerCalls === 1 ? '100' : containerCalls === 2 ? '200' : '300';
|
||||
return json({ source: healthySource, containers: items, total: 400, nextCursor });
|
||||
}
|
||||
if (url.pathname === '/api/v1/pools') return json({ source: healthySource, pools: [], total: 0 });
|
||||
if (url.pathname === '/api/v1/services') return json({ capabilityState: 'available', configurationState: 'not_configured', services: [], total: 0 });
|
||||
if (url.pathname === '/api/v1/incidents') return json({ items: [] });
|
||||
return json({}, 404);
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
const workloadStage = await screen.findByRole('button', { name: /Workloads: Gedeeltelijke data.*≥299\/400/ });
|
||||
expect(workloadStage.closest('.signal-path-stage')).toHaveAttribute('data-tone', 'unknown');
|
||||
expect(screen.getByText('Workloads: Gedeeltelijke data')).toBeVisible();
|
||||
expect(containerCalls).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApplicationPage } from '../../src/ApplicationPage';
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||
|
||||
describe('application status projection', () => {
|
||||
it('uses attention for failures and unknown for incomplete evidence', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
source: { id: 'agent+services', state: 'healthy', freshness: 'fresh' },
|
||||
total: 2,
|
||||
applications: [
|
||||
{ id: 'a', name: 'attention-app', status: 'DOWN', overridden: false, components: [] },
|
||||
{ id: 'b', name: 'unknown-app', status: 'unknown', overridden: false, components: [] },
|
||||
],
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<ApplicationPage />);
|
||||
|
||||
const attention = (await screen.findByText('attention-app')).closest('li');
|
||||
const unknown = screen.getByText('unknown-app').closest('li');
|
||||
expect(attention).not.toBeNull();
|
||||
expect(unknown).not.toBeNull();
|
||||
expect(within(attention!).getByText('Aandacht').closest('.status-badge')).toHaveClass('status-badge--attention');
|
||||
expect(within(unknown!).getByText('Onbekend').closest('.status-badge')).toHaveClass('status-badge--unknown');
|
||||
});
|
||||
|
||||
it('vertaalt staleness en toont nooit een jaar-1-waarneming als echte tijd', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
source: { id: 'agent+services', state: 'healthy', freshness: 'stale', observedAt: '0001-01-01T00:00:00Z', reason: 'source_stale' },
|
||||
total: 0,
|
||||
applications: [],
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<ApplicationPage />);
|
||||
|
||||
expect(await screen.findByText(/laatste meting is verouderd/i)).toBeVisible();
|
||||
expect(screen.getByText('Nooit ontvangen')).toBeVisible();
|
||||
expect(screen.queryByText(/1 jan 1/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('source_stale')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ArrayPage } from '../../src/ArrayPage';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('ArrayPage', () => {
|
||||
it('renders a bounded empty state when an older snapshot contains null collections', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
|
||||
contractVersion: '1',
|
||||
source: { id: 'array', state: 'unknown', freshness: 'unavailable' },
|
||||
state: 'unknown',
|
||||
parity: { present: false, state: 'unknown', errors: 0 },
|
||||
members: null,
|
||||
history: null,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<ArrayPage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Array en parity' })).toBeVisible();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CapacityPage } from '../../src/CapacityPage';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('CapacityPage forecast qualification', () => {
|
||||
it('does not count an insufficient assessment as a forecast', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
contractVersion: 'v1', generatedAt: '2026-08-12T01:00:00Z',
|
||||
policy: { enabled: true, windowSeconds: 2592000, minPoints: 3, method: 'linear_median_rate' },
|
||||
qualifiedCount: 0,
|
||||
items: [{ entityId: 'media', name: 'Media', kind: 'share', enabled: true, method: 'insufficient_data', windowSeconds: 2592000, dataPoints: 1, confidence: 'none', currentUsedBytes: 100, capacityBytes: 0, rateBytesPerDay: 0, reason: 'insufficient_points' }],
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<CapacityPage />);
|
||||
|
||||
expect(await screen.findByText(/0 gekwalificeerde prognoses/)).toBeVisible();
|
||||
expect(screen.getByRole('heading', { name: 'Media' })).toBeVisible();
|
||||
expect(screen.getByText('Er zijn minder historische metingen dan het ingestelde minimum.')).toBeVisible();
|
||||
expect(screen.queryByText('0 B / 0 B')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a concrete empty state without a synthetic entity', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
contractVersion: 'v1', generatedAt: '2026-08-12T01:00:00Z', reason: 'source_unavailable',
|
||||
policy: { enabled: true, windowSeconds: 2592000, minPoints: 3, method: 'linear_median_rate' },
|
||||
qualifiedCount: 0, items: [],
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<CapacityPage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Nog geen bruikbare capaciteitsprognose' })).toBeVisible();
|
||||
expect(screen.getByRole('link', { name: 'Bekijk shares en groeihistorie' })).toHaveAttribute('href', '/shares');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ContainerPage } from '../../src/ContainerPage';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('container status projection', () => {
|
||||
it('normalizes presentation and never fabricates missing health or metrics', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
source: { id: 'unraid', state: 'healthy', freshness: 'fresh' },
|
||||
total: 2,
|
||||
containers: [
|
||||
{ id: 'a', name: 'alpha', state: 'RUNNING', health: 'unknown', intentionalStop: false, metricsAvailable: false, lifecycleAvailable: false, uptimeSeconds: 0, restartCount: 0, exitCode: 0, cpuPercent: 0, memoryBytes: 0, memoryLimitBytes: 0, networkRxBytes: 0, networkTxBytes: 0, blockReadBytes: 0, blockWriteBytes: 0 },
|
||||
{ id: 'b', name: 'beta', state: 'restarting', health: 'unhealthy', intentionalStop: false, metricsAvailable: true, lifecycleAvailable: true, uptimeSeconds: 60, restartCount: 4, exitCode: 137, cpuPercent: 12.5, memoryBytes: 1024, memoryLimitBytes: 2048, networkRxBytes: 0, networkTxBytes: 0, blockReadBytes: 0, blockWriteBytes: 0 },
|
||||
],
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<ContainerPage />);
|
||||
|
||||
const alpha = (await screen.findAllByRole('link', { name: 'alpha' })).map((item) => item.closest('tr')).find(Boolean);
|
||||
const beta = screen.getAllByText('beta').map((item) => item.closest('tr')).find(Boolean);
|
||||
expect(alpha).not.toBeNull();
|
||||
expect(beta).not.toBeNull();
|
||||
expect(within(alpha!).getByText('Actief').closest('.status-badge')).toHaveClass('status-badge--ready');
|
||||
expect(within(alpha!).getAllByText('Onbekend').length).toBeGreaterThanOrEqual(2);
|
||||
expect(within(alpha!).getByText(/metingen niet beschikbaar/)).toBeVisible();
|
||||
expect(within(beta!).getByText('Wordt herstart').closest('.status-badge')).toHaveClass('status-badge--attention');
|
||||
expect(within(beta!).getByText('Ongezond').closest('.status-badge')).toHaveClass('status-badge--attention');
|
||||
expect(within(beta!).getByText('12,5%')).toBeVisible();
|
||||
});
|
||||
|
||||
it('does not render stale item states as ready', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
source: { id: 'unraid', state: 'unknown', freshness: 'stale', reason: 'stale_source' },
|
||||
total: 1,
|
||||
containers: [{ id: 'a', name: 'stale-alpha', state: 'running', health: 'healthy', intentionalStop: false, metricsAvailable: true, lifecycleAvailable: true, uptimeSeconds: 60, restartCount: 0, exitCode: 0, cpuPercent: 10, memoryBytes: 1024, memoryLimitBytes: 2048, networkRxBytes: 0, networkTxBytes: 0, blockReadBytes: 0, blockWriteBytes: 0 }],
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<ContainerPage />);
|
||||
|
||||
const row = (await screen.findAllByRole('link', { name: 'stale-alpha' })).map((item) => item.closest('tr')).find(Boolean);
|
||||
expect(row).not.toBeNull();
|
||||
expect(row!.querySelectorAll('.status-badge--unknown')).toHaveLength(2);
|
||||
expect(within(row!).queryByText('running')).not.toBeInTheDocument();
|
||||
expect(within(row!).queryByText('healthy')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import App from '../../src/App';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
describe('dashboard request caching', () => {
|
||||
it('does not reuse dashboard responses across authentication or onboarding changes', async () => {
|
||||
window.history.replaceState({}, '', '/dashboards');
|
||||
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.startsWith('/api/v1/dashboards')) {
|
||||
return new Response(JSON.stringify({ items: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
return new Response(JSON.stringify({ error: 'not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' } });
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Jouw dashboards' })).toBeVisible();
|
||||
const dashboardRequest = requests.find((request) => request.url.startsWith('/api/v1/dashboards'));
|
||||
expect(dashboardRequest?.init?.cache).toBe('no-store');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
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 { EventsPage } from '../../src/EventsPage';
|
||||
|
||||
const items = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: `event-${String(index + 1).padStart(3, '0')}`,
|
||||
type: index % 2 === 0 ? 'service.down' : 'container.restart',
|
||||
severity: index % 10 === 0 ? 'critical' : index % 3 === 0 ? 'warning' : 'info',
|
||||
entityId: `entity-${String(index % 5).padStart(2, '0')}`,
|
||||
sourceId: 'source-unraid',
|
||||
occurredAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 0) - index * 60_000).toISOString(),
|
||||
receivedAt: new Date(Date.UTC(2026, 7, 21, 12, 0, 5) - index * 60_000).toISOString(),
|
||||
summary: `Gebeurtenis ${String(index + 1).padStart(3, '0')}`,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
window.history.replaceState({}, '', '/events');
|
||||
});
|
||||
|
||||
function mockEvents(payload = items) {
|
||||
const fetch = vi.fn(async () => new Response(JSON.stringify({ items: payload }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
return fetch;
|
||||
}
|
||||
|
||||
describe('compacte eventtijdlijn', () => {
|
||||
it('houdt honderd events begrensd en pagineert deterministisch met focus', async () => {
|
||||
const fetch = mockEvents();
|
||||
const user = userEvent.setup();
|
||||
render(<EventsPage />);
|
||||
|
||||
const list = await screen.findByRole('list', { name: 'Resultaten' });
|
||||
expect(within(list).getAllByRole('listitem')).toHaveLength(20);
|
||||
expect(within(list).getByText('event-001')).toBeInTheDocument();
|
||||
expect(within(list).queryByText('event-021')).not.toBeInTheDocument();
|
||||
|
||||
const next = screen.getByRole('button', { name: 'Volgende pagina' });
|
||||
await user.click(next);
|
||||
await waitFor(() => expect(screen.getByText(/Pagina 2 van 5/)).toHaveFocus());
|
||||
expect(within(list).getByText('event-021')).toBeInTheDocument();
|
||||
expect(window.location.search).toContain('page=2');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('filtert ernst, soort, onderdeel en vrije tekst zonder nieuwe request', async () => {
|
||||
const fetch = mockEvents();
|
||||
const user = userEvent.setup();
|
||||
render(<EventsPage />);
|
||||
const list = await screen.findByRole('list', { name: 'Resultaten' });
|
||||
|
||||
await user.selectOptions(screen.getByLabelText('Ernst'), 'critical');
|
||||
expect(within(list).getAllByRole('listitem')).toHaveLength(10);
|
||||
await user.selectOptions(screen.getByLabelText('Soort'), 'service.down');
|
||||
expect(within(list).getAllByRole('listitem')).toHaveLength(10);
|
||||
await user.selectOptions(screen.getByLabelText('Onderdeel'), 'entity-00');
|
||||
expect(within(list).getAllByRole('listitem')).toHaveLength(10);
|
||||
await user.clear(screen.getByLabelText('Zoeken'));
|
||||
await user.type(screen.getByLabelText('Zoeken'), 'Gebeurtenis 091');
|
||||
expect(within(list).getAllByRole('listitem')).toHaveLength(1);
|
||||
expect(within(list).getByText('event-091')).toBeInTheDocument();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('houdt het kritieke totaal zichtbaar en biedt een herstelbare lege state', async () => {
|
||||
mockEvents();
|
||||
const user = userEvent.setup();
|
||||
render(<EventsPage />);
|
||||
|
||||
const critical = await screen.findByRole('button', { name: /Kritieke gebeurtenissen/i });
|
||||
expect(critical).toHaveTextContent('10');
|
||||
await user.type(screen.getByLabelText('Zoeken'), 'bestaat-niet');
|
||||
expect(screen.getByText('Geen gebeurtenissen binnen de huidige filters.')).toBeVisible();
|
||||
expect(critical).toBeVisible();
|
||||
await user.click(screen.getByRole('button', { name: 'Alle filters wissen' }));
|
||||
expect(await screen.findByRole('list', { name: 'Resultaten' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { InventoryPage } from '../../src/InventoryPage';
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||
|
||||
const json = (value: unknown) => new Response(JSON.stringify(value), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
describe('InventoryPage', () => {
|
||||
it('renders effective status and sends bounded filters and pagination', async () => {
|
||||
const fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('after=')) return json({ items: [{ id: 'b', entityType: 'probe', canonicalName: 'probe.b', displayName: 'Probe B', status: 'unknown', factCount: 0, overrideCount: 0, relationCount: 0, sourceCount: 0, staleFactCount: 0 }], hasMore: false, nextCursor: '' });
|
||||
return json({ items: [{ id: 'a', entityType: 'container', canonicalName: 'container.a', displayName: 'Handmatige API', status: 'operational', factCount: 2, overrideCount: 1, relationCount: 1, sourceCount: 2, staleFactCount: 0 }], hasMore: true, nextCursor: 'next' });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
|
||||
render(<InventoryPage />);
|
||||
expect(await screen.findByText('Handmatige API')).toBeVisible();
|
||||
expect(screen.getByText('2 bronnen · 2 feiten · 1 relaties · 1 correcties')).toBeVisible();
|
||||
fireEvent.change(screen.getByLabelText('Zoeken'), { target: { value: 'api' } });
|
||||
expect(await screen.findByText('Handmatige API')).toBeVisible();
|
||||
expect(fetch.mock.calls.some(([url]) => String(url).includes('q=api'))).toBe(true);
|
||||
expect(window.location.search).toContain('q=api');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Volgende pagina' }));
|
||||
expect(await screen.findByText('Probe B')).toBeVisible();
|
||||
expect(window.location.search).toContain('after=next');
|
||||
});
|
||||
|
||||
it('shows override priority while preserving discovered facts and stale relations', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => json({
|
||||
entity: { id: 'a', entityType: 'container', canonicalName: 'container.a', displayName: 'Handmatige API', status: 'operational', firstSeenAt: '2026-08-12T00:00:00Z', factCount: 2, overrideCount: 1, relationCount: 1, sourceCount: 2, staleFactCount: 1 },
|
||||
aliases: [{ sourceName: 'Unraid', externalType: 'container', externalId: 'api' }],
|
||||
facts: [{ fieldName: 'image', sourceId: 'unraid', sourceName: 'Unraid', value: 'discovered:latest', observedAt: '2026-08-12T00:00:00Z', confidence: 1, stale: false }, { fieldName: 'runtimeState', sourceId: 'unraid', sourceName: 'Unraid', value: 'running', observedAt: '2026-08-12T00:00:00Z', confidence: 1, stale: false }],
|
||||
overrides: [{ fieldName: 'image', value: 'manual:pinned', updatedAt: '2026-08-12T01:00:00Z' }],
|
||||
effectiveValues: [{ fieldName: 'image', value: 'manual:pinned', origin: 'override', stale: false, overriddenAt: '2026-08-12T01:00:00Z' }, { fieldName: 'runtimeState', value: 'running', origin: 'discovered', sourceName: 'Unraid', observedAt: '2026-08-12T00:00:00Z', confidence: 1, stale: false }],
|
||||
relations: [{ id: 'r', direction: 'outgoing', relationType: 'depends_on', peerId: 'b', peerType: 'service', peerName: 'Database', peerStatus: 'Ontbrekende entiteit', peerTombstonedAt: '2026-08-11T00:00:00Z', sourceName: 'Agent', confidence: .8, confirmed: false }],
|
||||
})));
|
||||
|
||||
render(<InventoryPage id="a" />);
|
||||
const effective = await screen.findByRole('heading', { name: 'Wat Pulse momenteel gebruikt' });
|
||||
expect(within(effective.closest('section')!).getByText('manual:pinned')).toBeVisible();
|
||||
expect(screen.getByText('Handmatige correctie')).toBeVisible();
|
||||
expect(screen.getByText('Runtime-status')).toBeVisible();
|
||||
expect(screen.getByText('Actief')).toBeVisible();
|
||||
fireEvent.click(screen.getByText('Alle bronfeiten en correcties'));
|
||||
expect(screen.getByText(/discovered:latest/)).toBeVisible();
|
||||
expect(screen.getByText(/afgeleid/)).toBeVisible();
|
||||
expect(screen.getByText('Ontbrekende entiteit')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MetricWidget, RankedListWidget, StatusGridWidget, metricStatus, type MetricWidgetProps } from '../../src/MetricWidgets';
|
||||
|
||||
const now = Date.parse('2026-08-10T04:00:00Z');
|
||||
const freshPoint = { timestamp: now, value: 24, freshness: 'fresh' as const, labels: { host: 'tower' } };
|
||||
|
||||
function props(overrides: Partial<MetricWidgetProps> = {}): MetricWidgetProps {
|
||||
return {
|
||||
kind: 'stat',
|
||||
series: [{ key: 'tower', points: [freshPoint] }],
|
||||
freshness: 'fresh',
|
||||
expectedStepSeconds: 15,
|
||||
availability: 'success',
|
||||
metricName: 'host.cpu.utilization',
|
||||
visualization: { unit: 'percent', decimals: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('metricStatus ADR-0008 invariant', () => {
|
||||
it('marks only fresh, available data as ready', () => {
|
||||
expect(metricStatus(props())).toMatchObject({ tone: 'ready' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
props({ availability: 'idle' }),
|
||||
props({ availability: 'loading' }),
|
||||
props({ availability: 'connecting' }),
|
||||
props({ availability: 'error' }),
|
||||
props({ series: [] }),
|
||||
props({ freshness: 'stale' }),
|
||||
props({ freshness: 'unavailable' }),
|
||||
props({ series: [{ key: 'tower', points: [{ ...freshPoint, freshness: 'delayed' }] }] }),
|
||||
])('never presents missing, stale or unavailable telemetry as ready', (value) => {
|
||||
expect(metricStatus(value).tone).toBe('unknown');
|
||||
});
|
||||
|
||||
it('renders an explicit unavailable notice without a numeric value', () => {
|
||||
render(<MetricWidget {...props({ freshness: 'unavailable', series: [], availability: 'error', error: 'Bron tijdelijk onbereikbaar.' })} />);
|
||||
expect(screen.getByText('Bron tijdelijk onbereikbaar.')).toBeVisible();
|
||||
expect(screen.getByText(/De waarde wordt niet als gezond geïnterpreteerd/)).toBeVisible();
|
||||
expect(screen.queryByText('24 %')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactive widgets', () => {
|
||||
it('presents metric series labels as human-readable values without raw JSON', () => {
|
||||
const rawKey = '{"interface":"eth0","host":"tower"}';
|
||||
render(<MetricWidget {...props({
|
||||
kind: 'timeseries',
|
||||
series: [{ key: rawKey, points: [
|
||||
{ ...freshPoint, timestamp: now - 15_000, labels: { __name__: 'host.network.receive', host: 'tower', interface: 'eth0' } },
|
||||
{ ...freshPoint, labels: { __name__: 'host.network.receive', host: 'tower', interface: 'eth0' } },
|
||||
] }],
|
||||
metricName: 'host.network.receive',
|
||||
})} />);
|
||||
|
||||
expect(screen.getByRole('list', { name: 'Legenda' })).toHaveTextContent('tower · eth0');
|
||||
expect(screen.getByRole('img', { name: /Tijdreeks voor Goedgekeurde meting/ })).toBeVisible();
|
||||
expect(document.querySelector('.metric-chart-line')).toHaveAttribute('d', 'M 28.000 192.000 L 628.000 192.000');
|
||||
expect(document.body).not.toHaveTextContent(rawKey);
|
||||
});
|
||||
|
||||
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} />);
|
||||
const row = screen.getByRole('button', { name: /Disk 1/ });
|
||||
row.focus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
expect(onSelect).toHaveBeenCalledWith('disk-1');
|
||||
});
|
||||
|
||||
it('localizes status codes in status-grid fallbacks', () => {
|
||||
render(<StatusGridWidget items={[{ id: 'pool-1', label: 'Cachepool', status: 'degraded' }]} onSelect={vi.fn()} />);
|
||||
expect(screen.getByText('Verstoord')).toBeVisible();
|
||||
expect(screen.queryByText('degraded')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { NetworkPage } from '../../src/NetworkPage';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('NetworkPage API boundary', () => {
|
||||
it('normalizes nullable PostgreSQL collections before rendering', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
contractVersion: 'v1',
|
||||
observedAt: '2026-08-10T04:41:55Z',
|
||||
source: { id: 'agent', freshness: 'fresh', observedAt: '2026-08-10T04:41:55Z', state: 'unknown' },
|
||||
health: null,
|
||||
interfaces: null,
|
||||
certificates: null,
|
||||
events: null,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<NetworkPage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Netwerk' })).toBeVisible();
|
||||
expect(screen.getByText('Geen betrouwbare interfacegegevens beschikbaar.')).toBeVisible();
|
||||
});
|
||||
|
||||
it('distinguishes an unconfigured DNS signal from generic unknown', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
contractVersion: 'v1', observedAt: '2026-08-10T04:41:55Z',
|
||||
source: { id: 'network-aggregate', freshness: 'unknown', observedAt: '2026-08-10T04:41:55Z', state: 'unknown' },
|
||||
health: [{ scope: 'dns', state: 'unknown', capabilityState: 'available', configurationState: 'not_configured', reason: 'not_configured', freshness: 'unavailable', observedAt: '2026-08-10T04:41:55Z' }],
|
||||
interfaces: [], certificates: [], events: [],
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<NetworkPage />);
|
||||
|
||||
expect(await screen.findByText('Niet geconfigureerd')).toBeVisible();
|
||||
expect(screen.getByText('Voor dit signaal is nog geen veilige probe geconfigureerd.')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { OnboardingPage } from '../../src/OnboardingPage';
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||
|
||||
const completed = {
|
||||
state: { completed: true, step: 'completed', dashboardChoice: 'default', rulesChoice: 'default', dashboardId: 'overview', rulesReady: true },
|
||||
capabilities: [
|
||||
{ id: 'auth', state: 'ready', detail: 'Aanmelding geconfigureerd.' },
|
||||
{ id: 'database', state: 'ready', detail: 'Database beschikbaar.' },
|
||||
],
|
||||
resume: false,
|
||||
};
|
||||
|
||||
describe('afgeronde onboarding', () => {
|
||||
it('toont eerst alleen de samenvatting en maakt herconfiguratie expliciet en rolbewust', async () => {
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/system/status')) return Promise.resolve(new Response(JSON.stringify({ version: '1', generatedAt: new Date().toISOString(), overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
if (init?.method === 'POST') return Promise.resolve(new Response('{}', { status: 403 }));
|
||||
return Promise.resolve(new Response(JSON.stringify(completed), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const confirm = vi.fn(() => false);
|
||||
vi.stubGlobal('confirm', confirm);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<OnboardingPage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Pulse is geconfigureerd' })).toBeVisible();
|
||||
expect(screen.getByText('Overzicht actief')).toBeVisible();
|
||||
expect(screen.getByText('Standaardmeldingen actief')).toBeVisible();
|
||||
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Configuratie afronden' })).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Herconfiguratie openen' }));
|
||||
expect(screen.getAllByRole('radio')).toHaveLength(4);
|
||||
const save = screen.getByRole('button', { name: 'Herconfiguratie opslaan' });
|
||||
await user.click(save);
|
||||
expect(confirm).toHaveBeenCalled();
|
||||
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false);
|
||||
|
||||
confirm.mockReturnValue(true);
|
||||
await user.click(save);
|
||||
expect(await screen.findByText('Alleen een beheerder kan onboardingkeuzes wijzigen.')).toBeVisible();
|
||||
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { OperationalSignalPath, type OperationalSignalStage } from '../../src/OperationalSignalPath';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const stages: OperationalSignalStage[] = [
|
||||
{
|
||||
id: 'host', label: 'Host', icon: '▣', tone: 'healthy', statusLabel: 'Gezond',
|
||||
primaryLabel: 'CPU-belasting', primaryValue: '42%', secondaryLabel: 'Geheugen', secondaryValue: '61%',
|
||||
detail: 'Actuele hostmeting.', route: '/host',
|
||||
},
|
||||
{
|
||||
id: 'storage', label: 'Opslag', icon: '▤', tone: 'critical', statusLabel: 'Kritiek',
|
||||
primaryLabel: 'Poolgebruik', primaryValue: '98%', secondaryLabel: 'Pools', secondaryValue: '2',
|
||||
detail: 'De capaciteitsgrens is overschreden.', route: '/storage',
|
||||
},
|
||||
];
|
||||
|
||||
describe('OperationalSignalPath', () => {
|
||||
it('selects the most urgent stage and supports explicit drill-down', async () => {
|
||||
const onNavigate = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<OperationalSignalPath stages={stages} onNavigate={onNavigate} />);
|
||||
|
||||
const storage = screen.getByRole('button', { name: /Opslag: Kritiek/ });
|
||||
expect(storage).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('region', { name: 'Opslag' })).toHaveTextContent('98%');
|
||||
|
||||
const host = screen.getByRole('button', { name: /Host: Gezond/ });
|
||||
await user.click(host);
|
||||
expect(host).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('region', { name: 'Host' })).toHaveTextContent('Actuele hostmeting.');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Open host' }));
|
||||
expect(onNavigate).toHaveBeenCalledWith('/host');
|
||||
});
|
||||
|
||||
it('preserves an explicit keyboard selection across urgency updates', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<OperationalSignalPath stages={stages} onNavigate={vi.fn()} />);
|
||||
const host = screen.getByRole('button', { name: /Host: Gezond/ });
|
||||
host.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
expect(host).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
const updated = stages.map((stage) => stage.id === 'storage' ? { ...stage, tone: 'attention' as const, statusLabel: 'Aandacht' } : stage);
|
||||
rerender(<OperationalSignalPath stages={updated} onNavigate={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: /Host: Gezond/ })).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
rerender(<OperationalSignalPath stages={updated.filter((stage) => stage.id !== 'host')} onNavigate={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: /Opslag: Aandacht/ })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PoolPage } from '../../src/PoolPage';
|
||||
|
||||
const criticalPool = {
|
||||
id: 'ssd', name: 'ssd', filesystem: 'zfs', state: 'healthy', usableBytes: 1000,
|
||||
usedBytes: 988, freeBytes: 12, utilizationPercent: 98.8, capacitySeverity: 'critical',
|
||||
capabilities: { members: 'available', capacity: 'available', redundancy: 'available', scrub: 'available', filesystemErrors: 'available', performance: 'available', ssdWear: 'available', moverSignals: 'available' },
|
||||
};
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });
|
||||
|
||||
describe('operationele poolstatus', () => {
|
||||
it.each([
|
||||
{ id: undefined, payload: { source: { id: 'unraid', state: 'healthy', freshness: 'fresh' }, pools: [criticalPool], total: 1 } },
|
||||
{ id: 'ssd', payload: { source: { id: 'unraid', state: 'healthy', freshness: 'fresh' }, pools: [criticalPool], total: 1, pool: criticalPool } },
|
||||
])('toont kritieke capaciteit niet primair als gezond voor $id', async ({ id, payload }) => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
render(<PoolPage id={id} />);
|
||||
|
||||
expect(await screen.findByText('Kritiek')).toBeVisible();
|
||||
expect(screen.queryByText('Gezond', { selector: '.status-badge' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ServicePage } from '../../src/ServicePage';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('ServicePage API boundary', () => {
|
||||
it('renders a stable empty state when PostgreSQL serializes an empty service list as null', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
contractVersion: 'v1',
|
||||
observedAt: '2026-08-10T04:38:27Z',
|
||||
services: null,
|
||||
total: 0,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<ServicePage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Services' })).toBeVisible();
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Nog geen services geconfigureerd' })).toBeVisible();
|
||||
expect(screen.getByRole('link', { name: 'Open eerste configuratie' })).toHaveAttribute('href', '/onboarding');
|
||||
});
|
||||
|
||||
it('does not present an unavailable source as an empty configuration', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
contractVersion: 'v1', observedAt: '2026-08-10T04:38:27Z', capabilityState: 'unavailable',
|
||||
configurationState: 'unknown', reason: 'source_unavailable', services: [], total: 0,
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
|
||||
render(<ServicePage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 2, name: 'Servicebron niet beschikbaar' })).toBeVisible();
|
||||
expect(screen.queryByRole('link', { name: 'Open eerste configuratie' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the service-level TLS certificate when bounded probe history no longer contains the TLS sample', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/dependencies?limit=100')) {
|
||||
return Promise.resolve(new Response(JSON.stringify({ serviceId: 'service-1', dependencies: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
}
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
service: {
|
||||
id: 'service-1', name: 'Pulse', state: 'up', sampleCount: 5, successfulSampleCount: 5,
|
||||
history: [{ probeId: 'http-probe', observedAt: '2026-08-12T10:20:00Z', state: 'up' }],
|
||||
certificate: { expiresAt: '2026-11-01T00:00:00Z', issuer: 'Pulse test issuer', subject: 'CN=pulse.test', hostnameValid: true, verificationState: 'valid' },
|
||||
},
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
}));
|
||||
|
||||
render(<ServicePage id="service-1" />);
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 2, name: 'TLS-certificaat' })).toBeVisible();
|
||||
expect(screen.getByText('Pulse test issuer')).toBeVisible();
|
||||
expect(screen.queryByText('Geen TLS-certificaat waargenomen.')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { SourceStatusDetails } from '../../src/SourceStatusDetails';
|
||||
|
||||
describe('SourceStatusDetails', () => {
|
||||
it('scheidt bronstatus van databruikbaarheid en houdt de code ingeklapt', () => {
|
||||
render(<SourceStatusDetails source={{ id: 'unraid', state: 'healthy', freshness: 'stale', observedAt: '2026-08-21T13:00:00Z', reason: 'source_stale' }} />);
|
||||
|
||||
expect(screen.getByText(/Bronstatus:/).closest('span')).toHaveTextContent('Gezond');
|
||||
expect(screen.getByText(/Data:/).closest('span')).toHaveTextContent('Verouderd');
|
||||
expect(screen.getByText(/laatste meting is verouderd/i)).toBeVisible();
|
||||
const code = screen.getByText('source_stale');
|
||||
expect(code.closest('details')).not.toHaveAttribute('open');
|
||||
expect(code).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('toont nooit ontvangen zonder een misleidend time-element', () => {
|
||||
const { container } = render(<SourceStatusDetails source={{ id: 'host', state: 'unknown', freshness: 'unavailable', observedAt: '0001-01-01T00:00:00Z' }} />);
|
||||
|
||||
expect(screen.getByText('Nooit ontvangen')).toBeVisible();
|
||||
expect(container.querySelector('time')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildStorageNodes, type StorageData } from '../../src/StoragePage';
|
||||
|
||||
function data(): StorageData {
|
||||
return {
|
||||
array: { source: { state: 'operational', freshness: 'fresh' }, state: 'operational', members: [{ id: 'disk-10', name: 'disk10', role: 'data', state: 'online' }] },
|
||||
disks: { source: { state: 'healthy', freshness: 'fresh' }, disks: [{ id: 'DISK-10', name: 'disk10', role: 'data', state: 'online', utilizationPercent: 99.99, capacitySeverity: 'critical', thermalSeverity: 'normal' }, { id: 'cache', name: 'cache', role: 'cache', state: 'online', utilizationPercent: 85.5, capacitySeverity: 'attention', thermalSeverity: 'critical' }] },
|
||||
pools: { source: { state: 'healthy', freshness: 'fresh' }, pools: [{ id: 'cache', name: 'cache', filesystem: 'zfs', state: 'healthy', utilizationPercent: 85.5, capacitySeverity: 'attention' }] },
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildStorageNodes', () => {
|
||||
it('merges array and disk observations by canonical physical identity', () => {
|
||||
const nodes = buildStorageNodes(data());
|
||||
expect(nodes.filter((node) => node.label === 'disk10')).toHaveLength(1);
|
||||
expect(nodes).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('keeps availability, capacity and thermal signals visible', () => {
|
||||
const nodes = buildStorageNodes(data());
|
||||
const disk = nodes.find((node) => node.label === 'disk10');
|
||||
const cacheDisk = nodes.find((node) => node.id === 'disk-cache');
|
||||
const cachePool = nodes.find((node) => node.id === 'pool-cache');
|
||||
expect(disk).toMatchObject({ state: 'critical' });
|
||||
expect(disk?.detail).toContain('Beschikbaarheid normaal · capaciteit kritiek · temperatuur normaal');
|
||||
expect(cacheDisk).toMatchObject({ state: 'critical' });
|
||||
expect(cachePool?.detail).toContain('Device-health normaal · capaciteit aandacht');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { StorageMapWidget, TemperatureHeatmap } from '../../src/StorageVisuals';
|
||||
|
||||
describe('storagevisualisaties', () => {
|
||||
it('localiseert statuscodes in de kaart en het toegankelijke tabelalternatief', () => {
|
||||
render(<StorageMapWidget title="Opslag" description="Status per onderdeel" nodes={[{ id: 'pool-1', label: 'Cachepool', kind: 'pool', state: 'degraded' }]} />);
|
||||
|
||||
expect(screen.getAllByText('Verstoord')).toHaveLength(2);
|
||||
expect(screen.getByRole('link', { name: 'Cachepool: Verstoord' })).toBeVisible();
|
||||
expect(screen.queryByText('degraded')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('localiseert de status in het tabelalternatief van de temperatuurkaart', () => {
|
||||
render(<TemperatureHeatmap title="Temperaturen" description="Recente metingen" points={[{ id: 'disk-1', label: 'Disk 1', observedAt: '2026-08-12T03:00:00Z', value: 48, status: 'critical' }]} />);
|
||||
|
||||
expect(screen.getByRole('cell', { name: 'Kritiek' })).toBeVisible();
|
||||
expect(screen.queryByRole('cell', { name: 'critical' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { apiRequestInit, installSessionWatcher, onUnauthenticated } from '../../src/auth';
|
||||
|
||||
const nativeFetch = window.fetch;
|
||||
afterEach(() => { window.fetch = nativeFetch; });
|
||||
|
||||
describe('authenticated API request caching', () => {
|
||||
it('forces same-origin API reads past stale pre-login responses', () => {
|
||||
expect(apiRequestInit('/api/v1/system/status')).toMatchObject({ cache: 'no-store' });
|
||||
expect(apiRequestInit(new URL('/api/v1/dashboards', window.location.href), { method: 'GET', cache: 'force-cache' })).toMatchObject({ method: 'GET', cache: 'no-store' });
|
||||
});
|
||||
|
||||
it('does not rewrite non-API or cross-origin requests', () => {
|
||||
const init = { method: 'GET' } satisfies RequestInit;
|
||||
expect(apiRequestInit('/assets/app.js', init)).toBe(init);
|
||||
expect(apiRequestInit('https://example.com/api/v1/status', init)).toBe(init);
|
||||
});
|
||||
|
||||
it('revokes the shared API context after one 401 and stops further network churn', async () => {
|
||||
const transport = vi.fn(async () => new Response(null, { status: 401 }));
|
||||
window.fetch = transport;
|
||||
const notices = vi.fn();
|
||||
const unsubscribe = onUnauthenticated(notices);
|
||||
installSessionWatcher();
|
||||
|
||||
expect((await window.fetch('/api/v1/system/status')).status).toBe(401);
|
||||
expect((await window.fetch('/api/v1/dashboards')).status).toBe(401);
|
||||
expect((await window.fetch('/api/v1/events')).status).toBe(401);
|
||||
|
||||
expect(transport).toHaveBeenCalledTimes(1);
|
||||
expect(notices).toHaveBeenCalledTimes(1);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveDashboardScope } from '../../src/dashboardScope';
|
||||
|
||||
describe('resolveDashboardScope', () => {
|
||||
it('resolves exact declared defaults and drops non-string scope data', () => {
|
||||
expect(resolveDashboardScope(
|
||||
{ serverId: '$server', literal: 'disk-1', unsafe: 42 },
|
||||
[{ name: 'server', default: 'primary' }],
|
||||
)).toEqual({ serverId: 'primary', literal: 'disk-1' });
|
||||
});
|
||||
|
||||
it('preserves unresolved and partial references for fail-closed API validation', () => {
|
||||
expect(resolveDashboardScope(
|
||||
{ serverId: '$missing', containerId: 'prefix-$server' },
|
||||
[{ name: 'server', default: 'primary' }],
|
||||
)).toEqual({ serverId: '$missing', containerId: 'prefix-$server' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LiveClient, type SocketFactory } from '../../src/liveClient';
|
||||
import type { MetricQueryRequest } from '../../src/metricClient';
|
||||
|
||||
class FakeSocket {
|
||||
readyState = 0;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
readonly sent: string[] = [];
|
||||
|
||||
open(): void { this.readyState = 1; this.onopen?.(); }
|
||||
send(payload: string): void { this.sent.push(payload); }
|
||||
close(): void { this.readyState = 3; this.onclose?.(); }
|
||||
}
|
||||
|
||||
const request: MetricQueryRequest = {
|
||||
metric: 'host.cpu.utilization',
|
||||
scope: { serverId: 'smoke-host' },
|
||||
range: { from: '2026-08-10T06:00:00.000Z', to: '2026-08-10T06:05:00.000Z', stepSeconds: 15 },
|
||||
aggregation: 'avg',
|
||||
};
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('LiveClient subscription lifecycle', () => {
|
||||
it('reuses one socket when React replaces an equivalent listener inside the release grace', async () => {
|
||||
vi.useFakeTimers();
|
||||
const sockets: FakeSocket[] = [];
|
||||
const factory = vi.fn(() => {
|
||||
const socket = new FakeSocket();
|
||||
sockets.push(socket);
|
||||
return socket;
|
||||
}) as unknown as SocketFactory;
|
||||
const client = new LiveClient('/api/v1/live', factory);
|
||||
|
||||
const first = client.subscribe(request, () => undefined);
|
||||
sockets[0].open();
|
||||
await Promise.resolve();
|
||||
first.unsubscribe();
|
||||
|
||||
const shifted = { ...request, range: { ...request.range, from: '2026-08-10T06:01:00.000Z', to: '2026-08-10T06:06:00.000Z' } };
|
||||
const second = client.subscribe(shifted, () => undefined);
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
expect(sockets[0].readyState).toBe(1);
|
||||
expect(sockets[0].sent.filter((payload) => payload.includes('unsubscribe'))).toHaveLength(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(sockets[0].sent.some((payload) => payload.includes('"type":"ping"'))).toBe(true);
|
||||
|
||||
second.unsubscribe();
|
||||
await vi.advanceTimersByTimeAsync(251);
|
||||
expect(sockets[0].readyState).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(sockets[0].readyState).toBe(3);
|
||||
});
|
||||
|
||||
it('reuses an idle transport while a rotating dashboard loads its next query', async () => {
|
||||
vi.useFakeTimers();
|
||||
const sockets: FakeSocket[] = [];
|
||||
const factory = vi.fn(() => {
|
||||
const socket = new FakeSocket();
|
||||
sockets.push(socket);
|
||||
return socket;
|
||||
}) as unknown as SocketFactory;
|
||||
const client = new LiveClient('/api/v1/live', factory);
|
||||
|
||||
const first = client.subscribe(request, () => undefined);
|
||||
sockets[0].open();
|
||||
await Promise.resolve();
|
||||
first.unsubscribe();
|
||||
|
||||
// Subscription state is released after 250 ms, but the bounded transport
|
||||
// grace bridges a slower dashboard document fetch.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(sockets[0].readyState).toBe(1);
|
||||
|
||||
const nextRequest = { ...request, metric: 'host.memory.utilization' };
|
||||
const second = client.subscribe(nextRequest, () => undefined);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
expect(sockets[0].readyState).toBe(1);
|
||||
expect(sockets[0].sent.some((payload) => payload.includes('host.memory.utilization'))).toBe(true);
|
||||
|
||||
second.unsubscribe();
|
||||
await vi.advanceTimersByTimeAsync(10_251);
|
||||
expect(sockets[0].readyState).toBe(3);
|
||||
});
|
||||
|
||||
it('resubscribes once after reconnect and resumes samples on the bounded subscription', async () => {
|
||||
vi.useFakeTimers();
|
||||
const sockets: FakeSocket[] = [];
|
||||
const factory = vi.fn(() => { const socket = new FakeSocket(); sockets.push(socket); return socket; }) as unknown as SocketFactory;
|
||||
const events: string[] = [];
|
||||
const client = new LiveClient('/api/v1/live', factory);
|
||||
const subscription = client.subscribe(request, (event) => events.push(event.type));
|
||||
sockets[0].open();
|
||||
await Promise.resolve();
|
||||
expect(sockets[0].sent.filter((payload) => payload.includes('"type":"subscribe"'))).toHaveLength(1);
|
||||
|
||||
sockets[0].close();
|
||||
expect(events).toContain('status');
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(factory).toHaveBeenCalledTimes(2);
|
||||
sockets[1].open();
|
||||
await Promise.resolve();
|
||||
const subscribe = JSON.parse(sockets[1].sent.find((payload) => payload.includes('"type":"subscribe"')) ?? '{}') as { subscriptionId?: string };
|
||||
expect(sockets[1].sent.filter((payload) => payload.includes('"type":"subscribe"'))).toHaveLength(1);
|
||||
sockets[1].onmessage?.({ data: JSON.stringify({ type: 'samples', subscriptionId: subscribe.subscriptionId, sequence: 1, samples: [{ timestamp: '2026-08-10T06:06:00.000Z', value: 42, labels: {} }] }) });
|
||||
expect(events.at(-1)).toBe('samples');
|
||||
expect(sockets).toHaveLength(2);
|
||||
|
||||
subscription.unsubscribe();
|
||||
await vi.advanceTimersByTimeAsync(10_251);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatDateTime, hasReceivedTimestamp, NEVER_RECEIVED } from '../../src/locale';
|
||||
|
||||
describe('veilige tijdpresentatie', () => {
|
||||
it('presenteert ontbrekende, ongeldige en nulwaarden als nooit ontvangen', () => {
|
||||
expect(formatDateTime()).toBe(NEVER_RECEIVED);
|
||||
expect(formatDateTime('ongeldig')).toBe(NEVER_RECEIVED);
|
||||
expect(formatDateTime('0001-01-01T00:00:00Z')).toBe(NEVER_RECEIVED);
|
||||
expect(formatDateTime('1970-01-01T00:00:00Z')).toBe(NEVER_RECEIVED);
|
||||
});
|
||||
|
||||
it('behoudt een werkelijk ontvangen timestamp en de vaste Brusselse tijdzone', () => {
|
||||
const value = '2026-08-21T14:05:00Z';
|
||||
expect(hasReceivedTimestamp(value)).toBe(true);
|
||||
expect(formatDateTime(value)).toContain('16:05');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { containerSignalTone, signalToneFromState, sourceSignalTone, worstSignalTone } from '../../src/overviewSignals';
|
||||
|
||||
describe('overview signal semantics', () => {
|
||||
it('fails source provenance closed when freshness is stale or unavailable', () => {
|
||||
expect(sourceSignalTone({ state: 'healthy', freshness: 'stale' })).toBe('stale');
|
||||
expect(sourceSignalTone({ state: 'healthy', freshness: 'unavailable' })).toBe('unknown');
|
||||
expect(sourceSignalTone({ state: 'degraded', freshness: 'fresh' })).toBe('attention');
|
||||
expect(sourceSignalTone({ state: 'healthy', freshness: 'fresh' })).toBe('healthy');
|
||||
expect(sourceSignalTone(undefined)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('mirrors container domain state without escalating intentional stops', () => {
|
||||
expect(containerSignalTone({ state: 'running', health: 'healthy' })).toBe('healthy');
|
||||
expect(containerSignalTone({ state: 'running', health: 'unhealthy' })).toBe('attention');
|
||||
expect(containerSignalTone({ state: 'restarting', health: 'unknown' })).toBe('attention');
|
||||
expect(containerSignalTone({ state: 'stopped', health: 'unknown' })).toBe('critical');
|
||||
expect(containerSignalTone({ state: 'stopped', health: 'unknown', intentionalStop: true })).toBe('unknown');
|
||||
});
|
||||
|
||||
it('keeps the most severe state deterministically', () => {
|
||||
expect(signalToneFromState('DOWN')).toBe('critical');
|
||||
expect(worstSignalTone(['healthy', 'unknown', 'attention', 'stale'])).toBe('attention');
|
||||
expect(worstSignalTone([])).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { operationalStorageState, plural, presentArrayRole, presentComponent, presentEntityType, presentEventSummary, presentEventType, presentInventoryField, presentMetric, presentReason, presentRelationType, presentStatus, presentStoragePolicy, presentUnit } from '../../src/presentation';
|
||||
|
||||
describe('Nederlandse presentatielaag', () => {
|
||||
it('vertaalt begrensde status- en reason-codes', () => {
|
||||
expect(presentStatus('critical')).toBe('Kritiek');
|
||||
expect(presentReason('source_health_unknown')).toBe('De gezondheid van deze bron is onbekend.');
|
||||
expect(presentReason('source_stale')).toContain('meting is verouderd');
|
||||
expect(presentReason('filesystem_root_not_configured')).toContain('Bestandssysteemmetingen');
|
||||
expect(presentReason('container_exited')).toBe('De container is gestopt.');
|
||||
expect(presentReason('backup_verified')).toBe('De backup is geverifieerd.');
|
||||
expect(presentReason('backup_stale')).toContain('maak en verifieer een nieuwe backup');
|
||||
expect(presentReason('backup_verification_failed')).toContain('controleer de backupbestemming');
|
||||
expect(presentReason('authenticated_session')).toContain('aanmeldsessie is geldig');
|
||||
expect(presentReason('internal.unknown_code')).toContain('Open de technische details');
|
||||
});
|
||||
|
||||
it('laat de zwaarste opslagernst winnen zonder device-health te herschrijven', () => {
|
||||
expect(operationalStorageState('healthy', 'critical')).toBe('critical');
|
||||
expect(operationalStorageState('degraded', 'normal')).toBe('degraded');
|
||||
expect(operationalStorageState('healthy', 'normal')).toBe('healthy');
|
||||
});
|
||||
|
||||
it('presenteert componenten en metrics zonder implementatiecode', () => {
|
||||
expect(presentComponent('worker')).toBe('Achtergrondverwerking');
|
||||
expect(presentComponent('notifications')).toBe('Notificaties');
|
||||
expect(presentComponent('oidc')).toBe('Aanmelding');
|
||||
expect(presentComponent('probes')).toBe('Servicecontroles');
|
||||
expect(presentMetric('storage.disk.temperature')).toBe('Temperatuur per disk');
|
||||
expect(presentMetric('future.metric')).toBe('Goedgekeurde meting');
|
||||
expect(presentUnit('percent')).toBe('%');
|
||||
});
|
||||
|
||||
it('vertaalt operationele domeincodes voor primaire schermen', () => {
|
||||
expect(presentStatus('sleeping')).toBe('Slapend');
|
||||
expect(presentStatus('online')).toBe('Online');
|
||||
expect(presentStoragePolicy('HIGHWATER')).toBe('Hoogwater');
|
||||
expect(presentArrayRole('parity')).toBe('Pariteit');
|
||||
expect(presentEntityType('probe')).toBe('Servicecontrole');
|
||||
expect(presentEntityType('application-project')).toBe('Compose-project');
|
||||
expect(presentInventoryField('runtimeState')).toBe('Runtime-status');
|
||||
expect(presentRelationType('depends_on')).toBe('is afhankelijk van');
|
||||
expect(presentEventType('container.restart')).toBe('Container herstart');
|
||||
expect(presentEventSummary('container.restart', 'Container restarted.')).toBe('De container is opnieuw gestart.');
|
||||
});
|
||||
|
||||
it('gebruikt de correcte enkelvoudsvorm alleen voor één', () => {
|
||||
expect(plural(0, 'melding', 'meldingen')).toBe('meldingen');
|
||||
expect(plural(1, 'melding', 'meldingen')).toBe('melding');
|
||||
expect(plural(2, 'melding', 'meldingen')).toBe('meldingen');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import App from '../../src/App';
|
||||
import { routeFromLocation } from '../../src/routes';
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); window.history.replaceState({}, '', '/'); });
|
||||
|
||||
describe('expliciete routing', () => {
|
||||
it('behoudt de Events-route en projecteert onbekende adressen op 404', () => {
|
||||
expect(routeFromLocation('/events')).toBe('/events');
|
||||
expect(routeFromLocation('/bestaat-niet')).toBe('/404');
|
||||
});
|
||||
|
||||
it('toont een toegankelijke not-foundpagina in plaats van het overzicht', async () => {
|
||||
window.history.replaceState({}, '', '/bestaat-niet');
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ version: '1', generatedAt: new Date().toISOString(), overallState: 'unknown', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } })));
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Deze pagina bestaat niet' })).toBeVisible();
|
||||
expect(screen.getByRole('link', { name: 'Naar overzicht' })).toHaveAttribute('href', '/');
|
||||
expect(screen.queryByRole('heading', { name: 'Status nog niet bevestigd' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { aggregateStatus, BACKUP_STALE_AFTER_SECONDS, backupPresentation, overviewTitle, STALE_AFTER_MS, statusProblems, type SystemStatus, type SystemStatusSnapshot } from '../../src/systemStatus';
|
||||
|
||||
const observedAt = Date.parse('2026-08-10T04:00:00Z');
|
||||
|
||||
function status(overrides: Partial<SystemStatus> = {}): SystemStatus {
|
||||
return {
|
||||
version: '1',
|
||||
generatedAt: new Date(observedAt).toISOString(),
|
||||
overallState: 'healthy',
|
||||
components: [],
|
||||
backup: { state: 'disabled', reason: 'not_configured' },
|
||||
sourceLag: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function ready(value: SystemStatus): SystemStatusSnapshot {
|
||||
return { state: 'ready', status: value, fetchedAt: observedAt };
|
||||
}
|
||||
|
||||
describe('aggregateStatus ADR-0008 invariant', () => {
|
||||
it.each([
|
||||
{ state: 'loading', status: null, fetchedAt: 0 },
|
||||
{ state: 'error', status: null, fetchedAt: observedAt },
|
||||
{ state: 'unauthorized', status: null, fetchedAt: observedAt },
|
||||
{ state: 'forbidden', status: null, fetchedAt: observedAt },
|
||||
] satisfies SystemStatusSnapshot[])('maps $state without telemetry to Unknown', (snapshot) => {
|
||||
expect(aggregateStatus(snapshot, observedAt)).toMatchObject({ state: 'unknown', tone: 'unknown', stale: false });
|
||||
});
|
||||
|
||||
it('allows healthy only for a fresh ready payload that explicitly says healthy', () => {
|
||||
expect(aggregateStatus(ready(status()), observedAt + 1_000)).toMatchObject({ state: 'healthy', tone: 'ready', stale: false });
|
||||
});
|
||||
|
||||
it.each(['unknown', 'degraded', 'disabled'])('never maps %s to a ready tone', (overallState) => {
|
||||
expect(aggregateStatus(ready(status({ overallState })), observedAt + 1_000).tone).toBe('unknown');
|
||||
});
|
||||
|
||||
it('expires a formerly healthy payload to Unknown', () => {
|
||||
expect(aggregateStatus(ready(status()), observedAt + STALE_AFTER_MS + 1)).toMatchObject({ state: 'unknown', tone: 'unknown', stale: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusProblems', () => {
|
||||
it('keeps non-healthy signals bounded and omits disabled components', () => {
|
||||
const value = status({
|
||||
components: [
|
||||
{ id: 'database', state: 'healthy', reason: 'ok' },
|
||||
{ id: 'prometheus', state: 'unknown', reason: 'stale' },
|
||||
{ id: 'optional', state: 'disabled', reason: 'not_configured' },
|
||||
],
|
||||
sourceLag: Array.from({ length: 12 }, (_, index) => ({ sourceId: `source-${index}`, state: 'unknown', reason: 'missing' })),
|
||||
});
|
||||
const problems = statusProblems(value);
|
||||
expect(problems).toHaveLength(10);
|
||||
expect(problems[0]).toEqual({ id: 'component:prometheus', label: 'Prometheus', reason: 'De laatste meting is verouderd; controleer de bronverbinding en collector.' });
|
||||
expect(problems.some((problem) => problem.label === 'optional')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not repeat a source that is already represented by its component', () => {
|
||||
const value = status({
|
||||
components: [{ id: 'prometheus', state: 'unknown', reason: 'stale' }],
|
||||
sourceLag: [{ sourceId: 'prometheus', state: 'unknown', reason: 'stale' }],
|
||||
});
|
||||
expect(statusProblems(value)).toEqual([{ id: 'component:prometheus', label: 'Prometheus', reason: 'De laatste meting is verouderd; controleer de bronverbinding en collector.' }]);
|
||||
});
|
||||
|
||||
it('keeps a disabled required source actionable while omitting optional disabled features', () => {
|
||||
const value = status({ components: [
|
||||
{ id: 'unraid', state: 'disabled', reason: 'not_configured' },
|
||||
{ id: 'notifications', state: 'disabled', reason: 'not_configured' },
|
||||
] });
|
||||
expect(statusProblems(value)).toEqual([{ id: 'component:unraid', label: 'Unraid', reason: 'Dit onderdeel is nog niet geconfigureerd; open de instellingen om het te activeren.' }]);
|
||||
});
|
||||
|
||||
it('promotes an old allegedly healthy backup to an actionable problem', () => {
|
||||
const value = status({ backup: { state: 'healthy', reason: 'backup_verified', ageSeconds: BACKUP_STALE_AFTER_SECONDS + 1 } });
|
||||
expect(statusProblems(value)).toEqual([{ id: 'backup', label: 'Backupstatus', reason: 'De laatste geverifieerde backup is verlopen; maak en verifieer een nieuwe backup.' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupPresentation', () => {
|
||||
it('allows healthy only while a verified backup is within the 24-hour boundary', () => {
|
||||
expect(backupPresentation({ state: 'healthy', reason: 'backup_verified', ageSeconds: 3600 })).toMatchObject({ state: 'healthy', reason: 'backup_verified' });
|
||||
expect(backupPresentation({ state: 'healthy', reason: 'backup_verified', ageSeconds: BACKUP_STALE_AFTER_SECONDS + 1 })).toMatchObject({ state: 'degraded', reason: 'backup_stale' });
|
||||
});
|
||||
|
||||
it('fails closed when a healthy claim has no age or timestamp', () => {
|
||||
expect(backupPresentation({ state: 'healthy', reason: 'backup_verified' })).toMatchObject({ state: 'unknown', reason: 'no_verified_backup' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('overviewTitle', () => {
|
||||
it('reserves the optimistic title for a healthy issue-free snapshot', () => {
|
||||
const healthy = aggregateStatus(ready(status()), observedAt + 1_000);
|
||||
expect(overviewTitle(healthy, 0)).toBe('Alles onder controle');
|
||||
expect(overviewTitle(healthy, 1)).toBe('Aandacht vereist');
|
||||
expect(overviewTitle(healthy, 0, true)).toBe('Aandacht vereist');
|
||||
});
|
||||
|
||||
it('uses a conservative title for unknown telemetry', () => {
|
||||
const unknown = aggregateStatus(ready(status({ overallState: 'unknown' })), observedAt + 1_000);
|
||||
expect(overviewTitle(unknown, 0)).toBe('Status nog niet bevestigd');
|
||||
});
|
||||
|
||||
it('does not claim that no sources exist when an unknown status has live sources', () => {
|
||||
const unknown = aggregateStatus(ready(status({ overallState: 'unknown', sourceLag: [{ sourceId: 'unraid', state: 'healthy', reason: 'fresh' }] })), observedAt + 1_000);
|
||||
expect(unknown.detail).toContain('Databronnen zijn verbonden');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { LiveClient } from '../../src/liveClient';
|
||||
import type { LiveSample } from '../../src/liveBuffer';
|
||||
import type { MetricQueryRequest } from '../../src/metricClient';
|
||||
import { useLiveMetric } from '../../src/useLiveMetric';
|
||||
|
||||
const request: MetricQueryRequest = {
|
||||
metric: 'host.cpu.utilization',
|
||||
range: { from: '2026-08-10T06:00:00.000Z', to: '2026-08-10T06:05:00.000Z', stepSeconds: 15 },
|
||||
aggregation: 'avg',
|
||||
};
|
||||
|
||||
function sample(timestamp: string, value: number): LiveSample {
|
||||
return { series: 'cpu', timestamp, value, freshness: 'fresh' };
|
||||
}
|
||||
|
||||
describe('useLiveMetric lifecycle', () => {
|
||||
it('updates historical seed data without recreating an unchanged live subscription', () => {
|
||||
const unsubscribe = vi.fn();
|
||||
const subscribe = vi.fn(() => ({ key: 'cpu', unsubscribe }));
|
||||
const releaseUnused = vi.fn();
|
||||
const client = { subscribe, releaseUnused } as unknown as LiveClient;
|
||||
|
||||
function Harness({ initial, query = request }: { initial: LiveSample[]; query?: MetricQueryRequest }) {
|
||||
useLiveMetric(client, query, initial);
|
||||
return null;
|
||||
}
|
||||
|
||||
const view = render(<Harness initial={[sample('2026-08-10T06:00:00.000Z', 10)]} />);
|
||||
expect(subscribe).toHaveBeenCalledTimes(1);
|
||||
|
||||
view.rerender(<Harness initial={[
|
||||
sample('2026-08-10T06:00:00.000Z', 10),
|
||||
sample('2026-08-10T06:00:15.000Z', 11),
|
||||
]} />);
|
||||
|
||||
expect(subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(unsubscribe).not.toHaveBeenCalled();
|
||||
|
||||
view.rerender(<Harness
|
||||
initial={[sample('2026-08-10T06:00:15.000Z', 11)]}
|
||||
query={{ ...request, range: { ...request.range, from: '2026-08-10T06:01:00.000Z', to: '2026-08-10T06:06:00.000Z' } }}
|
||||
/>);
|
||||
|
||||
expect(subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(unsubscribe).not.toHaveBeenCalled();
|
||||
|
||||
view.unmount();
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(releaseUnused).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { wallboardPlacement, wallboardSlideIndex } from '../../src/wallboardLayout';
|
||||
|
||||
describe('wallboard viewport layout', () => {
|
||||
it('splits the default 19-row layout into two deterministic 1080p slides', () => {
|
||||
expect([0, 6, 12, 13, 18].map(wallboardSlideIndex)).toEqual([0, 0, 0, 1, 1]);
|
||||
});
|
||||
|
||||
it('keeps every widget inside the 24 by 13 slide grid', () => {
|
||||
expect(wallboardPlacement({ x: 23, y: 12, w: 12, h: 8 })).toEqual({ columnStart: 24, columnSpan: 1, rowStart: 13, rowSpan: 1 });
|
||||
expect(wallboardPlacement({ x: -4, y: 13, w: 0, h: 0 })).toEqual({ columnStart: 1, columnSpan: 1, rowStart: 1, rowSpan: 1 });
|
||||
});
|
||||
|
||||
it('uses safe defaults for malformed coordinates', () => {
|
||||
expect(wallboardSlideIndex('not-a-row')).toBe(0);
|
||||
expect(wallboardPlacement({ x: Number.POSITIVE_INFINITY, y: null, w: 'wide', h: -2 }))
|
||||
.toEqual({ columnStart: 1, columnSpan: 6, rowStart: 1, rowSpan: 1 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user