1
0
mirror of https://github.com/tw93/Mole.git synced 2026-03-22 15:00:07 +00:00
Files
Mole/cmd/status/process_watch_test.go
Felix 82e25632e0 feat(status): alert on persistent high-cpu processes (#602)
* feat(status): alert on persistent high-cpu processes

* refactor(status): keep high-cpu alerts read-only

* fix(status): address lint and sudo test regressions

---------

Co-authored-by: Tw93 <hitw93@gmail.com>
2026-03-21 08:22:01 +08:00

183 lines
5.3 KiB
Go

package main
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestParseProcessOutput(t *testing.T) {
raw := strings.Join([]string{
"123 1 145.2 10.1 /Applications/Visual Studio Code.app/Contents/MacOS/Electron",
"456 1 99.5 2.2 /System/Library/CoreServices/Finder.app/Contents/MacOS/Finder",
"bad line",
}, "\n")
procs := parseProcessOutput(raw)
if len(procs) != 2 {
t.Fatalf("parseProcessOutput() len = %d, want 2", len(procs))
}
if procs[0].PID != 123 || procs[0].PPID != 1 {
t.Fatalf("unexpected pid/ppid: %+v", procs[0])
}
if procs[0].Name != "Electron" {
t.Fatalf("unexpected process name %q", procs[0].Name)
}
if !strings.Contains(procs[0].Command, "Visual Studio Code.app") {
t.Fatalf("command path missing spaces: %q", procs[0].Command)
}
}
func TestTopProcessesSortsByCPU(t *testing.T) {
procs := []ProcessInfo{
{PID: 3, Name: "low", CPU: 20, Memory: 3},
{PID: 1, Name: "high", CPU: 120, Memory: 1},
{PID: 2, Name: "mid", CPU: 120, Memory: 8},
}
top := topProcesses(procs, 2)
if len(top) != 2 {
t.Fatalf("topProcesses() len = %d, want 2", len(top))
}
if top[0].PID != 2 || top[1].PID != 1 {
t.Fatalf("unexpected order: %+v", top)
}
}
func TestProcessWatcherTriggersAfterContinuousWindow(t *testing.T) {
base := time.Date(2026, 3, 19, 10, 0, 0, 0, time.UTC)
watcher := NewProcessWatcher(ProcessWatchOptions{
Enabled: true,
CPUThreshold: 100,
Window: 5 * time.Minute,
})
proc := []ProcessInfo{{PID: 42, Name: "stress", CPU: 140}}
if alerts := watcher.Update(base, proc); len(alerts) != 0 {
t.Fatalf("unexpected early alerts: %+v", alerts)
}
if alerts := watcher.Update(base.Add(4*time.Minute), proc); len(alerts) != 0 {
t.Fatalf("unexpected early alerts at 4m: %+v", alerts)
}
alerts := watcher.Update(base.Add(5*time.Minute), proc)
if len(alerts) != 1 {
t.Fatalf("expected 1 alert after full window, got %+v", alerts)
}
if alerts[0].Status != "active" {
t.Fatalf("unexpected alert status %q", alerts[0].Status)
}
}
func TestProcessWatcherResetsWhenUsageDrops(t *testing.T) {
base := time.Date(2026, 3, 19, 10, 0, 0, 0, time.UTC)
watcher := NewProcessWatcher(ProcessWatchOptions{
Enabled: true,
CPUThreshold: 100,
Window: 5 * time.Minute,
})
high := []ProcessInfo{{PID: 42, Name: "stress", CPU: 140}}
low := []ProcessInfo{{PID: 42, Name: "stress", CPU: 30}}
watcher.Update(base, high)
watcher.Update(base.Add(4*time.Minute), high)
if alerts := watcher.Update(base.Add(4*time.Minute+30*time.Second), low); len(alerts) != 0 {
t.Fatalf("expected reset after dip, got %+v", alerts)
}
if alerts := watcher.Update(base.Add(9*time.Minute), high); len(alerts) != 0 {
t.Fatalf("expected no alert after reset, got %+v", alerts)
}
if alerts := watcher.Update(base.Add(14*time.Minute), high); len(alerts) != 1 {
t.Fatalf("expected alert after second full window, got %+v", alerts)
}
}
func TestProcessWatcherResetsOnPIDReuse(t *testing.T) {
base := time.Date(2026, 3, 19, 10, 0, 0, 0, time.UTC)
watcher := NewProcessWatcher(ProcessWatchOptions{
Enabled: true,
CPUThreshold: 100,
Window: 2 * time.Minute,
})
firstProc := []ProcessInfo{{
PID: 42,
PPID: 1,
Name: "stress",
Command: "/usr/bin/stress",
CPU: 140,
}}
secondProc := []ProcessInfo{{
PID: 42,
PPID: 99,
Name: "node",
Command: "/usr/local/bin/node /tmp/server.js",
CPU: 135,
}}
watcher.Update(base, firstProc)
if alerts := watcher.Update(base.Add(2*time.Minute), firstProc); len(alerts) != 1 {
t.Fatalf("expected first process to alert after window, got %+v", alerts)
}
if alerts := watcher.Update(base.Add(3*time.Minute), secondProc); len(alerts) != 0 {
t.Fatalf("expected pid reuse to reset tracking, got %+v", alerts)
}
if alerts := watcher.Update(base.Add(5*time.Minute), secondProc); len(alerts) != 1 {
t.Fatalf("expected reused pid to alert only after its own window, got %+v", alerts)
}
}
func TestRenderProcessAlertBar(t *testing.T) {
alerts := []ProcessAlert{
{PID: 10, Name: "node", CPU: 150, Threshold: 100, Window: "5m0s", Status: "active"},
{PID: 11, Name: "java", CPU: 130, Threshold: 100, Window: "5m0s", Status: "active"},
}
bar := renderProcessAlertBar(alerts, 120)
if !strings.Contains(bar, "ALERT") {
t.Fatalf("missing alert prefix: %q", bar)
}
if !strings.Contains(bar, "node (10)") {
t.Fatalf("missing lead process label: %q", bar)
}
if !strings.Contains(bar, "+1 more") {
t.Fatalf("missing additional alert count: %q", bar)
}
if strings.Contains(bar, "terminate") || strings.Contains(bar, "ignore") {
t.Fatalf("unexpected action text in read-only alert bar: %q", bar)
}
}
func TestMetricsSnapshotJSONIncludesProcessWatch(t *testing.T) {
snapshot := MetricsSnapshot{
ProcessWatch: ProcessWatchConfig{
Enabled: true,
CPUThreshold: 100,
Window: "5m0s",
},
ProcessAlerts: []ProcessAlert{{
PID: 99,
Name: "node",
CPU: 140,
Threshold: 100,
Window: "5m0s",
Status: "active",
}},
}
data, err := json.Marshal(snapshot)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
out := string(data)
if !strings.Contains(out, "\"process_watch\"") {
t.Fatalf("missing process_watch in json: %s", out)
}
if !strings.Contains(out, "\"process_alerts\"") {
t.Fatalf("missing process_alerts in json: %s", out)
}
}