Files
kandev/apps/web/components/settings/canvas-lifecycle-dialogs.test.tsx
T
2026-09-11 14:51:32 +00:00

553 lines
21 KiB
TypeScript

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 {
mockRequestCanvasPromotion,
mockConfirmCanvasPromotion,
mockListCanvasReleases,
mockApproveCanvasRelease,
mockRejectCanvasRelease,
mockRollbackCanvas,
} = vi.hoisted(() => ({
mockRequestCanvasPromotion: vi.fn(),
mockConfirmCanvasPromotion: vi.fn(),
mockListCanvasReleases: vi.fn(),
mockApproveCanvasRelease: vi.fn(),
mockRejectCanvasRelease: vi.fn(),
mockRollbackCanvas: vi.fn(),
}));
const translate = vi.hoisted(() => {
const translations: Record<string, string> = {
"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: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",
"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: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:promotionSourceScope": "Source scope",
"canvases:promotionSourceActor": "Source actor",
"canvases:promotionSourceTask": "Source task",
"canvases:promotionSourceSession": "Source session",
"canvases:promotionTargetScope": "Target scope",
"canvases:promotionPlacement": "Workspace placement",
"common:cancel": "Cancel",
"common:close": "Close",
};
return (key: string, options?: Record<string, unknown>) => {
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 ? <div>{children}</div> : null,
DialogContent: ({ children, ...props }: { children: ReactNode }) => (
<section {...props}>{children}</section>
),
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: ReactNode }) => <footer>{children}</footer>,
DialogHeader: ({ children }: { children: ReactNode }) => <header>{children}</header>,
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
}));
vi.mock("@/lib/api/domains/canvas-api", () => ({
approveCanvasRelease: mockApproveCanvasRelease,
confirmCanvasPromotion: mockConfirmCanvasPromotion,
listCanvasReleases: mockListCanvasReleases,
rejectCanvasRelease: mockRejectCanvasRelease,
requestCanvasPromotion: mockRequestCanvasPromotion,
rollbackCanvas: mockRollbackCanvas,
}));
import { CanvasPromotionDialog, CanvasReleaseDialog } 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",
cancel: "Cancel",
} as const;
const TASKS_READ_PERMISSION = "tasks.read";
const EXTERNAL_ORIGIN = "https://example.test";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function release(overrides: Partial<CanvasRelease> = {}): 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",
placement: "workspace_sidebar",
permissions: {
reads: [TASKS_READ_PERMISSION],
writes: ["tasks.write"],
events: ["task.updated"],
shared_state: true,
external_origins: [EXTERNAL_ORIGIN],
},
});
mockConfirmCanvasPromotion.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(<CanvasPromotionDialog canvas={canvas} open onOpenChange={vi.fn()} />);
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")).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();
});
it("maps stable API error codes to localized copy", async () => {
mockRequestCanvasPromotion.mockRejectedValue(
new ApiError("raw server failure", 401, { error_code: "runtime_token_expired" }),
);
render(<CanvasPromotionDialog canvas={canvas} open onOpenChange={vi.fn()} />);
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<Canvas>();
const promoted = { ...canvas, scope_kind: "workspace" };
const onCompleted = vi.fn();
const onOpenChange = vi.fn();
mockRequestCanvasPromotion.mockReturnValue(preview.promise);
mockConfirmCanvasPromotion.mockReturnValue(confirmation.promise);
render(
<CanvasPromotionDialog
canvas={canvas}
open
onOpenChange={onOpenChange}
onCompleted={onCompleted}
/>,
);
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(<CanvasPromotionDialog canvas={canvas} open onOpenChange={onOpenChange} />);
fireEvent.click(await screen.findByRole("button", { name: COPY.cancel }));
expect(mockConfirmCanvasPromotion).not.toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
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(<CanvasReleaseDialog canvas={canvas} open onOpenChange={vi.fn()} />);
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(<CanvasReleaseDialog canvas={canvas} open onOpenChange={vi.fn()} />);
await waitFor(() =>
expect(screen.getByTestId("canvas-release-permissions-release-pending")).toBeTruthy(),
);
expect(screen.getByText("Declared permissions")).toBeTruthy();
expect(screen.getByText("Read task data")).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(
<CanvasReleaseDialog
canvas={{ ...canvas, status: "archived" }}
open
onOpenChange={vi.fn()}
/>,
);
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(
<CanvasReleaseDialog canvas={canvas} open onOpenChange={vi.fn()} onChanged={onChanged} />,
);
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<Canvas>();
const refreshed = deferred<{ releases: CanvasRelease[] }>();
const pendingRelease = release();
mockApproveCanvasRelease.mockReturnValue(mutation.promise);
mockListCanvasReleases
.mockResolvedValueOnce({ releases: [pendingRelease] })
.mockReturnValueOnce(refreshed.promise);
render(<CanvasReleaseDialog canvas={canvas} open onOpenChange={vi.fn()} />);
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(<CanvasReleaseDialog canvas={canvas} open onOpenChange={vi.fn()} />);
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(<CanvasReleaseDialog canvas={canvas} open onOpenChange={vi.fn()} />);
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(<CanvasReleaseDialog canvas={canvas} open onOpenChange={vi.fn()} />);
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(<CanvasReleaseDialog canvas={canvas} open onOpenChange={vi.fn()} />);
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("keeps the phone review full-height with a fixed safe-area footer", async () => {
responsive.isMobile = true;
mockListCanvasReleases.mockResolvedValue({ releases: [release()] });
render(<CanvasReleaseDialog canvas={canvas} open onOpenChange={vi.fn()} />);
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(<CanvasReleaseDialog canvas={canvas} open onOpenChange={vi.fn()} />);
const approveButton = await screen.findByRole("button", { name: COPY.approveRelease });
expect((approveButton as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText("Unsupported permission: secrets")).toBeTruthy();
});
});