import { expect, test } from "../../fixtures/test-base"; import type { Page } from "@playwright/test"; import { waitForHttp } from "../../helpers/causal-waits"; import { waitForSessionDone } from "../../helpers/session"; import { resizeColumnViaSplitview } from "../../helpers/dockview-resize"; import { SessionPage } from "../../pages/session-page"; import { expectTaskDescription, readTaskDescription } from "../../pages/task-description-editor"; import { canvasHref, enableCanvasFeature, expectCanvasFrameFillsHost, listCanvasReleases, removeCanvas, seedCanvasWorkspacePreview, seedTaskCanvas, waitForSessionWorkspace, writeCanvasSource, } from "./canvas-fixture"; test.describe("Plugin-backed canvases in the desktop task workbench", () => { test("canvas setup opens the task dialog directly from the empty sidebar", async ({ testPage, apiClient, backend, seedData, }) => { test.setTimeout(120_000); const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); try { await testPage.goto(`/?workspaceId=${encodeURIComponent(seedData.workspaceId)}`); await expect(testPage.getByTestId("kanban-board")).toBeVisible({ timeout: 20_000 }); await expect(testPage.getByTestId("sidebar-canvases-settings")).toBeVisible({ timeout: 20_000, }); const sectionHeader = testPage.getByRole("button", { name: /canvases/i }).first(); await sectionHeader.click(); const setup = testPage.getByTestId("sidebar-canvases-empty"); await expect(setup).toBeVisible(); const routeBeforeOpen = testPage.url(); await setup.click(); const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); await expect(testPage).toHaveURL(routeBeforeOpen); await expect(dialog.getByTestId("source-mode-scratch")).toHaveAttribute( "aria-checked", "true", ); await expectTaskDescription( dialog.getByTestId("task-description-input"), "Create a new Kandev canvas with a coordinator view that lists the existing tasks.\n\n@create-canvas", ); await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); await expect(dialog).toBeHidden(); await expect(setup).toBeFocused(); await setup.click(); await expect(dialog).toBeVisible(); await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); await expect(dialog).toBeHidden(); } finally { await releaseFeature(); } }); test("shows the canvas creation prompt and retains the edited description on desktop", async ({ testPage, apiClient, backend, seedData, }) => { test.setTimeout(150_000); const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); let taskId: string | undefined; try { const { executors } = await apiClient.listExecutors(); const localExecutor = executors.find((executor) => ["local", "local_pc"].includes(executor.type), ); const localProfile = localExecutor?.profiles?.[0]; expect( localProfile, "a direct local executor profile is required by the fixture", ).toBeDefined(); await testPage.goto( `/settings/workspaces/${encodeURIComponent(seedData.workspaceId)}/canvases`, ); await expect(testPage.getByTestId("workspace-canvases-page")).toBeVisible(); await testPage.getByTestId("settings-create-canvas").click(); const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); await expect(dialog.getByTestId("source-mode-scratch")).toHaveAttribute( "aria-checked", "true", ); await expect(dialog.getByTestId("executor-profile-selector")).toContainText( localProfile!.name, ); const defaultPrompt = await readTaskDescription(dialog.getByTestId("task-description-input")); expect(defaultPrompt).toBe( "Create a new Kandev canvas with a coordinator view that lists the existing tasks.\n\n@create-canvas", ); expect(defaultPrompt).not.toContain("e2e:mcp:"); const editedDescription = "desktop canvas prompt override\n\n@create-canvas"; await dialog.getByTestId("task-title-input").fill("E2E Desktop Canvas Task"); await dialog.getByTestId("task-description-input").fill(editedDescription); const startAgent = dialog.getByTestId("submit-start-agent"); await expect(startAgent).toBeEnabled(); const responsePromise = waitForHttp(testPage, "POST", /\/api\/v1\/tasks$/); await startAgent.click(); const response = await responsePromise; const responseBody = await response.text(); expect(response.status(), responseBody).toBe(200); const created = JSON.parse(responseBody) as { id: string }; taskId = created.id; expect(taskId).toBeTruthy(); await expect(testPage).toHaveURL(new RegExp(`/t/${taskId}(?:[?]|$)`)); const session = new SessionPage(testPage); await session.waitForLoad(); await expect .poll(async () => (await apiClient.getTask(taskId!)).description) .toBe(editedDescription); await expect .poll(() => testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), ) .toBe(true); } finally { if (taskId) await apiClient.deleteTask(taskId).catch(() => undefined); await releaseFeature(); } }); test("discovers and operates an owner-created task canvas from the workbench", async ({ testPage, apiClient, backend, seedData, }) => { test.setTimeout(150_000); const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); let canvasId: string | undefined; let workspacePreview: Awaited> | undefined; try { workspacePreview = await seedCanvasWorkspacePreview(apiClient, seedData); const seeded = await seedTaskCanvas(testPage, apiClient, seedData, false, { foreignWorkspaceId: workspacePreview.foreignWorkspaceId, }); canvasId = seeded.canvas.id; await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible(); await expect(testPage.getByTestId("canvas-host-route")).toBeVisible({ timeout: 20_000 }); await expect .poll( () => testPage.evaluate((id) => { const dockview = ( window as unknown as { __dockviewApi__?: { panels?: Array<{ id: string }> }; } ).__dockviewApi__; return dockview?.panels?.filter((panel) => panel.id === `canvas:${id}`).length ?? 0; }, seeded.canvas.id), { timeout: 20_000 }, ) .toBe(1); await expect(testPage.getByTestId("canvas-host-state")).toHaveText("Ready", { timeout: 20_000, }); expect(seeded.canvas.pending_release).toBeUndefined(); expect(seeded.canvas.active_release_status).toBe("valid"); await expect(testPage.getByTestId("web-app-frame")).toHaveAttribute( "data-frame-state", "ready", { timeout: 20_000 }, ); await expectCanvasFrameFillsHost(testPage); const canvasPanelId = `canvas:${seeded.canvas.id}`; const normalCanvasWidth = await testPage.evaluate((id) => { const api = ( window as unknown as { __dockviewApi__?: { getPanel: (panelId: string) => { group: { width: number } } | undefined; }; } ).__dockviewApi__; const panel = api?.getPanel(id); if (!panel) throw new Error("canvas panel not found"); return panel.group.width; }, canvasPanelId); await testPage.evaluate((id) => { const api = ( window as unknown as { __dockviewApi__?: { getPanel: ( panelId: string, ) => { group: { api: { maximize: () => void } } } | undefined; }; } ).__dockviewApi__; api?.getPanel(id)?.group.api.maximize(); }, canvasPanelId); await expect .poll(() => testPage.evaluate(() => { const api = ( window as unknown as { __dockviewApi__?: { hasMaximizedGroup: () => boolean } } ).__dockviewApi__; return api?.hasMaximizedGroup() ?? false; }), ) .toBe(true); await expectCanvasFrameFillsHost(testPage); await testPage.evaluate((id) => { const api = ( window as unknown as { __dockviewApi__?: { getPanel: ( panelId: string, ) => { group: { api: { exitMaximized: () => void } } } | undefined; }; } ).__dockviewApi__; api?.getPanel(id)?.group.api.exitMaximized(); }, canvasPanelId); await expect .poll(() => testPage.evaluate(() => { const api = ( window as unknown as { __dockviewApi__?: { hasMaximizedGroup: () => boolean } } ).__dockviewApi__; return api?.hasMaximizedGroup() ?? false; }), ) .toBe(false); await expectCanvasFrameFillsHost(testPage); await resizeColumnViaSplitview(testPage, "right", 480); await expect .poll(() => testPage.evaluate( ({ id, previous }) => { const api = ( window as unknown as { __dockviewApi__?: { getPanel: (panelId: string) => { group: { width: number } } | undefined; }; } ).__dockviewApi__; const width = api?.getPanel(id)?.group.width; return typeof width === "number" && Math.abs(width - previous) > 2; }, { id: canvasPanelId, previous: normalCanvasWidth }, ), ) .toBe(true); await expectCanvasFrameFillsHost(testPage); const canvasOverflow = testPage .getByTestId("canvas-host-header") .getByTestId("panel-header-overflow"); await expect(canvasOverflow).toBeVisible(); await canvasOverflow.click(); await expect( testPage.getByRole("menuitem", { name: "Releases and permissions", exact: true }), ).toBeVisible(); await testPage.keyboard.press("Escape"); const fixture = testPage.frameLocator('iframe[title="E2E Plugin Canvas"]'); await expect(fixture.getByTestId("canvas-fixture-script")).toHaveText("inline-ready"); await expect(fixture.getByTestId("canvas-fixture-appearance-mode")).toHaveText("light"); await expect(fixture.getByTestId("canvas-fixture-appearance-color-scheme")).toHaveText( "light", ); const lightBackground = await fixture .getByTestId("canvas-fixture-appearance-background") .textContent(); await expect(fixture.getByTestId("canvas-fixture-context")).toHaveText(seeded.taskId); const expectedTaskCount = (await apiClient.listTasks(seedData.workspaceId)).tasks.length; expect(expectedTaskCount).toBeGreaterThanOrEqual(2); await fixture.getByTestId("canvas-fixture-refresh").click(); await expect(fixture.getByTestId("canvas-fixture-refresh-status")).toHaveText("refreshed"); await expect(fixture.getByTestId("canvas-fixture-task-count")).toHaveText( String(expectedTaskCount), ); await expect(fixture.locator(".task-item")).toHaveCount(expectedTaskCount); await expect(fixture.getByTestId("canvas-fixture-task-ids")).toContainText(seeded.taskId); await expect(fixture.getByTestId("canvas-fixture-task-ids")).toContainText( workspacePreview.workspaceTaskId, ); await expect(fixture.getByTestId("canvas-fixture-foreign-workspace-status")).toHaveText( "denied:403", ); await expect(fixture.getByTestId("canvas-fixture-workflow-count")).toHaveText("1"); await expect(fixture.getByTestId("canvas-fixture-step-id")).not.toHaveText("loading"); await expect(fixture.getByTestId("canvas-fixture-sse-status")).toHaveText("connected"); await fixture.getByTestId("canvas-fixture-move").dispatchEvent("click"); await expect(fixture.getByTestId("canvas-fixture-move-status")).toHaveText(/moved:/); await fixture.getByTestId("canvas-fixture-continue").dispatchEvent("click"); await expect(fixture.getByTestId("canvas-fixture-message-status")).toHaveText("accepted"); await expect .poll(async () => Number(await fixture.getByTestId("canvas-fixture-sse-events").textContent()), ) .toBeGreaterThan(0); await fixture.getByTestId("canvas-fixture-state").dispatchEvent("click"); await expect(fixture.getByTestId("canvas-fixture-state-status")).toHaveText( /conflict-recovered:/, ); await fixture.getByTestId("canvas-fixture-reconnect").dispatchEvent("click"); await expect(fixture.getByTestId("canvas-fixture-sse-status")).toHaveText("connected"); await fixture.getByTestId("canvas-fixture-resync").dispatchEvent("click"); await expect(fixture.getByTestId("canvas-fixture-sse-resync")).toHaveText("received", { timeout: 15_000, }); await expect .poll( () => testPage.evaluate((id) => { const dockview = ( window as unknown as { __dockviewApi__?: { panels?: Array<{ id: string; api?: { component?: string }; params?: Record; }>; }; } ).__dockviewApi__; const panel = dockview?.panels?.find((candidate) => candidate.id === `canvas:${id}`); return panel ? { id: panel.id, component: panel.api?.component, canvasId: panel.params?.canvasId, } : null; }, seeded.canvas.id), { timeout: 10_000 }, ) .toEqual({ id: `canvas:${seeded.canvas.id}`, component: "canvas", canvasId: seeded.canvas.id, }); const themeToggle = testPage.getByRole("button", { name: "Switch to Dark Mode", exact: true, }); await expect(themeToggle).toBeVisible(); await themeToggle.evaluate((element) => (element as HTMLButtonElement).click()); await expect(testPage.locator("html")).toHaveClass(/(^|\s)dark(\s|$)/); await expect(fixture.getByTestId("canvas-fixture-appearance-mode")).toHaveText("dark"); await expect(fixture.getByTestId("canvas-fixture-appearance-color-scheme")).toHaveText( "dark", ); await expect .poll(() => fixture.getByTestId("canvas-fixture-appearance-background").textContent()) .not.toBe(lightBackground); } finally { if (canvasId) await removeCanvas(apiClient, canvasId); await workspacePreview?.cleanup(); await releaseFeature(); } }); test("reconciles a published task canvas after returning to the task", async ({ testPage, apiClient, backend, seedData, }) => { test.setTimeout(180_000); const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); let canvasId: string | undefined; let taskId: string | undefined; let authoringPage: Page | undefined; try { await testPage.goto("/"); authoringPage = await testPage.context().newPage(); const seeded = await seedTaskCanvas(authoringPage, apiClient, seedData); canvasId = seeded.canvas.id; taskId = seeded.taskId; await authoringPage.close(); authoringPage = undefined; await testPage.reload(); await testPage.goto(`/t/${encodeURIComponent(seeded.taskId)}`); await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible(); await expect .poll( () => testPage.evaluate((id) => { const api = ( window as unknown as { __dockviewApi__?: { getPanel: (panelId: string) => { group?: { id?: string } } | undefined; panels?: Array<{ id: string }>; }; } ).__dockviewApi__; const panel = api?.getPanel(`canvas:${id}`); return { count: api?.panels?.filter((candidate) => candidate.id === `canvas:${id}`).length ?? 0, canvasGroup: panel?.group?.id ?? null, }; }, seeded.canvas.id), { timeout: 30_000 }, ) .toEqual({ count: 1, canvasGroup: "group-center" }); await expect(testPage.getByTestId("canvas-host-state")).toHaveText("Ready", { timeout: 20_000, }); await testPage.evaluate((id) => { const api = ( window as unknown as { __dockviewApi__?: { getPanel: (panelId: string) => { api: { close: () => void } } | undefined; }; __persistDockviewLayout__?: () => void; } ).__dockviewApi__; api?.getPanel(`canvas:${id}`)?.api.close(); ( window as unknown as { __persistDockviewLayout__?: () => void } ).__persistDockviewLayout__?.(); }, seeded.canvas.id); await expect .poll(() => testPage.evaluate((id) => { const api = ( window as unknown as { __dockviewApi__?: { getPanel: (panelId: string) => unknown } } ).__dockviewApi__; return api?.getPanel(`canvas:${id}`) !== undefined; }, seeded.canvas.id), ) .toBe(false); const taskInventory = waitForHttp( testPage, "GET", new RegExp(`/api/v1/tasks/${encodeURIComponent(seeded.taskId)}/canvases$`), ); await testPage.reload(); await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible(); await taskInventory; await expect(testPage.getByTestId("dockview-task-layout")).toHaveAttribute( "aria-busy", "false", ); await expect .poll(() => testPage.evaluate((id) => { const api = ( window as unknown as { __dockviewApi__?: { getPanel: (panelId: string) => unknown } } ).__dockviewApi__; return api?.getPanel(`canvas:${id}`) !== undefined; }, seeded.canvas.id), ) .toBe(false); } finally { await authoringPage?.close(); if (canvasId) await removeCanvas(apiClient, canvasId); if (taskId) await apiClient.deleteTask(taskId).catch(() => undefined); await releaseFeature(); } }); test("shows recoverable startup failure and retries with a fresh runtime", async ({ testPage, apiClient, backend, seedData, }) => { test.setTimeout(180_000); const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); let runtimeFailures = 0; let canvasId: string | undefined; try { const seeded = await seedTaskCanvas(testPage, apiClient, seedData); canvasId = seeded.canvas.id; await testPage.goto(canvasHref(seeded.canvas.id)); await expect(testPage.getByTestId("web-app-frame")).toHaveAttribute( "data-frame-state", "ready", { timeout: 20_000 }, ); await testPage.route( `**/api/v1/canvases/${encodeURIComponent(seeded.canvas.id)}/runtime**`, async (route) => { if (runtimeFailures === 0) { runtimeFailures += 1; await route.fulfill({ status: 503, body: "runtime unavailable" }); return; } await route.continue(); }, ); await testPage.reload(); await expect.poll(() => runtimeFailures).toBe(1); await expect(testPage.getByTestId("canvas-host-state")).toHaveText("Canvas unavailable", { timeout: 20_000, }); await expect(testPage.getByTestId("web-app-frame")).toHaveCount(0); await expect(testPage.getByRole("button", { name: "Try again", exact: true })).toBeVisible(); await testPage.getByRole("button", { name: "Try again", exact: true }).click(); await expect(testPage.getByTestId("canvas-host-state")).toHaveText("Ready", { timeout: 20_000, }); await expect(testPage.getByTestId("web-app-frame")).toHaveAttribute( "data-frame-state", "ready", { timeout: 20_000 }, ); expect(runtimeFailures).toBe(1); } finally { await testPage.unroute(`**/api/v1/canvases/${encodeURIComponent(canvasId ?? "")}/runtime**`); if (canvasId) await removeCanvas(apiClient, canvasId); await releaseFeature(); } }); test("keeps the desktop release review wide without scrolling two permissions", async ({ testPage, apiClient, backend, seedData, }) => { test.setTimeout(180_000); await testPage.setViewportSize({ width: 1280, height: 720 }); const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId); let canvasId: string | undefined; try { const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true, { noPermissions: true, }); canvasId = seeded.canvas.id; const workspacePath = await waitForSessionWorkspace( apiClient, seeded.taskId, seeded.taskSessionId, ); writeCanvasSource(workspacePath, seeded.canvas, { minimalPermissions: true }); const publishScript = `e2e:mcp:kandev:publish_canvas_kandev(${JSON.stringify({ canvas_id: seeded.canvas.id, source_path: `.kandev/canvases/${seeded.canvas.id}`, })})`; const reviewSession = await apiClient.launchSession({ task_id: seeded.taskId, agent_profile_id: seedData.agentProfileId, executor_profile_id: seedData.worktreeExecutorProfileId, workflow_step_id: seedData.startStepId, prompt: `e2e:delay(2500)\n${publishScript}`, }); const reviewWorkspacePath = await waitForSessionWorkspace( apiClient, seeded.taskId, reviewSession.session_id, ); writeCanvasSource(reviewWorkspacePath, seeded.canvas, { minimalPermissions: true }); await waitForSessionDone( apiClient, seeded.taskId, reviewSession.session_id, "The permission-increasing canvas publication did not finish.", 45_000, ); let pendingReleaseId: string | undefined; await expect .poll( async () => { const releases = await listCanvasReleases(apiClient, canvasId!); pendingReleaseId = releases.find( (release) => release.validation_status === "pending_permission", )?.id; return pendingReleaseId ?? null; }, { timeout: 30_000, message: "The permission-increasing canvas release did not pend.", }, ) .not.toBeNull(); expect(pendingReleaseId).toBeTruthy(); await testPage.goto(canvasHref(canvasId)); const releasesButton = testPage.getByRole("button", { name: "Releases and permissions", exact: true, }); await expect(releasesButton).toBeVisible({ timeout: 20_000, }); await releasesButton.click(); const dialog = testPage.getByTestId("canvas-releases-dialog"); await expect(dialog).toBeVisible(); const dialogBox = await dialog.boundingBox(); expect(dialogBox?.width).toBeGreaterThanOrEqual(720); expect(dialogBox?.width).toBeLessThanOrEqual(780); const permissions = dialog.getByTestId("canvas-permission-summary"); await expect(permissions).toBeVisible(); await expect(permissions.locator("li")).toHaveCount(2); const scrollMetrics = await dialog .getByTestId("canvas-release-review-scroll") .evaluate((element) => ({ clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, })); expect(scrollMetrics.scrollHeight).toBeLessThanOrEqual(scrollMetrics.clientHeight); } finally { if (canvasId) await removeCanvas(apiClient, canvasId); await releaseFeature(); } }); });