feat: preview task canvases with workspace data (#3887)

This commit is contained in:
Carlos Florêncio
2026-09-23 20:51:55 +01:00
committed by GitHub
parent 9874ac4cf5
commit 4bebf7c511
79 changed files with 3369 additions and 282 deletions
@@ -76,7 +76,7 @@ test.describe("authenticated same-origin canvas runtime", () => {
true,
);
await frame.getByTestId("canvas-fixture-continue").click();
await frame.getByTestId("canvas-fixture-continue").dispatchEvent("click");
await expect(frame.getByTestId("canvas-fixture-message-status")).toHaveText("accepted");
await expect
.poll(async () =>
+106 -26
View File
@@ -20,6 +20,8 @@ export type CanvasRecord = {
workspace_id: string;
task_id?: string;
scope_kind?: string;
data_scope_kind?: string;
grant_generation?: number;
status?: string;
active_release_id?: string;
active_release_status?: string;
@@ -47,6 +49,31 @@ export type SeededCanvas = {
session: SessionPage;
};
export async function seedCanvasWorkspacePreview(apiClient: ApiClient, seedData: SeedData) {
const previewTask = await apiClient.seedTask(
seedData.workspaceId,
"E2E Canvas Workspace Preview Task",
{ workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId },
);
const foreignWorkspaceName = "E2E Canvas Foreign Workspace";
try {
const foreignWorkspace = await apiClient.createWorkspace(foreignWorkspaceName);
return {
workspaceTaskId: previewTask.task_id,
foreignWorkspaceId: foreignWorkspace.id,
cleanup: async () => {
await apiClient.deleteTask(previewTask.task_id).catch(() => undefined);
await apiClient
.deleteWorkspace(foreignWorkspace.id, foreignWorkspaceName)
.catch(() => undefined);
},
};
} catch (error) {
await apiClient.deleteTask(previewTask.task_id).catch(() => undefined);
throw error;
}
}
export async function expectCanvasFrameFillsHost(page: Page): Promise<void> {
await expect
.poll(
@@ -182,6 +209,7 @@ export async function waitForSessionWorkspace(
export type CanvasSourceOptions = {
noPermissions?: boolean;
minimalPermissions?: boolean;
foreignWorkspaceId?: string;
};
function canvasCapabilities(options?: CanvasSourceOptions): string[] {
@@ -230,10 +258,12 @@ function canvasManifest(canvas: CanvasRecord, options?: CanvasSourceOptions): st
].join("\n");
}
function canvasFixtureScript(canvas: CanvasRecord): string {
function canvasFixtureScript(canvas: CanvasRecord, options?: CanvasSourceOptions): string {
const taskID = JSON.stringify(canvas.task_id ?? "");
const foreignWorkspaceID = JSON.stringify(options?.foreignWorkspaceId ?? "");
return String.raw`(() => {
const taskId = ${taskID};
const foreignWorkspaceId = ${foreignWorkspaceID};
const text = (testId, value) => {
const element = document.querySelector('[data-testid="' + testId + '"]');
if (element) element.textContent = String(value);
@@ -260,6 +290,23 @@ function canvasFixtureScript(canvas: CanvasRecord): string {
let streamReader;
let streamCompletion = Promise.resolve();
const renderTasks = (tasks) => {
const list = document.querySelector('[data-testid="canvas-fixture-task-list"]');
if (!list) return;
list.replaceChildren();
tasks.forEach((candidate) => {
const item = document.createElement("article");
item.className = "task-item";
const title = document.createElement("h2");
title.textContent = candidate.title || "Workspace task";
const status = document.createElement("span");
status.className = "task-status";
status.textContent = candidate.status || "Open";
item.append(title, status);
list.append(item);
});
};
const renderAppearance = () => {
const root = document.documentElement;
const styles = getComputedStyle(root);
@@ -275,12 +322,22 @@ function canvasFixtureScript(canvas: CanvasRecord): string {
});
const loadProjection = async () => {
const [context, taskPage, workflowPage] = await Promise.all([
const [context, tasks, workflowPage] = await Promise.all([
api("./_kandev/v1/context"),
api("./_kandev/v1/data/tasks?limit=10"),
(async () => {
const items = [];
let cursor = "";
do {
const query = new URLSearchParams({ limit: "1" });
if (cursor) query.set("cursor", cursor);
const page = await api("./_kandev/v1/data/tasks?" + query.toString());
items.push(...(page.items || []));
cursor = page.page_info?.next_cursor || "";
} while (cursor);
return items;
})(),
api("./_kandev/v1/data/workflows?limit=10"),
]);
const tasks = taskPage.items || [];
task = tasks.find((candidate) => candidate.id === taskId) || tasks[0];
const workflow = (workflowPage.items || []).find(
(candidate) => candidate.id === task?.workflow_id,
@@ -293,8 +350,19 @@ function canvasFixtureScript(canvas: CanvasRecord): string {
}
text("canvas-fixture-context", context.task_id || "workspace");
text("canvas-fixture-task-count", tasks.length);
text("canvas-fixture-task-ids", tasks.map((candidate) => candidate.id).join(","));
renderTasks(tasks);
text("canvas-fixture-workflow-count", workflowPage.items?.length || 0);
text("canvas-fixture-step-id", task?.workflow_step_id || "");
text("canvas-fixture-refresh-status", "refreshed");
if (foreignWorkspaceId) {
try {
await api("./_kandev/v1/data/tasks?workspace_id=" + encodeURIComponent(foreignWorkspaceId));
text("canvas-fixture-foreign-workspace-status", "unexpected-allowed");
} catch (error) {
text("canvas-fixture-foreign-workspace-status", "denied:" + error.status);
}
}
};
const parseEvent = (block) => {
@@ -425,6 +493,12 @@ function canvasFixtureScript(canvas: CanvasRecord): string {
document.querySelector('[data-testid="canvas-fixture-resync"]')?.addEventListener("click", () => {
void connectEvents("old-generation:1");
});
document.querySelector('[data-testid="canvas-fixture-refresh"]')?.addEventListener("click", () => {
text("canvas-fixture-refresh-status", "refreshing");
void loadProjection().catch((error) => {
text("canvas-fixture-refresh-status", "error:" + error.status);
});
});
text("canvas-fixture-script", "inline-ready");
renderAppearance();
@@ -445,33 +519,39 @@ export function writeCanvasSource(
[
"<!doctype html>",
'<html lang="en">',
' <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>E2E Plugin Canvas</title><link rel="stylesheet" href="./styles.css"><script src="./appearance.js"></script></head>',
' <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>E2E Plugin Canvas</title><script src="./appearance.js"></script>',
" <style>",
" :root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #172033; background: #f7f8fc; font-synthesis: none; }",
" * { box-sizing: border-box; } body { margin: 0; padding: 32px; } .canvas-shell { max-width: 920px; margin: 0 auto; }",
" .canvas-header { margin-bottom: 24px; } .eyebrow { margin: 0 0 8px; color: #6257df; font-size: 12px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }",
" h1 { margin: 0; font-size: clamp(24px, 4vw, 34px); line-height: 1.2; letter-spacing: -.03em; } .summary { margin: 8px 0 0; color: #687187; font-size: 14px; }",
" .canvas-card { padding: 20px; border: 1px solid #e2e6ef; border-radius: 16px; background: white; box-shadow: 0 10px 32px #27345b0a; }",
" .list-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; } .list-heading h2 { margin: 0; font-size: 15px; }",
" .task-count { color: #687187; font-size: 12px; } .task-list { display: grid; gap: 10px; }",
" .task-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 58px; padding: 12px 14px; border: 1px solid #e8ebf2; border-radius: 10px; background: #fff; }",
" .task-item h2 { margin: 0; font-size: 14px; font-weight: 600; } .task-status { flex: none; padding: 4px 8px; border-radius: 999px; background: #f0efff; color: #5046c7; font-size: 11px; font-weight: 600; text-transform: capitalize; }",
' .canvas-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } button { min-height: 34px; padding: 7px 11px; border: 1px solid #dfe3ec; border-radius: 8px; background: white; color: #384158; font: inherit; font-size: 12px; cursor: pointer; -webkit-tap-highlight-color: transparent; } button:hover { background: #f6f7fb; } button[data-testid="canvas-fixture-refresh"] { border-color: #5548e8; background: #5548e8; color: white; }',
" .diagnostics { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; clip-path: inset(50%); }",
' @media (max-width: 600px) { body { padding: 20px 16px; } .canvas-card { padding: 16px; } .task-item { align-items: flex-start; flex-direction: column; gap: 8px; } .canvas-actions button:not([data-testid="canvas-fixture-refresh"]) { display: none; } }',
" </style></head>",
" <body>",
' <main class="canvas-shell" data-testid="canvas-fixture-content">',
' <header class="canvas-header"><p class="eyebrow" data-testid="canvas-fixture-eyebrow">E2E Plugin Canvas</p><h1>E2E Plugin Canvas</h1></header>',
' <header class="canvas-header"><p class="eyebrow" data-testid="canvas-fixture-eyebrow">Workspace preview</p><h1>Your workspace tasks</h1><p class="summary">A live view of work across this workspace.</p></header>',
' <section class="canvas-card">',
' <p data-testid="canvas-fixture-script">loading</p>',
' <p data-testid="canvas-fixture-context">loading</p>',
' <p data-testid="canvas-fixture-task-count">0</p>',
' <p data-testid="canvas-fixture-workflow-count">0</p>',
' <p data-testid="canvas-fixture-step-id">loading</p>',
' <p data-testid="canvas-fixture-message-status">idle</p>',
' <p data-testid="canvas-fixture-move-status">idle</p>',
' <p data-testid="canvas-fixture-state-status">idle</p>',
' <p data-testid="canvas-fixture-sse-status">loading</p>',
' <p data-testid="canvas-fixture-sse-events">0</p>',
' <p data-testid="canvas-fixture-sse-resync">idle</p>',
' <p data-testid="canvas-fixture-appearance-mode">loading</p>',
' <p data-testid="canvas-fixture-appearance-background">loading</p>',
' <p data-testid="canvas-fixture-appearance-color-scheme">loading</p>',
' <button type="button" data-testid="canvas-fixture-continue">Continue</button>',
' <button type="button" data-testid="canvas-fixture-move">Move workflow step</button>',
' <button type="button" data-testid="canvas-fixture-state">Recover state</button>',
' <button type="button" data-testid="canvas-fixture-reconnect">Reconnect events</button>',
' <button type="button" data-testid="canvas-fixture-resync">Force resync</button>',
' <div class="list-heading"><h2>All workspace tasks</h2><span class="task-count"><span data-testid="canvas-fixture-task-count">0</span> tasks</span></div>',
' <div class="task-list" data-testid="canvas-fixture-task-list" aria-label="Workspace tasks"></div>',
' <div class="canvas-actions"><button type="button" data-testid="canvas-fixture-refresh">Refresh list</button></div>',
' <div class="diagnostics" aria-hidden="true">',
' <p data-testid="canvas-fixture-script">loading</p><p data-testid="canvas-fixture-context">loading</p><p data-testid="canvas-fixture-task-ids"></p>',
' <p data-testid="canvas-fixture-refresh-status">idle</p><p data-testid="canvas-fixture-foreign-workspace-status">not-tested</p><p data-testid="canvas-fixture-workflow-count">0</p><p data-testid="canvas-fixture-step-id">loading</p>',
' <p data-testid="canvas-fixture-message-status">idle</p><p data-testid="canvas-fixture-move-status">idle</p><p data-testid="canvas-fixture-state-status">idle</p><p data-testid="canvas-fixture-sse-status">loading</p>',
' <p data-testid="canvas-fixture-sse-events">0</p><p data-testid="canvas-fixture-sse-resync">idle</p><p data-testid="canvas-fixture-appearance-mode">loading</p><p data-testid="canvas-fixture-appearance-background">loading</p><p data-testid="canvas-fixture-appearance-color-scheme">loading</p>',
' <button type="button" data-testid="canvas-fixture-continue">Continue</button><button type="button" data-testid="canvas-fixture-move">Move workflow step</button><button type="button" data-testid="canvas-fixture-state">Recover state</button>',
' <button type="button" data-testid="canvas-fixture-reconnect">Reconnect events</button><button type="button" data-testid="canvas-fixture-resync">Force resync</button>',
" </div>",
" </section>",
" </main>",
` <script>${canvasFixtureScript(canvas)}</script>`,
` <script>${canvasFixtureScript(canvas, options)}</script>`,
' <script src="./script.js"></script>',
" </body>",
"</html>",
@@ -13,6 +13,7 @@ import {
publishTaskCanvas,
removeCanvas,
promoteCanvas,
seedCanvasWorkspacePreview,
seedTaskCanvas,
waitForTaskCanvas,
} from "./canvas-fixture";
@@ -348,6 +349,117 @@ test.describe("Plugin-backed canvases on mobile", () => {
}
});
test("reviews workspace data for a legacy task canvas before enabling it", async ({
testPage,
apiClient,
backend,
seedData,
}) => {
test.setTimeout(150_000);
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
const canvasIds: string[] = [];
try {
const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true);
canvasIds.push(seeded.canvas.id);
const activeCanvas = await approvePendingCanvasThroughHost(
testPage,
apiClient,
seeded.canvas,
);
const legacyCanvas = { ...activeCanvas, data_scope_kind: "task" };
const permissionDigest = "legacy-task-canvas-review";
const grantGeneration = activeCanvas.grant_generation ?? 0;
let workspaceDataEnabled = false;
let submittedReview: Record<string, unknown> | undefined;
const canvasURL = new RegExp(`/api/v1/canvases/${activeCanvas.id}(?:\\?.*)?$`);
await testPage.route(canvasURL, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
...activeCanvas,
data_scope_kind: workspaceDataEnabled ? "workspace" : "task",
}),
});
});
await testPage.route(
new RegExp(`/api/v1/canvases/${activeCanvas.id}/workspace-data-preview(?:\\?.*)?$`),
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
canvas: legacyCanvas,
active_release_id: activeCanvas.active_release_id,
permission_digest: permissionDigest,
grant_generation: grantGeneration,
current_data_scope_kind: "task",
target_data_scope_kind: "workspace",
permissions: {
reads: ["tasks"],
writes: ["messages"],
events: ["task.updated"],
shared_state: false,
external_origins: [],
},
}),
});
},
);
await testPage.route(
new RegExp(`/api/v1/canvases/${activeCanvas.id}/workspace-data(?:\\?.*)?$`),
async (route) => {
submittedReview = route.request().postDataJSON() as Record<string, unknown>;
workspaceDataEnabled = true;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ...legacyCanvas, data_scope_kind: "workspace" }),
});
},
);
await testPage.goto(canvasHref(activeCanvas.id));
await expect(testPage.getByTestId("canvas-data-scope")).toHaveText("Task data");
await testPage.getByTestId("canvas-mobile-actions").tap();
const actions = testPage.getByTestId("canvas-mobile-actions-sheet");
await actions.getByTestId("canvas-action-enable-workspace-data").tap();
const review = testPage.getByTestId("canvas-workspace-data-dialog");
await expect(review).toBeVisible();
await expect(review.getByTestId("canvas-workspace-data-release")).toHaveText(
activeCanvas.active_release_id ?? "",
);
await expect(review.getByTestId("canvas-workspace-data-current-scope")).toHaveText(
"Task data",
);
await expect(review.getByTestId("canvas-workspace-data-target-scope")).toHaveText(
"Workspace data",
);
await expect(review.getByText("Read task data")).toBeVisible();
await review.getByRole("button", { name: "Enable workspace data", exact: true }).tap();
await expect(review).toHaveCount(0);
await expect(testPage.getByTestId("canvas-data-scope")).toHaveText("Workspace data");
expect(submittedReview).toEqual({
expected_release_id: activeCanvas.active_release_id,
expected_permission_digest: permissionDigest,
expected_grant_generation: grantGeneration,
});
await testPage.getByTestId("canvas-mobile-actions").tap();
await expect(
testPage
.getByTestId("canvas-mobile-actions-sheet")
.getByTestId("canvas-action-enable-workspace-data"),
).toHaveCount(0);
} finally {
await Promise.all(canvasIds.map((canvasId) => removeCanvas(apiClient, canvasId)));
await releaseFeature();
}
});
test("uses a focused route, workspace navigation, and an inset action drawer", async ({
testPage,
apiClient,
@@ -358,8 +470,12 @@ test.describe("Plugin-backed canvases on mobile", () => {
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
const canvasIds: string[] = [];
let workspacePreview: Awaited<ReturnType<typeof seedCanvasWorkspacePreview>> | undefined;
try {
const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true);
workspacePreview = await seedCanvasWorkspacePreview(apiClient, seedData);
const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true, {
foreignWorkspaceId: workspacePreview.foreignWorkspaceId,
});
canvasIds.push(seeded.canvas.id);
const activeCanvas = await approvePendingCanvasThroughHost(
testPage,
@@ -390,6 +506,21 @@ test.describe("Plugin-backed canvases on mobile", () => {
);
await expect(fixture.getByTestId("canvas-fixture-context")).toHaveText(seeded.taskId);
await expect(fixture.getByTestId("canvas-fixture-sse-status")).toHaveText("connected");
const expectedTaskCount = (await apiClient.listTasks(seedData.workspaceId)).tasks.length;
expect(expectedTaskCount).toBeGreaterThanOrEqual(2);
await fixture.getByTestId("canvas-fixture-refresh").tap();
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",
);
const actionsButton = testPage.getByTestId("canvas-mobile-actions");
await expect(actionsButton).toBeVisible();
@@ -409,7 +540,7 @@ test.describe("Plugin-backed canvases on mobile", () => {
const promotionDialog = testPage.getByTestId("canvas-promotion-dialog");
await expect(promotionDialog).toBeVisible();
await expect(promotionDialog.getByTestId("canvas-promotion-target-scope")).toHaveText(
"workspace",
"Workspace",
);
await promotionDialog.getByRole("button", { name: "Confirm promotion", exact: true }).tap();
@@ -480,6 +611,7 @@ test.describe("Plugin-backed canvases on mobile", () => {
).toBeVisible();
} finally {
await Promise.all(canvasIds.map((canvasId) => removeCanvas(apiClient, canvasId)));
await workspacePreview?.cleanup();
await releaseFeature();
}
});
@@ -11,6 +11,7 @@ import {
expectCanvasFrameFillsHost,
listCanvasReleases,
removeCanvas,
seedCanvasWorkspacePreview,
seedTaskCanvas,
waitForSessionWorkspace,
writeCanvasSource,
@@ -150,8 +151,12 @@ test.describe("Plugin-backed canvases in the desktop task workbench", () => {
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
let canvasId: string | undefined;
let workspacePreview: Awaited<ReturnType<typeof seedCanvasWorkspacePreview>> | undefined;
try {
const seeded = await seedTaskCanvas(testPage, apiClient, seedData);
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();
@@ -281,7 +286,21 @@ test.describe("Plugin-backed canvases in the desktop task workbench", () => {
.getByTestId("canvas-fixture-appearance-background")
.textContent();
await expect(fixture.getByTestId("canvas-fixture-context")).toHaveText(seeded.taskId);
await expect(fixture.getByTestId("canvas-fixture-task-count")).toHaveText("1");
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");
@@ -357,6 +376,7 @@ test.describe("Plugin-backed canvases in the desktop task workbench", () => {
.not.toBe(lightBackground);
} finally {
if (canvasId) await removeCanvas(apiClient, canvasId);
await workspacePreview?.cleanup();
await releaseFeature();
}
});
@@ -8,7 +8,7 @@ import {
seedTaskCanvas,
} from "./canvas-fixture";
test("task and workspace transition reads stay inside canvas scope", async ({
test("workspace transition groups remain available before and after task canvas promotion", async ({
testPage,
apiClient,
backend,
@@ -45,13 +45,16 @@ test("task and workspace transition reads stay inside canvas scope", async ({
item.to_workflow_step_id === seedData.startStepId,
),
).toBe(true);
const taskScopeGroups = await body.evaluate(
async (_, flowId) =>
(await fetch(`./_kandev/v1/data/workflows/${encodeURIComponent(flowId)}/transition-groups`))
.status,
seedData.workflowId,
const prePromotionGroups = await body.evaluate(async (_, flowId) => {
const response = await fetch(
`./_kandev/v1/data/workflows/${encodeURIComponent(flowId)}/transition-groups?limit=200`,
);
return { status: response.status, body: await response.json() };
}, seedData.workflowId);
expect(prePromotionGroups.status).toBe(200);
expect(prePromotionGroups.body.items.some((item: { count: number }) => item.count > 0)).toBe(
true,
);
expect(taskScopeGroups).toBe(403);
const workspace = await promoteCanvas(apiClient, approved);
await testPage.goto(canvasHref(workspace.id));
const groups = await testPage