初始化仓库
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user