package worker import ( "context" "fmt" "testing" "time" "github.com/orchard9/rdev/internal/domain" ) func TestWorkExecutor_StartAndStop(t *testing.T) { deps := newTestDeps() executor := NewWorkExecutor(deps.workerSvc, deps.workSvc, deps.buildExec, nil, nil, &WorkExecutorConfig{ WorkerID: "test-worker-1", PollPeriod: 100 * time.Millisecond, HeartbeatPeriod: 100 * time.Millisecond, }) if err := executor.Start(); err != nil { t.Fatalf("Start() error = %v", err) } // Verify worker was registered deps.registry.mu.Lock() w, exists := deps.registry.workers["test-worker-1"] deps.registry.mu.Unlock() if !exists { t.Fatal("expected worker to be registered") } if w.Status != domain.WorkerStatusIdle { t.Errorf("got status %q, want %q", w.Status, domain.WorkerStatusIdle) } // Verify double-start returns error if err := executor.Start(); err == nil { t.Error("expected error on double-start") } executor.Stop() // Verify worker was deregistered deps.registry.mu.Lock() _, exists = deps.registry.workers["test-worker-1"] deps.registry.mu.Unlock() if exists { t.Error("expected worker to be deregistered after stop") } } func TestWorkExecutor_ClaimsAndExecutesTask(t *testing.T) { deps := newTestDeps() // Enqueue a build task deps.queue.mu.Lock() deps.queue.tasks["task-1"] = &domain.WorkTask{ ID: "task-1", ProjectID: "project-1", Type: domain.WorkTaskTypeBuild, Status: domain.WorkTaskStatusPending, Spec: map[string]any{"prompt": "Build a landing page"}, MaxRetries: 3, CreatedAt: time.Now(), } deps.queue.mu.Unlock() executor := NewWorkExecutor(deps.workerSvc, deps.workSvc, deps.buildExec, nil, nil, &WorkExecutorConfig{ WorkerID: "test-worker-2", PollPeriod: 50 * time.Millisecond, HeartbeatPeriod: 5 * time.Second, }) // Register the worker (normally done by Start) then call tryClaimAndExecute directly if err := executor.Start(); err != nil { t.Fatalf("Start() error = %v", err) } // Call tryClaimAndExecute directly to avoid timing dependency executor.tryClaimAndExecute() executor.Stop() // Verify task was completed deps.queue.mu.Lock() task := deps.queue.tasks["task-1"] deps.queue.mu.Unlock() if task.Status != domain.WorkTaskStatusCompleted { t.Errorf("got task status %q, want %q", task.Status, domain.WorkTaskStatusCompleted) } } func TestWorkExecutor_FailsTaskOnAgentError(t *testing.T) { deps := newTestDeps() deps.agent.err = fmt.Errorf("agent crashed") // Enqueue a build task deps.queue.mu.Lock() deps.queue.tasks["task-1"] = &domain.WorkTask{ ID: "task-1", ProjectID: "project-1", Type: domain.WorkTaskTypeBuild, Status: domain.WorkTaskStatusPending, Spec: map[string]any{"prompt": "Build something"}, MaxRetries: 3, CreatedAt: time.Now(), } deps.queue.mu.Unlock() executor := NewWorkExecutor(deps.workerSvc, deps.workSvc, deps.buildExec, nil, nil, &WorkExecutorConfig{ WorkerID: "test-worker-3", PollPeriod: 50 * time.Millisecond, HeartbeatPeriod: 5 * time.Second, }) if err := executor.Start(); err != nil { t.Fatalf("Start() error = %v", err) } // Call tryClaimAndExecute directly for each retry to avoid timing dependency for i := 0; i < 3; i++ { executor.tryClaimAndExecute() } executor.Stop() // Task should be permanently failed after all retries. deps.queue.mu.Lock() task := deps.queue.tasks["task-1"] deps.queue.mu.Unlock() if task.Status != domain.WorkTaskStatusFailed { t.Errorf("got task status %q, want %q (should be permanently failed after retries)", task.Status, domain.WorkTaskStatusFailed) } if task.RetryCount < 3 { t.Errorf("expected retry_count >= 3, got %d", task.RetryCount) } } func TestWorkExecutor_UnsupportedTaskType(t *testing.T) { deps := newTestDeps() // Enqueue a custom task (not build) deps.queue.mu.Lock() deps.queue.tasks["task-1"] = &domain.WorkTask{ ID: "task-1", ProjectID: "project-1", Type: domain.WorkTaskTypeCustom, Status: domain.WorkTaskStatusPending, Spec: map[string]any{"prompt": "Do something custom"}, MaxRetries: 1, CreatedAt: time.Now(), } deps.queue.mu.Unlock() executor := NewWorkExecutor(deps.workerSvc, deps.workSvc, deps.buildExec, nil, nil, &WorkExecutorConfig{ WorkerID: "test-worker-4", PollPeriod: 50 * time.Millisecond, HeartbeatPeriod: 5 * time.Second, }) if err := executor.Start(); err != nil { t.Fatalf("Start() error = %v", err) } // Call tryClaimAndExecute directly to avoid timing dependency executor.tryClaimAndExecute() executor.Stop() // Should fail because custom tasks are unsupported deps.queue.mu.Lock() task := deps.queue.tasks["task-1"] deps.queue.mu.Unlock() // With maxRetries=1 and retryCount=1, it should be permanently failed if task.Status != domain.WorkTaskStatusFailed { t.Errorf("got task status %q, want %q", task.Status, domain.WorkTaskStatusFailed) } } // ============================================================================= // BuildExecutor Tests // ============================================================================= func TestBuildExecutor_Execute(t *testing.T) { t.Run("successful build", func(t *testing.T) { agent := &mockCodeAgent{ result: &domain.AgentResult{ExitCode: 0, DurationMs: 500}, } registry := &mockCodeAgentRegistry{agent: agent} exec := NewBuildExecutor(registry, nil, nil, nil) task := &domain.WorkTask{ ID: "task-1", ProjectID: "project-1", Type: domain.WorkTaskTypeBuild, Spec: map[string]any{"prompt": "Build a landing page"}, } result := exec.Execute(context.Background(), task) if !result.Success { t.Errorf("expected success, got error: %s", result.Error) } if result.DurationMs < 0 { t.Errorf("expected non-negative duration, got %d", result.DurationMs) } }) t.Run("missing prompt", func(t *testing.T) { registry := &mockCodeAgentRegistry{agent: &mockCodeAgent{}} exec := NewBuildExecutor(registry, nil, nil, nil) task := &domain.WorkTask{ ID: "task-1", Type: domain.WorkTaskTypeBuild, Spec: map[string]any{}, } result := exec.Execute(context.Background(), task) if result.Success { t.Error("expected failure for missing prompt") } }) t.Run("no agent available", func(t *testing.T) { registry := &mockCodeAgentRegistry{agent: nil} exec := NewBuildExecutor(registry, nil, nil, nil) task := &domain.WorkTask{ ID: "task-1", Type: domain.WorkTaskTypeBuild, Spec: map[string]any{"prompt": "Build something"}, } result := exec.Execute(context.Background(), task) if result.Success { t.Error("expected failure when no agent available") } }) t.Run("agent execution error", func(t *testing.T) { agent := &mockCodeAgent{err: fmt.Errorf("connection refused")} registry := &mockCodeAgentRegistry{agent: agent} exec := NewBuildExecutor(registry, nil, nil, nil) task := &domain.WorkTask{ ID: "task-1", Type: domain.WorkTaskTypeBuild, Spec: map[string]any{"prompt": "Build something"}, } result := exec.Execute(context.Background(), task) if result.Success { t.Error("expected failure on agent error") } if result.Error == "" { t.Error("expected error message") } }) t.Run("agent non-zero exit code", func(t *testing.T) { agent := &mockCodeAgent{ result: &domain.AgentResult{ExitCode: 1, DurationMs: 500}, } registry := &mockCodeAgentRegistry{agent: agent} exec := NewBuildExecutor(registry, nil, nil, nil) task := &domain.WorkTask{ ID: "task-1", Type: domain.WorkTaskTypeBuild, Spec: map[string]any{"prompt": "Build something"}, } result := exec.Execute(context.Background(), task) if result.Success { t.Error("expected failure on non-zero exit code") } }) } func TestBuildExecutor_ParseSpec(t *testing.T) { exec := NewBuildExecutor(nil, nil, nil, nil) t.Run("valid spec", func(t *testing.T) { spec, err := exec.parseSpec(map[string]any{ "prompt": "Build a page", "template": "astro-landing", "auto_commit": true, "auto_push": true, }) if err != nil { t.Fatalf("parseSpec() error = %v", err) } if spec.Prompt != "Build a page" { t.Errorf("got prompt %q", spec.Prompt) } if !spec.AutoCommit { t.Error("expected auto_commit = true") } if !spec.AutoPush { t.Error("expected auto_push = true") } }) t.Run("missing prompt", func(t *testing.T) { _, err := exec.parseSpec(map[string]any{ "template": "astro-landing", }) if err == nil { t.Error("expected error for missing prompt") } }) } func TestTruncate(t *testing.T) { tests := []struct { input string maxLen int want string }{ {"short", 10, "short"}, {"exactly ten", 11, "exactly ten"}, {"this is a long string", 10, "this is..."}, {"abc", 3, "abc"}, {"abcd", 3, "abc"}, } for _, tt := range tests { got := truncate(tt.input, tt.maxLen) if got != tt.want { t.Errorf("truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want) } } }