export function percentile(values, fraction) { if (values.length === 0) return 0; const sorted = [...values].sort((a, b) => a - b); return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))]; } export function median(values) { return percentile(values, 0.5); } export function linearSlopePerHour(samples, field) { if (samples.length < 2) return 0; const xs = samples.map((sample) => sample.elapsedMs / 3_600_000); const ys = samples.map((sample) => sample[field]); const meanX = xs.reduce((sum, value) => sum + value, 0) / xs.length; const meanY = ys.reduce((sum, value) => sum + value, 0) / ys.length; const denominator = xs.reduce((sum, value) => sum + ((value - meanX) ** 2), 0); if (denominator === 0) return 0; return xs.reduce((sum, value, index) => sum + ((value - meanX) * (ys[index] - meanY)), 0) / denominator; } export function buildSoakSummary({ samples, reconnects, startedAt, completedAt, durationHours, sampleSeconds, counters, requireWallboardTitle = false }) { if (samples.length === 0) throw new Error('at least one wallboard sample is required'); const hourly = samples.filter((sample) => sample.forcedGC); if (hourly.length === 0) throw new Error('at least one forced-GC sample is required'); const quarterSize = Math.max(1, Math.ceil(hourly.length / 4)); const firstQuarter = hourly.slice(0, quarterSize).map((sample) => sample.usedHeapBytes); const lastQuarter = hourly.slice(-quarterSize).map((sample) => sample.usedHeapBytes); const retainedGrowthBytes = median(lastQuarter) - median(firstQuarter); const heapSlopeBytesPerHour = linearSlopePerHour(hourly, 'usedHeapBytes'); const maxAllowedGrowthBytes = Math.max(16 * 1024 * 1024, median(firstQuarter) * 0.20); const elapsedHours = (completedAt.getTime() - startedAt.getTime()) / 3_600_000; const expectedSampleCount = durationHours * 3_600 / sampleSeconds; const minimumSampleCount = Math.floor(Math.min(expectedSampleCount * 0.99, Math.max(1, expectedSampleCount - 2))); const sampleGapsSeconds = samples.slice(1).map((sample, index) => (sample.elapsedMs - samples[index].elapsedMs) / 1_000); const maxSampleGapSeconds = Math.max(0, ...sampleGapsSeconds); const maxAllowedSampleGapSeconds = sampleSeconds + 15; const summary = { status: 'pass', startedAt: startedAt.toISOString(), completedAt: completedAt.toISOString(), elapsedHours, sampleCount: samples.length, forcedGCSampleCount: hourly.length, expectedSampleCount, minimumSampleCount, maxSampleGapSeconds, maxAllowedSampleGapSeconds, reconnectCount: reconnects.length, reconnectFailures: reconnects.filter((item) => !item.recovered).length, reconnectP95Ms: percentile(reconnects.map((item) => item.durationMs), 0.95), retainedGrowthBytes, heapSlopeBytesPerHour, maxAllowedGrowthBytes, fpsP05: percentile(samples.map((sample) => sample.fps), 0.05), frameP95WorstMs: Math.max(...samples.map((sample) => sample.frameP95Ms)), longestTaskMs: Math.max(...samples.map((sample) => sample.longestTaskMs)), maxActiveSockets: counters.maxActiveSockets, openedSockets: counters.openedSockets, closedSockets: counters.closedSockets, websocketErrors: counters.websocketErrors ?? 0, socketChurnBudget: reconnects.length + 4, pageErrors: counters.pageErrors, apiFailures: counters.apiFailures, maxDOMNodes: Math.max(...samples.map((sample) => sample.nodes)), horizontalOverflowSamples: samples.filter((sample) => sample.bodyWidth > sample.viewportWidth).length, verticalOverflowSamples: samples.filter((sample) => sample.bodyHeight != null && sample.viewportHeight != null && sample.bodyHeight > sample.viewportHeight).length, invalidWallboardSamples: samples.filter((sample) => { if (sample.wallboardTitle == null || sample.activeDashboard == null) return requireWallboardTitle; return sample.wallboardTitle !== 'Operationeel wallboard' || !/^Wallboard soak [12]$/.test(sample.activeDashboard); }).length, }; const failures = []; if (summary.elapsedHours < durationHours) failures.push('elapsed duration shorter than requested'); if (summary.sampleCount < minimumSampleCount || maxSampleGapSeconds > maxAllowedSampleGapSeconds) failures.push('sample coverage or continuity fell below the uninterrupted-run budget'); if (retainedGrowthBytes > maxAllowedGrowthBytes || heapSlopeBytesPerHour > 1024 * 1024) failures.push('retained heap growth exceeded the bounded budget'); if (summary.reconnectFailures > 0 || summary.reconnectP95Ms > 10_000) failures.push('WebSocket reconnect exceeded 10 seconds'); if (summary.frameP95WorstMs > 50 || summary.fpsP05 < 30 || summary.longestTaskMs > 250) failures.push('visible-jank budget exceeded'); if (summary.maxActiveSockets > 2) failures.push('active WebSocket count was unbounded'); if (summary.openedSockets > summary.socketChurnBudget) failures.push('WebSocket lifecycle churn exceeded the bounded budget'); if (summary.websocketErrors > 0 || summary.pageErrors > 0 || summary.apiFailures > 0) failures.push('runtime browser, API or WebSocket failures occurred'); if (summary.horizontalOverflowSamples > 0 || summary.verticalOverflowSamples > 0) failures.push('wallboard overflowed the viewport'); if (summary.invalidWallboardSamples > 0) failures.push('wallboard content disappeared during the soak'); if (failures.length > 0) { summary.status = 'fail'; summary.failures = failures; } return summary; }