package platform import ( "context" "os" "os/exec" "path/filepath" "strings" "sync/atomic" "testing" "time" ) func TestParseScenarioJSONValidatesRequiredFields(t *testing.T) { cases := []struct { name string json string wantErr string }{ {"missing name", `{"jobs":[{"name":"a","type":"command","cmd":["true"]}]}`, `"name" is required`}, {"no jobs", `{"name":"x","jobs":[]}`, "at least one job is required"}, {"job missing name", `{"name":"x","jobs":[{"type":"command","cmd":["true"]}]}`, `"name" is required`}, {"duplicate job name", `{"name":"x","jobs":[{"name":"a","type":"command","cmd":["true"]},{"name":"a","type":"command","cmd":["true"]}]}`, "duplicate job name"}, {"bad type", `{"name":"x","jobs":[{"name":"a","type":"bogus","cmd":["true"]}]}`, `type must be "command" or "sampler"`}, {"missing cmd", `{"name":"x","jobs":[{"name":"a","type":"command"}]}`, `"cmd" is required`}, {"sampler no interval", `{"name":"x","jobs":[{"name":"a","type":"sampler","cmd":["true"]}]}`, `"interval_sec" must be > 0`}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { _, err := ParseScenarioJSON([]byte(c.json)) if err == nil || !strings.Contains(err.Error(), c.wantErr) { t.Fatalf("err=%v want containing %q", err, c.wantErr) } }) } } func TestParseScenarioJSONValid(t *testing.T) { spec, err := ParseScenarioJSON([]byte(`{ "name": "power-watch", "timeout_sec": 60, "jobs": [ {"name": "ipmi", "type": "sampler", "interval_sec": 2, "cmd": ["ipmitool", "sensor"]}, {"name": "load", "type": "command", "cmd": ["dcgmi", "diag", "-r", "nvbandwidth", "-i", "{{gpus}}"], "gpu_indices": [0,1,2]} ] }`)) if err != nil { t.Fatalf("ParseScenarioJSON error: %v", err) } if spec.Name != "power-watch" || spec.TimeoutSec != 60 || len(spec.Jobs) != 2 { t.Fatalf("spec=%+v", spec) } } func TestSubstituteGPUIndices(t *testing.T) { cmd := []string{"dcgmi", "diag", "-r", "nvbandwidth", "-i", "{{gpus}}"} got := substituteGPUIndices(cmd, []int{0, 2, 4}) want := []string{"dcgmi", "diag", "-r", "nvbandwidth", "-i", "0,2,4"} if len(got) != len(want) { t.Fatalf("got=%v want=%v", got, want) } for i := range want { if got[i] != want[i] { t.Fatalf("got=%v want=%v", got, want) } } // No GPUIndices: cmd passes through untouched (no {{gpus}} to replace). same := substituteGPUIndices(cmd, nil) if same[5] != "{{gpus}}" { t.Fatalf("expected token left alone when no indices given, got %v", same) } } func TestSanitizeScenarioName(t *testing.T) { cases := map[string]string{ "power-watch": "power-watch", "Power Watch!!": "power-watch--", "": "scenario", "already_ok-123": "already_ok-123", } for in, want := range cases { if got := sanitizeScenarioName(in); got != want { t.Fatalf("sanitizeScenarioName(%q)=%q want %q", in, got, want) } } } func TestRunScenarioRunsCommandsAndSamplersConcurrently(t *testing.T) { old := satExecCommand var samplerCalls int32 satExecCommand = func(name string, args ...string) *exec.Cmd { if name == "sample-cmd" { atomic.AddInt32(&samplerCalls, 1) return exec.Command("printf", "sampled\n") } return exec.Command(name, args...) } t.Cleanup(func() { satExecCommand = old }) dir := t.TempDir() spec := ScenarioSpec{ Name: "test-scenario", TimeoutSec: 10, Jobs: []ScenarioJob{ {Name: "watch", Type: "sampler", Cmd: []string{"sample-cmd"}, IntervalSec: 1}, {Name: "load", Type: "command", Cmd: []string{"printf", "loaded\n"}}, }, } s := &System{} runDir, err := s.RunScenario(context.Background(), dir, spec, nil) if err != nil { t.Fatalf("RunScenario error: %v", err) } if _, err := os.Stat(filepath.Join(runDir, "load.log")); err != nil { t.Fatalf("load.log missing: %v", err) } loadOut, err := os.ReadFile(filepath.Join(runDir, "load.log")) if err != nil || strings.TrimSpace(string(loadOut)) != "loaded" { t.Fatalf("load.log=%q err=%v", loadOut, err) } watchOut, err := os.ReadFile(filepath.Join(runDir, "watch.log")) if err != nil { t.Fatalf("watch.log missing: %v", err) } if !strings.Contains(string(watchOut), "sampled") { t.Fatalf("watch.log=%q want it to contain a sample", watchOut) } if atomic.LoadInt32(&samplerCalls) < 1 { t.Fatalf("sampler command was never invoked") } summary, err := os.ReadFile(filepath.Join(runDir, "summary.txt")) if err != nil { t.Fatalf("summary.txt missing: %v", err) } if !strings.Contains(string(summary), "load_status=OK") { t.Fatalf("summary=%q want load_status=OK", summary) } } func TestRunScenarioStopsSamplersWhenCommandsFinish(t *testing.T) { old := satExecCommand var samplerCalls int32 satExecCommand = func(name string, args ...string) *exec.Cmd { atomic.AddInt32(&samplerCalls, 1) return exec.Command("true") } t.Cleanup(func() { satExecCommand = old }) dir := t.TempDir() spec := ScenarioSpec{ Name: "fast-scenario", Jobs: []ScenarioJob{ // Interval much longer than the scenario itself takes to run — // if the sampler weren't stopped promptly when the command // finishes, this test would need to wait out the interval. {Name: "watch", Type: "sampler", Cmd: []string{"sample-cmd"}, IntervalSec: 3600}, {Name: "load", Type: "command", Cmd: []string{"true"}}, }, } s := &System{} start := time.Now() if _, err := s.RunScenario(context.Background(), dir, spec, nil); err != nil { t.Fatalf("RunScenario error: %v", err) } if elapsed := time.Since(start); elapsed > 5*time.Second { t.Fatalf("RunScenario took %s — sampler wasn't stopped promptly", elapsed) } // Exactly one sample: the immediate baseline one, no second tick. if got := atomic.LoadInt32(&samplerCalls); got != 1 { t.Fatalf("samplerCalls=%d want 1 (baseline only)", got) } } func TestRunScenarioRecordsFailedCommandInSummary(t *testing.T) { old := satExecCommand satExecCommand = func(name string, args ...string) *exec.Cmd { return exec.Command(name, args...) } t.Cleanup(func() { satExecCommand = old }) dir := t.TempDir() spec := ScenarioSpec{ Name: "failing-scenario", Jobs: []ScenarioJob{ {Name: "will-fail", Type: "command", Cmd: []string{"false"}}, }, } s := &System{} runDir, err := s.RunScenario(context.Background(), dir, spec, nil) if err != nil { t.Fatalf("RunScenario error: %v", err) } summary, err := os.ReadFile(filepath.Join(runDir, "summary.txt")) if err != nil { t.Fatalf("summary.txt missing: %v", err) } if !strings.Contains(string(summary), "will-fail_status=FAILED") { t.Fatalf("summary=%q want will-fail_status=FAILED", summary) } }