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(''); } 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(''); } 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; 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((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[] }; 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([]); });