Quick fix chosen (in askrun.go, the one place every build module's check ask runs): own-test packages already ran first, but the rest ran after their failure (issue 1921). 1921 still holds past them.
317 lines
15 KiB
Go
317 lines
15 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 and, when one of them fails, no other (issue 1289), 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"}`
|
|
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 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: a failure in the packages whose tests changed ends the level there, the others are not run, and no later level
|
|
// runs. Issue 1921: past the change's own packages every package still runs, so two failing packages none of which the
|
|
// change touched are two test results of the one level. A Node check does the same with its module directories.
|
|
func TestRunAskStopsAtTheChangesOwnFailureAndRunsTheRestAfterOthers(t *testing.T) {
|
|
for _, row := range []struct {
|
|
name string
|
|
changed []string
|
|
tests map[string]bool
|
|
want []string // the packages tested, in order
|
|
}{
|
|
{"the change's own test fails: nothing else runs", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/b"}},
|
|
{"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, " "))
|
|
}
|
|
}
|
|
// the passing row goes on to the integration level, which tests the same packages again; the first level is what is judged
|
|
if n := len(row.want); len(got) < n || fmt.Sprint(got[:n]) != fmt.Sprint(row.want) {
|
|
t.Errorf("%s: tested %v, want %v first", row.name, got, row.want)
|
|
}
|
|
a := out.(delivery.CheckAnswer)
|
|
failed := false
|
|
for _, raw := range a.Levels[0].Tests {
|
|
var res struct{ Passed bool }
|
|
_ = json.Unmarshal(raw, &res)
|
|
failed = failed || !res.Passed
|
|
}
|
|
if failed && (len(a.Levels) != 1 || len(got) != len(row.want)) {
|
|
t.Errorf("%s: a failed level went on: levels %d, tests %v", row.name, len(a.Levels), got)
|
|
}
|
|
}
|
|
|
|
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) != 2 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 1 {
|
|
t.Fatalf("calls %v, levels %+v: want the changed directory b alone and no integration level", r.calls, a.Levels)
|
|
}
|
|
}
|