import { afterEach, describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { TaskCard, formatElapsedDurationDone, __test_areTaskCardPropsEqual } from "../TaskCard"; import type { ConfirmOptions } from "../../hooks/useConfirm"; import type { Task } from "@fusion/core"; // Mock lucide-react to avoid SVG rendering issues in test env vi.mock("lucide-react", () => ({ Link: () => null, GitBranch: () => null, Clock: () => null, Pencil: () => null, Layers: () => null, ChevronDown: () => null, Folder: () => null, GitPullRequest: () => null, CircleDot: () => null, Target: () => null, Bot: () => null, Trash2: () => null, RotateCw: () => null, Zap: () => , })); vi.mock("../ProviderIcon", () => ({ ProviderIcon: ({ provider }: { provider: string }) => , })); const useTaskDiffStatsMock = vi.fn(() => ({ stats: null, loading: false })); vi.mock("../../hooks/useTaskDiffStats", () => ({ useTaskDiffStats: (...args: any[]) => useTaskDiffStatsMock(...args), })); const badgeUpdatesMock = new Map(); const subscribeToBadgeMock = vi.fn(); const unsubscribeFromBadgeMock = vi.fn(); vi.mock("../../hooks/useBadgeWebSocket", () => ({ useBadgeWebSocket: () => ({ badgeUpdates: badgeUpdatesMock, isConnected: true, subscribeToBadge: subscribeToBadgeMock, unsubscribeFromBadge: unsubscribeFromBadgeMock, }), })); vi.mock("../../hooks/useBatchBadgeFetch", () => ({ getFreshBatchData: vi.fn(() => null), })); // Mock the api module vi.mock("../../api", () => ({ fetchTaskDetail: vi.fn(), uploadAttachment: vi.fn(), fetchMission: vi.fn(), fetchAgent: vi.fn(), })); const mockConfirm = vi.fn<(options: ConfirmOptions) => Promise>(); vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: mockConfirm }), })); import { uploadAttachment, fetchMission, fetchAgent } from "../../api"; import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture"; function makeTask(overrides: Partial = {}): Task { return { id: "FN-001", title: "Test task", column: "in-progress", status: undefined as any, steps: [], dependencies: [], description: "", ...overrides, } as Task; } const noop = () => {}; function mountCssForBadgeTests() { const style = document.createElement("style"); style.textContent = loadAllAppCss(); document.head.appendChild(style); document.documentElement.style.setProperty("--status-error-bg", "rgb(255, 230, 230)"); document.documentElement.style.setProperty("--color-error-dark", "rgb(200, 0, 0)"); document.documentElement.style.setProperty("--status-in-review-bg", "rgb(230, 255, 230)"); document.documentElement.style.setProperty("--in-review", "rgb(0, 160, 0)"); return () => { style.remove(); document.documentElement.style.removeProperty("--status-error-bg"); document.documentElement.style.removeProperty("--color-error-dark"); document.documentElement.style.removeProperty("--status-in-review-bg"); document.documentElement.style.removeProperty("--in-review"); }; } const highFanout = { totalCount: 7, activeTodoCount: 3, dependentIds: ["FN-002", "FN-003"], staleBlockedByDependentIds: [], isHighFanout: true, } as const; afterEach(() => { vi.useRealTimers(); useTaskDiffStatsMock.mockReturnValue({ stats: null, loading: false }); badgeUpdatesMock.clear(); subscribeToBadgeMock.mockReset(); unsubscribeFromBadgeMock.mockReset(); }); describe("TaskCard", () => { it("uses githubIssueAction for tracked task delete", async () => { const onDeleteTask = vi.fn(async () => makeTask()); mockConfirm .mockResolvedValueOnce(true) .mockResolvedValueOnce(true); render( , ); await act(async () => { fireEvent.click(screen.getByLabelText("Delete task")); }); await waitFor(() => { expect(onDeleteTask).toHaveBeenCalledWith("FN-001", { githubIssueAction: "close" }); }); }); it("uses githubIssueAction=delete for tracked task delete", async () => { const onDeleteTask = vi.fn(async () => makeTask()); mockConfirm .mockResolvedValueOnce(true) .mockResolvedValueOnce(false) .mockResolvedValueOnce(true); render( , ); await act(async () => { fireEvent.click(screen.getByLabelText("Delete task")); }); await waitFor(() => { expect(onDeleteTask).toHaveBeenCalledWith("FN-001", { githubIssueAction: "delete" }); }); }); it("uses githubIssueAction=leave for tracked task delete", async () => { const onDeleteTask = vi.fn(async () => makeTask()); mockConfirm .mockResolvedValueOnce(true) .mockResolvedValueOnce(false) .mockResolvedValueOnce(false); render( , ); await act(async () => { fireEvent.click(screen.getByLabelText("Delete task")); }); await waitFor(() => { expect(onDeleteTask).toHaveBeenCalledWith("FN-001", { githubIssueAction: "leave" }); }); }); it("preserves githubIssueAction on dependency-conflict retry", async () => { const conflict = new Error("Cannot delete task FN-001: still referenced as a dependency by FN-002.") as Error & { status: number; details: { code: string; dependentIds: string[] } }; conflict.status = 409; conflict.details = { code: "TASK_HAS_DEPENDENTS", dependentIds: ["FN-002"] }; const onDeleteTask = vi.fn() .mockRejectedValueOnce(conflict) .mockResolvedValueOnce(makeTask()); mockConfirm .mockResolvedValueOnce(true) .mockResolvedValueOnce(false) .mockResolvedValueOnce(true) .mockResolvedValueOnce(true); render( , ); await act(async () => { fireEvent.click(screen.getByLabelText("Delete task")); }); await waitFor(() => { expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-001", { removeDependencyReferences: true, githubIssueAction: "delete" }); }); }); it("keeps legacy delete options for untracked task", async () => { const onDeleteTask = vi.fn(async () => makeTask()); mockConfirm.mockResolvedValueOnce(true); render(); await act(async () => { fireEvent.click(screen.getByLabelText("Delete task")); }); await waitFor(() => { expect(onDeleteTask).toHaveBeenCalledWith("FN-001"); }); }); it("renders the card ID text", () => { render(); expect(screen.getByText("FN-001")).toBeDefined(); }); it("keeps native card dragging enabled by default", () => { const { container } = render(); const card = container.querySelector(".card") as HTMLElement; expect(card.getAttribute("draggable")).toBe("true"); }); it("disables native card dragging when disableDrag is true", () => { const { container } = render(); const card = container.querySelector(".card") as HTMLElement; expect(card.getAttribute("draggable")).toBe("false"); }); it("clicking PR badge link does not open the task detail modal", () => { const onOpenDetail = vi.fn(); render( , ); fireEvent.click(screen.getByRole("link", { name: "#42" })); expect(onOpenDetail).not.toHaveBeenCalled(); }); it("renders GitHub badge from live websocket data even when task payload has no badge fields", () => { badgeUpdatesMock.set("default:FN-001", { prInfo: { url: "https://github.com/owner/repo/pull/77", number: 77, status: "open", title: "Live PR", headBranch: "feature/live", baseBranch: "main", commentCount: 0, }, timestamp: "2026-05-13T12:00:00.000Z", }); render( , ); expect(subscribeToBadgeMock).toHaveBeenCalledWith("FN-001"); expect(screen.getByRole("link", { name: "#77" })).toBeDefined(); }); it("clicking issue badge text does not open the task detail modal", () => { const onOpenDetail = vi.fn(); render( , ); fireEvent.click(screen.getByText("#123")); expect(onOpenDetail).not.toHaveBeenCalled(); }); it("renders the status badge when task.status is set", () => { render( , ); expect(screen.getByText("executing")).toBeDefined(); }); it("renders merge-remediation status as merge-active for in-review tasks", () => { const { container } = render( , ); expect(screen.getByText("Merging fixes…")).toBeDefined(); const badge = container.querySelector(".card-status-badge"); expect(badge?.className).toContain("pulsing"); }); it("FN-4208 keeps failed in-review TaskCard badge on error colors", () => { const cleanupCss = mountCssForBadgeTests(); try { const { container } = render( , ); const badge = container.querySelector(".card-status-badge") as HTMLElement; expect(badge.className).toContain("card-status-badge--in-review"); expect(badge.className).toContain("failed"); expect(getComputedStyle(badge).color).toBe("var(--color-error-dark)"); expect(getComputedStyle(badge).color).not.toBe("var(--in-review)"); } finally { cleanupCss(); } }); it("renders the status badge after the card ID in DOM order", () => { const { container } = render( , ); const cardId = container.querySelector(".card-id")!; const badge = container.querySelector(".card-status-badge")!; expect(cardId).toBeDefined(); expect(badge).toBeDefined(); // Badge should be the next sibling of card-id expect(cardId.nextElementSibling).toBe(badge); }); it("does not render a status badge when task.status is falsy", () => { const { container } = render( , ); expect(container.querySelector(".card-status-badge")).toBeNull(); }); it("renders stalled badge with visible reason when stalledReview is set", () => { render( , ); const stalledBadge = screen.getByText("Stalled"); expect(stalledBadge.getAttribute("title")).toContain("Re-enqueued for merge 3 times"); expect(screen.getByText("Re-enqueued for merge 3 times in the last 60 minutes without leaving in-review")).toBeDefined(); }); it("does not render stalled badge when stalledReview is undefined", () => { render( , ); expect(screen.queryByText("Stalled")).toBeNull(); expect(screen.queryByText(/Re-enqueued for merge/)).toBeNull(); }); it("shows paused by agent label when pausedByAgentId is set", () => { render( , ); expect(screen.getByText("paused by agent")).toBeDefined(); }); it("shows plain paused label when pausedByAgentId is not set", () => { render( , ); expect(screen.getByText("paused")).toBeDefined(); expect(screen.queryByText("paused by agent")).toBeNull(); }); it("does not render fan-out badge when fanout is missing or zero", () => { const { container, rerender } = render( , ); expect(container.querySelector(".card-fanout-badge")).toBeNull(); rerender( , ); expect(container.querySelector(".card-fanout-badge")).toBeNull(); }); it("renders fan-out badge with downstream count and tooltip", () => { render( , ); const badge = screen.getByText("Blocks").closest(".card-fanout-badge") as HTMLElement; expect(badge).not.toBeNull(); expect(badge.textContent).toContain("Blocks 7"); expect(badge.getAttribute("data-tooltip")).toBe("Blocking 7 active task(s); 4 waiting in todo"); }); it("applies stale fan-out modifier when stale blockedBy dependents exist", () => { const { container } = render( , ); const badge = container.querySelector(".card-fanout-badge") as HTMLElement; expect(badge.className).toContain("card-fanout-badge--stale"); expect(badge.textContent).toContain("(1 stale)"); }); it("renders high fan-out badge without visible todo suffix while keeping tooltip context", () => { render( , ); const badge = screen.getByText("High fan-out").closest(".card-fanout-badge") as HTMLElement; expect(badge).not.toBeNull(); expect(badge.textContent).toContain("High fan-out 7"); expect(badge.textContent).not.toContain("todo)"); expect(badge.getAttribute("data-tooltip")).toContain("3 waiting in todo"); }); it("escalates only threshold-crossing fan-out badges", () => { const { rerender } = render( , ); let badge = screen.getByText("Escalated").closest(".card-fanout-badge") as HTMLElement; expect(badge).not.toBeNull(); expect(badge.textContent).toContain("Escalated"); expect(badge.textContent).toContain("8"); expect(badge.textContent).not.toContain("todo)"); rerender( , ); badge = screen.getByText("Blocks").closest(".card-fanout-badge") as HTMLElement; expect(badge).not.toBeNull(); }); it("shows plain paused label when pausedByAgentId is not set", () => { render( , ); expect(screen.getByText("paused")).toBeDefined(); expect(screen.queryByText("paused by agent")).toBeNull(); }); it("hides default working branch and default base branch metadata", () => { const { container } = render( , ); expect(container.querySelector(".card-branch-row")).toBeNull(); }); it("hides auto-generated suffixed default working branches", () => { const { container } = render( , ); expect(container.querySelector(".card-branch-row")).toBeNull(); }); it("shows only custom working branch metadata when base branch is default", () => { render( , ); expect(screen.getByText("Branch")).toBeDefined(); expect(screen.getByText("feature/working-only")).toBeDefined(); expect(screen.queryByText("Base")).toBeNull(); }); it("shows only non-default base branch metadata when working branch is default", () => { render( , ); expect(screen.getByText("Base")).toBeDefined(); expect(screen.getByText("release/2026-05")).toBeDefined(); expect(screen.queryByText("Branch")).toBeNull(); }); it("renders merge target from task.baseBranch, not prInfo.baseBranch metadata", () => { render( , ); expect(screen.getByText("Base")).toBeDefined(); expect(screen.getByText("release/task-target")).toBeDefined(); expect(screen.queryByText("main")).toBeNull(); }); it("shows both chips when branch and base branch are both non-default", () => { const { container } = render( , ); const branchRow = container.querySelector(".card-branch-row"); expect(branchRow).not.toBeNull(); expect(screen.getByText("Branch")).toBeDefined(); expect(screen.getByText("feature/fn-3423-card-branches")).toBeDefined(); expect(screen.getByText("Base")).toBeDefined(); expect(screen.getByText("develop")).toBeDefined(); }); it("keeps long non-default branch names readable via text and title semantics", () => { const longBranch = "feature/fn-3423-display-very-long-working-branch-name-for-card-metadata"; const { container } = render( , ); const branchChip = container.querySelector(".card-branch-chip"); expect(branchChip?.getAttribute("title")).toBe(longBranch); expect(screen.getByText(longBranch)).toBeDefined(); }); it("renders fast-mode indicator only when executionMode is fast", () => { const { container, rerender } = render( , ); const fastBadge = container.querySelector(".card-execution-mode-badge"); expect(fastBadge).not.toBeNull(); expect(screen.getByTestId("icon-zap")).toBeDefined(); expect(fastBadge?.getAttribute("aria-label")).toBe("Fast mode"); rerender( , ); expect(container.querySelector(".card-execution-mode-badge")).toBeNull(); }); it("updates fast-mode indicator when executionMode changes", () => { const { container, rerender } = render( , ); expect(container.querySelector(".card-execution-mode-badge")).toBeNull(); rerender( , ); expect(container.querySelector(".card-execution-mode-badge")).not.toBeNull(); expect(screen.getByTestId("icon-zap")).toBeDefined(); }); describe("retry button on failed tasks", () => { it("renders when task is failed and onRetryTask is provided", () => { const onRetryTask = vi.fn(async () => ({}) as Task); render( , ); expect(screen.getByRole("button", { name: "Retry" })).toBeDefined(); }); it("does not render for non-failed tasks", () => { const onRetryTask = vi.fn(async () => ({}) as Task); render( , ); expect(screen.queryByRole("button", { name: "Retry" })).toBeNull(); }); it("calls onRetryTask with task id", async () => { const onRetryTask = vi.fn(async () => ({}) as Task); render( , ); fireEvent.click(screen.getByRole("button", { name: "Retry" })); await waitFor(() => expect(onRetryTask).toHaveBeenCalledWith("FN-001")); }); it("shows loading and disabled state while retry is in progress", async () => { let resolveRetry: ((value: Task) => void) | null = null; const onRetryTask = vi.fn(() => new Promise((resolve) => { resolveRetry = resolve; })); render( , ); const button = screen.getByRole("button", { name: "Retry" }) as HTMLButtonElement; fireEvent.click(button); expect(screen.getByRole("button", { name: "Retrying…" })).toBeDefined(); expect(button.disabled).toBe(true); await act(async () => { resolveRetry?.({} as Task); }); await waitFor(() => expect(screen.getByRole("button", { name: "Retry" })).toBeDefined()); }); it("shows toast when retry fails", async () => { const addToast = vi.fn(); const onRetryTask = vi.fn(async () => { throw new Error("network down"); }); render( , ); fireEvent.click(screen.getByRole("button", { name: "Retry" })); await waitFor(() => { expect(addToast).toHaveBeenCalledWith("Failed to retry FN-001: network down", "error"); }); }); }); it("renders unified progress counts for task steps + workflow checks", () => { render( , ); expect(screen.getByText("2/5")).toBeDefined(); expect(screen.getByText("5 steps")).toBeDefined(); }); it("uses singular step label when unified progress total is one", () => { render( , ); expect(screen.getByText("1 step")).toBeDefined(); expect(screen.queryByText("1 steps")).toBeNull(); }); it("renders workflow checks after normal steps with mapped statuses and phase badges", () => { const { container } = render( , ); const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent); expect(stepNames).toEqual([ "Step 0", "Step 1", "Browser Verification", "Frontend UX Design", "Accessibility Audit", ]); const dots = container.querySelectorAll(".card-step-dot"); expect(dots[1]?.className).toContain("card-step-dot--failed"); expect(dots[1]?.className).not.toContain("card-step-dot--workflow-failed"); expect(dots[2]?.className).toContain("card-step-dot--done"); expect(dots[2]?.className).not.toContain("card-step-dot--workflow-failed"); expect(dots[3]?.className).toContain("card-step-dot--failed"); expect(dots[3]?.className).toContain("card-step-dot--workflow-failed"); expect(dots[4]?.className).toContain("card-step-dot--pending"); expect(dots[4]?.className).not.toContain("card-step-dot--workflow-failed"); const workflowBadgeElements = container.querySelectorAll(".card-step-workflow-badge"); const workflowBadges = Array.from(workflowBadgeElements).map((el) => el.textContent); expect(workflowBadges).toEqual(["workflow", "workflow", "workflow"]); expect(workflowBadgeElements[0]?.className).toContain("card-step-workflow-badge--pre-merge"); expect(workflowBadgeElements[1]?.className).toContain("card-step-workflow-badge--post-merge"); expect(workflowBadgeElements[2]?.className).toContain("card-step-workflow-badge--pre-merge"); workflowBadgeElements.forEach((badge) => { expect(badge.getAttribute("title")).toBe("Workflow check"); }); }); it("falls back to workflow result name, then raw ID when lookup names are unavailable", () => { const { container } = render( , ); const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent); expect(stepNames).toEqual(["Fallback from result", "WS-003"]); }); it("shows drop indicator on file dragover and removes on dragleave", () => { const { container } = render( , ); const card = container.querySelector(".card")!; // Simulate file dragover fireEvent.dragOver(card, { dataTransfer: { types: ["Files"], dropEffect: "none" }, }); expect(card.classList.contains("file-drop-target")).toBe(true); // Simulate dragleave fireEvent.dragLeave(card, { dataTransfer: { types: ["Files"] }, }); expect(card.classList.contains("file-drop-target")).toBe(false); }); it("does not show drop indicator for non-file drag", () => { const { container } = render( , ); const card = container.querySelector(".card")!; // Simulate card dragover (not files) fireEvent.dragOver(card, { dataTransfer: { types: ["text/plain"], dropEffect: "none" }, }); expect(card.classList.contains("file-drop-target")).toBe(false); }); it("calls uploadAttachment on file drop", async () => { const mockUpload = vi.mocked(uploadAttachment); mockUpload.mockResolvedValue({ filename: "abc-test.png", originalName: "test.png", mimeType: "image/png", size: 1024, createdAt: new Date().toISOString(), }); const addToast = vi.fn(); const { container } = render( , ); const card = container.querySelector(".card")!; const file = new File(["content"], "test.png", { type: "image/png" }); fireEvent.drop(card, { dataTransfer: { types: ["Files"], files: [file] }, }); await waitFor(() => { expect(mockUpload).toHaveBeenCalledWith("FN-001", file, undefined); expect(addToast).toHaveBeenCalledWith( expect.stringContaining("Attached test.png"), "success", ); }); }); it("shows in-review files-changed chip from modifiedFiles fallback when no worktree diff is available", () => { const onOpenDetailWithTab = vi.fn(); const task = makeTask({ column: "in-review", worktree: undefined, modifiedFiles: ["packages/dashboard/app/App.tsx", "packages/dashboard/app/styles.css"], }); render( , ); const filesChangedButton = screen.getByRole("button", { name: "2 files changed" }); expect(filesChangedButton).toBeDefined(); expect((filesChangedButton as HTMLButtonElement).disabled).toBe(false); fireEvent.click(filesChangedButton); expect(onOpenDetailWithTab).toHaveBeenCalledWith(task, "changes"); }); it("shows in-progress files-changed chip from modifiedFiles fallback when no live diff is available", () => { const onOpenDetailWithTab = vi.fn(); const task = makeTask({ column: "in-progress", worktree: undefined, modifiedFiles: ["packages/core/src/store.ts", "packages/core/src/types.ts"], }); render( , ); const filesChangedButton = screen.getByRole("button", { name: "2 files changed" }); expect(filesChangedButton).toBeDefined(); expect((filesChangedButton as HTMLButtonElement).disabled).toBe(false); fireEvent.click(filesChangedButton); expect(onOpenDetailWithTab).toHaveBeenCalledWith(task, "changes"); }); it("shows error toast when upload fails", async () => { const mockUpload = vi.mocked(uploadAttachment); mockUpload.mockRejectedValue(new Error("Upload failed")); const addToast = vi.fn(); const { container } = render( , ); const card = container.querySelector(".card")!; const file = new File(["content"], "bad.png", { type: "image/png" }); fireEvent.drop(card, { dataTransfer: { types: ["Files"], files: [file] }, }); await waitFor(() => { expect(addToast).toHaveBeenCalledWith( expect.stringContaining("Failed to attach bad.png"), "error", ); }); }); // Size badge positioning regression tests (KB-197) it("renders size badge for sized tasks", () => { const { container } = render( , ); expect(container.querySelector(".card-size-badge")).not.toBeNull(); expect(screen.getByText("S")).toBeDefined(); }); it("does not render size badge when task has no size", () => { const { container } = render( , ); expect(container.querySelector(".card-size-badge")).toBeNull(); }); it("renders all three size values with correct CSS classes", () => { const sizes: Array<"S" | "M" | "L"> = ["S", "M", "L"]; const expectedClasses = ["size-s", "size-m", "size-l"]; sizes.forEach((size, index) => { const { container } = render( , ); const badge = container.querySelector(".card-size-badge"); expect(badge).not.toBeNull(); expect(badge?.classList.contains(expectedClasses[index])).toBe(true); // Clean up for next iteration container.remove(); }); }); it("places size badge inside card-header-actions container", () => { const { container } = render( , ); const actionsContainer = container.querySelector(".card-header-actions"); const sizeBadge = container.querySelector(".card-size-badge"); expect(actionsContainer).not.toBeNull(); expect(sizeBadge).not.toBeNull(); expect(actionsContainer?.contains(sizeBadge)).toBe(true); }); it("places card-header-actions after card-id in DOM order", () => { const { container } = render( , ); const cardId = container.querySelector(".card-id")!; const actionsContainer = container.querySelector(".card-header-actions")!; expect(cardId).not.toBeNull(); expect(actionsContainer).not.toBeNull(); // The actions container should come after card-id expect( cardId.compareDocumentPosition(actionsContainer) & Node.DOCUMENT_POSITION_FOLLOWING ).toBeTruthy(); }); it("renders edit button inside card-header-actions for editable columns", () => { const { container } = render( makeTask()} />, ); const actionsContainer = container.querySelector(".card-header-actions"); const editBtn = container.querySelector(".card-edit-btn"); expect(actionsContainer).not.toBeNull(); expect(editBtn).not.toBeNull(); expect(actionsContainer?.contains(editBtn)).toBe(true); }); it("renders archive button inside card-header-actions for done column", () => { const { container } = render( makeTask()} />, ); const actionsContainer = container.querySelector(".card-header-actions"); const archiveBtn = container.querySelector(".card-archive-btn"); expect(actionsContainer).not.toBeNull(); expect(archiveBtn).not.toBeNull(); expect(actionsContainer?.contains(archiveBtn)).toBe(true); }); it("shows timer chip for in-progress cards summing workflow runtime + timed events", () => { const { container } = render( , ); const timer = container.querySelector(".card-time-indicator"); expect(timer).not.toBeNull(); // 8m workflow + 4m timed = 12m expect(timer?.textContent).toContain("12m"); expect(timer?.getAttribute("title")).toContain("In progress 12m"); }); it("updates the in-progress timer when timedExecutionMs changes", () => { const { container, rerender } = render( , ); expect(container.querySelector(".card-time-indicator")?.textContent).toContain("1m"); rerender( , ); expect(container.querySelector(".card-time-indicator")?.textContent).toContain("2m"); }); it("shows timer chip for done cards summing workflow runtime + timed events", () => { const { container } = render( , ); const timer = container.querySelector(".card-time-indicator"); expect(timer).not.toBeNull(); // 1h workflow + 1h timed = 2h expect(timer?.textContent).toContain("2h"); expect(timer?.getAttribute("title")).toContain("Execution time 2h"); expect(timer?.getAttribute("title")).toContain("Completed"); }); it("renders GitHub provenance marker for github_import tasks", () => { const { container } = render( , ); const footerRow = container.querySelector(".card-footer-row"); const provenance = container.querySelector(".card-source-provenance"); expect(footerRow).not.toBeNull(); expect(provenance).not.toBeNull(); expect(provenance?.getAttribute("title")).toContain("https://github.com/owner/repo/issues/42"); expect(screen.getByTestId("provider-icon-github")).toBeDefined(); }); it("does not render GitHub provenance marker for non-imported tasks", () => { const { container } = render( , ); expect(container.querySelector(".card-source-provenance")).toBeNull(); expect(screen.queryByTestId("provider-icon-github")).toBeNull(); }); it("renders a GitHub tracking link for tracked issues on non-imported tasks", () => { const { container } = render( , ); const link = screen.getByRole("link", { name: "Linked GitHub issue #42" }); const bottomRightRow = container.querySelector(".card-bottom-right-row"); const footerRow = container.querySelector(".card-footer-row"); expect(link.getAttribute("href")).toBe("https://github.com/owner/repo/issues/42"); expect(link.getAttribute("title")).toBe("Linked GitHub issue: owner/repo#42"); expect(link).toHaveClass("card-github-tracking-chip", "card-github-tracking-link"); expect(link).toHaveTextContent("#42"); expect(bottomRightRow?.contains(link)).toBe(true); expect(footerRow).toBeNull(); expect(screen.getByTestId("provider-icon-github")).toBeDefined(); }); it("renders the GitHub tracking link below the queued badge in the bottom-right row", () => { const { container } = render( , ); const link = screen.getByRole("link", { name: "Linked GitHub issue #42" }); const bottomRightRow = container.querySelector(".card-bottom-right-row"); const queuedBadge = container.querySelector(".queued-badge"); expect(bottomRightRow).not.toBeNull(); expect(bottomRightRow?.contains(link)).toBe(true); expect(queuedBadge).not.toBeNull(); expect(queuedBadge?.compareDocumentPosition(bottomRightRow as Node) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); it("keeps the GitHub tracking link keyboard focusable", () => { render( , ); const link = screen.getByRole("link", { name: "Linked GitHub issue #42" }); expect(link.tabIndex).not.toBe(-1); link.focus(); expect(document.activeElement).toBe(link); }); it("renders safe external-link attributes for the GitHub tracking link", () => { render( , ); const link = screen.getByRole("link", { name: "Linked GitHub issue #42" }); expect(link.getAttribute("target")).toBe("_blank"); expect(link.getAttribute("rel")).toContain("noopener"); expect(link.getAttribute("rel")).toContain("noreferrer"); }); it("keeps GitHub tracking chip interaction-affordance CSS contract", () => { const css = loadAllAppCssBaseOnly(); expect(css).toMatch(/\.card-github-tracking-chip\s*\{[^}]*display:\s*inline-flex;[^}]*font-family:\s*var\(--font-mono\);[^}]*\}/); expect(css).toContain(".card-github-tracking-chip:hover"); expect(css).toMatch(/\.card-github-tracking-chip:focus-visible\s*\{[^}]*--focus-ring-strong/); render( , ); const link = screen.getByRole("link", { name: "Linked GitHub issue #42" }); const chipStyle = getComputedStyle(link); expect(chipStyle.display).toBe("inline-flex"); expect(chipStyle.whiteSpace).toBe("nowrap"); }); it("FN-4287: keeps GitHub provenance indicators grouped on the right edge", () => { const { container } = render( , ); const footerRow = container.querySelector(".card-footer-row"); const footerProvenance = container.querySelectorAll(".card-footer-row .card-source-provenance"); const trackingLink = container.querySelector(".card-github-tracking-link"); expect(footerRow).not.toBeNull(); expect(footerProvenance).toHaveLength(1); expect(trackingLink).not.toBeNull(); const css = loadAllAppCssBaseOnly(); expect(css).toMatch(/\.card-footer-row\s*>\s*\.card-source-provenance:first-of-type\s*\{[^}]*margin-left:\s*auto;[^}]*\}/); expect(css).toMatch(/\.card-source-provenance\s*\+\s*\.card-source-provenance\s*\{[^}]*margin-left:\s*0;[^}]*\}/); const provenanceRule = css.match(/\.card-source-provenance\s*\{[^}]*\}/)?.[0] ?? ""; expect(provenanceRule).not.toMatch(/margin-left\s*:\s*auto/); }); it("does not render a GitHub tracking link when githubTracking is absent", () => { render( , ); expect(screen.queryByRole("link", { name: /Linked GitHub issue/i })).toBeNull(); }); it("renders both provenance marker and tracking chip for github_import tasks when the tracking issue is distinct from source", () => { render( , ); expect(screen.getByRole("link", { name: "Linked GitHub issue #99" })).toBeDefined(); expect(screen.getByLabelText("Imported from GitHub")).toBeDefined(); }); it("hides tracking chip when github_import tracking issue matches source owner/repo/number", () => { render( , ); expect(screen.queryByRole("link", { name: /Linked GitHub issue/i })).toBeNull(); expect(screen.getByLabelText("Imported from GitHub")).toBeDefined(); }); it("does not render a GitHub tracking link when a matching issue badge is already shown", () => { render( , ); expect(screen.queryByRole("link", { name: /Linked GitHub issue/i })).toBeNull(); }); it("clicking the GitHub tracking link does not open the task detail modal", () => { const onOpenDetail = vi.fn(); render( , ); fireEvent.click(screen.getByRole("link", { name: "Linked GitHub issue #42" })); expect(onOpenDetail).not.toHaveBeenCalled(); }); it("renders agent-created provenance badge for automation tasks and prefers sourceMetadata.agentName", () => { const { container } = render( , ); const badge = container.querySelector(".card-agent-created-badge"); expect(badge).not.toBeNull(); expect(badge?.getAttribute("title")).toBe("Created by agent: Task Robot"); }); it("renders agent-created provenance badge for agent_heartbeat tasks", () => { const { container } = render( , ); const badge = container.querySelector(".card-agent-created-badge"); expect(badge).not.toBeNull(); expect(badge?.getAttribute("title")).toBe("Created by agent: Scheduler Bot"); expect(badge?.getAttribute("aria-label")).toBe("Created by agent: Scheduler Bot"); }); it("renders agent-created provenance badge for legacy sourceAgentId-only tasks", () => { const { container } = render( , ); const badge = container.querySelector(".card-agent-created-badge"); expect(badge).not.toBeNull(); expect(badge?.getAttribute("title")).toBe("Created by agent: legacy-agent-1"); }); it("does not render agent-created provenance badge for non-agent task sources", () => { const { container } = render( , ); expect(container.querySelector(".card-agent-created-badge")).toBeNull(); }); it("coexists with GitHub badge and timer metadata", () => { const { container } = render( , ); expect(container.querySelector(".card-github-badge")).not.toBeNull(); expect(container.querySelector(".card-source-provenance")).not.toBeNull(); expect(container.querySelector(".card-agent-created-badge")).not.toBeNull(); expect(container.querySelector(".card-time-indicator")).not.toBeNull(); }); it("prefers done-task /diff filesChanged over mergeDetails.filesChanged", () => { useTaskDiffStatsMock.mockReturnValue({ stats: { filesChanged: 4, additions: 10, deletions: 2 }, loading: false, }); render( , ); expect(screen.getByRole("button", { name: "4 files changed" })).toBeDefined(); expect(screen.queryByRole("button", { name: "1 file changed" })).toBeNull(); }); it("renders files-changed metadata and timer chip in footer row", () => { const { container } = render( , ); const header = container.querySelector(".card-header"); const footerRow = container.querySelector(".card-footer-row"); const filesChanged = container.querySelector(".card-session-files"); const timer = container.querySelector(".card-time-indicator"); expect(header).not.toBeNull(); expect(footerRow).not.toBeNull(); expect(filesChanged).not.toBeNull(); expect(timer).not.toBeNull(); expect(footerRow?.contains(filesChanged)).toBe(true); expect(footerRow?.contains(timer)).toBe(true); expect(header?.contains(timer)).toBe(false); expect(Array.from(footerRow?.children ?? [])).toEqual([filesChanged, timer]); }); it("shows timer chip for in-review cards", () => { const { container } = render( , ); const timer = container.querySelector(".card-time-indicator"); expect(timer).not.toBeNull(); expect(timer?.textContent).toContain("12m"); expect(timer?.getAttribute("title")).toContain("Execution time 12m"); expect(timer?.getAttribute("title")).not.toContain("Completed"); }); it("keeps the in-review timer live from executionStartedAt when present", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-25T12:30:00.000Z")); const { container } = render( , ); const timer = container.querySelector(".card-time-indicator"); expect(timer).not.toBeNull(); expect(timer?.textContent).toContain("30m"); expect(timer?.getAttribute("title")).toBe("Execution time 30m"); act(() => { vi.advanceTimersByTime(5 * 60_000); }); expect(container.querySelector(".card-time-indicator")?.textContent).toContain("35m"); expect(container.querySelector(".card-time-indicator")?.getAttribute("title")).toBe("Execution time 35m"); }); it.each(["merging", "merging-fix"] as const)("shows live merge elapsed in timer chip while task.status is %s", (status) => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-25T13:45:00.000Z")); try { const { container } = render( , ); const timer = container.querySelector(".card-time-indicator"); expect(timer).not.toBeNull(); expect(timer?.textContent).toContain("45m"); expect(timer?.getAttribute("title")).toBe("Execution time 45m. Merge phase <1m"); } finally { vi.useRealTimers(); } }); it("does not render timer chip for in-review cards without instrumentation data", () => { const { container } = render( , ); expect(container.querySelector(".card-time-indicator")).toBeNull(); }); it.each(["triage", "todo", "archived"] as const)( "does not render timer chip for %s cards", (column) => { const { container } = render( , ); expect(container.querySelector(".card-time-indicator")).toBeNull(); }, ); it("shows wall-clock timer for in-progress cards when columnMovedAt is available", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-25T12:05:00.000Z")); const { container } = render( , ); const timer = container.querySelector(".card-time-indicator"); expect(timer?.textContent).toContain("5m"); expect(timer?.getAttribute("title")).toContain("In progress 5m"); }); it("prefers executionStartedAt over a newer columnMovedAt for in-progress timers", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-25T12:10:00.000Z")); const { container } = render( , ); const timer = container.querySelector(".card-time-indicator"); expect(timer?.textContent).toContain("10m"); expect(timer?.getAttribute("title")).toContain("In progress 10m"); }); it("does not render timer chip on done card without instrumentation, even with old timestamps", () => { const { container } = render( , ); expect(container.querySelector(".card-time-indicator")).toBeNull(); }); describe("formatElapsedDuration rounding for done tasks", () => { it.each([ [59_999, "1m"], [60_000, "1m"], [90_000, "2m"], [3_540_000, "1h"], [3_600_000, "1h"], [86_400_000, "1d"], ])("formats %dms as %s for done tasks", (elapsedMs, expected) => { expect(formatElapsedDurationDone(elapsedMs)).toBe(expected); }); it("keeps in-progress rounding with floor semantics", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-25T12:01:30.000Z")); const { container } = render( , ); expect(container.querySelector(".card-time-indicator")?.textContent).toContain("1m"); }); it("renders done-card timer with ceiling rounding for fractional minutes", () => { const { container } = render( , ); expect(container.querySelector(".card-time-indicator")?.textContent).toContain("5m"); }); }); it("live-ticks workflow runtime for in-progress steps", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-25T12:00:30.000Z")); const { container } = render( , ); const timer = container.querySelector(".card-time-indicator"); expect(timer?.textContent).toContain("<1m"); act(() => { vi.advanceTimersByTime(30_000); }); expect(container.querySelector(".card-time-indicator")?.textContent).toContain("1m"); }); }); describe("TaskCard provider icons on agent row", () => { it("renders provider icons when task has model overrides", () => { render( , ); expect(screen.getByTestId("card-provider-icons")).toBeDefined(); expect(screen.getByTestId("provider-icon-anthropic")).toBeDefined(); }); it("keeps assigned agent badge accessible when label is visually collapsible", async () => { vi.mocked(fetchAgent).mockResolvedValue({ id: "agent-robot", name: "Task Robot", role: "executor", state: "active", metadata: {}, heartbeatHistory: [], completedRuns: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", } as any); const { container } = render( , ); await waitFor(() => { const badge = container.querySelector(".card-agent-badge"); expect(badge).not.toBeNull(); expect(badge?.getAttribute("title")).toBe("Assigned to Task Robot"); expect(badge?.querySelector(".visually-hidden")?.textContent).toContain("Assigned to Task Robot"); }); }); it("deduplicates when executor and validator use same provider", () => { render( , ); const icons = screen.getByTestId("card-provider-icons"); expect(icons.querySelectorAll("[data-testid^='provider-icon-']").length).toBe(2); expect(screen.getByTestId("provider-icon-openai")).toBeDefined(); expect(screen.getByTestId("provider-icon-anthropic")).toBeDefined(); }); it("renders agent row with provider icons even without assignedAgentId", () => { render( , ); expect(screen.getByTestId("card-provider-icons")).toBeDefined(); expect(screen.getByTestId("provider-icon-anthropic")).toBeDefined(); }); it("does not render provider icons when no model overrides set", () => { render( , ); expect(screen.queryByTestId("card-provider-icons")).toBeNull(); }); }); describe("TaskCard memo comparator provenance behavior", () => { it("returns false when disableDrag changes", () => { const task = makeTask(); expect( __test_areTaskCardPropsEqual( { task, onOpenDetail: noop, addToast: noop, disableDrag: false } as any, { task, onOpenDetail: noop, addToast: noop, disableDrag: true } as any, ), ).toBe(false); }); it("returns false when sourceMetadata.agentName changes", () => { const previousTask = makeTask({ sourceType: "automation", sourceMetadata: { agentName: "Agent One" } }); const nextTask = makeTask({ sourceType: "automation", sourceMetadata: { agentName: "Agent Two" } }); const previousProps = { task: previousTask, onOpenDetail: noop, addToast: noop, }; const nextProps = { task: nextTask, onOpenDetail: noop, addToast: noop, }; expect(__test_areTaskCardPropsEqual(previousProps as any, nextProps as any)).toBe(false); }); it("returns false when sourceType changes", () => { const previousTask = makeTask({ sourceType: "automation", sourceMetadata: { agentName: "Agent" } }); const nextTask = makeTask({ sourceType: "dashboard_ui", sourceMetadata: { agentName: "Agent" } }); expect( __test_areTaskCardPropsEqual( { task: previousTask, onOpenDetail: noop, addToast: noop } as any, { task: nextTask, onOpenDetail: noop, addToast: noop } as any, ), ).toBe(false); }); it("returns false when sourceAgentId changes", () => { const previousTask = makeTask({ sourceType: "automation", sourceAgentId: "agent-a" }); const nextTask = makeTask({ sourceType: "automation", sourceAgentId: "agent-b" }); expect( __test_areTaskCardPropsEqual( { task: previousTask, onOpenDetail: noop, addToast: noop } as any, { task: nextTask, onOpenDetail: noop, addToast: noop } as any, ), ).toBe(false); }); it("returns false when branch changes", () => { const previousTask = makeTask({ branch: "feature/old", baseBranch: "main" }); const nextTask = makeTask({ branch: "feature/new", baseBranch: "main" }); expect( __test_areTaskCardPropsEqual( { task: previousTask, onOpenDetail: noop, addToast: noop } as any, { task: nextTask, onOpenDetail: noop, addToast: noop } as any, ), ).toBe(false); }); it("returns false when baseBranch changes", () => { const previousTask = makeTask({ branch: "fusion/fn-001", baseBranch: "main" }); const nextTask = makeTask({ branch: "fusion/fn-001", baseBranch: "release/2026-05" }); expect( __test_areTaskCardPropsEqual( { task: previousTask, onOpenDetail: noop, addToast: noop } as any, { task: nextTask, onOpenDetail: noop, addToast: noop } as any, ), ).toBe(false); }); }); describe("TaskCard mission badge", () => { // Access the internal cache reset helper let clearCache: () => void; beforeAll(async () => { const mod = await import("../TaskCard"); clearCache = (mod as any).__test_clearMissionTitleCache; }); beforeEach(() => { clearCache?.(); vi.mocked(fetchMission).mockReset(); }); it("displays mission title instead of missionId", async () => { vi.mocked(fetchMission).mockResolvedValue({ id: "M-ABC123", title: "Database Optimization", status: "active", interviewState: "completed", milestones: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); const { container } = render( , ); const badge = container.querySelector(".card-mission-badge"); expect(badge).not.toBeNull(); await waitFor(() => { // MAX_MISSION_TITLE_LENGTH is 12, so first 9 chars + "..." expect(badge?.textContent).toContain("Database ..."); }); }); it("abbreviates long mission titles with ellipsis", async () => { vi.mocked(fetchMission).mockResolvedValue({ id: "M-LONG1", title: "This Is A Very Long Mission Title That Exceeds Twenty Characters", status: "active", interviewState: "completed", milestones: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); const { container } = render( , ); const badge = container.querySelector(".card-mission-badge"); expect(badge).not.toBeNull(); await waitFor(() => { // MAX_MISSION_TITLE_LENGTH is 12, so first 9 chars + "..." expect(badge?.textContent).toContain("This Is A..."); }); }); it("falls back to missionId on fetch error", async () => { vi.mocked(fetchMission).mockRejectedValue(new Error("Network error")); const { container } = render( , ); const badge = container.querySelector(".card-mission-badge"); expect(badge).not.toBeNull(); await waitFor(() => { expect(badge?.textContent).toContain("M-ERR99"); }); }); it("shows mission title in title attribute", async () => { vi.mocked(fetchMission).mockResolvedValue({ id: "M-TITLE", title: "Refactor Auth", status: "active", interviewState: "completed", milestones: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); const { container } = render( , ); const badge = container.querySelector(".card-mission-badge"); expect(badge).not.toBeNull(); await waitFor(() => { expect(badge?.getAttribute("title")).toBe("Mission: Refactor Auth"); }); }); it("shows short mission title without abbreviation", async () => { vi.mocked(fetchMission).mockResolvedValue({ id: "M-SHORT", title: "Auth Fix", status: "active", interviewState: "completed", milestones: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); const { container } = render( , ); const badge = container.querySelector(".card-mission-badge"); expect(badge).not.toBeNull(); await waitFor(() => { // "Auth Fix" is 8 chars, well under 20 — no abbreviation needed expect(badge?.textContent).toContain("Auth Fix"); expect(badge?.textContent).not.toContain("..."); }); }); }); describe("TaskCard agent badge", () => { let clearAgentCache: () => void; beforeAll(async () => { const mod = await import("../TaskCard"); clearAgentCache = (mod as { __test_clearAgentNameCache?: () => void }).__test_clearAgentNameCache ?? (() => undefined); }); beforeEach(() => { clearAgentCache?.(); vi.mocked(fetchAgent).mockReset(); }); it("renders agent badge when task has assignedAgentId", async () => { vi.mocked(fetchAgent).mockResolvedValue({ id: "agent-001", name: "Task Robot", 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(() => { const badge = screen.getByTitle("Assigned to Task Robot"); expect(badge).toBeDefined(); expect(badge.querySelector(".visually-hidden")?.textContent).toContain("Assigned to Task Robot"); }); }); it("does not render agent badge when assignedAgentId is undefined", () => { render( , ); expect(screen.queryByTitle(/Assigned to/)).toBeNull(); }); });