Files
kandev/apps/web/components/review/walkthrough-overlay.test.tsx
T
2026-09-12 14:58:49 +02:00

345 lines
13 KiB
TypeScript

import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionStatus } from "@/lib/types/connection";
import type { TaskWalkthrough } from "@/lib/types/http";
import { StateProvider, useAppStore } from "@/components/state-provider";
import { deleteTaskWalkthrough, getTaskWalkthrough } from "@/lib/api/domains/walkthrough-api";
import { getOpenWalkthroughTaskId, setOpenWalkthroughTaskId } from "@/lib/walkthrough-open-state";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { WalkthroughOverlay } from "./walkthrough-overlay";
vi.mock("@/lib/api/domains/walkthrough-api", () => ({
deleteTaskWalkthrough: vi.fn(),
getTaskWalkthrough: vi.fn(),
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
vi.mock("@/components/diff/walkthrough-floating-window", () => ({
WalkthroughFloatingWindow: () => <div data-testid="walkthrough-floating" />,
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: vi.fn(),
}));
const TASK_ID = "task-1";
const SECOND_TASK_ID = "task-2";
const LAUNCHER_TEST_ID = "walkthrough-launcher";
const FLOATING_TEST_ID = "walkthrough-floating";
const DISCARD_BUTTON_TEST_ID = "walkthrough-discard";
const DISCARD_CONFIRMATION_TEST_ID = "walkthrough-discard-confirmation";
const ALERT_DIALOG_ROLE = "alertdialog";
const CANCEL_BUTTON_NAME = "Cancel";
const DISCARD_BUTTON_NAME = "Discard walkthrough";
function responsive(isFinePointer: boolean): ReturnType<typeof useResponsiveBreakpoint> {
return {
breakpoint: isFinePointer ? "desktop" : "mobile",
isMobile: !isFinePointer,
isTablet: false,
isDesktop: isFinePointer,
isCompactDesktop: false,
isFullDesktop: isFinePointer,
isFinePointer,
usesDesktopWorkbench: isFinePointer,
};
}
function walkthrough(taskId = TASK_ID): TaskWalkthrough {
return {
id: `walkthrough-${taskId}`,
task_id: taskId,
title: "Walkthrough",
created_by: "agent",
created_at: "2026-07-07T12:00:00Z",
updated_at: "2026-07-07T12:00:00Z",
steps: [{ file: "src/example.ts", line: 3, text: "Read this line." }],
};
}
let setConnectionStatus: ((status: ConnectionStatus) => void) | null = null;
let setActiveTask: ((taskId: string) => void) | null = null;
let setStoredWalkthrough: ((taskId: string, walkthrough: TaskWalkthrough | null) => void) | null =
null;
function StoreProbe() {
setConnectionStatus = useAppStore((s) => s.setConnectionStatus);
setActiveTask = useAppStore((s) => s.setActiveTask);
setStoredWalkthrough = useAppStore((s) => s.setWalkthrough);
return null;
}
function ActiveTaskOverlay() {
const taskId = useAppStore((s) => s.tasks.activeTaskId);
return <WalkthroughOverlay taskId={taskId} />;
}
function renderOverlay({ followActiveTask = false } = {}) {
setConnectionStatus = null;
setStoredWalkthrough = null;
return render(
<StateProvider
initialState={{
tasks: {
activeTaskId: TASK_ID,
activeSessionId: null,
pinnedSessionId: null,
lastSessionByTaskId: {},
resumeSkippedSessionIds: {},
},
}}
>
<StoreProbe />
{followActiveTask ? <ActiveTaskOverlay /> : <WalkthroughOverlay taskId={TASK_ID} />}
</StateProvider>,
);
}
function resetOverlayMocks() {
vi.mocked(getTaskWalkthrough).mockReset();
vi.mocked(deleteTaskWalkthrough).mockReset();
vi.mocked(useResponsiveBreakpoint).mockReturnValue(responsive(true));
setOpenWalkthroughTaskId(null);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe("WalkthroughOverlay", () => {
beforeEach(resetOverlayMocks);
it("waits for websocket connection before backfilling the launcher", async () => {
vi.mocked(getTaskWalkthrough).mockResolvedValue(walkthrough());
renderOverlay();
await waitFor(() => expect(setConnectionStatus).toBeTruthy());
expect(getTaskWalkthrough).not.toHaveBeenCalled();
act(() => setConnectionStatus?.("connected"));
expect(await screen.findByTestId(LAUNCHER_TEST_ID)).toBeTruthy();
expect(getTaskWalkthrough).toHaveBeenCalledWith(TASK_ID);
});
it("uses a readable selected launcher state and clears open state on close", async () => {
vi.mocked(getTaskWalkthrough).mockResolvedValue(walkthrough());
renderOverlay();
await waitFor(() => expect(setConnectionStatus).toBeTruthy());
act(() => setConnectionStatus?.("connected"));
const launcher = await screen.findByTestId(LAUNCHER_TEST_ID);
fireEvent.click(launcher);
await waitFor(() => expect(getOpenWalkthroughTaskId()).toBe(TASK_ID));
expect(screen.getByTestId(FLOATING_TEST_ID)).toBeTruthy();
expect(launcher.className).not.toContain("bg-accent");
const count = within(launcher).getByText("1/1");
expect(count.className).toContain("text-foreground");
fireEvent.click(launcher);
expect(getOpenWalkthroughTaskId()).toBeNull();
});
it("closes the walkthrough when the active task changes", async () => {
vi.mocked(getTaskWalkthrough).mockImplementation(async (taskId) =>
taskId === TASK_ID ? walkthrough() : null,
);
const view = renderOverlay({ followActiveTask: true });
act(() => setConnectionStatus?.("connected"));
fireEvent.click(await within(view.container).findByTestId(LAUNCHER_TEST_ID));
await waitFor(() => expect(getOpenWalkthroughTaskId()).toBe(TASK_ID));
act(() => setActiveTask?.(SECOND_TASK_ID));
await waitFor(() => expect(getOpenWalkthroughTaskId()).toBeNull());
});
});
describe("WalkthroughOverlay discard task binding", () => {
beforeEach(resetOverlayMocks);
it("keeps discard bound to the task that opened the confirmation", async () => {
vi.mocked(getTaskWalkthrough).mockImplementation(async (taskId) => walkthrough(taskId));
vi.mocked(deleteTaskWalkthrough).mockResolvedValue(undefined);
renderOverlay({ followActiveTask: true });
act(() => {
setStoredWalkthrough?.(SECOND_TASK_ID, walkthrough(SECOND_TASK_ID));
setConnectionStatus?.("connected");
});
await screen.findByTestId(LAUNCHER_TEST_ID);
fireEvent.click(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
await screen.findByTestId(DISCARD_CONFIRMATION_TEST_ID);
act(() => setActiveTask?.(SECOND_TASK_ID));
await screen.findByTestId(LAUNCHER_TEST_ID);
await waitFor(() => expect(screen.queryByTestId(DISCARD_CONFIRMATION_TEST_ID)).toBeNull());
expect(deleteTaskWalkthrough).not.toHaveBeenCalled();
act(() => setActiveTask?.(TASK_ID));
const confirmation = await screen.findByTestId(DISCARD_CONFIRMATION_TEST_ID);
fireEvent.click(
within(confirmation).getByRole("button", {
name: DISCARD_BUTTON_NAME,
}),
);
await waitFor(() => expect(deleteTaskWalkthrough).toHaveBeenCalledWith(TASK_ID));
expect(deleteTaskWalkthrough).not.toHaveBeenCalledWith(SECOND_TASK_ID);
});
});
describe("WalkthroughOverlay fine-pointer discard confirmation", () => {
beforeEach(resetOverlayMocks);
it("confirms fine-pointer discard in an anchored non-modal shell", async () => {
vi.mocked(getTaskWalkthrough).mockResolvedValue(walkthrough());
renderOverlay();
act(() => setConnectionStatus?.("connected"));
const launcher = await screen.findByTestId(LAUNCHER_TEST_ID);
fireEvent.click(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
const confirmation = await screen.findByTestId(DISCARD_CONFIRMATION_TEST_ID);
expect(confirmation.getAttribute("role")).toBe("dialog");
expect(screen.queryByRole(ALERT_DIALOG_ROLE)).toBeNull();
expect(launcher).toBeTruthy();
});
it("cancels discard without closing the walkthrough", async () => {
vi.mocked(getTaskWalkthrough).mockResolvedValue(walkthrough());
renderOverlay();
act(() => setConnectionStatus?.("connected"));
const launcher = await screen.findByTestId(LAUNCHER_TEST_ID);
fireEvent.click(launcher);
await waitFor(() => expect(screen.getByTestId(FLOATING_TEST_ID)).toBeTruthy());
fireEvent.click(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
fireEvent.click(
within(screen.getByTestId(DISCARD_CONFIRMATION_TEST_ID)).getByRole("button", {
name: CANCEL_BUTTON_NAME,
}),
);
await waitFor(() => expect(screen.queryByTestId(DISCARD_CONFIRMATION_TEST_ID)).toBeNull());
expect(screen.getByTestId(FLOATING_TEST_ID)).toBeTruthy();
expect(getOpenWalkthroughTaskId()).toBe(TASK_ID);
});
it("closes the local shell before discarding once", async () => {
vi.mocked(getTaskWalkthrough).mockResolvedValue(walkthrough());
let shellClosedBeforeDelete = false;
vi.mocked(deleteTaskWalkthrough).mockImplementation(async () => {
shellClosedBeforeDelete = screen.queryByTestId(DISCARD_CONFIRMATION_TEST_ID) === null;
});
renderOverlay();
act(() => setConnectionStatus?.("connected"));
await screen.findByTestId(LAUNCHER_TEST_ID);
fireEvent.click(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
fireEvent.click(
within(screen.getByTestId(DISCARD_CONFIRMATION_TEST_ID)).getByRole("button", {
name: DISCARD_BUTTON_NAME,
}),
);
await waitFor(() => expect(deleteTaskWalkthrough).toHaveBeenCalledTimes(1));
expect(shellClosedBeforeDelete).toBe(true);
expect(screen.queryByTestId(DISCARD_CONFIRMATION_TEST_ID)).toBeNull();
expect(screen.queryByTestId(LAUNCHER_TEST_ID)).toBeNull();
expect(screen.queryByTestId(FLOATING_TEST_ID)).toBeNull();
});
});
describe("WalkthroughOverlay coarse-pointer discard confirmation", () => {
beforeEach(resetOverlayMocks);
it("shows a named phone sheet without replacing the walkthrough launcher", async () => {
vi.mocked(useResponsiveBreakpoint).mockReturnValue(responsive(false));
vi.mocked(getTaskWalkthrough).mockResolvedValue(walkthrough());
renderOverlay();
act(() => setConnectionStatus?.("connected"));
await screen.findByTestId(LAUNCHER_TEST_ID);
fireEvent.click(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
const confirmation = await screen.findByTestId(DISCARD_CONFIRMATION_TEST_ID);
expect(screen.getByRole("dialog").getAttribute("data-slot")).toBe("drawer-content");
expect(confirmation.textContent).toContain("Walkthrough");
expect(screen.getByTestId(DISCARD_BUTTON_TEST_ID).isConnected).toBe(true);
expect(confirmation.getAttribute("role")).toBe("group");
expect(screen.queryByRole(ALERT_DIALOG_ROLE)).toBeNull();
expect(
within(confirmation).getByRole("button", { name: DISCARD_BUTTON_NAME }).className,
).toContain("min-h-12");
});
it("restores focus to the coarse-pointer discard button after cancel", async () => {
let runAnimationFrame: FrameRequestCallback | null = null;
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
runAnimationFrame = callback;
return 0;
});
vi.mocked(useResponsiveBreakpoint).mockReturnValue(responsive(false));
vi.mocked(getTaskWalkthrough).mockResolvedValue(walkthrough());
renderOverlay();
act(() => setConnectionStatus?.("connected"));
await screen.findByTestId(LAUNCHER_TEST_ID);
fireEvent.click(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
fireEvent.click(
within(await screen.findByTestId(DISCARD_CONFIRMATION_TEST_ID)).getByRole("button", {
name: CANCEL_BUTTON_NAME,
}),
);
act(() => runAnimationFrame?.(0));
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByTestId(DISCARD_BUTTON_TEST_ID)),
);
});
});
describe("WalkthroughOverlay discard mutation state", () => {
beforeEach(resetOverlayMocks);
it("disables repeated discard while deletion is in flight", async () => {
let finishDelete: (() => void) | null = null;
vi.mocked(getTaskWalkthrough).mockResolvedValue(walkthrough());
vi.mocked(deleteTaskWalkthrough).mockImplementation(
() =>
new Promise<void>((resolve) => {
finishDelete = resolve;
}),
);
renderOverlay();
act(() => setConnectionStatus?.("connected"));
await screen.findByTestId(LAUNCHER_TEST_ID);
fireEvent.click(screen.getByTestId(DISCARD_BUTTON_TEST_ID));
fireEvent.click(
within(await screen.findByTestId(DISCARD_CONFIRMATION_TEST_ID)).getByRole("button", {
name: DISCARD_BUTTON_NAME,
}),
);
await waitFor(() => expect(deleteTaskWalkthrough).toHaveBeenCalledTimes(1));
const disabledDuringDelete = (screen.getByTestId(DISCARD_BUTTON_TEST_ID) as HTMLButtonElement)
.disabled;
act(() => finishDelete?.());
await waitFor(() => expect(screen.queryByTestId(LAUNCHER_TEST_ID)).toBeNull());
expect(disabledDuringDelete).toBe(true);
});
});