Compare commits

...

42 Commits

Author SHA1 Message Date
Max Claus Nunes c44dd59952 Update doc to include "program-args" example 2023-04-27 18:16:36 -03:00
Max Claus Nunes 5a02122f33
Merge pull request #18 from maxcnunes/fix-ci
Fix CI tasks
2022-08-08 14:34:12 -03:00
Max Claus Nunes edee55552a Add name workflow 2022-08-08 14:31:37 -03:00
Max Claus Nunes d314b7b928 Add missing test files 2022-08-08 11:47:49 -03:00
Max Claus Nunes f0479bba61 Fix lint CI 2022-08-08 11:40:24 -03:00
Max Claus Nunes 90f855c72b Use Github actions 2022-08-08 11:14:30 -03:00
Max Claus Nunes 1fde6281f1 Ignore go-tmp-unmask
Closes https://github.com/maxcnunes/gaper/issues/13
2022-08-07 10:47:30 -03:00
Max Claus Nunes 95fc7d2127 Update cli to v2 2022-08-07 10:40:15 -03:00
Max Claus Nunes b0dcdd49b0 Add extensions example 2022-08-07 10:30:58 -03:00
Max Claus Nunes 39d9c0ad64 Support multiple extensions with comma
Closes https://github.com/maxcnunes/gaper/issues/17
2022-08-07 10:10:24 -03:00
Max Claus Nunes 9229e8d69b Improve examples doc 2021-12-18 08:01:38 -03:00
Max Claus Nunes 4eb2525b49 Fix typo 2021-12-18 07:30:07 -03:00
Max Claus Nunes 7f381c7e58 Add example for custom configurations 2021-12-18 07:28:48 -03:00
Max Claus Nunes 2a09c4e713 Add example to install using the binary 2019-12-04 18:19:40 -03:00
Max Claus Nunes e99cd89574 Upgrade urfave/cli to version 1.22.2 2019-11-21 19:05:09 -03:00
Max Claus Nunes 3f3fcea575 Remove dep script 2019-11-21 19:04:37 -03:00
Max Claus Nunes 0324256f9c Migrate to go module 2019-11-12 18:37:44 -03:00
Max Claus Nunes 7c57257db3 Fix typo 2019-01-19 17:47:02 -02:00
Max Claus Nunes 60a0a90d11 Add notes about the watch method used in Gaper 2019-01-19 17:43:21 -02:00
Max Claus Nunes 2cdb29eb89 Add note about directory path settings
Close #11
2019-01-19 17:39:53 -02:00
Max Claus Nunes 69ab70821a Apply logic to ignore invalid extensions for non glob matches 2019-01-19 17:26:51 -02:00
Max Claus Nunes 56a75324ca Prepare release 1.0.3 2018-11-18 13:40:51 -02:00
Max Claus Nunes 7ecf55b3f6 Fix issue of gaper not restarting after a previous failure
Close #8
2018-11-18 13:37:19 -02:00
Max Claus Nunes 931f3778a8 Prepare release 1.0.2 2018-10-21 11:11:48 -03:00
Max Claus Nunes a5cac98c13 Skip restart from file changes when there is another restart going on 2018-10-21 11:11:31 -03:00
Max Claus Nunes a93d484fb3 Increase sleep on tests to avoid intermittent failure 2018-10-21 11:10:46 -03:00
Max Claus Nunes daee1bff02 Use a single logger instance
Close #6
2018-10-21 10:17:58 -03:00
Max Claus Nunes c9da986360 Handle incoming errors from the walking calls
Fix #7
2018-10-21 09:46:22 -03:00
Max Claus Nunes 612540b1b3 Hardcode gaper version 2018-09-08 10:49:30 -03:00
Max Claus Nunes e4d6c94e65 Fix invalid memory error after deleting a watched file
Closes #3
2018-09-08 10:41:56 -03:00
Max Claus Nunes 7f5268751f Show build details when a build fails
Closes #4
2018-09-08 10:35:29 -03:00
Max Claus Nunes a2082c3041 Remove extra space from logs 2018-09-08 10:27:10 -03:00
Max Claus Nunes e8e114f3a6 Don't leave during a restart failure
Closes #2
2018-08-06 10:48:19 -03:00
Max Claus Nunes 2f011065f0 Fix lint 2018-07-24 22:42:43 -03:00
Max Claus Nunes f0b6bfbe00 Add test for bad shell args 2018-07-24 22:37:03 -03:00
Max Claus Nunes 15d3ba41d3 Add test for build and runner errors 2018-07-24 22:05:10 -03:00
Max Claus Nunes a81558c850 Improve test coverage for watcher 2018-07-24 21:31:19 -03:00
Max Claus Nunes be4160275a Improve test coverage for logger 2018-07-22 10:56:23 -03:00
Max Claus Nunes dba823c23b Fix typo in docs 2018-07-22 10:41:26 -03:00
Max Claus Nunes a75eac8490 Improve watching search and configuration by improving default ignore
Now it will ignore by default hidden files and folders, test files and vendor
2018-07-22 10:30:30 -03:00
Max Claus Nunes 586c834cb9 Apply improvements suggested by the linter 2018-07-12 10:35:44 -03:00
Max Claus Nunes f578db31da Improve test coverage 2018-07-12 10:27:07 -03:00
34 changed files with 1129 additions and 372 deletions

37
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,37 @@
name: goreleaser
on:
push:
# run only against tags
tags:
- '*'
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
-
name: Fetch all tags
run: git fetch --force --tags
-
name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.19
-
name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
with:
distribution: goreleaser
version: latest
args: release --rm-dist
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

39
.github/workflows/workflow.yml vendored Normal file
View File

@ -0,0 +1,39 @@
name: dev-workflow
on:
- push
jobs:
run:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os:
- ubuntu-latest
# - macos-latest
# - windows-latest
go:
- '1.19'
# - '1.18'
# - '1.17'
# - '1.16'
# - '1.15'
env:
OS: ${{ matrix.os }}
steps:
- uses: actions/checkout@master
- name: Setup Go
uses: actions/setup-go@v3
with:
go-version: ${{ matrix.go }}
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: v1.48
- name: Test
run: make test
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ coverage.out
.DS_Store
testdata/server/server
dist
test-srv

View File

