feat(FN-3083): add GraphTaskNode wrapper and integrate into dependency grap
The merge introduces a new `GraphTaskNode` wrapper component for the dependency graph plugin (FN-3083) with visual parity tests, adds an AI security scan gate to the plugin install/unpack flows (FN-3077), updates the dashboard PluginManager UI and adds plugin API routes, and cleans up remaining main Fusion-Task-Id: FN-3083
This commit is contained in:
@@ -7,7 +7,7 @@ const fitToGraph = vi.fn();
|
||||
|
||||
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
|
||||
TaskCard: ({ task, onOpenDetail }: { task: Task; onOpenDetail: () => void }) => (
|
||||
<button data-testid={`task-${task.id}`} onClick={onOpenDetail}>{task.id}</button>
|
||||
<button data-testid={`task-${task.id}`} onClick={() => onOpenDetail(task)}>{task.id}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -42,15 +42,23 @@ describe("DependencyGraph", () => {
|
||||
expect(screen.getByText(/No active tasks/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders positioned nodes and edges for included tasks", () => {
|
||||
render(<DependencyGraph tasks={[
|
||||
it("renders graph task nodes at layout coordinates and edges", () => {
|
||||
const { container } = render(<DependencyGraph tasks={[
|
||||
createTask("A", "todo"),
|
||||
createTask("B", "in-progress", ["A"]),
|
||||
]} onOpenTaskDetail={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId("task-A")).toBeTruthy();
|
||||
expect(screen.getByTestId("task-B")).toBeTruthy();
|
||||
expect(screen.getByTestId("graph-task-node-A")).toBeTruthy();
|
||||
expect(screen.getByTestId("graph-task-node-B")).toBeTruthy();
|
||||
expect(container.querySelector(".dependency-graph__nodes-layer")).toBeTruthy();
|
||||
expect(screen.getAllByTestId("dependency-edge")).toHaveLength(1);
|
||||
|
||||
const nodeAStyle = screen.getByTestId("graph-task-node-A").getAttribute("style") ?? "";
|
||||
const nodeBStyle = screen.getByTestId("graph-task-node-B").getAttribute("style") ?? "";
|
||||
expect(nodeAStyle).toContain("left:");
|
||||
expect(nodeAStyle).toContain("top:");
|
||||
expect(nodeBStyle).toContain("left:");
|
||||
expect(nodeBStyle).toContain("top:");
|
||||
});
|
||||
|
||||
it("excludes done and archived nodes", () => {
|
||||
@@ -60,9 +68,16 @@ describe("DependencyGraph", () => {
|
||||
createTask("C", "archived"),
|
||||
]} onOpenTaskDetail={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId("task-A")).toBeTruthy();
|
||||
expect(screen.queryByTestId("task-B")).toBeNull();
|
||||
expect(screen.queryByTestId("task-C")).toBeNull();
|
||||
expect(screen.getByTestId("graph-task-node-A")).toBeTruthy();
|
||||
expect(screen.queryByTestId("graph-task-node-B")).toBeNull();
|
||||
expect(screen.queryByTestId("graph-task-node-C")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking a card triggers onOpenDetail", () => {
|
||||
const onOpenDetail = vi.fn();
|
||||
render(<DependencyGraph tasks={[createTask("A", "in-progress")]} onOpenDetail={onOpenDetail} />);
|
||||
fireEvent.click(screen.getByTestId("task-A"));
|
||||
expect(onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "A" }));
|
||||
});
|
||||
|
||||
it("fit-to-screen button triggers fitToGraph", () => {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { GraphTaskNode } from "../GraphTaskNode";
|
||||
import { TaskCard } from "@fusion/dashboard/app/components/TaskCard";
|
||||
|
||||
function createTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-TEST",
|
||||
description: "Task description",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function createProps(task: Task) {
|
||||
return {
|
||||
task,
|
||||
projectId: "proj-1",
|
||||
onOpenDetail: vi.fn(),
|
||||
addToast: vi.fn(),
|
||||
onUpdateTask: vi.fn(),
|
||||
onArchiveTask: vi.fn(),
|
||||
onUnarchiveTask: vi.fn(),
|
||||
onDeleteTask: vi.fn(),
|
||||
onRetryTask: vi.fn(),
|
||||
onOpenDetailWithTab: vi.fn(),
|
||||
onMoveTask: vi.fn(),
|
||||
onOpenMission: vi.fn(),
|
||||
taskStuckTimeoutMs: 60_000,
|
||||
lastFetchTimeMs: Date.now(),
|
||||
workflowStepNameLookup: new Map<string, string>(),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("GraphTaskNode", () => {
|
||||
it("renders a TaskCard and passes core props through", () => {
|
||||
const props = createProps(createTask());
|
||||
const { container } = render(<GraphTaskNode {...props} style={{ left: 10, top: 20 }} />);
|
||||
|
||||
const node = screen.getByTestId("graph-task-node-FN-TEST");
|
||||
expect(node).toBeTruthy();
|
||||
expect(container.querySelector(".card-title")?.textContent).toContain("Task description");
|
||||
expect(node.getAttribute("draggable")).toBe("false");
|
||||
expect(container.querySelector(".card")?.getAttribute("draggable")).toBe("false");
|
||||
});
|
||||
|
||||
it("shows steps expanded and agent-active styling for in-progress executing tasks", () => {
|
||||
const props = createProps(
|
||||
createTask({
|
||||
column: "in-progress",
|
||||
status: "executing",
|
||||
steps: [
|
||||
{ name: "step one", status: "in-progress" },
|
||||
{ name: "step two", status: "pending" },
|
||||
],
|
||||
currentStep: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(<GraphTaskNode {...props} />);
|
||||
expect(container.querySelector(".card")?.className).toContain("agent-active");
|
||||
expect(container.querySelector(".card-steps-list")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking card opens task detail", () => {
|
||||
const props = createProps(createTask());
|
||||
const { container } = render(<GraphTaskNode {...props} />);
|
||||
|
||||
const card = container.querySelector(".card");
|
||||
expect(card).toBeTruthy();
|
||||
fireEvent.click(card!);
|
||||
expect(props.onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-TEST" }));
|
||||
});
|
||||
|
||||
it("applies highlighted class only when requested", () => {
|
||||
const highlightedProps = createProps(createTask({ id: "FN-HL" }));
|
||||
const neutralProps = createProps(createTask({ id: "FN-NEUTRAL" }));
|
||||
|
||||
const { unmount } = render(<GraphTaskNode {...highlightedProps} isHighlighted={true} />);
|
||||
expect(screen.getByTestId("graph-task-node-FN-HL").className).toContain("graph-task-node--highlighted");
|
||||
unmount();
|
||||
|
||||
render(<GraphTaskNode {...neutralProps} />);
|
||||
const neutral = screen.getByTestId("graph-task-node-FN-NEUTRAL");
|
||||
expect(neutral.className).not.toContain("graph-task-node--highlighted");
|
||||
expect(neutral.className).not.toContain("graph-task-node--dimmed");
|
||||
});
|
||||
|
||||
it("renders the same TaskCard structure as board usage", () => {
|
||||
const task = createTask({
|
||||
id: "FN-SAME",
|
||||
column: "in-progress",
|
||||
status: "executing",
|
||||
error: "Execution failed",
|
||||
missionId: "M-1",
|
||||
sourceType: "automation",
|
||||
sourceAgentId: "agent-1",
|
||||
steps: [{ name: "sync", status: "in-progress" }],
|
||||
currentStep: 0,
|
||||
});
|
||||
const props = createProps(task);
|
||||
|
||||
const { container } = render(
|
||||
<div>
|
||||
<TaskCard {...props} disableDrag={true} />
|
||||
<GraphTaskNode {...props} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
const cards = container.querySelectorAll(".card");
|
||||
expect(cards.length).toBe(2);
|
||||
|
||||
const [boardCard, graphCard] = cards;
|
||||
const selectors = [
|
||||
".card-id",
|
||||
".card-title",
|
||||
".card-status-badge",
|
||||
".card-step-dot",
|
||||
".card-step-name",
|
||||
".card-progress",
|
||||
".card-progress-fill",
|
||||
".card-error",
|
||||
".card-mission-badge",
|
||||
".card-provider-icons",
|
||||
".card-agent-badge",
|
||||
];
|
||||
|
||||
for (const selector of selectors) {
|
||||
expect(Boolean(boardCard.querySelector(selector))).toBe(Boolean(graphCard.querySelector(selector)));
|
||||
}
|
||||
|
||||
expect(Boolean(boardCard.querySelector(".card-error"))).toBe(Boolean(graphCard.querySelector(".card-error")));
|
||||
expect(boardCard.querySelector(".card-id")?.textContent).toBe(graphCard.querySelector(".card-id")?.textContent);
|
||||
expect(boardCard.querySelector(".card-title")?.textContent).toBe(graphCard.querySelector(".card-title")?.textContent);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user