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>
359 lines
8.9 KiB
Go
359 lines
8.9 KiB
Go
package claudecode
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
)
|
|
|
|
func TestParseStreamMessage_Init(t *testing.T) {
|
|
line := []byte(`{"type":"init","session_id":"abc123","timestamp":"2024-01-01T00:00:00Z"}`)
|
|
|
|
msg, err := ParseStreamMessage(line)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if msg.Type != StreamMessageInit {
|
|
t.Errorf("expected type 'init', got %q", msg.Type)
|
|
}
|
|
if msg.SessionID != "abc123" {
|
|
t.Errorf("expected session_id 'abc123', got %q", msg.SessionID)
|
|
}
|
|
}
|
|
|
|
func TestParseStreamMessage_Message(t *testing.T) {
|
|
line := []byte(`{"type":"message","role":"assistant","content":[{"type":"text","text":"Hello, world!"}]}`)
|
|
|
|
msg, err := ParseStreamMessage(line)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if msg.Type != StreamMessageMessage {
|
|
t.Errorf("expected type 'message', got %q", msg.Type)
|
|
}
|
|
if msg.Role != "assistant" {
|
|
t.Errorf("expected role 'assistant', got %q", msg.Role)
|
|
}
|
|
if len(msg.Content) != 1 {
|
|
t.Fatalf("expected 1 content block, got %d", len(msg.Content))
|
|
}
|
|
if msg.Content[0].Text != "Hello, world!" {
|
|
t.Errorf("expected text 'Hello, world!', got %q", msg.Content[0].Text)
|
|
}
|
|
}
|
|
|
|
func TestParseStreamMessage_ToolUse(t *testing.T) {
|
|
line := []byte(`{"type":"tool_use","name":"Bash","input":{"command":"ls -la"}}`)
|
|
|
|
msg, err := ParseStreamMessage(line)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if msg.Type != StreamMessageToolUse {
|
|
t.Errorf("expected type 'tool_use', got %q", msg.Type)
|
|
}
|
|
if msg.Name != "Bash" {
|
|
t.Errorf("expected name 'Bash', got %q", msg.Name)
|
|
}
|
|
}
|
|
|
|
func TestParseStreamMessage_ToolResult(t *testing.T) {
|
|
line := []byte(`{"type":"tool_result","output":"total 64\ndrwxr-xr-x 10 user staff"}`)
|
|
|
|
msg, err := ParseStreamMessage(line)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if msg.Type != StreamMessageToolResult {
|
|
t.Errorf("expected type 'tool_result', got %q", msg.Type)
|
|
}
|
|
if msg.Output != "total 64\ndrwxr-xr-x 10 user staff" {
|
|
t.Errorf("unexpected output: %q", msg.Output)
|
|
}
|
|
}
|
|
|
|
func TestParseStreamMessage_Result(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
line string
|
|
wantSubtype string
|
|
wantIsError bool
|
|
wantMs int64
|
|
}{
|
|
{
|
|
name: "success",
|
|
line: `{"type":"result","subtype":"success","is_error":false,"duration_ms":1234}`,
|
|
wantSubtype: "success",
|
|
wantIsError: false,
|
|
wantMs: 1234,
|
|
},
|
|
{
|
|
name: "error",
|
|
line: `{"type":"result","subtype":"error","is_error":true,"error":"something went wrong"}`,
|
|
wantSubtype: "error",
|
|
wantIsError: true,
|
|
wantMs: 0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
msg, err := ParseStreamMessage([]byte(tt.line))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if msg.Type != StreamMessageResult {
|
|
t.Errorf("expected type 'result', got %q", msg.Type)
|
|
}
|
|
if msg.Subtype != tt.wantSubtype {
|
|
t.Errorf("expected subtype %q, got %q", tt.wantSubtype, msg.Subtype)
|
|
}
|
|
if msg.IsError != tt.wantIsError {
|
|
t.Errorf("expected is_error %v, got %v", tt.wantIsError, msg.IsError)
|
|
}
|
|
if msg.DurationMs != tt.wantMs {
|
|
t.Errorf("expected duration_ms %d, got %d", tt.wantMs, msg.DurationMs)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseStreamMessage_Invalid(t *testing.T) {
|
|
line := []byte(`not valid json`)
|
|
|
|
_, err := ParseStreamMessage(line)
|
|
if err == nil {
|
|
t.Error("expected error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestStreamMessage_ToAgentEvent_Init(t *testing.T) {
|
|
msg := &StreamMessage{
|
|
Type: StreamMessageInit,
|
|
SessionID: "test-session",
|
|
Timestamp: "2024-01-01T12:00:00Z",
|
|
}
|
|
|
|
event := msg.ToAgentEvent()
|
|
|
|
if event.Type != domain.AgentEventOutput {
|
|
t.Errorf("expected type %v, got %v", domain.AgentEventOutput, event.Type)
|
|
}
|
|
if event.Content != "Session started" {
|
|
t.Errorf("expected content 'Session started', got %q", event.Content)
|
|
}
|
|
if event.Metadata["session_id"] != "test-session" {
|
|
t.Errorf("expected session_id in metadata")
|
|
}
|
|
}
|
|
|
|
func TestStreamMessage_ToAgentEvent_Message(t *testing.T) {
|
|
msg := &StreamMessage{
|
|
Type: StreamMessageMessage,
|
|
Role: "assistant",
|
|
Content: []ContentBlock{
|
|
{Type: "text", Text: "Hello from Claude"},
|
|
},
|
|
}
|
|
|
|
event := msg.ToAgentEvent()
|
|
|
|
if event.Type != domain.AgentEventOutput {
|
|
t.Errorf("expected type %v, got %v", domain.AgentEventOutput, event.Type)
|
|
}
|
|
if event.Content != "Hello from Claude" {
|
|
t.Errorf("expected content 'Hello from Claude', got %q", event.Content)
|
|
}
|
|
}
|
|
|
|
func TestStreamMessage_ToAgentEvent_ToolUse(t *testing.T) {
|
|
msg := &StreamMessage{
|
|
Type: StreamMessageToolUse,
|
|
Name: "Read",
|
|
Input: []byte(`{"path":"/workspace/main.go"}`),
|
|
}
|
|
|
|
event := msg.ToAgentEvent()
|
|
|
|
if event.Type != domain.AgentEventToolUse {
|
|
t.Errorf("expected type %v, got %v", domain.AgentEventToolUse, event.Type)
|
|
}
|
|
if event.ToolName != "Read" {
|
|
t.Errorf("expected tool name 'Read', got %q", event.ToolName)
|
|
}
|
|
if event.ToolInput["path"] != "/workspace/main.go" {
|
|
t.Errorf("expected path in tool input")
|
|
}
|
|
}
|
|
|
|
func TestStreamMessage_ToAgentEvent_ToolResult(t *testing.T) {
|
|
msg := &StreamMessage{
|
|
Type: StreamMessageToolResult,
|
|
Output: "file contents here",
|
|
}
|
|
|
|
event := msg.ToAgentEvent()
|
|
|
|
if event.Type != domain.AgentEventToolResult {
|
|
t.Errorf("expected type %v, got %v", domain.AgentEventToolResult, event.Type)
|
|
}
|
|
if event.Content != "file contents here" {
|
|
t.Errorf("expected content 'file contents here', got %q", event.Content)
|
|
}
|
|
}
|
|
|
|
func TestStreamMessage_ToAgentEvent_ResultSuccess(t *testing.T) {
|
|
msg := &StreamMessage{
|
|
Type: StreamMessageResult,
|
|
Subtype: "success",
|
|
IsError: false,
|
|
DurationMs: 5000,
|
|
}
|
|
|
|
event := msg.ToAgentEvent()
|
|
|
|
if event.Type != domain.AgentEventComplete {
|
|
t.Errorf("expected type %v, got %v", domain.AgentEventComplete, event.Type)
|
|
}
|
|
if event.Metadata["status"] != "success" {
|
|
t.Errorf("expected status in metadata")
|
|
}
|
|
if event.Metadata["duration_ms"].(int64) != 5000 {
|
|
t.Errorf("expected duration_ms in metadata")
|
|
}
|
|
}
|
|
|
|
func TestStreamMessage_ToAgentEvent_ResultError(t *testing.T) {
|
|
msg := &StreamMessage{
|
|
Type: StreamMessageResult,
|
|
Subtype: "error",
|
|
IsError: true,
|
|
Error: "execution failed",
|
|
}
|
|
|
|
event := msg.ToAgentEvent()
|
|
|
|
if event.Type != domain.AgentEventError {
|
|
t.Errorf("expected type %v, got %v", domain.AgentEventError, event.Type)
|
|
}
|
|
if event.Content != "execution failed" {
|
|
t.Errorf("expected error content, got %q", event.Content)
|
|
}
|
|
}
|
|
|
|
func TestStreamMessage_IsTerminal(t *testing.T) {
|
|
tests := []struct {
|
|
msgType StreamMessageType
|
|
terminal bool
|
|
}{
|
|
{StreamMessageInit, false},
|
|
{StreamMessageMessage, false},
|
|
{StreamMessageToolUse, false},
|
|
{StreamMessageToolResult, false},
|
|
{StreamMessageResult, true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(string(tt.msgType), func(t *testing.T) {
|
|
msg := &StreamMessage{Type: tt.msgType}
|
|
if msg.IsTerminal() != tt.terminal {
|
|
t.Errorf("IsTerminal() = %v, want %v", msg.IsTerminal(), tt.terminal)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStreamMessage_IsSuccess(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
msg StreamMessage
|
|
success bool
|
|
}{
|
|
{"success result", StreamMessage{Type: StreamMessageResult, Subtype: "success", IsError: false}, true},
|
|
{"error result", StreamMessage{Type: StreamMessageResult, Subtype: "error", IsError: true}, false},
|
|
{"non-result", StreamMessage{Type: StreamMessageMessage}, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if tt.msg.IsSuccess() != tt.success {
|
|
t.Errorf("IsSuccess() = %v, want %v", tt.msg.IsSuccess(), tt.success)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseTimestamp(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
valid bool
|
|
}{
|
|
{"2024-01-01T12:00:00Z", true},
|
|
{"2024-01-01T12:00:00+00:00", true},
|
|
{"invalid", false},
|
|
{"", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
result := parseTimestamp(tt.input)
|
|
if tt.valid {
|
|
if result.Year() != 2024 {
|
|
t.Errorf("expected year 2024, got %d", result.Year())
|
|
}
|
|
} else {
|
|
// Should return current time for invalid input
|
|
if time.Since(result) > time.Second {
|
|
t.Error("expected recent time for invalid input")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractTextContent(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
blocks []ContentBlock
|
|
want string
|
|
}{
|
|
{
|
|
name: "single text block",
|
|
blocks: []ContentBlock{{Type: "text", Text: "hello"}},
|
|
want: "hello",
|
|
},
|
|
{
|
|
name: "text after tool_use",
|
|
blocks: []ContentBlock{
|
|
{Type: "tool_use", Name: "Bash"},
|
|
{Type: "text", Text: "result"},
|
|
},
|
|
want: "result",
|
|
},
|
|
{
|
|
name: "empty blocks",
|
|
blocks: []ContentBlock{},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "no text blocks",
|
|
blocks: []ContentBlock{{Type: "tool_use", Name: "Read"}},
|
|
want: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := extractTextContent(tt.blocks); got != tt.want {
|
|
t.Errorf("extractTextContent() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|