package process_test import ( "context" "cursor-api-proxy/internal/process" "os" "testing" "time" ) // sh 是跨平台 shell 執行小 script 的輔助函式 func sh(t *testing.T, script string, opts process.RunOptions) (process.RunResult, error) { t.Helper() return process.Run("sh", []string{"-c", script}, opts) } func TestRun_StdoutAndStderr(t *testing.T) { result, err := sh(t, "echo hello; echo world >&2", process.RunOptions{}) if err != nil { t.Fatal(err) } if result.Code != 0 { t.Errorf("Code = %d, want 0", result.Code) } if result.Stdout != "hello\n" { t.Errorf("Stdout = %q, want %q", result.Stdout, "hello\n") } if result.Stderr != "world\n" { t.Errorf("Stderr = %q, want %q", result.Stderr, "world\n") } } func TestRun_BasicSpawn(t *testing.T) { result, err := sh(t, "printf ok", process.RunOptions{}) if err != nil { t.Fatal(err) } if result.Code != 0 { t.Errorf("Code = %d, want 0", result.Code) } if result.Stdout != "ok" { t.Errorf("Stdout = %q, want ok", result.Stdout) } } func TestRun_ConfigDir_Propagated(t *testing.T) { result, err := process.Run("sh", []string{"-c", `printf "$CURSOR_CONFIG_DIR"`}, process.RunOptions{ConfigDir: "/test/account/dir"}) if err != nil { t.Fatal(err) } if result.Stdout != "/test/account/dir" { t.Errorf("Stdout = %q, want /test/account/dir", result.Stdout) } } func TestRun_ConfigDir_Absent(t *testing.T) { // 確保沒有殘留的環境變數 _ = os.Unsetenv("CURSOR_CONFIG_DIR") result, err := process.Run("sh", []string{"-c", `printf "${CURSOR_CONFIG_DIR:-unset}"`}, process.RunOptions{}) if err != nil { t.Fatal(err) } if result.Stdout != "unset" { t.Errorf("Stdout = %q, want unset", result.Stdout) } } func TestRun_NonZeroExit(t *testing.T) { result, err := sh(t, "exit 42", process.RunOptions{}) if err != nil { t.Fatal(err) } if result.Code != 42 { t.Errorf("Code = %d, want 42", result.Code) } } func TestRun_Timeout(t *testing.T) { start := time.Now() result, err := sh(t, "sleep 30", process.RunOptions{TimeoutMs: 300}) elapsed := time.Since(start) if err != nil { t.Fatal(err) } if result.Code == 0 { t.Error("expected non-zero exit code after timeout") } if elapsed > 2*time.Second { t.Errorf("elapsed %v, want < 2s", elapsed) } } func TestRunStreaming_OnLine(t *testing.T) { var lines []string result, err := process.RunStreaming("sh", []string{"-c", "printf 'a\nb\nc\n'"}, process.RunStreamingOptions{ OnLine: func(line string) { lines = append(lines, line) }, }) if err != nil { t.Fatal(err) } if result.Code != 0 { t.Errorf("Code = %d, want 0", result.Code) } if len(lines) != 3 { t.Errorf("got %d lines, want 3: %v", len(lines), lines) } if lines[0] != "a" || lines[1] != "b" || lines[2] != "c" { t.Errorf("lines = %v, want [a b c]", lines) } } func TestRunStreaming_FlushFinalLine(t *testing.T) { var lines []string result, err := process.RunStreaming("sh", []string{"-c", "printf tail"}, process.RunStreamingOptions{ OnLine: func(line string) { lines = append(lines, line) }, }) if err != nil { t.Fatal(err) } if result.Code != 0 { t.Errorf("Code = %d, want 0", result.Code) } if len(lines) != 1 { t.Errorf("got %d lines, want 1: %v", len(lines), lines) } if lines[0] != "tail" { t.Errorf("lines[0] = %q, want tail", lines[0]) } } func TestRunStreaming_ConfigDir(t *testing.T) { var lines []string _, err := process.RunStreaming("sh", []string{"-c", `printf "$CURSOR_CONFIG_DIR"`}, process.RunStreamingOptions{ RunOptions: process.RunOptions{ConfigDir: "/my/config/dir"}, OnLine: func(line string) { lines = append(lines, line) }, }) if err != nil { t.Fatal(err) } if len(lines) != 1 || lines[0] != "/my/config/dir" { t.Errorf("lines = %v, want [/my/config/dir]", lines) } } func TestRunStreaming_Stderr(t *testing.T) { result, err := process.RunStreaming("sh", []string{"-c", "echo err-output >&2"}, process.RunStreamingOptions{OnLine: func(string) {}}) if err != nil { t.Fatal(err) } if result.Stderr == "" { t.Error("expected stderr to contain output") } } func TestRunStreaming_Timeout(t *testing.T) { start := time.Now() result, err := process.RunStreaming("sh", []string{"-c", "sleep 30"}, process.RunStreamingOptions{ RunOptions: process.RunOptions{TimeoutMs: 300}, OnLine: func(string) {}, }) elapsed := time.Since(start) if err != nil { t.Fatal(err) } if result.Code == 0 { t.Error("expected non-zero exit code after timeout") } if elapsed > 2*time.Second { t.Errorf("elapsed %v, want < 2s", elapsed) } } func TestRunStreaming_Concurrent(t *testing.T) { var lines1, lines2 []string done := make(chan struct{}, 2) run := func(label string, target *[]string) { process.RunStreaming("sh", []string{"-c", "printf '" + label + "'"}, process.RunStreamingOptions{ OnLine: func(line string) { *target = append(*target, line) }, }) done <- struct{}{} } go run("stream1", &lines1) go run("stream2", &lines2) <-done <-done if len(lines1) != 1 || lines1[0] != "stream1" { t.Errorf("lines1 = %v, want [stream1]", lines1) } if len(lines2) != 1 || lines2[0] != "stream2" { t.Errorf("lines2 = %v, want [stream2]", lines2) } } func TestRunStreaming_ContextCancel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) start := time.Now() done := make(chan struct{}) go func() { process.RunStreaming("sh", []string{"-c", "sleep 30"}, process.RunStreamingOptions{ RunOptions: process.RunOptions{Ctx: ctx}, OnLine: func(string) {}, }) close(done) }() time.AfterFunc(100*time.Millisecond, cancel) <-done elapsed := time.Since(start) if elapsed > 2*time.Second { t.Errorf("elapsed %v, want < 2s", elapsed) } } func TestRun_ContextCancel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) start := time.Now() done := make(chan process.RunResult, 1) go func() { r, _ := process.Run("sh", []string{"-c", "sleep 30"}, process.RunOptions{Ctx: ctx}) done <- r }() time.AfterFunc(100*time.Millisecond, cancel) result := <-done elapsed := time.Since(start) if result.Code == 0 { t.Error("expected non-zero exit code after cancel") } if elapsed > 2*time.Second { t.Errorf("elapsed %v, want < 2s", elapsed) } } func TestRun_AlreadyCancelledContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // 已取消 start := time.Now() result, _ := process.Run("sh", []string{"-c", "sleep 30"}, process.RunOptions{Ctx: ctx}) elapsed := time.Since(start) if result.Code == 0 { t.Error("expected non-zero exit code") } if elapsed > 2*time.Second { t.Errorf("elapsed %v, want < 2s", elapsed) } } func TestKillAllChildProcesses(t *testing.T) { done := make(chan process.RunResult, 1) go func() { r, _ := process.Run("sh", []string{"-c", "sleep 30"}, process.RunOptions{}) done <- r }() time.Sleep(80 * time.Millisecond) process.KillAllChildProcesses() result := <-done if result.Code == 0 { t.Error("expected non-zero exit code after kill") } // 再次呼叫不應 panic process.KillAllChildProcesses() }