import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Column, Task, TaskDetail } from "@fusion/core";
import { TaskCard } from "../TaskCard";
import React, { useState } from "react";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
// Resolve paths relative to this test file so tests pass regardless of cwd
// (a global test safety guard may change cwd to a per-worker temp dir).
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
vi.mock("../../api", () => ({
fetchTaskDetail: vi.fn(),
uploadAttachment: vi.fn(),
fetchMission: vi.fn(),
fetchAgent: vi.fn(),
}));
const mockUseBadgeWebSocket = vi.fn(() => ({
badgeUpdates: new Map(),
isConnected: false,
subscribeToBadge: vi.fn(),
unsubscribeFromBadge: vi.fn(),
}));
vi.mock("../../hooks/useBadgeWebSocket", () => ({
useBadgeWebSocket: () => mockUseBadgeWebSocket(),
}));
const mockUseSessionFiles = vi.fn(() => ({ files: [], loading: false }));
vi.mock("../../hooks/useSessionFiles", () => ({
useSessionFiles: (...args: unknown[]) => mockUseSessionFiles(...args),
}));
const mockUseTaskDiffStats = vi.fn(() => ({ stats: null, loading: false }));
vi.mock("../../hooks/useTaskDiffStats", () => ({
useTaskDiffStats: (...args: unknown[]) => mockUseTaskDiffStats(...args),
}));
vi.mock("lucide-react", () => ({
Link: ({ size }: { size?: number }) => 🔗,
Clock: ({ size }: { size?: number }) => 🕐,
Layers: ({ size }: { size?: number }) => 📚,
Pencil: ({ size }: { size?: number }) => ✏️,
ChevronDown: ({ size, className }: { size?: number; className?: string }) => ▼,
Folder: ({ size }: { size?: number }) => 📁,
Maximize2: ({ size }: { size?: number }) => ⛶,
GitPullRequest: ({ size }: { size?: number }) => 🔀,
CircleDot: ({ size }: { size?: number }) => ⭕,
Target: ({ size }: { size?: number }) => 🎯,
Bot: ({ size }: { size?: number }) => 🤖,
Trash2: ({ size }: { size?: number }) => 🗑️,
}));
// Mock usePluginUiSlots hook
const mockUsePluginUiSlots = vi.fn(() => ({
slots: [],
getSlotsForId: vi.fn(() => []),
loading: false,
error: null,
}));
vi.mock("../../hooks/usePluginUiSlots", () => ({
usePluginUiSlots: (...args: unknown[]) => mockUsePluginUiSlots(...args),
}));
beforeEach(() => {
mockUseBadgeWebSocket.mockReset();
mockUseBadgeWebSocket.mockReturnValue({
badgeUpdates: new Map(),
isConnected: false,
subscribeToBadge: vi.fn(),
unsubscribeFromBadge: vi.fn(),
});
mockUseSessionFiles.mockReset();
mockUseSessionFiles.mockReturnValue({ files: [], loading: false });
mockUseTaskDiffStats.mockReset();
mockUseTaskDiffStats.mockReturnValue({ stats: null, loading: false });
});
/**
* Tests for the agent-active class logic in TaskCard.
*
* These tests use extracted helper functions to test class computation logic directly.
*/
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]);
/** Mirrors the cardClass computation from TaskCard.tsx */
function computeCardClass(opts: { dragging?: boolean; queued?: boolean; status?: string; column?: Column; globalPaused?: boolean; isStuck?: boolean; isPaused?: boolean; isAwaitingApproval?: boolean }): string {
const { dragging = false, queued = false, status, column = "todo", globalPaused, isStuck = false, isPaused = false, isAwaitingApproval = false } = opts;
const isFailed = status === "failed";
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && (column === "in-progress" || ACTIVE_STATUSES.has(status as string));
return `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isStuck ? " stuck" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}`;
}
describe("TaskCard memoization", () => {
const createTask = (overrides: Partial = {}): Task => ({
id: "FN-001",
description: "Test task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
columnMovedAt: "2026-01-01T00:00:00Z",
...overrides,
} as Task);
it("does not re-render when parent re-renders with an equivalent task object", async () => {
const onOpenDetail = vi.fn();
const addToast = vi.fn();
const cardRenderSpy = vi.fn();
function MemoProbe({ task }: { task: Task }) {
cardRenderSpy();
return ;
}
// Custom comparator that uses deep equality for task objects (mirrors TaskCard's areTaskCardPropsEqual)
const MemoizedProbe = React.memo(MemoProbe, (prevProps, nextProps) => {
// Compare task objects by value using JSON serialization
// This matches the behavior of areTaskCardPropsEqual used by TaskCard
return JSON.stringify(prevProps.task) === JSON.stringify(nextProps.task);
});
function Harness() {
const [count, setCount] = useState(0);
const task = createTask();
return (
<>
>
);
}
render();
expect(cardRenderSpy).toHaveBeenCalledTimes(1);
await userEvent.click(screen.getByRole("button", { name: /rerender/i }));
expect(cardRenderSpy).toHaveBeenCalledTimes(1);
expect(screen.getByText("Test task")).toBeDefined();
});
it("re-renders when a render-relevant task field changes", () => {
const onOpenDetail = vi.fn();
const addToast = vi.fn();
const cardRenderSpy = vi.fn();
function MemoProbe({ task }: { task: Task }) {
cardRenderSpy();
return ;
}
const MemoizedProbe = React.memo(MemoProbe);
const { rerender } = render();
expect(cardRenderSpy).toHaveBeenCalledTimes(1);
rerender();
expect(cardRenderSpy).toHaveBeenCalledTimes(2);
expect(screen.getByText("Updated")).toBeDefined();
});
it("re-renders when dependency badges change", () => {
const onOpenDetail = vi.fn();
const addToast = vi.fn();
const cardRenderSpy = vi.fn();
function MemoProbe({ task }: { task: Task }) {
cardRenderSpy();
return ;
}
const MemoizedProbe = React.memo(MemoProbe);
const { rerender } = render();
expect(cardRenderSpy).toHaveBeenCalledTimes(1);
rerender();
expect(cardRenderSpy).toHaveBeenCalledTimes(2);
expect(screen.getAllByTitle(/Click to view/)).toHaveLength(2);
});
it("re-renders when PR badge data changes", () => {
const onOpenDetail = vi.fn();
const addToast = vi.fn();
const cardRenderSpy = vi.fn();
function MemoProbe({ task }: { task: Task }) {
cardRenderSpy();
return ;
}
const MemoizedProbe = React.memo(MemoProbe);
const basePrInfo = {
number: 42,
url: "https://github.com/example/repo/pull/42",
status: "open" as const,
title: "Initial PR",
headBranch: "fusion/fn-129",
baseBranch: "main",
lastCheckedAt: "2026-01-01T00:00:00Z",
};
const { rerender } = render();
expect(cardRenderSpy).toHaveBeenCalledTimes(1);
rerender(
,
);
expect(cardRenderSpy).toHaveBeenCalledTimes(2);
});
it("re-renders when step progress changes", () => {
const onOpenDetail = vi.fn();
const addToast = vi.fn();
const cardRenderSpy = vi.fn();
function MemoProbe({ task }: { task: Task }) {
cardRenderSpy();
return ;
}
const MemoizedProbe = React.memo(MemoProbe);
const { rerender } = render(
,
);
expect(cardRenderSpy).toHaveBeenCalledTimes(1);
rerender(
,
);
expect(cardRenderSpy).toHaveBeenCalledTimes(2);
expect(screen.getByText("1/1")).toBeDefined();
});
it("re-renders when blockedBy changes", () => {
const onOpenDetail = vi.fn();
const addToast = vi.fn();
const cardRenderSpy = vi.fn();
function MemoProbe({ task }: { task: Task }) {
cardRenderSpy();
return ;
}
const MemoizedProbe = React.memo(MemoProbe);
const { rerender } = render();
expect(cardRenderSpy).toHaveBeenCalledTimes(1);
rerender();
expect(cardRenderSpy).toHaveBeenCalledTimes(2);
expect(screen.getByText("FN-777")).toBeDefined();
});
it("re-renders when queued state changes", () => {
const onOpenDetail = vi.fn();
const addToast = vi.fn();
const cardRenderSpy = vi.fn();
function MemoProbe({ queued }: { queued?: boolean }) {
cardRenderSpy();
return ;
}
const MemoizedProbe = React.memo(MemoProbe);
const { rerender } = render();
expect(cardRenderSpy).toHaveBeenCalledTimes(1);
rerender();
expect(cardRenderSpy).toHaveBeenCalledTimes(2);
expect(screen.getByText(/Queued/)).toBeDefined();
});
});
describe("TaskCard agent-active class", () => {
it("applies agent-active for an active status (executing)", () => {
const cls = computeCardClass({ status: "executing" });
expect(cls).toContain("agent-active");
});
it("applies agent-active for all active statuses", () => {
for (const status of ["planning", "researching", "executing", "finalizing", "merging", "specifying"]) {
const cls = computeCardClass({ status });
expect(cls).toContain("agent-active");
}
});
it("does NOT apply agent-active when status is undefined and column is not in-progress", () => {
const cls = computeCardClass({});
expect(cls).not.toContain("agent-active");
});
it("does NOT apply agent-active for non-active status (idle) outside in-progress", () => {
const cls = computeCardClass({ status: "idle" });
expect(cls).not.toContain("agent-active");
});
it("does NOT apply agent-active for queued card even with active status", () => {
const cls = computeCardClass({ status: "executing", queued: true });
expect(cls).not.toContain("agent-active");
expect(cls).toContain("queued");
});
it("does NOT apply agent-active for queued card with no status", () => {
const cls = computeCardClass({ queued: true });
expect(cls).not.toContain("agent-active");
});
it("combines dragging and agent-active correctly", () => {
const cls = computeCardClass({ status: "executing", dragging: true });
expect(cls).toContain("agent-active");
expect(cls).toContain("dragging");
});
it("base card class is always present", () => {
expect(computeCardClass({})).toBe("card");
expect(computeCardClass({ status: "executing" })).toMatch(/^card /);
});
// Column-based agent-active tests
it("applies agent-active for in-progress column with no status", () => {
const cls = computeCardClass({ column: "in-progress" });
expect(cls).toContain("agent-active");
});
it("applies agent-active for in-progress column with an active status", () => {
const cls = computeCardClass({ column: "in-progress", status: "executing" });
expect(cls).toContain("agent-active");
});
it("does NOT apply agent-active for todo column with no status", () => {
const cls = computeCardClass({ column: "todo" });
expect(cls).not.toContain("agent-active");
});
it("applies agent-active for in-review column with active status (merging)", () => {
const cls = computeCardClass({ column: "in-review", status: "merging" });
expect(cls).toContain("agent-active");
});
it("does NOT apply agent-active for queued card in in-progress column", () => {
const cls = computeCardClass({ column: "in-progress", queued: true });
expect(cls).not.toContain("agent-active");
expect(cls).toContain("queued");
});
it("does NOT apply agent-active when status is 'failed' even in in-progress column", () => {
const cls = computeCardClass({ column: "in-progress", status: "failed" });
expect(cls).not.toContain("agent-active");
expect(cls).toContain("failed");
});
// globalPaused tests (hard stop suppresses glow; soft pause does not)
it("does NOT apply agent-active when globalPaused is true with active status", () => {
for (const status of ["planning", "researching", "executing", "finalizing", "merging", "specifying"]) {
const cls = computeCardClass({ status, globalPaused: true });
expect(cls).not.toContain("agent-active");
}
});
it("does NOT apply agent-active when globalPaused is true for in-progress column", () => {
const cls = computeCardClass({ column: "in-progress", globalPaused: true });
expect(cls).not.toContain("agent-active");
});
it("does NOT apply agent-active when globalPaused is true with active status and in-progress column", () => {
const cls = computeCardClass({ column: "in-progress", status: "executing", globalPaused: true });
expect(cls).not.toContain("agent-active");
});
it("applies agent-active when globalPaused is false with active status", () => {
const cls = computeCardClass({ status: "executing", globalPaused: false });
expect(cls).toContain("agent-active");
});
it("applies agent-active when globalPaused is undefined (backward compat)", () => {
const cls = computeCardClass({ status: "executing", globalPaused: undefined });
expect(cls).toContain("agent-active");
});
it("applies agent-active when only soft-paused (globalPaused is false)", () => {
// Soft pause (enginePaused) should NOT suppress the glow — only globalPaused matters
const cls = computeCardClass({ status: "executing", globalPaused: false });
expect(cls).toContain("agent-active");
});
it("applies agent-active for in-progress column when only soft-paused", () => {
const cls = computeCardClass({ column: "in-progress", globalPaused: false });
expect(cls).toContain("agent-active");
});
});
describe("TaskCard failed status", () => {
it("applies 'failed' class to card when status is 'failed'", () => {
const cls = computeCardClass({ status: "failed", column: "in-progress" });
expect(cls).toContain("failed");
expect(cls).not.toContain("agent-active");
});
it("does NOT apply 'failed' class for non-failed statuses", () => {
const cls = computeCardClass({ status: "executing", column: "in-progress" });
expect(cls).not.toContain("failed");
});
it("does NOT apply 'failed' class when status is undefined", () => {
const cls = computeCardClass({ column: "in-progress" });
expect(cls).not.toContain("failed");
});
/** Mirrors the badge style condition from TaskCard.tsx */
function shouldShowFailedBadge(status?: string | null): boolean {
return status === "failed";
}
it("shows failed badge when status is 'failed'", () => {
expect(shouldShowFailedBadge("failed")).toBe(true);
});
it("does NOT show failed badge for other statuses", () => {
expect(shouldShowFailedBadge("executing")).toBe(false);
expect(shouldShowFailedBadge(undefined)).toBe(false);
expect(shouldShowFailedBadge(null)).toBe(false);
});
});
describe("TaskCard stuck status", () => {
it("applies 'stuck' class to card when task is stuck", () => {
const cls = computeCardClass({ isStuck: true, column: "in-progress", status: "executing" });
expect(cls).toContain("stuck");
});
it("does NOT apply 'stuck' class when task is not stuck", () => {
const cls = computeCardClass({ column: "in-progress", status: "executing" });
expect(cls).not.toContain("stuck");
});
it("stuck takes precedence over agent-active", () => {
const cls = computeCardClass({ isStuck: true, column: "in-progress", status: "executing" });
expect(cls).toContain("stuck");
expect(cls).not.toContain("agent-active");
});
it("stuck and failed can coexist (stuck appears in class list)", () => {
const cls = computeCardClass({ isStuck: true, status: "failed", column: "in-progress" });
expect(cls).toContain("stuck");
expect(cls).toContain("failed");
});
it("stuck and paused can coexist", () => {
const cls = computeCardClass({ isStuck: true, isPaused: true, column: "in-progress" });
expect(cls).toContain("stuck");
expect(cls).toContain("paused");
});
});
describe("TaskCard dependency tooltip", () => {
/** Mirrors the data-tooltip computation from TaskCard.tsx */
function computeDepTooltip(dependencies: string[]): string | undefined {
if (dependencies.length === 0) return undefined;
return dependencies.join(", ");
}
it("returns comma-separated dependency IDs when dependencies are present", () => {
expect(computeDepTooltip(["FN-001", "FN-042"])).toBe("FN-001, FN-042");
});
it("returns single dependency ID when only one dependency", () => {
expect(computeDepTooltip(["FN-010"])).toBe("FN-010");
});
it("returns undefined when dependencies array is empty", () => {
expect(computeDepTooltip([])).toBeUndefined();
});
it("handles many dependencies", () => {
const deps = ["FN-001", "FN-002", "FN-003", "FN-004"];
expect(computeDepTooltip(deps)).toBe("FN-001, FN-002, FN-003, FN-004");
});
it("data-tooltip attribute contains dependency IDs as a readable string", () => {
const deps = ["FN-005", "FN-012"];
const tooltip = computeDepTooltip(deps);
expect(tooltip).toBeDefined();
// Each dependency ID should appear in the tooltip
for (const dep of deps) {
expect(tooltip).toContain(dep);
}
});
});
describe("TaskCard file-scope overlap badge logic", () => {
/** Mirrors the card-meta visibility condition from TaskCard.tsx */
function shouldShowCardMeta(opts: { dependencies?: string[]; queued?: boolean; status?: string | null; blockedBy?: string }): boolean {
const deps = opts.dependencies || [];
return deps.length > 0 || !!opts.queued || opts.status === "queued" || !!opts.blockedBy;
}
/** Mirrors the card-scope-badge visibility condition from TaskCard.tsx */
function shouldShowScopeBadge(blockedBy?: string): boolean {
return !!blockedBy;
}
it("shows scope badge when blockedBy is set", () => {
expect(shouldShowScopeBadge("FN-003")).toBe(true);
});
it("does NOT show scope badge when blockedBy is undefined", () => {
expect(shouldShowScopeBadge(undefined)).toBe(false);
});
it("shows card-meta when blockedBy is set even with no deps or queued status", () => {
expect(shouldShowCardMeta({ blockedBy: "FN-003" })).toBe(true);
});
it("does NOT show card-meta when no deps, not queued, and no blockedBy", () => {
expect(shouldShowCardMeta({})).toBe(false);
});
/** Mirrors tooltip computation from TaskCard.tsx */
function computeScopeTooltip(blockedBy: string): string {
return `Blocked by ${blockedBy} (file overlap)`;
}
it("generates correct tooltip text", () => {
expect(computeScopeTooltip("FN-005")).toBe("Blocked by FN-005 (file overlap)");
});
});
describe("TaskCard queued badge logic", () => {
/** Mirrors the card-status-badge visibility condition from TaskCard.tsx */
function shouldShowStatusBadge(status?: string | null): boolean {
return !!status && status !== "queued";
}
/** Mirrors the queued-badge visibility condition from TaskCard.tsx */
function shouldShowQueuedBadge(opts: { queued?: boolean; status?: string | null; column?: string }): boolean {
return !!(opts.queued || opts.status === "queued") && opts.column !== "in-progress";
}
it("shows queued-badge when queued prop is true", () => {
expect(shouldShowQueuedBadge({ queued: true })).toBe(true);
});
it("shows queued-badge when task.status is 'queued'", () => {
expect(shouldShowQueuedBadge({ status: "queued" })).toBe(true);
});
it("shows queued-badge when both queued prop and status are set", () => {
expect(shouldShowQueuedBadge({ queued: true, status: "queued" })).toBe(true);
});
it("does NOT show queued-badge when neither queued prop nor status is 'queued'", () => {
expect(shouldShowQueuedBadge({ queued: false, status: "executing" })).toBe(false);
expect(shouldShowQueuedBadge({})).toBe(false);
});
it("does NOT show queued-badge when column is 'in-progress' even if status is stale 'queued'", () => {
expect(shouldShowQueuedBadge({ status: "queued", column: "in-progress" })).toBe(false);
expect(shouldShowQueuedBadge({ queued: true, column: "in-progress" })).toBe(false);
});
it("does NOT show card-status-badge when status is 'queued'", () => {
expect(shouldShowStatusBadge("queued")).toBe(false);
});
it("shows card-status-badge for non-queued statuses", () => {
expect(shouldShowStatusBadge("executing")).toBe(true);
expect(shouldShowStatusBadge("planning")).toBe(true);
});
it("does NOT show card-status-badge when status is null/undefined", () => {
expect(shouldShowStatusBadge(null)).toBe(false);
expect(shouldShowStatusBadge(undefined)).toBe(false);
});
});
/**
* Component tests for clickable dependencies in TaskCard.
*/
function makeTask(overrides: Partial = {}): Task {
return {
id: "FN-099",
description: "Test task",
column: "in-progress" as Column,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}
describe("TaskCard clickable dependencies", () => {
const noopToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("renders dependency badges as clickable when dependencies exist", () => {
const task = makeTask({ dependencies: ["FN-001", "FN-002"] });
const allTasks: Task[] = [
makeTask({ id: "FN-001", description: "Dep 1" }),
makeTask({ id: "FN-002", description: "Dep 2" }),
];
render(
);
const depBadges = screen.getAllByTitle(/Click to view/);
expect(depBadges).toHaveLength(2);
expect(depBadges[0].classList.contains("clickable")).toBe(true);
expect(depBadges[1].classList.contains("clickable")).toBe(true);
});
it("does not render dependency badges when no dependencies", () => {
const task = makeTask({ dependencies: [] });
render(
);
const depBadges = screen.queryAllByTitle(/Click to view/);
expect(depBadges).toHaveLength(0);
});
it("calls fetchTaskDetail and onOpenDetail when clicking a dependency", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
const mockDetail: TaskDetail = {
...makeTask({ id: "FN-001", description: "Dep 1" }),
prompt: "",
attachments: [],
};
mockFetch.mockResolvedValueOnce(mockDetail);
const onOpenDetail = vi.fn();
const task = makeTask({ dependencies: ["FN-001"] });
const allTasks: Task[] = [makeTask({ id: "FN-001", description: "Dep 1" })];
render(
);
const depBadge = screen.getByTitle(/Click to view/);
fireEvent.click(depBadge);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("FN-001", undefined);
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
});
});
it("shows error toast when dependency fetch fails", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
mockFetch.mockRejectedValueOnce(new Error("Task not found"));
const onOpenDetail = vi.fn();
const addToast = vi.fn();
const task = makeTask({ dependencies: ["FN-001"] });
render(
);
const depBadge = screen.getByTitle(/Click to view/);
fireEvent.click(depBadge);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to load dependency FN-001", "error");
});
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("uses stopPropagation so card click is not triggered", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
mockFetch.mockRejectedValueOnce(new Error("Stop here"));
const addToast = vi.fn();
const task = makeTask({ dependencies: ["FN-001"] });
const { container } = render(
);
const depBadge = screen.getByTitle(/Click to view/);
const clickEvent = new MouseEvent("click", { bubbles: true });
const stopPropagationSpy = vi.spyOn(clickEvent, "stopPropagation");
fireEvent(depBadge, clickEvent);
// The click handler is async, so we just verify the badge is clickable
expect(depBadge.classList.contains("clickable")).toBe(true);
});
});
/**
* Component tests for size badge rendering in TaskCard.
*/
describe("TaskCard size badge", () => {
const noopToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("renders size badge when task.size is 'S'", () => {
const task = makeTask({ size: "S" });
render(
);
const sizeBadge = screen.getByText("S");
expect(sizeBadge).toBeDefined();
expect(sizeBadge.classList.contains("card-size-badge")).toBe(true);
expect(sizeBadge.classList.contains("size-s")).toBe(true);
});
it("renders size badge when task.size is 'M'", () => {
const task = makeTask({ size: "M" });
render(
);
const sizeBadge = screen.getByText("M");
expect(sizeBadge).toBeDefined();
expect(sizeBadge.classList.contains("card-size-badge")).toBe(true);
expect(sizeBadge.classList.contains("size-m")).toBe(true);
});
it("renders size badge when task.size is 'L'", () => {
const task = makeTask({ size: "L" });
render(
);
const sizeBadge = screen.getByText("L");
expect(sizeBadge).toBeDefined();
expect(sizeBadge.classList.contains("card-size-badge")).toBe(true);
expect(sizeBadge.classList.contains("size-l")).toBe(true);
});
it("does NOT render size badge when task.size is undefined", () => {
const task = makeTask({ size: undefined });
render(
);
const sizeBadge = screen.queryByText(/^[SML]$/);
expect(sizeBadge).toBeNull();
});
it("positioned in card-header with other badges", () => {
const task = makeTask({ size: "M", status: "executing" });
const { container } = render(
);
const cardHeader = container.querySelector(".card-header");
expect(cardHeader).toBeDefined();
const sizeBadge = cardHeader?.querySelector(".card-size-badge");
expect(sizeBadge).toBeDefined();
expect(sizeBadge?.textContent).toBe("M");
});
it("size badge appears at far right (after Archive button) in done column cards", () => {
const onArchiveTask = vi.fn().mockResolvedValue(makeTask({ column: "done" }));
const task = makeTask({
column: "done",
size: "M",
status: undefined
});
const { container } = render(
);
const headerActions = container.querySelector(".card-header-actions");
expect(headerActions).toBeDefined();
// Get all children of header actions
const children = headerActions?.children;
expect(children).toBeDefined();
expect(children!.length).toBeGreaterThanOrEqual(2);
// The last element should be the size badge (after Archive button)
const lastElement = children![children!.length - 1];
expect(lastElement.classList.contains("card-size-badge")).toBe(true);
expect(lastElement.textContent).toBe("M");
// Archive button should be before the size badge
const archiveButton = headerActions?.querySelector(".card-archive-btn");
expect(archiveButton).toBeDefined();
// Find the index of archive button and size badge
const archiveIndex = Array.from(children!).findIndex(el => el.classList.contains("card-archive-btn"));
const sizeIndex = Array.from(children!).findIndex(el => el.classList.contains("card-size-badge"));
expect(archiveIndex).toBeGreaterThanOrEqual(0);
expect(sizeIndex).toBeGreaterThanOrEqual(0);
expect(sizeIndex).toBeGreaterThan(archiveIndex); // Size badge comes after Archive button
});
});
/**
* Tests for inline editing functionality in TaskCard.
*/
describe("TaskCard inline editing", () => {
const noopToast = vi.fn();
const noopUpdateTask = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
vi.clearAllMocks();
});
// Helper to make editable task (triage or todo column)
function makeEditableTask(overrides: Partial = {}): Task {
return makeTask({
column: "triage",
status: undefined,
paused: false,
...overrides,
});
}
// Helper to make non-editable task (in-progress, in-review, done, or agent active)
function makeNonEditableTask(overrides: Partial = {}): Task {
return makeTask({
column: "in-progress",
status: "executing",
paused: false,
...overrides,
});
}
it("shows edit button on hover for editable cards", () => {
const task = makeEditableTask({ column: "triage" });
render(
);
const editBtn = screen.getByRole("button", { name: /Edit task/i });
expect(editBtn).toBeDefined();
expect(editBtn.classList.contains("card-edit-btn")).toBe(true);
});
it("shows edit button for todo column tasks", () => {
const task = makeEditableTask({ column: "todo" });
render(
);
const editBtn = screen.getByRole("button", { name: /Edit task/i });
expect(editBtn).toBeDefined();
});
it("does NOT show edit button for in-progress column", () => {
const task = makeTask({
column: "in-progress",
status: "executing",
});
render(
);
const editBtn = screen.queryByRole("button", { name: /Edit task/i });
expect(editBtn).toBeNull();
});
it("does NOT show edit button for in-review column", () => {
const task = makeTask({
column: "in-review",
status: undefined,
});
render(
);
const editBtn = screen.queryByRole("button", { name: /Edit task/i });
expect(editBtn).toBeNull();
});
it("does NOT show edit button for done column", () => {
const task = makeTask({
column: "done",
status: undefined,
});
render(
);
const editBtn = screen.queryByRole("button", { name: /Edit task/i });
expect(editBtn).toBeNull();
});
it("does NOT show edit button when agent is active", () => {
const task = makeTask({
column: "in-progress",
status: "executing",
});
render(
);
const editBtn = screen.queryByRole("button", { name: /Edit task/i });
expect(editBtn).toBeNull();
});
it("does NOT show edit button when task is paused", () => {
const task = makeEditableTask({ paused: true });
render(
);
const editBtn = screen.queryByRole("button", { name: /Edit task/i });
expect(editBtn).toBeNull();
});
it("does NOT show edit button when onUpdateTask callback is not provided", () => {
const task = makeEditableTask();
render(
);
const editBtn = screen.queryByRole("button", { name: /Edit task/i });
expect(editBtn).toBeNull();
});
it("enters edit mode on double-click for editable cards", () => {
const task = makeEditableTask({ title: "Test Title", description: "Test Description" });
render(
);
const card = document.querySelector('[data-id="FN-099"]');
expect(card).toBeDefined();
fireEvent.doubleClick(card!);
// Should show editing UI — description textarea only, no title input
const titleInput = screen.queryByPlaceholderText(/Task title/i);
const descTextarea = screen.getByPlaceholderText(/Task description/i);
expect(titleInput).toBeNull();
expect(descTextarea).toBeDefined();
expect((descTextarea as HTMLTextAreaElement).value).toBe("Test Description");
});
it("enters edit mode when clicking edit button", () => {
const task = makeEditableTask();
render(
);
const editBtn = screen.getByRole("button", { name: /Edit task/i });
fireEvent.click(editBtn);
// Should show description textarea only, no title input
const titleInput = screen.queryByPlaceholderText(/Task title/i);
const descTextarea = screen.getByPlaceholderText(/Task description/i);
expect(titleInput).toBeNull();
expect(descTextarea).toBeDefined();
});
it("does NOT enter edit mode on double-click for non-editable cards", () => {
const task = makeNonEditableTask();
render(
);
const card = document.querySelector('[data-id="FN-099"]');
fireEvent.doubleClick(card!);
// Should NOT show editing UI
const descTextarea = screen.queryByPlaceholderText(/Task description/i);
expect(descTextarea).toBeNull();
});
it("Escape key cancels edit mode", () => {
const task = makeEditableTask({ title: "Original Title", description: "Original Desc" });
render(
);
// Enter edit mode
const card = document.querySelector('[data-id="FN-099"]');
fireEvent.doubleClick(card!);
const descTextarea = screen.getByPlaceholderText(/Task description/i) as HTMLTextAreaElement;
fireEvent.change(descTextarea, { target: { value: "Changed Desc" } });
// Press Escape
fireEvent.keyDown(descTextarea, { key: "Escape" });
// Should exit edit mode without saving
expect(screen.queryByPlaceholderText(/Task description/i)).toBeNull();
expect(noopUpdateTask).not.toHaveBeenCalled();
});
it("blurring with no changes cancels edit mode", async () => {
const user = userEvent.setup();
const task = makeEditableTask({ title: "Original Title", description: "Original Desc" });
render(
);
// Enter edit mode
const card = document.querySelector('[data-id="FN-099"]');
await user.dblClick(card!);
const descTextarea = screen.getByPlaceholderText(/Task description/i);
expect(descTextarea).toBeDefined();
// Tab out to move focus outside the editing area
await user.tab();
// Wait for the blur handler to execute
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
// Should have exited edit mode without saving
expect(screen.queryByPlaceholderText(/Task description/i)).toBeNull();
expect(noopUpdateTask).not.toHaveBeenCalled();
});
it("Enter in description saves changes", async () => {
const task = makeEditableTask({ title: "Title", description: "Old Desc" });
const mockUpdateTask = vi.fn().mockResolvedValue({ ...task, description: "New Desc" });
render(
);
// Enter edit mode
const card = document.querySelector('[data-id="FN-099"]');
fireEvent.doubleClick(card!);
const descTextarea = screen.getByPlaceholderText(/Task description/i) as HTMLTextAreaElement;
fireEvent.change(descTextarea, { target: { value: "New Desc" } });
// Press Enter (not Shift+Enter)
fireEvent.keyDown(descTextarea, { key: "Enter", shiftKey: false });
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-099", {
description: "New Desc",
});
});
});
it("Shift+Enter in description adds newline", () => {
const task = makeEditableTask({ description: "Line 1" });
render(
);
// Enter edit mode
const card = document.querySelector('[data-id="FN-099"]');
fireEvent.doubleClick(card!);
const descTextarea = screen.getByPlaceholderText(/Task description/i) as HTMLTextAreaElement;
// Press Shift+Enter - should not trigger save
fireEvent.keyDown(descTextarea, { key: "Enter", shiftKey: true });
// updateTask should not be called
expect(noopUpdateTask).not.toHaveBeenCalled();
});
it("shows loading state during save", async () => {
const task = makeEditableTask({ title: "Title" });
// Create a promise that we can resolve manually
let resolveUpdate: (value: Task) => void;
const updatePromise = new Promise((resolve) => {
resolveUpdate = resolve;
});
const mockUpdateTask = vi.fn().mockReturnValue(updatePromise);
render(
);
// Enter edit mode
const card = document.querySelector('[data-id="FN-099"]');
fireEvent.doubleClick(card!);
const descTextarea = screen.getByPlaceholderText(/Task description/i) as HTMLTextAreaElement;
fireEvent.change(descTextarea, { target: { value: "New Desc" } });
// Trigger save
fireEvent.keyDown(descTextarea, { key: "Enter" });
// Should show loading state
await waitFor(() => {
expect(screen.getByText(/Saving/i)).toBeDefined();
});
// Resolve the update
resolveUpdate!({ ...task, description: "New Desc" });
// Wait for save to complete
await waitFor(() => {
expect(screen.queryByText(/Saving/i)).toBeNull();
});
});
it("shows error toast when save fails", async () => {
const task = makeEditableTask({ title: "Title" });
const mockUpdateTask = vi.fn().mockRejectedValue(new Error("Network error"));
const addToast = vi.fn();
render(
);
// Enter edit mode
const card = document.querySelector('[data-id="FN-099"]');
fireEvent.doubleClick(card!);
const descTextarea = screen.getByPlaceholderText(/Task description/i) as HTMLTextAreaElement;
fireEvent.change(descTextarea, { target: { value: "New Desc" } });
// Trigger save
fireEvent.keyDown(descTextarea, { key: "Enter" });
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Failed to update"), "error");
});
// Should stay in edit mode on error
expect(screen.getByPlaceholderText(/Task description/i)).toBeDefined();
});
it("prevents drag during edit mode", () => {
const task = makeEditableTask();
render(
);
// Enter edit mode
const card = document.querySelector('[data-id="FN-099"]') as HTMLElement;
fireEvent.doubleClick(card);
// Card should have editing class
expect(card.classList.contains("card-editing")).toBe(true);
});
it("card has card-editing class when in edit mode", () => {
const task = makeEditableTask();
render(
);
const card = document.querySelector('[data-id="FN-099"]') as HTMLElement;
expect(card.classList.contains("card-editing")).toBe(false);
fireEvent.doubleClick(card);
const editingCard = document.querySelector(".card-editing");
expect(editingCard).toBeDefined();
});
it("saves only description — existing title is not sent in update", async () => {
const task = makeEditableTask({ title: "Keep This Title", description: "Old Desc" });
const mockUpdateTask = vi.fn().mockResolvedValue({ ...task, description: "New Desc" });
render(
);
// Enter edit mode
const card = document.querySelector('[data-id="FN-099"]');
fireEvent.doubleClick(card!);
const descTextarea = screen.getByPlaceholderText(/Task description/i) as HTMLTextAreaElement;
fireEvent.change(descTextarea, { target: { value: "New Desc" } });
// Press Enter to save
fireEvent.keyDown(descTextarea, { key: "Enter" });
await waitFor(() => {
// onUpdateTask should only receive description, not title
expect(mockUpdateTask).toHaveBeenCalledWith("FN-099", {
description: "New Desc",
});
});
});
it("description textarea is auto-focused when entering edit mode", () => {
const task = makeEditableTask({ description: "Some description" });
render(
);
// Enter edit mode
const card = document.querySelector('[data-id="FN-099"]');
fireEvent.doubleClick(card!);
const descTextarea = screen.getByPlaceholderText(/Task description/i);
expect(document.activeElement).toBe(descTextarea);
});
it("no-change blur exits edit mode without saving", async () => {
const user = userEvent.setup();
const task = makeEditableTask({ description: "Original Desc" });
render(
);
// Enter edit mode
const card = document.querySelector('[data-id="FN-099"]');
await user.dblClick(card!);
// Verify description textarea is visible with original value
const descTextarea = screen.getByPlaceholderText(/Task description/i);
expect((descTextarea as HTMLTextAreaElement).value).toBe("Original Desc");
// Tab away to move focus outside the editing area
await user.tab();
// Wait for the blur handler to execute
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
// Should exit edit mode without calling update
expect(screen.queryByPlaceholderText(/Task description/i)).toBeNull();
expect(noopUpdateTask).not.toHaveBeenCalled();
});
it("does not render an inline title input in edit mode", () => {
const task = makeEditableTask({ title: "Some Title" });
render(
);
// Enter edit mode
const card = document.querySelector('[data-id="FN-099"]');
fireEvent.doubleClick(card!);
// Only description textarea should exist, no title input
expect(screen.queryByPlaceholderText(/Task title/i)).toBeNull();
expect(screen.getByPlaceholderText(/Task description/i)).toBeDefined();
});
it("opens the description textarea with 4 visible rows", () => {
const task = makeEditableTask({ description: "Some text" });
render(
);
// Enter edit mode
const card = document.querySelector('[data-id="FN-099"]');
fireEvent.doubleClick(card!);
const descTextarea = screen.getByPlaceholderText(/Task description/i) as HTMLTextAreaElement;
expect(descTextarea).toBeDefined();
expect(descTextarea.getAttribute("rows")).toBe("4");
});
it("applies mount-time auto-resize for existing long descriptions", () => {
// A multi-line description that would exceed the default 4-row height
const longDescription = Array(10).fill("This is a line of description text.").join("\n");
const task = makeEditableTask({ description: longDescription });
render(
);
// Enter edit mode
const card = document.querySelector('[data-id="FN-099"]');
fireEvent.doubleClick(card!);
const descTextarea = screen.getByPlaceholderText(/Task description/i) as HTMLTextAreaElement;
// The mount-time resize effect sets height to scrollHeight + "px".
// In JSDOM, scrollHeight is 0 (no real layout), so the style ends up
// as "0px" — but the important thing is the effect *did* set the
// height property, proving the auto-resize logic runs on mount.
expect(descTextarea.style.height).not.toBe("");
});
});
/**
* Tests for collapsible steps toggle in TaskCard.
*/
describe("TaskCard steps toggle", () => {
const noopToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("does not show steps toggle when task has no steps", () => {
const task = makeTask({ steps: [] });
render(
);
const toggle = screen.queryByRole("button", { name: /steps/i });
expect(toggle).toBeNull();
});
it("shows steps toggle with count when task has steps", () => {
// Use 'todo' column to test default collapsed behavior
const task = makeTask({
column: "todo",
steps: [
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "pending" },
],
});
render(
);
const toggle = screen.getByRole("button", { name: /Show steps/i });
expect(toggle).toBeDefined();
expect(toggle.textContent).toContain("2 steps");
});
it("clicking toggle expands and shows step list", () => {
// Use 'todo' column to test default collapsed behavior
const task = makeTask({
column: "todo",
steps: [
{ name: "First step", status: "done" },
{ name: "Second step", status: "in-progress" },
],
});
render(
);
const toggle = screen.getByRole("button", { name: /Show steps/i });
fireEvent.click(toggle);
// Step list should be visible
expect(screen.getByText("First step")).toBeDefined();
expect(screen.getByText("Second step")).toBeDefined();
});
it("clicking toggle again collapses step list", () => {
// Use 'todo' column to test default collapsed behavior
const task = makeTask({
column: "todo",
steps: [{ name: "Single step", status: "pending" }],
});
render(
);
const toggle = screen.getByRole("button", { name: /Show steps/i });
// Expand
fireEvent.click(toggle);
expect(screen.getByText("Single step")).toBeDefined();
// Collapse
fireEvent.click(toggle);
expect(screen.queryByText("Single step")).toBeNull();
});
it("step list renders correct number of steps", () => {
// Use 'todo' column to test default collapsed behavior
const task = makeTask({
column: "todo",
steps: [
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "in-progress" },
{ name: "Step 3", status: "pending" },
{ name: "Step 4", status: "skipped" },
],
});
render(
);
const toggle = screen.getByRole("button", { name: /Show steps/i });
fireEvent.click(toggle);
// Should show all 4 steps
const stepItems = document.querySelectorAll(".card-step-item");
expect(stepItems.length).toBe(4);
});
it("completed steps have strikethrough style", () => {
// Use 'todo' column to test default collapsed behavior
const task = makeTask({
column: "todo",
steps: [
{ name: "Done step", status: "done" },
{ name: "Pending step", status: "pending" },
],
});
render(
);
const toggle = screen.getByRole("button", { name: /Show steps/i });
fireEvent.click(toggle);
const doneStepName = screen.getByText("Done step");
expect(doneStepName.classList.contains("completed")).toBe(true);
const pendingStepName = screen.getByText("Pending step");
expect(pendingStepName.classList.contains("completed")).toBe(false);
});
it("toggle does not trigger card click when clicked", () => {
// Use 'todo' column to test default collapsed behavior
const task = makeTask({
column: "todo",
steps: [{ name: "Test step", status: "pending" }],
});
const onOpenDetail = vi.fn();
render(
);
const toggle = screen.getByRole("button", { name: /Show steps/i });
fireEvent.click(toggle);
// Card click should not be triggered
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("aria-expanded reflects toggle state", () => {
// Use 'todo' column to test default collapsed behavior
const task = makeTask({
column: "todo",
steps: [{ name: "Test step", status: "pending" }],
});
render(
);
const toggle = screen.getByRole("button", { name: /Show steps/i });
expect(toggle.getAttribute("aria-expanded")).toBe("false");
fireEvent.click(toggle);
expect(toggle.getAttribute("aria-expanded")).toBe("true");
});
it("chevron icon rotates when expanded", () => {
// Use 'todo' column to test default collapsed behavior
const task = makeTask({
column: "todo",
steps: [{ name: "Test step", status: "pending" }],
});
render(
);
const toggle = screen.getByRole("button", { name: /Show steps/i });
const chevron = toggle.querySelector(".card-steps-toggle-icon");
expect(chevron).toBeDefined();
expect(chevron?.classList.contains("expanded")).toBe(false);
fireEvent.click(toggle);
expect(chevron?.classList.contains("expanded")).toBe(true);
});
it("progress bar counts skipped steps as completed", () => {
const task = makeTask({
steps: [
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "skipped" },
{ name: "Step 3", status: "pending" },
{ name: "Step 4", status: "in-progress" },
],
});
render(
);
// Progress label should show "2/4" (done + skipped = 2 completed)
const progressLabel = document.querySelector(".card-progress-label");
expect(progressLabel).toBeDefined();
expect(progressLabel?.textContent).toBe("2/4");
});
it("step list renders skipped status with correct CSS class", () => {
// Use 'todo' column to test default collapsed behavior
const task = makeTask({
column: "todo",
steps: [
{ name: "Done step", status: "done" },
{ name: "Skipped step", status: "skipped" },
{ name: "Pending step", status: "pending" },
],
});
render(
);
const toggle = screen.getByRole("button", { name: /Show steps/i });
fireEvent.click(toggle);
// Check that skipped step has the correct dot class
const stepDots = document.querySelectorAll(".card-step-dot");
expect(stepDots.length).toBe(3);
expect(stepDots[0].classList.contains("card-step-dot--done")).toBe(true);
expect(stepDots[1].classList.contains("card-step-dot--skipped")).toBe(true);
expect(stepDots[2].classList.contains("card-step-dot--pending")).toBe(true);
});
it("progress bar shows 100% when all steps are skipped", () => {
const task = makeTask({
steps: [
{ name: "Step 1", status: "skipped" },
{ name: "Step 2", status: "skipped" },
{ name: "Step 3", status: "skipped" },
],
});
render(
);
// Progress label should show "3/3"
const progressLabel = document.querySelector(".card-progress-label");
expect(progressLabel?.textContent).toBe("3/3");
// Progress fill should be 100% width
const progressFill = document.querySelector(".card-progress-fill") as HTMLElement;
expect(progressFill).toBeDefined();
expect(progressFill.style.width).toBe("100%");
});
});
/**
* Tests for auto-expanded steps disclosure in in-progress column (KB-193).
*/
describe("TaskCard steps auto-expand", () => {
const noopToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("steps are expanded by default for 'in-progress' column tasks", () => {
const task = makeTask({
column: "in-progress",
steps: [
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "pending" },
],
});
render(
);
// Steps should be visible without clicking
expect(screen.getByText("Step 1")).toBeDefined();
expect(screen.getByText("Step 2")).toBeDefined();
// Toggle should show "Hide steps"
const toggle = screen.getByRole("button", { name: /Hide steps/i });
expect(toggle.getAttribute("aria-expanded")).toBe("true");
});
it("steps are collapsed by default for fresh 'triage' column tasks (no completed steps)", () => {
const task = makeTask({
column: "triage",
steps: [
{ name: "Step 1", status: "pending" },
{ name: "Step 2", status: "pending" },
],
});
render(
);
// Steps should not be visible
expect(screen.queryByText("Step 1")).toBeNull();
expect(screen.queryByText("Step 2")).toBeNull();
// Toggle should show "Show steps"
const toggle = screen.getByRole("button", { name: /Show steps/i });
expect(toggle.getAttribute("aria-expanded")).toBe("false");
});
it("steps are auto-expanded for re-triaged tasks with completed steps", () => {
const task = makeTask({
column: "triage",
steps: [
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "pending" },
],
});
render(
);
// Steps should be visible without clicking
expect(screen.getByText("Step 1")).toBeDefined();
expect(screen.getByText("Step 2")).toBeDefined();
// Toggle should show "Hide steps"
const toggle = screen.getByRole("button", { name: /Hide steps/i });
expect(toggle.getAttribute("aria-expanded")).toBe("true");
});
it("steps are auto-expanded for re-triaged tasks with skipped steps", () => {
const task = makeTask({
column: "triage",
steps: [
{ name: "Step 1", status: "skipped" },
{ name: "Step 2", status: "pending" },
],
});
render(
);
// Steps should be visible without clicking
expect(screen.getByText("Step 1")).toBeDefined();
expect(screen.getByText("Step 2")).toBeDefined();
const toggle = screen.getByRole("button", { name: /Hide steps/i });
expect(toggle.getAttribute("aria-expanded")).toBe("true");
});
it("steps are collapsed by default for 'todo' column tasks", () => {
const task = makeTask({
column: "todo",
steps: [
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "pending" },
],
});
render(
);
// Steps should not be visible
expect(screen.queryByText("Step 1")).toBeNull();
expect(screen.queryByText("Step 2")).toBeNull();
// Toggle should show "Show steps"
const toggle = screen.getByRole("button", { name: /Show steps/i });
expect(toggle.getAttribute("aria-expanded")).toBe("false");
});
it("steps are collapsed by default for 'in-review' column tasks", () => {
const task = makeTask({
column: "in-review",
steps: [
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "pending" },
],
});
render(
);
// Steps should not be visible
expect(screen.queryByText("Step 1")).toBeNull();
expect(screen.queryByText("Step 2")).toBeNull();
// Toggle should show "Show steps"
const toggle = screen.getByRole("button", { name: /Show steps/i });
expect(toggle.getAttribute("aria-expanded")).toBe("false");
});
it("steps are collapsed by default for 'done' column tasks", () => {
const task = makeTask({
column: "done",
steps: [
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "done" },
],
});
render(
);
// Steps should not be visible
expect(screen.queryByText("Step 1")).toBeNull();
expect(screen.queryByText("Step 2")).toBeNull();
// Toggle should show "Show steps"
const toggle = screen.getByRole("button", { name: /Show steps/i });
expect(toggle.getAttribute("aria-expanded")).toBe("false");
});
it("toggle button works to collapse steps on in-progress cards", () => {
const task = makeTask({
column: "in-progress",
steps: [
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "pending" },
],
});
render(
);
// Steps should be visible initially
expect(screen.getByText("Step 1")).toBeDefined();
// Click toggle to collapse
const toggle = screen.getByRole("button", { name: /Hide steps/i });
fireEvent.click(toggle);
// Steps should now be hidden
expect(screen.queryByText("Step 1")).toBeNull();
expect(screen.queryByText("Step 2")).toBeNull();
// Toggle should now show "Show steps"
expect(toggle.getAttribute("aria-label")).toBe("Show steps");
expect(toggle.getAttribute("aria-expanded")).toBe("false");
});
it("toggle button works to expand steps on non-in-progress cards", () => {
const task = makeTask({
column: "todo",
steps: [
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "pending" },
],
});
render(
);
// Steps should be hidden initially
expect(screen.queryByText("Step 1")).toBeNull();
// Click toggle to expand
const toggle = screen.getByRole("button", { name: /Show steps/i });
fireEvent.click(toggle);
// Steps should now be visible
expect(screen.getByText("Step 1")).toBeDefined();
expect(screen.getByText("Step 2")).toBeDefined();
// Toggle should now show "Hide steps"
expect(toggle.getAttribute("aria-label")).toBe("Hide steps");
expect(toggle.getAttribute("aria-expanded")).toBe("true");
});
});
/**
* Tests for GitHub badges rendering in TaskCard.
*/
describe("TaskCard GitHub badges", () => {
const noopToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("renders GitHubBadge when task has prInfo", () => {
const task = makeTask({
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "Fix bug",
headBranch: "feature/bugfix",
baseBranch: "main",
commentCount: 3,
},
});
render(
);
// Should show the PR badge with the PR number
expect(screen.getByText("#42")).toBeDefined();
expect(screen.getByTitle("PR #42: Fix bug")).toBeDefined();
});
it("renders GitHubBadge when task has issueInfo", () => {
const task = makeTask({
issueInfo: {
url: "https://github.com/owner/repo/issues/123",
number: 123,
state: "open",
title: "Feature request",
},
});
render(
);
// Should show the Issue badge with the issue number
expect(screen.getByText("#123")).toBeDefined();
expect(screen.getByTitle("Issue #123: Feature request")).toBeDefined();
});
it("renders both PR and Issue badges when task has both", () => {
const task = makeTask({
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "Fix bug",
headBranch: "feature/bugfix",
baseBranch: "main",
commentCount: 3,
},
issueInfo: {
url: "https://github.com/owner/repo/issues/123",
number: 123,
state: "closed",
stateReason: "completed",
title: "Related issue",
},
});
render(
);
// Both badges should appear
expect(screen.getByText("#42")).toBeDefined();
expect(screen.getByText("#123")).toBeDefined();
expect(screen.getByTitle("PR #42: Fix bug")).toBeDefined();
expect(screen.getByTitle("Issue #123: Related issue")).toBeDefined();
});
it("does not render GitHubBadge when task has neither prInfo nor issueInfo", () => {
const task = makeTask();
render(
);
// No badge numbers should appear
const badgeNumbers = screen.queryAllByText(/^#\d+$/);
expect(badgeNumbers.length).toBe(0);
});
it("renders PR badge in all columns (not just in-review)", () => {
const columns: Column[] = ["triage", "todo", "in-progress", "in-review", "done"];
for (const column of columns) {
const task = makeTask({
column,
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "Fix bug",
headBranch: "feature/bugfix",
baseBranch: "main",
commentCount: 3,
},
});
const { unmount } = render(
);
expect(screen.getByText("#42")).toBeDefined();
unmount();
}
});
it("renders Issue badge with correct color class for open state", () => {
const task = makeTask({
issueInfo: {
url: "https://github.com/owner/repo/issues/123",
number: 123,
state: "open",
title: "Open issue",
},
});
const { container } = render(
);
const badge = container.querySelector(".card-github-badge--open");
expect(badge).toBeDefined();
});
it("renders Issue badge with correct color class for completed state", () => {
const task = makeTask({
issueInfo: {
url: "https://github.com/owner/repo/issues/123",
number: 123,
state: "closed",
stateReason: "completed",
title: "Completed issue",
},
});
const { container } = render(
);
const badge = container.querySelector(".card-github-badge--completed");
expect(badge).toBeDefined();
});
it("renders PR badge with correct color class for merged status", () => {
const task = makeTask({
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "merged",
title: "Merged PR",
headBranch: "feature/merged",
baseBranch: "main",
commentCount: 5,
},
});
const { container } = render(
);
const badge = container.querySelector(".card-github-badge--merged");
expect(badge).toBeDefined();
});
it("prefers newer live badge data over stale task badge data", () => {
mockUseBadgeWebSocket.mockReturnValue({
badgeUpdates: new Map([
[
"default:FN-099",
{
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "merged",
title: "Merged PR",
headBranch: "feature/merged",
baseBranch: "main",
commentCount: 5,
},
issueInfo: null,
timestamp: "2026-03-30T12:00:00.000Z",
},
],
]),
isConnected: true,
subscribeToBadge: vi.fn(),
unsubscribeFromBadge: vi.fn(),
});
const task = makeTask({
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "Open PR",
headBranch: "feature/bugfix",
baseBranch: "main",
commentCount: 0,
lastCheckedAt: "2026-03-30T11:00:00.000Z",
},
updatedAt: "2026-03-30T11:00:00.000Z",
});
render(
);
expect(screen.getByTitle("PR #42: Merged PR")).toBeDefined();
});
it("falls back to newer task badge data when cached live data is older", () => {
mockUseBadgeWebSocket.mockReturnValue({
badgeUpdates: new Map([
[
"FN-099",
{
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "Older Live PR",
headBranch: "feature/bugfix",
baseBranch: "main",
commentCount: 0,
},
timestamp: "2026-03-30T11:00:00.000Z",
},
],
]),
isConnected: false,
subscribeToBadge: vi.fn(),
unsubscribeFromBadge: vi.fn(),
});
const task = makeTask({
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "merged",
title: "Fresh Task PR",
headBranch: "feature/merged",
baseBranch: "main",
commentCount: 2,
lastCheckedAt: "2026-03-30T12:00:00.000Z",
},
updatedAt: "2026-03-30T12:00:00.000Z",
});
render(
);
expect(screen.getByTitle("PR #42: Fresh Task PR")).toBeDefined();
});
it("keeps task-provided badges when the first live update only includes one badge field", () => {
mockUseBadgeWebSocket.mockReturnValue({
badgeUpdates: new Map([
[
"default:FN-099",
{
issueInfo: {
url: "https://github.com/owner/repo/issues/123",
number: 123,
state: "closed",
title: "Updated issue",
stateReason: "completed",
},
timestamp: "2026-03-30T12:00:00.000Z",
},
],
]),
isConnected: true,
subscribeToBadge: vi.fn(),
unsubscribeFromBadge: vi.fn(),
});
const task = makeTask({
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "Tracked PR",
headBranch: "feature/bugfix",
baseBranch: "main",
commentCount: 1,
lastCheckedAt: "2026-03-30T11:00:00.000Z",
},
issueInfo: {
url: "https://github.com/owner/repo/issues/123",
number: 123,
state: "open",
title: "Tracked issue",
lastCheckedAt: "2026-03-30T11:00:00.000Z",
},
updatedAt: "2026-03-30T11:00:00.000Z",
});
render(
);
expect(screen.getByTitle("PR #42: Tracked PR")).toBeDefined();
expect(screen.getByTitle("Issue #123: Updated issue")).toBeDefined();
});
it("subscribes on mount and unsubscribes on unmount for linked GitHub tasks", () => {
const subscribeToBadge = vi.fn();
const unsubscribeFromBadge = vi.fn();
mockUseBadgeWebSocket.mockReturnValue({
badgeUpdates: new Map(),
isConnected: true,
subscribeToBadge,
unsubscribeFromBadge,
});
const task = makeTask({
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "Tracked PR",
headBranch: "feature/bugfix",
baseBranch: "main",
commentCount: 1,
},
});
const { unmount } = render(
);
expect(subscribeToBadge).toHaveBeenCalledWith("FN-099");
unmount();
expect(unsubscribeFromBadge).toHaveBeenCalledWith("FN-099");
});
it("waits for viewport intersection before subscribing and unsubscribes when leaving", () => {
const subscribeToBadge = vi.fn();
const unsubscribeFromBadge = vi.fn();
mockUseBadgeWebSocket.mockReturnValue({
badgeUpdates: new Map(),
isConnected: true,
subscribeToBadge,
unsubscribeFromBadge,
});
const originalIntersectionObserver = globalThis.IntersectionObserver;
const observers: Array<{ callback: IntersectionObserverCallback }> = [];
class MockIntersectionObserver {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
root = null;
rootMargin = "200px";
thresholds = [0];
readonly takeRecords = vi.fn(() => []);
constructor(callback: IntersectionObserverCallback) {
observers.push({ callback });
}
}
(globalThis as unknown as { IntersectionObserver: typeof IntersectionObserver }).IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver;
const task = makeTask({
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open",
title: "Tracked PR",
headBranch: "feature/bugfix",
baseBranch: "main",
commentCount: 1,
},
});
try {
render(
);
expect(subscribeToBadge).not.toHaveBeenCalled();
act(() => {
observers[0].callback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver);
});
expect(subscribeToBadge).toHaveBeenCalledWith("FN-099");
act(() => {
observers[0].callback([{ isIntersecting: false } as IntersectionObserverEntry], {} as IntersectionObserver);
});
expect(unsubscribeFromBadge).toHaveBeenCalledWith("FN-099");
} finally {
(globalThis as unknown as { IntersectionObserver: typeof IntersectionObserver | undefined }).IntersectionObserver = originalIntersectionObserver;
}
});
});
/**
* Tests for task detail opening behavior in TaskCard.
* The card body opens the modal directly; there is no separate expand button.
*/
describe("TaskCard detail opening", () => {
const noopToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("opens modal immediately with Task when clicking the card body", async () => {
const onOpenDetail = vi.fn();
const task = makeTask();
render(
);
const card = document.querySelector('[data-id="FN-099"]');
expect(card).toBeDefined();
const cardTitle = screen.getByText("Test task");
fireEvent.click(cardTitle);
// Should call onOpenDetail synchronously with the Task object (no fetch)
expect(onOpenDetail).toHaveBeenCalledWith(task);
expect(onOpenDetail).toHaveBeenCalledTimes(1);
});
it("opens modal only once per card click", async () => {
const onOpenDetail = vi.fn();
const task = makeTask();
render(
);
fireEvent.click(screen.getByText("Test task"));
// Should call onOpenDetail synchronously with the Task object (no fetch)
expect(onOpenDetail).toHaveBeenCalledWith(task);
expect(onOpenDetail).toHaveBeenCalledTimes(1);
});
it("does NOT open modal during vertical scrolling", async () => {
const onOpenDetail = vi.fn();
render(
);
const card = document.querySelector('[data-id="FN-099"]');
expect(card).toBeDefined();
fireEvent.touchStart(card!, {
touches: [{ clientX: 100, clientY: 100 }],
});
fireEvent.touchMove(card!, {
touches: [{ clientX: 100, clientY: 115 }],
});
fireEvent.touchEnd(card!, {
changedTouches: [{ clientX: 100, clientY: 115 }],
target: card,
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("does NOT open modal during horizontal scrolling", async () => {
const onOpenDetail = vi.fn();
render(
);
const card = document.querySelector('[data-id="FN-099"]');
expect(card).toBeDefined();
fireEvent.touchStart(card!, {
touches: [{ clientX: 100, clientY: 100 }],
});
fireEvent.touchMove(card!, {
touches: [{ clientX: 115, clientY: 100 }],
});
fireEvent.touchEnd(card!, {
changedTouches: [{ clientX: 115, clientY: 100 }],
target: card,
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("does NOT open modal on long press (slow touch)", async () => {
const onOpenDetail = vi.fn();
render(
);
const card = document.querySelector('[data-id="FN-099"]');
expect(card).toBeDefined();
// Simulate long press: touchStart, wait > 300ms, then touchEnd
fireEvent.touchStart(card!, {
touches: [{ clientX: 100, clientY: 100 }],
});
// Wait longer than tap threshold (300ms)
await new Promise((resolve) => setTimeout(resolve, 350));
fireEvent.touchEnd(card!, {
changedTouches: [{ clientX: 100, clientY: 100 }],
target: card,
});
// Wait for any async operations
await new Promise((resolve) => setTimeout(resolve, 50));
// Modal should NOT have opened
expect(onOpenDetail).not.toHaveBeenCalled();
});
});
/**
* Tests for TaskCard title/description display without truncation.
*/
describe("TaskCard title display", () => {
const noopToast = vi.fn();
const makeTask = (overrides: Partial = {}): Task => ({
id: "FN-001",
description: "Test task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
columnMovedAt: "2026-01-01T00:00:00Z",
...overrides,
} as Task);
it("truncates titles longer than 140 characters with ellipsis", () => {
const longTitle = "A".repeat(150);
const task = makeTask({ title: longTitle });
render(
);
// The title should be truncated to 140 chars + "…"
const expectedTruncated = "A".repeat(140) + "…";
const cardTitle = screen.getByText(expectedTruncated);
expect(cardTitle).toBeDefined();
expect(cardTitle.textContent?.length).toBe(141); // 140 + ellipsis
});
it("shows full title in tooltip via title attribute", () => {
const longTitle = "A".repeat(150);
const task = makeTask({ title: longTitle });
render(
);
// The title attribute should contain the full untruncated text
const cardTitle = document.querySelector(".card-title");
expect(cardTitle).toHaveAttribute("title", longTitle);
});
it("does not truncate titles exactly 140 characters", () => {
const exactTitle = "B".repeat(140);
const task = makeTask({ title: exactTitle });
render(
);
// Exactly 140 characters should NOT be truncated (no ellipsis)
const cardTitle = screen.getByText(exactTitle);
expect(cardTitle).toBeDefined();
expect(cardTitle.textContent).toBe(exactTitle);
expect(cardTitle.textContent?.length).toBe(140);
});
it("truncates description fallback when no title present and description exceeds 140 chars", () => {
const longDescription = "C".repeat(200);
const task = makeTask({ title: undefined, description: longDescription });
render(
);
// The description should be truncated to 140 chars + "…"
const expectedTruncated = "C".repeat(140) + "…";
const cardTitle = screen.getByText(expectedTruncated);
expect(cardTitle).toBeDefined();
expect(cardTitle.textContent?.length).toBe(141);
});
it("shows full description in tooltip when used as fallback", () => {
const longDescription = "D".repeat(200);
const task = makeTask({ title: undefined, description: longDescription });
render(
);
// The title attribute should contain the full untruncated description
const cardTitle = document.querySelector(".card-title");
expect(cardTitle).toHaveAttribute("title", longDescription);
});
it("does not truncate short titles under 140 characters", () => {
const shortTitle = "A".repeat(100);
const task = makeTask({ title: shortTitle });
render(
);
// Short titles should display unchanged
const cardTitle = screen.getByText(shortTitle);
expect(cardTitle).toBeDefined();
expect(cardTitle.textContent).toBe(shortTitle);
expect(cardTitle.textContent?.length).toBe(100);
});
it("displays title when title exists", () => {
const task = makeTask({ title: "My Task Title", description: "Some description" });
render(
);
expect(screen.getByText("My Task Title")).toBeDefined();
// Description should not be shown as title when title exists
expect(screen.queryByText("Some description")).toBeNull();
});
it("falls back to task id when no title and no description", () => {
const task = makeTask({ title: undefined, description: "" });
render(
);
// Look for the task ID within the card-title element specifically
const cardTitle = screen.getByText("FN-001", { selector: ".card-title" });
expect(cardTitle).toBeDefined();
});
});
/**
* Tests for awaiting-approval visual state in TaskCard.
* Tasks in triage with status "awaiting-approval" receive a distinct
* highlight and approval-specific badge text on the board.
*/
describe("TaskCard awaiting-approval state", () => {
const noopToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("applies awaiting-approval class when task is in triage with awaiting-approval status", () => {
const task = makeTask({ column: "triage", status: "awaiting-approval" });
render(
);
const card = document.querySelector('[data-id="FN-099"]');
expect(card?.classList.contains("awaiting-approval")).toBe(true);
});
it("does NOT apply awaiting-approval class for triage tasks with other statuses", () => {
const task = makeTask({ column: "triage", status: "specifying" });
render(
);
const card = document.querySelector('[data-id="FN-099"]');
expect(card?.classList.contains("awaiting-approval")).toBe(false);
});
it("does NOT apply awaiting-approval class for non-triage columns", () => {
const columns: Column[] = ["todo", "in-progress", "in-review", "done"];
for (const column of columns) {
const task = makeTask({ column, status: "awaiting-approval" });
const { unmount } = render(
);
const card = document.querySelector('[data-id="FN-099"]');
expect(card?.classList.contains("awaiting-approval")).toBe(false);
unmount();
}
});
it("shows 'Awaiting Approval' badge text for awaiting-approval tasks", () => {
const task = makeTask({ column: "triage", status: "awaiting-approval" });
render(
);
const badge = screen.getByText("Awaiting Approval");
expect(badge).toBeDefined();
expect(badge.classList.contains("card-status-badge")).toBe(true);
expect(badge.classList.contains("awaiting-approval")).toBe(true);
});
it("shows raw status text for other triage statuses", () => {
const task = makeTask({ column: "triage", status: "specifying" });
render(
);
// Should show raw status text, not "Awaiting Approval"
expect(screen.getByText("specifying")).toBeDefined();
expect(screen.queryByText("Awaiting Approval")).toBeNull();
});
it("does NOT apply agent-active class for awaiting-approval tasks", () => {
const task = makeTask({ column: "triage", status: "awaiting-approval" });
render(
);
const card = document.querySelector('[data-id="FN-099"]');
expect(card?.classList.contains("agent-active")).toBe(false);
expect(card?.classList.contains("awaiting-approval")).toBe(true);
});
it("awaiting-approval badge uses triage color styling", () => {
const task = makeTask({ column: "triage", status: "awaiting-approval" });
render(
);
const badge = screen.getByText("Awaiting Approval") as HTMLElement;
// The badge should use the awaiting-approval CSS class (no inline styles)
expect(badge.classList.contains("awaiting-approval")).toBe(true);
// Inline styles should not contain hardcoded colors
expect(badge.style.background).toBe("");
expect(badge.style.color).toBe("");
});
it("awaiting-approval card does NOT look like other states (no agent-active, failed, or paused)", () => {
const task = makeTask({ column: "triage", status: "awaiting-approval" });
render(
);
const card = document.querySelector('[data-id="FN-099"]');
expect(card?.classList.contains("awaiting-approval")).toBe(true);
expect(card?.classList.contains("agent-active")).toBe(false);
expect(card?.classList.contains("failed")).toBe(false);
expect(card?.classList.contains("paused")).toBe(false);
});
});
describe("TaskCard files-changed in done column", () => {
const noopToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockUseSessionFiles.mockReturnValue({ files: [], loading: false });
mockUseTaskDiffStats.mockReturnValue({ stats: null, loading: false });
});
it("shows mergeDetails.filesChanged for done column when set", () => {
const task = makeTask({
column: "done",
mergeDetails: { filesChanged: 7, mergedAt: "2026-01-01T00:00:00Z", targetBranch: "main" },
});
mockUseSessionFiles.mockReturnValue({ files: ["a.ts"], loading: false });
render(
);
expect(screen.getByText("7 files changed")).toBeInTheDocument();
// Should NOT show session files count since mergeDetails takes priority
expect(screen.queryByText("1 files changed")).not.toBeInTheDocument();
});
it("does not fetch session files count for done column with worktree but no mergeDetails.filesChanged", () => {
const task = makeTask({
column: "done",
worktree: "/repo/.worktrees/fn-099",
});
mockUseSessionFiles.mockReturnValue({ files: ["src/a.ts", "src/b.ts", "src/c.ts"], loading: false });
render(
);
expect(screen.queryByText("3 files changed")).not.toBeInTheDocument();
expect(screen.queryByText("Checking files…")).not.toBeInTheDocument();
});
it("shows nothing for done column without worktree, modifiedFiles, and mergeDetails.filesChanged", () => {
const task = makeTask({ column: "done" });
mockUseSessionFiles.mockReturnValue({ files: [], loading: false });
render(
);
expect(screen.queryByText(/files changed/)).not.toBeInTheDocument();
});
it("shows modifiedFiles count for done column without mergeDetails", () => {
const task = makeTask({
column: "done",
modifiedFiles: ["src/a.ts", "src/b.ts", "src/c.ts"],
});
mockUseSessionFiles.mockReturnValue({ files: [], loading: false });
render(
);
expect(screen.getByText("3 files changed")).toBeInTheDocument();
});
it("prefers mergeDetails.filesChanged over modifiedFiles for done column", () => {
const task = makeTask({
column: "done",
modifiedFiles: ["src/a.ts", "src/b.ts"],
mergeDetails: { filesChanged: 7, mergedAt: "2026-01-01T00:00:00Z", targetBranch: "main" },
});
mockUseSessionFiles.mockReturnValue({ files: [], loading: false });
render(
);
expect(screen.getByText("7 files changed")).toBeInTheDocument();
expect(screen.queryByText("2 files changed")).not.toBeInTheDocument();
});
it("prefers mergeDetails.filesChanged over sessionFiles for done column", () => {
const task = makeTask({
column: "done",
worktree: "/repo/.worktrees/fn-099",
mergeDetails: { filesChanged: 5, mergedAt: "2026-01-01T00:00:00Z", targetBranch: "main" },
});
mockUseSessionFiles.mockReturnValue({ files: ["a.ts", "b.ts"], loading: false });
render(
);
// Should show the mergeDetails count, not the sessionFiles count
expect(screen.getByText("5 files changed")).toBeInTheDocument();
expect(screen.queryByText("2 files changed")).not.toBeInTheDocument();
});
it("shows loading state for done column with worktree and no mergeDetails", () => {
const task = makeTask({
column: "done",
worktree: "/repo/.worktrees/fn-099",
});
mockUseSessionFiles.mockReturnValue({ files: [], loading: true });
render(
);
// No loading indicator is shown because sessionFiles.length is 0
// The button only appears when files.length > 0
expect(screen.queryByText(/files changed/)).not.toBeInTheDocument();
expect(screen.queryByText("Checking files…")).not.toBeInTheDocument();
});
it("prefers diffStats.filesChanged over mergeDetails.filesChanged when both are set", () => {
const task = makeTask({
column: "done",
mergeDetails: { filesChanged: 7, mergedAt: "2026-01-01T00:00:00Z", targetBranch: "main", commitSha: "abc123" },
});
mockUseSessionFiles.mockReturnValue({ files: [], loading: false });
mockUseTaskDiffStats.mockReturnValue({ stats: { filesChanged: 5, additions: 20, deletions: 3 }, loading: false });
render(
);
// Should show diffStats count, not mergeDetails count
expect(screen.getByText("5 files changed")).toBeInTheDocument();
expect(screen.queryByText("7 files changed")).not.toBeInTheDocument();
});
it("falls back to mergeDetails.filesChanged when diffStats returns null", () => {
const task = makeTask({
column: "done",
mergeDetails: { filesChanged: 7, mergedAt: "2026-01-01T00:00:00Z", targetBranch: "main", commitSha: "abc123" },
});
mockUseSessionFiles.mockReturnValue({ files: [], loading: false });
mockUseTaskDiffStats.mockReturnValue({ stats: null, loading: false });
render(
);
// Should fall back to mergeDetails count
expect(screen.getByText("7 files changed")).toBeInTheDocument();
});
it("falls back to mergeDetails.filesChanged when diffStats returns 0 files", () => {
const task = makeTask({
column: "done",
mergeDetails: { filesChanged: 7, mergedAt: "2026-01-01T00:00:00Z", targetBranch: "main", commitSha: "abc123" },
});
mockUseSessionFiles.mockReturnValue({ files: [], loading: false });
mockUseTaskDiffStats.mockReturnValue({ stats: { filesChanged: 0, additions: 0, deletions: 0 }, loading: false });
render(
);
// diffStats returns 0, which is > 0 is false, so it falls through to mergeDetails
// Actually 0 is not > 0, so the first if block is skipped and it falls to modifiedFiles/sessionFiles
// But mergeDetails.filesChanged is 7 — however the code uses diffCount ?? mergedCount
// diffCount is 0 (not null/undefined), so displayCount = 0, and 0 > 0 is false
// This means it falls through to modifiedFiles check
expect(screen.queryByText("7 files changed")).not.toBeInTheDocument();
});
it("shows diffStats count even without mergeDetails", () => {
const task = makeTask({
column: "done",
});
mockUseSessionFiles.mockReturnValue({ files: [], loading: false });
mockUseTaskDiffStats.mockReturnValue({ stats: { filesChanged: 3, additions: 10, deletions: 2 }, loading: false });
render(
);
expect(screen.getByText("3 files changed")).toBeInTheDocument();
});
});
describe("TaskCard singular/plural file count", () => {
const noopToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockUseSessionFiles.mockReturnValue({ files: [], loading: false });
mockUseTaskDiffStats.mockReturnValue({ stats: null, loading: false });
});
it("shows changed-file counts for in-progress worktrees", () => {
const task = makeTask({
column: "in-progress",
worktree: "/repo/.worktrees/fn-099",
status: "executing",
});
mockUseTaskDiffStats.mockReturnValue({ stats: { filesChanged: 3, additions: 10, deletions: 2 }, loading: false });
render(
);
expect(screen.getByText("3 files changed")).toBeInTheDocument();
expect(screen.queryByText("View files")).not.toBeInTheDocument();
expect(screen.queryByText("Checking files…")).not.toBeInTheDocument();
});
it("shows changed-file counts for in-review worktrees", () => {
const task = makeTask({
column: "in-review",
worktree: "/repo/.worktrees/fn-099",
status: "reviewing",
});
mockUseTaskDiffStats.mockReturnValue({ stats: { filesChanged: 2, additions: 7, deletions: 1 }, loading: false });
render(
);
expect(screen.getByText("2 files changed")).toBeInTheDocument();
expect(screen.queryByText("View files")).not.toBeInTheDocument();
});
it("hides file changes link for in-progress worktrees when filesChanged is 0", () => {
const task = makeTask({
column: "in-progress",
worktree: "/repo/.worktrees/fn-099",
status: "executing",
});
mockUseTaskDiffStats.mockReturnValue({ stats: { filesChanged: 0, additions: 0, deletions: 0 }, loading: false });
render(
);
expect(screen.queryByText("View files")).not.toBeInTheDocument();
expect(screen.queryByText(/files? changed/)).not.toBeInTheDocument();
});
it("hides file changes link for in-progress worktrees when diffStats is null", () => {
const task = makeTask({
column: "in-progress",
worktree: "/repo/.worktrees/fn-099",
status: "executing",
});
mockUseTaskDiffStats.mockReturnValue({ stats: null, loading: false });
render(
);
expect(screen.queryByText("View files")).not.toBeInTheDocument();
expect(screen.queryByText(/files? changed/)).not.toBeInTheDocument();
});
it("displays '1 file changed' (singular) for done column with displayCount=1 via diffStats", () => {
const task = makeTask({
column: "done",
mergeDetails: { filesChanged: 7, mergedAt: "2026-01-01T00:00:00Z", targetBranch: "main" },
});
mockUseSessionFiles.mockReturnValue({ files: [], loading: false });
mockUseTaskDiffStats.mockReturnValue({ stats: { filesChanged: 1, additions: 5, deletions: 0 }, loading: false });
render(
);
expect(screen.getByText("1 file changed")).toBeInTheDocument();
expect(screen.queryByText("1 files changed")).not.toBeInTheDocument();
});
it("displays '1 file changed' (singular) for done column with modifiedFiles of length 1", () => {
const task = makeTask({
column: "done",
modifiedFiles: ["src/a.ts"],
});
mockUseSessionFiles.mockReturnValue({ files: [], loading: false });
render(
);
expect(screen.getByText("1 file changed")).toBeInTheDocument();
expect(screen.queryByText("1 files changed")).not.toBeInTheDocument();
});
it("does not fetch session file counts as a done column fallback", () => {
const task = makeTask({
column: "done",
worktree: "/repo/.worktrees/fn-099",
});
mockUseSessionFiles.mockReturnValue({ files: ["src/a.ts"], loading: false });
render(
);
expect(screen.queryByText("1 file changed")).not.toBeInTheDocument();
expect(screen.queryByText("Checking files…")).not.toBeInTheDocument();
});
it("displays 'N files changed' (plural) for done column with diffStats count > 1", () => {
const task = makeTask({ column: "done" });
mockUseSessionFiles.mockReturnValue({ files: [], loading: false });
mockUseTaskDiffStats.mockReturnValue({ stats: { filesChanged: 3, additions: 10, deletions: 2 }, loading: false });
render(
);
expect(screen.getByText("3 files changed")).toBeInTheDocument();
});
it("displays 'N files changed' (plural) for done column with modifiedFiles length > 1", () => {
const task = makeTask({
column: "done",
modifiedFiles: ["src/a.ts", "src/b.ts"],
});
mockUseSessionFiles.mockReturnValue({ files: [], loading: false });
render(
);
expect(screen.getByText("2 files changed")).toBeInTheDocument();
});
});
describe("TaskCard mission badge", () => {
const createTask = (overrides: Partial = {}): Task => ({
id: "FN-001",
description: "Test task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
columnMovedAt: "2026-01-01T00:00:00Z",
...overrides,
} as Task);
it("renders mission badge when task has missionId", async () => {
const task = createTask({ missionId: "MSN-001" });
render();
const badge = screen.getByTitle("Mission: MSN-001");
expect(badge).toBeInTheDocument();
expect(badge).toHaveClass("card-mission-badge");
expect(badge).toHaveTextContent("MSN-001");
await act(async () => {
await Promise.resolve();
});
});
it("does not render mission badge when task has no missionId", () => {
const task = createTask();
render();
expect(screen.queryByTitle(/Mission:/)).not.toBeInTheDocument();
expect(screen.queryByTestId("target-icon")).not.toBeInTheDocument();
});
it("calls onOpenMission when mission badge is clicked", async () => {
const onOpenMission = vi.fn();
const task = createTask({ missionId: "MSN-042" });
render(
);
const badge = screen.getByTitle("Mission: MSN-042");
await userEvent.click(badge);
expect(onOpenMission).toHaveBeenCalledOnce();
expect(onOpenMission).toHaveBeenCalledWith("MSN-042");
});
it("stops propagation when mission badge is clicked", async () => {
const onOpenDetail = vi.fn();
const onOpenMission = vi.fn();
const task = createTask({ missionId: "MSN-001" });
render(
);
const badge = screen.getByTitle("Mission: MSN-001");
await userEvent.click(badge);
// onOpenDetail should NOT be called since click is stopped
expect(onOpenDetail).not.toHaveBeenCalled();
expect(onOpenMission).toHaveBeenCalledWith("MSN-001");
});
it("truncates long mission titles to 9 characters with ellipsis", async () => {
const { fetchMission } = await import("../../api");
vi.mocked(fetchMission).mockResolvedValue({
id: "MSN-LONG",
title: "This is a very long mission title that should be truncated",
description: "Test mission",
status: "active",
milestones: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
} as any);
const task = createTask({ missionId: "MSN-LONG" });
render();
await waitFor(() => {
const badge = screen.getByTitle(
"Mission: This is a very long mission title that should be truncated",
);
expect(badge).toBeInTheDocument();
expect(badge).toHaveClass("card-mission-badge");
// MAX_MISSION_TITLE_LENGTH is 12, so truncated form is 9 chars + "..."
expect(badge).toHaveTextContent("This is a...");
});
});
it("applies ellipsis CSS to mission badge for overflow handling", async () => {
const { fetchMission } = await import("../../api");
vi.mocked(fetchMission).mockResolvedValue({
id: "MSN-ELLIPSIS",
title: "A very long mission name that exceeds twelve chars",
description: "Test mission",
status: "active",
milestones: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
} as any);
const task = createTask({ missionId: "MSN-ELLIPSIS" });
render();
await waitFor(() => {
const badge = screen.getByTitle(
"Mission: A very long mission name that exceeds twelve chars",
);
// Check computed styles for ellipsis properties
expect(window.getComputedStyle(badge).textOverflow).toBe("ellipsis");
expect(window.getComputedStyle(badge).whiteSpace).toBe("nowrap");
expect(window.getComputedStyle(badge).overflow).toBe("hidden");
});
});
});
describe("TaskCard agent badge", () => {
const createTask = (overrides: Partial = {}): Task => ({
id: "FN-001",
description: "Test task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
columnMovedAt: "2026-01-01T00:00:00Z",
...overrides,
} as Task);
let clearAgentCache: () => void;
beforeAll(async () => {
const mod = await import("../TaskCard");
clearAgentCache = (mod as { __test_clearAgentNameCache?: () => void }).__test_clearAgentNameCache ?? (() => undefined);
});
beforeEach(async () => {
clearAgentCache?.();
const { fetchAgent } = await import("../../api");
vi.mocked(fetchAgent).mockReset();
});
it("renders agent badge when task has assignedAgentId", async () => {
const { fetchAgent } = await import("../../api");
vi.mocked(fetchAgent).mockResolvedValue({
id: "agent-001",
name: "Autopilot Agent",
role: "executor",
state: "active",
metadata: {},
heartbeatHistory: [],
completedRuns: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
} as any);
const task = createTask({ assignedAgentId: "agent-001" });
render();
await waitFor(() => {
const badge = screen.getByTitle("Assigned to Autopilot Agent");
expect(badge).toBeInTheDocument();
expect(badge).toHaveClass("card-agent-badge");
expect(screen.getByTestId("bot-icon")).toBeInTheDocument();
const text = badge.querySelector(".card-agent-badge-text");
expect(text).toBeInTheDocument();
});
const badge = screen.getByTitle("Assigned to Autopilot Agent");
expect(badge.closest(".card-agent-row")).toBeTruthy();
expect(badge.closest(".card-header")).toBeNull();
const styles = readFileSync(resolve(PACKAGE_ROOT, "app/styles.css"), "utf-8");
expect(styles).toMatch(/\.card-agent-badge\s*\{[^}]*font-size:\s*10px;/);
expect(styles).toMatch(/\.card-agent-badge\s*\{[^}]*border-radius:\s*var\(--radius-pill\);/);
expect(styles).toMatch(/\.card-agent-badge\s*\{[^}]*background:\s*color-mix\(/);
expect(styles).not.toMatch(/\.card-agent-badge\s*\{[^}]*font-family:\s*var\(--font-mono\);/);
expect(styles).toMatch(/\.card-agent-row\s*\{/);
expect(styles).toMatch(/\.card-agent-badge-text\s*\{[^}]*text-overflow:\s*ellipsis;/);
expect(styles).toMatch(/\.card-agent-badge-text\s*\{[^}]*white-space:\s*nowrap;/);
expect(styles).toMatch(/\.card-agent-badge-text\s*\{[^}]*overflow:\s*hidden;/);
});
it("shows loading state while agent name is being fetched", async () => {
const { fetchAgent } = await import("../../api");
vi.mocked(fetchAgent).mockReturnValue(new Promise(() => undefined) as any);
const task = createTask({ assignedAgentId: "agent-001" });
render();
const badge = await screen.findByTitle("Assigned to agent-001");
expect(badge).toHaveClass("card-agent-badge", "card-agent-badge--loading");
expect(badge).toHaveTextContent("agent-001");
});
it("truncates long fetched agent names", async () => {
const { fetchAgent } = await import("../../api");
vi.mocked(fetchAgent).mockResolvedValue({
id: "agent-001",
name: "AutopilotSuperLongAgentName",
role: "executor",
state: "active",
metadata: {},
heartbeatHistory: [],
completedRuns: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
} as any);
const task = createTask({ assignedAgentId: "agent-001" });
render();
await waitFor(() => {
const badge = screen.getByTitle("Assigned to AutopilotSuperLongAgentName");
const text = badge.querySelector(".card-agent-badge-text");
expect(text).toHaveTextContent("AutopilotSup...");
expect(badge).not.toHaveClass("card-agent-badge--loading");
});
});
it("falls back to assignedAgentId when fetchAgent fails", async () => {
const { fetchAgent } = await import("../../api");
vi.mocked(fetchAgent).mockRejectedValue(new Error("network error"));
const task = createTask({ assignedAgentId: "agent-404" });
render();
const badge = await screen.findByTitle("Assigned to agent-404");
await waitFor(() => {
expect(badge).not.toHaveClass("card-agent-badge--loading");
expect(badge).toHaveTextContent("agent-404");
});
});
it("does not render agent badge when assignedAgentId is undefined", () => {
const task = createTask();
render();
expect(screen.queryByTitle(/Assigned to/)).not.toBeInTheDocument();
expect(screen.queryByTestId("bot-icon")).not.toBeInTheDocument();
});
});
describe("TaskCard send-back functionality", () => {
const createTask = (overrides: Partial = {}): Task => ({
id: "FN-001",
description: "Test task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
columnMovedAt: "2026-01-01T00:00:00Z",
...overrides,
} as Task);
it("renders send-back button when task is in-progress and onMoveTask is provided", () => {
const onMoveTask = vi.fn().mockResolvedValue({});
const task = createTask({ column: "in-progress" });
render();
expect(screen.getByRole("button", { name: /send back/i })).toBeInTheDocument();
});
it("does not render send-back button when task is in todo", () => {
const onMoveTask = vi.fn().mockResolvedValue({});
const task = createTask({ column: "todo" });
render();
expect(screen.queryByRole("button", { name: /send back/i })).not.toBeInTheDocument();
});
it("renders Move button when task is in in-review and onMoveTask is provided", () => {
const onMoveTask = vi.fn().mockResolvedValue({});
const task = createTask({ column: "in-review" });
render();
expect(screen.getByRole("button", { name: /move task/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /send back/i })).not.toBeInTheDocument();
});
it("does not render Move button for in-review when onMoveTask is not provided", () => {
const task = createTask({ column: "in-review" });
render();
expect(screen.queryByRole("button", { name: /move task/i })).not.toBeInTheDocument();
});
it("toggles Move dropdown when in-review Move button is clicked", () => {
const onMoveTask = vi.fn().mockResolvedValue({});
const task = createTask({ column: "in-review" });
render();
// Initially, dropdown is not visible
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
// Click the Move button
const btn = screen.getByRole("button", { name: /move task/i });
fireEvent.click(btn);
// Dropdown should now be visible
expect(screen.getByRole("menu")).toBeInTheDocument();
});
it("in-review Move dropdown shows Done (no merge), In Progress, and Todo options", () => {
const onMoveTask = vi.fn().mockResolvedValue({});
const task = createTask({ column: "in-review" });
render();
// Open the dropdown
fireEvent.click(screen.getByRole("button", { name: /move task/i }));
// Check menu is visible
const menu = screen.getByRole("menu");
expect(menu).toBeInTheDocument();
// Should show all three options
expect(screen.getByRole("menuitem", { name: /done \(no merge\)/i })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: /in progress/i })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: /todo/i })).toBeInTheDocument();
});
it("clicking Done (no merge) in in-review dropdown calls onMoveTask with done and closes menu", async () => {
const onMoveTask = vi.fn().mockResolvedValue({});
const addToast = vi.fn();
const task = createTask({ column: "in-review" });
render();
// Open the dropdown
fireEvent.click(screen.getByRole("button", { name: /move task/i }));
// Click "Done (no merge)" option
fireEvent.click(screen.getByRole("menuitem", { name: /done \(no merge\)/i }));
// Should have called onMoveTask
await waitFor(() => {
expect(onMoveTask).toHaveBeenCalledWith("FN-001", "done");
});
// Dropdown should be closed
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
// Toast should have been shown
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Moved FN-001 to Done", "success");
});
});
it("clicking outside closes in-review Move dropdown", () => {
const onMoveTask = vi.fn().mockResolvedValue({});
const task = createTask({ column: "in-review" });
render();
// Open the dropdown
fireEvent.click(screen.getByRole("button", { name: /move task/i }));
expect(screen.getByRole("menu")).toBeInTheDocument();
// Click outside (on the card itself, not inside the Move dropdown)
fireEvent.click(document.querySelector(".card")!);
// Dropdown should be closed
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
it("does not render send-back button when onMoveTask is not provided", () => {
const task = createTask({ column: "in-progress" });
render();
expect(screen.queryByRole("button", { name: /send back/i })).not.toBeInTheDocument();
});
it("toggles dropdown menu when send-back button is clicked", () => {
const onMoveTask = vi.fn().mockResolvedValue({});
const task = createTask({ column: "in-progress" });
render();
// Initially, dropdown is not visible
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
// Click the send-back button
const btn = screen.getByRole("button", { name: /send back/i });
fireEvent.click(btn);
// Dropdown should now be visible
expect(screen.getByRole("menu")).toBeInTheDocument();
});
it("dropdown shows Todo and Triage options but not In Review", () => {
const onMoveTask = vi.fn().mockResolvedValue({});
const task = createTask({ column: "in-progress" });
render();
// Open the dropdown
fireEvent.click(screen.getByRole("button", { name: /send back/i }));
// Check menu is visible
const menu = screen.getByRole("menu");
expect(menu).toBeInTheDocument();
// Should show Todo and Triage
expect(screen.getByRole("menuitem", { name: /todo/i })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: /triage/i })).toBeInTheDocument();
// Should NOT show In Review
expect(screen.queryByRole("menuitem", { name: /in review/i })).not.toBeInTheDocument();
});
it("clicking a dropdown option calls onMoveTask with correct column and closes menu", async () => {
const onMoveTask = vi.fn().mockResolvedValue({});
const addToast = vi.fn();
const task = createTask({ column: "in-progress" });
render();
// Open the dropdown
fireEvent.click(screen.getByRole("button", { name: /send back/i }));
// Click "Todo" option
fireEvent.click(screen.getByRole("menuitem", { name: /todo/i }));
// Should have called onMoveTask
await waitFor(() => {
expect(onMoveTask).toHaveBeenCalledWith("FN-001", "todo");
});
// Dropdown should be closed
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
// Toast should have been shown
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Moved FN-001 to Todo", "success");
});
});
it("shows error toast when onMoveTask fails", async () => {
const onMoveTask = vi.fn().mockRejectedValue(new Error("Network error"));
const addToast = vi.fn();
const task = createTask({ column: "in-progress" });
render();
// Open the dropdown and click "Triage"
fireEvent.click(screen.getByRole("button", { name: /send back/i }));
fireEvent.click(screen.getByRole("menuitem", { name: /triage/i }));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Failed to move FN-001"), "error");
});
});
it("clicking outside dropdown closes it", () => {
const onMoveTask = vi.fn().mockResolvedValue({});
const task = createTask({ column: "in-progress" });
render();
// Open the dropdown
fireEvent.click(screen.getByRole("button", { name: /send back/i }));
expect(screen.getByRole("menu")).toBeInTheDocument();
// Click outside (on the card itself, not inside the send-back dropdown)
fireEvent.click(document.querySelector(".card")!);
// Dropdown should be closed
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
describe("useTaskDiffStats integration", () => {
beforeEach(() => {
mockUseTaskDiffStats.mockClear();
mockUseTaskDiffStats.mockReturnValue({ stats: null, loading: false });
});
it("passes stepVersion and pollIntervalMs for in-progress tasks", () => {
const task = createTask({
column: "in-progress",
steps: [
{ name: "Step 1", status: "pending" },
{ name: "Step 2", status: "done" },
],
worktree: "/repo/.worktrees/fn-001",
});
render();
// Verify useTaskDiffStats was called
expect(mockUseTaskDiffStats).toHaveBeenCalled();
// Get the options argument (5th argument)
const callArgs = mockUseTaskDiffStats.mock.calls[0];
const options = callArgs[4] as Record;
// Should pass stepVersion for in-progress
expect(options.stepVersion).toBe("Step 1:pending|Step 2:done");
// Should pass pollIntervalMs for in-progress
expect(options.pollIntervalMs).toBe(30_000);
});
it("passes stepVersion and pollIntervalMs for in-review tasks", () => {
const task = createTask({
column: "in-review",
steps: [{ name: "Verify", status: "in-progress" }],
worktree: "/repo/.worktrees/fn-001",
});
render();
const callArgs = mockUseTaskDiffStats.mock.calls[0];
const options = callArgs[4] as Record;
expect(options.stepVersion).toBe("Verify:in-progress");
expect(options.pollIntervalMs).toBe(30_000);
});
it("does not pass stepVersion or pollIntervalMs for done tasks", () => {
const task = createTask({
column: "done",
steps: [{ name: "Step 1", status: "done" }],
mergeDetails: { commitSha: "abc123" },
});
render();
const callArgs = mockUseTaskDiffStats.mock.calls[0];
const options = callArgs[4] as Record;
// done tasks should not have stepVersion or pollIntervalMs
expect(options.stepVersion).toBeUndefined();
expect(options.pollIntervalMs).toBeUndefined();
});
it("updates stepVersion when step status changes on in-progress task", () => {
const onOpenDetail = vi.fn();
const addToast = vi.fn();
const task1 = createTask({
column: "in-progress",
steps: [{ name: "Step 1", status: "pending" }],
worktree: "/repo/.worktrees/fn-001",
});
const { rerender } = render(
,
);
// First call should have the initial stepVersion
const firstCallArgs = mockUseTaskDiffStats.mock.calls[0];
const firstOptions = firstCallArgs[4] as Record;
expect(firstOptions.stepVersion).toBe("Step 1:pending");
// Update task with changed step
mockUseTaskDiffStats.mockClear();
const task2 = createTask({
column: "in-progress",
steps: [{ name: "Step 1", status: "done" }],
worktree: "/repo/.worktrees/fn-001",
});
rerender();
// Second call should have updated stepVersion
const secondCallArgs = mockUseTaskDiffStats.mock.calls[0];
const secondOptions = secondCallArgs[4] as Record;
expect(secondOptions.stepVersion).toBe("Step 1:done");
});
});
});
/**
* Tests for delete button functionality in TaskCard.
*/
describe("TaskCard delete button", () => {
const noopToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("shows delete button for triage column tasks when onDeleteTask is provided", () => {
const task = makeTask({ column: "triage" });
const onDeleteTask = vi.fn().mockResolvedValue(task);
render(
);
const deleteBtn = screen.getByRole("button", { name: /Delete task/i });
expect(deleteBtn).toBeDefined();
expect(deleteBtn.classList.contains("card-delete-btn")).toBe(true);
});
it("does not show delete button for non-triage column tasks", () => {
for (const column of ["todo", "in-progress", "in-review", "done", "archived"] as const) {
const task = makeTask({ column });
const onDeleteTask = vi.fn().mockResolvedValue(task);
render(
);
const deleteBtn = screen.queryByRole("button", { name: /Delete task/i });
expect(deleteBtn).toBeNull();
}
});
it("does not show delete button when onDeleteTask is not provided", () => {
const task = makeTask({ column: "triage" });
render(
);
const deleteBtn = screen.queryByRole("button", { name: /Delete task/i });
expect(deleteBtn).toBeNull();
});
it("clicking delete button shows confirmation dialog and calls onDeleteTask on confirm", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
const task = makeTask({ column: "triage", id: "FN-123" });
const onDeleteTask = vi.fn().mockResolvedValue(task);
render(
);
const deleteBtn = screen.getByRole("button", { name: /Delete task/i });
fireEvent.click(deleteBtn);
await waitFor(() => {
expect(window.confirm).toHaveBeenCalledWith("Delete FN-123?");
});
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-123");
});
await waitFor(() => {
expect(noopToast).toHaveBeenCalledWith("Deleted FN-123", "success");
});
});
it("clicking delete button does not call onDeleteTask when confirmation is cancelled", async () => {
vi.spyOn(window, "confirm").mockReturnValue(false);
const task = makeTask({ column: "triage", id: "FN-456" });
const onDeleteTask = vi.fn().mockResolvedValue(task);
render(
);
const deleteBtn = screen.getByRole("button", { name: /Delete task/i });
fireEvent.click(deleteBtn);
await waitFor(() => {
expect(window.confirm).toHaveBeenCalledWith("Delete FN-456?");
});
expect(onDeleteTask).not.toHaveBeenCalled();
});
it("delete button click does not propagate to card click (does not open detail)", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
const task = makeTask({ column: "triage", id: "FN-789" });
const onDeleteTask = vi.fn().mockResolvedValue(task);
const onOpenDetail = vi.fn();
render(
);
const deleteBtn = screen.getByRole("button", { name: /Delete task/i });
fireEvent.click(deleteBtn);
// Wait for the click to propagate
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
// onOpenDetail should not have been called because stopPropagation was used
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("shows error toast when delete fails", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
const task = makeTask({ column: "triage", id: "FN-ERROR" });
const onDeleteTask = vi.fn().mockRejectedValue(new Error("Network error"));
const addToast = vi.fn();
render(
);
const deleteBtn = screen.getByRole("button", { name: /Delete task/i });
fireEvent.click(deleteBtn);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("Failed to delete FN-ERROR"),
"error"
);
});
});
});
describe("TaskCard PluginSlot integration", () => {
function makeTaskWithDeps(id: string): Task {
return {
id,
title: `Task ${id}`,
description: "Test description",
column: "todo",
status: "todo",
priority: 0,
size: "M",
dependencies: ["FN-001"],
steps: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
it("renders PluginSlot for task-card-badge", () => {
mockUsePluginUiSlots.mockReturnValue({
slots: [{ pluginId: "test-plugin", slot: { slotId: "task-card-badge", label: "Badge", componentPath: "./test.js" } }],
getSlotsForId: (id: string) => id === "task-card-badge" ? [{ pluginId: "test-plugin", slot: { slotId: "task-card-badge", label: "Badge", componentPath: "./test.js" } }] : [],
loading: false,
error: null,
});
const task = makeTaskWithDeps("FN-001");
const { container } = render(
);
const slot = container.querySelector('[data-slot-id="task-card-badge"]');
expect(slot).not.toBeNull();
expect(slot).toHaveAttribute("data-plugin-id", "test-plugin");
});
it("renders PluginSlot even when task has no dependencies", () => {
mockUsePluginUiSlots.mockReturnValue({
slots: [{ pluginId: "test-plugin", slot: { slotId: "task-card-badge", label: "Badge", componentPath: "./test.js" } }],
getSlotsForId: (id: string) => id === "task-card-badge" ? [{ pluginId: "test-plugin", slot: { slotId: "task-card-badge", label: "Badge", componentPath: "./test.js" } }] : [],
loading: false,
error: null,
});
const task: Task = {
id: "FN-002",
title: "Task without deps",
column: "todo",
status: "todo",
priority: 0,
size: "M",
steps: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const { container } = render(
);
const slot = container.querySelector('[data-slot-id="task-card-badge"]');
expect(slot).not.toBeNull();
expect(slot).toHaveAttribute("data-plugin-id", "test-plugin");
});
it("renders nothing when no plugins register for task-card-badge slot", () => {
mockUsePluginUiSlots.mockReturnValue({
slots: [],
getSlotsForId: vi.fn(() => []),
loading: false,
error: null,
});
const task = makeTaskWithDeps("FN-001");
const { container } = render(
);
const slot = container.querySelector('[data-slot-id="task-card-badge"]');
expect(slot).toBeNull();
});
});