Validate shell completion across supported shells

This commit is contained in:
2026-11-03 04:16:39 +00:00
parent 96250d1887
commit 0450ecfc16
6 changed files with 207 additions and 29 deletions

93
completion_shell_test.go Normal file
View File

@@ -0,0 +1,93 @@
package commandkit
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
)
func TestCompletionConsumerInShells(t *testing.T) {
if os.Getenv("COMMANDKIT_COMPLETION_SHELLS") != "1" {
t.Skip("set COMMANDKIT_COMPLETION_SHELLS=1 to run shell completion checks")
}
dir := t.TempDir()
app := filepath.Join(dir, "probe")
build := exec.Command("go", "build", "-o", app, "./testdata/completion")
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build completion fixture: %v\n%s", err, output)
}
if err := os.Mkdir(filepath.Join(dir, "partial dir"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "partial file"), nil, 0o644); err != nil {
t.Fatal(err)
}
tests := []struct {
name, line string
want []string
directory bool
}{
{"canonical command", "probe ru", []string{"run"}, false},
{"entered alias", "probe shortcut --format=ya", []string{"shortcut", "--format=yaml"}, false},
{"choice", "probe run --format=te", []string{"run", "--format=text"}, false},
{"no file fallback", `probe run --format=./partial\ f`, []string{"run", "--format=./partial f"}, false},
{"separated file", `probe run --config ./partial\ f`, []string{"run", "--config", "./partial file"}, false},
{"attached file", `probe run --config=./partial\ f`, []string{"run", "--config=./partial file"}, false},
{"short attached file", `probe run -c./partial\ f`, []string{"run", "-c./partial file"}, false},
{"short cluster equals file", `probe run -sc=./partial\ f`, []string{"run", "-sc=./partial file"}, false},
{"separated directory", "probe run --directory ./partial", []string{"run", "--directory", "./partial dir"}, true},
{"attached directory", "probe run --directory=./partial", []string{"run", "--directory=./partial dir"}, true},
{"empty operand", "probe run ", []string{"run", "alpha"}, false},
{"leading dash", "probe run --for", []string{"run", "--format"}, false},
}
for _, shell := range []string{"bash", "zsh", "fish"} {
generator := exec.Command(app, "completion", shell)
script, err := generator.CombinedOutput()
if err != nil {
t.Fatalf("generate %s script: %v\n%s", shell, err, script)
}
scriptPath := filepath.Join(dir, shell+".completion")
if err := os.WriteFile(scriptPath, script, 0o600); err != nil {
t.Fatal(err)
}
for _, tc := range tests {
t.Run(shell+"/"+tc.name, func(t *testing.T) {
log := filepath.Join(dir, "log")
if err := os.WriteFile(log, nil, 0o600); err != nil {
t.Fatal(err)
}
terminal := exec.Command("python3", "internal/cobra/testdata/completion_terminal.py", shell, scriptPath, tc.line)
terminal.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "HOME="+dir, "TERM=xterm", "PROBE_LOG="+log, "PROBE_DIR="+dir)
if output, err := terminal.CombinedOutput(); err != nil {
t.Fatalf("shell completion: %v\n%s", err, output)
}
content, err := os.ReadFile(log)
if err != nil {
t.Fatal(err)
}
var got []string
if err := json.Unmarshal(content, &got); err != nil {
t.Fatalf("fixture output %q: %v", content, err)
}
if tc.directory && len(got) > 0 {
got[len(got)-1] = strings.TrimSuffix(got[len(got)-1], "/")
}
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("edited arguments=%q, want %q", got, tc.want)
}
})
}
t.Run(shell+"/descriptions", func(t *testing.T) {
terminal := exec.Command("python3", "internal/cobra/testdata/completion_terminal.py", shell, scriptPath, "probe run --format=")
terminal.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "HOME="+dir, "TERM=xterm", "PROBE_LOG="+filepath.Join(dir, "log"), "PROBE_DIR="+dir, "PROBE_LIST=1")
output, err := terminal.CombinedOutput()
if err != nil || !strings.Contains(string(output), "Readable text") || !strings.Contains(string(output), "YAML output") {
t.Fatalf("completion descriptions: %v\n%s", err, output)
}
})
}
}

View File

@@ -1,7 +1,7 @@
# Shell completion
Status: the selected initial scope is implemented; final sprint validation
remains. Canonical suggestions and alias-aware interpretation are available for
Status: the selected initial scope is implemented and validated. Canonical
suggestions and alias-aware interpretation are available for
Bash, Zsh, and Fish through explicitly installed completion support.
## Purpose

