import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { makeTask, noop, noopDelete, noopMerge, noopMove, noopOpenDetail, noopRetry, mockConfirm, mockUsePluginUiSlots, expectBaseRule, readDashboardStylesSource, setupTaskDetailModalHooks, } from "./TaskDetailModal.test-helpers"; import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal"; import * as dashboardApi from "../../api"; setupTaskDetailModalHooks(); describe("TaskDetailModal", () => { 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("FN-3755 renders provenance before created-updated timestamps", () => { const { container } = render( , ); const provenance = screen.getByText("Created via Dashboard").closest(".detail-provenance"); const timestamps = container.querySelector(".detail-timestamps"); expect(provenance).toBeTruthy(); expect(timestamps).toBeTruthy(); expect(provenance?.compareDocumentPosition(timestamps as Node) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); }); it("renders modal wrapper structure and default close control", () => { const { container } = render( , ); expect(container.querySelector(".modal-overlay.open")).toBeTruthy(); expect(container.querySelector(".modal.modal-lg.task-detail-modal")).toBeTruthy(); expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Back to task list" })).toBeNull(); }); it("renders mobile back control variant when requested", () => { render( , ); expect(screen.getByRole("button", { name: "Back to task list" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Close" })).toBeNull(); }); it("omits close control in embedded mode while rendering shared content", () => { const { container } = render( , ); expect(container.querySelector(".task-detail-content--embedded")).toBeTruthy(); expect(screen.queryByRole("button", { name: "Close" })).toBeNull(); expect(screen.getByRole("button", { name: "Definition" })).toBeInTheDocument(); }); it("styles detail-body scrollbar rules", () => { const css = readDashboardStylesSource(); 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 = readDashboardStylesSource(); 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 Review and Comments tabs", () => { render( , ); expect(screen.getByText("Review")).toBeTruthy(); expect(screen.getByText("Comments")).toBeTruthy(); }); it("shows non-PR review shell message in Review tab", async () => { render( , ); fireEvent.click(screen.getByRole("button", { name: "Review" })); expect(await screen.findByText("No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.")).toBeTruthy(); }); it("keeps Comments tab available after Review refresh", async () => { vi.mocked(dashboardApi.fetchTaskReview).mockResolvedValueOnce({ reviewState: { source: "pull-request", summary: { reviewDecision: "REVIEW_REQUIRED", reviewers: [], blockingReasons: [], checks: [], }, items: [], addressing: [], }, automationStatus: null, emptyMessage: null, }); vi.mocked(dashboardApi.refreshTaskReview).mockResolvedValueOnce({ reviewState: { source: "pull-request", summary: { reviewDecision: "APPROVED", reviewers: [{ login: "octocat", state: "APPROVED" }], blockingReasons: [], checks: [], }, items: [], addressing: [], refreshStatus: "ready", }, automationStatus: null, }); render( , ); fireEvent.click(screen.getByRole("button", { name: "Review" })); fireEvent.click(await screen.findByRole("button", { name: "Refresh" })); expect(await screen.findByText("APPROVED")).toBeTruthy(); const commentsTab = screen.getByRole("button", { name: "Comments" }); expect(commentsTab).toBeInTheDocument(); fireEvent.click(commentsTab); expect(screen.getByRole("heading", { name: "Comments" })).toBeInTheDocument(); }); it("shows PR review decision details in Review tab", async () => { vi.mocked(dashboardApi.fetchTaskReview).mockResolvedValueOnce({ reviewState: { source: "pull-request", summary: { reviewDecision: "CHANGES_REQUESTED", reviewers: [{ login: "octocat", state: "CHANGES_REQUESTED" }], blockingReasons: ["changes requested review is active"], checks: [], }, items: [], addressing: [], }, automationStatus: null, emptyMessage: null, }); render( , ); fireEvent.click(screen.getByRole("button", { name: "Review" })); expect(await screen.findByText("CHANGES_REQUESTED")).toBeTruthy(); expect(screen.getByText("changes requested review is active")).toBeTruthy(); }); describe("inline execution mode toggle", () => { it("renders standard mode as an unpressed toggle", () => { render( , ); const toggle = screen.getByRole("button", { name: "Execution mode: standard" }); expect(toggle).toHaveAttribute("aria-pressed", "false"); expect(toggle).toHaveTextContent("Standard"); expect(toggle).not.toHaveClass("detail-execution-mode-toggle--fast"); }); it("renders fast mode as a pressed toggle", () => { render( , ); const toggle = screen.getByRole("button", { name: "Execution mode: fast" }); expect(toggle).toHaveAttribute("aria-pressed", "true"); expect(toggle).toHaveTextContent("Fast"); expect(toggle).toHaveClass("detail-execution-mode-toggle--fast"); }); }); it("defines fast execution mode svg highlight styles with warning tokens", () => { const css = readDashboardStylesSource(); expectBaseRule(css, ".detail-execution-mode-toggle--fast svg", "color: var(--color-warning);"); expectBaseRule( css, ".detail-execution-mode-toggle--fast svg", "background: color-mix(in srgb, var(--color-warning) 20%, transparent);", ); }); 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("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.getAllByText("Fast").length).toBeGreaterThan(0); 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.getAllByText("Fast").length).toBeGreaterThan(0); 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(); }); }); }); it("renders corrected stats timing totals in Stats tab", () => { render( , ); fireEvent.click(screen.getByRole("button", { name: "Stats" })); const totalMetric = screen.getByText("Total execution time").closest(".task-token-stats-panel__metric"); const workflowMetric = screen.getByText("Workflow runtime").closest(".task-token-stats-panel__metric"); expect(totalMetric).toHaveTextContent("4m 0s"); expect(screen.getByText("Timed duration").closest(".task-token-stats-panel__metric")).toHaveTextContent("2m 0s"); expect(workflowMetric).toHaveTextContent("1m 0s"); }); });