import React from "react"; import { afterEach, describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent, waitFor, act, within } from "@testing-library/react"; import { TaskCard, formatElapsedDurationDone, __test_areTaskCardPropsEqual } from "../TaskCard"; import { CostBadgeProvider } from "../../context/CostBadgeContext"; // Pre-existing gap (unrelated to FN-7676): TaskCard unconditionally renders // RuntimeFallbackBadge, which calls the shared useToast() hook directly (not // via the addToast prop). This file renders outside a // ToastProvider, so mock the hook the same way sibling suites // (PlanningModeModal.*.test.tsx) already do to avoid a widespread // "useToast must be used within ToastProvider" failure across this file. vi.mock("../../hooks/useToast", () => ({ useToast: () => ({ addToast: vi.fn(), removeToast: vi.fn(), toasts: [], }), })); import { NavigationHistoryProvider, useNavigationHistory } from "../../hooks/useNavigationHistory"; import { useOverlayDismiss } from "../../hooks/useOverlayDismiss"; import type { ConfirmOptions } from "../../hooks/useConfirm"; import { TASK_PRIORITIES, type Task, type TaskPriority } from "@fusion/core"; import { getPriorityColorVar, getPriorityLabel } from "../../utils/priorityIndicator"; // Mock lucide-react to avoid SVG rendering issues in test env vi.mock("lucide-react", () => ({ Link: () => null, GitBranch: () => null, Gitlab: () => null, Clock: () => null, Pencil: () => null, Layers: () => null, ChevronDown: () => null, Folder: () => null, GitPullRequest: () => null, CircleDot: () => null, Target: () => null, Bot: () => null, Trash2: () => null, RotateCw: () => null, Zap: () => , AlertTriangle: () => null, ArrowDown: ({ style, ...props }: React.SVGProps) => , Flag: ({ style, ...props }: React.SVGProps) => , ArrowUp: ({ style, ...props }: React.SVGProps) => , TriangleAlert: ({ style, ...props }: React.SVGProps) => , ArrowUpRight: () => null, // FN-7592: the overseer badge now renders an icon child instead of a text label, // so tests must see a real SVG (like Zap) rather than a no-op render. Eye: () => , // FNXC:TaskCardMenu 2026-07-10-12:00: visible ⋯ card-actions button icon. MoreHorizontal: () => , })); vi.mock("../ProviderIcon", () => ({ ProviderIcon: ({ provider }: { provider: string }) => , })); vi.mock("../PrCreateModal", () => ({ PrCreateModal: ({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: (prInfo: any) => void }) => (
), })); 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", () => ({ addressPrFeedback: vi.fn(), fetchTaskDetail: vi.fn(), uploadAttachment: vi.fn(), fetchMission: vi.fn(), fetchAgent: vi.fn(), fetchAgents: vi.fn(), rebuildTaskSpec: vi.fn(), refreshPrStatus: vi.fn(), // FNXC:PlannerOversight 2026-07-04-13:00: tests that pass a `workflowBadge` // prop trigger the FN-7516 workflow-effective-oversight fetch effect; mock // it so those tests don't hit an unmocked API export. Resolves an empty // effective map (no workflow-level override) by default. fetchWorkflowSettingValues: vi.fn().mockResolvedValue({ stored: {}, effective: {}, orphaned: [] }), })); const mockConfirm = vi.fn<(options: ConfirmOptions) => Promise>(); const mockConfirmWithChoice = vi.fn<(options: ConfirmOptions) => Promise<"primary" | "tertiary" | "cancel">>(); vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: mockConfirm, confirmWithChoice: mockConfirmWithChoice }), })); import { addressPrFeedback, uploadAttachment, fetchMission, fetchAgent, fetchAgents, refreshPrStatus } from "../../api"; import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture"; import { writeCache, SWR_CACHE_KEYS } from "../../utils/swrCache"; 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 seedAgentsCache(projectId: string, agents: Array<{ id: string; name: string; role?: string; state?: string }>) { writeCache( `${SWR_CACHE_KEYS.CHAT_AGENTS_MAP_PREFIX}${projectId}`, agents.map((agent) => ({ role: "executor", state: "active", ...agent, })), { maxBytes: 500_000 }, ); } 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"); }; } function expectTimerInFooterRight(container: HTMLElement) { const timer = container.querySelector(".card-time-indicator"); const footerRow = container.querySelector(".card-footer-row"); const rightCluster = container.querySelector(".card-footer-row-right"); expect(timer).not.toBeNull(); expect(footerRow?.contains(timer)).toBe(true); expect(timer?.closest(".card-footer-row-right")).toBe(rightCluster); expect(timer?.closest(".card-meta-badges")).toBeNull(); } function mockBoardContextMenuGeometry() { const originalInnerWidth = window.innerWidth; const originalInnerHeight = window.innerHeight; Object.defineProperty(window, "innerWidth", { configurable: true, value: 800 }); Object.defineProperty(window, "innerHeight", { configurable: true, value: 600 }); const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function getMockRect(this: HTMLElement) { if (this.classList.contains("card")) { return { x: 520, y: 340, left: 520, top: 340, right: 760, bottom: 520, width: 240, height: 180, toJSON: () => ({}) } as DOMRect; } if (this.classList.contains("task-card-context-menu-popover")) { const left = Number.parseFloat(this.style.left || "0"); const top = Number.parseFloat(this.style.top || "0"); return { x: left, y: top, left, top, right: left + 180, bottom: top + 220, width: 180, height: 220, toJSON: () => ({}) } as DOMRect; } return { x: 0, y: 0, left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON: () => ({}) } as DOMRect; }); return () => { rectSpy.mockRestore(); Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth }); Object.defineProperty(window, "innerHeight", { configurable: true, value: originalInnerHeight }); }; } function expectBoardContextMenuPortaled() { const menu = screen.getByRole("menu"); const popover = menu.closest(".task-card-context-menu-popover") as HTMLElement | null; expect(popover).not.toBeNull(); expect(popover?.parentElement).toBe(document.body); expect(popover?.closest(".card")).toBeNull(); expect(popover?.closest(".column")).toBeNull(); expect(popover?.closest(".column-body")).toBeNull(); expect(popover?.style.left).not.toBe(""); expect(popover?.style.top).not.toBe(""); return popover!; } const highFanout = { totalCount: 7, activeTodoCount: 3, dependentIds: ["FN-002", "FN-003"], dependencyDependentIds: [], overlapBlockedDependentIds: ["FN-002", "FN-003"], overlapBlockedActiveCount: 3, overlapBlockedTodoCount: 3, staleBlockedByDependentIds: [], isHighFanout: true, } as const; afterEach(() => { vi.useRealTimers(); useTaskDiffStatsMock.mockReturnValue({ stats: null, loading: false }); badgeUpdatesMock.clear(); subscribeToBadgeMock.mockReset(); unsubscribeFromBadgeMock.mockReset(); mockConfirm.mockReset(); mockConfirmWithChoice.mockReset(); vi.mocked(addressPrFeedback).mockReset(); vi.mocked(refreshPrStatus).mockReset(); }); describe("TaskCard", () => { it("renders GitLab tracking badges for linked and stale items without dropping GitHub badges", () => { const gitlabItem = { kind: "merge_request" as const, url: "https://gitlab.com/acme/app/-/merge_requests/5", instanceUrl: "https://gitlab.com", host: "gitlab.com", iid: 5, projectPath: "acme/app", title: "MR title", state: "opened", createdAt: "2026-07-02T00:00:00.000Z", }; render( , ); expect(screen.getByTestId("card-gitlab-badge")).toHaveAccessibleName("GitLab MR !5: MR title"); expect(screen.getByRole("link", { name: /GitLab MR !5/ })).toHaveAttribute("href", gitlabItem.url); expect(screen.getByRole("link", { name: "#1" })).toHaveAttribute("href", "https://github.com/runfusion/fusion/issues/1"); }); it("updates memoized card equality when GitLab tracking changes", () => { const base = { task: makeTask({ gitlabTracking: undefined }) }; const withGitLab = { task: makeTask({ gitlabTracking: { item: { kind: "project_issue", url: "https://gitlab.com/acme/app/-/issues/42", instanceUrl: "https://gitlab.com", host: "gitlab.com", iid: 42, createdAt: "2026-07-02T00:00:00.000Z", }, }, }), }; expect(__test_areTaskCardPropsEqual(base as any, withGitLab as any)).toBe(false); }); it("repaints the memoized card when plannerOverseerState changes, and renders nothing when absent", () => { const idleTask = makeTask({ plannerOverseerState: undefined }); const watchingTask = makeTask({ plannerOverseerState: { state: "watching", oversightLevel: "autonomous", watchedStage: "executor", signal: "progressing", attemptCount: 0, attemptLimit: 3, pendingConfirmation: false, observedAt: 1700000000000, }, }); expect( __test_areTaskCardPropsEqual({ task: idleTask } as any, { task: watchingTask } as any), ).toBe(false); expect( __test_areTaskCardPropsEqual({ task: watchingTask } as any, { task: watchingTask } as any), ).toBe(true); const { rerender } = render(); expect(screen.queryByTestId("planner-overseer-state-badge")).not.toBeInTheDocument(); rerender(); expect(screen.getByTestId("planner-overseer-state-badge")).toBeInTheDocument(); }); // FN-7563: the badge used to print the raw kebab-case state (e.g. // "awaiting-confirmation") with a bare "Planner overseer: awaiting-confirmation" // tooltip. This reproduces the reported in-review symptom and asserts the badge // is now human-readable and self-explanatory. it("explains an in-review awaiting-confirmation badge with a readable label and a reason-bearing tooltip", () => { const task = makeTask({ column: "in-review", plannerOverseerState: { state: "awaiting-confirmation", oversightLevel: "autonomous", watchedStage: "executor", signal: "stalled", attemptCount: 2, attemptLimit: 3, pendingConfirmation: true, observedAt: 1700000000000, reason: "Retry limit reached; waiting for an operator decision", }, }); render(); // FN-7592: the badge is now an icon-only glyph. The readable label moved from // textContent to aria-label; the composed tooltip is unchanged on title. const badge = screen.getByTestId("planner-overseer-state-badge"); expect(badge.querySelector("svg")).toBeInTheDocument(); expect(badge.getAttribute("aria-label")).not.toBe("awaiting-confirmation"); expect(badge.getAttribute("aria-label")).toBe("Awaiting confirmation"); expect(badge.getAttribute("data-planner-overseer-state")).toBe("awaiting-confirmation"); const title = badge.getAttribute("title") ?? ""; expect(title).not.toBe("Planner overseer: awaiting-confirmation"); expect(title).toContain("Retry limit reached; waiting for an operator decision"); expect(title).toMatch(/human decision/i); expect(title).not.toMatch(/undefined/); }); it("renders readable labels for in-progress watching and recovering overseer states", () => { const watchingTask = makeTask({ column: "in-progress", plannerOverseerState: { state: "watching", oversightLevel: "autonomous", watchedStage: "executor", signal: "progressing", attemptCount: 0, attemptLimit: 3, pendingConfirmation: false, observedAt: 1700000000000, }, }); const { unmount } = render(); // FN-7592: icon-only badge — assert the accessible name via aria-label and the // per-state color hook via data-planner-overseer-state, not raw text content. let badge = screen.getByTestId("planner-overseer-state-badge"); expect(badge.querySelector("svg")).toBeInTheDocument(); expect(badge.getAttribute("aria-label")).toBe("Overseer watching"); expect(badge.getAttribute("data-planner-overseer-state")).toBe("watching"); expect(badge.getAttribute("title")).not.toMatch(/undefined/); unmount(); const recoveringTask = makeTask({ column: "in-progress", plannerOverseerState: { state: "recovering", oversightLevel: "autonomous", attemptCount: 1, attemptLimit: 3, pendingConfirmation: false, observedAt: 1700000000000, }, }); render(); badge = screen.getByTestId("planner-overseer-state-badge"); expect(badge.querySelector("svg")).toBeInTheDocument(); expect(badge.getAttribute("aria-label")).toBe("Overseer recovering"); // Distinct states expose distinct data-planner-overseer-state values, which is the // hook TaskCard.css keys per-state color off of (jsdom cannot compute color-mix()). expect(badge.getAttribute("data-planner-overseer-state")).toBe("recovering"); expect(badge.getAttribute("data-planner-overseer-state")).not.toBe("watching"); const title = badge.getAttribute("title") ?? ""; expect(title).not.toMatch(/undefined/); expect(title.length).toBeGreaterThan(0); }); it("shows an Answer-questions button when awaiting user input and opens the workflow tab", async () => { const onOpenDetailWithTab = vi.fn(); render( , ); const btn = screen.getByLabelText("Answer questions"); fireEvent.click(btn); expect(onOpenDetailWithTab).toHaveBeenCalledTimes(1); expect(onOpenDetailWithTab.mock.calls[0][1]).toBe("workflow"); }); it("opens the board card context menu as a viewport portal on right-click without opening detail", async () => { const cleanupGeometry = mockBoardContextMenuGeometry(); const onOpenDetail = vi.fn(); const onPauseTask = vi.fn(async () => makeTask({ paused: true })); try { render(
, ); fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 790, clientY: 590 }); const popover = await waitFor(() => expectBoardContextMenuPortaled()); expect(popover.style.left).toBe("612px"); expect(popover.style.top).toBe("372px"); expect(onOpenDetail).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("menuitem", { name: "Pause" })); await waitFor(() => expect(onPauseTask).toHaveBeenCalledWith("FN-001")); expect(onPauseTask).toHaveBeenCalledTimes(1); expect(screen.queryByRole("menu")).not.toBeInTheDocument(); expect(document.querySelector(".task-card-context-menu-popover")).toBeNull(); expect(onOpenDetail).not.toHaveBeenCalled(); } finally { cleanupGeometry(); } }); /* FNXC:TaskCardMenu 2026-07-10-12:00: The card actions menu must ALSO be reachable from the visible ⋯ button (first-run users never discovered right-click). The button must open the exact same TaskContextMenu (same items — no duplicated menu logic), anchored as a viewport portal, and toggle closed on a second press. */ it("opens the same card context menu from the visible ⋯ button and toggles it closed", async () => { const cleanupGeometry = mockBoardContextMenuGeometry(); const onOpenDetail = vi.fn(); const onPauseTask = vi.fn(async () => makeTask({ paused: true })); try { render( , ); // Capture the canonical right-click menu item set first. fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); await waitFor(() => expectBoardContextMenuPortaled()); const rightClickItems = screen.getAllByRole("menuitem").map((item) => item.textContent); expect(rightClickItems.length).toBeGreaterThan(0); fireEvent.keyDown(document, { key: "Escape" }); await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument()); // The ⋯ button opens the SAME menu (identical items) as right-click, portaled to the viewport. const menuButton = screen.getByTestId("card-menu-btn-FN-001"); expect(menuButton).toHaveAttribute("aria-haspopup", "menu"); expect(menuButton).toHaveAttribute("aria-expanded", "false"); fireEvent.click(menuButton); await waitFor(() => expectBoardContextMenuPortaled()); expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual(rightClickItems); expect(menuButton).toHaveAttribute("aria-expanded", "true"); expect(onOpenDetail).not.toHaveBeenCalled(); // A second press toggles the menu closed — the document pointerdown closer must not race it // shut and immediately reopen it. fireEvent.pointerDown(menuButton); fireEvent.click(menuButton); await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument()); expect(document.querySelector(".task-card-context-menu-popover")).toBeNull(); expect(menuButton).toHaveAttribute("aria-expanded", "false"); // Selecting an action from the button-opened menu invokes the shared handler and closes. fireEvent.click(menuButton); await waitFor(() => expectBoardContextMenuPortaled()); fireEvent.click(screen.getByRole("menuitem", { name: "Pause" })); await waitFor(() => expect(onPauseTask).toHaveBeenCalledWith("FN-001")); expect(screen.queryByRole("menu")).not.toBeInTheDocument(); expect(onOpenDetail).not.toHaveBeenCalled(); } finally { cleanupGeometry(); } }); it("opens Planning Mode from eligible pre-execution card menus only when wired", async () => { const cleanupGeometry = mockBoardContextMenuGeometry(); const onPlanningMode = vi.fn(); try { const { rerender } = render( , ); fireEvent.click(screen.getByTestId("card-menu-btn-FN-001")); await waitFor(() => expectBoardContextMenuPortaled()); fireEvent.click(screen.getByRole("menuitem", { name: "Plan" })); expect(onPlanningMode).toHaveBeenCalledWith("Plan from description", "WF-intake"); expect(screen.queryByRole("menu")).not.toBeInTheDocument(); rerender( , ); fireEvent.click(screen.getByTestId("card-menu-btn-FN-001")); await waitFor(() => expectBoardContextMenuPortaled()); fireEvent.click(screen.getByRole("menuitem", { name: "Plan" })); expect(onPlanningMode).toHaveBeenLastCalledWith("Custom intake title", "WF-custom"); rerender( , ); fireEvent.click(screen.getByTestId("card-menu-btn-FN-001")); await waitFor(() => expectBoardContextMenuPortaled()); expect(screen.queryByRole("menuitem", { name: "Plan" })).not.toBeInTheDocument(); fireEvent.keyDown(document, { key: "Escape" }); await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument()); rerender( , ); fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); expect(screen.queryByRole("menuitem", { name: "Plan" })).not.toBeInTheDocument(); } finally { cleanupGeometry(); } }); it("opens Planning Mode from the mobile/touch long-press menu for custom hold cards", async () => { vi.useFakeTimers(); const cleanupGeometry = mockBoardContextMenuGeometry(); const onPlanningMode = vi.fn(); try { render( , ); const card = document.querySelector(".card") as HTMLElement; fireEvent.pointerDown(card, { pointerType: "touch", pointerId: 1, clientX: 32, clientY: 36 }); act(() => vi.advanceTimersByTime(550)); expectBoardContextMenuPortaled(); fireEvent.pointerUp(screen.getByRole("menuitem", { name: "Plan" }), { pointerType: "touch", pointerId: 2 }); await act(async () => { await Promise.resolve(); }); expect(onPlanningMode).toHaveBeenCalledWith("Touch plan seed", "WF-hold"); expect(screen.queryByRole("menu")).not.toBeInTheDocument(); } finally { cleanupGeometry(); } }); it("enables GitHub tracking from the board card context menu and hides the action after refresh", async () => { const cleanupGeometry = mockBoardContextMenuGeometry(); const onOpenDetail = vi.fn(); const addToast = vi.fn(); const onUpdateTask = vi.fn(async () => makeTask({ githubTracking: { enabled: true } as any })); const { rerender } = render( , ); try { fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); fireEvent.click(screen.getByRole("menuitem", { name: "Enable GitHub tracking" })); await waitFor(() => expect(onUpdateTask).toHaveBeenCalledWith("FN-001", { githubTracking: { enabled: true } })); expect(addToast).toHaveBeenCalledWith("Requested GitHub tracking issue creation", "info"); expect(screen.queryByRole("menu")).not.toBeInTheDocument(); expect(onOpenDetail).not.toHaveBeenCalled(); rerender( , ); await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument()); fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); expect(screen.queryByRole("menuitem", { name: "Enable GitHub tracking" })).not.toBeInTheDocument(); } finally { cleanupGeometry(); } }); it("opens the board card context menu from keyboard as a viewport portal, selects an action, and closes", async () => { const cleanupGeometry = mockBoardContextMenuGeometry(); const onOpenDetail = vi.fn(); const onArchiveTask = vi.fn(async () => makeTask({ column: "archived" })); try { render(
, ); const card = document.querySelector(".card") as HTMLElement; card.focus(); fireEvent.keyDown(card, { key: "F10", shiftKey: true }); expectBoardContextMenuPortaled(); fireEvent.click(screen.getByRole("menuitem", { name: "Archive" })); await waitFor(() => expect(onArchiveTask).toHaveBeenCalledWith("FN-001")); expect(onArchiveTask).toHaveBeenCalledTimes(1); expect(screen.queryByRole("menu")).not.toBeInTheDocument(); expect(onOpenDetail).not.toHaveBeenCalled(); } finally { cleanupGeometry(); } }); it("shows refine for a done card context menu and routes to the refinement opener", () => { const onOpenDetail = vi.fn(); const onOpenRefine = vi.fn(); render( , ); fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); expect(onOpenRefine).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-001" })); expect(onOpenDetail).not.toHaveBeenCalled(); expect(screen.queryByRole("menu")).not.toBeInTheDocument(); }); it("shows refine for custom complete cards on touch long-press", () => { vi.useFakeTimers(); const onOpenRefine = vi.fn(); render( , ); const card = document.querySelector(".card") as HTMLElement; fireEvent.pointerDown(card, { pointerType: "touch", pointerId: 1, clientX: 16, clientY: 16 }); act(() => vi.advanceTimersByTime(550)); fireEvent.click(screen.getByRole("menuitem", { name: "Refine" })); expect(onOpenRefine).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-001" })); }); it("confirms preserving progress before moving from the board context menu", async () => { const onMoveTask = vi.fn(async () => makeTask({ column: "todo" })); mockConfirm.mockResolvedValueOnce(true); render( , ); fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); fireEvent.click(screen.getByRole("menuitem", { name: "Move to Todo" })); await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true })); expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Preserve Progress?" })); }); it("omits refine without a real modal callback and offers PR status actions from the board context menu", async () => { const onOpenDetail = vi.fn(); vi.mocked(refreshPrStatus).mockResolvedValueOnce({} as any); render( , ); fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); expect(screen.queryByRole("menuitem", { name: "Refine" })).not.toBeInTheDocument(); expect(onOpenDetail).not.toHaveBeenCalled(); fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); fireEvent.click(screen.getByRole("menuitem", { name: "Check PR Status" })); await waitFor(() => expect(refreshPrStatus).toHaveBeenCalledWith("FN-001", "project-1")); }); it("matches detail PR review labels before and during PR automation", () => { const onMergeTask = vi.fn(async () => ({ merged: false })); const { rerender } = render( , ); fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); expect(screen.getByRole("menuitem", { name: "Start PR Review" })).toBeInTheDocument(); expect(screen.queryByRole("menuitem", { name: "Merge & Close" })).not.toBeInTheDocument(); rerender( , ); fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); expect(screen.getByRole("menuitem", { name: "Creating PR…" })).toBeDisabled(); expect(screen.queryByRole("menuitem", { name: "Merge & Close" })).not.toBeInTheDocument(); }); it("opens the board card context menu on touch long-press as a viewport portal, selects the tapped action, and suppresses detail click", async () => { vi.useFakeTimers(); const cleanupGeometry = mockBoardContextMenuGeometry(); const onOpenDetail = vi.fn(); const onUnpauseTask = vi.fn(async () => makeTask()); try { render(
, ); const card = document.querySelector(".card") as HTMLElement; fireEvent.pointerDown(card, { pointerType: "touch", pointerId: 1, clientX: 790, clientY: 590 }); act(() => vi.advanceTimersByTime(550)); expectBoardContextMenuPortaled(); fireEvent.pointerUp(card, { pointerType: "touch", pointerId: 1, clientX: 790, clientY: 590 }); fireEvent.click(card); expect(onOpenDetail).not.toHaveBeenCalled(); fireEvent.pointerUp(screen.getByRole("menuitem", { name: "Unpause" }), { pointerType: "touch", pointerId: 2 }); await act(async () => { await Promise.resolve(); }); expect(onUnpauseTask).toHaveBeenCalledWith("FN-001"); expect(onUnpauseTask).toHaveBeenCalledTimes(1); expect(screen.queryByRole("menu")).not.toBeInTheDocument(); } finally { cleanupGeometry(); } }); it("suppresses native text selection when touch long-press opens the board card context menu", async () => { vi.useFakeTimers(); const cleanupGeometry = mockBoardContextMenuGeometry(); const onOpenDetail = vi.fn(); const onPauseTask = vi.fn(async () => makeTask({ paused: true })); try { render(
, ); const title = screen.getByText("Long selectable title"); const pointerDownWasNotCanceled = fireEvent.pointerDown(title, { pointerType: "touch", pointerId: 9, clientX: 180, clientY: 160, cancelable: true, }); expect(pointerDownWasNotCanceled).toBe(false); act(() => vi.advanceTimersByTime(550)); expectBoardContextMenuPortaled(); expect(onOpenDetail).not.toHaveBeenCalled(); fireEvent.pointerUp(title, { pointerType: "touch", pointerId: 9, clientX: 180, clientY: 160 }); } finally { cleanupGeometry(); } }); it("cancels board card long-press when touch moves before the delay", () => { vi.useFakeTimers(); render( , ); const card = document.querySelector(".card") as HTMLElement; fireEvent.pointerDown(card, { pointerType: "touch", pointerId: 1, clientX: 16, clientY: 16 }); fireEvent.pointerMove(card, { pointerType: "touch", pointerId: 1, clientX: 40, clientY: 16 }); act(() => vi.advanceTimersByTime(550)); expect(screen.queryByRole("menu")).toBeNull(); }); it("dispatches portaled board menu actions for the interacted duplicate card", async () => { mockConfirm.mockResolvedValueOnce(true); const onDuplicateTask = vi.fn(async () => makeTask({ id: "FN-002-copy" })); render(
, ); const secondCard = document.querySelectorAll(".card")[1] as HTMLElement; fireEvent.contextMenu(secondCard, { clientX: 64, clientY: 72 }); expectBoardContextMenuPortaled(); fireEvent.click(screen.getByRole("menuitem", { name: "Duplicate" })); await waitFor(() => expect(onDuplicateTask).toHaveBeenCalledWith("FN-002")); expect(onDuplicateTask).toHaveBeenCalledTimes(1); expect(screen.queryByRole("menu")).not.toBeInTheDocument(); }); it("cleans up the portaled board menu when the task column changes", () => { const { rerender } = render( , ); fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); expectBoardContextMenuPortaled(); rerender( , ); expect(screen.queryByRole("menu")).not.toBeInTheDocument(); expect(document.querySelector(".task-card-context-menu-popover")).toBeNull(); }); it("does not show the Answer-questions button when not awaiting input", () => { render( , ); expect(screen.queryByLabelText("Answer questions")).toBeNull(); }); 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("uses githubIssueAction for source-imported task delete close", 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 source-imported 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 source-imported 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("deletes without githubIssueAction when no linked github issue exists", 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("runs the delete flow from the desktop task context menu", async () => { const onDeleteTask = vi.fn(async () => makeTask()); mockConfirm.mockResolvedValueOnce(true); render( , ); fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); await waitFor(() => { expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Delete Task" })); expect(onDeleteTask).toHaveBeenCalledWith("FN-001"); }); }); it("runs the delete flow from the mobile pointer-up task context menu", async () => { const onDeleteTask = vi.fn(async () => makeTask()); mockConfirm.mockResolvedValueOnce(true); render( , ); fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); fireEvent.pointerUp(screen.getByRole("menuitem", { name: "Delete" }), { pointerType: "touch", pointerId: 7 }); await waitFor(() => { expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Delete Task" })); expect(onDeleteTask).toHaveBeenCalledWith("FN-001"); }); }); 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, removeLineageReferences: true, githubIssueAction: "delete", }); }); }); it("retries delete after lineage-conflict confirmation", async () => { const conflict = new Error("Cannot delete task FN-001: still referenced as a lineage parent by FN-010.") as Error & { status: number; details: { code: string; lineageChildIds: string[] }; }; conflict.status = 409; conflict.details = { code: "TASK_HAS_LINEAGE_CHILDREN", lineageChildIds: ["FN-010", "FN-011"] }; 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, removeLineageReferences: true, githubIssueAction: "delete", }); }); }); it("hides delete button for done tasks while keeping archive action in the actions dropdown", () => { const onDeleteTask = vi.fn(async () => makeTask()); const onArchiveTask = vi.fn(async () => makeTask({ column: "archived" })); render( , ); expect(screen.queryByLabelText("Delete task")).toBeNull(); expect(screen.getByRole("button", { name: "Actions" })).toBeDefined(); expect(screen.queryByLabelText("Archive task")).toBeNull(); }); it.each(["triage", "todo", "in-progress", "in-review"] as const)( "hides archive action for %s tasks", (column) => { render( makeTask({ column: "archived" }))} />, ); expect(screen.queryByLabelText("Archive task")).toBeNull(); expect(screen.queryByRole("button", { name: "Actions" })).toBeNull(); }, ); it("renders archive action for done tasks inside the actions dropdown", async () => { const onArchiveTask = vi.fn(async () => makeTask({ column: "archived" })); render( , ); const actionsButton = screen.getByRole("button", { name: "Actions" }); expect(screen.queryByLabelText("Archive task")).toBeNull(); fireEvent.click(actionsButton); const menu = screen.getByRole("menu"); expect(within(menu).getByRole("menuitem", { name: "Archive" })).toBeDefined(); fireEvent.click(within(menu).getByRole("menuitem", { name: "Archive" })); await waitFor(() => expect(onArchiveTask).toHaveBeenCalledWith("FN-001")); }); it("does not render an empty done actions dropdown when no done action is available", () => { const { container } = render( , ); expect(screen.queryByRole("button", { name: "Actions" })).toBeNull(); expect(container.querySelector(".card-done-actions")).toBeNull(); }); it("does not render archive action for archived tasks", () => { render( makeTask({ column: "archived" }))} onUnarchiveTask={vi.fn(async () => makeTask({ column: "done" }))} />, ); expect(screen.queryByLabelText("Archive task")).toBeNull(); expect(screen.queryByRole("button", { name: "Actions" })).toBeNull(); expect(screen.getByLabelText("Unarchive task")).toBeDefined(); }); /* FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): Coverage for the Revert affordance: presence/absence on done + archived cards (done-actions dropdown, archived inline row, and context menu), the disabled/omitted no-commit-to-revert guard, the auto→clean-success path, and the auto→conflict→confirm→AI-undo fallback path. */ describe("Revert affordance", () => { it("renders Revert inside the done actions dropdown for a done card with a landed commit", () => { const { container } = render( makeTask({ column: "archived" }))} onRevertTask={vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)} />, ); expect(container.querySelector(".card-revert-btn")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Actions" })); const menu = screen.getByRole("menu"); expect(within(menu).getByRole("menuitem", { name: "Archive" })).toBeDefined(); expect(within(menu).getByRole("menuitem", { name: "Revert" })).toBeDefined(); }); it("renders the inline Revert button for an archived card with a landed commit", () => { render( ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)} />, ); expect(screen.getByLabelText("Revert this task's changes")).toBeDefined(); }); it("omits the Revert button when onRevertTask is not provided", () => { render( , ); expect(screen.queryByLabelText("Revert this task's changes")).toBeNull(); }); it("omits Revert from the done actions dropdown when the task has no landed commit", () => { const { container } = render( makeTask({ column: "archived" }))} onRevertTask={vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)} />, ); expect(container.querySelector(".card-revert-btn")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Actions" })); const menu = screen.getByRole("menu"); expect(within(menu).getByRole("menuitem", { name: "Archive" })).toBeDefined(); expect(within(menu).queryByRole("menuitem", { name: "Revert" })).toBeNull(); }); it("shows a disabled Revert context-menu entry when the task has no landed commit", () => { render( ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)} />, ); fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); const menuItem = screen.getByRole("menuitem", { name: "Revert" }); expect(menuItem).toBeDisabled(); }); it("shows the Revert context-menu entry for done and archived cards", () => { render( ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)} />, ); fireEvent.contextMenu(document.querySelector(".card")!, { clientX: 24, clientY: 28 }); expect(screen.getByRole("menuitem", { name: "Revert" })).toBeDefined(); }); it("calls onRevertTask in auto mode and toasts the revert commit sha on a clean result", async () => { const addToast = vi.fn(); const onRevertTask = vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef1234" }) as any); render( , ); fireEvent.click(screen.getByRole("button", { name: "Actions" })); await act(async () => { fireEvent.click(screen.getByRole("menuitem", { name: "Revert" })); }); await waitFor(() => { expect(onRevertTask).toHaveBeenCalledWith("FN-001", { mode: "auto" }); }); await waitFor(() => { expect(addToast).toHaveBeenCalledWith( expect.stringContaining("deadbeef1234"), "success", ); }); }); it("opens a confirm dialog on conflict and falls back to mode: ai, surfacing the created task id", async () => { const addToast = vi.fn(); const onRevertTask = vi.fn() .mockResolvedValueOnce({ mode: "git", clean: false, conflicts: [{}] } as any) .mockResolvedValueOnce({ mode: "ai", createdTaskId: "FN-999" } as any); mockConfirm.mockResolvedValueOnce(true); render( , ); fireEvent.click(screen.getByRole("button", { name: "Actions" })); await act(async () => { fireEvent.click(screen.getByRole("menuitem", { name: "Revert" })); }); await waitFor(() => { expect(mockConfirm).toHaveBeenCalled(); }); await waitFor(() => { expect(onRevertTask).toHaveBeenNthCalledWith(2, "FN-001", { mode: "ai" }); }); await waitFor(() => { expect(addToast).toHaveBeenCalledWith( expect.stringContaining("FN-999"), "success", ); }); }); }); it("keeps two-button delete flow for non-done task", async () => { const onDeleteTask = vi.fn(async () => makeTask()); mockConfirm.mockResolvedValueOnce(false); render(); await act(async () => { fireEvent.click(screen.getByLabelText("Delete task")); }); expect(mockConfirmWithChoice).not.toHaveBeenCalled(); }); 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("renders a per-branch progress badge when the task is in a parallel window (U9/U13)", () => { const task = { ...makeTask(), branchProgress: [ { branchId: "b1", nodeId: "n1", status: "completed" }, { branchId: "b2", nodeId: "n2", status: "running" }, ], } as Task; render(); const badge = screen.getByTestId("branch-progress-badge"); expect(badge.textContent).toContain("1/2"); }); it("does not render a branch-progress badge when there is no parallel window", () => { render(); expect(screen.queryByTestId("branch-progress-badge")).toBeNull(); }); 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"); }); // FN-6389 follow-up: native HTML5 drag is desktop-mouse only and doesn't move // cards via touch, but a `draggable` element still arms the browser's touch-drag // heuristic, which intermittently hijacks horizontal swipes meant to scroll the // mobile board. On touch-primary (coarse pointer) devices we drop `draggable`. it("disables native card dragging on touch-primary (coarse pointer) devices", () => { const original = window.matchMedia; window.matchMedia = vi.fn().mockImplementation((query: string) => ({ matches: query === "(hover: none) and (pointer: coarse)", media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })) as unknown as typeof window.matchMedia; try { const { container } = render(); const card = container.querySelector(".card") as HTMLElement; expect(card.getAttribute("draggable")).toBe("false"); // No drag-start handler should be wired on touch (would arm the heuristic). const dragStart = new Event("dragstart", { bubbles: true, cancelable: true }); const prevented = !card.dispatchEvent(dragStart); expect(prevented).toBe(false); } finally { window.matchMedia = original; } }); it.each([ { name: "merged", primaryPr: { status: "merged" as const }, expectedClass: "card-github-badge--merged" }, { name: "conflicting", primaryPr: { status: "open" as const, mergeable: "conflicting" as const }, expectedClass: "card-github-badge--conflicting" }, ])("renders Nx PR badge label and resolver class for $name primary PR", ({ primaryPr, expectedClass }) => { render( , ); const badge = screen.getByRole("link", { name: /2x #42/i }); expect(badge).toBeDefined(); expect(badge).toHaveClass(expectedClass); }); 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 Create PR quick action on eligible in-review cards", () => { render( , ); expect(screen.getByRole("button", { name: "Create pull request" })).toBeDefined(); }); it("renders Create PR quick action with chip class instead of btn classes", () => { render( , ); const createPrButton = screen.getByRole("button", { name: "Create pull request" }); expect(createPrButton).toHaveClass("card-create-pr-action"); expect(createPrButton).not.toHaveClass("btn"); expect(createPrButton).not.toHaveClass("btn-sm"); }); it.each(["in-progress", "todo", "done"] as const)("does not render Create PR quick action outside in-review (%s)", (column) => { render( , ); expect(screen.queryByRole("button", { name: "Create pull request" })).toBeNull(); }); it("does not render Create PR quick action when prAuthAvailable is false", () => { render( , ); expect(screen.queryByRole("button", { name: "Create pull request" })).toBeNull(); }); it.each([ { name: "hides when task override turns auto-merge on while project default is off", taskAutoMerge: true, autoMergeEnabled: false, shouldShow: false, }, { name: "hides when task follows an enabled project default", taskAutoMerge: undefined, autoMergeEnabled: true, shouldShow: false, }, { name: "shows when task override turns auto-merge off while project default is on", taskAutoMerge: false, autoMergeEnabled: true, shouldShow: true, }, { name: "shows when task follows a disabled project default", taskAutoMerge: undefined, autoMergeEnabled: false, shouldShow: true, }, ])("Create PR quick action $name", ({ taskAutoMerge, autoMergeEnabled, shouldShow }) => { render( , ); if (shouldShow) { expect(screen.getByRole("button", { name: "Create pull request" })).toBeDefined(); return; } expect(screen.queryByRole("button", { name: "Create pull request" })).toBeNull(); }); it("does not render Create PR quick action when task already has prInfo", () => { render( , ); expect(screen.queryByRole("button", { name: "Create pull request" })).toBeNull(); }); it("hides Address PR feedback when the task has no actionable PR feedback", () => { const noPrRender = render( , ); expect(screen.queryByTestId("card-address-pr-feedback-FN-NO-PR")).toBeNull(); expect(noPrRender.container.querySelector(".card-action-row")).toBeNull(); noPrRender.unmount(); const noFeedbackRender = render( , ); expect(screen.queryByTestId("card-address-pr-feedback-FN-NO-FEEDBACK")).toBeNull(); expect(noFeedbackRender.container.querySelector(".card-action-row")).toBeNull(); noFeedbackRender.unmount(); const unsupportedColumnRender = render( , ); expect(screen.queryByTestId("card-address-pr-feedback-FN-DONE-FEEDBACK")).toBeNull(); expect(unsupportedColumnRender.container.querySelector(".card-action-row")).toBeNull(); }); it("renders Address PR feedback once for actionable primary PR feedback", () => { render( , ); const buttons = screen.getAllByTestId("card-address-pr-feedback-FN-PR-FEEDBACK"); expect(buttons).toHaveLength(1); expect(buttons[0]).toHaveClass("card-create-pr-action", "card-address-pr-feedback-action"); expect(buttons[0].closest(".card-action-row")).not.toBeNull(); }); it("starts Address PR feedback from the card without opening detail", async () => { const addToast = vi.fn(); const onOpenDetail = vi.fn(); vi.mocked(addressPrFeedback).mockResolvedValue({ task: makeTask({ id: "FN-CLICK" }) }); render( , ); fireEvent.click(screen.getByTestId("card-address-pr-feedback-FN-CLICK")); await waitFor(() => expect(addressPrFeedback).toHaveBeenCalledWith("FN-CLICK", "proj-1")); expect(addToast).toHaveBeenCalledWith("Addressing PR feedback — AI session started", "success"); expect(onOpenDetail).not.toHaveBeenCalled(); }); it("shows an error toast when Address PR feedback cannot start", async () => { const addToast = vi.fn(); vi.mocked(addressPrFeedback).mockRejectedValue(new Error("wake failed")); render( , ); fireEvent.click(screen.getByTestId("card-address-pr-feedback-FN-ERROR")); await waitFor(() => expect(addToast).toHaveBeenCalledWith("Failed to start PR feedback session: wake failed", "error")); }); it.each([ { paused: true, userPaused: false }, { paused: false, userPaused: true }, ])("does not render Create PR quick action when paused flags are set", ({ paused, userPaused }) => { render( , ); expect(screen.queryByRole("button", { name: "Create pull request" })).toBeNull(); }); it("opens Create PR modal on click and does not open task detail", () => { const onOpenDetail = vi.fn(); render( , ); fireEvent.click(screen.getByRole("button", { name: "Create pull request" })); expect(screen.getByTestId("pr-create-modal").getAttribute("data-open")).toBe("true"); expect(onOpenDetail).not.toHaveBeenCalled(); }); it("closes Create PR modal when onClose fires", () => { render( , ); fireEvent.click(screen.getByRole("button", { name: "Create pull request" })); fireEvent.click(screen.getByRole("button", { name: "close-pr-modal" })); expect(screen.getByTestId("pr-create-modal").getAttribute("data-open")).toBe("false"); }); it("shows success toast and closes modal when PR is created", () => { const addToast = vi.fn(); render( , ); fireEvent.click(screen.getByRole("button", { name: "Create pull request" })); fireEvent.click(screen.getByRole("button", { name: "create-pr-modal" })); expect(addToast).toHaveBeenCalledWith("Created PR #42", "success"); expect(screen.getByTestId("pr-create-modal").getAttribute("data-open")).toBe("false"); }); 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.each([ { name: "undefined results", workflowStepResults: undefined, shouldRender: false }, { name: "pending but not started", workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "pending" }], shouldRender: false }, { name: "running", workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "pending", startedAt: "2026-07-11T12:00:00.000Z" }], shouldRender: true }, { name: "passed", workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "passed", startedAt: "2026-07-11T12:00:00.000Z", completedAt: "2026-07-11T12:01:00.000Z" }], shouldRender: false }, { name: "failed", workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "failed", startedAt: "2026-07-11T12:00:00.000Z", completedAt: "2026-07-11T12:01:00.000Z" }], shouldRender: false }, { name: "skipped", workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "skipped", startedAt: "2026-07-11T12:00:00.000Z", completedAt: "2026-07-11T12:01:00.000Z" }], shouldRender: false }, { name: "advisory failure", workflowStepResults: [{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "advisory_failure", startedAt: "2026-07-11T12:00:00.000Z", completedAt: "2026-07-11T12:01:00.000Z" }], shouldRender: false }, ])("renders the Reviewing badge only while Plan Review is actively running: $name", ({ workflowStepResults, shouldRender }) => { const { container } = render( , ); const badge = container.querySelector('[data-testid="card-reviewing-FN-7831"]'); expect(Boolean(badge)).toBe(shouldRender); if (shouldRender) { expect(badge).toHaveTextContent("Reviewing"); expect(screen.getByText("planning")).toBeDefined(); } }); 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(); const headerBadges = container.querySelector(".card-header-badges")!; expect(headerBadges).toBeDefined(); expect(cardId.nextElementSibling).toBe(headerBadges); expect(headerBadges.contains(badge)).toBe(true); }); it("does not render a status badge when task.status is falsy", () => { const { container } = render( , ); expect(container.querySelector(".card-status-badge")).toBeNull(); }); /* * FNXC:ReleaseAuthorizationGate 2026-07-09-00:00: the triage release-authorization * gate was removed. A legacy release-authorization hold now renders the generic * "Awaiting Approval" badge like any manual plan-approval hold — no distinct * release-authorization label or badge class. */ it("renders the generic Awaiting Approval badge for a legacy release-authorization hold", () => { const { container: releaseContainer } = render( , ); expect(within(releaseContainer).getByText("Awaiting Approval")).toBeDefined(); expect(within(releaseContainer).queryByText("Awaiting Release Authorization")).toBeNull(); const releaseBadge = releaseContainer.querySelector(".card-status-badge") as HTMLElement; expect(releaseBadge.className).not.toContain("awaiting-release-authorization"); }); /* * FNXC:PlanReviewReplan 2026-07-15-11:09: * When Plan Review exhausts automatic REVISE replans, the card must not look like a * generic require-all hold — badge text + title explain the non-convergence reason. */ it("renders a distinct Plan Review Cap badge when awaitingApprovalReason is plan-review-replan-cap", () => { const { container } = render( , ); expect(within(container).getByText("Plan Review Cap")).toBeDefined(); expect(within(container).queryByText("Awaiting Approval")).toBeNull(); const badge = container.querySelector(".card-status-badge") as HTMLElement; expect(badge.className).toContain("awaiting-approval--plan-review-replan-cap"); expect(badge.getAttribute("data-awaiting-approval-reason")).toBe("plan-review-replan-cap"); expect(badge.getAttribute("title")).toMatch(/Plan Review requested revisions/i); }); 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("renders retry-exhausted in-review stall badge with counter, code, and tooltip", () => { render( , ); const badge = screen.getByText("Retries exhausted 3/3"); expect(badge.getAttribute("data-stall-code")).toBe("merge-retries-exhausted"); expect(badge.getAttribute("title")).toContain("Auto-merge retries exhausted"); }); it("renders merge-blocker in-review stall badge without retry counter", () => { render( , ); expect(screen.getByText("Merge blocked")).toBeDefined(); expect(screen.queryByText(/\/3/)).toBeNull(); }); it("FN-4570: hides merge-blocker stall badge while merge is active", () => { render( , ); expect(screen.getByText("Merging…")).toBeDefined(); expect(screen.queryByText("Merge blocked")).toBeNull(); }); it.each(["merging", "reviewing", "landing", "merging-pr"] as const)( "FN-merge-badge: shows Merging… badge while task.status is %s", (status) => { render( , ); expect(screen.getByText("Merging…")).toBeDefined(); }, ); it.each([ { label: "paused in-review task", task: makeTask({ column: "in-review", paused: true, status: "merging", inReviewStall: { code: "merge-retries-exhausted", reason: "Auto-merge retries exhausted", observedAt: "2026-05-13T00:00:00.000Z", }, }), }, { label: "in-review task without inReviewStall", task: makeTask({ column: "in-review", status: "merging", inReviewStall: undefined }), }, { label: "non in-review task with fabricated signal", task: makeTask({ column: "in-progress", status: "executing", inReviewStall: { code: "merge-retries-exhausted", reason: "Auto-merge retries exhausted", observedAt: "2026-05-13T00:00:00.000Z", }, }), }, ])("hides in-review stall badge for $label", ({ task }) => { render(); expect(screen.queryByText("Retries exhausted")).toBeNull(); }); it("renders stale paused review badge for paused in-review signal", () => { render( , ); expect(screen.getByText("Paused stall")).toBeDefined(); }); it("hides stale paused review badge when signal missing", () => { render( , ); expect(screen.queryByText("Paused stall")).toBeNull(); }); it("hides stale paused review badge when task is not paused", () => { render( , ); expect(screen.queryByText("Paused stall")).toBeNull(); }); it("renders warning task-age staleness badge", () => { render( , ); expect(screen.getByText("Stale")).toBeDefined(); }); it("renders critical task-age staleness badge", () => { render( , ); expect(screen.getByText("Stale (critical)")).toBeDefined(); }); it("hides task-age staleness badge when signal is absent", () => { render(); expect(screen.queryByText("Stale")).toBeNull(); expect(screen.queryByText("Stale (critical)")).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("renders todo userPaused tasks as paused", () => { const { container } = render( , ); expect(screen.getByText("paused")).toBeDefined(); expect(container.querySelector(".card")?.className).toContain("paused"); }); it("does not show paused by agent copy for userPaused-only tasks", () => { render( , ); expect(screen.getByText("paused")).toBeDefined(); expect(screen.queryByText("paused by agent")).toBeNull(); }); it("keeps done status badge when stale paused metadata exists", () => { const { container } = render( , ); expect(screen.queryByText("paused by agent")).toBeNull(); expect(screen.queryByText("paused")).toBeNull(); expect(screen.getByText("done")).toBeDefined(); expect(container.querySelector(".card")?.className).not.toContain("paused"); }); it("keeps done status badge when done task status is paused", () => { render( , ); expect(screen.queryByText("paused")).toBeNull(); expect(screen.getByText("done")).toBeDefined(); }); it("renders decision-only badge when noCommitsExpected is true", () => { render(); expect(screen.getByText("decision-only")).toBeTruthy(); }); it("hides decision-only badge when noCommitsExpected is false", () => { render(); expect(screen.queryByText("decision-only")).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 overlap scope badge when overlapBlockedBy is set without blockedBy", () => { render( , ); expect(screen.getByText("FN-OVER")).toBeInTheDocument(); }); it("does not render overlap scope badge when blockedBy and overlapBlockedBy are absent", () => { const { container } = render( , ); expect(container.querySelector(".card-scope-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")).toContain("overlap blockedBy queue: 0 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 overlap bottleneck badge without visible todo suffix while keeping tooltip context", () => { render( , ); const badge = screen.getByText("Overlap bottleneck").closest(".card-fanout-badge") as HTMLElement; expect(badge).not.toBeNull(); expect(badge.textContent).toContain("Overlap bottleneck 7"); expect(badge.textContent).not.toContain("todo)"); expect(badge.getAttribute("data-tooltip")).toContain("overlap blockedBy queue: 3 todo"); }); it("escalates only threshold-crossing fan-out badges", () => { const { rerender } = render( , ); let badge = screen.getByText("Escalated overlap").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("shows shared group chip with shared branch label for grouped tasks", () => { render( , ); expect(screen.getByText("Shared")).toBeDefined(); expect(screen.getAllByText("feature/shared-branch").length).toBeGreaterThan(0); }); it("opens group modal from shared branch chip", () => { const onOpenGroupModal = vi.fn(); render( , ); fireEvent.click(screen.getByText("Shared")); expect(onOpenGroupModal).toHaveBeenCalledWith("BG-22"); }); 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(); }); it("keeps priority and fast mode in meta while agent-created and time move to bottom rows", () => { const { container } = render( , ); const group = container.querySelector(".card-meta-badges"); expect(group).not.toBeNull(); const expectedMetaSelectors = [ ".card-priority-badge", ".card-execution-mode-badge", ]; expectedMetaSelectors.forEach((selector) => { const badge = container.querySelector(selector); expect(badge).not.toBeNull(); expect(badge?.closest(".card-meta-badges")).toBe(group); }); const timer = container.querySelector(".card-time-indicator"); expect(timer).not.toBeNull(); expect(timer?.closest(".card-meta-badges")).toBeNull(); expect(timer?.closest(".card-footer-row-right")).not.toBeNull(); // FNXC:PlannerOversight 2026-07-04-00:00: an unset per-task oversight // override resolving to the inherited schema default ("autonomous") no // longer renders a per-card badge (FN-7539) — an inherited default is not // meaningfully-configured oversight, so it does not appear among the // opt-in meta badges here. const agentBadge = container.querySelector(".card-agent-created-badge"); expect(agentBadge).not.toBeNull(); expect(agentBadge?.closest(".card-agent-badge-row")).not.toBeNull(); expect(agentBadge?.closest(".card-meta-badges")).toBeNull(); expect(agentBadge?.closest(".card-header")).toBeNull(); expect(Array.from(group?.children ?? []).map((child) => child.className)).toEqual([ "card-priority-badge card-priority-badge--high", "card-execution-mode-badge card-execution-mode-badge--fast", ]); }); it("renders icon-only urgency-colored priority badges with accessible labels while normal stays hidden", () => { for (const priority of TASK_PRIORITIES) { const { container, unmount } = render( , ); const badge = container.querySelector(".card-priority-badge"); if (priority === "normal") { expect(badge).toBeNull(); unmount(); continue; } const label = getPriorityLabel(priority); expect(badge).not.toBeNull(); expect(badge).toHaveAttribute("aria-label", label); expect(badge).toHaveAttribute("title", label); expect(Array.from(badge?.childNodes ?? []).filter((node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim())).toEqual([]); const visibleLabelSpans = Array.from(badge?.querySelectorAll("span") ?? []).filter((span) => !span.classList.contains("visually-hidden")); expect(visibleLabelSpans).toEqual([]); expect(badge?.querySelector(".visually-hidden")).toHaveTextContent(label); const icon = badge?.querySelector("svg"); expect(icon).not.toBeNull(); expect(icon).toHaveAttribute("aria-hidden", "true"); expect(icon?.getAttribute("style")).toContain(`color: ${getPriorityColorVar(priority)}`); unmount(); } const { container } = render( , ); expect(container.querySelector(".card-priority-badge")).toBeNull(); }); it("renders partial card meta groups without empty wrappers when time is absent", () => { const { container } = render( , ); const group = container.querySelector(".card-meta-badges"); expect(group).not.toBeNull(); expect(group?.querySelector(".card-priority-badge")).not.toBeNull(); expect(group?.querySelector(".card-execution-mode-badge")).not.toBeNull(); expect(group?.querySelector(".card-agent-created-badge")).toBeNull(); expect(container.querySelector(".card-agent-created-badge")?.closest(".card-agent-badge-row")).not.toBeNull(); expect(group?.querySelector(".card-time-indicator")).toBeNull(); expect(container.querySelector(".card-footer-row")).toBeNull(); expect(container.querySelector(".card-footer-row-right")).toBeNull(); }); it("moves a lone time chip into the footer without rendering empty meta badges", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-25T12:05:00.000Z")); const { container } = render( , ); const group = container.querySelector(".card-meta-badges"); const timer = container.querySelector(".card-time-indicator"); const footerRow = container.querySelector(".card-footer-row"); const rightCluster = container.querySelector(".card-footer-row-right"); expect(group).toBeNull(); expect(timer).not.toBeNull(); expect(footerRow).not.toBeNull(); expect(rightCluster).not.toBeNull(); expect(timer?.closest(".card-footer-row-right")).toBe(rightCluster); expect(Array.from(rightCluster?.children ?? [])).toEqual([timer]); expect(container.querySelector(".card-priority-badge")).toBeNull(); expect(container.querySelector(".card-execution-mode-badge")).toBeNull(); expect(container.querySelector(".card-agent-created-badge")).toBeNull(); expect(container.querySelector(".card-agent-badge-row")).toBeNull(); }); it("does not render card meta badge shells when all grouped affordances are absent", () => { const { container } = render( , ); expect(container.querySelector(".card-meta-badges")).toBeNull(); expect(container.querySelector(".card-footer-row")).toBeNull(); expect(container.querySelector(".card-footer-row-right")).toBeNull(); }); it("defines responsive flex-wrap styling for grouped card meta badges", () => { const fullCss = loadAllAppCss(); expect(fullCss).toMatch(/\.card-meta-badges\s*\{[^}]*display:\s*flex;[^}]*flex-wrap:\s*wrap;[^}]*gap:\s*var\(--space-xs\);[^}]*\}/); expect(fullCss).toMatch(/@media[^{]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.card-meta-badges\s*\{[^}]*gap:\s*calc\(var\(--space-xs\) \/ 2\);[^}]*\}/); }); 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("does not render card-error banner for auto-recovered transient row", () => { const { container } = render( , ); expect(container.querySelector(".card-error")).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("surfaces in-progress implementation steps on the collapsed card", () => { const { container } = render( , ); expect(screen.getByText("1/3")).toBeDefined(); expect(screen.getByText("1 active")).toBeDefined(); expect(screen.getByText("active")).toBeDefined(); expect(container.querySelector(".card-step-name.active")?.textContent).toBe("Step 1"); // FN-7676: the steps breakdown must still render once a task is out of Planning (`in-progress`/`executing`). expect(container.querySelector(".card-steps-toggle")).not.toBeNull(); expect(container.querySelector(".card-progress")).not.toBeNull(); }); it("hides the steps breakdown while the task is still in triage, even with a running Plan Review", () => { const { container } = render( , ); expect(container.querySelector(".card-progress")).toBeNull(); expect(container.querySelector(".card-steps-toggle")).toBeNull(); expect(container.querySelector(".card-steps-list")).toBeNull(); expect(screen.queryByText("0/2")).toBeNull(); expect(screen.queryByText("1 active")).toBeNull(); }); it("does not render the steps toggle for a triage card with populated steps", () => { const { container } = render( ({ name: `Step ${i}`, status: "pending" as const })), })} onOpenDetail={noop} addToast={noop} />, ); expect(container.querySelector(".card-progress")).toBeNull(); expect(container.querySelector(".card-steps-toggle")).toBeNull(); expect(container.querySelector(".card-steps-list")).toBeNull(); expect(screen.queryByText("0/10")).toBeNull(); expect(screen.queryByText("10 steps")).toBeNull(); }); it("does not show a false triage active indicator for enabled-but-not-started Plan Review", () => { const { container } = render( , ); expect(screen.queryByText("1 active")).toBeNull(); expect(container.querySelector(".card-progress")).toBeNull(); expect(container.querySelector(".card-steps-toggle")).toBeNull(); }); it("does not render an empty triage progress shell without workflow progress", () => { const { container } = render( , ); expect(container.querySelector(".card-progress")).toBeNull(); expect(container.querySelector(".card-steps-toggle")).toBeNull(); }); it("uses singular step label when unified progress total is one", () => { render( , ); expect(screen.getByText("1 step")).toBeDefined(); expect(screen.queryByText("1 steps")).toBeNull(); }); // FNXC:WorkflowSteps 2026-06-25-00:00 — graph-written results drive the card progress; names come from // result.workflowStepName (with raw-id fallback), and advisory_failure (amber) is visually distinct // from failed (red). No board-level name lookup is involved. // FNXC:WorkflowSteps 2026-06-30-12:00 — expanded task-card rows deliberately omit the redundant workflow text badge; tests preserve mixed implementation/workflow visibility through names, status dots, and the active badge instead. it("renders workflow checks after normal steps with graph-written statuses and no workflow text badges", () => { const { container } = render( , ); const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent); // WS-003 has no result → name falls back to the display-normalized id; all others resolve from result.workflowStepName. expect(stepNames).toEqual([ "Step 0", "Step 1", "Browser Verification", "Frontend UX Design", "WS 003", "Code Review Gate", "Merge Validation", ]); const dots = container.querySelectorAll(".card-step-dot"); // Impl step failure → blocking red. expect(dots[1]?.className).toContain("card-step-dot--failed"); expect(dots[1]?.className).not.toContain("card-step-dot--advisory_failure"); // Passed workflow step → done. expect(dots[2]?.className).toContain("card-step-dot--done"); // advisory_failure → amber, NOT the blocking red failed class. expect(dots[3]?.className).toContain("card-step-dot--advisory_failure"); expect(dots[3]?.className).not.toContain("card-step-dot--failed"); // Enabled-but-not-run → pending. expect(dots[4]?.className).toContain("card-step-dot--pending"); // Gate failure → blocking red, NOT amber advisory. expect(dots[5]?.className).toContain("card-step-dot--failed"); expect(dots[5]?.className).not.toContain("card-step-dot--advisory_failure"); // Started-but-not-finished workflow step → running with the same active badge as implementation steps. expect(dots[6]?.className).toContain("card-step-dot--running"); expect(dots[6]?.className).not.toContain("card-step-dot--pending"); expect(container.querySelector(".card-step-active-badge")?.textContent).toBe("active"); const workflowBadgeElements = container.querySelectorAll(".card-step-workflow-badge"); expect(workflowBadgeElements).toHaveLength(0); expect(container.querySelector('[title="Workflow check"]')).toBeNull(); expect(Array.from(container.querySelectorAll(".card-step-item")).some((item) => item.textContent === "workflow")).toBe(false); }); it("renders the running state for a started-but-not-completed workflow step", () => { const { container } = render( , ); const dots = container.querySelectorAll(".card-step-dot"); expect(dots[0]?.className).toContain("card-step-dot--running"); expect(dots[0]?.className).not.toContain("card-step-dot--pending"); }); it("falls back to the raw workflow step ID when the result name is blank", () => { const { container } = render( , ); const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent); // Blank result name → display-normalized id; WS-003 (no result) → display-normalized id. expect(stepNames).toEqual(["WS 002", "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("renders size badge as the right-most header action after trailing controls", () => { const { container } = render( makeTask({ paused: true })} />, ); const actionsContainer = container.querySelector(".card-header-actions") as HTMLElement | null; const menuButton = container.querySelector(".card-menu-btn"); const sizeBadge = container.querySelector(".card-size-badge"); expect(actionsContainer).not.toBeNull(); expect(menuButton).not.toBeNull(); expect(sizeBadge).not.toBeNull(); expect(actionsContainer?.contains(menuButton)).toBe(true); expect(actionsContainer?.lastElementChild).toBe(sizeBadge); expect(sizeBadge?.nextElementSibling).toBeNull(); }); it("places card-header-actions as a direct header child after the wrapped badge group", () => { const { container } = render( , ); const header = container.querySelector(".card-header")!; const cardId = container.querySelector(".card-id")!; const headerBadges = container.querySelector(".card-header-badges")!; const actionsContainer = container.querySelector(".card-header-actions")!; expect(cardId).not.toBeNull(); expect(headerBadges).not.toBeNull(); expect(actionsContainer).not.toBeNull(); expect(actionsContainer.parentElement).toBe(header); expect(headerBadges.parentElement).toBe(header); expect( cardId.compareDocumentPosition(headerBadges) & Node.DOCUMENT_POSITION_FOLLOWING ).toBeTruthy(); expect( headerBadges.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 done actions dropdown inside card-header-actions for done columns", () => { const { container } = render( makeTask()} />, ); const actionsContainer = container.querySelector(".card-header-actions"); const actionsButton = screen.getByRole("button", { name: "Actions" }); expect(actionsContainer).not.toBeNull(); expect(container.querySelector(".card-archive-btn")).toBeNull(); expect(actionsContainer?.contains(actionsButton)).toBe(true); }); it("renders in-review Move control inline in card-meta for overlap-blocked tasks", () => { const { container } = render( , ); const moveControl = container.querySelector(".card-send-back"); const metaRow = container.querySelector(".card-meta"); expect(metaRow).not.toBeNull(); expect(moveControl).not.toBeNull(); expect(metaRow?.contains(moveControl as HTMLElement)).toBe(true); expect(container.querySelector(".card-action-row")).toBeNull(); }); it("renders in-review Move control after queued badge in card-meta", () => { const { container } = render( , ); const metaRow = container.querySelector(".card-meta"); const queuedBadge = container.querySelector(".queued-badge"); const moveControl = container.querySelector(".card-send-back"); expect(metaRow).not.toBeNull(); expect(queuedBadge).not.toBeNull(); expect(moveControl).not.toBeNull(); expect(metaRow?.contains(moveControl as HTMLElement)).toBe(true); expect(queuedBadge?.compareDocumentPosition(moveControl as HTMLElement) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(container.querySelector(".card-action-row")).toBeNull(); }); it("keeps in-review Move control in card-action-row when meta row is not visible", () => { const { container } = render( , ); const moveButton = screen.getByRole("button", { name: "Move task" }); const actionRow = container.querySelector(".card-action-row"); expect(actionRow).not.toBeNull(); expect(actionRow?.contains(moveButton)).toBe(true); expect(moveButton.closest(".card-meta")).toBeNull(); }); it("renders Create PR before Move inside card-action-row", () => { const { container } = render( , ); const createPrButton = screen.getByRole("button", { name: "Create pull request" }); const moveButton = screen.getByRole("button", { name: "Move task" }); const actionRow = createPrButton.closest(".card-action-row"); expect(actionRow).not.toBeNull(); expect(moveButton.closest(".card-action-row")).toBe(actionRow); expect(createPrButton.compareDocumentPosition(moveButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); const moveControl = moveButton.closest(".card-send-back") as HTMLElement | null; expect(moveControl).not.toBeNull(); expect(getComputedStyle(moveControl as HTMLElement).marginLeft).toBe("auto"); fireEvent.click(moveButton); const menu = screen.getByRole("menu"); expect(moveControl?.contains(menu)).toBe(true); const menuStyle = getComputedStyle(menu); expect(menuStyle.right).toBe("0px"); expect(menuStyle.left).not.toBe("0px"); }); it.each([ { name: "meta-row-visible variant", task: makeTask({ column: "in-review", blockedBy: "FN-777" }), expectedContainer: ".card-meta" }, { name: "no-meta variant", task: makeTask({ column: "in-review", dependencies: [], blockedBy: undefined, overlapBlockedBy: undefined, status: undefined as any }), expectedContainer: ".card-action-row" }, ])("keeps Move dropdown behavior for $name", ({ task, expectedContainer }) => { const onMoveTask = vi.fn(); const { container } = render( , ); const host = container.querySelector(expectedContainer); const moveButton = screen.getByRole("button", { name: "Move task" }); expect(host?.contains(moveButton)).toBe(true); fireEvent.click(moveButton); expect(screen.getAllByRole("menuitem").length).toBeGreaterThan(0); fireEvent.click(screen.getByRole("menuitem", { name: "Done (no merge)" })); expect(onMoveTask).toHaveBeenCalledWith("FN-001", "done", undefined); }); it("FN-4540 keeps in-progress Send back control in card-header-actions", () => { const { container } = render( , ); const sendBackButton = screen.getByRole("button", { name: "Send back" }); const actionsContainer = container.querySelector(".card-header-actions"); expect(actionsContainer).not.toBeNull(); expect(actionsContainer?.contains(sendBackButton)).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"); expectTimerInFooterRight(container); // 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"); expectTimerInFooterRight(container); rerender( , ); expect(container.querySelector(".card-time-indicator")?.textContent).toContain("2m"); expectTimerInFooterRight(container); }); it("shows timer chip for done cards summing workflow runtime + timed events", () => { const { container } = render( , ); const timer = container.querySelector(".card-time-indicator"); expectTimerInFooterRight(container); // 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(footerRow).not.toBeNull(); expect(footerRow?.contains(link)).toBe(true); expect(bottomRightRow).toBeNull(); expect(screen.getByTestId("provider-icon-github")).toBeDefined(); }); it("renders the GitHub tracking link inline with queued metadata when the footer has no leading content", () => { const { container } = render( , ); const link = screen.getByRole("link", { name: "Linked GitHub issue #42" }); const metaRow = container.querySelector(".card-meta"); const queuedBadge = container.querySelector(".queued-badge"); expect(container.querySelector(".card-footer-row")).toBeNull(); expect(link.closest(".card-meta")).toBe(metaRow); expect(link.closest(".card-footer-row-right")?.closest(".card-meta")).toBe(metaRow); expect(container.querySelector(".card-bottom-right-row")).toBeNull(); expect(queuedBadge).not.toBeNull(); expect(queuedBadge?.compareDocumentPosition(link) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); it("renders tracking, retry, and timer in the footer right cluster", () => { const { container } = render( , ); const footerRow = container.querySelector(".card-footer-row"); const metaBadges = container.querySelector(".card-meta-badges"); const rightCluster = container.querySelector(".card-footer-row-right"); const trackingLink = container.querySelector(".card-github-tracking-chip"); const retryChip = container.querySelector(".card-retry-badge"); const timerChip = container.querySelector(".card-time-indicator"); expect(footerRow).not.toBeNull(); expect(metaBadges).toBeNull(); expect(rightCluster).not.toBeNull(); expect(footerRow?.contains(trackingLink)).toBe(true); expect(footerRow?.contains(retryChip)).toBe(true); expect(footerRow?.contains(timerChip)).toBe(true); expect(rightCluster?.contains(timerChip)).toBe(true); expect(Array.from(rightCluster?.children ?? [])).toContain(timerChip); expect(container.querySelector(".card-bottom-right-row")).toBeNull(); }); 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-time-indicator\s*,\s*\.card-cost-indicator\s*,\s*\.card-github-tracking-chip\s*,\s*\.card-retry-badge\s*,\s*\.card-create-pr-action\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/); expect(css).toMatch(/\.card-time-indicator\s*,\s*\.card-cost-indicator\s*,\s*\.card-github-tracking-chip\s*,\s*\.card-retry-badge\s*,\s*\.card-create-pr-action\s*\{[^}]*padding:\s*var\(--space-xs\)\s+var\(--space-sm\);[^}]*height:\s*var\(--card-chip-height\);[^}]*border-radius:\s*var\(--radius-pill\);[^}]*font-size:\s*0\.6875rem;[^}]*line-height:\s*1;[^}]*\}/); expect(css).toMatch(/\.card-github-tracking-chip\s+\.provider-icon\s+svg\s*\{[^}]*width:\s*12px;[^}]*height:\s*12px;[^}]*\}/); 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-right\s*\{[^}]*margin-left:\s*auto;[^}]*\}/); const provenanceRule = css.match(/\.card-source-provenance\s*\{[^}]*\}/)?.[0] ?? ""; expect(provenanceRule).not.toMatch(/margin-left\s*:\s*auto/); }); describe("FN-4607 TaskCard GitHub badge right-alignment", () => { it("keeps source provenance and tracking chip grouped in footer order", () => { const { container } = render( , ); const footerRow = container.querySelector(".card-footer-row"); const sourceBadge = container.querySelector(".card-footer-row > .card-source-provenance"); const trackingChip = container.querySelector(".card-footer-row-right > .card-github-tracking-chip"); expect(footerRow).not.toBeNull(); expect(sourceBadge).not.toBeNull(); expect(trackingChip).not.toBeNull(); const rightCluster = container.querySelector(".card-footer-row > .card-footer-row-right"); expect(rightCluster).not.toBeNull(); expect((sourceBadge as Element).nextElementSibling).toBe(rightCluster); const css = loadAllAppCssBaseOnly(); expect(css).toMatch(/\.card-footer-row-right\s*\{[^}]*margin-left:\s*auto;[^}]*\}/); }); it("applies right-alignment rule when only tracking chip is rendered", () => { const css = loadAllAppCssBaseOnly(); expect(css).toMatch(/\.card-footer-row-right\s*\{[^}]*margin-left:\s*auto;[^}]*\}/); const { container } = render( , ); const footerRow = container.querySelector(".card-footer-row"); const trackingChip = container.querySelector(".card-footer-row-right > .card-github-tracking-chip"); expect(footerRow).not.toBeNull(); expect(trackingChip).not.toBeNull(); expect(container.querySelector(".card-footer-row > .card-source-provenance")).toBeNull(); }); it("keeps github badges before retry while time chip ends the footer cluster", () => { const { container } = render( , ); const footerRow = container.querySelector(".card-footer-row"); expect(footerRow).not.toBeNull(); const sourceNode = footerRow?.querySelector(".card-source-provenance"); const rightCluster = footerRow?.querySelector(".card-footer-row-right"); const timerChip = container.querySelector(".card-time-indicator"); expect(sourceNode).not.toBeNull(); expect(rightCluster).not.toBeNull(); expect(timerChip?.closest(".card-footer-row-right")).toBe(rightCluster); const orderedNodes = [ rightCluster?.querySelector(".card-github-tracking-chip"), rightCluster?.querySelector(".card-retry-badge"), timerChip, ]; orderedNodes.forEach((node) => expect(node).not.toBeNull()); expect(Array.from((rightCluster as Element).children)).toEqual(orderedNodes); }); }); describe("FN-4634 in-progress GitHub linked badge far-right placement", () => { const trackedIssue = { owner: "owner", repo: "repo", number: 42, url: "https://github.com/owner/repo/issues/42", createdAt: "2026-05-12T00:00:00.000Z", }; it.each([ { name: "retry summary present", taskPatch: { retrySummary: { total: 2 } as any }, expectedLeftChipSelector: ".card-retry-badge", }, { name: "time indicator present", taskPatch: { executionStartedAt: "2026-04-25T12:00:00.000Z", updatedAt: "2026-04-25T12:12:00.000Z", }, expectedLeftChipSelector: ".card-time-indicator", }, ])("keeps right-side chips after tracking on in-progress when files changed is absent: $name", ({ taskPatch, expectedLeftChipSelector }) => { const { container } = render( , ); const footerRow = container.querySelector(".card-footer-row") as HTMLElement | null; const trackingChip = container.querySelector(".card-github-tracking-chip") as HTMLElement | null; const rightSideChip = container.querySelector(expectedLeftChipSelector) as HTMLElement | null; expect(footerRow).not.toBeNull(); expect(footerRow).toHaveClass("card-footer-row--chip-far-right"); expect(trackingChip).not.toBeNull(); expect(rightSideChip).not.toBeNull(); const rightCluster = container.querySelector(".card-footer-row-right") as HTMLElement | null; expect(rightCluster).not.toBeNull(); const children = Array.from((rightCluster as HTMLElement).children); expect(children).toContain(trackingChip as HTMLElement); expect(children).toContain(rightSideChip as HTMLElement); expect(children.indexOf(rightSideChip as HTMLElement)).toBeGreaterThan(children.indexOf(trackingChip as HTMLElement)); expect(getComputedStyle(rightCluster as HTMLElement).marginLeft).toBe("auto"); }); it.each(["in-progress", "in-review"] as const)("renders time indicator in footer right cluster beside tracking chip for %s", (column) => { const { container } = render( , ); const footerRow = container.querySelector(".card-footer-row") as HTMLElement | null; const trackingChip = container.querySelector(".card-github-tracking-chip") as HTMLElement | null; const timeChip = container.querySelector(".card-time-indicator") as HTMLElement | null; expect(footerRow).not.toBeNull(); expect(trackingChip).not.toBeNull(); expect(timeChip).not.toBeNull(); const rightCluster = container.querySelector(".card-footer-row-right") as HTMLElement | null; expect(rightCluster).not.toBeNull(); const children = Array.from((rightCluster as HTMLElement).children); expect(children).toContain(trackingChip); expect(children).toContain(timeChip); expect(timeChip?.closest(".card-footer-row-right")).toBe(rightCluster); }); it("does not force far-right modifier when in-progress card has files changed", () => { const { container } = render( , ); const footerRow = container.querySelector(".card-footer-row"); expect(footerRow).not.toBeNull(); expect(footerRow).not.toHaveClass("card-footer-row--chip-far-right"); }); it.each([ { name: "retry summary present", taskPatch: { retrySummary: { total: 2 } as any }, expectedLeftChipSelector: ".card-retry-badge", }, { name: "time indicator present", taskPatch: { executionStartedAt: "2026-04-25T12:00:00.000Z", updatedAt: "2026-04-25T12:12:00.000Z", }, expectedLeftChipSelector: ".card-time-indicator", }, ])("keeps right-side chips after tracking on in-review when files changed is absent: $name", ({ taskPatch, expectedLeftChipSelector }) => { const { container } = render( , ); const footerRow = container.querySelector(".card-footer-row") as HTMLElement | null; const trackingChip = container.querySelector(".card-github-tracking-chip") as HTMLElement | null; const rightSideChip = container.querySelector(expectedLeftChipSelector) as HTMLElement | null; expect(footerRow).not.toBeNull(); expect(footerRow).toHaveClass("card-footer-row--chip-far-right"); expect(trackingChip).not.toBeNull(); expect(rightSideChip).not.toBeNull(); const rightCluster = container.querySelector(".card-footer-row-right") as HTMLElement | null; expect(rightCluster).not.toBeNull(); const children = Array.from((rightCluster as HTMLElement).children); expect(children).toContain(trackingChip as HTMLElement); expect(children).toContain(rightSideChip as HTMLElement); expect(children.indexOf(rightSideChip as HTMLElement)).toBeGreaterThan(children.indexOf(trackingChip as HTMLElement)); expect(getComputedStyle(rightCluster as HTMLElement).marginLeft).toBe("auto"); }); it("does not force far-right modifier when in-review card has files changed", () => { const { container } = render( , ); const footerRow = container.querySelector(".card-footer-row"); expect(footerRow).not.toBeNull(); expect(footerRow).not.toHaveClass("card-footer-row--chip-far-right"); }); }); describe("FN-4923 tracking chip alignment", () => { it("FN-4923 right-aligns tracking chip next to timer in done when files-changed is absent", () => { const cleanupCss = mountCssForBadgeTests(); try { const { container } = render( , ); const footerRow = container.querySelector(".card-footer-row") as HTMLElement | null; const trackingChip = container.querySelector(".card-github-tracking-chip") as HTMLElement | null; const timerChip = container.querySelector(".card-time-indicator") as HTMLElement | null; expect(footerRow).not.toBeNull(); expect(footerRow).toHaveClass("card-footer-row--chip-far-right"); expect(trackingChip).not.toBeNull(); expect(timerChip).not.toBeNull(); const rightCluster = container.querySelector(".card-footer-row-right") as HTMLElement | null; expect(rightCluster).not.toBeNull(); expect(getComputedStyle(rightCluster as HTMLElement).marginLeft).toBe("auto"); expect((trackingChip as HTMLElement).nextElementSibling).toBe(timerChip); expect(timerChip?.closest(".card-footer-row-right")).toBe(rightCluster); } finally { cleanupCss(); } }); it("FN-4923 preserves in-progress tracking-chip right alignment without files-changed", () => { const cleanupCss = mountCssForBadgeTests(); try { const { container } = render( , ); const trackingChip = container.querySelector(".card-github-tracking-chip") as HTMLElement | null; const rightCluster = container.querySelector(".card-footer-row-right") as HTMLElement | null; expect(trackingChip).not.toBeNull(); expect(rightCluster).not.toBeNull(); expect(getComputedStyle(rightCluster as HTMLElement).marginLeft).toBe("auto"); } finally { cleanupCss(); } }); it("FN-4923 keeps tracking chip, retry/timer chips grouped as one right-aligned cluster with files-changed", () => { const cleanupCss = mountCssForBadgeTests(); try { const { container } = render( , ); const footerRow = container.querySelector(".card-footer-row") as HTMLElement | null; const filesChangedButton = screen.getByRole("button", { name: "1 file changed" }); const trackingChip = container.querySelector(".card-github-tracking-chip") as HTMLElement | null; const timerChip = container.querySelector(".card-time-indicator") as HTMLElement | null; expect(footerRow).not.toBeNull(); expect(footerRow).not.toHaveClass("card-footer-row--chip-far-right"); expect(trackingChip).not.toBeNull(); expect(timerChip).not.toBeNull(); const rightCluster = container.querySelector(".card-footer-row-right") as HTMLElement | null; expect(filesChangedButton.compareDocumentPosition(trackingChip as HTMLElement)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); expect(rightCluster).not.toBeNull(); expect(getComputedStyle(rightCluster as HTMLElement).marginLeft).toBe("auto"); expect(getComputedStyle(timerChip as HTMLElement).marginLeft).not.toBe("auto"); } finally { cleanupCss(); } }); }); it("does not render a GitHub tracking link when githubTracking is absent", () => { render( , ); expect(screen.queryByRole("link", { name: /Linked GitHub issue/i })).toBeNull(); }); it("keeps the plain provenance badge when source issue differs from tracked issue", () => { const { container } = render( , ); expect(container.querySelector(".card-source-provenance")).not.toBeNull(); expect(screen.getByRole("link", { name: "Linked GitHub issue #99" })).toBeDefined(); }); it("replaces the GitHub import provenance badge with the linked-issue chip when source and tracked issue match", () => { const { container } = render( , ); const chips = container.querySelectorAll(".card-github-tracking-chip"); const link = screen.getByRole("link", { name: "Linked GitHub issue #42" }); expect(chips).toHaveLength(1); expect(link.getAttribute("href")).toBe("https://github.com/owner/repo/issues/42"); expect(link).toHaveTextContent("#42"); expect(container.querySelector(".card-source-provenance")).toBeNull(); }); it("keeps the plain provenance badge alone when there is no linked issue", () => { const { container } = render( , ); expect(container.querySelector(".card-source-provenance")).not.toBeNull(); expect(container.querySelector(".card-github-tracking-chip")).toBeNull(); }); it("uses the same matching logic for task.issueInfo", () => { const { container } = render( , ); expect(screen.getByRole("link", { name: "Linked GitHub issue #42" })).toBeDefined(); expect(container.querySelector(".card-source-provenance")).toBeNull(); }); 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", () => { seedAgentsCache("p1", [{ id: "agent-123", name: "Cache Robot" }]); const { container } = render( , ); const badge = container.querySelector(".card-agent-created-badge"); expect(badge).not.toBeNull(); expect(badge?.closest(".card-agent-badge-row")).not.toBeNull(); expect(badge?.closest(".card-header")).toBeNull(); expect(badge?.getAttribute("title")).toBe("Created by agent: Task Robot"); expect(badge?.getAttribute("aria-label")).toBe("Created by agent: Task Robot"); expect(badge?.querySelector("span[aria-hidden='true']")?.textContent).toBe("Task Robot"); expect(badge?.querySelector(".visually-hidden")?.textContent).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?.closest(".card-agent-badge-row")).not.toBeNull(); expect(badge?.closest(".card-meta-badges")).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 with cached source agent names before falling back to ids", () => { seedAgentsCache("p1", [{ id: "legacy-agent-1", name: "Legacy Robot" }]); const { container } = render( , ); const badge = container.querySelector(".card-agent-created-badge"); expect(badge).not.toBeNull(); expect(badge?.closest(".card-agent-badge-row")).not.toBeNull(); expect(badge?.getAttribute("title")).toBe("Created by agent: Legacy Robot"); expect(badge?.querySelector("span[aria-hidden='true']")?.textContent).toBe("Legacy Robot"); }); it("falls back to the generic Agent label when source type is agent-created without a resolvable name", () => { const { container } = render( , ); const badge = container.querySelector(".card-agent-created-badge"); expect(badge).not.toBeNull(); expect(badge?.closest(".card-agent-badge-row")).not.toBeNull(); expect(badge?.getAttribute("title")).toBe("Created by agent"); expect(badge?.getAttribute("aria-label")).toBe("Created by agent"); expect(badge?.querySelector("span[aria-hidden='true']")?.textContent).toBe("Agent"); expect(badge?.querySelector(".visually-hidden")?.textContent).toBe("Created by agent"); }); it("does not render agent-created provenance badge for non-agent task sources", () => { const { container } = render( , ); expect(container.querySelector(".card-agent-created-badge")).toBeNull(); expect(container.querySelector(".card-agent-badge-row")).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")?.closest(".card-agent-badge-row")).not.toBeNull(); expect(container.querySelector(".card-time-indicator")).not.toBeNull(); }); it("FN-4511 keeps GitHub badge sizing tokens aligned with card-time-indicator", () => { const baseCss = loadAllAppCssBaseOnly(); expect(baseCss).toMatch(/\.card-github-badge\s*\{[^}]*padding:\s*var\(--space-xs\)\s+var\(--space-sm\);[^}]*\}/); expect(baseCss).toMatch(/\.card-github-badge\s*\{[^}]*font-size:\s*0\.6875rem;[^}]*\}/); expect(baseCss).toMatch(/\.card-github-badge\s*\{[^}]*gap:\s*var\(--space-xs\);[^}]*\}/); expect(baseCss).toMatch(/\.card-github-badge\s*\{[^}]*font-family:\s*var\(--font-mono\);[^}]*\}/); const fullCss = loadAllAppCss(); expect(fullCss).toMatch(/@media[^{]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.card-github-badge\s*\{[^}]*font-size:\s*0\.625rem;[^}]*\}/); }); it("FN-4525 defines shared card-chip height tokens and applies them to badges and chips", () => { const baseCss = loadAllAppCssBaseOnly(); expect(baseCss).toMatch(/:root\s*\{[^}]*--card-chip-height:\s*22px;[^}]*--card-chip-height-mobile:\s*20px;[^}]*\}/); expect(baseCss).toMatch(/\.card-github-badge\s*\{[^}]*height:\s*var\(--card-chip-height\);[^}]*\}/); expect(baseCss).toMatch(/\.card-time-indicator\s*,\s*\.card-cost-indicator\s*,\s*\.card-github-tracking-chip\s*,\s*\.card-retry-badge\s*,\s*\.card-create-pr-action\s*\{[^}]*height:\s*var\(--card-chip-height\);[^}]*\}/); }); it("FN-4525 applies shared mobile card-chip height token to badges and chips", () => { const fullCss = loadAllAppCss(); expect(fullCss).toMatch(/@media[^{]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.card-github-badge\s*\{[^}]*height:\s*var\(--card-chip-height-mobile\);[^}]*\}/); expect(fullCss).toMatch(/@media[^{]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.card-time-indicator\s*,\s*\.card-cost-indicator\s*,\s*\.card-github-tracking-chip\s*,\s*\.card-retry-badge\s*,\s*\.card-create-pr-action\s*\{[^}]*height:\s*var\(--card-chip-height-mobile\);[^}]*\}/); }); it("keeps Create PR action on shared chip height tokens", () => { const baseCss = loadAllAppCssBaseOnly(); const fullCss = loadAllAppCss(); expect(baseCss).toMatch(/\.card-create-pr-action\s*\{[^}]*height:\s*var\(--card-chip-height\);[^}]*\}/); expect(fullCss).toMatch(/@media[^{]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.card-create-pr-action\s*\{[^}]*height:\s*var\(--card-chip-height-mobile\);[^}]*\}/); }); it("FN-4511 keeps GitHub badge and timer chip geometry in parity", () => { const cleanupCss = mountCssForBadgeTests(); try { const { container } = render( , ); const githubBadge = container.querySelector(".card-github-badge") as HTMLElement; const timeIndicator = container.querySelector(".card-time-indicator") as HTMLElement; expect(githubBadge).toBeDefined(); expect(timeIndicator).toBeDefined(); const githubStyles = getComputedStyle(githubBadge); const timeStyles = getComputedStyle(timeIndicator); expect(githubStyles.padding).toBe(timeStyles.padding); expect(githubStyles.fontSize).toBe(timeStyles.fontSize); expect(githubStyles.lineHeight).toBe(timeStyles.lineHeight); const githubBorderTopWidth = githubStyles.borderTopWidth || "1px"; const timeBorderTopWidth = timeStyles.borderTopWidth || "1px"; const githubBorderBottomWidth = githubStyles.borderBottomWidth || "1px"; const timeBorderBottomWidth = timeStyles.borderBottomWidth || "1px"; expect(githubBorderTopWidth).toBe(timeBorderTopWidth); expect(githubBorderBottomWidth).toBe(timeBorderBottomWidth); expect(githubStyles.gap).toBe(timeStyles.gap); if (githubBadge.offsetHeight > 0 || timeIndicator.offsetHeight > 0) { expect(githubBadge.offsetHeight).toBe(timeIndicator.offsetHeight); } else { expect(githubStyles.height).not.toBe(""); expect(githubStyles.height).toBe(timeStyles.height); } } finally { cleanupCss(); } }); it("FN-4511 preserves transparent border slot on .card-github-badge", () => { const css = loadAllAppCssBaseOnly(); expect(css).toMatch(/\.card-github-badge\s*\{[^}]*border:\s*1px\s+solid\s+transparent;[^}]*\}/); }); it.each([ { name: "uses live diff stats over stale mergeDetails", diff: { stats: { filesChanged: 2, additions: 4, deletions: 1 }, loading: false }, mergeDetails: { filesChanged: 108 }, expectedLabel: "2 files changed", }, { name: "uses mergeDetails as transient placeholder while loading", diff: { stats: null, loading: true }, mergeDetails: { filesChanged: 108 }, expectedLabel: "108 files changed", }, { name: "hides badge when fetch resolved null and no execution fallback exists", diff: { stats: null, loading: false }, mergeDetails: { filesChanged: 108 }, expectedLabel: null, }, { name: "hides badge when live diff resolves zero", diff: { stats: { filesChanged: 0, additions: 0, deletions: 0 }, loading: false }, mergeDetails: { filesChanged: 108 }, expectedLabel: null, }, { name: "clamps live diff count when landed files are attribution-restricted", diff: { stats: { filesChanged: 5, additions: 10, deletions: 2 }, loading: false }, mergeDetails: { landedFilesAttributionRestricted: true, landedFiles: ["a.ts"] }, expectedLabel: "1 file changed", }, { name: "does not clamp when attribution restriction is absent", diff: { stats: { filesChanged: 5, additions: 10, deletions: 2 }, loading: false }, mergeDetails: { landedFiles: ["a.ts"] }, expectedLabel: "5 files changed", }, { name: "uses singular grammar for one live file", diff: { stats: { filesChanged: 1, additions: 1, deletions: 0 }, loading: false }, mergeDetails: undefined, expectedLabel: "1 file changed", }, ])("FN-4527 done-task files changed contract: $name", ({ diff, mergeDetails, expectedLabel }) => { useTaskDiffStatsMock.mockReturnValue(diff); render( , ); if (expectedLabel) { expect(screen.getByRole("button", { name: expectedLabel })).toBeDefined(); return; } const filesChangedButton = document.querySelector(".card-session-files"); expect(filesChangedButton).toBeNull(); }); it("backfills done-card files-changed chip when mergeDetails enrichment arrives without remount", () => { useTaskDiffStatsMock.mockImplementation((...args: any[]) => { const options = args[4] as { mergeSignature?: string } | undefined; if (options?.mergeSignature === "3:3") { return { stats: { filesChanged: 3, additions: 9, deletions: 2 }, loading: false }; } return { stats: null, loading: false }; }); const baseTask = makeTask({ column: "done", mergeDetails: { commitSha: "abc123", insertions: 10, deletions: 2, mergedAt: "2026-04-25T15:00:00.000Z", mergeConfirmed: true, }, }); const { rerender } = render( , ); expect(screen.queryByRole("button", { name: /files changed/i })).toBeNull(); expect(useTaskDiffStatsMock).toHaveBeenLastCalledWith( "FN-001", "done", "abc123", undefined, expect.objectContaining({ mergeSignature: ":" }), ); rerender( , ); expect(screen.getByRole("button", { name: "3 files changed" })).toBeDefined(); expect(useTaskDiffStatsMock).toHaveBeenLastCalledWith( "FN-001", "done", "abc123", undefined, expect.objectContaining({ mergeSignature: "3:3" }), ); }); it("prefers landedFiles fallback files-changed label for done tasks when lineage stats are unavailable", () => { const onOpenDetailWithTab = vi.fn(); useTaskDiffStatsMock.mockReturnValue({ stats: null, loading: false }); render( , ); const landedButton = screen.getByRole("button", { name: "2 files changed" }); expect(landedButton).toBeDefined(); fireEvent.click(landedButton); expect(onOpenDetailWithTab).toHaveBeenCalledTimes(1); expect(onOpenDetailWithTab.mock.calls[0]?.[1]).toBe("changes"); }); it("hides the done-task file chip when only execution-touched modifiedFiles exist", () => { useTaskDiffStatsMock.mockReturnValue({ stats: null, loading: false }); const { container } = render( , ); expect(screen.queryByText(/touched during execution/i)).toBeNull(); expect(screen.queryByRole("button", { name: /files changed/i })).toBeNull(); expect(container.querySelector(".card-session-files")).toBeNull(); }); it("prefers lineage files-changed stats over stale execution-touched modifiedFiles for done tasks", () => { useTaskDiffStatsMock.mockReturnValue({ stats: { filesChanged: 4, additions: 12, deletions: 3 }, loading: false, }); render( , ); expect(screen.getByRole("button", { name: "4 files changed" })).toBeDefined(); expect(screen.queryByText(/touched during execution/i)).toBeNull(); expect(screen.queryByText(/in merged commit/i)).toBeNull(); }); it("uses singular 'file changed' grammar for landedFiles-only done tasks", () => { useTaskDiffStatsMock.mockReturnValue({ stats: null, loading: false }); render( , ); expect(screen.getByRole("button", { name: "1 file changed" })).toBeDefined(); }); it("hides done-task file chip when lineage stats are unavailable and no execution fallback exists", () => { useTaskDiffStatsMock.mockReturnValue({ stats: null, loading: false }); const { container } = render( , ); expect(container.querySelector(".card-session-files")).toBeNull(); }); it("renders files-changed metadata and timer chip in footer row", () => { useTaskDiffStatsMock.mockReturnValue({ stats: { filesChanged: 4, additions: 10, deletions: 2 }, loading: false, }); 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(); const rightCluster = container.querySelector(".card-footer-row-right"); expect(footerRow?.contains(filesChanged)).toBe(true); expect(footerRow?.contains(timer)).toBe(true); expect(header?.contains(timer)).toBe(false); expect(timer?.closest(".card-footer-row-right")).toBe(rightCluster); expect(rightCluster).not.toBeNull(); expect(Array.from(footerRow?.children ?? [])).toEqual([filesChanged, rightCluster]); }); it("shows timer chip for in-review cards", () => { const { container } = render( , ); const timer = container.querySelector(".card-time-indicator"); expectTimerInFooterRight(container); 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"); expectTimerInFooterRight(container); 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("shows cumulative runtime across a user reopen", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-15T13:17:00.000Z")); const { container } = render( , ); const timer = container.querySelector(".card-time-indicator"); expectTimerInFooterRight(container); expect(timer?.textContent).toContain("6m"); expect(timer?.getAttribute("title")).toBe("Execution time 6m"); }); it("keeps legacy wall-clock timers after firstExecutionAt migration backfill", () => { const { container } = render( , ); const timer = container.querySelector(".card-time-indicator"); expect(timer).not.toBeNull(); expect(timer?.textContent).toContain("30m"); }); it("renders the optional cost badge only when enabled and token usage exists", () => { const pricedTask = makeTask({ column: "done", tokenUsage: { inputTokens: 1_000_000, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1_000_000, firstUsedAt: "2026-01-01T00:00:00Z", lastUsedAt: "2026-01-01T00:00:00Z", modelProvider: "openai", modelId: "gpt-5-mini", }, } as Partial); const disabled = render(); expect(disabled.container.querySelector(".card-cost-indicator")).toBeNull(); disabled.unmount(); const enabled = render( , ); const costBadge = enabled.container.querySelector(".card-cost-indicator") as HTMLElement | null; expect(costBadge).not.toBeNull(); expect(costBadge?.textContent).toContain("$0.25"); expect(costBadge?.querySelector("svg")).toBeNull(); expect(costBadge?.getAttribute("aria-label")).toBe("Estimated cost $0.25"); expect(costBadge?.getAttribute("title")).toBe("Estimated cost $0.25"); expect(costBadge?.closest(".card-footer-row-right")).toBe(enabled.container.querySelector(".card-footer-row-right")); expect(costBadge?.closest(".card-footer-row")).toBe(enabled.container.querySelector(".card-footer-row")); expect(costBadge?.closest(".card-meta")).toBeNull(); enabled.unmount(); const noUsage = render( , ); expect(noUsage.container.querySelector(".card-cost-indicator")).toBeNull(); }); it("places a todo cost badge inside the meta row when the footer has no leading content", () => { const { container } = render( )} onOpenDetail={noop} addToast={noop} /> , ); const costBadge = container.querySelector(".card-cost-indicator") as HTMLElement | null; const metaRow = container.querySelector(".card-meta"); const rightCluster = container.querySelector(".card-footer-row-right"); expect(costBadge).not.toBeNull(); expect(costBadge?.textContent).toContain("$0.25"); expect(costBadge?.closest(".card-meta")).toBe(metaRow); expect(costBadge?.closest(".card-footer-row")).toBeNull(); expect(rightCluster?.closest(".card-meta")).toBe(metaRow); expect(rightCluster?.contains(costBadge)).toBe(true); expect(container.querySelector(".card-footer-row")).toBeNull(); }); it("places the unavailable cost sentinel inside todo meta without adding an icon", () => { const { container } = render( )} onOpenDetail={noop} addToast={noop} /> , ); const costBadge = container.querySelector(".card-cost-indicator") as HTMLElement | null; expect(costBadge).not.toBeNull(); expect(costBadge?.textContent).toContain("—"); expect(costBadge?.querySelector("svg")).toBeNull(); expect(costBadge?.getAttribute("aria-label")).toBe("Estimated cost —"); expect(costBadge?.getAttribute("title")).toBe("Estimated cost —"); expect(costBadge?.closest(".card-meta")).toBe(container.querySelector(".card-meta")); expect(costBadge?.closest(".card-footer-row")).toBeNull(); expect(container.querySelector(".card-footer-row")).toBeNull(); }); it("keeps in-progress cost badges in the footer row with files changed", () => { const { container } = render( )} onOpenDetail={noop} addToast={noop} /> , ); const costBadge = container.querySelector(".card-cost-indicator") as HTMLElement | null; const footerRow = container.querySelector(".card-footer-row"); const rightCluster = container.querySelector(".card-footer-row-right"); expect(container.querySelector(".card-session-files")).not.toBeNull(); expect(costBadge).not.toBeNull(); expect(costBadge?.closest(".card-footer-row")).toBe(footerRow); expect(costBadge?.closest(".card-footer-row-right")).toBe(rightCluster); expect(costBadge?.closest(".card-meta")).toBeNull(); }); 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"); expectTimerInFooterRight(container); 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 near-duplicate chip", () => { it("renders duplicate chip when nearDuplicateOf is present", () => { render( , ); expect(screen.getByText("Duplicate of FN-1234")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Keep this task and dismiss duplicate warning" })).toBeInTheDocument(); }); it("hides duplicate chip when nearDuplicateDismissed is true", () => { render( , ); expect(screen.queryByText("Duplicate of FN-1234")).toBeNull(); }); it("hides duplicate chip when parent resolves the canonical as inactive or missing", () => { render( , ); expect(screen.queryByText("Duplicate of FN-1234")).toBeNull(); }); it("renders duplicate chip when canonical activity is unknown", () => { render( , ); expect(screen.getByText("Duplicate of FN-1234")).toBeInTheDocument(); }); it("hides duplicate chip in archived and done columns", () => { const { rerender } = render( , ); expect(screen.queryByText("Duplicate of FN-1234")).toBeNull(); rerender( , ); expect(screen.queryByText("Duplicate of FN-1234")).toBeNull(); }); it("clicking Keep calls updateTask dismissNearDuplicate", async () => { const onUpdateTask = vi.fn().mockResolvedValue(makeTask()); render( , ); fireEvent.click(screen.getByRole("button", { name: "Keep this task and dismiss duplicate warning" })); await waitFor(() => { expect(onUpdateTask).toHaveBeenCalledWith("FN-001", { dismissNearDuplicate: true }); }); }); }); /** * FNXC:TaskRevert 2026-07-04-00:00: * FN-7555 forward affordance coverage. Mirrors the near-duplicate chip test shape * above: an AI-undo task (`sourceMetadata.revertOf` set by `createAiUndoTask`) shows * an "Undo of " footer chip; an ordinary task without that marker renders no chip * and no empty footer shell. */ describe("TaskCard undo-of chip", () => { it("renders undo-of chip when sourceMetadata.revertOf is present", () => { render( , ); expect(screen.getByText("Undo of FN-1234")).toBeInTheDocument(); }); it("renders no undo-of chip and no empty footer shell for an ordinary task", () => { render( , ); expect(screen.queryByText(/Undo of/)).toBeNull(); expect(document.querySelector(".card-undo-chip")).toBeNull(); }); it("does not throw and renders nothing for malformed sourceMetadata.revertOf", () => { render( , ); expect(screen.queryByText(/Undo of/)).toBeNull(); }); }); describe("TaskCard memo comparator provenance behavior", () => { it("returns false when prAuthAvailable changes", () => { const task = makeTask({ column: "in-review" }); expect( __test_areTaskCardPropsEqual( { task, onOpenDetail: noop, addToast: noop, prAuthAvailable: false } as any, { task, onOpenDetail: noop, addToast: noop, prAuthAvailable: true } as any, ), ).toBe(false); }); 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 board context-menu action handlers change", () => { const task = makeTask(); const actionHandler = vi.fn(); expect( __test_areTaskCardPropsEqual( { task, onOpenDetail: noop, addToast: noop, onPauseTask: actionHandler } as any, { task, onOpenDetail: noop, addToast: noop, onUnpauseTask: actionHandler } 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); }); it.each([ { name: "mergeable", patch: { mergeable: "conflicting" } }, { name: "draft", patch: { draft: true } }, { name: "isDraft", patch: { isDraft: true } }, ])("returns false when multi-PR badge $name color input changes", ({ patch }) => { const basePr = { url: "https://github.com/owner/repo/pull/42", number: 42, status: "open" as const, title: "PR", headBranch: "fusion/fn-001", baseBranch: "main", commentCount: 0, }; const secondPr = { ...basePr, url: "https://github.com/owner/repo/pull/99", number: 99, title: "PR 2" }; const previousTask = makeTask({ prInfos: [basePr, secondPr] as any }); const nextTask = makeTask({ prInfos: [{ ...basePr, ...patch }, secondPr] as any }); expect( __test_areTaskCardPropsEqual( { task: previousTask, onOpenDetail: noop, addToast: noop } as any, { task: nextTask, onOpenDetail: noop, addToast: noop } as any, ), ).toBe(false); }); it("skips customFields JSON.stringify when both cardFieldDefs are absent", () => { // Without cardFieldDefs present, two tasks with different customFields should // compare equal (JSON.stringify is skipped — guard path). const taskA = makeTask({ customFields: { x: "a" } }); const taskB = makeTask({ customFields: { x: "b" } }); expect( __test_areTaskCardPropsEqual( { task: taskA, onOpenDetail: noop, addToast: noop } as any, { task: taskB, onOpenDetail: noop, addToast: noop } as any, ), ).toBe(true); }); it("detects customFields change when cardFieldDefs are present on both sides", () => { const defs = [{ id: "sev", name: "Severity", type: "enum" as const, render: { placement: "card" as const } }]; const taskA = makeTask({ customFields: { sev: "low" } }); const taskB = makeTask({ customFields: { sev: "high" } }); expect( __test_areTaskCardPropsEqual( { task: taskA, cardFieldDefs: defs, onOpenDetail: noop, addToast: noop } as any, { task: taskB, cardFieldDefs: defs, onOpenDetail: noop, addToast: noop } as any, ), ).toBe(false); }); it("detects customFields change when only one side has cardFieldDefs", () => { const defs = [{ id: "sev", name: "Severity", type: "enum" as const, render: { placement: "card" as const } }]; const task = makeTask({ customFields: { sev: "low" } }); expect( __test_areTaskCardPropsEqual( { task, cardFieldDefs: undefined, onOpenDetail: noop, addToast: noop } as any, { task, cardFieldDefs: defs, onOpenDetail: noop, addToast: noop } as any, ), ).toBe(false); }); it("detects workflow badge metadata changes", () => { const task = makeTask(); expect( __test_areTaskCardPropsEqual( { task, workflowBadge: { workflowId: "builtin:coding", workflowName: "Coding" }, onOpenDetail: noop, addToast: noop } as any, { task, workflowBadge: { workflowId: "wf-custom", workflowName: "Custom Flow" }, onOpenDetail: noop, addToast: noop } as any, ), ).toBe(false); }); }); describe("TaskCard workflow badges", () => { it("renders a compact accessible workflow badge only when metadata is present", () => { const { container, rerender } = render( , ); const badge = screen.getByTestId("card-workflow-badge"); const row = screen.getByTestId("card-workflow-badge-row"); const workflowBadgeBlock = [...loadAllAppCssBaseOnly().matchAll(/^\.card-workflow-badge\s*\{([^}]*)\}/gm)] .map((match) => match[1]) .join("\n"); expect(badge).toHaveTextContent("Custom Flow"); expect(badge.querySelector(".workflow-icon")).not.toBeNull(); expect(badge.querySelector(".workflow-icon")?.nextElementSibling).toHaveTextContent("Custom Flow"); expect(workflowBadgeBlock).toContain("column-gap: calc(var(--space-xs) / 2);"); expect(badge).toHaveAttribute("data-workflow-id", "wf-custom"); expect(badge).toHaveAccessibleName("Workflow Custom Flow"); expect(row).toContainElement(badge); expect(badge.closest(".card-meta-badges")).toBeNull(); expect(container.querySelector(".card-meta-badges")).toBeNull(); rerender( , ); expect(screen.queryByTestId("card-workflow-badge")).toBeNull(); expect(screen.queryByTestId("card-workflow-badge-row")).toBeNull(); }); it("keeps top badges in the meta cluster while rendering agent and workflow identity below card rows", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-06-30T12:10:00.000Z")); const { container } = render( , ); const metaBadges = screen.getByTestId("card-meta-badges"); const badge = screen.getByTestId("card-workflow-badge"); const workflowRow = screen.getByTestId("card-workflow-badge-row"); const agentRow = screen.getByTestId("card-agent-badge-row"); const agentBadge = container.querySelector(".card-agent-created-badge"); expect(metaBadges.querySelector(".card-priority-badge")).not.toBeNull(); expect(metaBadges.querySelector(".card-execution-mode-badge")).not.toBeNull(); expect(metaBadges.querySelector(".card-agent-created-badge")).toBeNull(); expect(metaBadges.querySelector(".card-workflow-badge")).toBeNull(); expect(agentRow).toContainElement(agentBadge as HTMLElement); expect(agentBadge?.closest(".card-header")).toBeNull(); expect(workflowRow).toContainElement(badge); expect(agentRow.compareDocumentPosition(workflowRow) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(container.querySelector(".card-footer-row")).toBeNull(); [".card-meta", ".card-agent-row"].forEach((selector) => { const row = container.querySelector(selector); expect(row, `${selector} should render for the placement fixture`).not.toBeNull(); expect(row!.compareDocumentPosition(workflowRow) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); expect(badge.closest(".card-action-row")).toBeNull(); vi.useRealTimers(); }); }); 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("renders a promote action when onPromote is provided", () => { const onPromote = vi.fn().mockResolvedValue(undefined); const style = document.createElement("style"); style.textContent = loadAllAppCss(); document.head.appendChild(style); try { render( , ); const promoteButton = screen.getByTestId("card-promote-FN-777"); expect(promoteButton).toBeDefined(); expect(promoteButton).toHaveClass("card-promote-action"); expect(promoteButton.textContent).toContain("Promote"); const styles = getComputedStyle(promoteButton); expect(styles.gap).toBe("var(--space-xs)"); expect(styles.padding).toBe("var(--space-xs) var(--space-sm)"); } finally { style.remove(); } }); it("right-aligns the promote action inside the card action row", () => { const onPromote = vi.fn().mockResolvedValue(undefined); const css = loadAllAppCssBaseOnly(); render( , ); const promoteButton = screen.getByTestId("card-promote-FN-781"); const actionRow = promoteButton.closest(".card-action-row"); expect(actionRow).not.toBeNull(); expect(actionRow?.contains(promoteButton)).toBe(true); expect(css).toMatch(/\.card-promote-action\s*\{[^}]*margin-left:\s*auto;[^}]*\}/); expect(css).toMatch(/\.card-promote-action\.card-send-back-btn\s*\{[^}]*margin-left:\s*auto;[^}]*\}/); }); it("keeps the promote action right-aligned in the mobile card action row", () => { const css = loadAllAppCss(); const onPromote = vi.fn().mockResolvedValue(undefined); const soloRender = render( , ); const soloPromoteButton = screen.getByTestId("card-promote-FN-782"); expect(soloPromoteButton.closest(".card-action-row")?.children).toHaveLength(1); expect(soloPromoteButton).toHaveClass("card-promote-action", "card-send-back-btn"); soloRender.unmount(); render( , ); const createPrButton = screen.getByRole("button", { name: "Create pull request" }); const promoteButton = screen.getByTestId("card-promote-FN-783"); const actionRow = promoteButton.closest(".card-action-row"); expect(actionRow).not.toBeNull(); expect(actionRow?.contains(createPrButton)).toBe(true); expect(createPrButton.compareDocumentPosition(promoteButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(css).toMatch(/@media[^{}]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.card-promote-action\.card-send-back-btn\s*\{[^}]*margin-left:\s*auto;[^}]*\}/); }); it("calls onPromote without opening the card when promote is clicked", () => { const onPromote = vi.fn().mockResolvedValue(undefined); const onOpenDetail = vi.fn(); render( , ); fireEvent.click(screen.getByTestId("card-promote-FN-778")); expect(onPromote).toHaveBeenCalledWith("FN-778"); expect(onOpenDetail).not.toHaveBeenCalled(); }); it("disables the promote action and shows loading copy while promoting", () => { const onPromote = vi.fn().mockResolvedValue(undefined); render( , ); const promoteButton = screen.getByTestId("card-promote-FN-779") as HTMLButtonElement; expect(promoteButton.disabled).toBe(true); expect(promoteButton.textContent).toContain("Promoting…"); fireEvent.click(promoteButton); expect(onPromote).not.toHaveBeenCalled(); }); it("does not render a promote action when onPromote is omitted", () => { render( , ); expect(screen.queryByTestId("card-promote-FN-780")).toBeNull(); }); 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 Android tap regression", () => { function AndroidTapHarness({ task, onOpenDetail, onOpenDetailWithTab, onClose, }: { task: Task; onOpenDetail: (task: Task) => void; onOpenDetailWithTab: (task: Task, tab: "changes") => void; onClose: () => void; }) { const [isOpen, setIsOpen] = React.useState(false); const nav = useNavigationHistory({ enabled: true }); const overlayDismiss = useOverlayDismiss(() => { onClose(); setIsOpen(false); }); return ( { onOpenDetail(nextTask as Task); setIsOpen(true); nav.pushNav({ type: "modal", close: () => { onClose(); setIsOpen(false); }, }); }} onOpenDetailWithTab={onOpenDetailWithTab} addToast={noop} /> {isOpen && (
detail
)}
); } it("keeps modal open after Android compatibility mouse sequence and supports popstate close", () => { const onOpenDetail = vi.fn(); const onOpenDetailWithTab = vi.fn(); const onClose = vi.fn(); const pushStateSpy = vi.spyOn(window.history, "pushState"); render( , ); const card = document.querySelector(".card") as HTMLElement; fireEvent.touchStart(card, { touches: [{ clientX: 20, clientY: 20 }], changedTouches: [{ clientX: 20, clientY: 20 }], }); fireEvent.touchEnd(card, { touches: [], changedTouches: [{ clientX: 20, clientY: 20 }], }); expect(onOpenDetail).toHaveBeenCalledTimes(1); expect(pushStateSpy).toHaveBeenCalledTimes(1); const overlay = screen.getByTestId("android-modal-overlay"); fireEvent.mouseDown(overlay); fireEvent.mouseUp(overlay); expect(onClose).toHaveBeenCalledTimes(0); window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } })); expect(onClose).toHaveBeenCalledTimes(1); }); it("keeps files-changed chip touch path opening changes tab once", () => { const onOpenDetail = vi.fn(); const onOpenDetailWithTab = vi.fn(); const onClose = vi.fn(); const task = makeTask({ column: "done", status: undefined, mergeDetails: { landedFiles: ["a.ts", "b.ts"] }, } as any); render( , ); const filesChip = screen.getByRole("button", { name: "2 files changed" }); fireEvent.touchStart(filesChip, { touches: [{ clientX: 12, clientY: 12 }], changedTouches: [{ clientX: 12, clientY: 12 }], }); fireEvent.touchEnd(filesChip, { touches: [], changedTouches: [{ clientX: 12, clientY: 12 }], }); fireEvent.click(filesChip); expect(onOpenDetailWithTab).toHaveBeenCalledTimes(1); expect(onOpenDetailWithTab).toHaveBeenCalledWith(task, "changes"); expect(onOpenDetail).toHaveBeenCalledTimes(0); expect(onClose).toHaveBeenCalledTimes(0); }); it("allows horizontal board pan from every non-editing card descendant on mobile", () => { const css = loadAllAppCssBaseOnly(); expect(css).toMatch( /\.card:not\(\.card-editing\)\s*,\s*\.card:not\(\.card-editing\)\s+\*\s*\{[^}]*touch-action:\s*pan-x\s+pan-y;[^}]*\}/, ); }); }); 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?.(); localStorage.clear(); vi.mocked(fetchAgent).mockReset(); vi.mocked(fetchAgents).mockReset(); vi.mocked(fetchAgents).mockResolvedValue([] as any); }); it("renders agent badge synchronously from the seeded agents cache", () => { seedAgentsCache("p1", [{ id: "agent-001", name: "Task Robot" }]); const { container } = render( , ); const badge = container.querySelector(".card-agent-badge"); expect(badge).not.toBeNull(); expect(badge?.getAttribute("title")).toBe("Assigned to Task Robot"); expect(badge?.className).not.toContain("card-agent-badge--loading"); expect(badge?.querySelector(".card-agent-badge-text")?.textContent).toBe("Task Robot"); expect(badge?.querySelector(".visually-hidden")?.textContent).toContain("Assigned to Task Robot"); expect(fetchAgent).not.toHaveBeenCalled(); }); it("renders agent badge when task has assignedAgentId and falls back to fetchAgent on cache miss", 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(); }); }); describe("TaskCard custom field badges (U13/KTD-14)", () => { type FieldDef = import("../../api").WorkflowFieldDefinition; const cardDef = (over: Partial & Pick): FieldDef => ({ render: { placement: "card" }, ...over, }); it("renders no badges and stays byte-identical when no field defs are passed", () => { const { container: withTask } = render( , ); expect(withTask.querySelector('[data-testid="card-field-badges"]')).toBeNull(); }); it("renders an enum badge with the option color and label", () => { const defs: FieldDef[] = [ cardDef({ id: "sev", name: "Severity", type: "enum", options: [{ value: "high", label: "High", color: "#ef4444" }] }), ]; render( , ); const badge = screen.getByText("High"); expect(badge.getAttribute("style")).toContain("rgb(239, 68, 68)"); }); it("renders a labeled chip for boolean true and nothing for false", () => { const defs: FieldDef[] = [cardDef({ id: "blk", name: "Blocked", type: "boolean" })]; const { rerender } = render( , ); expect(screen.getByText("Blocked")).toBeTruthy(); rerender( , ); expect(screen.queryByTestId("card-field-badges")).toBeNull(); }); it("caps at 3 badges and shows a +N overflow indicator", () => { const defs: FieldDef[] = [ cardDef({ id: "a", name: "A", type: "string" }), cardDef({ id: "b", name: "B", type: "string" }), cardDef({ id: "c", name: "C", type: "string" }), cardDef({ id: "d", name: "D", type: "string" }), cardDef({ id: "e", name: "E", type: "string" }), ]; render( , ); const overflow = screen.getByTestId("card-field-overflow"); expect(overflow.textContent).toBe("+2"); // Exactly 3 value badges + 1 overflow chip. const container = screen.getByTestId("card-field-badges"); expect(container.querySelectorAll(".card-field-badge").length).toBe(4); }); it("ignores non-card-placed defs", () => { const defs: FieldDef[] = [ { id: "detailOnly", name: "Detail", type: "string", render: { placement: "detail" } }, ]; render( , ); expect(screen.queryByTestId("card-field-badges")).toBeNull(); }); }); /* FNXC:CodingIdeasWorkflow 2026-07-05-00:00: FN-7596 regression-tests the TaskCard "Start" affordance that promotes a Coding (Ideas) manual-intake card. `showStartAction` requires taskColumnFlags.intake and a non-"triage" column; `startTargetColumn` derives the destination from `taskMoveColumns` (first non-intake/non-archived/non-hiddenFromBoard column) rather than a hard-coded "todo" string, per the FNXC comment at its call site. */ describe("TaskCard Start affordance (FN-7596)", () => { it("renders the Start button for a manual-intake column with onMoveTask provided", () => { render( , ); expect(screen.getByTestId("card-start-FN-001")).toBeInTheDocument(); }); it("omits the Start button when the column is not flagged as an intake", () => { render( , ); expect(screen.queryByTestId("card-start-FN-001")).toBeNull(); }); it("omits the Start button for the triage column even when intake is flagged", () => { render( , ); expect(screen.queryByTestId("card-start-FN-001")).toBeNull(); }); it("omits the Start button when no onMoveTask handler is provided", () => { render( , ); expect(screen.queryByTestId("card-start-FN-001")).toBeNull(); }); it("derives the Start target from taskMoveColumns instead of a hard-coded 'todo' string", async () => { const onMoveTask = vi.fn().mockResolvedValue(makeTask({ column: "custom-working-stage" as any })); const addToast = vi.fn(); // The intake column itself, plus a non-intake working column that is NOT literally // named "todo", must win over any coincidental fallback — proving derivation, not a hard-coded string. const taskMoveColumns = [ { id: "ideas" as any, label: "Ideas", flags: { intake: true } }, { id: "custom-working-stage" as any, label: "Custom Working Stage", flags: {} }, { id: "todo" as any, label: "Todo", flags: {} }, ]; render( , ); fireEvent.click(screen.getByTestId("card-start-FN-001")); await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-001", "custom-working-stage")); }); it("falls back to 'todo' when taskMoveColumns metadata is unavailable", async () => { const onMoveTask = vi.fn().mockResolvedValue(makeTask({ column: "todo" })); render( , ); fireEvent.click(screen.getByTestId("card-start-FN-001")); await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-001", "todo")); }); it("disables the button and shows the Starting label while the move is in flight, then shows a success toast", async () => { let resolveMove: (task: ReturnType) => void = () => {}; const onMoveTask = vi.fn().mockImplementation( () => new Promise((resolve) => { resolveMove = resolve; }), ); const addToast = vi.fn(); render( , ); const startButton = screen.getByTestId("card-start-FN-001"); fireEvent.click(startButton); await waitFor(() => expect(startButton).toBeDisabled()); expect(startButton.textContent).toContain("Starting"); resolveMove(makeTask({ column: "todo" })); await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.stringContaining("FN-001"), "success")); await waitFor(() => expect(startButton).not.toBeDisabled()); }); it("shows an error toast when the Start move fails", async () => { const onMoveTask = vi.fn().mockRejectedValue(new Error("move blocked")); const addToast = vi.fn(); render( , ); fireEvent.click(screen.getByTestId("card-start-FN-001")); await waitFor(() => expect(addToast).toHaveBeenCalledWith("move blocked", "error")); }); });