package task_test import ( "bytes" "cmp" "context" "fmt" "os" "path/filepath" "testing" "github.com/sebdah/goldie/v2" "github.com/stretchr/testify/require" "github.com/go-task/task/v3" "github.com/go-task/task/v3/internal/experiments" "github.com/go-task/task/v3/internal/filepathext" "github.com/go-task/task/v3/taskfile/ast" ) type ( // A ExecutorTestOption is a function that configures an [ExecutorTest]. ExecutorTestOption interface { applyToExecutorTest(*ExecutorTest) } // A ExecutorTest is a test wrapper around a [task.Executor] to make it easy // to write tests for tasks. See [NewExecutorTest] for information on // creating and running ExecutorTests. These tests use fixture files to // assert whether the result of a task is correct. If Task's behavior has // been changed, the fixture files can be updated by running `task // gen:fixtures`. ExecutorTest struct { TaskTest task string vars map[string]any input string executorOpts []task.ExecutorOption wantSetupError bool wantRunError bool wantStatusError bool } ) // NewExecutorTest sets up a new [task.Executor] with the given options and runs // a task with the given [ExecutorTestOption]s. The output of the task is // written to a set of fixture files depending on the configuration of the test. func NewExecutorTest(t *testing.T, opts ...ExecutorTestOption) { t.Helper() tt := &ExecutorTest{ task: "default", vars: map[string]any{}, TaskTest: TaskTest{ experiments: map[*experiments.Experiment]int{}, }, } // Apply the functional options for _, opt := range opts { opt.applyToExecutorTest(tt) } // Enable any experiments that have been set for x, v := range tt.experiments { prev := *x *x = experiments.Experiment{ Name: prev.Name, AllowedValues: []int{v}, Value: v, } t.Cleanup(func() { *x = prev }) } tt.run(t) } // Functional options // WithInput tells the test to create a reader with the given input. This can be // used to simulate user input when a task requires it. func WithInput(input string) ExecutorTestOption { return &inputTestOption{input} } type inputTestOption struct { input string } func (opt *inputTestOption) applyToExecutorTest(t *ExecutorTest) { t.input = opt.input } // WithRunError tells the test to expect an error during the run phase of the // task execution. A fixture will be created with the output of any errors. func WithRunError() ExecutorTestOption { return &runErrorTestOption{} } type runErrorTestOption struct{} func (opt *runErrorTestOption) applyToExecutorTest(t *ExecutorTest) { t.wantRunError = true } // WithStatusError tells the test to make an additional call to // [task.Executor.Status] after the task has been run. A fixture will be created // with the output of any errors. func WithStatusError() ExecutorTestOption { return &statusErrorTestOption{} } type statusErrorTestOption struct{} func (opt *statusErrorTestOption) applyToExecutorTest(t *ExecutorTest) { t.wantStatusError = true } // Helpers // writeFixtureErrRun is a wrapper for writing the output of an error during the // run phase of the task to a fixture file. func (tt *ExecutorTest) writeFixtureErrRun( t *testing.T, g *goldie.Goldie, err error, ) { t.Helper() tt.writeFixture(t, g, "err-run", []byte(err.Error())) } // writeFixtureStatus is a wrapper for writing the output of an error when // making an additional call to [task.Executor.Status] to a fixture file. func (tt *ExecutorTest) writeFixtureStatus( t *testing.T, g *goldie.Goldie, status string, ) { t.Helper() tt.writeFixture(t, g, "err-status", []byte(status)) } // run is the main function for running the test. It sets up the task executor, // runs the task, and writes the output to a fixture file. func (tt *ExecutorTest) run(t *testing.T) { t.Helper() f := func(t *testing.T) { t.Helper() var buf bytes.Buffer opts := append( tt.executorOpts, task.WithStdout(&buf), task.WithStderr(&buf), ) // If the test has input, create a reader for it and add it to the // executor options if tt.input != "" { var reader bytes.Buffer reader.WriteString(tt.input) opts = append(opts, task.WithStdin(&reader)) } // Set up the task executor e := task.NewExecutor(opts...) // Create a golden fixture file for the output g := goldie.New(t, goldie.WithFixtureDir(filepath.Join(e.Dir, "testdata")), ) // Call setup and check for errors if err := e.Setup(); tt.wantSetupError { require.Error(t, err) tt.writeFixtureErrSetup(t, g, err) tt.writeFixtureBuffer(t, g, buf) return } else { require.NoError(t, err) } // Create the task call vars := ast.NewVars() for key, value := range tt.vars { vars.Set(key, ast.Var{Value: value}) } call := &task.Call{ Task: tt.task, Vars: vars, } // Run the task and check for errors ctx := context.Background() if err := e.Run(ctx, call); tt.wantRunError { require.Error(t, err) tt.writeFixtureErrRun(t, g, err) tt.writeFixtureBuffer(t, g, buf) return } else { require.NoError(t, err) } // If the status flag is set, run the status check if tt.wantStatusError { if err := e.Status(ctx, call); err != nil { tt.writeFixtureStatus(t, g, err.Error()) } } tt.writeFixtureBuffer(t, g, buf) } // Run the test (with a name if it has one) if tt.name != "" { t.Run(tt.name, f) } else { f(t) } } func TestEmptyTask(t *testing.T) { t.Parallel() NewExecutorTest(t, WithExecutorOptions( task.WithDir("testdata/empty_task"), ), ) } func TestEmptyTaskfile(t *testing.T) { t.Parallel() NewExecutorTest(t, WithExecutorOptions( task.WithDir("testdata/empty_taskfile"), ), WithSetupError(), WithPostProcessFn(PPRemoveAbsolutePaths), ) } func TestEnv(t *testing.T) { t.Setenv("QUX", "from_os") NewExecutorTest(t, WithName("env precedence disabled"), WithExecutorOptions( task.WithDir("testdata/env"), task.WithSilent(true), ), ) NewExecutorTest(t, WithName("env precedence enabled"), WithExecutorOptions( task.WithDir("testdata/env"), task.WithSilent(true), ), WithExperiment(&experiments.EnvPrecedence, 1), ) } func TestVars(t *testing.T) { t.Parallel() NewExecutorTest(t, WithExecutorOptions( task.WithDir("testdata/vars"), task.WithSilent(true), ), ) } func TestRequires(t *testing.T) { t.Parallel() NewExecutorTest(t, WithName("required var missing"), WithExecutorOptions( task.WithDir("testdata/requires"), ), WithTask("missing-var"), WithRunError(), ) NewExecutorTest(t, WithName("required var ok"), WithExecutorOptions( task.WithDir("testdata/requires"), ), WithTask("missing-var"), WithVar("FOO", "bar"), ) NewExecutorTest(t, WithName("fails validation"), WithExecutorOptions( task.WithDir("testdata/requires"), ), WithTask("validation-var"), WithVar("ENV", "dev"), WithVar("FOO", "bar"), WithRunError(), ) NewExecutorTest(t, WithName("passes validation"), WithExecutorOptions( task.WithDir("testdata/requires"), ), WithTask("validation-var"), WithVar("FOO", "one"), WithVar("ENV", "dev"), ) NewExecutorTest(t, WithName("required var missing + fails validation"), WithExecutorOptions( task.WithDir("testdata/requires"), ), WithTask("validation-var"), WithRunError(), ) NewExecutorTest(t, WithName("required var missing + fails validation"), WithExecutorOptions( task.WithDir("testdata/requires"), ), WithTask("validation-var-dynamic"), WithVar("FOO", "one"), WithVar("ENV", "dev"), ) NewExecutorTest(t, WithName("require before compile"), WithExecutorOptions( task.WithDir("testdata/requires"), ), WithTask("require-before-compile"), WithRunError(), ) NewExecutorTest(t, WithName("var defined in task"), WithExecutorOptions( task.WithDir("testdata/requires"), ), WithTask("var-defined-in-task"), ) } // TODO: mock fs func TestSpecialVars(t *testing.T) { t.Parallel() const dir = "testdata/special_vars" const subdir = "testdata/special_vars/subdir" tests := []string{ // Root "print-task", "print-root-dir", "print-taskfile", "print-taskfile-dir", "print-task-dir", // Included "included:print-task", "included:print-root-dir", "included:print-taskfile", "included:print-taskfile-dir", } for _, dir := range []string{dir, subdir} { for _, test := range tests { NewExecutorTest(t, WithName(fmt.Sprintf("%s-%s", dir, test)), WithExecutorOptions( task.WithDir(dir), task.WithSilent(true), task.WithVersionCheck(true), ), WithTask(test), WithPostProcessFn(PPRemoveAbsolutePaths), ) } } } func TestConcurrency(t *testing.T) { t.Parallel() NewExecutorTest(t, WithExecutorOptions( task.WithDir("testdata/concurrency"), task.WithConcurrency(1), ), WithPostProcessFn(PPSortedLines), ) } func TestParams(t *testing.T) { t.Parallel() NewExecutorTest(t, WithExecutorOptions( task.WithDir("testdata/params"), task.WithSilent(true), ), WithPostProcessFn(PPSortedLines), ) } func TestDeps(t *testing.T) { t.Parallel() NewExecutorTest(t, WithExecutorOptions( task.WithDir("testdata/deps"), task.WithSilent(true), ), WithPostProcessFn(PPSortedLines), ) } // TODO: mock fs func TestStatus(t *testing.T) { t.Parallel() const dir = "testdata/status" files := []string{ "foo.txt", "bar.txt", "baz.txt", } for _, f := range files { path := filepathext.SmartJoin(dir, f) _ = os.Remove(path) if _, err := os.Stat(path); err == nil { t.Errorf("File should not exist: %v", err) } } // gen-foo creates foo.txt, and will always fail it's status check. NewExecutorTest(t, WithName("run gen-foo 1 silent"), WithExecutorOptions( task.WithDir(dir), task.WithSilent(true), ), WithTask("gen-foo"), ) // gen-foo creates bar.txt, and will pass its status-check the 3. time it // is run. It creates bar.txt, but also lists it as its source. So, the checksum // for the file won't match before after the second run as we the file // only exists after the first run. NewExecutorTest(t, WithName("run gen-bar 1 silent"), WithExecutorOptions( task.WithDir(dir), task.WithSilent(true), ), WithTask("gen-bar"), ) // gen-silent-baz is marked as being silent, and should only produce output // if e.Verbose is set to true. NewExecutorTest(t, WithName("run gen-baz silent"), WithExecutorOptions( task.WithDir(dir), task.WithSilent(true), ), WithTask("gen-silent-baz"), ) for _, f := range files { if _, err := os.Stat(filepathext.SmartJoin(dir, f)); err != nil { t.Errorf("File should exist: %v", err) } } // Run gen-bar a second time to produce a checksum file that matches bar.txt NewExecutorTest(t, WithName("run gen-bar 2 silent"), WithExecutorOptions( task.WithDir(dir), task.WithSilent(true), ), WithTask("gen-bar"), ) // Run gen-bar a third time, to make sure we've triggered the status check. NewExecutorTest(t, WithName("run gen-bar 3 silent"), WithExecutorOptions( task.WithDir(dir), task.WithSilent(true), ), WithTask("gen-bar"), ) // Now, let's remove source file, and run the task again to to prepare // for the next test. err := os.Remove(filepathext.SmartJoin(dir, "bar.txt")) require.NoError(t, err) NewExecutorTest(t, WithName("run gen-bar 4 silent"), WithExecutorOptions( task.WithDir(dir), task.WithSilent(true), ), WithTask("gen-bar"), ) // all: not up-to-date NewExecutorTest(t, WithName("run gen-foo 2"), WithExecutorOptions( task.WithDir(dir), ), WithTask("gen-foo"), ) // status: not up-to-date NewExecutorTest(t, WithName("run gen-foo 3"), WithExecutorOptions( task.WithDir(dir), ), WithTask("gen-foo"), ) // sources: not up-to-date NewExecutorTest(t, WithName("run gen-bar 5"), WithExecutorOptions( task.WithDir(dir), ), WithTask("gen-bar"), ) // all: up-to-date NewExecutorTest(t, WithName("run gen-bar 6"), WithExecutorOptions( task.WithDir(dir), ), WithTask("gen-bar"), ) // sources: not up-to-date, no output produced. NewExecutorTest(t, WithName("run gen-baz 2"), WithExecutorOptions( task.WithDir(dir), ), WithTask("gen-silent-baz"), ) // up-to-date, no output produced NewExecutorTest(t, WithName("run gen-baz 3"), WithExecutorOptions( task.WithDir(dir), ), WithTask("gen-silent-baz"), ) // up-to-date, output produced due to Verbose mode. NewExecutorTest(t, WithName("run gen-baz 4 verbose"), WithExecutorOptions( task.WithDir(dir), task.WithVerbose(true), ), WithTask("gen-silent-baz"), WithPostProcessFn(PPRemoveAbsolutePaths), ) } func TestPrecondition(t *testing.T) { t.Parallel() const dir = "testdata/precondition" NewExecutorTest(t, WithName("a precondition has been met"), WithExecutorOptions( task.WithDir(dir), ), WithTask("foo"), ) NewExecutorTest(t, WithName("a precondition was not met"), WithExecutorOptions( task.WithDir(dir), ), WithTask("impossible"), WithRunError(), ) NewExecutorTest(t, WithName("precondition in dependency fails the task"), WithExecutorOptions( task.WithDir(dir), ), WithTask("depends_on_impossible"), WithRunError(), ) NewExecutorTest(t, WithName("precondition in cmd fails the task"), WithExecutorOptions( task.WithDir(dir), ), WithTask("executes_failing_task_as_cmd"), WithRunError(), ) } func TestAlias(t *testing.T) { t.Parallel() NewExecutorTest(t, WithName("alias"), WithExecutorOptions( task.WithDir("testdata/alias"), ), WithTask("f"), ) NewExecutorTest(t, WithName("duplicate alias"), WithExecutorOptions( task.WithDir("testdata/alias"), ), WithTask("x"), WithRunError(), ) NewExecutorTest(t, WithName("alias summary"), WithExecutorOptions( task.WithDir("testdata/alias"), task.WithSummary(true), ), WithTask("f"), ) } func TestLabel(t *testing.T) { t.Parallel() NewExecutorTest(t, WithName("up to date"), WithExecutorOptions( task.WithDir("testdata/label_uptodate"), ), WithTask("foo"), ) NewExecutorTest(t, WithName("summary"), WithExecutorOptions( task.WithDir("testdata/label_summary"), task.WithSummary(true), ), WithTask("foo"), ) NewExecutorTest(t, WithName("status"), WithExecutorOptions( task.WithDir("testdata/label_status"), ), WithTask("foo"), WithStatusError(), ) NewExecutorTest(t, WithName("var"), WithExecutorOptions( task.WithDir("testdata/label_var"), ), WithTask("foo"), ) NewExecutorTest(t, WithName("label in summary"), WithExecutorOptions( task.WithDir("testdata/label_summary"), ), WithTask("foo"), ) } func TestPromptInSummary(t *testing.T) { t.Parallel() tests := []struct { name string input string wantError bool }{ {"test short approval", "y\n", false}, {"test long approval", "yes\n", false}, {"test uppercase approval", "Y\n", false}, {"test stops task", "n\n", true}, {"test junk value stops task", "foobar\n", true}, {"test Enter stops task", "\n", true}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() opts := []ExecutorTestOption{ WithName(test.name), WithExecutorOptions( task.WithDir("testdata/prompt"), task.WithAssumeTerm(true), ), WithTask("foo"), WithInput(test.input), } if test.wantError { opts = append(opts, WithRunError()) } NewExecutorTest(t, opts...) }) } } func TestPromptWithIndirectTask(t *testing.T) { t.Parallel() NewExecutorTest(t, WithExecutorOptions( task.WithDir("testdata/prompt"), task.WithAssumeTerm(true), ), WithTask("bar"), WithInput("y\n"), ) } func TestPromptAssumeYes(t *testing.T) { t.Parallel() NewExecutorTest(t, WithName("--yes flag should skip prompt"), WithExecutorOptions( task.WithDir("testdata/prompt"), task.WithAssumeTerm(true), task.WithAssumeYes(true), ), WithTask("foo"), WithInput("\n"), ) NewExecutorTest(t, WithName("task should raise errors.TaskCancelledError"), WithExecutorOptions( task.WithDir("testdata/prompt"), task.WithAssumeTerm(true), ), WithTask("foo"), WithInput("\n"), WithRunError(), ) } func TestForCmds(t *testing.T) { t.Parallel() tests := []struct { name string wantErr bool }{ {name: "loop-explicit"}, {name: "loop-matrix"}, {name: "loop-matrix-ref"}, { name: "loop-matrix-ref-error", wantErr: true, }, {name: "loop-sources"}, {name: "loop-sources-glob"}, {name: "loop-generates"}, {name: "loop-generates-glob"}, {name: "loop-vars"}, {name: "loop-vars-sh"}, {name: "loop-task"}, {name: "loop-task-as"}, {name: "loop-different-tasks"}, } for _, test := range tests { opts := []ExecutorTestOption{ WithName(test.name), WithExecutorOptions( task.WithDir("testdata/for/cmds"), task.WithSilent(true), task.WithForce(true), ), WithTask(test.name), WithPostProcessFn(PPRemoveAbsolutePaths), } if test.wantErr { opts = append(opts, WithRunError()) } NewExecutorTest(t, opts...) } } func TestForDeps(t *testing.T) { t.Parallel() tests := []struct { name string wantErr bool }{ {name: "loop-explicit"}, {name: "loop-matrix"}, {name: "loop-matrix-ref"}, { name: "loop-matrix-ref-error", wantErr: true, }, {name: "loop-sources"}, {name: "loop-sources-glob"}, {name: "loop-generates"}, {name: "loop-generates-glob"}, {name: "loop-vars"}, {name: "loop-vars-sh"}, {name: "loop-task"}, {name: "loop-task-as"}, {name: "loop-different-tasks"}, } for _, test := range tests { opts := []ExecutorTestOption{ WithName(test.name), WithExecutorOptions( task.WithDir("testdata/for/deps"), task.WithSilent(true), task.WithForce(true), // Force output of each dep to be grouped together to prevent interleaving task.WithOutputStyle(ast.Output{Name: "group"}), ), WithTask(test.name), WithPostProcessFn(PPRemoveAbsolutePaths), WithPostProcessFn(PPSortedLines), } if test.wantErr { opts = append(opts, WithRunError()) } NewExecutorTest(t, opts...) } } func TestReference(t *testing.T) { t.Parallel() tests := []struct { name string call string }{ { name: "reference in command", call: "ref-cmd", }, { name: "reference in dependency", call: "ref-dep", }, { name: "reference using templating resolver", call: "ref-resolver", }, { name: "reference using templating resolver and dynamic var", call: "ref-resolver-sh", }, } for _, test := range tests { NewExecutorTest(t, WithName(test.name), WithExecutorOptions( task.WithDir("testdata/var_references"), task.WithSilent(true), task.WithForce(true), ), WithTask(cmp.Or(test.call, "default")), ) } } func TestVarInheritance(t *testing.T) { enableExperimentForTest(t, &experiments.EnvPrecedence, 1) tests := []struct { name string call string }{ {name: "shell"}, {name: "entrypoint-global-dotenv"}, {name: "entrypoint-global-vars"}, // We can't send env vars to a called task, so the env var is not overridden {name: "entrypoint-task-call-vars"}, // Dotenv doesn't set variables {name: "entrypoint-task-call-dotenv"}, {name: "entrypoint-task-call-task-vars"}, // Dotenv doesn't set variables {name: "entrypoint-task-dotenv"}, {name: "entrypoint-task-vars"}, // { // // Dotenv not currently allowed in included taskfiles // name: "included-global-dotenv", // want: "included-global-dotenv\nincluded-global-dotenv\n", // }, { name: "included-global-vars", call: "included", }, { // We can't send env vars to a called task, so the env var is not overridden name: "included-task-call-vars", call: "included", }, { // Dotenv doesn't set variables // Dotenv not currently allowed in included taskfiles (but doesn't error in a task) name: "included-task-call-dotenv", call: "included", }, { name: "included-task-call-task-vars", call: "included", }, { // Dotenv doesn't set variables // Somehow dotenv is working here! name: "included-task-dotenv", call: "included", }, { name: "included-task-vars", call: "included", }, } t.Setenv("VAR", "shell") t.Setenv("ENV", "shell") for _, test := range tests { NewExecutorTest(t, WithName(test.name), WithExecutorOptions( task.WithDir(fmt.Sprintf("testdata/var_inheritance/v3/%s", test.name)), task.WithSilent(true), task.WithForce(true), ), WithTask(cmp.Or(test.call, "default")), ) } }