import type { Page } from "@playwright/test"
import { SINGLE_BOX_XML } from "./fixtures/diagrams"
import {
expect,
getChatInput,
getIframe,
getIframeContent,
openSettings,
sendMessage,
test,
waitForComplete,
waitForText,
} from "./lib/fixtures"
import { createMockSSEResponse } from "./lib/helpers"
test.describe("History and Session Restore", () => {
test("new chat button clears conversation", async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"Created your test diagram.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await test.step("create a conversation", async () => {
await sendMessage(page, "Create a test diagram")
await waitForText(page, "Created your test diagram.")
})
await test.step("click new chat button", async () => {
const newChatButton = page.locator(
'[data-testid="new-chat-button"]',
)
await expect(newChatButton).toBeVisible({ timeout: 5000 })
await newChatButton.click()
})
await test.step("verify conversation is cleared", async () => {
await expect(
page.locator('text="Created your test diagram."'),
).not.toBeVisible({ timeout: 5000 })
})
})
test("new chat keeps a conversation that could not be saved", async ({
page,
}) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"Created your test diagram.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Create a test diagram")
await waitForText(page, "Created your test diagram.")
// Browser storage is full from now on
await page.evaluate(() => {
IDBObjectStore.prototype.put = () => {
throw new DOMException("Storage is full", "QuotaExceededError")
}
})
await page.locator('[data-testid="new-chat-button"]').click()
await expect(
page.getByText(/Could not save this chat/).first(),
).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(
getIframeContent(page).getByText("Test Box", { exact: true }),
).toBeVisible()
})
test("new chat can go on without saving when storage is full", async ({
page,
}) => {
// Old chats can only be deleted from the empty chat's list, so the
// user must be able to get there
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"Created your test diagram.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Create a test diagram")
await waitForText(page, "Created your test diagram.")
await page.evaluate(() => {
IDBObjectStore.prototype.put = () => {
throw new DOMException("Storage is full", "QuotaExceededError")
}
})
await page.locator('[data-testid="new-chat-button"]').click()
await page
.getByRole("button", { name: "Continue without saving" })
.click({ timeout: 5000 })
await expect(
page.locator('text="Created your test diagram."'),
).toHaveCount(0, { timeout: 5000 })
await expect(page.getByText("Paper to Diagram")).toBeVisible()
})
// A diagram drawn by hand, without chat messages: loaded into draw.io
// directly, then moved with an arrow key, which draw.io reports as an
// edit like any manual change
async function drawByHand(page: Page, label: string) {
const shape = getIframeContent(page).getByText(label, { exact: true })
// draw.io may still be starting (after a new chat): send until shown
for (let i = 0; i < 10 && (await shape.count()) === 0; i++) {
await page.evaluate((label) => {
const xml = ``
document
.querySelector("iframe")
?.contentWindow?.postMessage(
JSON.stringify({ action: "load", xml, autosave: 1 }),
"*",
)
}, label)
await page.waitForTimeout(1000)
}
await shape.click({ timeout: 10000 })
await page.keyboard.press("ArrowRight")
// The app saves the edit about a second later
await page.waitForTimeout(2000)
}
const storageFull = (page: Page) =>
page.evaluate(() => {
IDBObjectStore.prototype.put = () => {
throw new DOMException("Storage is full", "QuotaExceededError")
}
})
test("new chat keeps a diagram without messages that could not be saved", async ({
page,
}) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await drawByHand(page, "Hand drawn")
await expect(
getIframeContent(page).getByText("Hand drawn", { exact: true }),
).toBeVisible({ timeout: 10000 })
await storageFull(page)
await page.locator('[data-testid="new-chat-button"]').click()
await expect(
page.getByText(/Could not save this chat/).first(),
).toBeVisible({ timeout: 5000 })
await expect(
getIframeContent(page).getByText("Hand drawn", { exact: true }),
).toBeVisible()
})
test("opening another chat keeps a diagram that could not be saved", async ({
page,
}) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"Created your test diagram.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Create a test diagram")
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/,
})
await expect(firstChat).toBeVisible({ timeout: 10000 })
await drawByHand(page, "Hand drawn")
await expect(
getIframeContent(page).getByText("Hand drawn", { exact: true }),
).toBeVisible({ timeout: 10000 })
await storageFull(page)
await firstChat.click()
await expect(
page.getByText(/Could not save this chat/).first(),
).toBeVisible({ timeout: 5000 })
await page.waitForTimeout(1000)
await expect(
getIframeContent(page).getByText("Hand drawn", { exact: true }),
).toBeVisible()
})
test("chat history sidebar shows past conversations", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const historyButton = page.locator(
'button[aria-label*="History"]:not([disabled]), button:has(svg.lucide-history):not([disabled]), button:has(svg.lucide-menu):not([disabled]), button:has(svg.lucide-sidebar):not([disabled]), button:has(svg.lucide-panel-left):not([disabled])',
)
const buttonCount = await historyButton.count()
if (buttonCount === 0) {
test.skip()
return
}
await historyButton.first().click()
await expect(getChatInput(page)).toBeVisible({ timeout: 3000 })
})
test("conversation persists after page reload", async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"This message should persist.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await test.step("create conversation", async () => {
await sendMessage(page, "Create persistent diagram")
await waitForText(page, "This message should persist.")
})
await test.step("verify message appears before reload", async () => {
await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })
await expect(
page.locator('text="This message should persist."'),
).toBeVisible({ timeout: 10000 })
})
// Note: After reload, mocked responses won't persist since we're not
// testing with real localStorage. We just verify the app loads correctly.
await test.step("verify app loads after reload", async () => {
await page.reload({ waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })
})
})
test("diagram state persists after reload", async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"Created a diagram that should be saved.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Create saveable diagram")
await waitForComplete(page)
await page.reload({ waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const frame = getIframeContent(page)
await expect(
frame
.locator(".geMenubarContainer, .geDiagramContainer, canvas")
.first(),
).toBeVisible({ timeout: 30000 })
})
test("can restore from browser back/forward", async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"Testing browser navigation.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Test navigation")
await waitForText(page, "Testing browser navigation.")
await page.goto("/about", { waitUntil: "networkidle" })
await page.goBack({ waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })
})
test("settings are restored after reload", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await openSettings(page)
await page.keyboard.press("Escape")
await page.reload({ waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await openSettings(page)
})
test("model selection persists", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const modelSelector = page.locator(
'button[aria-label*="Model"], [data-testid="model-selector"], button:has-text("Claude")',
)
const selectorCount = await modelSelector.count()
if (selectorCount === 0) {
test.skip()
return
}
const initialModel = await modelSelector.first().textContent()
await page.reload({ waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const modelAfterReload = await modelSelector.first().textContent()
expect(modelAfterReload).toBe(initialModel)
})
test("handles localStorage quota exceeded gracefully", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await page.evaluate(() => {
try {
const largeData = "x".repeat(5 * 1024 * 1024)
localStorage.setItem("test-large-data", largeData)
} catch {
// Expected to fail on some browsers
}
})
await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })
await page.evaluate(() => {
localStorage.removeItem("test-large-data")
})
})
})
/** Number of chats stored in this origin's IndexedDB */
const countSessions = (page: Page) =>
page.evaluate(
() =>
new Promise((resolve, reject) => {
const open = indexedDB.open("next-ai-drawio")
open.onerror = () => reject(open.error)
open.onsuccess = () => {
const db = open.result
if (!db.objectStoreNames.contains("sessions")) {
db.close()
return resolve(0)
}
const count = db
.transaction("sessions", "readonly")
.objectStore("sessions")
.count()
count.onsuccess = () => {
db.close()
resolve(count.result)
}
}
}),
)
test("new chat right after an answer saves that chat once", async ({
page,
}) => {
test.setTimeout(180_000)
await page.route("**/api/chat", async (route) => {
await 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 })
const newChat = page.locator('[data-testid="new-chat-button"]')
// The auto-save runs a second after the answer; New Chat around then
// waits for its thumbnail while the auto-save starts
for (let run = 1; run <= 10; run++) {
await sendMessage(page, `Draw box ${run}`)
await waitForText(page, "Drew the box.")
await page.waitForTimeout(500 + run * 100)
await newChat.click()
await expect(page.getByText("Drew the box.")).toHaveCount(0)
await page.waitForTimeout(2500)
expect(await countSessions(page), `run ${run}`).toBe(run)
}
})
test("an idle chat is not saved again and again", async ({ page }) => {
test.setTimeout(90_000)
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
// The tool result comes from the page, as with a real model
body: `${[
{ type: "start" },
{ type: "text-start", id: "t" },
{ type: "text-delta", id: "t", delta: "Drew the box." },
{ type: "text-end", id: "t" },
{
type: "tool-input-start",
toolCallId: "c1",
toolName: "display_diagram",
},
{
type: "tool-input-available",
toolCallId: "c1",
toolName: "display_diagram",
input: {
// Some diagrams (this one) export a new image each time
xml: '',
},
},
{ type: "finish" },
]
.map((e) => `data: ${JSON.stringify(e)}\n\n`)
.join("")}data: [DONE]\n\n`,
})
})
// Every auto-save takes a thumbnail: count draw.io's thumbnail exports
await page.addInitScript(() => {
const w = window as unknown as { thumbnails: number }
w.thumbnails = 0
window.addEventListener("message", (e) => {
try {
const m = JSON.parse(e.data)
if (
m.event === "export" &&
String(m.message?.message ?? "").startsWith("thumbnail")
) {
w.thumbnails++
}
} catch {}
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Draw a box")
await waitForText(page, "Drew the box.")
// Let the auto-save after the answer finish
await page.waitForTimeout(5000)
const thumbnails = () =>
page.evaluate(
() => (window as unknown as { thumbnails: number }).thumbnails,
)
const settled = await thumbnails()
await page.waitForTimeout(5000)
expect(await thumbnails()).toBe(settled)
})