Skip to content
Source
Sign inSign up

Model.test.ts

604 lines18.2 KB

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 };
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 };
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 };
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 };
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 });
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("keeps backend descendants out of desktop totals when Electron launches the server", () => {
178 const samples = [
179 processSample({ pid: 300, ppid: 1, startTimeMs: 1_000, cpuPercent: 2 }),
180 processSample({ pid: SERVER_PID, ppid: 300, startTimeMs: 2_000, cpuPercent: 5 }),
181 processSample({ pid: 200, ppid: SERVER_PID, startTimeMs: 3_000, cpuPercent: 400 }),
182 processSample({ pid: 201, ppid: 200, startTimeMs: 4_000, cpuPercent: 100 }),
183 processSample({ pid: 301, ppid: 300, startTimeMs: 3_000, cpuPercent: 3 }),
184 processSample({ pid: 900, ppid: SERVER_PID, startTimeMs: 3_000, cpuPercent: 1 }),
185 ];
186 const desktop = desktopSnapshot(BASE_TIME_MS, [
187 electronMetric({ pid: 300, creationTimeMs: 1_000, type: "Browser" }),
188 ]);
189 const first = merge({
190 sidecarPid: 900,
191 native: nativeSnapshot(BASE_TIME_MS, samples),
192 desktop,
193 });
194 const result = merge({
195 sidecarPid: 900,
196 previous: first,
197 native: nativeSnapshot(
198 BASE_TIME_MS + 1_000,
199 samples.map((sample) => ({
200 ...sample,
201 cpuTimeMs: sample.cpuPercent * 10,
202 ioReadBytes: sample.cpuPercent * 100,
203 ioWriteBytes: sample.cpuPercent * 200,
204 })),
205 2,
206 ),
207 desktop,
208 });
209​
210 expect(result.processes.find((process) => process.identity.pid === 200)?.category).toBe(
211 "server-child",
212 );
213 expect(result.processes.find((process) => process.identity.pid === 201)?.category).toBe(
214 "server-child",
215 );
216 expect(result.groups.backend).toMatchObject({
217 processCount: 3,
218 currentCpuPercent: 505,
219 cpuTimeMs: 5_050,
220 currentRssBytes: 3_072,
221 ioReadBytes: 50_500,
222 ioWriteBytes: 101_000,
223 processStarts: 3,
224 });
225 expect(result.groups.electron).toMatchObject({
226 processCount: 2,
227 currentCpuPercent: 5,
228 cpuTimeMs: 50,
229 currentRssBytes: 2_048,
230 ioReadBytes: 500,
231 ioWriteBytes: 1_000,
232 processStarts: 2,
233 });
234 expect(result.groups.monitor.processCount).toBe(1);
235 expect(result.groups.allT3.currentCpuPercent).toBe(511);
236 expect(result.groups.allT3.currentRssBytes).toBe(6_144);
237 });
238​
239 it("ignores stale Electron metrics after PID reuse", () => {
240 const result = merge({
241 native: nativeSnapshot(BASE_TIME_MS, [
242 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
243 processSample({ pid: 300, ppid: SERVER_PID, startTimeMs: 50_000 }),
244 ]),
245 desktop: desktopSnapshot(BASE_TIME_MS, [
246 electronMetric({
247 pid: 300,
248 creationTimeMs: 10_000,
249 type: "Browser",
250 }),
251 ]),
252 });
253​
254 expect(result.processes.find((process) => process.identity.pid === 300)?.category).toBe(
255 "server-child",
256 );
257 expect(result.groups.electron.processCount).toBe(0);
258 });
259​
260 it("derives cumulative CPU time for synthetic Electron-only processes", () => {
261 const first = merge({
262 native: nativeSnapshot(BASE_TIME_MS, [
263 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
264 ]),
265 desktop: desktopSnapshot(BASE_TIME_MS, [
266 electronMetric({
267 pid: 300,
268 creationTimeMs: 10_000,
269 type: "Browser",
270 cpuPercent: 50,
271 }),
272 ]),
273 });
274 const second = merge({
275 previous: first,
276 native: nativeSnapshot(
277 BASE_TIME_MS + 1_000,
278 [processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 })],
279 2,
280 ),
281 desktop: desktopSnapshot(BASE_TIME_MS + 1_000, [
282 electronMetric({
283 pid: 300,
284 creationTimeMs: 10_000,
285 type: "Browser",
286 cpuPercent: 50,
287 }),
288 ]),
289 });
290​
291 expect(second.processes.find((process) => process.identity.pid === 300)?.cpuTimeMs).toBe(500);
292 expect(second.groups.electron.cpuTimeMs).toBe(500);
293 });
294​
295 it("uses the native timestamp for native cumulative-counter deltas", () => {
296 const first = merge({
297 native: nativeSnapshot(BASE_TIME_MS, [
298 processSample({
299 pid: SERVER_PID,
300 ppid: 1,
301 startTimeMs: 1_000,
302 cpuTimeMs: 1_000,
303 }),
304 ]),
305 desktop: desktopSnapshot(BASE_TIME_MS + 10_000, []),
306 });
307 const second = merge({
308 previous: first,
309 native: nativeSnapshot(
310 BASE_TIME_MS + 1_000,
311 [
312 processSample({
313 pid: SERVER_PID,
314 ppid: 1,
315 startTimeMs: 1_000,
316 cpuTimeMs: 1_500,
317 }),
318 ],
319 2,
320 ),
321 desktop: desktopSnapshot(BASE_TIME_MS + 11_000, []),
322 });
323​
324 expect(second.processes[0]?.cpuPercent).toBe(50);
325 expect(second.groups.backend.cpuTimeMs).toBe(500);
326 });
327​
328 it("does not advance synthetic CPU time when reusing the same desktop sample", () => {
329 const metric = electronMetric({
330 pid: 300,
331 creationTimeMs: 10_000,
332 type: "Browser",
333 cpuPercent: 50,
334 });
335 const first = merge({
336 native: nativeSnapshot(BASE_TIME_MS, [
337 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
338 ]),
339 desktop: desktopSnapshot(BASE_TIME_MS, [metric]),
340 });
341 const second = merge({
342 previous: first,
343 native: nativeSnapshot(
344 BASE_TIME_MS + 1_000,
345 [processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 })],
346 2,
347 ),
348 desktop: desktopSnapshot(BASE_TIME_MS, [metric]),
349 });
350​
351 expect(second.processes.find((process) => process.identity.pid === 300)?.cpuTimeMs).toBe(0);
352 expect(second.groups.electron.cpuTimeMs).toBe(0);
353 });
354​
355 it("does not apply an explicit Electron root to a reused PID", () => {
356 const first = mergeProcesses({
357 serverPid: SERVER_PID,
358 sidecarPid: Option.none(),
359 electronRootPids: new Set([300]),
360 fallbackSampledAtMs: BASE_TIME_MS,
361 nativeSnapshot: Option.some(
362 nativeSnapshot(BASE_TIME_MS, [
363 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
364 processSample({ pid: 300, ppid: 1, startTimeMs: 10_000 }),
365 ]),
366 ),
367 desktopSnapshot: Option.some(
368 desktopSnapshot(BASE_TIME_MS, [
369 electronMetric({
370 pid: 300,
371 creationTimeMs: 10_000,
372 type: "Browser",
373 }),
374 ]),
375 ),
376 previous: new Map(),
377 counters: emptyTelemetryCounters(),
378 updatePrevious: true,
379 });
380 const reused = mergeProcesses({
381 serverPid: SERVER_PID,
382 sidecarPid: Option.none(),
383 electronRootPids: new Set([300]),
384 fallbackSampledAtMs: BASE_TIME_MS + 1_000,
385 nativeSnapshot: Option.some(
386 nativeSnapshot(
387 BASE_TIME_MS + 1_000,
388 [
389 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
390 processSample({ pid: 300, ppid: SERVER_PID, startTimeMs: 20_000 }),
391 ],
392 2,
393 ),
394 ),
395 desktopSnapshot: Option.none(),
396 previous: first.previous,
397 counters: first.counters,
398 updatePrevious: true,
399 });
400​
401 expect(reused.processes.find((process) => process.identity.pid === 300)?.category).toBe(
402 "server-child",
403 );
404 expect(reused.groups.electron.processCount).toBe(0);
405 });
406​
407 it("derives rates from cumulative counters and preserves I/O semantics", () => {
408 const first = merge({
409 native: nativeSnapshot(BASE_TIME_MS, [
410 processSample({
411 pid: SERVER_PID,
412 ppid: 1,
413 startTimeMs: 1_000,
414 cpuTimeMs: 1_000,
415 ioReadBytes: 10_000,
416 ioWriteBytes: 20_000,
417 ioSemantics: "all-io",
418 }),
419 ]),
420 });
421 const second = merge({
422 previous: first,
423 native: nativeSnapshot(
424 BASE_TIME_MS + 1_000,
425 [
426 processSample({
427 pid: SERVER_PID,
428 ppid: 1,
429 startTimeMs: 1_000,
430 cpuTimeMs: 1_250,
431 ioReadBytes: 12_000,
432 ioWriteBytes: 23_000,
433 ioSemantics: "all-io",
434 }),
435 ],
436 2,
437 ),
438 });
439 const server = second.processes[0]!;
440​
441 expect(server.cpuPercent).toBe(25);
442 expect(server.ioReadBytesPerSecond).toBe(2_000);
443 expect(server.ioWriteBytesPerSecond).toBe(3_000);
444 expect(server.ioSemantics).toBe("all-io");
445 expect(second.groups.backend.cpuTimeMs).toBe(250);
446 expect(second.groups.backend.ioReadBytes).toBe(2_000);
447 expect(second.groups.backend.ioWriteBytes).toBe(3_000);
448 });
449​
450 it("preserves native rates while applying a desktop-only update", () => {
451 const first = merge({
452 native: nativeSnapshot(BASE_TIME_MS, [
453 processSample({
454 pid: SERVER_PID,
455 ppid: 1,
456 startTimeMs: 1_000,
457 cpuTimeMs: 1_000,
458 ioReadBytes: 10_000,
459 ioWriteBytes: 20_000,
460 }),
461 ]),
462 });
463 const second = merge({
464 previous: first,
465 native: nativeSnapshot(
466 BASE_TIME_MS + 1_000,
467 [
468 processSample({
469 pid: SERVER_PID,
470 ppid: 1,
471 startTimeMs: 1_000,
472 cpuTimeMs: 1_250,
473 ioReadBytes: 12_000,
474 ioWriteBytes: 23_000,
475 }),
476 ],
477 2,
478 ),
479 });
480 const desktopOnly = mergeProcesses({
481 serverPid: SERVER_PID,
482 sidecarPid: Option.none(),
483 fallbackSampledAtMs: BASE_TIME_MS + 1_000,
484 nativeSnapshot: Option.some(
485 nativeSnapshot(
486 BASE_TIME_MS + 1_000,
487 [
488 processSample({
489 pid: SERVER_PID,
490 ppid: 1,
491 startTimeMs: 1_000,
492 cpuTimeMs: 1_250,
493 ioReadBytes: 12_000,
494 ioWriteBytes: 23_000,
495 }),
496 ],
497 2,
498 ),
499 ),
500 desktopSnapshot: Option.some(desktopSnapshot(BASE_TIME_MS + 1_500, [])),
501 previous: second.previous,
502 counters: second.counters,
503 updatePrevious: false,
504 });
505​
506 expect(desktopOnly.processes[0]?.cpuPercent).toBe(25);
507 expect(desktopOnly.processes[0]?.ioReadBytesPerSecond).toBe(2_000);
508 expect(desktopOnly.processes[0]?.ioWriteBytesPerSecond).toBe(3_000);
509 expect(desktopOnly.sampledAtMs).toBe(BASE_TIME_MS + 1_500);
510 });
511​
512 it("resets deltas when counters decrease or the sampling gap is unsafe", () => {
513 const first = merge({
514 native: nativeSnapshot(BASE_TIME_MS, [
515 processSample({
516 pid: SERVER_PID,
517 ppid: 1,
518 startTimeMs: 1_000,
519 cpuTimeMs: 1_000,
520 ioReadBytes: 10_000,
521 ioWriteBytes: 20_000,
522 }),
523 ]),
524 });
525 const decreased = merge({
526 previous: first,
527 native: nativeSnapshot(
528 BASE_TIME_MS + 1_000,
529 [
530 processSample({
531 pid: SERVER_PID,
532 ppid: 1,
533 startTimeMs: 1_000,
534 cpuTimeMs: 100,
535 ioReadBytes: 100,
536 ioWriteBytes: 200,
537 }),
538 ],
539 2,
540 ),
541 });
542 const delayed = merge({
543 previous: decreased,
544 native: nativeSnapshot(
545 BASE_TIME_MS + 90_000,
546 [
547 processSample({
548 pid: SERVER_PID,
549 ppid: 1,
550 startTimeMs: 1_000,
551 cpuTimeMs: 10_000,
552 ioReadBytes: 100_000,
553 ioWriteBytes: 200_000,
554 }),
555 ],
556 3,
557 ),
558 });
559​
560 expect(decreased.processes[0]?.cpuPercent).toBe(0);
561 expect(decreased.processes[0]?.ioReadBytesPerSecond).toBe(0);
562 expect(decreased.processes[0]?.ioWriteBytesPerSecond).toBe(0);
563 expect(delayed.processes[0]?.cpuPercent).toBe(0);
564 expect(delayed.processes[0]?.ioReadBytesPerSecond).toBe(0);
565 expect(delayed.processes[0]?.ioWriteBytesPerSecond).toBe(0);
566 expect(delayed.groups.backend.cpuTimeMs).toBe(0);
567 expect(delayed.groups.backend.ioReadBytes).toBe(0);
568 expect(delayed.groups.backend.ioWriteBytes).toBe(0);
569 });
570​
571 it("treats reused PIDs as an exit plus a new process", () => {
572 const first = merge({
573 native: nativeSnapshot(BASE_TIME_MS, [
574 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
575 processSample({ pid: 200, ppid: SERVER_PID, startTimeMs: 2_000 }),
576 ]),
577 });
578 const second = merge({
579 previous: first,
580 native: nativeSnapshot(
581 BASE_TIME_MS + 1_000,
582 [
583 processSample({ pid: SERVER_PID, ppid: 1, startTimeMs: 1_000 }),
584 processSample({
585 pid: 200,
586 ppid: SERVER_PID,
587 startTimeMs: 9_000,
588 cpuTimeMs: 999,
589 ioReadBytes: 999,
590 ioWriteBytes: 999,
591 }),
592 ],
593 2,
594 ),
595 });
596 const reused = second.processes.find((process) => process.identity.pid === 200)!;
597​
598 expect(reused.identity.startTimeMs).toBe(9_000);
599 expect(reused.cpuPercent).toBe(0);
600 expect(reused.ioReadBytesPerSecond).toBe(0);
601 expect(second.groups.backend.processStarts).toBe(3);
602 expect(second.groups.backend.processExits).toBe(1);
603 });
604});