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 { ApiError } from "@/lib/api/client"; import type { Canvas, CanvasRelease } from "@/lib/api/domains/canvas-api"; const ENABLE_WORKSPACE_DATA_TEXT = vi.hoisted(() => "Enable workspace data"); const READ_TASK_DATA_TEXT = vi.hoisted(() => "Read task data"); const { mockRequestCanvasPromotion, mockConfirmCanvasPromotion, mockRequestCanvasWorkspaceData, mockEnableCanvasWorkspaceData, mockListCanvasReleases, mockApproveCanvasRelease, mockRejectCanvasRelease, mockRollbackCanvas, } = vi.hoisted(() => ({ mockRequestCanvasPromotion: vi.fn(), mockConfirmCanvasPromotion: vi.fn(), mockRequestCanvasWorkspaceData: vi.fn(), mockEnableCanvasWorkspaceData: vi.fn(), mockListCanvasReleases: vi.fn(), mockApproveCanvasRelease: vi.fn(), mockRejectCanvasRelease: vi.fn(), mockRollbackCanvas: vi.fn(), })); const translate = vi.hoisted(() => { const translations: Record = { "canvases:runtimeTokenExpired": "The runtime token expired. Try again.", "canvases:actionFailed": "The canvas action failed.", "canvases:promoteCanvas": "Promote canvas", "canvases:promoteCanvasDescription": "Review permissions before promotion.", "canvases:loadingPermissions": "Loading requested permissions", "canvases:promotionScopeChange": "Promotion changes the canvas scope.", "canvases:promotionDataAccessPreserved": "Promotion changes placement only. Data access stays the same.", "canvases:promotionDataAccessChanges": "Promotion expands data access.", "canvases:promotionSourceScope": "Placement before promotion", "canvases:promotionTargetScope": "Placement after promotion", "canvases:promotionCurrentDataScope": "Data access before promotion", "canvases:promotionTargetDataScope": "Data access after promotion", "canvases:taskDataScope": "Task data", "canvases:workspaceDataScope": "Workspace data", "canvases:taskPlacementScope": "Task", "canvases:workspacePlacementScope": "Workspace", "canvases:enableWorkspaceData": ENABLE_WORKSPACE_DATA_TEXT, "canvases:enableWorkspaceDataHelp": "Enable declared permissions for this workspace.", "canvases:workspaceDataReviewDescription": "Review permissions for {{title}}.", "canvases:workspaceDataReviewScopeChange": "Only data access changes. The canvas stays in this task.", "canvases:enablingWorkspaceData": "Enabling workspace data", "canvases:activeRelease": "Active release", "canvases:noAdditionalPermissions": "No additional permissions were requested.", "canvases:permissionDeclaration": "Declared permissions", "canvases:missingPermissions": "Permissions still needed", "canvases:promotingCanvas": "Promoting canvas", "canvases:confirmPromotion": "Confirm promotion", "canvases:releasesAndPermissions": "Releases and permissions", "canvases:releasesDescription": "Review releases and permissions.", "canvases:loadingReleases": "Loading releases", "canvases:noReleases": "No releases are available.", "canvases:approveRelease": "Approve release", "canvases:rejectRelease": "Reject release", "canvases:rollbackRelease": "Roll back release", "canvases:statusActive": "Active", "canvases:statusPending": "Pending", "canvases:invalidRelease": "Invalid release", "canvases:unavailable": "Unavailable", "canvases:permissionReads": "API reads", "canvases:permissionWrites": "API writes", "canvases:permissionEvents": "Events", "canvases:permissionExternalOrigins": "External origins", "canvases:permissionExternalOrigin": "External HTTPS origin", "canvases:permissionSharedState": "Read and write shared canvas state", "canvases:permissionReadTasks": READ_TASK_DATA_TEXT, "canvases:permissionReadWorkflows": "Read workflow data", "canvases:permissionWriteTasks": "Write task data", "canvases:permissionWriteMessages": "Write task messages", "canvases:permissionEventTaskUpdated": "Receive task update events", "canvases:permissionEventWorkflowUpdated": "Receive workflow update events", "canvases:unsupportedPermission": "Unsupported permission: {{value}}", "canvases:workspaceDataReviewStale": "The review changed. Open it again.", "canvases:workspaceOwnerRequired": "Only the workspace owner can enable workspace data.", "canvases:selectRelease": "Select a release to review", "canvases:releaseOption": "{{date}} ({{status}})", "canvases:releaseDate": "Created", "canvases:releaseStatus": "Status", "canvases:newPermission": "New", "canvases:statusPrevious": "Previous", "canvases:unsupportedReleaseStatus": "Unsupported release status: {{status}}", "canvases:dateUnavailable": "Date unavailable", "canvases:sourceUnavailable": "Source unavailable", "canvases:sourceActorTaskAgent": "Task agent", "canvases:sourceActorUser": "User", "canvases:sourceActorSystem": "Kandev", "canvases:sourceActorUnknown": "Unknown author", "canvases:sharedState": "Shared state", "canvases:promotionSourceActor": "Source actor", "canvases:promotionSourceTask": "Source task", "canvases:promotionSourceSession": "Source session", "canvases:promotionPlacement": "Workspace placement", "common:cancel": "Cancel", "common:close": "Close", }; return (key: string, options?: Record) => { const value = translations[key] ?? key; return Object.entries(options ?? {}).reduce( (result, [name, replacement]) => result.replace(`{{${name}}}`, String(replacement)), value, ); }; }); const responsive = vi.hoisted(() => ({ isMobile: false })); vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: translate }), })); vi.mock("@/hooks/use-responsive-breakpoint", () => ({ useResponsiveBreakpoint: () => responsive, })); vi.mock("@kandev/ui/dialog", () => ({ Dialog: ({ children, open }: { children: ReactNode; open: boolean }) => open ?
{children}
: null, DialogContent: ({ children, ...props }: { children: ReactNode }) => (
{children}
), DialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, DialogTitle: ({ children }: { children: ReactNode }) =>

{children}

, })); vi.mock("@/lib/api/domains/canvas-api", () => ({ approveCanvasRelease: mockApproveCanvasRelease, confirmCanvasPromotion: mockConfirmCanvasPromotion, enableCanvasWorkspaceData: mockEnableCanvasWorkspaceData, listCanvasReleases: mockListCanvasReleases, rejectCanvasRelease: mockRejectCanvasRelease, requestCanvasWorkspaceData: mockRequestCanvasWorkspaceData, requestCanvasPromotion: mockRequestCanvasPromotion, rollbackCanvas: mockRollbackCanvas, })); import { CanvasPromotionDialog, CanvasReleaseDialog, CanvasWorkspaceDataDialog, } from "./canvas-lifecycle-dialogs"; 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", }; const COPY = { actionFailed: "The canvas action failed.", noReleases: "No releases are available.", approveRelease: "Approve release", rejectRelease: "Reject release", rollbackRelease: "Roll back release", promotingCanvas: "Promoting canvas", confirmPromotion: "Confirm promotion", enableWorkspaceData: ENABLE_WORKSPACE_DATA_TEXT, enablingWorkspaceData: "Enabling workspace data", cancel: "Cancel", } as const; const TASKS_READ_PERMISSION = "tasks.read"; const EXTERNAL_ORIGIN = "https://example.test"; function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject }; } function release(overrides: Partial = {}): CanvasRelease { return { id: "release-pending", validation_status: "pending_permission", ...overrides, }; } beforeEach(() => { mockRequestCanvasPromotion.mockReset().mockResolvedValue({ canvas_id: "canvas-1", title: "Task canvas", origin_task_id: "origin-task-1", source_actor_kind: "task_agent", source_user_id: "user-1", source_task_id: "source-task-1", source_task_title: "Source task title", source_session_id: "source-session-1", source_session_name: "Source session name", current_scope: "task", target_scope: "workspace", current_data_scope_kind: "task", target_data_scope_kind: "workspace", placement: "workspace_sidebar", permissions: { reads: [TASKS_READ_PERMISSION], writes: ["tasks.write"], events: ["task.updated"], shared_state: true, external_origins: [EXTERNAL_ORIGIN], }, }); mockConfirmCanvasPromotion.mockReset(); mockRequestCanvasWorkspaceData.mockReset().mockResolvedValue({ canvas, active_release_id: "release-1", permission_digest: "digest-1", grant_generation: 7, current_data_scope_kind: "task", target_data_scope_kind: "workspace", permissions: { reads: ["tasks"] }, }); mockEnableCanvasWorkspaceData.mockReset(); mockListCanvasReleases.mockReset().mockResolvedValue({ releases: [] }); mockApproveCanvasRelease.mockReset(); mockRejectCanvasRelease.mockReset(); mockRollbackCanvas.mockReset(); }); afterEach(() => { cleanup(); responsive.isMobile = false; }); describe("CanvasPromotionDialog", () => { it("shows promotion source scope, task/session, placement, and every permission group", async () => { render(); await waitFor(() => expect(screen.getByTestId("canvas-promotion-source-scope").textContent).toContain("Task"), ); expect(screen.getByTestId("canvas-promotion-source-task").textContent).toContain( "Source task title", ); expect(screen.getByTestId("canvas-promotion-source-session").textContent).toContain( "Source session name", ); expect(screen.getByTestId("canvas-promotion-placement").textContent).toContain( "workspace_sidebar", ); expect(screen.getByText("API reads")).toBeTruthy(); expect(screen.getByText(READ_TASK_DATA_TEXT)).toBeTruthy(); expect(screen.getByText("API writes")).toBeTruthy(); expect(screen.getByText("Write task data")).toBeTruthy(); expect(screen.getByText("Events")).toBeTruthy(); expect(screen.getByText("Receive task update events")).toBeTruthy(); expect(screen.getByText("Shared state")).toBeTruthy(); expect(screen.getByText("External origins")).toBeTruthy(); expect(screen.getByText("https://example.test")).toBeTruthy(); expect(screen.getByTestId("canvas-promotion-current-data-scope").textContent).toBe("Task data"); expect(screen.getByTestId("canvas-promotion-target-data-scope").textContent).toBe( "Workspace data", ); expect(screen.getByText("Promotion expands data access.")).toBeTruthy(); }); it("maps stable API error codes to localized copy", async () => { mockRequestCanvasPromotion.mockRejectedValue( new ApiError("raw server failure", 401, { error_code: "runtime_token_expired" }), ); render(); const alert = await screen.findByRole("alert"); expect(alert.textContent).toContain("The runtime token expired. Try again."); expect(alert.textContent).not.toContain("raw server failure"); }); it("confirms promotion, reports the completed canvas, and closes", async () => { const preview = deferred<{ canvas_id: string; current_scope: string; target_scope: string; active_release_id: string; permission_digest: string; grant_generation: number; permissions: { reads: string[] }; }>(); const confirmation = deferred(); const promoted = { ...canvas, scope_kind: "workspace" }; const onCompleted = vi.fn(); const onOpenChange = vi.fn(); mockRequestCanvasPromotion.mockReturnValue(preview.promise); mockConfirmCanvasPromotion.mockReturnValue(confirmation.promise); render( , ); const confirmButton = screen.getByRole("button", { name: COPY.confirmPromotion }); expect((confirmButton as HTMLButtonElement).disabled).toBe(true); preview.resolve({ canvas_id: canvas.id, current_scope: "task", target_scope: "workspace", active_release_id: "release-1", permission_digest: "digest-1", grant_generation: 7, permissions: { reads: [] }, }); await waitFor(() => expect((confirmButton as HTMLButtonElement).disabled).toBe(false)); fireEvent.click(confirmButton); await waitFor(() => expect(mockConfirmCanvasPromotion).toHaveBeenCalledWith(canvas.id, { expected_release_id: "release-1", expected_permission_digest: "digest-1", expected_grant_generation: 7, }), ); expect( (screen.getByRole("button", { name: COPY.promotingCanvas }) as HTMLButtonElement).disabled, ).toBe(true); confirmation.resolve(promoted); await waitFor(() => expect(onCompleted).toHaveBeenCalledWith(promoted)); expect(onOpenChange).toHaveBeenCalledWith(false); }); it("closes promotion without confirming when cancelled", async () => { const onOpenChange = vi.fn(); render(); fireEvent.click(await screen.findByRole("button", { name: COPY.cancel })); expect(mockConfirmCanvasPromotion).not.toHaveBeenCalled(); expect(onOpenChange).toHaveBeenCalledWith(false); }); }); describe("CanvasWorkspaceDataDialog", () => { it("shows the active release, current and target data scopes, and declared permissions", async () => { render(); expect((await screen.findByTestId("canvas-workspace-data-release")).textContent).toBe( "release-1", ); expect(screen.getByTestId("canvas-workspace-data-current-scope").textContent).toBe("Task data"); expect(screen.getByTestId("canvas-workspace-data-target-scope").textContent).toBe( "Workspace data", ); expect(screen.getByText(READ_TASK_DATA_TEXT)).toBeTruthy(); }); it("enables exactly the reviewed release and grant generation", async () => { const updated = { ...canvas, data_scope_kind: "workspace" }; const onCompleted = vi.fn(); const onOpenChange = vi.fn(); mockEnableCanvasWorkspaceData.mockResolvedValue(updated); render( , ); fireEvent.click(await screen.findByRole("button", { name: COPY.enableWorkspaceData })); await waitFor(() => expect(mockEnableCanvasWorkspaceData).toHaveBeenCalledWith(canvas.id, { expected_release_id: "release-1", expected_permission_digest: "digest-1", expected_grant_generation: 7, }), ); await waitFor(() => expect(onCompleted).toHaveBeenCalledWith(updated)); expect(onOpenChange).toHaveBeenCalledWith(false); }); it("cancels without changing workspace data access", async () => { const onOpenChange = vi.fn(); render(); fireEvent.click(await screen.findByRole("button", { name: COPY.cancel })); expect(mockEnableCanvasWorkspaceData).not.toHaveBeenCalled(); expect(onOpenChange).toHaveBeenCalledWith(false); }); it("uses a full-height phone review with one scroll region and safe-area actions", async () => { responsive.isMobile = true; render(); await screen.findByTestId("canvas-workspace-data-release"); expect(screen.getByTestId("canvas-workspace-data-dialog").className).toContain("h-dvh"); expect(screen.getByTestId("canvas-workspace-data-review-scroll").className).toContain( "overflow-y-auto", ); expect(screen.getByRole("button", { name: COPY.enableWorkspaceData }).className).toContain( "min-h-12", ); }); }); describe("CanvasReleaseDialog validation", () => { it("localizes stable release validation codes", async () => { mockListCanvasReleases.mockResolvedValue({ releases: [ { id: "release-1", validation_status: "unavailable", validation_error: "runtime_token_expired", }, ], }); render(); await waitFor(() => expect(screen.getByText("The runtime token expired. Try again.")).toBeTruthy(), ); }); }); describe("CanvasReleaseDialog permission review", () => { it("shows declared and missing permissions before approving a pending release", async () => { mockListCanvasReleases.mockResolvedValue({ releases: [ release({ permissions: { reads: [TASKS_READ_PERMISSION], writes: ["messages.write"], external_origins: [EXTERNAL_ORIGIN], }, missing_permissions: ["api_read:tasks"], source_actor_kind: "task_agent", source_task_id: "task-1", source_session_id: "session-1", }), ], }); render(); await waitFor(() => expect(screen.getByTestId("canvas-release-permissions-release-pending")).toBeTruthy(), ); expect(screen.getByText("Declared permissions")).toBeTruthy(); expect(screen.getByText(READ_TASK_DATA_TEXT)).toBeTruthy(); expect(screen.getByText("Write task messages")).toBeTruthy(); expect(screen.getByText(EXTERNAL_ORIGIN)).toBeTruthy(); expect(screen.getByText("New")).toBeTruthy(); expect(screen.getByRole("button", { name: COPY.approveRelease })).toBeTruthy(); }); it("disables release mutations for an archived canvas while keeping the review visible", async () => { mockListCanvasReleases.mockResolvedValue({ releases: [release()] }); render( , ); const approveButton = await screen.findByRole("button", { name: COPY.approveRelease }); const rejectButton = screen.getByRole("button", { name: COPY.rejectRelease }); expect((approveButton as HTMLButtonElement).disabled).toBe(true); expect((rejectButton as HTMLButtonElement).disabled).toBe(true); }); }); describe("CanvasReleaseDialog actions", () => { it.each([ { action: "approve", actionMock: mockApproveCanvasRelease, buttonName: COPY.approveRelease, release: release(), }, { action: "reject", actionMock: mockRejectCanvasRelease, buttonName: COPY.rejectRelease, release: release({ id: "release-to-reject" }), }, { action: "rollback", actionMock: mockRollbackCanvas, buttonName: COPY.rollbackRelease, release: release({ id: "release-to-rollback", validation_status: "valid" }), }, ])("runs the $action release action and refreshes the list", async (testCase) => { const next = { ...canvas, active_release_id: testCase.release.id }; const onChanged = vi.fn(); testCase.actionMock.mockResolvedValue(next); mockListCanvasReleases .mockResolvedValueOnce({ releases: [testCase.release] }) .mockResolvedValueOnce({ releases: [] }); render( , ); fireEvent.click(await screen.findByRole("button", { name: testCase.buttonName })); await waitFor(() => expect(testCase.actionMock).toHaveBeenCalledWith(canvas.id, testCase.release.id), ); await waitFor(() => expect(onChanged).toHaveBeenCalledWith(next)); await waitFor(() => expect(mockListCanvasReleases).toHaveBeenCalledTimes(2)); expect(screen.getByText(COPY.noReleases)).toBeTruthy(); }); it("disables both actions for a busy pending release until refresh completes", async () => { const mutation = deferred(); const refreshed = deferred<{ releases: CanvasRelease[] }>(); const pendingRelease = release(); mockApproveCanvasRelease.mockReturnValue(mutation.promise); mockListCanvasReleases .mockResolvedValueOnce({ releases: [pendingRelease] }) .mockReturnValueOnce(refreshed.promise); render(); const approveButton = await screen.findByRole("button", { name: COPY.approveRelease }); const rejectButton = screen.getByRole("button", { name: COPY.rejectRelease }); fireEvent.click(approveButton); await waitFor(() => expect((approveButton as HTMLButtonElement).disabled).toBe(true)); expect((rejectButton as HTMLButtonElement).disabled).toBe(true); expect((screen.getByRole("combobox") as HTMLSelectElement).disabled).toBe(true); mutation.resolve({ ...canvas, active_release_id: pendingRelease.id }); await waitFor(() => expect(mockListCanvasReleases).toHaveBeenCalledTimes(2)); expect((approveButton as HTMLButtonElement).disabled).toBe(true); refreshed.resolve({ releases: [] }); await waitFor(() => expect(screen.getByText(COPY.noReleases)).toBeTruthy()); }); it("shows a refresh error and releases the busy action after failure", async () => { const pendingRelease = release(); mockListCanvasReleases.mockResolvedValueOnce({ releases: [pendingRelease] }); mockApproveCanvasRelease.mockRejectedValue(new ApiError("raw action failure", 500, {})); render(); const approveButton = await screen.findByRole("button", { name: COPY.approveRelease }); fireEvent.click(approveButton); const alert = await screen.findByRole("alert"); expect(alert.textContent).toContain(COPY.actionFailed); await waitFor(() => expect((approveButton as HTMLButtonElement).disabled).toBe(false)); expect(mockListCanvasReleases).toHaveBeenCalledTimes(1); }); it("renders an error when the release list cannot be loaded", async () => { mockListCanvasReleases.mockRejectedValue(new ApiError("raw load failure", 500, {})); render(); const alert = await screen.findByRole("alert"); expect(alert.textContent).toBe(COPY.actionFailed); expect(screen.queryByRole("status")).toBeNull(); }); }); describe("CanvasReleaseDialog review surface", () => { it("uses safe source fallbacks and never renders source identifiers", async () => { mockListCanvasReleases.mockResolvedValue({ releases: [ release({ source_actor_kind: "task_agent", source_task_id: "task-secret-id", source_session_id: "session-secret-id", }), ], }); render(); await screen.findByTestId("canvas-release-review-release-pending"); expect(screen.getAllByText("Source unavailable")).toHaveLength(2); expect(screen.queryByText("task-secret-id")).toBeNull(); expect(screen.queryByText("session-secret-id")).toBeNull(); }); it("selects pending by default and keeps retained releases actionable", async () => { mockListCanvasReleases.mockResolvedValue({ releases: [ release({ id: "release-1", validation_status: "valid" }), release({ id: "release-previous", validation_status: "valid" }), release({ id: "release-pending", validation_status: "pending_permission" }), ], }); render(); const selector = (await screen.findByRole("combobox")) as HTMLSelectElement; expect(selector.value).toBe("release-pending"); fireEvent.change(selector, { target: { value: "release-previous" } }); await waitFor(() => expect(screen.getByTestId("canvas-release-status-release-previous").textContent).toBe( "Previous", ), ); expect(screen.getByRole("button", { name: COPY.rollbackRelease })).toBeTruthy(); }); it("uses the wide responsive desktop review surface", async () => { mockListCanvasReleases.mockResolvedValue({ releases: [release()] }); render(); await screen.findByTestId("canvas-release-review-release-pending"); expect(screen.getByTestId("canvas-releases-dialog").className).toContain("sm:max-w-[48rem]"); }); it("keeps the phone review full-height with a fixed safe-area footer", async () => { responsive.isMobile = true; mockListCanvasReleases.mockResolvedValue({ releases: [release()] }); render(); await screen.findByTestId("canvas-release-review-release-pending"); expect(screen.getByTestId("canvas-releases-dialog").className).toContain("h-dvh"); expect(screen.getByTestId("canvas-release-review-scroll").className).toContain( "overflow-y-auto", ); expect(screen.getByRole("button", { name: COPY.approveRelease }).className).toContain( "max-md:h-11", ); }); it("does not enable approval for an unknown permission kind", async () => { mockListCanvasReleases.mockResolvedValue({ releases: [ release({ permissions: { reads: ["secrets"] }, missing_permissions: ["api_read:secrets"], }), ], }); render(); const approveButton = await screen.findByRole("button", { name: COPY.approveRelease }); expect((approveButton as HTMLButtonElement).disabled).toBe(true); expect(screen.getByText("Unsupported permission: secrets")).toBeTruthy(); }); });