Repository: gotestyourself/gotestsum
Pull request #165 (2020-11-21 21:56:48)

watch: add support for running delve while watching
Pressing `d` in watch mode will rerun the tests for the previous event using `dlv test`. Breakpoints can be set either from the prompt or by adding `runtime.Breakpoint()` in the source.


Patch:
diff --git a/.golangci.yml b/.golangci.yml
index 2663423..a4f9880 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -15,8 +15,7 @@ issues:
     - linters: [stylecheck]
       text: 'ST1000: at least one file in a package should have a package comment'
     - linters: [errcheck]
-      path: log/
-      text: 'Error return value of `out.WriteString` is not checked'
+      text: 'Error return value of `.*\.WriteString` is not checked'
     - linters: [unparam]
       text: 'result .* is always'
     - linters: [unparam]
diff --git a/cmd/main.go b/cmd/main.go
index 594219b..5e51024 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -12,7 +12,6 @@ import (
 	"github.com/fatih/color"
 	"github.com/pkg/errors"
 	"github.com/spf13/pflag"
-	"gotest.tools/gotestsum/internal/filewatcher"
 	"gotest.tools/gotestsum/log"
 	"gotest.tools/gotestsum/testjson"
 )
@@ -41,19 +40,6 @@ func Run(name string, args []string) error {
 	return run(opts)
 }
 
