Skip to content

Commit

Permalink
Merge pull request #153 from dnephin/add-watch-flag
Browse files Browse the repository at this point in the history
Add --watch flag for running tests when files change.
  • Loading branch information
dnephin committed Oct 17, 2020
2 parents fddcbf4 + 1c19d1b commit d52d240
Show file tree
Hide file tree
Showing 6 changed files with 223 additions and 10 deletions.
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
}

0 comments on commit d52d240

Please sign in to comment.