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, appStoreState } = vi.hoisted(() => ({ moveTaskMock: vi.fn(), appStoreState: { tasks: { activeSessionId: null }, chatInput: { planModeBySessionId: {} }, kanban: { tasks: [] }, kanbanMulti: { snapshots: {} }, taskSessions: { items: {} }, taskSessionsByTask: { itemsByTaskId: {} }, agentProfiles: { items: [] }, setPlanMode: vi.fn(), setActiveDocument: 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, })); vi.mock("@/components/integrations/use-hover-popover", () => ({ useHoverPopover: () => ({ open: true, onOpenChange: vi.fn(), onTriggerEnter: vi.fn(), onTriggerLeave: vi.fn(), onContentEnter: vi.fn(), onContentLeave: vi.fn(), }), })); // 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("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; }) => ( {children} ), DrawerTrigger: ({ children }: { children: React.ReactElement }) => { const context = React.useContext(DrawerContext); return React.cloneElement(children as React.ReactElement>, { onClick: () => context?.onOpenChange(true), }); }, DrawerContent: ({ children, ...props }: React.HTMLAttributes) => { const context = React.useContext(DrawerContext); return context?.open ? (
{children}
) : null; }, DrawerHeader: ({ children, ...props }: React.HTMLAttributes) => (
{children}
), DrawerTitle: ({ children, ...props }: React.HTMLAttributes) => (

{children}

), DrawerDescription: ({ children, ...props }: React.HTMLAttributes) => (

{children}

), }; }); afterEach(() => { cleanup(); vi.clearAllMocks(); mocks.touchDrawer = false; appStoreState.kanban.tasks.length = 0; const taskSessions = appStoreState.taskSessions.items as Record; for (const key of Object.keys(taskSessions)) { delete taskSessions[key]; } appStoreState.agentProfiles.items.length = 0; }); // 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: (selector: (state: typeof appStoreState) => unknown) => selector(appStoreState), })); 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"; const WORK_TEST_ID = "workflow-step-Work"; const MARKER_FOOTPRINT_HEIGHT = "h-2"; const MARKER_FOOTPRINT_WIDTH = "w-2"; const SPINNER_HEIGHT = "h-3.5"; const SPINNER_WIDTH = "w-3.5"; const MARKER_SELECTOR = "[data-marker-state]"; describe("WorkflowStepper", () => { it("shows a pending marker while a move request is unresolved", async () => { collapsedMock.mockReturnValue(false); let resolveMove!: () => void; moveTaskMock.mockReturnValueOnce( new Promise((resolve) => { resolveMove = resolve; }), ); render( , ); fireEvent.click(screen.getAllByRole("button", { name: "Move here" })[0]); await waitFor(() => expect( screen .getByTestId("workflow-step-Spec") .querySelector(MARKER_SELECTOR) ?.getAttribute("data-marker-state"), ).toBe("pending"), ); const marker = screen.getByTestId("workflow-step-Spec").querySelector(MARKER_SELECTOR); expect(marker?.classList.contains(MARKER_FOOTPRINT_HEIGHT)).toBe(true); expect(marker?.classList.contains(MARKER_FOOTPRINT_WIDTH)).toBe(true); const spinner = marker?.querySelector("svg"); expect(spinner?.getAttribute("data-marker-visual-size")).toBe("8"); expect(spinner?.classList.contains(MARKER_FOOTPRINT_HEIGHT)).toBe(true); expect(spinner?.classList.contains(MARKER_FOOTPRINT_WIDTH)).toBe(true); resolveMove(); }); it("keeps the current pending spinner's 14px outer decoration without changing its 8px footprint", () => { collapsedMock.mockReturnValue(false); (appStoreState.kanban.tasks as unknown[]).push({ id: TASK_ID, state: "SCHEDULING", workflowStepId: "b", }); render( , ); const marker = screen.getByTestId(WORK_TEST_ID).querySelector(MARKER_SELECTOR); expect(marker?.classList.contains(MARKER_FOOTPRINT_HEIGHT)).toBe(true); expect(marker?.classList.contains(MARKER_FOOTPRINT_WIDTH)).toBe(true); const spinner = marker?.querySelector("svg"); expect(spinner?.getAttribute("data-marker-visual-size")).toBe("14"); expect(spinner?.classList.contains(SPINNER_HEIGHT)).toBe(true); expect(spinner?.classList.contains(SPINNER_WIDTH)).toBe(true); }); it("renders every step when there is room (not collapsed)", () => { collapsedMock.mockReturnValue(false); render(); 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(WORK_TEST_ID)).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(); 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(); // 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(WORK_TEST_ID); 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 progress disclosure", () => { it("keeps lifecycle details inside the existing disclosure", () => { collapsedMock.mockReturnValue(true); (appStoreState.kanban.tasks as unknown[]).push({ id: TASK_ID, workflowId: WORKFLOW_ID, workflowStepId: "b", state: "SCHEDULING", }); (appStoreState.agentProfiles.items as unknown[]).push({ id: "profile-1", label: "Luna" }); const steps = DISCLOSURE_STEPS.map((step) => step.id === "b" ? { ...step, agent_profile_id: "profile-1" } : step, ); render( , ); expect(screen.queryByText("Preparing agent")).toBeNull(); fireEvent.mouseEnter(screen.getByRole("button", { name: TRIGGER_LABEL })); expect(screen.getByTestId("workflow-step-progress-b").textContent).toContain("Preparing agent"); expect(screen.getByTestId("workflow-step-progress-b").textContent).toContain("Luna"); expect(screen.getByTestId("workflow-step-disclosure-row-b").getAttribute("aria-current")).toBe( "step", ); }); it.each([ ["STARTING", true, "Stopping agent"], ["FAILED", false, "Agent failed"], ] as const)( "renders %s lifecycle status in the existing full disclosure", (sessionState, cancellationPending, label) => { collapsedMock.mockReturnValue(false); (appStoreState.kanban.tasks as unknown[]).push({ id: TASK_ID, state: "IN_PROGRESS", workflowStepId: "b", primarySessionId: "session-1", }); (appStoreState.taskSessions.items as Record)["session-1"] = { id: "session-1", task_id: TASK_ID, state: sessionState, is_primary: true, cancellation_pending: cancellationPending, }; render( , ); expect(screen.getByTestId("workflow-step-progress-b").textContent).toContain(label); }, ); }); describe("WorkflowStepper compact disclosure", () => { it("opens every ordered step for fine-pointer users and marks only eligible targets", () => { collapsedMock.mockReturnValue(true); render( , ); 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>); render( , ); 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( , ); 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( , ); 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( , ); 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(); // 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(); 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( , ); 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(WORK_TEST_ID)).toBeNull(); expect(screen.queryByRole("button")).toBeNull(); }); it("renders nothing when there are no steps", () => { collapsedMock.mockReturnValue(false); const { container } = render(); 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 & { onMoveError: typeof onMoveError }; render(); 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 & { onMoveError: typeof onMoveError }; render(); 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 & { onMoveStart: typeof onMoveStart }; render(); fireEvent.click(screen.getAllByRole("button", { name: "Move here" })[0]); expect(onMoveStart).toHaveBeenCalledTimes(1); }); });