1
0
mirror of https://github.com/go-task/task.git synced 2025-06-02 23:27:37 +02:00

refactor: unify how executor tests are written (#2042)

* feat: use TaskTest for executor tests

* feat: more tests

* feat: separate tests for executing and formatting with new functional options that work for both test types

* feat: formatter tests

* refactor: more tests
This commit is contained in:
Pete Davison 2025-03-31 17:53:58 +01:00 committed by GitHub
parent 180fcef364
commit 4736bc2734
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
175 changed files with 1706 additions and 1111 deletions

View File

@ -43,6 +43,13 @@ tasks:
- "{{.BIN}}/mockery --dir ./internal/fingerprint --name SourcesCheckable"
- "{{.BIN}}/mockery --dir ./internal/fingerprint --name StatusCheckable"
generate:fixtures:
desc: Runs tests and generates golden fixture files
aliases: [gen:fixtures, g:fixtures]
cmds:
- find ./testdata -name '*.golden' -delete
- go test -update ./...
install:mockery:
desc: Installs mockgen; a tool to generate mock files
vars:

View File

@ -217,6 +217,14 @@ func ExecutorWithAssumeYes(assumeYes bool) ExecutorOption {
}
// WithAssumeTerm is used for testing purposes to simulate a terminal.
func ExecutorWithAssumeTerm(assumeTerm bool) ExecutorOption {
return func(e *Executor) {
e.AssumeTerm = assumeTerm
}
}
// ExecutorWithDry tells the [Executor] to output the commands that would be run
// without actually running them.
func ExecutorWithDry(dry bool) ExecutorOption {
return func(e *Executor) {
e.Dry = dry

946
executor_test.go Normal file
View File

@ -0,0 +1,946 @@
package task_test
import (
"bytes"
"cmp"
"context"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/sebdah/goldie/v2"
"github.com/stretchr/testify/assert"
"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.ExecutorWithStdout(&buf),
task.ExecutorWithStderr(&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.ExecutorWithStdin(&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.ExecutorWithDir("testdata/empty_task"),
),
)
}
func TestEmptyTaskfile(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithExecutorOptions(
task.ExecutorWithDir("testdata/empty_taskfile"),
),
WithSetupError(),
WithPostProcessFn(PPRemoveAbsolutePaths),
)
}
func TestEnv(t *testing.T) {
t.Setenv("QUX", "from_os")
NewExecutorTest(t,
WithName("env precedence disabled"),
WithExecutorOptions(
task.ExecutorWithDir("testdata/env"),
task.ExecutorWithSilent(true),
),
)
NewExecutorTest(t,
WithName("env precedence enabled"),
WithExecutorOptions(
task.ExecutorWithDir("testdata/env"),
task.ExecutorWithSilent(true),
),
WithExperiment(&experiments.EnvPrecedence, 1),
)
}
func TestVars(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithExecutorOptions(
task.ExecutorWithDir("testdata/vars"),
task.ExecutorWithSilent(true),
),
)
}
func TestRequires(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithName("required var missing"),
WithExecutorOptions(
task.ExecutorWithDir("testdata/requires"),
),
WithTask("missing-var"),
WithRunError(),
)
NewExecutorTest(t,
WithName("required var ok"),
WithExecutorOptions(
task.ExecutorWithDir("testdata/requires"),
),
WithTask("missing-var"),
WithVar("FOO", "bar"),
)
NewExecutorTest(t,
WithName("fails validation"),
WithExecutorOptions(
task.ExecutorWithDir("testdata/requires"),
),
WithTask("validation-var"),
WithVar("ENV", "dev"),
WithVar("FOO", "bar"),
WithRunError(),
)
NewExecutorTest(t,
WithName("passes validation"),
WithExecutorOptions(
task.ExecutorWithDir("testdata/requires"),
),
WithTask("validation-var"),
WithVar("FOO", "one"),
WithVar("ENV", "dev"),
)
NewExecutorTest(t,
WithName("required var missing + fails validation"),
WithExecutorOptions(
task.ExecutorWithDir("testdata/requires"),
),
WithTask("validation-var"),
WithRunError(),
)
NewExecutorTest(t,
WithName("required var missing + fails validation"),
WithExecutorOptions(
task.ExecutorWithDir("testdata/requires"),
),
WithTask("validation-var-dynamic"),
WithVar("FOO", "one"),
WithVar("ENV", "dev"),
)
NewExecutorTest(t,
WithName("require before compile"),
WithExecutorOptions(
task.ExecutorWithDir("testdata/requires"),
),
WithTask("require-before-compile"),
WithRunError(),
)
NewExecutorTest(t,
WithName("var defined in task"),
WithExecutorOptions(
task.ExecutorWithDir("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"
toAbs := func(rel string) string {
abs, err := filepath.Abs(rel)
assert.NoError(t, err)
return abs
}
tests := []struct {
target string
expected string
}{
// Root
{target: "print-task", expected: "print-task"},
{target: "print-root-dir", expected: toAbs(dir)},
{target: "print-taskfile", expected: toAbs(dir) + "/Taskfile.yml"},
{target: "print-taskfile-dir", expected: toAbs(dir)},
{target: "print-task-version", expected: "unknown"},
{target: "print-task-dir", expected: toAbs(dir) + "/foo"},
// Included
{target: "included:print-task", expected: "included:print-task"},
{target: "included:print-root-dir", expected: toAbs(dir)},
{target: "included:print-taskfile", expected: toAbs(dir) + "/included/Taskfile.yml"},
{target: "included:print-taskfile-dir", expected: toAbs(dir) + "/included"},
{target: "included:print-task-version", expected: "unknown"},
}
for _, dir := range []string{dir, subdir} {
for _, test := range tests {
NewExecutorTest(t,
WithName(fmt.Sprintf("%s-%s", dir, test.target)),
WithExecutorOptions(
task.ExecutorWithDir(dir),
task.ExecutorWithSilent(true),
task.ExecutorWithVersionCheck(true),
),
WithTask(test.target),
WithPostProcessFn(PPRemoveAbsolutePaths),
)
}
}
}
func TestConcurrency(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithExecutorOptions(
task.ExecutorWithDir("testdata/concurrency"),
task.ExecutorWithConcurrency(1),
),
WithPostProcessFn(PPSortedLines),
)
}
func TestParams(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithExecutorOptions(
task.ExecutorWithDir("testdata/params"),
task.ExecutorWithSilent(true),
),
WithPostProcessFn(PPSortedLines),
)
}
func TestDeps(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithExecutorOptions(
task.ExecutorWithDir("testdata/deps"),
task.ExecutorWithSilent(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.ExecutorWithDir(dir),
task.ExecutorWithSilent(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.ExecutorWithDir(dir),
task.ExecutorWithSilent(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.ExecutorWithDir(dir),
task.ExecutorWithSilent(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.ExecutorWithDir(dir),
task.ExecutorWithSilent(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.ExecutorWithDir(dir),
task.ExecutorWithSilent(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.ExecutorWithDir(dir),
task.ExecutorWithSilent(true),
),
WithTask("gen-bar"),
)
// all: not up-to-date
NewExecutorTest(t,
WithName("run gen-foo 2"),
WithExecutorOptions(
task.ExecutorWithDir(dir),
),
WithTask("gen-foo"),
)
// status: not up-to-date
NewExecutorTest(t,
WithName("run gen-foo 3"),
WithExecutorOptions(
task.ExecutorWithDir(dir),
),
WithTask("gen-foo"),
)
// sources: not up-to-date
NewExecutorTest(t,
WithName("run gen-bar 5"),
WithExecutorOptions(
task.ExecutorWithDir(dir),
),
WithTask("gen-bar"),
)
// all: up-to-date
NewExecutorTest(t,
WithName("run gen-bar 6"),
WithExecutorOptions(
task.ExecutorWithDir(dir),
),
WithTask("gen-bar"),
)
// sources: not up-to-date, no output produced.
NewExecutorTest(t,
WithName("run gen-baz 2"),
WithExecutorOptions(
task.ExecutorWithDir(dir),
),
WithTask("gen-silent-baz"),
)
// up-to-date, no output produced
NewExecutorTest(t,
WithName("run gen-baz 3"),
WithExecutorOptions(
task.ExecutorWithDir(dir),
),
WithTask("gen-silent-baz"),
)
// up-to-date, output produced due to Verbose mode.
NewExecutorTest(t,
WithName("run gen-baz 4 verbose"),
WithExecutorOptions(
task.ExecutorWithDir(dir),
task.ExecutorWithVerbose(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.ExecutorWithDir(dir),
),
WithTask("foo"),
)
NewExecutorTest(t,
WithName("a precondition was not met"),
WithExecutorOptions(
task.ExecutorWithDir(dir),
),
WithTask("impossible"),
WithRunError(),
)
NewExecutorTest(t,
WithName("precondition in dependency fails the task"),
WithExecutorOptions(
task.ExecutorWithDir(dir),
),
WithTask("depends_on_impossible"),
WithRunError(),
)
NewExecutorTest(t,
WithName("precondition in cmd fails the task"),
WithExecutorOptions(
task.ExecutorWithDir(dir),
),
WithTask("executes_failing_task_as_cmd"),
WithRunError(),
)
}
func TestAlias(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithName("alias"),
WithExecutorOptions(
task.ExecutorWithDir("testdata/alias"),
),
WithTask("f"),
)
NewExecutorTest(t,
WithName("duplicate alias"),
WithExecutorOptions(
task.ExecutorWithDir("testdata/alias"),
),
WithTask("x"),
WithRunError(),
)
NewExecutorTest(t,
WithName("alias summary"),
WithExecutorOptions(
task.ExecutorWithDir("testdata/alias"),
task.ExecutorWithSummary(true),
),
WithTask("f"),
)
}
func TestLabel(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithName("up to date"),
WithExecutorOptions(
task.ExecutorWithDir("testdata/label_uptodate"),
),
WithTask("foo"),
)
NewExecutorTest(t,
WithName("summary"),
WithExecutorOptions(
task.ExecutorWithDir("testdata/label_summary"),
task.ExecutorWithSummary(true),
),
WithTask("foo"),
)
NewExecutorTest(t,
WithName("status"),
WithExecutorOptions(
task.ExecutorWithDir("testdata/label_status"),
),
WithTask("foo"),
WithStatusError(),
)
NewExecutorTest(t,
WithName("var"),
WithExecutorOptions(
task.ExecutorWithDir("testdata/label_var"),
),
WithTask("foo"),
)
NewExecutorTest(t,
WithName("label in summary"),
WithExecutorOptions(
task.ExecutorWithDir("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.ExecutorWithDir("testdata/prompt"),
task.ExecutorWithAssumeTerm(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.ExecutorWithDir("testdata/prompt"),
task.ExecutorWithAssumeTerm(true),
),
WithTask("bar"),
WithInput("y\n"),
)
}
func TestPromptAssumeYes(t *testing.T) {
t.Parallel()
NewExecutorTest(t,
WithName("--yes flag should skip prompt"),
WithExecutorOptions(
task.ExecutorWithDir("testdata/prompt"),
task.ExecutorWithAssumeTerm(true),
task.ExecutorWithAssumeYes(true),
),
WithTask("foo"),
WithInput("\n"),
)
NewExecutorTest(t,
WithName("task should raise errors.TaskCancelledError"),
WithExecutorOptions(
task.ExecutorWithDir("testdata/prompt"),
task.ExecutorWithAssumeTerm(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-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.ExecutorWithDir("testdata/for/cmds"),
task.ExecutorWithSilent(true),
task.ExecutorWithForce(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-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.ExecutorWithDir("testdata/for/deps"),
task.ExecutorWithSilent(true),
task.ExecutorWithForce(true),
// Force output of each dep to be grouped together to prevent interleaving
task.ExecutorWithOutputStyle(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.ExecutorWithDir("testdata/var_references"),
task.ExecutorWithSilent(true),
task.ExecutorWithForce(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.ExecutorWithDir(fmt.Sprintf("testdata/var_inheritance/v3/%s", test.name)),
task.ExecutorWithSilent(true),
task.ExecutorWithForce(true),
),
WithTask(cmp.Or(test.call, "default")),
)
}
}

220
formatter_test.go Normal file
View File

@ -0,0 +1,220 @@
package task_test
import (
"bytes"
"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/taskfile/ast"
)
type (
// A FormatterTestOption is a function that configures an [FormatterTest].
FormatterTestOption interface {
applyToFormatterTest(*FormatterTest)
}
// A FormatterTest is a test wrapper around a [task.Executor] to make it
// easy to write tests for the task formatter. See [NewFormatterTest] for
// information on creating and running FormatterTests. These tests use
// fixture files to assert whether the result of the output is correct. If
// Task's behavior has been changed, the fixture files can be updated by
// running `task gen:fixtures`.
FormatterTest struct {
TaskTest
task string
vars map[string]any
executorOpts []task.ExecutorOption
listOptions task.ListOptions
wantSetupError bool
wantListError bool
}
)
// NewFormatterTest sets up a new [task.Executor] with the given options and
// runs a task with the given [FormatterTestOption]s. The output of the task is
// written to a set of fixture files depending on the configuration of the test.
func NewFormatterTest(t *testing.T, opts ...FormatterTestOption) {
t.Helper()
tt := &FormatterTest{
task: "default",
vars: map[string]any{},
TaskTest: TaskTest{
experiments: map[*experiments.Experiment]int{},
},
}
// Apply the functional options
for _, opt := range opts {
opt.applyToFormatterTest(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
// WithListOptions sets the list options for the formatter.
func WithListOptions(opts task.ListOptions) FormatterTestOption {
return &listOptionsTestOption{opts}
}
type listOptionsTestOption struct {
listOptions task.ListOptions
}
func (opt *listOptionsTestOption) applyToFormatterTest(t *FormatterTest) {
t.listOptions = opt.listOptions
}
// WithListError tells the test to expect an error when running the formatter.
// A fixture will be created with the output of any errors.
func WithListError() FormatterTestOption {
return &listErrorTestOption{}
}
type listErrorTestOption struct{}
func (opt *listErrorTestOption) applyToFormatterTest(t *FormatterTest) {
t.wantListError = true
}
// Helpers
// writeFixtureErrList is a wrapper for writing the output of an error when
// running the formatter to a fixture file.
func (tt *FormatterTest) writeFixtureErrList(
t *testing.T,
g *goldie.Goldie,
err error,
) {
t.Helper()
tt.writeFixture(t, g, "err-list", []byte(err.Error()))
}
// 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 *FormatterTest) run(t *testing.T) {
t.Helper()
f := func(t *testing.T) {
t.Helper()
var buf bytes.Buffer
opts := append(
tt.executorOpts,
task.ExecutorWithStdout(&buf),
task.ExecutorWithStderr(&buf),
)
// 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})
}
// Run the formatter and check for errors
if _, err := e.ListTasks(tt.listOptions); tt.wantListError {
require.Error(t, err)
tt.writeFixtureErrList(t, g, err)
tt.writeFixtureBuffer(t, g, buf)
return
} else {
require.NoError(t, err)
}
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 TestNoLabelInList(t *testing.T) {
t.Parallel()
NewFormatterTest(t,
WithExecutorOptions(
task.ExecutorWithDir("testdata/label_list"),
),
WithListOptions(task.ListOptions{
ListOnlyTasksWithDescriptions: true,
}),
)
}
// task -al case 1: listAll list all tasks
func TestListAllShowsNoDesc(t *testing.T) {
t.Parallel()
NewFormatterTest(t,
WithExecutorOptions(
task.ExecutorWithDir("testdata/list_mixed_desc"),
),
WithListOptions(task.ListOptions{
ListAllTasks: true,
}),
)
}
// task -al case 2: !listAll list some tasks (only those with desc)
func TestListCanListDescOnly(t *testing.T) {
t.Parallel()
NewFormatterTest(t,
WithExecutorOptions(
task.ExecutorWithDir("testdata/list_mixed_desc"),
),
WithListOptions(task.ListOptions{
ListOnlyTasksWithDescriptions: true,
}),
)
}
func TestListDescInterpolation(t *testing.T) {
t.Parallel()
NewFormatterTest(t,
WithExecutorOptions(
task.ExecutorWithDir("testdata/list_desc_interpolation"),
),
WithListOptions(task.ListOptions{
ListOnlyTasksWithDescriptions: true,
}),
)
}

1
go.mod
View File

@ -22,6 +22,7 @@ require (
github.com/otiai10/copy v1.14.1
github.com/puzpuzpuz/xsync/v3 v3.5.1
github.com/sajari/fuzzy v1.0.0
github.com/sebdah/goldie/v2 v2.5.5
github.com/spf13/pflag v1.0.6
github.com/stretchr/testify v1.10.0
github.com/zeebo/xxh3 v1.0.2

5
go.sum
View File

@ -98,6 +98,7 @@ github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs=
github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -108,6 +109,9 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sajari/fuzzy v1.0.0 h1:+FmwVvJErsd0d0hAPlj4CxqxUtQY/fOoY0DwX4ykpRY=
github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo=
github.com/sebdah/goldie/v2 v2.5.5 h1:rx1mwF95RxZ3/83sdS4Yp7t2C5TCokvWP4TBRbAyEWY=
github.com/sebdah/goldie/v2 v2.5.5/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
@ -119,6 +123,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
task: Found multiple tasks (foo, bar) that match "x"

View File

@ -0,0 +1,12 @@
done 1
done 2
done 3
done 4
done 5
done 6
task: [t1] echo done 1
task: [t2] echo done 2
task: [t3] echo done 3
task: [t4] echo done 4
task: [t5] echo done 5
task: [t6] echo done 6

View File

@ -1 +0,0 @@
*.txt

View File

@ -7,50 +7,50 @@ tasks:
d1:
deps: [d11, d12, d13]
cmds:
- echo 'Text' > d1.txt
- echo 'd1'
d2:
deps: [d21, d22, d23]
cmds:
- echo 'Text' > d2.txt
- echo 'd2'
d3:
deps: [d31, d32, d33]
cmds:
- echo 'Text' > d3.txt
- echo 'd3'
d11:
cmds:
- echo 'Text' > d11.txt
- echo 'd11'
d12:
cmds:
- echo 'Text' > d12.txt
- echo 'd12'
d13:
cmds:
- echo 'Text' > d13.txt
- echo 'd13'
d21:
cmds:
- echo 'Text' > d21.txt
- echo 'd21'
d22:
cmds:
- echo 'Text' > d22.txt
- echo 'd22'
d23:
cmds:
- echo 'Text' > d23.txt
- echo 'd23'
d31:
cmds:
- echo 'Text' > d31.txt
- echo 'd31'
d32:
cmds:
- echo 'Text' > d32.txt
- echo 'd32'
d33:
cmds:
- echo 'Text' > d33.txt
- echo 'd33'

1
testdata/deps/d1.txt vendored Normal file
View File

@ -0,0 +1 @@
Text

1
testdata/deps/d11.txt vendored Normal file
View File

@ -0,0 +1 @@
Text

1
testdata/deps/d12.txt vendored Normal file
View File

@ -0,0 +1 @@
Text

1
testdata/deps/d13.txt vendored Normal file
View File

@ -0,0 +1 @@
Text

1
testdata/deps/d2.txt vendored Normal file
View File

@ -0,0 +1 @@
Text

1
testdata/deps/d21.txt vendored Normal file
View File

@ -0,0 +1 @@
Text

1
testdata/deps/d22.txt vendored Normal file
View File

@ -0,0 +1 @@
Text

1
testdata/deps/d23.txt vendored Normal file
View File

@ -0,0 +1 @@
Text

1
testdata/deps/d3.txt vendored Normal file
View File

@ -0,0 +1 @@
Text

1
testdata/deps/d31.txt vendored Normal file
View File

@ -0,0 +1 @@
Text

1
testdata/deps/d32.txt vendored Normal file
View File

@ -0,0 +1 @@
Text

1
testdata/deps/d33.txt vendored Normal file
View File

@ -0,0 +1 @@
Text

12
testdata/deps/testdata/TestDeps.golden vendored Normal file
View File

@ -0,0 +1,12 @@
d1
d11
d12
d13
d2
d21
d22
d23
d3
d31
d32
d33

View File

View File

@ -0,0 +1 @@
task: Missing schema version in Taskfile "/testdata/empty_taskfile/Taskfile.yml"

View File

@ -1 +0,0 @@
*.txt

View File

@ -28,13 +28,13 @@ tasks:
CGO_ENABLED:
sh: echo '0'
cmds:
- echo "GOOS='$GOOS' GOARCH='$GOARCH' CGO_ENABLED='$CGO_ENABLED'" > local.txt
- echo "GOOS='$GOOS' GOARCH='$GOARCH' CGO_ENABLED='$CGO_ENABLED'"
global:
env:
BAR: overridden
cmds:
- echo "FOO='$FOO' BAR='$BAR' BAZ='$BAZ'" > global.txt
- echo "FOO='$FOO' BAR='$BAR' BAZ='$BAZ'"
multiple_type:
env:
@ -42,15 +42,15 @@ tasks:
BAR: true
BAZ: 1.1
cmds:
- echo "FOO='$FOO' BAR='$BAR' BAZ='$BAZ'" > multiple_type.txt
- echo "FOO='$FOO' BAR='$BAR' BAZ='$BAZ'"
not-overridden:
cmds:
- echo "QUX='$QUX'" > not-overridden.txt
- echo "QUX='$QUX'"
overridden:
cmds:
- echo "QUX='$QUX'" > overridden.txt
- echo "QUX='$QUX'"
dynamic:
silent: true
@ -58,4 +58,4 @@ tasks:
DYNAMIC_FOO:
sh: echo $FOO
cmds:
- echo "{{ .DYNAMIC_FOO }}" > dynamic.txt
- echo "{{ .DYNAMIC_FOO }}"

1
testdata/env/dynamic.txt vendored Normal file
View File

@ -0,0 +1 @@
foo

1
testdata/env/global.txt vendored Normal file
View File

@ -0,0 +1 @@
FOO='foo' BAR='overridden' BAZ='baz'

1
testdata/env/local.txt vendored Normal file
View File

@ -0,0 +1 @@
GOOS='linux' GOARCH='amd64' CGO_ENABLED='0'

1
testdata/env/multiple_type.txt vendored Normal file
View File

@ -0,0 +1 @@
FOO='1' BAR='true' BAZ='1.1'

1
testdata/env/not-overridden.txt vendored Normal file
View File

@ -0,0 +1 @@
QUX='from_os'

1
testdata/env/overridden.txt vendored Normal file
View File

@ -0,0 +1 @@
QUX='from_taskfile'

View File

@ -0,0 +1,5 @@
GOOS='linux' GOARCH='amd64' CGO_ENABLED='0'
FOO='foo' BAR='overridden' BAZ='baz'
QUX='from_os'
FOO='1' BAR='true' BAZ='1.1'
foo

View File

@ -0,0 +1,5 @@
GOOS='linux' GOARCH='amd64' CGO_ENABLED='0'
FOO='foo' BAR='overridden' BAZ='baz'
QUX='from_taskfile'
FOO='1' BAR='true' BAZ='1.1'
foo

View File

@ -0,0 +1,3 @@
1
2
3

View File

@ -0,0 +1,3 @@
a
b
c

View File

@ -0,0 +1,2 @@
task: Failed to parse /testdata/for/cmds/Taskfile.yml:
matrix reference ".NOT_A_LIST" must resolve to a list

View File

@ -0,0 +1,6 @@
windows/amd64
windows/arm64
linux/amd64
linux/arm64
darwin/amd64
darwin/arm64

View File

@ -0,0 +1,6 @@
windows/amd64
windows/arm64
linux/amd64
linux/arm64
darwin/amd64
darwin/arm64

View File

@ -0,0 +1,2 @@
bar
foo

View File

@ -0,0 +1,2 @@
bar
foo

View File

@ -0,0 +1,2 @@
foo
bar

View File

@ -0,0 +1,2 @@
foo
bar

View File

@ -0,0 +1,2 @@
bar
foo

View File

@ -0,0 +1,2 @@
foo
bar

View File

@ -0,0 +1,3 @@
1
2
3

View File

@ -0,0 +1,3 @@
a
b
c

View File

@ -0,0 +1,2 @@
matrix reference ".NOT_A_LIST" must resolve to a list
task: Failed to parse /testdata/for/deps/Taskfile.yml:

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,6 @@
darwin/amd64
darwin/arm64
linux/amd64
linux/arm64
windows/amd64
windows/arm64

View File

@ -0,0 +1,6 @@
darwin/amd64
darwin/arm64
linux/amd64
linux/arm64
windows/amd64
windows/arm64

View File

@ -0,0 +1,2 @@
bar
foo

View File

@ -0,0 +1,2 @@
bar
foo

View File

@ -0,0 +1,2 @@
bar
foo

View File

@ -0,0 +1,2 @@
bar
foo

View File

@ -0,0 +1,2 @@
bar
foo

View File

@ -0,0 +1,2 @@
bar
foo

View File

@ -0,0 +1,2 @@
task: Available tasks for this project:
* foo: task description

View File

@ -0,0 +1 @@
task: Task "foobar" is not up-to-date

View File

View File

@ -0,0 +1 @@
task: Task "foobar" is up to date

View File

@ -0,0 +1,3 @@
task: foobar
description

View File

@ -0,0 +1 @@
task: Task "foobar" is up to date

View File

@ -0,0 +1 @@
task: Task "foobaz" is up to date

View File

@ -0,0 +1,3 @@
task: Available tasks for this project:
* bar: task has desc with bar-var
* foo: task has desc with foo-var

View File

@ -0,0 +1,4 @@
task: Available tasks for this project:
* doo:
* foo: foo has desc and label
* voo:

View File

@ -0,0 +1,2 @@
task: Available tasks for this project:
* foo: foo has desc and label

View File

@ -1 +0,0 @@
*.txt

View File

@ -12,33 +12,33 @@ tasks:
GERMAN: "Welt!"
deps:
- task: write-file
vars: {CONTENT: Dependence1, FILE: dep1.txt}
vars: {CONTENT: Dependence1}
- task: write-file
vars: {CONTENT: Dependence2, FILE: dep2.txt}
vars: {CONTENT: Dependence2}
- task: write-file
vars: {CONTENT: "{{.SPANISH|replace \"mundo\" \"dependencia\"}}", FILE: spanish-dep.txt}
vars: {CONTENT: "{{.SPANISH|replace \"mundo\" \"dependencia\"}}"}
cmds:
- task: write-file
vars: {CONTENT: Hello, FILE: hello.txt}
vars: {CONTENT: Hello}
- task: write-file
vars: {CONTENT: "$echo 'World'", FILE: world.txt}
vars: {CONTENT: "$echo 'World'"}
- task: write-file
vars: {CONTENT: "!", FILE: exclamation.txt}
vars: {CONTENT: "!"}
- task: write-file
vars: {CONTENT: "{{.SPANISH}}", FILE: spanish.txt}
vars: {CONTENT: "{{.SPANISH}}"}
- task: write-file
vars: {CONTENT: "{{.PORTUGUESE}}", FILE: portuguese.txt}
vars: {CONTENT: "{{.PORTUGUESE}}"}
- task: write-file
vars: {CONTENT: "{{.GERMAN}}", FILE: german.txt}
vars: {CONTENT: "{{.GERMAN}}"}
- task: non-default
write-file:
cmds:
- echo {{.CONTENT}} > {{.FILE}}
- echo {{.CONTENT}}
non-default:
vars:
PORTUGUESE: "{{.PORTUGUESE_HELLO_WORLD}}"
cmds:
- task: write-file
vars: {CONTENT: "{{.PORTUGUESE}}", FILE: portuguese2.txt}
vars: {CONTENT: "{{.PORTUGUESE}}"}

1
testdata/params/dep1.txt vendored Normal file
View File

@ -0,0 +1 @@
Dependence1

1
testdata/params/dep2.txt vendored Normal file
View File

@ -0,0 +1 @@
Dependence2

1
testdata/params/exclamation.txt vendored Normal file
View File

@ -0,0 +1 @@
!

1
testdata/params/german.txt vendored Normal file
View File

@ -0,0 +1 @@
Welt!

1
testdata/params/hello.txt vendored Normal file
View File

@ -0,0 +1 @@
Hello

1
testdata/params/portuguese.txt vendored Normal file
View File

@ -0,0 +1 @@
Olá, mundo!

1
testdata/params/portuguese2.txt vendored Normal file
View File

@ -0,0 +1 @@
Olá, mundo!

1
testdata/params/spanish-dep.txt vendored Normal file
View File

@ -0,0 +1 @@
¡Holla dependencia!

1
testdata/params/spanish.txt vendored Normal file
View File

@ -0,0 +1 @@
¡Holla mundo!

View File

@ -0,0 +1,10 @@
!
Dependence1
Dependence2
Hello
Olá, mundo!
Olá, mundo!
Welt!
World
¡Holla dependencia!
¡Holla mundo!

1
testdata/params/world.txt vendored Normal file
View File

@ -0,0 +1 @@
World

View File

@ -0,0 +1 @@
task: precondition not met

View File

@ -0,0 +1 @@
task: 1 != 0 obviously!

View File

@ -0,0 +1 @@
task: Failed to run task "executes_failing_task_as_cmd": task: precondition not met

View File

@ -0,0 +1 @@
task: 1 != 0 obviously!

View File

@ -0,0 +1 @@
task: precondition not met

View File

@ -0,0 +1 @@
task: 1 != 0 obviously!

View File

@ -0,0 +1,3 @@
Do you want to continue? [assuming yes]
task: [foo] echo 'foo'
foo

View File

@ -0,0 +1 @@
task: Task "foo" cancelled by user

View File

@ -0,0 +1 @@
Do you want to continue? [y/N]:

View File

@ -0,0 +1 @@
task: Task "foo" cancelled by user

View File

@ -0,0 +1 @@
Do you want to continue? [y/N]:

View File

@ -0,0 +1 @@
task: Task "foo" cancelled by user

Some files were not shown because too many files have changed in this diff Show More