mirror of
https://github.com/go-task/task.git
synced 2026-08-29 10:08:27 +02:00
test(completion): drop the binary-building protocol tests
completion/protocol_test.go built a `task` binary in TestMain and drove it through 21 subprocess spawns. Every one of its table cases already had a one-for-one in-process equivalent in internal/complete, and the wire format is asserted by TestWrite_Format. The one guarantee worth keeping was the `-t -` guard, which stops a keystroke from blocking on a Taskfile read from the terminal. It moves from cmd/task into complete.NeedsTaskfile, where it reads the flagset instead of the flags package global — testable in-process, and one less global read in cmd/task. TestCompletionShells moves to internal/complete unchanged, and the in-process wildcard fixture gains `matches-exactly-*`, the only pattern of testdata/wildcards it was missing. The binary path stays covered end to end by completion/tests/run.sh, which the CI completion job runs against five real shells on Linux and macOS.
This commit is contained in:
@@ -28,8 +28,8 @@ func runComplete(args []string) error {
|
|||||||
task.WithDownload(false),
|
task.WithDownload(false),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Best-effort, and never from stdin: that would hang the shell.
|
// Best-effort: a missing or broken Taskfile must not break completion.
|
||||||
if complete.NeedsTaskfile(args, pflag.CommandLine) && flags.Entrypoint != "-" {
|
if complete.NeedsTaskfile(args, pflag.CommandLine) {
|
||||||
_ = e.Setup()
|
_ = e.Setup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,328 +0,0 @@
|
|||||||
// Black-box tests of the `task __complete` wire protocol. How each shell
|
|
||||||
// wrapper interprets the directive is smoke-tested in completion/tests/.
|
|
||||||
package completion_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/go-task/task/v3"
|
|
||||||
"github.com/go-task/task/v3/internal/complete"
|
|
||||||
)
|
|
||||||
|
|
||||||
var taskBin string
|
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
|
||||||
dir, err := os.MkdirTemp("", "task-completion-test")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
taskBin = filepath.Join(dir, "task")
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
taskBin += ".exe"
|
|
||||||
}
|
|
||||||
if out, err := exec.CommandContext(context.Background(), "go", "build", "-o", taskBin, "github.com/go-task/task/v3/cmd/task").CombinedOutput(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "failed to build task binary: %v\n%s", err, out)
|
|
||||||
os.RemoveAll(dir)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
code := m.Run()
|
|
||||||
os.RemoveAll(dir)
|
|
||||||
os.Exit(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fixtureTaskfile = `version: '3'
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
build:
|
|
||||||
desc: Build it
|
|
||||||
deploy:
|
|
||||||
desc: Deploy the application
|
|
||||||
aliases: [dep, ship]
|
|
||||||
requires:
|
|
||||||
vars:
|
|
||||||
- name: ENV
|
|
||||||
enum: [dev, staging, prod]
|
|
||||||
- REGION
|
|
||||||
docs:serve:
|
|
||||||
desc: Serve docs locally
|
|
||||||
`
|
|
||||||
|
|
||||||
// completeArgs runs `task __complete <args>` in a fresh fixture directory.
|
|
||||||
func completeArgs(t *testing.T, args ...string) ([]string, complete.Directive) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(fixtureTaskfile), 0o644))
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(t.Context(), taskBin, append([]string{complete.CommandName}, args...)...) //nolint:gosec
|
|
||||||
cmd.Dir = dir
|
|
||||||
out, err := cmd.Output()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return parseProtocol(t, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseProtocol(t *testing.T, out []byte) ([]string, complete.Directive) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n")
|
|
||||||
require.NotEmpty(t, lines, "protocol output must end with a directive line")
|
|
||||||
|
|
||||||
last := lines[len(lines)-1]
|
|
||||||
require.True(t, strings.HasPrefix(last, ":"), "last line must be the :<directive> line, got %q", last)
|
|
||||||
n, err := strconv.Atoi(strings.TrimPrefix(last, ":"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
values := make([]string, 0, len(lines)-1)
|
|
||||||
for _, line := range lines[:len(lines)-1] {
|
|
||||||
values = append(values, strings.SplitN(line, "\t", 2)[0])
|
|
||||||
}
|
|
||||||
return values, complete.Directive(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProtocol(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
args []string
|
|
||||||
want []string // candidate values that must be offered
|
|
||||||
absent []string // candidate values that must NOT be offered
|
|
||||||
directive complete.Directive
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "task names and aliases",
|
|
||||||
args: []string{""},
|
|
||||||
want: []string{"build", "deploy", "dep", "ship", "docs:serve"},
|
|
||||||
directive: complete.DirectiveNoFileComp,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no-aliases drops aliases",
|
|
||||||
args: []string{"--no-aliases", ""},
|
|
||||||
want: []string{"build", "deploy"},
|
|
||||||
absent: []string{"dep", "ship"},
|
|
||||||
directive: complete.DirectiveNoFileComp,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "flag names",
|
|
||||||
args: []string{"-"},
|
|
||||||
want: []string{"--taskfile", "--dir", "--output"},
|
|
||||||
directive: complete.DirectiveNoFileComp,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "separate flag value is bare",
|
|
||||||
args: []string{"--output", ""},
|
|
||||||
want: []string{"interleaved", "group", "prefixed"},
|
|
||||||
directive: complete.DirectiveNoFileComp,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "inline flag value is full form",
|
|
||||||
args: []string{"--output="},
|
|
||||||
want: []string{"--output=interleaved", "--output=group", "--output=prefixed"},
|
|
||||||
directive: complete.DirectiveNoFileComp,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "sort enum values",
|
|
||||||
args: []string{"--sort", ""},
|
|
||||||
want: []string{"default", "alphanumeric", "none"},
|
|
||||||
directive: complete.DirectiveNoFileComp,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "taskfile filters by extension",
|
|
||||||
args: []string{"--taskfile", ""},
|
|
||||||
want: []string{"yml", "yaml"},
|
|
||||||
directive: complete.DirectiveFilterFileExt,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dir filters to directories",
|
|
||||||
args: []string{"--dir", ""},
|
|
||||||
directive: complete.DirectiveFilterDirs,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "task variables keep order and suppress the space",
|
|
||||||
args: []string{"deploy", ""},
|
|
||||||
want: []string{"ENV=dev", "ENV=staging", "ENV=prod", "REGION="},
|
|
||||||
directive: complete.DirectiveNoSpace | complete.DirectiveNoFileComp | complete.DirectiveKeepOrder,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "after -- yields default file completion",
|
|
||||||
args: []string{"deploy", "--", ""},
|
|
||||||
directive: complete.DirectiveDefault,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
values, directive := completeArgs(t, tt.args...)
|
|
||||||
require.Equal(t, tt.directive, directive)
|
|
||||||
require.Subset(t, values, tt.want)
|
|
||||||
for _, a := range tt.absent {
|
|
||||||
require.NotContains(t, values, a)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --sort is the flag deciding how the Taskfile is read with a visible order.
|
|
||||||
func TestProtocol_SortFlagIsApplied(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const taskfile = `version: '3'
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
zebra:
|
|
||||||
desc: Declared first, last alphabetically
|
|
||||||
alpha:
|
|
||||||
desc: Declared last, first alphabetically
|
|
||||||
`
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o644))
|
|
||||||
|
|
||||||
sorted, _ := completeInDir(t, dir, nil, "")
|
|
||||||
require.Equal(t, []string{"alpha", "zebra"}, sorted)
|
|
||||||
|
|
||||||
declared, _ := completeInDir(t, dir, nil, "--sort", "none", "")
|
|
||||||
require.Equal(t, []string{"zebra", "alpha"}, declared)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProtocol_ExperimentGatedFlag(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(fixtureTaskfile), 0o644))
|
|
||||||
|
|
||||||
values, directive := completeInDir(t, dir, []string{"TASK_X_GENTLE_FORCE=1"}, "--force-all", "")
|
|
||||||
require.Equal(t, complete.DirectiveNoFileComp, directive)
|
|
||||||
require.Subset(t, values, []string{"build", "deploy"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Downloading an uncached remote include would freeze the shell for up to
|
|
||||||
// --timeout and prompt for trust.
|
|
||||||
func TestProtocol_RemoteIncludeStaysOffline(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var hits atomic.Int64
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
hits.Add(1)
|
|
||||||
<-r.Context().Done()
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
taskfile := fmt.Sprintf(`version: '3'
|
|
||||||
|
|
||||||
includes:
|
|
||||||
remote: %s/Taskfile.yml
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
build:
|
|
||||||
desc: Build it
|
|
||||||
`, srv.URL)
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o644))
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// A fresh cache dir leaves a download as the only way to resolve the
|
|
||||||
// include; the insecure opt-in keeps the plain-HTTP server from being
|
|
||||||
// rejected before it.
|
|
||||||
cmd := exec.CommandContext(ctx, taskBin, complete.CommandName, "") //nolint:gosec
|
|
||||||
cmd.Dir = dir
|
|
||||||
cmd.Env = append(os.Environ(),
|
|
||||||
"TASK_REMOTE_DIR="+t.TempDir(),
|
|
||||||
"TASK_REMOTE_INSECURE=1",
|
|
||||||
)
|
|
||||||
out, err := cmd.Output()
|
|
||||||
require.NoError(t, err, "completion must not hang on a remote include")
|
|
||||||
|
|
||||||
_, directive := parseProtocol(t, out)
|
|
||||||
require.Equal(t, complete.DirectiveNoFileComp, directive)
|
|
||||||
require.Zero(t, hits.Load(), "completion must not reach the network")
|
|
||||||
}
|
|
||||||
|
|
||||||
// `--taskfile -` would otherwise read the Taskfile from the terminal.
|
|
||||||
func TestProtocol_StdinEntrypointDoesNotHang(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// An unwritten pipe: reading it would block until the context expires.
|
|
||||||
r, w, err := os.Pipe()
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() {
|
|
||||||
r.Close()
|
|
||||||
w.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, taskBin, complete.CommandName, "-t", "-", "") //nolint:gosec
|
|
||||||
cmd.Dir = t.TempDir()
|
|
||||||
cmd.Stdin = r
|
|
||||||
out, err := cmd.Output()
|
|
||||||
require.NoError(t, err, "completion must not read the Taskfile from stdin")
|
|
||||||
|
|
||||||
_, directive := parseProtocol(t, out)
|
|
||||||
require.Equal(t, complete.DirectiveNoFileComp, directive)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProtocol_WildcardTaskNames(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
values, directive := completeInDir(t, filepath.Join("..", "testdata", "wildcards"), nil, "")
|
|
||||||
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, directive)
|
|
||||||
require.Subset(t, values, []string{"start-", "s-", "wildcard-", "matches-exactly-"})
|
|
||||||
for _, v := range values {
|
|
||||||
require.NotEmpty(t, v)
|
|
||||||
require.NotContains(t, v, "*")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// completeInDir runs `task __complete <args>` in dir, with env appended to the
|
|
||||||
// current environment.
|
|
||||||
func completeInDir(t *testing.T, dir string, env []string, args ...string) ([]string, complete.Directive) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(t.Context(), taskBin, append([]string{complete.CommandName}, args...)...) //nolint:gosec
|
|
||||||
cmd.Dir = dir
|
|
||||||
cmd.Env = append(os.Environ(), env...)
|
|
||||||
out, err := cmd.Output()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return parseProtocol(t, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keeps the shells the engine offers in step with the scripts the root package
|
|
||||||
// can actually serve.
|
|
||||||
func TestCompletionShells(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, flag := range []string{"--completion", "--new-completion"} {
|
|
||||||
shells, directive := completeArgs(t, flag, "")
|
|
||||||
require.Equal(t, complete.DirectiveNoFileComp, directive)
|
|
||||||
require.NotEmpty(t, shells)
|
|
||||||
|
|
||||||
for _, shell := range shells {
|
|
||||||
_, err := task.Completion(shell)
|
|
||||||
require.NoErrorf(t, err, "%s offers %q", flag, shell)
|
|
||||||
_, err = task.CompletionNext(shell)
|
|
||||||
require.NoErrorf(t, err, "%s offers %q", flag, shell)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,6 +26,8 @@ func newTestFlagSet() *pflag.FlagSet {
|
|||||||
fs.StringVarP(&s, "output", "o", "", "Output style")
|
fs.StringVarP(&s, "output", "o", "", "Output style")
|
||||||
fs.StringVar(&s, "sort", "", "Sort order")
|
fs.StringVar(&s, "sort", "", "Sort order")
|
||||||
fs.StringVar(&s, "cacert", "", "CA cert path")
|
fs.StringVar(&s, "cacert", "", "CA cert path")
|
||||||
|
fs.StringVar(&s, "completion", "", "Generate a completion script")
|
||||||
|
fs.StringVar(&s, "new-completion", "", "Generate a completion script")
|
||||||
return fs
|
return fs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +90,10 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- 'echo {{index .MATCH 0}}'
|
- 'echo {{index .MATCH 0}}'
|
||||||
|
|
||||||
|
matches-exactly-*:
|
||||||
|
cmds:
|
||||||
|
- 'echo {{.MATCH}}'
|
||||||
|
|
||||||
start-*:
|
start-*:
|
||||||
desc: Start a service
|
desc: Start a service
|
||||||
aliases: [s-*]
|
aliases: [s-*]
|
||||||
@@ -142,13 +148,13 @@ func TestComplete_WildcardTaskNames(t *testing.T) {
|
|||||||
|
|
||||||
// Patterns are cut at their first `*`: `wildcard-*` and `wildcard-*-*`
|
// Patterns are cut at their first `*`: `wildcard-*` and `wildcard-*-*`
|
||||||
// collapse into one candidate, and `*-wildcard-*` leaves nothing to insert.
|
// collapse into one candidate, and `*-wildcard-*` leaves nothing to insert.
|
||||||
require.Equal(t, []string{"build", "start-", "s-", "wildcard-"}, values(suggs))
|
require.Equal(t, []string{"build", "matches-exactly-", "start-", "s-", "wildcard-"}, values(suggs))
|
||||||
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, dir)
|
require.Equal(t, complete.DirectiveNoSpace|complete.DirectiveNoFileComp, dir)
|
||||||
// Without a desc, the pattern says what the prefix stands for.
|
// Without a desc, the pattern says what the prefix stands for.
|
||||||
require.Contains(t, descriptions(suggs), "wildcard-*")
|
require.Contains(t, descriptions(suggs), "wildcard-*")
|
||||||
|
|
||||||
suggs, _ = complete.Complete(e, newTestFlagSet(), []string{""}, complete.Options{NoDescriptions: true})
|
suggs, _ = complete.Complete(e, newTestFlagSet(), []string{""}, complete.Options{NoDescriptions: true})
|
||||||
require.Equal(t, []string{"", "", "", ""}, descriptions(suggs))
|
require.Equal(t, []string{"", "", "", "", ""}, descriptions(suggs))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestComplete_AliasResolvesToTaskVars(t *testing.T) {
|
func TestComplete_AliasResolvesToTaskVars(t *testing.T) {
|
||||||
@@ -379,6 +385,36 @@ func TestNeedsTaskfile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reading the Taskfile from standard input would block until EOF, freezing the
|
||||||
|
// shell on a keystroke.
|
||||||
|
func TestNeedsTaskfile_StdinEntrypoint(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := newTestFlagSet()
|
||||||
|
require.NoError(t, fs.Set("taskfile", "-"))
|
||||||
|
require.False(t, complete.NeedsTaskfile([]string{""}, fs))
|
||||||
|
require.False(t, complete.NeedsTaskfile([]string{"deploy", ""}, fs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keeps the shells the engine offers in step with the scripts the root package
|
||||||
|
// can actually serve.
|
||||||
|
func TestCompletionShells(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, flag := range []string{"--completion", "--new-completion"} {
|
||||||
|
suggs, dir := complete.Complete(nil, newTestFlagSet(), []string{flag, ""}, complete.Options{})
|
||||||
|
require.Equal(t, complete.DirectiveNoFileComp, dir)
|
||||||
|
require.NotEmpty(t, suggs)
|
||||||
|
|
||||||
|
for _, shell := range values(suggs) {
|
||||||
|
_, err := task.Completion(shell)
|
||||||
|
require.NoErrorf(t, err, "%s offers %q", flag, shell)
|
||||||
|
_, err = task.CompletionNext(shell)
|
||||||
|
require.NoErrorf(t, err, "%s offers %q", flag, shell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWrite_Format(t *testing.T) {
|
func TestWrite_Format(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,12 @@ func Complete(e *task.Executor, fs *pflag.FlagSet, args []string, opts Options)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NeedsTaskfile(args []string, fs *pflag.FlagSet) bool {
|
func NeedsTaskfile(args []string, fs *pflag.FlagSet) bool {
|
||||||
return parseContext(args).inTaskContext(fs)
|
if !parseContext(args).inTaskContext(fs) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Reading the Taskfile from standard input would hang the shell on a keystroke.
|
||||||
|
f := fs.Lookup("taskfile")
|
||||||
|
return f == nil || f.Value.String() != "-"
|
||||||
}
|
}
|
||||||
|
|
||||||
func taskNames(e *task.Executor) []string {
|
func taskNames(e *task.Executor) []string {
|
||||||
|
|||||||
Reference in New Issue
Block a user