Files
fusion/plugins/fusion-plugin-dependency-graph/src/__tests__/GraphTaskNode.test.tsx
Phil Larson 5ec47e58de fix: resolve late-acquire lifecycle and plugin API drift (#3492)
## Summary
- resolves renamed review, complete, and archived workflow columns
before admitting late workspace repositories
- avoids workflow resolution when an existing repository or landing
state already decides the result
- syncs the bundled dependency-graph plugin with the current TaskCard
and scoped-storage APIs

## Test plan
- `pnpm --filter @fusion/engine exec vitest run
src/__tests__/workspace-add-repo-midflight.test.ts --silent=passed-only
--reporter=dot`
- `pnpm --filter @fusion/engine typecheck`
- `pnpm --filter @fusion-plugin-examples/dependency-graph test`
- `pnpm --filter @fusion-plugin-examples/dependency-graph build`
- `pnpm check:plugin-interop-drift`
- `pnpm check:lifecycle-columns`
- `pnpm check:changesets`
- `pnpm lint`
- `pnpm build`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Late workspace repository acquisition now respects renamed review,
complete, and archived workflow columns.
* Tasks entering a blocked lifecycle state during acquisition are
refused safely.
* Existing safeguards for merge status, landed worktrees, legacy
terminal identifiers, and existing worktrees remain supported.
* Concurrent acquisition attempts now avoid duplicate worktrees and
preserve task updates reliably.

* **Improvements**
* Improved dependency-graph dashboard interoperability with current
task-card and storage APIs.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-21 07:48:23 -07:00

395 lines
16 KiB
TypeScript

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TaskCard } from "@fusion/dashboard/app/components/TaskCard";
import { GraphTaskNode } from "../GraphTaskNode";
/*
FNXC:DependencyGraphTests 2026-07-08-13:10:
GraphTaskNode renders the REAL TaskCard (to verify prop pass-through), and TaskCard now renders RuntimeFallbackBadge which calls the dashboard's useToast() hook. This file has no ToastProvider, so mock useToast the same way the dashboard's own TaskCard.test.tsx does to avoid "useToast must be used within ToastProvider".
*/
/*
FNXC:DependencyGraphTests 2026-07-18-04:35:
RuntimeFallbackBadge uses useOptionalToast (soft-fail path). Mock both exports so
GraphTaskNode suites stay provider-free.
*/
vi.mock("@fusion/dashboard/app/hooks/useToast", () => ({
useToast: () => ({ addToast: vi.fn(), removeToast: vi.fn(), toasts: [] }),
useOptionalToast: () => ({ addToast: vi.fn(), removeToast: vi.fn(), toasts: [] }),
}));
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,
position: { x: 0, y: 0 },
scale: 1,
onNodePositionChange: vi.fn(),
onNodeDragStateChange: vi.fn(),
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(),
lastFetchTimeMs: Date.now(),
workflowStepNameLookup: new Map<string, string>(),
};
}
afterEach(() => {
vi.useRealTimers();
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")).toBeNull();
});
it("shows active indicator with capitalized status for in-progress executing tasks", () => {
const props = createProps(
createTask({
column: "in-progress",
status: "executing",
steps: [{ name: "step one", status: "in-progress" }],
}),
);
const { container } = render(<GraphTaskNode {...props} />);
const node = screen.getByTestId("graph-task-node-FN-TEST");
expect(node.className).toContain("graph-task-node--active");
expect(container.querySelector(".card")?.className).toContain("agent-active");
expect(screen.getByText("Executing")).toBeTruthy();
expect(container.querySelector(".graph-task-active-indicator")).toBeTruthy();
});
it("defaults indicator text to Executing when status is missing on in-progress tasks", () => {
const props = createProps(createTask({ column: "in-progress", status: undefined }));
const { container } = render(<GraphTaskNode {...props} />);
expect(container.querySelector(".graph-task-active-indicator")).toBeTruthy();
expect(screen.getByText("Executing")).toBeTruthy();
});
it("applies in-review visual class and does not apply active class for in-review tasks", () => {
const props = createProps(createTask({ column: "in-review", status: "idle" }));
const { container } = render(<GraphTaskNode {...props} />);
const node = screen.getByTestId("graph-task-node-FN-TEST");
expect(container.querySelector(".graph-task-active-indicator")).toBeFalsy();
expect(node.className).toContain("graph-task-node--in-review");
expect(node.className).not.toContain("graph-task-node--active");
});
it.each(["todo", "triage", "in-progress"] as const)("does not apply in-review class for %s tasks", (column) => {
const props = createProps(createTask({ column, status: column === "in-progress" ? "executing" : "idle" }));
render(<GraphTaskNode {...props} />);
expect(screen.getByTestId("graph-task-node-FN-TEST").className).not.toContain("graph-task-node--in-review");
});
it("does not render active indicator for paused in-progress tasks", () => {
const props = createProps(createTask({ column: "in-progress", status: "executing", paused: true }));
const { container } = render(<GraphTaskNode {...props} />);
expect(container.querySelector(".graph-task-active-indicator")).toBeFalsy();
});
it("does not render active indicator for failed in-progress tasks", () => {
const props = createProps(createTask({ column: "in-progress", status: "failed" }));
const { container } = render(<GraphTaskNode {...props} />);
expect(container.querySelector(".graph-task-active-indicator")).toBeFalsy();
});
it("sets current-step attribute for active task when current step is valid", () => {
const props = createProps(
createTask({
column: "in-progress",
status: "executing",
steps: [
{ name: "step one", status: "done" },
{ name: "step two", status: "done" },
{ name: "step three", status: "in-progress" },
],
currentStep: 2,
}),
);
render(<GraphTaskNode {...props} />);
expect(screen.getByTestId("graph-task-node-FN-TEST").getAttribute("data-current-step")).toBe("2");
});
it("sets current-step attribute to zero when first step is active", () => {
const props = createProps(
createTask({
column: "in-progress",
status: "executing",
steps: [{ name: "step one", status: "in-progress" }],
currentStep: 0,
}),
);
render(<GraphTaskNode {...props} />);
expect(screen.getByTestId("graph-task-node-FN-TEST").getAttribute("data-current-step")).toBe("0");
});
it("omits current-step attribute when current step is out of bounds", () => {
const props = createProps(
createTask({
column: "in-progress",
status: "executing",
steps: [{ name: "step one", status: "in-progress" }],
currentStep: 10,
}),
);
render(<GraphTaskNode {...props} />);
expect(screen.getByTestId("graph-task-node-FN-TEST").hasAttribute("data-current-step")).toBe(false);
});
it("omits current-step attribute when current step is negative", () => {
const props = createProps(createTask({ column: "in-progress", status: "executing", steps: [], currentStep: -1 }));
render(<GraphTaskNode {...props} />);
expect(screen.getByTestId("graph-task-node-FN-TEST").hasAttribute("data-current-step")).toBe(false);
});
it("omits current-step attribute when current step is undefined", () => {
const props = createProps(createTask({ column: "in-progress", status: "executing", steps: [], currentStep: undefined }));
render(<GraphTaskNode {...props} />);
expect(screen.getByTestId("graph-task-node-FN-TEST").hasAttribute("data-current-step")).toBe(false);
});
it("does not set current-step for non-active tasks", () => {
const props = createProps(
createTask({
column: "todo",
status: "queued",
steps: [{ name: "step one", status: "in-progress" }],
currentStep: 0,
}),
);
render(<GraphTaskNode {...props} />);
expect(screen.getByTestId("graph-task-node-FN-TEST").hasAttribute("data-current-step")).toBe(false);
});
it("sets current-step to native step index when workflow steps are present", () => {
const props = createProps(
createTask({
column: "in-progress",
status: "executing",
steps: [
{ id: "native-1", name: "native one", status: "done" },
{ id: "native-2", name: "native two", status: "in-progress" },
],
enabledWorkflowSteps: ["wf-1"],
currentStep: 1,
}),
);
render(<GraphTaskNode {...props} />);
expect(screen.getByTestId("graph-task-node-FN-TEST").getAttribute("data-current-step")).toBe("1");
});
it("does not set current-step when native step list is empty even with workflow steps", () => {
const props = createProps(
createTask({
column: "in-progress",
status: "executing",
steps: [],
enabledWorkflowSteps: ["wf-1"],
currentStep: 0,
}),
);
render(<GraphTaskNode {...props} />);
expect(screen.getByTestId("graph-task-node-FN-TEST").hasAttribute("data-current-step")).toBe(false);
});
it("double-clicking card opens task detail exactly once", () => {
const props = createProps(createTask());
const { container } = render(<GraphTaskNode {...props} />);
const node = screen.getByTestId("graph-task-node-FN-TEST");
fireEvent.doubleClick(node);
expect(props.onOpenDetail).toHaveBeenCalledTimes(1);
expect(props.onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-TEST" }));
});
it("touch double-tap opens task detail exactly once", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const props = createProps(createTask());
render(<GraphTaskNode {...props} />);
const node = screen.getByTestId("graph-task-node-FN-TEST");
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 });
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 });
vi.advanceTimersByTime(120);
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 24, clientY: 32 });
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 24, clientY: 32 });
expect(props.onOpenDetail).toHaveBeenCalledTimes(1);
expect(props.onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-TEST" }));
});
it("touch taps outside the double-tap window do not open task detail", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const props = createProps(createTask());
render(<GraphTaskNode {...props} />);
const node = screen.getByTestId("graph-task-node-FN-TEST");
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 });
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 });
vi.advanceTimersByTime(320);
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 20, clientY: 30 });
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 20, clientY: 30 });
expect(props.onOpenDetail).not.toHaveBeenCalled();
});
it("touch drag gestures do not open task detail on pointer up", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const props = createProps(createTask());
render(<GraphTaskNode {...props} isSelected={true} />);
const node = screen.getByTestId("graph-task-node-FN-TEST");
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 });
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 });
vi.advanceTimersByTime(120);
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 20, clientY: 30 });
fireEvent.pointerMove(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 28, clientY: 30 });
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 28, clientY: 30 });
expect(props.onOpenDetail).not.toHaveBeenCalled();
});
it("mouse pointer taps do not trigger the touch double-tap path", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const props = createProps(createTask());
render(<GraphTaskNode {...props} />);
const node = screen.getByTestId("graph-task-node-FN-TEST");
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 1, pointerType: "mouse", clientX: 20, clientY: 30 });
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 1, pointerType: "mouse", clientX: 20, clientY: 30 });
vi.advanceTimersByTime(120);
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 2, pointerType: "mouse", clientX: 22, clientY: 30 });
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 2, pointerType: "mouse", clientX: 22, clientY: 30 });
expect(props.onOpenDetail).not.toHaveBeenCalled();
});
it("single click on active indicator surface does not open task detail", () => {
const props = createProps(createTask({ column: "in-progress", status: "executing" }));
const { container } = render(<GraphTaskNode {...props} />);
const indicator = container.querySelector(".graph-task-active-indicator");
expect(indicator).toBeTruthy();
fireEvent.click(indicator!);
expect(props.onOpenDetail).not.toHaveBeenCalled();
});
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} />
<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);
});
});
// FNXC:StuckTagRemoval 2026-08-17-22:30: stuck-task tagging removed from the dashboard; the stalled-card-as-stuck coverage went with it.
describe("active styling", () => {
it("still reads a legacy in-progress card as active when it is fresh", () => {
const props = createProps(createTask({ column: "in-progress", status: "executing", updatedAt: new Date().toISOString() } as Partial<Task>));
render(<GraphTaskNode {...props} />);
expect(screen.getByTestId("graph-task-node-FN-TEST").className).toContain("graph-task-node--active");
});
});