package buildstep import ( "context" "encoding/json" "errors" "fmt" "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 } 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} }), 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: 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 } out, err := RunAsk(context.Background(), steps, 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"}}})) if err != nil { t.Fatal(err) } got := strings.Join(r.calls, "\n") want := `copy {"artifact":"base","from":"registry/b@sha256:00","module":"m"} fetch {"commit":"` + 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"}` 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) } a := out.(delivery.BuildAnswer) if len(a.Artifacts) != 3 || a.Artifacts[1].Reference != "artifact-store://bundle" { t.Fatalf("answer %+v", a) } } // A build ask for another toolchain is refused, so its step ends refused and the asker reads why. 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 ref *Refusal if !errors.As(err, &ref) || len(r.calls) != 0 { t.Fatalf("err %v, calls %v", err, r.calls) } } // 1887/A3, A5a, issue 1887 design limit 1: 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 stops at the first failure, running // no later level. 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"}` if got != want { t.Fatalf("calls:\n%s\nwant:\n%s", got, want) } a := out.(delivery.CheckAnswer) if len(a.Levels) != 1 { t.Fatalf("levels %+v, want the unit level alone", 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) != 1 || u.Digest == nil { t.Fatalf("the unit level %+v", u) } } // 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) } }