Files
ssh-netbox-wrapper/internal/hook/completion_test.go
T
Sebastian UnterschützandClaude Opus 5.5 8fb7633783 feat(completion): fast zsh completion for large host caches
Cobra's generated zsh script spawns a subshell per candidate and sends
every host through _describe, so <Tab> hangs for seconds with thousands
of cached hosts. netssh now ships its own script: subcommands and flags
go through _describe, plain hostnames through compadd. 5000 hosts
complete in ~0.08 s instead of ~4 s.

Add completion.max_results to cap the number of offered hostnames and
sort host candidates for a stable order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:22:18 +01:00

208 lines
6.0 KiB
Go

package hook_test
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
"github.com/spf13/cobra"
)
func completionEnvironment(t *testing.T) map[string]string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache"))
if err := os.MkdirAll(hook.FishConfigDir(), 0o755); err != nil {
t.Fatal(err)
}
return map[string]string{
"bash": filepath.Join(home, ".local", "share", "bash-completion", "completions", "j"),
"zsh": filepath.Join(home, ".zfunc", "_j"),
"fish": filepath.Join(hook.FishConfigDir(), "completions", "j.fish"),
}
}
func TestCommandCompletionTracksAliasChanges(t *testing.T) {
paths := completionEnvironment(t)
if err := hook.WriteAliasFiles([]hook.AliasEntry{{Name: "j"}}); err != nil {
t.Fatal(err)
}
for _, path := range paths {
if _, err := os.Stat(path); err != nil {
t.Fatal(err)
}
}
// A manually replaced completion file must survive removal of the old alias.
custom := "# user-owned completion\n"
if err := os.WriteFile(paths["bash"], []byte(custom), 0o644); err != nil {
t.Fatal(err)
}
if err := hook.WriteAliasFiles([]hook.AliasEntry{{Name: "k"}}); err != nil {
t.Fatal(err)
}
for shell, path := range paths {
if shell == "bash" {
data, err := os.ReadFile(path)
if err != nil || string(data) != custom {
t.Fatal("user completion was removed")
}
} else if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("stale %s completion remains: %v", shell, err)
}
}
if err := hook.WriteAliasFiles(nil); err != nil {
t.Fatal(err)
}
for _, path := range []string{
filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", "k"),
filepath.Join(os.Getenv("HOME"), ".zfunc", "_k"),
filepath.Join(hook.FishConfigDir(), "completions", "k.fish"),
} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("disabled completion remains: %s: %v", path, err)
}
}
}
func TestCommandAliasCompletesServerNamesAcrossShells(t *testing.T) {
for _, shell := range []string{"bash", "zsh", "fish"} {
t.Run(shell, func(t *testing.T) {
executable, err := exec.LookPath(shell)
if err != nil {
t.Skipf("%s unavailable", shell)
}
paths := completionEnvironment(t)
if err := hook.SyncCommandCompletion("j"); err != nil {
t.Fatal(err)
}
fixtures := t.TempDir()
root := &cobra.Command{Use: "netssh"}
var canonical bytes.Buffer
switch shell {
case "bash":
err = root.GenBashCompletionV2(&canonical, true)
case "zsh":
_, err = canonical.WriteString(hook.ZshCompletionScript)
case "fish":
err = root.GenFishCompletion(&canonical, true)
}
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(fixtures, shell), canonical.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
// Exercise Cobra's actual generated shell code with a deterministic backend.
backend := `#!/bin/bash
case "$1" in
completion) cat "$COMPLETION_FIXTURES/$2" ;;
__complete|__completeNoDesc) printf 'server01\n:4\n' ;;
*) exit 1 ;;
esac
`
if err := os.WriteFile(filepath.Join(fixtures, "netssh"), []byte(backend), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("COMPLETION_FIXTURES", fixtures)
t.Setenv("ALIAS_COMPLETION", paths[shell])
t.Setenv("PATH", fixtures+string(os.PathListSeparator)+os.Getenv("PATH"))
var script string
var args []string
switch shell {
case "bash":
library := ""
for _, path := range []string{"/usr/share/bash-completion/bash_completion", "/etc/bash_completion"} {
if _, err := os.Stat(path); err == nil {
library = path
break
}
}
if library == "" {
t.Skip("bash-completion unavailable")
}
t.Setenv("BASH_COMPLETION_LIBRARY", library)
args = []string{"--noprofile", "--norc", "-c"}
script = `source "$BASH_COMPLETION_LIBRARY"
shopt -s expand_aliases
alias j=netssh
_completion_loader j
COMP_WORDS=(j ser)
COMP_CWORD=1
COMP_LINE='j ser'
COMP_POINT=5
COMP_TYPE=9
COMP_KEY=9
__start_netssh j ser j
printf '%s\n' "${COMPREPLY[@]}"
`
case "zsh":
args = []string{"-f", "-c"}
script = `fpath=("$HOME/.zfunc" $fpath)
autoload -Uz compinit
compinit -u -D
alias j=netssh
autoload -Uz _j
# Capture matches without requiring an interactive ZLE completion widget.
_describe() { print -rl -- "${(P)4}"; }
compadd() { print -rl -- "$@"; }
words=(j ser)
CURRENT=2
_j
`
case "fish":
args = []string{"--no-config", "-c"}
script = `alias j netssh
set -p fish_complete_path "$XDG_CONFIG_HOME/fish/completions"
complete --do-complete 'j ser'
`
}
cmd := exec.Command(executable, append(args, script)...)
out, err := cmd.CombinedOutput()
if err != nil || !strings.Contains(string(out), "server01") {
t.Fatalf("%s completion: %v\n%s", shell, err, out)
}
})
}
}
func TestZshCompletionSplitsHostsAndCommands(t *testing.T) {
zsh, err := exec.LookPath("zsh")
if err != nil {
t.Skip("zsh unavailable")
}
dir := t.TempDir()
backend := "#!/bin/sh\nprintf 'web01\\nweb:02\\ncache\\tManage the cache\\n:36\\n'\n"
if err := os.WriteFile(filepath.Join(dir, "netssh"), []byte(backend), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "_netssh"), []byte(hook.ZshCompletionScript), 0o644); err != nil {
t.Fatal(err)
}
script := `compdef() { :; }
source "$DIR/_netssh"
_describe() { print -r -- "describe ${(P)4}"; }
compadd() { print -r -- "compadd $*"; }
_files() { print -r -- files; }
words=(netssh "")
CURRENT=2
_netssh
`
cmd := exec.Command(zsh, "-f", "-c", script)
cmd.Env = append(os.Environ(), "DIR="+dir, "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"))
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%v\n%s", err, out)
}
// Directive 36 = NoFileComp|KeepOrder: unsorted host group, no file fallback.
want := "describe cache:Manage the cache\ncompadd -V hosts -X %Bhosts%b -- web01 web:02\n"
if string(out) != want {
t.Fatalf("got:\n%s\nwant:\n%s", out, want)
}
}