diff --git a/internal/location/location.go b/internal/location/location.go index 40efd498..43dc7735 100644 --- a/internal/location/location.go +++ b/internal/location/location.go @@ -12,13 +12,13 @@ import ( "strings" ) -// Location record a place in a source file. +// Location records a place in a source file. type Location struct { File string // File name Func string // Function name Line int // Line number inside file - Inside string - BehindCmp bool // BehindCmp is true when operator is behind a Cmp* function + Inside string // Inside is used when Location is inside something else + BehindCmp bool // BehindCmp is true when operator is behind a Cmp* function } // GetLocationer is the interface that wraps the basic GetLocation method. diff --git a/internal/trace/trace.go b/internal/trace/trace.go new file mode 100644 index 00000000..85bc5298 --- /dev/null +++ b/internal/trace/trace.go @@ -0,0 +1,235 @@ +// Copyright (c) 2021, Maxime Soulé +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +package trace + +import ( + "fmt" + "go/build" + "os" + "path/filepath" + "runtime" + "strings" +) + +var ( + ignorePkg = map[string]struct{}{} + goPaths []string + goModDir string +) + +// Level represents a level when retrieving a trace. +type Level struct { + Func string + FileLine string +} + +func getPackage(skip ...int) string { + sk := 2 + if len(skip) > 0 { + sk += skip[0] + } + pc, _, _, ok := runtime.Caller(sk) + if ok { + fn := runtime.FuncForPC(pc) + if fn != nil { + pkg, _ := SplitPackageFunc(fn.Name()) + return pkg + } + } + return "" +} + +// IgnorePackage records the calling package as ignored one in trace. +func IgnorePackage(skip ...int) bool { + if pkg := getPackage(skip...); pkg != "" { + ignorePkg[pkg] = struct{}{} + return true + } + return false +} + +// UnignorePackage cancels a previous use of IgnorePackage, so the +// calling package is no longer ignored. Only intended to be used in +// go-testdeep internal tests. +func UnignorePackage(skip ...int) bool { + if pkg := getPackage(skip...); pkg != "" { + delete(ignorePkg, pkg) + return true + } + return false +} + +// IsIgnoredPackage returns true if pkg is ignored, false +// otherwise. Only intended to be used in go-testdeep internal tests. +func IsIgnoredPackage(pkg string) (ok bool) { + _, ok = ignorePkg[pkg] + return +} + +// FindGoModDir finds the closest directory containing go.mod file +// starting from directory in. +func FindGoModDir(in string) string { + for { + _, err := os.Stat(filepath.Join(in, "go.mod")) + if err == nil { + // Do not accept /tmp/go.mod + if in != os.TempDir() { + return in + string(filepath.Separator) + } + return "" + } + + nd := filepath.Dir(in) + if nd == in { + return "" + } + in = nd + } +} + +// FindGoModDirLinks finds the closest directory containing go.mod +// file starting from directory in after cleaning it. If not found, +// expands symlinks and re-searches. +func FindGoModDirLinks(in string) string { + in = filepath.Clean(in) + + if gm := FindGoModDir(in); gm != "" { + return gm + } + + lin, err := filepath.EvalSymlinks(in) + if err == nil && lin != in { + return FindGoModDir(lin) + } + return "" +} + +// Reset resets the ignored packages map plus cached mod and GOPATH +// directories (Init() should be called again). Only intended to be +// used in go-testdeep internal tests. +func Reset() { + ignorePkg = map[string]struct{}{} + goPaths = nil + goModDir = "" +} + +// Init initializes trace global variables. +func Init() { + // GOPATH directories + goPaths = nil + for _, dir := range filepath.SplitList(build.Default.GOPATH) { + dir = filepath.Clean(dir) + goPaths = append(goPaths, + filepath.Join(dir, "pkg", "mod")+string(filepath.Separator), + filepath.Join(dir, "src")+string(filepath.Separator), + ) + } + + if wd, err := os.Getwd(); err == nil { + // go.mod directory + goModDir = FindGoModDirLinks(wd) + } +} + +// Frames is the interface corresponding to type returned by +// runtime.CallersFrames. See CallersFrames variable. +type Frames interface { + Next() (frame runtime.Frame, more bool) +} + +// CallersFrames is only intended to be used in go-testdeep internal +// tests to cover all cases. +var CallersFrames = func(callers []uintptr) Frames { + return runtime.CallersFrames(callers) +} + +// Retrieve retrieves a trace and returns it. +func Retrieve(skip int, endFunction string) []Level { + var trace []Level + var pc [40]uintptr + if num := runtime.Callers(skip+2, pc[:]); num > 0 { + checkIgnore := true + frames := CallersFrames(pc[:num]) + for { + frame, more := frames.Next() + + fn := frame.Function + if fn == endFunction { + break + } + + var pkg string + if fn == "" { + if frame.File == "" { + if more { + continue + } + break + } + fn = "" + } else { + pkg, fn = SplitPackageFunc(fn) + if checkIgnore && IsIgnoredPackage(pkg) { + if more { + continue + } + break + } + checkIgnore = false + } + + file := strings.TrimPrefix(frame.File, goModDir) + if file == frame.File { + for _, dir := range goPaths { + file = strings.TrimPrefix(frame.File, dir) + if file != frame.File { + break + } + } + + if file == frame.File { + file = strings.TrimPrefix(frame.File, build.Default.GOROOT) + if file != frame.File { + file = filepath.Join("$GOROOT", file) + } + } + } + + level := Level{Func: fn} + if file != "" { + level.FileLine = fmt.Sprintf("%s:%d", file, frame.Line) + } + + trace = append(trace, level) + if !more { + break + } + } + } + return trace +} + +// SplitPackageFunc splits a fully qualified function name into its +// package and function parts: +// "foo/bar/test.fn" → "foo/bar/test", "fn" +// "foo/bar/test.X.fn" → "foo/bar/test", "X.fn" +// "foo/bar/test.(*X).fn" → "foo/bar/test", "(*X).fn" +// "foo/bar/test.(*X).fn.func1" → "foo/bar/test", "(*X).fn.func1" +// "weird" → "", "weird" +func SplitPackageFunc(fn string) (string, string) { + sp := strings.LastIndexByte(fn, '/') + if sp < 0 { + sp = 0 // std package + } + + dp := strings.IndexByte(fn[sp:], '.') + if dp < 0 { + return "", fn + } + + return fn[:sp+dp], fn[sp+dp+1:] +} diff --git a/internal/trace/trace_test.go b/internal/trace/trace_test.go new file mode 100644 index 00000000..2b7f5b04 --- /dev/null +++ b/internal/trace/trace_test.go @@ -0,0 +1,251 @@ +// Copyright (c) 2021, Maxime Soulé +// All rights reserved. +// +// This source code is licensed under the BSD-style license found in the +// LICENSE file in the root directory of this source tree. + +package trace_test + +import ( + "go/build" + "io/ioutil" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/maxatome/go-testdeep/internal/trace" + + "github.com/maxatome/go-testdeep/internal/test" +) + +func TestIgnorePackage(t *testing.T) { + const ourPkg = "github.com/maxatome/go-testdeep/internal/trace_test" + + trace.Reset() + + test.IsFalse(t, trace.IsIgnoredPackage(ourPkg)) + test.IsTrue(t, trace.IgnorePackage()) + test.IsTrue(t, trace.IsIgnoredPackage(ourPkg)) + + test.IsTrue(t, trace.UnignorePackage()) + test.IsFalse(t, trace.IsIgnoredPackage(ourPkg)) + + test.IsTrue(t, trace.IgnorePackage()) + test.IsTrue(t, trace.IsIgnoredPackage(ourPkg)) + + test.IsFalse(t, trace.IgnorePackage(300)) + test.IsFalse(t, trace.UnignorePackage(300)) +} + +func TestFindGoModDir(t *testing.T) { + tmp, err := ioutil.TempDir("", "go-testdeep") + if err != nil { + t.Fatalf("TempDir() failed: %s", err) + } + final := filepath.Join(tmp, "a", "b", "c", "d", "e") + + err = os.MkdirAll(final, 0755) + if err != nil { + t.Fatalf("MkdirAll(%s) failed: %s", final, err) + } + defer os.RemoveAll(tmp) + + test.EqualStr(t, trace.FindGoModDir(final), "") + + t.Run("/tmp/.../a/b/c/go.mod", func(t *testing.T) { + goMod := filepath.Join(tmp, "a", "b", "c", "go.mod") + + err := ioutil.WriteFile(goMod, nil, 0644) + if err != nil { + t.Fatalf("WriteFile(%s) failed: %s", goMod, err) + } + defer os.Remove(goMod) + + test.EqualStr(t, + trace.FindGoModDir(final), + filepath.Join(tmp, "a", "b", "c")+string(filepath.Separator), + ) + }) + + t.Run("/tmp/go.mod", func(t *testing.T) { + goMod := filepath.Join(os.TempDir(), "go.mod") + + if _, err := os.Stat(goMod); err != nil { + if !os.IsNotExist(err) { + t.Fatalf("Stat(%s) failed: %s", goMod, err) + } + err := ioutil.WriteFile(goMod, nil, 0644) + if err != nil { + t.Fatalf("WriteFile(%s) failed: %s", goMod, err) + } + defer os.Remove(goMod) + } + + test.EqualStr(t, trace.FindGoModDir(final), "") + }) +} + +func TestFindGoModDirLinks(t *testing.T) { + tmp, err := ioutil.TempDir("", "go-testdeep") + if err != nil { + t.Fatalf("TempDir() failed: %s", err) + } + + goModDir := filepath.Join(tmp, "a", "b", "c") + truePath := filepath.Join(goModDir, "d", "e") + linkPath := filepath.Join(tmp, "a", "b", "e") + + err = os.MkdirAll(truePath, 0755) + if err != nil { + t.Fatalf("MkdirAll(%s) failed: %s", truePath, err) + } + defer os.RemoveAll(tmp) + + err = os.Symlink(truePath, linkPath) + if err != nil { + t.Fatalf("Symlink(%s, %s) failed: %s", truePath, linkPath, err) + } + + goMod := filepath.Join(goModDir, "go.mod") + + err = ioutil.WriteFile(goMod, nil, 0644) + if err != nil { + t.Fatalf("WriteFile(%s) failed: %s", goMod, err) + } + defer os.Remove(goMod) + + goModDir += string(filepath.Separator) + + // Simple FindGoModDir + test.EqualStr(t, trace.FindGoModDir(truePath), goModDir) + test.EqualStr(t, trace.FindGoModDir(linkPath), "") // not found + + // FindGoModDirLinks + test.EqualStr(t, trace.FindGoModDirLinks(truePath), goModDir) + test.EqualStr(t, trace.FindGoModDirLinks(linkPath), goModDir) + + test.EqualStr(t, trace.FindGoModDirLinks(tmp), "") +} + +func TestSplitPackageFunc(t *testing.T) { + pkg, fn := trace.SplitPackageFunc("testing.Fatal") + test.EqualStr(t, pkg, "testing") + test.EqualStr(t, fn, "Fatal") + + pkg, fn = trace.SplitPackageFunc("github.com/maxatome/go-testdeep/td.Cmp") + test.EqualStr(t, pkg, "github.com/maxatome/go-testdeep/td") + test.EqualStr(t, fn, "Cmp") + + pkg, fn = trace.SplitPackageFunc("foo/bar/test.(*T).Cmp") + test.EqualStr(t, pkg, "foo/bar/test") + test.EqualStr(t, fn, "(*T).Cmp") + + pkg, fn = trace.SplitPackageFunc("foo/bar/test.(*X).c.func1") + test.EqualStr(t, pkg, "foo/bar/test") + test.EqualStr(t, fn, "(*X).c.func1") + + pkg, fn = trace.SplitPackageFunc("foo/bar/test.(*X).c.func1") + test.EqualStr(t, pkg, "foo/bar/test") + test.EqualStr(t, fn, "(*X).c.func1") + + pkg, fn = trace.SplitPackageFunc("foobar") + test.EqualStr(t, pkg, "") + test.EqualStr(t, fn, "foobar") + + pkg, fn = trace.SplitPackageFunc("") + test.EqualStr(t, pkg, "") + test.EqualStr(t, fn, "") +} + +func d(end string) []trace.Level { return trace.Retrieve(0, end) } +func c(end string) []trace.Level { return d(end) } +func b(end string) []trace.Level { return c(end) } +func a(end string) []trace.Level { return b(end) } + +func TestZRetrieve(t *testing.T) { + trace.Reset() + + levels := a("testing.tRunner") + if !test.EqualInt(t, len(levels), 5) || + !test.EqualStr(t, levels[0].Func, "d") || + !test.EqualStr(t, levels[1].Func, "c") || + !test.EqualStr(t, levels[2].Func, "b") || + !test.EqualStr(t, levels[3].Func, "a") || + !test.EqualStr(t, levels[4].Func, "TestZRetrieve") { + t.Errorf("%#v", levels) + } + + levels = trace.Retrieve(0, "unknown.unknown") + maxLevels := len(levels) + test.IsTrue(t, maxLevels > 2) + test.EqualStr(t, levels[len(levels)-1].Func, "goexit") // runtime.goexit + + for i := range levels { + test.IsTrue(t, trace.IgnorePackage(i)) + } + levels = trace.Retrieve(0, "unknown.unknown") + test.EqualInt(t, len(levels), 0) + + // Init GOPATH filter + trace.Reset() + trace.Init() + + test.IsTrue(t, trace.IgnorePackage()) + levels = trace.Retrieve(0, "unknown.unknown") + test.EqualInt(t, len(levels), maxLevels-1) +} + +type FakeFrames struct { + frames []runtime.Frame + cur int +} + +func (f *FakeFrames) Next() (runtime.Frame, bool) { + if f.cur >= len(f.frames) { + return runtime.Frame{}, false + } + f.cur++ + return f.frames[f.cur-1], f.cur < len(f.frames) +} + +func TestZRetrieveFake(t *testing.T) { + saveCallersFrames, saveGOPATH := trace.CallersFrames, build.Default.GOPATH + defer func() { + trace.CallersFrames, build.Default.GOPATH = saveCallersFrames, saveGOPATH + }() + + var fakeFrames FakeFrames + trace.CallersFrames = func(_ []uintptr) trace.Frames { return &fakeFrames } + build.Default.GOPATH = "/foo/bar" + + trace.Reset() + trace.Init() + + fakeFrames = FakeFrames{ + frames: []runtime.Frame{ + {}, + {Function: "", File: "/foo/bar/src/zip/zip.go", Line: 23}, + {Function: "", File: "/foo/bar/pkg/mod/zzz/zzz.go", Line: 42}, + {Function: "", File: "/bar/foo.go", Line: 34}, + {Function: "MyFunc"}, + {}, + }, + } + levels := trace.Retrieve(0, "pipo") + if test.EqualInt(t, len(levels), 4) { + test.EqualStr(t, levels[0].Func, "") + test.EqualStr(t, levels[0].FileLine, "zip/zip.go:23") + + test.EqualStr(t, levels[1].Func, "") + test.EqualStr(t, levels[1].FileLine, "zzz/zzz.go:42") + + test.EqualStr(t, levels[2].Func, "") + test.EqualStr(t, levels[2].FileLine, "/bar/foo.go:34") + + test.EqualStr(t, levels[3].Func, "MyFunc") + test.EqualStr(t, levels[3].FileLine, "") + } else { + t.Errorf("%#v", levels) + } +} diff --git a/td/cmp_deeply.go b/td/cmp_deeply.go index c3841845..745bf308 100644 --- a/td/cmp_deeply.go +++ b/td/cmp_deeply.go @@ -8,14 +8,21 @@ package td import ( "bytes" + "fmt" "reflect" "github.com/maxatome/go-testdeep/helpers/tdutil" "github.com/maxatome/go-testdeep/internal/color" "github.com/maxatome/go-testdeep/internal/ctxerr" "github.com/maxatome/go-testdeep/internal/flat" + "github.com/maxatome/go-testdeep/internal/trace" ) +func init() { + trace.Init() + trace.IgnorePackage() +} + func formatError(t TestingT, isFatal bool, err *ctxerr.Error, args ...interface{}) { t.Helper() @@ -36,6 +43,25 @@ func formatError(t TestingT, isFatal bool, err *ctxerr.Error, args ...interface{ err.Append(&buf, "") + // Stask trace + if trace := trace.Retrieve(0, "testing.tRunner"); len(trace) > 1 { + buf.WriteString("\nThis is how we got here:\n") + + fnMaxLen := 0 + for _, level := range trace { + if len(level.Func) > fnMaxLen { + fnMaxLen = len(level.Func) + } + } + fnMaxLen += 2 + + nl := "" + for _, level := range trace { + fmt.Fprintf(&buf, "%s\t%-*s %s", nl, fnMaxLen, level.Func+"()", level.FileLine) + nl = "\n" + } + } + if isFatal { t.Fatal(buf.String()) } else {