mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
* super sketchy big merge with main * gitignore * gitignore * Delete cli/bin/air * Delete cli/bin directory * Delete cli/cline-host * Fix missing package.json in cli Copy the package JSON into the dist-standalone dir during compilation. Remove workaround for missing package.json * Update scripts/build-cli.sh Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * Remove reference to watchservice, it has been removed * Update scripts/build-go-proto.mjs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix timestamp to string conversion * diff.go ellipsis fix * COMMON_TYPES --------- Co-authored-by: Sarah Fortune <sarah.fortune@gmail.com> Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
379 lines
9.8 KiB
Go
379 lines
9.8 KiB
Go
package e2e
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/cline/cli/pkg/cli/global"
|
|
"github.com/cline/cli/pkg/common"
|
|
"github.com/cline/grpc-go/cline"
|
|
)
|
|
|
|
const (
|
|
defaultTimeout = 30 * time.Second
|
|
longTimeout = 60 * time.Second
|
|
pollInterval = 250 * time.Millisecond
|
|
instancesBinRel = "../bin/cline"
|
|
)
|
|
|
|
func repoAwareBinPath(t *testing.T) string {
|
|
// Tests live in repoRoot/cli/e2e. Binary is at repoRoot/cli/bin/cline
|
|
t.Helper()
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("Getwd error: %v", err)
|
|
}
|
|
// cli/e2e -> cli/bin/cline
|
|
p := filepath.Clean(filepath.Join(wd, instancesBinRel))
|
|
if _, err := os.Stat(p); err != nil {
|
|
t.Fatalf("CLI binary not found at %s; run `npm run compile-cli` first: %v", p, err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func setTempClineDir(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
clineDir := filepath.Join(dir, ".cline")
|
|
if err := os.MkdirAll(clineDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir clineDir: %v", err)
|
|
}
|
|
t.Setenv("CLINE_DIR", clineDir)
|
|
return clineDir
|
|
}
|
|
|
|
func runCLI(ctx context.Context, t *testing.T, args ...string) (string, string, int) {
|
|
t.Helper()
|
|
bin := repoAwareBinPath(t)
|
|
|
|
// Ensure CLI uses the same CLINE_DIR as the tests by passing --config=<CLINE_DIR>
|
|
// (InitializeGlobalConfig uses ConfigPath as the base directory for registry.)
|
|
if clineDir := os.Getenv("CLINE_DIR"); clineDir != "" && !contains(args, "--config") {
|
|
// Prepend persistent flag so Cobra sees it regardless of subcommand position
|
|
args = append([]string{"--config", clineDir}, args...)
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, bin, args...)
|
|
// Run CLI from repo root so relative paths inside CLI (./cli/bin/...) resolve
|
|
if wd, err := os.Getwd(); err == nil {
|
|
repoRoot := filepath.Clean(filepath.Join(wd, "..", ".."))
|
|
cmd.Dir = repoRoot
|
|
}
|
|
// propagate env including CLINE_DIR
|
|
cmd.Env = os.Environ()
|
|
outB, errB := &strings.Builder{}, &strings.Builder{}
|
|
cmd.Stdout = outB
|
|
cmd.Stderr = errB
|
|
err := cmd.Run()
|
|
exit := 0
|
|
if err != nil {
|
|
// Extract exit code if possible
|
|
if ee, ok := err.(*exec.ExitError); ok {
|
|
exit = ee.ExitCode()
|
|
} else {
|
|
exit = -1
|
|
}
|
|
}
|
|
return outB.String(), errB.String(), exit
|
|
}
|
|
|
|
func mustRunCLI(ctx context.Context, t *testing.T, args ...string) string {
|
|
t.Helper()
|
|
out, errOut, exit := runCLI(ctx, t, args...)
|
|
if exit != 0 {
|
|
t.Fatalf("cline %v failed (exit=%d)\nstdout:\n%s\nstderr:\n%s", args, exit, out, errOut)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func listInstancesJSON(ctx context.Context, t *testing.T) common.InstancesOutput {
|
|
t.Helper()
|
|
// Trigger CLI to perform cleanup/health by invoking list (table output is ignored)
|
|
_ = mustRunCLI(ctx, t, "instance", "list")
|
|
|
|
// Read from SQLite locks database to build structured output
|
|
clineDir := getClineDir(t)
|
|
|
|
// Load default instance from settings file
|
|
defaultInstance := readDefaultInstanceFromSettings(t, clineDir)
|
|
|
|
// Load instances from SQLite
|
|
instances := readInstancesFromSQLite(t, clineDir)
|
|
|
|
return common.InstancesOutput{
|
|
DefaultInstance: defaultInstance,
|
|
CoreInstances: instances,
|
|
}
|
|
}
|
|
|
|
func hasAddress(in common.InstancesOutput, addr string) bool {
|
|
for _, it := range in.CoreInstances {
|
|
if it.Address == addr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func getByAddress(in common.InstancesOutput, addr string) (common.CoreInstanceInfo, bool) {
|
|
for _, it := range in.CoreInstances {
|
|
if it.Address == addr {
|
|
return it, true
|
|
}
|
|
}
|
|
return common.CoreInstanceInfo{}, false
|
|
}
|
|
|
|
func waitFor(t *testing.T, timeout time.Duration, cond func() (bool, string)) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for {
|
|
ok, msg := cond()
|
|
if ok {
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("waitFor timeout: %s", msg)
|
|
}
|
|
time.Sleep(pollInterval)
|
|
}
|
|
}
|
|
|
|
func waitForAddressHealthy(t *testing.T, addr string, timeout time.Duration) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
t.Logf("Waiting for gRPC health check on %s...", addr)
|
|
|
|
waitFor(t, timeout, func() (bool, string) {
|
|
if common.IsInstanceHealthy(ctx, addr) {
|
|
return true, ""
|
|
}
|
|
return false, fmt.Sprintf("gRPC health check failed for %s", addr)
|
|
})
|
|
|
|
t.Logf("gRPC health check passed for %s", addr)
|
|
}
|
|
|
|
func waitForAddressRemoved(t *testing.T, addr string, timeout time.Duration) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
waitFor(t, timeout, func() (bool, string) {
|
|
out := listInstancesJSON(ctx, t)
|
|
if hasAddress(out, addr) {
|
|
return false, fmt.Sprintf("address %s still present", addr)
|
|
}
|
|
return true, ""
|
|
})
|
|
}
|
|
|
|
func findFreePort(t *testing.T) int {
|
|
t.Helper()
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen 127.0.0.1:0: %v", err)
|
|
}
|
|
defer l.Close()
|
|
_, portStr, _ := net.SplitHostPort(l.Addr().String())
|
|
var port int
|
|
fmt.Sscanf(portStr, "%d", &port)
|
|
return port
|
|
}
|
|
|
|
func getClineDir(t *testing.T) string {
|
|
t.Helper()
|
|
clineDir := os.Getenv("CLINE_DIR")
|
|
if clineDir == "" {
|
|
t.Fatalf("CLINE_DIR not set")
|
|
}
|
|
return clineDir
|
|
}
|
|
|
|
// isPortInUse checks if a port is currently in use by any process
|
|
func isPortInUse(port int) bool {
|
|
conn, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
|
if err != nil {
|
|
return true // Port is in use
|
|
}
|
|
conn.Close()
|
|
return false // Port is free
|
|
}
|
|
|
|
// waitForPortClosed waits for a port to become free (no process listening)
|
|
func waitForPortClosed(t *testing.T, port int, timeout time.Duration) {
|
|
t.Helper()
|
|
waitFor(t, timeout, func() (bool, string) {
|
|
if isPortInUse(port) {
|
|
return false, fmt.Sprintf("port %d still in use", port)
|
|
}
|
|
return true, ""
|
|
})
|
|
}
|
|
|
|
// waitForPortsClosed waits for both core and host ports to become free
|
|
func waitForPortsClosed(t *testing.T, corePort, hostPort int, timeout time.Duration) {
|
|
t.Helper()
|
|
waitFor(t, timeout, func() (bool, string) {
|
|
if isPortInUse(corePort) {
|
|
return false, fmt.Sprintf("core port %d still in use", corePort)
|
|
}
|
|
if isPortInUse(hostPort) {
|
|
return false, fmt.Sprintf("host port %d still in use", hostPort)
|
|
}
|
|
return true, ""
|
|
})
|
|
}
|
|
|
|
// findAndKillHostProcess finds and kills any process listening on the host port
|
|
// This is used to clean up dangling host processes after SIGKILL tests
|
|
func findAndKillHostProcess(t *testing.T, hostPort int) {
|
|
t.Helper()
|
|
// Use lsof to find process listening on the host port
|
|
cmd := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", hostPort))
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
// No process found on port - that's fine
|
|
return
|
|
}
|
|
|
|
pidStr := strings.TrimSpace(string(output))
|
|
if pidStr == "" {
|
|
return
|
|
}
|
|
|
|
var pid int
|
|
if _, err := fmt.Sscanf(pidStr, "%d", &pid); err != nil {
|
|
t.Logf("Warning: could not parse PID from lsof output: %s", pidStr)
|
|
return
|
|
}
|
|
|
|
if pid > 0 {
|
|
t.Logf("Cleaning up dangling host process PID %d on port %d", pid, hostPort)
|
|
if err := syscall.Kill(pid, syscall.SIGKILL); err != nil {
|
|
t.Logf("Warning: failed to kill dangling host process %d: %v", pid, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// getPIDByPort returns the PID of the process listening on the specified port (fallback method)
|
|
func getPIDByPort(t *testing.T, port int) int {
|
|
t.Helper()
|
|
cmd := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", port))
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return 0 // Process not found
|
|
}
|
|
|
|
pidStr := strings.TrimSpace(string(output))
|
|
if pidStr == "" {
|
|
return 0
|
|
}
|
|
|
|
pid, err := strconv.Atoi(pidStr)
|
|
if err != nil {
|
|
t.Logf("Warning: could not parse PID from lsof output: %s", pidStr)
|
|
return 0
|
|
}
|
|
|
|
return pid
|
|
}
|
|
|
|
// getCorePIDViaRPC returns the PID of the cline-core process using RPC (preferred method)
|
|
func getCorePIDViaRPC(t *testing.T, address string) int {
|
|
t.Helper()
|
|
|
|
// Initialize global config to access registry
|
|
clineDir := os.Getenv("CLINE_DIR")
|
|
if clineDir == "" {
|
|
t.Logf("Warning: CLINE_DIR not set, falling back to lsof")
|
|
return getCorePIDViaLsof(t, address)
|
|
}
|
|
|
|
cfg := &global.GlobalConfig{
|
|
ConfigPath: clineDir,
|
|
}
|
|
|
|
if err := global.InitializeGlobalConfig(cfg); err != nil {
|
|
t.Logf("Warning: failed to initialize global config, falling back to lsof: %v", err)
|
|
return getCorePIDViaLsof(t, address)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// Get client for the address
|
|
client, err := global.Clients.GetRegistry().GetClient(ctx, address)
|
|
if err != nil {
|
|
t.Logf("Warning: failed to get client for %s, falling back to lsof: %v", address, err)
|
|
return getCorePIDViaLsof(t, address)
|
|
}
|
|
|
|
// Call GetProcessInfo RPC
|
|
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
|
|
if err != nil {
|
|
t.Logf("Warning: GetProcessInfo RPC failed for %s, falling back to lsof: %v", address, err)
|
|
return getCorePIDViaLsof(t, address)
|
|
}
|
|
|
|
return int(processInfo.ProcessId)
|
|
}
|
|
|
|
// getCorePIDViaLsof returns the PID using lsof (fallback method)
|
|
func getCorePIDViaLsof(t *testing.T, address string) int {
|
|
t.Helper()
|
|
_, portStr, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
t.Logf("Warning: invalid address format %s", address)
|
|
return 0
|
|
}
|
|
|
|
port, err := strconv.Atoi(portStr)
|
|
if err != nil {
|
|
t.Logf("Warning: invalid port in address %s", address)
|
|
return 0
|
|
}
|
|
|
|
return getPIDByPort(t, port)
|
|
}
|
|
|
|
// getCorePID returns the PID of the cline-core process for the given address
|
|
// Uses RPC first, falls back to lsof if RPC fails
|
|
func getCorePID(t *testing.T, address string) int {
|
|
t.Helper()
|
|
|
|
// Try RPC first (preferred method)
|
|
if pid := getCorePIDViaRPC(t, address); pid > 0 {
|
|
return pid
|
|
}
|
|
|
|
// Fall back to lsof if RPC fails
|
|
return getCorePIDViaLsof(t, address)
|
|
}
|
|
|
|
// getHostPID returns the PID of the cline-host process for the given host port
|
|
func getHostPID(t *testing.T, hostPort int) int {
|
|
t.Helper()
|
|
return getPIDByPort(t, hostPort)
|
|
}
|
|
|
|
// contains reports whether slice has the target string.
|
|
func contains(slice []string, target string) bool {
|
|
for _, s := range slice {
|
|
if s == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|