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