Files
orca/internal/jobspec/spec_test.go
T
Jon Chery 667f20a7b3 feat(P0b): Markdown jobspec parser + dispatcher + fuzz harness (REQ-064,067)
P0b — Canonical Markdown+frontmatter jobspec parser (R-013/R-014).

Parser (internal/jobspec/markdown.go, REQ-064):
- WorkloadSpec/RuntimeBlock/PortSpec/VolumeSpec types. ParseMarkdown
  hand-rolled YAML frontmatter (no yaml.v3 dep). Kind validation (Job/
  Service/DaemonSet per R-012). BOM-stripped frontmatter, byte-exact body
  preservation (R-015) via the fuzz harness.

Dispatcher (internal/jobspec/dispatch.go, REQ-064):
- ParseFile/Dispatch routes on extension: .md->Markdown, .yaml/.yml->
  Markdown-with-empty-body, .hcl->ParseHCL adapter. HCL adapter converts
  Spec{Job,Tasks} to *WorkloadSpec (Kind=Job, Runtime.one_of=process).
  Backward compat preserved (REQ-090) — orca job run old-spec.hcl works.
- Legacy Parse renamed ParseHCLLegacy, marked // Deprecated per R-013.

Fuzz harness (internal/jobspec/markdown_fuzz_test.go, REQ-067, R-015):
- FuzzParseMarkdownRoundTrip with 10 seed corpus entries (CRLF, BOM,
  no-frontmatter, only-closing-separator, code-fence ---, trailing
  whitespace, empty body, etc). Asserts byte-exact body round-trip.

Tests: markdown_test.go (19 tests), dispatch_test.go (17 tests), fuzz
(10 seeds). jobspec package 89.2% coverage. cli 81.8% (no regression).

18 packages pass, 20 bats pass, gofmt clean, verify-reqs 90 consistent.

---ci---
project: orca
phase: P0b
milestone: v0.9
status: execute
---/ci---
2026-08-05 17:02:33 +00:00

284 lines
7.0 KiB
Go

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)
}
}
}