import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DropdownMenu, DropdownMenuContent } from "@kandev/ui/dropdown-menu"; import type { Canvas } from "@/lib/api/domains/canvas-api"; import { registerCanvasesHandlers } from "@/lib/ws/handlers/canvases"; const { mockGetCanvas, mockGetCanvasRuntime, mockListTaskCanvases, mockListWorkspaceCanvases, mockPush, mockIsMobile, mockRenderPageShellOverflow, } = vi.hoisted(() => ({ mockGetCanvas: vi.fn(), mockGetCanvasRuntime: vi.fn(), mockListTaskCanvases: vi.fn(), mockListWorkspaceCanvases: vi.fn(), mockPush: vi.fn(), mockIsMobile: { value: false }, mockRenderPageShellOverflow: { value: false }, })); const FRAME_TEST_ID = "canvas-frame"; const RUNTIME_URL_ATTRIBUTE = "data-runtime-url"; vi.mock("@/lib/api/domains/canvas-api", () => ({ canvasHref: (canvasId: string) => `/canvases/${canvasId}`, canvasDataScope: (value: Canvas) => value.data_scope_kind ?? value.scope_kind, canvasCanEnableWorkspaceData: (value: Canvas) => value.scope_kind === "task" && (value.data_scope_kind ?? value.scope_kind) === "task" && value.status === "active" && value.active_release_status === "valid", getCanvas: mockGetCanvas, getCanvasRuntime: mockGetCanvasRuntime, listTaskCanvases: mockListTaskCanvases, listWorkspaceCanvases: mockListWorkspaceCanvases, startCanvasEdit: vi.fn(), })); vi.mock("@/components/page-shell", () => ({ PageShell: ({ actions, children, overflowMenuItems, subtitle, titleSlot, topbarTestId, }: { actions?: ReactNode; children: ReactNode; overflowMenuItems?: ReactNode; subtitle?: string; titleSlot?: ReactNode; topbarTestId?: string; }) => (
{titleSlot} {subtitle} {mockIsMobile.value ? actions : null}
{overflowMenuItems && mockRenderPageShellOverflow.value ? ( {overflowMenuItems} ) : null} {children}
), })); vi.mock("@/components/plugins/canvas-page", () => ({ CanvasPage: ({ runtimeUrl, onError }: { runtimeUrl?: string; onError?: () => void }) => ( ), })); vi.mock("@/components/settings/canvas-lifecycle-dialogs", () => ({ CanvasPromotionDialog: () => null, CanvasReleaseDialog: () => null, CanvasWorkspaceDataDialog: () => null, })); vi.mock("@/hooks/use-responsive-breakpoint", () => ({ useResponsiveBreakpoint: () => ({ isMobile: mockIsMobile.value }), })); vi.mock("@/components/task/mobile/mobile-picker-sheet", () => ({ MobilePickerSheet: ({ children, open, contentTestId, }: { children: ReactNode; open: boolean; contentTestId?: string; }) => (open ?
{children}
: null), })); vi.mock("@/lib/routing/client-router", () => ({ useRouter: () => ({ push: mockPush }), })); vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: unknown) => unknown) => selector({ auth: { mode: "disabled", user: null } }), })); import { CanvasHostRoute } from "./canvas-host-route"; const canvas: Canvas = { id: "canvas-1", plugin_instance_id: "instance-1", plugin_id: "plugin-1", workspace_id: "workspace-1", task_id: "task-1", scope_kind: "task", title: "Task canvas", status: "active", active_release_id: "release-1", active_release_status: "valid", }; beforeEach(() => { mockGetCanvas.mockReset().mockResolvedValue(canvas); mockGetCanvasRuntime .mockReset() .mockResolvedValueOnce({ runtime_url: "/runtime/old", release_id: "release-1", expires_in_seconds: 900, }) .mockResolvedValueOnce({ runtime_url: "/runtime/renewed", release_id: "release-1", expires_in_seconds: 900, }); mockListTaskCanvases.mockReset().mockResolvedValue({ canvases: [canvas] }); mockListWorkspaceCanvases.mockReset().mockResolvedValue({ canvases: [] }); mockIsMobile.value = false; mockRenderPageShellOverflow.value = false; mockPush.mockReset(); }); afterEach(() => { cleanup(); }); describe("CanvasHostRoute runtime recovery", () => { it("exposes retry after a frame failure and renews the capability URL", async () => { render(); await waitFor(() => expect(screen.getByTestId(FRAME_TEST_ID).getAttribute(RUNTIME_URL_ATTRIBUTE)).toBe( "/runtime/old", ), ); fireEvent.click(screen.getByTestId(FRAME_TEST_ID)); await waitFor(() => expect(screen.getByTestId("canvas-host-state").textContent).toContain("unavailable"), ); fireEvent.click(screen.getByRole("button", { name: "Try again" })); await waitFor(() => { expect(mockGetCanvasRuntime).toHaveBeenCalledTimes(2); expect(screen.getByTestId(FRAME_TEST_ID).getAttribute(RUNTIME_URL_ATTRIBUTE)).toBe( "/runtime/renewed", ); }); }); it("renews the capability URL before its expiry", async () => { mockGetCanvasRuntime .mockReset() .mockResolvedValueOnce({ runtime_url: "/runtime/expiring", release_id: "release-1", expires_in_seconds: 30, }) .mockResolvedValueOnce({ runtime_url: "/runtime/refreshed", release_id: "release-1", expires_in_seconds: 900, }); render(); await waitFor(() => expect(screen.getByTestId(FRAME_TEST_ID).getAttribute(RUNTIME_URL_ATTRIBUTE)).toBe( "/runtime/expiring", ), ); await waitFor(() => { expect(mockGetCanvasRuntime).toHaveBeenCalledTimes(2); expect(screen.getByTestId(FRAME_TEST_ID).getAttribute(RUNTIME_URL_ATTRIBUTE)).toBe( "/runtime/refreshed", ); }); }); it("passes raw desktop overflow items to the standalone page shell", async () => { mockRenderPageShellOverflow.value = true; render(); await waitFor(() => expect(screen.getByTestId(FRAME_TEST_ID)).toBeTruthy()); expect(screen.getByRole("menuitem", { name: "Releases and permissions" })).toBeTruthy(); expect(screen.queryByTestId("panel-header-overflow")).toBeNull(); }); it("refreshes the visible host when a canvas lifecycle event arrives", async () => { const updated = { ...canvas, active_release_id: "release-2" }; mockGetCanvas.mockReset().mockResolvedValueOnce(canvas).mockResolvedValueOnce(updated); mockGetCanvasRuntime .mockReset() .mockResolvedValueOnce({ runtime_url: "/runtime/release-1", release_id: "release-1", expires_in_seconds: 900, }) .mockResolvedValueOnce({ runtime_url: "/runtime/release-2", release_id: "release-2", expires_in_seconds: 900, }); render(); await waitFor(() => expect(screen.getByTestId(FRAME_TEST_ID).getAttribute(RUNTIME_URL_ATTRIBUTE)).toBe( "/runtime/release-1", ), ); registerCanvasesHandlers({} as never)["canvas.release.activated"]?.({} as never); await waitFor(() => { expect(mockGetCanvas).toHaveBeenCalledTimes(2); expect(screen.getByTestId(FRAME_TEST_ID).getAttribute(RUNTIME_URL_ATTRIBUTE)).toBe( "/runtime/release-2", ); }); }); }); describe("CanvasHostRoute mobile data scope", () => { it("lets a mobile focused host switch to another applicable canvas", async () => { mockIsMobile.value = true; const otherCanvas = { ...canvas, id: "canvas-2", title: "Another task canvas", }; mockListTaskCanvases.mockResolvedValue({ canvases: [canvas, otherCanvas] }); render(); await waitFor(() => expect(screen.getByTestId(FRAME_TEST_ID)).toBeTruthy()); fireEvent.click(screen.getByTestId("canvas-mobile-actions")); const other = await screen.findByTestId("canvas-mobile-picker-item-canvas-2"); expect(other.textContent).toContain("Another task canvas"); fireEvent.click(other); expect(mockPush).toHaveBeenCalledWith("/canvases/canvas-2"); }); it("shows the data scope beside the title on mobile", async () => { mockIsMobile.value = true; mockGetCanvas.mockReset().mockResolvedValue({ ...canvas, data_scope_kind: "workspace" }); render(); expect((await screen.findByTestId("canvas-data-scope")).textContent).toBe("Workspace data"); }); });