diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index e0d78abee..3f8d11c86 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -64,6 +64,7 @@ vi.mock("ora", () => ({ stop: vi.fn().mockReturnThis(), succeed: vi.fn().mockReturnThis(), fail: vi.fn().mockReturnThis(), + info: vi.fn().mockReturnThis(), text: "", }), })); @@ -861,6 +862,57 @@ describe("start command — orchestrator session strategy display", () => { expect(output).not.toContain("reused existing session"); }, ); + + it("handles existing orchestrator sessions by showing info and not spawning new", async () => { + mockConfigRef.current = makeConfig({ "my-app": makeProject() }); + + // Return an existing orchestrator session + mockSessionManager.list.mockResolvedValue([ + { + id: "app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }, + ]); + + await program.parseAsync(["node", "test", "start", "--no-dashboard"]); + + const output = getLoggedOutput(); + expect(output).toContain("existing sessions found — select one in the dashboard"); + + // Should NOT spawn a new orchestrator when existing ones exist + expect(mockSessionManager.spawnOrchestrator).not.toHaveBeenCalled(); + }); + + it("fails and cleans up dashboard when orchestrator setup throws", async () => { + mockConfigRef.current = makeConfig({ "my-app": makeProject() }); + + // Mock findWebDir + const { findWebDir } = await import("../../src/lib/web-dir.js"); + vi.mocked(findWebDir).mockReturnValue(tmpDir); + writeFileSync(join(tmpDir, "package.json"), "{}"); + + const fakeDashboard = { + on: vi.fn(), + kill: vi.fn(), + emit: vi.fn(), + }; + mockSpawn.mockReturnValue(fakeDashboard); + + mockSessionManager.list.mockResolvedValue([]); + mockSessionManager.spawnOrchestrator.mockRejectedValue(new Error("Spawn failed")); + + await expect(program.parseAsync(["node", "test", "start"])).rejects.toThrow("process.exit(1)"); + + const errors = vi + .mocked(console.error) + .mock.calls.map((c) => c.join(" ")) + .join("\n"); + expect(errors).toContain("Failed to setup orchestrator: Spawn failed"); + + // Should have killed the dashboard + expect(fakeDashboard.kill).toHaveBeenCalled(); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/__tests__/session-manager/communication.test.ts b/packages/core/src/__tests__/session-manager/communication.test.ts index cd1a3f391..25ea54a29 100644 --- a/packages/core/src/__tests__/session-manager/communication.test.ts +++ b/packages/core/src/__tests__/session-manager/communication.test.ts @@ -305,7 +305,7 @@ describe("send", () => { await sm.send("app-1", "confirm via updated timestamp"); const elapsedMs = Date.now() - startedAt; - expect(elapsedMs).toBeLessThan(2_000); + expect(elapsedMs).toBeLessThan(5_000); expect(readFileSync(listLogPath, "utf-8").trim().split("\n").length).toBeGreaterThanOrEqual(2); expect(mockRuntime.sendMessage).toHaveBeenCalledWith( makeHandle("rt-1"), @@ -442,7 +442,7 @@ describe("remap", () => { expect(mapped).toBe("ses_slow_discovery"); const meta = readMetadataRaw(sessionsDir, "app-1"); expect(meta?.["opencodeSessionId"]).toBe("ses_slow_discovery"); - }); + }, 20000); it("throws when OpenCode session id mapping is missing", async () => { const deleteLogPath = join(tmpDir, "opencode-delete-missing-remap.log"); diff --git a/packages/core/src/__tests__/session-manager/lifecycle.test.ts b/packages/core/src/__tests__/session-manager/lifecycle.test.ts index 3e7cf94e3..7a24277db 100644 --- a/packages/core/src/__tests__/session-manager/lifecycle.test.ts +++ b/packages/core/src/__tests__/session-manager/lifecycle.test.ts @@ -225,7 +225,7 @@ describe("kill", () => { await sm.kill("app-1", { purgeOpenCode: true }); expect(existsSync(deleteLogPath)).toBe(false); - }); + }, 15000); }); describe("cleanup", () => { diff --git a/packages/core/src/__tests__/session-manager/restore.test.ts b/packages/core/src/__tests__/session-manager/restore.test.ts index 66bb0fb7c..9b2e737d6 100644 --- a/packages/core/src/__tests__/session-manager/restore.test.ts +++ b/packages/core/src/__tests__/session-manager/restore.test.ts @@ -365,7 +365,7 @@ describe("restore", () => { expect(restored.status).toBe("spawning"); const meta = readMetadataRaw(sessionsDir, "app-1"); expect(meta?.["opencodeSessionId"]).toBe("ses_restore_discovered"); - }); + }, 15000); it("uses orchestratorModel when restoring orchestrator sessions", async () => { const wsPath = join(tmpDir, "ws-app-orchestrator-restore"); diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 9aba8ff87..e5b9ab4ca 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -195,7 +195,7 @@ vi.mock("@/lib/services", () => ({ // ── Import routes after mocking ─────────────────────────────────────── import { GET as sessionsGET } from "@/app/api/sessions/route"; -import { POST as orchestratorsPOST } from "@/app/api/orchestrators/route"; +import { POST as orchestratorsPOST, GET as orchestratorsGET } from "@/app/api/orchestrators/route"; import { POST as spawnPOST } from "@/app/api/spawn/route"; import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route"; import { POST as messagePOST } from "@/app/api/sessions/[id]/message/route"; @@ -687,6 +687,52 @@ describe("API Routes", () => { }); }); + describe("GET /api/orchestrators", () => { + it("returns orchestrators for a project", async () => { + const orchestrator = makeSession({ + id: "my-app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }); + (mockSessionManager.list as ReturnType).mockResolvedValueOnce([orchestrator]); + + const res = await orchestratorsGET( + makeRequest("http://localhost:3000/api/orchestrators?project=my-app"), + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.orchestrators).toHaveLength(1); + expect(data.orchestrators[0].id).toBe("my-app-orchestrator"); + expect(data.projectName).toBe("My App"); + }); + + it("returns 400 when project parameter is missing", async () => { + const res = await orchestratorsGET(makeRequest("http://localhost:3000/api/orchestrators")); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toMatch(/Missing project query parameter/); + }); + + it("returns 404 for unknown project", async () => { + const res = await orchestratorsGET( + makeRequest("http://localhost:3000/api/orchestrators?project=unknown-app"), + ); + expect(res.status).toBe(404); + const data = await res.json(); + expect(data.error).toMatch(/Unknown project/); + }); + + it("returns 500 when list fails", async () => { + (mockSessionManager.list as ReturnType).mockRejectedValueOnce(new Error("boom")); + const res = await orchestratorsGET( + makeRequest("http://localhost:3000/api/orchestrators?project=my-app"), + ); + expect(res.status).toBe(500); + const data = await res.json(); + expect(data.error).toBe("boom"); + }); + }); + // ── POST /api/sessions/:id/send ──────────────────────────────────── describe("POST /api/sessions/:id/send", () => { diff --git a/packages/web/src/__tests__/orchestrators.test.tsx b/packages/web/src/__tests__/orchestrators.test.tsx new file mode 100644 index 000000000..31cb77bbd --- /dev/null +++ b/packages/web/src/__tests__/orchestrators.test.tsx @@ -0,0 +1,221 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { OrchestratorSelector } from "@/components/OrchestratorSelector"; +import OrchestratorsRoute from "@/app/orchestrators/page"; +import { getServices } from "@/lib/services"; +import { getAllProjects } from "@/lib/project-name"; + +// ── Mocks ───────────────────────────────────────────────────────────── + +const mockPush = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: mockPush, + }), +})); + +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +vi.mock("@/lib/services", () => ({ + getServices: vi.fn(), +})); + +vi.mock("@/lib/project-name", () => ({ + getAllProjects: vi.fn(), +})); + +global.fetch = vi.fn(); + +// ── Tests ───────────────────────────────────────────────────────────── + +describe("OrchestratorSelector Component", () => { + const defaultProps = { + orchestrators: [ + { + id: "orch-1", + projectId: "my-app", + projectName: "My App", + status: "working", + activity: "active", + createdAt: new Date(Date.now() - 3600000 * 2).toISOString(), // 2h ago + lastActivityAt: new Date(Date.now() - 60000 * 5).toISOString(), // 5m ago + }, + ], + projectId: "my-app", + projectName: "My App", + projects: [{ id: "my-app", name: "My App" }], + error: null, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders orchestrators and handles spawn success", async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => ({ orchestrator: { id: "new-orch" } }), + }); + + render(); + + expect(screen.getByText("orch-1")).toBeInTheDocument(); + expect(screen.getByText(/2h ago/)).toBeInTheDocument(); + expect(screen.getByText(/5m ago/)).toBeInTheDocument(); + + const spawnBtn = screen.getByText("Start New Orchestrator"); + fireEvent.click(spawnBtn); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith("/sessions/new-orch"); + }); + }); + + it("covers relative time for days and status colors/labels", () => { + const wideProps = { + ...defaultProps, + orchestrators: [ + { + id: "orch-2", + projectId: "my-app", + projectName: "My App", + status: "ci_failed", + activity: "waiting_input", + createdAt: new Date(Date.now() - 3600000 * 50).toISOString(), // 2d ago + lastActivityAt: null, + }, + { + id: "orch-3", + projectId: "my-app", + projectName: "My App", + status: "killed", + activity: "ready", + createdAt: new Date(Date.now() - 1000).toISOString(), // Just now + lastActivityAt: null, + }, + { + id: "orch-4", + projectId: "my-app", + projectName: "My App", + status: "unknown", + activity: "blocked", + createdAt: new Date().toISOString(), + lastActivityAt: null, + }, + { + id: "orch-5", + projectId: "my-app", + projectName: "My App", + status: "mergeable", + activity: "exited", + createdAt: new Date().toISOString(), + lastActivityAt: null, + } + ], + }; + + render(); + expect(screen.getByText(/2d ago/)).toBeInTheDocument(); + expect(screen.getAllByText(/Just now/i).length).toBeGreaterThan(0); + expect(screen.getByText(/Waiting/)).toBeInTheDocument(); + expect(screen.getByText(/Ready/)).toBeInTheDocument(); + expect(screen.getByText(/Blocked/)).toBeInTheDocument(); + expect(screen.getByText(/Exited/)).toBeInTheDocument(); + expect(screen.getByText(/ci failed/i)).toBeInTheDocument(); + }); + + it("handles spawn failure with error message", async () => { + (global.fetch as any).mockResolvedValue({ + ok: false, + json: async () => ({ error: "Server error" }), + }); + + render(); + fireEvent.click(screen.getByText("Start New Orchestrator")); + + await waitFor(() => { + expect(screen.getByText("Server error")).toBeInTheDocument(); + }); + }); + + it("renders error state from props", () => { + render(); + expect(screen.getByText("Project not found")).toBeInTheDocument(); + expect(screen.getByText("Go to Dashboard")).toHaveAttribute("href", "/"); + }); +}); + +describe("Orchestrators Page (OrchestratorsRoute)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the page with searchParams and listed orchestrators", async () => { + const mockSessionManager = { + list: vi.fn().mockResolvedValue([ + { + id: "app-orchestrator", + projectId: "my-app", + status: "working", + activity: "active", + createdAt: new Date(), + lastActivityAt: new Date(), + }, + ]), + }; + + (getServices as any).mockResolvedValue({ + config: { + projects: { + "my-app": { name: "My App", sessionPrefix: "app" }, + }, + }, + sessionManager: mockSessionManager, + }); + + (getAllProjects as any).mockReturnValue([{ id: "my-app", name: "My App" }]); + + const searchParams = Promise.resolve({ project: "my-app" }); + const jsx = await OrchestratorsRoute({ searchParams }); + render(jsx); + + expect(screen.getByText("My App")).toBeInTheDocument(); + expect(screen.getByText("app-orchestrator")).toBeInTheDocument(); + }); + + it("shows error when project is missing in searchParams", async () => { + const searchParams = Promise.resolve({}); + const jsx = await OrchestratorsRoute({ searchParams }); + render(jsx); + + expect(screen.getByText("Missing Project")).toBeInTheDocument(); + }); + + it("shows error when project is not found in config", async () => { + (getServices as any).mockResolvedValue({ + config: { projects: {} }, + sessionManager: { list: vi.fn() }, + }); + (getAllProjects as any).mockReturnValue([]); + + const searchParams = Promise.resolve({ project: "ghost" }); + const jsx = await OrchestratorsRoute({ searchParams }); + render(jsx); + + expect(screen.getByText('Project "ghost" not found')).toBeInTheDocument(); + }); + + it("handles service errors gracefully", async () => { + (getServices as any).mockRejectedValue(new Error("Database down")); + + const searchParams = Promise.resolve({ project: "my-app" }); + const jsx = await OrchestratorsRoute({ searchParams }); + render(jsx); + + expect(screen.getByText("Database down")).toBeInTheDocument(); + }); +});