A failed candidate build no longer needs mesh-delivery.recheck, an exception verb (ADR 0421 §12): the checker hears the builder's keys the failed verdict names and, once each is written done (builder_build_again), retakes the check of the same heads as it does after a failure of the nodes, at its start too. When the tests have already failed the check composes at once and names the builds it did not wait on, so its red status is never held back by a build. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
251 lines
12 KiB
Go
251 lines
12 KiB
Go
package check
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"maps"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
// fakeCompositions answers a check's composition from memory, recording the keys asked.
|
|
type fakeCompositions struct {
|
|
v delivery.CompositionVerdict
|
|
err error
|
|
asked []string
|
|
// waits answers only when its context ends.
|
|
waits bool
|
|
}
|
|
|
|
func (f *fakeCompositions) Compose(ctx context.Context, c *Case) (delivery.CompositionVerdict, error) {
|
|
f.asked = append(f.asked, c.Key)
|
|
if f.waits {
|
|
<-ctx.Done()
|
|
return delivery.CompositionVerdict{}, ctx.Err()
|
|
}
|
|
return f.v, f.err
|
|
}
|
|
|
|
// 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: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.CompositionPass, SDK: "v0.1.14-x", Machines: []string{"g14"}, Took: 40}}
|
|
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 != delivery.VerdictPass || v.Composition == nil || v.Composition.State != delivery.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)
|
|
}
|
|
// 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 delivery.Verdict, 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: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.CompositionFail, SDK: "x",
|
|
Machine: "g14", Words: "g14 runs web, which the change removes from its source"}}
|
|
v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict
|
|
if v.State != delivery.VerdictFail || v.Failed.Machine != "g14" || v.Composition == nil || v.Composition.State != delivery.CompositionFail {
|
|
t.Fatalf("verdict = %+v", v)
|
|
}
|
|
v.Group, v.Heads = "feat/x", "0123456789ab" // the checker names them when it writes the verdict
|
|
if _, err := delivery.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 != delivery.VerdictPass || v.Composition == nil || v.Composition.State != delivery.CompositionNotRun {
|
|
t.Fatalf("verdict = %+v", v)
|
|
}
|
|
b2 := newBench(t)
|
|
f2 := &fakeCompositions{v: delivery.CompositionVerdict{State: delivery.CompositionFail, Words: "w", SDK: "x"}}
|
|
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, builds map[string]string) (map[string]delivery.BuilderBuild, error) {
|
|
f.asked = builds
|
|
f.early = f.composed != nil && len(f.composed.asked) > 0
|
|
out := map[string]delivery.BuilderBuild{}
|
|
for module, fp := range builds {
|
|
if b, ok := f.builds[module+" "+fp]; ok && (b.State == delivery.BuildDone || b.State == delivery.BuildFailed) {
|
|
out[module] = b
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeEnds) Done(context.Context, map[string]string) bool { return false }
|
|
|
|
// 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 := delivery.BuilderBuild{State: delivery.BuildDone}
|
|
for _, row := range []struct {
|
|
name string
|
|
manifest map[string]catalogue.Manifest
|
|
builds map[string]delivery.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
|
|
}{
|
|
{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]delivery.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]delivery.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]delivery.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]delivery.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]delivery.BuilderBuild{"web": {State: delivery.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]delivery.BuilderBuild{"web": {State: delivery.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]delivery.BuilderBuild{"web": {State: delivery.BuildBuilding}},
|
|
want: []string{"the builds of web did not end within the build-wait-bound"}, 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]delivery.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: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.CompositionPass, SDK: "x", Machines: []string{"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
|
|
}
|
|
read, fps := fixedManifests{}, map[string]delivery.CandidateModule{}
|
|
for module, m := range row.manifest {
|
|
d := dig(string(rune('a' + len(read))))
|
|
read[d], fps[module] = m, delivery.CandidateModule{FP2: fp, Manifest: d}
|
|
}
|
|
b.ex.Read = read
|
|
p := pullOf(catalogueRepository, 9, head1)
|
|
p.Fps, p.Modules = fps, slices.Sorted(maps.Keys(fps))
|
|
v := b.judge(t, caseOf(t, p)).Verdict
|
|
if v.Composition == nil || len(comps.asked) != 1 {
|
|
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 != delivery.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 != delivery.VerdictFail:
|
|
t.Errorf("%s: %+v, want the tests' failure", row.name, v)
|
|
case row.failed == "" && !row.tests && v.State != delivery.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)
|
|
}
|
|
}
|
|
}
|