返回 DeepSeek-Reasonix
ui-perf.test.ts
根目录 / desktop / frontend / src / __tests__ / ui-perf.test.ts
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
141 lines TYPESCRIPT