From d59a3be8ae1dd8f53a791e37a09f07198a570de1 Mon Sep 17 00:00:00 2001 From: Dugi Date: Sat, 22 Oct 2022 20:41:35 -0600 Subject: [PATCH] Implement process wrapper & add executor tests (#27) * Setup executor tests & update parser tests * Add process implementation and mock * Add executor tests to cover watching * Add short timeout for ci * Refactor Parser (#28) * Parser interface, dependencies udpated * Get options moved to internal * Internal/util.go refactor and tests Co-authored-by: Dugi * Remove useless tests Co-authored-by: Sam --- .github/workflows/push_pull.yml | 1 + cmd/cli/main.go | 11 ++- goke.yml | 4 +- internal/cli/options.go | 21 ----- internal/cli/util.go | 132 ++++++++++++++++++++++++++++++++ internal/cli/util_test.go | 71 +++++++++++++++++ internal/executor.go | 48 +++++++++--- internal/executor_test.go | 63 +++++++++++++++ internal/lockfile_test.go | 10 ++- internal/options.go | 15 ++++ internal/parser.go | 67 ++-------------- internal/parser_test.go | 83 +++----------------- internal/process.go | 28 +++++++ internal/tests/helpers.go | 42 ++++++++++ internal/tests/process_mock.go | 88 +++++++++++++++++++++ internal/util.go | 67 ---------------- 16 files changed, 511 insertions(+), 240 deletions(-) delete mode 100644 internal/cli/options.go create mode 100644 internal/cli/util.go create mode 100644 internal/cli/util_test.go create mode 100644 internal/process.go create mode 100644 internal/tests/process_mock.go diff --git a/.github/workflows/push_pull.yml b/.github/workflows/push_pull.yml index 292e057..536acb4 100644 --- a/.github/workflows/push_pull.yml +++ b/.github/workflows/push_pull.yml @@ -12,6 +12,7 @@ on: jobs: ci: + timeout-minutes: 2 strategy: fail-fast: false matrix: diff --git a/cmd/cli/main.go b/cmd/cli/main.go index cfede52..ebda0b4 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -1,16 +1,16 @@ package main import ( + "context" "fmt" "os" app "github.com/dugajean/goke/internal" - "github.com/dugajean/goke/internal/cli" ) func main() { argIndex := app.PermutateArgs(os.Args) - opts := cli.GetOptions() + opts := app.GetOptions() handleGlobalFlags(&opts) @@ -20,13 +20,18 @@ func main() { os.Exit(1) } + // Wrappers fs := app.LocalFileSystem{} + proc := app.ShellProcess{} + + // Main components p := app.NewParser(cfg, &opts, &fs) p.Bootstrap() l := app.NewLockfile(p.GetFilePaths(), &opts, &fs) l.Bootstrap() - e := app.NewExecutor(&p, &l, &opts) + ctx := context.Background() + e := app.NewExecutor(&p, &l, &opts, &proc, &fs, &ctx) e.Start(parseTaskName(argIndex)) } diff --git a/goke.yml b/goke.yml index d4d85b6..a6c934b 100644 --- a/goke.yml +++ b/goke.yml @@ -8,11 +8,13 @@ main: - "go build -o './build/${BINARY}' ./cmd/cli" genmocks: - files: [internal/filesystem.go] + files: [internal/filesystem.go, internal/process.go] run: - "mockery --name=FileSystem --recursive --output=internal/tests --outpkg=tests --filename=filesystem_mock.go" + - "mockery --name=Process --recursive --output=internal/tests --outpkg=tests --filename=process_mock.go" greet-cats: + files: ["foo"] run: - 'echo "Hello Frey"' - 'echo "Hello Bunny"' diff --git a/internal/cli/options.go b/internal/cli/options.go deleted file mode 100644 index e8e80c0..0000000 --- a/internal/cli/options.go +++ /dev/null @@ -1,21 +0,0 @@ -package cli - -import ( - "flag" - - "github.com/dugajean/goke/internal" -) - -func GetOptions() internal.Options { - var opts internal.Options - - flag.BoolVar(&opts.ClearCache, "no-cache", false, "Clear Goke's cache. Default: false") - flag.BoolVar(&opts.Watch, "watch", false, "Goke remains on and watches the task's specified files for changes, then reruns the command. Default: false") - flag.BoolVar(&opts.Force, "force", false, "Executes the task regardless whether the files have changed or not. Default: false") - flag.BoolVar(&opts.Init, "init", false, "Initializes a goke.yml file in the current directory") - flag.BoolVar(&opts.Quiet, "quiet", false, "Disables all output to the console. Default: false") - flag.BoolVar(&opts.Version, "version", false, "Prints the current Goke version") - flag.Parse() - - return opts -} diff --git a/internal/cli/util.go b/internal/cli/util.go new file mode 100644 index 0000000..445d82e --- /dev/null +++ b/internal/cli/util.go @@ -0,0 +1,132 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "regexp" + "strings" +) + +var osCommandRegexp = regexp.MustCompile(`\$\((.+)\)`) +var osEnvRegexp = regexp.MustCompile(`\$\{(.+)\}`) + +// Parses the interpolated system commands, ie. "Hello $(echo 'World')" and returns it. +// Returns the command wrapper in $() and without the wrapper. +func parseSystemCmd(re *regexp.Regexp, str string) (string, string) { + match := re.FindAllStringSubmatch(str, -1) + + if len(match) > 0 && len(match[0]) > 0 { + return match[0][0], match[0][1] + } + + return "", "" +} + +// prase system commands and store results to env +func SetEnvVariables(vars map[string]string) (map[string]string, error) { + retVars := make(map[string]string) + for k, v := range vars { + _, cmd := parseSystemCmd(osCommandRegexp, v) + + if cmd == "" { + retVars[k] = v + _ = os.Setenv(k, v) + continue + } + + splitCmd, err := ParseCommandLine(os.ExpandEnv(cmd)) + if err != nil { + return retVars, err + } + + out, err := exec.Command(splitCmd[0], splitCmd[1:]...).Output() + if err != nil { + return retVars, err + } + + outStr := strings.TrimSpace(string(out)) + retVars[k] = outStr + _ = os.Setenv(k, outStr) + } + + return retVars, nil +} + +// Parses the command string into an array of [command, args, args]... +func ParseCommandLine(command string) ([]string, error) { + var args []string + state := "start" + current := "" + quote := "\"" + escapeNext := true + + for i := 0; i < len(command); i++ { + c := command[i] + + if state == "quotes" { + if string(c) != quote { + current += string(c) + } else { + args = append(args, current) + current = "" + state = "start" + } + continue + } + + if escapeNext { + current += string(c) + escapeNext = false + continue + } + + if c == '\\' { + escapeNext = true + continue + } + + if c == '"' || c == '\'' { + state = "quotes" + quote = string(c) + continue + } + + if state == "arg" { + if c == ' ' || c == '\t' { + args = append(args, current) + current = "" + state = "start" + } else { + current += string(c) + } + continue + } + + if c != ' ' && c != '\t' { + state = "arg" + current += string(c) + } + } + + if state == "quotes" { + return []string{}, fmt.Errorf("unclosed quote in command: %s", command) + } + + if current != "" { + args = append(args, current) + } + + return args, nil +} + +// Replace the placeholders with actual environment variable values in string pointer. +// Given that a string pointer must be provided, the replacement happens in place. +func ReplaceEnvironmentVariables(re *regexp.Regexp, str *string) { + resolved := *str + raw, env := parseSystemCmd(re, resolved) + + if raw != "" && env != "" { + *str = strings.Replace(resolved, raw, os.Getenv(env), -1) + } +} diff --git a/internal/cli/util_test.go b/internal/cli/util_test.go new file mode 100644 index 0000000..dd6a4a4 --- /dev/null +++ b/internal/cli/util_test.go @@ -0,0 +1,71 @@ +package cli + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseSystemCmd(t *testing.T) { + + cmds := []string{ + "$(echo 'Hello Thor')", + "hello world", + } + + want := [][]string{ + {"$(echo 'Hello Thor')", "echo 'Hello Thor'"}, + {"", ""}, + } + + for i, cmd := range cmds { + got0, got1 := parseSystemCmd(osCommandRegexp, cmd) + assert.Equal(t, want[i][0], got0, "expected "+want[i][0]+", got ", got0) + assert.Equal(t, want[i][1], got1, "expected "+want[i][1]+", got ", got1) + } + +} + +func TestSetEnvVariables(t *testing.T) { + + values := map[string]string{ + "THOR": "Lord of thunder", + "THOR_CMD": "$(echo 'Hello Thor')", + } + + want := map[string]string{ + "THOR": "Lord of thunder", + "THOR_CMD": "Hello Thor", + } + + got, _ := SetEnvVariables(values) + assert.Equal(t, want["THOR"], os.Getenv("THOR")) + assert.Equal(t, want["THOR_CMD"], os.Getenv("THOR_CMD")) + + for k := range got { + assert.Equal(t, want[k], got[k]) + } +} + +func TestParseCommandLine(t *testing.T) { + t.Skip() +} + +func TestReplaceEnvironmentVariables(t *testing.T) { + values := map[string]string{ + "THOR": "Lord of thunder", + "LOKI": "Lord of deception", + } + + for k, v := range values { + t.Setenv(k, v) + } + + str := "I am ${THOR}" + want := "I am Lord of thunder" + + ReplaceEnvironmentVariables(osEnvRegexp, &str) + + assert.Equal(t, want, str, "wrong env value is injected") +} diff --git a/internal/executor.go b/internal/executor.go index 8d96291..ba6659e 100644 --- a/internal/executor.go +++ b/internal/executor.go @@ -1,11 +1,13 @@ package internal import ( + "context" + "errors" "fmt" "os" - "os/exec" "time" + "github.com/dugajean/goke/internal/cli" "github.com/theckman/yacspin" ) @@ -33,10 +35,13 @@ type Executor struct { lockfile Lockfile spinner *yacspin.Spinner options Options + process Process + fs FileSystem + context context.Context } // Executor constructor. -func NewExecutor(p *Parser, l *Lockfile, opts *Options) Executor { +func NewExecutor(p *Parser, l *Lockfile, opts *Options, proc Process, fs FileSystem, ctx *context.Context) Executor { spinner, _ := yacspin.New(spinnerCfg) return Executor{ @@ -44,6 +49,9 @@ func NewExecutor(p *Parser, l *Lockfile, opts *Options) Executor { lockfile: *l, spinner: spinner, options: *opts, + process: proc, + fs: fs, + context: *ctx, } } @@ -55,7 +63,9 @@ func (e *Executor) Start(taskName string) { } if e.options.Watch { - e.watch(arg) + if err := e.watch(arg); err != nil { + e.logErr(err) + } } else { if err := e.execute(arg); err != nil { e.logErr(err) @@ -85,21 +95,36 @@ func (e *Executor) execute(taskName string) error { // Begins an infinite loop that watches for the file changes // in the "files" section of the task's configuration. -func (e *Executor) watch(taskName string) { +func (e *Executor) watch(taskName string) error { task := e.initTask(taskName) wait := make(chan struct{}) + if len(task.Files) == 0 { + return errors.New("task has no files to watch") + } + for { + if e.context.Err() != nil { + break + } + go func(ch chan struct{}) { e.checkAndDispatch(task) e.spinner.Message("Watching for file changes...") time.Sleep(time.Second) - ch <- struct{}{} + + select { + case ch <- struct{}{}: + case <-e.context.Done(): + return + } }(wait) <-wait } + + return nil } // Checks whether the task will be dispatched or not, @@ -159,12 +184,13 @@ func (e *Executor) shouldDispatchRoutine(task Task, ch chan Ref[bool]) { lockedModTimes := e.lockfile.GetCurrentProject() for _, f := range task.Files { - fo, err := os.Stat(f) + fo, err := e.fs.Stat(f) if err != nil { ch <- NewRef(false, err) } modTimeNow := fo.ModTime().Unix() + if lockedModTimes[f] < modTimeNow { ch <- NewRef(true, nil) return @@ -238,7 +264,7 @@ func (e *Executor) runSysOrRecurse(cmd string, ch *chan Ref[string]) error { } if !e.options.Quiet { - fmt.Print(output.Value()) + e.process.Fprint(os.Stdout, output.Value()) } } @@ -247,14 +273,14 @@ func (e *Executor) runSysOrRecurse(cmd string, ch *chan Ref[string]) error { // Executes the given string in the underlying OS. func (e *Executor) runSysCommand(c string, ch chan Ref[string]) { - splitCmd, err := ParseCommandLine(os.ExpandEnv(c)) + splitCmd, err := cli.ParseCommandLine(os.ExpandEnv(c)) if err != nil { ch <- NewRef("", err) return } - out, err := exec.Command(splitCmd[0], splitCmd[1:]...).Output() + out, err := e.process.Execute(splitCmd[0], splitCmd[1:]...) if err != nil { ch <- NewRef("", err) return @@ -283,12 +309,12 @@ func (e *Executor) logExit(status string, message string) { e.spinner.StopMessage(message) e.spinner.Stop() } - os.Exit(0) + e.process.Exit(0) case "error": if !e.options.Quiet { e.spinner.StopFailMessage(message) e.spinner.StopFail() } - os.Exit(1) + e.process.Exit(1) } } diff --git a/internal/executor_test.go b/internal/executor_test.go index 5bf0569..2126248 100644 --- a/internal/executor_test.go +++ b/internal/executor_test.go @@ -1 +1,64 @@ package internal + +import ( + "context" + "testing" + "time" + + "github.com/dugajean/goke/internal/tests" + "github.com/stretchr/testify/mock" +) + +func getDependencies(t *testing.T, opts *Options) (*Parser, *Lockfile, *tests.Process, FileSystem) { + fsMock := mockCacheDoesNotExist(t) + fsMock.On("FileExists", mock.Anything).Return(false) + fsMock.On("WriteFile", mock.Anything, mock.Anything, mock.Anything).Return(nil) + fsMock.On("Stat", mock.Anything).Return(tests.MemFileInfo{}, nil) + fsMock.On("ReadFile", mock.Anything).Return([]byte(dotGokeFile), nil) + fsMock.On("Glob", mock.Anything).Return(tests.ExpectedGlob, nil) + + process := tests.NewProcess(t) + + parser := NewParser(tests.YamlConfigStub, opts, fsMock) + lockfile := NewLockfile(files, opts, fsMock) + + parser.Bootstrap() + lockfile.Bootstrap() + + return &parser, &lockfile, process, fsMock +} + +func TestStartNonWatch(t *testing.T) { + parser, lockfile, process, fsMock := getDependencies(t, &clearCacheOpts) + + process.On("Execute", mock.Anything, mock.AnythingOfType("string")).Return([]byte("foo"), nil) + process.On("Fprint", mock.Anything, mock.AnythingOfType("string")).Return(10, nil) + + ctx := context.Background() + executor := NewExecutor(parser, lockfile, &clearCacheOpts, process, fsMock, &ctx) + executor.Start("greet-loki") + + process.AssertNumberOfCalls(t, "Execute", 1) + process.AssertNumberOfCalls(t, "Fprint", 1) + + process.AssertExpectations(t) +} + +func TestStartWatchWithNoFiles(t *testing.T) { + watchOpts := Options{ + Watch: true, + ClearCache: true, + } + + parser, lockfile, process, fsMock := getDependencies(t, &watchOpts) + process.On("Exit", mock.Anything).Return() + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + executor := NewExecutor(parser, lockfile, &watchOpts, process, fsMock, &ctx) + executor.Start("greet-loki") + cancel() + + process.AssertNotCalled(t, "Execute") + process.AssertNotCalled(t, "Fprint") + process.AssertNumberOfCalls(t, "Exit", 1) +} diff --git a/internal/lockfile_test.go b/internal/lockfile_test.go index f6503c4..3778cfa 100644 --- a/internal/lockfile_test.go +++ b/internal/lockfile_test.go @@ -14,6 +14,15 @@ var lockfileOpts = Options{ ClearCache: true, } +var dotGokeFile = `{ + "/path/to/project1": { + "path/to/file": 1664738433 + }, + "/path/to/project2": { + "./path/to/file": 1663812584 + } +}` + func TestNewLockfile(t *testing.T) { fsMock := tests.NewFileSystem(t) lockfile := NewLockfile(files, &lockfileOpts, fsMock) @@ -39,7 +48,6 @@ func TestGenerateLockfileWithFalse(t *testing.T) { fsMock.On("Getwd").Return("path/to/cwd", nil) fsMock.On("Stat", mock.Anything).Return(tests.MemFileInfo{}, nil) fsMock.On("WriteFile", mock.Anything, mock.Anything, mock.Anything).Return(nil) - // fsMock.On("FileExists", mock.Anything).Return(false) lockfile := NewLockfile(files, &lockfileOpts, fsMock) err := lockfile.generateLockfile(true) diff --git a/internal/options.go b/internal/options.go index ab0b30b..062c105 100644 --- a/internal/options.go +++ b/internal/options.go @@ -3,6 +3,7 @@ package internal import ( "encoding/json" "errors" + "flag" "net/http" "strings" ) @@ -18,6 +19,20 @@ type Options struct { Version bool } +func GetOptions() Options { + var opts Options + + flag.BoolVar(&opts.ClearCache, "no-cache", false, "Clear Goke's cache. Default: false") + flag.BoolVar(&opts.Watch, "watch", false, "Goke remains on and watches the task's specified files for changes, then reruns the command. Default: false") + flag.BoolVar(&opts.Force, "force", false, "Executes the task regardless whether the files have changed or not. Default: false") + flag.BoolVar(&opts.Init, "init", false, "Initializes a goke.yml file in the current directory") + flag.BoolVar(&opts.Quiet, "quiet", false, "Disables all output to the console. Default: false") + flag.BoolVar(&opts.Version, "version", false, "Prints the current Goke version") + flag.Parse() + + return opts +} + func (opts *Options) InitHandler() error { if !opts.Init { return nil diff --git a/internal/parser.go b/internal/parser.go index bc8a0c6..f838b17 100644 --- a/internal/parser.go +++ b/internal/parser.go @@ -2,13 +2,12 @@ package internal import ( "log" - "os" - "os/exec" "path" "path/filepath" "regexp" "strings" + "github.com/dugajean/goke/internal/cli" "gopkg.in/yaml.v3" ) @@ -19,12 +18,9 @@ type Parser interface { GetFilePaths() []string parseTasks() error parseGlobal() error - parseSystemCmd(*regexp.Regexp, string) (string, string) - replaceEnvironmentVariables(*regexp.Regexp, *string) expandFilePaths(string) ([]string, error) getTempFileName() string shouldClearCache(string) bool - setEnvVariables(vars map[string]string) (map[string]string, error) } type Task struct { @@ -142,7 +138,7 @@ func (p *parser) parseTasks() error { for k, c := range tasks { filePaths := []string{} for i := range c.Files { - p.replaceEnvironmentVariables(osCommandRegexp, &tasks[k].Files[i]) + cli.ReplaceEnvironmentVariables(osCommandRegexp, &tasks[k].Files[i]) expanded, err := p.expandFilePaths(tasks[k].Files[i]) if err != nil { @@ -158,11 +154,11 @@ func (p *parser) parseTasks() error { for i, r := range c.Run { tasks[k].Run[i] = strings.Replace(r, "{FILES}", strings.Join(c.Files, " "), -1) - p.replaceEnvironmentVariables(osCommandRegexp, &tasks[k].Run[i]) + cli.ReplaceEnvironmentVariables(osCommandRegexp, &tasks[k].Run[i]) } if len(c.Env) != 0 { - vars, err := p.setEnvVariables(c.Env) + vars, err := cli.SetEnvVariables(c.Env) if err != nil { return err } @@ -187,7 +183,7 @@ func (p *parser) parseGlobal() error { return err } - vars, err := p.setEnvVariables(g.Shared.Environment) + vars, err := cli.SetEnvVariables(g.Shared.Environment) if err != nil { return nil } @@ -198,29 +194,6 @@ func (p *parser) parseGlobal() error { return nil } -// Parses the interpolated system commands, ie. "Hello $(echo 'World')" and returns it. -// Returns the command wrapper in $() and without the wrapper. -func (p *parser) parseSystemCmd(re *regexp.Regexp, str string) (string, string) { - match := re.FindAllStringSubmatch(str, -1) - - if len(match) > 0 && len(match[0]) > 0 { - return match[0][0], match[0][1] - } - - return "", "" -} - -// Replace the placeholders with actual environment variable values in string pointer. -// Given that a string pointer must be provided, the replacement happens in place. -func (p *parser) replaceEnvironmentVariables(re *regexp.Regexp, str *string) { - resolved := *str - raw, env := p.parseSystemCmd(re, resolved) - - if raw != "" && env != "" { - *str = strings.Replace(resolved, raw, os.Getenv(env), -1) - } -} - // Expand the path glob and returns all paths in an array func (p *parser) expandFilePaths(file string) ([]string, error) { filePaths := []string{} @@ -268,33 +241,3 @@ func (p *parser) shouldClearCache(tempFile string) bool { return mustCleanCache } - -// prase system commands and store results to env -func (p *parser) setEnvVariables(vars map[string]string) (map[string]string, error) { - retVars := make(map[string]string) - for k, v := range vars { - _, cmd := p.parseSystemCmd(osCommandRegexp, v) - - if cmd == "" { - retVars[k] = v - _ = os.Setenv(k, v) - continue - } - - splitCmd, err := ParseCommandLine(os.ExpandEnv(cmd)) - if err != nil { - return retVars, err - } - - out, err := exec.Command(splitCmd[0], splitCmd[1:]...).Output() - if err != nil { - return retVars, err - } - - outStr := strings.TrimSpace(string(out)) - retVars[k] = outStr - _ = os.Setenv(k, outStr) - } - - return retVars, nil -} diff --git a/internal/parser_test.go b/internal/parser_test.go index 4742b86..a81216a 100644 --- a/internal/parser_test.go +++ b/internal/parser_test.go @@ -10,46 +10,6 @@ import ( "github.com/stretchr/testify/require" ) -var yamlConfigStub = ` -global: - environment: - FOO: "foo" - BAR: "$(echo 'bar')" - BAZ: "baz" - -events: - before_each_run: - - "echo 'before each 1'" - - "echo 'before each 2'" - after_each_run: - - "echo 'after each 1'" - - "greet-lisha" - before_each_task: - - "echo 'before task'" - after_each_task: - - "echo 'after task'" - -greet-lisha: - run: - - "echo 'Hello Lisha!'" - -greet-loki: - run: - - 'echo "Hello Boki"' - -greet-cats: - files: [cmd/cli/*] - run: - - 'echo "Hello Frey"' - - 'echo "Hello Sunny"' - - "greet-loki" - -greet-thor: - run: - - 'echo "Hello ${THOR}"' - env: - THOR: "LORD OF THUNDER"` - var clearCacheOpts = Options{ ClearCache: true, } @@ -86,7 +46,7 @@ func mockCacheExists(t *testing.T) *tests.FileSystem { func TestNewParserWithoutCache(t *testing.T) { fsMock := mockCacheDoesNotExist(t) - parser := NewParser(yamlConfigStub, &clearCacheOpts, fsMock) + parser := NewParser(tests.YamlConfigStub, &clearCacheOpts, fsMock) require.NotNil(t, parser) } @@ -94,7 +54,7 @@ func TestNewParserWithCache(t *testing.T) { fsMock := mockCacheDoesNotExistOnce(t) fsMock.On("ReadFile", mock.Anything).Return([]byte(tests.ReadFileBase64), nil) - parser := NewParser(yamlConfigStub, &clearCacheOpts, fsMock) + parser := NewParser(tests.YamlConfigStub, &clearCacheOpts, fsMock) require.NotNil(t, parser) } @@ -103,7 +63,7 @@ func TestNewParserWithCacheAndWithoutClearCacheFlag(t *testing.T) { fsMock.On("Stat", mock.Anything).Return(tests.MemFileInfo{}, nil).Twice() fsMock.On("ReadFile", mock.Anything).Return([]byte(tests.ReadFileBase64), nil).Once() - parser := NewParser(yamlConfigStub, &baseOptions, fsMock) + parser := NewParser(tests.YamlConfigStub, &baseOptions, fsMock) require.NotNil(t, parser) } @@ -115,14 +75,14 @@ func TestNewParserWithShouldClearCacheTrue(t *testing.T) { fsMock.On("FileExists", mock.Anything).Return(false).Once() fsMock.On("Remove", mock.Anything).Return(nil) - parser := NewParser(yamlConfigStub, &clearCacheOpts, fsMock) + parser := NewParser(tests.YamlConfigStub, &clearCacheOpts, fsMock) require.NotNil(t, parser) } func TestTaskParsing(t *testing.T) { fsMock := mockCacheDoesNotExist(t) fsMock.On("Glob", mock.Anything).Return([]string{"foo", "bar"}, nil).Once() - parser := NewParser(yamlConfigStub, &clearCacheOpts, fsMock) + parser := NewParser(tests.YamlConfigStub, &clearCacheOpts, fsMock) parser.parseTasks() @@ -136,7 +96,7 @@ func TestTaskParsing(t *testing.T) { func TestGlobalsParsing(t *testing.T) { fsMock := mockCacheDoesNotExist(t) - parser := NewParser(yamlConfigStub, &clearCacheOpts, fsMock) + parser := NewParser(tests.YamlConfigStub, &clearCacheOpts, fsMock) parser.parseGlobal() @@ -151,37 +111,12 @@ func TestGlobalsParsing(t *testing.T) { } func TestTaskGlobFilesExpansion(t *testing.T) { - expectedGlob := []string{"foo", "bar"} - fsMock := mockCacheDoesNotExist(t) - fsMock.On("Glob", mock.Anything).Return(expectedGlob, nil).Once() - parser := NewParser(yamlConfigStub, &clearCacheOpts, fsMock) + fsMock.On("Glob", mock.Anything).Return(tests.ExpectedGlob, nil) + parser := NewParser(tests.YamlConfigStub, &clearCacheOpts, fsMock) parser.parseTasks() greetCatsTask, _ := parser.GetTask("greet-cats") - require.Equal(t, expectedGlob, greetCatsTask.Files) -} - -func TestSetEnvVariables(t *testing.T) { - fsMock := mockCacheDoesNotExist(t) - parser := NewParser(yamlConfigStub, &clearCacheOpts, fsMock) - - values := map[string]string{ - "THOR": "Lord of thunder", - "THOR_CMD": "$(echo 'Hello Thor')", - } - - want := map[string]string{ - "THOR": "Lord of thunder", - "THOR_CMD": "Hello Thor", - } - - got, _ := parser.setEnvVariables(values) - require.Equal(t, want["THOR"], os.Getenv("THOR")) - require.Equal(t, want["THOR_CMD"], os.Getenv("THOR_CMD")) - - for k := range got { - require.Equal(t, want[k], got[k]) - } + require.Equal(t, tests.ExpectedGlob, greetCatsTask.Files) } diff --git a/internal/process.go b/internal/process.go new file mode 100644 index 0000000..94cffca --- /dev/null +++ b/internal/process.go @@ -0,0 +1,28 @@ +package internal + +import ( + "fmt" + "io" + "os" + "os/exec" +) + +type Process interface { + Execute(name string, args ...string) ([]byte, error) + Fprint(w io.Writer, a ...any) (n int, err error) + Exit(code int) +} + +type ShellProcess struct{} + +func (sp *ShellProcess) Execute(name string, args ...string) ([]byte, error) { + return exec.Command(name, args...).Output() +} + +func (sp *ShellProcess) Fprint(w io.Writer, a ...any) (n int, err error) { + return fmt.Fprint(w, a...) +} + +func (sp *ShellProcess) Exit(code int) { + os.Exit(code) +} diff --git a/internal/tests/helpers.go b/internal/tests/helpers.go index 36ea29c..7a4c5c3 100644 --- a/internal/tests/helpers.go +++ b/internal/tests/helpers.go @@ -55,3 +55,45 @@ func (fi MemFileInfo) IsDir() bool { func (fi MemFileInfo) Sys() any { return nil } + +const YamlConfigStub = ` +global: + environment: + FOO: "foo" + BAR: "$(echo 'bar')" + BAZ: "baz" + +events: + before_each_run: + - "echo 'before each 1'" + - "echo 'before each 2'" + after_each_run: + - "echo 'after each 1'" + - "greet-lisha" + before_each_task: + - "echo 'before task'" + after_each_task: + - "echo 'after task'" + +greet-lisha: + run: + - "echo 'Hello Lisha!'" + +greet-loki: + run: + - 'echo "Hello Boki"' + +greet-cats: + files: [cmd/cli/*] + run: + - 'echo "Hello Frey"' + - 'echo "Hello Sunny"' + - "greet-loki" + +greet-thor: + run: + - 'echo "Hello ${THOR}"' + env: + THOR: "LORD OF THUNDER"` + +var ExpectedGlob = []string{"foo", "bar"} diff --git a/internal/tests/process_mock.go b/internal/tests/process_mock.go new file mode 100644 index 0000000..30f116c --- /dev/null +++ b/internal/tests/process_mock.go @@ -0,0 +1,88 @@ +// Code generated by mockery v2.14.0. DO NOT EDIT. + +package tests + +import ( + io "io" + + mock "github.com/stretchr/testify/mock" +) + +// Process is an autogenerated mock type for the Process type +type Process struct { + mock.Mock +} + +// Execute provides a mock function with given fields: name, args +func (_m *Process) Execute(name string, args ...string) ([]byte, error) { + _va := make([]interface{}, len(args)) + for _i := range args { + _va[_i] = args[_i] + } + var _ca []interface{} + _ca = append(_ca, name) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 []byte + if rf, ok := ret.Get(0).(func(string, ...string) []byte); ok { + r0 = rf(name, args...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, ...string) error); ok { + r1 = rf(name, args...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Exit provides a mock function with given fields: code +func (_m *Process) Exit(code int) { + _m.Called(code) +} + +// Fprint provides a mock function with given fields: w, a +func (_m *Process) Fprint(w io.Writer, a ...interface{}) (int, error) { + var _ca []interface{} + _ca = append(_ca, w) + _ca = append(_ca, a...) + ret := _m.Called(_ca...) + + var r0 int + if rf, ok := ret.Get(0).(func(io.Writer, ...interface{}) int); ok { + r0 = rf(w, a...) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(io.Writer, ...interface{}) error); ok { + r1 = rf(w, a...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type mockConstructorTestingTNewProcess interface { + mock.TestingT + Cleanup(func()) +} + +// NewProcess creates a new instance of Process. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewProcess(t mockConstructorTestingTNewProcess) *Process { + mock := &Process{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/util.go b/internal/util.go index 469b1e3..e26b723 100644 --- a/internal/util.go +++ b/internal/util.go @@ -112,70 +112,3 @@ func PermutateArgs(args []string) int { return optind + 1 } - -// Parses the command string into an array of [command, args, args]... -func ParseCommandLine(command string) ([]string, error) { - var args []string - state := "start" - current := "" - quote := "\"" - escapeNext := true - - for i := 0; i < len(command); i++ { - c := command[i] - - if state == "quotes" { - if string(c) != quote { - current += string(c) - } else { - args = append(args, current) - current = "" - state = "start" - } - continue - } - - if escapeNext { - current += string(c) - escapeNext = false - continue - } - - if c == '\\' { - escapeNext = true - continue - } - - if c == '"' || c == '\'' { - state = "quotes" - quote = string(c) - continue - } - - if state == "arg" { - if c == ' ' || c == '\t' { - args = append(args, current) - current = "" - state = "start" - } else { - current += string(c) - } - continue - } - - if c != ' ' && c != '\t' { - state = "arg" - current += string(c) - } - } - - if state == "quotes" { - return []string{}, fmt.Errorf("unclosed quote in command: %s", command) - } - - if current != "" { - args = append(args, current) - } - - return args, nil -}