Public source validation / validate (push) Failing after 3m8s
281 lines
13 KiB
JavaScript
281 lines
13 KiB
JavaScript
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(() => {});
|
|
}
|