`netssh hook install` and the setup wizard now write the same marked `# >>> netssh >>>` block as install.sh and replace unmarked entries of older versions instead of appending duplicates. Uninstall shares the legacy patterns with the installer (eval/alias/source variants). For zsh the block adds ~/.zfunc to fpath and calls compdef, so completion also works when compinit already ran earlier in .zshrc, e.g. with oh-my-zsh. The installer reports which completion it set up per shell and how to reset ~/.zcompdump. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
358 lines
10 KiB
Go
358 lines
10 KiB
Go
package hook_test
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
|
)
|
|
|
|
// --- ProfilePath ---
|
|
|
|
func TestProfilePath_Bash(t *testing.T) {
|
|
orig := os.Getenv("HOME")
|
|
os.Setenv("HOME", t.TempDir())
|
|
defer os.Setenv("HOME", orig)
|
|
|
|
p, err := hook.ProfilePath("bash")
|
|
if err != nil {
|
|
t.Fatalf("ProfilePath(bash): %v", err)
|
|
}
|
|
if !strings.HasSuffix(p, ".bashrc") {
|
|
t.Errorf("bash profile should end with .bashrc, got %q", p)
|
|
}
|
|
}
|
|
|
|
func TestProfilePath_Zsh(t *testing.T) {
|
|
orig := os.Getenv("HOME")
|
|
os.Setenv("HOME", t.TempDir())
|
|
defer os.Setenv("HOME", orig)
|
|
|
|
p, err := hook.ProfilePath("zsh")
|
|
if err != nil {
|
|
t.Fatalf("ProfilePath(zsh): %v", err)
|
|
}
|
|
if !strings.HasSuffix(p, ".zshrc") {
|
|
t.Errorf("zsh profile should end with .zshrc, got %q", p)
|
|
}
|
|
}
|
|
|
|
func TestProfilePath_Fish(t *testing.T) {
|
|
dir := t.TempDir()
|
|
orig := os.Getenv("XDG_CONFIG_HOME")
|
|
os.Setenv("XDG_CONFIG_HOME", dir)
|
|
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
|
|
|
p, err := hook.ProfilePath("fish")
|
|
if err != nil {
|
|
t.Fatalf("ProfilePath(fish): %v", err)
|
|
}
|
|
if !strings.HasSuffix(p, "config.fish") {
|
|
t.Errorf("fish profile should end with config.fish, got %q", p)
|
|
}
|
|
}
|
|
|
|
func TestProfilePath_UnknownShell(t *testing.T) {
|
|
if _, err := hook.ProfilePath("ksh"); err == nil {
|
|
t.Error("unknown shell should return an error")
|
|
}
|
|
}
|
|
|
|
// --- IsInstalled ---
|
|
|
|
func TestIsInstalled_Present(t *testing.T) {
|
|
profile := writeProfile(t, hook.Line+"\n")
|
|
if !hook.IsInstalled(profile) {
|
|
t.Error("IsInstalled should return true when marker is present")
|
|
}
|
|
}
|
|
|
|
func TestIsInstalled_Absent(t *testing.T) {
|
|
profile := writeProfile(t, "export PATH=$PATH\n")
|
|
if hook.IsInstalled(profile) {
|
|
t.Error("IsInstalled should return false when marker is absent")
|
|
}
|
|
}
|
|
|
|
func TestIsInstalled_MissingFile(t *testing.T) {
|
|
if hook.IsInstalled("/nonexistent/path/.bashrc") {
|
|
t.Error("IsInstalled should return false for missing file")
|
|
}
|
|
}
|
|
|
|
// --- Install ---
|
|
|
|
func TestInstall_Fresh(t *testing.T) {
|
|
profile := filepath.Join(t.TempDir(), ".bashrc")
|
|
|
|
installed, err := hook.Install(profile, "bash")
|
|
if err != nil {
|
|
t.Fatalf("Install: %v", err)
|
|
}
|
|
if !installed {
|
|
t.Error("should report installed=true on fresh install")
|
|
}
|
|
if !hook.IsInstalled(profile) {
|
|
t.Error("profile should contain the hook line after Install")
|
|
}
|
|
}
|
|
|
|
func TestInstall_Idempotent(t *testing.T) {
|
|
profile := filepath.Join(t.TempDir(), ".bashrc")
|
|
|
|
hook.Install(profile, "bash")
|
|
installed, err := hook.Install(profile, "bash")
|
|
if err != nil {
|
|
t.Fatalf("second Install: %v", err)
|
|
}
|
|
if installed {
|
|
t.Error("second Install should report installed=false")
|
|
}
|
|
|
|
data, _ := os.ReadFile(profile)
|
|
if count := strings.Count(string(data), hook.Marker); count != 1 {
|
|
t.Errorf("hook line should appear exactly once, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestInstall_CreatesProfileAndParentDirs(t *testing.T) {
|
|
profile := filepath.Join(t.TempDir(), "nested", "dir", ".zshrc")
|
|
|
|
if _, err := hook.Install(profile, "bash"); err != nil {
|
|
t.Fatalf("Install should create missing directories: %v", err)
|
|
}
|
|
if _, err := os.Stat(profile); err != nil {
|
|
t.Error("profile file should have been created")
|
|
}
|
|
}
|
|
|
|
func TestInstall_PreservesExistingContent(t *testing.T) {
|
|
profile := writeProfile(t, "export PATH=$PATH:/usr/local/bin\n")
|
|
|
|
hook.Install(profile, "bash")
|
|
|
|
data, _ := os.ReadFile(profile)
|
|
content := string(data)
|
|
if !strings.Contains(content, "export PATH") {
|
|
t.Error("Install should not remove existing content")
|
|
}
|
|
if !strings.Contains(content, hook.Marker) {
|
|
t.Error("hook line should be appended")
|
|
}
|
|
}
|
|
|
|
func TestInstall_AppendedAtEnd(t *testing.T) {
|
|
profile := writeProfile(t, "existing line\n")
|
|
hook.Install(profile, "bash")
|
|
|
|
data, _ := os.ReadFile(profile)
|
|
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
|
last := lines[len(lines)-1]
|
|
if last != hook.BlockEnd {
|
|
t.Errorf("managed block should end the profile, got %q", last)
|
|
}
|
|
}
|
|
|
|
// --- Uninstall ---
|
|
|
|
func TestUninstall_Removes(t *testing.T) {
|
|
profile := writeProfile(t, "export PATH=$PATH\n"+hook.Line+"\nexport FOO=bar\n")
|
|
|
|
removed, err := hook.Uninstall(profile)
|
|
if err != nil {
|
|
t.Fatalf("Uninstall: %v", err)
|
|
}
|
|
if !removed {
|
|
t.Error("should report removed=true")
|
|
}
|
|
if hook.IsInstalled(profile) {
|
|
t.Error("hook line should have been removed")
|
|
}
|
|
}
|
|
|
|
func TestUninstall_PreservesOtherContent(t *testing.T) {
|
|
profile := writeProfile(t, "export FOO=bar\n"+hook.Line+"\nexport BAZ=qux\n")
|
|
|
|
hook.Uninstall(profile)
|
|
|
|
data, _ := os.ReadFile(profile)
|
|
content := string(data)
|
|
if !strings.Contains(content, "export FOO=bar") {
|
|
t.Error("content before hook should be preserved")
|
|
}
|
|
if !strings.Contains(content, "export BAZ=qux") {
|
|
t.Error("content after hook should be preserved")
|
|
}
|
|
}
|
|
|
|
func TestUninstall_NotPresent(t *testing.T) {
|
|
profile := writeProfile(t, "export PATH=$PATH\n")
|
|
|
|
removed, err := hook.Uninstall(profile)
|
|
if err != nil {
|
|
t.Fatalf("Uninstall: %v", err)
|
|
}
|
|
if removed {
|
|
t.Error("should report removed=false when hook was not present")
|
|
}
|
|
}
|
|
|
|
func TestUninstall_MissingFile(t *testing.T) {
|
|
removed, err := hook.Uninstall("/nonexistent/.bashrc")
|
|
if err != nil {
|
|
t.Errorf("Uninstall on missing file should not error: %v", err)
|
|
}
|
|
if removed {
|
|
t.Error("should report removed=false for missing file")
|
|
}
|
|
}
|
|
|
|
func TestUninstall_CollapsesExtraBlankLines(t *testing.T) {
|
|
// blank line before hook + hook + blank line after = three consecutive newlines after removal
|
|
profile := writeProfile(t, "line1\n\n"+hook.Line+"\n\nline2\n")
|
|
|
|
hook.Uninstall(profile)
|
|
|
|
data, _ := os.ReadFile(profile)
|
|
if strings.Contains(string(data), "\n\n\n") {
|
|
t.Error("three consecutive blank lines should be collapsed after uninstall")
|
|
}
|
|
}
|
|
|
|
// --- Roundtrip ---
|
|
|
|
func TestInstallUninstall_Roundtrip(t *testing.T) {
|
|
profile := writeProfile(t, "existing content\n")
|
|
|
|
hook.Install(profile, "bash")
|
|
if !hook.IsInstalled(profile) {
|
|
t.Fatal("should be installed after Install")
|
|
}
|
|
|
|
hook.Uninstall(profile)
|
|
if hook.IsInstalled(profile) {
|
|
t.Fatal("should not be installed after Uninstall")
|
|
}
|
|
|
|
data, _ := os.ReadFile(profile)
|
|
if !strings.Contains(string(data), "existing content") {
|
|
t.Error("original content should survive install+uninstall cycle")
|
|
}
|
|
}
|
|
|
|
// --- ReloadNote ---
|
|
|
|
func TestReloadNote_ContainsPath(t *testing.T) {
|
|
note := hook.ReloadNote("/home/user/.bashrc")
|
|
if !strings.Contains(note, "/home/user/.bashrc") {
|
|
t.Errorf("ReloadNote should contain the profile path, got %q", note)
|
|
}
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func writeProfile(t *testing.T, content string) string {
|
|
t.Helper()
|
|
f := filepath.Join(t.TempDir(), "profile")
|
|
if err := os.WriteFile(f, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return f
|
|
}
|
|
|
|
func TestManagedBlockInstallAndUninstall(t *testing.T) {
|
|
original := "export KEEP=yes\n# netssh shell-init documentation\necho 'netssh shell-init'\n"
|
|
profile := writeProfile(t, original+hook.BlockStart+"\nexport PATH='/custom/bin':\"$PATH\"\n'/custom/bin/netssh' shell-init\n[ ! -f '/custom/cache/netssh/aliases.sh' ] || . '/custom/cache/netssh/aliases.sh'\n"+hook.BlockEnd+"\n")
|
|
if installed, err := hook.Install(profile, "bash"); err != nil || installed {
|
|
t.Fatalf("duplicate install: %v, %v", installed, err)
|
|
}
|
|
if installed, err := hook.InstallAliasesSource(profile); err != nil || installed {
|
|
t.Fatalf("duplicate source: %v, %v", installed, err)
|
|
}
|
|
if removed, err := hook.Uninstall(profile); err != nil || !removed {
|
|
t.Fatalf("uninstall: %v, %v", removed, err)
|
|
}
|
|
data, _ := os.ReadFile(profile)
|
|
if string(data) != original {
|
|
t.Fatalf("unrelated content changed: %s", data)
|
|
}
|
|
}
|
|
|
|
func TestProfilePathRespectsZdotdir(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("ZDOTDIR", dir)
|
|
path, err := hook.ProfilePath("zsh")
|
|
if err != nil || path != filepath.Join(dir, ".zshrc") {
|
|
t.Fatalf("zsh path: %s, %v", path, err)
|
|
}
|
|
}
|
|
|
|
func TestInstallReplacesLegacyEntriesWithBlock(t *testing.T) {
|
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
|
profile := writeProfile(t, "export KEEP=yes\nnetssh shell-init\nsource ~/.cache/netssh/aliases.sh 2>/dev/null\neval \"$(netssh alias)\"\nalias j='netssh'\necho 'netssh shell-init'\n")
|
|
installed, err := hook.Install(profile, "zsh")
|
|
if err != nil || !installed {
|
|
t.Fatalf("install: %v, %v", installed, err)
|
|
}
|
|
data, _ := os.ReadFile(profile)
|
|
content := string(data)
|
|
for _, gone := range []string{"\nnetssh shell-init\nsource", "eval ", "alias j="} {
|
|
if strings.Contains(content, gone) {
|
|
t.Fatalf("legacy entry %q kept:\n%s", gone, content)
|
|
}
|
|
}
|
|
for _, kept := range []string{"export KEEP=yes", "echo 'netssh shell-init'", hook.BlockStart, "compdef _netssh netssh", "fpath=("} {
|
|
if !strings.Contains(content, kept) {
|
|
t.Fatalf("missing %q:\n%s", kept, content)
|
|
}
|
|
}
|
|
if strings.Count(content, hook.BlockStart) != 1 {
|
|
t.Fatalf("duplicate blocks:\n%s", content)
|
|
}
|
|
if removed, err := hook.Uninstall(profile); err != nil || !removed {
|
|
t.Fatalf("uninstall: %v, %v", removed, err)
|
|
}
|
|
data, _ = os.ReadFile(profile)
|
|
if string(data) != "export KEEP=yes\necho 'netssh shell-init'\n" {
|
|
t.Fatalf("unexpected remainder: %q", data)
|
|
}
|
|
}
|
|
|
|
func TestBlockPerShell(t *testing.T) {
|
|
if b := hook.Block("fish"); strings.Contains(b, "aliases.sh") || strings.Contains(b, "compdef") {
|
|
t.Fatalf("fish block: %s", b)
|
|
}
|
|
if b := hook.Block("bash"); !strings.Contains(b, "aliases.sh") || strings.Contains(b, "compdef") {
|
|
t.Fatalf("bash block: %s", b)
|
|
}
|
|
}
|
|
|
|
func TestZshBlockRegistersCompletionAfterCompinit(t *testing.T) {
|
|
zsh, err := exec.LookPath("zsh")
|
|
if err != nil {
|
|
t.Skip("zsh unavailable")
|
|
}
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("ZDOTDIR", home)
|
|
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache"))
|
|
if err := os.MkdirAll(hook.ZshFuncDir(), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(hook.ZshFuncDir(), "_netssh"), []byte(hook.ZshCompletionScript), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// compinit runs before the block, as with oh-my-zsh.
|
|
rc := "autoload -Uz compinit && compinit -u -d \"$HOME/.zcompdump\"\nnetssh() { :; }\n" + hook.Block("zsh")
|
|
if err := os.WriteFile(filepath.Join(home, ".zshrc"), []byte(rc), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := exec.Command(zsh, "-i", "-c", `print -r -- $_comps[netssh]`).CombinedOutput()
|
|
if err != nil || !strings.Contains(string(out), "_netssh") {
|
|
t.Fatalf("completion not registered: %v\n%s", err, out)
|
|
}
|
|
}
|