View File

@@ -1,6 +1,6 @@
# Shell completion implementation plan
Status: Stages 1–7 complete. Stage 8 is next.
Status: Stages 1–8 complete.
Implement the [shell completion roadmap](completion.md) from baseline
`2301872`. Follow the stages in order in one sprint. Intermediate stages may
@@ -305,6 +305,8 @@ checks pass.
## Stage 8: Validate the assembled sprint
Status: complete.
1. Exercise the generated scripts in Bash, Zsh, and Fish using a built fixture
executable, a disposable directory with filenames and directories containing
spaces, and isolated shell configuration. Verify actual completion output
@@ -323,3 +325,21 @@ checks pass.
dependency, and no changes to ordinary routing, flag acceptance, or report
behavior outside the selected completion scope. Review documentation links,
formatting, and the final diff. Record the actual validation results.
Completion: the opt-in `TestCompletionConsumerInShells` builds a CommandKit
fixture, generates its scripts, and verifies edited invocations and displayed
descriptions in interactive Bash 5.3.9, Zsh 5.9, and Fish 4.2.1. The fixture
uses isolated shell configuration and paths containing spaces. Run it with
Python pexpect, Bash completion, and the three shells available using
`COMMANDKIT_COMPLETION_SHELLS=1 GOTOOLCHAIN=local go test -count=1 -run
'^TestCompletionConsumerInShells$' .`. Zsh and Fish were extracted to a
disposable directory for this run; their executable, library, Zsh module, and
function paths were supplied through `PATH`, `LD_LIBRARY_PATH`,
`COMPLETION_ZSH_MODULE_PATH`, and `COMPLETION_ZSH_FUNCTION_ROOT`, respectively.
The adapter's interactive
transport matrix and additional short-equals and Unicode-basename checks also
pass in all three shells. The root and both optional module suites pass in the
local workspace, as does the root suite with `GOWORK=off`. Public GoDoc and
dependency checks show no exposed backend types or reverse optional-module
dependency. Existing execution suites, changed documentation links, formatting,
and diff whitespace checks pass. The local workspace file is ignored.

View File

@@ -67,8 +67,10 @@ sys.stdout.write(os.environ['PROBE_REPLY'])
if err := os.WriteFile(filepath.Join(dir, "probe"), []byte(fixture), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "probe.app"), []byte(fixture), 0o755); err != nil {
t.Fatal(err)
for _, executable := range []string{"probe.app", "café"} {
if err := os.WriteFile(filepath.Join(dir, executable), []byte(fixture), 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.Mkdir(filepath.Join(dir, "partial dir"), 0o755); err != nil {
t.Fatal(err)
@@ -110,6 +112,7 @@ sys.stdout.write(os.environ['PROBE_REPLY'])
{"short cluster directory", "probe -sc./partial", pathReply(commandmeta.CompletionDirectories, "-sc"), []string{"__complete", "-sc./partial"}, []string{"-sc./partial dir"}},
{"short path with equals", "probe -cfoo=ba", pathReply(commandmeta.CompletionFiles, "-c"), []string{"__complete", "-cfoo=ba"}, []string{"-cfoo=bar"}},
{"short attached choice", "probe -cbl", choiceReply("-c", "blue"), []string{"__complete", "-cbl"}, []string{"-cblue"}},
{"short equals choice", "probe -c=bl", choiceReply("-c=", "blue"), []string{"__complete", "-c=bl"}, []string{"-c=blue"}},
{"short choice with equals", "probe -cfoo=ba", choiceReply("-c", "foo=bar"), []string{"__complete", "-cfoo=ba"}, []string{"-cfoo=bar"}},
{"short no suggestions", "probe -cnomatch", pathReply(commandmeta.CompletionChoices, "-c"), []string{"__complete", "-cnomatch"}, []string{"-cnomatch"}},
{"literal prefix marker", "probe __commandkit_path_pre", choiceReply("", completionPathPrefixMarker+"-c"), []string{"__complete", "__commandkit_path_pre"}, []string{completionPathPrefixMarker + "-c"}},
@@ -158,28 +161,30 @@ sys.stdout.write(os.environ['PROBE_REPLY'])
}
})
}
t.Run(shell+"/dotted executable", func(t *testing.T) {
var generated bytes.Buffer
if err := CompletionScript(&generated, "probe.app", shell); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(script, generated.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
log := filepath.Join(dir, "log")
if err := os.WriteFile(log, nil, 0o600); err != nil {
t.Fatal(err)
}
cmd := exec.Command("python3", "testdata/completion_terminal.py", shell, script, "probe.app --config=./par")
cmd.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "HOME="+dir, "TERM=xterm", "PROBE_LOG="+log, "PROBE_REPLY="+reply("./partial file\tdescription\n", spfcobra.ShellCompDirectiveNoFileComp), "PROBE_DIR="+dir)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("shell completion: %v\n%s", err, out)
}
contents, err := os.ReadFile(log)
if err != nil || !strings.Contains(string(contents), `["__complete", "--config=./par"]`) || !strings.Contains(string(contents), `["--config=./partial file"]`) {
t.Fatalf("query and edited line: %q; read error: %v", contents, err)
}
})
for _, executable := range []string{"probe.app", "café"} {
t.Run(shell+"/executable "+executable, func(t *testing.T) {
var generated bytes.Buffer
if err := CompletionScript(&generated, executable, shell); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(script, generated.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
log := filepath.Join(dir, "log")
if err := os.WriteFile(log, nil, 0o600); err != nil {
t.Fatal(err)
}
cmd := exec.Command("python3", "testdata/completion_terminal.py", shell, script, executable+" --config=./par")
cmd.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "HOME="+dir, "TERM=xterm", "PROBE_LOG="+log, "PROBE_REPLY="+reply("./partial file\tdescription\n", spfcobra.ShellCompDirectiveNoFileComp), "PROBE_DIR="+dir)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("shell completion: %v\n%s", err, out)
}
contents, err := os.ReadFile(log)
if err != nil || !strings.Contains(string(contents), `["__complete", "--config=./par"]`) || !strings.Contains(string(contents), `["--config=./partial file"]`) {
t.Fatalf("query and edited line: %q; read error: %v", contents, err)
}
})
}
}
}

