| 1 | // Run: tsx src/__tests__/ui-perf.test.ts |
| 2 | // |
| 3 | // UI latency telemetry: percentiles, bounded (signal, bucket) mapping, the |
| 4 | // turn collector's frame/dispatch sampling, and the tracker's turn lifecycle. |
| 5 | // Everything reported must stay content-free. |
| 6 | |
| 7 | import { createUIPerfTracker, percentile, UIPerfTurnCollector, uiPerfSignals } from "../lib/uiPerf"; |
| 8 | |
| 9 | let passed = 0; |
| 10 | let failed = 0; |
| 11 | |
| 12 | function eq(a: unknown, b: unknown, label: string) { |
| 13 | if (a === b) { |
| 14 | process.stdout.write(` PASS ${label}\n`); |
| 15 | passed += 1; |
| 16 | } else { |
| 17 | process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`); |
| 18 | failed += 1; |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | function fakeEnv() { |
| 23 | let now = 0; |
| 24 | const frames: Array<(ts: number) => void> = []; |
| 25 | return { |
| 26 | env: { |
| 27 | now: () => now, |
| 28 | raf: (cb: (ts: number) => void) => { |
| 29 | frames.push(cb); |
| 30 | return frames.length; |
| 31 | }, |
| 32 | caf: () => {}, |
| 33 | observe: () => undefined, |
| 34 | domNodes: () => 1_234, |
| 35 | jsHeapMB: () => 100, |
| 36 | }, |
| 37 | advance(ms: number) { |
| 38 | now += ms; |
| 39 | }, |
| 40 | frame() { |
| 41 | frames.shift()?.(now); |
| 42 | }, |
| 43 | }; |
| 44 | } |
| 45 | |
| 46 | // --- percentile --- |
| 47 | { |
| 48 | eq(percentile([], 95), undefined, "empty sample set has no percentile"); |
| 49 | eq(percentile([7], 95), 7, "single sample is its own percentile"); |
| 50 | const values = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; |
| 51 | eq(percentile(values, 50), 50, "p50 of 10 evenly spread samples"); |
| 52 | eq(percentile(values, 95), 100, "p95 of 10 samples takes the top sample"); |
| 53 | } |
| 54 | |
| 55 | // --- signal mapping: bounded buckets, absent data omitted --- |
| 56 | { |
| 57 | const signals = uiPerfSignals({ |
| 58 | turnMs: 10_000, |
| 59 | bridgeEvents: 500, // 50/s |
| 60 | stateCommits: 250, // 25/s |
| 61 | streamPaintP95Ms: 30, |
| 62 | frameP95Ms: 12, |
| 63 | slowFramePct: 0.4, |
| 64 | inputLatencyP95Ms: 120, |
| 65 | markdownRenderP95Ms: 9, |
| 66 | longTasks: 0, |
| 67 | domNodes: 12_000, |
| 68 | jsHeapMB: 250, |
| 69 | }); |
| 70 | eq(signals.ui_bridge_events_rate, "lt60", "bridge event rate buckets by per-second rate"); |
| 71 | eq(signals.ui_state_commit_rate, "lt30", "state commit rate buckets by per-second rate"); |
| 72 | eq(signals.ui_stream_paint_p95, "lt50", "stream→paint p95 buckets in ms"); |
| 73 | eq(signals.ui_frame_p95, "lt17", "frame p95 under one display frame"); |
| 74 | eq(signals.ui_slow_frames, "lt1", "slow frame percentage buckets"); |
| 75 | eq(signals.ui_input_latency_p95, "ge100", "input latency over budget lands in the top bucket"); |
| 76 | eq(signals.ui_markdown_p95, "lt10", "markdown render p95 within budget"); |
| 77 | eq(signals.ui_long_tasks, "zero", "no long tasks reports zero"); |
| 78 | eq(signals.ui_dom_nodes, "lt20k", "DOM size buckets"); |
| 79 | eq(signals.ui_js_heap, "lt500", "JS heap buckets"); |
| 80 | |
| 81 | const sparse = uiPerfSignals({ turnMs: 500, bridgeEvents: 3, stateCommits: 2, longTasks: 1 }); |
| 82 | eq(sparse.ui_bridge_events_rate, undefined, "sub-second turns report no rates"); |
| 83 | eq(sparse.ui_stream_paint_p95, undefined, "missing samples are omitted, not reported as zero"); |
| 84 | eq(sparse.ui_long_tasks, "lt3", "long task count still buckets"); |
| 85 | } |
| 86 | |
| 87 | // --- collector: frames, slow frames, dispatch→frame latency --- |
| 88 | { |
| 89 | const f = fakeEnv(); |
| 90 | const c = new UIPerfTurnCollector(f.env); |
| 91 | c.noteBridgeEvent(); |
| 92 | c.noteBridgeEvent(); |
| 93 | c.noteStateCommit(); |
| 94 | |
| 95 | f.advance(100); |
| 96 | f.frame(); // first frame: baseline only |
| 97 | c.noteStreamDispatch(); |
| 98 | f.advance(40); |
| 99 | f.frame(); // 40ms frame → slow, and dispatch→frame sample of 40ms |
| 100 | f.advance(10); |
| 101 | f.frame(); // 10ms frame |
| 102 | f.advance(1_900); |
| 103 | |
| 104 | const summary = c.finish(); |
| 105 | eq(summary.bridgeEvents, 2, "bridge events counted"); |
| 106 | eq(summary.stateCommits, 1, "state commits counted"); |
| 107 | eq(summary.turnMs, 2_050, "turn duration from injected clock"); |
| 108 | eq(summary.frameP95Ms, 40, "frame p95 from sampled frame durations"); |
| 109 | eq(summary.slowFramePct, 50, "one of two frames over 33ms"); |
| 110 | eq(summary.streamPaintP95Ms, 40, "dispatch→frame latency sampled on the next frame"); |
| 111 | eq(summary.domNodes, 1_234, "DOM size snapshot at finish"); |
| 112 | eq(summary.jsHeapMB, 100, "heap snapshot at finish"); |
| 113 | } |
| 114 | |
| 115 | // --- tracker: per-tab turn lifecycle drives collection and reporting --- |
| 116 | { |
| 117 | const reports: Array<Record<string, string>> = []; |
| 118 | const f = fakeEnv(); |
| 119 | const tracker = createUIPerfTracker( |
| 120 | (signals) => reports.push(signals), |
| 121 | () => new UIPerfTurnCollector(f.env), |
| 122 | ); |
| 123 | |
| 124 | tracker.onWireEvent("tab-b", "text"); // no turn: ignored |
| 125 | tracker.onWireEvent("tab-a", "turn_started"); |
| 126 | tracker.onWireEvent("tab-a", "text"); |
| 127 | tracker.onWireEvent("tab-a", "text"); |
| 128 | tracker.onStateCommit(); |
| 129 | tracker.onStreamDispatch(); |
| 130 | f.advance(2_000); |
| 131 | tracker.onWireEvent("tab-a", "turn_done"); |
| 132 | |
| 133 | eq(reports.length, 1, "turn_done reports exactly once"); |
| 134 | eq(reports[0].ui_bridge_events_rate, "lt30", "reported signals come from the turn's own counters"); |
| 135 | tracker.onStateCommit(); // after turn end: no active collector, no crash |
| 136 | eq(reports.length, 1, "no reporting outside a turn"); |
| 137 | } |
| 138 | |
| 139 | process.stdout.write(`\n${passed} passed, ${failed} failed\n`); |
| 140 | if (failed > 0) process.exit(1); |
| 141 |