package runner import ( "os" "os/exec" "path/filepath" "runtime" "strings" "syscall" "testing" "time" "github.com/kotoyuuko/cc-switch-cli/internal/config" ) // makeExec writes an executable script at dir/name and returns its path. func makeExec(t *testing.T, dir, name, body string) string { t.Helper() p := filepath.Join(dir, name) if err := os.WriteFile(p, []byte(body), 0o755); err != nil { t.Fatal(err) } return p } func TestResolveClaudePath_FromConfig(t *testing.T) { dir := t.TempDir() p := makeExec(t, dir, "claude", "#!/bin/sh\nexit 0\n") got, err := ResolveClaudePath(config.Config{ClaudePath: p}) if err != nil { t.Fatalf("unexpected: %v", err) } if got != p { t.Errorf("got %q want %q", got, p) } } func TestResolveClaudePath_FromPATH(t *testing.T) { dir := t.TempDir() _ = makeExec(t, dir, "claude", "#!/bin/sh\nexit 0\n") t.Setenv("PATH", dir) got, err := ResolveClaudePath(config.Config{}) if err != nil { t.Fatalf("unexpected: %v", err) } // LookPath may return a symlink-resolved form; just check it matches. if !strings.HasSuffix(got, "/claude") { t.Errorf("unexpected path: %q", got) } } func TestResolveClaudePath_NotFound(t *testing.T) { t.Setenv("PATH", t.TempDir()) // empty dir on PATH _, err := ResolveClaudePath(config.Config{}) if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "config set claude-path") { t.Errorf("missing hint: %v", err) } } func TestResolveClaudePath_NonExecutable(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "claude") if err := os.WriteFile(p, []byte(""), 0o644); err != nil { t.Fatal(err) } _, err := ResolveClaudePath(config.Config{ClaudePath: p}) if err == nil { t.Fatal("expected non-executable error") } } func TestRun_EnvInjected(t *testing.T) { dir := t.TempDir() out := filepath.Join(dir, "out") // Capture FOO's value to a file and exit 0. script := makeExec(t, dir, "test", "#!/bin/sh\nprintf '%s' \"$FOO\" > "+out+"\nexit 0\n") code, err := Run(script, []string{"FOO=bar", "PATH=/usr/bin"}, nil) if err != nil { t.Fatalf("Run: %v", err) } if code != 0 { t.Errorf("exit code = %d", code) } b, err := os.ReadFile(out) if err != nil { t.Fatal(err) } if string(b) != "bar" { t.Errorf("FOO not injected: %q", string(b)) } } func TestRun_ExitCodePassthrough(t *testing.T) { dir := t.TempDir() script := makeExec(t, dir, "test", "#!/bin/sh\nexit 42\n") code, err := Run(script, nil, nil) if err != nil { t.Fatalf("Run: %v", err) } if code != 42 { t.Errorf("exit code = %d, want 42", code) } } func TestRun_SignalForwarding(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("signal forwarding is Unix-only") } dir := t.TempDir() // Child traps INT, exits with 130 (128+2). script := makeExec(t, dir, "trapper", "#!/bin/sh\ntrap 'exit 130' INT\nsleep 5\n") // Use a goroutine to launch Run, then send SIGINT to our own pid (Run // installs the handler which forwards it to the child). type result struct { code int err error } done := make(chan result, 1) go func() { c, e := Run(script, nil, nil) done <- result{c, e} }() // Give the child a moment to install its trap. time.Sleep(200 * time.Millisecond) if err := syscall.Kill(os.Getpid(), syscall.SIGINT); err != nil { t.Fatal(err) } select { case r := <-done: if r.err != nil { t.Fatalf("Run: %v", r.err) } if r.code != 130 { t.Errorf("exit code = %d, want 130", r.code) } case <-time.After(3 * time.Second): t.Fatal("timed out waiting for child to exit") } } func TestRun_SignalKill_128Plus(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Unix-only") } // Child that ignores nothing and just sleeps; we kill it with SIGKILL // directly to avoid the signal-forwarding path (which would route via the // current process first). dir := t.TempDir() script := makeExec(t, dir, "sleeper", "#!/bin/sh\nsleep 3\n") cmd := exec.Command(script) if err := cmd.Start(); err != nil { t.Fatal(err) } go func() { time.Sleep(100 * time.Millisecond) _ = cmd.Process.Signal(syscall.SIGKILL) }() code, _ := asExitError(cmd.Wait()) if code != 128+int(syscall.SIGKILL) { t.Errorf("exit code = %d, want %d", code, 128+int(syscall.SIGKILL)) } }