feat(ui): localize walkthrough discard confirmation (#2879)

This commit is contained in:
José Almeida
2026-08-21 16:34:07 +01:00
committed by GitHub
parent 580cadfcaf
commit f2f874cdbc
5 changed files with 370 additions and 113 deletions
@@ -1,10 +1,11 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
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", () => ({
@@ -20,13 +21,37 @@ 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 walkthrough(): TaskWalkthrough {
function responsive(isFinePointer: boolean): ReturnType<typeof useResponsiveBreakpoint> {
return {
id: "walkthrough-1",
task_id: TASK_ID,
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",
@@ -37,10 +62,13 @@ function walkthrough(): TaskWalkthrough {
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;
}
@@ -51,6 +79,7 @@ function ActiveTaskOverlay() {
function renderOverlay({ followActiveTask = false } = {}) {
setConnectionStatus = null;
setStoredWalkthrough = null;
return render(
<StateProvider
initialState={{
@@ -69,12 +98,20 @@ function renderOverlay({ followActiveTask = false } = {}) {
);
}
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(() => {
vi.mocked(getTaskWalkthrough).mockReset();
vi.mocked(deleteTaskWalkthrough).mockReset();
setOpenWalkthroughTaskId(null);
});
beforeEach(resetOverlayMocks);
it("waits for websocket connection before backfilling the launcher", async () => {
vi.mocked(getTaskWalkthrough).mockResolvedValue(walkthrough());
@@ -86,7 +123,7 @@ describe("WalkthroughOverlay", () => {
act(() => setConnectionStatus?.("connected"));
expect(await screen.findByTestId("walkthrough-launcher")).toBeTruthy();
expect(await screen.findByTestId(LAUNCHER_TEST_ID)).toBeTruthy();
expect(getTaskWalkthrough).toHaveBeenCalledWith(TASK_ID);
});
@@ -95,12 +132,12 @@ describe("WalkthroughOverlay", () => {
renderOverlay();
await waitFor(() => expect(setConnectionStatus).toBeTruthy());
act(() => setConnectionStatus?.("connected"));
const launcher = await screen.findByTestId("walkthrough-launcher");
const launcher = await screen.findByTestId(LAUNCHER_TEST_ID);
fireEvent.click(launcher);
await waitFor(() => expect(getOpenWalkthroughTaskId()).toBe(TASK_ID));
expect(screen.getByTestId("walkthrough-floating")).toBeTruthy();
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");
@@ -117,7 +154,7 @@ describe("WalkthroughOverlay", () => {
const view = renderOverlay({ followActiveTask: true });
act(() => setConnectionStatus?.("connected"));
fireEvent.click(await within(view.container).findByTestId("walkthrough-launcher"));
fireEvent.click(await within(view.container).findByTestId(LAUNCHER_TEST_ID));
await waitFor(() => expect(getOpenWalkthroughTaskId()).toBe(TASK_ID));
act(() => setActiveTask?.(SECOND_TASK_ID));
@@ -125,3 +162,178 @@ describe("WalkthroughOverlay", () => {
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("morphs coarse-pointer discard into local touch actions", 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(confirmation.getAttribute("role")).toBe("group");
expect(screen.queryByRole(ALERT_DIALOG_ROLE)).toBeNull();
expect(
within(confirmation).getByRole("button", { name: DISCARD_BUTTON_NAME }).className,
).toContain("h-11");
});
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));
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);
});
});
@@ -1,17 +1,7 @@
"use client";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { useEffect, useRef, useState, type RefObject } from "react";
import { IconRoute, IconX } from "@tabler/icons-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { deleteTaskWalkthrough, getTaskWalkthrough } from "@/lib/api/domains/walkthrough-api";
@@ -20,6 +10,9 @@ import { clearOpenWalkthroughTaskId, setOpenWalkthroughTaskId } from "@/lib/walk
import { cn } from "@kandev/ui/lib/utils";
import type { TaskWalkthrough } from "@/lib/types/http";
import { useTranslation } from "react-i18next";
import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
type WalkthroughOverlayProps = {
/** The task whose walkthrough launcher should be shown. */
@@ -31,22 +24,40 @@ type WalkthroughOverlayProps = {
type WalkthroughLauncherProps = {
activeStep: number;
confirmDiscardOpen: boolean;
discardAnchorRef: RefObject<HTMLButtonElement | null>;
discardDisabled: boolean;
hasUnseen: boolean;
isFinePointer: boolean;
isOpen: boolean;
onDiscardClick: () => void;
onDiscardCancel: () => void;
onDiscardClose: () => void;
onDiscardConfirm: () => void | Promise<void>;
onToggle: () => void;
stepCount: number;
};
function WalkthroughLauncher({
activeStep,
confirmDiscardOpen,
discardAnchorRef,
discardDisabled,
hasUnseen,
isFinePointer,
isOpen,
onDiscardClick,
onDiscardCancel,
onDiscardClose,
onDiscardConfirm,
onToggle,
stepCount,
}: WalkthroughLauncherProps) {
const { t } = useTranslation();
const discardLabel = t("review:discardWalkthrough");
const discardVisibility = isFinePointer
? "opacity-0 pointer-events-none scale-90 group-hover:pointer-events-auto group-hover:scale-100 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:scale-100 group-focus-within:opacity-100"
: "";
return (
<div className="group fixed bottom-[calc(1.5rem+var(--app-status-bar-height))] right-6 z-[41]">
<button
@@ -79,67 +90,45 @@ function WalkthroughLauncher({
{activeStep + 1}/{stepCount}
</span>
</button>
<button
type="button"
aria-label={t("review:discardWalkthrough")}
title={t("review:discardWalkthrough")}
data-testid="walkthrough-discard"
onClick={onDiscardClick}
className={cn(
"absolute -right-3 -top-3 flex size-9 cursor-pointer items-center justify-center rounded-full",
"border border-border bg-card text-muted-foreground shadow-md transition-all",
"hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
"sm:size-7 sm:opacity-0 sm:pointer-events-none sm:scale-90",
"sm:group-hover:pointer-events-auto sm:group-hover:scale-100 sm:group-hover:opacity-100",
"sm:group-focus-within:pointer-events-auto sm:group-focus-within:scale-100 sm:group-focus-within:opacity-100",
)}
>
<IconX className="size-4" />
</button>
{isFinePointer || !confirmDiscardOpen ? (
<button
ref={discardAnchorRef}
type="button"
aria-label={discardLabel}
title={discardLabel}
data-testid="walkthrough-discard"
disabled={discardDisabled}
onClick={onDiscardClick}
className={cn(
"absolute -right-3 -top-3 flex cursor-pointer items-center justify-center rounded-full",
isFinePointer ? "h-7 min-h-7 w-7 min-w-7" : "h-11 min-h-11 w-11 min-w-11",
"border border-border bg-card text-muted-foreground shadow-md transition-all",
"hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-45",
discardVisibility,
)}
>
<IconX className="size-4" />
</button>
) : (
<div className="absolute bottom-full right-0 z-[1] mb-2 rounded-lg border border-border bg-card/95 p-1 shadow-lg backdrop-blur-sm">
<InlineConfirmActions
density="touch"
testId="walkthrough-discard-confirmation"
ariaLabel={t("review:discardWalkthroughTitle")}
cancelLabel={t("common:cancel")}
confirmLabel={discardLabel}
confirmAriaLabel={discardLabel}
onCancel={onDiscardCancel}
onClose={onDiscardClose}
onConfirm={onDiscardConfirm}
/>
</div>
)}
</div>
);
}
type DiscardWalkthroughDialogProps = {
discarding: boolean;
onConfirm: (event: MouseEvent<HTMLButtonElement>) => void;
onOpenChange: (open: boolean) => void;
open: boolean;
};
function DiscardWalkthroughDialog({
discarding,
onConfirm,
onOpenChange,
open,
}: DiscardWalkthroughDialogProps) {
const { t } = useTranslation();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent data-testid="walkthrough-discard-dialog">
<AlertDialogHeader>
<AlertDialogTitle>{t("review:discardWalkthroughTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("review:discardWalkthroughDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer" disabled={discarding}>
{t("common:cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={discarding}
onClick={onConfirm}
>
{t("review:discardWalkthrough")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
function useWalkthroughBackfill(params: {
connectionStatus: string;
setWalkthrough: (taskId: string, walkthrough: TaskWalkthrough | null) => void;
@@ -189,6 +178,8 @@ function useWalkthroughBackfill(params: {
*/
export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayProps) {
const { t } = useTranslation();
const discardLabel = t("review:discardWalkthrough");
const { isFinePointer } = useResponsiveBreakpoint();
const walkthrough = useAppStore((s) => (taskId ? s.walkthroughs.byTaskId[taskId] : null));
const connectionStatus = useAppStore((s) => s.connection.status);
const activeStep = useAppStore((s) =>
@@ -199,10 +190,12 @@ export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayP
);
const setWalkthrough = useAppStore((s) => s.setWalkthrough);
const [openTaskId, setOpenTaskId] = useState<string | null>(null);
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
const [discardTargetTaskId, setDiscardTargetTaskId] = useState<string | null>(null);
const [discarding, setDiscarding] = useState(false);
const discardAnchorRef = useRef<HTMLButtonElement>(null);
const { toast } = useToast();
const open = taskId !== null && openTaskId === taskId;
const confirmDiscardOpen = taskId !== null && discardTargetTaskId === taskId;
useWalkthroughBackfill({ connectionStatus, setWalkthrough, taskId, walkthrough });
@@ -231,16 +224,15 @@ export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayP
clearOpenWalkthroughTaskId(taskId);
setOpenTaskId(null);
};
const confirmDiscard = async (event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
if (!taskId || discarding) return;
const discardWalkthrough = async (targetTaskId: string | null) => {
if (!targetTaskId || discarding) return;
setDiscarding(true);
try {
await deleteTaskWalkthrough(taskId);
clearOpenWalkthroughTaskId(taskId);
setOpenTaskId(null);
setWalkthrough(taskId, null);
setConfirmDiscardOpen(false);
await deleteTaskWalkthrough(targetTaskId);
clearOpenWalkthroughTaskId(targetTaskId);
setOpenTaskId((currentTaskId) => (currentTaskId === targetTaskId ? null : currentTaskId));
setWalkthrough(targetTaskId, null);
setDiscardTargetTaskId(null);
toast({ title: t("review:walkthroughDiscarded"), variant: "success" });
} catch (error) {
console.error("Failed to discard walkthrough:", error);
@@ -255,18 +247,36 @@ export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayP
{open ? <WalkthroughFloatingWindow onClose={closeTour} onSelectFile={onSelectFile} /> : null}
<WalkthroughLauncher
activeStep={activeStep}
confirmDiscardOpen={confirmDiscardOpen}
discardAnchorRef={discardAnchorRef}
discardDisabled={discarding}
hasUnseen={hasUnseen}
isFinePointer={isFinePointer}
isOpen={open}
onDiscardClick={() => setConfirmDiscardOpen(true)}
onDiscardClick={() => setDiscardTargetTaskId(taskId)}
onDiscardCancel={() => {
setDiscardTargetTaskId(null);
requestAnimationFrame(() => discardAnchorRef.current?.focus());
}}
onDiscardClose={() => setDiscardTargetTaskId(null)}
onDiscardConfirm={() => discardWalkthrough(discardTargetTaskId)}
onToggle={() => (open ? closeTour() : openTour())}
stepCount={walkthrough.steps.length}
/>
<DiscardWalkthroughDialog
discarding={discarding}
onConfirm={confirmDiscard}
onOpenChange={setConfirmDiscardOpen}
open={confirmDiscardOpen}
/>
{isFinePointer ? (
<ActionConfirmPopover
open={confirmDiscardOpen}
anchorRef={discardAnchorRef}
title={t("review:discardWalkthroughTitle")}
description={t("review:discardWalkthroughDescription")}
cancelLabel={t("common:cancel")}
confirmLabel={discardLabel}
confirmTestId="walkthrough-discard-confirm"
testId="walkthrough-discard-confirmation"
onOpenChange={(nextOpen) => setDiscardTargetTaskId(nextOpen ? taskId : null)}
onConfirm={() => discardWalkthrough(discardTargetTaskId)}
/>
) : null}
</>
);
}
+2 -2
View File
@@ -1089,8 +1089,8 @@ export class SessionPage {
return this.page.getByTestId("walkthrough-discard");
}
walkthroughDiscardDialog(): Locator {
return this.page.getByRole("alertdialog", { name: "Discard walkthrough?" });
walkthroughDiscardConfirmation(): Locator {
return this.page.locator('[data-testid="walkthrough-discard-confirmation"]:visible');
}
walkthroughFloating(): Locator {
@@ -155,18 +155,41 @@ test.describe("Mobile code walkthrough", () => {
testPage,
apiClient,
seedData,
prCapture,
}) => {
await seedWalkthroughTask(testPage, apiClient, seedData);
const session = new SessionPage(testPage);
await expect(session.walkthroughLauncher()).toBeVisible({ timeout: 30_000 });
await expect(session.walkthroughDiscardButton()).toBeVisible({ timeout: 5_000 });
await session.walkthroughDiscardButton().click();
await expect(session.walkthroughDiscardDialog()).toBeVisible();
await session.walkthroughDiscardButton().tap();
const discardConfirmation = session.walkthroughDiscardConfirmation();
await expect(discardConfirmation).toBeVisible();
await expect(discardConfirmation).toHaveAttribute("role", "group");
await expect(testPage.getByRole("alertdialog")).toHaveCount(0);
await prCapture.screenshot("mobile-walkthrough-discard-confirmation", {
caption: "Mobile walkthrough keeps discard confirmation inside its touch launcher",
});
const actionBoxes = await Promise.all(
["Cancel", "Discard walkthrough"].map((name) =>
discardConfirmation.getByRole("button", { name }).boundingBox(),
),
);
for (const box of actionBoxes) {
if (!box) throw new Error("walkthrough discard action geometry unavailable");
expect(box.width).toBeGreaterThanOrEqual(44);
expect(box.height).toBeGreaterThanOrEqual(44);
}
await discardConfirmation.getByRole("button", { name: "Cancel" }).tap();
await expect(discardConfirmation).toHaveCount(0);
await expect(session.walkthroughLauncher()).toBeVisible();
await session.walkthroughDiscardButton().tap();
await expect(session.walkthroughDiscardConfirmation()).toBeVisible();
await session
.walkthroughDiscardDialog()
.walkthroughDiscardConfirmation()
.getByRole("button", { name: "Discard walkthrough" })
.click();
.tap();
await expect(session.walkthroughLauncher()).toHaveCount(0);
await expect(session.walkthroughFloating()).toHaveCount(0);
+21 -9
View File
@@ -397,6 +397,7 @@ test.describe("Code walkthrough", () => {
testPage,
apiClient,
seedData,
prCapture,
}) => {
const session = await seedWalkthroughTask(
testPage,
@@ -438,25 +439,36 @@ test.describe("Code walkthrough", () => {
await session.walkthroughLauncher().hover();
await expect(session.walkthroughDiscardButton()).toBeVisible({ timeout: 5_000 });
await session.walkthroughDiscardButton().click();
const discardDialog = session.walkthroughDiscardDialog();
await expect(discardDialog).toBeVisible();
await expectWalkthroughBehindDialog(testPage, discardDialog, [
{ locator: card, name: "walkthrough window" },
{ locator: session.walkthroughLauncher().locator(".."), name: "walkthrough launcher" },
const discardConfirmation = session.walkthroughDiscardConfirmation();
await expect(discardConfirmation).toBeVisible();
await expect(discardConfirmation).toHaveAttribute("role", "dialog");
await expect(testPage.getByRole("alertdialog")).toHaveCount(0);
await prCapture.screenshot("desktop-walkthrough-discard-confirmation", {
caption: "Desktop walkthrough confirms discard in its anchored toolbar",
});
const [confirmationZIndex, launcherZIndex] = await Promise.all([
discardConfirmation.evaluate((element) =>
Number.parseInt(getComputedStyle(element).zIndex, 10),
),
session
.walkthroughLauncher()
.locator("..")
.evaluate((element) => Number.parseInt(getComputedStyle(element).zIndex, 10)),
]);
await discardDialog.getByRole("button", { name: "Cancel" }).click();
expect(confirmationZIndex).toBeGreaterThan(launcherZIndex);
await discardConfirmation.getByRole("button", { name: "Cancel" }).click();
await expect(card).toBeVisible();
await expect(session.walkthroughLauncher()).toHaveCount(1);
await session.walkthroughLauncher().hover();
await session.walkthroughDiscardButton().click();
await expect(session.walkthroughDiscardDialog()).toBeVisible();
await expect(session.walkthroughDiscardConfirmation()).toBeVisible();
await session
.walkthroughDiscardDialog()
.walkthroughDiscardConfirmation()
.getByRole("button", { name: "Discard walkthrough" })
.click();
await expect(session.walkthroughDiscardDialog()).toBeHidden({ timeout: 10_000 });
await expect(session.walkthroughDiscardConfirmation()).toBeHidden({ timeout: 10_000 });
await expect(session.walkthroughLauncher()).toHaveCount(0);
await expect(session.walkthroughFloating()).toHaveCount(0);
await expect(session.walkthroughEditorRange()).toHaveCount(0);