Files
DarkflameServer/tests/dWebTests/perf-charts.test.mjs
Aaron Kimbrell 992abf9d8a feat(dashboard): Performance page with frame times, slow frames and flame graphs
The dashboard's own loop is framed too, with a scope per module update. Frame time and stacked phase charts per server, the servers' loop summary, longest frames, packet handling times, the last 50 slow frames with a nested timeline, and profiling sessions (profiling_run, GM 8) drawn as a flame graph with folded stacks to download. PerfHistory keeps it in memory and is unit tested; the layouts are tested with node. Task 96.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:26:17 -05:00

59 lines
2.7 KiB
JavaScript

// The Performance page's layouts (static/js/perf-charts.js): flame graph, slow frame timeline, stacked phases.
// Run by ctest: node perf-charts.test.mjs <perf-charts.js>
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const [scriptPath] = process.argv.slice(2);
const context = { window: {} };
vm.createContext(context);
vm.runInContext(readFileSync(scriptPath, 'utf8'), context);
const C = context.window.PerfCharts;
let failures = 0;
const same = (actual, expected, what) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
failures++;
console.error(`${what}: ${JSON.stringify(actual)} is not ${JSON.stringify(expected)}`);
}
};
// A session's tree as PerfHistory::ProfileJson sends it
const nodes = [
{ label: 'All frames', depth: 0, total_us: 1000 },
{ label: 'Entities', depth: 1, total_us: 600 },
{ label: 'Script timer', depth: 2, total_us: 200 },
{ label: 'Physics step', depth: 1, total_us: 300 },
{ label: 'Tiny', depth: 1, total_us: 0.5 },
{ label: 'Under tiny', depth: 2, total_us: 0.5 },
];
same(Array.from(C.parents(nodes)), [-1, 0, 1, 0, 0, 4], 'parents');
const whole = C.flameLayout(nodes, 0, 0.001).map((r) => [r.index, +r.x.toFixed(3), +r.w.toFixed(3), r.row]);
same(whole, [[0, 0, 1, 0], [1, 0, 0.6, 1], [2, 0, 0.2, 2], [3, 0.6, 0.3, 1]], 'flame graph of the whole tree, too narrow ones left out');
// Zoomed into Entities: its ancestors span the width, it and its children are scaled to it
const zoomed = C.flameLayout(nodes, 1, 0).map((r) => [r.index, +r.x.toFixed(3), +r.w.toFixed(3), r.row, !!r.ancestor]);
same(zoomed, [[0, 0, 1, 0, true], [1, 0, 1, 1, false], [2, 0, 0.333, 2, false]], 'flame graph zoomed in');
// A slow frame: bars from their first start
const scopes = [
{ label: 'Frame', depth: 0, start_ms: 0, total_ms: 100 },
{ label: 'LoadPlayer', depth: 1, start_ms: 10, total_ms: 80 },
{ label: 'Late', depth: 1, start_ms: 95, total_ms: 20 },
];
same(C.timelineLayout(scopes, 100).map((r) => [r.index, +r.x.toFixed(2), +r.w.toFixed(2), r.row]), [[0, 0, 1, 0], [1, 0.1, 0.8, 1], [2, 0.95, 0.05, 1]], 'timeline');
// Phases: the ones with time in their fixed colours, scripts and log flushes folded into other
const series = C.phaseSeries({ packets: [1, 2], entities: [0, 0], physics: [3, null], scripts: [1, null], log_flush: [0.5, null], other: [1, null], cdclient: [0, 9] });
same(series.map((s) => [s.key, s.color, Array.from(s.values)]), [
['packets', 'var(--tr-1)', [1, 2]],
['physics', 'var(--tr-3)', [3, null]],
['cdclient', 'var(--tr-6)', [0, 9]],
['other', 'var(--tr-other)', [2.5, null]],
], 'phase series');
if (failures) {
console.error(`${failures} failure(s)`);
process.exit(1);
}
console.log('perf-charts: all passed');