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