package apidiff import ( "bufio" "fmt" "go/types" "os" "os/exec" "path/filepath" "runtime" "sort" "strings" "testing" "github.com/google/go-cmp/cmp" "golang.org/x/tools/go/packages" "golang.org/x/tools/go/packages/packagestest" ) func TestModuleChanges(t *testing.T) { packagestest.TestAll(t, testModuleChanges) } func testModuleChanges(t *testing.T, x packagestest.Exporter) { e := packagestest.Export(t, x, []packagestest.Module{ { Name: "example.com/moda", Files: map[string]any{ "foo/foo.go": "package foo\n\nconst Version = 1", "foo/baz/baz.go": "package baz", }, }, { Name: "example.com/modb", Files: map[string]any{ "foo/foo.go": "package foo\n\nconst Version = 2\nconst Other = 1", "bar/bar.go": "package bar", }, }, }) defer e.Cleanup() a, err := loadModule(t, e.Config, "example.com/moda") if err != nil { t.Fatal(err) } b, err := loadModule(t, e.Config, "example.com/modb") if err != nil { t.Fatal(err) } report := ModuleChanges(a, b) if len(report.Changes) == 0 { t.Fatal("expected some changes, but got none") } wanti := []string{ "./foo.Version: value changed from 1 to 2", "package example.com/moda/foo/baz: removed", } sort.Strings(wanti) got := report.messages(false) sort.Strings(got) if diff := cmp.Diff(wanti, got); diff != "" { t.Errorf("incompatibles: mismatch (-want, +got)\n%s", diff) } wantc := []string{ "./foo.Other: added", "package example.com/modb/bar: added", } sort.Strings(wantc) got = report.messages(true) sort.Strings(got) if diff := cmp.Diff(wantc, got); diff != "" { t.Errorf("compatibles: mismatch (-want, +got)\n%s", diff) } } func TestChanges(t *testing.T) { testfiles, err := filepath.Glob(filepath.Join("testdata", "*.go")) if err != nil { t.Fatal(err) } for _, testfile := range testfiles { name := strings.TrimSuffix(filepath.Base(testfile), ".go") t.Run(name, func(t *testing.T) { dir := filepath.Join(t.TempDir(), "go") wanti, wantc := splitIntoPackages(t, testfile, dir) sort.Strings(wanti) sort.Strings(wantc) oldpkg, err := loadPackage(t, "apidiff/old", dir) if err != nil { t.Fatal(err) } newpkg, err := loadPackage(t, "apidiff/new", dir) if err != nil { t.Fatal(err) } report := Changes(oldpkg.Types, newpkg.Types) got := report.messages(false) if diff := cmp.Diff(wanti, got); diff != "" { t.Errorf("incompatibles: mismatch (-want, +got)\n%s", diff) } got = report.messages(true) if diff := cmp.Diff(wantc, got); diff != "" { t.Errorf("compatibles: mismatch (-want, +got)\n%s", diff) } }) } } func splitIntoPackages(t *testing.T, file, dir string) (incompatibles, compatibles []string) { // Read the input file line by line. // Write a line into the old or new package, // dependent on comments. // Also collect expected messages. f, err := os.Open(file) if err != nil { t.Fatal(err) } defer f.Close() if err := os.MkdirAll(filepath.Join(dir, "src", "apidiff"), 0700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "src", "apidiff", "go.mod"), []byte("module apidiff\ngo 1.18\n"), 0600); err != nil { t.Fatal(err) } oldd := filepath.Join(dir, "src/apidiff/old") newd := filepath.Join(dir, "src/apidiff/new") if err := os.MkdirAll(oldd, 0700); err != nil { t.Fatal(err) } if err := os.Mkdir(newd, 0700); err != nil && !os.IsExist(err) { t.Fatal(err) } oldf, err := os.Create(filepath.Join(oldd, "old.go")) if err != nil { t.Fatal(err) } defer func() { if err := oldf.Close(); err != nil { t.Fatal(err) } }() newf, err := os.Create(filepath.Join(newd, "new.go")) if err != nil { t.Fatal(err) } defer func() { if err := newf.Close(); err != nil { t.Fatal(err) } }() wl := func(f *os.File, line string) { if _, err := fmt.Fprintln(f, line); err != nil { t.Fatal(err) } } writeBoth := func(line string) { wl(oldf, line); wl(newf, line) } writeln := writeBoth s := bufio.NewScanner(f) for s.Scan() { line := s.Text() tl := strings.TrimSpace(line) switch { case tl == "// old": writeln = func(line string) { wl(oldf, line) } case tl == "// new": writeln = func(line string) { wl(newf, line) } case tl == "// both": writeln = writeBoth case strings.HasPrefix(tl, "// i "): incompatibles = append(incompatibles, strings.TrimSpace(tl[4:])) case strings.HasPrefix(tl, "// c "): compatibles = append(compatibles, strings.TrimSpace(tl[4:])) default: writeln(line) } } if s.Err() != nil { t.Fatal(s.Err()) } return } // Copied from cmd/apidiff/main.go. func loadModule(t *testing.T, cfg *packages.Config, modulePath string) (*Module, error) { needsGoPackages(t) cfg.Mode = cfg.Mode | packages.LoadTypes loaded, err := packages.Load(cfg, fmt.Sprintf("%s/...", modulePath)) if err != nil { return nil, err } if len(loaded) == 0 { return nil, fmt.Errorf("found no packages for module %s", modulePath) } var tpkgs []*types.Package for _, p := range loaded { if len(p.Errors) > 0 { // TODO: use errors.Join once Go 1.21 is released. return nil, p.Errors[0] } tpkgs = append(tpkgs, p.Types) } return &Module{Path: modulePath, Packages: tpkgs}, nil } func loadPackage(t *testing.T, importPath, goPath string) (*packages.Package, error) { needsGoPackages(t) cfg := &packages.Config{ Mode: packages.LoadTypes, } if goPath != "" { cfg.Env = append(os.Environ(), "GOPATH="+goPath) cfg.Dir = filepath.Join(goPath, "src", filepath.FromSlash(importPath)) } pkgs, err := packages.Load(cfg, importPath) if err != nil { return nil, err } if len(pkgs[0].Errors) > 0 { return nil, pkgs[0].Errors[0] } return pkgs[0], nil } func TestExportedFields(t *testing.T) { pkg, err := loadPackage(t, "golang.org/x/exp/apidiff/testdata/exported_fields", "") if err != nil { t.Fatal(err) } typeof := func(name string) types.Type { return pkg.Types.Scope().Lookup(name).Type() } s := typeof("S") su := s.(*types.Named).Underlying().(*types.Struct) ef := exportedSelectableFields(su) wants := []struct { name string typ types.Type }{ {"A1", typeof("A1")}, {"D", types.Typ[types.Bool]}, {"E", types.Typ[types.Int]}, {"F", typeof("F")}, {"S", types.NewPointer(s)}, } if got, want := len(ef), len(wants); got != want { t.Errorf("got %d fields, want %d\n%+v", got, want, ef) } for _, w := range wants { if got := ef[w.name]; got != nil && !types.Identical(got.Type(), w.typ) { t.Errorf("%s: got %v, want %v", w.name, got.Type(), w.typ) } } } // needsGoPackages skips t if the go/packages driver (or 'go' tool) implied by // the current process environment is not present in the path. // // Copied and adapted from golang.org/x/tools/internal/testenv. func needsGoPackages(t *testing.T) { t.Helper() tool := os.Getenv("GOPACKAGESDRIVER") switch tool { case "off": // "off" forces go/packages to use the go command. tool = "go" case "": if _, err := exec.LookPath("gopackagesdriver"); err == nil { tool = "gopackagesdriver" } else { tool = "go" } } needsTool(t, tool) } // needsTool skips t if the named tool is not present in the path. // // Copied and adapted from golang.org/x/tools/internal/testenv. func needsTool(t *testing.T, tool string) { _, err := exec.LookPath(tool) if err == nil { return } t.Helper() if allowMissingTool(tool) { t.Skipf("skipping because %s tool not available: %v", tool, err) } else { t.Fatalf("%s tool not available: %v", tool, err) } } func allowMissingTool(tool string) bool { if runtime.GOOS == "android" { // Android builds generally run tests on a separate machine from the build, // so don't expect any external tools to be available. return true } if tool == "go" && os.Getenv("GO_BUILDER_NAME") == "illumos-amd64-joyent" { // Work around a misconfigured builder (see https://golang.org/issue/33950). return true } // If a developer is actively working on this test, we expect them to have all // of its dependencies installed. However, if it's just a dependency of some // other module (for example, being run via 'go test all'), we should be more // tolerant of unusual environments. return !packageMainIsDevel() } // packageMainIsDevel reports whether the module containing package main // is a development version (if module information is available). // // Builds in GOPATH mode and builds that lack module information are assumed to // be development versions. var packageMainIsDevel = func() bool { return true }