A build ask whose job built an artifact asks the new step build_go_imports once: `go list -deps` of each Go bundle's program, no tests, the forge's packages outside the module's own directory (a sibling package of the same repository counts), sorted and each once. The answer carries them as BuildAnswer.Imports. The step is also a tool, to read them by hand. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
337 lines
17 KiB
Go
337 lines
17 KiB
Go
package buildstep
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
const runCommit = "0123456789abcdef0123456789abcdef01234567"
|
|
|
|
// recorder is a build module's step tools that record each call.
|
|
type recorder struct {
|
|
calls []string
|
|
fetch map[string]any
|
|
tests map[string]bool // package or module directory -> passed
|
|
digests map[string]string
|
|
needs []delivery.PackageNeeds
|
|
}
|
|
|
|
func (r *recorder) steps() AskSteps {
|
|
call := func(name string, answer func(args map[string]any) any) StepFunc {
|
|
return func(ctx context.Context, args map[string]any) (any, error) {
|
|
b, _ := json.Marshal(args)
|
|
r.calls = append(r.calls, name+" "+string(b))
|
|
return answer(args), nil
|
|
}
|
|
}
|
|
return AskSteps{
|
|
Fetch: call("fetch", func(map[string]any) any { return r.fetch }),
|
|
Build: call("build", func(args map[string]any) any {
|
|
return map[string]any{"digest": "sha256:" + args["artifact"].(string), "reference": "artifact-store://" + args["artifact"].(string)}
|
|
}),
|
|
Copy: call("copy", func(args map[string]any) any { return map[string]any{"digest": "sha256:copied"} }),
|
|
Digest: call("digest", func(map[string]any) any {
|
|
return map[string]any{"level": "unit", "packages": r.digests, "needs": r.needs}
|
|
}),
|
|
Test: call("test", func(args map[string]any) any {
|
|
passed := true
|
|
if pkgs, ok := args["packages"].([]string); ok {
|
|
for _, p := range pkgs {
|
|
passed = passed && r.tests[p]
|
|
}
|
|
}
|
|
if dir, ok := args["module"].(string); ok {
|
|
passed = r.tests[dir]
|
|
}
|
|
return map[string]any{"passed": passed, "tests": 1}
|
|
}),
|
|
Kept: func(ctx context.Context, level, digest string) (int, bool, error) { return 7, digest == "d-kept", nil },
|
|
}
|
|
}
|
|
|
|
func ask(job any) delivery.StepAsk {
|
|
a := delivery.StepAsk{ID: sdkstep.NewID(), Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: "t", At: time.Now()}
|
|
switch j := job.(type) {
|
|
case delivery.BuildJob:
|
|
a.Build = &j
|
|
case delivery.CheckJob:
|
|
a.Check = &j
|
|
}
|
|
return a
|
|
}
|
|
|
|
// 1887/A3: a build ask fetches once, builds each artifact from the workspace with publish, copies a pinned one without a
|
|
// fetch, and answers each artifact's digest and reference.
|
|
func TestRunAskBuilds(t *testing.T) {
|
|
r := &recorder{fetch: map[string]any{"workspace": "/w/1"}}
|
|
steps := r.steps()
|
|
var released []string
|
|
steps.Release = func(ws string) error { released = append(released, ws); return nil }
|
|
// ADR 0593 §2: a Go module's build ends by saying which mesh packages its programs import.
|
|
steps.Imports = func(ctx context.Context, args map[string]any) (any, error) {
|
|
b, _ := json.Marshal(args)
|
|
r.calls = append(r.calls, "imports "+string(b))
|
|
return map[string]any{"imports": []string{"git.novox.be/novox/mesh-sdk/go/announce", "git.novox.be/novox/mesh-sdk/go/delivery"}}, nil
|
|
}
|
|
// Issue 1585: the ask's package versions reach the fetch, and the ask's own fetch arguments stay as they were.
|
|
a := ask(delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true,
|
|
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
|
|
Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}})
|
|
a.Packages = delivery.PackageVersions{"git.novox.be/novox/mesh-sdk/go": runCommit}
|
|
out, err := RunAsk(context.Background(), steps, a)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, put := a.Build.Fetch["packages"]; put {
|
|
t.Error("the ask's fetch arguments were changed")
|
|
}
|
|
got := strings.Join(r.calls, "\n")
|
|
want := `copy {"artifact":"base","from":"registry/b@sha256:00","module":"m"}
|
|
fetch {"commit":"` + runCommit + `","packages":{"git.novox.be/novox/mesh-sdk/go":"` + runCommit + `"},"repository":"novox/x"}
|
|
build {"artifact":"bundle","module":"modules/m","publish":true,"workspace":"/w/1"}
|
|
build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}
|
|
imports {"module":"modules/m","workspace":"/w/1"}`
|
|
if got != want {
|
|
t.Fatalf("calls:\n%s\nwant:\n%s", got, want)
|
|
}
|
|
if len(released) != 1 || released[0] != "/w/1" {
|
|
t.Errorf("released %v, want the one workspace", released)
|
|
}
|
|
answer := out.(delivery.BuildAnswer)
|
|
if len(answer.Artifacts) != 3 || answer.Artifacts[1].Reference != "artifact-store://bundle" {
|
|
t.Fatalf("answer %+v", answer)
|
|
}
|
|
if !slices.Equal(answer.Imports, []string{"git.novox.be/novox/mesh-sdk/go/announce", "git.novox.be/novox/mesh-sdk/go/delivery"}) {
|
|
t.Errorf("the answer's imports are %v", answer.Imports)
|
|
}
|
|
}
|
|
|
|
// A build ask for another toolchain is declined (ADR 0584 clause 2), so the node sits out and the asker puts it again.
|
|
func TestRunAskRefusesAnotherToolchain(t *testing.T) {
|
|
r := &recorder{fetch: map[string]any{"workspace": "/w"}}
|
|
s := r.steps()
|
|
s.Toolchain = func(context.Context) (delivery.ToolchainUsed, error) {
|
|
return delivery.ToolchainUsed{Version: "go1.27", Recipe: "3"}, nil
|
|
}
|
|
_, err := RunAsk(context.Background(), s, ask(delivery.BuildJob{Module: "m", Path: ".", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
|
|
Artifacts: []delivery.AskArtifact{{Artifact: "bundle"}}, Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}}))
|
|
var dec *Declining
|
|
if !errors.As(err, &dec) || len(dec.Lacks) != 1 || len(r.calls) != 0 {
|
|
t.Fatalf("err %v, calls %v", err, r.calls)
|
|
}
|
|
// A check ask names the toolchain too (ADR 0584), and a node on another declines it before it fetches.
|
|
_, err = RunAsk(context.Background(), s, ask(delivery.CheckJob{Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}, Levels: []string{delivery.LevelUnit},
|
|
Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}}))
|
|
if !errors.As(err, &dec) || len(r.calls) != 0 {
|
|
t.Fatalf("a check ask: err %v, calls %v", err, r.calls)
|
|
}
|
|
}
|
|
|
|
// 1887/A3, A5a: a check ask runs the levels of one check after one fetch: it digests the unit level, takes the kept
|
|
// verdicts, tests the packages whose tests changed first and then the others, also after one of them failed, and runs
|
|
// every level, also after one that did not pass, so one check names every failure (the operator, 2026-11-02).
|
|
func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
|
|
r := &recorder{
|
|
fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}},
|
|
digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b", "x/pkg/k": "d-kept"},
|
|
tests: map[string]bool{"x/pkg/a": true, "x/pkg/b": false},
|
|
}
|
|
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration}, Kept: true,
|
|
Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit, "base": runCommit}}))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := strings.Join(r.calls, "\n")
|
|
want := `fetch {"base":"` + runCommit + `","commit":"` + runCommit + `","repository":"novox/x"}
|
|
digest {"level":"unit","workspace":"/w"}
|
|
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}
|
|
test {"level":"unit","packages":["x/pkg/a"],"workspace":"/w"}
|
|
digest {"level":"integration","workspace":"/w"}
|
|
test {"level":"integration","packages":["x/pkg/b"],"workspace":"/w"}
|
|
test {"level":"integration","packages":["x/pkg/a"],"workspace":"/w"}`
|
|
if got != want {
|
|
t.Fatalf("calls:\n%s\nwant:\n%s", got, want)
|
|
}
|
|
a := out.(delivery.CheckAnswer)
|
|
if len(a.Levels) != 2 {
|
|
t.Fatalf("levels %+v, want both levels", a.Levels)
|
|
}
|
|
u := a.Levels[0]
|
|
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 2 || u.Digest == nil {
|
|
t.Fatalf("the unit level %+v", u)
|
|
}
|
|
|
|
// ADR 0567 §12: a check's node names reach the digest and test tools, for the names check (a kept verdict misses when
|
|
// they change).
|
|
r.calls = nil
|
|
if _, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, NodeNames: []string{"anchor"},
|
|
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})); err != nil || !strings.Contains(strings.Join(r.calls, "\n"), `digest {"level":"unit","node-names":["anchor"]`) ||
|
|
!strings.Contains(strings.Join(r.calls, "\n"), `test {"level":"unit","node-names":["anchor"]`) {
|
|
t.Fatalf("the node names are not handed to the digest and test tools: %v, %v", r.calls, err)
|
|
}
|
|
|
|
// Issue 890/AC2: a package declaring node-build-node is not tested on a node that lacks it but named unmet, and is
|
|
// tested on a node that has it.
|
|
for _, has := range []bool{false, true} {
|
|
r.calls, r.tests["x/pkg/b"] = nil, true
|
|
r.needs = []delivery.PackageNeeds{{Package: "x/pkg/a", Needs: []string{delivery.NeedNodeBuildNode}}}
|
|
steps := r.steps()
|
|
if has {
|
|
steps.Has = func(n string) bool { return n == delivery.NeedNodeBuildNode }
|
|
}
|
|
out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true,
|
|
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u := out.(delivery.CheckAnswer).Levels[0]
|
|
tested := strings.Contains(strings.Join(r.calls, "\n"), `"x/pkg/a"`)
|
|
if tested != has || (len(u.Unmet) == 0) == !has || (!has && (u.Unmet[0].Package != "x/pkg/a" || delivery.UnmetNeeds([]delivery.LevelAnswer{u})[0] != delivery.NeedNodeBuildNode)) {
|
|
t.Fatalf("has %v: tested x/pkg/a %v, unmet %+v, calls %v", has, tested, u.Unmet, r.calls)
|
|
}
|
|
}
|
|
// A placed ask naming its packages tests those alone and takes no other's kept verdict, also when the level would
|
|
// run whole (ADR 0552 §4).
|
|
for _, digests := range []map[string]string{r.digests, nil} {
|
|
r.calls, r.digests = nil, digests
|
|
steps := r.steps()
|
|
steps.Has = func(string) bool { return true }
|
|
out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true, Packages: []string{"x/pkg/a"},
|
|
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u := out.(delivery.CheckAnswer).Levels[0]
|
|
if last := r.calls[len(r.calls)-1]; !strings.HasSuffix(last, `"packages":["x/pkg/a"],"workspace":"/w"}`) || len(u.Kept) != 0 || len(u.Tests) != 1 {
|
|
t.Fatalf("digests %v: calls %v, kept %+v: want x/pkg/a alone tested, nothing taken kept", digests != nil, r.calls, u.Kept)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ADR 0535 §2, §4: a merge that changes the build module's own code is judged by the candidate's runner: its digest says
|
|
// the candidate ran, and every package runs with nothing kept; an installed module that ignores the runner is planned as
|
|
// any other, the runner dropped. A digest that does not cover the fetch's siblings runs the level whole too.
|
|
func TestRunAskRunsWholeUnderTheRunnerOrWithoutTheSiblings(t *testing.T) {
|
|
for _, tc := range []struct{ judged, want string }{
|
|
{"candidate 999", `digest {"level":"unit","runner":"workspace","workspace":"/w"}
|
|
test {"level":"unit","runner":"workspace","workspace":"/w"}`},
|
|
{"installed", `digest {"level":"unit","runner":"workspace","workspace":"/w"}`},
|
|
} {
|
|
r := &recorder{fetch: map[string]any{"workspace": "/w", "changed": []string{"modules/mesh-build-go/x.go"}},
|
|
digests: map[string]string{"x/pkg/a": "d-kept"}, tests: map[string]bool{}}
|
|
steps := r.steps()
|
|
digest := steps.Digest
|
|
steps.Digest = func(ctx context.Context, args map[string]any) (any, error) {
|
|
_, _ = digest(ctx, args)
|
|
return map[string]any{"level": "unit", "packages": r.digests, "judged_by": tc.judged}, nil
|
|
}
|
|
out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true,
|
|
Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := strings.Join(r.calls[1:], "\n"); got != tc.want {
|
|
t.Errorf("judged by %s: calls\n%s\nwant\n%s", tc.judged, got, tc.want)
|
|
}
|
|
l := out.(delivery.CheckAnswer).Levels[0]
|
|
if tc.judged == "installed" && (len(l.Kept) != 1 || l.Judged != "") {
|
|
t.Errorf("an installed module's level %+v, want the kept verdict taken", l)
|
|
}
|
|
if tc.judged != "installed" && (len(l.Kept) != 0 || l.Digest != nil || l.Judged != tc.judged) {
|
|
t.Errorf("the candidate's level %+v, want nothing kept and the program named", l)
|
|
}
|
|
}
|
|
r := &recorder{fetch: map[string]any{"workspace": "/w"}, digests: map[string]string{"x/pkg/a": "d-kept"}, tests: map[string]bool{}}
|
|
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true,
|
|
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit, "beside": []any{map[string]any{"repository": "novox/mesh-sdk", "commit": runCommit}}}}))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if l := out.(delivery.CheckAnswer).Levels[0]; len(l.Kept) != 0 || l.Digest != nil {
|
|
t.Fatalf("a digest without the siblings kept %+v", l)
|
|
}
|
|
}
|
|
|
|
// A Node check tests each module directory, the one whose tests changed first; a conflict ends the ask with the fetch
|
|
// alone, a verdict for the asker.
|
|
func TestRunAskChecksNodeModulesAndConflicts(t *testing.T) {
|
|
r := &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
|
|
tests: map[string]bool{"a": true, "b": true}}
|
|
s := r.steps()
|
|
s.Digest = nil
|
|
if _, err := RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fmt.Sprint(r.calls[1:]) != `[test {"level":"unit","module":"b","workspace":"/w"} test {"level":"unit","module":"a","workspace":"/w"}]` {
|
|
t.Fatalf("calls %v", r.calls)
|
|
}
|
|
r = &recorder{fetch: map[string]any{"workspace": "/w", "conflicts": []string{"go.mod"}}}
|
|
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
|
if err != nil || len(r.calls) != 1 || len(out.(delivery.CheckAnswer).Levels) != 0 {
|
|
t.Fatalf("a conflict: %v %v", err, r.calls)
|
|
}
|
|
}
|
|
|
|
// Issue 1289: the packages whose tests changed run first. Since 2026-11-02 (the operator: "A failed check shows only its
|
|
// first fault") a failure among them ends nothing: every package of the level and every level still runs, so one check
|
|
// names every failing test (issue 1921). A Node check does the same with its module directories.
|
|
func TestRunAskRunsEveryPackageAndLevelAfterAFailure(t *testing.T) {
|
|
for _, row := range []struct {
|
|
name string
|
|
changed []string
|
|
tests map[string]bool
|
|
want []string // the packages tested at each level, in order
|
|
}{
|
|
{"the change's own test fails: the rest still runs", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/b", "x/pkg/a"}},
|
|
{"the change's own test passes: the rest runs, also after a failure", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": true, "x/pkg/c": false}, []string{"x/pkg/b", "x/pkg/a x/pkg/c"}},
|
|
{"no test changed: every failure is named", nil, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/a x/pkg/b"}},
|
|
} {
|
|
r := &recorder{fetch: map[string]any{"workspace": "/w", "changed": row.changed}, tests: row.tests,
|
|
digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b"}}
|
|
if _, ok := row.tests["x/pkg/c"]; ok {
|
|
r.digests["x/pkg/c"] = "d-c"
|
|
}
|
|
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
|
|
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var got []string
|
|
for _, c := range r.calls[1:] {
|
|
if strings.HasPrefix(c, "test ") {
|
|
var a struct{ Packages []string }
|
|
_ = json.Unmarshal([]byte(strings.TrimPrefix(c, "test ")), &a)
|
|
got = append(got, strings.Join(a.Packages, " "))
|
|
}
|
|
}
|
|
if want := append(slices.Clone(row.want), row.want...); fmt.Sprint(got) != fmt.Sprint(want) {
|
|
t.Errorf("%s: tested %v, want %v", row.name, got, want)
|
|
}
|
|
if a := out.(delivery.CheckAnswer); len(a.Levels) != 2 || len(a.Levels[0].Tests) != len(row.want) {
|
|
t.Errorf("%s: levels %+v, want both, each with every test result", row.name, a.Levels)
|
|
}
|
|
}
|
|
|
|
r := &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
|
|
tests: map[string]bool{"a": true, "b": false}}
|
|
s := r.steps()
|
|
s.Digest = nil
|
|
out, err := RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
|
|
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if a := out.(delivery.CheckAnswer); len(r.calls) != 5 || len(a.Levels) != 2 || len(a.Levels[0].Tests) != 2 {
|
|
t.Fatalf("calls %v, levels %+v: want b then a at each of the two levels", r.calls, a.Levels)
|
|
}
|
|
}
|