import { 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(); 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>; 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 { 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 { 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((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((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(); } }); }