import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { loadAllAppCss } from "../../test/cssFixture"; import { useState } from "react"; import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { TaskDetailModal } from "../TaskDetailModal"; import type { TaskDetail, Column, MergeResult, Task } from "@fusion/core"; import { clearAuthToken } from "../../auth"; vi.mock("../../api", async (importOriginal) => { const { createDashboardApiMock } = await import("../../test/mockApi"); return createDashboardApiMock(() => importOriginal(), { uploadAttachment: vi.fn(), deleteAttachment: vi.fn(), updateTask: vi.fn().mockResolvedValue({}), fetchTaskDetail: vi.fn().mockResolvedValue(makeTask()), fetchAgentLogs: vi.fn().mockResolvedValue([]), requestSpecRevision: vi.fn().mockResolvedValue({}), approvePlan: vi.fn().mockResolvedValue({}), rejectPlan: vi.fn().mockResolvedValue({}), duplicateTask: vi.fn().mockResolvedValue({}), refineTask: vi.fn().mockResolvedValue({}), addSteeringComment: vi.fn(), assignTask: vi.fn().mockResolvedValue({}), fetchAgents: vi.fn().mockResolvedValue([]), fetchAgent: vi.fn(), fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [] }), fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }), fetchWorkflowSteps: vi.fn().mockResolvedValue([]), refineText: vi.fn(), getRefineErrorMessage: vi.fn((err: any) => err?.message || "Failed to refine"), updateGlobalSettings: vi.fn().mockResolvedValue({}), pauseTask: vi.fn().mockResolvedValue({}), unpauseTask: vi.fn().mockResolvedValue({}), fetchWorkflowResults: vi.fn().mockResolvedValue([]), }); }); // Mock lucide-react icons used by TaskDetailModal, TaskForm, PrSection, CustomModelDropdown vi.mock("lucide-react", () => ({ Pencil: () => null, Sparkles: () => null, Globe: () => null, GitPullRequest: () => null, ExternalLink: () => null, RefreshCw: () => null, Plus: () => null, MessageSquare: () => null, ChevronUp: () => null, ChevronDown: () => null, ChevronRight: (props: any) => , X: () => null, Maximize2: () => null, Minimize2: () => null, Loader2: () => null, Bot: () => null, CircleDot: () => null, XCircle: () => null, GitMerge: () => null, GitBranch: () => null, })); vi.mock("../../hooks/useAgentLogs", () => ({ useAgentLogs: vi.fn(() => ({ entries: [], loading: false, clear: vi.fn(), loadMore: vi.fn(async () => {}), hasMore: false, total: null, loadingMore: false })), })); // Mock usePluginUiSlots hook const mockUsePluginUiSlots = vi.fn((_projectId?: string) => ({ slots: [] as any[], getSlotsForId: vi.fn((_id: string) => [] as any[]), loading: false, error: null, })); vi.mock("../../hooks/usePluginUiSlots", () => ({ usePluginUiSlots: (projectId?: string) => mockUsePluginUiSlots(projectId), })); const mockConfirm = vi.fn(); vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: mockConfirm }), })); function makeTask(overrides: Partial = {}): TaskDetail { return { id: "FN-099", description: "Test task", column: "in-progress" as Column, dependencies: [], prompt: "", steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", ...overrides, } as TaskDetail; } const noop = vi.fn(); const noopMove = vi.fn(async () => ({}) as Task); const noopDelete = vi.fn(async () => ({}) as Task); const noopMerge = vi.fn(async () => ({ merged: false }) as MergeResult); const noopRetry = vi.fn(async () => ({}) as Task); const noopOpenDetail = vi.fn(); function getCssRuleBlock(css: string, selector: string): string { const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const ruleMatch = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`)); return ruleMatch?.[1] ?? ""; } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function expectBaseRule(css: string, selector: string, declaration: string): void { const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{[^}]*${escapeRegExp(declaration)}`); expect(pattern.test(css)).toBe(true); } function readDashboardStylesSource(): string { return loadAllAppCss(); } describe("TaskDetailModal", () => { beforeEach(() => { mockConfirm.mockReset(); mockConfirm.mockResolvedValue(true); clearAuthToken(); localStorage.removeItem("fn.authToken"); }); afterEach(() => { clearAuthToken(); localStorage.removeItem("fn.authToken"); }); describe("provenance display", () => { it.each([ ["dashboard_ui", undefined, "Created via Dashboard"], ["agent_heartbeat", "agent-123", "Created by"], ] as const)("renders provenance text for %s", (sourceType, sourceAgentId, expectedText) => { render( , ); expect(screen.getByText(new RegExp(expectedText))).toBeInTheDocument(); if (sourceType === "agent_heartbeat" && sourceAgentId) { expect(screen.getByRole("button", { name: sourceAgentId })).toBeInTheDocument(); } }); it("renders parent task link for refinement provenance", async () => { render( , ); expect(screen.getByText(/Created via Refinement/)).toBeInTheDocument(); const link = screen.getByRole("button", { name: "FN-001" }); expect(link).toBeInTheDocument(); await userEvent.click(link); await waitFor(() => { expect(noopOpenDetail).toHaveBeenCalled(); }); }); it("renders issue URL for github import provenance", () => { render( , ); expect(screen.getByText("Created via GitHub Import (https://github.com/owner/repo/issues/42)")).toBeInTheDocument(); }); it("renders finding label for research provenance", () => { render( , ); expect(screen.getByText("Created via Research (Pricing pressure in EU segment)")).toBeInTheDocument(); }); it("falls back to run id for research provenance context", () => { render( , ); expect(screen.getByText("Created via Research (RR-456)")).toBeInTheDocument(); }); it.each(["unknown", undefined] as const)("omits provenance for %s source", (sourceType) => { render( , ); expect(screen.queryByText(/Created via/)).not.toBeInTheDocument(); }); }); it("styles detail-body scrollbar rules", () => { const css = loadAllAppCss(); expectBaseRule(css, ".detail-body", "scrollbar-color: var(--border) transparent;"); expectBaseRule(css, ".detail-body", "scrollbar-width: thin;"); expectBaseRule(css, ".detail-body::-webkit-scrollbar", "width: 6px;"); expectBaseRule(css, ".detail-body::-webkit-scrollbar-track", "background: transparent;"); expectBaseRule(css, ".detail-body::-webkit-scrollbar-thumb", "background: var(--border);"); expectBaseRule(css, ".detail-body::-webkit-scrollbar-thumb:hover", "background: var(--text-muted);"); }); it("styles agent log viewer scroll container scrollbar rules", () => { const css = loadAllAppCss(); expectBaseRule(css, ".agent-log-viewer", "overflow: hidden;"); expectBaseRule(css, ".agent-log-viewer-scroll", "scrollbar-color: var(--border) transparent;"); expectBaseRule(css, ".agent-log-viewer-scroll", "scrollbar-width: thin;"); expectBaseRule(css, ".agent-log-viewer-scroll::-webkit-scrollbar", "width: 6px;"); expectBaseRule(css, ".agent-log-viewer-scroll::-webkit-scrollbar-thumb", "background: var(--border);"); expectBaseRule(css, ".agent-log-model-header", "background: var(--bg-tertiary);"); }); it("renders markdown-body without detail-prompt class when prompt exists", () => { const { container } = render( , ); const markdownDiv = container.querySelector(".markdown-body"); expect(markdownDiv).toBeTruthy(); expect(markdownDiv!.classList.contains("detail-prompt")).toBe(false); }); it("strips the leading heading from prompt and renders remaining markdown", () => { const { container } = render( , ); // The leading # heading should be stripped (modal has its own header) expect(container.querySelector(".markdown-body h1")).toBeNull(); expect(container.querySelector("strong")?.textContent).toBe("bold"); }); it("renders (no prompt) with detail-prompt class when prompt is absent", () => { const { container } = render( , ); const fallback = screen.getByText("(no prompt)"); expect(fallback).toBeTruthy(); expect(fallback.classList.contains("detail-prompt")).toBe(true); expect(fallback.classList.contains("markdown-body")).toBe(false); }); it("does not render a PROMPT.md heading", () => { render( , ); expect(screen.queryByText("PROMPT.md")).toBeNull(); }); it("renders Comments tab", () => { render( , ); expect(screen.getByText("Comments")).toBeTruthy(); }); it("appends daemon token query to attachment href/src URLs for direct browser loads", () => { localStorage.setItem("fn.authToken", "daemon-token"); render( , ); const attachmentLink = screen.getByRole("link", { name: "Screenshot" }); const attachmentImage = screen.getByAltText("Screenshot"); expect(attachmentLink.getAttribute("href")).toBe( "/api/tasks/FN-099/attachments/screenshot.png?fn_token=daemon-token", ); expect(attachmentImage.getAttribute("src")).toBe( "/api/tasks/FN-099/attachments/screenshot.png?fn_token=daemon-token", ); }); it("leaves attachment href/src URLs unchanged when no daemon token is present", () => { render( , ); const attachmentLink = screen.getByRole("link", { name: "Screenshot" }); const attachmentImage = screen.getByAltText("Screenshot"); expect(attachmentLink.getAttribute("href")).toBe("/api/tasks/FN-099/attachments/screenshot.png"); expect(attachmentImage.getAttribute("src")).toBe("/api/tasks/FN-099/attachments/screenshot.png"); }); it("renders Retry button when task status is 'failed' (in Actions dropdown)", () => { render( , ); // Open Actions dropdown to see Retry const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); expect(screen.getByRole("menuitem", { name: "Retry" })).toBeTruthy(); }); it("does NOT render Retry button when task status is not 'failed'", () => { render( , ); // No Retry should be visible in the Actions dropdown const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); expect(screen.queryByRole("menuitem", { name: "Retry" })).toBeNull(); }); it("does NOT render Retry button when onRetryTask is not provided", () => { render( , ); expect(screen.queryByText("Retry")).toBeNull(); }); describe("retry action uniqueness for in-review failed tasks", () => { it("shows exactly one Retry button when task is in-review AND failed (in Actions dropdown)", () => { render( , ); // Open Actions dropdown and check for exactly one Retry const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); const retryButtons = screen.getAllByRole("menuitem", { name: "Retry" }); expect(retryButtons).toHaveLength(1); }); it("shows exactly one Retry button when task is in-review AND stuck-killed (in Actions dropdown)", () => { render( , ); // Open Actions dropdown and check for exactly one Retry const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); const retryButtons = screen.getAllByRole("menuitem", { name: "Retry" }); expect(retryButtons).toHaveLength(1); }); it("shows Retry for a stranded planning triage task", () => { render( , ); const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); const retryButtons = screen.getAllByRole("menuitem", { name: "Retry" }); expect(retryButtons).toHaveLength(1); }); it("closes modal immediately when Retry is clicked (before API call)", async () => { const onClose = vi.fn(); const onRetryTask = vi.fn(async () => ({}) as Task); render( , ); // Open Actions dropdown and click Retry const actionsBtn = screen.getByRole("button", { name: /actions/i }); await act(async () => { fireEvent.click(actionsBtn); }); const retryBtn = screen.getByRole("menuitem", { name: "Retry" }); await act(async () => { fireEvent.click(retryBtn); }); // Modal should close immediately (optimistic close before API call) expect(onClose).toHaveBeenCalledTimes(1); // onRetryTask should still be called with the correct task ID expect(onRetryTask).toHaveBeenCalledWith("FN-099"); }); it("shows exactly one success toast when retry succeeds", async () => { const onClose = vi.fn(); const onRetryTask = vi.fn(async () => ({}) as Task); const addToast = vi.fn(); render( , ); // Open Actions dropdown and click Retry const actionsBtn = screen.getByRole("button", { name: /actions/i }); await act(async () => { fireEvent.click(actionsBtn); }); const retryBtn = screen.getByRole("menuitem", { name: "Retry" }); await act(async () => { fireEvent.click(retryBtn); }); // Wait for the promise to resolve await act(async () => {}); // Only one toast — the success toast, no info toast expect(addToast).toHaveBeenCalledTimes(1); expect(addToast).toHaveBeenCalledWith("Retried FN-099", "success"); }); it("shows exactly one error toast when retry fails", async () => { const onClose = vi.fn(); const onRetryTask = vi.fn(async () => { throw new Error("Server error"); }); const addToast = vi.fn(); render( , ); // Open Actions dropdown and click Retry const actionsBtn = screen.getByRole("button", { name: /actions/i }); await act(async () => { fireEvent.click(actionsBtn); }); const retryBtn = screen.getByRole("menuitem", { name: "Retry" }); await act(async () => { fireEvent.click(retryBtn); }); // Wait for the promise to reject await act(async () => {}); // Only one toast — the error toast expect(addToast).toHaveBeenCalledTimes(1); expect(addToast).toHaveBeenCalledWith("Server error", "error"); }); it("shows in-review split button with primary action and secondary move option", () => { const { container } = render( , ); const moveBtn = screen.getByRole("button", { name: "Move to Todo" }); expect(moveBtn).toBeTruthy(); const chevronZone = container.querySelector(".detail-move-btn__arrow"); expect(chevronZone).toBeTruthy(); fireEvent.keyDown(moveBtn, { key: "ArrowDown" }); expect(screen.getByRole("menuitem", { name: "Back to In Progress" })).toBeTruthy(); expect(screen.queryByRole("menuitem", { name: "Move to Todo" })).toBeNull(); const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); expect(screen.queryByRole("menuitem", { name: "Retry" })).toBeNull(); }); it("in-review failed task shows both Retry action and secondary move option", async () => { render( , ); const actionsBtn = screen.getByRole("button", { name: /actions/i }); await act(async () => { fireEvent.click(actionsBtn); }); expect(screen.getByRole("menuitem", { name: "Retry" })).toBeTruthy(); expect(screen.getAllByRole("menuitem", { name: "Retry" })).toHaveLength(1); const chevronZone = document.querySelector(".detail-move-btn__arrow"); await act(async () => { fireEvent.click(chevronZone!); }); expect(screen.getByRole("menuitem", { name: "Back to In Progress" })).toBeTruthy(); expect(screen.queryByRole("menuitem", { name: "Move to Todo" })).toBeNull(); }); it("split-button renders with chevron when multiple transitions exist", async () => { const { container } = render( , ); const moveBtn = screen.getByRole("button", { name: "Move to In Review" }); expect(moveBtn).toBeTruthy(); const chevronZone = container.querySelector(".detail-move-btn__arrow"); expect(chevronZone).toBeTruthy(); await act(async () => { fireEvent.click(chevronZone!); }); expect(screen.getByRole("menuitem", { name: "Move to Todo" })).toBeTruthy(); expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy(); expect(screen.getByRole("menuitem", { name: "Move to Done" })).toBeTruthy(); expect(screen.queryByRole("menuitem", { name: "Move to In Review" })).toBeNull(); }); it("split-button renders without chevron when only one transition", () => { const { container } = render( , ); expect(screen.getByRole("button", { name: "Move to Todo" })).toBeTruthy(); expect(container.querySelector(".detail-move-btn__arrow")).toBeNull(); expect(container.querySelector(".detail-move-split-btn__divider")).toBeNull(); }); it("clicking main button executes primary transition immediately", async () => { const onMoveTask = vi.fn().mockResolvedValue(undefined); render( , ); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Move to In Review" })); }); expect(onMoveTask).toHaveBeenCalledWith("FN-099", "in-review", undefined); expect(screen.queryByRole("menu")).toBeNull(); }); it("chevron dropdown includes only secondary transitions", async () => { render( , ); const moveBtn = screen.getByRole("button", { name: "Move to In Review" }); fireEvent.keyDown(moveBtn, { key: "ArrowDown" }); expect(screen.getByRole("menuitem", { name: "Move to Todo" })).toBeTruthy(); expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy(); expect(screen.getByRole("menuitem", { name: "Move to Done" })).toBeTruthy(); expect(screen.queryByRole("menuitem", { name: "Move to In Review" })).toBeNull(); fireEvent.keyDown(screen.getByRole("menuitem", { name: "Move to Todo" }), { key: "Escape" }); expect(screen.queryByRole("menuitem", { name: "Move to Todo" })).toBeNull(); expect(document.activeElement).toBe(moveBtn); }); }); it("shows description exactly once for a task without title", () => { const { container } = render( , ); // The heading "FN-099" should be stripped from the markdown const markdownBody = container.querySelector(".markdown-body"); expect(markdownBody?.innerHTML).not.toContain("FN-099"); // Description appears in the markdown body expect(markdownBody?.textContent).toContain("Fix the login bug"); // The detail header shows the ID (not duplicated as markdown heading) expect(container.querySelector(".detail-id")?.textContent).toBe("FN-099"); // The h2 title shows description, not the task ID const h2 = container.querySelector("h2.detail-title"); expect(h2?.textContent).toBe("Fix the login bug"); }); it("shows the title in

