/*
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 { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act, waitFor, cleanup } 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,
expectSingleStatsRuntimeStatus,
readDashboardStylesSource,
resetTaskDetailFetchMock,
setupTaskDetailModalHooks,
taskDetailSseSubscriptions,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
import * as dashboardApi from "../../api";
import { FileBrowserProvider } from "../../context/FileBrowserContext";
import type { Task } from "@fusion/core";
setupTaskDetailModalHooks();
describe("TaskDetailModal", () => {
/*
FNXC:TaskDetailStateStability 2026-08-05-02:55:
A real rendered detail host receives a newer queued-overlap detail and then the stale Todo row
produced by a scheduler resync. Rerender without remounting proves the visible lifecycle badge
never oscillates and the retained prompt/log survive the slim stale payload.
*/
it("keeps the rendered queued-overlap lifecycle through a stale scheduler rerender", async () => {
const queued = makeTask({
id: "FN-QUEUED",
column: "in-progress",
status: "queued",
overlapBlockedBy: "FN-OWNER",
prompt: "# Preserved prompt",
log: [{ timestamp: "2026-08-05T10:02:00.000Z", action: "Queued behind file overlap" }],
updatedAt: "2026-08-05T10:02:00.000Z",
columnMovedAt: "2026-08-05T10:02:00.000Z",
});
const staleTodo = makeTask({
id: queued.id,
column: "todo",
status: undefined,
prompt: undefined,
log: [],
updatedAt: "2026-08-05T10:00:00.000Z",
columnMovedAt: "2026-08-05T10:00:00.000Z",
});
const props = {
initialTab: "definition" as const,
onClose: noop,
onMoveTask: noopMove,
onDeleteTask: noopDelete,
onMergeTask: noopMerge,
onOpenDetail: noopOpenDetail,
addToast: noop,
};
const { rerender } = render();
expect(document.querySelector(".detail-column-badge")).toHaveClass("badge-in-progress");
rerender();
expect(document.querySelector(".detail-column-badge")).toHaveClass("badge-in-progress");
expect(screen.getByText("Preserved prompt")).toBeInTheDocument();
});
/*
FNXC:TaskDetailStateStability 2026-08-05-04:05:
Definition ticks must not publish a full task snapshot. Drive repeated planning ticks against
the production detail host and preserve the queued lifecycle and resolved workflow badge node.
*/
it("keeps queued lifecycle and workflow badge continuous across prompt-only ticks", async () => {
vi.useFakeTimers();
try {
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true, defaultWorkflowId: "builtin:coding",
workflows: [{ id: "builtin:coding", name: "Coding", columns: [], fields: [] }], taskWorkflowIds: {},
});
const promptFetch = vi.mocked(dashboardApi.fetchTaskPrompt);
promptFetch.mockResolvedValue({ id: "FN-POLL", prompt: "# Updated definition" });
const fullFetch = vi.mocked(dashboardApi.fetchTaskDetail);
const queued = makeTask({ id: "FN-POLL", column: "in-progress", status: "queued", prompt: "# Initial definition", workflowStepResults: [{ workflowStepId: "plan-review", status: "running", startedAt: "2026-08-05T00:00:00.000Z" }] });
render();
await act(async () => {});
const badge = screen.getByTestId("task-detail-workflow-badge");
const initialPromptRequests = promptFetch.mock.calls.length;
expect(document.querySelector(".detail-column-badge")).toHaveClass("badge-in-progress");
for (let tick = 1; tick <= 3; tick++) {
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
expect(fullFetch).not.toHaveBeenCalled();
expect(promptFetch).toHaveBeenCalledTimes(initialPromptRequests + tick);
expect(document.querySelector(".detail-column-badge")).toHaveClass("badge-in-progress");
expect(screen.getByTestId("task-detail-workflow-badge")).toBe(badge);
}
expect(screen.getByText("Updated definition")).toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
/*
FNXC:TaskDetailStateStability 2026-08-05-05:01:
Done cards use the same Definition timer as queued work. Keep both the resolved workflow badge and
applicable Actions control mounted through every narrow response so the fix cannot merely hide the
queued Todo rollback while completed-task controls still flash.
*/
it("keeps done workflow badge and action controls continuous across prompt-only ticks", async () => {
vi.useFakeTimers();
try {
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true, defaultWorkflowId: "builtin:coding",
workflows: [{ id: "builtin:coding", name: "Coding", columns: [], fields: [] }], taskWorkflowIds: {},
});
vi.mocked(dashboardApi.fetchTaskPrompt).mockResolvedValue({ id: "FN-DONE-POLL", prompt: "# Refreshed definition" });
const done = makeTask({ id: "FN-DONE-POLL", column: "done", status: "done", prompt: "# Original definition", workflowStepResults: [{ workflowStepId: "plan-review", status: "running", startedAt: "2026-08-05T00:00:00.000Z" }] });
render();
await act(async () => {});
const badge = screen.getByTestId("task-detail-workflow-badge");
const actions = screen.getByRole("button", { name: "Actions" });
const initialPromptRequests = vi.mocked(dashboardApi.fetchTaskPrompt).mock.calls.length;
for (let tick = 1; tick <= 3; tick++) {
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
expect(dashboardApi.fetchTaskDetail).not.toHaveBeenCalled();
expect(dashboardApi.fetchTaskPrompt).toHaveBeenCalledTimes(initialPromptRequests + tick);
expect(screen.getByTestId("task-detail-workflow-badge")).toBe(badge);
expect(screen.getByRole("button", { name: "Actions" })).toBe(actions);
}
} finally {
vi.useRealTimers();
}
});
it("revalidates column actions without clearing same-task workflow metadata", async () => {
const payload = {
flagEnabled: true, defaultWorkflowId: "wf-columns", taskWorkflowIds: {},
workflows: [{ id: "wf-columns", name: "Column workflow", columns: [
{ id: "in-progress", name: "Building", flags: { countsTowardWip: true } },
{ id: "done", name: "Shipped", flags: { complete: true } },
], fields: [] }],
};
let settleColumnMove: (value: typeof payload) => void = () => undefined;
vi.mocked(dashboardApi.fetchBoardWorkflows)
.mockResolvedValueOnce(payload)
.mockImplementationOnce(() => new Promise((resolve) => { settleColumnMove = resolve; }));
const task = makeTask({ id: "FN-COLUMN-MOVE", column: "in-progress", status: "queued" });
const props = { embedded: true, active: true, initialTab: "definition" as const, onMoveTask: noopMove, onDeleteTask: noopDelete, onMergeTask: noopMerge, onOpenDetail: noopOpenDetail, addToast: noop };
const { rerender } = render();
const badge = await screen.findByTestId("task-detail-workflow-badge");
const actions = screen.getByRole("button", { name: "Actions" });
const initialMoveLabel = document.querySelector(".detail-move-btn")?.getAttribute("aria-label");
rerender();
expect(screen.getByTestId("task-detail-workflow-badge")).toBe(badge);
expect(screen.getByRole("button", { name: "Actions" })).toBe(actions);
await act(async () => { settleColumnMove(payload); });
await waitFor(() => expect(document.querySelector(".detail-move-btn")?.getAttribute("aria-label")).not.toBe(initialMoveLabel));
});
it("keeps a prompt-only response when slim initial detail resolves later", async () => {
let resolveDetail: (detail: TaskDetail) => void = () => undefined;
vi.mocked(dashboardApi.fetchTaskDetail).mockImplementationOnce(() => new Promise((resolve) => {
resolveDetail = resolve;
}));
vi.mocked(dashboardApi.fetchTaskPrompt).mockResolvedValueOnce({ id: "FN-slim-prompt", prompt: "# Newer narrow prompt" });
const slimTask = makeTask({ id: "FN-slim-prompt", prompt: undefined }) as Task;
render();
await waitFor(() => expect(dashboardApi.fetchTaskPrompt).toHaveBeenCalledWith("FN-slim-prompt", undefined));
await act(async () => {
resolveDetail(makeTask({ id: "FN-slim-prompt", prompt: "# Older full prompt" }));
});
expect(await screen.findByText("Newer narrow prompt")).toBeInTheDocument();
expect(screen.queryByText("Older full prompt")).toBeNull();
});
it("revalidates selected workflow metadata after its workflow SSE revision", async () => {
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValueOnce({
flagEnabled: true, defaultWorkflowId: "builtin:coding",
workflows: [{ id: "builtin:coding", name: "Coding", columns: [], fields: [] }],
taskWorkflowIds: { "FN-workflow-revision": "builtin:coding" },
});
let resolveRevalidation: (payload: Awaited>) => void = () => undefined;
vi.mocked(dashboardApi.fetchBoardWorkflows).mockImplementationOnce(() => new Promise((resolve) => {
resolveRevalidation = resolve;
}));
render();
expect(await screen.findByText("Coding")).toBeInTheDocument();
const badge = screen.getByTestId("task-detail-workflow-badge");
const workflowSubscription = taskDetailSseSubscriptions.find((subscription) => subscription.options.events?.["workflow:updated"]);
expect(workflowSubscription).toBeDefined();
await act(async () => {
workflowSubscription?.options.events?.["workflow:updated"](new MessageEvent("workflow:updated"));
});
expect(screen.getByTestId("task-detail-workflow-badge")).toBe(badge);
expect(screen.getByText("Coding")).toBeInTheDocument();
await act(async () => {
resolveRevalidation({
flagEnabled: true, defaultWorkflowId: "wf-docs",
workflows: [{ id: "wf-docs", name: "Docs", columns: [], fields: [] }],
taskWorkflowIds: { "FN-workflow-revision": "wf-docs" },
});
});
expect(await screen.findByText("Docs")).toBeInTheDocument();
});
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(document.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(document.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 = document.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(document.querySelector(".detail-title-row .detail-workflow-badge")).toBeNull();
expect(screen.getByRole("button", { name: "Back to task list" })).toBeInTheDocument();
});
});
it("keeps refreshed prompt and summary file links interactive in code wrappers", async () => {
const openFile = vi.fn();
const initialDetail = makeTask({
column: "done",
summary: "See `packages/dashboard/app/App.tsx:25:3` for context.",
prompt: "# Prompt\n\nInspect `packages/dashboard/app/App.tsx:11`.",
});
vi.mocked(dashboardApi.fetchTaskPrompt).mockResolvedValue({
id: initialDetail.id,
prompt: "# Prompt\n\nInspect `packages/dashboard/app/App.tsx:12`.",
});
render(
,
);
/*
FNXC:DashboardTests 2026-08-05-04:05:
Definition refresh updates only its prompt tree. Query after the narrow response settles so
this integration test clicks the live prompt link, then assert the separately rendered
completed-summary surface retains the same FileBrowser contract.
*/
await waitFor(() => expect(dashboardApi.fetchTaskPrompt).toHaveBeenCalledWith("FN-099", undefined));
const promptLink = await screen.findByRole("button", { name: "packages/dashboard/app/App.tsx:12" });
expect(screen.queryByRole("button", { name: "packages/dashboard/app/App.tsx:11" })).toBeNull();
expect(promptLink.closest("code")?.querySelector("button.file-path-link")).toBe(promptLink);
await userEvent.click(promptLink);
await userEvent.click(screen.getByRole("button", { name: "Summary" }));
const summaryLink = screen.getByRole("button", { name: "packages/dashboard/app/App.tsx:25:3" });
expect(summaryLink.closest("code")?.querySelector("button.file-path-link")).toBe(summaryLink);
await userEvent.click(summaryLink);
expect(openFile).toHaveBeenNthCalledWith(1, "packages/dashboard/app/App.tsx", { line: 12, col: undefined });
expect(openFile).toHaveBeenNthCalledWith(2, "packages/dashboard/app/App.tsx", { line: 25, col: 3 });
});
/*
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 = document.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");
//