package buildstep import ( "bytes" "encoding/json" "errors" "os" "path/filepath" "strings" "testing" "time" ) const ( testCommit = "0123456789abcdef0123456789abcdef01234567" testAskID = "m2kx9a1b3c4d5e" ) func goodBuild() StepAsk { return StepAsk{ID: testAskID, Kind: AskDelivery, Language: AskGo, Asker: "mesh-delivery-builder", At: time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC), Build: &BuildJob{Module: "nats", Path: "modules/nats", Fetch: &Fetch{Repository: "novox/mesh-catalog", Commit: testCommit}, Artifacts: []AskArtifact{{Artifact: "go"}}}} } func goodCheck() StepAsk { return StepAsk{ID: testAskID, Kind: AskCheck, Language: AskGo, Asker: "mesh-delivery-checker", At: time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC), Check: &CheckJob{Fetch: Fetch{Repository: "novox/mesh-catalog", Commit: testCommit}, Levels: []string{LevelUnit}}} } // The ask the three build seats share is one neutral form: it round-trips, and every ill-formed ask is refused, // naming what is wrong (the rules of the old delivery.StepAsk, now the shared runner's). func TestAskCheckAndRoundTrip(t *testing.T) { for _, a := range []StepAsk{goodBuild(), goodCheck()} { raw, err := EncodeStepAsk(a) if err != nil { t.Fatal(err) } back, err := DecodeStepAsk(raw) if err != nil || back.ID != a.ID || back.V != StepAskVersion { t.Fatalf("round trip: %+v, %v", back, err) } } for _, c := range []struct { name string edit func(*StepAsk) want string }{ {"no id", func(a *StepAsk) { a.ID = "x" }, "not an ask id"}, {"unknown kind", func(a *StepAsk) { a.Kind = "build" }, "ask kind"}, {"unknown language", func(a *StepAsk) { a.Language = "rust" }, "ask language"}, {"no time", func(a *StepAsk) { a.At = time.Time{} }, "names no time"}, {"recheck build", func(a *StepAsk) { a.Kind = AskRecheck }, "recheck stream"}, {"two jobs", func(a *StepAsk) { a.Check = goodCheck().Check }, "exactly one"}, {"needs on a build", func(a *StepAsk) { a.Needs = []string{NeedNodeBuildNode} }, "names needs"}, {"unknown registry seat", func(a *StepAsk) { a.Build.Artifacts[0].Seat = "somewhere" }, "no registry seat"}, {"version without seat", func(a *StepAsk) { a.Build.Artifacts[0].Version = "1.0.0" }, "names no seat"}, {"no path", func(a *StepAsk) { a.Build.Path = "" }, "names no directory"}, {"short commit", func(a *StepAsk) { a.Build.Fetch.Commit = "abc" }, "full commit"}, {"bad package pin", func(a *StepAsk) { a.Packages = PackageVersions{"m": "main"} }, "neither a full commit"}, } { a := goodBuild() c.edit(&a) if _, err := EncodeStepAsk(a); err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("%s: %v, want %q", c.name, err, c.want) } } chk := goodCheck() chk.Check.Levels = []string{"nightly"} if _, err := EncodeStepAsk(chk); err == nil || !strings.Contains(err.Error(), "nightly") { t.Errorf("a level: %v", err) } chk = goodCheck() chk.Check.Toolchain = &ToolchainUsed{Language: "go", Version: "1.26.0", Recipe: "2"} raw, err := EncodeStepAsk(chk) if err != nil { t.Fatal(err) } if back, err := DecodeStepAsk(raw); err != nil || back.Check.Toolchain == nil || back.Check.Toolchain.Version != "1.26.0" { t.Errorf("the check's toolchain is lost in a round trip: %+v, %v", back.Check, err) } chk.Check.Toolchain.Recipe = "" if _, err := EncodeStepAsk(chk); err == nil || !strings.Contains(err.Error(), "toolchain") { t.Errorf("a toolchain without a recipe: %v", err) } chk = goodCheck() chk.Language = AskImage if _, err := EncodeStepAsk(chk); err == nil || !strings.Contains(err.Error(), "no tests") { t.Errorf("an image check: %v", err) } if _, err := DecodeStepAsk([]byte(`{"v":2}`)); !errors.Is(err, ErrUnknownVersion) { t.Errorf("another version: %v", err) } } // The seat's value and the neutral form are the same JSON: AskOf takes any seat's type (here its shape, with a // pointer time), AskAs gives one back. func TestAskOfAnySeatsType(t *testing.T) { type seatAsk struct { V int64 `json:"v,omitempty"` ID string `json:"id"` Kind string `json:"kind"` Lang string `json:"language"` Asker string `json:"asker"` At *time.Time `json:"at,omitempty"` Check *struct { Fetch Fetch `json:"fetch"` Levels []string `json:"levels"` } `json:"check,omitempty"` } at := time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC) s := seatAsk{V: 1, ID: testAskID, Kind: AskCheck, Lang: AskGo, Asker: "mesh-delivery-checker", At: &at} s.Check = &struct { Fetch Fetch `json:"fetch"` Levels []string `json:"levels"` }{Fetch{Repository: "novox/mesh-catalog", Commit: testCommit}, []string{LevelUnit}} a, err := AskOf(s) if err != nil || a.Check == nil || !a.At.Equal(at) || a.Check.Fetch.Commit != testCommit { t.Fatalf("%+v %v", a, err) } back, err := AskAs[seatAsk](a) if err != nil || back.ID != s.ID || !back.At.Equal(at) { t.Fatalf("%+v %v", back, err) } s.Check = nil if _, err := AskOf(s); err == nil { t.Error("an ask with no job is accepted") } } func TestAskSubjectsStreamsAndKeys(t *testing.T) { for _, l := range AskLanguages { for _, k := range AskKinds { subj, err := AskSubject(l, k) if err != nil { t.Fatal(err) } gl, gk, err := ParseAskSubject(subj) if err != nil || gl != l || gk != k { t.Errorf("%s: %s %s %v", subj, gl, gk, err) } } } if got, _ := AskWorker(AskGo, AskCheck); got != "SEAT_NODE_BUILD_GO_CHECK_worker" { t.Errorf("worker %q", got) } if got, _ := AskStream(AskImage); got != "SEAT_NODE_BUILD_IMAGE" { t.Errorf("stream %q", got) } if _, _, err := ParseAskSubject("mesh.seat.node-build-go.accept.nothing"); err == nil { t.Error("an unknown kind parses") } if _, err := AskSubject("rust", AskCheck); err == nil { t.Error("an unknown language has a subject") } if k, err := AskStepKey("ace", testAskID); err != nil || k != "ace."+testAskID { t.Errorf("%q %v", k, err) } if w, _ := AskStepWatch(testAskID); w != "*."+testAskID { t.Errorf("watch %q", w) } if k, _ := PauseKey("ace"); k != "paused.ace" { t.Errorf("pause key %q", k) } if k, _ := CapacityKey("ace"); k != "capacity.ace" { t.Errorf("capacity key %q", k) } if _, err := CapacityKey("a.b"); err == nil { t.Error("a dotted node keys") } } func TestDeclinesNeedsAndLevelDigest(t *testing.T) { if got := Lacks([]string{NeedNodeBuildNode}, nil); len(got) != 1 { t.Errorf("a nil has lacks all: %v", got) } if got := Lacks([]string{NeedNodeBuildNode}, func(string) bool { return true }); len(got) != 0 { t.Errorf("%v", got) } step := Step{State: StepRefused, Result: json.RawMessage(`{"declined":true,"needs":["node-build-node"],"lacks":["node-build-node"]}`)} if d, ok := DeclinedOf(step); !ok || d.Lacks[0] != NeedNodeBuildNode { t.Errorf("%+v %v", d, ok) } step.State = StepFailed if _, ok := DeclinedOf(step); ok { t.Error("a failed step is a decline") } var l LevelAnswer raw := `{"level":"unit","digest":{"packages":{"b":"2","a":"1"},"needs":[{"package":"a","needs":["node-build-node"]}]},"unmet":[{"package":"a","needs":["node-build-node"]}]}` if err := json.Unmarshal([]byte(raw), &l); err != nil { t.Fatal(err) } if p := l.Digest.Packages; len(p) != 2 || p[0].Package != "a" || l.Digest.Map()["b"] != "2" || l.Digest.NeedsOf("a")[0] != NeedNodeBuildNode { t.Errorf("%+v", l.Digest) } if got := UnmetNeeds([]LevelAnswer{l, l}); len(got) != 1 { t.Errorf("%v", got) } } func TestPauseCapacityAndComposerKey(t *testing.T) { raw, err := EncodePause(Pause{Paused: true, By: "operator@shanks", At: time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC)}) if err != nil { t.Fatal(err) } if p, err := DecodePause(raw); err != nil || !p.Paused || p.V != Version { t.Fatalf("%+v %v", p, err) } if _, err := DecodePause([]byte(`{"v":9}`)); !errors.Is(err, ErrUnknownVersion) { t.Errorf("%v", err) } craw, _ := EncodeCapacity(Capacity{Effective: 2, Configured: 2, Why: "fits"}) if c, err := DecodeCapacity(craw); err != nil || c.Effective != 2 { t.Fatalf("%+v %v", c, err) } tc := ToolchainUsed{Language: "go", Version: "1.26.0", Recipe: "1"} if k := ComposerKey("novox/r", testCommit, "d", "p", tc); k != ComposerKey("novox/r", testCommit, "d", "p", tc) || !strings.HasPrefix(k, "composer-") || len(k) != len("composer-")+16 { t.Errorf("composer key %q", k) } if ComposerKey("novox/r", testCommit, "d", "p", tc) == ComposerKey("novox/r", testCommit, "d2", "p", tc) { t.Error("the directory is not in the key") } } // The part `build-step` is one layout for the three build seats: each seat's fixtures read strictly in the neutral // forms (an unknown field, or a field this package lacks, fails). Where the generator cannot share a part, this is // what holds the three seats' types and this form equal. func TestTheThreeSeatsFixturesFitTheNeutralForms(t *testing.T) { strict := func(t *testing.T, raw json.RawMessage, into any) { t.Helper() dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if err := dec.Decode(into); err != nil { t.Error(err) } } seen := 0 for _, seat := range []string{"node-build-go", "node-build-image", "node-build-node"} { raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "seats", seat, "fixtures", "states.json")) if err != nil { t.Fatal(err) } var fixtures []struct { Name string `json:"name"` Of string `json:"of"` Given json.RawMessage `json:"given"` } if err := json.Unmarshal(raw, &fixtures); err != nil { t.Fatal(err) } for _, f := range fixtures { t.Run(seat+"/"+f.Name, func(t *testing.T) { switch { case strings.HasPrefix(f.Of, "accept:"): seen++ strict(t, f.Given, &StepAsk{}) case strings.HasPrefix(f.Of, "state:pauses/paused."): seen++ strict(t, f.Given, &Pause{}) case strings.HasPrefix(f.Of, "state:pauses/capacity."): seen++ strict(t, f.Given, &Capacity{}) case strings.HasPrefix(f.Of, "state:steps/"): seen++ strict(t, f.Given, &Step{}) var st Step if err := json.Unmarshal(f.Given, &st); err == nil && len(st.Result) > 0 { // a step's result keeps what the tool answered, whole (the fetch tool's answer too) strict(t, st.Result, &StepResult{}) } } }) } } if seen < 12 { t.Errorf("only %d fixtures read", seen) } }