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) } }