diff --git a/modules/mesh-build-go/cmd/mesh-build-go/integration.go b/modules/mesh-build-go/cmd/mesh-build-go/integration.go index 957514bf..0edcfb6a 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/integration.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/integration.go @@ -10,6 +10,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strconv" "strings" "sync" @@ -68,6 +69,26 @@ func (m *Module) integrationHere(ctx context.Context, workspace, tree string, pa if err != nil { return nil, err } + // The tests the tags add, not the unit tests again (issue 1921, try speed S7): only the packages holding one compile + // and run, with a -run pattern naming them. A try that names its own pattern runs it as asked. + var runByDir map[string]string + if pattern == "" { + tagged, err := taggedTests(tree, dirs, packages, integrationTags) + if err != nil { + return nil, err + } + if len(tagged) == 0 { + res := &TestResult{Level: LevelIntegration, Packages: []PackageResult{}} + res.judge(m.integrationBounds()) + return res, nil // no test the tags add: nothing to start or run + } + byDir, runByDir, packages = map[string][]string{}, map[string]string{}, nil + for d, tr := range tagged { + byDir[d], runByDir[d] = tr.packages, tr.pattern + packages = append(packages, tr.packages...) + } + sort.Strings(packages) + } // The store, the bus and the compile start together (issue 1921, try speed S5); the setup is the longest of them. // The compile runs no test binary (-exec true), so it needs neither server: whether the tests compile is for the run // to say, and its failure is not read here; the run below finds them built. @@ -127,7 +148,7 @@ func (m *Module) integrationHere(ctx context.Context, workspace, tree string, pa setup := m.clock().Sub(began) return m.runLevel(ctx, tree, levelOpts{ - workspace: workspace, run: pattern, + workspace: workspace, run: pattern, runByDir: runByDir, level: LevelIntegration, tags: integrationTags, packages: packages, bounds: m.integrationBounds(), env: env, setup: setup, wall: true, 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 index f90ffd66..1c231392 100644 --- 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 @@ -128,9 +128,32 @@ func integModule(t *testing.T, f *integFake) (*Module, *keptStates) { if err := os.MkdirAll(filepath.Join(m.Work, "ws1", "tree"), 0o755); err != nil { t.Fatal(err) } + // A test the integration tag adds, so the level has a package to run (issue 1921, try speed S7). + writeTaggedTest(t, filepath.Join(m.Work, "ws1", "tree"), "fixture/slow") return m, states } +// taggedTestFile is the file writeTaggedTest writes: one test the integration tag adds. +const taggedTestFile = "level_integration_test.go" + +// writeTaggedTest gives the go.mod directory dir the module path mp and one test the integration tag adds, in its root +// package and in its package x. +func writeTaggedTest(t *testing.T, dir, mp string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module "+mp+"\n\ngo 1.25.0\n"), 0o644); err != nil { + t.Fatal(err) + } + body := "//go:build integration\n\npackage x\n\nimport \"testing\"\n\nfunc TestTakesAWhile(t *testing.T) {}\n" + for _, d := range []string{dir, filepath.Join(dir, "x")} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(d, taggedTestFile), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } +} + func runLevelTool(t *testing.T, m *Module, states *keptStates, level string) (TestResult, delivery.Step) { t.Helper() for _, tool := range m.Tools(m.StepTools()) { @@ -406,7 +429,7 @@ func TestTheSetupCompilesTheDirectoriesAtOnceBesideTheStoreAndTheBus(t *testing. tree := filepath.Join(m.Work, "ws1", "tree") for _, d := range []string{"a", "b", "c", "d", "e"} { os.MkdirAll(filepath.Join(tree, d), 0o755) - os.WriteFile(filepath.Join(tree, d, "go.mod"), []byte("module x/"+d+"\n"), 0o644) + writeTaggedTest(t, filepath.Join(tree, d), "x/"+d) } inner := m.Run m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { @@ -447,3 +470,60 @@ func TestTheSetupCompilesTheDirectoriesAtOnceBesideTheStoreAndTheBus(t *testing. t.Errorf("setup = %v", res.Setup) } } + +// 1921/S7: the integration level runs the tests its tags add, not the unit tests again: only the packages holding a +// tagged test compile and run, with a -run pattern naming those tests; a tree with no tagged test starts no store. +func TestTheIntegrationLevelRunsOnlyTheTestsItsTagsAdd(t *testing.T) { + f := newIntegFake(t, "slow") + m, states := integModule(t, f) + tree := filepath.Join(m.Work, "ws1", "tree") + os.RemoveAll(filepath.Join(tree, "x")) + os.Remove(filepath.Join(tree, taggedTestFile)) + for name, body := range map[string]string{ + "go.mod": "module fixture/slow\n", + "unit/u_test.go": "package unit\n\nimport \"testing\"\n\nfunc TestUnit(t *testing.T) {}\n", + "store/s_test.go": "package store\n\nimport \"testing\"\n\nfunc TestPlain(t *testing.T) {}\n", + "store/s_integration_test.go": "//go:build integration\n\npackage store\n\nimport \"testing\"\n\nfunc TestMain(m *testing.M) {}\nfunc TestReal(t *testing.T) {}\nfunc Testable() {}\nfunc helper() {}\n", + "store/fake_test.go": "//go:build !integration\n\npackage store\n\nimport \"testing\"\n\nfunc TestFake(t *testing.T) {}\n", + "iso/iso_test.go": "//go:build integration && isolation && linux\n\npackage iso\n\nimport \"testing\"\n\nfunc TestShutOff(t *testing.T) {}\n", + "plat/p_linux_test.go": "//go:build linux\n\npackage plat\n\nimport \"testing\"\n\nfunc TestLinux(t *testing.T) {}\n", + } { + os.MkdirAll(filepath.Dir(filepath.Join(tree, name)), 0o755) + if err := os.WriteFile(filepath.Join(tree, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + if _, st := runLevelTool(t, m, states, "integration"); st.State != delivery.StepPassed { + t.Fatalf("the step ended %s: %s", st.State, st.Reason) + } + var compiled, ran []string + for _, c := range f.calls { + switch { + case strings.HasPrefix(c.line, "go ") && strings.Contains(c.line, "-exec true"): + compiled = append(compiled, c.line) + case strings.HasPrefix(c.line, "go ") && strings.Contains(c.line, " -json"): + ran = append(ran, c.line) + } + } + if len(ran) != 1 || !strings.Contains(ran[0], "-run ^(TestReal|TestShutOff)$") || + !strings.HasSuffix(ran[0], " fixture/slow/iso fixture/slow/store") { + t.Errorf("the run = %q, want the tagged tests of iso and store alone", ran) + } + if len(compiled) != 1 || !strings.HasSuffix(compiled[0], " fixture/slow/iso fixture/slow/store") { + t.Errorf("the compile = %q, want iso and store alone", compiled) + } + + g := newIntegFake(t, "slow") + m, states = integModule(t, g) + tree = filepath.Join(m.Work, "ws1", "tree") + os.RemoveAll(filepath.Join(tree, "x")) + os.Remove(filepath.Join(tree, taggedTestFile)) + os.WriteFile(filepath.Join(tree, "u_test.go"), []byte("package x\n\nimport \"testing\"\n\nfunc TestUnit(t *testing.T) {}\n"), 0o644) + res, st := runLevelTool(t, m, states, "integration") + if st.State != delivery.StepPassed || !res.Passed || !res.NoTests { + t.Fatalf("a tree with no tagged test: %s %+v", st.State, res) + } + if strings.Contains(g.lines(), "pg_ctl") || strings.Contains(g.lines(), "go -C") { + t.Errorf("a tree with no tagged test started the store or ran go:\n%s", g.lines()) + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/selected_dirs_test.go b/modules/mesh-build-go/cmd/mesh-build-go/selected_dirs_test.go index 095ff793..f379c51f 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/selected_dirs_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/selected_dirs_test.go @@ -24,9 +24,7 @@ func threeModules(t *testing.T, f *integFake) (*Module, *keptStates) { if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module fixture/"+d+"\n\ngo 1.25.0\n"), 0o644); err != nil { - t.Fatal(err) - } + writeTaggedTest(t, dir, "fixture/"+d) } return m, states } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/tagged.go b/modules/mesh-build-go/cmd/mesh-build-go/tagged.go new file mode 100644 index 00000000..93d2e040 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/tagged.go @@ -0,0 +1,161 @@ +package main + +// The integration level runs the tests its tags add, not the unit tests again (issue 1921, try speed S7): `go test +// -tags integration` without -run would run every untagged test too, and the unit level ran those already. Which tests a +// tag adds is read from the source: a test function in a _test.go file whose //go:build line holds with the level's +// tags and does not hold without them. + +import ( + "go/ast" + "go/build/constraint" + "go/parser" + "go/token" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "unicode" + "unicode/utf8" +) + +// taggedRun is what the integration level runs in one go.mod directory: the packages holding a test the tags add, by +// import path, and the -run pattern naming those tests. +type taggedRun struct { + packages []string + pattern string +} + +// taggedTests reads, for each go.mod directory, the tests that tags add. selected are the packages the level was asked +// to run (import paths, as build_go_digest keys them); none, or a relative pattern, is every package. A directory with no +// test the tags add is left out of the answer. +func taggedTests(tree string, dirs, selected, tags []string) (map[string]taggedRun, error) { + want := map[string]bool{} + for _, p := range selected { + if strings.HasPrefix(p, "./") || p == "." { + want = nil + break + } + want[p] = true + } + if len(selected) == 0 { + want = nil + } + with := map[string]bool{} + for _, t := range tags { + with[t] = true + } + out := map[string]taggedRun{} + for _, d := range dirs { + raw, _ := os.ReadFile(filepath.Join(tree, d, "go.mod")) + mp := modulePath(string(raw)) + base := filepath.Join(tree, d) + pkgs := map[string]bool{} + names := map[string]bool{} + err := lintable(base, func(path string) error { + if !strings.HasSuffix(path, "_test.go") { + return nil + } + src, err := os.ReadFile(path) + if err != nil { + return err + } + expr := buildLine(src) + if expr == nil || !expr.Eval(func(tag string) bool { return with[tag] || platformTag(tag) }) || expr.Eval(platformTag) { + return nil // no constraint, or one the tags do not change: the unit level's + } + rel, _ := filepath.Rel(base, filepath.Dir(path)) + pkg := mp + if rel != "." { + pkg = mp + "/" + filepath.ToSlash(rel) + } + if want != nil && !want[pkg] { + return nil + } + found := testNames(path, src) + for _, n := range found { + names[n] = true + } + if len(found) > 0 { + pkgs[pkg] = true + } + return nil + }) + if err != nil { + return nil, err + } + if len(pkgs) == 0 { + continue + } + run := taggedRun{packages: sortedKeys(pkgs)} + run.pattern = "^(" + strings.Join(sortedKeys(names), "|") + ")$" + out[d] = run + } + return out, nil +} + +func sortedKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// buildLine is a file's //go:build constraint, nil when it has none. +func buildLine(src []byte) constraint.Expr { + for _, line := range strings.Split(string(src), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "package ") { + break + } + if constraint.IsGoBuild(line) { + if e, err := constraint.Parse(line); err == nil { + return e + } + } + } + return nil +} + +// platformTag is how a tag other than the level's evaluates, the same with the level's tags and without them: the +// machine's platform, unix, cgo and every Go version hold; any other tag does not. +func platformTag(tag string) bool { + switch tag { + case runtime.GOOS, runtime.GOARCH, "unix", "cgo", "gc": + return true + } + return strings.HasPrefix(tag, "go1.") +} + +// testNames are the top-level test functions of a test file: func TestXxx, as go test runs them, TestMain left out. +func testNames(path string, src []byte) []string { + f, err := parser.ParseFile(token.NewFileSet(), path, src, parser.SkipObjectResolution) + if err != nil { + return nil // a file that does not parse fails the compile, which the run reports + } + var out []string + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv != nil || fn.Name.Name == "TestMain" || !isTestName(fn.Name.Name) { + continue + } + out = append(out, fn.Name.Name) + } + return out +} + +// isTestName says whether go test runs a function of this name as a test: Test, then nothing or a rune that is not +// lower case. +func isTestName(name string) bool { + rest, ok := strings.CutPrefix(name, "Test") + if !ok { + return false + } + if rest == "" { + return true + } + r, _ := utf8.DecodeRuneInString(rest) + return !unicode.IsLower(r) +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/test.go b/modules/mesh-build-go/cmd/mesh-build-go/test.go index 931192d2..72386f0f 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/test.go @@ -171,6 +171,9 @@ type levelOpts struct { // parallelism, where the unit level's is the slowest package's. setup time.Duration wall bool + // runByDir is the -run pattern of each go.mod directory, over run: the tests the integration level's tags add there + // (issue 1921, try speed S7). + runByDir map[string]string // compose is a state of the level to put in the result (the store and the bus it ran against). store, bus string } @@ -251,7 +254,9 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR if len(o.tags) > 0 { args = append(args, "-tags", strings.Join(o.tags, ",")) } - if o.run != "" { + if r := o.runByDir[d]; r != "" { + args = append(args, "-run", r) + } else if o.run != "" { args = append(args, "-run", o.run) } args = append(args, byDir[d]...)