Files
kandev/apps/web/e2e/tests/canvas/canvas-fixture.ts
T
2026-09-14 09:31:20 +01:00

692 lines
23 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import type { BackendContext } from "../../fixtures/backend";
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { waitForSessionDone } from "../../helpers/session";
import { SessionPage } from "../../pages/session-page";
export class CanvasFixtureUnavailable extends Error {
constructor(message: string) {
super(message);
this.name = "CanvasFixtureUnavailable";
}
}
export type CanvasRecord = {
id: string;
title: string;
workspace_id: string;
task_id?: string;
scope_kind?: string;
status?: string;
active_release_id?: string;
active_release_status?: string;
pending_release?: {
id: string;
validation_status?: string;
};
};
export type CanvasReleaseRecord = {
id: string;
validation_status?: string;
permissions?: {
reads?: string[];
writes?: string[];
events?: string[];
shared_state?: boolean;
};
};
export type SeededCanvas = {
taskId: string;
taskSessionId: string;
canvas: CanvasRecord;
session: SessionPage;
};
export async function expectCanvasFrameFillsHost(page: Page): Promise<void> {
await expect
.poll(
() =>
page.evaluate(() => {
const host = document.querySelector('[data-testid="canvas-host-route"]');
const header = document.querySelector('[data-testid="canvas-host-header"]');
const frame = document.querySelector('[data-testid="web-app-frame"]');
if (!host || !header || !frame) return false;
const hostRect = host.getBoundingClientRect();
const headerRect = header.getBoundingClientRect();
const frameRect = frame.getBoundingClientRect();
const heightMatches =
Math.abs(frameRect.height - (hostRect.height - headerRect.height)) <= 2;
const widthMatches = Math.abs(frameRect.width - hostRect.width) <= 2;
const documentFits = document.documentElement.scrollWidth <= window.innerWidth;
return heightMatches && widthMatches && documentFits;
}),
{
timeout: 5_000,
message: "The canvas iframe host did not settle to the available viewport.",
},
)
.toBe(true);
}
export async function readCanvasFeature(apiClient: ApiClient): Promise<boolean | null> {
const response = await apiClient.rawRequest("GET", "/api/v1/features");
if (!response.ok) return null;
const body = (await response.json()) as { canvases?: unknown };
return typeof body.canvases === "boolean" ? body.canvases : null;
}
export async function enableCanvasFeature(
backend: BackendContext,
apiClient: ApiClient,
workspaceId: string,
): Promise<() => Promise<void>> {
const release = await backend.useEnv({ KANDEV_FEATURES_CANVASES: "true" });
const enabled = await readCanvasFeature(apiClient);
if (enabled !== true) {
await release();
throw new CanvasFixtureUnavailable(
"Canvas fixture skipped: KANDEV_FEATURES_CANVASES could not be enabled.",
);
}
const probe = await apiClient.rawRequest(
"GET",
`/api/v1/workspaces/${encodeURIComponent(workspaceId)}/canvases`,
);
if (!probe.ok) {
await release();
throw new CanvasFixtureUnavailable(
`Canvas fixture skipped: canvas API is unavailable (${probe.status}).`,
);
}
return release;
}
export async function listTaskCanvases(
apiClient: ApiClient,
taskId: string,
): Promise<CanvasRecord[]> {
const response = await apiClient.rawRequest(
"GET",
`/api/v1/tasks/${encodeURIComponent(taskId)}/canvases`,
);
if (!response.ok) {
throw new Error(`Canvas task discovery failed (${response.status}).`);
}
const body = (await response.json()) as { canvases?: CanvasRecord[] };
return body.canvases ?? [];
}
export async function getCanvas(
apiClient: ApiClient,
canvasId: string,
): Promise<CanvasRecord | null> {
const response = await apiClient.rawRequest(
"GET",
`/api/v1/canvases/${encodeURIComponent(canvasId)}`,
);
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(`Canvas lookup failed (${response.status}).`);
}
return (await response.json()) as CanvasRecord;
}
export async function listCanvasReleases(
apiClient: ApiClient,
canvasId: string,
): Promise<CanvasReleaseRecord[]> {
const response = await apiClient.rawRequest(
"GET",
`/api/v1/canvases/${encodeURIComponent(canvasId)}/releases`,
);
if (!response.ok) {
throw new Error(`Canvas release lookup failed (${response.status}).`);
}
const body = (await response.json()) as { releases?: CanvasReleaseRecord[] };
return body.releases ?? [];
}
export async function waitForSessionWorkspace(
apiClient: ApiClient,
taskId: string,
sessionId: string,
): Promise<string> {
let workspacePath = "";
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(taskId);
const session = sessions.find((candidate) => candidate.id === sessionId);
workspacePath =
session?.workspace_path ??
session?.worktree_path ??
session?.worktrees?.find((worktree) => worktree.worktree_path)?.worktree_path ??
"";
return workspacePath;
},
{ timeout: 30_000, message: "The canvas task session did not expose a local workspace." },
)
.toMatch(/\S/);
return path.resolve(workspacePath);
}
export type CanvasSourceOptions = {
noPermissions?: boolean;
minimalPermissions?: boolean;
};
function canvasCapabilities(options?: CanvasSourceOptions): string[] {
if (options?.noPermissions) return [];
if (options?.minimalPermissions) return [" api_read:", " - tasks", " - workflows"];
return [
" api_read:",
" - tasks",
" - workflows",
" api_write:",
" - tasks",
" - messages",
" events:",
" - task.updated",
" state: true",
];
}
function canvasManifest(canvas: CanvasRecord, options?: CanvasSourceOptions): string {
const compactId = canvas.id.replaceAll("-", "").slice(0, 12);
const capabilities = canvasCapabilities(options);
return [
`id: "canvas-${compactId}"`,
"api_version: 2",
'version: "1.0.0"',
'display_name: "E2E Plugin Canvas"',
'description: "Canvas fixture for Playwright acceptance coverage."',
'author: "Kandev E2E"',
'min_kandev_version: "0.94.0"',
"distribution:",
" schema_version: 1",
' kind: "canvas"',
' license: "MIT"',
' source_mode: "static"',
"ui:",
" web_apps:",
" - key: main",
' title: "E2E Plugin Canvas"',
" entry: index.html",
" placements:",
" - task-canvas",
" - workspace-canvas",
"capabilities:",
...capabilities,
"",
].join("\n");
}
function canvasFixtureScript(canvas: CanvasRecord): string {
const taskID = JSON.stringify(canvas.task_id ?? "");
return String.raw`(() => {
const taskId = ${taskID};
const text = (testId, value) => {
const element = document.querySelector('[data-testid="' + testId + '"]');
if (element) element.textContent = String(value);
};
const api = async (path, options = {}) => {
const response = await fetch(path, {
...options,
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(body.error || ("HTTP " + response.status));
error.status = response.status;
error.body = body;
throw error;
}
return body;
};
let task;
let steps = [];
let stateRevision = 0;
let lastEventId = "";
let streamController;
let streamReader;
const renderAppearance = () => {
const root = document.documentElement;
const styles = getComputedStyle(root);
text("canvas-fixture-appearance-mode", root.dataset.kandevAppearance || "loading");
text(
"canvas-fixture-appearance-background",
styles.getPropertyValue("--background").trim() || "loading",
);
text("canvas-fixture-appearance-color-scheme", root.style.colorScheme || "loading");
};
window.addEventListener("message", (event) => {
if (event.source === window.parent) renderAppearance();
});
const loadProjection = async () => {
const [context, taskPage, workflowPage] = await Promise.all([
api("./_kandev/v1/context"),
api("./_kandev/v1/data/tasks?limit=10"),
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,
);
if (workflow) {
const stepPage = await api(
"./_kandev/v1/data/workflows/" + encodeURIComponent(workflow.id) + "/steps",
);
steps = stepPage.items || [];
}
text("canvas-fixture-context", context.task_id || "workspace");
text("canvas-fixture-task-count", tasks.length);
text("canvas-fixture-workflow-count", workflowPage.items?.length || 0);
text("canvas-fixture-step-id", task?.workflow_step_id || "");
};
const parseEvent = (block) => {
if (block.trim().startsWith(":")) return;
let eventType = "message";
let eventId = "";
let data = "";
block.split("\n").forEach((line) => {
if (line.startsWith("event: ")) eventType = line.slice(7);
if (line.startsWith("id: ")) eventId = line.slice(4);
if (line.startsWith("data: ")) data += line.slice(6);
});
if (eventId) lastEventId = eventId;
if (eventType === "runtime.resync_required") {
text("canvas-fixture-sse-resync", "received");
void loadProjection();
return;
}
const current = Number(document.querySelector('[data-testid="canvas-fixture-sse-events"]')?.textContent || 0);
text("canvas-fixture-sse-events", current + 1);
void loadProjection();
};
const connectEvents = async (cursor = "") => {
if (streamController) streamController.abort();
const controller = new AbortController();
streamController = controller;
text("canvas-fixture-sse-status", "connecting");
try {
const response = await fetch("./_kandev/v1/events", {
headers: cursor ? { "Last-Event-ID": cursor } : {},
signal: controller.signal,
});
if (!response.ok || !response.body) throw new Error("event stream unavailable");
text("canvas-fixture-sse-status", "connected");
streamReader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const result = await streamReader.read();
if (result.done) break;
buffer += decoder.decode(result.value, { stream: true });
let boundary = buffer.indexOf("\n\n");
while (boundary >= 0) {
const block = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
if (block.trim()) parseEvent(block);
boundary = buffer.indexOf("\n\n");
}
}
} catch (error) {
if (!controller.signal.aborted) text("canvas-fixture-sse-status", "disconnected");
}
};
document.querySelector('[data-testid="canvas-fixture-continue"]')?.addEventListener("click", async () => {
text("canvas-fixture-message-status", "sending");
try {
await api("./_kandev/v1/data/tasks/" + encodeURIComponent(taskId) + "/messages", {
method: "POST",
body: JSON.stringify({ text: "continue" }),
});
text("canvas-fixture-message-status", "accepted");
} catch (error) {
text("canvas-fixture-message-status", "error:" + error.status);
}
});
document.querySelector('[data-testid="canvas-fixture-move"]')?.addEventListener("click", async () => {
const next = steps.find((step) => step.id !== task?.workflow_step_id);
if (!next) {
text("canvas-fixture-move-status", "no-next-step");
return;
}
try {
task = await api("./_kandev/v1/data/tasks/" + encodeURIComponent(taskId), {
method: "PATCH",
body: JSON.stringify({ workflow_step_id: next.id }),
});
text("canvas-fixture-move-status", "moved:" + next.id);
text("canvas-fixture-step-id", task.workflow_step_id);
} catch (error) {
text("canvas-fixture-move-status", "error:" + error.status);
}
});
document.querySelector('[data-testid="canvas-fixture-state"]')?.addEventListener("click", async () => {
try {
const first = await api("./_kandev/v1/state/board", {
method: "PUT",
headers: { "If-Match": '"0"' },
body: JSON.stringify({ selected: true }),
});
stateRevision = first.revision;
try {
await api("./_kandev/v1/state/board", {
method: "PUT",
headers: { "If-Match": '"0"' },
body: JSON.stringify({ selected: false }),
});
} catch (error) {
if (error.status !== 409) throw error;
const recovered = await api("./_kandev/v1/state/board");
stateRevision = recovered.revision;
text("canvas-fixture-state-status", "conflict-recovered:" + stateRevision);
return;
}
text("canvas-fixture-state-status", "unexpected-no-conflict");
} catch (error) {
text("canvas-fixture-state-status", "error:" + error.status);
}
});
document.querySelector('[data-testid="canvas-fixture-reconnect"]')?.addEventListener("click", () => {
void connectEvents(lastEventId);
});
document.querySelector('[data-testid="canvas-fixture-resync"]')?.addEventListener("click", () => {
void connectEvents("old-generation:1");
});
text("canvas-fixture-script", "inline-ready");
renderAppearance();
void loadProjection().then(() => connectEvents());
})();`;
}
export function writeCanvasSource(
workspacePath: string,
canvas: CanvasRecord,
options?: CanvasSourceOptions,
): void {
const sourceDirectory = path.join(workspacePath, ".kandev", "canvases", canvas.id);
fs.mkdirSync(sourceDirectory, { recursive: true });
fs.writeFileSync(path.join(sourceDirectory, "manifest.yaml"), canvasManifest(canvas, options));
fs.writeFileSync(
path.join(sourceDirectory, "index.html"),
[
"<!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>',
" <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>',
' <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>',
" </section>",
" </main>",
` <script>${canvasFixtureScript(canvas)}</script>`,
' <script src="./script.js"></script>',
" </body>",
"</html>",
"",
].join("\n"),
);
}
export async function seedTaskCanvas(
page: Page,
apiClient: ApiClient,
seedData: SeedData,
useMobileSubmit = false,
sourceOptions?: CanvasSourceOptions,
): Promise<SeededCanvas> {
const title = "E2E Plugin Canvas";
const script = [
`e2e:mcp:kandev:create_canvas_kandev(${JSON.stringify({
title,
summary: "Canvas fixture for Playwright acceptance coverage.",
})})`,
'e2e:message("Canvas created.")',
].join("\n");
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"E2E Plugin Canvas Task",
seedData.agentProfileId,
{
description: script,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) {
throw new CanvasFixtureUnavailable("Canvas fixture skipped: task session was not created.");
}
await page.goto(`/t/${encodeURIComponent(task.id)}`);
const session = new SessionPage(page);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 45_000 });
const canvas = await waitForTaskCanvas(apiClient, task.id, title);
const publishedCanvas = await publishTaskCanvas({
apiClient,
taskId: task.id,
taskSessionId: task.session_id,
session,
canvas,
useMobileSubmit,
sourceOptions,
});
return {
taskId: task.id,
taskSessionId: task.session_id,
canvas: publishedCanvas,
session,
};
}
export async function waitForTaskCanvas(
apiClient: ApiClient,
taskId: string,
title: string,
): Promise<CanvasRecord> {
let canvas: CanvasRecord | null = null;
await expect
.poll(
async () => {
const canvases = await listTaskCanvases(apiClient, taskId);
canvas =
canvases.find(
(candidate) =>
candidate.title === title &&
candidate.scope_kind === "task" &&
candidate.task_id === taskId,
) ?? null;
return canvas?.id ?? null;
},
{ timeout: 30_000, message: "The mock agent did not create a task canvas." },
)
.not.toBeNull();
if (!canvas) throw new Error("The canvas fixture returned no canvas record.");
return canvas;
}
type PublishTaskCanvasOptions = {
apiClient: ApiClient;
taskId: string;
taskSessionId: string;
session: SessionPage;
canvas: CanvasRecord;
useMobileSubmit?: boolean;
sourceOptions?: CanvasSourceOptions;
};
export async function publishTaskCanvas({
apiClient,
taskId,
taskSessionId,
session,
canvas,
useMobileSubmit = false,
sourceOptions,
}: PublishTaskCanvasOptions): Promise<CanvasRecord> {
const workspacePath = await waitForSessionWorkspace(apiClient, taskId, taskSessionId);
writeCanvasSource(workspacePath, canvas, sourceOptions);
const sourcePath = `.kandev/canvases/${canvas.id}`;
const publishScript = `e2e:mcp:kandev:publish_canvas_kandev(${JSON.stringify({
canvas_id: canvas.id,
source_path: sourcePath,
})})`;
if (useMobileSubmit) {
await session.sendMessageViaButton(publishScript);
} else {
await session.sendMessage(publishScript);
}
// The release poll below is the authoritative completion signal. Waiting
// for the chat composer is an unrelated UI settle condition and can time
// out while the publish request is still being processed.
let publishedCanvas: CanvasRecord | null = null;
await expect
.poll(
async () => {
publishedCanvas = await getCanvas(apiClient, canvas.id);
return Boolean(
publishedCanvas?.pending_release ||
publishedCanvas?.active_release_id ||
publishedCanvas?.active_release_status,
);
},
{ timeout: 30_000, message: "The mock agent did not publish the canvas package." },
)
.toBe(true);
if (!publishedCanvas) throw new Error("The canvas publish response was empty.");
await waitForSessionDone(
apiClient,
taskId,
taskSessionId,
"The canvas publishing session did not finish before interaction coverage.",
45_000,
);
return publishedCanvas;
}
export async function approvePendingCanvas(
apiClient: ApiClient,
canvas: CanvasRecord,
): Promise<CanvasRecord> {
const pendingRelease = canvas.pending_release;
if (!pendingRelease) throw new Error("The canvas has no pending release to approve.");
const response = await apiClient.rawRequest(
"POST",
`/api/v1/canvases/${encodeURIComponent(canvas.id)}/releases/${encodeURIComponent(pendingRelease.id)}/approve`,
);
if (!response.ok) {
throw new Error(`Canvas release approval failed (${response.status}).`);
}
let approvedCanvas: CanvasRecord | null = null;
await expect
.poll(
async () => {
approvedCanvas = await getCanvas(apiClient, canvas.id);
return approvedCanvas?.active_release_status === "valid";
},
{ timeout: 30_000, message: "The canvas release did not become active." },
)
.toBe(true);
if (!approvedCanvas) throw new Error("The approved canvas record was empty.");
return approvedCanvas;
}
export async function promoteCanvas(
apiClient: ApiClient,
canvas: CanvasRecord,
): Promise<CanvasRecord> {
const previewResponse = await apiClient.rawRequest(
"GET",
`/api/v1/canvases/${encodeURIComponent(canvas.id)}/promotion-preview`,
);
if (!previewResponse.ok) {
throw new Error(`Canvas promotion preview failed (${previewResponse.status}).`);
}
const preview = (await previewResponse.json()) as {
active_release_id?: string;
permission_digest?: string;
grant_generation?: number;
};
if (
!preview.active_release_id ||
!preview.permission_digest ||
preview.grant_generation === undefined
) {
throw new Error("Canvas promotion preview was incomplete.");
}
const response = await apiClient.rawRequest(
"POST",
`/api/v1/canvases/${encodeURIComponent(canvas.id)}/promotion`,
{
expected_release_id: preview.active_release_id,
expected_permission_digest: preview.permission_digest,
expected_grant_generation: preview.grant_generation,
},
);
if (!response.ok) {
throw new Error(`Canvas promotion failed (${response.status}).`);
}
let promotedCanvas: CanvasRecord | null = null;
await expect
.poll(
async () => {
promotedCanvas = await getCanvas(apiClient, canvas.id);
return promotedCanvas?.scope_kind === "workspace";
},
{ timeout: 30_000, message: "The canvas did not become workspace-scoped." },
)
.toBe(true);
if (!promotedCanvas) throw new Error("The promoted canvas record was empty.");
return promotedCanvas;
}
export async function removeCanvas(apiClient: ApiClient, canvasId: string): Promise<void> {
await apiClient.rawRequest("DELETE", `/api/v1/canvases/${encodeURIComponent(canvasId)}`);
}
export function canvasHref(canvasId: string): string {
return `/canvases/${encodeURIComponent(canvasId)}`;
}