helpers_test.go 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. package e2e
  2. import (
  3. "context"
  4. "fmt"
  5. "net"
  6. "os"
  7. "os/exec"
  8. "path/filepath"
  9. "strconv"
  10. "strings"
  11. "syscall"
  12. "testing"
  13. "time"
  14. "github.com/cline/cli/pkg/cli/global"
  15. "github.com/cline/cli/pkg/common"
  16. "github.com/cline/grpc-go/cline"
  17. )
  18. const (
  19. defaultTimeout = 30 * time.Second
  20. longTimeout = 60 * time.Second
  21. pollInterval = 250 * time.Millisecond
  22. instancesBinRel = "../bin/cline"
  23. )
  24. func repoAwareBinPath(t *testing.T) string {
  25. // Tests live in repoRoot/cli/e2e. Binary is at repoRoot/cli/bin/cline
  26. t.Helper()
  27. wd, err := os.Getwd()
  28. if err != nil {
  29. t.Fatalf("Getwd error: %v", err)
  30. }
  31. // cli/e2e -> cli/bin/cline
  32. p := filepath.Clean(filepath.Join(wd, instancesBinRel))
  33. if _, err := os.Stat(p); err != nil {
  34. t.Fatalf("CLI binary not found at %s; run `npm run compile-cli` first: %v", p, err)
  35. }
  36. return p
  37. }
  38. func setTempClineDir(t *testing.T) string {
  39. t.Helper()
  40. dir := t.TempDir()
  41. clineDir := filepath.Join(dir, ".cline")
  42. if err := os.MkdirAll(clineDir, 0o755); err != nil {
  43. t.Fatalf("mkdir clineDir: %v", err)
  44. }
  45. t.Setenv("CLINE_DIR", clineDir)
  46. return clineDir
  47. }
  48. func runCLI(ctx context.Context, t *testing.T, args ...string) (string, string, int) {
  49. t.Helper()
  50. bin := repoAwareBinPath(t)
  51. // Ensure CLI uses the same CLINE_DIR as the tests by passing --config=<CLINE_DIR>
  52. // (InitializeGlobalConfig uses ConfigPath as the base directory for registry.)
  53. if clineDir := os.Getenv("CLINE_DIR"); clineDir != "" && !contains(args, "--config") {
  54. // Prepend persistent flag so Cobra sees it regardless of subcommand position
  55. args = append([]string{"--config", clineDir}, args...)
  56. }
  57. cmd := exec.CommandContext(ctx, bin, args...)
  58. // Run CLI from repo root so relative paths inside CLI (./cli/bin/...) resolve
  59. if wd, err := os.Getwd(); err == nil {
  60. repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
  61. cmd.Dir = repoRoot
  62. }
  63. // propagate env including CLINE_DIR
  64. cmd.Env = os.Environ()
  65. outB, errB := &strings.Builder{}, &strings.Builder{}
  66. cmd.Stdout = outB
  67. cmd.Stderr = errB
  68. err := cmd.Run()
  69. exit := 0
  70. if err != nil {
  71. // Extract exit code if possible
  72. if ee, ok := err.(*exec.ExitError); ok {
  73. exit = ee.ExitCode()
  74. } else {
  75. exit = -1
  76. }
  77. }
  78. return outB.String(), errB.String(), exit
  79. }
  80. func mustRunCLI(ctx context.Context, t *testing.T, args ...string) string {
  81. t.Helper()
  82. out, errOut, exit := runCLI(ctx, t, args...)
  83. if exit != 0 {
  84. t.Fatalf("cline %v failed (exit=%d)\nstdout:\n%s\nstderr:\n%s", args, exit, out, errOut)
  85. }
  86. return out
  87. }
  88. func listInstancesJSON(ctx context.Context, t *testing.T) common.InstancesOutput {
  89. t.Helper()
  90. // Trigger CLI to perform cleanup/health by invoking list (table output is ignored)
  91. _ = mustRunCLI(ctx, t, "instance", "list")
  92. // Read from SQLite locks database to build structured output
  93. clineDir := getClineDir(t)
  94. // Load default instance from settings file
  95. defaultInstance := readDefaultInstanceFromSettings(t, clineDir)
  96. // Load instances from SQLite
  97. instances := readInstancesFromSQLite(t, clineDir)
  98. return common.InstancesOutput{
  99. DefaultInstance: defaultInstance,
  100. CoreInstances: instances,
  101. }
  102. }
  103. func hasAddress(in common.InstancesOutput, addr string) bool {
  104. for _, it := range in.CoreInstances {
  105. if it.Address == addr {
  106. return true
  107. }
  108. }
  109. return false
  110. }
  111. func getByAddress(in common.InstancesOutput, addr string) (common.CoreInstanceInfo, bool) {
  112. for _, it := range in.CoreInstances {
  113. if it.Address == addr {
  114. return it, true
  115. }
  116. }
  117. return common.CoreInstanceInfo{}, false
  118. }
  119. func waitFor(t *testing.T, timeout time.Duration, cond func() (bool, string)) {
  120. t.Helper()
  121. deadline := time.Now().Add(timeout)
  122. for {
  123. ok, msg := cond()
  124. if ok {
  125. return
  126. }
  127. if time.Now().After(deadline) {
  128. t.Fatalf("waitFor timeout: %s", msg)
  129. }
  130. time.Sleep(pollInterval)
  131. }
  132. }
  133. func waitForAddressHealthy(t *testing.T, addr string, timeout time.Duration) {
  134. t.Helper()
  135. ctx, cancel := context.WithTimeout(context.Background(), timeout)
  136. defer cancel()
  137. t.Logf("Waiting for gRPC health check on %s...", addr)
  138. waitFor(t, timeout, func() (bool, string) {
  139. if common.IsInstanceHealthy(ctx, addr) {
  140. return true, ""
  141. }
  142. return false, fmt.Sprintf("gRPC health check failed for %s", addr)
  143. })
  144. t.Logf("gRPC health check passed for %s", addr)
  145. }
  146. func waitForAddressRemoved(t *testing.T, addr string, timeout time.Duration) {
  147. t.Helper()
  148. ctx, cancel := context.WithTimeout(context.Background(), timeout)
  149. defer cancel()
  150. waitFor(t, timeout, func() (bool, string) {
  151. out := listInstancesJSON(ctx, t)
  152. if hasAddress(out, addr) {
  153. return false, fmt.Sprintf("address %s still present", addr)
  154. }
  155. return true, ""
  156. })
  157. }
  158. func findFreePort(t *testing.T) int {
  159. t.Helper()
  160. l, err := net.Listen("tcp", "127.0.0.1:0")
  161. if err != nil {
  162. t.Fatalf("listen 127.0.0.1:0: %v", err)
  163. }
  164. defer l.Close()
  165. _, portStr, _ := net.SplitHostPort(l.Addr().String())
  166. var port int
  167. fmt.Sscanf(portStr, "%d", &port)
  168. return port
  169. }
  170. func getClineDir(t *testing.T) string {
  171. t.Helper()
  172. clineDir := os.Getenv("CLINE_DIR")
  173. if clineDir == "" {
  174. t.Fatalf("CLINE_DIR not set")
  175. }
  176. return clineDir
  177. }
  178. // isPortInUse checks if a port is currently in use by any process
  179. func isPortInUse(port int) bool {
  180. conn, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
  181. if err != nil {
  182. return true // Port is in use
  183. }
  184. conn.Close()
  185. return false // Port is free
  186. }
  187. // waitForPortClosed waits for a port to become free (no process listening)
  188. func waitForPortClosed(t *testing.T, port int, timeout time.Duration) {
  189. t.Helper()
  190. waitFor(t, timeout, func() (bool, string) {
  191. if isPortInUse(port) {
  192. return false, fmt.Sprintf("port %d still in use", port)
  193. }
  194. return true, ""
  195. })
  196. }
  197. // waitForPortsClosed waits for both core and host ports to become free
  198. func waitForPortsClosed(t *testing.T, corePort, hostPort int, timeout time.Duration) {
  199. t.Helper()
  200. waitFor(t, timeout, func() (bool, string) {
  201. if isPortInUse(corePort) {
  202. return false, fmt.Sprintf("core port %d still in use", corePort)
  203. }
  204. if isPortInUse(hostPort) {
  205. return false, fmt.Sprintf("host port %d still in use", hostPort)
  206. }
  207. return true, ""
  208. })
  209. }
  210. // findAndKillHostProcess finds and kills any process listening on the host port
  211. // This is used to clean up dangling host processes after SIGKILL tests
  212. func findAndKillHostProcess(t *testing.T, hostPort int) {
  213. t.Helper()
  214. // Use lsof to find process listening on the host port
  215. cmd := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", hostPort))
  216. output, err := cmd.Output()
  217. if err != nil {
  218. // No process found on port - that's fine
  219. return
  220. }
  221. pidStr := strings.TrimSpace(string(output))
  222. if pidStr == "" {
  223. return
  224. }
  225. var pid int
  226. if _, err := fmt.Sscanf(pidStr, "%d", &pid); err != nil {
  227. t.Logf("Warning: could not parse PID from lsof output: %s", pidStr)
  228. return
  229. }
  230. if pid > 0 {
  231. t.Logf("Cleaning up dangling host process PID %d on port %d", pid, hostPort)
  232. if err := syscall.Kill(pid, syscall.SIGKILL); err != nil {
  233. t.Logf("Warning: failed to kill dangling host process %d: %v", pid, err)
  234. }
  235. }
  236. }
  237. // getPIDByPort returns the PID of the process listening on the specified port (fallback method)
  238. func getPIDByPort(t *testing.T, port int) int {
  239. t.Helper()
  240. cmd := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", port))
  241. output, err := cmd.Output()
  242. if err != nil {
  243. return 0 // Process not found
  244. }
  245. pidStr := strings.TrimSpace(string(output))
  246. if pidStr == "" {
  247. return 0
  248. }
  249. pid, err := strconv.Atoi(pidStr)
  250. if err != nil {
  251. t.Logf("Warning: could not parse PID from lsof output: %s", pidStr)
  252. return 0
  253. }
  254. return pid
  255. }
  256. // getCorePIDViaRPC returns the PID of the cline-core process using RPC (preferred method)
  257. func getCorePIDViaRPC(t *testing.T, address string) int {
  258. t.Helper()
  259. // Initialize global config to access registry
  260. clineDir := os.Getenv("CLINE_DIR")
  261. if clineDir == "" {
  262. t.Logf("Warning: CLINE_DIR not set, falling back to lsof")
  263. return getCorePIDViaLsof(t, address)
  264. }
  265. cfg := &global.GlobalConfig{
  266. ConfigPath: clineDir,
  267. }
  268. if err := global.InitializeGlobalConfig(cfg); err != nil {
  269. t.Logf("Warning: failed to initialize global config, falling back to lsof: %v", err)
  270. return getCorePIDViaLsof(t, address)
  271. }
  272. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  273. defer cancel()
  274. // Get client for the address
  275. client, err := global.Clients.GetRegistry().GetClient(ctx, address)
  276. if err != nil {
  277. t.Logf("Warning: failed to get client for %s, falling back to lsof: %v", address, err)
  278. return getCorePIDViaLsof(t, address)
  279. }
  280. // Call GetProcessInfo RPC
  281. processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
  282. if err != nil {
  283. t.Logf("Warning: GetProcessInfo RPC failed for %s, falling back to lsof: %v", address, err)
  284. return getCorePIDViaLsof(t, address)
  285. }
  286. return int(processInfo.ProcessId)
  287. }
  288. // getCorePIDViaLsof returns the PID using lsof (fallback method)
  289. func getCorePIDViaLsof(t *testing.T, address string) int {
  290. t.Helper()
  291. _, portStr, err := net.SplitHostPort(address)
  292. if err != nil {
  293. t.Logf("Warning: invalid address format %s", address)
  294. return 0
  295. }
  296. port, err := strconv.Atoi(portStr)
  297. if err != nil {
  298. t.Logf("Warning: invalid port in address %s", address)
  299. return 0
  300. }
  301. return getPIDByPort(t, port)
  302. }
  303. // getCorePID returns the PID of the cline-core process for the given address
  304. // Uses RPC first, falls back to lsof if RPC fails
  305. func getCorePID(t *testing.T, address string) int {
  306. t.Helper()
  307. // Try RPC first (preferred method)
  308. if pid := getCorePIDViaRPC(t, address); pid > 0 {
  309. return pid
  310. }
  311. // Fall back to lsof if RPC fails
  312. return getCorePIDViaLsof(t, address)
  313. }
  314. // getHostPID returns the PID of the cline-host process for the given host port
  315. func getHostPID(t *testing.T, hostPort int) int {
  316. t.Helper()
  317. return getPIDByPort(t, hostPort)
  318. }
  319. // contains reports whether slice has the target string.
  320. func contains(slice []string, target string) bool {
  321. for _, s := range slice {
  322. if s == target {
  323. return true
  324. }
  325. }
  326. return false
  327. }