iop/apps/node/internal/adapters/cli/cli_workspace_test.go

538 lines
16 KiB
Go

package cli
import (
"context"
"iop/apps/node/internal/runtime"
"iop/packages/go/config"
"os"
"os/exec"
"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)
}
}
// TestBuildCmdAppliesEnvAndCwd locks the pre-refactor dir-before-env order
// for the context one-shot lifecycle: workspace is applied before env, so
// exec.Cmd.Environ() picks up PWD=<workspace> alongside the custom env.
func TestBuildCmdAppliesEnvAndCwd(t *testing.T) {
tmpDir := t.TempDir()
subDir := tmpDir + "/sub"
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatalf("failed to create sub dir: %v", err)
}
// 1. Happy path: valid command, valid workspace, valid env
cmd, err := buildCmd(context.Background(), "echo", []string{"hello"}, []string{"FOO=bar"}, subDir)
if err != nil {
t.Fatalf("buildCmd failed: %v", err)
}
wantCmd := exec.CommandContext(context.Background(), "echo", "hello")
wantCmd.Dir = subDir
wantCmd.Env = append(wantCmd.Environ(), "FOO=bar")
if cmd.Dir != wantCmd.Dir {
t.Errorf("expected cmd.Dir to be %q, got %q", wantCmd.Dir, cmd.Dir)
}
if cmd.Path != wantCmd.Path {
t.Errorf("expected cmd.Path %q, got %q", wantCmd.Path, cmd.Path)
}
if len(cmd.Args) != len(wantCmd.Args) {
t.Fatalf("expected cmd.Args %v, got %v", wantCmd.Args, cmd.Args)
}
for i := range wantCmd.Args {
if cmd.Args[i] != wantCmd.Args[i] {
t.Errorf("expected cmd.Args %v, got %v", wantCmd.Args, cmd.Args)
break
}
}
assertEnvEqual(t, wantCmd.Env, cmd.Env)
// 2. Invalid workspace path
_, err = buildCmd(context.Background(), "echo", []string{"hello"}, nil, "/nonexistent/workspace/path")
if err == nil {
t.Error("expected buildCmd to fail with invalid workspace")
}
if !strings.Contains(err.Error(), "cli adapter: workspace not found") {
t.Errorf("expected 'workspace not found' error, got %v", err)
}
}
// TestBuildCmdWithoutContextAppliesEnvAndCwd locks the pre-refactor
// env-before-dir order for the codex app-server, opencode local server, and
// non-terminal persistent lifecycles: env is applied before workspace, so
// exec.Cmd.Environ() keeps the parent PWD instead of being overridden to
// match workspace.
func TestBuildCmdWithoutContextAppliesEnvAndCwd(t *testing.T) {
tmpDir := t.TempDir()
subDir := tmpDir + "/sub"
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatalf("failed to create sub dir: %v", err)
}
// 1. Happy path: valid command, valid workspace, valid env
cmd, err := buildCmdWithoutContext("echo", []string{"hello"}, []string{"FOO=bar"}, subDir)
if err != nil {
t.Fatalf("buildCmdWithoutContext failed: %v", err)
}
wantCmd := exec.Command("echo", "hello")
wantCmd.Env = append(wantCmd.Environ(), "FOO=bar")
wantCmd.Dir = subDir
if cmd.Dir != wantCmd.Dir {
t.Errorf("expected cmd.Dir to be %q, got %q", wantCmd.Dir, cmd.Dir)
}
if cmd.Path != wantCmd.Path {
t.Errorf("expected cmd.Path %q, got %q", wantCmd.Path, cmd.Path)
}
if len(cmd.Args) != len(wantCmd.Args) {
t.Fatalf("expected cmd.Args %v, got %v", wantCmd.Args, cmd.Args)
}
for i := range wantCmd.Args {
if cmd.Args[i] != wantCmd.Args[i] {
t.Errorf("expected cmd.Args %v, got %v", wantCmd.Args, cmd.Args)
break
}
}
assertEnvEqual(t, wantCmd.Env, cmd.Env)
// cmd.Env must NOT carry PWD=<workspace>: env is computed before Dir is
// set, so PWD (if present at all) must still match the parent's PWD.
for _, e := range cmd.Env {
if e == "PWD="+subDir {
t.Errorf("expected cmd.Env to not override PWD to workspace %q, got env %v", subDir, cmd.Env)
}
}
// 2. Invalid workspace path
_, err = buildCmdWithoutContext("echo", []string{"hello"}, nil, "/nonexistent/workspace/path")
if err == nil {
t.Error("expected buildCmdWithoutContext to fail with invalid workspace")
}
if !strings.Contains(err.Error(), "cli adapter: workspace not found") {
t.Errorf("expected 'workspace not found' error, got %v", err)
}
}
func assertEnvEqual(t *testing.T, want, got []string) {
t.Helper()
if len(want) != len(got) {
t.Errorf("expected cmd.Env length %d, got %d\nwant: %v\ngot: %v", len(want), len(got), want, got)
return
}
for i := range want {
if want[i] != got[i] {
t.Errorf("expected cmd.Env[%d] to be %q, got %q\nwant: %v\ngot: %v", i, want[i], got[i], want, got)
}
}
}