1import {
2 type DesktopElectronProcessMetric,
3 type DesktopHostTelemetrySnapshot,
4 type ResourceMonitorProcessSample,
5 type ResourceMonitorSnapshotEvent,
6} from "@t3tools/contracts";
7import { describe, expect, it } from "@effect/vitest";
8import * as DateTime from "effect/DateTime";
9import * as Option from "effect/Option";
10
11import { emptyTelemetryCounters, mergeProcesses, type MergeProcessesResult } from "./Model.ts";
12
13const SERVER_PID = 100;
14const BASE_TIME_MS = DateTime.toEpochMillis(DateTime.makeUnsafe("2026-06-17T12:00:00.000Z"));
15
16function processSample(
17 input: Partial<ResourceMonitorProcessSample> &
18 Pick<ResourceMonitorProcessSample, "pid" | "ppid" | "startTimeMs">,
19): ResourceMonitorProcessSample {
20 return {
21 runTimeMs: 1_000,
22 name: `process-${input.pid}`,
23 command: `process-${input.pid}`,
24 status: "Running",
25 cpuPercent: 0,
26 cpuTimeMs: 0,
27 residentBytes: 1_024,
28 virtualBytes: 2_048,
29 ioReadBytes: 0,
30 ioWriteBytes: 0,
31 ioSemantics: "storage",
32 ...input,
33 };
34}
35
36function nativeSnapshot(
37 sampledAtUnixMs: number,
38 processes: ReadonlyArray<ResourceMonitorProcessSample>,
39 sequence = 1,
40): ResourceMonitorSnapshotEvent {
41 return {
42 version: 3,
43 type: "snapshot",
44 sequence,
45 sampledAtUnixMs,
46 collectionDurationMicros: 250,
47 scannedProcessCount: processes.length,
48 retainedProcessCount: processes.length,
49 inaccessibleProcessCount: 0,
50 processes: [...processes],
51 };
52}
53
54function electronMetric(
55 input: Partial<DesktopElectronProcessMetric> &
56 Pick<DesktopElectronProcessMetric, "pid" | "creationTimeMs" | "type">,
57): DesktopElectronProcessMetric {
58 return {
59 cpuPercent: 0,
60 idleWakeupsPerSecond: 0,
61 workingSetBytes: 1_024,
62 peakWorkingSetBytes: 2_048,
63 ...input,
64 };
65}
66
67function desktopSnapshot(
68 sampledAtUnixMs: number,
69 electronProcesses: ReadonlyArray<DesktopElectronProcessMetric>,
70): DesktopHostTelemetrySnapshot {
71 const sampledAt = DateTime.makeUnsafe(sampledAtUnixMs);
72 return {
73 version: 1,
74 type: "desktopTelemetry",
75 sequence: 1,
76 sampledAtUnixMs,
77 electronPid: electronProcesses[0]?.pid ?? 10_000,
78 power: {
79 source: "electron-main",
80 idle: "false",
81 idleSeconds: 0,
82 locked: "false",
83 suspended: false,
84 onBattery: "false",
85 lowPowerMode: "unknown",
86 thermalState: "nominal",
87 stale: false,
88 updatedAt: sampledAt,
89 },
90 speedLimitPercent: Option.none(),
91 electronProcesses: [...electronProcesses],
92 };
93}
94
95function merge(input: {
96 readonly native: ResourceMonitorSnapshotEvent;
97 readonly desktop?: DesktopHostTelemetrySnapshot;
98 readonly previous?: MergeProcessesResult;
99 readonly sidecarPid?: number;
100}): MergeProcessesResult {
101 return mergeProcesses({
102 serverPid: SERVER_PID,
103 sidecarPid: Option.fromUndefinedOr(input.sidecarPid),
104 fallbackSampledAtMs: input.native.sampledAtUnixMs,
105 nativeSnapshot: Option.some(input.native),
106 desktopSnapshot: Option.fromUndefinedOr(input.desktop),
107 previous: input.previous?.previous ?? new Map(),
108 counters: input.previous?.counters ?? emptyTelemetryCounters(),
109 updatePrevious: true,
110 });
111}
112
113describe("resource telemetry process model", () => {
114 it("builds complete descendant depths and isolates monitor overhead", () => {
115 const result = merge({
116 sidecarPid: 900,
117 native: nativeSnapshot(BASE_TIME_MS, [
118 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
119 processSample({ pid: 200, ppid: SERVER_PID, startTimeMs: 2_000 }),
120 processSample({ pid: 201, ppid: 200, startTimeMs: 3_000 }),
121 processSample({ pid: 202, ppid: 201, startTimeMs: 4_000 }),
122 processSample({ pid: 900, ppid: SERVER_PID, startTimeMs: 5_000 }),
123 ]),
124 });
125
126 expect(result.processes.map((process) => [process.identity.pid, process.depth])).toEqual([
127 [100, 0],
128 [200, 1],
129 [201, 2],
130 [202, 3],
131 [900, 1],
132 ]);
133 expect(result.processes.find((process) => process.identity.pid === 900)?.category).toBe(
134 "resource-monitor",
135 );
136 expect(result.groups.backend.processCount).toBe(4);
137 expect(result.groups.monitor.processCount).toBe(1);
138 expect(result.groups.monitor.processStarts).toBe(1);
139 expect(result.groups.allT3.processStarts).toBe(5);
140 });
141
142 it("deduplicates Electron metrics and classifies Electron descendants", () => {
143 const electronStart = 10_000;
144 const result = merge({
145 native: nativeSnapshot(BASE_TIME_MS, [
146 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
147 processSample({ pid: 300, ppid: 1, startTimeMs: electronStart }),
148 processSample({ pid: 301, ppid: 300, startTimeMs: electronStart + 1 }),
149 ]),
150 desktop: desktopSnapshot(BASE_TIME_MS, [
151 electronMetric({
152 pid: 300,
153 creationTimeMs: electronStart + 500,
154 type: "Browser",
155 name: "electron",
156 }),
157 electronMetric({
158 pid: 301,
159 creationTimeMs: electronStart + 500,
160 type: "Utility",
161 name: "network-service",
162 }),
163 ]),
164 });
165
166 expect(result.processes.filter((process) => process.identity.pid === 300)).toHaveLength(1);
167 expect(result.processes.find((process) => process.identity.pid === 300)?.category).toBe(
168 "electron-main",
169 );
170 expect(result.processes.find((process) => process.identity.pid === 301)?.category).toBe(
171 "electron-utility",
172 );
173 expect(result.processes.find((process) => process.identity.pid === 301)?.depth).toBe(1);
174 expect(result.groups.electron.processCount).toBe(2);
175 });
176
177 it("ignores stale Electron metrics after PID reuse", () => {
178 const result = merge({
179 native: nativeSnapshot(BASE_TIME_MS, [
180 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
181 processSample({ pid: 300, ppid: SERVER_PID, startTimeMs: 50_000 }),
182 ]),
183 desktop: desktopSnapshot(BASE_TIME_MS, [
184 electronMetric({
185 pid: 300,
186 creationTimeMs: 10_000,
187 type: "Browser",
188 }),
189 ]),
190 });
191
192 expect(result.processes.find((process) => process.identity.pid === 300)?.category).toBe(
193 "server-child",
194 );
195 expect(result.groups.electron.processCount).toBe(0);
196 });
197
198 it("derives cumulative CPU time for synthetic Electron-only processes", () => {
199 const first = merge({
200 native: nativeSnapshot(BASE_TIME_MS, [
201 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
202 ]),
203 desktop: desktopSnapshot(BASE_TIME_MS, [
204 electronMetric({
205 pid: 300,
206 creationTimeMs: 10_000,
207 type: "Browser",
208 cpuPercent: 50,
209 }),
210 ]),
211 });
212 const second = merge({
213 previous: first,
214 native: nativeSnapshot(
215 BASE_TIME_MS + 1_000,
216 [processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 })],
217 2,
218 ),
219 desktop: desktopSnapshot(BASE_TIME_MS + 1_000, [
220 electronMetric({
221 pid: 300,
222 creationTimeMs: 10_000,
223 type: "Browser",
224 cpuPercent: 50,
225 }),
226 ]),
227 });
228
229 expect(second.processes.find((process) => process.identity.pid === 300)?.cpuTimeMs).toBe(500);
230 expect(second.groups.electron.cpuTimeMs).toBe(500);
231 });
232
233 it("uses the native timestamp for native cumulative-counter deltas", () => {
234 const first = merge({
235 native: nativeSnapshot(BASE_TIME_MS, [
236 processSample({
237 pid: SERVER_PID,
238 ppid: 1,
239 startTimeMs: 1_000,
240 cpuTimeMs: 1_000,
241 }),
242 ]),
243 desktop: desktopSnapshot(BASE_TIME_MS + 10_000, []),
244 });
245 const second = merge({
246 previous: first,
247 native: nativeSnapshot(
248 BASE_TIME_MS + 1_000,
249 [
250 processSample({
251 pid: SERVER_PID,
252 ppid: 1,
253 startTimeMs: 1_000,
254 cpuTimeMs: 1_500,
255 }),
256 ],
257 2,
258 ),
259 desktop: desktopSnapshot(BASE_TIME_MS + 11_000, []),
260 });
261
262 expect(second.processes[0]?.cpuPercent).toBe(50);
263 expect(second.groups.backend.cpuTimeMs).toBe(500);
264 });
265
266 it("does not advance synthetic CPU time when reusing the same desktop sample", () => {
267 const metric = electronMetric({
268 pid: 300,
269 creationTimeMs: 10_000,
270 type: "Browser",
271 cpuPercent: 50,
272 });
273 const first = merge({
274 native: nativeSnapshot(BASE_TIME_MS, [
275 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
276 ]),
277 desktop: desktopSnapshot(BASE_TIME_MS, [metric]),
278 });
279 const second = merge({
280 previous: first,
281 native: nativeSnapshot(
282 BASE_TIME_MS + 1_000,
283 [processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 })],
284 2,
285 ),
286 desktop: desktopSnapshot(BASE_TIME_MS, [metric]),
287 });
288
289 expect(second.processes.find((process) => process.identity.pid === 300)?.cpuTimeMs).toBe(0);
290 expect(second.groups.electron.cpuTimeMs).toBe(0);
291 });
292
293 it("does not apply an explicit Electron root to a reused PID", () => {
294 const first = mergeProcesses({
295 serverPid: SERVER_PID,
296 sidecarPid: Option.none(),
297 electronRootPids: new Set([300]),
298 fallbackSampledAtMs: BASE_TIME_MS,
299 nativeSnapshot: Option.some(
300 nativeSnapshot(BASE_TIME_MS, [
301 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
302 processSample({ pid: 300, ppid: 1, startTimeMs: 10_000 }),
303 ]),
304 ),
305 desktopSnapshot: Option.some(
306 desktopSnapshot(BASE_TIME_MS, [
307 electronMetric({
308 pid: 300,
309 creationTimeMs: 10_000,
310 type: "Browser",
311 }),
312 ]),
313 ),
314 previous: new Map(),
315 counters: emptyTelemetryCounters(),
316 updatePrevious: true,
317 });
318 const reused = mergeProcesses({
319 serverPid: SERVER_PID,
320 sidecarPid: Option.none(),
321 electronRootPids: new Set([300]),
322 fallbackSampledAtMs: BASE_TIME_MS + 1_000,
323 nativeSnapshot: Option.some(
324 nativeSnapshot(
325 BASE_TIME_MS + 1_000,
326 [
327 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
328 processSample({ pid: 300, ppid: SERVER_PID, startTimeMs: 20_000 }),
329 ],
330 2,
331 ),
332 ),
333 desktopSnapshot: Option.none(),
334 previous: first.previous,
335 counters: first.counters,
336 updatePrevious: true,
337 });
338
339 expect(reused.processes.find((process) => process.identity.pid === 300)?.category).toBe(
340 "server-child",
341 );
342 expect(reused.groups.electron.processCount).toBe(0);
343 });
344
345 it("derives rates from cumulative counters and preserves I/O semantics", () => {
346 const first = merge({
347 native: nativeSnapshot(BASE_TIME_MS, [
348 processSample({
349 pid: SERVER_PID,
350 ppid: 1,
351 startTimeMs: 1_000,
352 cpuTimeMs: 1_000,
353 ioReadBytes: 10_000,
354 ioWriteBytes: 20_000,
355 ioSemantics: "all-io",
356 }),
357 ]),
358 });
359 const second = merge({
360 previous: first,
361 native: nativeSnapshot(
362 BASE_TIME_MS + 1_000,
363 [
364 processSample({
365 pid: SERVER_PID,
366 ppid: 1,
367 startTimeMs: 1_000,
368 cpuTimeMs: 1_250,
369 ioReadBytes: 12_000,
370 ioWriteBytes: 23_000,
371 ioSemantics: "all-io",
372 }),
373 ],
374 2,
375 ),
376 });
377 const server = second.processes[0]!;
378
379 expect(server.cpuPercent).toBe(25);
380 expect(server.ioReadBytesPerSecond).toBe(2_000);
381 expect(server.ioWriteBytesPerSecond).toBe(3_000);
382 expect(server.ioSemantics).toBe("all-io");
383 expect(second.groups.backend.cpuTimeMs).toBe(250);
384 expect(second.groups.backend.ioReadBytes).toBe(2_000);
385 expect(second.groups.backend.ioWriteBytes).toBe(3_000);
386 });
387
388 it("derives deltas at the constrained 15-second sampling cadence", () => {
389 const first = merge({
390 native: nativeSnapshot(BASE_TIME_MS, [
391 processSample({
392 pid: SERVER_PID,
393 ppid: 1,
394 startTimeMs: 1_000,
395 cpuTimeMs: 1_000,
396 ioReadBytes: 10_000,
397 ioWriteBytes: 20_000,
398 }),
399 ]),
400 });
401 const second = merge({
402 previous: first,
403 native: nativeSnapshot(
404 BASE_TIME_MS + 15_000,
405 [
406 processSample({
407 pid: SERVER_PID,
408 ppid: 1,
409 startTimeMs: 1_000,
410 cpuTimeMs: 2_500,
411 ioReadBytes: 25_000,
412 ioWriteBytes: 50_000,
413 }),
414 ],
415 2,
416 ),
417 });
418
419 expect(second.processes[0]?.cpuPercent).toBe(10);
420 expect(second.processes[0]?.ioReadBytesPerSecond).toBe(1_000);
421 expect(second.processes[0]?.ioWriteBytesPerSecond).toBe(2_000);
422 expect(second.groups.backend.cpuTimeMs).toBe(1_500);
423 expect(second.groups.backend.ioReadBytes).toBe(15_000);
424 expect(second.groups.backend.ioWriteBytes).toBe(30_000);
425 });
426
427 it("preserves native rates while applying a desktop-only update", () => {
428 const first = merge({
429 native: nativeSnapshot(BASE_TIME_MS, [
430 processSample({
431 pid: SERVER_PID,
432 ppid: 1,
433 startTimeMs: 1_000,
434 cpuTimeMs: 1_000,
435 ioReadBytes: 10_000,
436 ioWriteBytes: 20_000,
437 }),
438 ]),
439 });
440 const second = merge({
441 previous: first,
442 native: nativeSnapshot(
443 BASE_TIME_MS + 1_000,
444 [
445 processSample({
446 pid: SERVER_PID,
447 ppid: 1,
448 startTimeMs: 1_000,
449 cpuTimeMs: 1_250,
450 ioReadBytes: 12_000,
451 ioWriteBytes: 23_000,
452 }),
453 ],
454 2,
455 ),
456 });
457 const desktopOnly = mergeProcesses({
458 serverPid: SERVER_PID,
459 sidecarPid: Option.none(),
460 fallbackSampledAtMs: BASE_TIME_MS + 1_000,
461 nativeSnapshot: Option.some(
462 nativeSnapshot(
463 BASE_TIME_MS + 1_000,
464 [
465 processSample({
466 pid: SERVER_PID,
467 ppid: 1,
468 startTimeMs: 1_000,
469 cpuTimeMs: 1_250,
470 ioReadBytes: 12_000,
471 ioWriteBytes: 23_000,
472 }),
473 ],
474 2,
475 ),
476 ),
477 desktopSnapshot: Option.some(desktopSnapshot(BASE_TIME_MS + 1_500, [])),
478 previous: second.previous,
479 counters: second.counters,
480 updatePrevious: false,
481 });
482
483 expect(desktopOnly.processes[0]?.cpuPercent).toBe(25);
484 expect(desktopOnly.processes[0]?.ioReadBytesPerSecond).toBe(2_000);
485 expect(desktopOnly.processes[0]?.ioWriteBytesPerSecond).toBe(3_000);
486 expect(desktopOnly.sampledAtMs).toBe(BASE_TIME_MS + 1_500);
487 });
488
489 it("resets deltas when counters decrease or the sampling gap is unsafe", () => {
490 const first = merge({
491 native: nativeSnapshot(BASE_TIME_MS, [
492 processSample({
493 pid: SERVER_PID,
494 ppid: 1,
495 startTimeMs: 1_000,
496 cpuTimeMs: 1_000,
497 ioReadBytes: 10_000,
498 ioWriteBytes: 20_000,
499 }),
500 ]),
501 });
502 const decreased = merge({
503 previous: first,
504 native: nativeSnapshot(
505 BASE_TIME_MS + 1_000,
506 [
507 processSample({
508 pid: SERVER_PID,
509 ppid: 1,
510 startTimeMs: 1_000,
511 cpuTimeMs: 100,
512 ioReadBytes: 100,
513 ioWriteBytes: 200,
514 }),
515 ],
516 2,
517 ),
518 });
519 const delayed = merge({
520 previous: decreased,
521 native: nativeSnapshot(
522 BASE_TIME_MS + 90_000,
523 [
524 processSample({
525 pid: SERVER_PID,
526 ppid: 1,
527 startTimeMs: 1_000,
528 cpuTimeMs: 10_000,
529 ioReadBytes: 100_000,
530 ioWriteBytes: 200_000,
531 }),
532 ],
533 3,
534 ),
535 });
536
537 expect(decreased.processes[0]?.cpuPercent).toBe(0);
538 expect(decreased.processes[0]?.ioReadBytesPerSecond).toBe(0);
539 expect(decreased.processes[0]?.ioWriteBytesPerSecond).toBe(0);
540 expect(delayed.processes[0]?.cpuPercent).toBe(0);
541 expect(delayed.processes[0]?.ioReadBytesPerSecond).toBe(0);
542 expect(delayed.processes[0]?.ioWriteBytesPerSecond).toBe(0);
543 expect(delayed.groups.backend.cpuTimeMs).toBe(0);
544 expect(delayed.groups.backend.ioReadBytes).toBe(0);
545 expect(delayed.groups.backend.ioWriteBytes).toBe(0);
546 });
547
548 it("treats reused PIDs as an exit plus a new process", () => {
549 const first = merge({
550 native: nativeSnapshot(BASE_TIME_MS, [
551 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
552 processSample({ pid: 200, ppid: SERVER_PID, startTimeMs: 2_000 }),
553 ]),
554 });
555 const second = merge({
556 previous: first,
557 native: nativeSnapshot(
558 BASE_TIME_MS + 1_000,
559 [
560 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
561 processSample({
562 pid: 200,
563 ppid: SERVER_PID,
564 startTimeMs: 9_000,
565 cpuTimeMs: 999,
566 ioReadBytes: 999,
567 ioWriteBytes: 999,
568 }),
569 ],
570 2,
571 ),
572 });
573 const reused = second.processes.find((process) => process.identity.pid === 200)!;
574
575 expect(reused.identity.startTimeMs).toBe(9_000);
576 expect(reused.cpuPercent).toBe(0);
577 expect(reused.ioReadBytesPerSecond).toBe(0);
578 expect(second.groups.backend.processStarts).toBe(3);
579 expect(second.groups.backend.processExits).toBe(1);
580 });
581});