Files
fusion/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx
gsxdsm 447e1963ef test(dashboard): trim permutation residue and fake the settings auto-save debounce
Slow-test trim, DELETE + dashboard OPTIMIZE tiers. Deletions follow the
docs/testing.md taxonomy exactly — 40 tests removed across
TaskDetailModal.rendering (134→129), QuickEntryBox (300→280),
TerminalModal (247→240), ModelOnboardingModal (205→197), and
AgentDetailView.settings (27→25): byte-identical field-presence twins,
CSS-class permutation residue, and strict-subset interaction copies,
with every FN-tagged describe and user-noticeable behavior kept and
condensed matrices converted to it.each with all cases retained. The
real payoff is the FN-2707 latency pass: SettingsModal's real 500ms
auto-save debounce (and AgentDetailView's 700ms config debounce) now
flush under fake timers via a shared flushSettingsAutoSave harness
export — SettingsModal splits 102.3s→63.6s. Also fixed (not appeased) a
pre-existing race the speedup exposed: nav-button lookups now
findByRole after the settings promise resolves, file-wide. Verified:
1235 tests green across the touched set, tsconfig.app.json typecheck
clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 21:31:29 -07:00

3456 lines
136 KiB
TypeScript

/*
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: "<svg data-testid='mock-mermaid-svg'></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(<TaskDetailModal {...props} task={queued} />);
expect(document.querySelector(".detail-column-badge")).toHaveClass("badge-in-progress");
rerender(<TaskDetailModal {...props} task={staleTodo} />);
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(<TaskDetailContent embedded active initialTab="definition" task={queued} onMoveTask={noopMove} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
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(<TaskDetailContent embedded active initialTab="definition" task={done} onMoveTask={noopMove} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
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<typeof payload>((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(<TaskDetailContent {...props} task={task} />);
const badge = await screen.findByTestId("task-detail-workflow-badge");
const actions = screen.getByRole("button", { name: "Actions" });
const initialMoveLabel = document.querySelector<HTMLButtonElement>(".detail-move-btn")?.getAttribute("aria-label");
rerender(<TaskDetailContent {...props} task={{ ...task, column: "done", status: "done" }} />);
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<HTMLButtonElement>(".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<TaskDetail>((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(<TaskDetailContent embedded active initialTab="definition" task={slimTask} onMoveTask={noopMove} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
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<ReturnType<typeof dashboardApi.fetchBoardWorkflows>>) => void = () => undefined;
vi.mocked(dashboardApi.fetchBoardWorkflows).mockImplementationOnce(() => new Promise((resolve) => {
resolveRevalidation = resolve;
}));
render(<TaskDetailContent embedded active task={makeTask({ id: "FN-workflow-revision", column: "todo" })} onMoveTask={noopMove} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
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(
<TaskDetailModal
initialTab="definition"
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
}
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(<TaskDetailContent {...props} embedded onRequestClose={noop} />);
expect(await screen.findByTestId("task-detail-workflow-badge")).toHaveTextContent("Docs");
rerender(<TaskDetailContent {...props} task={makeTask({ id: "FN-default", column: "in-review", title: "Coding task" })} embedded onRequestClose={noop} />);
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(<TaskDetailContent {...props} embedded onRequestClose={noop} />);
expect(await screen.findByTestId("task-detail-workflow-badge")).toHaveTextContent("Docs");
rerender(<TaskDetailContent {...props} task={makeTask({ id: "FN-default", column: "in-review", title: "Coding task" })} embedded onRequestClose={noop} />);
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(
<TaskDetailModal
initialTab="definition"
mobileHeaderMode="back"
task={makeTask({ id: "FN-101", column: "todo", title: "Docs task" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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(
<FileBrowserProvider openFile={openFile}>
<TaskDetailModal
initialTab="definition"
task={initialDetail}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>
</FileBrowserProvider>,
);
/*
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, <script> is stripped, and
```mermaid fences render diagrams — while keeping `.markdown-body` styling.
*/
it("renders raw HTML and mermaid in the description while stripping unsafe content", async () => {
const prompt = [
"# Prompt",
"",
"<details><summary>Disclosure title</summary>Hidden detail body.</details>",
"",
"<!-- secret comment -->",
"",
"<script>window.__pwned = true;</script>",
"",
"```mermaid",
"graph TD; A-->B;",
"```",
].join("\n");
const { container } = render(
<FileBrowserProvider openFile={vi.fn()}>
<TaskDetailModal
initialTab="definition"
task={makeTask({ prompt })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>
</FileBrowserProvider>,
);
// Raw <details>/<summary> 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");
// <script> is stripped by sanitize: not rendered and never executed.
expect(document.querySelector("script")).toBeNull();
expect((window as unknown as { __pwned?: boolean }).__pwned).toBeUndefined();
// ```mermaid fence renders the diagram container (lazy MermaidDiagram).
const diagram = await screen.findByTestId("task-detail-mermaid-diagram");
expect(diagram).not.toBeNull();
});
describe("provenance display", () => {
it.each([
["dashboard_ui", undefined, "Created via Dashboard"],
["agent_heartbeat", "agent-123", "Created by"],
] as const)("renders provenance text for %s", (sourceType, sourceAgentId, expectedText) => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ sourceType, sourceAgentId })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText(new RegExp(expectedText))).toBeInTheDocument();
if (sourceType === "agent_heartbeat" && sourceAgentId) {
expect(screen.getByRole("button", { name: sourceAgentId })).toBeInTheDocument();
}
});
// FNXC:TaskDetailModal 2026-08-15-00:00 (slow-test trim): refinement and API-created
// parent-link cases shared one body; converted to it.each with both cases kept.
it.each([
["refinement provenance", "task_refine", "FN-001", /Created via Refinement/],
["API-created planning tasks", "api", "FN-PLANNER", /Created via API/],
] as const)("renders parent task link for %s", async (_label, sourceType, parentId, expectedText) => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ sourceType, sourceParentTaskId: parentId })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText(expectedText)).toBeInTheDocument();
const link = screen.getByRole("button", { name: parentId });
expect(link).toBeInTheDocument();
await userEvent.click(link);
await waitFor(() => {
expect(noopOpenDetail).toHaveBeenCalled();
});
});
it.each([
["desktop", undefined],
["compact/mobile", "back"],
] as const)("links only the GitHub Import label to the source issue on %s markup", (_layout, mobileHeaderMode) => {
const { container } = render(
<TaskDetailModal
initialTab="definition"
mobileHeaderMode={mobileHeaderMode}
task={makeTask({
sourceType: "github_import",
sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/42" },
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const issueLink = screen.getByRole("link", { name: "GitHub Import" });
const provenance = issueLink.closest(".detail-provenance");
expect(provenance).toHaveTextContent("Created via GitHub Import");
expect(provenance?.textContent).not.toMatch(/[()]/);
expect(provenance?.querySelectorAll("a")).toHaveLength(1);
expect(document.querySelector(".detail-provenance-context")).toBeNull();
expect(issueLink).toHaveAttribute("href", "https://github.com/owner/repo/issues/42");
expect(issueLink).toHaveAttribute("target", "_blank");
expect(issueLink).toHaveAttribute("rel", "noopener noreferrer");
expect(issueLink).not.toHaveAttribute("title");
});
it("keeps GitHub Import as the sole link for a populated nonstandard source URL", () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({
sourceType: "github_import",
sourceMetadata: { issueUrl: "https://example.com/something" },
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const issueLink = screen.getByRole("link", { name: "GitHub Import" });
const provenance = issueLink.closest(".detail-provenance");
expect(issueLink).toHaveAttribute("href", "https://example.com/something");
expect(provenance?.querySelectorAll("a")).toHaveLength(1);
expect(provenance?.textContent).toBe("Created via GitHub Import");
expect(screen.queryByText("Open issue")).toBeNull();
});
it("renders a URL-absent GitHub import as plain text without a link shell", () => {
const { container, rerender } = render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ sourceType: "github_import", sourceMetadata: undefined })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const assertPlainFallback = () => {
const provenance = screen.getByText("Created via GitHub Import").closest(".detail-provenance");
expect(provenance?.textContent).toBe("Created via GitHub Import");
expect(provenance?.querySelector("a")).toBeNull();
expect(document.querySelector(".detail-provenance-context")).toBeNull();
};
assertPlainFallback();
rerender(
<TaskDetailModal
initialTab="definition"
task={makeTask({ sourceType: "github_import", sourceMetadata: {} })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
assertPlainFallback();
});
it("renders finding label for research provenance", () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({
sourceType: "research",
sourceMetadata: {
runId: "RR-123",
findingLabel: "Pricing pressure in EU segment",
},
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText(/Created via Research/).closest(".detail-provenance")).toHaveTextContent(
"Created via Research (Pricing pressure in EU segment)",
);
});
it("falls back to run id for research provenance context", () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({
sourceType: "research",
sourceMetadata: { runId: "RR-456" },
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText(/Created via Research/).closest(".detail-provenance")).toHaveTextContent(
"Created via Research (RR-456)",
);
});
it.each(["unknown", undefined] as const)("omits provenance for %s source", (sourceType) => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ sourceType })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText(/Created via/)).not.toBeInTheDocument();
});
/**
* FNXC:TaskRevert 2026-07-04-00:00:
* FN-7555 bidirectional undo→source affordance coverage. Forward: an AI-undo
* task (`sourceMetadata.revertOf` set by `createAiUndoTask`) shows a clickable
* "Created to undo <id>" link. Reverse: a source task shows an "Undo task: <id>"
* link only when an OPEN undo task referencing it exists in the loaded `tasks`
* list — mirroring `TaskStore.findOpenRevertTaskForSource`'s open-only semantics
* (done/archived/soft-deleted undo tasks must not surface as an active link).
*/
describe("undo/revert provenance", () => {
it("renders a clickable 'Created to undo <id>' link for an AI-undo task", async () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ id: "FN-200", sourceType: "recovery", sourceMetadata: { revertOf: "FN-100" } })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText(/Created to undo/)).toBeInTheDocument();
const link = screen.getByRole("button", { name: "FN-100" });
expect(link).toBeInTheDocument();
await userEvent.click(link);
await waitFor(() => {
expect(noopOpenDetail).toHaveBeenCalled();
});
});
it("renders nothing for the forward link when sourceMetadata.revertOf is absent", () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ id: "FN-200", sourceType: "recovery", sourceMetadata: {} })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText(/Created to undo/)).not.toBeInTheDocument();
});
it("does not throw and renders nothing for malformed revertOf metadata", () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ id: "FN-200", sourceMetadata: { revertOf: 999 as any } })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText(/Created to undo/)).not.toBeInTheDocument();
});
it("renders an 'Undo task: <id>' link when an OPEN undo task references this source task", async () => {
const sourceTask = makeTask({ id: "FN-100", column: "done" });
const undoTask = makeTask({ id: "FN-201", column: "todo", sourceType: "recovery", sourceMetadata: { revertOf: "FN-100" }, createdAt: "2026-07-04T00:00:00.000Z" });
render(
<TaskDetailModal
initialTab="definition"
task={sourceTask}
tasks={[sourceTask, undoTask]}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const link = screen.getByRole("button", { name: "FN-201" });
expect(link).toBeInTheDocument();
await userEvent.click(link);
await waitFor(() => {
expect(noopOpenDetail).toHaveBeenCalled();
});
});
it("renders no reverse link when the only undo task for this source is done/archived (open-only invariant)", () => {
const sourceTask = makeTask({ id: "FN-100", column: "done" });
const doneUndoTask = makeTask({ id: "FN-202", column: "done", sourceType: "recovery", sourceMetadata: { revertOf: "FN-100" } });
const archivedUndoTask = makeTask({ id: "FN-203", column: "archived", sourceType: "recovery", sourceMetadata: { revertOf: "FN-100" } });
render(
<TaskDetailModal
initialTab="definition"
task={sourceTask}
tasks={[sourceTask, doneUndoTask, archivedUndoTask]}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByRole("button", { name: "FN-202" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "FN-203" })).not.toBeInTheDocument();
});
it("renders no reverse link and no empty shell when no undo task exists for this source", () => {
const sourceTask = makeTask({ id: "FN-100", column: "done" });
const { container } = render(
<TaskDetailModal
initialTab="definition"
task={sourceTask}
tasks={[sourceTask]}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(document.querySelector(".detail-undo-task-row")).toBeNull();
});
it("picks the most recently created open undo task when multiple exist", async () => {
const sourceTask = makeTask({ id: "FN-100", column: "done" });
const olderUndo = makeTask({ id: "FN-204", column: "todo", sourceMetadata: { revertOf: "FN-100" }, createdAt: "2026-07-01T00:00:00.000Z" });
const newerUndo = makeTask({ id: "FN-205", column: "todo", sourceMetadata: { revertOf: "FN-100" }, createdAt: "2026-07-03T00:00:00.000Z" });
render(
<TaskDetailModal
initialTab="definition"
task={sourceTask}
tasks={[sourceTask, olderUndo, newerUndo]}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByRole("button", { name: "FN-205" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "FN-204" })).not.toBeInTheDocument();
});
});
it("FN-3755 renders provenance before created-updated timestamps", () => {
const { container } = render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ sourceType: "dashboard_ui" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const provenance = screen.getByText("Created via Dashboard").closest(".detail-provenance");
const timestamps = document.querySelector(".detail-timestamps");
expect(provenance).toBeTruthy();
expect(timestamps).toBeTruthy();
expect(provenance?.compareDocumentPosition(timestamps as Node) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("keeps inline controls, provenance, and timestamps as direct detail-meta children", () => {
const { container } = render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ sourceType: "task_refine", sourceParentTaskId: "FN-001" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const meta = document.querySelector(".detail-meta");
const controls = document.querySelector(".detail-meta-inline-controls");
const provenance = screen.getByText(/Created via Refinement/).closest(".detail-provenance");
const timestamps = document.querySelector(".detail-timestamps");
expect(meta).toBeTruthy();
expect(controls?.parentElement).toBe(meta);
expect(provenance?.parentElement).toBe(meta);
expect(timestamps?.parentElement).toBe(meta);
});
it("keeps the optional PR link row in the same detail-meta row as provenance and timestamps", () => {
const { container } = render(
<TaskDetailModal
initialTab="definition"
task={makeTask({
sourceType: "dashboard_ui",
prInfo: { number: 42, url: "https://github.com/owner/repo/pull/42" },
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const meta = document.querySelector(".detail-meta");
const controls = document.querySelector(".detail-meta-inline-controls");
const provenance = screen.getByText("Created via Dashboard").closest(".detail-provenance");
const prRow = document.querySelector(".detail-pr-link-row");
const timestamps = document.querySelector(".detail-timestamps");
expect(meta).toBeTruthy();
expect(controls?.parentElement).toBe(meta);
expect(provenance?.parentElement).toBe(meta);
expect(prRow?.parentElement).toBe(meta);
expect(timestamps?.parentElement).toBe(meta);
});
describe("compact timestamp metadata", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-11T12:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("renders compact relative timestamps for recent tasks", () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({
sourceType: "dashboard_ui",
createdAt: "2026-05-09T12:00:00.000Z",
updatedAt: "2026-05-11T09:00:00.000Z",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const timestamps = screen.getByLabelText("Task timestamps");
expect(timestamps).toHaveTextContent("Created 2d ago");
expect(timestamps).toHaveTextContent("Updated 3h ago");
expect(getComputedStyle(timestamps).flexWrap).toBe("nowrap");
expect(timestamps.querySelector(".detail-timestamp-separator")).toBeTruthy();
const times = timestamps.querySelectorAll("time");
expect(times[0]?.getAttribute("dateTime")).toBe("2026-05-09T12:00:00.000Z");
expect(times[1]?.getAttribute("dateTime")).toBe("2026-05-11T09:00:00.000Z");
});
// FNXC:TaskDetailModal 2026-08-15-00:00 (slow-test trim): the standalone
// "short calendar date for older timestamps" case was one more bucket permutation of the
// same formatter; folded into the bucket/edge-case test below as an extra rerender.
it("preserves byte-identical timestamp buckets and edge cases", () => {
const { rerender } = render(
<TaskDetailModal
initialTab="definition"
task={makeTask({
sourceType: "dashboard_ui",
createdAt: "2026-05-11T11:59:30.000Z",
updatedAt: "2026-05-11T11:55:00.000Z",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
let timestamps = screen.getByLabelText("Task timestamps");
expect(timestamps).toHaveTextContent("Created just now");
expect(timestamps).toHaveTextContent("Updated 5m ago");
rerender(
<TaskDetailModal
initialTab="definition"
task={makeTask({
sourceType: "dashboard_ui",
createdAt: "not-a-date",
updatedAt: "2026-05-11T12:00:01.000Z",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
timestamps = screen.getByLabelText("Task timestamps");
expect(timestamps).toHaveTextContent("Created Invalid Date");
expect(timestamps).toHaveTextContent("Updated just now");
rerender(
<TaskDetailModal
initialTab="definition"
task={makeTask({
sourceType: "dashboard_ui",
createdAt: "2026-05-01T12:00:00.000Z",
updatedAt: "2026-05-02T12:00:00.000Z",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
timestamps = screen.getByLabelText("Task timestamps");
expect(timestamps).toHaveTextContent("Created May 1");
expect(timestamps).toHaveTextContent("Updated May 2");
});
});
});
it("shows active file scope overlap blocker in Dependencies section", () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ id: "FN-T", column: "todo", overlapBlockedBy: "FN-OVER" })}
tasks={[
makeTask({ id: "FN-T", column: "todo", overlapBlockedBy: "FN-OVER" }),
makeTask({ id: "FN-OVER", column: "in-progress" }),
]}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("File scope overlap blocker: FN-OVER")).toBeInTheDocument();
expect(screen.queryByText("File scope overlap blocker: FN-OVER (stale)")).toBeNull();
});
it("keeps clear overlap blocker button when slim live task omits overlapBlockedBy", () => {
const { rerender } = render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ id: "FN-T", column: "todo", overlapBlockedBy: "FN-OVER" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
rerender(
<TaskDetailModal
initialTab="definition"
task={makeTask({ id: "FN-T", column: "todo", overlapBlockedBy: undefined })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
});
it("repairs overlap blocker when clicking Clear", async () => {
vi.mocked(dashboardApi.repairOverlapBlocker).mockResolvedValueOnce({
taskId: "FN-T",
dryRun: false,
repaired: true,
statusCleared: true,
previousOverlapBlockedBy: "FN-OVER",
reason: "repaired",
message: "Cleared stale overlap blocker FN-OVER",
task: makeTask({ id: "FN-T", column: "todo", overlapBlockedBy: undefined, status: undefined }),
});
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ id: "FN-T", column: "todo", overlapBlockedBy: "FN-OVER", status: "queued" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Clear" }));
await waitFor(() => {
expect(dashboardApi.repairOverlapBlocker).toHaveBeenCalledWith(
"FN-T",
{ reason: "dashboard-clear-overlap-blocker" },
undefined,
);
});
await waitFor(() => {
expect(screen.queryByRole("button", { name: "Clear" })).toBeNull();
});
});
it("applies rerouted overlap blocker returned by repair API", async () => {
vi.mocked(dashboardApi.repairOverlapBlocker).mockResolvedValueOnce({
taskId: "FN-T",
dryRun: false,
repaired: true,
statusCleared: false,
previousOverlapBlockedBy: "FN-OLD",
currentOverlapBlockedBy: "FN-NEW",
reason: "rerouted-to-current-overlap",
message: "Stale overlap blocker FN-OLD rerouted to FN-NEW",
task: makeTask({ id: "FN-T", column: "todo", overlapBlockedBy: "FN-NEW", status: "queued" }),
});
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ id: "FN-T", column: "todo", overlapBlockedBy: "FN-OLD", status: "queued" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Clear" }));
await waitFor(() => {
expect(screen.getByText("File scope overlap blocker: FN-NEW (stale)")).toBeInTheDocument();
});
});
it("shows toast and preserves overlap blocker when repair fails", async () => {
const addToast = vi.fn();
vi.mocked(dashboardApi.repairOverlapBlocker).mockRejectedValueOnce(new Error("boom"));
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ id: "FN-T", column: "todo", overlapBlockedBy: "FN-OVER", status: "queued" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Clear" }));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("boom", "error");
});
expect(screen.getByText("File scope overlap blocker: FN-OVER (stale)")).toBeInTheDocument();
});
it("counts the overlap blockedBy summary using the board's OWN lane names", () => {
/*
FNXC:WorkflowResolvedColumns 2026-07-30-23:35:
The case below renamed and nothing else. Without resolved traits the count is taken against the
literal `todo`, which no card is in, so this line read "blocking 0 todo task(s)" while two cards
were in fact blocked. The dependent LIST stayed correct throughout (core builds it without
consulting lanes), which is what made the wrong number easy to miss.
*/
const tasks = [
makeTask({ id: "FN-B", column: "building" }),
makeTask({ id: "FN-1", column: "drafting", blockedBy: "FN-B" }),
makeTask({ id: "FN-2", column: "drafting", blockedBy: "FN-B" }),
];
const columnFlagsByTaskId = new Map(tasks.map((task) => [
task.id,
task.column === "building" ? { countsTowardWip: true } : { hold: true },
]));
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ id: "FN-B", column: "building" })}
tasks={tasks}
columnFlagsByTaskId={columnFlagsByTaskId}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("FN-B is blocking 2 todo task(s) via blockedBy overlap")).toBeInTheDocument();
});
it("shows overlap blockedBy summary in Blocking section", () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ id: "FN-B", column: "in-progress" })}
tasks={[
makeTask({ id: "FN-B", column: "in-progress" }),
makeTask({ id: "FN-1", column: "todo", blockedBy: "FN-B" }),
makeTask({ id: "FN-2", column: "todo", blockedBy: "FN-B" }),
]}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("FN-B is blocking 2 todo task(s) via blockedBy overlap")).toBeInTheDocument();
});
it("renders modal wrapper structure and default close control", () => {
const { container } = render(
<TaskDetailModal
initialTab="definition"
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
/*
FNXC:TaskDetailModal 2026-07-30-22:50 (#2895 review — greptile, "obsolete modal overlay selector"):
THE SHELL IS `FloatingWindow`, NOT A `.modal-overlay`.
`TaskDetailModal` renders `<FloatingWindow modal>`, whose overlay class is
`floating-window-overlay--modal`. The only `.modal-overlay` left in this component is the refine
SUB-overlay at ~line 6801, which this case does not open — so the assertion was querying a class
that is never in the tree for the default render and failed outright.
Asserting the modal-ness (`--modal`), not just the overlay: a non-modal FloatingWindow renders the
same base class, so the bare selector would keep passing if the `modal` prop were dropped.
*/
expect(document.querySelector(".floating-window-overlay--modal")).toBeTruthy();
expect(document.querySelector(".modal.modal-lg.task-detail-modal")).toBeTruthy();
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Back to task list" })).toBeNull();
});
it("renders mobile back control variant when requested", () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
mobileHeaderMode="back"
/>,
);
expect(screen.getByRole("button", { name: "Back to task list" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Close" })).toBeNull();
});
it("omits close control in embedded mode while rendering shared content", () => {
const { container } = render(
<TaskDetailContent
task={makeTask()}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
embedded
/>,
);
expect(document.querySelector(".task-detail-content--embedded")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Close" })).toBeNull();
expect(screen.getByRole("button", { name: "Plan" })).toBeInTheDocument();
});
it("renders header close control for embedded floating task details", () => {
const onRequestClose = vi.fn();
render(
<TaskDetailContent
task={makeTask()}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
embedded
onRequestClose={onRequestClose}
/>,
);
const closeButton = screen.getByRole("button", { name: "Close" });
expect(closeButton).toHaveClass("task-detail-floating-close");
fireEvent.click(closeButton);
expect(onRequestClose).toHaveBeenCalledTimes(1);
});
function expectNoBranchReattachmentAffordance(container: HTMLElement): void {
// FN-6983: Task Detail must not ask users to manually reattach branches; self-healing owns recovery.
expect(screen.queryByText(/Branch needs reattachment/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Branch binding lost/i)).not.toBeInTheDocument();
expect(screen.queryByText(/isn't currently attached to a fusion branch/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Reattached branch/i)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Reattach branch/i })).not.toBeInTheDocument();
expect(document.querySelector(".rebind-banner")).toBeNull();
expect(document.querySelector(".rebind-banner-actions")).toBeNull();
expect(document.querySelector(".rebind-banner-result")).toBeNull();
}
function renderTaskDetail(task: ReturnType<typeof makeTask>, mobileHeaderMode?: "back") {
return render(
<TaskDetailModal
initialTab="definition"
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
mobileHeaderMode={mobileHeaderMode}
/>,
);
}
describe("branch reattachment affordance absence", () => {
it.each([
["null branch and worktree", makeTask({ column: "in-review", branch: null, worktree: null })],
["undefined branch with missing worktree", makeTask({ column: "in-review", branch: undefined, worktree: null })],
["populated branch control", makeTask({ column: "in-review", branch: "fusion/fn-099", worktree: "/tmp/fn-099" })],
["non-in-review missing branch", makeTask({ column: "todo", branch: null, worktree: null })],
] as const)("renders no reattachment banner for %s", (_label, task) => {
const { container } = renderTaskDetail(task);
expectNoBranchReattachmentAffordance(container);
});
it("renders no reattachment banner for workspace in-review tasks with no singular branch", () => {
const task = makeTask({
column: "in-review",
worktree: null,
workspaceWorktrees: {
"repo-a": { worktreePath: "/tmp/fn-099/repo-a", branch: "fusion/fn-099", baseCommitSha: "abc123" },
"repo-b": { worktreePath: "/tmp/fn-099/repo-b", branch: "fusion/fn-099" },
},
});
delete (task as { branch?: string | null }).branch;
const { container } = renderTaskDetail(task);
expectNoBranchReattachmentAffordance(container);
});
it("keeps the removed mobile rebind action shell absent in narrow task detail rendering", () => {
const { container } = renderTaskDetail(makeTask({ column: "in-review", branch: null, worktree: null }), "back");
expect(screen.getByRole("button", { name: "Back to task list" })).toBeInTheDocument();
expectNoBranchReattachmentAffordance(container);
});
it("renders no reattachment banner from embedded TaskDetailContent", () => {
const { container } = render(
<TaskDetailContent
task={makeTask({ column: "in-review", branch: null, worktree: null })}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
embedded
/>,
);
expectNoBranchReattachmentAffordance(container);
});
});
// FNXC:TaskDetailModal 2026-08-15-00:00 (slow-test trim): the detail-body and
// agent-log-viewer scrollbar rules were two copies of the same structural CSS-string
// pattern; merged into one case with identical assertions.
it("styles detail-body and agent log viewer scrollbar rules", () => {
const css = readDashboardStylesSource();
expectBaseRule(css, ".detail-body", "scrollbar-color: var(--border) transparent;");
expectBaseRule(css, ".detail-body", "scrollbar-width: thin;");
expectBaseRule(css, ".detail-body::-webkit-scrollbar", "width: 6px;");
expectBaseRule(css, ".detail-body::-webkit-scrollbar-track", "background: transparent;");
expectBaseRule(css, ".detail-body::-webkit-scrollbar-thumb", "background: var(--border);");
expectBaseRule(css, ".detail-body::-webkit-scrollbar-thumb:hover", "background: var(--text-muted);");
expectBaseRule(css, ".agent-log-viewer", "overflow: hidden;");
expectBaseRule(css, ".agent-log-viewer-scroll", "scrollbar-color: var(--border) transparent;");
expectBaseRule(css, ".agent-log-viewer-scroll", "scrollbar-width: thin;");
expectBaseRule(css, ".agent-log-viewer-scroll::-webkit-scrollbar", "width: 6px;");
expectBaseRule(css, ".agent-log-viewer-scroll::-webkit-scrollbar-thumb", "background: var(--border);");
expectBaseRule(css, ".agent-log-model-header", "background: var(--bg-tertiary);");
});
// FNXC:TaskDetailModal 2026-08-15-00:00 (slow-test trim): the markdown-body class shape,
// heading stripping, and PROMPT.md-heading absence cases were three separate renders of the
// same prompt-bearing props; merged into one render with all assertions intact.
it("strips the leading heading and renders prompt markdown without detail-prompt class or PROMPT.md heading", () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ prompt: "# Hello\n\nSome **bold** text" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const markdownDiv = document.querySelector(".markdown-body");
expect(markdownDiv).toBeTruthy();
expect(markdownDiv!.classList.contains("detail-prompt")).toBe(false);
// The leading # heading should be stripped (modal has its own header)
expect(document.querySelector(".markdown-body h1")).toBeNull();
expect(document.querySelector("strong")?.textContent).toBe("bold");
expect(screen.queryByText("PROMPT.md")).toBeNull();
});
it("renders (no prompt) with detail-prompt class when prompt is absent", () => {
const { container } = render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ prompt: undefined })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const fallback = screen.getByText("(no prompt)");
expect(fallback).toBeTruthy();
expect(fallback.classList.contains("detail-prompt")).toBe(true);
expect(fallback.classList.contains("markdown-body")).toBe(false);
});
it("renders Review and Comments tabs", () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Review")).toBeTruthy();
expect(screen.getByText("Comments")).toBeTruthy();
});
it("shows non-PR review shell message in Review tab", async () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ reviewState: { source: "reviewer-agent", items: [], addressing: [] } })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Review" }));
expect(await screen.findByText("No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.")).toBeTruthy();
});
it("keeps Comments tab available after Review refresh", async () => {
vi.mocked(dashboardApi.fetchTaskReview).mockResolvedValueOnce({
reviewState: {
source: "pull-request",
summary: {
reviewDecision: "REVIEW_REQUIRED",
reviewers: [],
blockingReasons: [],
checks: [],
},
items: [],
addressing: [],
},
automationStatus: null,
emptyMessage: null,
});
vi.mocked(dashboardApi.refreshTaskReview).mockResolvedValueOnce({
reviewState: {
source: "pull-request",
summary: {
reviewDecision: "APPROVED",
reviewers: [{ login: "octocat", state: "APPROVED" }],
blockingReasons: [],
checks: [],
},
items: [],
addressing: [],
refreshStatus: "ready",
},
automationStatus: null,
});
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ reviewState: { source: "pull-request", summary: { reviewDecision: "REVIEW_REQUIRED", reviewers: [], blockingReasons: [], checks: [] }, items: [], addressing: [] } })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Review" }));
fireEvent.click(await screen.findByRole("button", { name: "Refresh" }));
expect((await screen.findAllByText("APPROVED")).length).toBeGreaterThan(0);
const commentsTab = screen.getByRole("button", { name: "Comments" });
expect(commentsTab).toBeInTheDocument();
fireEvent.click(commentsTab);
expect(screen.getByRole("heading", { name: "Comments" })).toBeInTheDocument();
});
it("shows PR review decision details in Review tab", async () => {
vi.mocked(dashboardApi.fetchTaskReview).mockResolvedValueOnce({
reviewState: {
source: "pull-request",
summary: {
reviewDecision: "CHANGES_REQUESTED",
reviewers: [{ login: "octocat", state: "CHANGES_REQUESTED" }],
blockingReasons: ["changes requested review is active"],
checks: [],
},
items: [],
addressing: [],
},
automationStatus: null,
emptyMessage: null,
});
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ reviewState: { source: "pull-request", summary: { reviewDecision: "CHANGES_REQUESTED", reviewers: [{ login: "octocat", state: "CHANGES_REQUESTED" }], blockingReasons: ["changes requested review is active"], checks: [] }, items: [], addressing: [] } })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Review" }));
expect((await screen.findAllByText("CHANGES_REQUESTED")).length).toBeGreaterThan(0);
expect(screen.getByText(/No review items yet\./i)).toBeTruthy();
});
describe("inline action row icon-only controls", () => {
it("renders priority and Fast controls as accessible icon-only Quick Add buttons", () => {
render(<TaskDetailModal initialTab="definition" task={makeTask({ column: "todo", priority: "high", executionMode: "fast" })} onClose={noop} onMoveTask={noopMove} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
const priority = screen.getByTestId("detail-priority-trigger");
const fast = screen.getByRole("button", { name: "Execution mode: fast" });
expect(priority).toHaveClass("btn", "btn-icon", "btn-sm");
expect(priority).toHaveAttribute("title", "Priority: High");
expect(fast).toHaveClass("btn", "btn-icon", "btn-sm", "btn-primary");
expect(fast).toHaveAttribute("title", "Execution mode: fast");
expect(fast).not.toHaveTextContent("Fast");
});
it("keeps every inline action icon-only with the production size-prop contracts", () => {
const source = readFileSync(resolve(__dirname, "../TaskDetailModal.tsx"), "utf8");
const rowStart = source.indexOf('data-testid="detail-meta-inline-controls"');
const rowEnd = source.indexOf('className="detail-hidden-file-input"', rowStart);
const row = source.slice(rowStart, rowEnd);
// FNXC:TaskDetailModalResponsive 2026-07-19-12:00: The row stays ordered
// attach → GitHub → Oversight → priority → Fast; CSS owns icon parity.
expect(row).toMatch(/<Paperclip size=\{12\}[^>]*aria-hidden="true"/);
expect(row).toMatch(/<ProviderIcon provider="github" size="sm"/);
expect(row).toMatch(/<PriorityIcon size=\{14\}[^>]*aria-hidden="true"/);
expect(row).toMatch(/<Zap size=\{14\}[^>]*aria-hidden="true"/);
expect(row).toMatch(/overseerTriggerOn \? <Eye aria-hidden="true"\s*\/> : <EyeOff aria-hidden="true"\s*\/>/);
expect(row).not.toMatch(/<(?:Eye|EyeOff)\s+[^>]*\bsize=/);
expect(row.indexOf("detail-inline-attach")).toBeLessThan(row.indexOf("detail-inline-github-toggle"));
expect(row.indexOf("detail-inline-github-toggle")).toBeLessThan(row.indexOf("detail-oversight-menu-trigger"));
expect(row.indexOf("detail-oversight-menu-trigger")).toBeLessThan(row.indexOf("detail-priority-trigger"));
expect(row.indexOf("detail-priority-trigger")).toBeLessThan(row.indexOf("detail-execution-mode-toggle"));
// FNXC:QuickAddActionRow 2026-07-20-12:00: Every test-id affordance must
// also carry its FN-8287 sizing class, including optional GitHub and
// Oversight surfaces, so mounted tablet controls share one compact box.
expect(row).toMatch(/className="btn btn-icon btn-sm detail-inline-attach"/);
expect(row).toMatch(/className=\{`btn btn-icon btn-sm detail-inline-github-toggle/);
expect(row).toMatch(/className="btn btn-icon btn-sm detail-oversight-menu-trigger"/);
expect(row).toMatch(/className="btn btn-icon btn-sm detail-priority-trigger"/);
expect(row).toMatch(/className=\{`btn btn-icon btn-sm detail-execution-mode-toggle/);
for (const label of ["aria-label", "title"]) {
expect(row.match(new RegExp(label, "g"))?.length).toBeGreaterThanOrEqual(5);
}
});
it("removes bespoke toolbar SVG sizing rules", () => {
const css = readDashboardStylesSource();
expect(css).not.toMatch(/\.detail-oversight-menu-trigger svg\s*\{[^}]*width:\s*1em/);
expect(css).not.toMatch(/\.detail-execution-mode-toggle svg\s*\{[^}]*width:\s*1em/);
});
});
it("appends daemon token query to attachment href/src URLs for direct browser loads", () => {
localStorage.setItem("fn.authToken", "daemon-token");
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({
attachments: [
{
filename: "screenshot.png",
originalName: "Screenshot",
mimeType: "image/png",
size: 1024,
createdAt: "2026-01-01T00:00:00Z",
},
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const attachmentLink = screen.getByRole("link", { name: "Screenshot" });
const attachmentImage = screen.getByAltText("Screenshot");
expect(attachmentLink.getAttribute("href")).toBe(
"/api/tasks/FN-099/attachments/screenshot.png?fn_token=daemon-token",
);
expect(attachmentImage.getAttribute("src")).toBe(
"/api/tasks/FN-099/attachments/screenshot.png?fn_token=daemon-token",
);
});
it("leaves attachment href/src URLs unchanged when no daemon token is present", () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({
attachments: [
{
filename: "screenshot.png",
originalName: "Screenshot",
mimeType: "image/png",
size: 1024,
createdAt: "2026-01-01T00:00:00Z",
},
],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const attachmentLink = screen.getByRole("link", { name: "Screenshot" });
const attachmentImage = screen.getByAltText("Screenshot");
expect(attachmentLink.getAttribute("href")).toBe("/api/tasks/FN-099/attachments/screenshot.png");
expect(attachmentImage.getAttribute("src")).toBe("/api/tasks/FN-099/attachments/screenshot.png");
});
it("renders Retry button when task status is 'failed' (in Actions dropdown)", () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ status: "failed" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={noopRetry}
addToast={noop}
/>,
);
// Open Actions dropdown to see Retry
const actionsBtn = screen.getByRole("button", { name: "Actions" });
fireEvent.click(actionsBtn);
expect(screen.getByRole("menuitem", { name: "Retry" })).toBeTruthy();
});
it("does NOT render Retry button when task status is not 'failed'", () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ status: "executing" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={noopRetry}
addToast={noop}
/>,
);
// No Retry should be visible in the Actions dropdown
const actionsBtn = screen.getByRole("button", { name: "Actions" });
fireEvent.click(actionsBtn);
expect(screen.queryByRole("menuitem", { name: "Retry" })).toBeNull();
});
it("does NOT render Retry button when onRetryTask is not provided", () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ status: "failed" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText("Retry")).toBeNull();
});
it("suppresses failure alert and Retry actions while a stale failed task has automatic recovery pending", () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({
status: "failed",
error: "Transient provider error",
recoveryRetryCount: 1,
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={noopRetry}
addToast={noop}
/>,
);
expect(screen.queryByRole("alert")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Actions" }));
expect(screen.queryByRole("menuitem", { name: "Retry" })).toBeNull();
expect(screen.queryByText("Retry with a different model/node")).toBeNull();
});
describe("retry action uniqueness for in-review failed tasks", () => {
// FNXC:TaskDetailModal 2026-08-15-00:00 (slow-test trim): the failed and stuck-killed
// in-review uniqueness cases shared one body; converted to it.each with both statuses kept.
it.each(["failed", "stuck-killed"] as const)(
"shows exactly one Retry button when task is in-review AND %s (in Actions dropdown)",
(status) => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ column: "in-review", status })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={noopRetry}
addToast={noop}
/>,
);
// Open Actions dropdown and check for exactly one Retry
const actionsBtn = screen.getByRole("button", { name: "Actions" });
fireEvent.click(actionsBtn);
const retryButtons = screen.getAllByRole("menuitem", { name: "Retry" });
expect(retryButtons).toHaveLength(1);
});
it("shows Retry for a stranded planning triage task", () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ column: "triage", status: "planning", stuckKillCount: 6 })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={noopRetry}
addToast={noop}
/>,
);
const actionsBtn = screen.getByRole("button", { name: "Actions" });
fireEvent.click(actionsBtn);
const retryButtons = screen.getAllByRole("menuitem", { name: "Retry" });
expect(retryButtons).toHaveLength(1);
});
it("closes modal immediately when Retry is clicked (before API call)", async () => {
const onClose = vi.fn();
const onRetryTask = vi.fn(async () => ({}) as Task);
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ column: "in-review", status: "failed" })}
onClose={onClose}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={onRetryTask}
addToast={noop}
/>,
);
// Open Actions dropdown and click Retry
const actionsBtn = screen.getByRole("button", { name: "Actions" });
await act(async () => {
fireEvent.click(actionsBtn);
});
const retryBtn = screen.getByRole("menuitem", { name: "Retry" });
await act(async () => {
fireEvent.click(retryBtn);
});
// Modal should close immediately (optimistic close before API call)
expect(onClose).toHaveBeenCalledTimes(1);
// onRetryTask should still be called with the correct task ID
expect(onRetryTask).toHaveBeenCalledWith("FN-099");
});
it("shows exactly one success toast when retry succeeds", async () => {
const onClose = vi.fn();
const onRetryTask = vi.fn(async () => ({}) as Task);
const addToast = vi.fn();
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ column: "in-review", status: "failed" })}
onClose={onClose}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={onRetryTask}
addToast={addToast}
/>,
);
// Open Actions dropdown and click Retry
const actionsBtn = screen.getByRole("button", { name: "Actions" });
await act(async () => {
fireEvent.click(actionsBtn);
});
const retryBtn = screen.getByRole("menuitem", { name: "Retry" });
await act(async () => {
fireEvent.click(retryBtn);
});
// Wait for the promise to resolve
await act(async () => {});
// Only one toast — the success toast, no info toast
expect(addToast).toHaveBeenCalledTimes(1);
expect(addToast).toHaveBeenCalledWith("Retried FN-099", "success");
});
it("shows exactly one error toast when retry fails", async () => {
const onClose = vi.fn();
const onRetryTask = vi.fn(async () => {
throw new Error("Server error");
});
const addToast = vi.fn();
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ column: "in-review", status: "failed" })}
onClose={onClose}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={onRetryTask}
addToast={addToast}
/>,
);
// Open Actions dropdown and click Retry
const actionsBtn = screen.getByRole("button", { name: "Actions" });
await act(async () => {
fireEvent.click(actionsBtn);
});
const retryBtn = screen.getByRole("menuitem", { name: "Retry" });
await act(async () => {
fireEvent.click(retryBtn);
});
// Wait for the promise to reject
await act(async () => {});
// Only one toast — the error toast
expect(addToast).toHaveBeenCalledTimes(1);
expect(addToast).toHaveBeenCalledWith("Server error", "error");
});
it("shows in-review split button with primary action and secondary move option", () => {
const { container } = render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ column: "in-review" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const moveBtn = screen.getByRole("button", { name: "Move to Todo" });
expect(moveBtn).toBeTruthy();
const chevronZone = document.querySelector(".detail-move-btn__arrow");
expect(chevronZone).toBeTruthy();
fireEvent.keyDown(moveBtn, { key: "ArrowDown" });
expect(screen.getByRole("menuitem", { name: "Back to In Progress" })).toBeTruthy();
expect(screen.queryByRole("menuitem", { name: "Move to Todo" })).toBeNull();
const actionsBtn = screen.getByRole("button", { name: "Actions" });
fireEvent.click(actionsBtn);
expect(screen.queryByRole("menuitem", { name: "Retry" })).toBeNull();
});
it("in-review failed task shows both Retry action and secondary move option", async () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ column: "in-review", status: "failed" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={noopRetry}
addToast={noop}
/>,
);
const actionsBtn = screen.getByRole("button", { name: "Actions" });
await act(async () => {
fireEvent.click(actionsBtn);
});
expect(screen.getByRole("menuitem", { name: "Retry" })).toBeTruthy();
expect(screen.getAllByRole("menuitem", { name: "Retry" })).toHaveLength(1);
const chevronZone = document.querySelector(".detail-move-btn__arrow");
await act(async () => {
fireEvent.click(chevronZone!);
});
expect(screen.getByRole("menuitem", { name: "Back to In Progress" })).toBeTruthy();
expect(screen.queryByRole("menuitem", { name: "Move to Todo" })).toBeNull();
});
it("split-button renders with chevron when multiple transitions exist", async () => {
const { container } = render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ column: "in-progress" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const moveBtn = screen.getByRole("button", { name: "Move to In Review" });
expect(moveBtn).toBeTruthy();
const chevronZone = document.querySelector(".detail-move-btn__arrow");
expect(chevronZone).toBeTruthy();
await act(async () => {
fireEvent.click(chevronZone!);
});
expect(screen.getByRole("menuitem", { name: "Move to Todo" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Done" })).toBeTruthy();
expect(screen.queryByRole("menuitem", { name: "Move to In Review" })).toBeNull();
});
// FNXC:TaskDetailModal 2026-08-15-00:00 (slow-test trim): the "renders without chevron
// when only one transition" case was a `expect(true).toBe(true)` stub (triage currently
// has multiple transitions, so the scenario is unreachable). Deleted rather than kept as
// dead weight; restore real assertions from git history once a single-transition column exists.
it("clicking main button executes primary transition immediately", async () => {
const onMoveTask = vi.fn().mockResolvedValue(undefined);
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ column: "in-progress" })}
onClose={noop}
onMoveTask={onMoveTask}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Move to In Review" }));
});
expect(onMoveTask).toHaveBeenCalledWith("FN-099", "in-review", undefined);
expect(screen.queryByRole("menu")).toBeNull();
});
it("chevron dropdown includes only secondary transitions", async () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ column: "in-progress" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const moveBtn = screen.getByRole("button", { name: "Move to In Review" });
fireEvent.keyDown(moveBtn, { key: "ArrowDown" });
expect(screen.getByRole("menuitem", { name: "Move to Todo" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Planning" })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Move to Done" })).toBeTruthy();
expect(screen.queryByRole("menuitem", { name: "Move to In Review" })).toBeNull();
fireEvent.keyDown(screen.getByRole("menuitem", { name: "Move to Todo" }), { key: "Escape" });
expect(screen.queryByRole("menuitem", { name: "Move to Todo" })).toBeNull();
expect(document.activeElement).toBe(moveBtn);
});
});
it("shows description exactly once for a task without title", () => {
const { container } = render(
<TaskDetailModal
initialTab="definition"
task={makeTask({
title: undefined,
description: "Fix the login bug",
prompt: "# KB-099\n\nFix the login bug\n",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// The heading "FN-099" should be stripped from the markdown
const markdownBody = document.querySelector(".markdown-body");
expect(markdownBody?.innerHTML).not.toContain("FN-099");
// Description appears in the markdown body
expect(markdownBody?.textContent).toContain("Fix the login bug");
// The detail header shows the ID (not duplicated as markdown heading)
expect(document.querySelector(".detail-id")?.textContent).toBe("FN-099");
// The h2 title shows description, not the task ID
const h2 = document.querySelector("h2.detail-title");
expect(h2?.textContent).toBe("Fix the login bug");
});
it("shows the title in <h2> when task.title is set", () => {
const { container } = render(
<TaskDetailModal
initialTab="definition"
task={makeTask({
title: "Implement dark mode",
description: "Add dark mode toggle to the settings page",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const h2 = document.querySelector("h2.detail-title");
expect(h2?.textContent).toBe("Implement dark mode");
});
describe("description truncation", () => {
let titleScrollHeight = 0;
let titleClientHeight = 0;
let titleResizeObservers: Array<{ callback: ResizeObserverCallback; disconnected: boolean }> = [];
const originalScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
const originalResizeObserver = Object.getOwnPropertyDescriptor(globalThis, "ResizeObserver");
const setTitleLayout = ({ scrollHeight, clientHeight }: { scrollHeight: number; clientHeight: number }) => {
titleScrollHeight = scrollHeight;
titleClientHeight = clientHeight;
};
const renderDetail = (taskOverrides: Parameters<typeof makeTask>[0] = {}) => render(
<TaskDetailModal
initialTab="definition"
task={makeTask(taskOverrides)}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const expectNoStandaloneTitleToggle = () => {
expect(document.querySelector(".detail-description-toggle")).toBeNull();
expect(screen.queryByRole("button", { name: "Show more" })).toBeNull();
expect(screen.queryByRole("button", { name: "Show less" })).toBeNull();
};
beforeEach(() => {
setTitleLayout({ scrollHeight: 120, clientHeight: 40 });
titleResizeObservers = [];
Object.defineProperty(globalThis, "ResizeObserver", {
configurable: true,
value: class TitleResizeObserver {
private readonly observation: { callback: ResizeObserverCallback; disconnected: boolean };
constructor(callback: ResizeObserverCallback) {
this.observation = { callback, disconnected: false };
titleResizeObservers.push(this.observation);
}
observe() {}
unobserve() {}
disconnect() { this.observation.disconnected = true; }
},
});
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
configurable: true,
get() {
return this instanceof HTMLElement && this.classList.contains("detail-title-measurement") ? titleScrollHeight : 0;
},
});
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
configurable: true,
get() {
return this instanceof HTMLElement && this.classList.contains("detail-title-measurement") ? titleClientHeight : 0;
},
});
});
afterEach(() => {
if (originalScrollHeight) {
Object.defineProperty(HTMLElement.prototype, "scrollHeight", originalScrollHeight);
} else {
Reflect.deleteProperty(HTMLElement.prototype, "scrollHeight");
}
if (originalClientHeight) {
Object.defineProperty(HTMLElement.prototype, "clientHeight", originalClientHeight);
} else {
Reflect.deleteProperty(HTMLElement.prototype, "clientHeight");
}
if (originalResizeObserver) {
Object.defineProperty(globalThis, "ResizeObserver", originalResizeObserver);
} else {
Reflect.deleteProperty(globalThis, "ResizeObserver");
}
});
it("toggles a long triage title directly without a standalone affordance", async () => {
/*
FNXC:TaskDetailTitle 2026-08-04-18:00:
Every TaskDetailModal lifecycle column and title fallback shares this definition header. An overflowed title alone owns expansion for pointer, touch, and keyboard users; short and fallback headings retain no empty control or legacy Show more/Show less shell, while Summarize remains separate.
*/
const longTitle = "Triage title ".repeat(25);
renderDetail({
column: "triage",
title: longTitle,
description: "Triage planning context",
});
const h2 = document.querySelector("h2.detail-title");
expect(h2?.textContent).toBe(longTitle);
expect(h2).toHaveClass("detail-title--collapsed");
const titleControl = await screen.findByRole("button", { name: "Expand task title" });
expect(titleControl).toHaveAttribute("aria-expanded", "false");
expectNoStandaloneTitleToggle();
await userEvent.click(titleControl);
expect(document.querySelector("h2.detail-title")?.textContent).toBe(longTitle);
expect(document.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed");
expect(screen.getByRole("button", { name: "Collapse task title" })).toHaveAttribute("aria-expanded", "true");
expectNoStandaloneTitleToggle();
await userEvent.click(screen.getByRole("button", { name: "Collapse task title" }));
expect(document.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed");
expect(screen.getByRole("button", { name: "Expand task title" })).toHaveAttribute("aria-expanded", "false");
expectNoStandaloneTitleToggle();
});
it("keeps the modal title control stable through repeated resize callbacks after each activation", async () => {
const longTitle = "Resize-safe title ".repeat(25);
renderDetail({ title: longTitle });
const titleControl = await screen.findByRole("button", { name: "Expand task title" });
const measuredText = document.querySelector(".detail-title-measurement");
const collapsedObserver = titleResizeObservers.at(-1);
expect(collapsedObserver).toBeDefined();
expect(measuredText?.textContent).toBe(longTitle);
await userEvent.click(titleControl);
expect(document.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed");
expect(screen.getByRole("button", { name: "Collapse task title" })).toBe(titleControl);
expect(titleControl).toHaveAttribute("aria-expanded", "true");
expect(document.querySelector(".detail-title-measurement")).toBe(measuredText);
expect(document.querySelector("h2.detail-title")?.textContent).toBe(longTitle);
expect(collapsedObserver?.disconnected).toBe(true);
// Delivery can race disconnect; a stale collapsed-layout observer must not reclaim the choice.
await act(async () => {
for (let index = 0; index < 3; index++) {
collapsedObserver?.callback([], {} as ResizeObserver);
}
});
expect(screen.getByRole("button", { name: "Collapse task title" })).toBe(titleControl);
expect(titleControl).toHaveAttribute("aria-expanded", "true");
expect(screen.getAllByRole("button", { name: "Collapse task title" })).toHaveLength(1);
await userEvent.click(titleControl);
const recollapsedObserver = titleResizeObservers.at(-1);
expect(document.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed");
expect(document.querySelector(".detail-title-measurement")).toBe(measuredText);
expect(screen.getByRole("button", { name: "Expand task title" })).toBe(titleControl);
expect(titleControl).toHaveAttribute("aria-expanded", "false");
expect(recollapsedObserver).not.toBe(collapsedObserver);
await act(async () => {
for (let index = 0; index < 3; index++) {
recollapsedObserver?.callback([], {} as ResizeObserver);
}
});
expect(document.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed");
expect(screen.getByRole("button", { name: "Expand task title" })).toBe(titleControl);
expect(screen.getAllByRole("button", { name: "Expand task title" })).toHaveLength(1);
expect(document.querySelector("h2.detail-title")?.textContent).toBe(longTitle);
expectNoStandaloneTitleToggle();
});
it("keeps the embedded narrow title choice stable and ignores a switched task's stale observer", async () => {
const longTitle = "Embedded mobile title ".repeat(25);
const props = {
embedded: true,
active: true,
initialTab: "definition" as const,
onMoveTask: noopMove,
onDeleteTask: noopDelete,
onMergeTask: noopMerge,
onOpenDetail: noopOpenDetail,
addToast: noop,
};
const originalInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 375 });
const { rerender } = render(<TaskDetailContent {...props} task={makeTask({ id: "FN-EMBEDDED", title: longTitle })} />);
fireEvent(window, new Event("resize"));
const titleControl = await screen.findByRole("button", { name: "Expand task title" });
const oldObserver = titleResizeObservers.at(-1);
await userEvent.click(titleControl);
await act(async () => {
oldObserver?.callback([], {} as ResizeObserver);
oldObserver?.callback([], {} as ResizeObserver);
});
expect(document.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed");
expect(screen.getByRole("button", { name: "Collapse task title" })).toBe(titleControl);
setTitleLayout({ scrollHeight: 40, clientHeight: 40 });
rerender(<TaskDetailContent {...props} task={makeTask({ id: "FN-EMBEDDED-NEXT", title: "Narrow fitting title" })} />);
await act(async () => {});
expect(document.querySelector("h2.detail-title")?.textContent).toBe("Narrow fitting title");
expect(screen.queryByRole("button", { name: /task title/ })).toBeNull();
expect(oldObserver?.disconnected).toBe(true);
await act(async () => {
oldObserver?.callback([], {} as ResizeObserver);
oldObserver?.callback([], {} as ResizeObserver);
});
expect(screen.queryByRole("button", { name: /task title/ })).toBeNull();
expectNoStandaloneTitleToggle();
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
});
it("ignores title observer deliveries while a kept-alive pop-out is hidden", async () => {
setTitleLayout({ scrollHeight: 40, clientHeight: 40 });
const props = {
embedded: true,
active: true,
initialTab: "definition" as const,
onMoveTask: noopMove,
onDeleteTask: noopDelete,
onMergeTask: noopMerge,
onOpenDetail: noopOpenDetail,
addToast: noop,
};
const { rerender } = render(<TaskDetailContent {...props} task={makeTask({ id: "FN-HIDDEN", title: "Visibility fenced title" })} />);
const visibleObserver = titleResizeObservers.at(-1);
expect(screen.queryByRole("button", { name: /task title/ })).toBeNull();
rerender(<TaskDetailContent {...props} active={false} task={makeTask({ id: "FN-HIDDEN", title: "Visibility fenced title" })} />);
expect(visibleObserver?.disconnected).toBe(true);
setTitleLayout({ scrollHeight: 120, clientHeight: 40 });
await act(async () => {
visibleObserver?.callback([], {} as ResizeObserver);
visibleObserver?.callback([], {} as ResizeObserver);
});
expect(screen.queryByRole("button", { name: /task title/ })).toBeNull();
rerender(<TaskDetailContent {...props} active task={makeTask({ id: "FN-HIDDEN", title: "Visibility fenced title" })} />);
expect(await screen.findByRole("button", { name: "Expand task title" })).toHaveAttribute("aria-expanded", "false");
expectNoStandaloneTitleToggle();
});
it("supports keyboard activation through the title control", async () => {
renderDetail({ title: "Keyboard title ".repeat(25) });
const titleControl = await screen.findByRole("button", { name: "Expand task title" });
titleControl.focus();
await userEvent.keyboard("{Enter}");
expect(document.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed");
expect(screen.getByRole("button", { name: "Collapse task title" })).toHaveAttribute("aria-expanded", "true");
expectNoStandaloneTitleToggle();
});
it("collapses a long triage description fallback by default when title is missing", async () => {
const longDescription = "Triage description ".repeat(20);
renderDetail({ column: "triage", title: undefined, description: longDescription });
const h2 = document.querySelector("h2.detail-title");
expect(h2?.textContent).toBe(longDescription);
expect(h2).toHaveClass("detail-title--collapsed");
expect(await screen.findByRole("button", { name: "Expand task title" })).toHaveAttribute("aria-expanded", "false");
expectNoStandaloneTitleToggle();
});
it("uses the title, description, and id fallback chain for the clamped heading", async () => {
/*
FNXC:TaskDetailModal 2026-07-30-23:20 (#2895 review — the portal defect, at the sites the reported
one did not cover):
CLEANUP BETWEEN RENDERS, NOT A WIDER SELECTOR.
These queried the render `container`, and the modal PORTALS out of it, so every one returned
null. The obvious repair — swap to `document.querySelector` — is wrong here and I watched it
fail: this case renders the modal THREE times, the portals accumulate on `document.body`, and a
document-wide query returns the FIRST one. The failure moved from "Title wins" to
"Description fallback" rather than going away.
Unmounting between renders makes the document unambiguous, so each assertion reads the render it
belongs to. That also matches what the case is actually testing — three independent fallback
inputs, not three coexisting modals.
*/
renderDetail({ title: "Title wins", description: "Description loses" });
expect(document.querySelector("h2.detail-title")?.textContent).toBe("Title wins");
expect(document.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed");
expect(await screen.findByRole("button", { name: "Expand task title" })).toBeInTheDocument();
expectNoStandaloneTitleToggle();
cleanup();
setTitleLayout({ scrollHeight: 40, clientHeight: 40 });
renderDetail({ title: undefined, description: "Description fallback" });
expect(document.querySelector("h2.detail-title")?.textContent).toBe("Description fallback");
expect(screen.queryByRole("button", { name: "Expand task title" })).toBeNull();
expectNoStandaloneTitleToggle();
cleanup();
renderDetail({ id: "FN-FALLBACK", title: undefined, description: undefined });
expect(document.querySelector("h2.detail-title")?.textContent).toBe("FN-FALLBACK");
expect(screen.queryByRole("button", { name: "Expand task title" })).toBeNull();
expectNoStandaloneTitleToggle();
});
it.each(["todo", "in-progress", "in-review", "done", "archived"] as const)(
"collapses overflowing non-triage %s titles with a title-owned control",
async (column) => {
const longTitle = `${column} title `.repeat(25);
renderDetail({ column, title: longTitle });
const h2 = document.querySelector("h2.detail-title");
expect(h2?.textContent).toBe(longTitle);
expect(h2).toHaveClass("detail-title--collapsed");
expect(await screen.findByRole("button", { name: "Expand task title" })).toHaveAttribute("aria-expanded", "false");
expectNoStandaloneTitleToggle();
},
);
it("does not render an empty title control when the title fits within two lines", () => {
setTitleLayout({ scrollHeight: 40, clientHeight: 40 });
renderDetail({
title: "Short title",
description: "This is a longer description that is not shown as the heading while title is present",
});
const h2 = document.querySelector("h2.detail-title");
expect(h2?.textContent).toBe("Short title");
expect(h2).toHaveClass("detail-title--collapsed");
expect(screen.queryByRole("button", { name: "Expand task title" })).toBeNull();
expectNoStandaloneTitleToggle();
});
it("resets to collapsed when switching from a non-triage task to a triage task", async () => {
const todoDescription = "G".repeat(250);
const triageDescription = "H".repeat(250);
const { rerender } = render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ id: "FN-TODO", column: "todo", title: undefined, description: todoDescription })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await userEvent.click(await screen.findByRole("button", { name: "Expand task title" }));
expect(document.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed");
rerender(
<TaskDetailModal
initialTab="definition"
task={makeTask({ id: "FN-TRIAGE", column: "triage", title: undefined, description: triageDescription })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await waitFor(() => {
expect(document.querySelector("h2.detail-title")?.textContent).toBe(triageDescription);
});
expect(document.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed");
expect(screen.getByRole("button", { name: "Expand task title" })).toHaveAttribute("aria-expanded", "false");
expectNoStandaloneTitleToggle();
});
it("keeps the editing title form unaffected by the read-only clamp", async () => {
const longTitle = "Editable title ".repeat(25);
renderDetail({ column: "todo", title: longTitle, description: "Editable description" });
expect(await screen.findByRole("button", { name: "Expand task title" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Edit task" }));
expect(document.querySelector("h2.detail-title")).toBeNull();
expectNoStandaloneTitleToggle();
expect(screen.getByLabelText("Title")).toHaveValue(longTitle);
});
it("keeps the summarize-title affordance distinct from title expansion", async () => {
renderDetail({
column: "todo",
title: "Summarize me ".repeat(25),
description: "Description available for summarization",
});
expect(document.querySelector(".detail-heading-row h2.detail-title--collapsed")).toBeInTheDocument();
const titleControl = await screen.findByRole("button", { name: "Expand task title" });
const summarizeButton = screen.getByTestId("summarize-title-btn");
expect(summarizeButton).not.toBe(titleControl);
await userEvent.click(summarizeButton);
expect(titleControl).toHaveAttribute("aria-expanded", "false");
expectNoStandaloneTitleToggle();
});
it("keeps the clamp available in chat-expanded layout", async () => {
render(
<TaskDetailContent
task={makeTask({
column: "todo",
title: "Chat expanded title ".repeat(25),
description: "Description",
})}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
initialTab="chat"
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Expand activity to full modal" }));
expect(document.querySelector(".task-detail-content--chat-expanded")).toBeInTheDocument();
expect(document.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed");
expect(await screen.findByRole("button", { name: "Expand task title" })).toBeInTheDocument();
expectNoStandaloneTitleToggle();
});
it("has desktop and mobile CSS rules that preserve the two-line title clamp", () => {
const css = readDashboardStylesSource();
expect(css).toContain(".detail-title--collapsed");
expectBaseRule(css, ".detail-title--collapsed .detail-title-measurement", "-webkit-line-clamp: 2");
expectBaseRule(css, ".detail-title--collapsed .detail-title-measurement", "line-clamp: 2");
expectBaseRule(css, ".detail-title-control", "width: 100%");
expectBaseRule(css, ".detail-title-control:focus-visible", "box-shadow: var(--focus-ring-strong)");
expect(css).toContain("@media (max-width: 768px)");
expectBaseRule(css, ".detail-title", "font-size: 16px");
expect(css).not.toContain(".detail-description-toggle");
});
});
it("always shows task.id in the detail-id badge regardless of title", () => {
// With title
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ title: "Some title" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
/* Portaled, and rendered twice — see the cleanup note on the clamped-heading case. */
expect(document.querySelector(".detail-id")?.textContent).toBe("FN-099");
cleanup();
// Without title
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ title: undefined, description: "A description" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(document.querySelector(".detail-id")?.textContent).toBe("FN-099");
});
describe("optimistic opening with Task", () => {
beforeEach(async () => {
await resetTaskDetailFetchMock();
vi.mocked(dashboardApi.fetchTaskPrompt).mockReset();
vi.mocked(dashboardApi.fetchTaskPrompt).mockResolvedValue({ id: "FN-099", prompt: "# Task FN-099" });
});
it("restores a resolved detail Promise after an override is reset", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
mockFetch.mockReset();
mockFetch.mockResolvedValueOnce(makeTask({ id: "FN-override", prompt: "# Override" }));
await expect(mockFetch("FN-override", undefined)).resolves.toMatchObject({ id: "FN-override" });
await resetTaskDetailFetchMock();
/*
FNXC:DashboardTests 2026-08-04-15:05:
The reproducing reset sequence must never leave Definition refresh with
Vitest's undefined return value instead of the API's Promise contract.
*/
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ prompt: "# Restored default prompt" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await waitFor(() => expect(dashboardApi.fetchTaskPrompt).toHaveBeenCalledWith("FN-099", undefined));
await waitFor(() => expect(screen.queryByText("Restored default prompt")).toBeNull());
});
it("renders immediately when opened with a Task prop (no prompt)", async () => {
const { fetchTaskDetail } = await import("../../api");
vi.mocked(fetchTaskDetail).mockResolvedValueOnce({
id: "FN-200",
description: "Optimistic task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
prompt: "# Spec",
} as TaskDetail);
const task: Task = {
id: "FN-200",
description: "Optimistic task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
} as Task;
const { container } = render(
<TaskDetailModal
initialTab="definition"
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
/* Same obsolete selector as the wrapper case: the shell is FloatingWindow, not `.modal-overlay`. */
expect(document.querySelector(".floating-window-overlay--modal")).toBeTruthy();
expect(screen.getByText("FN-200")).toBeDefined();
});
it("calls fetchTaskDetail on mount when prop is Task without prompt", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
mockFetch.mockResolvedValueOnce({
id: "FN-201",
description: "Optimistic task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
prompt: "# Spec",
} as TaskDetail);
const task: Task = {
id: "FN-201",
description: "Optimistic task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
} as Task;
render(
<TaskDetailModal
initialTab="definition"
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("FN-201", undefined);
});
});
it("uses a prompt-only Definition refresh when prop is already a TaskDetail with prompt", async () => {
const mockPromptFetch = vi.mocked(dashboardApi.fetchTaskPrompt);
const mockFetch = vi.mocked(dashboardApi.fetchTaskDetail);
const detail: TaskDetail = {
id: "FN-202",
description: "Full detail task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
prompt: "# Full spec",
} as TaskDetail;
render(
<TaskDetailModal
initialTab="definition"
task={detail}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// A full detail skips the full client; Definition reads only its prompt.
await waitFor(() => expect(mockPromptFetch).toHaveBeenCalledWith("FN-202", undefined));
expect(mockFetch).not.toHaveBeenCalled();
});
it("retains the last good prompt when Definition refresh rejects", async () => {
const mockFetch = vi.mocked(dashboardApi.fetchTaskPrompt);
const detail = makeTask({ id: "FN-202-rejected", prompt: "# Last good prompt" });
mockFetch.mockRejectedValueOnce(new Error("refresh failed"));
render(
<TaskDetailModal
initialTab="definition"
task={detail}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await waitFor(() => expect(mockFetch).toHaveBeenCalledWith("FN-202-rejected", undefined));
expect(screen.getByText("Last good prompt")).toBeInTheDocument();
expect(mockFetch).toHaveBeenCalledTimes(1);
});
/*
FNXC:TaskDetailPlan 2026-08-04-15:21:
Definition refresh is lifecycle-owned, not modal-owned: embedded kept-alive hosts
must defer while hidden, refresh immediately on reveal, keep one request during a
pending interval tick, and stop polling again when hidden. Exercise that actual
visibility sequence so a mock-default repair cannot hide a broken refresh fence.
*/
it("defers embedded refresh while inactive, deduplicates pending interval work, and resumes on re-show", async () => {
vi.useFakeTimers();
try {
const mockFetch = vi.mocked(dashboardApi.fetchTaskPrompt);
let resolveFirstRequest: (detail: { id: string; prompt?: string }) => void = () => undefined;
mockFetch.mockImplementationOnce(() => new Promise<{ id: string; prompt?: string }>((resolve) => {
resolveFirstRequest = resolve;
}));
mockFetch.mockResolvedValueOnce({ id: "FN-lifecycle", prompt: "# Interval refresh" });
mockFetch.mockResolvedValue({ id: "FN-lifecycle", prompt: "# Re-shown refresh" });
const props = {
embedded: true,
initialTab: "definition" as const,
task: makeTask({ id: "FN-lifecycle", status: "planning", prompt: "# Initial prompt" }),
onMoveTask: noopMove,
onDeleteTask: noopDelete,
onMergeTask: noopMerge,
onOpenDetail: noopOpenDetail,
addToast: noop,
};
const { rerender } = render(<TaskDetailContent {...props} active={false} />);
expect(mockFetch).not.toHaveBeenCalled();
rerender(<TaskDetailContent {...props} active />);
await act(async () => {});
expect(mockFetch).toHaveBeenCalledTimes(1);
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
expect(mockFetch).toHaveBeenCalledTimes(1);
await act(async () => { resolveFirstRequest({ id: "FN-lifecycle", prompt: "# First refresh" }); });
expect(screen.getByText("First refresh")).toBeInTheDocument();
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
expect(mockFetch).toHaveBeenCalledTimes(2);
rerender(<TaskDetailContent {...props} active={false} />);
await act(async () => { await vi.advanceTimersByTimeAsync(10_000); });
expect(mockFetch).toHaveBeenCalledTimes(2);
rerender(<TaskDetailContent {...props} active />);
await act(async () => {});
expect(mockFetch).toHaveBeenCalledTimes(3);
} finally {
vi.useRealTimers();
}
});
it("fences a cancelled embedded refresh after a visible task switch", async () => {
const mockFetch = vi.mocked(dashboardApi.fetchTaskPrompt);
let resolveStaleRequest: (detail: { id: string; prompt?: string }) => void = () => undefined;
let resolveCurrentRequest: (detail: { id: string; prompt?: string }) => void = () => undefined;
mockFetch
.mockImplementationOnce(() => new Promise<{ id: string; prompt?: string }>((resolve) => { resolveStaleRequest = resolve; }))
.mockImplementationOnce(() => new Promise<{ id: string; prompt?: string }>((resolve) => { resolveCurrentRequest = resolve; }));
const sharedProps = {
embedded: true,
initialTab: "definition" as const,
onMoveTask: noopMove,
onDeleteTask: noopDelete,
onMergeTask: noopMerge,
onOpenDetail: noopOpenDetail,
addToast: noop,
};
const staleTask = makeTask({ id: "FN-stale-detail", prompt: "# Stale initial" });
const currentTask = makeTask({ id: "FN-current-detail", prompt: "# Current initial" });
const { rerender } = render(<TaskDetailContent {...sharedProps} task={staleTask} active />);
await waitFor(() => expect(mockFetch).toHaveBeenCalledWith("FN-stale-detail", undefined));
rerender(<TaskDetailContent {...sharedProps} task={staleTask} active={false} />);
rerender(<TaskDetailContent {...sharedProps} task={currentTask} active />);
await waitFor(() => expect(mockFetch).toHaveBeenCalledWith("FN-current-detail", undefined));
await act(async () => { resolveCurrentRequest({ id: "FN-current-detail", prompt: "# Current response" }); });
expect(await screen.findByText("Current response")).toBeInTheDocument();
await act(async () => { resolveStaleRequest({ id: "FN-stale-detail", prompt: "# Stale response" }); });
expect(screen.queryByText("Stale response")).toBeNull();
expect(screen.getByText("Current response")).toBeInTheDocument();
});
it("shows loading state in spec area when detailLoading is true", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
// Set up a pending promise so loading state persists
mockFetch.mockResolvedValueOnce(new Promise(() => {}) as any);
const task: Task = {
id: "FN-203",
description: "Loading spec test",
column: "todo",
dependencies: [],
steps: [{ name: "Plan", status: "in-progress" }],
currentStep: 0,
log: [{ timestamp: "2026-04-24T09:00:00.000Z", action: "[timing] setup in 120ms" }],
executionMode: "fast",
status: "executing",
assignedAgentId: "agent-loading",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
} as Task;
render(
<TaskDetailModal
initialTab="definition"
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Loading specification…")).toBeDefined();
// Token stats now live in their own Stats tab — switch to it before
// asserting on token-loading text.
fireEvent.click(screen.getByRole("button", { name: "Stats" }));
expect(screen.getByText("Execution Timing")).toBeInTheDocument();
expect(screen.getByText("Execution Details")).toBeInTheDocument();
expect(screen.getByText("Loading token statistics…")).toBeDefined();
expect(screen.getAllByText("Fast").length).toBeGreaterThan(0);
expectSingleStatsRuntimeStatus("executing");
});
it("shows spec content after fetchTaskDetail resolves", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
const task: Task = {
id: "FN-204",
description: "Async spec test",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
} as Task;
const fullDetail: TaskDetail = {
...task,
prompt: "# Async Spec\n\nThis is the loaded spec content.",
log: [
{ timestamp: "2026-04-24T09:00:00.000Z", action: "[timing] prepare env in 120ms" },
{ timestamp: "2026-04-24T09:01:00.000Z", action: "[timing] run tests in 3400ms" },
],
workflowStepResults: [
{
workflowStepId: "WS-101",
workflowStepName: "Workflow QA",
status: "passed",
startedAt: "2026-04-24T09:10:00.000Z",
completedAt: "2026-04-24T09:10:07.000Z",
},
],
executionMode: "fast",
status: "executing",
mergeRetries: 1,
workflowStepRetries: 2,
recoveryRetryCount: 3,
taskDoneRetryCount: 4,
tokenUsage: {
inputTokens: 1200,
outputTokens: 450,
cachedTokens: 210,
cacheWriteTokens: 15,
totalTokens: 1860,
firstUsedAt: "2026-04-24T09:00:00.000Z",
lastUsedAt: "2026-04-24T10:15:00.000Z",
},
} as TaskDetail;
// Resolve with full detail
mockFetch.mockResolvedValueOnce(fullDetail);
const { container } = render(
<TaskDetailModal
initialTab="definition"
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Initially shows loading
expect(screen.getByText("Loading specification…")).toBeDefined();
// After fetch resolves, spec content appears
await waitFor(() => {
const markdownBody = document.querySelector(".markdown-body");
expect(markdownBody).toBeTruthy();
}, { timeout: 3000 });
// Loading indicator should be gone
expect(screen.queryByText("Loading specification…")).toBeNull();
// Token stats live behind the Stats tab now.
fireEvent.click(screen.getByRole("button", { name: "Stats" }));
expect(screen.queryByText("Loading token statistics…")).toBeNull();
expect(screen.getByText("Execution Timing")).toBeInTheDocument();
expect(screen.getByText("Execution Details")).toBeInTheDocument();
expect(screen.getByText("Timing events")).toBeInTheDocument();
expect(screen.getByText("Workflow runtime")).toBeInTheDocument();
expect(screen.getByText("Execution mode")).toBeInTheDocument();
expect(screen.getByText("Runtime status")).toBeInTheDocument();
expect(screen.getAllByText("Fast").length).toBeGreaterThan(0);
expectSingleStatsRuntimeStatus("executing");
expect(screen.getByText((1200).toLocaleString())).toBeInTheDocument();
expect(screen.getByText((450).toLocaleString())).toBeInTheDocument();
expect(screen.getByText((210).toLocaleString())).toBeInTheDocument();
expect(screen.getByText((1860).toLocaleString())).toBeInTheDocument();
const firstUsed = document.querySelector('time[datetime="2026-04-24T09:00:00.000Z"]');
const lastUsed = document.querySelector('time[datetime="2026-04-24T10:15:00.000Z"]');
expect(firstUsed).toBeTruthy();
expect(lastUsed).toBeTruthy();
});
it("preserves fullDetail.log when SSE-stripped task prop has empty log", async () => {
// Regression: SSE strips `log` to [] in task list payloads (see
// stripTaskListHeavyFields in packages/dashboard/src/sse.ts). The modal
// merges live `task` over `fullDetail` to keep tokenUsage/status fresh,
// which previously clobbered fullDetail.log and emptied the Activity tab.
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
const strippedTask: Task = {
id: "FN-LOG-1",
description: "SSE stripped task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
};
mockFetch.mockResolvedValueOnce({
...strippedTask,
prompt: "# Spec",
log: [
{ timestamp: "2026-04-24T09:00:00.000Z", action: "Created task" },
{ timestamp: "2026-04-24T09:01:00.000Z", action: "Started executor", outcome: "OK" },
],
} as TaskDetail);
const { container } = render(
<TaskDetailModal
initialTab="definition"
task={strippedTask}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Wait for fetchTaskDetail to resolve.
await waitFor(() => {
expect(document.querySelector(".markdown-body")).toBeTruthy();
}, { timeout: 3000 });
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Feed" }));
const activityList = document.querySelector(".detail-activity-list");
expect(activityList).toBeTruthy();
const logEntries = document.querySelectorAll(".detail-log-entry");
expect(logEntries).toHaveLength(2);
expect(logEntries[0].textContent).toContain("Started executor");
expect(logEntries[1].textContent).toContain("Created task");
});
it("shows token stats empty state once detail is loaded without usage", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
const task: Task = {
id: "FN-205",
description: "No token stats",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
} as Task;
mockFetch.mockResolvedValueOnce({
...task,
prompt: "# Async Spec\n\nSpec without usage.",
tokenUsage: undefined,
} as TaskDetail);
render(
<TaskDetailModal
initialTab="definition"
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Token stats live behind the Stats tab now — wait for the modal to
// settle, then switch tabs and assert on the empty state.
await waitFor(() => {
expect(screen.queryByText("Loading specification…")).toBeNull();
});
fireEvent.click(screen.getByRole("button", { name: "Stats" }));
await waitFor(() => {
expect(screen.getByText("No token usage recorded for this task yet.")).toBeInTheDocument();
});
});
});
it("shows near-duplicate banner and keeps warning on Keep click", async () => {
const { updateTask } = await import("../../api");
const mockUpdateTask = vi.mocked(updateTask);
mockUpdateTask.mockResolvedValueOnce(makeTask({
id: "FN-099",
sourceMetadata: { nearDuplicateOf: "FN-1234", nearDuplicateDismissed: true },
}));
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
tasks={[makeTask({ id: "FN-1234" })]}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Potential duplicate detected")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Keep" }));
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-099", { dismissNearDuplicate: true }, undefined);
});
});
it("hides near-duplicate banner once dismissed", () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234", nearDuplicateDismissed: true } })}
tasks={[makeTask({ id: "FN-1234" })]}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText("Potential duplicate detected")).toBeNull();
});
it.each([
["archived", makeTask({ id: "FN-1234", column: "archived" })],
["done", makeTask({ id: "FN-1234", column: "done" })],
["missing", undefined],
])("hides near-duplicate decision banner when canonical is %s", (_label, canonical) => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
tasks={canonical ? [canonical] : []}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText("Potential duplicate detected")).toBeNull();
expect(screen.queryByRole("button", { name: "Archive" })).toBeNull();
expect(screen.queryByRole("button", { name: "Keep" })).toBeNull();
});
it("archives from near-duplicate banner when confirmed", async () => {
const onArchiveTask = vi.fn().mockResolvedValue(makeTask({ column: "archived" }));
mockConfirm.mockResolvedValueOnce(true);
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
tasks={[makeTask({ id: "FN-1234" })]}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onArchiveTask={onArchiveTask}
addToast={noop}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Archive" }));
await waitFor(() => {
expect(onArchiveTask).toHaveBeenCalledWith("FN-099");
});
});
it("deletes a triage-marker duplicate through the shared delete API", async () => {
const onDeleteTask = vi.fn().mockResolvedValue(makeTask());
mockConfirm.mockResolvedValueOnce(true);
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234", duplicateSource: "triage-marker" } })}
tasks={[makeTask({ id: "FN-1234" })]}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={onDeleteTask}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByRole("status")).toHaveTextContent("Choose Delete to remove this duplicate, or Keep to continue anyway.");
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-099", { removeLineageReferences: true });
});
});
it("renders corrected stats timing totals in Stats tab", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
const task: Task = {
id: "FN-206",
description: "Stats timing regression",
column: "done",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
} as Task;
mockFetch.mockResolvedValueOnce({
...task,
prompt: "# Async Spec\n\nStats timing regression.",
executionStartedAt: "2026-05-15T13:10:00.000Z",
executionCompletedAt: "2026-05-15T13:14:00.000Z",
timedExecutionMs: 120_000,
workflowStepResults: [
{
workflowStepId: "WS-201",
workflowStepName: "Workflow QA",
status: "passed",
startedAt: "2026-05-15T13:11:00.000Z",
completedAt: "2026-05-15T13:12:00.000Z",
},
],
} as TaskDetail);
render(
<TaskDetailModal
initialTab="definition"
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await waitFor(() => {
expect(screen.queryByText("Loading specification…")).toBeNull();
});
fireEvent.click(screen.getByRole("button", { name: "Stats" }));
await waitFor(() => {
const metric = screen.getByText("Total execution time").closest(".task-token-stats-panel__metric");
expect(metric).toHaveTextContent("4m 0s");
});
});
describe("Coding (Ideas) workflow detail moves", () => {
const ideasWorkflowPayload = {
flagEnabled: true,
defaultWorkflowId: "builtin:coding-ideas",
workflows: [{
id: "builtin:coding-ideas",
name: "Coding (Ideas)",
columns: [
{ id: "ideas", name: "Ideas", flags: { intake: true } },
{ id: "todo", name: "Todo", flags: {} },
],
}],
taskWorkflowIds: { "FN-ideas": "builtin:coding-ideas" },
};
const ideasTask = () => makeTask({ id: "FN-ideas", column: "ideas", title: "Parked coding idea" });
const setViewport = (width: number) => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
window.dispatchEvent(new Event("resize"));
};
beforeEach(() => {
vi.mocked(dashboardApi.fetchBoardWorkflows).mockReset();
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValue(ideasWorkflowPayload);
});
afterEach(() => {
setViewport(1024);
});
async function expectIdeasMove(renderDetail: () => void, onMoveTask: ReturnType<typeof vi.fn>) {
renderDetail();
const moveButton = await screen.findByRole("button", { name: "Move to Todo" });
expect(moveButton).toBeEnabled();
await userEvent.click(moveButton);
expect(onMoveTask).toHaveBeenCalledWith("FN-ideas", "todo", undefined);
}
// FNXC:TaskDetailModal 2026-08-15-00:00 (slow-test trim): the modal/embedded x
// desktop/375px matrix is deliberate surface enumeration (Standing Rule: fix the
// invariant across all surfaces) — all four cases are KEPT, condensed to it.each.
it.each([
["the modal at desktop width", "modal", 1024],
["the modal at 375px", "modal", 375],
["embedded detail at desktop width", "embedded", 1024],
["embedded detail at 375px", "embedded", 375],
] as const)("moves an Ideas task to Todo from %s", async (_label, surface, width) => {
setViewport(width);
const onMoveTask = vi.fn(async () => ideasTask());
await expectIdeasMove(() => {
if (surface === "modal") {
render(<TaskDetailModal initialTab="definition" task={ideasTask()} onClose={noop} onMoveTask={onMoveTask} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
} else {
render(<TaskDetailContent embedded initialTab="definition" task={ideasTask()} onMoveTask={onMoveTask} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
}
}, onMoveTask);
});
it("keeps supplied fields while independently resolving and clearing Ideas move metadata", async () => {
const onMoveTask = vi.fn(async () => ideasTask());
const workflowFieldDefs = [{ id: "owner", name: "Owner", type: "string" as const, render: { placement: "detail" as const } }];
const { rerender } = render(
<TaskDetailModal initialTab="definition" task={ideasTask()} workflowFieldDefs={workflowFieldDefs} onClose={noop} onMoveTask={onMoveTask} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />,
);
expect(await screen.findByLabelText("Owner")).toBeInTheDocument();
const moveButton = await screen.findByRole("button", { name: "Move to Todo" });
await userEvent.click(moveButton);
expect(onMoveTask).toHaveBeenCalledWith("FN-ideas", "todo", undefined);
expect(dashboardApi.fetchBoardWorkflows).toHaveBeenCalledTimes(1);
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValueOnce({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} });
rerender(
<TaskDetailModal initialTab="definition" task={makeTask({ id: "FN-unresolved", column: "ideas" })} workflowFieldDefs={workflowFieldDefs} onClose={noop} onMoveTask={onMoveTask} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />,
);
await waitFor(() => expect(dashboardApi.fetchBoardWorkflows).toHaveBeenCalledTimes(2));
expect(screen.queryByRole("button", { name: "Move to Todo" })).toBeNull();
});
});
});