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