Validate shell completion across supported shells
This commit is contained in:
93
completion_shell_test.go
Normal file
93
completion_shell_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
internal/cobra/testdata/completion_terminal.py
vendored
14
internal/cobra/testdata/completion_terminal.py
vendored
@@ -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
50
testdata/completion/main.go
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user