import AxeBuilder from '@axe-core/playwright'; import { expect, test, type Page, type Route } from '@playwright/test'; type Dashboard = { id: string; slug: string; name: string; description: string; ownerId: string; scope: string; revision: number; currentVersion: number; archivedAt?: string }; const observedAt = new Date().toISOString(); const initial: Dashboard = { id: 'dashboard-1', slug: 'operations', name: 'Operations', description: 'Dagelijkse operatie', ownerId: 'editor-1', scope: 'shared', revision: 1, currentVersion: 2 }; async function fulfill(route: Route, body: unknown, status = 200): Promise { await route.fulfill({ status, contentType: status >= 400 ? 'application/problem+json' : 'application/json', body: JSON.stringify(body) }); } async function mockShell(page: Page, session: unknown): Promise { await page.route('**/api/v1/system/status', (route) => fulfill(route, { version: '1.5.0', generatedAt: observedAt, overallState: 'healthy', components: [], backup: { state: 'disabled', reason: 'not_configured' }, sourceLag: [{ sourceId: 'test', state: 'healthy', reason: 'ok' }] })); await page.route('**/api/v1/session', (route) => fulfill(route, session)); await page.route('**/api/v1/host', (route) => fulfill(route, { identity: { name: 'lifecycle-fixture' } })); await page.route('**/api/v1/alerts**', (route) => fulfill(route, { items: [] })); await page.route('**/api/v1/incidents**', (route) => fulfill(route, { items: [] })); } test('editor completes the dashboard lifecycle with revision locks', async ({ page }, testInfo) => { test.skip(testInfo.project.name === 'wallboard-chromium', 'Dashboardbeheer is geen wallboardtaak.'); const dashboards: Dashboard[] = [{ ...initial }]; const revisionHeaders: string[] = []; await mockShell(page, { subject: 'editor-1', role: 'editor', environment: 'test', permissions: { view: true, operate: true, edit: true, admin: false } }); await page.route('**/api/v1/dashboards**', async (route) => { const request = route.request(); const url = new URL(request.url()); const path = url.pathname; const method = request.method(); if (path === '/api/v1/dashboards' && method === 'GET') return fulfill(route, { items: dashboards }); if (path === '/api/v1/dashboards' && method === 'POST') { const document = request.postDataJSON() as { slug: string; name: string; description: string; scope: string }; const created = { id: 'dashboard-created', slug: document.slug, name: document.name, description: document.description, ownerId: 'editor-1', scope: document.scope, revision: 1, currentVersion: 1 }; dashboards.push(created); return fulfill(route, { dashboard: created }, 201); } const item = dashboards.find((candidate) => path.includes(candidate.id)); if (!item) return fulfill(route, { code: 'NOT_FOUND' }, 404); if (path.endsWith('/versions') && method === 'GET') return fulfill(route, { items: [ { id: 'v2', dashboardId: item.id, number: 2, schemaVersion: 2, document: {}, changeSummary: 'metadata', createdBy: 'editor-1', createdAt: observedAt }, { id: 'v1', dashboardId: item.id, number: 1, schemaVersion: 2, document: {}, changeSummary: 'initial', createdBy: 'editor-1', createdAt: observedAt }, ] }); if (path.endsWith('/restore/1') && method === 'POST') { revisionHeaders.push(request.headers()['if-match'] ?? ''); item.revision += 1; item.currentVersion += 1; return fulfill(route, { dashboard: item }); } if (path.endsWith('/clone') && method === 'POST') { const body = request.postDataJSON() as { slug: string; name: string }; const clone = { ...item, id: 'dashboard-clone', slug: body.slug, name: body.name, revision: 1, currentVersion: 1 }; dashboards.push(clone); return fulfill(route, { dashboard: clone }, 201); } if (method === 'PATCH') { revisionHeaders.push(request.headers()['if-match'] ?? ''); const body = request.postDataJSON() as { name: string; description: string }; Object.assign(item, body, { revision: item.revision + 1 }); return fulfill(route, { dashboard: item }); } if (method === 'DELETE') { revisionHeaders.push(request.headers()['if-match'] ?? ''); item.revision += 1; item.archivedAt = observedAt; return fulfill(route, { dashboard: item }); } return fulfill(route, { code: 'NOT_FOUND' }, 404); }); await page.goto('/dashboards'); await expect(page.getByRole('heading', { level: 1, name: 'Dashboardbeheer' })).toBeVisible(); await page.getByRole('button', { name: 'Nieuw dashboard' }).click(); await page.getByLabel('Naam').fill('Nachtzicht'); await page.getByLabel('Template').selectOption('blank'); await page.getByRole('button', { name: 'Dashboard maken' }).click(); await expect(page.locator('.dashboard-manager-row').filter({ has: page.getByText('Nachtzicht', { exact: true }) })).toBeVisible(); let row = page.locator('.dashboard-manager-row').filter({ hasText: 'Operations' }); await row.getByRole('button', { name: 'Beheren' }).click(); await row.getByLabel('Naam').fill('Operations EU'); await row.getByRole('button', { name: 'Naam en beschrijving opslaan' }).click(); row = page.locator('.dashboard-manager-row').filter({ has: page.getByText('Operations EU', { exact: true }) }); await row.getByRole('button', { name: 'Beheren' }).click(); await row.getByRole('button', { name: 'Dupliceren' }).click(); await expect(page.getByText('Operations EU (kopie)', { exact: true })).toBeVisible(); await row.getByRole('button', { name: 'Versies bekijken' }).click(); await row.getByRole('button', { name: 'Herstellen als huidige versie' }).click(); await row.getByRole('button', { name: 'Archiveren' }).click(); await row.getByRole('button', { name: 'Ja, archiveren' }).click(); await expect(page.getByText('Dashboard gearchiveerd.')).toBeVisible(); expect(revisionHeaders).toEqual(['1', '2', '3']); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); const serious = (await new AxeBuilder({ page }).analyze()).violations.filter((violation) => violation.impact === 'critical' || violation.impact === 'serious'); expect(serious).toEqual([]); }); test('viewer gets a read-only dashboard manager', async ({ page }, testInfo) => { test.skip(testInfo.project.name === 'wallboard-chromium', 'Dashboardbeheer is geen wallboardtaak.'); await mockShell(page, { subject: 'viewer-1', role: 'viewer', environment: 'test', permissions: { view: true, operate: false, edit: false, admin: false } }); await page.route('**/api/v1/dashboards**', (route) => new URL(route.request().url()).pathname === '/api/v1/dashboards/dashboard-1' ? fulfill(route, { dashboard: initial, version: { document: { schemaVersion: 2, id: '00000000-0000-4000-8000-000000000001', slug: 'operations', name: 'Operations', scope: 'shared', variables: [], widgets: [], settings: { defaultTimeRange: '1h' } } } }) : fulfill(route, { items: [initial] })); await page.goto('/dashboards'); await expect(page.getByText('Alleen bekijken')).toBeVisible(); await expect(page.getByRole('button', { name: 'Nieuw dashboard' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Beheren' })).toHaveCount(0); await page.locator('.dashboard-manager-open').click(); await expect(page.getByRole('heading', { level: 1, name: 'Operations' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Bewerken' })).toHaveCount(0); });