Files
kandev/apps/web/components/task/workflow-stepper.test.tsx
T
2026-09-11 12:51:31 +01:00

418 lines
15 KiB
TypeScript

import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ComponentProps, ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { moveTask } from "@/lib/api";
import { WorkflowStepper, type WorkflowStepperStep } from "./workflow-stepper";
const { moveTaskMock } = vi.hoisted(() => ({ moveTaskMock: vi.fn() }));
const mocks = vi.hoisted(() => ({ touchDrawer: false }));
function Passthrough({ children }: { children: ReactNode }) {
return <>{children}</>;
}
vi.mock("@/lib/api", () => ({
moveTask: moveTaskMock,
}));
vi.mock("@kandev/ui/hover-card", () => ({
HoverCard: Passthrough,
HoverCardTrigger: Passthrough,
HoverCardContent: Passthrough,
}));
// The stepper only threads the shared move-options draft through the hover
// popover; the fields themselves are covered by the form's own tests.
vi.mock("./workflow-move-options", () => ({
useWorkflowMoveOptionsForm: () => ({
draft: {},
patchDraft: vi.fn(),
resetDraft: vi.fn(),
}),
WorkflowMoveOptionsFields: () => null,
workflowMoveOptionsPayload: () => undefined,
}));
vi.mock("@/hooks/use-compact-task-chrome", () => ({
useTouchDrawer: () => mocks.touchDrawer,
}));
vi.mock("@kandev/ui/drawer", async () => {
const React = await vi.importActual<typeof import("react")>("react");
const DrawerContext = React.createContext<{
open: boolean;
onOpenChange: (open: boolean) => void;
} | null>(null);
return {
Drawer: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
}) => (
<DrawerContext.Provider value={{ open, onOpenChange }}>{children}</DrawerContext.Provider>
),
DrawerTrigger: ({ children }: { children: React.ReactElement }) => {
const context = React.useContext(DrawerContext);
return React.cloneElement(children as React.ReactElement<Record<string, unknown>>, {
onClick: () => context?.onOpenChange(true),
});
},
DrawerContent: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => {
const context = React.useContext(DrawerContext);
return context?.open ? (
<div role="dialog" {...props}>
{children}
</div>
) : null;
},
DrawerHeader: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
DrawerTitle: ({ children, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2 {...props}>{children}</h2>
),
DrawerDescription: ({ children, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => (
<p {...props}>{children}</p>
),
};
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
mocks.touchDrawer = false;
});
// useToolbarCollapsed is mocked because the test DOM can't measure offsetWidth.
const collapsedMock = vi.fn(() => false);
vi.mock("@/hooks/use-toolbar-collapsed", () => ({
useToolbarCollapsed: () => collapsedMock(),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: () => undefined,
}));
vi.mock("@/lib/state/context-files-store", () => ({
useContextFilesStore: () => vi.fn(),
}));
vi.mock("@/lib/state/layout-store", () => ({
useLayoutStore: () => vi.fn(),
}));
vi.mock("@/lib/state/dockview-store", () => ({
useDockviewStore: () => vi.fn(),
}));
const STEPS: WorkflowStepperStep[] = [
{ id: "a", name: "Spec", color: "#111", position: 0 },
{ id: "b", name: "Work", color: "#222", position: 1 },
{ id: "c", name: "Review", color: "#333", position: 2 },
];
const DISCLOSURE_STEPS: WorkflowStepperStep[] = [
...STEPS,
{ id: "d", name: "Done", color: "#444", position: 3, allow_manual_move: false },
];
const TASK_ID = "task-1";
const WORKFLOW_ID = "workflow-1";
const DISCLOSURE_TEST_ID = "workflow-step-disclosure";
const MOVE_A_TEST_ID = "workflow-step-disclosure-move-a";
const MOVE_C_TEST_ID = "workflow-step-disclosure-move-c";
const MOVE_D_TEST_ID = "workflow-step-disclosure-move-d";
const TRIGGER_LABEL = "Step 2 of 4: Work";
const SPEC_TEST_ID = "workflow-step-Spec";
const ARIA_CURRENT = "aria-current";
describe("WorkflowStepper", () => {
it("renders every step when there is room (not collapsed)", () => {
collapsedMock.mockReturnValue(false);
render(<WorkflowStepper steps={STEPS} currentStepId="b" />);
expect(screen.getByTestId("workflow-stepper")).toBeTruthy();
expect(screen.queryByTestId("workflow-stepper-minimal")).toBeNull();
// All steps render under the persistent outer container.
expect(screen.getByTestId(SPEC_TEST_ID)).toBeTruthy();
expect(screen.getByTestId("workflow-step-Work")).toBeTruthy();
expect(screen.getByTestId("workflow-step-Review")).toBeTruthy();
});
it("orders steps sharing a position by ascending id, not insertion order", () => {
collapsedMock.mockReturnValue(false);
const TIEBREAK_STEPS: WorkflowStepperStep[] = [
{ id: "z", name: "ZStep", color: "#111", position: 0 },
{ id: "a", name: "AStep", color: "#222", position: 0 },
];
const { container } = render(<WorkflowStepper steps={TIEBREAK_STEPS} currentStepId={null} />);
const aIndex = container.innerHTML.indexOf("AStep");
const zIndex = container.innerHTML.indexOf("ZStep");
expect(aIndex).toBeGreaterThan(-1);
expect(aIndex).toBeLessThan(zIndex);
});
it("collapses to only the current step when space runs out", () => {
collapsedMock.mockReturnValue(true);
render(<WorkflowStepper steps={STEPS} currentStepId="b" />);
// Outer container persists across variants (stable e2e locator); minimal child marks collapsed state.
expect(screen.getByTestId("workflow-stepper")).toBeTruthy();
expect(screen.getByTestId("workflow-stepper-minimal")).toBeTruthy();
// Current step keeps its test id + aria-current in either variant.
const current = screen.getByTestId("workflow-step-Work");
expect(current.getAttribute(ARIA_CURRENT)).toBe("step");
expect(screen.queryByTestId(SPEC_TEST_ID)).toBeNull();
expect(screen.queryByTestId("workflow-step-Review")).toBeNull();
// Position indicator reflects the current step out of the total.
expect(screen.getByText("2/3")).toBeTruthy();
});
});
describe("WorkflowStepper compact disclosure", () => {
it("opens every ordered step for fine-pointer users and marks only eligible targets", () => {
collapsedMock.mockReturnValue(true);
render(
<WorkflowStepper
steps={DISCLOSURE_STEPS}
currentStepId="b"
taskId={TASK_ID}
workflowId={WORKFLOW_ID}
/>,
);
const trigger = screen.getByRole("button", { name: TRIGGER_LABEL });
expect(trigger.getAttribute("aria-expanded")).toBe("false");
fireEvent.mouseEnter(trigger);
expect(screen.getByTestId(DISCLOSURE_TEST_ID)).toBeTruthy();
expect(screen.getByTestId("workflow-step-disclosure-row-a")).toBeTruthy();
expect(screen.getByTestId("workflow-step-disclosure-row-b").getAttribute(ARIA_CURRENT)).toBe(
"step",
);
expect(screen.getByTestId("workflow-step-disclosure-row-c")).toBeTruthy();
expect(screen.getByTestId("workflow-step-disclosure-row-d")).toBeTruthy();
expect(screen.getByTestId(MOVE_A_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId("workflow-step-disclosure-move-b")).toBeNull();
expect(screen.getByTestId(MOVE_C_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(MOVE_D_TEST_ID)).toBeNull();
});
it("moves an eligible target with the existing payload and closes after success", async () => {
collapsedMock.mockReturnValue(true);
vi.mocked(moveTask).mockResolvedValue({} as Awaited<ReturnType<typeof moveTask>>);
render(
<WorkflowStepper
steps={DISCLOSURE_STEPS}
currentStepId="b"
taskId={TASK_ID}
workflowId={WORKFLOW_ID}
/>,
);
fireEvent.mouseEnter(screen.getByRole("button", { name: TRIGGER_LABEL }));
fireEvent.click(screen.getByTestId(MOVE_C_TEST_ID));
await waitFor(() =>
expect(moveTask).toHaveBeenCalledWith(TASK_ID, {
workflow_id: WORKFLOW_ID,
workflow_step_id: "c",
position: 0,
}),
);
await waitFor(() => expect(screen.queryByTestId(DISCLOSURE_TEST_ID)).toBeNull());
});
it("keeps the disclosure open and re-enables the target after a failed move", async () => {
collapsedMock.mockReturnValue(true);
vi.mocked(moveTask).mockRejectedValue(new Error("network error"));
render(
<WorkflowStepper
steps={DISCLOSURE_STEPS}
currentStepId="b"
taskId={TASK_ID}
workflowId={WORKFLOW_ID}
/>,
);
fireEvent.mouseEnter(screen.getByRole("button", { name: TRIGGER_LABEL }));
const moveButton = screen.getByTestId(MOVE_C_TEST_ID);
fireEvent.click(moveButton);
await waitFor(() => expect(moveButton.hasAttribute("disabled")).toBe(false));
expect(screen.getByTestId(DISCLOSURE_TEST_ID)).toBeTruthy();
});
it("uses the same step choices in a coarse-pointer drawer", () => {
collapsedMock.mockReturnValue(true);
mocks.touchDrawer = true;
render(
<WorkflowStepper
steps={DISCLOSURE_STEPS}
currentStepId="b"
taskId={TASK_ID}
workflowId={WORKFLOW_ID}
/>,
);
fireEvent.click(screen.getByRole("button", { name: TRIGGER_LABEL }));
expect(screen.getByRole("dialog")).toBeTruthy();
expect(screen.getByTestId(DISCLOSURE_TEST_ID)).toBeTruthy();
expect(screen.getByTestId(MOVE_A_TEST_ID)).toBeTruthy();
expect(screen.getByTestId(MOVE_C_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(MOVE_D_TEST_ID)).toBeNull();
});
});
describe("WorkflowStepper compact disclosure options", () => {
it("reveals opt-in one-time move options per eligible target", () => {
collapsedMock.mockReturnValue(true);
render(
<WorkflowStepper
steps={DISCLOSURE_STEPS}
currentStepId="b"
taskId={TASK_ID}
workflowId={WORKFLOW_ID}
/>,
);
fireEvent.mouseEnter(screen.getByRole("button", { name: TRIGGER_LABEL }));
// Only eligible, non-current targets expose the options toggle.
expect(screen.getByTestId("workflow-step-disclosure-options-c")).toBeTruthy();
expect(screen.queryByTestId("workflow-step-disclosure-options-b")).toBeNull();
expect(screen.queryByTestId("workflow-step-disclosure-options-d")).toBeNull();
// The options panel stays hidden until the toggle is pressed.
expect(screen.queryByTestId("workflow-step-disclosure-options-panel-c")).toBeNull();
fireEvent.click(screen.getByTestId("workflow-step-disclosure-options-c"));
expect(screen.getByTestId("workflow-step-disclosure-options-panel-c")).toBeTruthy();
});
});
describe("WorkflowStepper fallback states", () => {
it("falls back to the first step when collapsed with no current step", () => {
collapsedMock.mockReturnValue(true);
render(<WorkflowStepper steps={STEPS} currentStepId={null} />);
// Fallback step isn't the real current step, so it must not claim aria-current.
const fallbackStep = screen.getByTestId(SPEC_TEST_ID);
expect(fallbackStep.getAttribute(ARIA_CURRENT)).toBeNull();
// aria-current alone is a false negative here: it was already conditioned
// correctly before the fix. The marker itself must also not read "current".
expect(
fallbackStep.querySelector("[data-marker-state]")?.getAttribute("data-marker-state"),
).toBe("upcoming");
expect(screen.getByText("1/3")).toBeTruthy();
});
it("marks the marker current when collapsed on a resolved current step", () => {
collapsedMock.mockReturnValue(true);
render(<WorkflowStepper steps={STEPS} currentStepId="a" />);
const currentStep = screen.getByTestId(SPEC_TEST_ID);
expect(currentStep.getAttribute(ARIA_CURRENT)).toBe("step");
expect(
currentStep.querySelector("[data-marker-state]")?.getAttribute("data-marker-state"),
).toBe("current");
});
it("shows the archived badge instead of a step when collapsed and archived", () => {
collapsedMock.mockReturnValue(true);
render(
<WorkflowStepper
steps={STEPS}
currentStepId="b"
taskId={TASK_ID}
workflowId={WORKFLOW_ID}
isArchived
/>,
);
expect(screen.getByText("Archived")).toBeTruthy();
// Archived badge carries the minimal test id for collapsed-mode detection.
expect(screen.getByTestId("workflow-stepper-minimal")).toBeTruthy();
expect(screen.queryByTestId("workflow-step-Work")).toBeNull();
expect(screen.queryByRole("button")).toBeNull();
});
it("renders nothing when there are no steps", () => {
collapsedMock.mockReturnValue(false);
const { container } = render(<WorkflowStepper steps={[]} currentStepId={null} />);
expect(container.innerHTML).toBe("");
});
it("reports a rejected move to the owning surface", async () => {
const error = new Error("task has an active session (RUNNING)");
moveTaskMock.mockRejectedValueOnce(error);
const onMoveError = vi.fn();
const props = {
steps: STEPS,
currentStepId: "b",
taskId: TASK_ID,
workflowId: WORKFLOW_ID,
onMoveError,
} as ComponentProps<typeof WorkflowStepper> & { onMoveError: typeof onMoveError };
render(<WorkflowStepper {...props} />);
const moveButton = screen.getAllByRole("button", { name: "Move here" })[0];
fireEvent.click(moveButton);
await waitFor(() => expect(onMoveError).toHaveBeenCalledWith(error));
expect(moveButton.hasAttribute("disabled")).toBe(false);
});
it("ignores a superseded move's rejection so it cannot overwrite a newer result", async () => {
// Only the in-flight step's own button is disabled, so the user can start a
// second move before the first resolves. A rejection from the abandoned
// request must not paint a banner describing a move nobody is waiting on.
let rejectFirst!: (error: unknown) => void;
moveTaskMock.mockReturnValueOnce(new Promise((_res, rej) => (rejectFirst = rej)));
moveTaskMock.mockResolvedValueOnce(undefined);
const onMoveError = vi.fn();
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const props = {
steps: STEPS,
currentStepId: "b",
taskId: TASK_ID,
workflowId: WORKFLOW_ID,
onMoveError,
} as ComponentProps<typeof WorkflowStepper> & { onMoveError: typeof onMoveError };
render(<WorkflowStepper {...props} />);
const moveButtons = screen.getAllByRole("button", { name: "Move here" });
fireEvent.click(moveButtons[0]);
fireEvent.click(moveButtons[1]);
await waitFor(() => expect(moveTaskMock).toHaveBeenCalledTimes(2));
rejectFirst(new Error("task has an active session (RUNNING)"));
await waitFor(() => expect(consoleErrorSpy).toHaveBeenCalled());
expect(onMoveError).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
it("notifies the owning surface when a move starts", () => {
const onMoveStart = vi.fn();
moveTaskMock.mockResolvedValueOnce(undefined);
const props = {
steps: STEPS,
currentStepId: "b",
taskId: TASK_ID,
workflowId: WORKFLOW_ID,
onMoveStart,
} as ComponentProps<typeof WorkflowStepper> & { onMoveStart: typeof onMoveStart };
render(<WorkflowStepper {...props} />);
fireEvent.click(screen.getAllByRole("button", { name: "Move here" })[0]);
expect(onMoveStart).toHaveBeenCalledTimes(1);
});
});