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` });
|
||||
});
|
||||
Reference in New Issue
Block a user