Files
fusion/packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx
Fusion 188cb64c3d test(FN-4489): complete Step 3 — assert no-commits toggle render order
Fusion-Task-Id: FN-4489
Fusion-Task-Lineage: 9f732400-0deb-4537-be92-cc58c815e9fd
2026-05-14 10:38:22 -07:00

2780 lines
98 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { useState } from "react";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Task } from "@fusion/core";
import {
makeTask,
noop,
noopDelete,
noopMerge,
noopMove,
noopOpenDetail,
mockConfirm,
mockUsePluginUiSlots,
expectBaseRule,
readDashboardStylesSource,
setupTaskDetailModalHooks,
} from "./TaskDetailModal.test-helpers";
import { loadAllAppCss } from "../../test/cssFixture";
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
setupTaskDetailModalHooks();
function getMediaBlocks(css: string, mediaQuery: string): string[] {
const blocks: string[] = [];
let searchFrom = 0;
while (searchFrom < css.length) {
const mediaStart = css.indexOf(mediaQuery, searchFrom);
if (mediaStart === -1) {
break;
}
const blockStart = css.indexOf("{", mediaStart);
if (blockStart === -1) {
break;
}
let depth = 1;
let index = blockStart + 1;
while (index < css.length && depth > 0) {
const char = css[index];
if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
}
index += 1;
}
if (depth === 0) {
blocks.push(css.slice(blockStart + 1, index - 1));
searchFrom = index;
} else {
break;
}
}
return blocks;
}
function getRuleBlock(css: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const ruleMatch = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
return ruleMatch?.[1] ?? "";
}
describe("TaskDetailModal", () => {
describe("source issue metadata", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders source issue collapsed by default and expands details on toggle", async () => {
const user = userEvent.setup();
render(
<TaskDetailModal
task={makeTask({
sourceIssue: {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "I_kgDOExample",
issueNumber: 2473,
url: "https://github.com/runfusion/fusion/issues/2473",
},
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Source issue")).toBeTruthy();
expect(screen.getByLabelText("GitHub source issue")).toBeTruthy();
const summaryIssueLink = screen.getByRole("link", { name: "(#2473)" });
expect(summaryIssueLink).toHaveAttribute(
"href",
"https://github.com/runfusion/fusion/issues/2473",
);
expect(summaryIssueLink.classList.contains("detail-source-link--summary")).toBe(true);
expect(screen.queryByText("Provider")).toBeNull();
const toggle = screen.getByRole("button", { name: "Expand source issue details" });
expect(toggle).toHaveAttribute("aria-expanded", "false");
const chevron = toggle.querySelector("svg");
expect(chevron?.classList.contains("detail-source-chevron--expanded")).toBe(false);
await user.click(toggle);
expect(screen.getByRole("button", { name: "Collapse source issue details" })).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("Provider")).toBeTruthy();
expect(screen.getByText("github")).toBeTruthy();
expect(screen.getByText("runfusion/fusion")).toBeTruthy();
const sourceLink = screen.getByRole("link", { name: "https://github.com/runfusion/fusion/issues/2473" });
expect(sourceLink).toHaveAttribute("href", "https://github.com/runfusion/fusion/issues/2473");
expect(sourceLink).toHaveAttribute("target", "_blank");
const expandedChevron = screen.getByRole("button", { name: "Collapse source issue details" }).querySelector("svg");
expect(expandedChevron?.classList.contains("detail-source-chevron--expanded")).toBe(true);
});
it("applies compact GitHub source summary styling contracts", () => {
const css = readDashboardStylesSource();
expectBaseRule(css, ".detail-source-provider-badge", "border-radius: var(--radius-pill);");
expectBaseRule(css, ".detail-source-provider-badge", "background: color-mix(in srgb, var(--text-muted) 18%, transparent);");
expectBaseRule(css, ".detail-source-link--summary", "text-decoration: none;");
expectBaseRule(css, ".detail-source-section .detail-source-grid", "margin-top: var(--space-sm);");
expectBaseRule(css, ".detail-source-section .detail-source-grid", "padding-top: var(--space-sm);");
});
it("does not render GitHub badge for non-github providers", () => {
render(
<TaskDetailModal
task={makeTask({
sourceIssue: {
provider: "gitlab",
repository: "runfusion/fusion",
externalIssueId: "42",
issueNumber: 42,
url: "https://gitlab.com/runfusion/fusion/-/issues/42",
},
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByLabelText("GitHub source issue")).toBeNull();
expect(screen.getByRole("link", { name: "(#42)" })).toBeTruthy();
});
it("hides source issue read section when sourceIssue metadata is missing", () => {
render(
<TaskDetailModal
task={makeTask({ sourceIssue: undefined })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText("Source issue")).toBeNull();
expect(screen.queryByText("No source issue metadata recorded.")).toBeNull();
});
it("prefills source issue inputs in edit mode", async () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "todo",
sourceIssue: {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "I_kgDOExample",
issueNumber: 2473,
url: "https://github.com/runfusion/fusion/issues/2473",
},
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
await waitFor(() => {
expect((screen.getByTestId("task-source-provider-input") as HTMLInputElement).value).toBe("github");
});
expect((screen.getByTestId("task-source-repository-input") as HTMLInputElement).value).toBe("runfusion/fusion");
expect((screen.getByTestId("task-source-external-id-input") as HTMLInputElement).value).toBe("I_kgDOExample");
expect((screen.getByTestId("task-source-url-input") as HTMLInputElement).value).toBe("https://github.com/runfusion/fusion/issues/2473");
});
it("renders source issue block below Model Configuration in edit mode", async () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "todo",
sourceIssue: {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "I_kgDOExample",
issueNumber: 2473,
},
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
await waitFor(() => {
const modelLabel = screen.getByText("Model Configuration");
const sourceLabel = screen.getByText("Source Issue");
const workflowSection = screen.getByTestId("workflow-steps-section");
expect(
modelLabel.compareDocumentPosition(sourceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(
sourceLabel.compareDocumentPosition(workflowSection) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
});
it("sends sourceIssue payload when source metadata is edited", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "todo",
sourceIssue: {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "I_kgDOExample",
issueNumber: 2473,
url: "https://github.com/runfusion/fusion/issues/2473",
},
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByTestId("task-source-provider-input"), { target: { value: "gitlab" } });
fireEvent.change(screen.getByTestId("task-source-repository-input"), { target: { value: "runfusion/dashboard" } });
fireEvent.change(screen.getByTestId("task-source-external-id-input"), { target: { value: "I_kgDONew" } });
fireEvent.change(screen.getByTestId("task-source-url-input"), { target: { value: "https://gitlab.com/runfusion/dashboard/-/issues/2473" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({
sourceIssue: {
provider: "gitlab",
repository: "runfusion/dashboard",
externalIssueId: "I_kgDONew",
issueNumber: 2473,
url: "https://gitlab.com/runfusion/dashboard/-/issues/2473",
},
}), undefined);
});
});
it("sends sourceIssue: null when all source metadata fields are cleared", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "todo",
sourceIssue: {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "I_kgDOExample",
issueNumber: 2473,
url: "https://github.com/runfusion/fusion/issues/2473",
},
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByTestId("task-source-provider-input"), { target: { value: "" } });
fireEvent.change(screen.getByTestId("task-source-repository-input"), { target: { value: "" } });
fireEvent.change(screen.getByTestId("task-source-external-id-input"), { target: { value: "" } });
fireEvent.change(screen.getByTestId("task-source-url-input"), { target: { value: "" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({ sourceIssue: null }), undefined);
});
});
it("keeps edit mode active and shows error toast when source metadata save fails", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockRejectedValueOnce(new Error("source patch failed"));
const addToast = vi.fn();
const { container } = render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "todo",
sourceIssue: {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "I_kgDOExample",
issueNumber: 2473,
},
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(screen.getByTestId("task-source-provider-input"), { target: { value: "gitlab" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to update FN-001: source patch failed", "error");
});
expect(container.querySelector("#task-form-title")).toBeTruthy();
});
});
describe("inline editing", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows Edit button in header when task is in triage column", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test task" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const editButton = container.querySelector(".modal-edit-btn");
expect(editButton).toBeTruthy();
});
it("shows Edit button in header when task is in todo column", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", title: "Test task" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const editButton = container.querySelector(".modal-edit-btn");
expect(editButton).toBeTruthy();
});
it("does not show Edit button when task is in in-progress column", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "in-progress", title: "Test task" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const editButton = container.querySelector(".modal-edit-btn");
expect(editButton).toBeNull();
});
it("does not show Edit button when already in edit mode", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test task" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
const editButton = container.querySelector(".modal-edit-btn");
expect(editButton).toBeTruthy();
fireEvent.click(editButton!);
// Edit button should be hidden now
expect(container.querySelector(".modal-edit-btn")).toBeNull();
// But TaskForm title input should be visible
expect(container.querySelector("#task-form-title")).toBeTruthy();
});
it("entering edit mode shows title input and description textarea", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test task", description: "Test description" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Initially shows title as h2
expect(container.querySelector("h2.detail-title")).toBeTruthy();
expect(container.querySelector("#task-form-title")).toBeNull();
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
// Now shows edit form with TaskForm fields
expect(container.querySelector("h2.detail-title")).toBeNull();
expect(container.querySelector("#task-form-title")).toBeTruthy();
expect(container.querySelector("#task-form-description")).toBeTruthy();
});
it("clicking Cancel exits edit mode without saving", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Original title", description: "Original description" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
// Change values
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "Modified title" } });
// Click Cancel
fireEvent.click(screen.getByText("Cancel"));
// Should exit edit mode without saving
expect(container.querySelector("#task-form-title")).toBeNull();
expect(container.querySelector("h2.detail-title")?.textContent).toBe("Original title");
});
it("clicking Save calls updateTask with correct parameters", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Original title", description: "Original description" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
// Change values
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
const descTextarea = container.querySelector("#task-form-description") as HTMLTextAreaElement;
fireEvent.change(titleInput, { target: { value: "New title" } });
fireEvent.change(descTextarea, { target: { value: "New description" } });
// Click Save
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({
title: "New title",
description: "New description",
}), undefined);
});
});
it("Save button is enabled in edit mode", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test title", description: "Test description" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
const saveButton = screen.getByText("Save");
expect(saveButton.hasAttribute("disabled")).toBe(false);
});
it("Save button shows 'Saving…' during save operation", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
// Delay the resolution to keep isSaving true
mockUpdate.mockImplementationOnce(() => new Promise(resolve => setTimeout(() => resolve({ id: "FN-001" } as Task), 100)));
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Original" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "Changed title" } });
// Click Save
fireEvent.click(screen.getByText("Save"));
// Should show "Saving…" immediately
expect(screen.getByText("Saving…")).toBeTruthy();
});
it("successful save shows toast and exits edit mode", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
const addToast = vi.fn();
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Original" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "Changed title" } });
// Click Save
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Updated FN-001", "success");
});
// Should exit edit mode
expect(container.querySelector("#task-form-title")).toBeNull();
});
it("failed save shows toast with error and stays in edit mode", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockRejectedValueOnce(new Error("Network error"));
const addToast = vi.fn();
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Original" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "Changed title" } });
// Click Save
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to update FN-001: Network error", "error");
});
// Should stay in edit mode
expect(container.querySelector("#task-form-title")).toBeTruthy();
});
it("Escape key exits edit mode", async () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test title" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
expect(container.querySelector("#task-form-title")).toBeTruthy();
// Press Escape (handled via document-level keydown listener)
await act(async () => {
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
document.dispatchEvent(event);
});
// Should exit edit mode
expect(container.querySelector("#task-form-title")).toBeNull();
});
it("edit mode shows both title and description fields", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test title", description: "Test description" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
// Both title and description should be present in TaskForm
expect(container.querySelector("#task-form-title")).toBeTruthy();
expect(container.querySelector("#task-form-description")).toBeTruthy();
});
it("edit mode renders model configuration and workflow steps", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test task" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
// Model configuration and workflow steps should be present via TaskForm
expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
expect(screen.getByText(/Workflow Steps/i)).toBeTruthy();
});
it("save sends only changed fields via updateTask", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test", description: "Desc", dependencies: ["FN-002"] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
const descTextarea = container.querySelector("#task-form-description") as HTMLTextAreaElement;
fireEvent.change(descTextarea, { target: { value: "Updated desc" } });
// Click Save
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", {
description: "Updated desc",
}, undefined);
});
});
it("includes priority in update payload only when changed", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValue({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test", description: "Desc", priority: "normal" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).not.toHaveBeenCalled();
});
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
fireEvent.change(container.querySelector("#task-priority") as HTMLSelectElement, { target: { value: "urgent" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { priority: "urgent" }, undefined);
});
});
it("sends executionMode: \"fast\" when changed from standard to fast", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValue({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test", description: "Desc", executionMode: "standard" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.change(screen.getByTestId("task-form-execution-mode-select"), { target: { value: "fast" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: "fast" }, undefined);
});
});
it("sends executionMode: null when changed from fast to standard", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValue({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test", description: "Desc", executionMode: "fast" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.change(screen.getByTestId("task-form-execution-mode-select"), { target: { value: "standard" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: null }, undefined);
});
});
it("omits executionMode from update payload when unchanged", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValue({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test", description: "Desc", executionMode: "fast" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).not.toHaveBeenCalled();
});
});
it("renders normalized priority in detail metadata", async () => {
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", description: "Priority metadata", priority: undefined })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const prioritySelect = screen.getByRole("combobox", { name: "Task priority" }) as HTMLSelectElement;
expect(prioritySelect.value).toBe("normal");
});
it("renders priority select and execution mode toggle together and keeps both interactive", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate
.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "todo", priority: "urgent", executionMode: "standard" }) as Task)
.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "todo", priority: "urgent", executionMode: "fast" }) as Task);
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", priority: "high", executionMode: "standard" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const controls = screen.getByTestId("detail-meta-inline-controls");
const prioritySelect = screen.getByRole("combobox", { name: "Task priority" });
const executionModeToggle = screen.getByRole("button", { name: "Execution mode: standard" });
expect(prioritySelect.parentElement).toBe(controls.firstElementChild);
expect(executionModeToggle.parentElement).toBe(controls);
fireEvent.change(prioritySelect, {
target: { value: "urgent" },
});
fireEvent.click(executionModeToggle);
await waitFor(() => {
expect(mockUpdate).toHaveBeenNthCalledWith(1, "FN-001", { priority: "urgent" }, undefined);
expect(mockUpdate).toHaveBeenNthCalledWith(2, "FN-001", { executionMode: "fast" }, undefined);
});
});
it("updates priority inline and propagates successful save without moving triage tasks", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
const onTaskUpdated = vi.fn();
const addToast = vi.fn();
const updatedTask = makeTask({
id: "FN-001",
column: "triage",
status: "awaiting-approval",
priority: "urgent",
});
mockUpdate.mockResolvedValueOnce(updatedTask as Task);
render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "triage",
status: "awaiting-approval",
description: "Priority metadata",
priority: "normal",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onTaskUpdated={onTaskUpdated}
addToast={addToast}
/>,
);
fireEvent.change(screen.getByRole("combobox", { name: "Task priority" }), {
target: { value: "urgent" },
});
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { priority: "urgent" }, undefined);
});
expect(onTaskUpdated).toHaveBeenCalledWith(expect.objectContaining({
id: "FN-001",
column: "triage",
status: "awaiting-approval",
priority: "urgent",
}));
expect(addToast).toHaveBeenCalledWith("Priority updated to urgent", "success");
});
it("does not call updateTask when inline priority is unchanged", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", description: "Priority metadata", priority: "high" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.change(screen.getByRole("combobox", { name: "Task priority" }), {
target: { value: "high" },
});
await waitFor(() => {
expect(mockUpdate).not.toHaveBeenCalled();
});
});
it("reverts inline priority when save fails", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
const addToast = vi.fn();
mockUpdate.mockRejectedValueOnce(new Error("Request failed"));
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", description: "Priority metadata", priority: "low" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
const prioritySelect = screen.getByRole("combobox", { name: "Task priority" }) as HTMLSelectElement;
fireEvent.change(prioritySelect, { target: { value: "urgent" } });
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { priority: "urgent" }, undefined);
});
await waitFor(() => {
expect(prioritySelect.value).toBe("low");
});
expect(addToast).toHaveBeenCalledWith("Failed to update FN-001: Request failed", "error");
});
it("toggles inline execution mode from standard to fast", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
const addToast = vi.fn();
const onTaskUpdated = vi.fn();
const updatedTask = makeTask({ id: "FN-001", column: "todo", executionMode: "fast" });
mockUpdate.mockResolvedValueOnce(updatedTask as Task);
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", executionMode: "standard" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onTaskUpdated={onTaskUpdated}
addToast={addToast}
/>,
);
const toggle = screen.getByRole("button", { name: "Execution mode: standard" });
fireEvent.click(toggle);
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: "fast" }, undefined);
});
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
expect(addToast).toHaveBeenCalledWith("Execution mode updated to fast", "success");
await waitFor(() => {
expect(screen.getByRole("button", { name: "Execution mode: fast" })).toHaveAttribute("aria-pressed", "true");
});
});
it("toggles inline execution mode from fast to standard", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "todo", executionMode: null }) as Task);
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", executionMode: "fast" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Execution mode: fast" }));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: null }, undefined);
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "Execution mode: standard" })).toHaveAttribute("aria-pressed", "false");
});
});
it("reverts inline execution mode when save fails", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
const addToast = vi.fn();
mockUpdate.mockRejectedValueOnce(new Error("Request failed"));
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", executionMode: "standard" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Execution mode: standard" }));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: "fast" }, undefined);
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "Execution mode: standard" })).toHaveAttribute("aria-pressed", "false");
});
expect(addToast).toHaveBeenCalledWith("Failed to update FN-001: Request failed", "error");
});
it("disables inline execution mode toggle while save is in-flight", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockImplementationOnce(
() => new Promise((resolve) => setTimeout(() => resolve(makeTask({ executionMode: "fast" }) as Task), 100)),
);
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", executionMode: "standard" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const toggle = screen.getByRole("button", { name: "Execution mode: standard" });
fireEvent.click(toggle);
expect(toggle).toBeDisabled();
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: "fast" }, undefined);
});
});
it("renders no-commits-expected toggle after plan and before attachments", () => {
render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "todo",
noCommitsExpected: false,
prompt: "# FN-001\n\n## Plan\n\nPlan marker text for ordering assertion.",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const planMarker = screen.getByText("Plan marker text for ordering assertion.");
const noCommitsCheckbox = screen.getByLabelText("No commits expected (decision-only task)");
const attachmentsHeading = screen.getByRole("heading", { name: "Attachments" });
const noCommitsWrapper = noCommitsCheckbox.closest(".detail-section");
expect(noCommitsWrapper).toBeTruthy();
expect(planMarker.compareDocumentPosition(noCommitsWrapper!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(noCommitsWrapper!.compareDocumentPosition(attachmentsHeading)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
it("toggles no-commits-expected checkbox and patches task", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "todo", noCommitsExpected: true }) as Task);
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", noCommitsExpected: false })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByLabelText("No commits expected (decision-only task)"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { noCommitsExpected: true }, undefined);
});
});
it("pre-populates form with existing task values", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", title: "My Task", description: "My Description" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
const titleInput = container.querySelector("#task-form-title") as HTMLInputElement;
const descTextarea = container.querySelector("#task-form-description") as HTMLTextAreaElement;
expect(titleInput.value).toBe("My Task");
expect(descTextarea.value).toBe("My Description");
});
it("pre-populates working/base branch inputs and saves changed branch only", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", branch: "feature/fn-3422", baseBranch: "develop" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
const workingBranchInput = container.querySelector("#task-working-branch") as HTMLInputElement;
const baseBranchInput = container.querySelector("#task-base-branch") as HTMLInputElement;
expect(workingBranchInput.value).toBe("feature/fn-3422");
expect(baseBranchInput.value).toBe("develop");
fireEvent.change(workingBranchInput, { target: { value: "feature/fn-3422-updated" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { branch: "feature/fn-3422-updated" }, undefined);
});
});
it("saves changed baseBranch independently of branch", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", branch: "feature/fn-3422", baseBranch: "develop" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.change(container.querySelector("#task-base-branch") as HTMLInputElement, { target: { value: "release/2026-05" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { baseBranch: "release/2026-05" }, undefined);
});
});
it("sends null branch fields when working/base branches are cleared", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", branch: "feature/fn-3422", baseBranch: "main" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
fireEvent.change(container.querySelector("#task-working-branch") as HTMLInputElement, { target: { value: "" } });
fireEvent.change(container.querySelector("#task-base-branch") as HTMLInputElement, { target: { value: "" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith(
"FN-001",
expect.objectContaining({ branch: null, baseBranch: null }),
undefined,
);
});
});
it("propagates auto-saved description updates via onTaskUpdated", async () => {
const { updateTask } = await import("../../api");
const mockUpdateTask = vi.mocked(updateTask);
const onTaskUpdated = vi.fn();
const initialTask = makeTask({
id: "FN-001",
column: "todo",
title: "My Task",
description: "Old Description",
});
const updatedTask = {
...initialTask,
description: "New Description",
};
mockUpdateTask.mockResolvedValueOnce(updatedTask);
const { container } = render(
<TaskDetailModal
task={initialTask}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onTaskUpdated={onTaskUpdated}
addToast={noop}
/>,
);
fireEvent.click(container.querySelector(".modal-edit-btn")!);
const descTextarea = container.querySelector("#task-form-description") as HTMLTextAreaElement;
fireEvent.change(descTextarea, { target: { value: "New Description" } });
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", { description: "New Description" }, undefined);
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
}, { timeout: 3500 });
});
it("uses updated model values in edit mode after saving from the Model tab", async () => {
const { fetchModels, updateTask } = await import("../../api");
const mockFetchModels = vi.mocked(fetchModels);
const mockUpdateTask = vi.mocked(updateTask);
const user = userEvent.setup();
const availableModels = [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
];
mockFetchModels.mockResolvedValue({
models: availableModels,
favoriteProviders: [],
favoriteModels: [],
});
const initialTask = makeTask({ id: "FN-001", column: "triage", title: "Model sync test" });
const updatedAfterExecutor: Task = {
...initialTask,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
};
const updatedAfterValidator: Task = {
...updatedAfterExecutor,
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
};
mockUpdateTask
.mockResolvedValueOnce(updatedAfterExecutor)
.mockResolvedValueOnce(updatedAfterValidator);
function StatefulModal() {
const [task, setTask] = useState<TaskDetail>(initialTask);
return (
<TaskDetailModal
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onTaskUpdated={(updated) => setTask((prev) => ({ ...prev, ...updated }))}
addToast={noop}
/>
);
}
const { container } = render(<StatefulModal />);
await user.click(screen.getByText("Model"));
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
await user.click(screen.getByLabelText("Executor Model"));
await user.click(screen.getByText("Claude Sonnet 4.5"));
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenNthCalledWith(
1,
"FN-001",
expect.objectContaining({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
}),
);
});
await user.click(screen.getByLabelText("Reviewer Model"));
await user.click(screen.getByText("GPT-4o"));
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenNthCalledWith(
2,
"FN-001",
{
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
},
);
});
fireEvent.click(container.querySelector(".modal-edit-btn")!);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toHaveTextContent("Claude Sonnet 4.5");
expect(screen.getByLabelText("Reviewer Model")).toHaveTextContent("GPT-4o");
});
});
it("renders Save and Cancel in the modal footer, not inside the edit form body", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test task" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
// The edit form body should NOT contain the Save or Cancel action buttons
const editForm = container.querySelector(".modal-edit-form");
expect(editForm).toBeTruthy();
const formButtons = Array.from(editForm!.querySelectorAll("button"));
const formButtonTexts = formButtons.map((b) => b.textContent);
expect(formButtonTexts).not.toContain("Save");
expect(formButtonTexts).not.toContain("Cancel");
expect(formButtonTexts).not.toContain("Saving…");
// The modal-actions footer should contain the Save and Cancel buttons
const modalActions = container.querySelector(".modal-actions");
expect(modalActions).toBeTruthy();
const footerButtons = modalActions!.querySelectorAll("button");
const buttonTexts = Array.from(footerButtons).map((b) => b.textContent);
expect(buttonTexts).toContain("Cancel");
expect(buttonTexts).toContain("Save");
});
it("renders keyboard hint in the modal footer when editing", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "triage", title: "Test task" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
// The hint should be in the modal-actions footer, not inside the edit form body
const editForm = container.querySelector(".modal-edit-form");
expect(editForm!.querySelector(".modal-edit-hint")).toBeNull();
const modalActions = container.querySelector(".modal-actions");
expect(modalActions!.querySelector(".modal-edit-hint")).toBeTruthy();
});
it("shows normal modal actions (not edit actions) when not editing", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", title: "Test task" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Should NOT be in edit mode — no edit hint, no Save/Cancel in footer
const modalActions = container.querySelector(".modal-actions");
expect(modalActions!.querySelector(".modal-edit-hint")).toBeNull();
const footerButtons = modalActions!.querySelectorAll("button");
const buttonTexts = Array.from(footerButtons).map((b) => b.textContent);
expect(buttonTexts).not.toContain("Save");
expect(buttonTexts).not.toContain("Cancel");
// Should contain Actions dropdown and Move primary action
expect(buttonTexts).toContain("Actions");
expect(buttonTexts.some((t) => t?.includes("Move to"))).toBe(true);
});
});
describe("comment state propagation (FN-845)", () => {
it("passes onTaskUpdated to TaskComments when provided", async () => {
const { addSteeringComment } = await import("../../api");
const onTaskUpdated = vi.fn();
const updatedTask = makeTask({
comments: [{ id: "c1", text: "New comment", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }],
});
vi.mocked(addSteeringComment).mockResolvedValueOnce(updatedTask);
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onTaskUpdated={onTaskUpdated}
addToast={noop}
/>,
);
// Switch to Comments tab
fireEvent.click(screen.getByText("Comments"));
// Add a comment
fireEvent.change(screen.getByPlaceholderText(/Add a comment/), { target: { value: "New comment" } });
fireEvent.click(screen.getByText("Add Comment"));
await waitFor(() => {
expect(addSteeringComment).toHaveBeenCalledWith("FN-099", "New comment", undefined);
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
});
});
it("comment mutations still work when onTaskUpdated is not provided", async () => {
const { addSteeringComment } = await import("../../api");
const addToast = vi.fn();
vi.mocked(addSteeringComment).mockResolvedValueOnce(makeTask({
comments: [{ id: "c1", text: "Hello", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }],
}));
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
// Switch to Comments tab
fireEvent.click(screen.getByText("Comments"));
// Add a comment — should succeed without error even without onTaskUpdated
fireEvent.change(screen.getByPlaceholderText(/Add a comment/), { target: { value: "Hello" } });
fireEvent.click(screen.getByText("Add Comment"));
await waitFor(() => {
expect(addSteeringComment).toHaveBeenCalledWith("FN-099", "Hello", undefined);
expect(addToast).toHaveBeenCalledWith("Comment added", "success");
});
});
});
describe("Workflow step ordering in edit mode (FN-836)", () => {
it("sends ordered enabledWorkflowSteps when saving with reordered steps", async () => {
const { updateTask, fetchWorkflowSteps } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
]);
const { container } = render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "triage",
title: "Test",
description: "Desc",
enabledWorkflowSteps: ["WS-001", "WS-002"],
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Enter edit mode
fireEvent.click(container.querySelector(".modal-edit-btn")!);
// Wait for workflow steps to load and reorder controls to appear
await waitFor(() => {
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
});
// Move WS-002 up (swap with WS-001)
fireEvent.click(screen.getByTestId("workflow-step-move-up-WS-002"));
// Save
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({
enabledWorkflowSteps: ["WS-002", "WS-001"],
}), undefined);
});
});
});
describe("agent assignment", () => {
it("shows Assign Agent button when task has no assigned agent", () => {
render(
<TaskDetailModal
task={makeTask({ assignedAgentId: undefined })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByRole("button", { name: "Assign Agent" })).toBeInTheDocument();
});
it("shows assigned agent chip and clear button when task has assignedAgentId", async () => {
const { fetchAgent } = await import("../../api");
vi.mocked(fetchAgent).mockResolvedValue({
id: "agent-002",
name: "Pipeline Helper",
role: "executor",
state: "active",
metadata: {},
heartbeatHistory: [],
completedRuns: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
} as any);
render(
<TaskDetailModal
task={makeTask({ assignedAgentId: "agent-002" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await waitFor(() => {
expect(screen.getByText("Pipeline Helper")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Unassign agent" })).toBeInTheDocument();
});
});
it("assigns selected agent via assignTask", async () => {
const { fetchAgents, assignTask } = await import("../../api");
vi.mocked(fetchAgents).mockResolvedValue([
{
id: "agent-001",
name: "Task Runner",
role: "executor",
state: "active",
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
] as any);
vi.mocked(assignTask).mockResolvedValue(makeTask({ assignedAgentId: "agent-001" }) as any);
render(
<TaskDetailModal
task={makeTask({ assignedAgentId: undefined })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Assign Agent" }));
await userEvent.click(screen.getByRole("button", { name: /Task Runner/i }));
await waitFor(() => {
expect(assignTask).toHaveBeenCalledWith("FN-099", "agent-001", undefined);
});
});
it("clears assigned agent via assignTask(null)", async () => {
const { fetchAgent, assignTask } = await import("../../api");
vi.mocked(fetchAgent).mockResolvedValue({
id: "agent-005",
name: "Doc Bot",
role: "executor",
state: "active",
metadata: {},
heartbeatHistory: [],
completedRuns: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
} as any);
vi.mocked(assignTask).mockResolvedValue(makeTask({ assignedAgentId: undefined }) as any);
render(
<TaskDetailModal
task={makeTask({ assignedAgentId: "agent-005" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Unassign agent" }));
await waitFor(() => {
expect(assignTask).toHaveBeenCalledWith("FN-099", null, undefined);
});
});
});
describe("optimistic opening with Task", () => {
beforeEach(async () => {
const { fetchTaskDetail } = await import("../../api");
vi.mocked(fetchTaskDetail).mockReset();
});
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
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Modal renders immediately without crashing
expect(container.querySelector(".modal-overlay")).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
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("FN-201", undefined);
});
});
it("does NOT call fetchTaskDetail when prop is already a TaskDetail with prompt", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(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
task={detail}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Give a tick for any async operations
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockFetch).not.toHaveBeenCalledWith("FN-202", undefined);
});
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
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);
expect(screen.getByText("executing")).toBeInTheDocument();
});
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,
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
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 = container.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);
expect(screen.getByText("executing")).toBeInTheDocument();
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 = container.querySelector('time[datetime="2026-04-24T09:00:00.000Z"]');
const lastUsed = container.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
task={strippedTask}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Wait for fetchTaskDetail to resolve.
await waitFor(() => {
expect(container.querySelector(".markdown-body")).toBeTruthy();
}, { timeout: 3000 });
fireEvent.click(screen.getByText("Logs"));
const activityList = container.querySelector(".detail-activity-list");
expect(activityList).toBeTruthy();
const logEntries = container.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
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();
});
});
});
describe("PluginSlot integration", () => {
it("renders plugin tabs when plugins register for task-detail-tab slot", async () => {
mockUsePluginUiSlots.mockReturnValue({
slots: [
{ pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } },
{ pluginId: "plugin-b", slot: { slotId: "task-detail-tab", label: "Plugin B Tab", componentPath: "./b.js" } },
],
getSlotsForId: (id: string) => id === "task-detail-tab" ? [
{ pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } },
{ pluginId: "plugin-b", slot: { slotId: "task-detail-tab", label: "Plugin B Tab", componentPath: "./b.js" } },
] : [],
loading: false,
error: null,
} as any);
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onOpenDetail={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>
);
// Both plugin tabs should appear
expect(screen.getByText("Plugin A Tab")).toBeDefined();
expect(screen.getByText("Plugin B Tab")).toBeDefined();
});
it("shows only the selected plugin tab content when plugin tab is clicked", async () => {
mockUsePluginUiSlots.mockReturnValue({
slots: [
{ pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } },
{ pluginId: "plugin-b", slot: { slotId: "task-detail-tab", label: "Plugin B Tab", componentPath: "./b.js" } },
],
getSlotsForId: (id: string) => id === "task-detail-tab" ? [
{ pluginId: "plugin-a", slot: { slotId: "task-detail-tab", label: "Plugin A Tab", componentPath: "./a.js" } },
{ pluginId: "plugin-b", slot: { slotId: "task-detail-tab", label: "Plugin B Tab", componentPath: "./b.js" } },
] : [],
loading: false,
error: null,
} as any);
const { container } = render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onOpenDetail={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>
);
await userEvent.click(screen.getByText("Plugin B Tab"));
const slots = container.querySelectorAll('[data-slot-id="task-detail-tab"]');
expect(slots).toHaveLength(1);
expect(slots[0]).toHaveAttribute("data-plugin-id", "plugin-b");
expect(container.querySelector('[data-plugin-id="plugin-a"]')).toBeNull();
});
it("renders no extra tabs when no plugins register", () => {
mockUsePluginUiSlots.mockReturnValue({
slots: [],
getSlotsForId: vi.fn(() => []),
loading: false,
error: null,
});
render(
<TaskDetailModal
task={makeTask()}
onClose={noop}
onOpenDetail={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>
);
// Only standard tabs should be visible (Definition, Logs, etc.)
expect(screen.getByText("Definition")).toBeDefined();
expect(screen.getByText("Logs")).toBeDefined();
// Plugin tabs should not exist
expect(screen.queryByText("Plugin A Tab")).toBeNull();
});
});
describe("github tracking section", () => {
const expandGithubTracking = () => {
fireEvent.click(screen.getByRole("button", { name: "Expand GitHub tracking details" }));
};
it("renders linked issue as link when url exists", () => {
render(
<TaskDetailModal
task={makeTask({
githubTracking: {
enabled: true,
issue: {
owner: "runfusion",
repo: "fusion",
number: 123,
url: "https://github.com/runfusion/fusion/issues/123",
createdAt: "2026-01-01T00:00:00Z",
},
},
issueInfo: { url: "https://github.com/runfusion/fusion/issues/123", number: 123, state: "open", title: "Issue" },
})}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
expect(screen.getByText("GitHub tracking")).toBeTruthy();
expect(screen.getByLabelText("GitHub tracking status")).toHaveTextContent("Linked");
expect(screen.queryByRole("link", { name: "runfusion/fusion#123" })).toBeNull();
expandGithubTracking();
expect(screen.getByRole("button", { name: "Collapse GitHub tracking details" })).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("link", { name: "runfusion/fusion#123" })).toHaveAttribute("href", "https://github.com/runfusion/fusion/issues/123");
});
it("preserves fetched githubTracking detail when the optimistic task prop came from a slim restart listing", async () => {
const { fetchTaskDetail } = await import("../../api");
vi.mocked(fetchTaskDetail).mockResolvedValueOnce(
makeTask({
id: "FN-301",
column: "todo",
prompt: "# Spec",
githubTracking: {
enabled: true,
repoOverride: "runfusion/fusion",
issue: {
owner: "runfusion",
repo: "fusion",
number: 301,
url: "https://github.com/runfusion/fusion/issues/301",
createdAt: "2026-01-01T00:00:00Z",
},
},
}),
);
const optimisticTask = makeTask({ id: "FN-301", column: "todo" }) as Task;
delete (optimisticTask as Partial<Task>).prompt;
delete (optimisticTask as Partial<Task>).githubTracking;
render(
<TaskDetailModal
task={optimisticTask}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
await waitFor(() => {
// FN-4161 repro: the optimistic task prop came from a slim restart listing,
// so fetched full detail must win when the prop omits githubTracking.
expect(screen.getByLabelText("GitHub tracking status")).toHaveTextContent("Linked");
});
expandGithubTracking();
expect(screen.getByDisplayValue("runfusion/fusion")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "runfusion/fusion#301" })).toHaveAttribute("href", "https://github.com/runfusion/fusion/issues/301");
});
it("shows section when tracking is disabled and task is in an eligible column", () => {
render(
<TaskDetailModal
task={makeTask({ column: "todo", githubTracking: { enabled: false } })}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
expect(screen.getByText("GitHub tracking")).toBeTruthy();
expect(screen.getByText("Tracking is currently disabled")).toBeTruthy();
expect(screen.getByRole("button", { name: "Enable GitHub tracking" })).toHaveTextContent("Enable");
});
it("FN-4228 shows loading state instead of disabled CTA while detail fetch is unresolved", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
let resolveFetch: ((value: TaskDetail) => void) | undefined;
mockFetch.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFetch = resolve;
}),
);
const optimisticTask = makeTask({ id: "FN-001", column: "todo" });
delete (optimisticTask as Partial<Task>).prompt;
delete (optimisticTask as Partial<Task>).githubTracking;
render(
<TaskDetailModal
task={optimisticTask}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
expect(screen.getByLabelText("GitHub tracking status")).toHaveTextContent("Loading");
expect(screen.getByText("Checking tracking status")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Enable GitHub tracking" })).toBeNull();
expect(screen.getByRole("status", { name: "Loading GitHub tracking status" })).toBeInTheDocument();
await act(async () => {
resolveFetch?.({
...(makeTask({ id: "FN-001", column: "todo" }) as TaskDetail),
prompt: "# Spec",
githubTracking: { enabled: false },
});
});
await waitFor(() => {
expect(screen.getByLabelText("GitHub tracking status")).toHaveTextContent("Disabled");
});
expect(screen.getByText("Tracking is currently disabled")).toBeInTheDocument();
expect(screen.queryByRole("status", { name: "Loading GitHub tracking status" })).toBeNull();
expect(screen.getByRole("button", { name: "Enable GitHub tracking" })).toBeInTheDocument();
});
it("enables GitHub tracking via the inline header button without expanding the disclosure", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
const addToast = vi.fn();
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "todo",
githubTracking: {
enabled: false,
},
})}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={addToast}
/>,
);
const expandButton = screen.getByRole("button", { name: "Expand GitHub tracking details" });
expect(expandButton).toHaveAttribute("aria-expanded", "false");
fireEvent.click(screen.getByRole("button", { name: "Enable GitHub tracking" }));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { githubTracking: { enabled: true } }, undefined);
});
expect(addToast).not.toHaveBeenCalledWith(expect.stringContaining("Failed to update FN-001"), "error");
expect(screen.getByRole("button", { name: "Expand GitHub tracking details" })).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole("button", { name: "Collapse GitHub tracking details" })).toBeNull();
});
it("FN-4228 keeps the inline enable button mounted and disabled while saving", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
let resolveUpdate: ((task: Task) => void) | undefined;
mockUpdate.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveUpdate = resolve;
}),
);
render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "todo",
githubTracking: {
enabled: false,
},
})}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Enable GitHub tracking" }));
const enableButton = await screen.findByRole("button", { name: "Enable GitHub tracking" });
expect(enableButton).toBeDisabled();
expect(screen.getByRole("status", { name: "Enabling GitHub tracking" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Expand GitHub tracking details" })).toHaveAttribute("aria-expanded", "false");
resolveUpdate?.({
id: "FN-001",
githubTracking: {
enabled: true,
issue: {
number: 42,
title: "Tracked issue",
url: "https://github.com/runfusion/fusion/issues/42",
state: "open",
},
},
} as Task);
await waitFor(() => {
expect(screen.queryByRole("status", { name: "Enabling GitHub tracking" })).toBeNull();
});
});
it("hides the inline enable button when tracking is already enabled", () => {
render(
<TaskDetailModal
task={makeTask({ column: "todo", githubTracking: { enabled: true } })}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
expect(screen.queryByRole("button", { name: "Enable GitHub tracking" })).toBeNull();
expect(screen.getByRole("button", { name: "Expand GitHub tracking details" })).toBeInTheDocument();
});
it("hides the inline enable button when an issue is already linked", () => {
render(
<TaskDetailModal
task={makeTask({
column: "todo",
githubTracking: {
enabled: false,
issue: {
owner: "runfusion",
repo: "fusion",
number: 456,
url: "https://github.com/runfusion/fusion/issues/456",
createdAt: "2026-01-01T00:00:00Z",
},
},
})}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
expect(screen.queryByRole("button", { name: "Enable GitHub tracking" })).toBeNull();
expect(screen.getByRole("button", { name: "Expand GitHub tracking details" })).toBeInTheDocument();
});
it("mobile layout keeps the enable button in source order without width forcing at the 768px breakpoint", () => {
const css = loadAllAppCss();
const mobileCss = getMediaBlocks(css, "@media (max-width: 768px)").join("\n");
const enableRule = getRuleBlock(mobileCss, ".detail-github-tracking-enable");
const headerRule = getRuleBlock(mobileCss, ".detail-source-header");
const githubSummaryRule = getRuleBlock(mobileCss, ".detail-github-tracking-section .detail-source-summary");
const sourceSummaryRule = getRuleBlock(mobileCss, ".detail-source-section .detail-source-summary");
expect(mobileCss).toBeTruthy();
expect(enableRule).toBeTruthy();
expect(headerRule).toBeTruthy();
expect(githubSummaryRule).toBeTruthy();
expect(sourceSummaryRule).toBeTruthy();
expect(enableRule).not.toMatch(/\border\s*:/);
expect(enableRule).not.toMatch(/\bwidth\s*:\s*100%/);
expect(headerRule).toMatch(/\bflex-wrap\s*:\s*wrap/);
expect(githubSummaryRule).toMatch(/\bflex\s*:\s*1\s+1\s+auto/);
expect(githubSummaryRule).toMatch(/\bmin-width\s*:\s*0/);
expect(githubSummaryRule).not.toMatch(/\bflex\s*:\s*1\s+1\s+100%/);
expect(githubSummaryRule).not.toMatch(/\bflex-basis\s*:\s*100%/);
expect(githubSummaryRule).not.toMatch(/\bwidth\s*:\s*100%/);
expect(sourceSummaryRule).toMatch(/\bflex\s*:\s*1\s+1\s+auto/);
});
it("hides section when tracking is disabled and task is not in an eligible column", () => {
render(
<TaskDetailModal
task={makeTask({ column: "done", githubTracking: { enabled: false } })}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
expect(screen.queryByText("GitHub tracking")).toBeNull();
});
it("shows create tracking issue action for enabled but unlinked tasks outside editable columns", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
const onTaskUpdated = vi.fn();
const addToast = vi.fn();
const updatedTask = makeTask({
id: "FN-001",
column: "done",
githubTracking: {
enabled: true,
issue: {
owner: "runfusion",
repo: "fusion",
number: 77,
url: "https://github.com/runfusion/fusion/issues/77",
createdAt: "2026-01-01T00:00:00Z",
},
},
});
mockUpdate.mockResolvedValueOnce(updatedTask as Task);
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "done", githubTracking: { enabled: true } })}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onTaskUpdated={onTaskUpdated}
addToast={addToast}
/>,
);
expandGithubTracking();
fireEvent.click(screen.getByRole("button", { name: "Create tracking issue" }));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { githubTracking: { enabled: true } }, undefined);
});
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
expect(addToast).toHaveBeenCalledWith("Requested GitHub tracking issue creation", "info");
expect(screen.queryByLabelText("Enable GitHub tracking")).toBeNull();
});
it("sends githubTracking disabled→enabled toggle payload", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "todo",
githubTracking: {
enabled: false,
issue: {
owner: "runfusion",
repo: "fusion",
number: 99,
url: "https://github.com/runfusion/fusion/issues/99",
createdAt: "2026-01-01T00:00:00Z",
},
},
})}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
expandGithubTracking();
const toggle = screen.getByLabelText("Enable GitHub tracking") as HTMLInputElement;
expect(toggle.checked).toBe(false);
fireEvent.click(toggle);
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { githubTracking: { enabled: true } }, undefined);
});
expect(toggle.checked).toBe(true);
});
it("sends githubTracking enabled→disabled toggle payload", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "in-progress",
githubTracking: {
enabled: true,
},
})}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
expandGithubTracking();
fireEvent.click(screen.getByLabelText("Enable GitHub tracking"));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { githubTracking: { enabled: false } }, undefined);
});
});
it("sends repo override updates and null when cleared", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValue({ id: "FN-001" } as Task);
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", githubTracking: { enabled: true, repoOverride: "runfusion/fusion" } })}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
expandGithubTracking();
fireEvent.change(screen.getByPlaceholderText("owner/repo"), { target: { value: "runfusion/cli" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { githubTracking: { repoOverride: "runfusion/cli" } }, undefined);
});
fireEvent.change(screen.getByPlaceholderText("owner/repo"), { target: { value: " " } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { githubTracking: { repoOverride: null } }, undefined);
});
});
it("unlinks issue after confirm and skips on cancel", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValue({ id: "FN-001" } as Task);
mockConfirm.mockResolvedValueOnce(false);
render(
<TaskDetailModal
task={makeTask({
id: "FN-001",
column: "todo",
githubTracking: {
enabled: true,
issue: {
owner: "runfusion",
repo: "fusion",
number: 200,
url: "https://github.com/runfusion/fusion/issues/200",
createdAt: "2026-01-01T00:00:00Z",
},
},
})}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
expandGithubTracking();
fireEvent.click(screen.getByRole("button", { name: "Unlink GitHub issue" }));
await waitFor(() => {
expect(mockUpdate).not.toHaveBeenCalledWith("FN-001", { githubTracking: { issue: null } }, undefined);
});
mockConfirm.mockResolvedValueOnce(true);
fireEvent.click(screen.getByRole("button", { name: "Unlink GitHub issue" }));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { githubTracking: { issue: null } }, undefined);
});
});
});
});