fix: restore canvas runtime and panel toolbar behavior (#3827)

This commit is contained in:
Carlos Florêncio
2026-09-20 11:33:30 +01:00
committed by GitHub
parent 02ac02cd66
commit 93a7cba937
86 changed files with 6295 additions and 1640 deletions
@@ -0,0 +1,263 @@
import { expect, test } from "../../fixtures/test-base";
import type { Browser, BrowserContext, Page } from "@playwright/test";
import {
canvasHref,
enableCanvasFeature,
expectCanvasFrameFillsHost,
getCanvas,
removeCanvas,
seedTaskCanvas,
} from "./canvas-fixture";
import {
startCanvasAuthenticatedProxy,
type CanvasAuthenticatedProxy,
} from "./canvas-authenticated-proxy-fixture";
async function openAuthenticatedCanvas(
browser: Browser,
proxy: CanvasAuthenticatedProxy,
canvasId: string,
mobile = false,
): Promise<{ context: BrowserContext; page: Page }> {
const context = await browser.newContext({
baseURL: proxy.origin,
ignoreHTTPSErrors: true,
...(mobile ? { viewport: { width: 393, height: 852 }, isMobile: true, hasTouch: true } : {}),
});
await context.addInitScript(() => {
localStorage.setItem("kandev.onboarding.completed", "true");
delete (window as unknown as { __KANDEV_API_PORT?: string }).__KANDEV_API_PORT;
});
await proxy.setAuthentication(context);
const page = await context.newPage();
await page.goto(canvasHref(canvasId));
return { context, page };
}
test.describe("authenticated same-origin canvas runtime", () => {
test("loads a retained canvas through cookie authentication", async ({
browser,
testPage,
apiClient,
backend,
seedData,
}) => {
test.setTimeout(180_000);
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
let canvasId: string | undefined;
let proxy: CanvasAuthenticatedProxy | undefined;
let context: BrowserContext | undefined;
try {
const seeded = await seedTaskCanvas(testPage, apiClient, seedData);
canvasId = seeded.canvas.id;
proxy = await startCanvasAuthenticatedProxy(backend.baseUrl);
const opened = await openAuthenticatedCanvas(browser, proxy, canvasId);
context = opened.context;
const page = opened.page;
await expect(page.getByTestId("web-app-frame")).toHaveAttribute("data-frame-state", "ready", {
timeout: 30_000,
});
await expectCanvasFrameFillsHost(page);
const frame = page.frameLocator('iframe[title="E2E Plugin Canvas"]');
await expect(frame.getByTestId("canvas-fixture-script")).toHaveText("inline-ready");
await expect(frame.getByTestId("canvas-fixture-context")).toHaveText(seeded.taskId);
await expect(frame.getByTestId("canvas-fixture-task-count")).toHaveText("1");
await expect(frame.getByTestId("canvas-fixture-sse-status")).toHaveText("connected", {
timeout: 20_000,
});
await expect.poll(() => proxy?.count("runtime-entry", true) ?? 0).toBeGreaterThan(0);
await expect.poll(() => proxy?.count("runtime-asset", true) ?? 0).toBeGreaterThan(0);
await expect.poll(() => proxy?.count("context", true) ?? 0).toBeGreaterThan(0);
await expect.poll(() => proxy?.count("data", true) ?? 0).toBeGreaterThan(0);
await expect.poll(() => proxy?.count("events", true) ?? 0).toBeGreaterThan(0);
expect(proxy?.observations().every((observation) => observation.host === proxy?.host)).toBe(
true,
);
await frame.getByTestId("canvas-fixture-continue").click();
await expect(frame.getByTestId("canvas-fixture-message-status")).toHaveText("accepted");
await expect
.poll(async () =>
Number(await frame.getByTestId("canvas-fixture-sse-events").textContent()),
)
.toBeGreaterThan(0);
await expect.poll(() => proxy?.count("write", true) ?? 0).toBeGreaterThan(0);
} finally {
await context?.close();
await proxy?.close();
if (canvasId) await removeCanvas(apiClient, canvasId);
await releaseFeature();
}
});
test("releases a disconnected event stream before reconnecting", async ({
browser,
testPage,
apiClient,
backend,
seedData,
}) => {
test.setTimeout(180_000);
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
let canvasId: string | undefined;
let proxy: CanvasAuthenticatedProxy | undefined;
let context: BrowserContext | undefined;
try {
const seeded = await seedTaskCanvas(testPage, apiClient, seedData);
canvasId = seeded.canvas.id;
proxy = await startCanvasAuthenticatedProxy(backend.baseUrl);
const opened = await openAuthenticatedCanvas(browser, proxy, canvasId);
context = opened.context;
await expect(opened.page.getByTestId("web-app-frame")).toHaveAttribute(
"data-frame-state",
"ready",
{ timeout: 30_000 },
);
const frame = opened.page.frameLocator('iframe[title="E2E Plugin Canvas"]');
await expect(frame.getByTestId("canvas-fixture-sse-status")).toHaveText("connected", {
timeout: 20_000,
});
await expect.poll(() => proxy?.activeUpstreamRequests() ?? 0).toBeGreaterThan(0);
await opened.page.close();
await expect
.poll(() => proxy?.activeUpstreamRequests() ?? 0, {
timeout: 10_000,
message: "The proxy kept the disconnected canvas event stream open",
})
.toBe(0);
const reconnectedPage = await context.newPage();
await reconnectedPage.goto(canvasHref(canvasId));
await expect(reconnectedPage.getByTestId("web-app-frame")).toHaveAttribute(
"data-frame-state",
"ready",
{ timeout: 30_000 },
);
const reconnectedFrame = reconnectedPage.frameLocator('iframe[title="E2E Plugin Canvas"]');
await expect(reconnectedFrame.getByTestId("canvas-fixture-sse-status")).toHaveText(
"connected",
{ timeout: 20_000 },
);
await expect.poll(() => proxy?.count("events", true) ?? 0).toBeGreaterThan(1);
} finally {
await context?.close();
await proxy?.close();
if (canvasId) await removeCanvas(apiClient, canvasId);
await releaseFeature();
}
});
test("recovers after proxy authentication expires", async ({
browser,
testPage,
apiClient,
backend,
seedData,
}) => {
test.setTimeout(180_000);
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
let canvasId: string | undefined;
let proxy: CanvasAuthenticatedProxy | undefined;
let context: BrowserContext | undefined;
try {
const seeded = await seedTaskCanvas(testPage, apiClient, seedData);
canvasId = seeded.canvas.id;
const releaseBefore = (await getCanvas(apiClient, canvasId))?.active_release_id;
proxy = await startCanvasAuthenticatedProxy(backend.baseUrl);
const opened = await openAuthenticatedCanvas(browser, proxy, canvasId);
context = opened.context;
const page = opened.page;
await expect(page.getByTestId("web-app-frame")).toHaveAttribute("data-frame-state", "ready", {
timeout: 30_000,
});
await proxy.clearAuthentication(context);
await page.reload();
await expect(page.getByTestId("canvas-host-state")).toHaveText("Canvas unavailable", {
timeout: 30_000,
});
await expect(page.getByTestId("web-app-frame")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Try again", exact: true })).toBeVisible();
await proxy.setAuthentication(context);
await page.getByRole("button", { name: "Try again", exact: true }).click();
await expect(page.getByTestId("web-app-frame")).toHaveAttribute("data-frame-state", "ready", {
timeout: 30_000,
});
await expectCanvasFrameFillsHost(page);
expect((await getCanvas(apiClient, canvasId))?.active_release_id).toBe(releaseBefore);
await expect.poll(() => proxy?.count("runtime-entry", false) ?? 0).toBeGreaterThan(0);
await expect.poll(() => proxy?.count("runtime-entry", true) ?? 0).toBeGreaterThan(1);
} finally {
await context?.close();
await proxy?.close();
if (canvasId) await removeCanvas(apiClient, canvasId);
await releaseFeature();
}
});
test("preserves capability checks with cookies", async ({
browser,
testPage,
apiClient,
backend,
seedData,
}) => {
test.setTimeout(180_000);
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
let canvasId: string | undefined;
let proxy: CanvasAuthenticatedProxy | undefined;
let context: BrowserContext | undefined;
try {
const seeded = await seedTaskCanvas(testPage, apiClient, seedData);
canvasId = seeded.canvas.id;
proxy = await startCanvasAuthenticatedProxy(backend.baseUrl);
const opened = await openAuthenticatedCanvas(browser, proxy, canvasId);
context = opened.context;
const page = opened.page;
await expect(page.getByTestId("web-app-frame")).toHaveAttribute("data-frame-state", "ready", {
timeout: 30_000,
});
const checks = await page.evaluate(async () => {
const frame = document.querySelector<HTMLIFrameElement>(
'iframe[title="E2E Plugin Canvas"]',
);
if (!frame?.src) throw new Error("canvas runtime URL was not mounted");
const runtimeURL = new URL(frame.src);
const marker = "/api/v1/plugins/web-apps/runtime/";
const tokenStart = runtimeURL.pathname.indexOf(marker) + marker.length;
const tokenEnd = runtimeURL.pathname.indexOf("/", tokenStart);
if (tokenStart < marker.length || tokenEnd < 0) {
throw new Error("canvas runtime URL did not contain a capability segment");
}
runtimeURL.pathname = `${runtimeURL.pathname.slice(0, tokenStart)}invalid-capability/${runtimeURL.pathname.slice(tokenEnd + 1)}`;
const invalidToken = await fetch(runtimeURL, { credentials: "same-origin" });
const deniedWrite = await fetch(`${frame.src}_kandev/v1/actions/not-declared`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: "{}",
});
return {
deniedBody: await deniedWrite.text(),
deniedStatus: deniedWrite.status,
invalidBody: await invalidToken.text(),
invalidStatus: invalidToken.status,
};
});
expect(checks.invalidStatus).toBe(404);
expect(checks.invalidBody).toContain("runtime_token_invalid");
expect(checks.deniedStatus).toBe(403);
expect(checks.deniedBody).toContain("plugin_permission_denied");
} finally {
await context?.close();
await proxy?.close();
if (canvasId) await removeCanvas(apiClient, canvasId);
await releaseFeature();
}
});
});