From dbc36d3d3cd18c59d302827b6607dcc0c302b1ab Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Tue, 20 Oct 2026 20:56:12 +0000 Subject: [PATCH] Test the Go build module's integration level against a faked and a real store and bus (issues 1486, 1290, 1487), part 12 of 20 --- .../mesh-build-go/integration_level_test.go | 318 ++++++++++++++++++ .../test_real_integration_test.go | 159 +++++++++ 2 files changed, 477 insertions(+) create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/integration_level_test.go create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/test_real_integration_test.go diff --git a/modules/mesh-build-go/cmd/mesh-build-go/integration_level_test.go b/modules/mesh-build-go/cmd/mesh-build-go/integration_level_test.go new file mode 100644 index 00000000..65f91ad3 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/integration_level_test.go @@ -0,0 +1,318 @@ +package main + +import ( + "context" + "encoding/json" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// Issue 1486: the integration level, with the store and the bus faked at the process boundary: the commands the level +// runs are recorded, and a clock the commands advance stands in for the time they would take. + +type integFake struct { + mu sync.Mutex + calls []cmdRecord + clock time.Time + postgres string // what `postgres --version` prints + captured string // the go test -json output of the run + // advance is how long a phase takes on the fake clock: compile, start (of the store), test. + advance map[string]time.Duration + busURL string + busDir string + busDown bool + runDirs []string // run directories seen when the store was started, to be gone at the end + panicOn string // a phase that panics +} + +func newIntegFake(t *testing.T, captured string) *integFake { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "captured", captured+".json")) + if err != nil { + t.Fatal(err) + } + return &integFake{clock: time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC), postgres: "postgres (PostgreSQL) 18.6", + captured: string(raw), advance: map[string]time.Duration{}, busURL: "nats://127.0.0.1:45222"} +} + +func (f *integFake) now() time.Time { f.mu.Lock(); defer f.mu.Unlock(); return f.clock } + +func (f *integFake) tick(phase string) { + f.mu.Lock() + f.clock = f.clock.Add(f.advance[phase]) + f.mu.Unlock() + if f.panicOn == phase { + panic("the " + phase + " phase panicked") + } +} + +func (f *integFake) run(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + line := name + " " + strings.Join(args, " ") + f.mu.Lock() + f.calls = append(f.calls, cmdRecord{env: env, line: line}) + f.mu.Unlock() + switch { + case name == "postgres": + return forgekey.Ran{Stdout: f.postgres + "\n"} + case name == "git" && len(args) > 2 && args[2] == "ls-files": + return forgekey.Ran{Stdout: "go.mod\n"} + case name == "go" && strings.Contains(line, "-run ^$"): + f.tick("compile") + case name == "go": + f.tick("test") + status := 0 + if strings.Contains(f.captured, `"Action":"fail"`) { + status = 1 + } + return forgekey.Ran{Stdout: f.captured, Status: status} + case name == "pg_ctl" && strings.HasSuffix(line, " start"): + f.tick("start") + for i, a := range args { + if a == "-D" { + f.runDirs = append(f.runDirs, filepath.Dir(filepath.Dir(args[i+1]))) + } + } + } + return forgekey.Ran{} +} + +func (f *integFake) startBus(_ context.Context, dir string) (string, func(), error) { + f.mu.Lock() + f.busDir = dir + f.mu.Unlock() + return f.busURL, func() { f.mu.Lock(); f.busDown = true; f.mu.Unlock() }, nil +} + +func (f *integFake) lines() string { + f.mu.Lock() + defer f.mu.Unlock() + out := []string{} + for _, c := range f.calls { + out = append(out, c.line) + } + return strings.Join(out, "\n") +} + +func integModule(t *testing.T, f *integFake) (*Module, *keptStates) { + t.Helper() + states := newStates() + m := moduleWith(states, "go1.27.1") + m.Home, m.Work, m.Cache = t.TempDir(), t.TempDir(), t.TempDir() + m.Run, m.Now, m.StartBus = f.run, f.now, f.startBus + if err := os.MkdirAll(filepath.Join(m.Work, "ws1", "tree"), 0o755); err != nil { + t.Fatal(err) + } + return m, states +} + +func runLevelTool(t *testing.T, m *Module, states *keptStates, level string) (TestResult, delivery.Step) { + t.Helper() + for _, tool := range m.Tools(m.StepTools()) { + if tool.Name != "build_go_test" { + continue + } + if _, err := tool.Run(map[string]any{"workspace": "ws1", "level": level}); 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.Fatal(err) + } + } + return res, st +} + +// 1486/AC2: the store's start carries listen_addresses=” and a socket directory under the run; the tests are given +// its address and the bus's, and the run's directory is in the workspace's. +func TestTheStoreListensOnASocketUnderTheRunAndNowhereElse(t *testing.T) { + f := newIntegFake(t, "slow") + m, states := integModule(t, f) + res, st := runLevelTool(t, m, states, "integration") + if st.State != delivery.StepPassed || !res.Passed || res.Level != "integration" { + t.Fatalf("the step ended %s (%s): %+v", st.State, st.Reason, res) + } + var start, initdb string + for _, c := range f.calls { + if strings.HasPrefix(c.line, "pg_ctl ") && strings.HasSuffix(c.line, " start") { + start = c.line + } + if strings.HasPrefix(c.line, "initdb ") { + initdb = c.line + } + } + run := f.runDirs[0] + if !strings.HasPrefix(run, filepath.Join(m.Work, "ws1", "run-")) { + t.Errorf("the run directory is %s, not under the workspace's", run) + } + for _, want := range []string{"listen_addresses=''", "-k " + filepath.Join(run, "pg", "socket"), "fsync=off", "-w"} { + if !strings.Contains(start, want) { + t.Errorf("the store's start %q lacks %q", start, want) + } + } + if strings.Contains(start, "listen_addresses='*'") || strings.Contains(start, "-h ") || strings.Contains(start, "port=") { + t.Errorf("the store listens on TCP: %s", start) + } + if !strings.Contains(initdb, "-D "+filepath.Join(run, "pg", "data")) || !strings.Contains(initdb, "--auth=trust") { + t.Errorf("initdb = %q", initdb) + } + var test cmdRecord + for _, c := range f.calls { + if strings.HasPrefix(c.line, "go ") && strings.Contains(c.line, "-json") { + test = c + } + } + if !strings.Contains(test.line, "-tags integration") { + t.Errorf("the run does not ask for the tag: %s", test.line) + } + env := map[string]string{} + for _, w := range test.env { + k, v, _ := strings.Cut(w, "=") + env[k] = v + } + u, err := url.Parse(env["MESH_TEST_POSTGRES"]) + if err != nil || u.Scheme != "postgres" || u.Query().Get("host") != filepath.Join(run, "pg", "socket") || u.Host != "" { + t.Errorf("MESH_TEST_POSTGRES = %q", env["MESH_TEST_POSTGRES"]) + } + if strings.Count(strings.TrimPrefix(env["MESH_TEST_POSTGRES"], "postgres://"), "/") != 1 { + t.Errorf("MESH_TEST_POSTGRES = %q has a slash beyond the one before the database: the controller's tests cut it at the last", env["MESH_TEST_POSTGRES"]) + } + if env["MESH_TEST_NATS"] != "nats://127.0.0.1:45222" || f.busDir != filepath.Join(run, "nats") { + t.Errorf("MESH_TEST_NATS = %q, bus dir %q", env["MESH_TEST_NATS"], f.busDir) + } + if res.Store != "postgres 18" || res.Bus != "nats-server "+m.Recipe.NatsServer { + t.Errorf("the result names %q and %q", res.Store, res.Bus) + } +} + +// 1486/AC3: a postgres of major 17 refuses the integration level, naming 17 and 18; the unit level still runs. +func TestAnotherPostgresMajorRefusesTheIntegrationLevelOnly(t *testing.T) { + f := newIntegFake(t, "slow") + f.postgres = "postgres (PostgreSQL) 17.2" + m, states := integModule(t, f) + _, st := runLevelTool(t, m, states, "integration") + if st.State != delivery.StepRefused || !strings.Contains(st.Reason, "17") || !strings.Contains(st.Reason, "18") { + t.Fatalf("the integration step ended %s: %q", st.State, st.Reason) + } + if strings.Contains(f.lines(), "initdb") || strings.Contains(f.lines(), "-tags") { + t.Errorf("the refused level ran something:\n%s", f.lines()) + } + res, st := runLevelTool(t, m, states, "unit") + if st.State != delivery.StepPassed || !res.Passed || res.Level != "unit" { + t.Errorf("the unit level: %s %+v", st.State, res) + } +} + +// 1486/AC4: a setup over thirty seconds fails the level naming the setup bound; a run over sixty names the running +// bound and the slowest tests. The clock the commands move stands in for the time they take. +func TestSetupAndRunningTimeAreJudgedApart(t *testing.T) { + f := newIntegFake(t, "slow") + f.advance["compile"], f.advance["start"] = 20*time.Second, 15*time.Second + m, states := integModule(t, f) + res, _ := runLevelTool(t, m, states, "integration") + if res.Passed || res.Setup != 35 || !strings.Contains(res.Why, "setup took 35.00 s, over its setup bound of 30 s") || strings.Contains(res.Why, "slowest tests") { + t.Errorf("a setup of 35 s: %+v", res) + } + + g := newIntegFake(t, "slow") + g.advance["test"] = 61 * time.Second + m, states = integModule(t, g) + res, _ = runLevelTool(t, m, states, "integration") + if res.Passed || res.Elapsed != 61 || !strings.Contains(res.Why, "took 61.00 s, over its bound of 60 s; the slowest tests: fixture/slow TestTakesAWhile") || strings.Contains(res.Why, "setup") { + t.Errorf("a run of 61 s: %+v", res) + } + + h := newIntegFake(t, "slow") + h.advance["compile"], h.advance["test"] = 29*time.Second, 59*time.Second + m, states = integModule(t, h) + if res, _ = runLevelTool(t, m, states, "integration"); !res.Passed || res.Setup != 29 || res.Elapsed != 59 { + t.Errorf("29 s of setup and 59 s of running are within both bounds: %+v", res) + } +} + +// 1486/AC5: a run that fails, and a run that panics: the store is stopped, the bus is stopped, the run's directory is +// gone. +func TestAFailedOrPanickedRunStopsTheServersAndRemovesTheRunDirectory(t *testing.T) { + f := newIntegFake(t, "failing") + m, states := integModule(t, f) + res, _ := runLevelTool(t, m, states, "integration") + if res.Passed { + t.Fatalf("the failing fixture passed: %+v", res) + } + checkCleaned := func(f *integFake, m *Module) { + t.Helper() + lines := f.lines() + if !strings.Contains(lines, "pg_ctl -D "+filepath.Join(f.runDirs[0], "pg", "data")+" -m immediate -w stop") { + t.Errorf("the store was not stopped:\n%s", lines) + } + if strings.LastIndex(lines, " stop") < strings.LastIndex(lines, "-json") { + t.Errorf("the store was stopped before the tests ended:\n%s", lines) + } + if !f.busDown { + t.Errorf("the bus was not stopped") + } + if _, err := os.Stat(f.runDirs[0]); !os.IsNotExist(err) { + t.Errorf("the run directory %s is still there (%v)", f.runDirs[0], err) + } + } + checkCleaned(f, m) + + g := newIntegFake(t, "failing") + g.panicOn = "test" + m, _ = integModule(t, g) + func() { + defer func() { recover() }() + m.integration(context.Background(), "ws1", filepath.Join(m.Work, "ws1", "tree"), nil) + }() + checkCleaned(g, m) +} + +// A store that does not start ends the step failed, with the server stopped and the directory gone. +func TestAStoreThatDoesNotStartEndsTheStepFailedAndLeavesNothing(t *testing.T) { + f := newIntegFake(t, "slow") + m, states := integModule(t, f) + inner := m.Run + m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + r := inner(ctx, env, name, args...) + if name == "pg_ctl" && strings.HasSuffix(strings.Join(args, " "), " start") { + return forgekey.Ran{Status: 1, Stderr: "pg_ctl: could not start server\n"} + } + return r + } + _, st := runLevelTool(t, m, states, "integration") + if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "pg_ctl: could not start server") { + t.Errorf("the step ended %s: %q", st.State, st.Reason) + } + if !strings.Contains(f.lines(), " stop") { + t.Errorf("the half-started store was not stopped:\n%s", f.lines()) + } + if f.busDir != "" { + t.Errorf("the bus was started after the store failed") + } + if entries, _ := os.ReadDir(filepath.Join(m.Work, "ws1")); len(entries) != 1 || entries[0].Name() != "tree" { + t.Errorf("the workspace holds %v, want only its tree", entries) + } +} + +func TestThePostgresMajorIsReadFromTheVersionLine(t *testing.T) { + for line, want := range map[string]int{"postgres (PostgreSQL) 18.6": 18, "postgres (PostgreSQL) 9.6.24": 9, "postgres (PostgreSQL) 17.2 (Arch)": 17} { + f := &integFake{postgres: line, advance: map[string]time.Duration{}} + m := &Module{Run: f.run} + if got, err := m.postgresMajor(context.Background()); err != nil || got != want { + t.Errorf("%q = %d, %v; want %d", line, got, err, want) + } + } + if _, err := (&Module{Run: (&integFake{postgres: "no", advance: map[string]time.Duration{}}).run}).postgresMajor(context.Background()); err == nil { + t.Errorf("accepted a line that is not postgres's") + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/test_real_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/test_real_integration_test.go new file mode 100644 index 00000000..fa0b5ac5 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/test_real_integration_test.go @@ -0,0 +1,159 @@ +//go:build integration + +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestMain makes this test binary the bus when the module starts it as one (bus.go). +func TestMain(m *testing.M) { + if len(os.Args) > 1 && os.Args[1] == busArg { + os.Exit(runBus(os.Args[2:])) + } + os.Exit(m.Run()) +} + +// repository copies a fixture repository into a git tree, as a fetch leaves one. +func repository(t *testing.T, name string) string { + t.Helper() + tree := t.TempDir() + if out, err := exec.Command("cp", "-r", filepath.Join("testdata", "repos", name)+"/.", tree).CombinedOutput(); err != nil { + t.Fatalf("%s: %v %s", name, err, out) + } + for _, c := range [][]string{{"init", "-q"}, {"add", "-A"}, {"-c", "user.email=a@b", "-c", "user.name=x", "commit", "-qm", "x"}} { + if out, err := exec.Command("git", append([]string{"-C", tree}, c...)...).CombinedOutput(); err != nil { + t.Fatalf("%s: git %v: %v %s", name, c, err, out) + } + } + return tree +} + +// Issue 1290: the unit level against the real go on the fixture repositories in testdata/repos: the tests of +// test_test.go read the output captured from them, and this one proves the capture is what the real go says today. It +// starts go test, so it carries the tag. +func TestUnitLevelRunsTheRealGoOnTheFixtureRepositories(t *testing.T) { + want := map[string]struct { + passed, noTests bool + failed string + }{ + "failing": {failed: "fixture/failing/bad"}, + "tagged": {passed: true, noTests: true}, + "empty": {passed: true, noTests: true}, + "slow": {passed: true}, + "broken": {failed: "fixture/broken"}, + } + for name, w := range want { + tree := repository(t, name) + home := t.TempDir() + m := &Module{Home: home, Work: t.TempDir(), Cache: t.TempDir()} + m.Run = StepRunner(home) + res, err := m.runLevel(context.Background(), tree, levelOpts{level: LevelUnit, bounds: Bounds{Run: UnitBound}}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if res.Passed != w.passed || res.NoTests != w.noTests || (w.failed != "" && (len(res.Failed) != 1 || res.Failed[0].Package != w.failed)) { + t.Errorf("%s: result = %+v", name, res) + } + } +} + +// processesNamed are the processes whose command line holds text. +func processesNamed(text string) []string { + var out []string + cmdlines, _ := filepath.Glob("/proc/[0-9]*/cmdline") + for _, f := range cmdlines { + if raw, err := os.ReadFile(f); err == nil && strings.Contains(string(raw), text) { + out = append(out, f) + } + } + return out +} + +// 1486/AC1: given a fixture repository whose integration test opens MESH_TEST_POSTGRES and MESH_TEST_NATS, the +// integration level passes, and afterwards no postgres or bus process of the run is left and its directory is gone. +// It starts postgres and a bus, so it runs in the merge check and in a try, never on the way. +func TestIntegrationLevelRunsAgainstARealStoreAndBusAndLeavesNothing(t *testing.T) { + tree := repository(t, "store") + work := t.TempDir() + if err := os.MkdirAll(filepath.Join(work, "ws1"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Rename(tree, filepath.Join(work, "ws1", "tree")); err != nil { + t.Fatal(err) + } + home := t.TempDir() + m := &Module{Recipe: Current(), Home: home, Work: work, Cache: t.TempDir()} + m.Run = StepRunner(home) + res, err := m.integration(context.Background(), "ws1", filepath.Join(work, "ws1", "tree"), nil) + if err != nil { + t.Fatalf("the integration level: %v", err) + } + if !res.Passed || res.Tests != 1 || res.Setup <= 0 || res.Setup > IntegrationSetup.Seconds() { + t.Errorf("result = %+v", res) + } + entries, _ := os.ReadDir(filepath.Join(work, "ws1")) + if len(entries) != 1 || entries[0].Name() != "tree" { + t.Errorf("the workspace holds %v, want only its tree: the run directory is gone", entries) + } + for deadline := time.Now().Add(5 * time.Second); len(processesNamed(work)) > 0 && time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) { + } + if left := processesNamed(work); len(left) > 0 { + t.Errorf("processes of the run are left: %v", left) + } +} + +// 1487/AC2: given a fixture Go module, when it is built twice, both digests are equal and the archive holds one +// executable named as binaryName says. It runs the real go, so it carries the tag. +func TestBuildTwiceGivesOneDigestAndOneExecutable(t *testing.T) { + work := t.TempDir() + tree := filepath.Join(work, "ws1", "tree") + if err := os.MkdirAll(tree, 0o755); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("cp", "-r", filepath.Join("testdata", "repos", "build")+"/.", tree).CombinedOutput(); err != nil { + t.Fatalf("%v %s", err, out) + } + home := t.TempDir() + m := &Module{Home: home, Work: work, Cache: t.TempDir()} + m.Run = StepRunner(home) + args := map[string]any{"workspace": "ws1", "module": ".", "artifact": "tools"} + first, err := m.build(context.Background(), args) + if err != nil { + t.Fatal(err) + } + second, err := m.build(context.Background(), args) + if err != nil { + t.Fatal(err) + } + a, b := first.(BuildResult), second.(BuildResult) + if a.Digest != b.Digest || a.Binary != "prog-bin" { + t.Errorf("two builds of one tree: %+v and %+v", a, b) + } + raw, err := os.ReadFile(a.Path) + if err != nil { + t.Fatal(err) + } + zr, err := gzip.NewReader(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + tr := tar.NewReader(zr) + h, err := tr.Next() + if err != nil || h.Name != "prog-bin" || h.Mode&0o111 == 0 { + t.Fatalf("first entry = %+v, %v", h, err) + } + if _, err := tr.Next(); err != io.EOF { + t.Errorf("the archive holds more than one file") + } +}