- agent-readable-repository-refactor 완료: 기존 테스트 파일 아카이브 이동 - 새 테스트 파일 추가 (edge, node, client, config, readability) - readability_audit 스크립트 및 baseline 추가 - roadmap/SDD 문서 갱신 - agent-client/pi/extensions/openai-sampling-parameters 추가
418 lines
12 KiB
Go
418 lines
12 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"iop/apps/node/internal/runtime"
|
|
"iop/packages/go/config"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestCLIOneshotExecutorCwd(t *testing.T) {
|
|
c := New(config.CLIConf{
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"oneshot-pwd": {
|
|
Command: "pwd",
|
|
},
|
|
},
|
|
}, nil)
|
|
tmpDir := t.TempDir()
|
|
spec := runtime.ExecutionSpec{
|
|
Target: "oneshot-pwd",
|
|
Workspace: tmpDir,
|
|
}
|
|
sink := &testSink{}
|
|
err := c.Execute(context.Background(), spec, sink)
|
|
if err != nil {
|
|
t.Fatalf("Execute failed: %v", err)
|
|
}
|
|
|
|
var output string
|
|
for _, ev := range sink.events {
|
|
if ev.Type == runtime.EventTypeDelta {
|
|
output += ev.Delta
|
|
}
|
|
}
|
|
resolved, err := os.Readlink(tmpDir)
|
|
if err != nil {
|
|
resolved = tmpDir
|
|
}
|
|
if !strings.Contains(output, tmpDir) && !strings.Contains(output, resolved) {
|
|
t.Errorf("expected output to contain workspace path %q, got: %q", tmpDir, output)
|
|
}
|
|
}
|
|
|
|
func TestCLIPersistentNonTerminalCwd(t *testing.T) {
|
|
c := New(config.CLIConf{
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"persistent-pwd": {
|
|
Command: "sh",
|
|
Persistent: true,
|
|
},
|
|
},
|
|
}, nil)
|
|
tmpDir := t.TempDir()
|
|
spec := runtime.ExecutionSpec{
|
|
Target: "persistent-pwd",
|
|
Workspace: tmpDir,
|
|
SessionID: "sess-cwd-nt",
|
|
SessionMode: runtime.SessionModeCreateIfMissing,
|
|
Input: map[string]any{"prompt": "pwd\n"},
|
|
}
|
|
sink := &testSink{}
|
|
err := c.Execute(context.Background(), spec, sink)
|
|
if err != nil {
|
|
t.Fatalf("Execute failed: %v", err)
|
|
}
|
|
defer func() {
|
|
_ = c.Stop(context.Background())
|
|
}()
|
|
|
|
var output string
|
|
success := false
|
|
resolved, err := os.Readlink(tmpDir)
|
|
if err != nil {
|
|
resolved = tmpDir
|
|
}
|
|
// Wait up to 2 seconds for output
|
|
for i := 0; i < 20; i++ {
|
|
output = ""
|
|
for _, ev := range sink.events {
|
|
if ev.Type == runtime.EventTypeDelta {
|
|
output += ev.Delta
|
|
}
|
|
}
|
|
if strings.Contains(output, tmpDir) || strings.Contains(output, resolved) {
|
|
success = true
|
|
break
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
if !success {
|
|
t.Errorf("expected output to contain workspace path %q, got: %q", tmpDir, output)
|
|
}
|
|
}
|
|
|
|
func TestCLIPersistentTerminalCwd(t *testing.T) {
|
|
c := New(config.CLIConf{
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"persistent-term-pwd": {
|
|
Command: "sh",
|
|
Persistent: true,
|
|
Terminal: true,
|
|
},
|
|
},
|
|
}, nil)
|
|
tmpDir := t.TempDir()
|
|
spec := runtime.ExecutionSpec{
|
|
Target: "persistent-term-pwd",
|
|
Workspace: tmpDir,
|
|
SessionID: "sess-cwd-term",
|
|
SessionMode: runtime.SessionModeCreateIfMissing,
|
|
Input: map[string]any{"prompt": "pwd"},
|
|
}
|
|
sink := &testSink{}
|
|
err := c.Execute(context.Background(), spec, sink)
|
|
if err != nil {
|
|
t.Fatalf("Execute failed: %v", err)
|
|
}
|
|
defer func() {
|
|
_ = c.Stop(context.Background())
|
|
}()
|
|
|
|
var output string
|
|
success := false
|
|
resolved, err := os.Readlink(tmpDir)
|
|
if err != nil {
|
|
resolved = tmpDir
|
|
}
|
|
// Wait up to 2 seconds for output
|
|
for i := 0; i < 20; i++ {
|
|
output = ""
|
|
for _, ev := range sink.events {
|
|
if ev.Type == runtime.EventTypeDelta {
|
|
output += ev.Delta
|
|
}
|
|
}
|
|
if strings.Contains(output, tmpDir) || strings.Contains(output, resolved) {
|
|
success = true
|
|
break
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
if !success {
|
|
t.Errorf("expected output to contain workspace path %q, got: %q", tmpDir, output)
|
|
}
|
|
}
|
|
|
|
func TestCLIWorkspacePreflightFailures(t *testing.T) {
|
|
// 1. prepareWorkspaceDir helper tests
|
|
t.Run("Helper", func(t *testing.T) {
|
|
// Blank workspace
|
|
dir, err := prepareWorkspaceDir("")
|
|
if err != nil || dir != "" {
|
|
t.Errorf("expected empty dir and nil error for blank workspace, got dir=%q, err=%v", dir, err)
|
|
}
|
|
dir, err = prepareWorkspaceDir(" ")
|
|
if err != nil || dir != "" {
|
|
t.Errorf("expected empty dir and nil error for whitespace workspace, got dir=%q, err=%v", dir, err)
|
|
}
|
|
|
|
// Not found directory
|
|
nonExistentPath := "/nonexistent/path/for/workspace/test"
|
|
dir, err = prepareWorkspaceDir(nonExistentPath)
|
|
if err == nil || !strings.Contains(err.Error(), "cli adapter: workspace not found") {
|
|
t.Errorf("expected 'workspace not found' error, got dir=%q, err=%v", dir, err)
|
|
}
|
|
|
|
// File is not a directory
|
|
tmpFile, err := os.CreateTemp("", "test-workspace-file-*")
|
|
if err != nil {
|
|
t.Fatalf("failed to create temp file: %v", err)
|
|
}
|
|
defer os.Remove(tmpFile.Name())
|
|
_ = tmpFile.Close()
|
|
|
|
dir, err = prepareWorkspaceDir(tmpFile.Name())
|
|
if err == nil || !strings.Contains(err.Error(), "cli adapter: workspace is not a directory") {
|
|
t.Errorf("expected 'workspace is not a directory' error, got dir=%q, err=%v", dir, err)
|
|
}
|
|
|
|
// Inaccessible directory
|
|
if os.Geteuid() != 0 {
|
|
// Create a directory and make it inaccessible
|
|
inaccessibleDir := t.TempDir() + "/inaccessible"
|
|
if err := os.Mkdir(inaccessibleDir, 0000); err != nil {
|
|
t.Fatalf("failed to create inaccessible dir: %v", err)
|
|
}
|
|
dir, err = prepareWorkspaceDir(inaccessibleDir)
|
|
if err == nil || !strings.Contains(err.Error(), "cli adapter: workspace inaccessible") {
|
|
t.Errorf("expected 'workspace inaccessible' error, got dir=%q, err=%v", dir, err)
|
|
}
|
|
// Restore permission for cleanup
|
|
_ = os.Chmod(inaccessibleDir, 0700)
|
|
} else {
|
|
t.Log("running as root; skipping inaccessible workspace helper test")
|
|
}
|
|
})
|
|
|
|
// 2. Execute path preflight error propagation
|
|
t.Run("ExecuteOneshot", func(t *testing.T) {
|
|
c := New(config.CLIConf{
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"oneshot-pwd": {
|
|
Command: "pwd",
|
|
},
|
|
},
|
|
}, nil)
|
|
|
|
spec := runtime.ExecutionSpec{
|
|
RunID: "oneshot-run-id",
|
|
Target: "oneshot-pwd",
|
|
Workspace: "/nonexistent/path/for/workspace/test",
|
|
}
|
|
sink := &testSink{}
|
|
err := c.Execute(context.Background(), spec, sink)
|
|
if err == nil {
|
|
t.Fatal("expected execute to fail for nonexistent workspace")
|
|
}
|
|
if !strings.Contains(err.Error(), "cli adapter: workspace not found") {
|
|
t.Errorf("expected 'workspace not found' error, got: %v", err)
|
|
}
|
|
|
|
// Verify sink EventTypeError event
|
|
hasErrorEvent := false
|
|
for _, ev := range sink.events {
|
|
if ev.Type == runtime.EventTypeError {
|
|
hasErrorEvent = true
|
|
if ev.RunID != spec.RunID {
|
|
t.Errorf("expected EventTypeError RunID to be %q, got %q", spec.RunID, ev.RunID)
|
|
}
|
|
if !strings.Contains(ev.Error, "cli adapter: workspace not found") {
|
|
t.Errorf("expected EventTypeError Error to contain 'workspace not found', got %q", ev.Error)
|
|
}
|
|
}
|
|
}
|
|
if !hasErrorEvent {
|
|
t.Errorf("expected sink to contain EventTypeError event, but it did not")
|
|
}
|
|
})
|
|
|
|
t.Run("ExecutePersistent", func(t *testing.T) {
|
|
c := New(config.CLIConf{
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"persistent-pwd": {
|
|
Command: "sh",
|
|
Persistent: true,
|
|
},
|
|
},
|
|
}, nil)
|
|
|
|
spec := runtime.ExecutionSpec{
|
|
RunID: "persistent-run-id",
|
|
Target: "persistent-pwd",
|
|
Workspace: "/nonexistent/path/for/workspace/test",
|
|
SessionID: "sess-preflight-fail",
|
|
SessionMode: runtime.SessionModeCreateIfMissing,
|
|
Input: map[string]any{"prompt": "pwd\n"},
|
|
}
|
|
sink := &testSink{}
|
|
err := c.Execute(context.Background(), spec, sink)
|
|
if err == nil {
|
|
t.Fatal("expected execute to fail for nonexistent workspace")
|
|
}
|
|
if !strings.Contains(err.Error(), "cli adapter: workspace not found") {
|
|
t.Errorf("expected 'workspace not found' error, got: %v", err)
|
|
}
|
|
|
|
// Verify sink EventTypeError event
|
|
hasErrorEvent := false
|
|
for _, ev := range sink.events {
|
|
if ev.Type == runtime.EventTypeError {
|
|
hasErrorEvent = true
|
|
if ev.RunID != spec.RunID {
|
|
t.Errorf("expected EventTypeError RunID to be %q, got %q", spec.RunID, ev.RunID)
|
|
}
|
|
if !strings.Contains(ev.Error, "cli adapter: workspace not found") {
|
|
t.Errorf("expected EventTypeError Error to contain 'workspace not found', got %q", ev.Error)
|
|
}
|
|
}
|
|
}
|
|
if !hasErrorEvent {
|
|
t.Errorf("expected sink to contain EventTypeError event, but it did not")
|
|
}
|
|
})
|
|
|
|
t.Run("ExecuteCodexAppServer", func(t *testing.T) {
|
|
c := New(config.CLIConf{
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"codex-app": {
|
|
Mode: modeCodexAppServer,
|
|
Command: "codex",
|
|
Persistent: true,
|
|
Args: []string{"--app-server"},
|
|
},
|
|
},
|
|
}, nil)
|
|
|
|
spec := runtime.ExecutionSpec{
|
|
RunID: "codex-run-id",
|
|
Target: "codex-app",
|
|
Workspace: "/nonexistent/path/for/workspace/test",
|
|
SessionID: "sess-codex-preflight-fail",
|
|
SessionMode: runtime.SessionModeCreateIfMissing,
|
|
Input: map[string]any{"prompt": "some prompt"},
|
|
}
|
|
sink := &testSink{}
|
|
err := c.Execute(context.Background(), spec, sink)
|
|
if err == nil {
|
|
t.Fatal("expected execute to fail for nonexistent workspace")
|
|
}
|
|
if !strings.Contains(err.Error(), "cli adapter: start codex app-server") ||
|
|
!strings.Contains(err.Error(), "cli adapter: workspace not found") {
|
|
t.Errorf("expected 'start codex app-server' and 'workspace not found' errors, got: %v", err)
|
|
}
|
|
|
|
// Verify sink EventTypeError event
|
|
hasErrorEvent := false
|
|
for _, ev := range sink.events {
|
|
if ev.Type == runtime.EventTypeError {
|
|
hasErrorEvent = true
|
|
if ev.RunID != spec.RunID {
|
|
t.Errorf("expected EventTypeError RunID to be %q, got %q", spec.RunID, ev.RunID)
|
|
}
|
|
if !strings.Contains(ev.Error, "cli adapter: start codex app-server") ||
|
|
!strings.Contains(ev.Error, "cli adapter: workspace not found") {
|
|
t.Errorf("expected EventTypeError Error to contain 'start codex app-server' and 'workspace not found', got %q", ev.Error)
|
|
}
|
|
}
|
|
}
|
|
if !hasErrorEvent {
|
|
t.Errorf("expected sink to contain EventTypeError event, but it did not")
|
|
}
|
|
})
|
|
|
|
t.Run("ExecuteOpencodeSSE", func(t *testing.T) {
|
|
c := New(config.CLIConf{
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"opencode-sse-target": {
|
|
Mode: modeOpencodeSSE,
|
|
Command: "opencode",
|
|
Persistent: true,
|
|
Args: []string{"serve"}, // triggers opencode-sse mode
|
|
},
|
|
},
|
|
}, nil)
|
|
|
|
spec := runtime.ExecutionSpec{
|
|
RunID: "opencode-run-id",
|
|
Target: "opencode-sse-target",
|
|
Workspace: "/nonexistent/path/for/workspace/test",
|
|
SessionID: "sess-opencode-preflight-fail",
|
|
SessionMode: runtime.SessionModeCreateIfMissing,
|
|
Input: map[string]any{"prompt": "some prompt"},
|
|
}
|
|
sink := &testSink{}
|
|
err := c.Execute(context.Background(), spec, sink)
|
|
if err == nil {
|
|
t.Fatal("expected execute to fail for nonexistent workspace")
|
|
}
|
|
if !strings.Contains(err.Error(), "cli adapter: start opencode server") ||
|
|
!strings.Contains(err.Error(), "cli adapter: workspace not found") {
|
|
t.Errorf("expected 'start opencode server' and 'workspace not found' errors, got: %v", err)
|
|
}
|
|
|
|
// Verify sink EventTypeError event
|
|
hasErrorEvent := false
|
|
for _, ev := range sink.events {
|
|
if ev.Type == runtime.EventTypeError {
|
|
hasErrorEvent = true
|
|
if ev.RunID != spec.RunID {
|
|
t.Errorf("expected EventTypeError RunID to be %q, got %q", spec.RunID, ev.RunID)
|
|
}
|
|
if !strings.Contains(ev.Error, "cli adapter: start opencode server") ||
|
|
!strings.Contains(ev.Error, "cli adapter: workspace not found") {
|
|
t.Errorf("expected EventTypeError Error to contain 'start opencode server' and 'workspace not found', got %q", ev.Error)
|
|
}
|
|
}
|
|
}
|
|
if !hasErrorEvent {
|
|
t.Errorf("expected sink to contain EventTypeError event, but it did not")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCLIProcessExitFailureRemainsDistinct(t *testing.T) {
|
|
c := New(config.CLIConf{
|
|
Profiles: map[string]config.CLIProfileConf{
|
|
"fail-oneshot": {
|
|
Command: "sh",
|
|
Args: []string{"-c", "exit 7"},
|
|
},
|
|
},
|
|
}, nil)
|
|
|
|
tmpDir := t.TempDir()
|
|
spec := runtime.ExecutionSpec{
|
|
Target: "fail-oneshot",
|
|
Workspace: tmpDir,
|
|
}
|
|
sink := &testSink{}
|
|
err := c.Execute(context.Background(), spec, sink)
|
|
if err == nil {
|
|
t.Fatal("expected command execution to fail")
|
|
}
|
|
|
|
errStr := err.Error()
|
|
// Command failure should NOT look like a workspace error.
|
|
if strings.Contains(errStr, "workspace not found") ||
|
|
strings.Contains(errStr, "workspace is not a directory") ||
|
|
strings.Contains(errStr, "workspace inaccessible") {
|
|
t.Errorf("process exit failure should be distinct from workspace preflight error, got: %q", errStr)
|
|
}
|
|
if !strings.Contains(errStr, "exit status 7") && !strings.Contains(errStr, "exit code 7") {
|
|
t.Errorf("expected error to mention exit status/code 7, got: %q", errStr)
|
|
}
|
|
}
|