This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
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(() => {});
|
||||
}
|
||||
Reference in New Issue
Block a user