From 2462f96f65ae19294bb01a66f8d0372bbefbdd46 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 20:54:52 +0000 Subject: [PATCH] Add the Go build module's test step with its unit tests and the guard tests (issues 1290, 1483), part 11 of 20 --- .../cmd/mesh-build-go/module_test.go | 201 +++++++++++++++ .../mesh-build-go/cmd/mesh-build-go/test.go | 143 +++++++++++ .../cmd/mesh-build-go/test_test.go | 243 ++++++++++++++++++ 3 files changed, 587 insertions(+) create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/module_test.go create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/test.go create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/test_test.go diff --git a/modules/mesh-build-go/cmd/mesh-build-go/module_test.go b/modules/mesh-build-go/cmd/mesh-build-go/module_test.go new file mode 100644 index 00000000..6e1035fe --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/module_test.go @@ -0,0 +1,201 @@ +package main + +import ( + "context" + "encoding/json" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/health" +) + +// keptStates stands in for the toolchains and the steps states. +type keptStates struct { + mu sync.Mutex + toolchains map[string]delivery.Toolchain + steps map[string][]delivery.Step + ended chan string +} + +func newStates() *keptStates { + return &keptStates{toolchains: map[string]delivery.Toolchain{}, steps: map[string][]delivery.Step{}, ended: make(chan string, 16)} +} + +func (s *keptStates) putToolchain(key string, value any) (uint64, error) { + tc, err := delivery.DecodeToolchain(value.(json.RawMessage)) + if err != nil { + return 0, err + } + s.mu.Lock() + defer s.mu.Unlock() + s.toolchains[key] = tc + return 1, nil +} + +func (s *keptStates) putStep(key string, value any) (uint64, error) { + st, err := delivery.DecodeStep(value.(json.RawMessage)) + if err != nil { + return 0, err + } + s.mu.Lock() + s.steps[key] = append(s.steps[key], st) + s.mu.Unlock() + if st.State != delivery.StepRunning { + s.ended <- key + } + return 1, nil +} + +func (s *keptStates) toolchain(key string) (delivery.Toolchain, bool) { + s.mu.Lock() + defer s.mu.Unlock() + tc, ok := s.toolchains[key] + return tc, ok +} + +func (s *keptStates) waitEnd(t *testing.T) delivery.Step { + t.Helper() + select { + case key := <-s.ended: + s.mu.Lock() + defer s.mu.Unlock() + v := s.steps[key] + return v[len(v)-1] + case <-time.After(3 * time.Second): + t.Fatal("the step never ended on its key") + return delivery.Step{} + } +} + +func moduleWith(states *keptStates, installed string) *Module { + return &Module{Node: "ace", Recipe: Recipe{Steps: "1", Go: "go1.27.1", PostgresMajor: 18, NatsServer: "2.11.17"}, + Probe: func(context.Context) (string, error) { return installed, nil }, + Toolchains: states.putToolchain, Steps: states.putStep, Ctx: context.Background()} +} + +// 1483/AC2: a probe answering go1.27.1: the bundle's start writes ace.go with that version and the recipe's, and +// the health tool answers healthy. +func TestStartWritesTheToolchainAndHealthIsHealthy(t *testing.T) { + states := newStates() + m := moduleWith(states, "go1.27.1") + ctx, stop := context.WithCancel(context.Background()) + defer stop() + go m.Start(ctx) + var tc delivery.Toolchain + for deadline := time.Now().Add(3 * time.Second); time.Now().Before(deadline); time.Sleep(5 * time.Millisecond) { + if got, ok := states.toolchain("ace.go"); ok { + tc = got + break + } + } + if tc.Version != "go1.27.1" || tc.Recipe != m.Recipe.Version() || tc.Language != "go" { + t.Fatalf("ace.go = %+v, want go1.27.1 and recipe %s", tc, m.Recipe.Version()) + } + if a := m.Health(ctx); a.State != health.StateHealthy || !a.Healthy { + t.Errorf("health = %+v, want healthy", a) + } +} + +// 1483/AC3: a probe answering go1.27.2: every step tool's step ends refused naming both versions, ace.go holds +// go1.27.2, and the health tool answers unhealthy naming both. +func TestAnotherGoRefusesEveryStepAndHealthIsUnhealthy(t *testing.T) { + states := newStates() + m := moduleWith(states, "go1.27.2") + ran := false + run := func(context.Context, map[string]any) (any, error) { ran = true; return "done", nil } + steps := append([]StepTool{ + {Name: "long_probe", Long: true, Run: run}, + {Name: "short_probe", Run: run}, + }, m.StepTools()...) + for _, tool := range m.Tools(steps) { + if tool.Name == StatusTool || tool.Name == HealthTool { + continue + } + var reason string + out, err := tool.Run(map[string]any{}) + if isLong(steps, tool.Name) { + if err != nil { + t.Fatalf("%s: %v", tool.Name, err) + } + if out.(map[string]any)["step"] == "" { + t.Fatalf("%s answered no step id: %v", tool.Name, out) + } + st := states.waitEnd(t) + if st.State != delivery.StepRefused { + t.Errorf("%s: the step ended %s, want refused", tool.Name, st.State) + } + reason = st.Reason + } else { + if err == nil { + t.Fatalf("%s: answered %v, want a refusal", tool.Name, out) + } + reason = err.Error() + } + if !strings.Contains(reason, "go1.27.2") || !strings.Contains(reason, "go1.27.1") { + t.Errorf("%s: the refusal %q does not name both versions", tool.Name, reason) + } + } + if ran { + t.Errorf("a step ran on a node that refuses") + } + if tc, _ := states.toolchain("ace.go"); tc.Version != "go1.27.2" { + t.Errorf("ace.go holds %q, want the installed go1.27.2", tc.Version) + } + a := m.Health(context.Background()) + if a.State != health.StateUnhealthy || !strings.Contains(a.Why, "go1.27.2") || !strings.Contains(a.Why, "go1.27.1") { + t.Errorf("health = %+v, want unhealthy naming both versions", a) + } +} + +func isLong(steps []StepTool, name string) bool { + for _, s := range steps { + if s.Name == name { + return s.Long + } + } + return false +} + +// 1483/AC4: recipes differing only in the postgres major or the nats-server version have different recipe versions; +// equal recipes have the same one. +func TestRecipeVersionMovesWithEachOfItsValues(t *testing.T) { + base := Recipe{Steps: "1", Go: "go1.27.1", PostgresMajor: 18, NatsServer: "2.11.17"} + if base.Version() != (Recipe{Steps: "1", Go: "go1.27.1", PostgresMajor: 18, NatsServer: "2.11.17"}).Version() { + t.Errorf("one recipe has two versions") + } + seen := map[string]string{base.Version(): "base"} + for name, r := range map[string]Recipe{ + "postgres major": {Steps: "1", Go: "go1.27.1", PostgresMajor: 19, NatsServer: "2.11.17"}, + "nats-server": {Steps: "1", Go: "go1.27.1", PostgresMajor: 18, NatsServer: "2.11.18"}, + "go": {Steps: "1", Go: "go1.27.2", PostgresMajor: 18, NatsServer: "2.11.17"}, + "steps": {Steps: "2", Go: "go1.27.1", PostgresMajor: 18, NatsServer: "2.11.17"}, + } { + v := r.Version() + if was, dup := seen[v]; dup { + t.Errorf("the recipe differing in %s has the version of the %s recipe: %s", name, was, v) + } + seen[v] = name + } +} + +func TestTheRecipeNamesGo1271AndPostgres18AndTheLibrarysNatsVersion(t *testing.T) { + r := Current() + if r.Go != "go1.27.1" || r.PostgresMajor != 18 { + t.Errorf("recipe = %+v", r) + } + if r.NatsServer == "" || strings.HasPrefix(r.NatsServer, "v") { + t.Errorf("nats-server version = %q", r.NatsServer) + } +} + +func TestParseGoVersion(t *testing.T) { + if v, err := parseGoVersion("go version go1.27.1 linux/amd64\n"); err != nil || v != "go1.27.1" { + t.Errorf("= %q, %v", v, err) + } + if _, err := parseGoVersion("not go"); err == nil { + t.Errorf("accepted a text that is not `go version`'s") + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/test.go b/modules/mesh-build-go/cmd/mesh-build-go/test.go new file mode 100644 index 00000000..426cdd44 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/test.go @@ -0,0 +1,143 @@ +package main + +// The test step (novox/hq ADR 0445; issues 1290 and 1486): a fetched workspace's tests by Go's convention, one level +// at a time, each judged by its own bounds. The unit level is `go test` over every package without the `integration` +// build tag. + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// testTimeout is go test's own timeout for a level: a bound on a test that never ends, far above any level's bound. +const testTimeout = "10m" + +var packageOK = regexp.MustCompile(`^(\./|[A-Za-z0-9])[A-Za-z0-9._/@~-]*(/\.\.\.)?$`) + +func (m *Module) testTool() StepTool { + return StepTool{ + Name: "build_go_test", + Description: "Run the tests of a fetched workspace by Go's convention, one level: the unit level is go test over every " + + "package without the integration build tag, within five seconds. Answers a step id at once; the step ends on " + + "its key with the level's result: its packages, its times, the tests that failed and the slowest tests. A " + + "level that fails is a finished step whose result says passed false.", + Input: map[string]any{ + "type": "object", + "properties": map[string]any{ + "workspace": map[string]any{"type": "string", "description": "the workspace id build_go_fetch answered"}, + "level": map[string]any{"type": "string", "enum": []string{LevelUnit, LevelIntegration}, "description": "the level"}, + "packages": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": "package patterns of each module, ./... when none"}, + }, + "required": []string{"workspace", "level"}, + }, + Long: true, + Run: m.test, + } +} + +// unitBound is the unit level's bound: UnitBound unless a test lowered it. +func (m *Module) unitBound() time.Duration { + if m.UnitBound > 0 { + return m.UnitBound + } + return UnitBound +} + +func (m *Module) test(ctx context.Context, args map[string]any) (any, error) { + workspace, _ := args["workspace"].(string) + level, _ := args["level"].(string) + packages, err := packagesArg(args["packages"]) + if err != nil { + return nil, err + } + if !workspaceOK.MatchString(workspace) { + return nil, fmt.Errorf("%q is not a workspace id", workspace) + } + tree := filepath.Join(m.Work, workspace, "tree") + if _, err := os.Stat(tree); err != nil { + return nil, fmt.Errorf("no workspace %s: it was never fetched here, or it is older than a day and was removed", workspace) + } + switch level { + case LevelUnit: + return m.runLevel(ctx, tree, levelOpts{level: LevelUnit, packages: packages, bounds: Bounds{Run: m.unitBound()}}) + case LevelIntegration: + return m.integration(ctx, workspace, tree, packages) + } + return nil, fmt.Errorf("the level %q is neither unit nor integration", level) +} + +func packagesArg(v any) ([]string, error) { + if v == nil { + return nil, nil + } + list, ok := v.([]any) + if !ok { + return nil, fmt.Errorf("packages is a list of package patterns") + } + out := []string{} + for _, e := range list { + s, _ := e.(string) + if !packageOK.MatchString(s) || strings.Contains(strings.TrimSuffix(s, "/..."), "..") { + return nil, fmt.Errorf("%q is not a package pattern", s) + } + out = append(out, s) + } + return out, nil +} + +// levelOpts are what a level asks of runLevel. +type levelOpts struct { + level string + tags []string + packages []string + bounds Bounds + // env is what the level adds to a go command's environment (MESH_TEST_POSTGRES, MESH_TEST_NATS). + env []string + // setup is the time the level's setup took, which the caller measured; wall says the level's running time is the + // wall time of its go test commands, as the integration level's packages run side by side by go test's own + // parallelism, where the unit level's is the slowest package's. + setup time.Duration + wall bool + // compose is a state of the level to put in the result (the store and the bus it ran against). + store, bus string +} + +// runLevel runs `go test -json` in each go.mod directory of the tree and judges the whole against the bounds. +func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestResult, error) { + dirs, err := m.goModDirs(ctx, tree) + if err != nil { + return nil, err + } + res := &TestResult{Level: o.level, Packages: []PackageResult{}, Setup: o.setup.Seconds(), Store: o.store, Bus: o.bus} + packages := o.packages + if len(packages) == 0 { + packages = []string{"./..."} + } + began := m.clock() + for _, d := range dirs { + args := []string{"-C", filepath.Join(tree, d), "test", "-json", "-count=1", "-timeout", testTimeout} + if len(o.tags) > 0 { + args = append(args, "-tags", strings.Join(o.tags, ",")) + } + args = append(args, packages...) + r := m.Run(ctx, append(m.goEnvFor(filepath.Join(tree, d)), o.env...), "go", args...) + rd := newReader(res, d) + noise := rd.read(r.Stdout) + rd.finish() + if (r.Status != 0 || r.Err != "") && len(rd.pkgs) == 0 { + // go test said nothing a test said: it could not run (a go.mod it cannot read, a toolchain it cannot find). + return nil, fmt.Errorf("go test in %s: %s", d, firstLine(r.Err+"\n"+strings.Join(noise, "\n")+"\n"+r.Stderr)) + } + } + if o.wall { + res.Elapsed = m.clock().Sub(began).Seconds() + } + res.judge(o.bounds) + return res, nil +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/test_test.go b/modules/mesh-build-go/cmd/mesh-build-go/test_test.go new file mode 100644 index 00000000..563b3194 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/test_test.go @@ -0,0 +1,243 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// capturedRunner answers the commands of a test step from a fixture repository: ls-files with the go.mod, and every +// `go test` with the output captured from the real go (testdata/captured, see its README). +func capturedRunner(t *testing.T, captured string, calls *[]cmdRecord) forgekey.Runner { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "captured", captured+".json")) + if err != nil { + t.Fatal(err) + } + status := 0 + if strings.Contains(string(raw), `"Action":"fail"`) { + status = 1 // go test exits 1 when a test or a build failed + } + return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + *calls = append(*calls, cmdRecord{env: env, line: name + " " + strings.Join(args, " ")}) + switch { + case name == "git" && args[2] == "ls-files": + return forgekey.Ran{Stdout: "go.mod\n"} + case name == "go": + return forgekey.Ran{Stdout: string(raw), Status: status} + } + return forgekey.Ran{} + } +} + +// workspaceFor is a module whose work directory holds a fetched workspace, and the id of it. +func workspaceFor(t *testing.T, captured string) (*Module, *keptStates, string, *[]cmdRecord) { + t.Helper() + calls := &[]cmdRecord{} + states := newStates() + m := moduleWith(states, "go1.27.1") + m.Home, m.Work, m.Cache = t.TempDir(), t.TempDir(), t.TempDir() + m.Run = capturedRunner(t, captured, calls) + if err := os.MkdirAll(filepath.Join(m.Work, "ws1", "tree"), 0o755); err != nil { + t.Fatal(err) + } + return m, states, "ws1", calls +} + +func runUnit(t *testing.T, m *Module, states *keptStates, ws string) (TestResult, delivery.Step) { + t.Helper() + var run func(map[string]any) (any, error) + for _, tool := range m.Tools(m.StepTools()) { + if tool.Name == "build_go_test" { + run = tool.Run + } + } + if _, err := run(map[string]any{"workspace": ws, "level": "unit"}); err != nil { + t.Fatal(err) + } + st := states.waitEnd(t) + var res TestResult + if st.State == delivery.StepPassed { + if err := json.Unmarshal(st.Result, &res); err != nil { + t.Fatalf("result %s: %v", st.Result, err) + } + } + return res, st +} + +// 1290/AC1: a fixture with one passing package and one failing test: the result names the failing test and its +// package, and the level fails. The step itself finished, so it ends passed with the verdict in its result. +func TestUnitLevelNamesTheFailingTestAndFailsTheLevel(t *testing.T) { + m, states, ws, _ := workspaceFor(t, "failing") + res, st := runUnit(t, m, states, ws) + if st.State != delivery.StepPassed { + t.Fatalf("the step ended %s: %s", st.State, st.Reason) + } + if res.Passed || res.Level != "unit" { + t.Errorf("result = %+v, want the unit level failed", res) + } + if len(res.Failed) != 1 || res.Failed[0].Package != "fixture/failing/bad" || res.Failed[0].Test != "TestThreeIsFour" || + !strings.Contains(res.Failed[0].Output, "Three() = 3, want 4") { + t.Errorf("failed = %+v", res.Failed) + } + if !strings.Contains(res.Why, "fixture/failing/bad TestThreeIsFour") { + t.Errorf("why = %q", res.Why) + } + good := false + for _, p := range res.Packages { + if p.Package == "fixture/failing/good" && p.Passed && p.Tests == 1 { + good = true + } + } + if !good || res.Tests != 2 { + t.Errorf("packages = %+v, tests = %d; want the good package passed with its test, 2 tests", res.Packages, res.Tests) + } +} + +// 1290/AC2: a fixture whose tests take more than the bound (lowered by the module's option, not by waiting five +// seconds): the level fails naming the bound and the slowest tests; under the real bound the same run passes. +func TestUnitLevelOverItsBoundFailsNamingTheBoundAndTheSlowestTests(t *testing.T) { + m, states, ws, _ := workspaceFor(t, "slow") + m.UnitBound = 10 * time.Millisecond + res, _ := runUnit(t, m, states, ws) + if res.Passed { + t.Fatalf("a level of %.3f s passed under a bound of %v", res.Elapsed, m.UnitBound) + } + for _, want := range []string{"over its bound of 0.01 s", "fixture/slow TestTakesAWhile"} { + if !strings.Contains(res.Why, want) { + t.Errorf("why = %q lacks %q", res.Why, want) + } + } + if len(res.Slowest) == 0 || res.Slowest[0].Test != "TestTakesAWhile" || res.Bound != 0.01 { + t.Errorf("slowest = %+v, bound = %v", res.Slowest, res.Bound) + } + + m2, states2, ws2, _ := workspaceFor(t, "slow") + if res, _ := runUnit(t, m2, states2, ws2); !res.Passed || res.Bound != 5 { + t.Errorf("under the bound of five seconds: %+v", res) + } +} + +// 1290/AC3: a fixture whose only test carries //go:build integration: go test says [no test files], and the unit +// level reports no tests and passes. No command of the unit level asks for the tag. +func TestUnitLevelOfAFixtureWithOnlyAnIntegrationTestHasNoTests(t *testing.T) { + m, states, ws, calls := workspaceFor(t, "tagged") + res, _ := runUnit(t, m, states, ws) + if !res.Passed || !res.NoTests || res.Tests != 0 || !strings.Contains(res.Why, "no tests") { + t.Errorf("result = %+v, want no tests, passed", res) + } + for _, c := range *calls { + if strings.Contains(c.line, "-tags") { + t.Errorf("the unit level asked for a tag: %s", c.line) + } + } +} + +// 1290/AC4: a fixture with no tests at all: the result says the repository has no tests, neither a failure nor a warning. +func TestUnitLevelOfAFixtureWithNoTestsSaysSo(t *testing.T) { + m, states, ws, _ := workspaceFor(t, "empty") + res, st := runUnit(t, m, states, ws) + if st.State != delivery.StepPassed || !res.Passed || !res.NoTests || res.Why != "the repository has no tests at the unit level" || len(res.Failed) != 0 { + t.Errorf("result = %+v (%s)", res, st.State) + } +} + +// A package that does not build fails the level with the compiler's words. +func TestUnitLevelOfAPackageThatDoesNotBuildFailsWithTheCompilersLine(t *testing.T) { + m, states, ws, _ := workspaceFor(t, "broken") + res, _ := runUnit(t, m, states, ws) + if res.Passed || len(res.Failed) != 1 || res.Failed[0].Package != "fixture/broken" || + !strings.Contains(res.Failed[0].Output, `cannot use "one" (untyped string constant) as int value`) { + t.Errorf("result = %+v", res) + } +} + +// The command is go test -json in the module's directory of the workspace, with the toolchain pinned to the installed +// one and the module's caches. +func TestUnitLevelRunsGoTestJsonInTheModulesDirectoryWithTheStepsEnvironment(t *testing.T) { + m, states, ws, calls := workspaceFor(t, "empty") + runUnit(t, m, states, ws) + var line string + var env string + for _, c := range *calls { + if strings.HasPrefix(c.line, "go ") { + line, env = c.line, strings.Join(c.env, " ") + } + } + want := "go -C " + filepath.Join(m.Work, ws, "tree", ".") + " test -json -count=1 -timeout 10m ./..." + if line != want { + t.Errorf("command = %q, want %q", line, want) + } + for _, w := range []string{"GOTOOLCHAIN=local", "GOFLAGS=-mod=mod", "GOMODCACHE=" + filepath.Join(m.Cache, "gomod")} { + if !strings.Contains(env, w) { + t.Errorf("environment %s lacks %s", env, w) + } + } +} + +// A go test that said nothing a test says (it could not run) ends the step failed, not the level. +func TestAGoTestThatCouldNotRunEndsTheStepFailed(t *testing.T) { + m, states, ws, _ := workspaceFor(t, "empty") + m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + if name == "go" { + return forgekey.Ran{Status: 1, Stderr: "go: cannot find main module\n"} + } + return forgekey.Ran{Stdout: "go.mod\n"} + } + _, st := runUnit(t, m, states, ws) + if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "go: cannot find main module") { + t.Errorf("the step ended %s: %q", st.State, st.Reason) + } +} + +func TestATestOfAnUnknownWorkspaceOrAnotherLevelIsRefused(t *testing.T) { + m, states, _, _ := workspaceFor(t, "empty") + var run func(map[string]any) (any, error) + for _, tool := range m.Tools(m.StepTools()) { + if tool.Name == "build_go_test" { + run = tool.Run + } + } + for _, args := range []map[string]any{ + {"workspace": "nope", "level": "unit"}, + {"workspace": "../ws1", "level": "unit"}, + {"workspace": "ws1", "level": "stress"}, + {"workspace": "ws1", "level": "unit", "packages": []any{"-run=x"}}, + {"workspace": "ws1", "level": "unit", "packages": []any{"../x"}}, + } { + run(args) + if st := states.waitEnd(t); st.State != delivery.StepFailed { + t.Errorf("%v: the step ended %s", args, st.State) + } + } +} + +// A directory that vendors is tested, and listed, from its vendor directory: fetch downloaded nothing for it, and the +// last value of GOFLAGS is the one go uses. +func TestADirectoryThatVendorsIsReadFromItsVendorDirectory(t *testing.T) { + m, states, ws, calls := workspaceFor(t, "empty") + vendor := filepath.Join(m.Work, ws, "tree", "vendor") + os.MkdirAll(vendor, 0o755) + os.WriteFile(filepath.Join(vendor, "modules.txt"), nil, 0o644) + runUnit(t, m, states, ws) + flags := "" + for _, c := range *calls { + if strings.HasPrefix(c.line, "go ") { + for _, w := range c.env { + if strings.HasPrefix(w, "GOFLAGS=") { + flags = w + } + } + } + } + if flags != "GOFLAGS=-mod=vendor" { + t.Errorf("the last GOFLAGS of go test in a vendoring directory is %q", flags) + } +}