import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { AutoModeService } from "@/services/auto-mode-service.js"; import { ProviderFactory } from "@/providers/provider-factory.js"; import { FeatureLoader } from "@/services/feature-loader.js"; import { createTestGitRepo, createTestFeature, listBranches, listWorktrees, branchExists, worktreeExists, type TestRepo, } from "../helpers/git-test-repo.js"; import * as fs from "fs/promises"; import * as path from "path"; import { exec } from "child_process"; import { promisify } from "util"; const execAsync = promisify(exec); vi.mock("@/providers/provider-factory.js"); describe("auto-mode-service.ts (integration)", () => { let service: AutoModeService; let testRepo: TestRepo; let featureLoader: FeatureLoader; const mockEvents = { subscribe: vi.fn(), emit: vi.fn(), }; beforeEach(async () => { vi.clearAllMocks(); service = new AutoModeService(mockEvents as any); featureLoader = new FeatureLoader(); testRepo = await createTestGitRepo(); }); afterEach(async () => { // Stop any running auto loops await service.stopAutoLoop(); // Cleanup test repo if (testRepo) { await testRepo.cleanup(); } }); describe("worktree operations", () => { it("should use existing git worktree for feature", async () => { const branchName = "feature/test-feature-1"; // Create a test feature with branchName set await createTestFeature(testRepo.path, "test-feature-1", { id: "test-feature-1", category: "test", description: "Test feature", status: "pending", branchName: branchName, }); // Create worktree before executing (worktrees are now created when features are added/edited) const worktreesDir = path.join(testRepo.path, ".worktrees"); const worktreePath = path.join(worktreesDir, "test-feature-1"); await fs.mkdir(worktreesDir, { recursive: true }); await execAsync(`git worktree add -b ${branchName} "${worktreePath}" HEAD`, { cwd: testRepo.path, }); // Mock provider to complete quickly const mockProvider = { getName: () => "claude", executeQuery: async function* () { yield { type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "Feature implemented" }], }, }; yield { type: "result", subtype: "success", }; }, }; vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( mockProvider as any ); // Execute feature with worktrees enabled await service.executeFeature( testRepo.path, "test-feature-1", true, // useWorktrees false // isAutoMode ); // Verify branch exists (was created when worktree was created) const branches = await listBranches(testRepo.path); expect(branches).toContain(branchName); // Verify worktree exists and is being used // The service should have found and used the worktree (check via logs) // We can verify the worktree exists by checking git worktree list const worktrees = await listWorktrees(testRepo.path); expect(worktrees.length).toBeGreaterThan(0); // Verify that at least one worktree path contains our feature ID const worktreePathsMatch = worktrees.some(wt => wt.includes("test-feature-1") || wt.includes(".worktrees") ); expect(worktreePathsMatch).toBe(true); // Note: Worktrees are not automatically cleaned up by the service // This is expected behavior - manual cleanup is required }, 30000); it("should handle error gracefully", async () => { await createTestFeature(testRepo.path, "test-feature-error", { id: "test-feature-error", category: "test", description: "Test feature that errors", status: "pending", }); // Mock provider that throws error const mockProvider = { getName: () => "claude", executeQuery: async function* () { throw new Error("Provider error"); }, }; vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( mockProvider as any ); // Execute feature (should handle error) await service.executeFeature( testRepo.path, "test-feature-error", true, false ); // Verify feature status was updated to backlog (error status) const feature = await featureLoader.get( testRepo.path, "test-feature-error" ); expect(feature?.status).toBe("backlog"); }, 30000); it("should work without worktrees", async () => { await createTestFeature(testRepo.path, "test-no-worktree", { id: "test-no-worktree", category: "test", description: "Test without worktree", status: "pending", }); const mockProvider = { getName: () => "claude", executeQuery: async function* () { yield { type: "result", subtype: "success", }; }, }; vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( mockProvider as any ); // Execute without worktrees await service.executeFeature( testRepo.path, "test-no-worktree", false, // useWorktrees = false false ); // Feature should be updated successfully const feature = await featureLoader.get( testRepo.path, "test-no-worktree" ); expect(feature?.status).toBe("waiting_approval"); }, 30000); }); describe("feature execution", () => { it("should execute feature and update status", async () => { await createTestFeature(testRepo.path, "feature-exec-1", { id: "feature-exec-1", category: "ui", description: "Execute this feature", status: "pending", }); const mockProvider = { getName: () => "claude", executeQuery: async function* () { yield { type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "Implemented the feature" }], }, }; yield { type: "result", subtype: "success", }; }, }; vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( mockProvider as any ); await service.executeFeature( testRepo.path, "feature-exec-1", false, // Don't use worktrees so agent output is saved to main project false ); // Check feature status was updated const feature = await featureLoader.get(testRepo.path, "feature-exec-1"); expect(feature?.status).toBe("waiting_approval"); // Check agent output was saved const agentOutput = await featureLoader.getAgentOutput( testRepo.path, "feature-exec-1" ); expect(agentOutput).toBeTruthy(); expect(agentOutput).toContain("Implemented the feature"); }, 30000); it("should handle feature not found", async () => { const mockProvider = { getName: () => "claude", executeQuery: async function* () { yield { type: "result", subtype: "success", }; }, }; vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( mockProvider as any ); // Try to execute non-existent feature await service.executeFeature( testRepo.path, "nonexistent-feature", true, false ); // Should emit error event expect(mockEvents.emit).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ featureId: "nonexistent-feature", error: expect.stringContaining("not found"), }) ); }, 30000); it("should prevent duplicate feature execution", async () => { await createTestFeature(testRepo.path, "feature-dup", { id: "feature-dup", category: "test", description: "Duplicate test", status: "pending", }); const mockProvider = { getName: () => "claude", executeQuery: async function* () { // Simulate slow execution await new Promise((resolve) => setTimeout(resolve, 500)); yield { type: "result", subtype: "success", }; }, }; vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( mockProvider as any ); // Start first execution const promise1 = service.executeFeature( testRepo.path, "feature-dup", false, false ); // Try to start second execution (should throw) await expect( service.executeFeature(testRepo.path, "feature-dup", false, false) ).rejects.toThrow("already running"); await promise1; }, 30000); it("should use feature-specific model", async () => { await createTestFeature(testRepo.path, "feature-model", { id: "feature-model", category: "test", description: "Model test", status: "pending", model: "claude-sonnet-4-20250514", }); const mockProvider = { getName: () => "claude", executeQuery: async function* () { yield { type: "result", subtype: "success", }; }, }; vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( mockProvider as any ); await service.executeFeature( testRepo.path, "feature-model", false, false ); // Should have used claude-sonnet-4-20250514 expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith( "claude-sonnet-4-20250514" ); }, 30000); }); describe("auto loop", () => { it("should start and stop auto loop", async () => { const startPromise = service.startAutoLoop(testRepo.path, 2); // Give it time to start await new Promise((resolve) => setTimeout(resolve, 100)); // Stop the loop const runningCount = await service.stopAutoLoop(); expect(runningCount).toBe(0); await startPromise.catch(() => {}); // Cleanup }, 10000); it("should process pending features in auto loop", async () => { // Create multiple pending features await createTestFeature(testRepo.path, "auto-1", { id: "auto-1", category: "test", description: "Auto feature 1", status: "pending", }); await createTestFeature(testRepo.path, "auto-2", { id: "auto-2", category: "test", description: "Auto feature 2", status: "pending", }); const mockProvider = { getName: () => "claude", executeQuery: async function* () { yield { type: "result", subtype: "success", }; }, }; vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( mockProvider as any ); // Start auto loop const startPromise = service.startAutoLoop(testRepo.path, 2); // Wait for features to be processed await new Promise((resolve) => setTimeout(resolve, 3000)); // Stop the loop await service.stopAutoLoop(); await startPromise.catch(() => {}); // Check that features were updated const feature1 = await featureLoader.get(testRepo.path, "auto-1"); const feature2 = await featureLoader.get(testRepo.path, "auto-2"); // At least one should have been processed const processedCount = [feature1, feature2].filter( (f) => f?.status === "waiting_approval" || f?.status === "in_progress" ).length; expect(processedCount).toBeGreaterThan(0); }, 15000); it("should respect max concurrency", async () => { // Create 5 features for (let i = 1; i <= 5; i++) { await createTestFeature(testRepo.path, `concurrent-${i}`, { id: `concurrent-${i}`, category: "test", description: `Concurrent feature ${i}`, status: "pending", }); } let concurrentCount = 0; let maxConcurrent = 0; const mockProvider = { getName: () => "claude", executeQuery: async function* () { concurrentCount++; maxConcurrent = Math.max(maxConcurrent, concurrentCount); // Simulate work await new Promise((resolve) => setTimeout(resolve, 500)); concurrentCount--; yield { type: "result", subtype: "success", }; }, }; vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( mockProvider as any ); // Start with max concurrency of 2 const startPromise = service.startAutoLoop(testRepo.path, 2); // Wait for some features to be processed await new Promise((resolve) => setTimeout(resolve, 3000)); await service.stopAutoLoop(); await startPromise.catch(() => {}); // Max concurrent should not exceed 2 expect(maxConcurrent).toBeLessThanOrEqual(2); }, 15000); it("should emit auto mode events", async () => { const startPromise = service.startAutoLoop(testRepo.path, 1); // Wait for start event await new Promise((resolve) => setTimeout(resolve, 100)); // Check start event was emitted const startEvent = mockEvents.emit.mock.calls.find((call) => call[1]?.message?.includes("Auto mode started") ); expect(startEvent).toBeTruthy(); await service.stopAutoLoop(); await startPromise.catch(() => {}); // Check stop event was emitted (emitted immediately by stopAutoLoop) const stopEvent = mockEvents.emit.mock.calls.find( (call) => call[1]?.type === "auto_mode_stopped" || call[1]?.message?.includes("Auto mode stopped") ); expect(stopEvent).toBeTruthy(); }, 10000); }); describe("error handling", () => { it("should handle provider errors gracefully", async () => { await createTestFeature(testRepo.path, "error-feature", { id: "error-feature", category: "test", description: "Error test", status: "pending", }); const mockProvider = { getName: () => "claude", executeQuery: async function* () { throw new Error("Provider execution failed"); }, }; vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( mockProvider as any ); // Should not throw await service.executeFeature(testRepo.path, "error-feature", true, false); // Feature should be marked as backlog (error status) const feature = await featureLoader.get(testRepo.path, "error-feature"); expect(feature?.status).toBe("backlog"); }, 30000); it("should continue auto loop after feature error", async () => { await createTestFeature(testRepo.path, "fail-1", { id: "fail-1", category: "test", description: "Will fail", status: "pending", }); await createTestFeature(testRepo.path, "success-1", { id: "success-1", category: "test", description: "Will succeed", status: "pending", }); let callCount = 0; const mockProvider = { getName: () => "claude", executeQuery: async function* () { callCount++; if (callCount === 1) { throw new Error("First feature fails"); } yield { type: "result", subtype: "success", }; }, }; vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( mockProvider as any ); const startPromise = service.startAutoLoop(testRepo.path, 1); // Wait for both features to be attempted await new Promise((resolve) => setTimeout(resolve, 5000)); await service.stopAutoLoop(); await startPromise.catch(() => {}); // Both features should have been attempted expect(callCount).toBeGreaterThanOrEqual(1); }, 15000); }); describe("planning mode", () => { it("should execute feature with skip planning mode", async () => { await createTestFeature(testRepo.path, "skip-plan-feature", { id: "skip-plan-feature", category: "test", description: "Feature with skip planning", status: "pending", planningMode: "skip", }); const mockProvider = { getName: () => "claude", executeQuery: async function* () { yield { type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "Feature implemented" }], }, }; yield { type: "result", subtype: "success", }; }, }; vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( mockProvider as any ); await service.executeFeature( testRepo.path, "skip-plan-feature", false, false ); const feature = await featureLoader.get(testRepo.path, "skip-plan-feature"); expect(feature?.status).toBe("waiting_approval"); }, 30000); it("should execute feature with lite planning mode without approval", async () => { await createTestFeature(testRepo.path, "lite-plan-feature", { id: "lite-plan-feature", category: "test", description: "Feature with lite planning", status: "pending", planningMode: "lite", requirePlanApproval: false, }); const mockProvider = { getName: () => "claude", executeQuery: async function* () { yield { type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "[PLAN_GENERATED] Planning outline complete.\n\nFeature implemented" }], }, }; yield { type: "result", subtype: "success", }; }, }; vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( mockProvider as any ); await service.executeFeature( testRepo.path, "lite-plan-feature", false, false ); const feature = await featureLoader.get(testRepo.path, "lite-plan-feature"); expect(feature?.status).toBe("waiting_approval"); }, 30000); it("should emit planning_started event for spec mode", async () => { await createTestFeature(testRepo.path, "spec-plan-feature", { id: "spec-plan-feature", category: "test", description: "Feature with spec planning", status: "pending", planningMode: "spec", requirePlanApproval: false, }); const mockProvider = { getName: () => "claude", executeQuery: async function* () { yield { type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "Spec generated\n\n[SPEC_GENERATED] Review the spec." }], }, }; yield { type: "result", subtype: "success", }; }, }; vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( mockProvider as any ); await service.executeFeature( testRepo.path, "spec-plan-feature", false, false ); // Check planning_started event was emitted const planningEvent = mockEvents.emit.mock.calls.find( (call) => call[1]?.mode === "spec" ); expect(planningEvent).toBeTruthy(); }, 30000); it("should handle feature with full planning mode", async () => { await createTestFeature(testRepo.path, "full-plan-feature", { id: "full-plan-feature", category: "test", description: "Feature with full planning", status: "pending", planningMode: "full", requirePlanApproval: false, }); const mockProvider = { getName: () => "claude", executeQuery: async function* () { yield { type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "Full spec with phases\n\n[SPEC_GENERATED] Review." }], }, }; yield { type: "result", subtype: "success", }; }, }; vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue( mockProvider as any ); await service.executeFeature( testRepo.path, "full-plan-feature", false, false ); // Check planning_started event was emitted with full mode const planningEvent = mockEvents.emit.mock.calls.find( (call) => call[1]?.mode === "full" ); expect(planningEvent).toBeTruthy(); }, 30000); it("should track pending approval correctly", async () => { // Initially no pending approvals expect(service.hasPendingApproval("non-existent")).toBe(false); }); it("should cancel pending approval gracefully", () => { // Should not throw when cancelling non-existent approval expect(() => service.cancelPlanApproval("non-existent")).not.toThrow(); }); it("should resolve approval with error for non-existent feature", async () => { const result = await service.resolvePlanApproval( "non-existent", true, undefined, undefined, undefined ); expect(result.success).toBe(false); expect(result.error).toContain("No pending approval"); }); }); });