import { appendFile, mkdir, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import path from 'node:path'; import { buildSoakSummary } from './wallboard-soak-analysis.mjs'; const requireFromWebWorkspace = createRequire(new URL('../apps/web/package.json', import.meta.url)); const { chromium } = requireFromWebWorkspace('@playwright/test'); const baseURL = process.env.PULSE_SOAK_BASE_URL; const outputDirectory = path.resolve(process.env.PULSE_SOAK_OUTPUT_DIR ?? 'artifacts/evidence/M10-14/raw'); const durationHours = Number(process.env.PULSE_SOAK_DURATION_HOURS ?? '24'); const sampleSeconds = Number(process.env.PULSE_SOAK_SAMPLE_SECONDS ?? '60'); const reconnectSeconds = Number(process.env.PULSE_SOAK_RECONNECT_SECONDS ?? '3600'); const dashboardIDs = ['51000000-0000-4000-8000-000000000001', '51000000-0000-4000-8000-000000000002']; if (!baseURL || !Number.isFinite(durationHours) || durationHours <= 0 || !Number.isFinite(sampleSeconds) || sampleSeconds < 5 || !Number.isFinite(reconnectSeconds) || reconnectSeconds < 30) { throw new Error('PULSE_SOAK_BASE_URL and positive bounded soak durations are required'); } await mkdir(outputDirectory, { recursive: true }); const samplesPath = path.join(outputDirectory, 'samples.ndjson'); const reconnectsPath = path.join(outputDirectory, 'reconnects.ndjson'); const eventsPath = path.join(outputDirectory, 'events.ndjson'); const metadataPath = path.join(outputDirectory, 'metadata.json'); const summaryPath = path.join(outputDirectory, 'result.json'); const startedAt = new Date(); const durationMs = durationHours * 60 * 60 * 1000; const endAt = startedAt.getTime() + durationMs; let eventWrite = Promise.resolve(); const writeEvent = (type, details = {}) => { eventWrite = eventWrite.then(() => appendFile(eventsPath, `${JSON.stringify({ timestamp: new Date().toISOString(), type, ...details })}\n`)); return eventWrite; }; function dashboardDocument(id, index) { const widgetID = `52000000-0000-4000-8000-00000000000${index}`; return { schemaVersion: 2, id, slug: `wallboard-soak-${index}`, name: `Wallboard soak ${index}`, description: 'Echte 24-uursmeting met begrensde live CPU-data.', // Mock login intentionally does not provision a durable user row. A // system-scoped soak dashboard stays visible to the same read principal // without weakening the production ownership query. scope: 'system', variables: [], widgets: [{ id: widgetID, type: 'timeseries', title: 'CPU live', description: 'Prometheus-query en WebSocket live-buffer.', data: { sourceType: 'semantic-metric', metric: 'host.cpu.utilization', scope: { serverId: 'smoke-host' }, aggregation: 'avg', groupBy: ['instance'], 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: 10 }, }; } const browser = await chromium.launch({ headless: true, args: ['--enable-precise-memory-info', '--disable-background-timer-throttling', '--disable-renderer-backgrounding'] }); const context = await browser.newContext({ baseURL, viewport: { width: 1920, height: 1080 }, locale: 'nl-BE' }); let page; const samples = []; const reconnects = []; let activeSockets = 0; let maxActiveSockets = 0; let openedSockets = 0; let closedSockets = 0; let websocketErrors = 0; let pageErrors = 0; let apiFailures = 0; try { const login = await context.request.get('/auth/test-login'); if (!login.ok()) throw new Error(`mock login failed with ${login.status()}`); for (let index = 0; index < dashboardIDs.length; index += 1) { const response = await context.request.post('/api/v1/dashboards', { data: dashboardDocument(dashboardIDs[index], index + 1) }); if (!response.ok() && response.status() !== 409) throw new Error(`dashboard seed failed with ${response.status()}: ${await response.text()}`); } await context.addInitScript((ids) => { for (const id of ids) window.localStorage.setItem(`pulse.dashboard.view.${id}.range`, 'live'); window.__pulseSoak = { longTasks: [], sockets: new Set(), startedAt: Date.now() }; const NativeWebSocket = window.WebSocket; window.WebSocket = class SoakObservableWebSocket extends NativeWebSocket { constructor(...args) { super(...args); window.__pulseSoak.sockets.add(this); this.addEventListener('close', () => window.__pulseSoak.sockets.delete(this), { once: true }); } }; if ('PerformanceObserver' in window) { const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) window.__pulseSoak.longTasks.push({ at: Date.now(), duration: entry.duration }); }); try { observer.observe({ type: 'longtask', buffered: true }); } catch { /* unsupported browsers remain measurable through frame deltas */ } } }, dashboardIDs); page = await context.newPage(); page.on('pageerror', (error) => { pageErrors += 1; void writeEvent('page-error', { message: error.message.slice(0, 300) }); }); page.on('response', (response) => { if (new URL(response.url()).pathname.startsWith('/api/') && response.status() >= 400) { apiFailures += 1; void writeEvent('api-failure', { status: response.status(), path: new URL(response.url()).pathname }); } }); page.on('websocket', (socket) => { openedSockets += 1; activeSockets += 1; maxActiveSockets = Math.max(maxActiveSockets, activeSockets); void writeEvent('websocket-open', { url: new URL(socket.url()).pathname, activeSockets }); socket.on('close', () => { activeSockets = Math.max(0, activeSockets - 1); closedSockets += 1; void writeEvent('websocket-close', { activeSockets }); }); socket.on('socketerror', (error) => { websocketErrors += 1; void writeEvent('websocket-error', { message: String(error).slice(0, 300) }); }); }); const cdp = await context.newCDPSession(page); await cdp.send('Performance.enable'); await cdp.send('HeapProfiler.enable'); await page.goto('/wallboard?interval=10&refresh=10', { waitUntil: 'networkidle' }); await page.getByRole('heading', { level: 1, name: 'Operationeel wallboard' }).waitFor({ state: 'visible', timeout: 30_000 }); await page.getByText(/Wallboard soak [12]/).first().waitFor({ state: 'visible', timeout: 30_000 }); await writeFile(metadataPath, `${JSON.stringify({ startedAt: startedAt.toISOString(), plannedEndAt: new Date(endAt).toISOString(), durationHours, sampleSeconds, reconnectSeconds, viewport: { width: 1920, height: 1080 }, browser: await browser.version(), baseURL: new URL(baseURL).origin, dashboards: dashboardIDs.length, }, null, 2)}\n`); await writeEvent('soak-started'); let nextSampleAt = Date.now(); let nextReconnectAt = startedAt.getTime() + reconnectSeconds * 1000; let nextGCAt = startedAt.getTime(); let sampleNumber = 0; while (Date.now() < endAt) { const now = Date.now(); if (now >= nextReconnectAt) { const outageMs = 500; const reconnectStarted = Date.now(); const openedBefore = openedSockets; await context.setOffline(true); await page.evaluate(() => { for (const socket of window.__pulseSoak.sockets) socket.close(4000, 'scheduled soak disconnect'); }); await page.waitForTimeout(outageMs); await context.setOffline(false); let recovered = false; const deadline = Date.now() + 10_000; while (Date.now() < deadline) { if (openedSockets > openedBefore && activeSockets > 0) { recovered = true; break; } await page.waitForTimeout(250); } const reconnect = { timestamp: new Date().toISOString(), elapsedMs: Date.now() - startedAt.getTime(), outageMs, durationMs: Date.now() - reconnectStarted - outageMs, recovered, openedBefore, openedAfter: openedSockets, activeSockets }; reconnects.push(reconnect); await appendFile(reconnectsPath, `${JSON.stringify(reconnect)}\n`); nextReconnectAt += reconnectSeconds * 1000; } if (now >= nextSampleAt) { sampleNumber += 1; const elapsedMs = now - startedAt.getTime(); const forcedGC = now >= nextGCAt; if (forcedGC) { await cdp.send('HeapProfiler.collectGarbage'); nextGCAt += 3_600_000; } const [{ metrics }, heap, frame] = await Promise.all([ cdp.send('Performance.getMetrics'), cdp.send('Runtime.getHeapUsage'), page.evaluate(async () => { const deltas = []; const started = performance.now(); let previous = started; await new Promise((resolve) => { const tick = (timestamp) => { deltas.push(timestamp - previous); previous = timestamp; if (timestamp - started >= 2_000) resolve(); else requestAnimationFrame(tick); }; requestAnimationFrame(tick); }); const longTasks = window.__pulseSoak?.longTasks?.splice(0) ?? []; return { frameCount: deltas.length, frameDurationMs: performance.now() - started, frameP95Ms: deltas.sort((a, b) => a - b)[Math.min(deltas.length - 1, Math.floor(deltas.length * 0.95))] ?? 0, framesOver50Ms: deltas.filter((value) => value > 50).length, longTaskCount: longTasks.length, longestTaskMs: longTasks.reduce((max, task) => Math.max(max, task.duration), 0), domNodes: document.getElementsByTagName('*').length, bodyWidth: document.body.scrollWidth, viewportWidth: window.innerWidth, bodyHeight: document.documentElement.scrollHeight, viewportHeight: window.innerHeight, wallboardTitle: document.querySelector('#wallboard-title')?.textContent ?? '', activeDashboard: document.querySelector('#dashboard-view-title')?.textContent ?? '', }; }), ]); const metricMap = Object.fromEntries(metrics.map((metric) => [metric.name, metric.value])); const sample = { sequence: sampleNumber, timestamp: new Date().toISOString(), elapsedMs, forcedGC, usedHeapBytes: heap.usedSize, totalHeapBytes: heap.totalSize, jsHeapUsedBytes: metricMap.JSHeapUsedSize ?? null, jsHeapTotalBytes: metricMap.JSHeapTotalSize ?? null, nodes: metricMap.Nodes ?? frame.domNodes, documents: metricMap.Documents ?? null, listeners: metricMap.JSEventListeners ?? null, layoutCount: metricMap.LayoutCount ?? null, recalcStyleCount: metricMap.RecalcStyleCount ?? null, taskDurationSeconds: metricMap.TaskDuration ?? null, fps: frame.frameDurationMs > 0 ? (frame.frameCount * 1000) / frame.frameDurationMs : 0, frameP95Ms: frame.frameP95Ms, framesOver50Ms: frame.framesOver50Ms, longTaskCount: frame.longTaskCount, longestTaskMs: frame.longestTaskMs, bodyWidth: frame.bodyWidth, viewportWidth: frame.viewportWidth, bodyHeight: frame.bodyHeight, viewportHeight: frame.viewportHeight, activeSockets, openedSockets, closedSockets, pageErrors, apiFailures, wallboardTitle: frame.wallboardTitle, activeDashboard: frame.activeDashboard, }; samples.push(sample); await appendFile(samplesPath, `${JSON.stringify(sample)}\n`); nextSampleAt += sampleSeconds * 1000; } await page.waitForTimeout(Math.max(100, Math.min(1_000, Math.min(nextSampleAt, nextReconnectAt, endAt) - Date.now()))); } const summary = buildSoakSummary({ samples, reconnects, startedAt, completedAt: new Date(), durationHours, sampleSeconds, counters: { maxActiveSockets, openedSockets, closedSockets, websocketErrors, pageErrors, apiFailures }, requireWallboardTitle: true, }); await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`); await writeEvent('soak-completed', { status: summary.status }); if (summary.status !== 'pass') throw new Error(`wallboard soak failed: ${summary.failures.join('; ')}`); } catch (error) { await writeEvent('soak-failed', { message: error instanceof Error ? error.message : String(error) }); throw error; } finally { await page?.close().catch(() => {}); await context.close().catch(() => {}); await browser.close().catch(() => {}); }