1
0
mirror of https://github.com/go-task/task.git synced 2025-04-18 12:04:04 +03:00
task/executor_test.go
2025-04-05 23:55:43 +01:00

940 lines
20 KiB
Go

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")),
)
}
}