Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 54 additions & 43 deletions tables/energyimpact/energy_impact.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
163 changes: 144 additions & 19 deletions tables/energyimpact/energy_impact_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package energyimpact

import (
"context"
_ "embed"
"errors"
"testing"
Expand All @@ -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

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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 {
Expand Down
Loading