-func runWatcher(opts *options) error {
-	fn := func(pkg string) error {
-		opts := *opts
-		opts.packages = []string{pkg}
-		err := run(&opts)
-		if !isExitCoder(err) {
-			return err
-		}
-		return nil
-	}
-	return filewatcher.Watch(opts.packages, fn)
-}
-
 func setupFlags(name string) (*pflag.FlagSet, *options) {
 	opts := &options{
 		hideSummary:                  newHideSummaryValue(),
diff --git a/cmd/watch.go b/cmd/watch.go
new file mode 100644
index 0000000..f865918
--- /dev/null
+++ b/cmd/watch.go
@@ -0,0 +1,128 @@
+package cmd
+
+import (
+	"bufio"
+	"context"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"os/exec"
+
+	"gotest.tools/gotestsum/internal/filewatcher"
+	"gotest.tools/gotestsum/testjson"
+)
+
+func runWatcher(opts *options) error {
+	w := &watchRuns{opts: *opts}
+	return filewatcher.Watch(opts.packages, w.run)
+}
+
+type watchRuns struct {
+	opts     options
+	prevExec *testjson.Execution
+}
+
+func (w *watchRuns) run(runOpts filewatcher.RunOptions) error {
+	if runOpts.Debug {
+		path, cleanup, err := delveInitFile(w.prevExec)
+		if err != nil {
+			return err
+		}
+		defer cleanup()
+		o := delveOpts{
+			pkgPath:      runOpts.PkgPath,
+			args:         w.opts.args,
+			initFilePath: path,
+		}
+		if err := runDelve(o); !isExitCoder(err) {
+			return err
+		}
+		return nil
+	}
+
+	opts := w.opts
+	opts.packages = []string{runOpts.PkgPath}
+	var err error
+	if w.prevExec, err = runSingle(&opts); !isExitCoder(err) {
+		return err
+	}
+	return nil
+}
+
+// runSingle is similar to run. It doesn't support rerun-fails. It may be
+// possible to share runSingle with run, but the defer close on the handler
+// would require at least 3 return values, so for now it is a copy.
+func runSingle(opts *options) (*testjson.Execution, error) {
+	ctx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+
+	if err := opts.Validate(); err != nil {
+		return nil, err
+	}
+
+	goTestProc, err := startGoTestFn(ctx, goTestCmdArgs(opts, rerunOpts{}))
+	if err != nil {
+		return nil, err
+	}
+
+	handler, err := newEventHandler(opts)
+	if err != nil {
+		return nil, err
+	}
+	defer handler.Close() // nolint: errcheck
+	cfg := testjson.ScanConfig{
+		Stdout:  goTestProc.stdout,
+		Stderr:  goTestProc.stderr,
+		Handler: handler,
+		Stop:    cancel,
+	}
+	exec, err := testjson.ScanTestOutput(cfg)
+	if err != nil {
+		return exec, finishRun(opts, exec, err)
+	}
+	err = goTestProc.cmd.Wait()
+	return exec, finishRun(opts, exec, err)
+}
+
+func delveInitFile(exec *testjson.Execution) (string, func(), error) {
+	fh, err := ioutil.TempFile("", "gotestsum-delve-init")
+	if err != nil {
+		return "", nil, err
+	}
+	remove := func() {
+		os.Remove(fh.Name()) // nolint: errcheck
+	}
+
+	buf := bufio.NewWriter(fh)
+	for _, tc := range exec.Failed() {
+		fmt.Fprintf(buf, "break %s\n", tc.Test.Name())
+	}
+	buf.WriteString("continue\n")
+	if err := buf.Flush(); err != nil {
+		remove()
+		return "", nil, err
+	}
+	return fh.Name(), remove, nil
+}
+
+type delveOpts struct {
+	pkgPath      string
+	args         []string
+	initFilePath string
+}
+
+func runDelve(opts delveOpts) error {
+	pkg := opts.pkgPath
+	args := []string{"dlv", "test", "--wd", pkg}
+	args = append(args, "--output", "gotestsum-watch-debug.test")
+	args = append(args, "--init", opts.initFilePath)
+	args = append(args, pkg, "--")
+	args = append(args, opts.args...)
+
+	cmd := exec.Command(args[0], args[1:]...)
+	cmd.Stdin = os.Stdin
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	return cmd.Run()
+}
diff --git a/internal/filewatcher/term_unix.go b/internal/filewatcher/term_unix.go
index 31ba630..a277b9a 100644
--- a/internal/filewatcher/term_unix.go
+++ b/internal/filewatcher/term_unix.go
@@ -13,19 +13,27 @@ import (
 )
 
 type redoHandler struct {
-	prevPath string
-	ch       chan string
-	reset    func()
+	ch    chan RunOptions
+	reset func()
 }
 
 func newRedoHandler() *redoHandler {
+	h := &redoHandler{ch: make(chan RunOptions)}
+	h.SetupTerm()
+	return h
+}
+
+func (r *redoHandler) SetupTerm() {
+	if r == nil {
+		return
+	}
 	fd := int(os.Stdin.Fd())
 	reset, err := enableNonBlockingRead(fd)
 	if err != nil {
 		log.Warnf("failed to put terminal (fd %d) into raw mode: %v", fd, err)
-		return nil
+		return
 	}
-	return &redoHandler{ch: make(chan string), reset: reset}
+	r.reset = reset
 }
 
 func enableNonBlockingRead(fd int) (func(), error) {
@@ -57,10 +65,6 @@ func (r *redoHandler) Run(ctx context.Context) {
 	}
 	in := bufio.NewReader(os.Stdin)
 	for {
-		if ctx.Err() != nil {
-			return
-		}
-
 		char, err := in.ReadByte()
 		if err != nil {
 			log.Warnf("failed to read input: %v", err)
@@ -68,31 +72,38 @@ func (r *redoHandler) Run(ctx context.Context) {
 		}
 		log.Debugf("received byte %v (%v)", char, string(char))
 
+		var chResume chan struct{}
 		switch char {
 		case 'r':
-			r.ch <- r.prevPath
+			chResume = make(chan struct{})
+			r.ch <- RunOptions{resume: chResume}
+		case 'd':
+			chResume = make(chan struct{})
+			r.ch <- RunOptions{Debug: true, resume: chResume}
 		case '\n':
 			fmt.Println()
+			continue
+		default:
+			continue
+		}
+
+		select {
+		case <-ctx.Done():
+			return
+		case <-chResume:
 		}
 	}
 }
 
-func (r *redoHandler) Ch() <-chan string {
+func (r *redoHandler) Ch() <-chan RunOptions {
 	if r == nil {
 		return nil
 	}
 	return r.ch
 }
 
-func (r *redoHandler) Reset() {
-	if r != nil {
+func (r *redoHandler) ResetTerm() {
+	if r != nil && r.reset != nil {
 		r.reset()
 	}
 }
-
-func (r *redoHandler) Save(path string) {
-	if r == nil {
-		return
-	}
-	r.prevPath = path
-}
diff --git a/internal/filewatcher/term_windows.go b/internal/filewatcher/term_windows.go
index 2ef2967..5929d1e 100644
--- a/internal/filewatcher/term_windows.go
+++ b/internal/filewatcher/term_windows.go
@@ -10,10 +10,10 @@ func newRedoHandler() *redoHandler {
 
 func (r *redoHandler) Run(_ context.Context) {}
 
-func (r *redoHandler) Ch() <-chan string {
+func (r *redoHandler) Ch() <-chan RunOptions {
 	return nil
 }
 
-func (r *redoHandler) Reset() {}
+func (r *redoHandler) SetupTerm() {}
 
-func (r *redoHandler) Save(_ string) {}
+func (r *redoHandler) ResetTerm() {}
diff --git a/internal/filewatcher/watch.go b/internal/filewatcher/watch.go
index ffffd60..7df7981 100644
--- a/internal/filewatcher/watch.go
+++ b/internal/filewatcher/watch.go
@@ -15,7 +15,13 @@ import (
 
 const maxDepth = 7
 
-func Watch(dirs []string, run func(pkg string) error) error {
+type RunOptions struct {
+	PkgPath string
+	Debug   bool
+	resume  chan struct{}
+}
+
+func Watch(dirs []string, run func(opts RunOptions) error) error {
 	ctx, cancel := context.WithCancel(context.Background())
 	defer cancel()
 
@@ -37,7 +43,7 @@ func Watch(dirs []string, run func(pkg string) error) error {
 	defer timer.Stop()
 
 	redo := newRedoHandler()
-	defer redo.Reset()
+	defer redo.ResetTerm()
 	go redo.Run(ctx)
 
 	h := &handler{last: time.Now(), fn: run}
@@ -46,11 +52,16 @@ func Watch(dirs []string, run func(pkg string) error) error {
 		case <-timer.C:
 			return fmt.Errorf("exceeded idle timeout while watching files")
 
-		case path := <-redo.Ch():
+		case opts := <-redo.Ch():
 			resetTimer(timer)
-			if err := h.runTests(path); err != nil {
-				return fmt.Errorf("failed to rerun tests for %v: %v", path, err)
+
+			redo.ResetTerm()
+			opts.PkgPath = h.lastPath
+			if err := h.runTests(opts); err != nil {
+				return fmt.Errorf("failed to rerun tests for %v: %v", opts.PkgPath, err)
 			}
+			redo.SetupTerm()
+			close(opts.resume)
 
 		case event := <-watcher.Events:
 			resetTimer(timer)
@@ -63,7 +74,6 @@ func Watch(dirs []string, run func(pkg string) error) error {
 			if err := h.handleEvent(event); err != nil {
 				return fmt.Errorf("failed to run tests for %v: %v", event.Name, err)
 			}
-			redo.Save(event.Name)
 
 		case err := <-watcher.Errors:
 			return fmt.Errorf("failed while watching files: %v", err)
@@ -188,8 +198,9 @@ func handleDirCreated(watcher *fsnotify.Watcher, event fsnotify.Event) (handled
 }
 
 type handler struct {
-	last time.Time
-	fn   func(pkg string) error
+	last     time.Time
+	lastPath string
+	fn       func(opts RunOptions) error
 }
 
 const floodThreshold = 250 * time.Millisecond
@@ -207,15 +218,16 @@ func (h *handler) handleEvent(event fsnotify.Event) error {
 		log.Debugf("skipping event received less than %v after the previous", floodThreshold)
 		return nil
 	}
-	return h.runTests(event.Name)
+	return h.runTests(RunOptions{PkgPath: "./" + filepath.Dir(event.Name)})
 }
 
-func (h *handler) runTests(path string) error {
-	pkg := "./" + filepath.Dir(path)
-	fmt.Printf("\nRunning tests in %v\n", pkg)
-	if err := h.fn(pkg); err != nil {
+func (h *handler) runTests(opts RunOptions) error {
+	fmt.Printf("\nRunning tests in %v\n", opts.PkgPath)
+
+	if err := h.fn(opts); err != nil {
 		return err
 	}
 	h.last = time.Now()
+	h.lastPath = opts.PkgPath
 	return nil
 }


Test patch:
diff --git a/cmd/rerunfails_test.go b/cmd/rerunfails_test.go
index d103a25..faf1153 100644
--- a/cmd/rerunfails_test.go
+++ b/cmd/rerunfails_test.go
@@ -78,6 +78,7 @@ func TestGoTestRunFlagFromTestCases(t *testing.T) {
 			expected: "-test.run=^TestOne$/^SubtestA$",
 		},
 	}
+
 	for name := range testCases {
 		t.Run(name, func(t *testing.T) {
 			fn(t, testCases[name])
diff --git a/internal/filewatcher/watch_test.go b/internal/filewatcher/watch_test.go
index 657db54..15f350f 100644
--- a/internal/filewatcher/watch_test.go
+++ b/internal/filewatcher/watch_test.go
@@ -22,7 +22,7 @@ func TestHandler_HandleEvent(t *testing.T) {
 
 	fn := func(t *testing.T, tc testCase) {
 		var ran bool
-		run := func(pkg string) error {
+		run := func(opts RunOptions) error {
 			ran = true
 			return nil
 		}