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