diff --git a/tables/energyimpact/energy_impact.go b/tables/energyimpact/energy_impact.go index 0d5f67a..0880e62 100644 --- a/tables/energyimpact/energy_impact.go +++ b/tables/energyimpact/energy_impact.go @@ -21,25 +21,25 @@ type powermetricsOutput struct { // task represents individual process data from powermetrics type task struct { - PID int `plist:"pid"` - Name string `plist:"name"` - EnergyImpact float64 `plist:"energy_impact"` - EnergyImpactPerS float64 `plist:"energy_impact_per_s"` - CPUTimeNS int64 `plist:"cputime_ns"` - CPUTimeMSPerS float64 `plist:"cputime_ms_per_s"` - CPUTimeUserlandRatio float64 `plist:"cputime_userland_ratio"` - IntrWakeups int `plist:"intr_wakeups"` - IntrWakeupsPerS float64 `plist:"intr_wakeups_per_s"` - IdleWakeups int `plist:"idle_wakeups"` - IdleWakeupsPerS float64 `plist:"idle_wakeups_per_s"` - DiskIOBytesRead int64 `plist:"diskio_bytesread"` - DiskIOBytesReadPerS float64 `plist:"diskio_bytesread_per_s"` - DiskIOBytesWritten int64 `plist:"diskio_byteswritten"` + PID int `plist:"pid"` + Name string `plist:"name"` + EnergyImpact float64 `plist:"energy_impact"` + EnergyImpactPerS float64 `plist:"energy_impact_per_s"` + CPUTimeNS int64 `plist:"cputime_ns"` + CPUTimeMSPerS float64 `plist:"cputime_ms_per_s"` + CPUTimeUserlandRatio float64 `plist:"cputime_userland_ratio"` + IntrWakeups int `plist:"intr_wakeups"` + IntrWakeupsPerS float64 `plist:"intr_wakeups_per_s"` + IdleWakeups int `plist:"idle_wakeups"` + IdleWakeupsPerS float64 `plist:"idle_wakeups_per_s"` + DiskIOBytesRead int64 `plist:"diskio_bytesread"` + DiskIOBytesReadPerS float64 `plist:"diskio_bytesread_per_s"` + DiskIOBytesWritten int64 `plist:"diskio_byteswritten"` DiskIOBytesWrittenPerS float64 `plist:"diskio_byteswritten_per_s"` - PacketsReceived int `plist:"packets_received"` - PacketsSent int `plist:"packets_sent"` - BytesReceived int64 `plist:"bytes_received"` - BytesSent int64 `plist:"bytes_sent"` + PacketsReceived int `plist:"packets_received"` + PacketsSent int `plist:"packets_sent"` + BytesReceived int64 `plist:"bytes_received"` + BytesSent int64 `plist:"bytes_sent"` } // EnergyImpactColumns returns the column definitions for the energy_impact table @@ -70,9 +70,12 @@ func EnergyImpactColumns() []table.ColumnDefinition { // EnergyImpactGenerate generates the table data when queried func EnergyImpactGenerate(ctx context.Context, queryContext table.QueryContext) ([]map[string]string, error) { - var results []map[string]string + r := utils.NewRunner() + fs := utils.OSFileSystem{} + return generateWithRunner(queryContext, r, fs) +} - // Get interval from WHERE clause, default to 1000ms +func parseInterval(queryContext table.QueryContext) int { interval := defaultInterval if constraintList, present := queryContext.Constraints["interval"]; present { for _, constraint := range constraintList.Constraints { @@ -84,40 +87,48 @@ func EnergyImpactGenerate(ctx context.Context, queryContext table.QueryContext) } } - r := utils.NewRunner() - fs := utils.OSFileSystem{} + return interval +} + +func generateWithRunner(queryContext table.QueryContext, r utils.Runner, fs utils.FileSystem) ([]map[string]string, error) { + interval := parseInterval(queryContext) tasks, err := runPowermetrics(r, fs, interval) if err != nil { fmt.Println(err) - return results, err + return nil, err } + return buildOutput(tasks, interval), nil +} + +func buildOutput(tasks []task, interval int) []map[string]string { + var results []map[string]string for _, t := range tasks { results = append(results, map[string]string{ - "pid": strconv.Itoa(t.PID), - "name": t.Name, - "energy_impact": fmt.Sprintf("%.2f", t.EnergyImpact), - "energy_impact_per_s": fmt.Sprintf("%.2f", t.EnergyImpactPerS), - "cputime_ns": strconv.FormatInt(t.CPUTimeNS, 10), - "cputime_ms_per_s": fmt.Sprintf("%.2f", t.CPUTimeMSPerS), - "cputime_userland_ratio": fmt.Sprintf("%.2f", t.CPUTimeUserlandRatio), - "intr_wakeups": strconv.Itoa(t.IntrWakeups), - "intr_wakeups_per_s": fmt.Sprintf("%.2f", t.IntrWakeupsPerS), - "idle_wakeups": strconv.Itoa(t.IdleWakeups), - "idle_wakeups_per_s": fmt.Sprintf("%.2f", t.IdleWakeupsPerS), - "diskio_bytesread": strconv.FormatInt(t.DiskIOBytesRead, 10), - "diskio_bytesread_per_s": fmt.Sprintf("%.2f", t.DiskIOBytesReadPerS), - "diskio_byteswritten": strconv.FormatInt(t.DiskIOBytesWritten, 10), + "pid": strconv.Itoa(t.PID), + "name": t.Name, + "energy_impact": fmt.Sprintf("%.2f", t.EnergyImpact), + "energy_impact_per_s": fmt.Sprintf("%.2f", t.EnergyImpactPerS), + "cputime_ns": strconv.FormatInt(t.CPUTimeNS, 10), + "cputime_ms_per_s": fmt.Sprintf("%.2f", t.CPUTimeMSPerS), + "cputime_userland_ratio": fmt.Sprintf("%.2f", t.CPUTimeUserlandRatio), + "intr_wakeups": strconv.Itoa(t.IntrWakeups), + "intr_wakeups_per_s": fmt.Sprintf("%.2f", t.IntrWakeupsPerS), + "idle_wakeups": strconv.Itoa(t.IdleWakeups), + "idle_wakeups_per_s": fmt.Sprintf("%.2f", t.IdleWakeupsPerS), + "diskio_bytesread": strconv.FormatInt(t.DiskIOBytesRead, 10), + "diskio_bytesread_per_s": fmt.Sprintf("%.2f", t.DiskIOBytesReadPerS), + "diskio_byteswritten": strconv.FormatInt(t.DiskIOBytesWritten, 10), "diskio_byteswritten_per_s": fmt.Sprintf("%.2f", t.DiskIOBytesWrittenPerS), - "packets_received": strconv.Itoa(t.PacketsReceived), - "packets_sent": strconv.Itoa(t.PacketsSent), - "bytes_received": strconv.FormatInt(t.BytesReceived, 10), - "bytes_sent": strconv.FormatInt(t.BytesSent, 10), - "interval": strconv.Itoa(interval), + "packets_received": strconv.Itoa(t.PacketsReceived), + "packets_sent": strconv.Itoa(t.PacketsSent), + "bytes_received": strconv.FormatInt(t.BytesReceived, 10), + "bytes_sent": strconv.FormatInt(t.BytesSent, 10), + "interval": strconv.Itoa(interval), }) } - return results, nil + return results } // runPowermetrics executes the powermetrics command and parses the output diff --git a/tables/energyimpact/energy_impact_test.go b/tables/energyimpact/energy_impact_test.go index 22cf6f4..297f86b 100644 --- a/tables/energyimpact/energy_impact_test.go +++ b/tables/energyimpact/energy_impact_test.go @@ -1,7 +1,6 @@ package energyimpact import ( - "context" _ "embed" "errors" "testing" @@ -11,6 +10,23 @@ import ( "github.com/stretchr/testify/assert" ) +type recordingCmdRunner struct { + output string + err error + name string + args []string +} + +func (r *recordingCmdRunner) RunCmd(name string, arg ...string) ([]byte, error) { + r.name = name + r.args = append([]string(nil), arg...) + return []byte(r.output), r.err +} + +func (r *recordingCmdRunner) RunCmdWithStdin(name string, stdin string, arg ...string) ([]byte, error) { + return r.RunCmd(name, arg...) +} + //go:embed test_powermetrics_output.plist var testPlist string @@ -41,27 +57,122 @@ func TestEnergyImpactColumns(t *testing.T) { } } -func TestEnergyImpactGenerate(t *testing.T) { - // This test verifies that the generate function works with the default context - // Since it requires actual powermetrics execution, we only test the function signature - ctx := context.Background() - queryContext := table.QueryContext{ - Constraints: make(map[string]table.ConstraintList), +func TestParseInterval(t *testing.T) { + tests := []struct { + name string + queryContext table.QueryContext + expected int + }{ + { + name: "default", + queryContext: table.QueryContext{Constraints: map[string]table.ConstraintList{}}, + expected: defaultInterval, + }, + { + name: "equals constraint", + queryContext: table.QueryContext{Constraints: map[string]table.ConstraintList{ + "interval": {Constraints: []table.Constraint{{ + Operator: table.OperatorEquals, + Expression: "2500", + }}}, + }}, + expected: 2500, + }, + { + name: "invalid equals constraint", + queryContext: table.QueryContext{Constraints: map[string]table.ConstraintList{ + "interval": {Constraints: []table.Constraint{{ + Operator: table.OperatorEquals, + Expression: "not-a-number", + }}}, + }}, + expected: defaultInterval, + }, + { + name: "non equals constraint", + queryContext: table.QueryContext{Constraints: map[string]table.ConstraintList{ + "interval": {Constraints: []table.Constraint{{ + Operator: table.OperatorGreaterThan, + Expression: "2500", + }}}, + }}, + expected: defaultInterval, + }, } - // Call the function - it may return empty results if powermetrics isn't available - // or require root, but it shouldn't panic - results, err := EnergyImpactGenerate(ctx, queryContext) - - // The function should return without panicking - // On Linux: powermetrics doesn't exist, returns nil results with no error - // On macOS without root: returns error (requires superuser) - // On macOS with root: returns results - if err != nil { - // Error case (e.g., not running as root on macOS) - assert.Nil(t, results) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, parseInterval(tt.queryContext)) + }) } - // If no error, results could be nil (binary not found) or populated (successful run) +} + +func TestGenerateWithRunnerUsesParsedInterval(t *testing.T) { + runner := &recordingCmdRunner{output: testPlist} + queryContext := table.QueryContext{Constraints: map[string]table.ConstraintList{ + "interval": {Constraints: []table.Constraint{{ + Operator: table.OperatorEquals, + Expression: "5000", + }}}, + }} + + results, err := generateWithRunner( + queryContext, + utils.Runner{Runner: runner}, + utils.MockFileSystem{FileExists: true}, + ) + assert.NoError(t, err) + assert.Len(t, results, 3) + assert.Equal(t, "5000", results[0]["interval"]) + assert.Equal(t, "/usr/bin/powermetrics", runner.name) + assert.Contains(t, runner.args, "5000") +} + +func TestBuildOutputFormatsValues(t *testing.T) { + results := buildOutput([]task{{ + PID: 1234, + Name: "Safari", + EnergyImpact: 125.555, + EnergyImpactPerS: 12.554, + CPUTimeNS: 500000000, + CPUTimeMSPerS: 50.505, + CPUTimeUserlandRatio: 0.755, + IntrWakeups: 100, + IntrWakeupsPerS: 10.555, + IdleWakeups: 50, + IdleWakeupsPerS: 5.255, + DiskIOBytesRead: 1048576, + DiskIOBytesReadPerS: 104857.655, + DiskIOBytesWritten: 524288, + DiskIOBytesWrittenPerS: 52428.855, + PacketsReceived: 200, + PacketsSent: 150, + BytesReceived: 204800, + BytesSent: 102400, + }}, 2500) + + assert.Equal(t, []map[string]string{{ + "pid": "1234", + "name": "Safari", + "energy_impact": "125.56", + "energy_impact_per_s": "12.55", + "cputime_ns": "500000000", + "cputime_ms_per_s": "50.51", + "cputime_userland_ratio": "0.76", + "intr_wakeups": "100", + "intr_wakeups_per_s": "10.55", + "idle_wakeups": "50", + "idle_wakeups_per_s": "5.25", + "diskio_bytesread": "1048576", + "diskio_bytesread_per_s": "104857.65", + "diskio_byteswritten": "524288", + "diskio_byteswritten_per_s": "52428.86", + "packets_received": "200", + "packets_sent": "150", + "bytes_received": "204800", + "bytes_sent": "102400", + "interval": "2500", + }}, results) } func TestRunPowermetrics(t *testing.T) { @@ -133,6 +244,17 @@ func TestRunPowermetrics(t *testing.T) { wantErr: true, wantTasks: 0, }, + { + name: "Stat error", + mockCmd: utils.MockCmdRunner{ + Output: "", + Err: nil, + }, + fileExist: true, + interval: 1000, + wantErr: true, + wantTasks: 0, + }, { name: "Invalid plist output", mockCmd: utils.MockCmdRunner{ @@ -161,6 +283,9 @@ func TestRunPowermetrics(t *testing.T) { t.Run(tt.name, func(t *testing.T) { runner := utils.Runner{Runner: tt.mockCmd} fs := utils.MockFileSystem{FileExists: tt.fileExist} + if tt.name == "Stat error" { + fs.Err = errors.New("stat error") + } tasks, err := runPowermetrics(runner, fs, tt.interval) if tt.wantErr {