refactor(completion): move the shell test harness out of completion/

Packaging globs completion/ into the release archives, so the harness
added alongside the engine was about to ship run.sh and five wrapper
scripts to every user. The previous commit worked around it by listing
the packaged directories one by one, which quietly stops packaging any
shell added later.

Moving the harness to testdata/completion/ leaves completion/ holding
only what we ship, so the glob can go back to completion/**/* and needs
no maintenance when a shell is added.
This commit is contained in:
Valentin Maerten
2026-08-29 11:05:08 +02:00
parent b782d18a45
commit a9bf2c8020
8 changed files with 8 additions and 12 deletions

98
testdata/completion/run.sh vendored Executable file
View File

@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# Builds the task binary and a fixture Taskfile, then runs every installed shell
# wrapper against them. The engine itself is covered by the Go tests.
set -u
# fish, Nushell and PowerShell resolve the binary through these; an ambient value
# would silently test something other than the binary built below.
unset TASK_EXE GO_TASK_PROGNAME
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
root=$(cd "$here/../.." && pwd)
bindir=$(mktemp -d)
fixture=$(mktemp -d)
trap 'rm -rf "$bindir" "$fixture"' EXIT
if ! go build -o "$bindir/task" "$root/cmd/task"; then
echo "failed to build task binary" >&2
exit 1
fi
export TASK_BIN="$bindir/task"
# fish and PowerShell register completion for the command name `task`.
export PATH="$bindir:$PATH"
cat > "$fixture/Taskfile.yml" <<'YML'
version: '3'
tasks:
build:
desc: Build it
deploy:
desc: Deploy it
aliases: [dep]
requires:
vars:
- name: ENV
enum: [dev, prod]
- REGION
docs:serve:
desc: Serve docs
YML
touch "$fixture/extra.yaml" "$fixture/notes.txt"
mkdir -p "$fixture/sub" "$fixture/other"
# Nested path completion must keep the directory prefix.
touch "$fixture/sub/nested.yml"
# Shells must pass a quoted `--dir` value to the engine unquoted, and quote it
# back on insert.
mkdir -p "$fixture/with space"
cat > "$fixture/with space/Taskfile.yml" <<'YML'
version: '3'
tasks:
spaced:
desc: Task from the spaced dir
YML
export TASK_FIXTURE="$fixture"
# Strict mode (CI) turns a missing shell into a failure instead of a skip, so an
# absent pwsh never reads as a pass.
strict=${TASK_COMPLETION_STRICT:-}
fails=0
run() { # LABEL COMMAND...
echo "== $1 =="
"${@:2}" || fails=$((fails + 1))
echo
}
run_if() { # BIN LABEL COMMAND...
if command -v "$1" >/dev/null 2>&1; then run "${@:2}"; else skip "$2"; fi
}
skip() { # LABEL
if [[ -n "$strict" ]]; then
echo "== $1 == (MISSING — required under TASK_COMPLETION_STRICT)"
fails=$((fails + 1))
else
echo "== $1 == (skipped: not installed)"
fi
echo
}
run "bash wrapper" bash "$here/wrapper.bash"
run_if zsh "zsh wrapper" zsh "$here/wrapper.zsh"
run_if fish "fish wrapper" fish "$here/wrapper.fish"
# --no-config-file: the user's own external completer must not interfere.
run_if nu "nu wrapper" nu --no-config-file "$here/wrapper.nu"
pwsh_bin=$(command -v pwsh || command -v pwsh-preview || true)
if [[ -n "$pwsh_bin" ]]; then
run "powershell wrapper" "$pwsh_bin" -NoProfile -File "$here/wrapper.ps1"
else
skip "powershell wrapper"
fi
if ((fails)); then
echo "completion tests: $fails suite(s) failed"
exit 1
fi
echo "completion tests: all suites passed"

