import type { Page, Route } from "@playwright/test" import { FLOWCHART_XML } from "./fixtures/diagrams" import { expect, getAttachmentInput, getChatInput, getIframe, getIframeContent, sendMessage, test, waitForComplete, waitForCompleteCount, } from "./lib/fixtures" import { createMockSSEResponse, createToolErrorResponse } from "./lib/helpers" /** An edit_diagram response that adds one node next to "Start" */ function createEditResponse() { const toolCallId = `call_edit_${Date.now()}` const newNode = `` const events = [ { type: "start", messageId: `msg_${Date.now()}` }, { type: "tool-input-start", toolCallId, toolName: "edit_diagram" }, { type: "tool-input-available", toolCallId, toolName: "edit_diagram", input: { operations: [ { operation: "add", cell_id: "review", new_xml: newNode }, ], }, }, { type: "finish" }, ] return ( events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") + "data: [DONE]\n\n" ) } /** First request draws the flowchart, later ones add a node */ function drawThenEdit(onRequest?: (body: any) => void) { let count = 0 return async (route: Route) => { onRequest?.(route.request().postDataJSON()) const body = count === 0 ? createMockSSEResponse(FLOWCHART_XML, "Here is the flowchart.") : createEditResponse() count++ await route.fulfill({ status: 200, contentType: "text/event-stream", body, }) } } async function openApp(page: Page) { await page.goto("/", { waitUntil: "networkidle" }) await getIframe(page).waitFor({ state: "visible", timeout: 30000 }) // The cover goes away once draw.io has loaded and been styled await expect(page.locator('[data-testid="canvas-loading"]')).toHaveCount( 0, { timeout: 30000, }, ) } async function drawAndEdit(page: Page) { await sendMessage(page, "Create a flowchart") await waitForComplete(page) await sendMessage(page, "Add a review step") await waitForCompleteCount(page, 2) } test.describe("Workspace", () => { test("start screen shows on an empty canvas and examples fill the input", async ({ page, }) => { await openApp(page) await expect(page.locator('[data-testid="lobby-hero"]')).toBeVisible() await page.locator('[data-testid="example-cat"]').click() await expect(getChatInput(page)).toHaveValue("Draw a cat for me") }) test("each AI change becomes a version card", async ({ page }) => { await page.route("**/api/chat", drawThenEdit()) await openApp(page) await drawAndEdit(page) const cards = page.locator('[data-testid="version-card"]') await expect(cards).toHaveCount(2) await expect(cards.nth(1)).toContainText("v2") await expect(page.locator('[data-testid="version-thumb"]')).toHaveCount( 2, ) await expect( getIframeContent(page).getByText("Review Step"), ).toBeVisible() }) test("the latest card undoes and redoes the AI change", async ({ page, }) => { await page.route("**/api/chat", drawThenEdit()) await openApp(page) await drawAndEdit(page) const canvas = getIframeContent(page) await page.locator('[data-testid="version-undo"]').click() await expect(canvas.getByText("Review Step")).toHaveCount(0) await expect(page.getByText("Change undone")).toBeVisible() await page.locator('[data-testid="version-undo"]').click() await expect(canvas.getByText("Review Step")).toBeVisible() }) test("Ctrl+Z in the canvas undoes the AI change in one step", async ({ page, }) => { await page.route("**/api/chat", drawThenEdit()) await openApp(page) await drawAndEdit(page) // The thumbnail appears once draw.io has finished with this version await expect( page.locator('[data-testid="version-card"] img'), ).toHaveCount(2) // draw.io ignores keys while it exports, as for the auto-save's // thumbnail a second after the answer: let that one finish await expect(page).toHaveURL(/session=/) await page.waitForTimeout(1500) const canvas = getIframeContent(page) await canvas .locator(".geDiagramContainer") .click({ position: { x: 10, y: 10 } }) await page.keyboard.press("ControlOrMeta+z") await expect(canvas.getByText("Review Step")).toHaveCount(0) await expect( canvas.getByText("Process", { exact: true }).first(), ).toBeVisible() }) test("restoring a version from the compare dialog", async ({ page }) => { await page.route("**/api/chat", drawThenEdit()) await openApp(page) await drawAndEdit(page) await page.locator('[data-testid="version-thumb"]').first().click() await expect(page.getByRole("dialog")).toContainText("Version 1") await page.locator('[data-testid="compare-restore"]').click() await expect( getIframeContent(page).getByText("Review Step"), ).toHaveCount(0) }) test("selected shapes are shown and sent with the message", async ({ page, }) => { const bodies: any[] = [] await page.route( "**/api/chat", drawThenEdit((body) => bodies.push(body)), ) await openApp(page) await sendMessage(page, "Create a flowchart") await waitForComplete(page) await getIframeContent(page) .getByText("Process", { exact: true }) .click() await expect( page.locator('[data-testid="selection-chip"]'), ).toContainText("Process") await expect( page.locator('[data-testid="selection-ask"]'), ).toBeVisible() await sendMessage(page, "Make this red") await waitForCompleteCount(page, 2) expect(bodies[1].selectedCells).toEqual([ { id: "process", label: "Process" }, ]) }) test("the chat panel can be hidden and shown", async ({ page }) => { await page.route("**/api/chat", drawThenEdit()) await openApp(page) await sendMessage(page, "Create a flowchart") await waitForComplete(page) const panel = page.locator('[data-testid="chat-panel"]') await page.getByRole("button", { name: "Hide chat panel" }).click() await expect(panel).toBeHidden() await page.locator('[data-testid="show-panel"]').click() await expect(panel).toBeVisible() }) test("a renamed diagram keeps its name after reload", async ({ page }) => { await page.route("**/api/chat", drawThenEdit()) await openApp(page) await sendMessage(page, "Create a flowchart") await waitForComplete(page) await page.locator('[data-testid="session-title"]').click() await page.locator('[data-testid="rename-session"]').click() const input = page.getByRole("textbox", { name: "Rename" }) await input.fill("Order flow") await input.press("Enter") await page.keyboard.press("Escape") await expect( page.locator('[data-testid="session-title"]'), ).toContainText("Order flow") await expect(page).toHaveURL(/session=/) await page.waitForTimeout(1500) await page.reload({ waitUntil: "networkidle" }) await expect( page.locator('[data-testid="session-title"]'), ).toContainText("Order flow", { timeout: 15000 }) await expect(page.locator('[data-testid="version-card"]')).toHaveCount( 1, ) }) test("opening a .drawio file loads it into a new diagram", async ({ page, }) => { await openApp(page) const file = `` const [chooser] = await Promise.all([ page.waitForEvent("filechooser"), page .getByRole("button", { name: "Open .drawio file" }) .first() .click(), ]) await chooser.setFiles({ name: "order-flow.drawio", mimeType: "application/xml", buffer: Buffer.from(file), }) await expect( getIframeContent(page).getByText("From File"), ).toBeVisible() await expect( page.locator('[data-testid="session-title"]'), ).toContainText("order-flow") }) test("attachments do not go through the file opener", async ({ page }) => { await openApp(page) await getAttachmentInput(page).setInputFiles({ name: "notes.txt", mimeType: "text/plain", buffer: Buffer.from("hello"), }) await expect(page.getByText("notes.txt")).toBeVisible() await expect(page.locator('[data-testid="lobby-hero"]')).toBeVisible() }) test("an error shows its text and can be retried", async ({ page }) => { let calls = 0 await page.route("**/api/chat", async (route) => { calls++ if (calls === 1) { await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "Model is overloaded" }), }) return } await route.fulfill({ status: 200, contentType: "text/event-stream", body: createMockSSEResponse(FLOWCHART_XML, "Here it is."), }) }) await openApp(page) await sendMessage(page, "Create a flowchart") const alert = page .getByRole("alert") .filter({ hasText: "Model is overloaded" }) await expect(alert).toBeVisible({ timeout: 10000 }) await expect(alert).not.toContainText("{") await page.locator('[data-testid="retry-button"]').click() await waitForComplete(page) expect(calls).toBe(2) }) test("a file opened inside a saved diagram stays on the canvas", async ({ page, }) => { await page.route("**/api/chat", drawThenEdit()) await openApp(page) await sendMessage(page, "Create a flowchart") await waitForComplete(page) // The auto-save gives the chat a session first await expect(page).toHaveURL(/session=/, { timeout: 10000 }) const file = `` await page .locator('input[type="file"][accept^=".drawio"]') .first() .setInputFiles({ name: "mine.drawio", mimeType: "application/xml", buffer: Buffer.from(file), }) await page.waitForTimeout(1500) await expect( getIframeContent(page).getByText("From File"), ).toBeVisible() }) test("Ctrl+B in the canvas stays with draw.io", async ({ page }) => { await page.route("**/api/chat", drawThenEdit()) await openApp(page) await sendMessage(page, "Create a flowchart") await waitForComplete(page) await getIframeContent(page) .getByText("Process", { exact: true }) .click() await page.keyboard.press("ControlOrMeta+b") await page.waitForTimeout(500) await expect(page.locator('[data-testid="chat-panel"]')).toBeVisible() }) test("picking the same shapes again attaches them again", async ({ page, }) => { const bodies: any[] = [] await page.route( "**/api/chat", drawThenEdit((body) => bodies.push(body)), ) await openApp(page) await sendMessage(page, "Create a flowchart") await waitForComplete(page) const shape = getIframeContent(page).getByText("Process", { exact: true, }) await shape.click() await sendMessage(page, "Make this red") await waitForCompleteCount(page, 2) // Click away, then pick the same shape await page.keyboard.press("Escape") await shape.click() await expect( page.locator('[data-testid="selection-chip"]'), ).toContainText("Process") await sendMessage(page, "Make it bigger") // (Its edit repeats the earlier one, so the retries that follow // are requests too) await expect.poll(() => bodies.length).toBeGreaterThanOrEqual(3) expect(bodies[2].selectedCells).toEqual([ { id: "process", label: "Process" }, ]) }) test("restoring a version keeps the zoom", async ({ page }) => { await page.route("**/api/chat", drawThenEdit()) await openApp(page) await drawAndEdit(page) // The percentage shown in the toolbar const zoom = () => page.getByRole("button", { name: "Zoom", exact: true }).innerText() const initial = await zoom() await page.getByRole("button", { name: "Zoom out" }).click() await expect.poll(zoom).not.toBe(initial) const before = await zoom() await page.locator('[data-testid="version-restore"]').first().click() await page.waitForTimeout(800) expect(await zoom()).toBe(before) await expect(page.getByText("Restored v1")).toBeVisible() }) test("other diagrams can't be opened while an answer streams", async ({ page, }) => { await page.route("**/api/chat", drawThenEdit()) await openApp(page) await sendMessage(page, "Create a flowchart") await waitForComplete(page) await page.waitForTimeout(1500) await page.locator('[data-testid="new-chat-button"]').click() await expect(page.locator('[data-testid="lobby-hero"]')).toBeVisible() // An answer that takes a while await page.unroute("**/api/chat") await page.route("**/api/chat", async (route) => { await new Promise((r) => setTimeout(r, 4000)) await route.fulfill({ status: 200, contentType: "text/event-stream", body: createMockSSEResponse(FLOWCHART_XML, "Second one."), }) }) await sendMessage(page, "Create another flowchart") await page.locator('[data-testid="session-title"]').click() const other = page.getByRole("button", { name: /Create a flowchart/ }) await expect(other).toHaveAttribute("aria-disabled", "true") await other.click({ force: true }) await waitForComplete(page) await expect(page.getByText("Second one.")).toBeVisible() }) test("the Shapes button opens the shape library", async ({ page }) => { await openApp(page) await page.locator('[data-testid="draw-yourself"]').click() const library = getIframeContent(page).locator( ".geSidebarContainer:not(.geFormatContainer)", ) await expect(library).toBeHidden() await page.locator('[data-testid="toolbar-shapes"]').click() await expect(library).toBeVisible() await page.locator('[data-testid="toolbar-shapes"]').click() await expect(library).toBeHidden() }) test("resizing the window leaves draw.io's style panel closed", async ({ page, }) => { await page.route("**/api/chat", drawThenEdit()) await openApp(page) await sendMessage(page, "Create a flowchart") await waitForComplete(page) const formatWidth = () => getIframeContent(page) .locator(".geFormatContainer") .evaluate((el) => (el as HTMLElement).offsetWidth) expect(await formatWidth()).toBe(0) // draw.io's own rule opens it when its window gets wide again await page.setViewportSize({ width: 1000, height: 800 }) await page.waitForTimeout(600) await page.setViewportSize({ width: 1440, height: 900 }) await page.waitForTimeout(1500) expect(await formatWidth()).toBe(0) }) test("crossing the phone width keeps draw.io and its undo history", async ({ page, }) => { await page.route("**/api/chat", drawThenEdit()) await openApp(page) await sendMessage(page, "Create a flowchart") await waitForComplete(page) // A mark on draw.io's window: a reload would make a new window const mark = (set: boolean) => getIframe(page).evaluate((el, set) => { const w = (el as HTMLIFrameElement).contentWindow as any if (set) w.__naiMark = 1 return w.__naiMark }, set) await mark(true) await page.setViewportSize({ width: 700, height: 800 }) await page.waitForTimeout(800) await page.setViewportSize({ width: 1280, height: 800 }) await page.waitForTimeout(800) expect(await mark(false)).toBe(1) await expect(page.locator('[data-testid="toolbar-undo"]')).toBeEnabled() }) test("after Ctrl+Z in the canvas the version can be restored", async ({ page, }) => { await page.route("**/api/chat", drawThenEdit()) await openApp(page) await drawAndEdit(page) await expect( page.locator('[data-testid="version-card"] img'), ).toHaveCount(2) await expect(page).toHaveURL(/session=/) await page.waitForTimeout(1500) const canvas = getIframeContent(page) await canvas .locator(".geDiagramContainer") .click({ position: { x: 10, y: 10 } }) await page.keyboard.press("ControlOrMeta+z") await expect(canvas.getByText("Review Step")).toHaveCount(0) // The card no longer says v2 is on the canvas const restore = page .locator('[data-testid="version-card"]') .nth(1) .locator('[data-testid="version-restore"]') await expect(restore).toBeEnabled() await restore.click() await expect(canvas.getByText("Review Step")).toBeVisible() }) test("the toolbar fits a narrow window with the chat panel open", async ({ page, }) => { await page.setViewportSize({ width: 800, height: 800 }) await openApp(page) await page.locator('[data-testid="draw-yourself"]').click() await expect(page.locator('[data-testid="chat-panel"]')).toBeVisible() const toolbar = page.getByRole("toolbar") await expect(toolbar).toBeVisible() await page.waitForTimeout(600) const bar = await toolbar.boundingBox() const stage = await page .locator('[data-testid="canvas-stage"]') .boundingBox() expect(bar && stage).toBeTruthy() expect(bar!.x).toBeGreaterThanOrEqual(stage!.x) expect(bar!.x + bar!.width).toBeLessThanOrEqual(stage!.x + stage!.width) await expect( page.locator('[data-testid="toolbar-export"]'), ).toBeVisible() }) test("settings put their sections on top in a narrow window", async ({ page, }) => { await page.setViewportSize({ width: 800, height: 800 }) await openApp(page) await page.locator('[data-testid="settings-button"]').first().click() const models = page.locator('[data-testid="settings-tab-models"]') const appearance = page.locator( '[data-testid="settings-tab-appearance"]', ) await expect(models).toBeVisible() const a = await models.boundingBox() const b = await appearance.boundingBox() expect(Math.abs(a!.y - b!.y)).toBeLessThan(2) }) test("New chat brings the start screen back", async ({ page }) => { await openApp(page) await page.locator('[data-testid="draw-yourself"]').click() await expect(page.locator('[data-testid="lobby-hero"]')).toHaveCount(0) await page.locator('[data-testid="new-chat-button"]').click() await expect(page.locator('[data-testid="lobby-hero"]')).toBeVisible() }) test("the shape library shortcut works", async ({ page }) => { await openApp(page) await page.locator('[data-testid="draw-yourself"]').click() const library = getIframeContent(page).locator( ".geSidebarContainer:not(.geFormatContainer)", ) await expect(library).toBeHidden() await page.keyboard.press("ControlOrMeta+Shift+K") await expect(library).toBeVisible() // Also with the focus in the canvas await getIframeContent(page) .locator(".geDiagramContainer") .click({ position: { x: 300, y: 300 } }) await page.keyboard.press("ControlOrMeta+Shift+K") await expect(library).toBeHidden() }) test("Enter that picks an IME candidate does not end a rename", async ({ page, }) => { await page.route("**/api/chat", drawThenEdit()) await openApp(page) await sendMessage(page, "Create a flowchart") await waitForComplete(page) await page.locator('[data-testid="session-title"]').click() await page.locator('[data-testid="rename-session"]').click() const input = page.getByRole("textbox", { name: "Rename" }) await input.fill("流程") await input.dispatchEvent("keydown", { key: "Enter", isComposing: true, }) await expect(input).toBeVisible() await input.fill("流程图") await input.press("Enter") await expect(input).toHaveCount(0) await page.keyboard.press("Escape") await expect( page.locator('[data-testid="session-title"]'), ).toContainText("流程图") }) test("the restore message's Undo goes away with the chat", async ({ page, }) => { await page.route("**/api/chat", drawThenEdit()) await openApp(page) await drawAndEdit(page) await page.locator('[data-testid="version-thumb"]').first().click() await page.locator('[data-testid="compare-restore"]').click() const toast = page.getByText("Restored v1") await expect(toast).toBeVisible() await page.locator('[data-testid="new-chat-button"]').click() await expect(toast).toHaveCount(0) }) })