mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-10 03:14:32 +08:00
1202 lines
30 KiB
Go
1202 lines
30 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Helper to reset test hooks
|
|
func resetTestHooks() {
|
|
stdinReader = os.Stdin
|
|
isTerminalFn = defaultIsTerminal
|
|
codexCommand = "codex"
|
|
cleanupHook = nil
|
|
closeLogger()
|
|
}
|
|
|
|
type capturedStdout struct {
|
|
buf bytes.Buffer
|
|
old *os.File
|
|
reader *os.File
|
|
writer *os.File
|
|
}
|
|
|
|
type errReader struct {
|
|
err error
|
|
}
|
|
|
|
func (e errReader) Read([]byte) (int, error) {
|
|
return 0, e.err
|
|
}
|
|
|
|
func captureStdout() *capturedStdout {
|
|
r, w, _ := os.Pipe()
|
|
state := &capturedStdout{old: os.Stdout, reader: r, writer: w}
|
|
os.Stdout = w
|
|
return state
|
|
}
|
|
|
|
func restoreStdout(c *capturedStdout) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
c.writer.Close()
|
|
os.Stdout = c.old
|
|
io.Copy(&c.buf, c.reader)
|
|
}
|
|
|
|
func (c *capturedStdout) String() string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
return c.buf.String()
|
|
}
|
|
|
|
func createFakeCodexScript(t *testing.T, threadID, message string) string {
|
|
t.Helper()
|
|
scriptPath := filepath.Join(t.TempDir(), "codex.sh")
|
|
script := fmt.Sprintf(`#!/bin/sh
|
|
printf '%%s\n' '{"type":"thread.started","thread_id":"%s"}'
|
|
printf '%%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"%s"}}'
|
|
`, threadID, message)
|
|
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("failed to create fake codex script: %v", err)
|
|
}
|
|
return scriptPath
|
|
}
|
|
|
|
func TestRunParseArgs_NewMode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
want *Config
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "simple task",
|
|
args: []string{"codex-wrapper", "analyze code"},
|
|
want: &Config{
|
|
Mode: "new",
|
|
Task: "analyze code",
|
|
WorkDir: ".",
|
|
ExplicitStdin: false,
|
|
},
|
|
},
|
|
{
|
|
name: "task with workdir",
|
|
args: []string{"codex-wrapper", "analyze code", "/path/to/dir"},
|
|
want: &Config{
|
|
Mode: "new",
|
|
Task: "analyze code",
|
|
WorkDir: "/path/to/dir",
|
|
ExplicitStdin: false,
|
|
},
|
|
},
|
|
{
|
|
name: "explicit stdin mode",
|
|
args: []string{"codex-wrapper", "-"},
|
|
want: &Config{
|
|
Mode: "new",
|
|
Task: "-",
|
|
WorkDir: ".",
|
|
ExplicitStdin: true,
|
|
},
|
|
},
|
|
{
|
|
name: "stdin with workdir",
|
|
args: []string{"codex-wrapper", "-", "/some/dir"},
|
|
want: &Config{
|
|
Mode: "new",
|
|
Task: "-",
|
|
WorkDir: "/some/dir",
|
|
ExplicitStdin: true,
|
|
},
|
|
},
|
|
{
|
|
name: "no args",
|
|
args: []string{"codex-wrapper"},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
os.Args = tt.args
|
|
|
|
cfg, err := parseArgs()
|
|
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Errorf("parseArgs() expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Errorf("parseArgs() unexpected error: %v", err)
|
|
return
|
|
}
|
|
|
|
if cfg.Mode != tt.want.Mode {
|
|
t.Errorf("Mode = %v, want %v", cfg.Mode, tt.want.Mode)
|
|
}
|
|
if cfg.Task != tt.want.Task {
|
|
t.Errorf("Task = %v, want %v", cfg.Task, tt.want.Task)
|
|
}
|
|
if cfg.WorkDir != tt.want.WorkDir {
|
|
t.Errorf("WorkDir = %v, want %v", cfg.WorkDir, tt.want.WorkDir)
|
|
}
|
|
if cfg.ExplicitStdin != tt.want.ExplicitStdin {
|
|
t.Errorf("ExplicitStdin = %v, want %v", cfg.ExplicitStdin, tt.want.ExplicitStdin)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunParseArgs_ResumeMode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
want *Config
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "resume with task",
|
|
args: []string{"codex-wrapper", "resume", "session-123", "continue task"},
|
|
want: &Config{
|
|
Mode: "resume",
|
|
SessionID: "session-123",
|
|
Task: "continue task",
|
|
WorkDir: ".",
|
|
ExplicitStdin: false,
|
|
},
|
|
},
|
|
{
|
|
name: "resume with workdir",
|
|
args: []string{"codex-wrapper", "resume", "session-456", "task", "/work"},
|
|
want: &Config{
|
|
Mode: "resume",
|
|
SessionID: "session-456",
|
|
Task: "task",
|
|
WorkDir: "/work",
|
|
ExplicitStdin: false,
|
|
},
|
|
},
|
|
{
|
|
name: "resume with stdin",
|
|
args: []string{"codex-wrapper", "resume", "session-789", "-"},
|
|
want: &Config{
|
|
Mode: "resume",
|
|
SessionID: "session-789",
|
|
Task: "-",
|
|
WorkDir: ".",
|
|
ExplicitStdin: true,
|
|
},
|
|
},
|
|
{
|
|
name: "resume missing session_id",
|
|
args: []string{"codex-wrapper", "resume"},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "resume missing task",
|
|
args: []string{"codex-wrapper", "resume", "session-123"},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
os.Args = tt.args
|
|
|
|
cfg, err := parseArgs()
|
|
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Errorf("parseArgs() expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Errorf("parseArgs() unexpected error: %v", err)
|
|
return
|
|
}
|
|
|
|
if cfg.Mode != tt.want.Mode {
|
|
t.Errorf("Mode = %v, want %v", cfg.Mode, tt.want.Mode)
|
|
}
|
|
if cfg.SessionID != tt.want.SessionID {
|
|
t.Errorf("SessionID = %v, want %v", cfg.SessionID, tt.want.SessionID)
|
|
}
|
|
if cfg.Task != tt.want.Task {
|
|
t.Errorf("Task = %v, want %v", cfg.Task, tt.want.Task)
|
|
}
|
|
if cfg.WorkDir != tt.want.WorkDir {
|
|
t.Errorf("WorkDir = %v, want %v", cfg.WorkDir, tt.want.WorkDir)
|
|
}
|
|
if cfg.ExplicitStdin != tt.want.ExplicitStdin {
|
|
t.Errorf("ExplicitStdin = %v, want %v", cfg.ExplicitStdin, tt.want.ExplicitStdin)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunShouldUseStdin(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
task string
|
|
piped bool
|
|
want bool
|
|
}{
|
|
{"simple task", "analyze code", false, false},
|
|
{"piped input", "analyze code", true, true},
|
|
{"contains newline", "line1\nline2", false, true},
|
|
{"contains backslash", "path\\to\\file", false, true},
|
|
{"long task", strings.Repeat("a", 801), false, true},
|
|
{"exactly 800 chars", strings.Repeat("a", 800), false, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := shouldUseStdin(tt.task, tt.piped)
|
|
if got != tt.want {
|
|
t.Errorf("shouldUseStdin(%q, %v) = %v, want %v", truncate(tt.task, 20), tt.piped, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunBuildCodexArgs_NewMode(t *testing.T) {
|
|
cfg := &Config{
|
|
Mode: "new",
|
|
WorkDir: "/test/dir",
|
|
}
|
|
|
|
args := buildCodexArgs(cfg, "my task")
|
|
|
|
expected := []string{
|
|
"e",
|
|
"--skip-git-repo-check",
|
|
"-C", "/test/dir",
|
|
"--json",
|
|
"my task",
|
|
}
|
|
|
|
if len(args) != len(expected) {
|
|
t.Errorf("buildCodexArgs() returned %d args, want %d", len(args), len(expected))
|
|
return
|
|
}
|
|
|
|
for i, arg := range args {
|
|
if arg != expected[i] {
|
|
t.Errorf("buildCodexArgs()[%d] = %v, want %v", i, arg, expected[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunBuildCodexArgs_ResumeMode(t *testing.T) {
|
|
cfg := &Config{
|
|
Mode: "resume",
|
|
SessionID: "session-abc",
|
|
}
|
|
|
|
args := buildCodexArgs(cfg, "-")
|
|
|
|
expected := []string{
|
|
"e",
|
|
"--skip-git-repo-check",
|
|
"--json",
|
|
"resume",
|
|
"session-abc",
|
|
"-",
|
|
}
|
|
|
|
if len(args) != len(expected) {
|
|
t.Errorf("buildCodexArgs() returned %d args, want %d", len(args), len(expected))
|
|
return
|
|
}
|
|
|
|
for i, arg := range args {
|
|
if arg != expected[i] {
|
|
t.Errorf("buildCodexArgs()[%d] = %v, want %v", i, arg, expected[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunResolveTimeout(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
envVal string
|
|
want int
|
|
}{
|
|
{"empty env", "", 7200},
|
|
{"milliseconds", "7200000", 7200},
|
|
{"seconds", "3600", 3600},
|
|
{"invalid", "invalid", 7200},
|
|
{"negative", "-100", 7200},
|
|
{"zero", "0", 7200},
|
|
{"small milliseconds", "5000", 5000},
|
|
{"boundary", "10000", 10000},
|
|
{"above boundary", "10001", 10},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
os.Setenv("CODEX_TIMEOUT", tt.envVal)
|
|
defer os.Unsetenv("CODEX_TIMEOUT")
|
|
|
|
got := resolveTimeout()
|
|
if got != tt.want {
|
|
t.Errorf("resolveTimeout() with env=%q = %v, want %v", tt.envVal, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunNormalizeText(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input interface{}
|
|
want string
|
|
}{
|
|
{"string", "hello world", "hello world"},
|
|
{"string array", []interface{}{"hello", " ", "world"}, "hello world"},
|
|
{"empty array", []interface{}{}, ""},
|
|
{"mixed array", []interface{}{"text", 123, "more"}, "textmore"},
|
|
{"nil", nil, ""},
|
|
{"number", 123, ""},
|
|
{"empty string", "", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := normalizeText(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("normalizeText(%v) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseJSONStream(t *testing.T) {
|
|
type testCase struct {
|
|
name string
|
|
input string
|
|
wantMessage string
|
|
wantThreadID string
|
|
}
|
|
|
|
longText := strings.Repeat("a", 2*1024*1024) // >1MB agent_message payload
|
|
|
|
tests := []testCase{
|
|
{
|
|
name: "thread started and agent message",
|
|
input: `{"type":"thread.started","thread_id":"abc-123"}
|
|
{"type":"item.completed","item":{"type":"agent_message","text":"Hello world"}}`,
|
|
wantMessage: "Hello world",
|
|
wantThreadID: "abc-123",
|
|
},
|
|
{
|
|
name: "multiple agent messages (last wins)",
|
|
input: `{"type":"item.completed","item":{"type":"agent_message","text":"First"}}
|
|
{"type":"item.completed","item":{"type":"agent_message","text":"Second"}}`,
|
|
wantMessage: "Second",
|
|
wantThreadID: "",
|
|
},
|
|
{
|
|
name: "text as array",
|
|
input: `{"type":"item.completed","item":{"type":"agent_message","text":["Hello"," ","World"]}}`,
|
|
wantMessage: "Hello World",
|
|
wantThreadID: "",
|
|
},
|
|
{
|
|
name: "ignore other event types",
|
|
input: `{"type":"other.event","data":"ignored"}
|
|
{"type":"item.completed","item":{"type":"other_type","text":"ignored"}}
|
|
{"type":"item.completed","item":{"type":"agent_message","text":"Valid"}}`,
|
|
wantMessage: "Valid",
|
|
wantThreadID: "",
|
|
},
|
|
{
|
|
name: "super long single line (>1MB)",
|
|
input: `{"type":"item.completed","item":{"type":"agent_message","text":"` + longText + `"}}`,
|
|
wantMessage: longText,
|
|
wantThreadID: "",
|
|
},
|
|
{
|
|
name: "empty input",
|
|
input: "",
|
|
wantMessage: "",
|
|
wantThreadID: "",
|
|
},
|
|
{
|
|
name: "item completed with nil item",
|
|
input: strings.Join([]string{
|
|
`{"type":"thread.started","thread_id":"nil-item-thread"}`,
|
|
`{"type":"item.completed","item":null}`,
|
|
}, "\n"),
|
|
wantMessage: "",
|
|
wantThreadID: "nil-item-thread",
|
|
},
|
|
{
|
|
name: "agent message with non-string text",
|
|
input: `{"type":"item.completed","item":{"type":"agent_message","text":12345}}`,
|
|
wantMessage: "",
|
|
wantThreadID: "",
|
|
},
|
|
{
|
|
name: "corrupted json does not break stream",
|
|
input: strings.Join([]string{
|
|
`{"type":"item.completed","item":{"type":"agent_message","text":"before"}}`,
|
|
`{"type":"item.completed","item":{"type":"agent_message","text":"broken"}`,
|
|
`{"type":"thread.started","thread_id":"after-thread"}`,
|
|
`{"type":"item.completed","item":{"type":"agent_message","text":"after"}}`,
|
|
}, "\n"),
|
|
wantMessage: "after",
|
|
wantThreadID: "after-thread",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gotMessage, gotThreadID := parseJSONStream(strings.NewReader(tt.input))
|
|
|
|
if gotMessage != tt.wantMessage {
|
|
t.Errorf("parseJSONStream() message = %q, want %q", gotMessage, tt.wantMessage)
|
|
}
|
|
if gotThreadID != tt.wantThreadID {
|
|
t.Errorf("parseJSONStream() threadID = %q, want %q", gotThreadID, tt.wantThreadID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunGetEnv(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
key string
|
|
defaultVal string
|
|
envVal string
|
|
setEnv bool
|
|
want string
|
|
}{
|
|
{"env set", "TEST_KEY", "default", "custom", true, "custom"},
|
|
{"env not set", "TEST_KEY_MISSING", "default", "", false, "default"},
|
|
{"env empty", "TEST_KEY_EMPTY", "default", "", true, "default"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
os.Unsetenv(tt.key)
|
|
if tt.setEnv {
|
|
os.Setenv(tt.key, tt.envVal)
|
|
defer os.Unsetenv(tt.key)
|
|
}
|
|
|
|
got := getEnv(tt.key, tt.defaultVal)
|
|
if got != tt.want {
|
|
t.Errorf("getEnv(%q, %q) = %q, want %q", tt.key, tt.defaultVal, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunTruncate(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
maxLen int
|
|
want string
|
|
}{
|
|
{"short string", "hello", 10, "hello"},
|
|
{"exact length", "hello", 5, "hello"},
|
|
{"truncate", "hello world", 5, "hello..."},
|
|
{"empty", "", 5, ""},
|
|
{"zero maxLen", "hello", 0, "..."},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := truncate(tt.input, tt.maxLen)
|
|
if got != tt.want {
|
|
t.Errorf("truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunMin(t *testing.T) {
|
|
tests := []struct {
|
|
a, b, want int
|
|
}{
|
|
{1, 2, 1},
|
|
{2, 1, 1},
|
|
{5, 5, 5},
|
|
{-1, 0, -1},
|
|
{0, -1, -1},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run("", func(t *testing.T) {
|
|
got := min(tt.a, tt.b)
|
|
if got != tt.want {
|
|
t.Errorf("min(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunLogFunctions(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
tempDir := t.TempDir()
|
|
t.Setenv("TMPDIR", tempDir)
|
|
|
|
logger, err := NewLogger()
|
|
if err != nil {
|
|
t.Fatalf("NewLogger() error = %v", err)
|
|
}
|
|
setLogger(logger)
|
|
defer closeLogger()
|
|
|
|
logInfo("info message")
|
|
logWarn("warn message")
|
|
logError("error message")
|
|
|
|
logger.Flush()
|
|
|
|
data, err := os.ReadFile(logger.Path())
|
|
if err != nil {
|
|
t.Fatalf("failed to read log file: %v", err)
|
|
}
|
|
|
|
output := string(data)
|
|
|
|
if !strings.Contains(output, "INFO: info message") {
|
|
t.Errorf("logInfo output missing, got: %s", output)
|
|
}
|
|
if !strings.Contains(output, "WARN: warn message") {
|
|
t.Errorf("logWarn output missing, got: %s", output)
|
|
}
|
|
if !strings.Contains(output, "ERROR: error message") {
|
|
t.Errorf("logError output missing, got: %s", output)
|
|
}
|
|
}
|
|
|
|
func TestRunPrintHelp(t *testing.T) {
|
|
// Capture stdout
|
|
oldStdout := os.Stdout
|
|
r, w, _ := os.Pipe()
|
|
os.Stdout = w
|
|
|
|
printHelp()
|
|
|
|
w.Close()
|
|
os.Stdout = oldStdout
|
|
|
|
var buf bytes.Buffer
|
|
io.Copy(&buf, r)
|
|
output := buf.String()
|
|
|
|
expectedPhrases := []string{
|
|
"codex-wrapper",
|
|
"Usage:",
|
|
"resume",
|
|
"CODEX_TIMEOUT",
|
|
"Exit Codes:",
|
|
}
|
|
|
|
for _, phrase := range expectedPhrases {
|
|
if !strings.Contains(output, phrase) {
|
|
t.Errorf("printHelp() missing phrase %q", phrase)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tests for isTerminal with mock
|
|
func TestRunIsTerminal(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockFn func() bool
|
|
want bool
|
|
}{
|
|
{"is terminal", func() bool { return true }, true},
|
|
{"is not terminal", func() bool { return false }, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
isTerminalFn = tt.mockFn
|
|
got := isTerminal()
|
|
if got != tt.want {
|
|
t.Errorf("isTerminal() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Tests for readPipedTask with mock
|
|
func TestReadPipedTask(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
tests := []struct {
|
|
name string
|
|
isTerminal bool
|
|
stdin io.Reader
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{"terminal mode", true, strings.NewReader("ignored"), "", false},
|
|
{"piped with data", false, strings.NewReader("task from pipe"), "task from pipe", false},
|
|
{"piped empty", false, strings.NewReader(""), "", false},
|
|
{"piped read error", false, errReader{errors.New("boom")}, "", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
isTerminalFn = func() bool { return tt.isTerminal }
|
|
stdinReader = tt.stdin
|
|
|
|
got, err := readPipedTask()
|
|
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("readPipedTask() expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("readPipedTask() unexpected error: %v", err)
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("readPipedTask() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseJSONStream_CoverageSuite(t *testing.T) {
|
|
suite := []struct {
|
|
name string
|
|
fn func(*testing.T)
|
|
}{
|
|
{"TestRunParseArgs_NewMode", TestRunParseArgs_NewMode},
|
|
{"TestRunParseArgs_ResumeMode", TestRunParseArgs_ResumeMode},
|
|
{"TestRunShouldUseStdin", TestRunShouldUseStdin},
|
|
{"TestRunBuildCodexArgs_NewMode", TestRunBuildCodexArgs_NewMode},
|
|
{"TestRunBuildCodexArgs_ResumeMode", TestRunBuildCodexArgs_ResumeMode},
|
|
{"TestRunResolveTimeout", TestRunResolveTimeout},
|
|
{"TestRunNormalizeText", TestRunNormalizeText},
|
|
{"TestParseJSONStream", TestParseJSONStream},
|
|
{"TestRunGetEnv", TestRunGetEnv},
|
|
{"TestRunTruncate", TestRunTruncate},
|
|
{"TestRunMin", TestRunMin},
|
|
{"TestRunLogFunctions", TestRunLogFunctions},
|
|
{"TestRunPrintHelp", TestRunPrintHelp},
|
|
{"TestRunIsTerminal", TestRunIsTerminal},
|
|
{"TestRunCodexProcess_CommandNotFound", TestRunCodexProcess_CommandNotFound},
|
|
{"TestRunCodexProcess_WithEcho", TestRunCodexProcess_WithEcho},
|
|
{"TestRunCodexProcess_NoMessage", TestRunCodexProcess_NoMessage},
|
|
{"TestRunCodexProcess_WithStdin", TestRunCodexProcess_WithStdin},
|
|
{"TestRunCodexProcess_ExitError", TestRunCodexProcess_ExitError},
|
|
{"TestRunCodexProcess_ContextTimeout", TestRunCodexProcess_ContextTimeout},
|
|
{"TestRunCodexProcess_SignalCancellation", TestRunCodexProcess_SignalCancellation},
|
|
{"TestRunCancelReason", TestRunCancelReason},
|
|
{"TestRunDefaultIsTerminal", TestRunDefaultIsTerminal},
|
|
{"TestRunTerminateProcess_NoProcess", TestRunTerminateProcess_NoProcess},
|
|
{"TestRun_Version", TestRun_Version},
|
|
{"TestRun_VersionShort", TestRun_VersionShort},
|
|
{"TestRun_Help", TestRun_Help},
|
|
{"TestRun_HelpShort", TestRun_HelpShort},
|
|
{"TestRun_NoArgs", TestRun_NoArgs},
|
|
{"TestRun_ExplicitStdinEmpty", TestRun_ExplicitStdinEmpty},
|
|
{"TestRun_ExplicitStdinReadError", TestRun_ExplicitStdinReadError},
|
|
{"TestRun_CommandFails", TestRun_CommandFails},
|
|
{"TestRun_SuccessfulExecution", TestRun_SuccessfulExecution},
|
|
{"TestRun_ExplicitStdinSuccess", TestRun_ExplicitStdinSuccess},
|
|
{"TestRun_PipedTaskReadError", TestRun_PipedTaskReadError},
|
|
{"TestRun_PipedTaskSuccess", TestRun_PipedTaskSuccess},
|
|
{"TestRun_CleanupHookAlwaysCalled", TestRun_CleanupHookAlwaysCalled},
|
|
}
|
|
|
|
for _, tt := range suite {
|
|
t.Run(tt.name, tt.fn)
|
|
}
|
|
}
|
|
|
|
// Tests for runCodexProcess with mock command
|
|
func TestRunCodexProcess_CommandNotFound(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
codexCommand = "nonexistent-command-xyz"
|
|
|
|
_, _, exitCode := runCodexProcess(context.Background(), []string{"arg1"}, "task", false, 10)
|
|
|
|
if exitCode != 127 {
|
|
t.Errorf("runCodexProcess() exitCode = %d, want 127 for command not found", exitCode)
|
|
}
|
|
}
|
|
|
|
func TestRunCodexProcess_WithEcho(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
// Use echo to simulate codex output
|
|
codexCommand = "echo"
|
|
|
|
jsonOutput := `{"type":"thread.started","thread_id":"test-session"}
|
|
{"type":"item.completed","item":{"type":"agent_message","text":"Test output"}}`
|
|
|
|
message, threadID, exitCode := runCodexProcess(context.Background(), []string{jsonOutput}, "", false, 10)
|
|
|
|
if exitCode != 0 {
|
|
t.Errorf("runCodexProcess() exitCode = %d, want 0", exitCode)
|
|
}
|
|
if message != "Test output" {
|
|
t.Errorf("runCodexProcess() message = %q, want %q", message, "Test output")
|
|
}
|
|
if threadID != "test-session" {
|
|
t.Errorf("runCodexProcess() threadID = %q, want %q", threadID, "test-session")
|
|
}
|
|
}
|
|
|
|
func TestRunCodexProcess_NoMessage(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
codexCommand = "echo"
|
|
|
|
// Output without agent_message
|
|
jsonOutput := `{"type":"thread.started","thread_id":"test-session"}`
|
|
|
|
_, _, exitCode := runCodexProcess(context.Background(), []string{jsonOutput}, "", false, 10)
|
|
|
|
if exitCode != 1 {
|
|
t.Errorf("runCodexProcess() exitCode = %d, want 1 for no message", exitCode)
|
|
}
|
|
}
|
|
|
|
func TestRunCodexProcess_WithStdin(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
// Use cat to echo stdin back
|
|
codexCommand = "cat"
|
|
|
|
message, _, exitCode := runCodexProcess(context.Background(), []string{}, `{"type":"item.completed","item":{"type":"agent_message","text":"from stdin"}}`, true, 10)
|
|
|
|
if exitCode != 0 {
|
|
t.Errorf("runCodexProcess() exitCode = %d, want 0", exitCode)
|
|
}
|
|
if message != "from stdin" {
|
|
t.Errorf("runCodexProcess() message = %q, want %q", message, "from stdin")
|
|
}
|
|
}
|
|
|
|
func TestRunCodexProcess_ExitError(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
// Use false command which exits with code 1
|
|
codexCommand = "false"
|
|
|
|
_, _, exitCode := runCodexProcess(context.Background(), []string{}, "", false, 10)
|
|
|
|
if exitCode == 0 {
|
|
t.Errorf("runCodexProcess() exitCode = 0, want non-zero for failed command")
|
|
}
|
|
}
|
|
|
|
func TestRunCodexProcess_ContextTimeout(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
codexCommand = "sleep"
|
|
|
|
_, _, exitCode := runCodexProcess(context.Background(), []string{"2"}, "", false, 1)
|
|
|
|
if exitCode != 124 {
|
|
t.Fatalf("runCodexProcess() exitCode = %d, want 124 on timeout", exitCode)
|
|
}
|
|
}
|
|
|
|
func TestRunCodexProcess_SignalCancellation(t *testing.T) {
|
|
defer resetTestHooks()
|
|
defer signal.Reset(syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
codexCommand = "sleep"
|
|
sigCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
go func() {
|
|
time.Sleep(100 * time.Millisecond)
|
|
_ = syscall.Kill(os.Getpid(), syscall.SIGINT)
|
|
}()
|
|
|
|
_, _, exitCode := runCodexProcess(sigCtx, []string{"5"}, "", false, 10)
|
|
|
|
if exitCode != 130 {
|
|
t.Fatalf("runCodexProcess() exitCode = %d, want 130 on signal", exitCode)
|
|
}
|
|
}
|
|
|
|
func TestRunCancelReason(t *testing.T) {
|
|
if got := cancelReason(nil); got != "Context cancelled" {
|
|
t.Fatalf("cancelReason(nil) = %q, want Context cancelled", got)
|
|
}
|
|
}
|
|
|
|
func TestRunDefaultIsTerminal(t *testing.T) {
|
|
// This test just ensures defaultIsTerminal doesn't panic
|
|
// The actual result depends on the test environment
|
|
_ = defaultIsTerminal()
|
|
}
|
|
|
|
func TestRunTerminateProcess_NoProcess(t *testing.T) {
|
|
timer := terminateProcess(nil)
|
|
|
|
if timer != nil {
|
|
t.Fatalf("terminateProcess(nil) expected nil timer, got non-nil")
|
|
}
|
|
}
|
|
|
|
// Tests for run() function
|
|
func TestRun_Version(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
os.Args = []string{"codex-wrapper", "--version"}
|
|
exitCode := run()
|
|
if exitCode != 0 {
|
|
t.Errorf("run() with --version returned %d, want 0", exitCode)
|
|
}
|
|
}
|
|
|
|
func TestRun_VersionShort(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
os.Args = []string{"codex-wrapper", "-v"}
|
|
exitCode := run()
|
|
if exitCode != 0 {
|
|
t.Errorf("run() with -v returned %d, want 0", exitCode)
|
|
}
|
|
}
|
|
|
|
func TestRun_Help(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
os.Args = []string{"codex-wrapper", "--help"}
|
|
exitCode := run()
|
|
if exitCode != 0 {
|
|
t.Errorf("run() with --help returned %d, want 0", exitCode)
|
|
}
|
|
}
|
|
|
|
func TestRun_HelpShort(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
os.Args = []string{"codex-wrapper", "-h"}
|
|
exitCode := run()
|
|
if exitCode != 0 {
|
|
t.Errorf("run() with -h returned %d, want 0", exitCode)
|
|
}
|
|
}
|
|
|
|
func TestRun_NoArgs(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
os.Args = []string{"codex-wrapper"}
|
|
exitCode := run()
|
|
if exitCode != 1 {
|
|
t.Errorf("run() with no args returned %d, want 1", exitCode)
|
|
}
|
|
}
|
|
|
|
func TestRun_ExplicitStdinEmpty(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
os.Args = []string{"codex-wrapper", "-"}
|
|
stdinReader = strings.NewReader("")
|
|
isTerminalFn = func() bool { return false }
|
|
|
|
exitCode := run()
|
|
if exitCode != 1 {
|
|
t.Errorf("run() with empty stdin returned %d, want 1", exitCode)
|
|
}
|
|
}
|
|
|
|
func TestRun_ExplicitStdinReadError(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
tempDir := t.TempDir()
|
|
t.Setenv("TMPDIR", tempDir)
|
|
logPath := filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d.log", os.Getpid()))
|
|
|
|
var logOutput string
|
|
cleanupHook = func() {
|
|
data, err := os.ReadFile(logPath)
|
|
if err == nil {
|
|
logOutput = string(data)
|
|
}
|
|
}
|
|
|
|
os.Args = []string{"codex-wrapper", "-"}
|
|
stdinReader = errReader{errors.New("broken stdin")}
|
|
isTerminalFn = func() bool { return false }
|
|
|
|
exitCode := run()
|
|
|
|
if exitCode != 1 {
|
|
t.Fatalf("run() with stdin read error returned %d, want 1", exitCode)
|
|
}
|
|
if !strings.Contains(logOutput, "Failed to read stdin: broken stdin") {
|
|
t.Fatalf("log missing read error entry, got %q", logOutput)
|
|
}
|
|
// Log file is kept for debugging after run completes
|
|
if _, err := os.Stat(logPath); os.IsNotExist(err) {
|
|
t.Fatalf("log file should exist after run for debugging")
|
|
}
|
|
defer os.Remove(logPath)
|
|
}
|
|
|
|
func TestRun_CommandFails(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
os.Args = []string{"codex-wrapper", "task"}
|
|
stdinReader = strings.NewReader("")
|
|
isTerminalFn = func() bool { return true }
|
|
codexCommand = "false"
|
|
|
|
exitCode := run()
|
|
if exitCode == 0 {
|
|
t.Errorf("run() with failing command returned 0, want non-zero")
|
|
}
|
|
}
|
|
|
|
func TestRun_SuccessfulExecution(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
stdout := captureStdout()
|
|
|
|
codexCommand = createFakeCodexScript(t, "tid-123", "ok")
|
|
stdinReader = strings.NewReader("")
|
|
isTerminalFn = func() bool { return true }
|
|
os.Args = []string{"codex-wrapper", "task"}
|
|
|
|
exitCode := run()
|
|
if exitCode != 0 {
|
|
t.Fatalf("run() returned %d, want 0", exitCode)
|
|
}
|
|
|
|
restoreStdout(stdout)
|
|
output := stdout.String()
|
|
if !strings.Contains(output, "ok") {
|
|
t.Fatalf("stdout missing agent message, got %q", output)
|
|
}
|
|
if !strings.Contains(output, "SESSION_ID: tid-123") {
|
|
t.Fatalf("stdout missing session id, got %q", output)
|
|
}
|
|
}
|
|
|
|
func TestRun_ExplicitStdinSuccess(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
stdout := captureStdout()
|
|
|
|
codexCommand = createFakeCodexScript(t, "tid-stdin", "from-stdin")
|
|
stdinReader = strings.NewReader("line1\nline2")
|
|
isTerminalFn = func() bool { return false }
|
|
os.Args = []string{"codex-wrapper", "-"}
|
|
|
|
exitCode := run()
|
|
restoreStdout(stdout)
|
|
if exitCode != 0 {
|
|
t.Fatalf("run() returned %d, want 0", exitCode)
|
|
}
|
|
|
|
output := stdout.String()
|
|
if !strings.Contains(output, "from-stdin") {
|
|
t.Fatalf("stdout missing agent message for stdin, got %q", output)
|
|
}
|
|
if !strings.Contains(output, "SESSION_ID: tid-stdin") {
|
|
t.Fatalf("stdout missing session id for stdin, got %q", output)
|
|
}
|
|
}
|
|
|
|
func TestRun_PipedTaskReadError(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
tempDir := t.TempDir()
|
|
t.Setenv("TMPDIR", tempDir)
|
|
logPath := filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d.log", os.Getpid()))
|
|
|
|
var logOutput string
|
|
cleanupHook = func() {
|
|
data, err := os.ReadFile(logPath)
|
|
if err == nil {
|
|
logOutput = string(data)
|
|
}
|
|
}
|
|
|
|
codexCommand = createFakeCodexScript(t, "tid-pipe", "piped-task")
|
|
isTerminalFn = func() bool { return false }
|
|
stdinReader = errReader{errors.New("pipe failure")}
|
|
os.Args = []string{"codex-wrapper", "cli-task"}
|
|
|
|
exitCode := run()
|
|
|
|
if exitCode != 1 {
|
|
t.Fatalf("run() with piped read error returned %d, want 1", exitCode)
|
|
}
|
|
if !strings.Contains(logOutput, "Failed to read piped stdin: read stdin: pipe failure") {
|
|
t.Fatalf("log missing piped read error entry, got %q", logOutput)
|
|
}
|
|
// Log file is kept for debugging after run completes
|
|
if _, err := os.Stat(logPath); os.IsNotExist(err) {
|
|
t.Fatalf("log file should exist after run for debugging")
|
|
}
|
|
defer os.Remove(logPath)
|
|
}
|
|
|
|
func TestRun_PipedTaskSuccess(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
stdout := captureStdout()
|
|
|
|
codexCommand = createFakeCodexScript(t, "tid-pipe", "piped-task")
|
|
isTerminalFn = func() bool { return false }
|
|
stdinReader = strings.NewReader("piped task text")
|
|
os.Args = []string{"codex-wrapper", "cli-task"}
|
|
|
|
exitCode := run()
|
|
restoreStdout(stdout)
|
|
if exitCode != 0 {
|
|
t.Fatalf("run() returned %d, want 0", exitCode)
|
|
}
|
|
|
|
output := stdout.String()
|
|
if !strings.Contains(output, "piped-task") {
|
|
t.Fatalf("stdout missing agent message for piped task, got %q", output)
|
|
}
|
|
if !strings.Contains(output, "SESSION_ID: tid-pipe") {
|
|
t.Fatalf("stdout missing session id for piped task, got %q", output)
|
|
}
|
|
}
|
|
|
|
func TestRun_LoggerLifecycle(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
tempDir := t.TempDir()
|
|
t.Setenv("TMPDIR", tempDir)
|
|
logPath := filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d.log", os.Getpid()))
|
|
|
|
stdout := captureStdout()
|
|
|
|
codexCommand = createFakeCodexScript(t, "tid-logger", "ok")
|
|
isTerminalFn = func() bool { return true }
|
|
stdinReader = strings.NewReader("")
|
|
os.Args = []string{"codex-wrapper", "task"}
|
|
|
|
var fileExisted bool
|
|
cleanupHook = func() {
|
|
if _, err := os.Stat(logPath); err == nil {
|
|
fileExisted = true
|
|
}
|
|
}
|
|
|
|
exitCode := run()
|
|
restoreStdout(stdout)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("run() returned %d, want 0", exitCode)
|
|
}
|
|
if !fileExisted {
|
|
t.Fatalf("log file was not present during run")
|
|
}
|
|
// Log file is kept for debugging after run completes
|
|
if _, err := os.Stat(logPath); os.IsNotExist(err) {
|
|
t.Fatalf("log file should exist after run for debugging")
|
|
}
|
|
defer os.Remove(logPath)
|
|
}
|
|
|
|
func TestRun_LoggerRemovedOnSignal(t *testing.T) {
|
|
defer resetTestHooks()
|
|
defer signal.Reset(syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
tempDir := t.TempDir()
|
|
t.Setenv("TMPDIR", tempDir)
|
|
logPath := filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d.log", os.Getpid()))
|
|
|
|
scriptPath := filepath.Join(tempDir, "sleepy-codex.sh")
|
|
script := `#!/bin/sh
|
|
printf '%s\n' '{"type":"thread.started","thread_id":"sig-thread"}'
|
|
sleep 5
|
|
printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"late"}}'`
|
|
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("failed to write script: %v", err)
|
|
}
|
|
|
|
codexCommand = scriptPath
|
|
isTerminalFn = func() bool { return true }
|
|
stdinReader = strings.NewReader("")
|
|
os.Args = []string{"codex-wrapper", "task"}
|
|
|
|
exitCh := make(chan int, 1)
|
|
go func() {
|
|
exitCh <- run()
|
|
}()
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(logPath); err == nil {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
|
|
_ = syscall.Kill(os.Getpid(), syscall.SIGINT)
|
|
|
|
var exitCode int
|
|
select {
|
|
case exitCode = <-exitCh:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatalf("run() did not return after signal")
|
|
}
|
|
|
|
if exitCode != 130 {
|
|
t.Fatalf("run() exit code = %d, want 130 on signal", exitCode)
|
|
}
|
|
// Log file is kept for debugging even after signal exit
|
|
if _, err := os.Stat(logPath); os.IsNotExist(err) {
|
|
t.Fatalf("log file should exist after signal exit for debugging")
|
|
}
|
|
defer os.Remove(logPath)
|
|
}
|
|
|
|
func TestRun_CleanupHookAlwaysCalled(t *testing.T) {
|
|
defer resetTestHooks()
|
|
|
|
called := false
|
|
cleanupHook = func() { called = true }
|
|
|
|
os.Args = []string{"codex-wrapper", "--version"}
|
|
|
|
exitCode := run()
|
|
if exitCode != 0 {
|
|
t.Fatalf("run() with --version returned %d, want 0", exitCode)
|
|
}
|
|
|
|
if !called {
|
|
t.Fatalf("cleanup hook was not invoked")
|
|
}
|
|
}
|