package claudecode import ( "bytes" "context" "io" "os" "os/exec" "testing" "time" "github.com/chenhg5/cc-connect/core" ) func TestHandleResultParsesUsage(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() cs := &claudeSession{ events: make(chan core.Event, 8), ctx: ctx, } cs.sessionID.Store("test-session") cs.alive.Store(true) raw := map[string]any{ "type": "result", "result": "done", "session_id": "test-session", "usage": map[string]any{ "input_tokens": float64(150000), "output_tokens": float64(2000), }, } cs.handleResult(raw) evt := <-cs.events if evt.InputTokens != 150000 { t.Errorf("InputTokens = %d, want 150000", evt.InputTokens) } if evt.OutputTokens != 2000 { t.Errorf("OutputTokens = %d, want 2000", evt.OutputTokens) } } func TestHandleResultNoUsage(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() cs := &claudeSession{ events: make(chan core.Event, 8), ctx: ctx, } cs.sessionID.Store("test-session") cs.alive.Store(true) raw := map[string]any{ "type": "result", "result": "done", } cs.handleResult(raw) evt := <-cs.events if evt.InputTokens != 0 { t.Errorf("InputTokens = %d, want 0", evt.InputTokens) } if evt.OutputTokens != 0 { t.Errorf("OutputTokens = %d, want 0", evt.OutputTokens) } } func TestReadLoop_ChildHoldsStdoutPipe(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() pr, pw := io.Pipe() t.Cleanup(func() { _ = pw.Close() }) writeDone := make(chan error, 1) go func() { _, err := io.WriteString(pw, `{"type":"system","session_id":"test-pipe"}`+"\n") writeDone <- err }() cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^$") var stderrBuf bytes.Buffer cmd.Stderr = &stderrBuf if err := cmd.Start(); err != nil { t.Fatal(err) } cs := &claudeSession{ cmd: cmd, events: make(chan core.Event, 64), ctx: ctx, cancel: cancel, done: make(chan struct{}), } cs.alive.Store(true) go cs.readLoop(pr, &stderrBuf) timeout := time.After(5 * time.Second) gotEvent := false for { select { case err := <-writeDone: if err != nil { t.Fatal(err) } writeDone = nil case evt, ok := <-cs.events: if !ok { if !gotEvent { t.Fatal("events closed but system event lost") } return } if evt.SessionID == "test-pipe" { gotEvent = true } case <-timeout: t.Fatal("HANG: events not closed within 5s - readLoop stuck in scanner.Scan()") } } } func TestReadLoop_CtxCancelClosesChannels(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() pr, pw := io.Pipe() t.Cleanup(func() { _ = pw.Close() }) // "err-then-sleep" emits stderr before sleeping so that ctx cancel // produces a non-empty stderrBuf in readLoop's defer — exercising the // `case <-cs.ctx.Done()` select branch in finishReadLoop. cmd := helperCommand(ctx, "err-then-sleep") var stderrBuf bytes.Buffer cmd.Stderr = &stderrBuf if err := cmd.Start(); err != nil { t.Fatal(err) } cs := &claudeSession{ cmd: cmd, events: make(chan core.Event, 64), ctx: ctx, cancel: cancel, done: make(chan struct{}), } cs.alive.Store(true) go cs.readLoop(pr, &stderrBuf) time.Sleep(200 * time.Millisecond) cancel() timeout := time.After(5 * time.Second) for { select { case _, ok := <-cs.events: if !ok { goto closed } case <-timeout: t.Fatal("HANG: events not closed within 5s after ctx cancel") } } closed: select { case <-cs.done: case <-timeout: t.Fatal("HANG: done not closed within 5s after ctx cancel") } } func TestClaudeSessionClose_IdempotentNoPanic(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() cmd := helperCommand(ctx, "stdin-eof-exit") stdin, err := cmd.StdinPipe() if err != nil { t.Fatal(err) } cmd.Stdout = io.Discard cmd.Stderr = io.Discard if err := cmd.Start(); err != nil { t.Fatal(err) } done := make(chan struct{}) go func() { _ = cmd.Wait() close(done) }() cs := &claudeSession{ cmd: cmd, stdin: stdin, ctx: ctx, cancel: cancel, done: done, gracefulStopTimeout: 200 * time.Millisecond, } cs.alive.Store(true) defer func() { if r := recover(); r != nil { t.Errorf("Close panicked: %v", r) } }() if err := cs.Close(); err != nil { t.Fatalf("first Close: %v", err) } if err := cs.Close(); err != nil { t.Fatalf("second Close: %v", err) } } func TestShellJoinArgs(t *testing.T) { tests := []struct { name string args []string want string }{ {"empty", nil, ""}, {"single_plain", []string{"--verbose"}, "--verbose"}, {"multiple_plain", []string{"--verbose", "--model", "opus"}, "--verbose --model opus"}, {"arg_with_space", []string{"--prompt", "hello world"}, "--prompt 'hello world'"}, {"arg_with_tab", []string{"a\tb"}, "'a\tb'"}, {"arg_with_newline", []string{"line1\nline2"}, "'line1\nline2'"}, {"arg_with_single_quote", []string{"it's"}, "'it'\\''s'"}, {"arg_with_double_quote", []string{`say "hi"`}, `'say "hi"'`}, {"arg_with_backslash", []string{`path\to`}, `'path\to'`}, {"mixed", []string{"--flag", "has space", "plain", "it's here"}, "--flag 'has space' plain 'it'\\''s here'"}, {"empty_string_arg", []string{""}, ""}, {"long_prompt", []string{"--append-system-prompt", "You are a helpful assistant.\nBe concise."}, "--append-system-prompt 'You are a helpful assistant.\nBe concise.'"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := shellJoinArgs(tt.args) if got != tt.want { t.Errorf("shellJoinArgs(%v)\n got = %q\n want = %q", tt.args, got, tt.want) } }) } } func helperCommand(ctx context.Context, mode string) *exec.Cmd { cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestHelperProcess", "--", mode) cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1") return cmd } // TestHelperProcess lets this test binary act as a tiny external command for // cases that need a process with controlled lifetime semantics. func TestHelperProcess(t *testing.T) { if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { return } mode := os.Args[len(os.Args)-1] switch mode { case "sleep": time.Sleep(30 * time.Second) os.Exit(0) case "err-then-sleep": _, _ = os.Stderr.WriteString("helper: starting up\n") time.Sleep(30 * time.Second) os.Exit(0) case "stdin-eof-exit": _, _ = io.Copy(io.Discard, os.Stdin) os.Exit(0) default: os.Exit(2) } }