View File

@@ -39,7 +39,17 @@ try:
child.expect_exact("PROMPT> ")
child.send(line)
child.send("\t")
child.sendline("")
child.expect_exact("PROMPT> ")
if os.environ.get("PROBE_LIST") == "1":
child.send("\t")
remaining = ["Readable text", "YAML output"]
captured = []
while remaining:
index = child.expect_exact(remaining)
captured.append(child.before + child.after)
remaining.pop(index)
print("".join(captured))
else:
child.sendline("")
child.expect_exact("PROMPT> ")
finally:
child.close(force=True)

50
testdata/completion/main.go vendored Normal file
View File

@@ -0,0 +1,50 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
commandkit "gitea.maximumdirect.net/eric/CommandKit"
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
)
func main() {
args := os.Args[1:]
if logPath := os.Getenv("PROBE_LOG"); logPath != "" && (len(args) == 0 || args[0] != internalcobra.CompletionQueryToken) {
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
_ = json.NewEncoder(file).Encode(args)
_ = file.Close()
}
parse := func(value string) (string, error) { return value, nil }
flag := func(meta commandkit.FlagMeta) commandkit.Flag[struct{}] {
return commandkit.ValueFlag(meta, (*string)(nil), parse, func(*struct{}, commandkit.Supplied[string]) {})
}
run := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
Name: "run", Summary: "Run a fixture command.",
Flags: []commandkit.Flag[struct{}]{
commandkit.BoolFlag(commandkit.FlagMeta{Name: "switch", Short: "s"}, false, func(*struct{}, commandkit.Supplied[bool]) {}),
flag(commandkit.FlagMeta{Name: "format", Usage: "Report format.", Completion: commandkit.CompleteChoices(commandkit.CompletionChoice{Value: "text", Description: "Readable text"}, commandkit.CompletionChoice{Value: "yaml", Description: "YAML output"})}),
flag(commandkit.FlagMeta{Name: "config", Short: "c", Usage: "Configuration path.", Completion: commandkit.CompleteFiles()}),
flag(commandkit.FlagMeta{Name: "directory", Short: "d", Usage: "Directory path.", Completion: commandkit.CompleteDirectories()}),
},
Arguments: []commandkit.Argument[struct{}]{commandkit.ValueArgument(
commandkit.ArgumentMeta{Name: "item", Completion: commandkit.CompleteChoices(commandkit.CompletionChoice{Value: "alpha", Description: "Example item"})},
false, parse, func(*struct{}, commandkit.Supplied[string]) {})},
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() },
})
app, err := commandkit.New(commandkit.Identity{Name: "Completion fixture"}, commandkit.Completion("probe"), run, commandkit.Alias("shortcut", "run"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: os.Stdout, Err: os.Stderr})
if outcome.Status != 0 {
os.Exit(outcome.Status)
}
}