feat(ui): canvas-first redesign with a self-hosted draw.io

The canvas now fills the window and the chat floats on its right. draw.io is
served from our own origin (public/drawio, downloaded at build time and
checked against the release's SHA-256), so the app can drive the editor
directly:

- every AI change becomes a version card with a thumbnail; compare, restore
  or undo it, and Ctrl+Z on the canvas takes an AI change back in one step
- shapes the AI just changed are highlighted on the canvas
- selected shapes appear as a chip and are sent to the model
- the app's own canvas toolbar, shape library and page tabs replace
  draw.io's chrome
- new start screen, session rename and switcher, open .drawio files
- settings split into Models & keys, Appearance, Advanced and About
- phones switch between canvas and chat
- dark mode keeps the diagram on a white sheet and no longer flashes

With an external draw.io (NEXT_PUBLIC_DRAWIO_BASE_URL) the app falls back to
draw.io's own toolbar and the features above that need same origin are off.

The chat logic moved from the 1500-line chat panel into a chat engine plus
small UI components; zustand holds canvas, versions, settings and UI state.
This commit is contained in:
dayuan.jiang
2026-10-06 16:17:11 +09:00
parent 18a20aa1ca
commit 23d5c83b0d
105 changed files with 12499 additions and 7770 deletions
+8 -6
View File
@@ -32,9 +32,10 @@ test("a rejected key shows a hint, the provider's words and a settings button",
// First match: the Next.js dev overlay at the end also lists the error
await expect(page.getByText("Authentication Fails").first()).toBeVisible()
await page.getByRole("button", { name: "Open model settings" }).click()
await expect(
page.getByRole("dialog", { name: "AI Model Configuration" }),
).toBeVisible()
// Settings opens on its models tab
const dialog = page.getByRole("dialog", { name: "Settings" })
await expect(dialog).toBeVisible()
await expect(dialog.getByText("Providers", { exact: true })).toBeVisible()
})
test("an error before the stream shows its text, not raw JSON", async ({
@@ -98,7 +99,8 @@ test("a refused server key shows only the quota hint and a settings button", asy
0,
)
await page.getByRole("button", { name: "Open model settings" }).click()
await expect(
page.getByRole("dialog", { name: "AI Model Configuration" }),
).toBeVisible()
// Settings opens on its models tab
const dialog = page.getByRole("dialog", { name: "Settings" })
await expect(dialog).toBeVisible()
await expect(dialog.getByText("Providers", { exact: true })).toBeVisible()
})
+8 -11
View File
@@ -71,38 +71,35 @@ test.describe("Crossing the mobile breakpoint", () => {
})
test("keeps the chat and its streaming answer", async ({ page }) => {
const chat = page.locator('[data-panel-id="chat-panel"]')
await sendMessage(page, "Tell me a story")
await expect(page.getByText("Once upon")).toBeVisible({
timeout: 10000,
})
// Phones show the canvas or the chat, one at a time
await page.setViewportSize({ width: 600, height: 900 })
await page.getByRole("button", { name: "Chat", exact: true }).click()
await expect(page.getByText("Tell me a story")).toBeVisible()
// Half the height on mobile
await expect
.poll(async () => (await chat.boundingBox())?.height ?? 0)
.toBeCloseTo(450, -1)
await page.setViewportSize({ width: 1280, height: 800 })
// A third of the width on desktop
await expect
.poll(async () => (await chat.boundingBox())?.width ?? 0)
.toBeCloseTo(1280 / 3, -1)
await expect(page.getByText("Once upon a time.")).toBeVisible({
timeout: 10000,
})
await expect(page.getByText("Tell me a story")).toBeVisible()
})
test("opens a chat collapsed on desktop", async ({ page }) => {
await page.locator("button:has(svg.lucide-panel-right-close)").click()
test("reaches a chat hidden on desktop", async ({ page }) => {
// The start screen has no chat panel yet
await page.getByTestId("draw-yourself").click()
await page.getByRole("button", { name: "Hide chat panel" }).click()
await expect(getChatInput(page)).toBeHidden()
await page.setViewportSize({ width: 600, height: 900 })
await page.getByRole("button", { name: "Chat", exact: true }).click()
await expect(getChatInput(page)).toBeVisible()
await page.setViewportSize({ width: 1280, height: 800 })
await page.getByTestId("show-panel").click()
await expect(getChatInput(page)).toBeVisible()
})
})
+210 -12
View File
@@ -1,3 +1,4 @@
import zlib from "node:zlib"
import { expect, type Page, test } from "@playwright/test"
import { getIframe, sendMessage, waitForCompleteCount } from "./lib/fixtures"
@@ -127,7 +128,9 @@ test("display_diagram replaces the document with the fixed diagram", async ({
await sendMessage(p, "Draw two pages")
await waitForCompleteCount(p, 1)
await expect(canvas.getByText("Old A")).toBeVisible({ timeout: 15000 })
await expect(canvas.getByText("Second", { exact: true })).toBeVisible()
// The app's page switcher (draw.io's own page tabs are hidden)
const pageTabs = p.getByTestId("page-tabs")
await expect(pageTabs.getByText("Second", { exact: true })).toBeVisible()
await sendMessage(p, "Start over with three boxes")
await waitForCompleteCount(p, 2)
@@ -140,7 +143,7 @@ test("display_diagram replaces the document with the fixed diagram", async ({
}
// The old pages are gone
await expect(canvas.getByText("Old A")).toHaveCount(0)
await expect(canvas.getByText("Second", { exact: true })).toHaveCount(0)
await expect(pageTabs.getByText("Second", { exact: true })).toHaveCount(0)
})
test("an edit with a fixable cell is fixed, not rejected", async ({
@@ -221,9 +224,14 @@ test("edit_diagram applies all operations or none", async ({ page: p }) => {
await expect(canvas.getByText("Alpha", { exact: true })).toHaveCount(0)
await sendMessage(p, "Change again")
await expect(p.getByText(/No changes were made/).first()).toBeAttached({
timeout: 15000,
})
// The failed call's row opens to show the error
await p
.locator('[data-testid="tool-row"][data-tool-state="output-error"]')
.first()
.getByRole("button")
.first()
.click({ timeout: 15000 })
await expect(p.getByText(/No changes were made/).first()).toBeAttached()
await p.waitForTimeout(1000)
await expect(canvas.getByText("Gamma", { exact: true })).toBeVisible()
await expect(canvas.getByText("Broken", { exact: true })).toHaveCount(0)
@@ -609,9 +617,9 @@ test("a broken edit's preview is undone after a shape library call", async ({
await waitForCompleteCount(p, 1)
await sendMessage(p, "Add another box")
// The preview shows Gamma only for a moment; afterwards it must be gone
await expect(p.getByText("Get Shape Library").first()).toBeVisible({
timeout: 15000,
})
await expect(
p.locator('[data-tool-name="get_shape_library"]').first(),
).toBeVisible({ timeout: 15000 })
await p.waitForTimeout(2000)
await expect(canvas.getByText("Gamma", { exact: true })).toHaveCount(0)
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
@@ -640,10 +648,12 @@ test("a drawing rejected by the checks undoes its preview", async ({
await expect(canvas.getByText("Beta", { exact: true })).toBeVisible({
timeout: 15000,
})
// The tool card shows the rejection
await expect(p.locator('text="Error"').first()).toBeVisible({
timeout: 15000,
})
// The tool row shows the rejection
await expect(
p
.locator('[data-testid="tool-row"][data-tool-state="output-error"]')
.first(),
).toBeVisible({ timeout: 15000 })
await p.waitForTimeout(1000)
await expect(canvas.getByText("Beta", { exact: true })).toHaveCount(0)
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
@@ -735,3 +745,191 @@ test("stopping during the screenshot check lets the next message go at once", as
.find((part: any) => part.type === "tool-display_diagram")
expect(draw?.state).toBe("output-available")
})
test("the request after a failed screenshot check keeps the model and diagram", async ({
page: p,
}) => {
// After a failed check the SDK sends the next request itself; it must
// carry the turn's model headers and diagram
await p.addInitScript(() => {
localStorage.setItem("next-ai-draw-io-vlm-validation-enabled", "true")
})
const requests: { body: any; headers: Record<string, string> }[] = []
await p.route("**/api/chat", async (route) => {
requests.push({
body: route.request().postDataJSON(),
headers: route.request().headers(),
})
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: drawReply(`d${requests.length}`, cell("a", "Alpha", 40)),
})
})
let checks = 0
await p.route("**/api/validate-diagram", async (route) => {
checks++
await new Promise((r) => setTimeout(r, 500))
await route.fulfill({
status: 200,
contentType: "text/plain",
body: JSON.stringify(
checks === 1
? {
valid: false,
issues: [
{
type: "overlap",
severity: "critical",
description: "Boxes overlap",
},
],
suggestions: ["Move them apart"],
}
: { valid: true, issues: [], suggestions: [] },
),
})
})
await p.goto("/", { waitUntil: "networkidle" })
await getIframe(p).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(p, "Draw a box")
await expect.poll(() => requests.length, { timeout: 20000 }).toBe(2)
const [first, retry] = requests
expect(retry.body.sessionId).toBe(first.body.sessionId)
expect(typeof retry.body.xml).toBe("string")
for (const name of Object.keys(first.headers)) {
if (name.startsWith("x-")) {
expect(retry.headers[name], name).toBe(first.headers[name])
}
}
})
test("a cut off drawing whose continuation fails undoes its preview", async ({
page: p,
}) => {
// The last cell is cut off: the model has to continue with append_diagram
const cut = toolCallEvents("d2", "display_diagram", {
xml: `${cell("b", "Beta", 40)}<mxCell id="c" value="Gam`,
})
const canvas = await chunkedReplies(p, [
[drawReply("d1", cell("a", "Alpha", 40))],
[
sse([{ type: "start" }, cut.start, ...cut.deltas]),
1500,
`${sse([cut.done, { type: "finish" }])}data: [DONE]\n\n`,
],
[
`${sse([{ type: "start" }, { type: "error", errorText: "Upstream connection lost" }])}data: [DONE]\n\n`,
],
])
await sendMessage(p, "Draw a box")
await waitForCompleteCount(p, 1)
await sendMessage(p, "Draw a bigger one")
await expect(canvas.getByText("Beta", { exact: true })).toBeVisible({
timeout: 15000,
})
await expect(p.getByText("Upstream connection lost").first()).toBeVisible({
timeout: 15000,
})
await expect(canvas.getByText("Beta", { exact: true })).toHaveCount(0)
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
})
test("retrying after an error keeps what the failed turn drew", async ({
page: p,
}) => {
const draw = toolCallEvents("d1", "display_diagram", {
xml: cell("a", "Alpha", 40),
})
const canvas = await chunkedReplies(p, [
[
sse([{ type: "start" }, draw.start, ...draw.deltas, draw.done]),
`${sse([{ type: "error", errorText: "Model is overloaded" }])}data: [DONE]\n\n`,
],
[textReply("Nothing to draw this time.")],
])
await sendMessage(p, "Draw a box")
await expect(p.getByText("Model is overloaded").first()).toBeVisible({
timeout: 15000,
})
await p.locator('[data-testid="retry-button"]').click()
await expect(p.getByText("Nothing to draw this time.")).toBeVisible({
timeout: 15000,
})
// Its card went with the answer; the strip keeps the version
const thumb = p.locator('[data-testid="version-thumb"]')
await expect(thumb).toHaveCount(1)
await thumb.click()
await p.locator('[data-testid="compare-restore"]').click()
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
})
test("the request after opening a compressed file has the cells", async ({
page: p,
}) => {
const requests: any[] = []
await p.route("**/api/chat", async (route) => {
requests.push(route.request().postDataJSON())
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: textReply("Looks fine."),
})
})
await p.goto("/", { waitUntil: "networkidle" })
await getIframe(p).waitFor({ state: "visible", timeout: 30000 })
// draw.io's compressed page: raw deflate of the URI-encoded model
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cell("a", "Packed", 40)}</root></mxGraphModel>`
const packed = zlib
.deflateRawSync(Buffer.from(encodeURIComponent(model)))
.toString("base64")
await p
.locator('input[type="file"][accept^=".drawio"]')
.first()
.setInputFiles({
name: "packed.drawio",
mimeType: "application/xml",
buffer: Buffer.from(
`<mxfile><diagram name="P" id="p">${packed}</diagram></mxfile>`,
),
})
await expect(
p.frameLocator("iframe").getByText("Packed", { exact: true }),
).toBeVisible()
await sendMessage(p, "What is this?")
await expect(p.getByText("Looks fine.")).toBeVisible()
// Regenerate sends the snapshot taken before the message
await p.getByText("Looks fine.").hover()
await p.getByRole("button", { name: "Regenerate response" }).click()
await expect.poll(() => requests.length).toBe(2)
expect(requests[1].xml).toContain('value="Packed"')
})
test("a rejected drawing stops saying it tries again once retries end", async ({
page: p,
}) => {
// Rejected by the checks every time: the automatic retries run out
let calls = 0
await p.route("**/api/chat", async (route) => {
calls++
const call = toolCallEvents(`r${calls}`, "display_diagram", {
xml: `<UserObject id="1" label="Bad" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell></UserObject>`,
})
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: `${sse([{ type: "start" }, call.start, ...call.deltas, call.done, { type: "finish" }])}data: [DONE]\n\n`,
})
})
await p.goto("/", { waitUntil: "networkidle" })
await getIframe(p).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(p, "Draw a box")
await expect.poll(() => calls, { timeout: 20000 }).toBe(4)
await expect(p.locator('[data-testid="new-chat-button"]')).toBeEnabled({
timeout: 15000,
})
await p.waitForTimeout(500)
expect(calls).toBe(4)
await expect(p.getByText("trying again")).toHaveCount(0)
await expect(p.getByText("This attempt failed").first()).toBeVisible()
})
+1 -3
View File
@@ -35,10 +35,8 @@ test.describe("Diagram Generation", () => {
test("generates and displays a diagram", async ({ page }) => {
await sendMessage(page, "Draw a cat")
await expect(page.locator('text="Generate Diagram"')).toBeVisible({
timeout: 15000,
})
await waitForComplete(page)
await expect(page.locator('[data-testid="version-card"]')).toBeVisible()
})
test("chat input clears after sending", async ({ page }) => {
+6 -1
View File
@@ -169,9 +169,14 @@ test.describe("Error Handling", () => {
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Draw something")
// The failed call's row opens to show the error
const failed = page
.locator('[data-testid="tool-row"][data-tool-state="output-error"]')
.first()
await failed.getByRole("button").first().click({ timeout: 15000 })
await expect(
page.getByText("Invalid input for tool display_diagram").first(),
).toBeVisible({ timeout: 15000 })
).toBeVisible()
await expect(page.locator('text="Truncated"')).toHaveCount(0)
})
})
+14 -13
View File
@@ -1,6 +1,7 @@
import { SINGLE_BOX_XML } from "./fixtures/diagrams"
import {
expect,
getAttachmentInput,
getChatInput,
getIframe,
sendMessage,
@@ -13,19 +14,22 @@ test.describe("File Upload", () => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const uploadButton = page.locator(
'button[aria-label="Upload file"], button:has(svg.lucide-image)',
)
await expect(uploadButton.first()).toBeVisible({ timeout: 10000 })
await expect(uploadButton.first()).toBeEnabled()
// The "Add" menu holds the upload entry
const addButton = page.locator('[data-testid="composer-add"]')
await expect(addButton).toBeVisible({ timeout: 10000 })
await addButton.click()
const [chooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.getByRole("button", { name: "Upload file" }).click(),
])
expect(chooser.isMultiple()).toBe(true)
})
test("shows file preview after selecting image", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
// The chat attachment input; the template panel has its own file input
const fileInput = page.locator('input[type="file"][multiple]')
const fileInput = getAttachmentInput(page)
await fileInput.setInputFiles({
name: "test-image.png",
@@ -45,8 +49,7 @@ test.describe("File Upload", () => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
// The chat attachment input; the template panel has its own file input
const fileInput = page.locator('input[type="file"][multiple]')
const fileInput = getAttachmentInput(page)
await fileInput.setInputFiles({
name: "test-image.png",
@@ -93,8 +96,7 @@ test.describe("File Upload", () => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
// The chat attachment input; the template panel has its own file input
const fileInput = page.locator('input[type="file"][multiple]')
const fileInput = getAttachmentInput(page)
await fileInput.setInputFiles({
name: "architecture.png",
@@ -118,8 +120,7 @@ test.describe("File Upload", () => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
// The chat attachment input; the template panel has its own file input
const fileInput = page.locator('input[type="file"][multiple]')
const fileInput = getAttachmentInput(page)
const largeBuffer = Buffer.alloc(3 * 1024 * 1024, "x")
await fileInput.setInputFiles({
+16 -12
View File
@@ -80,7 +80,7 @@ test.describe("History and Session Restore", () => {
).toBeVisible({ timeout: 5000 })
await page.waitForTimeout(1000)
// Still the conversation and its diagram, not the empty chat's examples
await expect(page.getByText("Paper to Diagram")).toHaveCount(0)
await expect(page.getByText("Paper to diagram")).toHaveCount(0)
await expect(
getIframeContent(page).getByText("Test Box", { exact: true }),
).toBeVisible()
@@ -117,7 +117,7 @@ test.describe("History and Session Restore", () => {
await expect(
page.locator('text="Created your test diagram."'),
).toHaveCount(0, { timeout: 5000 })
await expect(page.getByText("Paper to Diagram")).toBeVisible()
await expect(page.getByText("Paper to diagram")).toBeVisible()
})
// A diagram drawn by hand, without chat messages: loaded into draw.io
@@ -155,6 +155,8 @@ test.describe("History and Session Restore", () => {
}) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
// Leave the start screen to draw on the canvas
await page.getByTestId("draw-yourself").click()
await drawByHand(page, "Hand drawn")
await expect(
getIframeContent(page).getByText("Hand drawn", { exact: true }),
@@ -188,17 +190,21 @@ test.describe("History and Session Restore", () => {
await waitForText(page, "Created your test diagram.")
await page.waitForTimeout(1500)
await page.locator('[data-testid="new-chat-button"]').click()
// The empty chat lists the first one; draw something by hand
const firstChat = page.getByRole("button", {
name: /Create a test diagram/,
// The empty chat shows the start screen; draw something by hand
await expect(page.getByTestId("lobby-hero")).toBeVisible({
timeout: 10000,
})
await expect(firstChat).toBeVisible({ timeout: 10000 })
await page.getByTestId("draw-yourself").click()
await drawByHand(page, "Hand drawn")
await expect(
getIframeContent(page).getByText("Hand drawn", { exact: true }),
).toBeVisible({ timeout: 10000 })
await storageFull(page)
await firstChat.click()
// The chat list lists the first one
await page.getByTestId("session-title").click()
await page
.getByRole("button", { name: /Create a test diagram/ })
.click()
await expect(
page.getByText(/Could not save this chat/).first(),
).toBeVisible({ timeout: 5000 })
@@ -284,11 +290,9 @@ test.describe("History and Session Restore", () => {
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const frame = getIframeContent(page)
await expect(
frame
.locator(".geMenubarContainer, .geDiagramContainer, canvas")
.first(),
).toBeVisible({ timeout: 30000 })
await expect(frame.locator(".geDiagramContainer").first()).toBeVisible({
timeout: 30000,
})
})
test("can restore from browser back/forward", async ({ page }) => {
+3 -5
View File
@@ -18,11 +18,9 @@ test.describe("Iframe Interaction", () => {
// iframe should have loaded draw.io content
const frame = getIframeContent(page)
await expect(
frame
.locator(".geMenubarContainer, .geDiagramContainer, canvas")
.first(),
).toBeVisible({ timeout: 30000 })
await expect(frame.locator(".geDiagramContainer").first()).toBeVisible({
timeout: 30000,
})
})
test("can interact with draw.io toolbar", async ({ page }) => {
+27 -20
View File
@@ -2,7 +2,8 @@ import {
expect,
getChatInput,
getIframe,
openSettings,
getSendButton,
openSettingsTab,
sleep,
test,
} from "./lib/fixtures"
@@ -15,7 +16,7 @@ test.describe("Language Switching", () => {
const chatInput = getChatInput(page)
await expect(chatInput).toBeVisible({ timeout: 10000 })
await expect(page.locator('button:has-text("Send")')).toBeVisible()
await expect(getSendButton(page, "Send")).toBeVisible()
})
test("can switch to Japanese", async ({ page }) => {
@@ -23,14 +24,16 @@ test.describe("Language Switching", () => {
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await test.step("open settings and select Japanese", async () => {
await openSettings(page)
const languageSelector = page.locator('button:has-text("English")')
await languageSelector.first().click()
await page.locator('text="日本語"').click()
await openSettingsTab(page, "appearance")
await page.locator("#language-select").click()
await page
.getByRole("option", { name: "日本語", exact: true })
.click()
await page.keyboard.press("Escape")
})
await test.step("verify UI is in Japanese", async () => {
await expect(page.locator('button:has-text("送信")')).toBeVisible({
await expect(getSendButton(page, "送信")).toBeVisible({
timeout: 5000,
})
})
@@ -41,14 +44,16 @@ test.describe("Language Switching", () => {
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await test.step("open settings and select Chinese", async () => {
await openSettings(page)
const languageSelector = page.locator('button:has-text("English")')
await languageSelector.first().click()
await page.locator('text="中文"').click()
await openSettingsTab(page, "appearance")
await page.locator("#language-select").click()
await page
.getByRole("option", { name: "中文", exact: true })
.click()
await page.keyboard.press("Escape")
})
await test.step("verify UI is in Chinese", async () => {
await expect(page.locator('button:has-text("发送")')).toBeVisible({
await expect(getSendButton(page, "发送")).toBeVisible({
timeout: 5000,
})
})
@@ -59,16 +64,18 @@ test.describe("Language Switching", () => {
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await test.step("switch to Japanese", async () => {
await openSettings(page)
const languageSelector = page.locator('button:has-text("English")')
await languageSelector.first().click()
await page.locator('text="日本語"').click()
await openSettingsTab(page, "appearance")
await page.locator("#language-select").click()
await page
.getByRole("option", { name: "日本語", exact: true })
.click()
await page.keyboard.press("Escape")
await page.keyboard.press("Escape")
await sleep(500)
})
await test.step("verify Japanese before reload", async () => {
await expect(page.locator('button:has-text("送信")')).toBeVisible({
await expect(getSendButton(page, "送信")).toBeVisible({
timeout: 10000,
})
})
@@ -78,7 +85,7 @@ test.describe("Language Switching", () => {
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
// Wait for hydration and localStorage to be read
await sleep(1000)
await expect(page.locator('button:has-text("送信")')).toBeVisible({
await expect(getSendButton(page, "送信")).toBeVisible({
timeout: 10000,
})
})
@@ -88,7 +95,7 @@ test.describe("Language Switching", () => {
await page.goto("/ja", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await expect(page.locator('button:has-text("送信")')).toBeVisible({
await expect(getSendButton(page, "送信")).toBeVisible({
timeout: 10000,
})
})
@@ -97,7 +104,7 @@ test.describe("Language Switching", () => {
await page.goto("/zh", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await expect(page.locator('button:has-text("发送")')).toBeVisible({
await expect(getSendButton(page, "发送")).toBeVisible({
timeout: 10000,
})
})
+31 -9
View File
@@ -30,7 +30,7 @@ export { expect }
/** Get the chat input textarea */
export function getChatInput(page: Page) {
return page.locator('textarea[aria-label="Chat input"]')
return page.getByTestId("chat-input")
}
/** Get the draw.io iframe */
@@ -60,20 +60,33 @@ export async function sendMessage(page: Page, message: string) {
await chatInput.press("ControlOrMeta+Enter")
}
/** Wait for diagram generation to complete */
export async function waitForComplete(page: Page, timeout = 15000) {
await expect(page.locator('text="Complete"')).toBeVisible({ timeout })
/** Tool calls that finished (version cards and tool rows) */
export function getCompletedTools(page: Page) {
return page.locator('[data-tool-state="output-available"]')
}
/** Wait for N "Complete" badges */
/** Wait for diagram generation to complete */
export async function waitForComplete(page: Page, timeout = 15000) {
await expect(getCompletedTools(page).first()).toBeVisible({ timeout })
}
/** Wait for N finished tool calls */
export async function waitForCompleteCount(
page: Page,
count: number,
timeout = 15000,
) {
await expect(page.locator('text="Complete"')).toHaveCount(count, {
timeout,
})
await expect(getCompletedTools(page)).toHaveCount(count, { timeout })
}
/** The file input behind the message box's "Add" menu */
export function getAttachmentInput(page: Page) {
return page.locator('[data-testid="attachment-input"]')
}
/** The send button (icon only; found by its accessible name) */
export function getSendButton(page: Page, name = "Send") {
return page.getByRole("button", { name, exact: true })
}
/** Wait for a specific text to appear */
@@ -83,10 +96,19 @@ export async function waitForText(page: Page, text: string, timeout = 15000) {
/** Open settings dialog */
export async function openSettings(page: Page) {
await getSettingsButton(page).click()
await getSettingsButton(page).first().click()
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 })
}
/** Open settings on one of its pages: models, appearance, advanced, about */
export async function openSettingsTab(
page: Page,
tab: "models" | "appearance" | "advanced" | "about",
) {
await openSettings(page)
await page.locator(`[data-testid="settings-tab-${tab}"]`).click()
}
// ============================================
// Mock helpers
// ============================================
+2 -2
View File
@@ -48,7 +48,7 @@ test("the Test button checks all models at once and shows each result", async ({
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await page.locator("button:has(svg.lucide-bot)").first().click()
await page.getByTestId("model-selector").click()
await page.getByText("Configure Models...").click()
const dialog = page.locator('[role="dialog"]')
await dialog.getByText("GLM (Zhipu)").first().click()
@@ -75,7 +75,7 @@ test("the key link and the base URL cleanup", async ({ page }) => {
}, CONFIG)
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await page.locator("button:has(svg.lucide-bot)").first().click()
await page.getByTestId("model-selector").click()
await page.getByText("Configure Models...").click()
const dialog = page.locator('[role="dialog"]')
await dialog.getByText("GLM (Zhipu)").first().click()
+1 -1
View File
@@ -23,7 +23,7 @@ async function openQwenSettings(page: Page, config: object = CONFIG) {
}, config)
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await page.locator("button:has(svg.lucide-bot)").first().click()
await page.getByTestId("model-selector").click()
await page.getByText("Configure Models...").click()
const dialog = page.locator('[role="dialog"]')
await dialog.getByText("Qwen (Alibaba)").first().click()
+17 -8
View File
@@ -1,4 +1,10 @@
import { expect, getIframe, openSettings, test } from "./lib/fixtures"
import {
expect,
getIframe,
openSettings,
openSettingsTab,
test,
} from "./lib/fixtures"
test.describe("Settings", () => {
test.beforeEach(async ({ page }) => {
@@ -12,14 +18,14 @@ test.describe("Settings", () => {
})
test("language selection is available", async ({ page }) => {
await openSettings(page)
await openSettingsTab(page, "appearance")
const dialog = page.locator('[role="dialog"]')
await expect(dialog.locator('text="English"')).toBeVisible()
await expect(dialog.locator("#language-select")).toHaveText(/English/)
})
test("max output tokens is editable and persists", async ({ page }) => {
await openSettings(page)
await openSettingsTab(page, "advanced")
const input = page.locator("#max-output-tokens")
await expect(input).toBeVisible()
@@ -38,11 +44,14 @@ test.describe("Settings", () => {
await expect(input).toHaveValue("12000")
})
test("draw.io theme toggle exists", async ({ page }) => {
await openSettings(page)
test("theme can be light, dark or follow the system", async ({ page }) => {
await openSettingsTab(page, "appearance")
const dialog = page.locator('[role="dialog"]')
const themeText = dialog.locator("text=/sketch|minimal/i")
await expect(themeText.first()).toBeVisible()
for (const name of ["Light", "Dark", "System"]) {
await expect(dialog.getByRole("radio", { name })).toBeVisible()
}
await dialog.getByRole("radio", { name: "Dark" }).click()
await expect(page.locator("html")).toHaveClass(/dark/)
})
})
+594
View File
@@ -0,0 +1,594 @@
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 = `<mxCell id="review" value="Review Step" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"><mxGeometry x="360" y="130" width="100" height="40" as="geometry"/></mxCell>`
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 = `<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="From File" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
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 = `<mxfile><diagram name="P" id="p"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="From File" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
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)
})
})
+144
View File
@@ -0,0 +1,144 @@
import pako from "pako"
import { describe, expect, it } from "vitest"
import {
countCells,
diffDiagrams,
inflatePages,
isEmptySummary,
isSameDocument,
} from "@/lib/diagram-diff"
const doc = (cells: string) =>
`<mxfile><diagram name="Page-1" id="p1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
const box = (id: string, value = id) =>
`<mxCell id="${id}" value="${value}" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
const edge = (id: string, source: string, target: string) =>
`<mxCell id="${id}" edge="1" parent="1" source="${source}" target="${target}"><mxGeometry relative="1" as="geometry"/></mxCell>`
describe("diffDiagrams", () => {
it("counts added shapes and connectors on an empty canvas", () => {
const { summary, touchedIds } = diffDiagrams(
"",
doc(box("a") + box("b") + edge("e", "a", "b")),
)
expect(summary.shapesAdded).toBe(2)
expect(summary.edgesAdded).toBe(1)
expect(touchedIds.sort()).toEqual(["a", "b", "e"])
})
it("separates added, changed and removed cells", () => {
const before = doc(box("a") + box("b") + edge("e", "a", "b"))
const after = doc(box("a", "renamed") + box("c") + edge("e", "a", "c"))
const { summary, touchedIds } = diffDiagrams(before, after)
expect(summary).toEqual({
shapesAdded: 1,
shapesRemoved: 1,
shapesChanged: 1,
edgesAdded: 0,
edgesRemoved: 0,
edgesChanged: 1,
})
expect(touchedIds.sort()).toEqual(["a", "c", "e"])
})
it("ignores how the same cell is written", () => {
// As the model writes it, and as draw.io saves it
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="A" style="rounded=1;html=1;" vertex="1" parent="1"><mxGeometry x="10" y="20" width="80" height="40" as="geometry"/></mxCell></root></mxGraphModel>`
const saved = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" parent="1" style="html=1;rounded=1" value="A" vertex="1"><mxGeometry as="geometry" height="40" width="80" x="10" y="20"/></mxCell></root></mxGraphModel>`
expect(isEmptySummary(diffDiagrams(model, saved).summary)).toBe(true)
})
it("reports nothing for identical diagrams", () => {
const xml = doc(box("a"))
const { summary, touchedIds } = diffDiagrams(xml, xml)
expect(isEmptySummary(summary)).toBe(true)
expect(touchedIds).toEqual([])
})
it("reads the id from <UserObject> wrappers", () => {
const wrapped = `<UserObject label="Linked" link="https://example.com" id="u1"><mxCell vertex="1" parent="1"><mxGeometry width="80" height="40" as="geometry"/></mxCell></UserObject>`
const { summary, touchedIds } = diffDiagrams(doc(""), doc(wrapped))
expect(summary.shapesAdded).toBe(1)
expect(touchedIds).toEqual(["u1"])
})
it("only looks at the first page", () => {
const twoPages = `<mxfile><diagram id="p1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${box("a")}</root></mxGraphModel></diagram><diagram id="p2"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${box("z")}</root></mxGraphModel></diagram></mxfile>`
expect(countCells(twoPages)).toEqual({ shapes: 1, edges: 0 })
})
it("reads compressed pages", () => {
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${box("a")}${box("b")}</root></mxGraphModel>`
const compressed = btoa(
String.fromCharCode(...pako.deflateRaw(encodeURIComponent(model))),
)
const xml = `<mxfile><diagram id="p1">${compressed}</diagram></mxfile>`
expect(countCells(xml)).toEqual({ shapes: 2, edges: 0 })
})
it("treats unparsable XML as empty", () => {
expect(countCells("<mxfile><diagram>")).toEqual({ shapes: 0, edges: 0 })
})
})
const pageOf = (name: string, cells: string, attrs = "") =>
`<diagram name="${name}" id="${name}"><mxGraphModel${attrs}><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram>`
const file = (...pages: string[]) => `<mxfile>${pages.join("")}</mxfile>`
const packedPage = (name: string, cells: string) => {
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel>`
const packed = Buffer.from(
pako.deflateRaw(encodeURIComponent(model)),
).toString("base64")
return `<diagram name="${name}" id="${name}">${packed}</diagram>`
}
describe("isSameDocument", () => {
const a = file(pageOf("One", box("a")), pageOf("Two", box("b")))
it("is true for the same pages written differently", () => {
const b = file(
pageOf("One", box("a"), ' dx="300" dy="120" grid="1"'),
packedPage("Two", box("b")),
)
expect(isSameDocument(a, b)).toBe(true)
})
it("sees a change on the second page", () => {
const b = file(pageOf("One", box("a")), pageOf("Two", box("b", "x")))
expect(diffDiagrams(a, b).summary).toEqual(diffDiagrams(a, a).summary)
expect(isSameDocument(a, b)).toBe(false)
})
it("sees added pages, renamed pages and changed page settings", () => {
expect(isSameDocument(a, file(pageOf("One", box("a"))))).toBe(false)
expect(
isSameDocument(
a,
file(pageOf("One", box("a")), pageOf("Renamed", box("b"))),
),
).toBe(false)
expect(
isSameDocument(
file(pageOf("One", box("a"), ' background="#ffffff"')),
file(pageOf("One", box("a"), ' background="#000000"')),
),
).toBe(false)
})
})
describe("inflatePages", () => {
it("inflates compressed pages in place", () => {
const xml = file(pageOf("One", box("a")), packedPage("Two", box("b")))
const inflated = inflatePages(xml)
expect(inflated).toContain('<mxCell id="b"')
expect(inflated.match(/<mxGraphModel/g)).toHaveLength(2)
expect(isSameDocument(xml, inflated)).toBe(true)
})
it("leaves plain documents as they are", () => {
const xml = file(pageOf("One", box("a")))
expect(inflatePages(xml)).toBe(xml)
expect(inflatePages("not xml <")).toBe("not xml <")
})
})
+173
View File
@@ -0,0 +1,173 @@
import { beforeEach, describe, expect, it } from "vitest"
import { formatSelectionContext } from "@/lib/chat-helpers"
import { EMPTY_SUMMARY } from "@/lib/diagram-diff"
import { withPageDefaults } from "@/lib/drawio/drawio-config"
import { describeChanges } from "@/lib/version-text"
import {
MAX_VERSIONS,
useVersionsStore,
versionsFromLegacyHistory,
} from "@/stores/versions-store"
describe("withPageDefaults", () => {
it("adds page and grid when the model has none", () => {
expect(withPageDefaults("<mxGraphModel><root/></mxGraphModel>")).toBe(
'<mxGraphModel page="1" grid="1" gridSize="10"><root/></mxGraphModel>',
)
})
it("keeps values the diagram already sets", () => {
const xml = '<mxGraphModel dx="10" page="0" grid="0" gridSize="5">'
expect(withPageDefaults(xml)).toBe(xml)
})
it("reads attributes written with spaces around =", () => {
const xml = '<mxGraphModel page = "0" grid ="0" gridSize= "5">'
expect(withPageDefaults(xml)).toBe(xml)
})
it("handles self-closing models and several pages", () => {
const xml =
'<mxfile><diagram><mxGraphModel/></diagram><diagram><mxGraphModel page="0"></mxGraphModel></diagram></mxfile>'
expect(withPageDefaults(xml)).toBe(
'<mxfile><diagram><mxGraphModel page="1" grid="1" gridSize="10"/></diagram><diagram><mxGraphModel page="0" grid="1" gridSize="10"></mxGraphModel></diagram></mxfile>',
)
})
})
describe("describeChanges", () => {
const en = {
shapesAddedOne: "added 1 shape",
shapesAddedOther: "added {count} shapes",
edgesChangedOne: "changed 1 connector",
edgesChangedOther: "changed {count} connectors",
noChanges: "No visible changes",
listSeparator: ", ",
}
it("uses singular and plural forms and sentence case", () => {
expect(
describeChanges(
{ ...EMPTY_SUMMARY, shapesAdded: 2, edgesChanged: 1 },
en,
),
).toBe("Added 2 shapes, changed 1 connector")
})
it("says so when nothing changed", () => {
expect(describeChanges({ ...EMPTY_SUMMARY }, en)).toBe(
"No visible changes",
)
})
it("uses the language's list separator", () => {
const zh = {
shapesAddedOther: "新增 {count} 个图形",
edgesChangedOne: "改了 1 条连线",
listSeparator: ",",
}
expect(
describeChanges(
{ ...EMPTY_SUMMARY, shapesAdded: 3, edgesChanged: 1 },
zh,
),
).toBe("新增 3 个图形,改了 1 条连线")
})
})
describe("formatSelectionContext", () => {
it("lists selected ids with their labels", () => {
const text = formatSelectionContext([
{ id: "4", label: "EC2" },
{ id: "cf", label: "" },
])
expect(text).toContain('- id="4" (EC2)')
expect(text).toContain('- id="cf"')
expect(text).not.toContain('id="cf" (')
})
it("returns nothing for missing or malformed input", () => {
expect(formatSelectionContext(undefined)).toBe("")
expect(formatSelectionContext("4")).toBe("")
expect(formatSelectionContext([{ label: "no id" }])).toBe("")
})
it("strips quotes and newlines and limits length and count", () => {
const many = Array.from({ length: 80 }, (_, i) => ({
id: `c${i}`,
label: `line\n"quoted"${"x".repeat(200)}`,
}))
const text = formatSelectionContext(many)
const lines = text.split("\n").filter((l) => l.startsWith("- id="))
expect(lines).toHaveLength(50)
expect(text).not.toContain('"quoted"')
expect(lines[0].length).toBeLessThan(130)
})
})
describe("versions store", () => {
beforeEach(() => useVersionsStore.getState().clear())
const add = (n: number) =>
useVersionsStore.getState().addVersion({
xml: `<x n="${n}"/>`,
beforeXml: `<before n="${n}"/>`,
turnIndex: n,
summary: { ...EMPTY_SUMMARY },
})
it("keeps the before-XML only on the newest version", () => {
add(0)
add(2)
const [first, second] = useVersionsStore.getState().versions
expect(first.beforeXml).toBeUndefined()
expect(second.beforeXml).toBe('<before n="2"/>')
})
it("keeps at most MAX_VERSIONS versions", () => {
for (let i = 0; i < MAX_VERSIONS + 5; i++) add(i)
const { versions } = useVersionsStore.getState()
expect(versions).toHaveLength(MAX_VERSIONS)
expect(versions[0].turnIndex).toBe(5)
})
it("drops versions from a turn that is re-run", () => {
add(0)
add(2)
add(4)
useVersionsStore.getState().removeFromTurn(2)
expect(
useVersionsStore.getState().versions.map((v) => v.turnIndex),
).toEqual([0])
})
it("marks no version as on the canvas after a turn is re-run", () => {
add(0)
add(2)
useVersionsStore.getState().removeFromTurn(2)
expect(useVersionsStore.getState().onCanvasVersionId).toBeNull()
})
it("hand edits clear both version flags", () => {
const id = add(0)
expect(useVersionsStore.getState().onCanvasVersionId).toBe(id)
useVersionsStore.getState().clearCanvasFlags()
let state = useVersionsStore.getState()
expect(state.onCanvasVersionId).toBeNull()
expect(state.versions[0].onCanvas).toBeUndefined()
useVersionsStore.getState().setUndone(id)
useVersionsStore.getState().clearCanvasFlags()
state = useVersionsStore.getState()
expect(state.undoneVersionId).toBeNull()
expect(state.versions[0].undone).toBeUndefined()
})
it("converts the old history list", () => {
const versions = versionsFromLegacyHistory([
{ svg: "data:image/svg+xml;base64,AA", xml: "<a/>" },
])
expect(versions).toHaveLength(1)
expect(versions[0]).toMatchObject({ xml: "<a/>", turnIndex: -1 })
expect(versionsFromLegacyHistory(undefined)).toEqual([])
})
})
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest"
import { shortModelName } from "@/components/model-selector"
describe("shortModelName", () => {
it("drops the provider or region prefix the logo already shows", () => {
expect(shortModelName("nvidia/nemotron-3-ultra-550b-a55b:free")).toBe(
"nemotron-3-ultra-550b-a55b:free",
)
expect(shortModelName("global.anthropic.claude-opus-5-5")).toBe(
"claude-opus-5-5",
)
expect(shortModelName("us.amazon.nova-2-lite-v1:0")).toBe(
"nova-2-lite-v1:0",
)
expect(shortModelName("openai.gpt-6-luna")).toBe("gpt-6-luna")
expect(shortModelName("@tx/deepseek-ai/deepseek-v32")).toBe(
"deepseek-v32",
)
})
it("keeps version numbers with dots", () => {
for (const id of [
"gpt-5.5",
"glm-4.7",
"gemini-2.5-flash",
"kimi-k2.6",
]) {
expect(shortModelName(id)).toBe(id)
}
})
})
@@ -1,15 +1,10 @@
import { cleanup, render } from "@testing-library/react"
import { afterEach, describe, expect, it } from "vitest"
import { ToolCallCard } from "@/components/chat/ToolCallCard"
import { ToolInputDetails } from "@/components/chat/tool-activity"
afterEach(cleanup)
const dict = {
tools: { complete: "Complete" },
chat: { copied: "Copied", failedToCopy: "Failed", copyResponse: "Copy" },
}
describe("ToolCallCard", () => {
describe("ToolInputDetails", () => {
it("shows streamed operations without crashing on broken entries", () => {
// A partly streamed or invalid edit_diagram input
const operations = [
@@ -22,7 +17,7 @@ describe("ToolCallCard", () => {
{ operation: "update", cell_id: "3", new_xml: '<mxCell id="3"/>' },
]
const { container } = render(
<ToolCallCard
<ToolInputDetails
part={
{
type: "tool-edit_diagram",
@@ -31,15 +26,25 @@ describe("ToolCallCard", () => {
input: { operations },
} as any
}
expandedTools={{ t1: true }}
setExpandedTools={() => {}}
onCopy={() => {}}
copiedToolCallId={null}
copyFailedToolCallId={null}
dict={dict}
/>,
)
expect(container.textContent).toContain("cell_id: 3")
expect(container.textContent).toContain("update3")
expect(container.textContent).toContain('<mxCell id="3"/>')
})
it("ignores an xml input that is not text", () => {
const { container } = render(
<ToolInputDetails
part={
{
type: "tool-display_diagram",
toolCallId: "t2",
state: "input-streaming",
input: { xml: { broken: true } },
} as any
}
/>,
)
expect(container.textContent).toContain("broken")
})
})
@@ -1,6 +1,5 @@
import { render } from "@testing-library/react"
import { renderHook } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import en from "@/lib/i18n/dictionaries/en.json"
const page = (cells: string) =>
`<mxfile><diagram id="p" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
@@ -19,12 +18,8 @@ vi.mock("@/contexts/diagram-context", () => ({
loadDiagram: vi.fn(() => null),
}),
}))
vi.mock("@/hooks/use-dictionary", () => ({ useDictionary: () => en }))
import { ChatMessageDisplay } from "@/components/chat-message-display"
// jsdom has no layout
Element.prototype.scrollIntoView = () => {}
import { useDiagramStreaming } from "@/components/chat/use-diagram-streaming"
describe("the streaming preview of a second edit", () => {
it("starts from the first edit's result", () => {
@@ -51,15 +46,13 @@ describe("the streaming preview of a second edit", () => {
],
},
] as any
render(
<ChatMessageDisplay
messages={messages}
setInput={() => {}}
setFiles={() => {}}
processedToolCallsRef={{ current: new Set() }}
editDiagramOriginalXmlRef={editDiagramOriginalXmlRef}
status="streaming"
/>,
renderHook(() =>
useDiagramStreaming({
messages,
processedToolCallsRef: { current: new Set() },
editDiagramOriginalXmlRef,
loadedMessageIdsRef: { current: new Set() },
}),
)
expect(editDiagramOriginalXmlRef.current.get("edit-2")).toBe(
AFTER_FIRST_EDIT,
+35 -2
View File
@@ -26,7 +26,6 @@ function setup(partialXml: string) {
...refs,
onDisplayChart,
onFetchChart: async () => "",
onExport: () => {},
enableVlmValidation: false,
}),
)
@@ -63,7 +62,6 @@ describe("the screenshot check and Stop", () => {
chartXMLRef: { current: "" },
onDisplayChart: () => null,
onFetchChart: async () => "",
onExport: () => {},
enableVlmValidation: true,
captureValidationPng:
opts.captureValidationPng ??
@@ -202,3 +200,38 @@ describe("append_diagram and the stored previews", () => {
expect(refs.editDiagramOriginalXmlRef.current.size).toBe(1)
})
})
describe("display_diagram and undo", () => {
it("commits the diagram as one undo step before any check", async () => {
const onDisplayChart = vi.fn(
(
_xml: string,
_skipValidation?: boolean,
_mode?: string,
): string | null => null,
)
const { result } = renderHook(() =>
useDiagramToolHandlers({
partialXmlRef: { current: "" },
editDiagramOriginalXmlRef: { current: new Map() },
processedToolCallsRef: { current: new Set() },
validationRetryCountRef: { current: 0 },
chartXMLRef: { current: "" },
onDisplayChart,
onFetchChart: async () => "",
enableVlmValidation: false,
}),
)
await result.current.handleToolCall(
{
toolCall: {
toolCallId: "d1",
toolName: "display_diagram",
input: { xml: box("2") },
},
},
vi.fn(),
)
expect(onDisplayChart.mock.calls.map((c) => c[2])).toEqual(["commit"])
})
})