/*
FNXC:TaskDetailTabs 2026-06-17-08:20:
FN-7306 labels the stable internal `chat` tab as Activity and keeps it as the default TaskDetailModal tab. Tests that assert Definition-only sections must opt into `initialTab="definition"` so they verify the intended surface instead of the Activity landing state.
FNXC:PlannerOversight 2026-07-05-00:00:
FN-7604 — the footer "Actions" dropdown button name is matched EXACTLY
(`{ name: "Actions" }`) throughout this file, not via a loose `/actions/i`
regex. The now-universal Oversight overflow trigger's aria-label is
"Oversight actions", which also matches `/actions/i` and made every such
query ambiguous once the trigger stopped being a mobile-only affordance.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
// FNXC:Markdown 2026-06-23-03:30: Mock the heavy `mermaid` library so the shared
// markdown pipeline's MermaidDiagram resolves without loading the real renderer.
vi.mock("mermaid", () => ({
default: {
initialize: vi.fn(),
render: vi.fn().mockResolvedValue({ svg: "" }),
},
}));
import userEvent from "@testing-library/user-event";
import {
makeTask,
noop,
noopDelete,
noopMerge,
noopMove,
noopOpenDetail,
noopRetry,
mockConfirm,
mockUsePluginUiSlots,
expectBaseRule,
readDashboardStylesSource,
setupTaskDetailModalHooks,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
import * as dashboardApi from "../../api";
import { FileBrowserProvider } from "../../context/FileBrowserContext";
setupTaskDetailModalHooks();
describe("TaskDetailModal", () => {
describe("workflow timestamp badge", () => {
const workflowPayload = {
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [
{ id: "builtin:coding", name: "Coding", columns: [], fields: [{ id: "risk", name: "Risk", type: "text" }] },
{ id: "wf-docs", name: "Docs", columns: [] },
],
taskWorkflowIds: { "FN-101": "wf-docs" },
};
beforeEach(() => {
vi.mocked(dashboardApi.fetchBoardWorkflows).mockReset();
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValue({
flagEnabled: false,
defaultWorkflowId: "",
workflows: [],
taskWorkflowIds: {},
});
});
function renderDetail(task = makeTask({ id: "FN-101", column: "todo", title: "Docs task" })) {
return render(
,
);
}
it("renders the resolved workflow name in the timestamp section instead of the title row", async () => {
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValueOnce(workflowPayload);
const { container } = renderDetail();
const badge = await screen.findByTestId("task-detail-workflow-badge");
expect(badge).toHaveTextContent("Docs");
expect(badge.closest(".detail-timestamps")).toBeTruthy();
expect(badge.closest(".detail-title-row")).toBeNull();
expect(container.querySelector(".detail-title-row .detail-workflow-badge")).toBeNull();
expect(screen.getAllByTestId("task-detail-workflow-badge")).toHaveLength(1);
expect(screen.getByText("FN-101")).toBeInTheDocument();
expect(screen.getByText("Todo")).toBeInTheDocument();
expect(dashboardApi.fetchBoardWorkflows).toHaveBeenCalledTimes(1);
});
it("uses the default workflow for tasks without explicit workflow assignment", async () => {
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValueOnce(workflowPayload);
renderDetail(makeTask({ id: "FN-default", column: "todo", title: "Default workflow task" }));
expect(await screen.findByTestId("task-detail-workflow-badge")).toHaveTextContent("Coding");
});
it("hides the badge when an explicit workflow assignment cannot be resolved", async () => {
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValueOnce({
...workflowPayload,
taskWorkflowIds: { "FN-stale": "wf-deleted" },
});
renderDetail(makeTask({ id: "FN-stale", column: "todo", title: "Stale workflow task" }));
await waitFor(() => expect(dashboardApi.fetchBoardWorkflows).toHaveBeenCalledTimes(1));
expect(screen.queryByTestId("task-detail-workflow-badge")).toBeNull();
});
it("updates the badge when a mounted task detail switches tasks", async () => {
vi.mocked(dashboardApi.fetchBoardWorkflows)
.mockResolvedValueOnce(workflowPayload)
.mockResolvedValueOnce(workflowPayload);
const props = {
initialTab: "definition" as const,
task: makeTask({ id: "FN-101", column: "todo", title: "Docs task" }),
onMoveTask: noopMove,
onDeleteTask: noopDelete,
onMergeTask: noopMerge,
onOpenDetail: noopOpenDetail,
addToast: noop,
};
const { rerender } = render();
expect(await screen.findByTestId("task-detail-workflow-badge")).toHaveTextContent("Docs");
rerender();
await waitFor(() => expect(screen.getByTestId("task-detail-workflow-badge")).toHaveTextContent("Coding"));
});
it("clears the previous workflow badge while a mounted task switch reloads metadata", async () => {
let resolveNextPayload: (payload: typeof workflowPayload) => void = () => undefined;
vi.mocked(dashboardApi.fetchBoardWorkflows)
.mockResolvedValueOnce(workflowPayload)
.mockImplementationOnce(() => new Promise((resolve) => {
resolveNextPayload = resolve;
}));
const props = {
initialTab: "definition" as const,
task: makeTask({ id: "FN-101", column: "todo", title: "Docs task" }),
onMoveTask: noopMove,
onDeleteTask: noopDelete,
onMergeTask: noopMerge,
onOpenDetail: noopOpenDetail,
addToast: noop,
};
const { rerender } = render();
expect(await screen.findByTestId("task-detail-workflow-badge")).toHaveTextContent("Docs");
rerender();
await waitFor(() => expect(dashboardApi.fetchBoardWorkflows).toHaveBeenCalledTimes(2));
expect(screen.queryByTestId("task-detail-workflow-badge")).toBeNull();
await act(async () => {
resolveNextPayload(workflowPayload);
});
expect(await screen.findByTestId("task-detail-workflow-badge")).toHaveTextContent("Coding");
});
it("hides the badge without workflow metadata and leaves no empty shell", async () => {
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValueOnce({
flagEnabled: false,
defaultWorkflowId: "",
workflows: [],
taskWorkflowIds: {},
});
const { container } = renderDetail();
await waitFor(() => expect(dashboardApi.fetchBoardWorkflows).toHaveBeenCalledTimes(1));
expect(screen.queryByTestId("task-detail-workflow-badge")).toBeNull();
expect(container.querySelector(".detail-workflow-badge")).toBeNull();
});
it("renders the canonical badge beside the Updated timestamp in the mobile back-header variant", async () => {
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValueOnce(workflowPayload);
const { container } = render(
,
);
const badge = await screen.findByTestId("task-detail-workflow-badge");
const timestamps = container.querySelector(".detail-timestamps");
const updatedLabel = screen.getByText("Updated").closest(".detail-timestamp-item");
expect(badge).toHaveTextContent("Docs");
expect(badge.parentElement).toBe(timestamps);
expect(updatedLabel?.nextElementSibling).toBe(badge);
expect(screen.getAllByTestId("task-detail-workflow-badge")).toHaveLength(1);
expect(screen.queryByTestId("task-detail-workflow-badge-mobile")).toBeNull();
expect(container.querySelector(".detail-title-row .detail-workflow-badge")).toBeNull();
expect(screen.getByRole("button", { name: "Back to task list" })).toBeInTheDocument();
});
});
it("renders clickable file links in markdown inline code while preserving code wrappers", async () => {
const openFile = vi.fn();
render(
,
);
const fileLinks = screen.getAllByRole("button", { name: "packages/dashboard/app/App.tsx:12" });
expect(fileLinks.length).toBeGreaterThan(0);
const code = fileLinks[0]?.closest("code");
expect(code).toBeTruthy();
expect(code?.querySelector("button.file-path-link")).toBe(fileLinks[0]);
await userEvent.click(fileLinks[0]!);
expect(openFile).toHaveBeenCalledWith("packages/dashboard/app/App.tsx", { line: 12, col: undefined });
});
/*
FNXC:Markdown 2026-06-23-03:30:
The task DESCRIPTION (spec/prompt) + SUMMARY now share the markdown pipeline's
rehype-raw -> rehype-sanitize chain, so embedded raw HTML renders as real
elements (not literal text), HTML comments drop, ",
"",
"```mermaid",
"graph TD; A-->B;",
"```",
].join("\n");
const { container } = render(
,
);
// Raw / renders as a real disclosure element.
const details = container.querySelector(".markdown-body details");
expect(details).not.toBeNull();
expect(details?.querySelector("summary")?.textContent).toBe("Disclosure title");
expect(details?.textContent).toContain("Hidden detail body.");
// HTML comment is dropped, never shown as literal text.
expect(container.textContent).not.toContain("secret comment");
//