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

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
- draw.io's own simple UI: its toolbar, menus (auto layout, Mermaid, CSV
  import) and page tabs; the shape library starts closed
- new start screen, session rename and switcher, open .drawio files
- settings split into Models & keys, Appearance, Advanced and About
- Bedrock in the model settings also takes an API key instead of access keys
- phones switch between canvas and chat
- dark mode uses draw.io's own dark mode, with neutral greys around it

With an external draw.io (NEXT_PUBLIC_DRAWIO_BASE_URL) 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-08 16:20:15 +09:00
committed by GitHub
parent 18a20aa1ca
commit c513d1a0c2
110 changed files with 17891 additions and 6944 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()
})
})
+382 -13
View File
@@ -1,5 +1,11 @@
import zlib from "node:zlib"
import { expect, type Page, test } from "@playwright/test"
import { getIframe, sendMessage, waitForCompleteCount } from "./lib/fixtures"
import {
getIframe,
openDrawioFile,
sendMessage,
waitForCompleteCount,
} from "./lib/fixtures"
/**
* Checks what draw.io actually shows after the diagram tools, not only the
@@ -45,6 +51,15 @@ const cell = (id: string, label: string, x: number) =>
const page = (id: string, cells: string) =>
`<diagram id="${id}" name="${id}"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram>`
// A screenshot check that finds a problem
const FAILED_CHECK = {
valid: false,
issues: [
{ type: "overlap", severity: "critical", description: "Boxes overlap" },
],
suggestions: ["Move them apart"],
}
const sse = (events: object[]) =>
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")
const EDIT_GAMMA = {
@@ -127,7 +142,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()
// draw.io's own page tabs
const pageTabs = canvas.locator(".geTabContainer")
await expect(pageTabs.getByText("Second", { exact: true })).toBeVisible()
await sendMessage(p, "Start over with three boxes")
await waitForCompleteCount(p, 2)
@@ -140,7 +157,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 +238,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 +631,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 +662,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 +759,348 @@ 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
? FAILED_CHECK
: { 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])
}
}
})
// A display_diagram cut off by the output limit: its last cell is half
// written, so 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 cutReply = [
sse([{ type: "start" }, CUT.start, ...CUT.deltas]),
1500,
`${sse([CUT.done, { type: "finish" }])}data: [DONE]\n\n`,
]
test("a cut off drawing stays while it is continued, and goes if that fails", async ({
page: p,
}) => {
const canvas = await chunkedReplies(p, [
[drawReply("d1", cell("a", "Alpha", 40))],
cutReply,
[
sse([{ type: "start" }]),
...KEEP_OPEN.slice(0, 10),
`${sse([{ 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")
// The cut off call has its result; the continuation is running
await expect(
p.locator(
'[data-tool-name="display_diagram"][data-tool-state="output-error"]',
),
).toBeVisible({ timeout: 15000 })
await p.waitForTimeout(800)
await expect(canvas.getByText("Beta", { exact: true })).toBeVisible()
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("a finished continuation is one change, undone back to the diagram before", async ({
page: p,
}) => {
const rest = toolCallEvents("a1", "append_diagram", {
xml: `ma" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="220" y="40" width="120" height="60" as="geometry"/></mxCell>`,
})
const canvas = await chunkedReplies(p, [
[drawReply("d1", cell("a", "Alpha", 40))],
cutReply,
[
`${sse([{ type: "start" }, rest.start, ...rest.deltas, rest.done, { type: "finish" }])}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("Gamma", { exact: true })).toBeVisible({
timeout: 15000,
})
await expect(canvas.getByText("Beta", { exact: true })).toBeVisible()
await expect(p.locator('[data-testid="new-chat-button"]')).toBeEnabled({
timeout: 15000,
})
await p.waitForTimeout(1500)
await p.locator('[data-testid="version-undo"]').click()
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
await expect(canvas.getByText("Beta", { exact: true })).toHaveCount(0)
})
test("a cut off drawing goes when the model stops without continuing", async ({
page: p,
}) => {
const canvas = await chunkedReplies(p, [
[drawReply("d1", cell("a", "Alpha", 40))],
cutReply,
[textReply("I could not finish it.")],
])
await sendMessage(p, "Draw a box")
await waitForCompleteCount(p, 1)
await sendMessage(p, "Draw a bigger one")
await expect(p.getByText("I could not finish it.")).toBeVisible({
timeout: 15000,
})
await expect(canvas.getByText("Beta", { exact: true })).toHaveCount(0)
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
// The first drawing is on the canvas again: its card can still undo it
await p.waitForTimeout(1500)
await expect(p.locator('[data-testid="version-undo"]')).toBeVisible()
})
test("stopping a continuation leaves the diagram from before the cut off one", async ({
page: p,
}) => {
// The model draws anew instead of continuing, and the user stops it
const redraw = toolCallEvents("d3", "display_diagram", {
xml: cell("g", "Gamma", 400),
})
const canvas = await chunkedReplies(p, [
[drawReply("d1", cell("a", "Alpha", 40))],
cutReply,
[
sse([{ type: "start" }, redraw.start, ...redraw.deltas]),
...KEEP_OPEN,
],
])
await sendMessage(p, "Draw a box")
await waitForCompleteCount(p, 1)
await sendMessage(p, "Draw a bigger one")
await expect(canvas.getByText("Gamma", { exact: true })).toBeVisible({
timeout: 15000,
})
await p.getByRole("button", { name: "Stop generation" }).click()
await p.waitForTimeout(1000)
await expect(canvas.getByText("Gamma", { exact: true })).toHaveCount(0)
await expect(canvas.getByText("Beta", { exact: true })).toHaveCount(0)
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
})
test("a drawing that failed its screenshot check can still be restored", async ({
page: p,
}) => {
await p.addInitScript(() => {
localStorage.setItem("next-ai-draw-io-vlm-validation-enabled", "true")
})
let chatRequests = 0
await p.route("**/api/chat", async (route) => {
chatRequests++
if (chatRequests === 1) {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: drawReply("d1", cell("a", "Alpha", 40)),
})
return
}
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ error: "Model is overloaded" }),
})
})
await p.route("**/api/validate-diagram", (route) =>
route.fulfill({
status: 200,
contentType: "text/plain",
body: JSON.stringify(FAILED_CHECK),
}),
)
await p.goto("/", { waitUntil: "networkidle" })
await getIframe(p).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(p, "Draw a box")
await expect(p.getByText("Model is overloaded").first()).toBeVisible({
timeout: 20000,
})
await expect(p.locator('[data-testid="version-thumb"]')).toHaveCount(1)
})
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, and the
// retry started from the canvas with it
const thumb = p.locator('[data-testid="version-thumb"]')
await expect(thumb).toHaveCount(1)
await expect(canvas.getByText("Alpha", { exact: true })).toBeVisible()
await thumb.click()
await expect(
p.getByText("This version is what the canvas shows now."),
).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 openDrawioFile(
p,
"packed.drawio",
`<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++
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: drawReply(
`r${calls}`,
`<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 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()
})
test("the version strip stays away while the first drawing is checked", async ({
page: p,
}) => {
await p.addInitScript(() => {
localStorage.setItem("next-ai-draw-io-vlm-validation-enabled", "true")
})
await p.route("**/api/chat", (route) =>
route.fulfill({
status: 200,
contentType: "text/event-stream",
body: drawReply("d1", cell("a", "Alpha", 40)),
}),
)
let checking = false
await p.route("**/api/validate-diagram", async (route) => {
checking = true
await new Promise((r) => setTimeout(r, 3000))
await route
.fulfill({
status: 200,
contentType: "text/plain",
body: JSON.stringify({
valid: true,
issues: [],
suggestions: [],
}),
})
.catch(() => {})
})
await p.goto("/", { waitUntil: "networkidle" })
await getIframe(p).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(p, "Draw a box")
await expect.poll(() => checking, { timeout: 15000 }).toBe(true)
// The version exists already; its card comes when the check is done
await p.waitForTimeout(1000)
await expect(p.locator('[data-testid="version-thumb"]')).toHaveCount(0)
await expect(p.locator('[data-testid="version-card"]')).toHaveCount(1, {
timeout: 10000,
})
await expect(p.locator('[data-testid="version-thumb"]')).toHaveCount(0)
})
+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({
+59 -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,50 @@ 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()
})
test("the offer to go on without saving goes away with its chat", async ({
page,
}) => {
let n = 0
await page.route("**/api/chat", async (route) => {
n++
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(SINGLE_BOX_XML, `Answer ${n}.`),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "First chat")
await waitForText(page, "Answer 1.")
await page.waitForTimeout(1500)
await page.locator('[data-testid="new-chat-button"]').click()
await sendMessage(page, "Second chat")
await waitForText(page, "Answer 2.")
// Storage is full: leaving the second chat offers to go on unsaved
await page.evaluate(() => {
const put = IDBObjectStore.prototype.put
;(window as any).__restorePut = () => {
IDBObjectStore.prototype.put = put
}
IDBObjectStore.prototype.put = () => {
throw new DOMException("Storage is full", "QuotaExceededError")
}
})
await page.locator('[data-testid="new-chat-button"]').click()
const offer = page.getByRole("button", {
name: "Continue without saving",
})
await expect(offer).toBeVisible({ timeout: 5000 })
// Storage works again, and the user opens the first chat
await page.evaluate(() => (window as any).__restorePut())
await page.locator('[data-testid="session-title"]').click()
await page.getByRole("button", { name: /First chat/ }).click()
await expect(page.getByText("Answer 1.")).toBeVisible()
await expect(offer).toHaveCount(0)
})
// A diagram drawn by hand, without chat messages: loaded into draw.io
@@ -155,6 +198,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 +233,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 +333,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 }) => {
+78 -21
View File
@@ -1,11 +1,25 @@
import type { Page } from "@playwright/test"
import { SINGLE_BOX_XML } from "./fixtures/diagrams"
import {
expect,
getChatInput,
getIframe,
openSettings,
getSendButton,
openSettingsTab,
sendMessage,
sleep,
test,
waitForComplete,
} from "./lib/fixtures"
import { createMockSSEResponse } from "./lib/helpers"
/** Pick a language in the settings, then close them */
async function pickLanguage(page: Page, name: string) {
await openSettingsTab(page, "appearance")
await page.locator("#language-select").click()
await page.getByRole("option", { name, exact: true }).click()
await page.keyboard.press("Escape")
}
test.describe("Language Switching", () => {
test("loads English by default", async ({ page }) => {
@@ -15,7 +29,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 +37,11 @@ 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 pickLanguage(page, "日本語")
})
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 +52,11 @@ 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 pickLanguage(page, "中文")
})
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 +67,12 @@ 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 page.keyboard.press("Escape")
await pickLanguage(page, "日本語")
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 +82,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 +92,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,8 +101,61 @@ 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,
})
})
test("a stale offer to switch without saving does nothing once a message is being sent", async ({
page,
}) => {
// Registered before the app's own listener, so it can drop
// draw.io's export replies once asked to
await page.addInitScript(() => {
window.addEventListener("message", (event) => {
if (
(window as any).__dropExports &&
typeof event.data === "string" &&
event.data.includes('"event":"export"')
) {
event.stopImmediatePropagation()
}
})
})
await page.route("**/api/chat", (route) =>
route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(SINGLE_BOX_XML, "Drew the box."),
}),
)
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Draw a box")
await waitForComplete(page)
// Storage is full: switching the language offers to go on unsaved
await page.evaluate(() => {
IDBObjectStore.prototype.put = () => {
throw new DOMException("Storage is full", "QuotaExceededError")
}
})
await pickLanguage(page, "日本語")
const offer = page.getByRole("button", {
name: "Continue without saving",
})
await expect(offer).toBeVisible({ timeout: 5000 })
// The next message waits for its diagram export (it never comes)
await page.evaluate(() => {
;(window as any).__dropExports = true
})
await sendMessage(page, "Make it red")
// Other toasts may stack over it
await offer.dispatchEvent("click")
await sleep(1500)
await expect(page).toHaveURL(/\/en(\?|$)/)
// The export gives up: the message is back in the composer
await expect(getChatInput(page)).toHaveValue("Make it red", {
timeout: 15000,
})
})
})
+43 -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 */
@@ -43,6 +43,18 @@ export function getIframeContent(page: Page) {
return page.frameLocator("iframe")
}
/** Open a .drawio file through the app's file picker */
export async function openDrawioFile(page: Page, name: string, xml: string) {
await page
.locator('input[type="file"][accept^=".drawio"]')
.first()
.setInputFiles({
name,
mimeType: "application/xml",
buffer: Buffer.from(xml),
})
}
/** Get the settings button */
export function getSettingsButton(page: Page) {
return page.locator('[data-testid="settings-button"]')
@@ -60,20 +72,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 +108,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()
+211 -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()
@@ -357,3 +357,213 @@ test("no spinner stays after another tab's change while elsewhere", async ({
await dialog.getByText("Qwen (Alibaba)").first().click()
await expect(dialog.locator(".animate-spin")).toHaveCount(0)
})
const savedConfig = (page: Page) =>
page.evaluate(() =>
JSON.parse(
localStorage.getItem("next-ai-draw-io-model-configs") ?? "{}",
),
)
test("a test result that arrives after the settings closed is kept", async ({
page,
}) => {
const release = await holdRoute(page, "**/api/validate-model", {
valid: true,
responseTime: 1000,
})
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
await dialog.getByRole("button", { name: "Test", exact: true }).click()
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
release()
await expect
.poll(
async () =>
(await savedConfig(page)).providers[0].models[0].validated,
)
.toBe(true)
})
test("a test result for a key changed on another settings tab is dropped", async ({
page,
}) => {
const release = await holdRoute(page, "**/api/validate-model", {
valid: true,
responseTime: 1000,
})
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
await dialog.getByRole("button", { name: "Test", exact: true }).click()
// The panel goes away while the test runs, then the key changes
await page.locator('[data-testid="settings-tab-advanced"]').click()
await page.locator('[data-testid="settings-tab-models"]').click()
await dialog.getByText("Qwen (Alibaba)").first().click()
await dialog.locator("input[type=password]").first().fill("new-key")
release()
await page.waitForTimeout(800)
const saved = await savedConfig(page)
expect(saved.providers[0].apiKey).toBe("new-key")
expect(saved.providers[0].models[0].validated).not.toBe(true)
expect(saved.providers[0].validated).not.toBe(true)
})
test("a test result for a model renamed on another settings tab is dropped", async ({
page,
}) => {
const release = await holdRoute(page, "**/api/validate-model", {
valid: true,
responseTime: 1000,
})
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
await dialog.getByRole("button", { name: "Test", exact: true }).click()
// The panel goes away while the test runs, then the model id changes
await page.locator('[data-testid="settings-tab-advanced"]').click()
await page.locator('[data-testid="settings-tab-models"]').click()
await dialog.getByText("Qwen (Alibaba)").first().click()
const input = dialog.locator('input[title="qwen-max"]')
await input.fill("qwen-plus")
await input.blur()
release()
await page.waitForTimeout(800)
const saved = await savedConfig(page)
expect(saved.providers[0].models[0].modelId).toBe("qwen-plus")
expect(saved.providers[0].models[0].validated).not.toBe(true)
})
test("a failed test speaks the page's language", async ({ page }) => {
await page.addInitScript((config) => {
localStorage.setItem("next-ai-draw-io-locale", "zh")
localStorage.setItem(
"next-ai-draw-io-model-configs",
JSON.stringify(config),
)
}, TWO_PROVIDERS)
await page.route("**/api/validate-model", (route) => route.abort())
await page.goto("/zh", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await page.getByTestId("settings-button").first().click()
const dialog = page.locator('[role="dialog"]')
await dialog.getByText("Qwen (Alibaba)").first().click()
await dialog.getByRole("button", { name: "测试", exact: true }).click()
await expect(dialog.getByText("1 个模型验证失败")).toBeVisible()
await expect(
dialog.getByText("网络错误。请检查您的连接。").first(),
).toBeAttached()
})
test("testing a provider without models speaks the page's language", async ({
page,
}) => {
await page.addInitScript(
(config) => {
localStorage.setItem("next-ai-draw-io-locale", "zh")
localStorage.setItem(
"next-ai-draw-io-model-configs",
JSON.stringify(config),
)
},
{
version: 1,
providers: [{ ...CONFIG.providers[0], models: [] }],
},
)
await page.goto("/zh", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await page.getByTestId("settings-button").first().click()
const dialog = page.locator('[role="dialog"]')
await dialog.getByText("Qwen (Alibaba)").first().click()
await dialog.getByRole("button", { name: "测试", exact: true }).click()
await expect(
dialog.getByText("请先添加至少一个模型以进行验证"),
).toBeVisible()
})
test("adding a model id twice is refused in the page's language", async ({
page,
}) => {
await page.addInitScript((config) => {
localStorage.setItem("next-ai-draw-io-locale", "zh")
localStorage.setItem(
"next-ai-draw-io-model-configs",
JSON.stringify(config),
)
}, CONFIG)
await page.goto("/zh", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await page.getByTestId("settings-button").first().click()
const dialog = page.locator('[role="dialog"]')
await dialog.getByText("Qwen (Alibaba)").first().click()
const input = dialog.getByPlaceholder("自定义模型 ID...")
await input.fill("qwen-mt-plus")
await input.press("Enter")
await expect(dialog.getByText("此模型 ID 已存在")).toBeVisible()
})
test("Bedrock can be tested and used with an API key alone", async ({
page,
}) => {
const bodies: any[] = []
await page.route("**/api/validate-model", async (route) => {
bodies.push(route.request().postDataJSON())
await route.fulfill({
status: 200,
json: { valid: true, responseTime: 900 },
})
})
await page.addInitScript(
(config) => {
localStorage.setItem(
"next-ai-draw-io-model-configs",
JSON.stringify(config),
)
},
{
version: 1,
providers: [
{
id: "b1",
provider: "bedrock",
awsRegion: "us-east-1",
models: [{ id: "m1", modelId: "amazon.nova-lite-v1:0" }],
},
],
},
)
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await page.getByTestId("model-selector").click()
await page.getByText("Configure Models...").click()
const dialog = page.locator('[role="dialog"]')
await dialog.getByText("Amazon Bedrock").first().click()
const test = dialog.getByRole("button", { name: "Test", exact: true })
await expect(test).toBeDisabled()
await dialog.getByLabel("Bedrock API key").fill("bedrock-api-key")
await expect(test).toBeEnabled()
await test.click()
await expect.poll(() => bodies.length).toBe(1)
expect(bodies[0]).toMatchObject({
provider: "bedrock",
apiKey: "bedrock-api-key",
awsRegion: "us-east-1",
})
// The chat sends it with the request
let headers: Record<string, string> = {}
await page.route("**/api/chat", async (route) => {
headers = route.request().headers()
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: 'data: {"type":"start"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n',
})
})
await page.keyboard.press("Escape")
await page.getByTestId("model-selector").click()
await page.getByText("amazon.nova-lite-v1:0").first().click()
const input = page.getByTestId("chat-input")
await input.fill("Hello")
await input.press("ControlOrMeta+Enter")
await expect.poll(() => headers["x-ai-api-key"]).toBe("bedrock-api-key")
expect(headers["x-ai-provider"]).toBe("bedrock")
expect(headers["x-aws-region"]).toBe("us-east-1")
})
+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/)
})
})
File diff suppressed because it is too large Load Diff