286 lines
6.5 KiB
Go
286 lines
6.5 KiB
Go
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)
|
|
}
|
|
}
|