import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
makeTask,
noop,
noopDelete,
noopMerge,
noopMove,
noopOpenDetail,
setupTaskDetailModalHooks,
mockConfirmWithChoice,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailModal } from "../TaskDetailModal";
vi.mock("../BranchGroupCard", () => ({
BranchGroupCard: ({ groupId }: { groupId: string }) =>
Mock Branch Group {groupId}
,
}));
setupTaskDetailModalHooks();
describe("TaskDetailModal GitHub tracking CTA", () => {
it("disables create tracking issue when task has no usable title", async () => {
const user = userEvent.setup();
render(
,
);
await user.click(screen.getByRole("button", { name: "Expand GitHub tracking details" }));
const button = screen.getByRole("button", { name: "Create tracking issue" });
expect(button).toBeDisabled();
expect(button).toHaveAttribute("title", "Add a title or description so a tracking issue can be created.");
expect(screen.getByText("Tracking issue will be created once this task has a title or description to summarize.")).toBeInTheDocument();
});
it("enables create tracking issue when task title is present", async () => {
const user = userEvent.setup();
render(
,
);
await user.click(screen.getByRole("button", { name: "Expand GitHub tracking details" }));
expect(screen.getByRole("button", { name: "Create tracking issue" })).toBeEnabled();
expect(screen.queryByText("Tracking issue will be created once this task has a title or description to summarize.")).not.toBeInTheDocument();
});
it("enables create tracking issue when task description has a non-empty first line", async () => {
const user = userEvent.setup();
render(
,
);
await user.click(screen.getByRole("button", { name: "Expand GitHub tracking details" }));
expect(screen.getByRole("button", { name: "Create tracking issue" })).toBeEnabled();
expect(screen.queryByText("Tracking issue will be created once this task has a title or description to summarize.")).not.toBeInTheDocument();
});
});
describe("TaskDetailModal Logs activity loading", () => {
function renderLogsModal(task: ReturnType | Record) {
return render(
,
);
}
function makeSlimTask(overrides: Record = {}) {
const { prompt: _prompt, log: _log, steps: _steps, ...task } = makeTask({
id: "FN-6040",
description: "Slim task",
...overrides,
});
return task;
}
it("shows activity loading instead of empty state while slim task detail is pending", async () => {
const { fetchTaskDetail } = await import("../../api");
vi.mocked(fetchTaskDetail).mockReset();
vi.mocked(fetchTaskDetail).mockImplementationOnce(() => new Promise(() => {}));
renderLogsModal(makeSlimTask());
expect(await screen.findByRole("status")).toHaveTextContent("Loading activity…");
expect(screen.queryByText("(no activity)")).not.toBeInTheDocument();
});
it("shows activity loading when switching to Logs before slim task detail resolves", async () => {
const user = userEvent.setup();
const { fetchTaskDetail } = await import("../../api");
vi.mocked(fetchTaskDetail).mockReset();
vi.mocked(fetchTaskDetail).mockImplementationOnce(() => new Promise(() => {}));
render(
,
);
await user.click(screen.getByRole("button", { name: "Logs" }));
expect(await screen.findByRole("status")).toHaveTextContent("Loading activity…");
expect(screen.queryByText("(no activity)")).not.toBeInTheDocument();
});
it("shows empty activity only after loaded detail has no entries", async () => {
const { fetchTaskDetail } = await import("../../api");
vi.mocked(fetchTaskDetail).mockReset();
vi.mocked(fetchTaskDetail).mockResolvedValueOnce(makeTask({ id: "FN-6040", prompt: "# Loaded", log: [] }));
renderLogsModal(makeSlimTask());
expect(await screen.findByText("(no activity)")).toBeInTheDocument();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
it("renders loaded activity entries newest first", async () => {
const { fetchTaskDetail } = await import("../../api");
vi.mocked(fetchTaskDetail).mockReset();
vi.mocked(fetchTaskDetail).mockResolvedValueOnce(makeTask({
id: "FN-6040",
prompt: "# Loaded",
log: [
{ timestamp: "2026-06-08T00:00:00.000Z", action: "older entry" },
{ timestamp: "2026-06-08T00:01:00.000Z", action: "newer entry" },
],
}));
const { container } = renderLogsModal(makeSlimTask());
await screen.findByText("newer entry");
const actions = Array.from(container.querySelectorAll(".detail-log-action")).map((node) => node.textContent);
expect(actions).toEqual(["newer entry", "older entry"]);
expect(screen.queryByText("(no activity)")).not.toBeInTheDocument();
});
it("preserves truncated activity message after detail load", async () => {
const { fetchTaskDetail } = await import("../../api");
vi.mocked(fetchTaskDetail).mockReset();
vi.mocked(fetchTaskDetail).mockResolvedValueOnce(makeTask({
id: "FN-6040",
prompt: "# Loaded",
log: [{ timestamp: "2026-06-08T00:00:00.000Z", action: "kept entry" }],
activityLogTruncatedCount: 25,
} as any));
renderLogsModal(makeSlimTask());
expect(await screen.findByText("Showing the most recent 1 activity entries.")).toBeInTheDocument();
expect(screen.getByText("kept entry")).toBeInTheDocument();
});
});
describe("TaskDetailModal Logs agent loading", () => {
it("shows the Agent Log loading indicator when entering the subview", async () => {
const user = userEvent.setup();
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
const mockUseAgentLogs = vi.mocked(useAgentLogs);
mockUseAgentLogs.mockImplementation((_taskId, enabled) => ({
entries: [],
loading: enabled,
clear: vi.fn(),
loadMore: vi.fn(async () => {}),
hasMore: false,
total: null,
loadingMore: false,
}));
render(
,
);
await user.click(screen.getByRole("button", { name: "Logs" }));
await user.click(screen.getByRole("button", { name: "Agent Log" }));
expect(screen.getByText("Loading agent logs…")).toBeInTheDocument();
expect(screen.queryByText("No agent output yet.")).not.toBeInTheDocument();
mockUseAgentLogs.mockImplementation(() => ({ entries: [], loading: false, clear: vi.fn(), loadMore: vi.fn(async () => {}), hasMore: false, total: null, loadingMore: false }));
});
});
describe("TaskDetailModal branch group surfacing", () => {
it("renders branch group card when task has group context", () => {
render(
,
);
expect(screen.getByText("Mock Branch Group BG-1")).toBeInTheDocument();
});
});
describe("TaskDetailModal delete affordance", () => {
it("archives done task when Archive Instead is chosen", async () => {
const user = userEvent.setup();
const onArchiveTask = vi.fn(async () => makeTask({ column: "archived" }));
const onDeleteTask = vi.fn(async () => makeTask());
const onClose = vi.fn();
mockConfirmWithChoice.mockResolvedValueOnce("tertiary");
render(
,
);
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(screen.getByRole("menuitem", { name: "Delete" }));
await waitFor(() => {
expect(mockConfirmWithChoice).toHaveBeenCalledWith(expect.objectContaining({ tertiaryLabel: "Archive Instead" }));
expect(onArchiveTask).toHaveBeenCalledWith("FN-099");
expect(onDeleteTask).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
});
});
describe("TaskDetailModal in-review stall diagnostics", () => {
it("renders diagnostic row and jumps to highlighted activity entry", async () => {
const user = userEvent.setup();
render(
,
);
await user.click(screen.getByRole("button", { name: "Pull Request" }));
expect(screen.getByText("Merge blocked by a pre-merge check")).toBeInTheDocument();
expect(screen.getByText("Workflow pre-merge check failed")).toBeInTheDocument();
expect(screen.getByText("Open the Review tab to see which step is blocking, then fix the failure or override the step.")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "View activity log" }));
expect(screen.getByRole("button", { name: "Logs" })).toHaveClass("detail-tab-active");
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("log-subview-btn-active");
const highlighted = document.querySelector(".detail-log-entry--stall-highlight .detail-log-action");
expect(highlighted?.textContent).toContain("In-review stall surfaced [merge-blocker]");
});
it("renders retry-exhausted badge label with counter", async () => {
const user = userEvent.setup();
render(
,
);
await user.click(screen.getByRole("button", { name: "Pull Request" }));
expect(screen.getByText("Retries exhausted 3/3")).toBeInTheDocument();
});
it("shows no-log copy when no matching stall entry exists", async () => {
const user = userEvent.setup();
render(
,
);
await user.click(screen.getByRole("button", { name: "Pull Request" }));
expect(screen.getByText("No log entry yet")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "View activity log" })).not.toBeInTheDocument();
});
it("FN-4570: hides merge-blocker diagnostic while task is actively merging", () => {
render(
,
);
expect(screen.queryByText("Merge blocked by a pre-merge check")).not.toBeInTheDocument();
});
it.each([
{
label: "paused in-review task",
task: makeTask({
column: "in-review",
paused: true,
inReviewStall: {
code: "merge-blocker",
reason: "Workflow pre-merge check failed",
observedAt: "2026-05-13T00:00:00.000Z",
},
}),
},
{
label: "non in-review task",
task: makeTask({
column: "in-progress",
inReviewStall: {
code: "merge-blocker",
reason: "Workflow pre-merge check failed",
observedAt: "2026-05-13T00:00:00.000Z",
},
}),
},
])("does not render diagnostic row for $label", ({ task }) => {
render(
,
);
expect(screen.queryByText("Merge blocked by a pre-merge check")).not.toBeInTheDocument();
});
});