repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/canvas/__e2e__/canvas.spec.ts
Rawimport { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import {
expect,
type Page,
type Request,
type Route,
test,
} from "@playwright/test";
import { createPiSession } from "../../../test/fixture/create-pi-session.ts";
import type { CanvasAction, CanvasState } from "../server-test.ts";
const canvasServer: typeof import("../server-test.ts") = await import(
new URL("../../../dist/canvas/server-test.ts", import.meta.url).href
);
const {
cancelDraw,
changeScene,
commitDraw,
createDraw,
emptyState,
prepareDraw,
startCanvasServer,
} = canvasServer;
const canvasExtensionEntry = (
JSON.parse(
readFileSync(
new URL("../../../dist/canvas/package.json", import.meta.url),
"utf8",
),
) as { pi: { extensions: string[] } }
).pi.extensions[0];
if (!canvasExtensionEntry) throw new Error("Canvas Pi extension entry missing");
const canvasExtensionPath = fileURLToPath(
new URL(`../../../dist/canvas/${canvasExtensionEntry}`, import.meta.url),
);
const browserErrors: string[] = [];
const browserDialogs: string[] = [];
const externalRequests = new Map<Request, string>();
function watchPage(page: Page): void {
page.on("pageerror", (error) => browserErrors.push(error.message));
page.on("dialog", (dialog) => browserDialogs.push(dialog.type()));
page.on("request", (request) => {
if (!/^(?:http:\/\/127\.0\.0\.1:|blob:|data:)/u.test(request.url()))
externalRequests.set(request, "pending");
});
page.on("requestfailed", (request) => {
if (externalRequests.has(request))
externalRequests.set(request, request.failure()?.errorText ?? "unknown");
});
page.on("response", (response) => {
if (externalRequests.has(response.request()))
externalRequests.set(response.request(), `response ${response.status()}`);
});
}
test.beforeEach(({ context }) => {
browserErrors.length = 0;
browserDialogs.length = 0;
externalRequests.clear();
for (const page of context.pages()) watchPage(page);
context.on("page", watchPage);
});
test.afterEach(() => {
for (const [request, status] of externalRequests) {
if (
request
.url()
.startsWith(
"https://esm.sh/@excalidraw/excalidraw@0.18.1/dist/prod/fonts/",
) &&
["csp", "net::ERR_BLOCKED_BY_CSP"].includes(status)
)
continue;
browserErrors.push(`${status}: ${request.url()}`);
}
expect(browserErrors).toEqual([]);
expect(browserDialogs.filter((type) => type !== "beforeunload")).toEqual([]);
});
function harness() {
let state = emptyState();
const actions: CanvasAction[] = [];
let server: Awaited<ReturnType<typeof startCanvasServer>>;
const apply = (action: CanvasAction): void => {
switch (action.kind) {
case "scene":
state = changeScene(state, action.revision, action.elements);
break;
case "prepare":
state = prepareDraw(
state,
action.requestId,
action.clientId,
action.additions,
);
break;
case "commit":
state = commitDraw(
state,
action.requestId,
action.clientId,
action.revision,
action.elements,
);
break;
case "cancel":
state = cancelDraw(state, action.requestId, action.clientId);
break;
}
actions.push(action);
};
return {
get state() {
return state;
},
set state(next: CanvasState) {
state = next;
},
actions,
publish: (): void => server.publish(state),
async start() {
server = await startCanvasServer(() => state, apply);
return server;
},
};
}
async function openCanvas(
page: import("@playwright/test").Page,
url: string,
): Promise<void> {
await page.goto(url);
await expect(page.locator(".excalidraw")).toBeVisible();
await expect(page.locator("[data-connection='connected']")).toBeVisible();
}
async function drawRectangle(
page: import("@playwright/test").Page,
): Promise<void> {
await page
.getByRole("radio", { name: "Rectangle", exact: true })
.locator("..")
.click();
await expect(
page.getByRole("radio", { name: "Rectangle", exact: true }),
).toBeChecked();
const canvas = page.locator(".excalidraw canvas").first();
const bounds = await canvas.boundingBox();
expect(bounds).not.toBeNull();
await page.mouse.move(
bounds!.x + bounds!.width * 0.4,
bounds!.y + bounds!.height * 0.4,
);
await page.mouse.down();
await page.mouse.move(
bounds!.x + bounds!.width * 0.55,
bounds!.y + bounds!.height * 0.55,
);
await page.mouse.up();
}
test("native editor draws, keeps Pi notifications quiet until opened", async ({
page,
}) => {
const canvas = harness();
const server = await canvas.start();
try {
await openCanvas(page, server.url);
await page.getByTestId("main-menu-trigger").click();
const menu = page.getByTestId("dropdown-menu");
await expect(menu.getByText("Pi", { exact: true })).toHaveCount(0);
await expect(
menu.getByRole("button", { name: "Latest Pi notification", exact: true }),
).toHaveCount(0);
await page.getByTestId("main-menu-trigger").click();
await drawRectangle(page);
await expect
.poll(
() => canvas.state.elements.filter((item) => !item.isDeleted).length,
)
.toBe(1);
canvas.state = {
...canvas.state,
notice: { id: "reply-1", text: "Latest notification\nwith full detail" },
};
canvas.publish();
await expect(
page.getByRole("region", { name: "Latest Pi notification" }),
).not.toBeVisible();
await expect(page.locator(".Toast")).toHaveCount(0);
await page.getByTestId("main-menu-trigger").click();
await expect(menu.getByText("Pi", { exact: true })).toBeVisible();
await menu
.getByRole("button", { name: "Latest Pi notification", exact: true })
.click();
await expect(page.locator(".pi-notice pre")).toHaveText(
"Latest notification\nwith full detail",
);
await expect(page.locator(".Modal")).toHaveCount(0);
} finally {
await server.close();
}
});
test("viewport image captures visible drawing after pan and zoom without moving viewport", async ({
page,
}) => {
const canvas = harness();
canvas.state = changeScene(canvas.state, 0, [
{
id: "visible-red",
type: "rectangle",
x: 100,
y: 200,
width: 180,
height: 180,
backgroundColor: "#ff0000",
fillStyle: "solid",
strokeColor: "#ff0000",
},
{
id: "offscreen-blue",
type: "rectangle",
x: 5000,
y: 5000,
width: 180,
height: 180,
backgroundColor: "#0000ff",
fillStyle: "solid",
strokeColor: "#0000ff",
},
]);
const server = await canvas.start();
try {
await openCanvas(page, server.url);
await expect
.poll(() => canvas.actions.some((action) => action.kind === "scene"))
.toBe(true);
canvas.actions.length = 0;
await page.getByRole("button", { name: "Zoom in", exact: true }).click();
await page.mouse.move(600, 400);
await page.mouse.wheel(40, 40);
await page.waitForTimeout(300);
await drawRectangle(page);
await expect
.poll(
() => canvas.actions.filter((action) => action.kind === "scene").length,
)
.toBe(1);
const first = canvas.actions.find((action) => action.kind === "scene");
if (first?.kind !== "scene" || !first.viewport)
throw new Error("Missing viewport image");
const viewport = first.viewport;
expect(viewport.zoom).toBeGreaterThan(1);
expect(viewport.scrollX !== 0 || viewport.scrollY !== 0).toBe(true);
expect(viewport.data.length).toBeLessThanOrEqual(256 * 1024);
const pixels = await page.evaluate(async (snapshot) => {
const image = new Image();
image.src = `data:${snapshot.mimeType};base64,${snapshot.data}`;
await image.decode();
const layer = document.createElement("canvas");
layer.width = image.width;
layer.height = image.height;
const ctx = layer.getContext("2d")!;
ctx.drawImage(image, 0, 0);
const bytes = ctx.getImageData(0, 0, image.width, image.height).data;
let red = 0;
let blue = 0;
for (let i = 0; i < bytes.length; i += 4) {
if (bytes[i]! > 200 && bytes[i + 1]! < 50 && bytes[i + 2]! < 50) red++;
if (bytes[i]! < 50 && bytes[i + 1]! < 50 && bytes[i + 2]! > 200) blue++;
}
return { width: image.width, height: image.height, red, blue };
}, viewport);
expect(pixels.width).toBe(viewport.imageWidth);
expect(pixels.height).toBe(viewport.imageHeight);
expect(Math.max(pixels.width, pixels.height)).toBeLessThanOrEqual(1024);
expect(pixels.red).toBeGreaterThan(100);
expect(pixels.blue).toBe(0);
await drawRectangle(page);
await expect
.poll(
() => canvas.actions.filter((action) => action.kind === "scene").length,
)
.toBe(2);
const second = canvas.actions.filter(
(action) => action.kind === "scene",
)[1];
if (second?.kind !== "scene") throw new Error("Missing second drawing");
expect(second.viewport).toMatchObject({
scrollX: viewport.scrollX,
scrollY: viewport.scrollY,
zoom: viewport.zoom,
width: viewport.width,
height: viewport.height,
});
} finally {
await server.close();
}
});
test("successive native edits wake real Pi despite streamed state arriving before save acknowledgment", async ({
page,
}) => {
let releaseReply: () => void = () => undefined;
const holdReply = new Promise<void>((resolve) => {
releaseReply = resolve;
});
const pi = await createPiSession({
extensionPaths: [canvasExtensionPath],
env: { PATH: "/canvas-test-no-executables", TERMUX_VERSION: undefined },
responses: [
async () => {
await holdReply;
return fauxAssistantMessage("First drawing received.");
},
fauxAssistantMessage("Second drawing received."),
],
});
try {
let url = "";
const ctx = pi.session.extensionRunner.createCommandContext();
ctx.ui.notify = (text) => {
const match = text.match(/http:\/\/127\.0\.0\.1:\d+\/#\S+/u);
if (match) url = match[0];
};
await pi.extensions[0]!.commands.get("canvas")!.handler("open", ctx);
expect(url).not.toBe("");
await page.route("**/api/action", async (route) => {
const action = route.request().postDataJSON() as CanvasAction;
const result = await route.fetch();
if (action.kind === "scene")
await new Promise((resolve) => setTimeout(resolve, 350));
await route.fulfill({ response: result });
});
await openCanvas(page, url);
await drawRectangle(page);
await expect.poll(() => pi.providerState.callCount).toBe(1);
expect(JSON.stringify(pi.session.messages)).toContain('"type":"image"');
expect(JSON.stringify(pi.session.messages)).toContain(
"Current visible viewport",
);
releaseReply();
await expect(
page.getByRole("button", { name: "Latest Pi notification" }),
).toBeVisible();
await expect.poll(() => pi.session.isIdle).toBe(true);
await expect(page.locator(".conflict")).not.toBeVisible();
await drawRectangle(page);
await expect.poll(() => pi.providerState.callCount).toBe(2);
await page
.getByRole("button", { name: "Latest Pi notification", exact: true })
.click();
await expect(
page.getByRole("region", { name: "Latest Pi notification" }),
).toContainText("Second drawing received.");
await expect.poll(() => pi.session.isIdle).toBe(true);
expect(pi.errors).toEqual([]);
} finally {
releaseReply();
await pi.dispose();
}
});
for (const viewport of [
{ width: 1280, height: 720 },
{ width: 390, height: 844 },
]) {
test(`Pi notification wraps, pads, and scrolls at ${viewport.width}×${viewport.height}`, async ({
page,
}) => {
await page.setViewportSize(viewport);
const canvas = harness();
const notice = `First line\n\n${"unbrokenword".repeat(35)}\n${"Notification line\n".repeat(100)}End of notification`;
canvas.state = {
...canvas.state,
notice: { id: "assistant-reply-long", text: notice },
};
const server = await canvas.start();
try {
await openCanvas(page, server.url);
await page.locator(".pi-notification-trigger").click();
const sidebar = page.locator(".pi-sidebar");
const content = sidebar.locator(".pi-notice");
const text = content.locator("pre");
await expect(sidebar).toBeVisible();
await expect(text).toHaveText(notice);
expect(
await text.evaluate((element) => getComputedStyle(element).whiteSpace),
).toBe("pre-wrap");
const bounds = await content.boundingBox();
const textBounds = await text.boundingBox();
expect(textBounds!.x - bounds!.x).toBeGreaterThanOrEqual(15);
await expect
.poll(() =>
content.evaluate(
(element) => element.scrollHeight > element.clientHeight,
),
)
.toBe(true);
expect(
await content.evaluate(
(element) => element.scrollWidth <= element.clientWidth,
),
).toBe(true);
await content.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
expect(
await content.evaluate((element) => element.scrollTop),
).toBeGreaterThan(0);
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBeLessThanOrEqual(viewport.width);
} finally {
await server.close();
}
});
test(`assistant replies control unread notification state at ${viewport.width}×${viewport.height}`, async ({
page,
}) => {
await page.setViewportSize(viewport);
const canvas = harness();
const server = await canvas.start();
try {
await openCanvas(page, server.url);
const trigger = page.locator(".pi-notification-trigger");
await expect(trigger).toHaveCount(0);
canvas.state = {
...canvas.state,
notice: { id: "reply-1", text: "Exact assistant reply one" },
};
canvas.publish();
await expect(trigger).toHaveAccessibleName("Latest Pi notification");
await expect(trigger.locator(".notification-unread-dot")).toBeVisible();
await expect(page.locator(".pi-sidebar")).toHaveCount(0);
await expect(page.locator(".Modal, .Toast")).toHaveCount(0);
await trigger.click();
await expect(page.locator(".pi-sidebar")).toBeVisible();
await expect(page.locator(".pi-notice pre")).toHaveText(
"Exact assistant reply one",
);
await expect(trigger.locator(".notification-unread-dot")).toHaveCount(0);
canvas.publish();
await expect(trigger.locator(".notification-unread-dot")).toHaveCount(0);
canvas.state = {
...canvas.state,
notice: { id: "reply-2", text: "Exact assistant reply two" },
};
canvas.publish();
await expect(trigger.locator(".notification-unread-dot")).toBeVisible();
await expect(page.locator(".pi-notice pre")).toHaveText(
"Exact assistant reply two",
);
await page
.getByRole("button", { name: "Close notification", exact: true })
.click();
await expect(page.locator(".pi-sidebar")).toHaveCount(0);
await trigger.click();
await expect(page.locator(".pi-notice pre")).toHaveText(
"Exact assistant reply two",
);
await expect(trigger.locator(".notification-unread-dot")).toHaveCount(0);
} finally {
await server.close();
}
});
}
test("native text and arrow notes prepare, commit, and undo as one editor update", async ({
page,
}) => {
const canvas = harness();
const server = await canvas.start();
try {
await openCanvas(page, server.url);
canvas.state = createDraw(canvas.state, {
baseRevision: 0,
title: "Add native notes",
additions: [
{
id: "tool-text-note",
type: "text",
x: 160,
y: 120,
text: "Review this detail",
},
{
id: "tool-arrow-note",
type: "arrow",
x: 160,
y: 190,
width: 120,
height: 80,
points: [
[0, 0],
[120, 80],
],
},
],
});
canvas.publish();
await expect.poll(() => canvas.state.revision).toBe(1);
expect(canvas.actions.map((action) => action.kind)).toEqual([
"prepare",
"commit",
]);
expect(
canvas.state.elements.filter((item) => !item.isDeleted),
).toHaveLength(2);
expect(
canvas.state.elements.find((item) => item.id === "tool-arrow-note")?.[
"points"
],
).toEqual([
[0, 0],
[120, 80],
]);
const undo = page.getByRole("button", { name: "Undo", exact: true });
await expect(undo).toBeEnabled();
await undo.click();
await expect
.poll(
() => canvas.state.elements.filter((item) => !item.isDeleted).length,
)
.toBe(0);
await page.getByRole("button", { name: "Redo", exact: true }).click();
await expect
.poll(
() => canvas.state.elements.filter((item) => !item.isDeleted).length,
)
.toBe(2);
} finally {
await server.close();
}
});
test("Mermaid becomes editable native elements while unsupported diagram is cancelled unchanged", async ({
page,
}) => {
const canvas = harness();
const server = await canvas.start();
try {
await openCanvas(page, server.url);
canvas.state = createDraw(canvas.state, {
baseRevision: 0,
title: "Flowchart",
mermaid: {
definition: "flowchart TD\nA[Start] --> B[Done]",
x: 100,
y: 100,
},
});
canvas.publish();
await expect.poll(() => canvas.state.revision).toBe(1);
expect(
canvas.state.elements.every((item) => item.id.includes("-mermaid-")),
).toBe(true);
canvas.state = {
...canvas.state,
pendingDraw: {
id: "unsafe-diagram",
title: "Unsafe",
baseRevision: canvas.state.revision,
additions: [],
updates: [],
deleteIds: [],
mermaid: {
definition: 'flowchart TD\nA --> B\nclick A "https://example.test"',
x: 0,
y: 0,
},
preparedAdditions: null,
clientId: null,
},
};
canvas.publish();
await expect.poll(() => canvas.state.pendingDraw).toBeNull();
expect(canvas.state.revision).toBe(1);
} finally {
await server.close();
}
});
test("only owner prepares concurrent draw and local download keeps unsaved scene", async ({
page,
context,
}) => {
const canvas = harness();
const server = await canvas.start();
const other = await context.newPage();
try {
await openCanvas(page, server.url);
await openCanvas(other, server.url);
canvas.state = createDraw(canvas.state, {
baseRevision: 0,
title: "One owner",
additions: [
{
id: "owner-shape",
type: "ellipse",
x: 90,
y: 90,
width: 90,
height: 90,
},
],
});
canvas.publish();
await expect.poll(() => canvas.state.revision).toBe(1);
expect(
canvas.actions.filter((action) => action.kind === "prepare"),
).toHaveLength(1);
const held: Route[] = [];
await page.route("**/api/action", async (route) => {
if (route.request().postDataJSON().kind === "scene") held.push(route);
else await route.continue();
});
await drawRectangle(page);
const download = page.waitForEvent("download");
await page.getByTestId("main-menu-trigger").click();
await page
.getByRole("button", { name: "Download drawing", exact: true })
.click();
expect((await download).suggestedFilename()).toBe(
"canvas-local.excalidraw",
);
const leaving = page.waitForEvent("dialog");
await page.close({ runBeforeUnload: true });
const dialog = await leaving;
expect(dialog.type()).toBe("beforeunload");
await dialog.dismiss();
for (const route of held) await route.continue();
await page.unroute("**/api/action");
} finally {
if (!other.isClosed()) await other.close();
await server.close();
}
});
test("genuine remote edits remain conflicts while local save acknowledgment is pending", async ({
page,
}) => {
const canvas = harness();
const server = await canvas.start();
let release: () => void = () => undefined;
const hold = new Promise<void>((resolve) => {
release = resolve;
});
try {
await openCanvas(page, server.url);
await page.route("**/api/action", async (route) => {
const result = await route.fetch();
if (route.request().postDataJSON().kind === "scene") await hold;
await route.fulfill({ response: result });
});
await drawRectangle(page);
await expect.poll(() => canvas.state.revision).toBe(1);
canvas.state = changeScene(canvas.state, 1, [
...canvas.state.elements,
{
id: "remote-shape",
type: "rectangle",
x: 40,
y: 50,
width: 60,
height: 70,
},
]);
canvas.publish();
await expect(page.locator(".conflict")).toContainText(
"Remote drawing changed",
);
expect(
canvas.state.elements.some((element) => element.id === "remote-shape"),
).toBe(true);
release();
await expect(page.locator(".conflict")).toBeVisible();
} finally {
release();
await page.unrouteAll({ behavior: "wait" });
await server.close();
}
});
test("inline recovery explains sharing, saves a copy, and confirms discard without dialogs", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
const canvas = harness();
const server = await canvas.start();
try {
await openCanvas(page, server.url);
await expect(
page.getByRole("region", { name: "Drawing sharing" }),
).toHaveCount(0);
await page.route("**/api/action", async (route) => {
if (route.request().postDataJSON().kind === "scene")
await route.fulfill({
status: 409,
json: { error: "Drawing revision changed; reconnect or retry." },
});
else await route.continue();
});
await drawRectangle(page);
const recovery = page.getByRole("region", { name: "Drawing sharing" });
await expect(recovery).toContainText("Not shared with Pi");
await expect(recovery).toContainText("Pi cannot see your latest edits");
for (const name of ["Keep my drawing", "Use shared drawing", "Save copy"])
await expect(
recovery.getByRole("button", { name, exact: true }),
).toBeVisible();
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBeLessThanOrEqual(390);
await recovery
.getByRole("button", { name: "Use shared drawing", exact: true })
.click();
await expect(recovery).toContainText("Discard this tab's unsaved edits");
await recovery.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(recovery).toBeVisible();
const downloaded = page.waitForEvent("download");
await recovery
.getByRole("button", { name: "Save copy", exact: true })
.click();
const stream = await (await downloaded).createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of stream!) chunks.push(Buffer.from(chunk));
const saved = JSON.parse(Buffer.concat(chunks).toString("utf8"));
expect(
saved.elements.filter(
(element: { isDeleted: boolean }) => !element.isDeleted,
),
).toHaveLength(1);
expect(canvas.state.elements).toHaveLength(0);
await recovery
.getByRole("button", { name: "Use shared drawing", exact: true })
.click();
await recovery
.getByRole("button", { name: "Discard my edits", exact: true })
.click();
await expect(recovery).toHaveCount(0);
await expect(page.locator(".pi-footer")).toContainText("Shared with Pi");
} finally {
await server.close();
}
});
test("transient sharing retries and explicit keep-mine recovery resume real Pi observations", async ({
page,
}) => {
const pi = await createPiSession({
extensionPaths: [canvasExtensionPath],
env: { PATH: "/canvas-test-no-executables", TERMUX_VERSION: undefined },
responses: [
fauxAssistantMessage("First drawing received."),
fauxAssistantMessage("Recovered drawing received."),
],
});
try {
let url = "";
const ctx = pi.session.extensionRunner.createCommandContext();
ctx.ui.notify = (text) => {
const match = text.match(/http:\/\/127\.0\.0\.1:\d+\/#\S+/u);
if (match) url = match[0];
};
await pi.extensions[0]!.commands.get("canvas")!.handler("open", ctx);
let failure: number | null = 503;
let attempts = 0;
await page.route("**/api/action", async (route) => {
if (route.request().postDataJSON().kind === "scene") {
attempts++;
if (failure) {
const status = failure;
if (status === 503) failure = null;
await route.fulfill({
status,
json: {
error:
status === 503
? "Temporary sharing failure."
: "Drawing revision changed.",
},
});
return;
}
}
await route.continue();
});
await openCanvas(page, url);
await drawRectangle(page);
await expect.poll(() => pi.providerState.callCount).toBe(1);
expect(attempts).toBe(2);
await expect.poll(() => pi.session.isIdle).toBe(true);
await expect(page.locator(".pi-footer")).toContainText("Shared with Pi");
failure = 409;
await drawRectangle(page);
const recovery = page.getByRole("region", { name: "Drawing sharing" });
await expect(recovery).toBeVisible();
const blockedAttempts = attempts;
await drawRectangle(page);
expect(attempts).toBe(blockedAttempts);
expect(pi.providerState.callCount).toBe(1);
await recovery
.getByRole("button", { name: "Keep my drawing", exact: true })
.click();
await expect(recovery).toContainText(
"Other-tab or Pi edits will be replaced",
);
failure = null;
await recovery
.getByRole("button", { name: "Replace shared drawing", exact: true })
.click();
await expect(recovery).toHaveCount(0);
await expect.poll(() => pi.providerState.callCount).toBe(2);
await expect.poll(() => pi.session.isIdle).toBe(true);
await page.getByTestId("main-menu-trigger").click();
await page
.getByTestId("dropdown-menu")
.getByRole("button", { name: "Latest Pi notification", exact: true })
.click();
await expect(
page.getByRole("region", { name: "Latest Pi notification" }),
).toContainText("Recovered drawing received.");
const shared = (await (
await page.request.get(new URL("/api/state", url).href, {
headers: { Authorization: `Bearer ${new URL(url).hash.slice(1)}` },
})
).json()) as CanvasState;
expect(
shared.elements.filter((element) => !element.isDeleted),
).toHaveLength(3);
expect(pi.errors).toEqual([]);
} finally {
await pi.dispose();
}
});
test("sharing retries are bounded and stale recovery choices cannot replace newer shared drawing", async ({
page,
}) => {
const canvas = harness();
const server = await canvas.start();
let attempts = 0;
try {
await openCanvas(page, server.url);
await page.route("**/api/action", async (route) => {
attempts++;
await route.fulfill({
status: 503,
json: { error: "Temporary sharing failure." },
});
});
await drawRectangle(page);
const recovery = page.getByRole("region", { name: "Drawing sharing" });
await expect(
recovery.getByRole("button", { name: "Keep my drawing", exact: true }),
).toBeVisible({ timeout: 10000 });
expect(attempts).toBe(4);
await recovery
.getByRole("button", { name: "Keep my drawing", exact: true })
.click();
// State changes without a stream event: recovery must revalidate its confirmation against the server.
canvas.state = changeScene(canvas.state, 0, [
{
id: "new-remote",
type: "rectangle",
x: 50,
y: 50,
width: 80,
height: 80,
},
]);
await recovery
.getByRole("button", { name: "Replace shared drawing", exact: true })
.click();
await expect(
recovery.getByRole("button", {
name: "Replace shared drawing",
exact: true,
}),
).toHaveCount(0);
await expect(recovery).toContainText("Drawing changed again");
expect(attempts).toBe(4);
expect(canvas.state.elements[0]?.id).toBe("new-remote");
await page.unroute("**/api/action");
await recovery
.getByRole("button", { name: "Keep my drawing", exact: true })
.click();
await recovery
.getByRole("button", { name: "Replace shared drawing", exact: true })
.click();
await expect(recovery).toHaveCount(0);
expect(
canvas.state.elements.some((element) => element.id === "new-remote"),
).toBe(false);
} finally {
await server.close();
}
});
test("large deleted shapes stay undoable locally without entering scene save or recovery payloads", async ({
page,
}) => {
const canvas = harness();
canvas.state = changeScene(canvas.state, 0, [
{
id: "large-history",
type: "rectangle",
x: 100,
y: 100,
width: 100,
height: 100,
customData: { data: "x".repeat(500000) },
},
]);
const server = await canvas.start();
try {
await openCanvas(page, server.url);
// Native initialization fills missing defaults before the first user edit.
await expect.poll(() => canvas.actions.length).toBe(1);
let revision = canvas.state.revision;
await page
.locator(".excalidraw canvas.interactive")
.click({ position: { x: 150, y: 150 } });
await page.keyboard.press("Control+a");
await page.keyboard.press("Delete");
await expect.poll(() => canvas.state.revision).toBe(++revision);
expect(canvas.state.elements).toEqual([]);
await drawRectangle(page);
await expect.poll(() => canvas.state.revision).toBe(++revision);
await page.keyboard.press("Control+z");
await expect.poll(() => canvas.state.revision).toBe(++revision);
expect(canvas.state.elements).toEqual([]);
await page.keyboard.press("Control+z");
await expect.poll(() => canvas.state.revision).toBe(++revision);
expect(canvas.state.elements[0]).toMatchObject({
id: "large-history",
customData: { data: "x".repeat(500000) },
});
await expect(page.locator(".pi-footer")).toContainText("Shared with Pi");
await page.keyboard.press("Control+Shift+z");
await expect.poll(() => canvas.state.revision).toBe(++revision);
expect(canvas.state.elements).toEqual([]);
await page.route("**/api/action", async (route) => {
await route.fulfill({
status: 409,
json: { error: "Drawing revision changed; reconnect or retry." },
});
});
await drawRectangle(page);
const recovery = page.getByRole("region", { name: "Drawing sharing" });
await expect(recovery).toBeVisible();
await page.unroute("**/api/action");
await recovery
.getByRole("button", { name: "Keep my drawing", exact: true })
.click();
await recovery
.getByRole("button", { name: "Replace shared drawing", exact: true })
.click();
await expect.poll(() => canvas.state.revision).toBe(++revision);
await expect(recovery).toHaveCount(0);
for (const action of canvas.actions)
if (action.kind === "scene")
expect(
(action.elements as CanvasState["elements"]).some(
(element) => element.isDeleted,
),
).toBe(false);
await drawRectangle(page);
await expect.poll(() => canvas.state.revision).toBe(++revision);
} finally {
await server.close();
}
});
test("oversized saves explain size failure instead of claiming shared drawing changed", async ({
page,
}) => {
const canvas = harness();
const server = await canvas.start();
let attempts = 0;
try {
await openCanvas(page, server.url);
await page.route("**/api/action", async (route) => {
attempts++;
await route.fulfill({
status: 413,
json: { error: "Canvas payload exceeds size limit." },
});
});
await drawRectangle(page);
const recovery = page.getByRole("region", { name: "Drawing sharing" });
await expect(recovery).toContainText("Drawing is too large to share.");
await expect(recovery).not.toContainText("Shared drawing changed");
await expect(recovery).toContainText("Canvas payload exceeds size limit.");
expect(attempts).toBe(1);
expect(canvas.state.revision).toBe(0);
} finally {
await server.close();
}
});
test("lost HTTP save acknowledgment recognized from stream does not block later edits", async ({
page,
}) => {
const canvas = harness();
const server = await canvas.start();
let loseResponse = true;
try {
await openCanvas(page, server.url);
await page.route("**/api/action", async (route) => {
const result = await route.fetch();
if (route.request().postDataJSON().kind === "scene" && loseResponse) {
loseResponse = false;
await new Promise((resolve) => setTimeout(resolve, 300));
await route.abort("failed");
} else await route.fulfill({ response: result });
});
await drawRectangle(page);
await expect.poll(() => canvas.state.revision).toBe(1);
await expect(page.locator(".pi-footer")).toContainText("Shared with Pi");
await expect(
page.getByRole("region", { name: "Drawing sharing" }),
).toHaveCount(0);
await drawRectangle(page);
await expect.poll(() => canvas.state.revision).toBe(2);
await expect(page.locator(".pi-footer")).toContainText("Shared with Pi");
} finally {
await server.close();
}
});
test("closed event stream reconnects and later Pi notices arrive without popups", async ({
page,
}) => {
const canvas = harness();
const server = await canvas.start();
let streams = 0;
try {
await page.route("**/api/events", async (route) => {
streams++;
if (streams === 1)
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: `event: state\ndata: ${JSON.stringify(canvas.state)}\n\n`,
});
else await route.continue();
});
await openCanvas(page, server.url);
await expect.poll(() => streams).toBe(2);
await expect(page.locator("[data-connection='connected']")).toBeVisible();
canvas.state = {
...canvas.state,
notice: { id: "reconnected", text: "Pi stream recovered." },
};
canvas.publish();
await page
.getByRole("button", { name: "Latest Pi notification", exact: true })
.click();
await expect(
page.getByRole("region", { name: "Latest Pi notification" }),
).toContainText("Pi stream recovered.");
await expect(page.locator(".Modal")).toHaveCount(0);
} finally {
await server.close();
}
});
test("notification SVG inherits native menu theme color", async ({
page,
}, info) => {
await page.setViewportSize({ width: 390, height: 844 });
const canvas = harness();
canvas.state = {
...canvas.state,
notice: { id: "theme-reply", text: "Theme color fixture" },
};
const server = await canvas.start();
try {
await openCanvas(page, server.url);
await page.getByTestId("main-menu-trigger").click();
const notification = page.getByTestId("dropdown-menu").getByRole("button", {
name: "Latest Pi notification",
exact: true,
});
const icon = notification.locator("svg");
await expect(icon).toHaveAttribute("stroke", "currentColor");
await expect(icon).toHaveAttribute("aria-hidden", "true");
const lightColor = await notification.evaluate(
(element) => getComputedStyle(element).color,
);
await expect(icon).toHaveCSS("color", lightColor);
await page.getByRole("button", { name: "Dark mode", exact: true }).click();
await expect(page.locator(".excalidraw")).toHaveClass(/theme--dark/u);
await expect(notification).toBeVisible();
const darkColor = await notification.evaluate(
(element) => getComputedStyle(element).color,
);
expect(darkColor).not.toBe(lightColor);
await expect(icon).toHaveCSS("color", darkColor);
await page.screenshot({ path: info.outputPath("native-menu-dark.png") });
} finally {
await server.close();
}
});
for (const viewport of [
{ width: 360, height: 800 },
{ width: 390, height: 844 },
{ width: 480, height: 900 },
{ width: 844, height: 390 },
{ width: 1024, height: 768 },
]) {
test(`native toolbar and icon menu fit ${viewport.width}×${viewport.height}`, async ({
page,
}, info) => {
await page.setViewportSize(viewport);
const canvas = harness();
const server = await canvas.start();
try {
await openCanvas(page, server.url);
canvas.state = {
...canvas.state,
notice: { id: "mobile-notice", text: "Full notification on demand." },
};
canvas.publish();
for (const name of [
"Selection",
"Rectangle",
"Diamond",
"Ellipse",
"Arrow",
"Line",
"Draw",
"Text",
"Eraser",
]) {
const label = page
.getByRole("radio", { name, exact: true })
.locator("..");
await expect(label).toBeVisible();
const bounds = await label.boundingBox();
expect(bounds).not.toBeNull();
expect(bounds!.x).toBeGreaterThanOrEqual(0);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(viewport.width);
}
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBeLessThanOrEqual(viewport.width);
await page.getByTestId("main-menu-trigger").click();
const menu = page.locator(".dropdown-menu").filter({
has: page.getByRole("button", {
name: "Download drawing",
exact: true,
}),
});
for (const name of ["Latest Pi notification", "Download drawing"]) {
const item = menu.getByRole("button", { name, exact: true });
await expect(item).toBeVisible();
await expect(item.locator("svg")).toHaveCount(1);
const bounds = await item.boundingBox();
expect(bounds!.x).toBeGreaterThanOrEqual(0);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(viewport.width);
}
await expect(
menu.getByRole("button", { name: /Discard local edits/u }),
).toHaveCount(0);
await expect(
menu.getByRole("button", { name: "Save to…", exact: true }),
).toHaveCount(0);
await page.screenshot({ path: info.outputPath("native-menu.png") });
await menu
.getByRole("button", { name: "Latest Pi notification", exact: true })
.click();
await expect(
page.getByRole("region", { name: "Latest Pi notification" }),
).toContainText("Full notification on demand.");
await page
.getByRole("button", { name: "Close notification", exact: true })
.click();
} finally {
await server.close();
}
});
}