- Add CSS custom properties and modifier classes for all status colors (open, closed, merged, draft, review, in-progress, etc.) - Remove hardcoded hex colors from GitHubBadge, replace with CSS classes - Remove hardcoded colors from PrSection, use CSS classes and Lucide icons - Remove COLUMN_COLOR_MAP and inline styles from TaskCard, use CSS classes - Add theme-safety regression tests ensuring components never use inline color styles - Update existing GitHubBadge and TaskCard tests for class-based styling
4195 lines
143 KiB
TypeScript
4195 lines
143 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
|
import { TaskDetailModal } from "../TaskDetailModal";
|
|
import type { TaskDetail, Column, MergeResult, Task } from "@fusion/core";
|
|
|
|
vi.mock("../../api", () => ({
|
|
uploadAttachment: vi.fn(),
|
|
deleteAttachment: vi.fn(),
|
|
updateTask: vi.fn().mockResolvedValue({}),
|
|
fetchTaskDetail: vi.fn(),
|
|
fetchAgentLogs: vi.fn().mockResolvedValue([]),
|
|
requestSpecRevision: vi.fn().mockResolvedValue({}),
|
|
approvePlan: vi.fn().mockResolvedValue({}),
|
|
rejectPlan: vi.fn().mockResolvedValue({}),
|
|
duplicateTask: vi.fn().mockResolvedValue({}),
|
|
refineTask: vi.fn().mockResolvedValue({}),
|
|
addSteeringComment: vi.fn(),
|
|
// TaskForm dependencies
|
|
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [] }),
|
|
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
|
|
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
|
refineText: vi.fn(),
|
|
getRefineErrorMessage: vi.fn((err: any) => err?.message || "Failed to refine"),
|
|
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
|
pauseTask: vi.fn().mockResolvedValue({}),
|
|
unpauseTask: vi.fn().mockResolvedValue({}),
|
|
fetchWorkflowResults: vi.fn().mockResolvedValue([]),
|
|
}));
|
|
|
|
// Mock lucide-react icons used by TaskDetailModal, TaskForm, PrSection, CustomModelDropdown
|
|
vi.mock("lucide-react", () => ({
|
|
Pencil: () => null,
|
|
Sparkles: () => null,
|
|
Globe: () => null,
|
|
GitPullRequest: () => null,
|
|
ExternalLink: () => null,
|
|
RefreshCw: () => null,
|
|
Plus: () => null,
|
|
MessageSquare: () => null,
|
|
ChevronUp: () => null,
|
|
ChevronDown: () => null,
|
|
X: () => null,
|
|
CircleDot: () => null,
|
|
XCircle: () => null,
|
|
GitMerge: () => null,
|
|
}));
|
|
|
|
vi.mock("../../hooks/useAgentLogs", () => ({
|
|
useAgentLogs: vi.fn(() => ({ entries: [], loading: false, clear: vi.fn() })),
|
|
}));
|
|
|
|
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
|
return {
|
|
id: "FN-099",
|
|
description: "Test task",
|
|
column: "in-progress" as Column,
|
|
dependencies: [],
|
|
prompt: "",
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: "2026-01-01T00:00:00Z",
|
|
updatedAt: "2026-01-01T00:00:00Z",
|
|
...overrides,
|
|
} as TaskDetail;
|
|
}
|
|
|
|
const noop = vi.fn();
|
|
const noopMove = vi.fn(async () => ({}) as Task);
|
|
const noopDelete = vi.fn(async () => ({}) as Task);
|
|
const noopMerge = vi.fn(async () => ({ merged: false }) as MergeResult);
|
|
const noopRetry = vi.fn(async () => ({}) as Task);
|
|
const noopOpenDetail = vi.fn();
|
|
|
|
describe("TaskDetailModal", () => {
|
|
it("renders markdown-body without detail-prompt class when prompt exists", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Hello\n\nSome **bold** text" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const markdownDiv = container.querySelector(".markdown-body");
|
|
expect(markdownDiv).toBeTruthy();
|
|
expect(markdownDiv!.classList.contains("detail-prompt")).toBe(false);
|
|
});
|
|
|
|
it("strips the leading heading from prompt and renders remaining markdown", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Hello\n\nSome **bold** text" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// The leading # heading should be stripped (modal has its own header)
|
|
expect(container.querySelector(".markdown-body h1")).toBeNull();
|
|
expect(container.querySelector("strong")?.textContent).toBe("bold");
|
|
});
|
|
|
|
it("renders (no prompt) with detail-prompt class when prompt is absent", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
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("does not render a PROMPT.md heading", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Some prompt content" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText("PROMPT.md")).toBeNull();
|
|
});
|
|
|
|
it("renders Comments tab", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Comments")).toBeTruthy();
|
|
});
|
|
|
|
it("renders Retry button when task status is 'failed'", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ status: "failed" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
onRetryTask={noopRetry}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Retry")).toBeTruthy();
|
|
});
|
|
|
|
it("does NOT render Retry button when task status is not 'failed'", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ status: "executing" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
onRetryTask={noopRetry}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText("Retry")).toBeNull();
|
|
});
|
|
|
|
it("does NOT render Retry button when onRetryTask is not provided", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ status: "failed" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText("Retry")).toBeNull();
|
|
});
|
|
|
|
it("shows description exactly once for a task without title", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
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 = container.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(container.querySelector(".detail-id")?.textContent).toBe("FN-099");
|
|
// The h2 title shows description, not the task ID
|
|
const h2 = container.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
|
|
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 = container.querySelector("h2.detail-title");
|
|
expect(h2?.textContent).toBe("Implement dark mode");
|
|
});
|
|
|
|
it("always shows task.id in the detail-id badge regardless of title", () => {
|
|
// With title
|
|
const { container: withTitle } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ title: "Some title" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
expect(withTitle.querySelector(".detail-id")?.textContent).toBe("FN-099");
|
|
|
|
// Without title
|
|
const { container: withoutTitle } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ title: undefined, description: "A description" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
expect(withoutTitle.querySelector(".detail-id")?.textContent).toBe("FN-099");
|
|
});
|
|
|
|
describe("paste image upload", () => {
|
|
it("uploads an image when pasting clipboard image data", async () => {
|
|
const { uploadAttachment } = await import("../../api");
|
|
const mockUpload = vi.mocked(uploadAttachment);
|
|
const mockAttachment = {
|
|
filename: "abc123.png",
|
|
originalName: "image.png",
|
|
size: 1024,
|
|
mimeType: "image/png",
|
|
createdAt: "2026-01-01T00:00:00Z",
|
|
};
|
|
mockUpload.mockResolvedValueOnce(mockAttachment);
|
|
const addToast = vi.fn();
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={addToast}
|
|
/>,
|
|
);
|
|
|
|
const imageFile = new File(["fake-image"], "image.png", { type: "image/png" });
|
|
const pasteEvent = new Event("paste", { bubbles: true }) as any;
|
|
pasteEvent.clipboardData = {
|
|
items: [
|
|
{
|
|
type: "image/png",
|
|
getAsFile: () => imageFile,
|
|
},
|
|
],
|
|
};
|
|
|
|
await act(async () => {
|
|
document.dispatchEvent(pasteEvent);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpload).toHaveBeenCalledWith("FN-099", imageFile, undefined);
|
|
expect(addToast).toHaveBeenCalledWith("Screenshot attached", "success");
|
|
});
|
|
});
|
|
|
|
it("does not intercept paste events without image data", async () => {
|
|
const { uploadAttachment } = await import("../../api");
|
|
const mockUpload = vi.mocked(uploadAttachment);
|
|
mockUpload.mockClear();
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const pasteEvent = new Event("paste", { bubbles: true }) as any;
|
|
pasteEvent.clipboardData = {
|
|
items: [
|
|
{
|
|
type: "text/plain",
|
|
getAsFile: () => null,
|
|
},
|
|
],
|
|
};
|
|
|
|
await act(async () => {
|
|
document.dispatchEvent(pasteEvent);
|
|
});
|
|
|
|
expect(mockUpload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows uploading state during paste upload", async () => {
|
|
const { uploadAttachment } = await import("../../api");
|
|
const mockUpload = vi.mocked(uploadAttachment);
|
|
let resolveUpload!: (value: any) => void;
|
|
mockUpload.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveUpload = resolve;
|
|
}),
|
|
);
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const imageFile = new File(["fake"], "shot.png", { type: "image/png" });
|
|
const pasteEvent = new Event("paste", { bubbles: true }) as any;
|
|
pasteEvent.clipboardData = {
|
|
items: [{ type: "image/png", getAsFile: () => imageFile }],
|
|
};
|
|
|
|
act(() => {
|
|
document.dispatchEvent(pasteEvent);
|
|
});
|
|
|
|
// While uploading, button should show "Uploading…"
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Uploading…")).toBeTruthy();
|
|
});
|
|
|
|
await act(async () => {
|
|
resolveUpload({
|
|
filename: "x.png",
|
|
originalName: "shot.png",
|
|
size: 100,
|
|
mimeType: "image/png",
|
|
createdAt: "2026-01-01T00:00:00Z",
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Attach Screenshot")).toBeTruthy();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("drag and drop image upload", () => {
|
|
it("uploads an image when dropped onto the modal", async () => {
|
|
const { uploadAttachment } = await import("../../api");
|
|
const mockUpload = vi.mocked(uploadAttachment);
|
|
const mockAttachment = {
|
|
filename: "drop123.png",
|
|
originalName: "dropped.png",
|
|
size: 2048,
|
|
mimeType: "image/png",
|
|
createdAt: "2026-01-01T00:00:00Z",
|
|
};
|
|
mockUpload.mockResolvedValueOnce(mockAttachment);
|
|
const addToast = vi.fn();
|
|
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={addToast}
|
|
/>,
|
|
);
|
|
|
|
const modal = container.querySelector(".modal.modal-lg")!;
|
|
const imageFile = new File(["fake-image"], "dropped.png", { type: "image/png" });
|
|
|
|
await act(async () => {
|
|
fireEvent.drop(modal, {
|
|
dataTransfer: {
|
|
files: [imageFile],
|
|
},
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpload).toHaveBeenCalledWith("FN-099", imageFile, undefined);
|
|
expect(addToast).toHaveBeenCalledWith("Screenshot attached", "success");
|
|
});
|
|
});
|
|
});
|
|
|
|
it("renders (no dependencies) when dependencies is empty", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ dependencies: [] })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("(no dependencies)")).toBeTruthy();
|
|
});
|
|
|
|
it("renders dependency list when dependencies exist", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ dependencies: ["FN-001", "FN-002"] })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("FN-001")).toBeTruthy();
|
|
expect(screen.getByText("FN-002")).toBeTruthy();
|
|
expect(screen.queryByText("(no dependencies)")).toBeNull();
|
|
});
|
|
|
|
it("can add a dependency via the dropdown", async () => {
|
|
const { updateTask } = await import("../../api");
|
|
const allTasks: Task[] = [
|
|
{ id: "FN-001", description: "Dep 1", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
|
|
{ id: "FN-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
|
|
];
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ dependencies: [] })}
|
|
tasks={allTasks}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
// Should show KB-001 in the dropdown but not KB-099 (self is excluded)
|
|
const dropdown = document.querySelector(".dep-dropdown")!;
|
|
expect(dropdown).toBeTruthy();
|
|
expect(dropdown.textContent).toContain("FN-001");
|
|
expect(dropdown.querySelectorAll(".dep-dropdown-item")).toHaveLength(1);
|
|
|
|
fireEvent.click(screen.getByText("FN-001"));
|
|
|
|
await waitFor(() => {
|
|
expect(updateTask).toHaveBeenCalledWith("FN-099", { dependencies: ["FN-001"] }, undefined);
|
|
});
|
|
});
|
|
|
|
it("can remove a dependency", async () => {
|
|
const { updateTask } = await import("../../api");
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ dependencies: ["FN-001", "FN-002"] })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const removeButtons = screen.getAllByTitle(/Remove dependency/);
|
|
fireEvent.click(removeButtons[0]); // Remove KB-001
|
|
|
|
await waitFor(() => {
|
|
expect(updateTask).toHaveBeenCalledWith("FN-099", { dependencies: ["FN-002"] }, undefined);
|
|
});
|
|
});
|
|
|
|
it("activity list does not have nested scroll constraints", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
log: [
|
|
{ timestamp: "2026-01-01T00:00:00Z", action: "Created task" },
|
|
{ timestamp: "2026-01-01T00:01:00Z", action: "Started work" },
|
|
{ timestamp: "2026-01-01T00:02:00Z", action: "Completed step 1" },
|
|
],
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Click Logs tab — Activity is the default subview
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
|
|
const activityList = container.querySelector(".detail-activity-list");
|
|
expect(activityList).toBeTruthy();
|
|
const style = (activityList as HTMLElement).style;
|
|
expect(style.overflowY).not.toBe("auto");
|
|
expect(style.maxHeight).toBe("");
|
|
});
|
|
|
|
it("renders dependency dropdown items sorted newest-first by createdAt", () => {
|
|
const allTasks: Task[] = [
|
|
{ id: "FN-001", description: "Oldest", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
|
|
{ id: "FN-003", description: "Newest", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-01T00:00:00Z", updatedAt: "2026-03-01T00:00:00Z" },
|
|
{ id: "FN-002", description: "Middle", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-02-01T00:00:00Z", updatedAt: "2026-02-01T00:00:00Z" },
|
|
{ id: "FN-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-15T00:00:00Z", updatedAt: "2026-03-15T00:00:00Z" },
|
|
];
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ dependencies: [] })}
|
|
tasks={allTasks}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
const items = document.querySelectorAll(".dep-dropdown-item");
|
|
expect(items).toHaveLength(3);
|
|
|
|
const ids = Array.from(items).map((el) => el.querySelector(".dep-dropdown-id")?.textContent);
|
|
expect(ids).toEqual(["FN-003", "FN-002", "FN-001"]);
|
|
});
|
|
|
|
it("renders tasks with identical createdAt sorted newest-ID-first in dependency dropdown", () => {
|
|
const allTasks: Task[] = [
|
|
{ id: "FN-001", description: "First", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
|
|
{ id: "FN-002", description: "Second", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
|
|
{ id: "FN-003", description: "Third", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
|
|
{ id: "FN-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
|
|
];
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ dependencies: [] })}
|
|
tasks={allTasks}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
const items = document.querySelectorAll(".dep-dropdown-item");
|
|
expect(items).toHaveLength(3);
|
|
|
|
const ids = Array.from(items).map((el) => el.querySelector(".dep-dropdown-id")?.textContent);
|
|
expect(ids).toEqual(["FN-003", "FN-002", "FN-001"]);
|
|
});
|
|
|
|
describe("tab toggle", () => {
|
|
it("defaults to the Definition tab", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Hello\n\nContent" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Definition")).toBeTruthy();
|
|
expect(screen.getByText("Logs")).toBeTruthy();
|
|
// Activity and Agent Log are subviews inside the Logs tab, not top-level tabs
|
|
// They should NOT be visible on the Definition tab
|
|
expect(screen.queryByText("Activity")).toBeNull();
|
|
expect(screen.queryByText("Agent Log")).toBeNull();
|
|
// Definition content should be visible
|
|
expect(container.querySelector(".markdown-body")).toBeTruthy();
|
|
// Activity section should NOT be visible initially
|
|
expect(container.querySelector(".detail-activity")).toBeNull();
|
|
// Agent log viewer should not be visible
|
|
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull();
|
|
|
|
// After clicking Logs tab, the subview toggle buttons should appear
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
const logSubviewToggle = container.querySelector(".log-subview-toggle");
|
|
expect(logSubviewToggle).toBeTruthy();
|
|
expect(logSubviewToggle!.textContent).toContain("Activity");
|
|
expect(logSubviewToggle!.textContent).toContain("Agent Log");
|
|
});
|
|
|
|
it("switches to Activity subview via Logs tab and shows activity feed", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
prompt: "# Hello\n\nContent",
|
|
log: [
|
|
{ timestamp: "2026-01-01T00:00:00Z", action: "Created task" },
|
|
],
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Click Logs tab — Activity is the default subview
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
|
|
// Activity section should be visible
|
|
expect(container.querySelector(".detail-activity")).toBeTruthy();
|
|
// Activity list should be visible
|
|
expect(container.querySelector(".detail-activity-list")).toBeTruthy();
|
|
// Definition content should be hidden
|
|
expect(container.querySelector(".markdown-body")).toBeNull();
|
|
});
|
|
|
|
it("Activity subview renders log entries correctly", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
log: [
|
|
{ timestamp: "2026-01-01T00:00:00Z", action: "Created task" },
|
|
{ timestamp: "2026-01-01T00:01:00Z", action: "Started work", outcome: "Success" },
|
|
{ timestamp: "2026-01-01T00:02:00Z", action: "Completed step 1" },
|
|
],
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Click Logs tab — Activity is the default subview
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
|
|
const activityList = container.querySelector(".detail-activity-list");
|
|
expect(activityList).toBeTruthy();
|
|
|
|
// Check log entries are rendered (in reverse order - newest first)
|
|
const logEntries = container.querySelectorAll(".detail-log-entry");
|
|
expect(logEntries).toHaveLength(3);
|
|
|
|
// Most recent entry should be first
|
|
expect(logEntries[0].textContent).toContain("Completed step 1");
|
|
expect(logEntries[1].textContent).toContain("Started work");
|
|
expect(logEntries[1].textContent).toContain("Success"); // outcome
|
|
expect(logEntries[2].textContent).toContain("Created task");
|
|
});
|
|
|
|
it("Activity subview shows empty state when no logs", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ log: [] })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Click Logs tab — Activity is the default subview
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
|
|
// Activity section should be visible
|
|
expect(container.querySelector(".detail-activity")).toBeTruthy();
|
|
// Empty state should be shown
|
|
expect(container.querySelector(".detail-log-empty")).toBeTruthy();
|
|
expect(screen.getByText("(no activity)")).toBeTruthy();
|
|
// Activity list should NOT be present when empty
|
|
expect(container.querySelector(".detail-activity-list")).toBeNull();
|
|
});
|
|
|
|
it("can switch between all tabs and Logs subviews", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
prompt: "# Hello\n\nContent",
|
|
log: [{ timestamp: "2026-01-01T00:00:00Z", action: "Test" }],
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Start on Definition tab
|
|
expect(container.querySelector(".markdown-body")).toBeTruthy();
|
|
expect(container.querySelector(".detail-activity")).toBeNull();
|
|
|
|
// Switch to Logs tab (Activity subview is default)
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
expect(container.querySelector(".detail-activity")).toBeTruthy();
|
|
expect(container.querySelector(".markdown-body")).toBeNull();
|
|
|
|
// Switch to Agent Log subview within Logs tab
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeTruthy();
|
|
expect(container.querySelector(".detail-activity")).toBeNull();
|
|
|
|
// Switch back to Activity subview within Logs tab
|
|
fireEvent.click(screen.getByText("Activity"));
|
|
expect(container.querySelector(".detail-activity")).toBeTruthy();
|
|
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull();
|
|
|
|
// Switch to Comments tab
|
|
fireEvent.click(screen.getByText("Comments"));
|
|
expect(screen.getByPlaceholderText(/Add a comment/)).toBeTruthy();
|
|
expect(container.querySelector(".detail-activity")).toBeNull();
|
|
|
|
// Switch back to Definition tab
|
|
fireEvent.click(screen.getByText("Definition"));
|
|
expect(container.querySelector(".markdown-body")).toBeTruthy();
|
|
expect(container.querySelector(".detail-activity")).toBeNull();
|
|
|
|
});
|
|
|
|
it("switches to Agent Log subview via Logs tab and back", async () => {
|
|
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
|
const mockUseAgentLogs = vi.mocked(useAgentLogs);
|
|
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Hello\n\nContent" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Click Logs tab, then Agent Log subview
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
|
|
// Agent log viewer should appear
|
|
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeTruthy();
|
|
// Definition content should be hidden
|
|
expect(container.querySelector(".markdown-body")).toBeNull();
|
|
|
|
// Click Definition tab to go back
|
|
fireEvent.click(screen.getByText("Definition"));
|
|
|
|
// Definition content should reappear
|
|
expect(container.querySelector(".markdown-body")).toBeTruthy();
|
|
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull();
|
|
});
|
|
|
|
it("passes enabled=true to useAgentLogs only when Logs → Agent Log subview is active", async () => {
|
|
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
|
const mockUseAgentLogs = vi.mocked(useAgentLogs);
|
|
mockUseAgentLogs.mockClear();
|
|
|
|
const { rerender } = render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Default: Definition tab active → enabled should be false
|
|
const initialCall = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
|
|
expect(initialCall[1]).toBe(false);
|
|
|
|
// Switch to Logs tab (Activity subview is default) — enabled should still be false
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
const afterLogsClick = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
|
|
expect(afterLogsClick[1]).toBe(false);
|
|
|
|
// Switch to Agent Log subview — enabled should become true
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
const afterAgentLog = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
|
|
expect(afterAgentLog[1]).toBe(true);
|
|
});
|
|
|
|
it("switches to Comments tab", async () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Hello\n\nContent" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Click Comments tab
|
|
fireEvent.click(screen.getByText("Comments"));
|
|
|
|
// Comments content should appear
|
|
const headings = screen.getAllByText("Comments");
|
|
expect(headings.length).toBeGreaterThanOrEqual(1);
|
|
expect(screen.getByPlaceholderText(/Add a comment/)).toBeTruthy();
|
|
// Definition content should be hidden
|
|
expect(container.querySelector(".markdown-body")).toBeNull();
|
|
});
|
|
|
|
it("shows correct top-level tabs including Logs", async () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// For an in-progress task (no workflow steps, no merge commit),
|
|
// the top-level tabs are: Definition, Logs, Changes, Comments, Model, Workflow
|
|
const tabTexts = ["Definition", "Logs", "Changes", "Comments", "Model", "Workflow"];
|
|
const tabs = screen.getAllByRole("button").filter((b) =>
|
|
tabTexts.includes(b.textContent || "")
|
|
);
|
|
expect(tabs.length).toBe(6);
|
|
expect(tabs[0].textContent).toBe("Definition");
|
|
expect(tabs[1].textContent).toBe("Logs");
|
|
expect(tabs[2].textContent).toBe("Changes");
|
|
expect(tabs[3].textContent).toBe("Comments");
|
|
expect(tabs[4].textContent).toBe("Model");
|
|
expect(tabs[5].textContent).toBe("Workflow");
|
|
|
|
// Activity and Agent Log are NOT top-level tabs (they are subviews inside Logs)
|
|
expect(container.querySelectorAll(".detail-tab").length).toBe(6);
|
|
// Workflow tab should always appear even when no workflow steps are configured
|
|
expect(screen.getByText("Workflow")).toBeInTheDocument();
|
|
// Commits tab should NOT appear for non-done tasks
|
|
expect(screen.queryByText("Commits")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Agent Log full-height layout", () => {
|
|
it("applies detail-body--agent-log class when Logs → Agent Log subview is active", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Hello\n\nContent" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Initially, detail-body should NOT have the agent-log modifier
|
|
expect(container.querySelector(".detail-body--agent-log")).toBeNull();
|
|
|
|
// Switch to Logs tab, then Agent Log subview
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
expect(container.querySelector(".detail-body--agent-log")).toBeNull(); // Activity subview default
|
|
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
|
|
// detail-body should now have the agent-log modifier class
|
|
expect(container.querySelector(".detail-body--agent-log")).toBeTruthy();
|
|
|
|
// Switch back to Definition tab
|
|
fireEvent.click(screen.getByText("Definition"));
|
|
|
|
// modifier class should be removed
|
|
expect(container.querySelector(".detail-body--agent-log")).toBeNull();
|
|
});
|
|
|
|
it("wraps AgentLogViewer in detail-section--agent-log class", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Hello\n\nContent" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Switch to Logs tab, then Agent Log subview
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
|
|
// The section wrapping AgentLogViewer should have the full-height class
|
|
const section = container.querySelector(".detail-section--agent-log");
|
|
expect(section).toBeTruthy();
|
|
expect(section!.querySelector("[data-testid='agent-log-viewer']")).toBeTruthy();
|
|
});
|
|
|
|
it("does not apply detail-body--agent-log when editing", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ column: "triage", prompt: "# Hello\n\nContent" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Switch to Logs tab, then Agent Log subview first
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
expect(container.querySelector(".detail-body--agent-log")).toBeTruthy();
|
|
|
|
// Now enter edit mode via the pencil button in the header
|
|
const editBtn = screen.getByLabelText("Edit task");
|
|
fireEvent.click(editBtn);
|
|
|
|
// The detail-body--agent-log class should be removed while editing
|
|
expect(container.querySelector(".detail-body--agent-log")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Agent Log model resolution", () => {
|
|
// AgentLogViewer only renders the model header when entries.length > 0,
|
|
// so we mock useAgentLogs to return at least one entry.
|
|
const mockLogEntry = { timestamp: "2026-01-01T00:00:00Z", taskId: "FN-099", text: "hello", type: "text" as const };
|
|
|
|
async function setupModelTest(settingsOverrides: Record<string, any> = {}) {
|
|
const { fetchSettings } = await import("../../api");
|
|
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
|
|
|
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
|
modelPresets: [],
|
|
autoSelectModelPreset: false,
|
|
defaultPresetBySize: {},
|
|
...settingsOverrides,
|
|
} as any);
|
|
|
|
vi.mocked(useAgentLogs).mockReturnValue({
|
|
entries: [mockLogEntry],
|
|
loading: false,
|
|
clear: vi.fn(),
|
|
});
|
|
|
|
return render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Hello\n\nContent" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
async function setupModelTestWithTask(taskOverrides: Partial<TaskDetail>, settingsOverrides: Record<string, any> = {}) {
|
|
const { fetchSettings } = await import("../../api");
|
|
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
|
|
|
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
|
modelPresets: [],
|
|
autoSelectModelPreset: false,
|
|
defaultPresetBySize: {},
|
|
...settingsOverrides,
|
|
} as any);
|
|
|
|
vi.mocked(useAgentLogs).mockReturnValue({
|
|
entries: [mockLogEntry],
|
|
loading: false,
|
|
clear: vi.fn(),
|
|
});
|
|
|
|
return render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Hello\n\nContent", ...taskOverrides })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
it("shows resolved executor from settings when task has no explicit executor override", async () => {
|
|
const { container } = await setupModelTest({
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
});
|
|
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
|
|
await waitFor(() => {
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
|
expect(header).toBeTruthy();
|
|
expect(header!.textContent).toContain("anthropic/claude-sonnet-4-5");
|
|
});
|
|
|
|
// Validator should also fall back to the default
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
|
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
|
|
});
|
|
|
|
it("shows resolved validator from project validator settings when task has no validator override", async () => {
|
|
const { container } = await setupModelTest({
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
validatorProvider: "openai",
|
|
validatorModelId: "gpt-4o",
|
|
});
|
|
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
|
|
await waitFor(() => {
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
|
expect(header).toBeTruthy();
|
|
expect(header!.textContent).toContain("openai/gpt-4o");
|
|
});
|
|
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
|
// Executor falls back to default
|
|
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
|
|
// Validator uses the validator-specific setting
|
|
expect(header.textContent).toContain("openai/gpt-4o");
|
|
});
|
|
|
|
it("falls back to default settings for validator when no validator-specific setting exists", async () => {
|
|
const { container } = await setupModelTest({
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
// No validatorProvider or validatorModelId
|
|
});
|
|
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
|
|
await waitFor(() => {
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
|
expect(header).toBeTruthy();
|
|
// Both executor and validator should resolve to the default
|
|
expect(header!.textContent).toContain("anthropic/claude-sonnet-4-5");
|
|
});
|
|
|
|
// Count occurrences - should appear three times (once for executor, once for validator, once for planning)
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
|
const matches = header.textContent!.match(/anthropic\/claude-sonnet-4-5/g);
|
|
expect(matches).toHaveLength(3);
|
|
});
|
|
|
|
it("shows task executor override even when settings provide a default", async () => {
|
|
const { container } = await setupModelTestWithTask(
|
|
{ modelProvider: "openai", modelId: "gpt-4o" },
|
|
{ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" },
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
|
|
await waitFor(() => {
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
|
expect(header).toBeTruthy();
|
|
// Task override should win
|
|
expect(header!.textContent).toContain("openai/gpt-4o");
|
|
});
|
|
|
|
// Default model should not appear for executor
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
|
expect(header.textContent).toContain("openai/gpt-4o");
|
|
// Validator falls back to default
|
|
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
|
|
});
|
|
|
|
it("shows task validator override even when settings provide a validator default", async () => {
|
|
const { container } = await setupModelTestWithTask(
|
|
{ validatorModelProvider: "google", validatorModelId: "gemini-pro" },
|
|
{ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" },
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
|
|
await waitFor(() => {
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
|
expect(header).toBeTruthy();
|
|
// Task validator override should win
|
|
expect(header!.textContent).toContain("google/gemini-pro");
|
|
});
|
|
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
|
// Executor falls back to default
|
|
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
|
|
// Settings validator should not appear (task override wins)
|
|
expect(header.textContent).not.toContain("openai/gpt-4o");
|
|
});
|
|
|
|
it("shows 'Using default' for both when no models can be resolved", async () => {
|
|
const { container } = await setupModelTest({
|
|
// No defaultProvider/defaultModelId
|
|
});
|
|
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
|
|
await waitFor(() => {
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
|
expect(header).toBeTruthy();
|
|
});
|
|
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
|
expect(header.textContent).toContain("Using default");
|
|
// Should show "Using default" for executor, validator, and planning
|
|
const defaultBadges = header.querySelectorAll(".model-badge-default");
|
|
expect(defaultBadges).toHaveLength(3);
|
|
});
|
|
|
|
it("shows 'Using default' for both when settings fetch fails", async () => {
|
|
const { fetchSettings } = await import("../../api");
|
|
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
|
|
|
vi.mocked(fetchSettings).mockRejectedValueOnce(new Error("Network error"));
|
|
vi.mocked(useAgentLogs).mockReturnValue({
|
|
entries: [mockLogEntry],
|
|
loading: false,
|
|
clear: vi.fn(),
|
|
});
|
|
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Hello\n\nContent" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
|
|
// Wait for the failed fetch to settle
|
|
await waitFor(() => {
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
|
expect(header).toBeTruthy();
|
|
});
|
|
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
|
expect(header.textContent).toContain("Using default");
|
|
const defaultBadges = header.querySelectorAll(".model-badge-default");
|
|
expect(defaultBadges).toHaveLength(3);
|
|
});
|
|
|
|
it("shows partial override: task executor with settings-based validator", async () => {
|
|
const { container } = await setupModelTestWithTask(
|
|
{
|
|
modelProvider: "google",
|
|
modelId: "gemini-pro",
|
|
// No validator override — should use settings validator
|
|
},
|
|
{
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
validatorProvider: "openai",
|
|
validatorModelId: "gpt-4o",
|
|
},
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
|
|
await waitFor(() => {
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
|
expect(header).toBeTruthy();
|
|
expect(header!.textContent).toContain("google/gemini-pro");
|
|
});
|
|
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
|
// Executor uses task override
|
|
expect(header.textContent).toContain("google/gemini-pro");
|
|
// Validator uses settings-specific validator
|
|
expect(header.textContent).toContain("openai/gpt-4o");
|
|
});
|
|
|
|
// Planning/Triage model resolution tests
|
|
describe("Planning/Triage model resolution", () => {
|
|
it("shows planning model from runtime triage log marker", async () => {
|
|
const { fetchSettings } = await import("../../api");
|
|
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
|
|
|
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
|
modelPresets: [],
|
|
autoSelectModelPreset: false,
|
|
defaultPresetBySize: {},
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
} as any);
|
|
|
|
vi.mocked(useAgentLogs).mockReturnValue({
|
|
entries: [
|
|
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-099", text: "hello", type: "text" as const },
|
|
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-099", text: "Triage using model: google/gemini-pro", type: "text" as const, agent: "triage" },
|
|
],
|
|
loading: false,
|
|
clear: vi.fn(),
|
|
});
|
|
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Hello\n\nContent" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Navigate to Agent Log subview
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
|
|
await waitFor(() => {
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
|
expect(header).toBeTruthy();
|
|
// Planning should show the runtime triage marker, not settings default
|
|
expect(header!.textContent).toContain("Planning/Triage:");
|
|
expect(header!.textContent).toContain("google/gemini-pro");
|
|
});
|
|
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
|
// Executor/Validator should still show settings default
|
|
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
|
|
});
|
|
|
|
it("shows planning model from settings planningProvider when no runtime marker", async () => {
|
|
const { fetchSettings } = await import("../../api");
|
|
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
|
|
|
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
|
modelPresets: [],
|
|
autoSelectModelPreset: false,
|
|
defaultPresetBySize: {},
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
planningProvider: "openai",
|
|
planningModelId: "gpt-4o",
|
|
} as any);
|
|
|
|
vi.mocked(useAgentLogs).mockReturnValue({
|
|
entries: [mockLogEntry],
|
|
loading: false,
|
|
clear: vi.fn(),
|
|
});
|
|
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Hello\n\nContent" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Navigate to Agent Log subview
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
|
|
await waitFor(() => {
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
|
expect(header).toBeTruthy();
|
|
// Planning should use planningProvider/planningModelId from settings
|
|
expect(header!.textContent).toContain("Planning/Triage:");
|
|
expect(header!.textContent).toContain("openai/gpt-4o");
|
|
});
|
|
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
|
// Executor/Validator should show default
|
|
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
|
|
// Planning should NOT show the default
|
|
expect(header.textContent).toContain("openai/gpt-4o");
|
|
});
|
|
|
|
it("falls back to default settings for planning when no planning-specific setting exists", async () => {
|
|
const { container } = await setupModelTest({
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
});
|
|
|
|
// Navigate to Agent Log subview
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
|
|
await waitFor(() => {
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
|
expect(header).toBeTruthy();
|
|
expect(header!.textContent).toContain("Planning/Triage:");
|
|
expect(header!.textContent).toContain("anthropic/claude-sonnet-4-5");
|
|
});
|
|
|
|
// Planning falls back to default - same as executor/validator
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
|
const matches = header.textContent!.match(/anthropic\/claude-sonnet-4-5/g);
|
|
expect(matches).toHaveLength(3); // executor, validator, planning
|
|
});
|
|
|
|
it("shows 'Using default' for planning when no models can be resolved", async () => {
|
|
const { container } = await setupModelTest({
|
|
// No defaultProvider/defaultModelId
|
|
});
|
|
|
|
// Navigate to Agent Log subview
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
|
|
await waitFor(() => {
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
|
expect(header).toBeTruthy();
|
|
});
|
|
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
|
expect(header.textContent).toContain("Planning/Triage:");
|
|
const defaultBadges = header.querySelectorAll(".model-badge-default");
|
|
// 3 default badges: executor, validator, planning
|
|
expect(defaultBadges).toHaveLength(3);
|
|
});
|
|
|
|
it("runtime triage marker takes precedence over planningProvider settings", async () => {
|
|
const { fetchSettings } = await import("../../api");
|
|
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
|
|
|
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
|
modelPresets: [],
|
|
autoSelectModelPreset: false,
|
|
defaultPresetBySize: {},
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
planningProvider: "openai",
|
|
planningModelId: "gpt-4o",
|
|
} as any);
|
|
|
|
vi.mocked(useAgentLogs).mockReturnValue({
|
|
entries: [
|
|
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-099", text: "hello", type: "text" as const },
|
|
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-099", text: "Triage using model: google/gemini-pro", type: "text" as const, agent: "triage" },
|
|
],
|
|
loading: false,
|
|
clear: vi.fn(),
|
|
});
|
|
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Hello\n\nContent" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Navigate to Agent Log subview
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
|
|
await waitFor(() => {
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']");
|
|
expect(header).toBeTruthy();
|
|
// Runtime marker should win over planning settings
|
|
expect(header!.textContent).toContain("google/gemini-pro");
|
|
});
|
|
|
|
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
|
|
// Should NOT show the planning settings model
|
|
expect(header.textContent).not.toContain("openai/gpt-4o");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("step progress", () => {
|
|
it("renders step progress section when steps exist", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
steps: [
|
|
{ name: "Step 1", status: "done" },
|
|
{ name: "Step 2", status: "in-progress" },
|
|
],
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(container.querySelector(".detail-step-progress")).toBeTruthy();
|
|
expect(screen.getByText("Progress")).toBeTruthy();
|
|
});
|
|
|
|
it("shows '(no steps defined)' when steps array is empty", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ steps: [] })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(container.querySelector(".detail-step-progress")).toBeTruthy();
|
|
expect(screen.getByText("(no steps defined)")).toBeTruthy();
|
|
});
|
|
|
|
it("renders correct number of segments matching step count", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
steps: [
|
|
{ name: "Step 1", status: "done" },
|
|
{ name: "Step 2", status: "in-progress" },
|
|
{ name: "Step 3", status: "pending" },
|
|
],
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const segments = container.querySelectorAll(".step-progress-segment");
|
|
expect(segments).toHaveLength(3);
|
|
});
|
|
|
|
it("segments have correct status modifier classes", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
steps: [
|
|
{ name: "Step 1", status: "done" },
|
|
{ name: "Step 2", status: "in-progress" },
|
|
{ name: "Step 3", status: "pending" },
|
|
{ name: "Step 4", status: "skipped" },
|
|
],
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const segments = container.querySelectorAll(".step-progress-segment");
|
|
expect(segments[0].classList.contains("step-progress-segment--done")).toBe(true);
|
|
expect(segments[1].classList.contains("step-progress-segment--in-progress")).toBe(true);
|
|
expect(segments[2].classList.contains("step-progress-segment--pending")).toBe(true);
|
|
expect(segments[3].classList.contains("step-progress-segment--skipped")).toBe(true);
|
|
});
|
|
|
|
it("segments have correct inline background colors based on status", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
steps: [
|
|
{ name: "Step 1", status: "done" },
|
|
{ name: "Step 2", status: "in-progress" },
|
|
{ name: "Step 3", status: "pending" },
|
|
{ name: "Step 4", status: "skipped" },
|
|
],
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const segments = container.querySelectorAll(".step-progress-segment");
|
|
expect((segments[0] as HTMLElement).style.backgroundColor).toBe("var(--color-success, #3fb950)");
|
|
expect((segments[1] as HTMLElement).style.backgroundColor).toBe("var(--todo, #58a6ff)");
|
|
expect((segments[2] as HTMLElement).style.backgroundColor).toBe("var(--border, #30363d)");
|
|
expect((segments[3] as HTMLElement).style.backgroundColor).toBe("var(--text-dim, #484f58)");
|
|
});
|
|
|
|
it("displays correct completion count", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
steps: [
|
|
{ name: "Step 1", status: "done" },
|
|
{ name: "Step 2", status: "done" },
|
|
{ name: "Step 3", status: "pending" },
|
|
{ name: "Step 4", status: "in-progress" },
|
|
],
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("2/4 steps")).toBeTruthy();
|
|
});
|
|
|
|
it("has data-tooltip attribute with step name and status on each segment", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
steps: [
|
|
{ name: "Initialize project", status: "done" },
|
|
{ name: "Add tests", status: "in-progress" },
|
|
],
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const segments = container.querySelectorAll(".step-progress-segment");
|
|
expect(segments[0].getAttribute("data-tooltip")).toBe("Initialize project (done)");
|
|
expect(segments[1].getAttribute("data-tooltip")).toBe("Add tests (in-progress)");
|
|
});
|
|
|
|
it("step progress only renders in Definition tab, not in Agent Log subview", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
prompt: "# Test",
|
|
steps: [
|
|
{ name: "Step 1", status: "done" },
|
|
],
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Should be visible in Definition tab
|
|
expect(container.querySelector(".detail-step-progress")).toBeTruthy();
|
|
|
|
// Switch to Logs tab, then Agent Log subview
|
|
fireEvent.click(screen.getByText("Logs"));
|
|
fireEvent.click(screen.getByText("Agent Log"));
|
|
|
|
// Should not be visible in Agent Log subview
|
|
expect(container.querySelector(".detail-step-progress")).toBeNull();
|
|
});
|
|
|
|
it("step progress is hidden in Comments tab", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
prompt: "# Test",
|
|
steps: [
|
|
{ name: "Step 1", status: "done" },
|
|
],
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Switch to Comments tab
|
|
fireEvent.click(screen.getByText("Comments"));
|
|
|
|
// Should not be visible in Comments tab
|
|
expect(container.querySelector(".detail-step-progress")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("mobile responsive structure", () => {
|
|
it("modal container has both 'modal' and 'modal-lg' classes for responsive CSS targeting", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const modal = container.querySelector(".modal.modal-lg");
|
|
expect(modal).toBeTruthy();
|
|
});
|
|
|
|
it("modal overlay has 'modal-overlay' and 'open' classes", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const overlay = container.querySelector(".modal-overlay.open");
|
|
expect(overlay).toBeTruthy();
|
|
});
|
|
|
|
it("modal-actions contains the spacer div for flex layout", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ column: "in-progress" as Column })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const actions = container.querySelector(".modal-actions");
|
|
expect(actions).toBeTruthy();
|
|
// Spacer div separates left actions from right actions via CSS class
|
|
const spacer = actions!.querySelector(".modal-actions-spacer");
|
|
expect(spacer).toBeTruthy();
|
|
expect((spacer as HTMLElement).className).toContain("modal-actions-spacer");
|
|
});
|
|
|
|
it("tab buttons use CSS classes instead of inline styles for responsive override", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const tabs = container.querySelectorAll(".detail-tab");
|
|
expect(tabs.length).toBe(6); // Definition, Logs, Changes, Comments, Model, Workflow
|
|
// Tabs should use class-based styling, not inline styles
|
|
expect(tabs[0].classList.contains("detail-tab")).toBe(true);
|
|
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true); // Definition is default active
|
|
expect(tabs[1].classList.contains("detail-tab-active")).toBe(false);
|
|
expect(tabs[2].classList.contains("detail-tab-active")).toBe(false);
|
|
expect(tabs[3].classList.contains("detail-tab-active")).toBe(false);
|
|
expect(tabs[4].classList.contains("detail-tab-active")).toBe(false);
|
|
expect(tabs[5].classList.contains("detail-tab-active")).toBe(false);
|
|
// Verify no inline padding/fontSize (responsive CSS controls this)
|
|
expect((tabs[0] as HTMLElement).style.padding).toBe("");
|
|
expect((tabs[0] as HTMLElement).style.fontSize).toBe("");
|
|
});
|
|
|
|
it("detail-tabs container uses CSS class instead of inline styles", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const tabsContainer = container.querySelector(".detail-tabs");
|
|
expect(tabsContainer).toBeTruthy();
|
|
// Should not have inline display/borderBottom styles — CSS class handles it
|
|
expect((tabsContainer as HTMLElement).style.display).toBe("");
|
|
expect((tabsContainer as HTMLElement).style.borderBottom).toBe("");
|
|
});
|
|
|
|
it("detail-body is present and scrollable (flex: 1 + overflow-y: auto via CSS)", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const body = container.querySelector(".detail-body");
|
|
expect(body).toBeTruthy();
|
|
});
|
|
|
|
it("modal-actions contains Delete and Pause buttons for non-done tasks", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ column: "in-progress" as Column })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Delete")).toBeTruthy();
|
|
expect(screen.getByText("Pause")).toBeTruthy();
|
|
});
|
|
|
|
it("in-review modal-actions contains Merge & Close and Back to In Progress buttons", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ column: "in-review" as Column })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Merge & Close")).toBeTruthy();
|
|
expect(screen.getByText("Back to In Progress")).toBeTruthy();
|
|
});
|
|
|
|
it("shows PR automation waiting label instead of Merge & Close when awaiting PR checks", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ column: "in-review" as Column, status: "awaiting-pr-checks", prInfo: {
|
|
url: "https://github.com/owner/repo/pull/42",
|
|
number: 42,
|
|
status: "open",
|
|
title: "Task",
|
|
headBranch: "fusion/fn-099",
|
|
baseBranch: "main",
|
|
commentCount: 0,
|
|
} })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const button = screen.getByText("Awaiting PR checks") as HTMLButtonElement;
|
|
expect(button.disabled).toBe(true);
|
|
expect(screen.queryByText("Merge & Close")).toBeNull();
|
|
});
|
|
|
|
it("shows Creating PR label while PR-first automation is creating a PR", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ column: "in-review" as Column, status: "creating-pr" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const button = screen.getByText("Creating PR…") as HTMLButtonElement;
|
|
expect(button.disabled).toBe(true);
|
|
expect(screen.queryByText("Merge & Close")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("dependency dropdown search", () => {
|
|
const searchTasks: Task[] = [
|
|
{ id: "FN-010", title: "Fix login bug", description: "Users cannot log in", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
|
|
{ id: "FN-020", title: "Add dark mode", description: "Theme support", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-02-01T00:00:00Z", updatedAt: "2026-02-01T00:00:00Z" },
|
|
{ id: "FN-030", title: "Refactor API", description: "Clean up endpoints", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-01T00:00:00Z", updatedAt: "2026-03-01T00:00:00Z" },
|
|
{ id: "FN-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-03-15T00:00:00Z", updatedAt: "2026-03-15T00:00:00Z" },
|
|
];
|
|
|
|
function renderWithSearch(taskOverrides: Partial<TaskDetail> = {}) {
|
|
return render(
|
|
<TaskDetailModal
|
|
task={makeTask(taskOverrides)}
|
|
tasks={searchTasks}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
it("shows search input when dropdown is opened", () => {
|
|
renderWithSearch();
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
|
expect(input).toBeTruthy();
|
|
expect(input.placeholder).toBe("Search tasks…");
|
|
});
|
|
|
|
it("filters tasks by search term", () => {
|
|
renderWithSearch();
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: "login" } });
|
|
|
|
const items = document.querySelectorAll(".dep-dropdown-item");
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].querySelector(".dep-dropdown-id")?.textContent).toBe("FN-010");
|
|
});
|
|
|
|
it("matches task ID case-insensitively", () => {
|
|
renderWithSearch();
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: "fn-020" } });
|
|
|
|
const items = document.querySelectorAll(".dep-dropdown-item");
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].querySelector(".dep-dropdown-id")?.textContent).toBe("FN-020");
|
|
});
|
|
|
|
it("matches task title", () => {
|
|
renderWithSearch();
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: "dark mode" } });
|
|
|
|
const items = document.querySelectorAll(".dep-dropdown-item");
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].querySelector(".dep-dropdown-id")?.textContent).toBe("FN-020");
|
|
});
|
|
|
|
it("shows empty state when search matches nothing", () => {
|
|
renderWithSearch();
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: "zzz-nonexistent" } });
|
|
|
|
const items = document.querySelectorAll(".dep-dropdown-item");
|
|
expect(items).toHaveLength(0);
|
|
expect(document.querySelector(".dep-dropdown-empty")?.textContent).toBe("No available tasks");
|
|
});
|
|
|
|
it("resets search when dropdown closes and reopens", () => {
|
|
renderWithSearch();
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: "login" } });
|
|
expect(input.value).toBe("login");
|
|
|
|
// Close by clicking again
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
expect(document.querySelector(".dep-dropdown")).toBeNull();
|
|
|
|
// Reopen
|
|
fireEvent.click(screen.getByText("Add Dependency"));
|
|
const newInput = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
|
expect(newInput.value).toBe("");
|
|
// All items visible again
|
|
expect(document.querySelectorAll(".dep-dropdown-item")).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
describe("clickable dependency links", () => {
|
|
it("renders dependency list items with clickable class", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ dependencies: ["FN-001", "FN-002"] })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const depLinks = container.querySelectorAll(".detail-dep-link");
|
|
expect(depLinks).toHaveLength(2);
|
|
expect(depLinks[0].textContent).toBe("FN-001");
|
|
expect(depLinks[1].textContent).toBe("FN-002");
|
|
});
|
|
|
|
it("calls fetchTaskDetail and onOpenDetail when clicking a dependency", async () => {
|
|
const { fetchTaskDetail } = await import("../../api");
|
|
const mockFetch = vi.mocked(fetchTaskDetail);
|
|
const mockDetail: TaskDetail = {
|
|
...makeTask({ id: "FN-001", description: "Dep 1" }),
|
|
prompt: "",
|
|
attachments: [],
|
|
};
|
|
mockFetch.mockResolvedValueOnce(mockDetail);
|
|
const onOpenDetail = vi.fn();
|
|
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ dependencies: ["FN-001"] })}
|
|
onOpenDetail={onOpenDetail}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const depLink = container.querySelector(".detail-dep-link")!;
|
|
fireEvent.click(depLink);
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetch).toHaveBeenCalledWith("FN-001", undefined);
|
|
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
|
|
});
|
|
});
|
|
|
|
it("shows error toast when dependency fetch fails", async () => {
|
|
const { fetchTaskDetail } = await import("../../api");
|
|
const mockFetch = vi.mocked(fetchTaskDetail);
|
|
mockFetch.mockRejectedValueOnce(new Error("Task not found"));
|
|
const onOpenDetail = vi.fn();
|
|
const addToast = vi.fn();
|
|
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ dependencies: ["FN-001"] })}
|
|
onOpenDetail={onOpenDetail}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={addToast}
|
|
/>,
|
|
);
|
|
|
|
const depLink = container.querySelector(".detail-dep-link")!;
|
|
fireEvent.click(depLink);
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith("Failed to load dependency FN-001", "error");
|
|
});
|
|
expect(onOpenDetail).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("remove button click does not trigger dependency click", async () => {
|
|
const { updateTask } = await import("../../api");
|
|
const { fetchTaskDetail } = await import("../../api");
|
|
const mockFetch = vi.mocked(fetchTaskDetail);
|
|
mockFetch.mockRejectedValueOnce(new Error("Should not be called"));
|
|
const onOpenDetail = vi.fn();
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ dependencies: ["FN-001"] })}
|
|
onOpenDetail={onOpenDetail}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const removeButton = screen.getByTitle(/Remove dependency/);
|
|
fireEvent.click(removeButton);
|
|
|
|
// onOpenDetail should not be called when clicking remove
|
|
expect(onOpenDetail).not.toHaveBeenCalled();
|
|
// updateTask should be called to remove the dependency
|
|
await waitFor(() => {
|
|
expect(updateTask).toHaveBeenCalledWith("FN-099", { dependencies: [] }, undefined);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Definition tab edit mode", () => {
|
|
it("shows Edit button in Definition tab", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Test\n\nSpec content." })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Edit")).toBeTruthy();
|
|
});
|
|
|
|
it("clicking Edit shows textarea with current prompt content", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Test\n\nSpec content." })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Initially showing markdown view
|
|
expect(container.querySelector(".markdown-body")).toBeTruthy();
|
|
|
|
// Click Edit button
|
|
fireEvent.click(screen.getByText("Edit"));
|
|
|
|
// Should show spec edit textarea (query by class for specificity)
|
|
const textarea = container.querySelector(".spec-editor-textarea") as HTMLTextAreaElement;
|
|
expect(textarea).toBeTruthy();
|
|
expect(textarea.value).toBe("# Test\n\nSpec content.");
|
|
});
|
|
|
|
it("clicking Cancel returns to view mode without saving", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Test Task\n\nTest specification." })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Edit"));
|
|
const textarea = container.querySelector(".spec-editor-textarea") as HTMLTextAreaElement;
|
|
fireEvent.change(textarea, { target: { value: "Modified content" } });
|
|
|
|
// Click Cancel
|
|
fireEvent.click(screen.getByText("Cancel"));
|
|
|
|
// Should show markdown view with original content
|
|
expect(container.querySelector(".markdown-body")).toBeTruthy();
|
|
expect(container.querySelector(".spec-editor-textarea")).toBeNull();
|
|
});
|
|
|
|
it("saving updates the task and returns to view mode", async () => {
|
|
const { updateTask } = await import("../../api");
|
|
const mockUpdate = vi.mocked(updateTask);
|
|
mockUpdate.mockResolvedValueOnce({ id: "FN-099" } as Task);
|
|
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-099", prompt: "# Original" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Edit"));
|
|
const textarea = container.querySelector(".spec-editor-textarea") as HTMLTextAreaElement;
|
|
fireEvent.change(textarea, { target: { value: "# Updated" } });
|
|
|
|
fireEvent.click(screen.getByText("Save"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdate).toHaveBeenCalledWith("FN-099", { prompt: "# Updated" }, undefined);
|
|
});
|
|
|
|
// Should return to view mode
|
|
expect(container.querySelector(".markdown-body")).toBeTruthy();
|
|
});
|
|
|
|
it("AI revision feedback section appears in edit mode", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "# Test" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Edit"));
|
|
|
|
expect(screen.getByText("Ask AI to Revise")).toBeTruthy();
|
|
expect(screen.getByPlaceholderText(/e.g., 'Add more details/)).toBeTruthy();
|
|
expect(screen.getByText("Request AI Revision")).toBeTruthy();
|
|
});
|
|
|
|
it("requesting AI revision works and closes modal", async () => {
|
|
const { requestSpecRevision } = await import("../../api");
|
|
vi.mocked(requestSpecRevision).mockResolvedValueOnce({});
|
|
const onClose = vi.fn();
|
|
const addToast = vi.fn();
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-099", column: "todo", prompt: "# Test" })}
|
|
onClose={onClose}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={addToast}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Edit"));
|
|
|
|
const feedbackInput = screen.getByPlaceholderText(/e.g., 'Add more details/);
|
|
fireEvent.change(feedbackInput, { target: { value: "Please add more error handling details" } });
|
|
|
|
fireEvent.click(screen.getByText("Request AI Revision"));
|
|
|
|
await waitFor(() => {
|
|
expect(requestSpecRevision).toHaveBeenCalledWith("FN-099", "Please add more error handling details", undefined);
|
|
expect(addToast).toHaveBeenCalledWith("AI revision requested. Task moved to triage.", "success");
|
|
expect(onClose).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("shows all tabs in correct order for in-progress task", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// In-progress tasks show exactly 6 tabs:
|
|
// Definition, Logs, Changes, Comments, Model, Workflow
|
|
const tabs = container.querySelectorAll(".detail-tab");
|
|
expect(tabs.length).toBe(6);
|
|
expect(tabs[0].textContent).toBe("Definition");
|
|
expect(tabs[1].textContent).toBe("Logs");
|
|
expect(tabs[2].textContent).toBe("Changes");
|
|
expect(tabs[3].textContent).toBe("Comments");
|
|
expect(tabs[4].textContent).toBe("Model");
|
|
expect(tabs[5].textContent).toBe("Workflow");
|
|
// Commits tab should NOT be present for non-done tasks
|
|
expect(screen.queryByText("Commits")).toBeNull();
|
|
});
|
|
|
|
it("shows Workflow tab in correct position when enabledWorkflowSteps is non-empty", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ enabledWorkflowSteps: ["WS-001"] })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// In-progress task with workflow steps: 6 tabs (Workflow after Model)
|
|
const tabs = container.querySelectorAll(".detail-tab");
|
|
expect(tabs.length).toBe(6);
|
|
expect(tabs[0].textContent).toBe("Definition");
|
|
expect(tabs[1].textContent).toBe("Logs");
|
|
expect(tabs[2].textContent).toBe("Changes");
|
|
expect(tabs[3].textContent).toBe("Comments");
|
|
expect(tabs[4].textContent).toBe("Model");
|
|
expect(tabs[5].textContent).toBe("Workflow");
|
|
});
|
|
|
|
it("shows Commits tab for done task with mergeDetails.commitSha", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
column: "done",
|
|
mergeDetails: { commitSha: "abc1234567890", filesChanged: 3 },
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Done task with commit SHA: Definition, Logs, Changes, Commits, Comments, Model, Workflow
|
|
const tabs = container.querySelectorAll(".detail-tab");
|
|
expect(tabs.length).toBe(7);
|
|
expect(tabs[0].textContent).toBe("Definition");
|
|
expect(tabs[1].textContent).toBe("Logs");
|
|
expect(tabs[2].textContent).toBe("Changes");
|
|
expect(tabs[3].textContent).toBe("Commits");
|
|
expect(tabs[4].textContent).toBe("Comments");
|
|
expect(tabs[5].textContent).toBe("Model");
|
|
expect(tabs[6].textContent).toBe("Workflow");
|
|
});
|
|
|
|
it("shows all conditional tabs for done task with workflow steps and commit SHA", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
column: "done",
|
|
mergeDetails: { commitSha: "abc1234567890", filesChanged: 3 },
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Done task with both workflow steps and commit SHA:
|
|
// Definition, Logs, Changes, Commits, Comments, Model, Workflow
|
|
const tabs = container.querySelectorAll(".detail-tab");
|
|
expect(tabs.length).toBe(7);
|
|
expect(tabs[0].textContent).toBe("Definition");
|
|
expect(tabs[1].textContent).toBe("Logs");
|
|
expect(tabs[2].textContent).toBe("Changes");
|
|
expect(tabs[3].textContent).toBe("Commits");
|
|
expect(tabs[4].textContent).toBe("Comments");
|
|
expect(tabs[5].textContent).toBe("Model");
|
|
expect(tabs[6].textContent).toBe("Workflow");
|
|
});
|
|
|
|
it("does NOT show Changes tab for triage/todo tasks", () => {
|
|
const { container: triageContainer } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ column: "triage" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const triageTabs = triageContainer.querySelectorAll(".detail-tab");
|
|
expect(triageTabs.length).toBe(5); // Definition, Logs, Comments, Model, Workflow
|
|
expect(Array.from(triageTabs).map(t => t.textContent)).toEqual([
|
|
"Definition", "Logs", "Comments", "Model", "Workflow",
|
|
]);
|
|
|
|
const { container: todoContainer } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ column: "todo" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
const todoTabs = todoContainer.querySelectorAll(".detail-tab");
|
|
expect(todoTabs.length).toBe(5); // Definition, Logs, Comments, Model, Workflow
|
|
expect(Array.from(todoTabs).map(t => t.textContent)).toEqual([
|
|
"Definition", "Logs", "Comments", "Model", "Workflow",
|
|
]);
|
|
});
|
|
|
|
it("shows empty state and Edit button when no prompt", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ prompt: "" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("(no prompt)")).toBeTruthy();
|
|
expect(screen.getByText("Edit")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("Plan Approval UI", () => {
|
|
it("shows Approve Plan and Reject Plan buttons for awaiting-approval tasks in triage", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
column: "triage",
|
|
status: "awaiting-approval",
|
|
prompt: "# Task Spec",
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Approve Plan")).toBeTruthy();
|
|
expect(screen.getByText("Reject Plan")).toBeTruthy();
|
|
});
|
|
|
|
it("does not show approval buttons when task is not in triage", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
column: "todo",
|
|
status: "awaiting-approval",
|
|
prompt: "# Task Spec",
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText("Approve Plan")).toBeNull();
|
|
expect(screen.queryByText("Reject Plan")).toBeNull();
|
|
});
|
|
|
|
it("does not show approval buttons when task does not have awaiting-approval status", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
column: "triage",
|
|
status: "specifying",
|
|
prompt: "# Task Spec",
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText("Approve Plan")).toBeNull();
|
|
expect(screen.queryByText("Reject Plan")).toBeNull();
|
|
});
|
|
|
|
it("does not show approval buttons when task has no prompt", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
column: "triage",
|
|
status: "awaiting-approval",
|
|
prompt: "",
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText("Approve Plan")).toBeNull();
|
|
expect(screen.queryByText("Reject Plan")).toBeNull();
|
|
});
|
|
|
|
it("calls approvePlan API and shows success toast when Approve Plan is clicked", async () => {
|
|
const { approvePlan } = await import("../../api");
|
|
const mockApprovePlan = vi.mocked(approvePlan);
|
|
const addToast = vi.fn();
|
|
const onClose = vi.fn();
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
id: "FN-001",
|
|
column: "triage",
|
|
status: "awaiting-approval",
|
|
prompt: "# Task Spec",
|
|
})}
|
|
onClose={onClose}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={addToast}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Approve Plan"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockApprovePlan).toHaveBeenCalledWith("FN-001", undefined);
|
|
});
|
|
expect(addToast).toHaveBeenCalledWith("Plan approved — FN-001 moved to Todo", "success");
|
|
expect(onClose).toHaveBeenCalled();
|
|
});
|
|
|
|
it("calls rejectPlan API and shows success toast when Reject Plan is confirmed", async () => {
|
|
const { rejectPlan } = await import("../../api");
|
|
const mockRejectPlan = vi.mocked(rejectPlan);
|
|
const addToast = vi.fn();
|
|
const onClose = vi.fn();
|
|
|
|
// Mock confirm to return true
|
|
const originalConfirm = window.confirm;
|
|
window.confirm = vi.fn(() => true);
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
id: "FN-001",
|
|
column: "triage",
|
|
status: "awaiting-approval",
|
|
prompt: "# Task Spec",
|
|
})}
|
|
onClose={onClose}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={addToast}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Reject Plan"));
|
|
|
|
expect(window.confirm).toHaveBeenCalledWith(
|
|
"Reject this plan? The specification will be discarded and regenerated."
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(mockRejectPlan).toHaveBeenCalledWith("FN-001", undefined);
|
|
});
|
|
expect(addToast).toHaveBeenCalledWith(
|
|
"Plan rejected — FN-001 returned to Triage for re-specification",
|
|
"info"
|
|
);
|
|
expect(onClose).toHaveBeenCalled();
|
|
|
|
window.confirm = originalConfirm;
|
|
});
|
|
|
|
it("does not call rejectPlan API when Reject Plan is cancelled", async () => {
|
|
const { rejectPlan } = await import("../../api");
|
|
const mockRejectPlan = vi.mocked(rejectPlan);
|
|
mockRejectPlan.mockClear(); // Clear any previous calls
|
|
|
|
const addToast = vi.fn();
|
|
|
|
// Mock confirm to return false
|
|
const originalConfirm = window.confirm;
|
|
window.confirm = vi.fn(() => false);
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
column: "triage",
|
|
status: "awaiting-approval",
|
|
prompt: "# Task Spec",
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={addToast}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Reject Plan"));
|
|
|
|
expect(window.confirm).toHaveBeenCalled();
|
|
expect(mockRejectPlan).not.toHaveBeenCalled();
|
|
expect(addToast).not.toHaveBeenCalled();
|
|
|
|
window.confirm = originalConfirm;
|
|
});
|
|
|
|
it("shows error toast when approvePlan fails", async () => {
|
|
const { approvePlan } = await import("../../api");
|
|
const mockApprovePlan = vi.mocked(approvePlan);
|
|
mockApprovePlan.mockRejectedValueOnce(new Error("Network error"));
|
|
|
|
const addToast = vi.fn();
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
id: "FN-001",
|
|
column: "triage",
|
|
status: "awaiting-approval",
|
|
prompt: "# Task Spec",
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={addToast}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Approve Plan"));
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith("Network error", "error");
|
|
});
|
|
});
|
|
|
|
it("shows error toast when rejectPlan fails", async () => {
|
|
const { rejectPlan } = await import("../../api");
|
|
const mockRejectPlan = vi.mocked(rejectPlan);
|
|
mockRejectPlan.mockRejectedValueOnce(new Error("Server error"));
|
|
|
|
const addToast = vi.fn();
|
|
|
|
// Mock confirm to return true
|
|
const originalConfirm = window.confirm;
|
|
window.confirm = vi.fn(() => true);
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
id: "FN-001",
|
|
column: "triage",
|
|
status: "awaiting-approval",
|
|
prompt: "# Task Spec",
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={addToast}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Reject Plan"));
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith("Server error", "error");
|
|
});
|
|
|
|
window.confirm = originalConfirm;
|
|
});
|
|
});
|
|
|
|
describe("Duplicate button", () => {
|
|
it("renders Duplicate button in modal actions when onDuplicateTask is provided", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
onDuplicateTask={vi.fn()}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Duplicate")).toBeTruthy();
|
|
});
|
|
|
|
it("does NOT render Duplicate button when onDuplicateTask is not provided", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask()}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText("Duplicate")).toBeNull();
|
|
});
|
|
|
|
it("clicking Duplicate shows confirmation dialog", () => {
|
|
const originalConfirm = window.confirm;
|
|
window.confirm = vi.fn(() => false);
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-001" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
onDuplicateTask={vi.fn()}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Duplicate"));
|
|
|
|
expect(window.confirm).toHaveBeenCalledWith(
|
|
"Duplicate FN-001? This will create a new task in Triage with the same description and prompt."
|
|
);
|
|
|
|
window.confirm = originalConfirm;
|
|
});
|
|
|
|
it("confirming duplicate calls onDuplicateTask and closes modal", async () => {
|
|
const originalConfirm = window.confirm;
|
|
window.confirm = vi.fn(() => true);
|
|
|
|
const mockDuplicate = vi.fn().mockResolvedValue({ id: "FN-002" } as Task);
|
|
const onClose = vi.fn();
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-001" })}
|
|
onClose={onClose}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
onDuplicateTask={mockDuplicate}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Duplicate"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockDuplicate).toHaveBeenCalledWith("FN-001");
|
|
expect(onClose).toHaveBeenCalled();
|
|
});
|
|
|
|
window.confirm = originalConfirm;
|
|
});
|
|
|
|
it("successful duplicate shows success toast with new task ID", async () => {
|
|
const originalConfirm = window.confirm;
|
|
window.confirm = vi.fn(() => true);
|
|
|
|
const mockDuplicate = vi.fn().mockResolvedValue({ id: "FN-002" } as Task);
|
|
const addToast = vi.fn();
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-001" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
onDuplicateTask={mockDuplicate}
|
|
addToast={addToast}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Duplicate"));
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith("Duplicated FN-001 → FN-002", "success");
|
|
});
|
|
|
|
window.confirm = originalConfirm;
|
|
});
|
|
|
|
it("cancelling confirmation does not call onDuplicateTask", () => {
|
|
const originalConfirm = window.confirm;
|
|
window.confirm = vi.fn(() => false);
|
|
|
|
const mockDuplicate = vi.fn().mockResolvedValue({ id: "FN-002" } as Task);
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-001" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
onDuplicateTask={mockDuplicate}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Duplicate"));
|
|
|
|
expect(mockDuplicate).not.toHaveBeenCalled();
|
|
|
|
window.confirm = originalConfirm;
|
|
});
|
|
|
|
it("shows error toast when duplicate fails", async () => {
|
|
const originalConfirm = window.confirm;
|
|
window.confirm = vi.fn(() => true);
|
|
|
|
const mockDuplicate = vi.fn().mockRejectedValue(new Error("Duplicate failed"));
|
|
const addToast = vi.fn();
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-001" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
onDuplicateTask={mockDuplicate}
|
|
addToast={addToast}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Duplicate"));
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith("Duplicate failed", "error");
|
|
});
|
|
|
|
window.confirm = originalConfirm;
|
|
});
|
|
});
|
|
|
|
describe("Refinement button", () => {
|
|
it("renders Refine button for 'done' column tasks", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ column: "done" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Refine")).toBeTruthy();
|
|
});
|
|
|
|
it("renders Refine button for 'in-review' column tasks", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ column: "in-review" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Refine")).toBeTruthy();
|
|
});
|
|
|
|
it("does NOT render Refine button for 'triage' column tasks", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ column: "triage" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText("Refine")).toBeNull();
|
|
});
|
|
|
|
it("does NOT render Refine button for 'todo' column tasks", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ column: "todo" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText("Refine")).toBeNull();
|
|
});
|
|
|
|
it("does NOT render Refine button for 'in-progress' column tasks", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ column: "in-progress" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText("Refine")).toBeNull();
|
|
});
|
|
|
|
it("clicking Refine opens the refinement modal", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-001", column: "done" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Refine"));
|
|
|
|
expect(screen.getByText("Refine", { selector: "h3" })).toBeTruthy();
|
|
expect(screen.getByPlaceholderText("Enter your feedback here...")).toBeTruthy();
|
|
});
|
|
|
|
it("shows character counter in refinement modal", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-001", column: "done" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Refine"));
|
|
|
|
expect(screen.getByText("0/2000 characters")).toBeTruthy();
|
|
});
|
|
|
|
it("character counter updates when typing feedback", async () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-001", column: "done" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Refine"));
|
|
|
|
const textarea = screen.getByPlaceholderText("Enter your feedback here...");
|
|
await act(async () => {
|
|
fireEvent.change(textarea, { target: { value: "Need to fix the error handling" } });
|
|
});
|
|
|
|
expect(screen.getByText("30/2000 characters")).toBeTruthy();
|
|
});
|
|
|
|
it("submit button is disabled when feedback is empty", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-001", column: "done" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Refine"));
|
|
|
|
const submitButton = screen.getByText("Create Refinement Task");
|
|
expect(submitButton.hasAttribute("disabled")).toBe(true);
|
|
});
|
|
|
|
it("submit button is enabled when feedback is entered", async () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-001", column: "done" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Refine"));
|
|
|
|
const textarea = screen.getByPlaceholderText("Enter your feedback here...");
|
|
await act(async () => {
|
|
fireEvent.change(textarea, { target: { value: "Need to fix error handling" } });
|
|
});
|
|
|
|
const submitButton = screen.getByText("Create Refinement Task");
|
|
expect(submitButton.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
it("clicking Cancel closes the refinement modal", () => {
|
|
const onClose = vi.fn();
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-001", column: "done" })}
|
|
onClose={onClose}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Refine"));
|
|
fireEvent.click(screen.getByText("Cancel"));
|
|
|
|
// Modal should be closed, but detail modal stays open (onClose not called)
|
|
expect(screen.queryByText("Refine", { selector: "h3" })).toBeNull();
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows error toast when submitting empty feedback", async () => {
|
|
const addToast = vi.fn();
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-001", column: "done" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={addToast}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Refine"));
|
|
|
|
// Try to submit with empty text (manually trigger submit since button is disabled)
|
|
const { refineTask } = await import("../../api");
|
|
|
|
// Should not call API, instead show error toast
|
|
expect(refineTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("calls refineTask and closes modal on successful submission", async () => {
|
|
const { refineTask } = await import("../../api");
|
|
vi.mocked(refineTask).mockResolvedValue({ id: "FN-002", column: "triage" } as Task);
|
|
|
|
const onClose = vi.fn();
|
|
const addToast = vi.fn();
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-001", column: "done" })}
|
|
onClose={onClose}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={addToast}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Refine"));
|
|
|
|
const textarea = screen.getByPlaceholderText("Enter your feedback here...");
|
|
fireEvent.change(textarea, { target: { value: "Need to add more tests" } });
|
|
|
|
fireEvent.click(screen.getByText("Create Refinement Task"));
|
|
|
|
await waitFor(() => {
|
|
expect(refineTask).toHaveBeenCalledWith("FN-001", "Need to add more tests", undefined);
|
|
expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-002", "success");
|
|
expect(onClose).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("shows error toast when refineTask fails", async () => {
|
|
const { refineTask } = await import("../../api");
|
|
vi.mocked(refineTask).mockRejectedValue(new Error("Task must be in 'done' or 'in-review' column"));
|
|
|
|
const addToast = vi.fn();
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-001", column: "done" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={addToast}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Refine"));
|
|
|
|
const textarea = screen.getByPlaceholderText("Enter your feedback here...");
|
|
fireEvent.change(textarea, { target: { value: "Need to add more tests" } });
|
|
|
|
fireEvent.click(screen.getByText("Create Refinement Task"));
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith("Task must be in 'done' or 'in-review' column", "error");
|
|
});
|
|
});
|
|
|
|
it("renders submit button inside the input group adjacent to textarea", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-001", column: "done" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Refine"));
|
|
|
|
// The submit button should be inside .detail-refine-input-group (the input area)
|
|
const inputGroup = container.querySelector(".detail-refine-input-group");
|
|
expect(inputGroup).toBeTruthy();
|
|
const submitButton = inputGroup!.querySelector("button.btn-primary");
|
|
expect(submitButton).toBeTruthy();
|
|
expect(submitButton!.textContent).toBe("Create Refinement Task");
|
|
|
|
// The submit button should NOT be in the footer .modal-actions
|
|
const modalActions = container.querySelector(".detail-refine-modal .modal-actions");
|
|
expect(modalActions).toBeTruthy();
|
|
expect(modalActions!.querySelector("button.btn-primary")).toBeNull();
|
|
});
|
|
|
|
it("submit button in input group follows the same disabled/enabled rules", async () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-001", column: "done" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Refine"));
|
|
|
|
// Submit button starts disabled (no feedback)
|
|
const submitButton = screen.getByText("Create Refinement Task");
|
|
expect(submitButton.hasAttribute("disabled")).toBe(true);
|
|
|
|
// Enter feedback to enable it
|
|
const textarea = screen.getByPlaceholderText("Enter your feedback here...");
|
|
await act(async () => {
|
|
fireEvent.change(textarea, { target: { value: "Some feedback" } });
|
|
});
|
|
|
|
expect(submitButton.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
it("character count and submit button are siblings in the input group", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ id: "FN-001", column: "done" })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Refine"));
|
|
|
|
const inputGroup = container.querySelector(".detail-refine-input-group")!;
|
|
expect(inputGroup.querySelector(".detail-refine-char-count")).toBeTruthy();
|
|
expect(inputGroup.querySelector("button.btn-primary")).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")!);
|
|
|
|
// 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")!);
|
|
|
|
// 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")!);
|
|
|
|
// 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();
|
|
expect(screen.getByTestId("browser-verification-checkbox")).toBeTruthy();
|
|
});
|
|
|
|
it("save sends all 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")!);
|
|
|
|
// Click Save
|
|
fireEvent.click(screen.getByText("Save"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({
|
|
title: "Test",
|
|
description: "Desc",
|
|
dependencies: ["FN-002"],
|
|
enabledWorkflowSteps: [],
|
|
}), 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("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: "triage", 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 standard actions like Delete
|
|
expect(buttonTexts).toContain("Delete");
|
|
});
|
|
});
|
|
|
|
describe("Commits tab visibility", () => {
|
|
it("shows Commits tab for done tasks with mergeDetails.commitSha", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
column: "done",
|
|
mergeDetails: { commitSha: "abc1234567890", filesChanged: 3, insertions: 10, deletions: 2 },
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Commits")).toBeTruthy();
|
|
});
|
|
|
|
it("does NOT show Commits tab for non-done tasks", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
column: "in-progress",
|
|
mergeDetails: { commitSha: "abc1234567890" },
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText("Commits")).toBeNull();
|
|
});
|
|
|
|
it("does NOT show Commits tab for done tasks without commitSha", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
column: "done",
|
|
mergeDetails: { filesChanged: 3 },
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText("Commits")).toBeNull();
|
|
});
|
|
|
|
it("does NOT show Commits tab for done tasks without mergeDetails", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
column: "done",
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText("Commits")).toBeNull();
|
|
});
|
|
});
|
|
|
|
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", enabled: true, createdAt: "", updatedAt: "" },
|
|
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", 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("Workflow tab", () => {
|
|
it("shows Workflow tab even when enabledWorkflowSteps is empty", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ enabledWorkflowSteps: [] })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Workflow")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows Workflow tab even when enabledWorkflowSteps is undefined", () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({ enabledWorkflowSteps: undefined, workflowStepResults: undefined })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Workflow")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows Workflow tab when enabledWorkflowSteps is non-empty", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ enabledWorkflowSteps: ["WS-001"] })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Workflow")).toBeTruthy();
|
|
});
|
|
|
|
it("shows Workflow tab when task has previous workflow results", () => {
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
enabledWorkflowSteps: [],
|
|
workflowStepResults: [
|
|
{ workflowStepId: "WS-001", workflowStepName: "QA Check", status: "passed" },
|
|
],
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Workflow")).toBeTruthy();
|
|
});
|
|
|
|
it("switches to Workflow tab and calls fetchWorkflowResults", async () => {
|
|
const { fetchWorkflowResults } = await import("../../api");
|
|
const mockFetch = vi.mocked(fetchWorkflowResults);
|
|
const mockResults: import("@fusion/core").WorkflowStepResult[] = [
|
|
{
|
|
workflowStepId: "WS-001",
|
|
workflowStepName: "QA Check",
|
|
status: "passed",
|
|
output: "All tests passed.",
|
|
startedAt: "2026-04-04T10:00:00Z",
|
|
completedAt: "2026-04-04T10:02:00Z",
|
|
},
|
|
];
|
|
mockFetch.mockResolvedValueOnce(mockResults);
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ enabledWorkflowSteps: ["WS-001"] })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Workflow"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetch).toHaveBeenCalledWith("FN-099", undefined);
|
|
});
|
|
|
|
// Should render the workflow results
|
|
expect(screen.getByText("QA Check")).toBeTruthy();
|
|
});
|
|
|
|
it("shows loading state when workflow results are being fetched", async () => {
|
|
const { fetchWorkflowResults } = await import("../../api");
|
|
const mockFetch = vi.mocked(fetchWorkflowResults);
|
|
// Never resolve to keep loading state
|
|
mockFetch.mockReturnValueOnce(new Promise(() => {}));
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ enabledWorkflowSteps: ["WS-001"] })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Workflow"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("workflow-results-loading")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("shows error toast when fetchWorkflowResults fails", async () => {
|
|
const { fetchWorkflowResults } = await import("../../api");
|
|
const mockFetch = vi.mocked(fetchWorkflowResults);
|
|
mockFetch.mockRejectedValueOnce(new Error("Server error"));
|
|
const addToast = vi.fn();
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ enabledWorkflowSteps: ["WS-001"] })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={addToast}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Workflow"));
|
|
|
|
await waitFor(() => {
|
|
expect(addToast).toHaveBeenCalledWith(
|
|
"Failed to load workflow results: Server error",
|
|
"error",
|
|
);
|
|
});
|
|
});
|
|
|
|
it("renders empty state when workflow results are empty", async () => {
|
|
const { fetchWorkflowResults } = await import("../../api");
|
|
const mockFetch = vi.mocked(fetchWorkflowResults);
|
|
mockFetch.mockResolvedValueOnce([]);
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ enabledWorkflowSteps: ["WS-001"] })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Workflow"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("workflow-results-empty")).toBeTruthy();
|
|
expect(screen.getByText("Workflow steps configured but haven't run yet.")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("renders multiple workflow step results with status badges", async () => {
|
|
const { fetchWorkflowResults } = await import("../../api");
|
|
const mockFetch = vi.mocked(fetchWorkflowResults);
|
|
const mockResults: import("@fusion/core").WorkflowStepResult[] = [
|
|
{
|
|
workflowStepId: "WS-001",
|
|
workflowStepName: "QA Check",
|
|
status: "passed",
|
|
output: "All tests passed.",
|
|
startedAt: "2026-04-04T10:00:00Z",
|
|
completedAt: "2026-04-04T10:02:00Z",
|
|
},
|
|
{
|
|
workflowStepId: "WS-002",
|
|
workflowStepName: "Security Audit",
|
|
status: "failed",
|
|
output: "Found 2 issues.",
|
|
startedAt: "2026-04-04T10:02:05Z",
|
|
completedAt: "2026-04-04T10:03:00Z",
|
|
},
|
|
];
|
|
mockFetch.mockResolvedValueOnce(mockResults);
|
|
|
|
render(
|
|
<TaskDetailModal
|
|
task={makeTask({ enabledWorkflowSteps: ["WS-001", "WS-002"] })}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("Workflow"));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("QA Check")).toBeTruthy();
|
|
expect(screen.getByText("Security Audit")).toBeTruthy();
|
|
expect(screen.getByTestId("workflow-result-badge-WS-001")).toHaveTextContent("Passed");
|
|
expect(screen.getByTestId("workflow-result-badge-WS-002")).toHaveTextContent("Failed");
|
|
});
|
|
});
|
|
|
|
it("hides Definition content when Workflow tab is active", async () => {
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={makeTask({
|
|
enabledWorkflowSteps: ["WS-001"],
|
|
prompt: "# Test prompt",
|
|
})}
|
|
onClose={noop}
|
|
onMoveTask={noopMove}
|
|
onDeleteTask={noopDelete}
|
|
onMergeTask={noopMerge}
|
|
onOpenDetail={noopOpenDetail}
|
|
addToast={noop}
|
|
/>,
|
|
);
|
|
|
|
// Definition content visible initially
|
|
expect(container.querySelector(".markdown-body")).toBeTruthy();
|
|
|
|
// Switch to Workflow tab
|
|
fireEvent.click(screen.getByText("Workflow"));
|
|
|
|
// Definition content should be hidden
|
|
await waitFor(() => {
|
|
expect(container.querySelector(".markdown-body")).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
});
|