Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add --watch flag for running tests when files change. #153

Merged
merged 2 commits into from
Oct 17, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ A demonstration of three `--format` options.
- [Add `go test` flags](#custom-go-test-command), or
[run a compiled test binary](#executing-a-compiled-test-binary).
- [Find or skip slow tests](#finding-and-skipping-slow-tests) using `gotestsum tool slowest`.
- [Run tests when a file is saved](#run-tests-when-a-file-is-saved) using
[filewatcher](https://github.com/dnephin/filewatcher).
- [Run tests when a file is saved](#run-tests-when-a-file-is-saved).

### Output Format

Expand Down Expand Up @@ -300,13 +299,16 @@ The next time tests are run using `--short` all the slow tests will be skipped.

### Run tests when a file is saved

[filewatcher](https://github.com/dnephin/filewatcher) will automatically set the
`TEST_DIRECTORY` environment variable to the directory if the file that was saved.
`gotestsum` uses the environment variable to run only the tests in that directory.
When the `--watch` flag is set, `gotestsum` will watch directories using
[file system notifications](https://pkg.go.dev/github.com/fsnotify/fsnotify).
When a Go file in one of those directories is modified, `gotestsum` will run the
tests for the package which contains the changed file. By default all
directories under the current directory will be watched. Use the `--packages` flag
to specify a different list.

**Example: run tests for a package when any file in that package is saved**
```
filewatcher gotestsum --format testname
gotestsum --watch --format testname
```

## Development
Expand Down
27 changes: 26 additions & 1 deletion cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ 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"
)
Expand All @@ -30,13 +31,29 @@ func Run(name string, args []string) error {
opts.args = flags.Args()
setupLogging(opts)

if opts.version {
switch {
case opts.version:
fmt.Fprintf(os.Stdout, "gotestsum version %s\n", version)
return nil
case opts.watch:
return runWatcher(opts)
}
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(),
Expand Down Expand Up @@ -68,6 +85,8 @@ func setupFlags(name string) (*pflag.FlagSet, *options) {
"hide sections of the summary: "+testjson.SummarizeAll.String())
flags.Var(opts.postRunHookCmd, "post-run-command",
"command to run after the tests have completed")
flags.BoolVar(&opts.watch, "watch", false,
"watch go files, and run tests when a file is modified")

flags.StringVar(&opts.junitFile, "junitfile",
lookEnvWithDefault("GOTESTSUM_JUNITFILE", ""),
Expand Down Expand Up @@ -143,6 +162,7 @@ type options struct {
rerunFailsReportFile string
rerunFailsOnlyRootCases bool
packages []string
watch bool
version bool

// shims for testing
Expand Down Expand Up @@ -372,6 +392,11 @@ type exitCoder interface {
ExitCode() int
}

func isExitCoder(err error) bool {
_, ok := err.(exitCoder)
return ok
}

func newSignalHandler(ctx context.Context, pid int) {
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
Expand Down
1 change: 1 addition & 0 deletions cmd/testdata/gotestsum-help-text
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Flags:
--rerun-fails-max-failures int do not rerun any tests if the initial run has more than this number of failures (default 10)
--rerun-fails-report string write a report to the file, of the tests that were rerun
--version show version and exit
--watch watch go files, and run tests when a file is modified

Formats:
dots print a character for each test
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ replace github.com/spf13/pflag => github.com/dnephin/pflag v0.0.0-20200521001137

require (
github.com/fatih/color v1.9.0
github.com/fsnotify/fsnotify v1.4.9
github.com/google/go-cmp v0.3.0
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/jonboulle/clockwork v0.1.0
Expand All @@ -12,7 +13,7 @@ require (
github.com/spf13/pflag v1.0.3
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2
golang.org/x/sync v0.0.0-20190423024810-112230192c58
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634
golang.org/x/tools v0.0.0-20190624222133-a101b041ded4
gotest.tools/v3 v3.0.2
)
Expand Down
7 changes: 5 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ github.com/dnephin/pflag v0.0.0-20200521001137-0f09ccd3add8 h1:7JFEKdSKf4LLYMqIM
github.com/dnephin/pflag v0.0.0-20200521001137-0f09ccd3add8/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
Expand Down Expand Up @@ -29,12 +31,13 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9 h1:YTzHMGlqJu67/uEo1lBv0n3wBXhXNeUbB1XfN2vmTm0=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634 h1:bNEHhJCnrwMKNMmOx3yAynp5vs5/gRy+XWFtZFu7NBM=
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190624222133-a101b041ded4 h1:1mMox4TgefDwqluYCv677yNXwlfTkija4owZve/jr78=
Expand Down
181 changes: 181 additions & 0 deletions internal/filewatcher/watch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package filewatcher

import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"

"github.com/fsnotify/fsnotify"
"gotest.tools/gotestsum/log"
)

const maxDepth = 7

func Watch(dirs []string, run func(pkg string) error) error {
toWatch := findAllDirs(dirs, maxDepth)
watcher, err := fsnotify.NewWatcher()
if err != nil {
return err
}
defer watcher.Close() // nolint: errcheck

fmt.Printf("Watching %v directories. Use Ctrl-c to to stop a run or exit.\n", len(toWatch))
for _, dir := range toWatch {
if err = watcher.Add(dir); err != nil {
return err
}
}

timer := time.NewTimer(time.Hour)
defer timer.Stop()

h := &handler{last: time.Now(), fn: run}
for {
select {
case <-timer.C:
return fmt.Errorf("exceeded idle timeout while watching files")
case event := <-watcher.Events:
log.Debugf("handling event %v", event)

if handleDirCreated(watcher, event) {
continue
}

if err := h.handleEvent(event); err != nil {
return fmt.Errorf("failed to run tests for %v: %v", event.Name, err)
}
timer.Reset(time.Hour)
case err := <-watcher.Errors:
return fmt.Errorf("failed while watching files: %v", err)
}
}
}

func findAllDirs(dirs []string, depth int) []string {
var output []string

walker := func(path string, info os.FileInfo, err error) error {
if err != nil {
log.Warnf("failed to watch %v: %v", path, err)
return nil
}
if !info.IsDir() {
return nil
}
if isMaxDepth(path, depth) || exclude(path) {
log.Debugf("Ignoring %v because of max depth or exclude list", path)
return filepath.SkipDir
}
if noGoFiles(path) {
log.Debugf("Ignoring %v because it has no .go files", path)
return nil
}
output = append(output, path)
return nil
}

if len(dirs) == 0 {
dirs = []string{"."}
}

for _, dir := range dirs {
dir = strings.TrimSuffix(dir, "/...")
// nolint: errcheck // error is handled by walker func
filepath.Walk(dir, walker)
}
return output
}

func isMaxDepth(path string, depth int) bool {
return strings.Count(filepath.Clean(path), string(filepath.Separator)) >= depth
}

// return true if path is vendor, testdata, or starts with a dot
func exclude(path string) bool {
base := filepath.Base(path)
switch {
case strings.HasPrefix(base, ".") && len(base) > 1:
return true
case base == "vendor" || base == "testdata":
return true
}
return false
}

func noGoFiles(path string) bool {
fh, err := os.Open(path)
if err != nil {
return true
}

for {
names, err := fh.Readdirnames(20)
switch {
case err == io.EOF:
return true
case err != nil:
log.Warnf("failed to read directory %v: %v", path, err)
return true
}

for _, name := range names {
if strings.HasSuffix(name, ".go") {
return false
}
}
}
}

func handleDirCreated(watcher *fsnotify.Watcher, event fsnotify.Event) bool {
if event.Op&fsnotify.Create != fsnotify.Create {
return false
}

fileInfo, err := os.Stat(event.Name)
if err != nil {
log.Warnf("failed to stat %s: %s", event.Name, err)
return false
}

if !fileInfo.IsDir() {
return false
}

if err := watcher.Add(event.Name); err != nil {
log.Warnf("failed to watch new directory %v: %v", event.Name, err)
}
return true
}

type handler struct {
last time.Time
fn func(pkg string) error
}

const floodThreshold = 250 * time.Millisecond

func (h *handler) handleEvent(event fsnotify.Event) error {
if event.Op&fsnotify.Write|fsnotify.Create == 0 {
return nil
}

if !strings.HasSuffix(event.Name, ".go") {
return nil
}

if time.Since(h.last) < floodThreshold {
log.Debugf("skipping event received less than %v after the previous", floodThreshold)
return nil
}

pkg := "./" + filepath.Dir(event.Name)
fmt.Printf("\nRunning tests in %v\n", pkg)
if err := h.fn(pkg); err != nil {
return err
}
h.last = time.Now()
return nil
}