Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/canvas/__e2e__/canvas.spec.ts

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