├── .gitignore ├── logger.go ├── utils.go ├── README.md ├── cmd └── main.go ├── LICENSE.md ├── runner.go └── watcher.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package lazytest 2 | 3 | import ( 4 | "github.com/k0kubun/go-ansi" 5 | ) 6 | 7 | func log(text string) { 8 | ansi.Println(text) 9 | } 10 | 11 | /* 12 | * Render listens on a provided channel and logs incoming messages 13 | */ 14 | func Render(report chan Report) { 15 | for { 16 | r := <-report 17 | for _, test := range r { 18 | log(test.Message) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package lazytest 2 | 3 | /* 4 | * Batch is a struct holding information about a batch of unit tests. 5 | */ 6 | type Batch struct { 7 | Package string 8 | TestName string 9 | } 10 | 11 | func match(events chan Mod, batch chan Batch) { 12 | for { 13 | event := <-events 14 | batch <- Batch{Package: event.Package} 15 | } 16 | } 17 | 18 | /* 19 | * MatchTests launches a go routine to match file change events to unit tests. 20 | */ 21 | func MatchTests(events chan Mod) chan Batch { 22 | batch := make(chan Batch, 50) 23 | go match(events, batch) 24 | return batch 25 | } 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lazytest 2 | 3 | [![Go Report Card Badge](http://goreportcard.com/badge/gophergala2016/lazytest)](http://goreportcard.com/report/gophergala2016/lazytest) 4 | 5 | A continuous test runner for Go. 6 | 7 | Once started, it will listen for file changes in a given directory. If a file change is detected, only the tests affected by that file change will be re-run. 8 | 9 | ### Usage: 10 | ```` 11 | -exclude string 12 | exclude paths (default "/vendor/") 13 | -extensions string 14 | file extensions to watch (default "go,tpl,html") 15 | -root string 16 | watch root (default ".") 17 | ```` 18 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "strings" 7 | 8 | "github.com/gophergala2016/lazytest" 9 | ) 10 | 11 | var flags struct { 12 | root string 13 | exclude string 14 | extensions string 15 | } 16 | 17 | func init() { 18 | flag.StringVar(&flags.root, "root", ".", "watch root") 19 | flag.StringVar(&flags.exclude, "exclude", "/vendor/", "exclude paths") 20 | flag.StringVar(&flags.extensions, "extensions", "go,tpl,html", 21 | "file extensions to watch") 22 | flag.Parse() 23 | } 24 | 25 | func main() { 26 | testBatch := lazytest.MatchTests(watch()) 27 | report := lazytest.Runner(testBatch) 28 | lazytest.Render(report) 29 | } 30 | 31 | func watch() chan lazytest.Mod { 32 | exclude := strings.Split(flags.exclude, ",") 33 | extensions := strings.Split(flags.extensions, ",") 34 | 35 | events, err := lazytest.Watch(flags.root, extensions, exclude) 36 | if err != nil { 37 | log.Fatal(err) 38 | } 39 | 40 | return events 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /runner.go: -------------------------------------------------------------------------------- 1 | package lazytest 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "regexp" 7 | "strings" 8 | "sync" 9 | "sync/atomic" 10 | "time" 11 | ) 12 | 13 | type testQueue struct { 14 | tests []Batch 15 | } 16 | 17 | const ( 18 | RunnerIdle int32 = iota 19 | RunnerBusy 20 | ) 21 | 22 | type TestStatus int8 23 | 24 | const ( 25 | StatusPending TestStatus = iota 26 | StatusSkipped 27 | StatusFailed 28 | StatusPanicked 29 | StatusPassed 30 | ) 31 | 32 | type TestReport struct { 33 | Name string 34 | Package string 35 | Status TestStatus 36 | Message string 37 | } 38 | 39 | var ( 40 | runnerDone chan struct{} = make(chan struct{}) 41 | runnerStatus int32 42 | mux sync.Mutex 43 | queue *testQueue = &testQueue{} 44 | rep chan Report 45 | ) 46 | 47 | type Report []TestReport 48 | 49 | func Runner(batch chan Batch) chan Report { 50 | rep = make(chan Report, 50) 51 | go queueTests(batch, rep) 52 | return rep 53 | } 54 | 55 | func (t *testQueue) run() { 56 | packageTests := make(map[string][]string) 57 | for _, test := range t.tests { 58 | if _, ok := packageTests[test.Package]; !ok { 59 | packageTests[test.Package] = make([]string, 0) 60 | } 61 | packageTests[test.Package] = append(packageTests[test.Package], regexp.QuoteMeta(test.TestName)) 62 | } 63 | for pkg, tests := range packageTests { 64 | cmdStr := []string{"test", "-v", pkg} 65 | if len(tests) > 0 { 66 | testRegexp := fmt.Sprintf("'(%s)'", strings.Join(tests, "|")) 67 | cmdStr = append(cmdStr, "-run", testRegexp) 68 | } 69 | 70 | cmd := exec.Command("go", cmdStr...) 71 | out, err := cmd.CombinedOutput() 72 | if err != nil { 73 | log(err.Error()) 74 | } 75 | log(string(out)) 76 | } 77 | atomic.StoreInt32(&runnerStatus, RunnerIdle) 78 | runnerDone <- struct{}{} 79 | } 80 | 81 | func queueTests(batch chan Batch, rep chan Report) { 82 | block := make(chan struct{}) 83 | var delay *time.Timer 84 | for { 85 | select { 86 | case b := <-batch: 87 | mux.Lock() 88 | if delay == nil { 89 | log("Filechange detected, running tests...") 90 | 91 | delay = time.NewTimer(time.Second * 2) 92 | go func(d *time.Timer) { 93 | <-d.C 94 | block <- struct{}{} 95 | }(delay) 96 | } 97 | if queue.tests == nil { 98 | queue.tests = make([]Batch, 0) 99 | } 100 | queue.tests = append(queue.tests, b) 101 | mux.Unlock() 102 | 103 | case <-block: 104 | mux.Lock() 105 | if atomic.CompareAndSwapInt32(&runnerStatus, RunnerIdle, RunnerBusy) { 106 | delay = nil 107 | go queue.run() 108 | queue = &testQueue{} 109 | } 110 | mux.Unlock() 111 | 112 | case <-runnerDone: 113 | mux.Lock() 114 | if delay == nil && len(queue.tests) > 0 { 115 | atomic.StoreInt32(&runnerStatus, RunnerBusy) 116 | go queue.run() 117 | queue = &testQueue{} 118 | } 119 | mux.Unlock() 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /watcher.go: -------------------------------------------------------------------------------- 1 | package lazytest 2 | 3 | import ( 4 | "fmt" 5 | "go/parser" 6 | "go/token" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "gopkg.in/fsnotify.v1" 12 | ) 13 | 14 | type fileWatcher struct { 15 | extensions []string 16 | exclude []string 17 | watcher *fsnotify.Watcher 18 | } 19 | 20 | /* 21 | * Mod is a struct holding all the information about a file modification. 22 | */ 23 | type Mod struct { 24 | Package string 25 | FilePath string 26 | Function string 27 | Line int 28 | } 29 | 30 | func (w *fileWatcher) handleDir(path string) error { 31 | if !w.isIncluded(path, false) { 32 | return filepath.SkipDir 33 | } 34 | 35 | if len(path) > 1 && strings.HasPrefix(filepath.Base(path), ".") { 36 | return filepath.SkipDir 37 | } 38 | 39 | return w.watcher.Add(path) 40 | } 41 | 42 | func (w *fileWatcher) handleEvent(e fsnotify.Event, eventChannel chan Mod) { 43 | if e.Op|fsnotify.Rename == e.Op || e.Op|fsnotify.Chmod == e.Op { 44 | return 45 | } 46 | 47 | eventChannel <- Mod{ 48 | FilePath: e.Name, 49 | Package: packageName(e.Name), 50 | } 51 | // TODO: remove old watches on delete, add new watches on create, do both on rename 52 | } 53 | 54 | func (w *fileWatcher) isIncluded(path string, isFile bool) bool { 55 | include := len(w.extensions) == 0 56 | 57 | if !isFile { 58 | include = true 59 | } else { 60 | for _, e := range w.extensions { 61 | if filepath.Ext(path) == e { 62 | include = true 63 | } 64 | } 65 | } 66 | 67 | for _, e := range w.exclude { 68 | if filepath.HasPrefix(path, e) { 69 | return false 70 | } 71 | } 72 | 73 | return include 74 | } 75 | 76 | func (w *fileWatcher) listenForEvents(eventChannel chan Mod) { 77 | for { 78 | select { 79 | case e := <-w.watcher.Events: 80 | w.handleEvent(e, eventChannel) 81 | 82 | case err := <-w.watcher.Errors: 83 | log(fmt.Sprintf("Watcher error %v", err)) 84 | } 85 | } 86 | } 87 | 88 | func (w *fileWatcher) walk(path string, info os.FileInfo, err error) error { 89 | if info.IsDir() { 90 | return w.handleDir(path) 91 | } 92 | 93 | if w.isIncluded(path, true) { 94 | return w.watcher.Add(path) 95 | } 96 | 97 | return err 98 | } 99 | 100 | /* 101 | * Watch sets up a file watcher using the provided options. It returns a channel 102 | * of modifications. 103 | */ 104 | func Watch(root string, extensions []string, exclude []string) (chan Mod, 105 | error) { 106 | 107 | watcher, err := fsnotify.NewWatcher() 108 | if err != nil { 109 | return nil, err 110 | } 111 | 112 | absolutePath, err := filepath.Abs(root) 113 | if err != nil { 114 | return nil, err 115 | } 116 | 117 | w := &fileWatcher{ 118 | extensions: extensions, 119 | exclude: exclude, 120 | watcher: watcher, 121 | } 122 | 123 | events := make(chan Mod, 50) 124 | go w.listenForEvents(events) 125 | return events, filepath.Walk(absolutePath, w.walk) 126 | } 127 | 128 | func packageName(path string) string { 129 | fset := token.NewFileSet() 130 | // parse the go source file, but only the package clause 131 | astFile, err := parser.ParseFile(fset, path, nil, parser.PackageClauseOnly) 132 | if err != nil { 133 | log(err.Error()) 134 | return "" 135 | } 136 | if astFile.Name == nil { 137 | log("no name") 138 | return "" 139 | } 140 | pkg := filepath.Dir(path) 141 | lastSlash := strings.LastIndex(pkg, string(filepath.Separator)) + 1 142 | pkg = pkg[0:lastSlash] 143 | pkg = pkg + astFile.Name.Name 144 | 145 | gopath := os.Getenv("GOPATH") 146 | gosrc := gopath + string(filepath.Separator) + "src" + string(filepath.Separator) 147 | 148 | pkg = strings.TrimPrefix(pkg, gosrc) 149 | 150 | return pkg 151 | } 152 | --------------------------------------------------------------------------------