81
testdata/completion/wrapper.bash vendored Executable file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Smoke-tests how the bash wrapper routes each directive, by stubbing the
# bash-completion helpers. Requires TASK_BIN and TASK_FIXTURE.
set -u
: "${TASK_BIN:?}"; : "${TASK_FIXTURE:?}"
export TASK_EXE="$TASK_BIN"
cd "$TASK_FIXTURE" || exit 1
fails=0
CAP=""
_init_completion() {
words=("${TEST_WORDS[@]}")
cword=$TEST_CWORD
cur="${TEST_WORDS[$TEST_CWORD]}"
prev="${TEST_WORDS[$((TEST_CWORD - 1))]}"
return 0
}
# Records $cur so a test can assert the inline `--flag=` prefix was stripped.
_filedir() { CAP+="filedir:$* cur=$cur"$'\n'; }
compopt() { CAP+="compopt:$*"$'\n'; }
__ltrim_colon_completions() { :; }
source "$(dirname "${BASH_SOURCE[0]}")/../../completion/bash/task.bash"
run() {
CAP=""
TEST_WORDS=("$@")
TEST_CWORD=$((${#TEST_WORDS[@]} - 1))
COMPREPLY=()
_task
}
reply_has() { # LABEL VALUE
local v
for v in "${COMPREPLY[@]}"; do [[ "$v" == "$2" ]] && { echo " ok $1"; return; }; done
echo " FAIL $1 — '$2' missing from COMPREPLY: ${COMPREPLY[*]}"
fails=$((fails + 1))
}
cap_has() { # LABEL PATTERN
if [[ "$CAP" == *"$2"* ]]; then echo " ok $1"; else
echo " FAIL $1 — expected '$2' in: $CAP"; fails=$((fails + 1)); fi
}
cap_hasnot() { # LABEL PATTERN
if [[ "$CAP" == *"$2"* ]]; then
echo " FAIL $1 — '$2' should be absent in: $CAP"; fails=$((fails + 1)); else
echo " ok $1"; fi
}
echo "bash: :4 (NoFileComp) forwards candidates, no file fallback"
run task ''
reply_has "candidate forwarded" build
cap_hasnot "no file fallback" "filedir:"
echo "bash: :2|:32 (NoSpace|KeepOrder) disable the trailing space and the sort"
run task deploy ''
cap_has "nospace applied" "compopt:-o nospace"
cap_has "keeporder applied" "compopt:-o nosort"
echo "bash: :8 (FilterFileExt) routes to extension-filtered files"
run task --taskfile ''
cap_has "filedir ext glob" "filedir:@(yml|yaml)"
echo "bash: :16 (FilterDirs) routes to directory completion"
run task --dir ''
cap_has "filedir -d" "filedir:-d"
echo "bash: :0 (Default) falls back to files"
run task build -- ''
cap_has "filedir default" "filedir:"
echo "bash: inline --flag= strips the prefix before file completion"
run task --taskfile=sub/x
cap_has "inline cur stripped" "cur=sub/x"
if ((fails)); then
echo "bash: $fails failure(s)"
exit 1
fi
echo "bash: all passed"

55
testdata/completion/wrapper.fish vendored Executable file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env fish
# Smoke-tests how the fish wrapper routes each directive, via `complete -C`.
# Set up by run.sh: TASK_FIXTURE, and `task` on PATH = the binary under test.
cd $TASK_FIXTURE
source (dirname (status -f))/../../completion/fish/task.fish
set -g fails 0
function cands
complete -C $argv[1] | string split -f1 \t
end
function has # LABEL LINE VALUE
if contains -- $argv[3] (cands $argv[2])
echo " ok $argv[1]"
else
echo " FAIL $argv[1] — '$argv[3]' missing from: "(cands $argv[2])
set fails (math $fails + 1)
end
end
function hasnot # LABEL LINE VALUE
if contains -- $argv[3] (cands $argv[2])
echo " FAIL $argv[1] — '$argv[3]' should be absent"
set fails (math $fails + 1)
else
echo " ok $argv[1]"
end
end
echo "fish: :4 (NoFileComp) forwards candidates, offers no files"
has "candidate forwarded" 'task ' build
hasnot "no file fallback" 'task ' notes.txt
echo "fish: :16 (FilterDirs) offers directories only"
has "dir offered" 'task --dir ' sub/
hasnot "no plain file" 'task --dir ' notes.txt
echo "fish: :8 (FilterFileExt) filters by extension"
has "matching file" 'task --taskfile ' Taskfile.yml
hasnot "non-matching file" 'task --taskfile ' notes.txt
echo "fish: :0 (Default) falls back to files"
has "file offered" 'task build -- ' notes.txt
echo "fish: inline --flag=path keeps the --flag= prefix"
has "inline nested" 'task --taskfile=sub/' --taskfile=sub/nested.yml
hasnot "inline non-matching" 'task --taskfile=' --taskfile=notes.txt
if test $fails -ne 0
echo "fish: $fails failure(s)"
exit 1
end
echo "fish: all passed"

94
testdata/completion/wrapper.nu vendored Normal file
View File

@@ -0,0 +1,94 @@
#!/usr/bin/env nu
# Smoke-tests how the Nushell wrapper routes each directive. External completers
# only run in the interactive REPL, so the closure is called directly.
# Set up by run.sh: $env.TASK_FIXTURE, and `task` on PATH = the binary under test.
# `source` needs a parse-time constant path.
const TASK_NU = (path self "../../completion/nu/task-completions.nu")
# Installed before the wrapper is sourced, to assert the delegation path.
$env.config.completions.external.completer = {|spans| [{ value: $"prev:($spans | first)" }] }
source $TASK_NU
cd $env.TASK_FIXTURE
let completer = $env.config.completions.external.completer
def cands [spans: list<string>] {
let out = (do $completer $spans)
if $out == null { [] } else { $out | get value }
}
def has [label: string, spans: list<string>, value: string] {
let values = (cands $spans)
if $value in $values {
print $" ok ($label)"
0
} else {
print $" FAIL ($label) — '($value)' missing from: ($values | str join ' ')"
1
}
}
def hasnot [label: string, spans: list<string>, value: string] {
if $value in (cands $spans) {
print $" FAIL ($label) — '($value)' should be absent"
1
} else {
print $" ok ($label)"
0
}
}
def check [label: string, ok: bool] {
if $ok {
print $" ok ($label)"
0
} else {
print $" FAIL ($label)"
1
}
}
mut fails = 0
print "nu: :4 (NoFileComp) forwards candidates, offers no files"
$fails += (has "candidate forwarded" [task ""] "build")
$fails += (hasnot "no file fallback" [task ""] "notes.txt")
print "nu: filters candidates by the current word"
$fails += (has "prefix keeps match" [task b] "build")
$fails += (hasnot "prefix drops others" [task b] "deploy")
print "nu: :16 (FilterDirs) offers directories only"
$fails += (has "dir offered" [task --dir ""] $"sub(char path_sep)")
$fails += (hasnot "no plain file" [task --dir ""] "notes.txt")
print "nu: :8 (FilterFileExt) filters by extension"
$fails += (has "matching file" [task --taskfile ""] "Taskfile.yml")
$fails += (hasnot "non-matching file" [task --taskfile ""] "notes.txt")
print "nu: nested path completion keeps the directory prefix"
$fails += (has "prefix kept" [task --taskfile $"sub(char path_sep)"] $"sub(char path_sep)nested.yml")
print "nu: inline --flag=path keeps the --flag= prefix"
$fails += (has "inline nested" [task $"--taskfile=sub(char path_sep)"] $"--taskfile=sub(char path_sep)nested.yml")
$fails += (hasnot "inline non-matching" [task "--taskfile="] "--taskfile=notes.txt")
print "nu: :2|:32 (NoSpace|KeepOrder) keep the order the engine emitted"
let vars = (cands [task deploy ""])
$fails += (has "required var offered" [task deploy ""] "ENV=dev")
$fails += (check "declaration order kept" (($vars | enumerate | where item == "ENV=dev" | get 0.index) < ($vars | enumerate | where item == "REGION=" | get 0.index)))
print "nu: :0 (Default) returns null so Nushell completes files itself"
$fails += (check "null returned" ((do $completer [task build "--" ""]) == null))
print "nu: other commands go to the previously installed completer"
$fails += (has "delegated" [git status ""] "prev:git")
if $fails != 0 {
print $"nu: ($fails) failure\(s\)"
exit 1
}
print "nu: all passed"

67
testdata/completion/wrapper.ps1 vendored Normal file
View File

@@ -0,0 +1,67 @@
# Smoke-tests how the PowerShell wrapper routes each directive, via the
# completion API. Set up by run.sh: $env:TASK_FIXTURE, and `task` on PATH =
# the binary under test.
Set-Location $env:TASK_FIXTURE
. "$PSScriptRoot/../../completion/ps/task.ps1"
$fails = 0
function Cands($line) {
([System.Management.Automation.CommandCompletion]::CompleteInput($line, $line.Length, $null)).CompletionMatches |
ForEach-Object { $_.CompletionText }
}
function Has($label, $line, $value) {
if ((Cands $line) -contains $value) {
Write-Output " ok $label"
} else {
Write-Output " FAIL $label — '$value' missing from: $((Cands $line) -join ' ')"
$script:fails++
}
}
function HasNot($label, $line, $value) {
if ((Cands $line) -contains $value) {
Write-Output " FAIL $label — '$value' should be absent"
$script:fails++
} else {
Write-Output " ok $label"
}
}
Write-Output "powershell: :4 (NoFileComp) forwards candidates, offers no files"
Has "candidate forwarded" 'task ' 'build'
HasNot "no file fallback" 'task ' 'notes.txt'
Write-Output "powershell: filters candidates by the current word"
Has "prefix keeps match" 'task b' 'build'
HasNot "prefix drops others" 'task b' 'deploy'
Write-Output "powershell: :16 (FilterDirs) offers directories only"
Has "dir offered" 'task --dir ' 'sub'
HasNot "no plain file" 'task --dir ' 'notes.txt'
Write-Output "powershell: :8 (FilterFileExt) filters by extension"
Has "matching file" 'task --taskfile ' 'Taskfile.yml'
HasNot "non-matching file" 'task --taskfile ' 'notes.txt'
Write-Output "powershell: nested path completion keeps the directory prefix"
Has "prefix kept" 'task --taskfile sub/' 'sub/nested.yml'
Write-Output "powershell: inline --flag=path keeps the --flag= prefix"
Has "inline nested" 'task --taskfile=sub/' '--taskfile=sub/nested.yml'
HasNot "inline non-matching" 'task --taskfile=' '--taskfile=notes.txt'
Write-Output "powershell: a quoted argument reaches the engine unquoted"
Has "single-quoted dir" "task --dir 'with space' " 'spaced'
Has "double-quoted dir" 'task --dir "with space" ' 'spaced'
Write-Output "powershell: a candidate holding a space is quoted for insertion"
Has "dir quoted" 'task --dir w' "'with space'"
if ($fails -ne 0) {
Write-Output "powershell: $fails failure(s)"
exit 1
}
Write-Output "powershell: all passed"

88
testdata/completion/wrapper.zsh vendored Executable file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env zsh
# Smoke-tests how the zsh wrapper routes each directive, by stubbing _describe,
# _files and _path_files. Requires TASK_BIN and TASK_FIXTURE.
export TASK_EXE=$TASK_BIN
cd $TASK_FIXTURE
integer fails=0
local CAP
compdef() { } # no-op: we call _task directly, not through compinit
# Mirrors the real signature — `_describe [-12JVoOx] [-t tag] descr array
# [compadd-opt ...]` — so an option landing in the wrong zone is visible: the
# trailing zone goes to compadd, where -J and -V swallow the next argument.
_describe() {
local -a flags
while [[ $1 == -* ]]; do
case $1 in
(-t) flags+=($1 $2); shift 2 ;;
(*) flags+=($1); shift ;;
esac
done
local arr=$2 # $1 is descr
CAP+="describe_flags:[${flags[*]}]"$'\n'
CAP+="compadd_opts:[${@[3,-1]}]"$'\n'
local c; for c in ${(P)arr}; do CAP+="cand:$c"$'\n'; done
}
_files() { CAP+="files:$*"$'\n' }
_path_files() { CAP+="path_files:$*"$'\n' }
# Sourcing avoids the autoload first-call quirk; `compdef` is stubbed above.
source ${0:A:h}/../../completion/zsh/_task
run() {
CAP=""
local -a words=("$@")
integer CURRENT=$#words
local curcontext=":completion:complete:task:"
_task
}
has() { # LABEL PATTERN
if [[ "$CAP" == *"$2"* ]]; then
echo " ok $1"
else
echo " FAIL $1 — expected '$2' in:"$'\n'"$CAP"
(( fails++ ))
fi
}
hasnot() { # LABEL PATTERN
if [[ "$CAP" == *"$2"* ]]; then
echo " FAIL $1 — '$2' should be absent in:"$'\n'"$CAP"
(( fails++ ))
else
echo " ok $1"
fi
}
echo "zsh: :4 (NoFileComp) forwards candidates, no file fallback"
run task ''
has "candidate forwarded" "cand:build"
hasnot "no file fallback" "files:"
# In the compadd zone, -V would take the next argument as a group name and
# swallow _describe's own `-d`, offering its internal variables as candidates.
echo "zsh: :2|:32 (NoSpace|KeepOrder) reach the right option zones"
run task deploy ''
has "KeepOrder -> _describe -V" "describe_flags:[-V"
has "NoSpace -> compadd -S" "compadd_opts:[-S ]"
echo "zsh: :8 (FilterFileExt) routes to extension-filtered files"
run task --taskfile ''
has "files glob" "files:"
has "yml in glob" "yml"
echo "zsh: :16 (FilterDirs) routes to directory completion"
run task --dir ''
has "path_files -/" "path_files:-/"
echo "zsh: :0 (Default) falls back to files"
run task build -- ''
has "files default" "files:"
if (( fails )); then
echo "zsh: $fails failure(s)"
exit 1
fi
echo "zsh: all passed"