package check import ( "context" "encoding/json" "errors" "maps" "mesh-delivery-checker/rules" "slices" "strings" "testing" nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" buildsrec "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-sdk/go/catalogue" ) // fakeCompositions answers a check's composition from memory, recording the keys asked. type fakeCompositions struct { v rules.Composed err error asked []string // seats are the seat lists the cases asked carried (Case.Seats), one per ask. seats [][]json.RawMessage // waits answers only when its context ends. waits bool } func (f *fakeCompositions) Compose(ctx context.Context, c *Case) (rules.Composed, error) { f.asked = append(f.asked, c.Key) f.seats = append(f.seats, c.Seats) if f.waits { <-ctx.Done() return rules.Composed{}, ctx.Err() } return f.v, f.err } // composedOn is the engines' answers of a composition by composer on the nodes, each composing it, taking took ms. func composedOn(composer string, took int64, nodes ...string) rules.Composed { c := rules.Composed{Group: "feat/x", Heads: "0123456789ab", Composer: composer, Nodes: nodes, Took: took, Answers: map[string]ne.ComposeCandidateAnswer{}} for _, n := range nodes { c.Answers[n] = ne.ComposeCandidateAnswer{Node: n, Verdict: nec.Composed} } return c } // refusedOn is the engines' answers of a composition the node refuses with the words. func refusedOn(composer, node, words string) rules.Composed { return rules.Composed{Group: "feat/x", Heads: "0123456789ab", Composer: composer, Nodes: []string{node}, Answers: map[string]ne.ComposeCandidateAnswer{node: {Node: node, Verdict: nec.Refused, Refused: words}}} } // 1297/AC3 (its unit half): a check whose tests pass takes the assigner's composition of it, by the check's own key, // into its verdict: a pass says it passed with the mesh-sdk and machines it composed, and is no longer "not run". func TestAPassingCheckTakesTheAssignersComposition(t *testing.T) { t.Run("a change to a build module or base names the modules built on it", aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt) b := newBench(t) f := &fakeCompositions{v: composedOn("v0.1.14-x", 40, "g14")} b.ex.Compositions = f cs := caseOf(t, pullOf(repoA, 7, head1)) cs.FPSet = "fps-of-the-candidate" v := b.judge(t, cs).Verdict if v.State != meshdeliverycheckerclient.VerdictPass || v.Composition == nil || v.Composition.State != meshdeliverycheckerclient.CompositionPass || v.Composition.Sdk != "v0.1.14-x" || v.Took.Composition != 40 { t.Fatalf("verdict = %+v, composition %+v", v, v.Composition) } if len(f.asked) != 1 || f.asked[0] != cs.Key { t.Errorf("asked %v, want the check's key %s", f.asked, cs.Key) } if !compositionMentions(v, "passed") { t.Errorf("the line says %q", compositionWords(v.Composition, v.Took.Composition)) } if v.Composition.Fpset == "" || v.Composition.Fpset != cs.FPSet { t.Errorf("the composition names the fingerprint set %q, want the candidate's %q (ADR 0548 §5)", v.Composition.Fpset, cs.FPSet) } // Issue 1966 (the group try of 2026-11-06 10:07): the composition is handed the check's seat list, the judge's, once the // checker's reader read it; a list not read hands none (the composer then judges no machine, ADR 0636 §1). if len(f.seats) != 1 || f.seats[0] != nil { t.Errorf("with no seat list reader the composition was handed %v, want none", f.seats) } for _, read := range []bool{true, false} { b.ex.SeatList = fakeSeatList{read: read} asked := len(f.seats) rv := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict if read { if got := f.seats[len(f.seats)-1]; len(got) != 2 || string(got[0]) != `{"seat":"a"}` { t.Errorf("with the list read the composition was handed %s, want the reader's two definitions", got) } } else if len(f.seats) != asked || rv.Failed == nil || !strings.Contains(rv.Failed.Words, "the seat list is not read") { // A reader that has not read the list acts on nothing new (ADR 0636 §1): the check ends before any judge. t.Errorf("with the list not read the composition was asked %d more, verdict %+v; want none and a fail naming the list", len(f.seats)-asked, rv.Failed) } } // ADR 0635 §3: at a node-engine member's head a list not read fails the check, naming why, and asks no composer // (a composer handed none would judge no machine and the composition would pass); read, it composes. for _, read := range []bool{false, true} { b.ex.SeatList = fakeSeatList{read: read} asked := len(f.asked) hv := b.judge(t, caseOf(t, pullOf(repoA, 7, head1), pullOf(nodeEngineRepo, 3, head2))).Verdict switch { case !read && (hv.State != meshdeliverycheckerclient.VerdictFail || hv.Failed == nil || !strings.Contains(hv.Failed.Words, "seat list") || len(f.asked) != asked): t.Errorf("at a node-engine head with the list not read: verdict %+v, composition %+v, asked %d more; want a failed composition naming the seat list, no ask", hv, hv.Composition, len(f.asked)-asked) case read && (hv.Composition == nil || hv.Composition.State != meshdeliverycheckerclient.CompositionPass || len(f.asked) != asked+1): t.Errorf("at a node-engine head with the list read: composition %+v, want the composer's pass", hv.Composition) } } b.ex.SeatList = nil f.asked, f.seats = f.asked[:1], f.seats[:1] // ADR 0548 §5: a carry whose fingerprints did not change keeps the composition it passed and asks the assigner nothing. carry := caseOf(t, pullOf(repoA, 7, head1)) carry.CarriedFrom, carry.Composition = map[string]string{repoA: trunkA2}, v.Composition if kept := b.judge(t, carry).Verdict; kept.Composition == nil || kept.Composition.Sdk != "v0.1.14-x" || len(f.asked) != 1 { t.Errorf("the carry's composition is %+v and the assigner was asked %d times; want the kept one and no second ask", kept.Composition, len(f.asked)) } } func compositionMentions(v meshdeliverychecker.VerdictsByGroupHeadsValue, word string) bool { return strings.Contains(compositionWords(v.Composition, v.Took.Composition), word) } // 1297/AC2 at the checker: a failed composition fails the check, naming the machine it failed at and its words. func TestAFailedCompositionFailsTheCheck(t *testing.T) { b := newBench(t) b.ex.Compositions = &fakeCompositions{v: refusedOn("x", "g14", "g14 runs web, which the change removes from its source")} v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict if v.State != meshdeliverycheckerclient.VerdictFail || v.Failed.Machine != "g14" || v.Composition == nil || v.Composition.State != meshdeliverycheckerclient.CompositionFail { t.Fatalf("verdict = %+v", v) } v.Group, v.Heads = "feat/x", "0123456789ab" // the checker names them when it writes the verdict if _, err := meshdeliverycheckerclient.EncodeVerdict(v); err != nil { t.Errorf("the verdict cannot be written: %v", err) } } // 1297/B3d: a composition that cannot be read leaves it not run; a try is not composed. func TestACompositionNotHadIsNotRun(t *testing.T) { b := newBench(t) f := &fakeCompositions{err: errors.New("the check's bound passed")} b.ex.Compositions = f v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict if v.State != meshdeliverycheckerclient.VerdictPass || v.Composition == nil || v.Composition.State != meshdeliverycheckerclient.CompositionNotRun { t.Fatalf("verdict = %+v", v) } b2 := newBench(t) f2 := &fakeCompositions{v: refusedOn("x", "g14", "w")} b2.ex.Compositions = f2 cs := caseOf(t, pullOf(repoA, 7, head1)) cs.Try = &TryOptions{} b2.judge(t, cs) if len(f2.asked) != 0 { t.Errorf("a try asked for a composition: %v", f2.asked) } } // fixedTrunks are trunk heads that never move. type fixedTrunks struct{ heads map[string]string } func (f fixedTrunks) Heads() map[string]string { return f.heads } // fakeEnds answers the builder's builds of a candidate as fakeBuilds holds them, recording the builds waited on; the // composition must not have been asked yet when it is (ADR 0603 §10: the check composes with the builds). type fakeEnds struct { builds fakeBuilds asked map[string]string composed *fakeCompositions early bool } func (f *fakeEnds) Ended(_ context.Context, asked map[string]string) (map[string]buildsrec.BuilderBuild, error) { f.asked = asked f.early = f.composed != nil && len(f.composed.asked) > 0 out := map[string]buildsrec.BuilderBuild{} for module, fp := range asked { if b, ok := f.builds[module+" "+fp]; ok && (b.State == buildsrec.BuildDone || b.State == buildsrec.BuildFailed) { out[module] = b } } return out, nil } func (f *fakeEnds) Done(context.Context, map[string]string) bool { return false } // fixedServing are the verbs the running builds serve. type fixedServing []string func (f fixedServing) Served(context.Context) (func(string) bool, error) { return func(v string) bool { return slices.Contains(f, v) }, nil } // knownRuns are the modules the trunk holds a record of (the indexer's modules): any commit. type knownRuns []string func (k knownRuns) CommitOf(module string) string { if slices.Contains(k, module) { return trunkA } return "" } // ADR 0603 §10: before it composes, the check waits on the builder's builds of the candidate's changed modules and composes // with them; a failed build fails the check naming it, and one not ended within the bound is named. It does not wait on a // module built by a build module the change alters, nor on one standing on a base the change alters, nor on one the trunk // holds no record of, and names each in the composition's warnings (and so the check's line); a change that alters none of // these says nothing. func aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt(t *testing.T) { dig := func(c string) string { return strings.Repeat(c, 64) } fp := "src2:" + dig("f") goBuild := &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "bin", Kind: catalogue.ArtifactBundle, Language: "go"}}} tsBuild := &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "app", Kind: catalogue.ArtifactBundle, Language: "typescript"}}} done := buildsrec.BuilderBuild{State: buildsrec.BuildDone} for _, row := range []struct { name string manifest map[string]catalogue.Manifest builds map[string]buildsrec.BuilderBuild // by module, at fp; nil leaves the check without the builds' ends runs knownRuns // nil: the trunk holds every module want []string waited string // the modules waited on, sorted failed string // the failure's test, when the check fails on a build tests bool // the tests fail: the check fails before it would wait served []string // what the running builds serve (ADR 0614 §7); nil reads nothing of what runs preview bool // the check began on the candidate the indexer wrote before its merge preview (issue 1921) late bool // a build did not end within the bound: the change is not composed (issue 1921) }{ {name: "the Go build module invokes a verb no running build serves", manifest: map[string]catalogue.Manifest{"mesh-build-go": {Module: "mesh-build-go", Build: goBuild, Invokes: []string{"seat:package-registry.publish", "seat:artifact-store.put"}}, "web": {Module: "web", Build: goBuild}}, builds: map[string]buildsrec.BuilderBuild{"mesh-build-go": done}, served: []string{"seat:artifact-store.put"}, want: []string{"the changed build module mesh-build-go invokes seat:package-registry.publish, which no running build serves, " + "so the check cannot run it: web, which it builds, are composed from their manifests (ADR 0614 §7)"}, waited: "mesh-build-go"}, {name: "the Go build module changes", manifest: map[string]catalogue.Manifest{"mesh-build-go": {Module: "mesh-build-go", Build: goBuild}, "web": {Module: "web", Build: goBuild}, "mail": {Module: "mail", Build: goBuild}, "docs": {Module: "docs"}}, builds: map[string]buildsrec.BuilderBuild{"mesh-build-go": done, "docs": done}, want: []string{"the build module mesh-build-go: mail, web, which it builds"}, waited: "docs, mesh-build-go"}, {name: "the Node build module changes", manifest: map[string]catalogue.Manifest{"mesh-build-node": {Module: "mesh-build-node", Build: goBuild}, "panel": {Module: "panel", Build: tsBuild}, "web": {Module: "web", Build: goBuild}}, builds: map[string]buildsrec.BuilderBuild{"mesh-build-node": done, "web": done}, want: []string{"the build module mesh-build-node: panel, which it builds"}, waited: "mesh-build-node, web"}, {name: "a base changes", manifest: map[string]catalogue.Manifest{"image": {Module: "image"}, "web": {Module: "web", Build: &catalogue.Build{On: []catalogue.BuildsOn{{Arg: "BASE", Module: "image", Artifact: "img"}}}}}, builds: map[string]buildsrec.BuilderBuild{"image": done}, want: []string{"alters image, a base of web", "waits on the build of image alone"}, waited: "image"}, {name: "neither changes", manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}}, builds: map[string]buildsrec.BuilderBuild{"web": done}, waited: "web"}, {name: "a build fails", manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}, "mail": {Module: "mail", Build: goBuild}}, builds: map[string]buildsrec.BuilderBuild{"web": {State: buildsrec.BuildFailed, Error: "go build: undefined: x"}, "mail": done}, waited: "mail, web", failed: "build: web"}, {name: "the tests fail", manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}}, builds: map[string]buildsrec.BuilderBuild{"web": {State: buildsrec.BuildBuilding}}, tests: true, want: []string{"composes at once, without waiting on the builds of web"}}, {name: "a build does not end within the bound", manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}}, builds: map[string]buildsrec.BuilderBuild{"web": {State: buildsrec.BuildBuilding}}, late: true, want: []string{"the builder's builds of web did not end within the build-wait-bound", "the change is not composed"}, waited: "web"}, {name: "the check began before the merge preview", manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}}, builds: map[string]buildsrec.BuilderBuild{"web": done}, preview: true, waited: "web"}, {name: "a module the trunk does not hold", manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}, "new": {Module: "new", Build: goBuild}}, builds: map[string]buildsrec.BuilderBuild{"web": done}, runs: knownRuns{"web"}, want: []string{"new: the trunk holds no record of it yet"}, waited: "web"}, {name: "the builds cannot be heard", manifest: map[string]catalogue.Manifest{"web": {Module: "web", Build: goBuild}}, want: []string{"the builds of web cannot be heard"}}, } { b := candidateBench(t, true) if row.tests { b.test = func(c stepCall) (any, error) { r := captured[Tested](t, "go-test-failing") r.Level, _ = c.args["level"].(string) return r, nil } } comps := &fakeCompositions{v: composedOn("x", 0, "g14")} b.ex.Compositions = comps var ends *fakeEnds if row.builds != nil { ends = &fakeEnds{builds: fakeBuilds{}, composed: comps} for module, build := range row.builds { ends.builds[module+" "+fp] = build } b.ex.BuildEnds = ends } if row.runs != nil { b.ex.Runs = row.runs } if row.served != nil { b.ex.Serving = fixedServing(row.served) } read, fps := fixedManifests{}, map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue{} for module, m := range row.manifest { d := dig(string(rune('a' + len(read)))) read[d], fps[module] = m, meshcatalog.CandidatesByGroupValuePullsItemFpsValue{Fp2: fp, Manifest: d} } b.ex.Read = read p := pullOf(catalogueRepository, 9, head1) p.Fps, p.Modules = fps, slices.Sorted(maps.Keys(fps)) if row.preview { previewed := p b.ex.Candidates = &fakeTryCandidates{cand: meshcatalog.CandidatesByGroupValue{Fpset: "fps", Pulls: []meshcatalog.CandidatesByGroupValuePullsItem{previewed}}} p.Fps = map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue{} for module, m := range fps { p.Fps[module] = meshcatalog.CandidatesByGroupValuePullsItemFpsValue{Manifest: m.Manifest, None2: meshcatalogclient.NoneNoPreview} } } v := b.judge(t, caseOf(t, p)).Verdict asks := 1 if row.late { asks = 0 // never asked: an engine would read the builder's lateness as the change's refusal } if v.Composition == nil || len(comps.asked) != asks || row.late && v.Composition.State != meshdeliverycheckerclient.CompositionNotRun { t.Fatalf("%s: composed %d times: %+v", row.name, len(comps.asked), v) } if ends != nil { if got := strings.Join(slices.Sorted(maps.Keys(ends.asked)), ", "); got != row.waited || ends.early { t.Errorf("%s: waited on %q (after the composition: %v), want %q before it composes", row.name, got, ends.early, row.waited) } } switch { case row.failed != "" && (v.State != meshdeliverycheckerclient.VerdictFail || v.Failed.Test != row.failed || !strings.Contains(v.Failed.Words, "undefined: x") || !strings.Contains(v.Failed.Words, "the check asks itself again when the build is done")): t.Errorf("%s: the verdict is %s failing %+v, want it failed naming %s, the builder's words and how it is asked again", row.name, v.State, v.Failed, row.failed) case row.tests && v.State != meshdeliverycheckerclient.VerdictFail: t.Errorf("%s: %+v, want the tests' failure", row.name, v) case row.failed == "" && !row.tests && v.State != meshdeliverycheckerclient.VerdictPass: t.Errorf("%s: %+v", row.name, v) } words := compositionWords(v.Composition, v.Took.Composition) for _, w := range row.want { if !strings.Contains(words, w) { t.Errorf("%s: the line says %q, want it to name %q", row.name, words, w) } } if row.want == nil && v.Composition.Words != "" { t.Errorf("%s: warned %q though the check waited on every build of the change", row.name, v.Composition.Words) } } } func ptr[T any](v T) *T { return &v }