when task.title is set", () => { const { container } = render( , ); const h2 = container.querySelector("h2.detail-title"); expect(h2?.textContent).toBe("Implement dark mode"); }); describe("description truncation", () => { it("truncates description over 200 characters with Show more button", () => { const longDescription = "A".repeat(250); const { container } = render( , ); const h2 = container.querySelector("h2.detail-title"); expect(h2?.textContent).toBe("A".repeat(200) + "…"); const toggle = container.querySelector(".detail-description-toggle"); expect(toggle?.textContent).toBe("Show more"); }); it("expands full description when Show more is clicked", async () => { const longDescription = "B".repeat(250); const { container } = render( , ); const toggle = container.querySelector(".detail-description-toggle") as HTMLButtonElement; await act(async () => { fireEvent.click(toggle); }); const h2 = container.querySelector("h2.detail-title"); expect(h2?.textContent).toBe("B".repeat(250)); expect(toggle.textContent).toBe("Show less"); }); it("collapses description when Show less is clicked", async () => { const longDescription = "C".repeat(250); const { container } = render( , ); // First expand const toggle = container.querySelector(".detail-description-toggle") as HTMLButtonElement; await act(async () => { fireEvent.click(toggle); }); // Then collapse await act(async () => { fireEvent.click(toggle); }); const h2 = container.querySelector("h2.detail-title"); expect(h2?.textContent).toBe("C".repeat(200) + "…"); expect(toggle.textContent).toBe("Show more"); }); it("does not show toggle for description under 200 characters", () => { const shortDescription = "Short description"; const { container } = render( , ); const h2 = container.querySelector("h2.detail-title"); expect(h2?.textContent).toBe(shortDescription); expect(container.querySelector(".detail-description-toggle")).toBeNull(); }); it("does not show toggle when title is present and short", () => { const { container } = render( , ); const h2 = container.querySelector("h2.detail-title"); expect(h2?.textContent).toBe("Short title"); expect(container.querySelector(".detail-description-toggle")).toBeNull(); }); it("shows toggle when title exceeds 200 characters", () => { const longTitle = "D".repeat(250); const { container } = render( , ); const h2 = container.querySelector("h2.detail-title"); expect(h2?.textContent).toBe("D".repeat(200) + "…"); const toggle = container.querySelector(".detail-description-toggle"); expect(toggle?.textContent).toBe("Show more"); }); it("resets expanded state when task changes", async () => { const longDescription1 = "E".repeat(250); const longDescription2 = "F".repeat(250); const { container, rerender } = render( , ); // Expand the first task const toggle = container.querySelector(".detail-description-toggle") as HTMLButtonElement; await act(async () => { fireEvent.click(toggle); }); // Verify expanded const h2Before = container.querySelector("h2.detail-title"); expect(h2Before?.textContent).toBe("E".repeat(250)); // Change to a different task rerender( , ); // Should be collapsed again const h2After = container.querySelector("h2.detail-title"); expect(h2After?.textContent).toBe("F".repeat(200) + "…"); }); }); it("always shows task.id in the detail-id badge regardless of title", () => { // With title const { container: withTitle } = render( , ); expect(withTitle.querySelector(".detail-id")?.textContent).toBe("FN-099"); // Without title const { container: withoutTitle } = render( , ); expect(withoutTitle.querySelector(".detail-id")?.textContent).toBe("FN-099"); }); describe("paste image upload", () => { it("uploads an image when pasting clipboard image data", async () => { const { uploadAttachment } = await import("../../api"); const mockUpload = vi.mocked(uploadAttachment); const mockAttachment = { filename: "abc123.png", originalName: "image.png", size: 1024, mimeType: "image/png", createdAt: "2026-01-01T00:00:00Z", }; mockUpload.mockResolvedValueOnce(mockAttachment); const addToast = vi.fn(); render( , ); const imageFile = new File(["fake-image"], "image.png", { type: "image/png" }); const pasteEvent = new Event("paste", { bubbles: true }) as any; pasteEvent.clipboardData = { items: [ { type: "image/png", getAsFile: () => imageFile, }, ], }; await act(async () => { document.dispatchEvent(pasteEvent); }); await waitFor(() => { expect(mockUpload).toHaveBeenCalledWith("FN-099", imageFile, undefined); expect(addToast).toHaveBeenCalledWith("Screenshot attached", "success"); }); }); it("does not intercept paste events without image data", async () => { const { uploadAttachment } = await import("../../api"); const mockUpload = vi.mocked(uploadAttachment); mockUpload.mockClear(); render( , ); const pasteEvent = new Event("paste", { bubbles: true }) as any; pasteEvent.clipboardData = { items: [ { type: "text/plain", getAsFile: () => null, }, ], }; await act(async () => { document.dispatchEvent(pasteEvent); }); expect(mockUpload).not.toHaveBeenCalled(); }); it("shows uploading state during paste upload", async () => { const { uploadAttachment } = await import("../../api"); const mockUpload = vi.mocked(uploadAttachment); let resolveUpload!: (value: any) => void; mockUpload.mockResolvedValueOnce( new Promise((resolve) => { resolveUpload = resolve; }) as any, ); render( , ); const imageFile = new File(["fake"], "shot.png", { type: "image/png" }); const pasteEvent = new Event("paste", { bubbles: true }) as any; pasteEvent.clipboardData = { items: [{ type: "image/png", getAsFile: () => imageFile }], }; act(() => { document.dispatchEvent(pasteEvent); }); // While uploading, button should show "Uploading…" await waitFor(() => { expect(screen.getByText("Uploading…")).toBeTruthy(); }); await act(async () => { resolveUpload({ filename: "x.png", originalName: "shot.png", size: 100, mimeType: "image/png", createdAt: "2026-01-01T00:00:00Z", }); }); await waitFor(() => { expect(screen.getByText("Attach Screenshot")).toBeTruthy(); }); }); }); describe("drag and drop image upload", () => { it("uploads an image when dropped onto the modal", async () => { const { uploadAttachment } = await import("../../api"); const mockUpload = vi.mocked(uploadAttachment); const mockAttachment = { filename: "drop123.png", originalName: "dropped.png", size: 2048, mimeType: "image/png", createdAt: "2026-01-01T00:00:00Z", }; mockUpload.mockResolvedValueOnce(mockAttachment); const addToast = vi.fn(); const { container } = render( , ); const modal = container.querySelector(".modal.modal-lg")!; const imageFile = new File(["fake-image"], "dropped.png", { type: "image/png" }); await act(async () => { fireEvent.drop(modal, { dataTransfer: { files: [imageFile], }, }); }); await waitFor(() => { expect(mockUpload).toHaveBeenCalledWith("FN-099", imageFile, undefined); expect(addToast).toHaveBeenCalledWith("Screenshot attached", "success"); }); }); }); it("renders (no dependencies) when dependencies is empty", () => { render( , ); expect(screen.getByText("(no dependencies)")).toBeTruthy(); }); it("renders dependency list when dependencies exist", () => { const allTasks: Task[] = [ { id: "FN-001", title: "First dependency", description: "Desc 1", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }, { id: "FN-002", title: "Second dependency", description: "Desc 2", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }, ]; render( , ); // Check that dependency IDs are rendered const depIds = document.querySelectorAll(".detail-dep-id"); expect(depIds).toHaveLength(2); expect(depIds[0].textContent).toBe("FN-001"); expect(depIds[1].textContent).toBe("FN-002"); // Check that dependency labels (titles) are rendered const depLabels = document.querySelectorAll(".detail-dep-label"); expect(depLabels).toHaveLength(2); expect(depLabels[0].textContent).toBe("First dependency"); expect(depLabels[1].textContent).toBe("Second dependency"); expect(screen.queryByText("(no dependencies)")).toBeNull(); }); it("can add a dependency via the dropdown", async () => { const { updateTask } = await import("../../api"); const allTasks: Task[] = [ { id: "FN-001", description: "Dep 1", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }, { id: "FN-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }, ]; render( , ); fireEvent.click(screen.getByText("Add Dependency")); // Should show KB-001 in the dropdown but not KB-099 (self is excluded) const dropdown = document.querySelector(".dep-dropdown")!; expect(dropdown).toBeTruthy(); expect(dropdown.textContent).toContain("FN-001"); expect(dropdown.querySelectorAll(".dep-dropdown-item")).toHaveLength(1); fireEvent.click(screen.getByText("FN-001")); await waitFor(() => { expect(updateTask).toHaveBeenCalledWith("FN-099", { dependencies: ["FN-001"] }, undefined); }); }); it("can remove a dependency", async () => { const { updateTask } = await import("../../api"); render( , ); const removeButtons = screen.getAllByTitle(/Remove dependency/); fireEvent.click(removeButtons[0]); // Remove KB-001 await waitFor(() => { expect(updateTask).toHaveBeenCalledWith("FN-099", { dependencies: ["FN-002"] }, undefined); }); }); it("wraps in-review PR content in a spaced detail section after dependencies", () => { const { container } = render( , ); const depsSection = container.querySelector(".detail-deps"); const prSection = container.querySelector(".detail-pr-section"); expect(depsSection).toBeTruthy(); expect(prSection).toBeTruthy(); expect(depsSection?.nextElementSibling).toBe(prSection); expect(prSection?.querySelector(".pr-section")).toBeTruthy(); }); it("defines tokenized margin on detail-pr-section spacing contract", () => { const css = loadAllAppCss(); expectBaseRule(css, ".detail-pr-section", "margin-top: var(--space-lg);"); }); it("activity list does not have nested scroll constraints", () => { const { container } = render( , ); // Click Logs tab — Activity is the default subview fireEvent.click(screen.getByText("Logs")); const activityList = container.querySelector(".detail-activity-list"); expect(activityList).toBeTruthy(); const style = (activityList as HTMLElement).style; expect(style.overflowY).not.toBe("auto"); expect(style.maxHeight).toBe(""); }); it("renders dependency dropdown items sorted newest-first by createdAt", () => { const allTasks: Task[] = [ { id: "FN-001", description: "Oldest", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" }, { id: "FN-003", description: "Newest", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-01T00:00:00Z", updatedAt: "2026-03-01T00:00:00Z" }, { id: "FN-002", description: "Middle", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-02-01T00:00:00Z", updatedAt: "2026-02-01T00:00:00Z" }, { id: "FN-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-15T00:00:00Z", updatedAt: "2026-03-15T00:00:00Z" }, ]; render( , ); fireEvent.click(screen.getByText("Add Dependency")); const items = document.querySelectorAll(".dep-dropdown-item"); expect(items).toHaveLength(3); const ids = Array.from(items).map((el) => el.querySelector(".dep-dropdown-id")?.textContent); expect(ids).toEqual(["FN-003", "FN-002", "FN-001"]); }); it("renders tasks with identical createdAt sorted newest-ID-first in dependency dropdown", () => { const allTasks: Task[] = [ { id: "FN-001", description: "First", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" }, { id: "FN-002", description: "Second", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" }, { id: "FN-003", description: "Third", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" }, { id: "FN-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" }, ]; render( , ); fireEvent.click(screen.getByText("Add Dependency")); const items = document.querySelectorAll(".dep-dropdown-item"); expect(items).toHaveLength(3); const ids = Array.from(items).map((el) => el.querySelector(".dep-dropdown-id")?.textContent); expect(ids).toEqual(["FN-003", "FN-002", "FN-001"]); }); describe("tab toggle", () => { it("defaults to the Definition tab", () => { const { container } = render( , ); expect(screen.getByText("Definition")).toBeTruthy(); expect(screen.getByText("Logs")).toBeTruthy(); // Activity and Agent Log are subviews inside the Logs tab, not top-level tabs // They should NOT be visible on the Definition tab expect(screen.queryByText("Activity")).toBeNull(); expect(screen.queryByText("Agent Log")).toBeNull(); // Definition content should be visible expect(container.querySelector(".markdown-body")).toBeTruthy(); // Activity section should NOT be visible initially expect(container.querySelector(".detail-activity")).toBeNull(); // Agent log viewer should not be visible expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull(); // After clicking Logs tab, the subview toggle buttons should appear fireEvent.click(screen.getByText("Logs")); const logSubviewToggle = container.querySelector(".log-subview-toggle"); expect(logSubviewToggle).toBeTruthy(); expect(logSubviewToggle!.textContent).toContain("Activity"); expect(logSubviewToggle!.textContent).toContain("Agent Log"); }); it("switches to Activity subview via Logs tab and shows activity feed", () => { const { container } = render( , ); // Click Logs tab — Activity is the default subview fireEvent.click(screen.getByText("Logs")); // Activity section should be visible expect(container.querySelector(".detail-activity")).toBeTruthy(); // Activity list should be visible expect(container.querySelector(".detail-activity-list")).toBeTruthy(); // Definition content should be hidden expect(container.querySelector(".markdown-body")).toBeNull(); }); it("Activity subview renders log entries correctly", () => { const { container } = render( , ); // Click Logs tab — Activity is the default subview fireEvent.click(screen.getByText("Logs")); const activityList = container.querySelector(".detail-activity-list"); expect(activityList).toBeTruthy(); // Check log entries are rendered (in reverse order - newest first) const logEntries = container.querySelectorAll(".detail-log-entry"); expect(logEntries).toHaveLength(3); // Most recent entry should be first expect(logEntries[0].textContent).toContain("Completed step 1"); expect(logEntries[1].textContent).toContain("Started work"); expect(logEntries[1].textContent).toContain("Success"); // outcome expect(logEntries[2].textContent).toContain("Created task"); }); it("Activity subview keeps action/outcome rendering intact", () => { const { container } = render( , ); fireEvent.click(screen.getByText("Logs")); const actions = container.querySelectorAll(".detail-log-action"); const outcomes = container.querySelectorAll(".detail-log-outcome"); expect(actions).toHaveLength(2); expect(outcomes).toHaveLength(1); expect(Array.from(actions).map((entry) => entry.textContent)).toEqual(["Created task", "Started work"]); expect(outcomes[0].textContent).toBe("Step completed successfully"); }); it("Activity timeline CSS keeps action/outcome high-contrast and timestamp secondary", () => { const stylesCssText = readDashboardStylesSource(); expect(stylesCssText).toContain(".detail-log-action"); const actionRule = getCssRuleBlock(stylesCssText, ".detail-log-action"); const outcomeRule = getCssRuleBlock(stylesCssText, ".detail-log-outcome"); const timestampRule = getCssRuleBlock(stylesCssText, ".detail-log-timestamp"); expect(actionRule).toContain("color: var(--text);"); expect(outcomeRule).toContain("color: var(--text);"); expect(outcomeRule).toContain("background: var(--surface);"); expect(timestampRule).toContain("color: var(--text-muted);"); expect(timestampRule).not.toContain("color: var(--text);"); }); it("Activity subview shows empty state when no logs", () => { const { container } = render( , ); // Click Logs tab — Activity is the default subview fireEvent.click(screen.getByText("Logs")); // Activity section should be visible expect(container.querySelector(".detail-activity")).toBeTruthy(); // Empty state should be shown expect(container.querySelector(".detail-log-empty")).toBeTruthy(); expect(screen.getByText("(no activity)")).toBeTruthy(); // Activity list should NOT be present when empty expect(container.querySelector(".detail-activity-list")).toBeNull(); }); it("can switch between all tabs and Logs subviews", () => { const { container } = render( , ); // Start on Definition tab expect(container.querySelector(".markdown-body")).toBeTruthy(); expect(container.querySelector(".detail-activity")).toBeNull(); // Switch to Logs tab (Activity subview is default) fireEvent.click(screen.getByText("Logs")); expect(container.querySelector(".detail-activity")).toBeTruthy(); expect(container.querySelector(".markdown-body")).toBeNull(); // Switch to Agent Log subview within Logs tab fireEvent.click(screen.getByText("Agent Log")); expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeTruthy(); expect(container.querySelector(".detail-activity")).toBeNull(); // Switch back to Activity subview within Logs tab fireEvent.click(screen.getByText("Activity")); expect(container.querySelector(".detail-activity")).toBeTruthy(); expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull(); // Switch to Comments tab fireEvent.click(screen.getByText("Comments")); expect(screen.getByPlaceholderText(/Add a comment/)).toBeTruthy(); expect(container.querySelector(".detail-activity")).toBeNull(); // Switch back to Definition tab fireEvent.click(screen.getByText("Definition")); expect(container.querySelector(".markdown-body")).toBeTruthy(); expect(container.querySelector(".detail-activity")).toBeNull(); }); it("switches to Agent Log subview via Logs tab and back", async () => { const { useAgentLogs } = await import("../../hooks/useAgentLogs"); const mockUseAgentLogs = vi.mocked(useAgentLogs); const { container } = render( , ); // Click Logs tab, then Agent Log subview fireEvent.click(screen.getByText("Logs")); fireEvent.click(screen.getByText("Agent Log")); // Agent log viewer should appear expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeTruthy(); // Definition content should be hidden expect(container.querySelector(".markdown-body")).toBeNull(); // Click Definition tab to go back fireEvent.click(screen.getByText("Definition")); // Definition content should reappear expect(container.querySelector(".markdown-body")).toBeTruthy(); expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull(); }); it("passes enabled=true to useAgentLogs only when Logs → Agent Log subview is active", async () => { const { useAgentLogs } = await import("../../hooks/useAgentLogs"); const mockUseAgentLogs = vi.mocked(useAgentLogs); mockUseAgentLogs.mockClear(); const { rerender } = render( , ); // Default: Definition tab active → enabled should be false const initialCall = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1]; expect(initialCall[1]).toBe(false); // Switch to Logs tab (Activity subview is default) — enabled should still be false fireEvent.click(screen.getByText("Logs")); const afterLogsClick = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1]; expect(afterLogsClick[1]).toBe(false); // Switch to Agent Log subview — enabled should become true fireEvent.click(screen.getByText("Agent Log")); const afterAgentLog = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1]; expect(afterAgentLog[1]).toBe(true); }); it("switches to Comments tab", async () => { const { container } = render( , ); // Click Comments tab fireEvent.click(screen.getByText("Comments")); // Comments content should appear const headings = screen.getAllByText("Comments"); expect(headings.length).toBeGreaterThanOrEqual(1); expect(screen.getByPlaceholderText(/Add a comment/)).toBeTruthy(); // Definition content should be hidden expect(container.querySelector(".markdown-body")).toBeNull(); }); it("shows correct top-level tabs including Logs", async () => { const { container } = render( , ); // For an in-progress task (no workflow steps, no merge commit), the // top-level tabs are: Definition, Logs, Changes, Comments, Documents, // Model, Workflow, Stats, Routing. const tabTexts = ["Definition", "Logs", "Changes", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing"]; const tabs = screen.getAllByRole("button").filter((b) => tabTexts.includes(b.textContent || "") ); expect(tabs.length).toBe(9); expect(tabs[0].textContent).toBe("Definition"); expect(tabs[1].textContent).toBe("Logs"); expect(tabs[2].textContent).toBe("Changes"); expect(tabs[3].textContent).toBe("Comments"); expect(tabs[4].textContent).toBe("Documents"); expect(tabs[5].textContent).toBe("Model"); expect(tabs[6].textContent).toBe("Workflow"); expect(tabs[7].textContent).toBe("Stats"); expect(tabs[8].textContent).toBe("Routing"); // Activity and Agent Log are NOT top-level tabs (they are subviews inside Logs) expect(container.querySelectorAll(".detail-tab").length).toBe(9); // Workflow tab should always appear even when no workflow steps are configured expect(screen.getByText("Workflow")).toBeInTheDocument(); // Commits tab should NOT appear for non-done tasks expect(screen.queryByText("Commits")).toBeNull(); }); }); describe("Agent Log full-height layout", () => { it("applies detail-body--agent-log class when Logs → Agent Log subview is active", () => { const { container } = render( , ); // Initially, detail-body should NOT have the agent-log modifier expect(container.querySelector(".detail-body--agent-log")).toBeNull(); // Switch to Logs tab, then Agent Log subview fireEvent.click(screen.getByText("Logs")); expect(container.querySelector(".detail-body--agent-log")).toBeNull(); // Activity subview default fireEvent.click(screen.getByText("Agent Log")); // detail-body should now have the agent-log modifier class expect(container.querySelector(".detail-body--agent-log")).toBeTruthy(); // Switch back to Definition tab fireEvent.click(screen.getByText("Definition")); // modifier class should be removed expect(container.querySelector(".detail-body--agent-log")).toBeNull(); }); it("wraps AgentLogViewer in detail-section--agent-log class", () => { const { container } = render( , ); // Switch to Logs tab, then Agent Log subview fireEvent.click(screen.getByText("Logs")); fireEvent.click(screen.getByText("Agent Log")); // The section wrapping AgentLogViewer should have the full-height class const section = container.querySelector(".detail-section--agent-log"); expect(section).toBeTruthy(); expect(section!.querySelector("[data-testid='agent-log-viewer']")).toBeTruthy(); }); it("does not apply detail-body--agent-log when editing", () => { const { container } = render( , ); // Switch to Logs tab, then Agent Log subview first fireEvent.click(screen.getByText("Logs")); fireEvent.click(screen.getByText("Agent Log")); expect(container.querySelector(".detail-body--agent-log")).toBeTruthy(); // Now enter edit mode via the pencil button in the header const editBtn = screen.getByLabelText("Edit task"); fireEvent.click(editBtn); // The detail-body--agent-log class should be removed while editing expect(container.querySelector(".detail-body--agent-log")).toBeNull(); }); }); describe("Agent Log model resolution", () => { // AgentLogViewer only renders the model header when entries.length > 0, // so we mock useAgentLogs to return at least one entry. const mockLogEntry = { timestamp: "2026-01-01T00:00:00Z", taskId: "FN-099", text: "hello", type: "text" as const }; async function setupModelTest(settingsOverrides: Record = {}) { const { fetchSettings } = await import("../../api"); const { useAgentLogs } = await import("../../hooks/useAgentLogs"); vi.mocked(fetchSettings).mockResolvedValueOnce({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {}, ...settingsOverrides, } as any); vi.mocked(useAgentLogs).mockReturnValue({ entries: [mockLogEntry], loading: false, clear: vi.fn(), loadMore: vi.fn(async () => {}), hasMore: false, total: null, loadingMore: false, }); return render( , ); } async function setupModelTestWithTask(taskOverrides: Partial, settingsOverrides: Record = {}) { const { fetchSettings } = await import("../../api"); const { useAgentLogs } = await import("../../hooks/useAgentLogs"); vi.mocked(fetchSettings).mockResolvedValueOnce({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {}, ...settingsOverrides, } as any); vi.mocked(useAgentLogs).mockReturnValue({ entries: [mockLogEntry], loading: false, clear: vi.fn(), loadMore: vi.fn(async () => {}), hasMore: false, total: null, loadingMore: false, }); return render( , ); } async function openAgentLogAndExpandModelDetails(container: HTMLElement) { fireEvent.click(screen.getByText("Logs")); fireEvent.click(screen.getByText("Agent Log")); await waitFor(() => { const header = container.querySelector("[data-testid='agent-log-model-header']"); expect(header).toBeTruthy(); }); const expandButton = screen.getByTestId("agent-log-model-expand") as HTMLButtonElement; if (expandButton.getAttribute("aria-expanded") !== "true") { fireEvent.click(expandButton); } return container.querySelector("[data-testid='agent-log-model-header']") as HTMLElement; } it("shows resolved executor from settings when task has no explicit executor override", async () => { const { container } = await setupModelTest({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", }); const header = await openAgentLogAndExpandModelDetails(container); // Validator should also fall back to the default expect(header.textContent).toContain("anthropic/claude-sonnet-4-5"); }); it("shows the project default override before the global default", async () => { const { container } = await setupModelTest({ defaultProviderOverride: "openai", defaultModelIdOverride: "gpt-4o", defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", }); const header = await openAgentLogAndExpandModelDetails(container); const matches = header.textContent!.match(/openai\/gpt-4o/g); expect(matches).toHaveLength(3); expect(header.textContent).not.toContain("anthropic/claude-sonnet-4-5"); }); it("shows resolved validator from project validator settings when task has no validator override", async () => { const { container } = await setupModelTest({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o", }); const header = await openAgentLogAndExpandModelDetails(container); // Executor falls back to default expect(header.textContent).toContain("anthropic/claude-sonnet-4-5"); // Validator uses the validator-specific setting expect(header.textContent).toContain("openai/gpt-4o"); }); it("falls back to default settings for validator when no validator-specific setting exists", async () => { const { container } = await setupModelTest({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", // No validatorProvider or validatorModelId }); const header = await openAgentLogAndExpandModelDetails(container); // Count occurrences - should appear three times (once for executor, once for validator, once for planning) const matches = header.textContent!.match(/anthropic\/claude-sonnet-4-5/g); expect(matches).toHaveLength(3); }); it("shows task executor override even when settings provide a default", async () => { const { container } = await setupModelTestWithTask( { modelProvider: "openai", modelId: "gpt-4o" }, { defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" }, ); const header = await openAgentLogAndExpandModelDetails(container); // Default model should not appear for executor expect(header.textContent).toContain("openai/gpt-4o"); // Validator falls back to default expect(header.textContent).toContain("anthropic/claude-sonnet-4-5"); }); it("shows task validator override even when settings provide a validator default", async () => { const { container } = await setupModelTestWithTask( { validatorModelProvider: "google", validatorModelId: "gemini-pro" }, { defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" }, ); const header = await openAgentLogAndExpandModelDetails(container); // Executor falls back to default expect(header.textContent).toContain("anthropic/claude-sonnet-4-5"); // Settings validator should not appear (task override wins) expect(header.textContent).not.toContain("openai/gpt-4o"); }); it("shows 'Using default' for both when no models can be resolved", async () => { const { container } = await setupModelTest({ // No defaultProvider/defaultModelId }); const header = await openAgentLogAndExpandModelDetails(container); expect(header.textContent).toContain("Using default"); // Should show "Using default" for executor, validator, and planning const defaultBadges = header.querySelectorAll(".model-badge-default"); expect(defaultBadges).toHaveLength(3); }); it("shows 'Using default' for both when settings fetch fails", async () => { const { fetchSettings } = await import("../../api"); const { useAgentLogs } = await import("../../hooks/useAgentLogs"); vi.mocked(fetchSettings).mockRejectedValueOnce(new Error("Network error")); vi.mocked(useAgentLogs).mockReturnValue({ entries: [mockLogEntry], loading: false, clear: vi.fn(), loadMore: vi.fn(async () => {}), hasMore: false, total: null, loadingMore: false, }); const { container } = render( , ); // Wait for the failed fetch to settle const header = await openAgentLogAndExpandModelDetails(container); expect(header.textContent).toContain("Using default"); const defaultBadges = header.querySelectorAll(".model-badge-default"); expect(defaultBadges).toHaveLength(3); }); it("shows partial override: task executor with settings-based validator", async () => { const { container } = await setupModelTestWithTask( { modelProvider: "google", modelId: "gemini-pro", // No validator override — should use settings validator }, { defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o", }, ); const header = await openAgentLogAndExpandModelDetails(container); // Executor uses task override expect(header.textContent).toContain("google/gemini-pro"); // Validator uses settings-specific validator expect(header.textContent).toContain("openai/gpt-4o"); }); // Planning model resolution tests describe("Planning model resolution", () => { it("shows planning model from runtime triage log marker", async () => { const { fetchSettings } = await import("../../api"); const { useAgentLogs } = await import("../../hooks/useAgentLogs"); vi.mocked(fetchSettings).mockResolvedValueOnce({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {}, defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", } as any); vi.mocked(useAgentLogs).mockReturnValue({ entries: [ { timestamp: "2026-01-01T00:00:00Z", taskId: "FN-099", text: "hello", type: "text" as const }, { timestamp: "2026-01-01T00:00:01Z", taskId: "FN-099", text: "Triage using model: google/gemini-pro", type: "text" as const, agent: "triage" }, ], loading: false, clear: vi.fn(), loadMore: vi.fn(async () => {}), hasMore: false, total: null, loadingMore: false, }); const { container } = render( , ); const header = await openAgentLogAndExpandModelDetails(container); // Planning should show the runtime triage marker, not settings default expect(header.textContent).toContain("Planning:"); expect(header.textContent).toContain("google/gemini-pro"); // Executor/Validator should still show settings default expect(header.textContent).toContain("anthropic/claude-sonnet-4-5"); }); it("shows planning model from settings planningProvider when no runtime marker", async () => { const { fetchSettings } = await import("../../api"); const { useAgentLogs } = await import("../../hooks/useAgentLogs"); vi.mocked(fetchSettings).mockResolvedValueOnce({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {}, defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", planningProvider: "openai", planningModelId: "gpt-4o", } as any); vi.mocked(useAgentLogs).mockReturnValue({ entries: [mockLogEntry], loading: false, clear: vi.fn(), loadMore: vi.fn(async () => {}), hasMore: false, total: null, loadingMore: false, }); const { container } = render( , ); const header = await openAgentLogAndExpandModelDetails(container); // Planning should use planningProvider/planningModelId from settings expect(header.textContent).toContain("Planning:"); expect(header.textContent).toContain("openai/gpt-4o"); // Executor/Validator should show default expect(header.textContent).toContain("anthropic/claude-sonnet-4-5"); // Planning should NOT show the default expect(header.textContent).toContain("openai/gpt-4o"); }); it("falls back to default settings for planning when no planning-specific setting exists", async () => { const { container } = await setupModelTest({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", }); const header = await openAgentLogAndExpandModelDetails(container); expect(header.textContent).toContain("Planning:"); expect(header.textContent).toContain("anthropic/claude-sonnet-4-5"); // Planning falls back to default - same as executor/validator const matches = header.textContent!.match(/anthropic\/claude-sonnet-4-5/g); expect(matches).toHaveLength(3); // executor, validator, planning }); it("shows 'Using default' for planning when no models can be resolved", async () => { const { container } = await setupModelTest({ // No defaultProvider/defaultModelId }); const header = await openAgentLogAndExpandModelDetails(container); expect(header.textContent).toContain("Planning:"); const defaultBadges = header.querySelectorAll(".model-badge-default"); // 3 default badges: executor, validator, planning expect(defaultBadges).toHaveLength(3); }); it("per-task planning model override takes precedence over settings", async () => { const { fetchSettings } = await import("../../api"); const { useAgentLogs } = await import("../../hooks/useAgentLogs"); vi.mocked(fetchSettings).mockResolvedValueOnce({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {}, defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", planningProvider: "openai", planningModelId: "gpt-4o", } as any); vi.mocked(useAgentLogs).mockReturnValue({ entries: [mockLogEntry], loading: false, clear: vi.fn(), loadMore: vi.fn(async () => {}), hasMore: false, total: null, loadingMore: false, }); const { container } = render( , ); const header = await openAgentLogAndExpandModelDetails(container); // Per-task override should take precedence over settings expect(header.textContent).toContain("Planning:"); expect(header.textContent).toContain("google/gemini-2.5-pro"); // Should NOT show the settings planning model expect(header.textContent).not.toContain("openai/gpt-4o"); }); it("runtime triage marker takes precedence over planningProvider settings", async () => { const { fetchSettings } = await import("../../api"); const { useAgentLogs } = await import("../../hooks/useAgentLogs"); vi.mocked(fetchSettings).mockResolvedValueOnce({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {}, defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", planningProvider: "openai", planningModelId: "gpt-4o", } as any); vi.mocked(useAgentLogs).mockReturnValue({ entries: [ { timestamp: "2026-01-01T00:00:00Z", taskId: "FN-099", text: "hello", type: "text" as const }, { timestamp: "2026-01-01T00:00:01Z", taskId: "FN-099", text: "Triage using model: google/gemini-pro", type: "text" as const, agent: "triage" }, ], loading: false, clear: vi.fn(), loadMore: vi.fn(async () => {}), hasMore: false, total: null, loadingMore: false, }); const { container } = render( , ); const header = await openAgentLogAndExpandModelDetails(container); // Runtime marker should win over planning settings expect(header.textContent).toContain("google/gemini-pro"); // Should NOT show the planning settings model expect(header.textContent).not.toContain("openai/gpt-4o"); }); }); }); describe("step progress", () => { it("renders step progress section when steps exist", () => { const { container } = render( , ); expect(container.querySelector(".detail-step-progress")).toBeTruthy(); expect(screen.getByText("Progress")).toBeTruthy(); }); it("shows '(no steps defined)' when steps array is empty", () => { const { container } = render( , ); expect(container.querySelector(".detail-step-progress")).toBeTruthy(); expect(screen.getByText("(no steps defined)")).toBeTruthy(); }); it("renders correct number of segments matching step count", () => { const { container } = render( , ); const segments = container.querySelectorAll(".step-progress-segment"); expect(segments).toHaveLength(3); }); it("segments have correct status modifier classes", () => { const { container } = render( , ); const segments = container.querySelectorAll(".step-progress-segment"); expect(segments[0].classList.contains("step-progress-segment--done")).toBe(true); expect(segments[1].classList.contains("step-progress-segment--in-progress")).toBe(true); expect(segments[2].classList.contains("step-progress-segment--pending")).toBe(true); expect(segments[3].classList.contains("step-progress-segment--skipped")).toBe(true); }); it("segments have correct inline background colors based on status", () => { const { container } = render( , ); const segments = container.querySelectorAll(".step-progress-segment"); expect((segments[0] as HTMLElement).style.backgroundColor).toBe("var(--color-success, #3fb950)"); expect((segments[1] as HTMLElement).style.backgroundColor).toBe("var(--todo, #58a6ff)"); expect((segments[2] as HTMLElement).style.backgroundColor).toBe("var(--border, #30363d)"); expect((segments[3] as HTMLElement).style.backgroundColor).toBe("var(--text-dim, #484f58)"); }); it("displays singular completion label for one-step tasks", () => { render( , ); expect(screen.getByText("1/1 step")).toBeTruthy(); expect(screen.queryByText("1/1 steps")).toBeNull(); }); it("displays correct completion count", () => { render( , ); expect(screen.getByText("2/4 steps")).toBeTruthy(); expect(screen.queryByText("2/4 step")).toBeNull(); }); it("has data-tooltip attribute with step name and status on each segment", () => { const { container } = render( , ); const segments = container.querySelectorAll(".step-progress-segment"); expect(segments[0].getAttribute("data-tooltip")).toBe("Initialize project (done)"); expect(segments[1].getAttribute("data-tooltip")).toBe("Add tests (in-progress)"); }); it("step progress only renders in Definition tab, not in Agent Log subview", () => { const { container } = render( , ); // Should be visible in Definition tab expect(container.querySelector(".detail-step-progress")).toBeTruthy(); // Switch to Logs tab, then Agent Log subview fireEvent.click(screen.getByText("Logs")); fireEvent.click(screen.getByText("Agent Log")); // Should not be visible in Agent Log subview expect(container.querySelector(".detail-step-progress")).toBeNull(); }); it("step progress is hidden in Comments tab", () => { const { container } = render( , ); // Switch to Comments tab fireEvent.click(screen.getByText("Comments")); // Should not be visible in Comments tab expect(container.querySelector(".detail-step-progress")).toBeNull(); }); }); describe("mobile responsive structure", () => { it("renders responsive structural classes (modal-lg, overlay, spacer, tabs, detail-body)", () => { const { container } = render( , ); expect(container.querySelector(".modal.modal-lg")).toBeTruthy(); expect(container.querySelector(".modal-overlay.open")).toBeTruthy(); expect(container.querySelector(".modal-actions .modal-actions-spacer")).toBeTruthy(); expect(container.querySelector(".detail-body")).toBeTruthy(); const tabs = container.querySelectorAll(".detail-tab"); expect(tabs.length).toBe(9); expect(tabs[0].classList.contains("detail-tab-active")).toBe(true); expect(Array.from(tabs).slice(1).every((t) => !t.classList.contains("detail-tab-active"))).toBe(true); // Responsive CSS controls sizing — no inline padding/fontSize/borderBottom leaks expect((tabs[0] as HTMLElement).style.padding).toBe(""); expect((tabs[0] as HTMLElement).style.fontSize).toBe(""); expect((container.querySelector(".detail-tabs") as HTMLElement).style.borderBottom).toBe(""); }); it("modal-actions contains Delete and Pause buttons for non-done tasks (via Actions dropdown)", () => { render( , ); // Actions are now in a dropdown - open it first const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); // Now the dropdown items should be visible expect(screen.getByRole("menuitem", { name: "Delete" })).toBeTruthy(); expect(screen.getByRole("menuitem", { name: "Pause" })).toBeTruthy(); }); it("prompts for dependency-removal confirmation and retries delete with explicit flag", async () => { const onDeleteTask = vi.fn(); const conflict = new Error("Cannot delete task FN-099: still referenced as a dependency by FN-100, FN-101.") as Error & { status: number; details: { code: string; dependentIds: string[] }; }; conflict.status = 409; conflict.details = { code: "TASK_HAS_DEPENDENTS", dependentIds: ["FN-100", "FN-101"] }; onDeleteTask .mockRejectedValueOnce(conflict) .mockResolvedValueOnce({} as Task); mockConfirm .mockResolvedValueOnce(true) .mockResolvedValueOnce(true); render( , ); fireEvent.click(screen.getByRole("button", { name: /actions/i })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); await waitFor(() => { expect(mockConfirm).toHaveBeenNthCalledWith(1, { title: "Delete Task", message: "Delete FN-099?", danger: true, }); expect(mockConfirm).toHaveBeenNthCalledWith(2, { title: "Force Delete Task", message: "FN-099 is a dependency of FN-100, FN-101.\n\nDelete anyway by removing these dependency references first?", danger: true, }); }); await waitFor(() => { expect(onDeleteTask).toHaveBeenNthCalledWith(1, "FN-099"); expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", { removeDependencyReferences: true }); expect(noop).toHaveBeenCalledWith("Deleted FN-099 after removing dependency references", "info"); }); }); it("does not retry delete when dependency-removal confirmation is canceled", async () => { const onDeleteTask = vi.fn(); const conflict = new Error("Cannot delete task FN-099: still referenced as a dependency by FN-102.") as Error & { status: number; details: { code: string; dependentIds: string[] }; }; conflict.status = 409; conflict.details = { code: "TASK_HAS_DEPENDENTS", dependentIds: ["FN-102"] }; onDeleteTask.mockRejectedValue(conflict); mockConfirm .mockResolvedValueOnce(true) .mockResolvedValueOnce(false); render( , ); fireEvent.click(screen.getByRole("button", { name: /actions/i })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); await waitFor(() => { expect(onDeleteTask).toHaveBeenCalledTimes(1); }); }); it("shows error when dependency-removal retry fails", async () => { const onDeleteTask = vi.fn(); const conflict = new Error("Cannot delete task FN-099: still referenced as a dependency by FN-103.") as Error & { status: number; details: { code: string; dependentIds: string[] }; }; conflict.status = 409; conflict.details = { code: "TASK_HAS_DEPENDENTS", dependentIds: ["FN-103"] }; onDeleteTask .mockRejectedValueOnce(conflict) .mockRejectedValueOnce(new Error("Retry failed")); mockConfirm .mockResolvedValueOnce(true) .mockResolvedValueOnce(true); render( , ); fireEvent.click(screen.getByRole("button", { name: /actions/i })); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); await waitFor(() => { expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-099", { removeDependencyReferences: true }); expect(noop).toHaveBeenCalledWith("Retry failed", "error"); }); }); it("in-review modal-actions contains Merge & Close and Back to In Progress buttons", () => { render( , ); expect(screen.getByText("Merge & Close")).toBeTruthy(); // Back to In Progress is in secondary move options fireEvent.click(document.querySelector(".detail-move-btn__arrow")!); expect(screen.getByRole("menuitem", { name: "Back to In Progress" })).toBeTruthy(); }); it("shows PR automation waiting label instead of Merge & Close when awaiting PR checks", () => { render( , ); const button = screen.getByText("Awaiting PR checks") as HTMLButtonElement; expect(button.disabled).toBe(true); expect(screen.queryByText("Merge & Close")).toBeNull(); }); it("shows Creating PR label while PR-first automation is creating a PR", () => { render( , ); const button = screen.getByText("Creating PR…") as HTMLButtonElement; expect(button.disabled).toBe(true); expect(screen.queryByText("Merge & Close")).toBeNull(); }); }); describe("dependency dropdown search", () => { const searchTasks: Task[] = [ { id: "FN-010", title: "Fix login bug", description: "Users cannot log in", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" }, { id: "FN-020", title: "Add dark mode", description: "Theme support", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-02-01T00:00:00Z", updatedAt: "2026-02-01T00:00:00Z" }, { id: "FN-030", title: "Refactor API", description: "Clean up endpoints", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-01T00:00:00Z", updatedAt: "2026-03-01T00:00:00Z" }, { id: "FN-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-15T00:00:00Z", updatedAt: "2026-03-15T00:00:00Z" }, ]; function renderWithSearch(taskOverrides: Partial = {}) { return render( , ); } it("shows search input when dropdown is opened", () => { renderWithSearch(); fireEvent.click(screen.getByText("Add Dependency")); const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement; expect(input).toBeTruthy(); expect(input.placeholder).toBe("Search tasks…"); }); it("filters tasks by search term", () => { renderWithSearch(); fireEvent.click(screen.getByText("Add Dependency")); const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement; fireEvent.change(input, { target: { value: "login" } }); const items = document.querySelectorAll(".dep-dropdown-item"); expect(items).toHaveLength(1); expect(items[0].querySelector(".dep-dropdown-id")?.textContent).toBe("FN-010"); }); it("matches task ID case-insensitively", () => { renderWithSearch(); fireEvent.click(screen.getByText("Add Dependency")); const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement; fireEvent.change(input, { target: { value: "fn-020" } }); const items = document.querySelectorAll(".dep-dropdown-item"); expect(items).toHaveLength(1); expect(items[0].querySelector(".dep-dropdown-id")?.textContent).toBe("FN-020"); }); it("matches task title", () => { renderWithSearch(); fireEvent.click(screen.getByText("Add Dependency")); const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement; fireEvent.change(input, { target: { value: "dark mode" } }); const items = document.querySelectorAll(".dep-dropdown-item"); expect(items).toHaveLength(1); expect(items[0].querySelector(".dep-dropdown-id")?.textContent).toBe("FN-020"); }); it("shows empty state when search matches nothing", () => { renderWithSearch(); fireEvent.click(screen.getByText("Add Dependency")); const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement; fireEvent.change(input, { target: { value: "zzz-nonexistent" } }); const items = document.querySelectorAll(".dep-dropdown-item"); expect(items).toHaveLength(0); expect(document.querySelector(".dep-dropdown-empty")?.textContent).toBe("No available tasks"); }); it("resets search when dropdown closes and reopens", () => { renderWithSearch(); fireEvent.click(screen.getByText("Add Dependency")); const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement; fireEvent.change(input, { target: { value: "login" } }); expect(input.value).toBe("login"); // Close by clicking again fireEvent.click(screen.getByText("Add Dependency")); expect(document.querySelector(".dep-dropdown")).toBeNull(); // Reopen fireEvent.click(screen.getByText("Add Dependency")); const newInput = document.querySelector(".dep-dropdown-search") as HTMLInputElement; expect(newInput.value).toBe(""); // All items visible again expect(document.querySelectorAll(".dep-dropdown-item")).toHaveLength(3); }); }); describe("clickable dependency links", () => { it("renders dependency list items with clickable class and ID + label", () => { // Provide tasks prop to enable title lookup const allTasks: Task[] = [ { id: "FN-001", title: "Fix login bug", description: "Login broken", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }, { id: "FN-002", title: "Add tests", description: "Test coverage", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }, ]; const { container } = render( , ); const depLinks = container.querySelectorAll(".detail-dep-link"); expect(depLinks).toHaveLength(2); // Check detail-dep-id elements const depIds = container.querySelectorAll(".detail-dep-id"); expect(depIds).toHaveLength(2); expect(depIds[0].textContent).toBe("FN-001"); expect(depIds[1].textContent).toBe("FN-002"); // Check detail-dep-label elements const depLabels = container.querySelectorAll(".detail-dep-label"); expect(depLabels).toHaveLength(2); expect(depLabels[0].textContent).toBe("Fix login bug"); expect(depLabels[1].textContent).toBe("Add tests"); }); it("renders dependency label from description when title is not available", () => { const allTasks: Task[] = [ { id: "FN-001", description: "Login is broken", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }, ]; const { container } = render( , ); const depLabels = container.querySelectorAll(".detail-dep-label"); expect(depLabels).toHaveLength(1); expect(depLabels[0].textContent).toBe("Login is broken"); }); it("renders dependency ID as label when no title or description available", () => { const { container } = render( , ); const depLabels = container.querySelectorAll(".detail-dep-label"); expect(depLabels).toHaveLength(1); // Should fall back to the ID itself expect(depLabels[0].textContent).toBe("FN-001"); }); it("truncates long dependency labels at 40 characters", () => { // Title is exactly 50 chars, should be truncated to 40 with ellipsis const longTitle = "This is a very long task title that exceeds the limit"; const allTasks: Task[] = [ { id: "FN-001", title: longTitle, description: "Short desc", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }, ]; const { container } = render( , ); const depLabels = container.querySelectorAll(".detail-dep-label"); expect(depLabels).toHaveLength(1); // Title is 50 chars, should be truncated to 40 with ellipsis // "This is a very long task title that exceed" + "…" = 41 chars expect(depLabels[0].textContent!.length).toBe(41); // 40 chars + ellipsis expect(depLabels[0].textContent).toContain("…"); }); it("preserves full text in title attribute for truncated labels", () => { const allTasks: Task[] = [ { id: "FN-001", title: "Very long title that gets truncated in the UI but should show full text on hover", description: "Desc", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" }, ]; const { container } = render( , ); const depLink = container.querySelector(".detail-dep-link")!; // The title attribute should contain the full ID for context expect(depLink.getAttribute("title")).toContain("FN-001"); }); it("calls fetchTaskDetail and onOpenDetail when clicking a dependency", async () => { const { fetchTaskDetail } = await import("../../api"); const mockFetch = vi.mocked(fetchTaskDetail); const mockDetail: TaskDetail = { ...makeTask({ id: "FN-001", description: "Dep 1" }), prompt: "", attachments: [], }; mockFetch.mockResolvedValueOnce(mockDetail); const onOpenDetail = vi.fn(); const { container } = render( , ); const depLink = container.querySelector(".detail-dep-link")!; fireEvent.click(depLink); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith("FN-001", undefined); expect(onOpenDetail).toHaveBeenCalledWith(mockDetail); }); }); it("shows error toast when dependency fetch fails", async () => { const { fetchTaskDetail } = await import("../../api"); const mockFetch = vi.mocked(fetchTaskDetail); mockFetch.mockRejectedValueOnce(new Error("Task not found")); const onOpenDetail = vi.fn(); const addToast = vi.fn(); const { container } = render( , ); const depLink = container.querySelector(".detail-dep-link")!; fireEvent.click(depLink); await waitFor(() => { expect(addToast).toHaveBeenCalledWith("Failed to load dependency FN-001", "error"); }); expect(onOpenDetail).not.toHaveBeenCalled(); }); it("remove button click does not trigger dependency click", async () => { const { updateTask } = await import("../../api"); const { fetchTaskDetail } = await import("../../api"); const mockFetch = vi.mocked(fetchTaskDetail); mockFetch.mockRejectedValueOnce(new Error("Should not be called")); const onOpenDetail = vi.fn(); render( , ); const removeButton = screen.getByTitle(/Remove dependency/); fireEvent.click(removeButton); // onOpenDetail should not be called when clicking remove expect(onOpenDetail).not.toHaveBeenCalled(); // updateTask should be called to remove the dependency await waitFor(() => { expect(updateTask).toHaveBeenCalledWith("FN-099", { dependencies: [] }, undefined); }); }); }); describe("Definition tab edit mode", () => { it("shows Edit button in Definition tab", () => { render( , ); expect(screen.getByText("Edit")).toBeTruthy(); }); it("clicking Edit shows textarea with current prompt content", () => { const { container } = render( , ); // Initially showing markdown view expect(container.querySelector(".markdown-body")).toBeTruthy(); // Click Edit button fireEvent.click(screen.getByText("Edit")); // Should show spec edit textarea (query by class for specificity) const textarea = container.querySelector(".spec-editor-textarea") as HTMLTextAreaElement; expect(textarea).toBeTruthy(); expect(textarea.value).toBe("# Test\n\nSpec content."); }); it("clicking Cancel returns to view mode without saving", () => { const { container } = render( , ); fireEvent.click(screen.getByText("Edit")); const textarea = container.querySelector(".spec-editor-textarea") as HTMLTextAreaElement; fireEvent.change(textarea, { target: { value: "Modified content" } }); // Click Cancel fireEvent.click(screen.getByText("Cancel")); // Should show markdown view with original content expect(container.querySelector(".markdown-body")).toBeTruthy(); expect(container.querySelector(".spec-editor-textarea")).toBeNull(); }); it("saving updates the task and returns to view mode", async () => { const { updateTask } = await import("../../api"); const mockUpdate = vi.mocked(updateTask); mockUpdate.mockResolvedValueOnce({ id: "FN-099" } as Task); const { container } = render( , ); fireEvent.click(screen.getByText("Edit")); const textarea = container.querySelector(".spec-editor-textarea") as HTMLTextAreaElement; fireEvent.change(textarea, { target: { value: "# Updated" } }); fireEvent.click(screen.getByText("Save")); await waitFor(() => { expect(mockUpdate).toHaveBeenCalledWith("FN-099", { prompt: "# Updated" }, undefined); }); // Should return to view mode expect(container.querySelector(".markdown-body")).toBeTruthy(); }); it("AI revision feedback section appears in edit mode", () => { render( , ); fireEvent.click(screen.getByText("Edit")); expect(screen.getByText("Ask AI to Revise")).toBeTruthy(); expect(screen.getByPlaceholderText(/e.g., 'Add more details/)).toBeTruthy(); expect(screen.getByText("Request AI Revision")).toBeTruthy(); }); it("requesting AI revision works and closes modal", async () => { const { requestSpecRevision } = await import("../../api"); vi.mocked(requestSpecRevision).mockResolvedValueOnce({} as any); const onClose = vi.fn(); const addToast = vi.fn(); render( , ); fireEvent.click(screen.getByText("Edit")); const feedbackInput = screen.getByPlaceholderText(/e.g., 'Add more details/); fireEvent.change(feedbackInput, { target: { value: "Please add more error handling details" } }); fireEvent.click(screen.getByText("Request AI Revision")); await waitFor(() => { expect(requestSpecRevision).toHaveBeenCalledWith("FN-099", "Please add more error handling details", undefined); expect(addToast).toHaveBeenCalledWith("AI revision requested. Task moved to planning.", "success"); expect(onClose).toHaveBeenCalled(); }); }); it("shows all tabs in correct order for in-progress task", () => { const { container } = render( , ); // In-progress tasks show exactly 9 tabs: // Definition, Logs, Changes, Comments, Documents, Model, Workflow, Stats, Routing const tabs = container.querySelectorAll(".detail-tab"); expect(tabs.length).toBe(9); expect(tabs[0].textContent).toBe("Definition"); expect(tabs[1].textContent).toBe("Logs"); expect(tabs[2].textContent).toBe("Changes"); expect(tabs[3].textContent).toBe("Comments"); expect(tabs[4].textContent).toBe("Documents"); expect(tabs[5].textContent).toBe("Model"); expect(tabs[6].textContent).toBe("Workflow"); expect(tabs[7].textContent).toBe("Stats"); expect(tabs[8].textContent).toBe("Routing"); // Commits tab should NOT be present for non-done tasks expect(screen.queryByText("Commits")).toBeNull(); }); it("shows Workflow tab in correct position when enabledWorkflowSteps is non-empty", () => { const { container } = render( , ); // In-progress task with workflow steps: 9 tabs (Workflow after Model, Stats then Routing) const tabs = container.querySelectorAll(".detail-tab"); expect(tabs.length).toBe(9); expect(tabs[0].textContent).toBe("Definition"); expect(tabs[1].textContent).toBe("Logs"); expect(tabs[2].textContent).toBe("Changes"); expect(tabs[3].textContent).toBe("Comments"); expect(tabs[4].textContent).toBe("Documents"); expect(tabs[5].textContent).toBe("Model"); expect(tabs[6].textContent).toBe("Workflow"); expect(tabs[7].textContent).toBe("Stats"); expect(tabs[8].textContent).toBe("Routing"); }); it("does NOT show Commits tab for done task with mergeDetails.commitSha (changes merged into Changes tab)", () => { const { container } = render( , ); // Done task with commit SHA: Definition, Logs, Changes, Comments, Documents, Model, Workflow, Stats, Routing (9 tabs, no Commits) const tabs = container.querySelectorAll(".detail-tab"); expect(tabs.length).toBe(9); expect(tabs[0].textContent).toBe("Definition"); expect(tabs[1].textContent).toBe("Logs"); expect(tabs[2].textContent).toBe("Changes"); expect(tabs[3].textContent).toBe("Comments"); expect(tabs[4].textContent).toBe("Documents"); expect(tabs[5].textContent).toBe("Model"); expect(tabs[6].textContent).toBe("Workflow"); expect(tabs[7].textContent).toBe("Stats"); expect(tabs[8].textContent).toBe("Routing"); // Commits tab should NOT be present expect(screen.queryByText("Commits")).toBeNull(); }); it("shows 9 tabs for done task with workflow steps and commit SHA (Commits merged into Changes)", () => { const { container } = render( , ); // Done task with workflow steps and commit SHA: 9 tabs (no Commits) const tabs = container.querySelectorAll(".detail-tab"); expect(tabs.length).toBe(9); expect(tabs[0].textContent).toBe("Definition"); expect(tabs[1].textContent).toBe("Logs"); expect(tabs[2].textContent).toBe("Changes"); expect(tabs[3].textContent).toBe("Comments"); expect(tabs[4].textContent).toBe("Documents"); expect(tabs[5].textContent).toBe("Model"); expect(tabs[6].textContent).toBe("Workflow"); expect(tabs[7].textContent).toBe("Stats"); expect(tabs[8].textContent).toBe("Routing"); // Commits tab should NOT be present expect(screen.queryByText("Commits")).toBeNull(); }); it("does NOT show Changes tab for triage/todo tasks", () => { const { container: triageContainer } = render( , ); const triageTabs = triageContainer.querySelectorAll(".detail-tab"); expect(triageTabs.length).toBe(8); // Definition, Logs, Comments, Documents, Model, Workflow, Stats, Routing expect(Array.from(triageTabs).map(t => t.textContent)).toEqual([ "Definition", "Logs", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing", ]); const { container: todoContainer } = render( , ); const todoTabs = todoContainer.querySelectorAll(".detail-tab"); expect(todoTabs.length).toBe(8); // Definition, Logs, Comments, Documents, Model, Workflow, Stats, Routing expect(Array.from(todoTabs).map(t => t.textContent)).toEqual([ "Definition", "Logs", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing", ]); }); it("shows empty state and Edit button when no prompt", () => { render( , ); expect(screen.getByText("(no prompt)")).toBeTruthy(); expect(screen.getByText("Edit")).toBeTruthy(); }); }); describe("Plan Approval UI", () => { it("shows Approve Plan and Reject Plan buttons for awaiting-approval tasks in triage", () => { render( , ); expect(screen.getByText("Approve Plan")).toBeTruthy(); expect(screen.getByText("Reject Plan")).toBeTruthy(); }); it("does not show approval buttons when task is not in triage", () => { render( , ); expect(screen.queryByText("Approve Plan")).toBeNull(); expect(screen.queryByText("Reject Plan")).toBeNull(); }); it("does not show approval buttons when task does not have awaiting-approval status", () => { render( , ); expect(screen.queryByText("Approve Plan")).toBeNull(); expect(screen.queryByText("Reject Plan")).toBeNull(); }); it("does not show approval buttons when task has no prompt", () => { render( , ); expect(screen.queryByText("Approve Plan")).toBeNull(); expect(screen.queryByText("Reject Plan")).toBeNull(); }); it("calls approvePlan API and shows success toast when Approve Plan is clicked", async () => { const { approvePlan } = await import("../../api"); const mockApprovePlan = vi.mocked(approvePlan); const addToast = vi.fn(); const onClose = vi.fn(); render( , ); fireEvent.click(screen.getByText("Approve Plan")); await waitFor(() => { expect(mockApprovePlan).toHaveBeenCalledWith("FN-001", undefined); }); expect(addToast).toHaveBeenCalledWith("Plan approved — FN-001 moved to Todo", "success"); expect(onClose).toHaveBeenCalled(); }); it("calls rejectPlan API and shows success toast when Reject Plan is confirmed", async () => { const { rejectPlan } = await import("../../api"); const mockRejectPlan = vi.mocked(rejectPlan); const addToast = vi.fn(); const onClose = vi.fn(); // Mock confirm to return true mockConfirm.mockResolvedValue(true); render( , ); fireEvent.click(screen.getByText("Reject Plan")); expect(mockConfirm).toHaveBeenCalledWith({ title: "Reject Plan", message: "Reject this plan? The specification will be discarded and regenerated.", danger: true, }); await waitFor(() => { expect(mockRejectPlan).toHaveBeenCalledWith("FN-001", undefined); }); expect(addToast).toHaveBeenCalledWith( "Plan rejected — FN-001 returned to Planning for replanning", "info" ); expect(onClose).toHaveBeenCalled(); }); it("does not call rejectPlan API when Reject Plan is cancelled", async () => { const { rejectPlan } = await import("../../api"); const mockRejectPlan = vi.mocked(rejectPlan); mockRejectPlan.mockClear(); // Clear any previous calls const addToast = vi.fn(); // Mock confirm to return false mockConfirm.mockResolvedValue(false); render( , ); fireEvent.click(screen.getByText("Reject Plan")); expect(mockConfirm).toHaveBeenCalled(); expect(mockRejectPlan).not.toHaveBeenCalled(); expect(addToast).not.toHaveBeenCalled(); }); it("shows error toast when approvePlan fails", async () => { const { approvePlan } = await import("../../api"); const mockApprovePlan = vi.mocked(approvePlan); mockApprovePlan.mockRejectedValueOnce(new Error("Network error")); const addToast = vi.fn(); render( , ); fireEvent.click(screen.getByText("Approve Plan")); await waitFor(() => { expect(addToast).toHaveBeenCalledWith("Network error", "error"); }); }); it("shows error toast when rejectPlan fails", async () => { const { rejectPlan } = await import("../../api"); const mockRejectPlan = vi.mocked(rejectPlan); mockRejectPlan.mockRejectedValueOnce(new Error("Server error")); const addToast = vi.fn(); // Mock confirm to return true mockConfirm.mockResolvedValue(true); render( , ); fireEvent.click(screen.getByText("Reject Plan")); await waitFor(() => { expect(addToast).toHaveBeenCalledWith("Server error", "error"); }); }); }); describe("Duplicate button", () => { it("renders Duplicate button in modal actions when onDuplicateTask is provided (in Actions dropdown)", () => { render( , ); // Open Actions dropdown to see Duplicate const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); expect(screen.getByRole("menuitem", { name: "Duplicate" })).toBeTruthy(); }); it("does NOT render Duplicate button when onDuplicateTask is not provided", () => { render( , ); // Open Actions dropdown - Duplicate should not be there const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); expect(screen.queryByRole("menuitem", { name: "Duplicate" })).toBeNull(); }); it("clicking Duplicate shows confirmation dialog", () => { mockConfirm.mockResolvedValue(false); render( , ); // Open Actions dropdown first const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Duplicate" })); expect(mockConfirm).toHaveBeenCalledWith({ title: "Duplicate Task", message: "Duplicate FN-001? This will create a new task in Triage with the same description and prompt.", }); }); it("confirming duplicate calls onDuplicateTask and closes modal", async () => { mockConfirm.mockResolvedValue(true); const mockDuplicate = vi.fn().mockResolvedValue({ id: "FN-002" } as Task); const onClose = vi.fn(); render( , ); // Open Actions dropdown first const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Duplicate" })); await waitFor(() => { expect(mockDuplicate).toHaveBeenCalledWith("FN-001"); expect(onClose).toHaveBeenCalled(); }); }); it("successful duplicate shows success toast with new task ID", async () => { mockConfirm.mockResolvedValue(true); const mockDuplicate = vi.fn().mockResolvedValue({ id: "FN-002" } as Task); const addToast = vi.fn(); render( , ); // Open Actions dropdown first const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Duplicate" })); await waitFor(() => { expect(addToast).toHaveBeenCalledWith("Duplicated FN-001 → FN-002", "success"); }); }); it("cancelling confirmation does not call onDuplicateTask", () => { mockConfirm.mockResolvedValue(false); const mockDuplicate = vi.fn().mockResolvedValue({ id: "FN-002" } as Task); render( , ); // Open Actions dropdown first const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Duplicate" })); expect(mockDuplicate).not.toHaveBeenCalled(); }); it("shows error toast when duplicate fails", async () => { mockConfirm.mockResolvedValue(true); const mockDuplicate = vi.fn().mockRejectedValue(new Error("Duplicate failed")); const addToast = vi.fn(); render( , ); // Open Actions dropdown first const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Duplicate" })); await waitFor(() => { expect(addToast).toHaveBeenCalledWith("Duplicate failed", "error"); }); }); }); describe("Refinement button", () => { it.each<[Column, boolean]>([ ["done", true], ["in-review", true], ["todo", false], ["in-progress", false], ])("Refine action visibility in column=%s is %s", (column, shouldShow) => { render( , ); fireEvent.click(screen.getByRole("button", { name: /actions/i })); const item = screen.queryByRole("menuitem", { name: "Refine" }); if (shouldShow) expect(item).toBeTruthy(); else expect(item).toBeNull(); }); it("does NOT render Refine button for 'triage' column tasks (no Actions dropdown)", () => { render( , ); expect(screen.queryByText("Refine")).toBeNull(); }); it("renders Actions dropdown for a paused triage task", () => { render( , ); expect(screen.getByRole("button", { name: /actions/i })).toBeTruthy(); }); it("renders Unpause button for a paused triage task", () => { render( , ); fireEvent.click(screen.getByRole("button", { name: /actions/i })); expect(screen.getByRole("menuitem", { name: "Unpause" })).toBeTruthy(); }); it("does NOT render Actions dropdown for a non-paused, non-awaiting-approval, non-retryable triage task", () => { render( , ); expect(screen.queryByRole("button", { name: /actions/i })).toBeNull(); }); it("clicking Refine opens the refinement modal", () => { render( , ); // Open Actions dropdown first const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); expect(screen.getByText("Refine", { selector: "h3" })).toBeTruthy(); expect(screen.getByPlaceholderText("Enter your feedback here...")).toBeTruthy(); }); it("shows character counter in refinement modal", () => { render( , ); // Open Actions dropdown first const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); expect(screen.getByText("0/2000 characters")).toBeTruthy(); }); it("character counter updates when typing feedback", async () => { render( , ); // Open Actions dropdown first const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); const textarea = screen.getByPlaceholderText("Enter your feedback here..."); await act(async () => { fireEvent.change(textarea, { target: { value: "Need to fix the error handling" } }); }); expect(screen.getByText("30/2000 characters")).toBeTruthy(); }); it("submit button is disabled when feedback is empty", () => { render( , ); // Open Actions dropdown first const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); const submitButton = screen.getByText("Create Refinement Task"); expect(submitButton.hasAttribute("disabled")).toBe(true); }); it("submit button is enabled when feedback is entered", async () => { render( , ); // Open Actions dropdown first const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); const textarea = screen.getByPlaceholderText("Enter your feedback here..."); await act(async () => { fireEvent.change(textarea, { target: { value: "Need to fix error handling" } }); }); const submitButton = screen.getByText("Create Refinement Task"); expect(submitButton.hasAttribute("disabled")).toBe(false); }); it("clicking Cancel closes the refinement modal", () => { const onClose = vi.fn(); render( , ); // Open Actions dropdown first const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); fireEvent.click(screen.getByText("Cancel")); // Modal should be closed, but detail modal stays open (onClose not called) expect(screen.queryByText("Refine", { selector: "h3" })).toBeNull(); expect(onClose).not.toHaveBeenCalled(); }); it("shows error toast when submitting empty feedback", async () => { const addToast = vi.fn(); render( , ); // Open Actions dropdown first const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); // Try to submit with empty text (manually trigger submit since button is disabled) const { refineTask } = await import("../../api"); // Should not call API, instead show error toast expect(refineTask).not.toHaveBeenCalled(); }); it("calls refineTask and closes modal on successful submission", async () => { const { refineTask } = await import("../../api"); vi.mocked(refineTask).mockResolvedValue({ id: "FN-002", column: "triage" } as Task); const onClose = vi.fn(); const addToast = vi.fn(); render( , ); // Open Actions dropdown first const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); const textarea = screen.getByPlaceholderText("Enter your feedback here..."); fireEvent.change(textarea, { target: { value: "Need to add more tests" } }); fireEvent.click(screen.getByText("Create Refinement Task")); await waitFor(() => { expect(refineTask).toHaveBeenCalledWith("FN-001", "Need to add more tests", undefined); expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-002", "success"); expect(onClose).toHaveBeenCalled(); }); }); it("shows error toast when refineTask fails", async () => { const { refineTask } = await import("../../api"); vi.mocked(refineTask).mockRejectedValue(new Error("Task must be in 'done' or 'in-review' column")); const addToast = vi.fn(); render( , ); // Open Actions dropdown first const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); // Click Refine from the dropdown fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); const textarea = screen.getByPlaceholderText("Enter your feedback here..."); fireEvent.change(textarea, { target: { value: "Need to add more tests" } }); fireEvent.click(screen.getByText("Create Refinement Task")); await waitFor(() => { expect(addToast).toHaveBeenCalledWith("Task must be in 'done' or 'in-review' column", "error"); }); }); it("renders submit button inside the input group adjacent to textarea", () => { const { container } = render( , ); // Open Actions dropdown first const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); // Click Refine from the dropdown fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); // The submit button should be inside .detail-refine-input-group (the input area) const inputGroup = container.querySelector(".detail-refine-input-group"); expect(inputGroup).toBeTruthy(); const submitButton = inputGroup!.querySelector("button.btn-primary"); expect(submitButton).toBeTruthy(); expect(submitButton!.textContent).toBe("Create Refinement Task"); // The submit button should NOT be in the footer .modal-actions const modalActions = container.querySelector(".detail-refine-modal .modal-actions"); expect(modalActions).toBeTruthy(); expect(modalActions!.querySelector("button.btn-primary")).toBeNull(); }); it("submit button in input group follows the same disabled/enabled rules", async () => { render( , ); // Open Actions dropdown first const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); // Click Refine from the dropdown fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); // Submit button starts disabled (no feedback) const submitButton = screen.getByText("Create Refinement Task"); expect(submitButton.hasAttribute("disabled")).toBe(true); // Enter feedback to enable it const textarea = screen.getByPlaceholderText("Enter your feedback here..."); await act(async () => { fireEvent.change(textarea, { target: { value: "Some feedback" } }); }); expect(submitButton.hasAttribute("disabled")).toBe(false); }); it("character count and submit button are siblings in the input group", () => { const { container } = render( , ); // Open Actions dropdown first const actionsBtn = screen.getByRole("button", { name: /actions/i }); fireEvent.click(actionsBtn); // Click Refine from the dropdown fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); const inputGroup = container.querySelector(".detail-refine-input-group")!; expect(inputGroup.querySelector(".detail-refine-char-count")).toBeTruthy(); expect(inputGroup.querySelector("button.btn-primary")).toBeTruthy(); }); }); describe("source issue metadata", () => { beforeEach(() => { vi.clearAllMocks(); }); it("renders source issue collapsed by default and expands details on toggle", async () => { const user = userEvent.setup(); render( , ); expect(screen.getByText("Source issue")).toBeTruthy(); expect(screen.getByLabelText("GitHub source issue")).toBeTruthy(); const summaryIssueLink = screen.getByRole("link", { name: "(#2473)" }); expect(summaryIssueLink).toHaveAttribute( "href", "https://github.com/runfusion/fusion/issues/2473", ); expect(summaryIssueLink.classList.contains("detail-source-link--summary")).toBe(true); expect(screen.queryByText("Provider")).toBeNull(); const toggle = screen.getByRole("button", { name: "Expand source issue details" }); expect(toggle).toHaveAttribute("aria-expanded", "false"); const chevron = screen.getByTestId("chevron-right-icon"); expect(chevron.classList.contains("detail-source-chevron--expanded")).toBe(false); await user.click(toggle); expect(screen.getByRole("button", { name: "Collapse source issue details" })).toHaveAttribute("aria-expanded", "true"); expect(screen.getByText("Provider")).toBeTruthy(); expect(screen.getByText("github")).toBeTruthy(); expect(screen.getByText("runfusion/fusion")).toBeTruthy(); const sourceLink = screen.getByRole("link", { name: "https://github.com/runfusion/fusion/issues/2473" }); expect(sourceLink).toHaveAttribute("href", "https://github.com/runfusion/fusion/issues/2473"); expect(sourceLink).toHaveAttribute("target", "_blank"); expect(screen.getByTestId("chevron-right-icon").classList.contains("detail-source-chevron--expanded")).toBe(true); }); it("applies compact GitHub source summary styling contracts", () => { const css = loadAllAppCss(); expectBaseRule(css, ".detail-source-provider-badge", "border-radius: var(--radius-pill);"); expectBaseRule(css, ".detail-source-provider-badge", "background: color-mix(in srgb, var(--text-muted) 18%, transparent);"); expectBaseRule(css, ".detail-source-link--summary", "text-decoration: none;"); expectBaseRule(css, ".detail-source-section .detail-source-grid", "margin-top: var(--space-sm);"); expectBaseRule(css, ".detail-source-section .detail-source-grid", "padding-top: var(--space-sm);"); }); it("does not render GitHub badge for non-github providers", () => { render( , ); expect(screen.queryByLabelText("GitHub source issue")).toBeNull(); expect(document.querySelector(".detail-source-provider-badge")).toBeNull(); expect(screen.getByRole("link", { name: "(#42)" })).toBeTruthy(); }); it("hides source issue read section when sourceIssue metadata is missing", () => { render( , ); expect(screen.queryByText("Source issue")).toBeNull(); expect(screen.queryByText("No source issue metadata recorded.")).toBeNull(); }); it("prefills source issue inputs in edit mode", async () => { const { container } = render( , ); fireEvent.click(container.querySelector(".modal-edit-btn")!); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); await waitFor(() => { expect((screen.getByTestId("task-source-provider-input") as HTMLInputElement).value).toBe("github"); }); expect((screen.getByTestId("task-source-repository-input") as HTMLInputElement).value).toBe("runfusion/fusion"); expect((screen.getByTestId("task-source-external-id-input") as HTMLInputElement).value).toBe("I_kgDOExample"); expect((screen.getByTestId("task-source-url-input") as HTMLInputElement).value).toBe("https://github.com/runfusion/fusion/issues/2473"); }); it("renders source issue block below Model Configuration in edit mode", async () => { const { container } = render( , ); fireEvent.click(container.querySelector(".modal-edit-btn")!); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); await waitFor(() => { const modelLabel = screen.getByText("Model Configuration"); const sourceLabel = screen.getByText("Source Issue"); const workflowSection = screen.getByTestId("workflow-steps-section"); expect( modelLabel.compareDocumentPosition(sourceLabel) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); expect( sourceLabel.compareDocumentPosition(workflowSection) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); }); }); it("sends sourceIssue payload when source metadata is edited", async () => { const { updateTask } = await import("../../api"); const mockUpdate = vi.mocked(updateTask); mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task); const { container } = render( , ); fireEvent.click(container.querySelector(".modal-edit-btn")!); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(screen.getByTestId("task-source-provider-input"), { target: { value: "gitlab" } }); fireEvent.change(screen.getByTestId("task-source-repository-input"), { target: { value: "runfusion/dashboard" } }); fireEvent.change(screen.getByTestId("task-source-external-id-input"), { target: { value: "I_kgDONew" } }); fireEvent.change(screen.getByTestId("task-source-url-input"), { target: { value: "https://gitlab.com/runfusion/dashboard/-/issues/2473" } }); fireEvent.click(screen.getByText("Save")); await waitFor(() => { expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({ sourceIssue: { provider: "gitlab", repository: "runfusion/dashboard", externalIssueId: "I_kgDONew", issueNumber: 2473, url: "https://gitlab.com/runfusion/dashboard/-/issues/2473", }, }), undefined); }); }); it("sends sourceIssue: null when all source metadata fields are cleared", async () => { const { updateTask } = await import("../../api"); const mockUpdate = vi.mocked(updateTask); mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task); const { container } = render( , ); fireEvent.click(container.querySelector(".modal-edit-btn")!); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(screen.getByTestId("task-source-provider-input"), { target: { value: "" } }); fireEvent.change(screen.getByTestId("task-source-repository-input"), { target: { value: "" } }); fireEvent.change(screen.getByTestId("task-source-external-id-input"), { target: { value: "" } }); fireEvent.change(screen.getByTestId("task-source-url-input"), { target: { value: "" } }); fireEvent.click(screen.getByText("Save")); await waitFor(() => { expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({ sourceIssue: null }), undefined); }); }); it("keeps edit mode active and shows error toast when source metadata save fails", async () => { const { updateTask } = await import("../../api"); const mockUpdate = vi.mocked(updateTask); mockUpdate.mockRejectedValueOnce(new Error("source patch failed")); const addToast = vi.fn(); const { container } = render( , ); fireEvent.click(container.querySelector(".modal-edit-btn")!); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(screen.getByTestId("task-source-provider-input"), { target: { value: "gitlab" } }); fireEvent.click(screen.getByText("Save")); await waitFor(() => { expect(addToast).toHaveBeenCalledWith("Failed to update FN-001: source patch failed", "error"); }); expect(container.querySelector("#task-form-title")).toBeTruthy(); }); }); describe("inline editing", () => { beforeEach(() => { vi.clearAllMocks(); }); it("shows Edit button in header when task is in triage column", () => { const { container } = render( , ); const editButton = container.querySelector(".modal-edit-btn"); expect(editButton).toBeTruthy(); }); it("shows Edit button in header when task is in todo column", () => { const { container } = render( , ); const editButton = container.querySelector(".modal-edit-btn"); expect(editButton).toBeTruthy(); }); it("does not show Edit button when task is in in-progress column", () => { const { container } = render( , ); const editButton = container.querySelector(".modal-edit-btn"); expect(editButton).toBeNull(); }); it("does not show Edit button when already in edit mode", () => { const { container } = render( , ); // Enter edit mode const editButton = container.querySelector(".modal-edit-btn"); expect(editButton).toBeTruthy(); fireEvent.click(editButton!); // Edit button should be hidden now expect(container.querySelector(".modal-edit-btn")).toBeNull(); // But TaskForm title input should be visible expect(container.querySelector("#task-form-title")).toBeTruthy(); }); it("entering edit mode shows title input and description textarea", () => { const { container } = render( , ); // Initially shows title as h2 expect(container.querySelector("h2.detail-title")).toBeTruthy(); expect(container.querySelector("#task-form-title")).toBeNull(); // Enter edit mode fireEvent.click(container.querySelector(".modal-edit-btn")!); // Now shows edit form with TaskForm fields expect(container.querySelector("h2.detail-title")).toBeNull(); expect(container.querySelector("#task-form-title")).toBeTruthy(); expect(container.querySelector("#task-form-description")).toBeTruthy(); }); it("clicking Cancel exits edit mode without saving", () => { const { container } = render( , ); // Enter edit mode fireEvent.click(container.querySelector(".modal-edit-btn")!); // Change values const titleInput = container.querySelector("#task-form-title") as HTMLInputElement; fireEvent.change(titleInput, { target: { value: "Modified title" } }); // Click Cancel fireEvent.click(screen.getByText("Cancel")); // Should exit edit mode without saving expect(container.querySelector("#task-form-title")).toBeNull(); expect(container.querySelector("h2.detail-title")?.textContent).toBe("Original title"); }); it("clicking Save calls updateTask with correct parameters", async () => { const { updateTask } = await import("../../api"); const mockUpdate = vi.mocked(updateTask); mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task); const { container } = render( , ); // Enter edit mode fireEvent.click(container.querySelector(".modal-edit-btn")!); // Change values const titleInput = container.querySelector("#task-form-title") as HTMLInputElement; const descTextarea = container.querySelector("#task-form-description") as HTMLTextAreaElement; fireEvent.change(titleInput, { target: { value: "New title" } }); fireEvent.change(descTextarea, { target: { value: "New description" } }); // Click Save fireEvent.click(screen.getByText("Save")); await waitFor(() => { expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({ title: "New title", description: "New description", }), undefined); }); }); it("Save button is enabled in edit mode", () => { const { container } = render( , ); // Enter edit mode fireEvent.click(container.querySelector(".modal-edit-btn")!); const saveButton = screen.getByText("Save"); expect(saveButton.hasAttribute("disabled")).toBe(false); }); it("Save button shows 'Saving…' during save operation", async () => { const { updateTask } = await import("../../api"); const mockUpdate = vi.mocked(updateTask); // Delay the resolution to keep isSaving true mockUpdate.mockImplementationOnce(() => new Promise(resolve => setTimeout(() => resolve({ id: "FN-001" } as Task), 100))); const { container } = render( , ); // Enter edit mode fireEvent.click(container.querySelector(".modal-edit-btn")!); const titleInput = container.querySelector("#task-form-title") as HTMLInputElement; fireEvent.change(titleInput, { target: { value: "Changed title" } }); // Click Save fireEvent.click(screen.getByText("Save")); // Should show "Saving…" immediately expect(screen.getByText("Saving…")).toBeTruthy(); }); it("successful save shows toast and exits edit mode", async () => { const { updateTask } = await import("../../api"); const mockUpdate = vi.mocked(updateTask); mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task); const addToast = vi.fn(); const { container } = render( , ); // Enter edit mode fireEvent.click(container.querySelector(".modal-edit-btn")!); const titleInput = container.querySelector("#task-form-title") as HTMLInputElement; fireEvent.change(titleInput, { target: { value: "Changed title" } }); // Click Save fireEvent.click(screen.getByText("Save")); await waitFor(() => { expect(addToast).toHaveBeenCalledWith("Updated FN-001", "success"); }); // Should exit edit mode expect(container.querySelector("#task-form-title")).toBeNull(); }); it("failed save shows toast with error and stays in edit mode", async () => { const { updateTask } = await import("../../api"); const mockUpdate = vi.mocked(updateTask); mockUpdate.mockRejectedValueOnce(new Error("Network error")); const addToast = vi.fn(); const { container } = render( , ); // Enter edit mode fireEvent.click(container.querySelector(".modal-edit-btn")!); const titleInput = container.querySelector("#task-form-title") as HTMLInputElement; fireEvent.change(titleInput, { target: { value: "Changed title" } }); // Click Save fireEvent.click(screen.getByText("Save")); await waitFor(() => { expect(addToast).toHaveBeenCalledWith("Failed to update FN-001: Network error", "error"); }); // Should stay in edit mode expect(container.querySelector("#task-form-title")).toBeTruthy(); }); it("Escape key exits edit mode", async () => { const { container } = render( , ); // Enter edit mode fireEvent.click(container.querySelector(".modal-edit-btn")!); expect(container.querySelector("#task-form-title")).toBeTruthy(); // Press Escape (handled via document-level keydown listener) await act(async () => { const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true }); document.dispatchEvent(event); }); // Should exit edit mode expect(container.querySelector("#task-form-title")).toBeNull(); }); it("edit mode shows both title and description fields", () => { const { container } = render( , ); // Enter edit mode fireEvent.click(container.querySelector(".modal-edit-btn")!); // Both title and description should be present in TaskForm expect(container.querySelector("#task-form-title")).toBeTruthy(); expect(container.querySelector("#task-form-description")).toBeTruthy(); }); it("edit mode renders model configuration and workflow steps", () => { const { container } = render( , ); // Enter edit mode fireEvent.click(container.querySelector(".modal-edit-btn")!); // Model configuration and workflow steps should be present via TaskForm expect(screen.getByText(/Model Configuration/i)).toBeTruthy(); expect(screen.getByText(/Workflow Steps/i)).toBeTruthy(); }); it("save sends only changed fields via updateTask", async () => { const { updateTask } = await import("../../api"); const mockUpdate = vi.mocked(updateTask); mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task); const { container } = render( , ); // Enter edit mode fireEvent.click(container.querySelector(".modal-edit-btn")!); const descTextarea = container.querySelector("#task-form-description") as HTMLTextAreaElement; fireEvent.change(descTextarea, { target: { value: "Updated desc" } }); // Click Save fireEvent.click(screen.getByText("Save")); await waitFor(() => { expect(mockUpdate).toHaveBeenCalledWith("FN-001", { description: "Updated desc", }, undefined); }); }); it("includes priority in update payload only when changed", async () => { const { updateTask } = await import("../../api"); const mockUpdate = vi.mocked(updateTask); mockUpdate.mockResolvedValue({ id: "FN-001" } as Task); const { container } = render( , ); fireEvent.click(container.querySelector(".modal-edit-btn")!); fireEvent.click(screen.getByText("Save")); await waitFor(() => { expect(mockUpdate).not.toHaveBeenCalled(); }); fireEvent.click(container.querySelector(".modal-edit-btn")!); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(container.querySelector("#task-priority") as HTMLSelectElement, { target: { value: "urgent" } }); fireEvent.click(screen.getByText("Save")); await waitFor(() => { expect(mockUpdate).toHaveBeenCalledWith("FN-001", { priority: "urgent" }, undefined); }); }); it("sends executionMode: \"fast\" when changed from standard to fast", async () => { const { updateTask } = await import("../../api"); const mockUpdate = vi.mocked(updateTask); mockUpdate.mockResolvedValue({ id: "FN-001" } as Task); const { container } = render( , ); fireEvent.click(container.querySelector(".modal-edit-btn")!); fireEvent.change(screen.getByTestId("task-form-execution-mode-select"), { target: { value: "fast" } }); fireEvent.click(screen.getByText("Save")); await waitFor(() => { expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: "fast" }, undefined); }); }); it("sends executionMode: null when changed from fast to standard", async () => { const { updateTask } = await import("../../api"); const mockUpdate = vi.mocked(updateTask); mockUpdate.mockResolvedValue({ id: "FN-001" } as Task); const { container } = render( , ); fireEvent.click(container.querySelector(".modal-edit-btn")!); fireEvent.change(screen.getByTestId("task-form-execution-mode-select"), { target: { value: "standard" } }); fireEvent.click(screen.getByText("Save")); await waitFor(() => { expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: null }, undefined); }); }); it("omits executionMode from update payload when unchanged", async () => { const { updateTask } = await import("../../api"); const mockUpdate = vi.mocked(updateTask); mockUpdate.mockResolvedValue({ id: "FN-001" } as Task); const { container } = render( , ); fireEvent.click(container.querySelector(".modal-edit-btn")!); fireEvent.click(screen.getByText("Save")); await waitFor(() => { expect(mockUpdate).not.toHaveBeenCalled(); }); }); it("renders normalized priority in detail metadata", async () => { render( , ); const prioritySelect = screen.getByRole("combobox", { name: "Task priority" }) as HTMLSelectElement; expect(prioritySelect.value).toBe("normal"); }); it("updates priority inline and propagates successful save", async () => { const { updateTask } = await import("../../api"); const mockUpdate = vi.mocked(updateTask); const onTaskUpdated = vi.fn(); const addToast = vi.fn(); const updatedTask = makeTask({ id: "FN-001", column: "triage", priority: "urgent" }); mockUpdate.mockResolvedValueOnce(updatedTask as Task); render( , ); fireEvent.change(screen.getByRole("combobox", { name: "Task priority" }), { target: { value: "urgent" }, }); await waitFor(() => { expect(mockUpdate).toHaveBeenCalledWith("FN-001", { priority: "urgent" }, undefined); }); expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask); expect(addToast).toHaveBeenCalledWith("Priority updated to urgent", "success"); }); it("does not call updateTask when inline priority is unchanged", async () => { const { updateTask } = await import("../../api"); const mockUpdate = vi.mocked(updateTask); render( , ); fireEvent.change(screen.getByRole("combobox", { name: "Task priority" }), { target: { value: "high" }, }); await waitFor(() => { expect(mockUpdate).not.toHaveBeenCalled(); }); }); it("reverts inline priority when save fails", async () => { const { updateTask } = await import("../../api"); const mockUpdate = vi.mocked(updateTask); const addToast = vi.fn(); mockUpdate.mockRejectedValueOnce(new Error("Request failed")); render( , ); const prioritySelect = screen.getByRole("combobox", { name: "Task priority" }) as HTMLSelectElement; fireEvent.change(prioritySelect, { target: { value: "urgent" } }); await waitFor(() => { expect(mockUpdate).toHaveBeenCalledWith("FN-001", { priority: "urgent" }, undefined); }); await waitFor(() => { expect(prioritySelect.value).toBe("low"); }); expect(addToast).toHaveBeenCalledWith("Failed to update FN-001: Request failed", "error"); }); it("pre-populates form with existing task values", () => { const { container } = render( , ); // Enter edit mode fireEvent.click(container.querySelector(".modal-edit-btn")!); const titleInput = container.querySelector("#task-form-title") as HTMLInputElement; const descTextarea = container.querySelector("#task-form-description") as HTMLTextAreaElement; expect(titleInput.value).toBe("My Task"); expect(descTextarea.value).toBe("My Description"); }); it("propagates auto-saved description updates via onTaskUpdated", async () => { const { updateTask } = await import("../../api"); const mockUpdateTask = vi.mocked(updateTask); const onTaskUpdated = vi.fn(); const initialTask = makeTask({ id: "FN-001", column: "todo", title: "My Task", description: "Old Description", }); const updatedTask = { ...initialTask, description: "New Description", }; mockUpdateTask.mockResolvedValueOnce(updatedTask); const { container } = render( , ); fireEvent.click(container.querySelector(".modal-edit-btn")!); const descTextarea = container.querySelector("#task-form-description") as HTMLTextAreaElement; fireEvent.change(descTextarea, { target: { value: "New Description" } }); await waitFor(() => { expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", { description: "New Description" }, undefined); expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask); }, { timeout: 3500 }); }); it("uses updated model values in edit mode after saving from the Model tab", async () => { const { fetchModels, updateTask } = await import("../../api"); const mockFetchModels = vi.mocked(fetchModels); const mockUpdateTask = vi.mocked(updateTask); const user = userEvent.setup(); const availableModels = [ { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 }, { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }, ]; mockFetchModels.mockResolvedValue({ models: availableModels, favoriteProviders: [], favoriteModels: [], }); const initialTask = makeTask({ id: "FN-001", column: "triage", title: "Model sync test" }); const updatedAfterExecutor: Task = { ...initialTask, modelProvider: "anthropic", modelId: "claude-sonnet-4-5", }; const updatedAfterValidator: Task = { ...updatedAfterExecutor, validatorModelProvider: "openai", validatorModelId: "gpt-4o", }; mockUpdateTask .mockResolvedValueOnce(updatedAfterExecutor) .mockResolvedValueOnce(updatedAfterValidator); function StatefulModal() { const [task, setTask] = useState(initialTask); return ( setTask((prev) => ({ ...prev, ...updated }))} addToast={noop} /> ); } const { container } = render(); await user.click(screen.getByText("Model")); await waitFor(() => { expect(screen.getByLabelText("Executor Model")).toBeInTheDocument(); }); await user.click(screen.getByLabelText("Executor Model")); await user.click(screen.getByText("Claude Sonnet 4.5")); await waitFor(() => { expect(mockUpdateTask).toHaveBeenNthCalledWith( 1, "FN-001", expect.objectContaining({ modelProvider: "anthropic", modelId: "claude-sonnet-4-5", }), ); }); await user.click(screen.getByLabelText("Reviewer Model")); await user.click(screen.getByText("GPT-4o")); await waitFor(() => { expect(mockUpdateTask).toHaveBeenNthCalledWith( 2, "FN-001", { validatorModelProvider: "openai", validatorModelId: "gpt-4o", }, ); }); fireEvent.click(container.querySelector(".modal-edit-btn")!); await waitFor(() => { expect(screen.getByLabelText("Executor Model")).toHaveTextContent("Claude Sonnet 4.5"); expect(screen.getByLabelText("Reviewer Model")).toHaveTextContent("GPT-4o"); }); }); it("renders Save and Cancel in the modal footer, not inside the edit form body", () => { const { container } = render( , ); // Enter edit mode fireEvent.click(container.querySelector(".modal-edit-btn")!); // The edit form body should NOT contain the Save or Cancel action buttons const editForm = container.querySelector(".modal-edit-form"); expect(editForm).toBeTruthy(); const formButtons = Array.from(editForm!.querySelectorAll("button")); const formButtonTexts = formButtons.map((b) => b.textContent); expect(formButtonTexts).not.toContain("Save"); expect(formButtonTexts).not.toContain("Cancel"); expect(formButtonTexts).not.toContain("Saving…"); // The modal-actions footer should contain the Save and Cancel buttons const modalActions = container.querySelector(".modal-actions"); expect(modalActions).toBeTruthy(); const footerButtons = modalActions!.querySelectorAll("button"); const buttonTexts = Array.from(footerButtons).map((b) => b.textContent); expect(buttonTexts).toContain("Cancel"); expect(buttonTexts).toContain("Save"); }); it("renders keyboard hint in the modal footer when editing", () => { const { container } = render( , ); // Enter edit mode fireEvent.click(container.querySelector(".modal-edit-btn")!); // The hint should be in the modal-actions footer, not inside the edit form body const editForm = container.querySelector(".modal-edit-form"); expect(editForm!.querySelector(".modal-edit-hint")).toBeNull(); const modalActions = container.querySelector(".modal-actions"); expect(modalActions!.querySelector(".modal-edit-hint")).toBeTruthy(); }); it("shows normal modal actions (not edit actions) when not editing", () => { const { container } = render( , ); // Should NOT be in edit mode — no edit hint, no Save/Cancel in footer const modalActions = container.querySelector(".modal-actions"); expect(modalActions!.querySelector(".modal-edit-hint")).toBeNull(); const footerButtons = modalActions!.querySelectorAll("button"); const buttonTexts = Array.from(footerButtons).map((b) => b.textContent); expect(buttonTexts).not.toContain("Save"); expect(buttonTexts).not.toContain("Cancel"); // Should contain Actions dropdown and Move primary action expect(buttonTexts).toContain("Actions"); expect(buttonTexts.some((t) => t?.includes("Move to"))).toBe(true); }); }); describe("Commits tab visibility", () => { it.each<[string, Parameters[0]]>([ ["with mergeDetails.commitSha", { column: "done", mergeDetails: { commitSha: "abc1234567890", filesChanged: 3, insertions: 10, deletions: 2 } }], ["with mergeDetails but no commitSha", { column: "done", mergeDetails: { filesChanged: 3 } }], ["without mergeDetails", { column: "done" }], ])("never shows a separate Commits tab for done tasks (%s) — changes are in the Changes tab", (_label, taskOverrides) => { const { container } = render( , ); expect(screen.queryByText("Commits")).toBeNull(); const tabTexts = Array.from(container.querySelectorAll(".detail-tab")).map((t) => t.textContent); expect(tabTexts).toContain("Changes"); }); }); describe("comment state propagation (FN-845)", () => { it("passes onTaskUpdated to TaskComments when provided", async () => { const { addSteeringComment } = await import("../../api"); const onTaskUpdated = vi.fn(); const updatedTask = makeTask({ comments: [{ id: "c1", text: "New comment", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }], }); vi.mocked(addSteeringComment).mockResolvedValueOnce(updatedTask); render( , ); // Switch to Comments tab fireEvent.click(screen.getByText("Comments")); // Add a comment fireEvent.change(screen.getByPlaceholderText(/Add a comment/), { target: { value: "New comment" } }); fireEvent.click(screen.getByText("Add Comment")); await waitFor(() => { expect(addSteeringComment).toHaveBeenCalledWith("FN-099", "New comment", undefined); expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask); }); }); it("comment mutations still work when onTaskUpdated is not provided", async () => { const { addSteeringComment } = await import("../../api"); const addToast = vi.fn(); vi.mocked(addSteeringComment).mockResolvedValueOnce(makeTask({ comments: [{ id: "c1", text: "Hello", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }], })); render( , ); // Switch to Comments tab fireEvent.click(screen.getByText("Comments")); // Add a comment — should succeed without error even without onTaskUpdated fireEvent.change(screen.getByPlaceholderText(/Add a comment/), { target: { value: "Hello" } }); fireEvent.click(screen.getByText("Add Comment")); await waitFor(() => { expect(addSteeringComment).toHaveBeenCalledWith("FN-099", "Hello", undefined); expect(addToast).toHaveBeenCalledWith("Comment added", "success"); }); }); }); describe("Workflow step ordering in edit mode (FN-836)", () => { it("sends ordered enabledWorkflowSteps when saving with reordered steps", async () => { const { updateTask, fetchWorkflowSteps } = await import("../../api"); const mockUpdate = vi.mocked(updateTask); mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task); vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([ { id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, { id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" }, ]); const { container } = render( , ); // Enter edit mode fireEvent.click(container.querySelector(".modal-edit-btn")!); // Wait for workflow steps to load and reorder controls to appear await waitFor(() => { expect(screen.getByTestId("workflow-step-order")).toBeTruthy(); }); // Move WS-002 up (swap with WS-001) fireEvent.click(screen.getByTestId("workflow-step-move-up-WS-002")); // Save fireEvent.click(screen.getByText("Save")); await waitFor(() => { expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({ enabledWorkflowSteps: ["WS-002", "WS-001"], }), undefined); }); }); }); describe("Workflow tab", () => { it.each<[string, Parameters[0]]>([ ["empty enabledWorkflowSteps", { enabledWorkflowSteps: [] }], ["undefined enabledWorkflowSteps", { enabledWorkflowSteps: undefined, workflowStepResults: undefined }], ["non-empty enabledWorkflowSteps", { enabledWorkflowSteps: ["WS-001"] }], ["previous workflow results", { enabledWorkflowSteps: [], workflowStepResults: [{ workflowStepId: "WS-001", workflowStepName: "QA Check", status: "passed" }] }], ])("Workflow tab is always rendered (%s)", (_label, taskOverrides) => { render( , ); expect(screen.getByText("Workflow")).toBeTruthy(); }); it("switches to Workflow tab and calls fetchWorkflowResults", async () => { const { fetchWorkflowResults } = await import("../../api"); const mockFetch = vi.mocked(fetchWorkflowResults); const mockResults: import("@fusion/core").WorkflowStepResult[] = [ { workflowStepId: "WS-001", workflowStepName: "QA Check", status: "passed", output: "All tests passed.", startedAt: "2026-04-04T10:00:00Z", completedAt: "2026-04-04T10:02:00Z", }, ]; mockFetch.mockResolvedValueOnce(mockResults); render( , ); fireEvent.click(screen.getByText("Workflow")); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith("FN-099", undefined); }); // Should render the workflow results after async tab load completes expect(await screen.findByText("QA Check", {}, { timeout: 15_000 })).toBeTruthy(); }); it("shows loading state when workflow results are being fetched", async () => { const { fetchWorkflowResults } = await import("../../api"); const mockFetch = vi.mocked(fetchWorkflowResults); // Never resolve to keep loading state mockFetch.mockResolvedValueOnce(new Promise(() => {}) as any); render( , ); fireEvent.click(screen.getByText("Workflow")); await waitFor(() => { expect(screen.getByTestId("workflow-results-loading")).toBeTruthy(); }); }); it("shows error toast when fetchWorkflowResults fails", async () => { const { fetchWorkflowResults } = await import("../../api"); const mockFetch = vi.mocked(fetchWorkflowResults); mockFetch.mockRejectedValueOnce(new Error("Server error")); const addToast = vi.fn(); render( , ); fireEvent.click(screen.getByText("Workflow")); await waitFor(() => { expect(addToast).toHaveBeenCalledWith( "Failed to load workflow results: Server error", "error", ); }); }); it("renders configured workflow steps state when results are empty", async () => { const { fetchWorkflowResults } = await import("../../api"); const mockFetch = vi.mocked(fetchWorkflowResults); mockFetch.mockResolvedValueOnce([]); render( , ); fireEvent.click(screen.getByText("Workflow")); await waitFor(() => { expect(screen.getByTestId("workflow-configured-steps")).toBeTruthy(); expect(screen.getByTestId("workflow-configured-step-WS-001")).toHaveTextContent("WS-001"); }); }); it("renders multiple workflow step results with status badges", async () => { const { fetchWorkflowResults } = await import("../../api"); const mockFetch = vi.mocked(fetchWorkflowResults); const mockResults: import("@fusion/core").WorkflowStepResult[] = [ { workflowStepId: "WS-001", workflowStepName: "QA Check", status: "passed", output: "All tests passed.", startedAt: "2026-04-04T10:00:00Z", completedAt: "2026-04-04T10:02:00Z", }, { workflowStepId: "WS-002", workflowStepName: "Security Audit", status: "failed", output: "Found 2 issues.", startedAt: "2026-04-04T10:02:05Z", completedAt: "2026-04-04T10:03:00Z", }, ]; mockFetch.mockResolvedValueOnce(mockResults); render( , ); fireEvent.click(screen.getByText("Workflow")); await waitFor(() => { expect(screen.getByText("QA Check")).toBeTruthy(); expect(screen.getByText("Security Audit")).toBeTruthy(); expect(screen.getByTestId("workflow-result-badge-WS-001")).toHaveTextContent("Passed"); expect(screen.getByTestId("workflow-result-badge-WS-002")).toHaveTextContent("Failed"); }); }); it("hides Definition content when Workflow tab is active", async () => { const { container } = render( , ); // Definition content visible initially expect(container.querySelector(".markdown-body")).toBeTruthy(); // Switch to Workflow tab fireEvent.click(screen.getByText("Workflow")); // Definition content should be hidden await waitFor(() => { expect(container.querySelector(".markdown-body")).toBeNull(); }); }); }); describe("agent assignment", () => { it("shows Assign Agent button when task has no assigned agent", () => { render( , ); expect(screen.getByRole("button", { name: "Assign Agent" })).toBeInTheDocument(); }); it("shows assigned agent chip and clear button when task has assignedAgentId", async () => { const { fetchAgent } = await import("../../api"); vi.mocked(fetchAgent).mockResolvedValue({ id: "agent-002", name: "Pipeline Helper", role: "executor", state: "active", metadata: {}, heartbeatHistory: [], completedRuns: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", } as any); render( , ); await waitFor(() => { expect(screen.getByText("Pipeline Helper")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Unassign agent" })).toBeInTheDocument(); }); }); it("assigns selected agent via assignTask", async () => { const { fetchAgents, assignTask } = await import("../../api"); vi.mocked(fetchAgents).mockResolvedValue([ { id: "agent-001", name: "Task Runner", role: "executor", state: "active", metadata: {}, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, ] as any); vi.mocked(assignTask).mockResolvedValue(makeTask({ assignedAgentId: "agent-001" }) as any); render( , ); await userEvent.click(screen.getByRole("button", { name: "Assign Agent" })); await userEvent.click(screen.getByRole("button", { name: /Task Runner/i })); await waitFor(() => { expect(assignTask).toHaveBeenCalledWith("FN-099", "agent-001", undefined); }); }); it("clears assigned agent via assignTask(null)", async () => { const { fetchAgent, assignTask } = await import("../../api"); vi.mocked(fetchAgent).mockResolvedValue({ id: "agent-005", name: "Doc Bot", role: "executor", state: "active", metadata: {}, heartbeatHistory: [], completedRuns: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", } as any); vi.mocked(assignTask).mockResolvedValue(makeTask({ assignedAgentId: undefined }) as any); render( , ); await userEvent.click(screen.getByRole("button", { name: "Unassign agent" })); await waitFor(() => { expect(assignTask).toHaveBeenCalledWith("FN-099", null, undefined); }); }); }); describe("optimistic opening with Task", () => { beforeEach(async () => { const { fetchTaskDetail } = await import("../../api"); vi.mocked(fetchTaskDetail).mockReset(); }); it("renders immediately when opened with a Task prop (no prompt)", async () => { const { fetchTaskDetail } = await import("../../api"); vi.mocked(fetchTaskDetail).mockResolvedValueOnce({ id: "FN-200", description: "Optimistic task", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", prompt: "# Spec", } as TaskDetail); const task: Task = { id: "FN-200", description: "Optimistic task", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", } as Task; const { container } = render( , ); // Modal renders immediately without crashing expect(container.querySelector(".modal-overlay")).toBeTruthy(); expect(screen.getByText("FN-200")).toBeDefined(); }); it("calls fetchTaskDetail on mount when prop is Task without prompt", async () => { const { fetchTaskDetail } = await import("../../api"); const mockFetch = vi.mocked(fetchTaskDetail); mockFetch.mockResolvedValueOnce({ id: "FN-201", description: "Optimistic task", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", prompt: "# Spec", } as TaskDetail); const task: Task = { id: "FN-201", description: "Optimistic task", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", } as Task; render( , ); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith("FN-201", undefined); }); }); it("does NOT call fetchTaskDetail when prop is already a TaskDetail with prompt", async () => { const { fetchTaskDetail } = await import("../../api"); const mockFetch = vi.mocked(fetchTaskDetail); const detail: TaskDetail = { id: "FN-202", description: "Full detail task", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", prompt: "# Full spec", } as TaskDetail; render( , ); // Give a tick for any async operations await new Promise((resolve) => setTimeout(resolve, 50)); expect(mockFetch).not.toHaveBeenCalledWith("FN-202", undefined); }); it("shows loading state in spec area when detailLoading is true", async () => { const { fetchTaskDetail } = await import("../../api"); const mockFetch = vi.mocked(fetchTaskDetail); // Set up a pending promise so loading state persists mockFetch.mockResolvedValueOnce(new Promise(() => {}) as any); const task: Task = { id: "FN-203", description: "Loading spec test", column: "todo", dependencies: [], steps: [{ name: "Plan", status: "in-progress" }], currentStep: 0, log: [{ timestamp: "2026-04-24T09:00:00.000Z", action: "[timing] setup in 120ms" }], executionMode: "fast", status: "executing", assignedAgentId: "agent-loading", createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", } as Task; render( , ); expect(screen.getByText("Loading specification…")).toBeDefined(); // Token stats now live in their own Stats tab — switch to it before // asserting on token-loading text. fireEvent.click(screen.getByRole("button", { name: "Stats" })); expect(screen.getByText("Execution Timing")).toBeInTheDocument(); expect(screen.getByText("Execution Details")).toBeInTheDocument(); expect(screen.getByText("Loading token statistics…")).toBeDefined(); expect(screen.getByText("Fast")).toBeInTheDocument(); expect(screen.getByText("executing")).toBeInTheDocument(); }); it("shows spec content after fetchTaskDetail resolves", async () => { const { fetchTaskDetail } = await import("../../api"); const mockFetch = vi.mocked(fetchTaskDetail); const task: Task = { id: "FN-204", description: "Async spec test", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", } as Task; const fullDetail: TaskDetail = { ...task, prompt: "# Async Spec\n\nThis is the loaded spec content.", log: [ { timestamp: "2026-04-24T09:00:00.000Z", action: "[timing] prepare env in 120ms" }, { timestamp: "2026-04-24T09:01:00.000Z", action: "[timing] run tests in 3400ms" }, ], workflowStepResults: [ { workflowStepId: "WS-101", workflowStepName: "Workflow QA", status: "passed", startedAt: "2026-04-24T09:10:00.000Z", completedAt: "2026-04-24T09:10:07.000Z", }, ], executionMode: "fast", status: "executing", mergeRetries: 1, workflowStepRetries: 2, recoveryRetryCount: 3, taskDoneRetryCount: 4, tokenUsage: { inputTokens: 1200, outputTokens: 450, cachedTokens: 210, totalTokens: 1860, firstUsedAt: "2026-04-24T09:00:00.000Z", lastUsedAt: "2026-04-24T10:15:00.000Z", }, } as TaskDetail; // Resolve with full detail mockFetch.mockResolvedValueOnce(fullDetail); const { container } = render( , ); // Initially shows loading expect(screen.getByText("Loading specification…")).toBeDefined(); // After fetch resolves, spec content appears await waitFor(() => { const markdownBody = container.querySelector(".markdown-body"); expect(markdownBody).toBeTruthy(); }, { timeout: 3000 }); // Loading indicator should be gone expect(screen.queryByText("Loading specification…")).toBeNull(); // Token stats live behind the Stats tab now. fireEvent.click(screen.getByRole("button", { name: "Stats" })); expect(screen.queryByText("Loading token statistics…")).toBeNull(); expect(screen.getByText("Execution Timing")).toBeInTheDocument(); expect(screen.getByText("Execution Details")).toBeInTheDocument(); expect(screen.getByText("Timing events")).toBeInTheDocument(); expect(screen.getByText("Workflow runtime")).toBeInTheDocument(); expect(screen.getByText("Execution mode")).toBeInTheDocument(); expect(screen.getByText("Runtime status")).toBeInTheDocument(); expect(screen.getByText("Fast")).toBeInTheDocument(); expect(screen.getByText("executing")).toBeInTheDocument(); expect(screen.getByText((1200).toLocaleString())).toBeInTheDocument(); expect(screen.getByText((450).toLocaleString())).toBeInTheDocument(); expect(screen.getByText((210).toLocaleString())).toBeInTheDocument(); expect(screen.getByText((1860).toLocaleString())).toBeInTheDocument(); const firstUsed = container.querySelector('time[datetime="2026-04-24T09:00:00.000Z"]'); const lastUsed = container.querySelector('time[datetime="2026-04-24T10:15:00.000Z"]'); expect(firstUsed).toBeTruthy(); expect(lastUsed).toBeTruthy(); }); it("preserves fullDetail.log when SSE-stripped task prop has empty log", async () => { // Regression: SSE strips `log` to [] in task list payloads (see // stripTaskListHeavyFields in packages/dashboard/src/sse.ts). The modal // merges live `task` over `fullDetail` to keep tokenUsage/status fresh, // which previously clobbered fullDetail.log and emptied the Activity tab. const { fetchTaskDetail } = await import("../../api"); const mockFetch = vi.mocked(fetchTaskDetail); const strippedTask: Task = { id: "FN-LOG-1", description: "SSE stripped task", column: "in-progress", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", }; mockFetch.mockResolvedValueOnce({ ...strippedTask, prompt: "# Spec", log: [ { timestamp: "2026-04-24T09:00:00.000Z", action: "Created task" }, { timestamp: "2026-04-24T09:01:00.000Z", action: "Started executor", outcome: "OK" }, ], } as TaskDetail); const { container } = render( , ); // Wait for fetchTaskDetail to resolve. await waitFor(() => { expect(container.querySelector(".markdown-body")).toBeTruthy(); }, { timeout: 3000 }); fireEvent.click(screen.getByText("Logs")); const activityList = container.querySelector(".detail-activity-list"); expect(activityList).toBeTruthy(); const logEntries = container.querySelectorAll(".detail-log-entry"); expect(logEntries).toHaveLength(2); expect(logEntries[0].textContent).toContain("Started executor"); expect(logEntries[1].textContent).toContain("Created task"); }); it("shows token stats empty state once detail is loaded without usage", async () => { const { fetchTaskDetail } = await import("../../api"); const mockFetch = vi.mocked(fetchTaskDetail); const task: Task = { id: "FN-205", description: "No token stats", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", } as Task; mockFetch.mockResolvedValueOnce({ ...task, prompt: "# Async Spec\n\nSpec without usage.", tokenUsage: undefined, } as TaskDetail); render( , ); // Token stats live behind the Stats tab now — wait for the modal to // settle, then switch tabs and assert on the empty state. await waitFor(() => { expect(screen.queryByText("Loading specification…")).toBeNull(); }); fireEvent.click(screen.getByRole("button", { name: "Stats" })); await waitFor(() => { expect(screen.getByText("No token usage recorded for this task yet.")).toBeInTheDocument(); }); }); }); describe("PluginSlot integration", () => { it("renders plugin tabs when plugins register for task-detail-tab slot", async () => { mockUsePluginUiSlots.mockReturnValue({ slots: [ { pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } }, { pluginId: "plugin-b", slot: { slotId: "task-detail-tab", label: "Plugin B Tab", componentPath: "./b.js" } }, ], getSlotsForId: (id: string) => id === "task-detail-tab" ? [ { pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } }, { pluginId: "plugin-b", slot: { slotId: "task-detail-tab", label: "Plugin B Tab", componentPath: "./b.js" } }, ] : [], loading: false, error: null, } as any); render( ); // Both plugin tabs should appear expect(screen.getByText("Plugin A Tab")).toBeDefined(); expect(screen.getByText("Plugin B Tab")).toBeDefined(); }); it("shows only the selected plugin tab content when plugin tab is clicked", async () => { mockUsePluginUiSlots.mockReturnValue({ slots: [ { pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } }, { pluginId: "plugin-b", slot: { slotId: "task-detail-tab", label: "Plugin B Tab", componentPath: "./b.js" } }, ], getSlotsForId: (id: string) => id === "task-detail-tab" ? [ { pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } }, { pluginId: "plugin-b", slot: { slotId: "task-detail-tab", label: "Plugin B Tab", componentPath: "./b.js" } }, ] : [], loading: false, error: null, } as any); const { container } = render( ); await userEvent.click(screen.getByText("Plugin B Tab")); const slots = container.querySelectorAll('[data-slot-id="task-detail-tab"]'); expect(slots).toHaveLength(1); expect(slots[0]).toHaveAttribute("data-plugin-id", "plugin-b"); expect(container.querySelector('[data-plugin-id="plugin-a"]')).toBeNull(); }); it("renders no extra tabs when no plugins register", () => { mockUsePluginUiSlots.mockReturnValue({ slots: [], getSlotsForId: vi.fn(() => []), loading: false, error: null, }); render( ); // Only standard tabs should be visible (Definition, Logs, etc.) expect(screen.getByText("Definition")).toBeDefined(); expect(screen.getByText("Logs")).toBeDefined(); // Plugin tabs should not exist expect(screen.queryByText("Plugin A Tab")).toBeNull(); }); }); });