286 lines
13 KiB
Go
286 lines
13 KiB
Go
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
|
|
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: 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: 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, then the rest also after a failure (issue 1921), and runs no
|
|
// level after one that did not pass (ADR 0484 clause 13).
|
|
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"}`
|
|
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) != 2 || u.Digest == nil {
|
|
t.Fatalf("the unit level %+v", u)
|
|
}
|
|
|
|
// 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 1921: a failure in the packages whose tests changed does not stop the level: the other packages still run, so two
|
|
// failing packages, one changed and one not, are two test results of the one level, and the level does not pass. A Node
|
|
// check runs every module directory after one fails too.
|
|
func TestRunAskRunsEveryPackageAfterAFailure(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"},
|
|
tests: map[string]bool{"x/pkg/a": false, "x/pkg/b": false},
|
|
}
|
|
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelIntegration, delivery.LevelUnit},
|
|
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a := out.(delivery.CheckAnswer)
|
|
if len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
|
|
t.Fatalf("levels %+v, want the one level with both packages' results", a.Levels)
|
|
}
|
|
for i, raw := range a.Levels[0].Tests {
|
|
var res struct{ Passed bool }
|
|
if err := json.Unmarshal(raw, &res); err != nil || res.Passed {
|
|
t.Errorf("result %d %s, want a failure", i, raw)
|
|
}
|
|
}
|
|
|
|
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) != 3 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
|
|
t.Fatalf("calls %v, levels %+v: want both directories of the unit level and no integration level", r.calls, a.Levels)
|
|
}
|
|
}
|