@ -1,29 +0,0 @@
language: go
go:
# - 1.7.x
# - 1.8.x
# - 1.9.x
- 1.10.x
# - master
before_script:
- go version
- make setup
- make lint
script:
- make test
after_success:
- bash <(curl -s https://codecov.io/bash)
notifications:
email: false
deploy:
- provider: script
skip_cleanup: true
script: curl -sL http://git.io/goreleaser | bash
on:
tags: true

View File

@ -28,7 +28,13 @@ make setup
### Running gaper in development
```
make build && ./gaper --verbose --bin-name srv --build-path ./testdata/server
make build && \
./gaper \
--verbose \
--bin-name srv \
--build-path ./testdata/server \
--build-args="-ldflags=\"-X 'main.Version=v1.0.0'\"" \
--extensions "go,txt"
```
### Running lint
@ -49,3 +55,6 @@ A single test:
go test -run TestSimplePost ./...
```
### Release
The release runs automatically with a Github action on pushed git tags.

72
Gopkg.lock generated
View File

@ -1,72 +0,0 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
name = "github.com/davecgh/go-spew"
packages = ["spew"]
revision = "346938d642f2ec3594ed81d874461961cd0faa76"
version = "v1.1.0"
[[projects]]
name = "github.com/fatih/color"
packages = ["."]
revision = "5b77d2a35fb0ede96d138fc9a99f5c9b6aef11b4"
version = "v1.7.0"
[[projects]]
name = "github.com/mattn/go-colorable"
packages = ["."]
revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072"
version = "v0.0.9"
[[projects]]
name = "github.com/mattn/go-isatty"
packages = ["."]
revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39"
version = "v0.0.3"
[[projects]]
name = "github.com/mattn/go-shellwords"
packages = ["."]
revision = "02e3cf038dcea8290e44424da473dd12be796a8a"
version = "v1.0.3"
[[projects]]
branch = "master"
name = "github.com/mattn/go-zglob"
packages = [
".",
"fastwalk"
]
revision = "49693fbb3fe3c3a75fc4e4d6fb1d7cedcbdeb385"
[[projects]]
name = "github.com/pmezard/go-difflib"
packages = ["difflib"]
revision = "792786c7400a136282c1664665ae0a8db921c6c2"
version = "v1.0.0"
[[projects]]
name = "github.com/stretchr/testify"
packages = ["assert"]
revision = "f35b8ab0b5a2cef36673838d662e249dd9c94686"
version = "v1.2.2"
[[projects]]
name = "github.com/urfave/cli"
packages = ["."]
revision = "cfb38830724cc34fedffe9a2a29fb54fa9169cd1"
version = "v1.20.0"
[[projects]]
branch = "master"
name = "golang.org/x/sys"
packages = ["unix"]
revision = "6c888cc515d3ed83fc103cf1d84468aad274b0a7"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "501c303fff1c8cdb5806d0ebb0c92b671095cbc2049f5b8d2286df401f5efce5"
solver-name = "gps-cdcl"
solver-version = 1

View File

@ -1,23 +0,0 @@
[[constraint]]
name = "github.com/fatih/color"
version = "1.7.0"
[[constraint]]
name = "github.com/mattn/go-shellwords"
version = "1.0.3"
[[constraint]]
name = "github.com/urfave/cli"
version = "1.20.0"
[prune]
go-tests = true
unused-packages = true
[[constraint]]
branch = "master"
name = "github.com/mattn/go-zglob"
[[constraint]]
name = "github.com/stretchr/testify"
version = "1.2.2"

View File

@ -1,33 +1,16 @@
OS := $(shell uname -s)
TEST_PACKAGES := $(shell go list ./...)
COVER_PACKAGES := $(shell go list ./... | paste -sd "," -)
TEST_PACKAGES := $(shell go list ./... | grep -v cmd)
COVER_PACKAGES := $(shell go list ./... | grep -v cmd | paste -sd "," -)
LINTER := $(shell command -v gometalinter 2> /dev/null)
.PHONY: setup
setup:
ifeq ($(OS), Darwin)
brew install dep
else
curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh
endif
dep ensure -vendor-only
ifndef LINTER
@echo "Installing linter"
@go get -u github.com/alecthomas/gometalinter
@gometalinter --install
endif
build:
@go build -o ./gaper cmd/gaper/main.go
## lint: Validate golang code
# Install it following this doc https://golangci-lint.run/usage/install/#local-installation,
# please use the same version from .github/workflows/workflow.yml.
lint:
@gometalinter \
--deadline=120s \
--line-length=120 \
--enable-all \
--vendor ./...
@golangci-lint run
test:
@go test -p=1 -coverpkg $(COVER_PACKAGES) \

View File

@ -18,15 +18,23 @@
[![Go Report Card](https://goreportcard.com/badge/github.com/maxcnunes/gaper)](https://goreportcard.com/report/github.com/maxcnunes/gaper)
[![Powered By: GoReleaser](https://img.shields.io/badge/powered%20by-goreleaser-green.svg?style=flat-square)](https://github.com/goreleaser)
## Changelog
See [Releases](https://github.com/maxcnunes/gaper/releases) for detailed history changes.
## Installation
Using go tooling:
```
go get -u github.com/maxcnunes/gaper/cmd/gaper
```
## Changelog
Or, downloading the binary instead (example for version 1.1.0, make sure you are using the latest version though):
See [Releases](https://github.com/maxcnunes/gaper/releases) for detailed history changes.
```
curl -SL https://github.com/maxcnunes/gaper/releases/download/v1.1.0/gaper_1.1.0_linux_amd64.tar.gz | tar -xvzf - -C "${GOPATH}/bin"
```
## Usage
@ -49,11 +57,11 @@ GLOBAL OPTIONS:
--build-args value arguments used on building the program
--program-args value arguments used on executing the program
--verbose turns on the verbose messages from gaper
--disable-default-ignore turns off default ignore for hidden files and folders, "*_test.go" files, and vendor folder
--watch value, -w value list of folders or files to watch for changes
--ignore value, -i value list of folders or files to ignore for changes
(always ignores all hidden files and directories)
--poll-interval value, -p value how often in milliseconds to poll watched files for changes (default: 500)
--extensions value, -e value a comma-delimited list of file extensions to watch for changes (default: "go")
--extensions value, -e value extensions to watch for changes (default: "go")
--no-restart-on value, -n value don't automatically restart the supervised program if it ends:
if "error", an exit code of 0 will still restart.
if "exit", no restart regardless of exit code.
@ -62,12 +70,46 @@ GLOBAL OPTIONS:
--version, -v print the version
```
### Watch and Ignore paths
For those options Gaper supports static paths (e.g. `build/`, `seed.go`) or glob paths (e.g. `migrations/**/up.go`, `*_test.go`).
On using a path to a directory please add a `/` at the end (e.g. `build/`) to make sure Gaper won't include other matches that starts with that same value (e.g. `build/`, `build_settings.go`).
### Default ignore settings
Since in most projects there is no need to watch changes for:
* hidden files and folders
* test files (`*_test.go`)
* vendor folder
Gaper by default ignores those cases already. Although, if you need Gaper to watch those files anyway it is possible to disable this setting with `--disable-default-ignore` argument.
### Watch method
Currently Gaper uses polling to watch file changes. We have plans to [support fs events](https://github.com/maxcnunes/gaper/issues/12) though in a near future.
### Examples
Ignore watch over all test files:
Using all defaults provided by Gaper:
```
--ignore './**/*_test.go'
gaper
```
Example providing a few custom configurations:
```
gaper \
--bin-name build/api-dev \
--build-path cmd/server \
--build-args "-ldflags=\"-X 'main.Version=dev'" \
-w 'public/**' -w '*.go' \
-e js -e css -e html \
--ignore './**/*_mock.go' \
--program-args "-arg1 ok -arg2=nope" \
--watch .
```
## Contributing

View File

@ -1,31 +0,0 @@
version: "{build}"
# Source Config
clone_folder: c:\gopath\src\github.com\maxcnunes\gaper
# Build host
environment:
GOPATH: c:\gopath
GOBIN: c:\gopath\bin
init:
- git config --global core.autocrlf input
# Build
install:
- set Path=c:\go\bin;c:\gopath\bin;%Path%
- go version
- go env
- go get -u github.com/golang/dep/cmd/dep
- choco install make
- make setup
build: false
deploy: false
test_script:
- go build github.com/maxcnunes/gaper/cmd/gaper
- make test

View File

@ -17,7 +17,6 @@ type Builder interface {
type builder struct {
dir string
binary string
errors string
wd string
buildArgs []string
}
@ -56,7 +55,7 @@ func (b *builder) Build() error {
output, err := command.CombinedOutput()
if err != nil {
return err
return fmt.Errorf("build failed with %v\n%s", err, output)
}
if !command.ProcessState.Success() {

View File

@ -41,7 +41,10 @@ func TestBuilderFailureBuild(t *testing.T) {
b := NewBuilder(dir, bin, wd, bArgs)
err = b.Build()
assert.NotNil(t, err, "build error")
assert.Equal(t, err.Error(), "exit status 2")
assert.Equal(t, err.Error(), "build failed with exit status 2\n"+
"# github.com/maxcnunes/gaper/testdata/build-failure\n"+
"./main.go:4:6: func main must have no arguments and no return values\n"+
"./main.go:5:1: missing return\n")
}
func TestBuilderDefaultBinName(t *testing.T) {

View File

@ -4,30 +4,35 @@ import (
"os"
"github.com/maxcnunes/gaper"
"github.com/urfave/cli"
"github.com/urfave/cli/v2"
)
// build info
var (
version = "dev"
// Version is hardcoded because when installing it through "go get/install"
// the build tags are not available to override it.
// Update it after every release.
version = "1.0.3-dev"
)
var logger = gaper.NewLogger("gaper")
func main() {
logger := gaper.Logger()
loggerVerbose := false
parseArgs := func(c *cli.Context) *gaper.Config {
loggerVerbose = c.Bool("verbose")
return &gaper.Config{
BinName: c.String("bin-name"),
BuildPath: c.String("build-path"),
BuildArgsMerged: c.String("build-args"),
ProgramArgsMerged: c.String("program-args"),
Verbose: c.Bool("verbose"),
DisableDefaultIgnore: c.Bool("disable-default-ignore"),
WatchItems: c.StringSlice("watch"),
IgnoreItems: c.StringSlice("ignore"),
PollInterval: c.Int("poll-interval"),
Extensions: c.StringSlice("extensions"),
NoRestartOn: c.String("no-restart-on"),
ExitOnSIGINT: true,
}
}
@ -36,62 +41,61 @@ func main() {
app.Usage = "Used to build and restart a Go project when it crashes or some watched file changes"
app.Version = version
app.Action = func(c *cli.Context) {
app.Action = func(c *cli.Context) error {
args := parseArgs(c)
if err := gaper.Run(args); err != nil {
logger.Error(err)
os.Exit(1)
}
}
chOSSiginal := make(chan os.Signal, 2)
logger.Verbose(loggerVerbose)
exts := make(cli.StringSlice, len(gaper.DefaultExtensions))
for i := range gaper.DefaultExtensions {
exts[i] = gaper.DefaultExtensions[i]
return gaper.Run(args, chOSSiginal)
}
// supported arguments
app.Flags = []cli.Flag{
cli.StringFlag{
&cli.StringFlag{
Name: "bin-name",
Usage: "name for the binary built by gaper for the executed program (default current directory name)",
},
cli.StringFlag{
&cli.StringFlag{
Name: "build-path",
Value: gaper.DefaultBuildPath,
Usage: "path to the program source code",
},
cli.StringFlag{
&cli.StringFlag{
Name: "build-args",
Usage: "arguments used on building the program",
},
cli.StringFlag{
&cli.StringFlag{
Name: "program-args",
Usage: "arguments used on executing the program",
},
cli.BoolFlag{
&cli.BoolFlag{
Name: "verbose",
Usage: "turns on the verbose messages from gaper",
},
cli.StringSliceFlag{
&cli.BoolFlag{
Name: "disable-default-ignore",
Usage: "turns off default ignore for hidden files and folders, \"*_test.go\" files, and vendor folder",
},
&cli.StringSliceFlag{
Name: "watch, w",
Usage: "list of folders or files to watch for changes",
},
cli.StringSliceFlag{
&cli.StringSliceFlag{
Name: "ignore, i",
Usage: "list of folders or files to ignore for changes\n" +
"\t\t(always ignores all hidden files and directories)",
},
cli.IntFlag{
&cli.IntFlag{
Name: "poll-interval, p",
Value: gaper.DefaultPoolInterval,
Usage: "how often in milliseconds to poll watched files for changes",
},
cli.StringSliceFlag{
&cli.StringSliceFlag{
Name: "extensions, e",
Value: &exts,
Value: cli.NewStringSlice(gaper.DefaultExtensions...),
Usage: "a comma-delimited list of file extensions to watch for changes",
},
cli.StringFlag{
&cli.StringFlag{
Name: "no-restart-on, n",
Usage: "don't automatically restart the supervised program if it ends:\n" +
"\t\tif \"error\", an exit code of 0 will still restart.\n" +

151
gaper.go
View File

@ -5,9 +5,9 @@ package gaper
import (
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
@ -23,7 +23,12 @@ var DefaultExtensions = []string{"go"}
// DefaultPoolInterval is the time in ms used by the watcher to wait between scans
var DefaultPoolInterval = 500
var logger = NewLogger("gaper")
// No restart types
var (
NoRestartOnError = "error"
NoRestartOnSuccess = "success"
NoRestartOnExit = "exit"
)
// exit statuses
var exitStatusSuccess = 0
@ -42,72 +47,71 @@ type Config struct {
PollInterval int
Extensions []string
NoRestartOn string
Verbose bool
ExitOnSIGINT bool
DisableDefaultIgnore bool
WorkingDirectory string
}
// Run in the gaper high level API
// It starts the whole gaper process watching for file changes or exit codes
// Run starts the whole gaper process watching for file changes or exit codes
// and restarting the program
func Run(cfg *Config) error { // nolint: gocyclo
var err error
logger.Verbose(cfg.Verbose)
func Run(cfg *Config, chOSSiginal chan os.Signal) error {
logger.Debug("Starting gaper")
if len(cfg.BuildPath) == 0 {
cfg.BuildPath = DefaultBuildPath
}
cfg.BuildArgs, err = parseInnerArgs(cfg.BuildArgs, cfg.BuildArgsMerged)
if err != nil {
if err := setupConfig(cfg); err != nil {
return err
}
cfg.ProgramArgs, err = parseInnerArgs(cfg.ProgramArgs, cfg.ProgramArgsMerged)
logger.Debugf("Config: %+v", cfg)
wCfg := WatcherConfig{
DefaultIgnore: !cfg.DisableDefaultIgnore,
PollInterval: cfg.PollInterval,
WatchItems: cfg.WatchItems,
IgnoreItems: cfg.IgnoreItems,
Extensions: cfg.Extensions,
}
builder := NewBuilder(cfg.BuildPath, cfg.BinName, cfg.WorkingDirectory, cfg.BuildArgs)
runner := NewRunner(os.Stdout, os.Stderr, filepath.Join(cfg.WorkingDirectory, builder.Binary()), cfg.ProgramArgs)
watcher, err := NewWatcher(wCfg)
if err != nil {
return err
return fmt.Errorf("watcher error: %v", err)
}
wd, err := os.Getwd()
if err != nil {
return err
return run(cfg, chOSSiginal, builder, runner, watcher)
}
if len(cfg.WatchItems) == 0 {
cfg.WatchItems = append(cfg.WatchItems, cfg.BuildPath)
}
builder := NewBuilder(cfg.BuildPath, cfg.BinName, wd, cfg.BuildArgs)
runner := NewRunner(os.Stdout, os.Stderr, filepath.Join(wd, builder.Binary()), cfg.ProgramArgs)
if err = builder.Build(); err != nil {
// nolint: gocyclo
func run(cfg *Config, chOSSiginal chan os.Signal, builder Builder, runner Runner, watcher Watcher) error {
if err := builder.Build(); err != nil {
return fmt.Errorf("build error: %v", err)
}
shutdown(runner, cfg.ExitOnSIGINT)
// listen for OS signals
signal.Notify(chOSSiginal, os.Interrupt, syscall.SIGTERM)
if _, err = runner.Run(); err != nil {
if _, err := runner.Run(); err != nil {
return fmt.Errorf("run error: %v", err)
}
watcher, err := NewWatcher(cfg.PollInterval, cfg.WatchItems, cfg.IgnoreItems, cfg.Extensions)
if err != nil {
return fmt.Errorf("watcher error: %v", err)
}
// flag to know if an exit was caused by a restart from a file changing
changeRestart := false
go watcher.Watch()
for {
select {
case event := <-watcher.Events:
case event := <-watcher.Events():
logger.Debug("Detected new changed file:", event)
changeRestart = true
if changeRestart {
logger.Debug("Skip restart due to existing on going restart")
continue
}
changeRestart = runner.IsRunning()
if err := restart(builder, runner); err != nil {
return err
}
case err := <-watcher.Errors:
case err := <-watcher.Errors():
return fmt.Errorf("error on watching files: %v", err)
case err := <-runner.Errors():
logger.Debug("Detected program exit:", err)
@ -121,6 +125,14 @@ func Run(cfg *Config) error { // nolint: gocyclo
if err = handleProgramExit(builder, runner, err, cfg.NoRestartOn); err != nil {
return err
}
case signal := <-chOSSiginal:
logger.Debug("Got signal:", signal)
if err := runner.Kill(); err != nil {
logger.Error("Error killing:", err)
}
return fmt.Errorf("OS signal: %v", signal)
default:
time.Sleep(time.Duration(cfg.PollInterval) * time.Millisecond)
}
@ -138,60 +150,73 @@ func restart(builder Builder, runner Runner) error {
}
if err := builder.Build(); err != nil {
return fmt.Errorf("build error: %v", err)
logger.Error("Error building binary during a restart:", err)
return nil
}
if _, err := runner.Run(); err != nil {
return fmt.Errorf("run error: %v", err)
logger.Error("Error starting process during a restart:", err)
return nil
}
return nil
}
func handleProgramExit(builder Builder, runner Runner, err error, noRestartOn string) error {
var exitStatus int
if exiterr, ok := err.(*exec.ExitError); ok {
status, oks := exiterr.Sys().(syscall.WaitStatus)
if !oks {
return fmt.Errorf("couldn't resolve exit status: %v", err)
}
exitStatus = status.ExitStatus()
}
exitStatus := runner.ExitStatus(err)
// if "error", an exit code of 0 will still restart.
if noRestartOn == "error" && exitStatus == exitStatusError {
if noRestartOn == NoRestartOnError && exitStatus == exitStatusError {
return nil
}
// if "success", no restart only if exit code is 0.
if noRestartOn == "success" && exitStatus == exitStatusSuccess {
if noRestartOn == NoRestartOnSuccess && exitStatus == exitStatusSuccess {
return nil
}
// if "exit", no restart regardless of exit code.
if noRestartOn == "exit" {
if noRestartOn == NoRestartOnExit {
return nil
}
return restart(builder, runner)
}
func shutdown(runner Runner, exitOnSIGINT bool) {
c := make(chan os.Signal, 2)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
s := <-c
logger.Debug("Got signal: ", s)
func setupConfig(cfg *Config) error {
var err error
if err := runner.Kill(); err != nil {
logger.Error("Error killing: ", err)
if len(cfg.BuildPath) == 0 {
cfg.BuildPath = DefaultBuildPath
}
if exitOnSIGINT {
os.Exit(0)
cfg.BuildArgs, err = parseInnerArgs(cfg.BuildArgs, cfg.BuildArgsMerged)
if err != nil {
return err
}
}()
cfg.ProgramArgs, err = parseInnerArgs(cfg.ProgramArgs, cfg.ProgramArgsMerged)
if err != nil {
return err
}
cfg.WorkingDirectory, err = os.Getwd()
if err != nil {
return err
}
if len(cfg.WatchItems) == 0 {
cfg.WatchItems = append(cfg.WatchItems, cfg.BuildPath)
}
var extensions []string
for i := range cfg.Extensions {
values := strings.Split(cfg.Extensions[i], ",")
extensions = append(extensions, values...)
}
cfg.Extensions = extensions
return nil
}
func parseInnerArgs(args []string, argsm string) ([]string, error) {

View File

@ -1,9 +1,290 @@
package gaper
import (
"errors"
"os"
"os/exec"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/maxcnunes/gaper/testdata"
"github.com/stretchr/testify/assert"
)
func TestGaper(t *testing.T) {
// TODO: add test to gaper high level API
func TestGaperRunStopOnSGINT(t *testing.T) {
args := &Config{
BuildPath: filepath.Join("testdata", "server"),
}
chOSSiginal := make(chan os.Signal, 2)
go func() {
time.Sleep(1 * time.Second)
chOSSiginal <- syscall.SIGINT
}()
err := Run(args, chOSSiginal)
assert.NotNil(t, err, "build error")
assert.Equal(t, "OS signal: interrupt", err.Error())
}
func TestGaperSetupConfigNoParams(t *testing.T) {
cwd, _ := os.Getwd()
args := &Config{}
err := setupConfig(args)
assert.Nil(t, err, "build error")
assert.Equal(t, args.BuildPath, ".")
assert.Equal(t, args.WorkingDirectory, cwd)
assert.Equal(t, args.WatchItems, []string{"."})
}
func TestGaperBuildError(t *testing.T) {
mockBuilder := new(testdata.MockBuilder)
mockBuilder.On("Build").Return(errors.New("build-error"))
mockRunner := new(testdata.MockRunner)
mockWatcher := new(testdata.MockWacther)
cfg := &Config{}
chOSSiginal := make(chan os.Signal, 2)
err := run(cfg, chOSSiginal, mockBuilder, mockRunner, mockWatcher)
assert.NotNil(t, err, "build error")
assert.Equal(t, "build error: build-error", err.Error())
}
func TestGaperRunError(t *testing.T) {
mockBuilder := new(testdata.MockBuilder)
mockBuilder.On("Build").Return(nil)
mockRunner := new(testdata.MockRunner)
mockRunner.On("Run").Return(nil, errors.New("runner-error"))
mockWatcher := new(testdata.MockWacther)
cfg := &Config{}
chOSSiginal := make(chan os.Signal, 2)
err := run(cfg, chOSSiginal, mockBuilder, mockRunner, mockWatcher)
assert.NotNil(t, err, "runner error")
assert.Equal(t, "run error: runner-error", err.Error())
}
func TestGaperWatcherError(t *testing.T) {
mockBuilder := new(testdata.MockBuilder)
mockBuilder.On("Build").Return(nil)
mockRunner := new(testdata.MockRunner)
cmd := &exec.Cmd{}
runnerErrorsChan := make(chan error)
mockRunner.On("Run").Return(cmd, nil)
mockRunner.On("Errors").Return(runnerErrorsChan)
mockWatcher := new(testdata.MockWacther)
watcherErrorsChan := make(chan error)
watcherEvetnsChan := make(chan string)
mockWatcher.On("Errors").Return(watcherErrorsChan)
mockWatcher.On("Events").Return(watcherEvetnsChan)
dir := filepath.Join("testdata", "server")
cfg := &Config{
BinName: "test-srv",
BuildPath: dir,
}
go func() {
time.Sleep(3 * time.Second)
watcherErrorsChan <- errors.New("watcher-error")
}()
chOSSiginal := make(chan os.Signal, 2)
err := run(cfg, chOSSiginal, mockBuilder, mockRunner, mockWatcher)
assert.NotNil(t, err, "build error")
assert.Equal(t, "error on watching files: watcher-error", err.Error())
mockBuilder.AssertExpectations(t)
mockRunner.AssertExpectations(t)
mockWatcher.AssertExpectations(t)
}
func TestGaperProgramExit(t *testing.T) {
testCases := []struct {
name string
exitStatus int
noRestartOn string
restart bool
}{
{
name: "no restart on exit error with no-restart-on=error",
exitStatus: exitStatusError,
noRestartOn: NoRestartOnError,
restart: false,
},
{
name: "no restart on exit success with no-restart-on=success",
exitStatus: exitStatusSuccess,
noRestartOn: NoRestartOnSuccess,
restart: false,
},
{
name: "no restart on exit error with no-restart-on=exit",
exitStatus: exitStatusError,
noRestartOn: NoRestartOnExit,
restart: false,
},
{
name: "no restart on exit success with no-restart-on=exit",
exitStatus: exitStatusSuccess,
noRestartOn: NoRestartOnExit,
restart: false,
},
{
name: "restart on exit error with disabled no-restart-on",
exitStatus: exitStatusError,
restart: true,
},
{
name: "restart on exit success with disabled no-restart-on",
exitStatus: exitStatusSuccess,
restart: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
mockBuilder := new(testdata.MockBuilder)
mockBuilder.On("Build").Return(nil)
mockRunner := new(testdata.MockRunner)
cmd := &exec.Cmd{}
runnerErrorsChan := make(chan error)
mockRunner.On("Run").Return(cmd, nil)
mockRunner.On("Kill").Return(nil)
mockRunner.On("Errors").Return(runnerErrorsChan)
mockRunner.On("ExitStatus").Return(tc.exitStatus)
if tc.restart {
mockRunner.On("Exited").Return(true)
}
mockWatcher := new(testdata.MockWacther)
watcherErrorsChan := make(chan error)
watcherEvetnsChan := make(chan string)
mockWatcher.On("Errors").Return(watcherErrorsChan)
mockWatcher.On("Events").Return(watcherEvetnsChan)
dir := filepath.Join("testdata", "server")
cfg := &Config{
BinName: "test-srv",
BuildPath: dir,
NoRestartOn: tc.noRestartOn,
}
chOSSiginal := make(chan os.Signal, 2)
go func() {
time.Sleep(1 * time.Second)
runnerErrorsChan <- errors.New("runner-error")
time.Sleep(1 * time.Second)
chOSSiginal <- syscall.SIGINT
}()
err := run(cfg, chOSSiginal, mockBuilder, mockRunner, mockWatcher)
assert.NotNil(t, err, "build error")
assert.Equal(t, "OS signal: interrupt", err.Error())
mockBuilder.AssertExpectations(t)
mockRunner.AssertExpectations(t)
mockWatcher.AssertExpectations(t)
})
}
}
func TestGaperRestartExited(t *testing.T) {
mockBuilder := new(testdata.MockBuilder)
mockBuilder.On("Build").Return(nil)
mockRunner := new(testdata.MockRunner)
cmd := &exec.Cmd{}
mockRunner.On("Run").Return(cmd, nil)
mockRunner.On("Exited").Return(true)
err := restart(mockBuilder, mockRunner)
assert.Nil(t, err, "restart error")
mockBuilder.AssertExpectations(t)
mockRunner.AssertExpectations(t)
}
func TestGaperRestartNotExited(t *testing.T) {
mockBuilder := new(testdata.MockBuilder)
mockBuilder.On("Build").Return(nil)
mockRunner := new(testdata.MockRunner)
cmd := &exec.Cmd{}
mockRunner.On("Run").Return(cmd, nil)
mockRunner.On("Kill").Return(nil)
mockRunner.On("Exited").Return(false)
err := restart(mockBuilder, mockRunner)
assert.Nil(t, err, "restart error")
mockBuilder.AssertExpectations(t)
mockRunner.AssertExpectations(t)
}
func TestGaperRestartNotExitedKillFail(t *testing.T) {
mockBuilder := new(testdata.MockBuilder)
mockRunner := new(testdata.MockRunner)
mockRunner.On("Kill").Return(errors.New("kill-error"))
mockRunner.On("Exited").Return(false)
err := restart(mockBuilder, mockRunner)
assert.NotNil(t, err, "restart error")
assert.Equal(t, "kill error: kill-error", err.Error())
mockBuilder.AssertExpectations(t)
mockRunner.AssertExpectations(t)
}
func TestGaperRestartBuildFail(t *testing.T) {
mockBuilder := new(testdata.MockBuilder)
mockBuilder.On("Build").Return(errors.New("build-error"))
mockRunner := new(testdata.MockRunner)
mockRunner.On("Exited").Return(true)
err := restart(mockBuilder, mockRunner)
assert.Nil(t, err, "restart error")
mockBuilder.AssertExpectations(t)
mockRunner.AssertExpectations(t)
}
func TestGaperRestartRunFail(t *testing.T) {
mockBuilder := new(testdata.MockBuilder)
mockBuilder.On("Build").Return(nil)
mockRunner := new(testdata.MockRunner)
cmd := &exec.Cmd{}
mockRunner.On("Run").Return(cmd, errors.New("run-error"))
mockRunner.On("Exited").Return(true)
err := restart(mockBuilder, mockRunner)
assert.Nil(t, err, "restart error")
mockBuilder.AssertExpectations(t)
mockRunner.AssertExpectations(t)
}
func TestGaperFailBadBuildArgsMerged(t *testing.T) { // nolint: dupl
args := &Config{
BuildArgsMerged: "foo '",
}
chOSSiginal := make(chan os.Signal, 2)
err := Run(args, chOSSiginal)
assert.NotNil(t, err, "run error")
assert.Equal(t, "invalid command line string", err.Error())
}
func TestGaperFailBadProgramArgsMerged(t *testing.T) { // nolint: dupl
args := &Config{
ProgramArgsMerged: "foo '",
}
chOSSiginal := make(chan os.Signal, 2)
err := Run(args, chOSSiginal)
assert.NotNil(t, err, "run error")
assert.Equal(t, "invalid command line string", err.Error())
}

16
go.mod Normal file
View File

@ -0,0 +1,16 @@
module github.com/maxcnunes/gaper
go 1.13
require (
github.com/fatih/color v1.7.0
github.com/mattn/go-colorable v0.0.9 // indirect
github.com/mattn/go-isatty v0.0.3 // indirect
github.com/mattn/go-shellwords v1.0.3
github.com/mattn/go-zglob v0.0.0-20180607075734-49693fbb3fe3
github.com/stretchr/objx v0.1.1 // indirect
github.com/stretchr/testify v1.4.0
github.com/urfave/cli/v2 v2.11.1
golang.org/x/sys v0.0.0-20180616030259-6c888cc515d3 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

38
go.sum Normal file
View File

@ -0,0 +1,38 @@
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-isatty v0.0.3 h1:ns/ykhmWi7G9O+8a448SecJU3nSMBXJfqQkl0upE1jI=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-shellwords v1.0.3 h1:K/VxK7SZ+cvuPgFSLKi5QPI9Vr/ipOf4C1gN+ntueUk=
github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
github.com/mattn/go-zglob v0.0.0-20180607075734-49693fbb3fe3 h1:GWnsQiFbiQ7lREZbKkiJC6xxbymvny8GKtpdkPxjB6o=
github.com/mattn/go-zglob v0.0.0-20180607075734-49693fbb3fe3/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/urfave/cli/v2 v2.11.1 h1:UKK6SP7fV3eKOefbS87iT9YHefv7iB/53ih6e+GNAsE=
github.com/urfave/cli/v2 v2.11.1/go.mod h1:f8iq5LtQ/bLxafbdBSLPPNsgaW0l/2fYYEHhAyPlwvo=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
golang.org/x/sys v0.0.0-20180616030259-6c888cc515d3 h1:FCfAlbS73+IQQJktaKGHldMdL2bGDVpm+OrCEbVz1f4=
golang.org/x/sys v0.0.0-20180616030259-6c888cc515d3/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -7,18 +7,26 @@ import (
"github.com/fatih/color"
)
// Logger used by gaper
type Logger struct {
// logger use by the whole package
var logger = newLogger("gaper")
// Logger give access to external packages to use gaper logger
func Logger() *LoggerEntity {
return logger
}
// LoggerEntity used by gaper
type LoggerEntity struct {
verbose bool
logDebug *log.Logger
logInfo *log.Logger
logError *log.Logger
}
// NewLogger creates a new logger
func NewLogger(prefix string) *Logger {
// newLogger creates a new logger
func newLogger(prefix string) *LoggerEntity {
prefix = "[" + prefix + "] "
return &Logger{
return &LoggerEntity{
verbose: false,
logDebug: log.New(os.Stdout, prefix, 0),
logInfo: log.New(os.Stdout, color.CyanString(prefix), 0),
@ -27,35 +35,35 @@ func NewLogger(prefix string) *Logger {
}
// Verbose toggle this logger verbosity
func (l *Logger) Verbose(verbose bool) {
func (l *LoggerEntity) Verbose(verbose bool) {
l.verbose = verbose
}
// Debug logs a debug message
func (l *Logger) Debug(v ...interface{}) {
func (l *LoggerEntity) Debug(v ...interface{}) {
if l.verbose {
l.logDebug.Println(v...)
}
}
// Debugf logs a debug message with format
func (l *Logger) Debugf(format string, v ...interface{}) {
func (l *LoggerEntity) Debugf(format string, v ...interface{}) {
if l.verbose {
l.logDebug.Printf(format, v...)
}
}
// Info logs a info message
func (l *Logger) Info(v ...interface{}) {
func (l *LoggerEntity) Info(v ...interface{}) {
l.logInfo.Println(v...)
}
// Error logs an error message
func (l *Logger) Error(v ...interface{}) {
func (l *LoggerEntity) Error(v ...interface{}) {
l.logError.Println(v...)
}
// Errorf logs and error message with format
func (l *Logger) Errorf(format string, v ...interface{}) {
func (l *LoggerEntity) Errorf(format string, v ...interface{}) {
l.logError.Printf(format, v...)
}

39
logger_test.go Normal file
View File

@ -0,0 +1,39 @@
package gaper
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestLoggerDefault(t *testing.T) {
l := newLogger("gaper-test")
assert.Equal(t, l.verbose, false)
}
func TestLoggerEnableVerbose(t *testing.T) {
l := newLogger("gaper-test")
l.Verbose(true)
assert.Equal(t, l.verbose, true)
}
func TestLoggerRunAllLogsWithoutVerbose(t *testing.T) {
// no asserts, just checking it doesn't crash
l := newLogger("gaper-test")
l.Debug("debug")
l.Debugf("%s", "debug")
l.Info("info")
l.Error("error")
l.Errorf("%s", "error")
}
func TestLoggerRunAllLogsWithVerbose(t *testing.T) {
// no asserts, just checking it doesn't crash
l := newLogger("gaper-test")
l.Verbose(true)
l.Debug("debug")
l.Debugf("%s", "debug")
l.Info("info")
l.Error("error")
l.Errorf("%s", "error")
}

View File

@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"runtime"
"syscall"
"time"
)
@ -22,6 +23,8 @@ type Runner interface {
Kill() error
Errors() chan error
Exited() bool
IsRunning() bool
ExitStatus(err error) int
}
type runner struct {
@ -106,11 +109,28 @@ func (r *runner) Exited() bool {
return r.command != nil && r.command.ProcessState != nil && r.command.ProcessState.Exited()
}
// IsRunning returns if the process is running
func (r *runner) IsRunning() bool {
return r.command != nil && r.command.Process != nil && r.command.Process.Pid > 0
}
// Errors get errors occurred during the build
func (r *runner) Errors() chan error {
return r.errors
}
// ExitStatus resolves the exit status
func (r *runner) ExitStatus(err error) int {
var exitStatus int
if exiterr, ok := err.(*exec.ExitError); ok {
if status, oks := exiterr.Sys().(syscall.WaitStatus); oks {
exitStatus = status.ExitStatus()
}
}
return exitStatus
}
func (r *runner) runBin() error {
r.command = exec.Command(r.bin, r.args...) // nolint gas
stdout, err := r.command.StdoutPipe()

View File

@ -2,7 +2,9 @@ package gaper
import (
"bytes"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
@ -48,3 +50,32 @@ func TestRunnerSuccessKill(t *testing.T) {
errCmd := <-runner.Errors()
assert.NotNil(t, errCmd, "kill program")
}
func TestRunnerExitedNotStarted(t *testing.T) {
runner := NewRunner(os.Stdout, os.Stderr, "", nil)
assert.Equal(t, runner.Exited(), false)
}
func TestRunnerExitStatusNonExitError(t *testing.T) {
runner := NewRunner(os.Stdout, os.Stderr, "", nil)
err := errors.New("non exec.ExitError")
assert.Equal(t, runner.ExitStatus(err), 0)
}
func testExit() {
os.Exit(1)
}
func TestRunnerExitStatusExitError(t *testing.T) {
if os.Getenv("TEST_EXIT") == "1" {
testExit()
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestRunnerExitStatusExitError")
cmd.Env = append(os.Environ(), "TEST_EXIT=1")
err := cmd.Run()
runner := NewRunner(os.Stdout, os.Stderr, "", nil)
assert.Equal(t, runner.ExitStatus(err), 1)
}

0
testdata/.hidden-file vendored Normal file
View File

0
testdata/.hidden-folder/.gitkeep vendored Normal file
View File

0
testdata/ignore-test-name.txt vendored Normal file
View File

1
testdata/ignore-test-name/main.go vendored Normal file
View File

@ -0,0 +1 @@
package ignoretestname

90
testdata/mocks.go vendored Normal file
View File

@ -0,0 +1,90 @@
package testdata
import (
"os/exec"
"github.com/stretchr/testify/mock"
)
// MockBuilder ...
type MockBuilder struct {
mock.Mock
}
// Build ...
func (m *MockBuilder) Build() error {
args := m.Called()
return args.Error(0)
}
// Binary ...
func (m *MockBuilder) Binary() string {
args := m.Called()
return args.String(0)
}
// MockRunner ...
type MockRunner struct {
mock.Mock
}
// Run ...
func (m *MockRunner) Run() (*exec.Cmd, error) {
args := m.Called()
cmdArg := args.Get(0)
if cmdArg == nil {
return nil, args.Error(1)
}
return cmdArg.(*exec.Cmd), args.Error(1)
}
// Kill ...
func (m *MockRunner) Kill() error {
args := m.Called()
return args.Error(0)
}
// Errors ...
func (m *MockRunner) Errors() chan error {
args := m.Called()
return args.Get(0).(chan error)
}
// Exited ...
func (m *MockRunner) Exited() bool {
args := m.Called()
return args.Bool(0)
}
// IsRunning ...
func (m *MockRunner) IsRunning() bool {
args := m.Called()
return args.Bool(0)
}
// ExitStatus ...
func (m *MockRunner) ExitStatus(err error) int {
args := m.Called()
return args.Int(0)
}
// MockWacther ...
type MockWacther struct {
mock.Mock
}
// Watch ...
func (m *MockWacther) Watch() {}
// Events ...
func (m *MockWacther) Events() chan string {
args := m.Called()
return args.Get(0).(chan string)
}
// Errors ...
func (m *MockWacther) Errors() chan error {
args := m.Called()
return args.Get(0).(chan error)
}

View File

@ -1,3 +1,3 @@
#!/usr/bin/env bash
sleep 2
sleep 3
echo "Gaper Test Message"

View File

@ -1,2 +1,2 @@
timeout 2 2>NUL
timeout 3 2>NUL
@echo Gaper Test Message

1
testdata/server/data.txt vendored Normal file
View File

@ -0,0 +1 @@
test

View File

@ -7,6 +7,8 @@ import (
"net/http"
)
var Version string
func main() {
http.HandleFunc("/foo", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path)) // nolint gas
@ -16,6 +18,6 @@ func main() {
log.Fatal("Forced failure")
})
log.Println("Starting server")
log.Println("Starting server: Version", Version)
log.Fatal(http.ListenAndServe(":8080", nil))
}

View File

View File

View File

@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
@ -12,49 +13,67 @@ import (
)
// Watcher is a interface for the watch process
type Watcher struct {
type Watcher interface {
Watch()
Errors() chan error
Events() chan string
}
// watcher is a interface for the watch process
type watcher struct {
defaultIgnore bool
pollInterval int
watchItems map[string]bool
ignoreItems map[string]bool
allowedExtensions map[string]bool
events chan string
errors chan error
}
// WatcherConfig defines the settings available for the watcher
type WatcherConfig struct {
DefaultIgnore bool
PollInterval int
WatchItems map[string]bool
IgnoreItems map[string]bool
AllowedExtensions map[string]bool
Events chan string
Errors chan error
WatchItems []string
IgnoreItems []string
Extensions []string
}
// NewWatcher creates a new watcher
func NewWatcher(pollInterval int, watchItems []string, ignoreItems []string, extensions []string) (*Watcher, error) {
if pollInterval == 0 {
pollInterval = DefaultPoolInterval
func NewWatcher(cfg WatcherConfig) (Watcher, error) {
if cfg.PollInterval == 0 {
cfg.PollInterval = DefaultPoolInterval
}
if len(extensions) == 0 {
extensions = DefaultExtensions
if len(cfg.Extensions) == 0 {
cfg.Extensions = DefaultExtensions
}
allowedExts := make(map[string]bool)
for _, ext := range extensions {
for _, ext := range cfg.Extensions {
allowedExts["."+ext] = true
}
watchPaths, err := resolvePaths(watchItems, allowedExts)
watchPaths, err := resolvePaths(cfg.WatchItems, allowedExts)
if err != nil {
return nil, err
}
ignorePaths, err := resolvePaths(ignoreItems, allowedExts)
ignorePaths, err := resolvePaths(cfg.IgnoreItems, allowedExts)
if err != nil {
return nil, err
}
logger.Debugf("Resolved watch paths: %v", watchPaths)
logger.Debugf("Resolved ignore paths: %v", ignorePaths)
return &Watcher{
Events: make(chan string),
Errors: make(chan error),
PollInterval: pollInterval,
WatchItems: watchPaths,
IgnoreItems: ignorePaths,
AllowedExtensions: allowedExts,
return &watcher{
events: make(chan string),
errors: make(chan error),
defaultIgnore: cfg.DefaultIgnore,
pollInterval: cfg.PollInterval,
watchItems: watchPaths,
ignoreItems: ignorePaths,
allowedExtensions: allowedExts,
}, nil
}
@ -62,42 +81,57 @@ var startTime = time.Now()
var errDetectedChange = errors.New("done")
// Watch starts watching for file changes
func (w *Watcher) Watch() {
func (w *watcher) Watch() {
for {
for watchPath := range w.WatchItems {
for watchPath := range w.watchItems {
fileChanged, err := w.scanChange(watchPath)
if err != nil {
w.Errors <- err
w.errors <- err
return
}
if fileChanged != "" {
w.Events <- fileChanged
w.events <- fileChanged
startTime = time.Now()
}
}
time.Sleep(time.Duration(w.PollInterval) * time.Millisecond)
time.Sleep(time.Duration(w.pollInterval) * time.Millisecond)
}
}
func (w *Watcher) scanChange(watchPath string) (string, error) {
// Events get events occurred during the watching
// these events are emitted only a file changing is detected
func (w *watcher) Events() chan string {
return w.events
}
// Errors get errors occurred during the watching
func (w *watcher) Errors() chan error {
return w.errors
}
func (w *watcher) scanChange(watchPath string) (string, error) {
logger.Debug("Watching ", watchPath)
var fileChanged string
err := filepath.Walk(watchPath, func(path string, info os.FileInfo, err error) error {
// always ignore hidden files and directories
if dir := filepath.Base(path); dir[0] == '.' && dir != "." {
return skipFile(info)
if err != nil {
// Ignore attempt to acess go temporary unmask
if strings.Contains(err.Error(), "-go-tmp-umask") {
return filepath.SkipDir
}
if _, ignored := w.IgnoreItems[path]; ignored {
return fmt.Errorf("couldn't walk to path \"%s\": %v", path, err)
}
if w.ignoreFile(path, info) {
return skipFile(info)
}
ext := filepath.Ext(path)
if _, ok := w.AllowedExtensions[ext]; ok && info.ModTime().After(startTime) {
if _, ok := w.allowedExtensions[ext]; ok && info.ModTime().After(startTime) {
fileChanged = path
return errDetectedChange
}
@ -112,6 +146,38 @@ func (w *Watcher) scanChange(watchPath string) (string, error) {
return fileChanged, nil
}
func (w *watcher) ignoreFile(path string, info os.FileInfo) bool {
// if a file has been deleted after gaper was watching it
// info will be nil in the other iterations
if info == nil {
return true
}
// check if preset ignore is enabled
if w.defaultIgnore {
// check for hidden files and directories
if name := info.Name(); name[0] == '.' && name != "." {
return true
}
// check if it is a Go testing file
if strings.HasSuffix(path, "_test.go") {
return true
}
// check if it is the vendor folder
if info.IsDir() && info.Name() == "vendor" {
return true
}
}
if _, ignored := w.ignoreItems[path]; ignored {
return true
}
return false
}
func resolvePaths(paths []string, extensions map[string]bool) (map[string]bool, error) {
result := map[string]bool{}
@ -128,13 +194,14 @@ func resolvePaths(paths []string, extensions map[string]bool) (map[string]bool,
}
for _, match := range matches {
// don't care for extension filter right now for non glob paths
// since they could be a directory
if isGlob {
if _, ok := extensions[filepath.Ext(path)]; !ok {
// ignore existing files that don't match the allowed extensions
if f, err := os.Stat(match); !os.IsNotExist(err) && !f.IsDir() {
if ext := filepath.Ext(match); ext != "" {
if _, ok := extensions[ext]; !ok {
continue
}
}
}
if _, ok := result[match]; !ok {
result[match] = true
@ -149,13 +216,19 @@ func resolvePaths(paths []string, extensions map[string]bool) (map[string]bool,
// remove overlapped paths so it makes the scan for changes later faster and simpler
func removeOverlappedPaths(mapPaths map[string]bool) {
startDot := regexp.MustCompile(`^\./`)
for p1 := range mapPaths {
p1 = startDot.ReplaceAllString(p1, "")
// skip to next item if this path has already been checked
if v, ok := mapPaths[p1]; ok && !v {
continue
}
for p2 := range mapPaths {
p2 = startDot.ReplaceAllString(p2, "")
if p1 == p2 {
continue
}

View File

@ -16,18 +16,26 @@ func TestWatcherDefaultValues(t *testing.T) {
var ignoreItems []string
var extensions []string
w, err := NewWatcher(pollInterval, watchItems, ignoreItems, extensions)
wCfg := WatcherConfig{
DefaultIgnore: true,
PollInterval: pollInterval,
WatchItems: watchItems,
IgnoreItems: ignoreItems,
Extensions: extensions,
}
wt, err := NewWatcher(wCfg)
expectedPath := "testdata/server"
if runtime.GOOS == OSWindows {
expectedPath = "testdata\\server"
}
w := wt.(*watcher)
assert.Nil(t, err, "wacher error")
assert.Equal(t, 500, w.PollInterval)
assert.Equal(t, map[string]bool{expectedPath: true}, w.WatchItems)
assert.Len(t, w.IgnoreItems, 0)
assert.Equal(t, map[string]bool{".go": true}, w.AllowedExtensions)
assert.Equal(t, 500, w.pollInterval)
assert.Equal(t, map[string]bool{expectedPath: true}, w.watchItems)
assert.Len(t, w.ignoreItems, 0)
assert.Equal(t, map[string]bool{".go": true}, w.allowedExtensions)
}
func TestWatcherGlobPath(t *testing.T) {
@ -36,20 +44,36 @@ func TestWatcherGlobPath(t *testing.T) {
ignoreItems := []string{"./testdata/**/*_test.go"}
var extensions []string
w, err := NewWatcher(pollInterval, watchItems, ignoreItems, extensions)
wCfg := WatcherConfig{
DefaultIgnore: true,
PollInterval: pollInterval,
WatchItems: watchItems,
IgnoreItems: ignoreItems,
Extensions: extensions,
}
wt, err := NewWatcher(wCfg)
assert.Nil(t, err, "wacher error")
assert.Equal(t, map[string]bool{"testdata/server/main_test.go": true}, w.IgnoreItems)
w := wt.(*watcher)
assert.Equal(t, map[string]bool{"testdata/server/main_test.go": true}, w.ignoreItems)
}
func TestWatcherRemoveOverlapdPaths(t *testing.T) {
pollInterval := 0
watchItems := []string{filepath.Join("testdata", "server")}
ignoreItems := []string{"./testdata/**/*", "./testdata/server"}
ignoreItems := []string{"./testdata/server/**/*", "./testdata/server"}
var extensions []string
w, err := NewWatcher(pollInterval, watchItems, ignoreItems, extensions)
wCfg := WatcherConfig{
DefaultIgnore: true,
PollInterval: pollInterval,
WatchItems: watchItems,
IgnoreItems: ignoreItems,
Extensions: extensions,
}
wt, err := NewWatcher(wCfg)
assert.Nil(t, err, "wacher error")
assert.Equal(t, map[string]bool{"./testdata/server": true}, w.IgnoreItems)
w := wt.(*watcher)
assert.Equal(t, map[string]bool{"./testdata/server": true}, w.ignoreItems)
}
func TestWatcherWatchChange(t *testing.T) {
@ -66,26 +90,172 @@ func TestWatcherWatchChange(t *testing.T) {
ignoreItems := []string{testfile}
extensions := []string{"go"}
w, err := NewWatcher(pollInterval, watchItems, ignoreItems, extensions)
wCfg := WatcherConfig{
DefaultIgnore: true,
PollInterval: pollInterval,
WatchItems: watchItems,
IgnoreItems: ignoreItems,
Extensions: extensions,
}
w, err := NewWatcher(wCfg)
assert.Nil(t, err, "wacher error")
go w.Watch()
time.Sleep(time.Millisecond * 500)
// update hidden files and dirs to check builtin hidden ignore is working
os.Chtimes(hiddenfile1, time.Now(), time.Now())
os.Chtimes(hiddenfile2, time.Now(), time.Now())
err = os.Chtimes(hiddenfile1, time.Now(), time.Now())
assert.Nil(t, err, "chtimes error")
err = os.Chtimes(hiddenfile2, time.Now(), time.Now())
assert.Nil(t, err, "chtimes error")
// update testfile first to check ignore is working
os.Chtimes(testfile, time.Now(), time.Now())
err = os.Chtimes(testfile, time.Now(), time.Now())
assert.Nil(t, err, "chtimes error")
time.Sleep(time.Millisecond * 500)
os.Chtimes(mainfile, time.Now(), time.Now())
err = os.Chtimes(mainfile, time.Now(), time.Now())
assert.Nil(t, err, "chtimes error")
select {
case event := <-w.Events:
case event := <-w.Events():
assert.Equal(t, mainfile, event)
case err := <-w.Errors:
case err := <-w.Errors():
assert.Nil(t, err, "wacher event error")
}
}
func TestWatcherIgnoreFile(t *testing.T) {
testCases := []struct {
name, file, ignoreFile string
defaultIgnore, expectIgnore bool
}{
{
name: "with default ignore enabled it ignores vendor folder",
file: "vendor",
defaultIgnore: true,
expectIgnore: true,
},
{
name: "without default ignore enabled it does not ignore vendor folder",
file: "vendor",
defaultIgnore: false,
expectIgnore: false,
},
{
name: "with default ignore enabled it ignores test file",
file: filepath.Join("testdata", "server", "main_test.go"),
defaultIgnore: true,
expectIgnore: true,
},
{
name: "with default ignore enabled it does no ignore non test files which have test in the name",
file: filepath.Join("testdata", "ignore-test-name.txt"),
defaultIgnore: true,
expectIgnore: false,
},
{
name: "without default ignore enabled it does not ignore test file",
file: filepath.Join("testdata", "server", "main_test.go"),
defaultIgnore: false,
expectIgnore: false,
},
{
name: "with default ignore enabled it ignores ignored items",
file: filepath.Join("testdata", "server", "main.go"),
ignoreFile: filepath.Join("testdata", "server", "main.go"),
defaultIgnore: true,
expectIgnore: true,
},
{
name: "without default ignore enabled it ignores ignored items",
file: filepath.Join("testdata", "server", "main.go"),
ignoreFile: filepath.Join("testdata", "server", "main.go"),
defaultIgnore: false,
expectIgnore: true,
},
}
// create vendor folder for testing
if err := os.MkdirAll("vendor", os.ModePerm); err != nil {
t.Fatal(err)
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
srvdir := "."
watchItems := []string{srvdir}
ignoreItems := []string{}
if len(tc.ignoreFile) > 0 {
ignoreItems = append(ignoreItems, tc.ignoreFile)
}
extensions := []string{"go"}
wCfg := WatcherConfig{
DefaultIgnore: tc.defaultIgnore,
WatchItems: watchItems,
IgnoreItems: ignoreItems,
Extensions: extensions,
}
w, err := NewWatcher(wCfg)
assert.Nil(t, err, "wacher error")
wt := w.(*watcher)
filePath := tc.file
file, err := os.Open(filePath)
if err != nil {
t.Fatal(err)
}
fileInfo, err := file.Stat()
if err != nil {
t.Fatal(err)
}
assert.Equal(t, tc.expectIgnore, wt.ignoreFile(filePath, fileInfo))
})
}
}
func TestWatcherResolvePaths(t *testing.T) {
testCases := []struct {
name string
paths []string
extensions, expectPaths map[string]bool
err error
}{
{
name: "remove duplicated paths",
paths: []string{"testdata/test-duplicated-paths", "testdata/test-duplicated-paths"},
extensions: map[string]bool{".txt": true},
expectPaths: map[string]bool{"testdata/test-duplicated-paths": true},
},
{
name: "remove duplicated paths from glob",
paths: []string{"testdata/test-duplicated-paths", "testdata/test-duplicated-paths/**/*"},
extensions: map[string]bool{".txt": true},
expectPaths: map[string]bool{"testdata/test-duplicated-paths": true},
},
{
name: "remove duplicated paths from glob with inverse order",
paths: []string{"testdata/test-duplicated-paths/**/*", "testdata/test-duplicated-paths"},
extensions: map[string]bool{".txt": true},
expectPaths: map[string]bool{"testdata/test-duplicated-paths": true},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
paths, err := resolvePaths(tc.paths, tc.extensions)
if tc.err == nil {
assert.Nil(t, err, "resolve path error")
assert.Equal(t, tc.expectPaths, paths)
} else {
assert.Equal(t, tc.err, err)
}
})
}
}