package jobspec import ( "os" "path/filepath" "strings" "testing" ) func TestParseValid(t *testing.T) { hcl := ` job "demo" { } task "build" { command = "/bin/echo" args = ["hello", "world"] } ` spec, err := Parse([]byte(hcl), "test.hcl") if err != nil { t.Fatalf("parse: %v", err) } if spec.Job.Name != "demo" { t.Errorf("expected job name 'demo', got %q", spec.Job.Name) } if len(spec.Tasks) != 1 { t.Fatalf("expected 1 task, got %d", len(spec.Tasks)) } if spec.Tasks[0].Command != "/bin/echo" { t.Errorf("expected command '/bin/echo', got %q", spec.Tasks[0].Command) } if len(spec.Tasks[0].Args) != 2 { t.Errorf("expected 2 args, got %d", len(spec.Tasks[0].Args)) } } func TestParseMissingJob(t *testing.T) { hcl := `task "x" { command = "/bin/echo" }` _, err := Parse([]byte(hcl), "test.hcl") if err == nil { t.Fatal("expected error for missing job name") } } func TestParseNoTasks(t *testing.T) { hcl := `job "empty" {}` _, err := Parse([]byte(hcl), "test.hcl") if err == nil { t.Fatal("expected error for no tasks") } } func TestParseTaskMissingCommand(t *testing.T) { hcl := ` job "x" {} task "no-cmd" {} ` _, err := Parse([]byte(hcl), "test.hcl") if err == nil { t.Fatal("expected error for missing command") } } func TestParse_GoldenFiles(t *testing.T) { cases := []struct { name string file string wantJob string wantJobType string wantTasks int checkTask func(t *testing.T, s *Spec) }{ { name: "single_task", file: "valid_single_task.hcl", wantJob: "single", wantTasks: 1, wantJobType: "", checkTask: func(t *testing.T, s *Spec) { if s.Tasks[0].Name != "solo" { t.Errorf("task name = %q, want solo", s.Tasks[0].Name) } if s.Tasks[0].Command != "/bin/true" { t.Errorf("command = %q, want /bin/true", s.Tasks[0].Command) } }, }, { name: "multi_task", file: "valid_multi_task.hcl", wantJob: "multi", wantJobType: "batch", wantTasks: 3, checkTask: func(t *testing.T, s *Spec) { byName := map[string]TaskSpec{} for _, tk := range s.Tasks { byName[tk.Name] = tk } if _, ok := byName["build"]; !ok { t.Errorf("missing task 'build'") } if _, ok := byName["test"]; !ok { t.Errorf("missing task 'test'") } if len(byName["test"].Env) != 2 { t.Errorf("test env count = %d, want 2", len(byName["test"].Env)) } if _, ok := byName["deploy"]; !ok { t.Errorf("missing task 'deploy'") } }, }, { name: "env_vars", file: "valid_env_vars.hcl", wantJob: "envvars", wantTasks: 1, checkTask: func(t *testing.T, s *Spec) { if len(s.Tasks[0].Env) != 3 { t.Errorf("env count = %d, want 3", len(s.Tasks[0].Env)) } want := "FOO=bar" if s.Tasks[0].Env[0] != want { t.Errorf("env[0] = %q, want %q", s.Tasks[0].Env[0], want) } }, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { path := filepath.Join("testdata", tc.file) spec, err := ParseHCLFile(path) if err != nil { t.Fatalf("ParseHCLFile(%s): %v", tc.file, err) } if spec.Job.Name != tc.wantJob { t.Errorf("job name = %q, want %q", spec.Job.Name, tc.wantJob) } if tc.wantJobType != "" && spec.Job.Type != tc.wantJobType { t.Errorf("job type = %q, want %q", spec.Job.Type, tc.wantJobType) } if len(spec.Tasks) != tc.wantTasks { t.Fatalf("tasks = %d, want %d", len(spec.Tasks), tc.wantTasks) } if tc.checkTask != nil { tc.checkTask(t, spec) } }) } } func TestParse_ErrorPaths(t *testing.T) { cases := []struct { name string file string wantErr string useParse bool hcl string }{ {name: "no_tasks", file: "err_no_tasks.hcl", wantErr: "at least one task"}, {name: "missing_command", file: "err_missing_command.hcl", wantErr: "required"}, {name: "malformed", file: "err_malformed.hcl", wantErr: "decode hcl"}, {name: "missing_job", file: "err_missing_job.hcl", wantErr: "Missing job block"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { path := filepath.Join("testdata", tc.file) _, err := ParseFile(path) if err == nil { t.Fatalf("expected error containing %q, got nil", tc.wantErr) } if !strings.Contains(err.Error(), tc.wantErr) { t.Errorf("error = %q, want it to contain %q", err.Error(), tc.wantErr) } }) } } func TestParse_EmptyFile(t *testing.T) { _, err := Parse([]byte(""), "empty.hcl") if err == nil { t.Fatal("expected error for empty file") } } func TestParse_MalformedHCL(t *testing.T) { _, err := Parse([]byte("job = "), "bad.hcl") if err == nil { t.Fatal("expected error for malformed HCL") } if !strings.Contains(err.Error(), "decode hcl") { t.Errorf("error = %q, want it to contain 'decode hcl'", err.Error()) } } func TestParseFile_Nonexistent(t *testing.T) { _, err := ParseFile(filepath.Join("testdata", "does_not_exist.hcl")) if err == nil { t.Fatal("expected error for nonexistent file") } if !strings.Contains(err.Error(), "read spec file") { t.Errorf("error = %q, want it to contain 'read spec file'", err.Error()) } } func TestParseFile_ReadError(t *testing.T) { // Directory exists but is not readable as a file. _, err := ParseFile("testdata") if err == nil { t.Fatal("expected error when ParseFile target is a directory") } } func TestSpec_Validate(t *testing.T) { cases := []struct { name string spec *Spec wantErr string }{ { name: "empty_job_name", spec: &Spec{Job: JobSpec{Name: " "}, Tasks: []TaskSpec{{Name: "t", Command: "/bin/echo"}}}, wantErr: "job name is required", }, { name: "no_tasks", spec: &Spec{Job: JobSpec{Name: "x"}}, wantErr: "at least one task is required", }, { name: "valid", spec: &Spec{Job: JobSpec{Name: "x"}, Tasks: []TaskSpec{{Name: "t", Command: "/bin/echo"}}}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := tc.spec.Validate() if tc.wantErr == "" { if err != nil { t.Errorf("Validate: got %v, want nil", err) } return } if err == nil { t.Fatalf("expected error containing %q, got nil", tc.wantErr) } if !strings.Contains(err.Error(), tc.wantErr) { t.Errorf("error = %q, want it to contain %q", err.Error(), tc.wantErr) } }) } } func TestSpec_Validate_RoundTripFromParse(t *testing.T) { path := filepath.Join("testdata", "valid_single_task.hcl") spec, err := ParseHCLFile(path) if err != nil { t.Fatalf("ParseHCLFile: %v", err) } if err := spec.Validate(); err != nil { t.Errorf("Validate on parsed spec: %v", err) } } func TestParseFile_GoldenFilesExist(t *testing.T) { // Guard against accidentally removing testdata fixtures. files := []string{ "valid_single_task.hcl", "valid_multi_task.hcl", "valid_env_vars.hcl", "err_no_tasks.hcl", "err_missing_command.hcl", "err_malformed.hcl", "err_missing_job.hcl", } for _, f := range files { path := filepath.Join("testdata", f) if _, err := os.Stat(path); err != nil { t.Errorf("missing testdata fixture %s: %v", f, err) } } }