Files
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

199 lines
12 KiB
TypeScript

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