Files
task/completion/protocol_test.go
Valentin Maerten ed7a206e19 feat(completion): unify shell completions behind an opt-in task __complete engine
Bash, Fish, Zsh, Nushell and PowerShell now share a single backend: `task __complete` returns the suggestions plus a directive, and every wrapper is a thin shim around it. All five shells offer the same suggestions — task names, aliases, flags, flag values and per-task CLI variables. The Zsh `show-aliases` and `verbose` zstyles keep working, now backed by the `--no-aliases` and `--no-descriptions` completion flags.

The engine is opt-in via `task --new-completion <shell>`, leaving `--completion` and the legacy scripts untouched; it will become the default in a future release. The new wrappers live under `completion/next/`.

Completing a keystroke never reaches the network, never blocks on a stdin entrypoint, and honors every flag that decides how the Taskfile is loaded. Ref resolution shared by `requires` and enum completion moved to `internal/refs`.

A cross-shell test suite exercises the protocol in Go with thin shell smoke tests, and runs in CI.
2026-08-20 14:18:54 +02:00

329 lines
9.4 KiB
Go

// 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)
}
}
}