Files
next-ai-draw-io/tests/e2e/error-handling.spec.ts
T
dayuan.jiang 2e84e02666 fix(chat): keep the canvas after an unrelated error, and more review fixes
Found by the second PR review:
- After a streamed edit, an older render of the stream stored the edit's
  original diagram again, and the next failed request (no quota, a lost
  connection) put that old diagram back on the canvas. The tool handler
  now marks its call as handled, so the preview code leaves it alone.
- An edit applied before the UI showed an earlier broken edit's error was
  erased when that error undid its preview, or was built on that preview.
  The handler now starts from the diagram before all unhandled previews,
  and reads the diagram state that updates at once.
- A failed or stopped display_diagram left its half drawn diagram on the
  canvas. Its preview is undone now, like an edit's.
- "New chat" cleared a chat that could not be saved (storage full).
- The settings dialog showed a model list, a fetch error or a test result
  on the provider that was opened after the request started, and marked a
  model id changed during the test as tested.
- A tool call with broken JSON was shown as cut off by the output limit.
- History entries and session thumbnails could pair with a later diagram
  when draw.io answered an export late.
- A server model saved before non-ASCII provider names got into the id
  was reset to the default model.
2026-10-05 10:52:03 +09:00

178 lines
6.2 KiB
TypeScript

import { TRUNCATED_XML } from "./fixtures/diagrams"
import {
createErrorMock,
expect,
getChatInput,
getIframe,
sendMessage,
test,
} from "./lib/fixtures"
test.describe("Error Handling", () => {
test("displays error message when API returns 500", async ({ page }) => {
await page.route(
"**/api/chat",
createErrorMock(500, "Internal server error"),
)
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Draw a cat")
// Should show error indication
const errorIndicator = page
.locator('[role="alert"]')
.or(page.locator("[data-sonner-toast]"))
.or(page.locator("text=/error|failed|something went wrong/i"))
await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 })
// User should be able to type again
const chatInput = getChatInput(page)
await chatInput.fill("Retry message")
await expect(chatInput).toHaveValue("Retry message")
})
test("displays error message when API returns 429 rate limit", async ({
page,
}) => {
await page.route(
"**/api/chat",
createErrorMock(429, "Rate limit exceeded"),
)
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Draw a cat")
// Should show error indication for rate limit
const errorIndicator = page
.locator('[role="alert"]')
.or(page.locator("[data-sonner-toast]"))
.or(page.locator("text=/rate limit|too many|try again/i"))
await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 })
// User should be able to type again
const chatInput = getChatInput(page)
await chatInput.fill("Retry after rate limit")
await expect(chatInput).toHaveValue("Retry after rate limit")
})
test("handles network timeout gracefully", async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2000))
await route.abort("timedout")
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Draw a cat")
// Should show error indication for network failure
const errorIndicator = page
.locator('[role="alert"]')
.or(page.locator("[data-sonner-toast]"))
.or(page.locator("text=/error|failed|network|timeout/i"))
await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 })
// After timeout, user should be able to type again
const chatInput = getChatInput(page)
await chatInput.fill("Try again after timeout")
await expect(chatInput).toHaveValue("Try again after timeout")
})
test("shows truncated badge for incomplete XML", async ({ page }) => {
const toolCallId = `call_${Date.now()}`
const textId = `text_${Date.now()}`
const messageId = `msg_${Date.now()}`
const events = [
{ type: "start", messageId },
{ type: "text-start", id: textId },
{ type: "text-delta", id: textId, delta: "Creating diagram..." },
{ type: "text-end", id: textId },
{
type: "tool-input-start",
toolCallId,
toolName: "display_diagram",
},
{
type: "tool-input-available",
toolCallId,
toolName: "display_diagram",
input: { xml: TRUNCATED_XML },
},
{
type: "tool-output-error",
toolCallId,
error: "XML validation failed",
},
{ type: "finish" },
]
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body:
events
.map((e) => `data: ${JSON.stringify(e)}\n\n`)
.join("") + "data: [DONE]\n\n",
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Draw something")
// Should show truncated badge
await expect(page.locator('text="Truncated"')).toBeVisible({
timeout: 15000,
})
})
test("shows the error of a tool call with broken input", async ({
page,
}) => {
// Invalid JSON that the server could not repair: not a length limit
const toolCallId = `call_${Date.now()}`
const events = [
{ type: "start", messageId: `msg_${Date.now()}` },
{
type: "tool-input-start",
toolCallId,
toolName: "display_diagram",
},
{
type: "tool-input-error",
toolCallId,
toolName: "display_diagram",
input: '{"xml": "<mxCell value="a"/>"}',
errorText: "Invalid input for tool display_diagram",
},
{ type: "finish" },
]
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body:
events
.map((e) => `data: ${JSON.stringify(e)}\n\n`)
.join("") + "data: [DONE]\n\n",
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Draw something")
await expect(
page.getByText("Invalid input for tool display_diagram").first(),
).toBeVisible({ timeout: 15000 })
await expect(page.locator('text="Truncated"')).toHaveCount(0)
})
})