runner_test.go 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. package runner
  2. import (
  3. "os"
  4. "os/exec"
  5. "path/filepath"
  6. "runtime"
  7. "strings"
  8. "syscall"
  9. "testing"
  10. "time"
  11. "github.com/kotoyuuko/cc-switch-cli/internal/config"
  12. )
  13. // makeExec writes an executable script at dir/name and returns its path.
  14. func makeExec(t *testing.T, dir, name, body string) string {
  15. t.Helper()
  16. p := filepath.Join(dir, name)
  17. if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
  18. t.Fatal(err)
  19. }
  20. return p
  21. }
  22. func TestResolveClaudePath_FromConfig(t *testing.T) {
  23. dir := t.TempDir()
  24. p := makeExec(t, dir, "claude", "#!/bin/sh\nexit 0\n")
  25. got, err := ResolveClaudePath(config.Config{ClaudePath: p})
  26. if err != nil {
  27. t.Fatalf("unexpected: %v", err)
  28. }
  29. if got != p {
  30. t.Errorf("got %q want %q", got, p)
  31. }
  32. }
  33. func TestResolveClaudePath_FromPATH(t *testing.T) {
  34. dir := t.TempDir()
  35. _ = makeExec(t, dir, "claude", "#!/bin/sh\nexit 0\n")
  36. t.Setenv("PATH", dir)
  37. got, err := ResolveClaudePath(config.Config{})
  38. if err != nil {
  39. t.Fatalf("unexpected: %v", err)
  40. }
  41. // LookPath may return a symlink-resolved form; just check it matches.
  42. if !strings.HasSuffix(got, "/claude") {
  43. t.Errorf("unexpected path: %q", got)
  44. }
  45. }
  46. func TestResolveClaudePath_NotFound(t *testing.T) {
  47. t.Setenv("PATH", t.TempDir()) // empty dir on PATH
  48. _, err := ResolveClaudePath(config.Config{})
  49. if err == nil {
  50. t.Fatal("expected error")
  51. }
  52. if !strings.Contains(err.Error(), "config set claude-path") {
  53. t.Errorf("missing hint: %v", err)
  54. }
  55. }
  56. func TestResolveClaudePath_NonExecutable(t *testing.T) {
  57. dir := t.TempDir()
  58. p := filepath.Join(dir, "claude")
  59. if err := os.WriteFile(p, []byte(""), 0o644); err != nil {
  60. t.Fatal(err)
  61. }
  62. _, err := ResolveClaudePath(config.Config{ClaudePath: p})
  63. if err == nil {
  64. t.Fatal("expected non-executable error")
  65. }
  66. }
  67. func TestRun_EnvInjected(t *testing.T) {
  68. dir := t.TempDir()
  69. out := filepath.Join(dir, "out")
  70. // Capture FOO's value to a file and exit 0.
  71. script := makeExec(t, dir, "test", "#!/bin/sh\nprintf '%s' \"$FOO\" > "+out+"\nexit 0\n")
  72. code, err := Run(script, []string{"FOO=bar", "PATH=/usr/bin"}, nil)
  73. if err != nil {
  74. t.Fatalf("Run: %v", err)
  75. }
  76. if code != 0 {
  77. t.Errorf("exit code = %d", code)
  78. }
  79. b, err := os.ReadFile(out)
  80. if err != nil {
  81. t.Fatal(err)
  82. }
  83. if string(b) != "bar" {
  84. t.Errorf("FOO not injected: %q", string(b))
  85. }
  86. }
  87. func TestRun_ExitCodePassthrough(t *testing.T) {
  88. dir := t.TempDir()
  89. script := makeExec(t, dir, "test", "#!/bin/sh\nexit 42\n")
  90. code, err := Run(script, nil, nil)
  91. if err != nil {
  92. t.Fatalf("Run: %v", err)
  93. }
  94. if code != 42 {
  95. t.Errorf("exit code = %d, want 42", code)
  96. }
  97. }
  98. func TestRun_SignalForwarding(t *testing.T) {
  99. if runtime.GOOS == "windows" {
  100. t.Skip("signal forwarding is Unix-only")
  101. }
  102. dir := t.TempDir()
  103. // Child traps INT, exits with 130 (128+2).
  104. script := makeExec(t, dir, "trapper", "#!/bin/sh\ntrap 'exit 130' INT\nsleep 5\n")
  105. // Use a goroutine to launch Run, then send SIGINT to our own pid (Run
  106. // installs the handler which forwards it to the child).
  107. type result struct {
  108. code int
  109. err error
  110. }
  111. done := make(chan result, 1)
  112. go func() {
  113. c, e := Run(script, nil, nil)
  114. done <- result{c, e}
  115. }()
  116. // Give the child a moment to install its trap.
  117. time.Sleep(200 * time.Millisecond)
  118. if err := syscall.Kill(os.Getpid(), syscall.SIGINT); err != nil {
  119. t.Fatal(err)
  120. }
  121. select {
  122. case r := <-done:
  123. if r.err != nil {
  124. t.Fatalf("Run: %v", r.err)
  125. }
  126. if r.code != 130 {
  127. t.Errorf("exit code = %d, want 130", r.code)
  128. }
  129. case <-time.After(3 * time.Second):
  130. t.Fatal("timed out waiting for child to exit")
  131. }
  132. }
  133. func TestRun_SignalKill_128Plus(t *testing.T) {
  134. if runtime.GOOS == "windows" {
  135. t.Skip("Unix-only")
  136. }
  137. // Child that ignores nothing and just sleeps; we kill it with SIGKILL
  138. // directly to avoid the signal-forwarding path (which would route via the
  139. // current process first).
  140. dir := t.TempDir()
  141. script := makeExec(t, dir, "sleeper", "#!/bin/sh\nsleep 3\n")
  142. cmd := exec.Command(script)
  143. if err := cmd.Start(); err != nil {
  144. t.Fatal(err)
  145. }
  146. go func() {
  147. time.Sleep(100 * time.Millisecond)
  148. _ = cmd.Process.Signal(syscall.SIGKILL)
  149. }()
  150. code, _ := asExitError(cmd.Wait())
  151. if code != 128+int(syscall.SIGKILL) {
  152. t.Errorf("exit code = %d, want %d", code, 128+int(syscall.SIGKILL))
  153. }
  154. }