This commit captures the current state before implementing the composable monorepo template system. Key changes included: Infrastructure: - Add CockroachDB provisioner adapter for database provisioning - Add Redis provisioner adapter for cache provisioning - Add build events system with PostgreSQL storage - Add WebSocket endpoint for real-time build progress Code agent improvements: - Fix Claude Code adapter to use default allowed tools instead of dangerously-skip-permissions - Add context-aware stream closing for cancellation support - Improve parser tests for edge cases Build system: - Add build event constants and metrics - Remove deprecated git_operations.go (replaced by pod_git_operations.go) - Add rollback logic for multi-step provisioning operations Documentation: - Add composable-monorepo feature documentation - Add DNS/Cloudflare service documentation - Update deployment and troubleshooting guides Cookbooks: - Add fullstack-app cookbook - Refactor landing-test with shared library Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
297 lines
7.8 KiB
Go
297 lines
7.8 KiB
Go
package claudecode
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
)
|
|
|
|
// Ensure Adapter implements port.CodeAgent at compile time.
|
|
var _ port.CodeAgent = (*Adapter)(nil)
|
|
|
|
func TestAdapter_Name(t *testing.T) {
|
|
adapter := NewAdapter("test-ns")
|
|
if name := adapter.Name(); name != "Claude Code" {
|
|
t.Errorf("expected name 'Claude Code', got %q", name)
|
|
}
|
|
}
|
|
|
|
func TestAdapter_Provider(t *testing.T) {
|
|
adapter := NewAdapter("test-ns")
|
|
if p := adapter.Provider(); p != domain.AgentProviderClaudeCode {
|
|
t.Errorf("expected provider 'claudecode', got %q", p)
|
|
}
|
|
}
|
|
|
|
func TestAdapter_Capabilities(t *testing.T) {
|
|
adapter := NewAdapter("test-ns")
|
|
caps := adapter.Capabilities()
|
|
|
|
if caps.Provider != domain.AgentProviderClaudeCode {
|
|
t.Errorf("expected provider claudecode")
|
|
}
|
|
if !caps.SupportsSessionContinuation {
|
|
t.Error("expected session continuation support")
|
|
}
|
|
if caps.SupportsModelSelection {
|
|
t.Error("expected no model selection support")
|
|
}
|
|
if !caps.SupportsToolControl {
|
|
t.Error("expected tool control support")
|
|
}
|
|
if !caps.SupportsStreaming {
|
|
t.Error("expected streaming support")
|
|
}
|
|
if len(caps.SupportedModels) == 0 {
|
|
t.Error("expected at least one supported model")
|
|
}
|
|
}
|
|
|
|
func TestAdapter_buildCommandArgs_Basic(t *testing.T) {
|
|
adapter := NewAdapter("rdev")
|
|
req := &domain.AgentRequest{
|
|
Prompt: "Hello, Claude",
|
|
}
|
|
|
|
args := adapter.buildCommandArgs("rdev", "pod-123", req)
|
|
|
|
// Verify essential args are present
|
|
argsStr := strings.Join(args, " ")
|
|
|
|
if !strings.Contains(argsStr, "exec -n rdev pod-123") {
|
|
t.Error("expected kubectl exec command")
|
|
}
|
|
if !strings.Contains(argsStr, "claude") {
|
|
t.Error("expected claude command")
|
|
}
|
|
if !strings.Contains(argsStr, "-p") {
|
|
t.Error("expected -p flag")
|
|
}
|
|
if !strings.Contains(argsStr, "--output-format stream-json") {
|
|
t.Error("expected stream-json output format")
|
|
}
|
|
if !strings.Contains(argsStr, "--verbose") {
|
|
t.Error("expected --verbose flag for stream-json output")
|
|
}
|
|
// Should include default allowed tools instead of --dangerously-skip-permissions
|
|
expectedTools := []string{"Bash", "Edit", "Write", "Read", "Glob", "Grep", "Task", "WebFetch", "WebSearch"}
|
|
for _, tool := range expectedTools {
|
|
if !strings.Contains(argsStr, "--allowedTools "+tool) {
|
|
t.Errorf("expected default allowed tool: %s", tool)
|
|
}
|
|
}
|
|
if !strings.Contains(argsStr, "Hello, Claude") {
|
|
t.Error("expected prompt in args")
|
|
}
|
|
}
|
|
|
|
func TestAdapter_buildCommandArgs_WithSessionResume(t *testing.T) {
|
|
adapter := NewAdapter("rdev")
|
|
req := &domain.AgentRequest{
|
|
Prompt: "Continue working",
|
|
SessionID: "session-abc",
|
|
}
|
|
|
|
args := adapter.buildCommandArgs("rdev", "pod-123", req)
|
|
argsStr := strings.Join(args, " ")
|
|
|
|
if !strings.Contains(argsStr, "--resume session-abc") {
|
|
t.Error("expected --resume flag with session ID")
|
|
}
|
|
}
|
|
|
|
func TestAdapter_buildCommandArgs_WithAllowedTools(t *testing.T) {
|
|
adapter := NewAdapter("rdev")
|
|
req := &domain.AgentRequest{
|
|
Prompt: "Run tests",
|
|
AllowedTools: []string{"Bash", "Read", "Edit"},
|
|
}
|
|
|
|
args := adapter.buildCommandArgs("rdev", "pod-123", req)
|
|
argsStr := strings.Join(args, " ")
|
|
|
|
if !strings.Contains(argsStr, "--allowedTools Bash") {
|
|
t.Error("expected --allowedTools Bash")
|
|
}
|
|
if !strings.Contains(argsStr, "--allowedTools Read") {
|
|
t.Error("expected --allowedTools Read")
|
|
}
|
|
if !strings.Contains(argsStr, "--allowedTools Edit") {
|
|
t.Error("expected --allowedTools Edit")
|
|
}
|
|
}
|
|
|
|
func TestAdapter_buildCommandArgs_WithWorkingDir(t *testing.T) {
|
|
adapter := NewAdapter("rdev")
|
|
req := &domain.AgentRequest{
|
|
Prompt: "List files",
|
|
WorkingDir: "/workspace/subdir",
|
|
}
|
|
|
|
args := adapter.buildCommandArgs("rdev", "pod-123", req)
|
|
argsStr := strings.Join(args, " ")
|
|
|
|
if !strings.Contains(argsStr, "--add-dir /workspace/subdir") {
|
|
t.Error("expected --add-dir for non-default working directory")
|
|
}
|
|
}
|
|
|
|
func TestAdapter_buildCommandArgs_DefaultWorkingDir(t *testing.T) {
|
|
adapter := NewAdapter("rdev")
|
|
req := &domain.AgentRequest{
|
|
Prompt: "List files",
|
|
WorkingDir: "/workspace", // default
|
|
}
|
|
|
|
args := adapter.buildCommandArgs("rdev", "pod-123", req)
|
|
argsStr := strings.Join(args, " ")
|
|
|
|
// Should NOT have --add-dir for default workspace
|
|
if strings.Contains(argsStr, "--add-dir") {
|
|
t.Error("expected no --add-dir for default workspace")
|
|
}
|
|
}
|
|
|
|
func TestAdapter_Execute_MissingPrompt(t *testing.T) {
|
|
adapter := NewAdapter("rdev")
|
|
req := &domain.AgentRequest{
|
|
Prompt: "", // missing
|
|
Metadata: map[string]string{
|
|
"pod_name": "test-pod",
|
|
},
|
|
}
|
|
|
|
_, err := adapter.Execute(context.Background(), req, func(e domain.AgentEvent) {})
|
|
if err == nil {
|
|
t.Error("expected error for missing prompt")
|
|
}
|
|
if !strings.Contains(err.Error(), "prompt is required") {
|
|
t.Errorf("expected 'prompt is required' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAdapter_Execute_MissingPodName(t *testing.T) {
|
|
adapter := NewAdapter("rdev")
|
|
req := &domain.AgentRequest{
|
|
Prompt: "Hello",
|
|
Metadata: map[string]string{}, // no pod_name
|
|
}
|
|
|
|
result, _ := adapter.Execute(context.Background(), req, func(e domain.AgentEvent) {})
|
|
if result.Error == nil {
|
|
t.Error("expected error for missing pod_name")
|
|
}
|
|
if !strings.Contains(result.Error.Error(), "pod_name") {
|
|
t.Errorf("expected pod_name error, got: %v", result.Error)
|
|
}
|
|
}
|
|
|
|
func TestAdapter_Cancel(t *testing.T) {
|
|
adapter := NewAdapter("rdev")
|
|
|
|
// Cancel non-existent session should not error
|
|
err := adapter.Cancel(context.Background(), "nonexistent")
|
|
if err != nil {
|
|
t.Errorf("expected no error for non-existent session, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAdapter_parseStreamOutput(t *testing.T) {
|
|
adapter := NewAdapter("rdev")
|
|
|
|
input := strings.NewReader(`{"type":"init","session_id":"test-123"}
|
|
{"type":"message","role":"assistant","content":[{"type":"text","text":"Hello!"}]}
|
|
{"type":"result","subtype":"success","is_error":false,"duration_ms":100}
|
|
`)
|
|
|
|
var events []domain.AgentEvent
|
|
handler := func(e domain.AgentEvent) {
|
|
events = append(events, e)
|
|
}
|
|
|
|
var output strings.Builder
|
|
resultMsg, err := adapter.parseStreamOutput(input, handler, &output)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(events) != 3 {
|
|
t.Errorf("expected 3 events, got %d", len(events))
|
|
}
|
|
|
|
// Verify event types
|
|
if events[0].Type != domain.AgentEventOutput {
|
|
t.Errorf("expected first event to be output (init)")
|
|
}
|
|
if events[1].Type != domain.AgentEventOutput {
|
|
t.Errorf("expected second event to be output (message)")
|
|
}
|
|
if events[2].Type != domain.AgentEventComplete {
|
|
t.Errorf("expected third event to be complete (result)")
|
|
}
|
|
|
|
// Verify result message
|
|
if resultMsg == nil {
|
|
t.Fatal("expected result message")
|
|
}
|
|
if !resultMsg.IsSuccess() {
|
|
t.Error("expected success result")
|
|
}
|
|
|
|
// Verify output was collected
|
|
if !strings.Contains(output.String(), "Hello!") {
|
|
t.Error("expected output to contain assistant message")
|
|
}
|
|
}
|
|
|
|
func TestAdapter_parseStreamOutput_PlainText(t *testing.T) {
|
|
adapter := NewAdapter("rdev")
|
|
|
|
// Non-JSON lines should be treated as plain output
|
|
input := strings.NewReader(`Not JSON output
|
|
Another plain line
|
|
{"type":"result","status":"success"}
|
|
`)
|
|
|
|
var events []domain.AgentEvent
|
|
handler := func(e domain.AgentEvent) {
|
|
events = append(events, e)
|
|
}
|
|
|
|
var output strings.Builder
|
|
_, err := adapter.parseStreamOutput(input, handler, &output)
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(events) != 3 {
|
|
t.Errorf("expected 3 events, got %d", len(events))
|
|
}
|
|
|
|
// First two should be plain output events
|
|
if events[0].Content != "Not JSON output" {
|
|
t.Errorf("expected plain text content")
|
|
}
|
|
if events[0].Stream != "stdout" {
|
|
t.Errorf("expected stdout stream for plain text")
|
|
}
|
|
}
|
|
|
|
func TestGenerateSessionID(t *testing.T) {
|
|
id1 := generateSessionID()
|
|
id2 := generateSessionID()
|
|
|
|
if id1 == id2 {
|
|
t.Error("expected unique session IDs")
|
|
}
|
|
|
|
if !strings.HasPrefix(id1, "claude-") {
|
|
t.Errorf("expected session ID to start with 'claude-', got %q", id1)
|
|
}
|
|
}
|